Skip to content
Snippets Groups Projects
Commit d6de2117 authored by geant-release-service's avatar geant-release-service
Browse files

Finished release 0.5.

parents 28d3faca 9f6f9148
No related branches found
No related tags found
No related merge requests found
Showing
with 282 additions and 94 deletions
# Changelog # Changelog
## [0.3] - 2025-02-13 ## [0.5] - 2025-03-14
- Improved CSV eport functionality
- Added Google authentication support
- Set up PstgreSQL as the default DB
- Imporved file export name
- Added unittests
## [0.4] - 2025-02-13
- Added CSV Export and Modify Functionality - Added CSV Export and Modify Functionality
## [0.2] - 2025-01-27 ## [0.3] - 2025-01-27
- Added new validations - Added new validations
## [0.2] - 2025-01-09 ## [0.2] - 2025-01-09
......
...@@ -37,4 +37,10 @@ line-length = 120 ...@@ -37,4 +37,10 @@ line-length = 120
[tool.ruff.lint.flake8-tidy-imports] [tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "all" ban-relative-imports = "all"
[tool.ruff.lint.per-file-ignores]
"test/*" = ["ARG001", "D", "S101", "PLR2004", "PLR0917", "PLR0914", "PLC0415", "PLC2701"]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "test.settings"
django_find_project = false
python_files = ["tests.py", "test_*.py", "*_tests.py"]
\ No newline at end of file
"""Admin configuration for the UserActivityLog model."""
from django.contrib import admin
from sage_validation.accounts.models import UserActivityLog
@admin.register(UserActivityLog)
class UserActivityLogAdmin(admin.ModelAdmin):
"""Admin configuration for the UserActivityLog model."""
list_display = ("user", "action", "name", "input_file_hash", "output_file_hash", "timestamp")
search_fields = ("user__username", "name", "action")
list_filter = ("action", "timestamp")
"""Django app configuration for the accounts' app."""
from django.apps import AppConfig
class AccountsConfig(AppConfig):
"""App configuration for the accounts' app."""
default_auto_field = "django.db.models.BigAutoField"
name = "sage_validation.accounts"
# Generated by Django 5.0.11 on 2025-03-03 09:09
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="UserActivityLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"action",
models.CharField(
choices=[("upload", "Upload"), ("download", "Download")],
max_length=10,
),
),
("name", models.CharField(max_length=255)),
(
"input_file_hash",
models.CharField(blank=True, max_length=64, null=True),
),
(
"output_file_hash",
models.CharField(blank=True, max_length=64, null=True),
),
("timestamp", models.DateTimeField(auto_now_add=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="activity_logs",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]
"""Models for the accounts app."""
import hashlib
from typing import ClassVar
from django.contrib.auth.models import User
from django.db import models
class UserActivityLog(models.Model):
"""Model to log user activities."""
ACTION_CHOICES: ClassVar[list[tuple[str, str]]] = [
("upload", "Upload"),
("download", "Download"),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="activity_logs")
action = models.CharField(max_length=10, choices=ACTION_CHOICES)
name = models.CharField(max_length=255)
input_file_hash = models.CharField(max_length=64, blank=True, null=True)
output_file_hash = models.CharField(max_length=64, blank=True, null=True)
timestamp = models.DateTimeField(auto_now_add=True)
def __str__(self) -> str:
"""Return a string representation of the UserActivityLog object."""
return f"{self.user.username} - {self.action} - {self.name} ({self.timestamp})"
@staticmethod
def generate_file_hash(file_obj: object) -> str:
"""Generate SHA-256 hash for a file-like object."""
hasher = hashlib.sha256()
if hasattr(file_obj, "chunks"):
for chunk in file_obj.chunks():
hasher.update(chunk)
elif isinstance(file_obj, str):
hasher.update(file_obj.encode("utf-8"))
elif isinstance(file_obj, list):
hasher.update(str(file_obj).encode("utf-8"))
else:
err = "generate_file_hash() expected a file, string, or list."
raise TypeError(err)
return hasher.hexdigest()
"""URL configuration for the accounts' app."""
from django.contrib.auth.views import LogoutView
from django.urls import path
urlpatterns = [
path("logout/", LogoutView.as_view(), name="logout"),
]
"""Views for accounts app."""
...@@ -40,21 +40,18 @@ ...@@ -40,21 +40,18 @@
</div> </div>
</div> </div>
<script> <script>
const form = document.getElementById('uploadForm'); document.getElementById('uploadForm').addEventListener('submit', async function (e) {
const fileInput = document.getElementById('fileInput');
const errorSection = document.getElementById('errorSection');
const errorList = document.getElementById('errorList');
const successSection = document.getElementById('successSection');
const successMessage = document.getElementById('successMessage');
const downloadSection = document.getElementById('downloadSection');
const downloadLink = document.getElementById('downloadLink');
form.addEventListener('submit', async function (e) {
e.preventDefault(); e.preventDefault();
// Clear previous messages const fileInput = document.getElementById('fileInput');
const errorSection = document.getElementById('errorSection');
const errorList = document.getElementById('errorList');
const successSection = document.getElementById('successSection');
const successMessage = document.getElementById('successMessage');
const downloadSection = document.getElementById('downloadSection');
const downloadLink = document.getElementById('downloadLink');
errorList.innerHTML = ''; errorList.innerHTML = '';
successMessage.innerHTML = ''; successMessage.innerHTML = '';
errorSection.classList.add('hidden'); errorSection.classList.add('hidden');
...@@ -65,7 +62,7 @@ ...@@ -65,7 +62,7 @@
formData.append('file', fileInput.files[0]); formData.append('file', fileInput.files[0]);
try { try {
const response = await fetch('', { const response = await fetch("{% url 'upload-file' %}", {
method: 'POST', method: 'POST',
body: formData, body: formData,
headers: { headers: {
...@@ -80,26 +77,14 @@ ...@@ -80,26 +77,14 @@
successSection.classList.remove('hidden'); successSection.classList.remove('hidden');
downloadLink.href = result.download_url; downloadLink.href = result.download_url;
downloadSection.classList.remove('hidden'); downloadSection.classList.remove('hidden');
} else if (response.status === 400 && result.status === 'error') { } else {
errorList.innerHTML = ''; errorList.innerHTML = '';
if (Array.isArray(result.errors)) { if (response.status === 403) {
result.errors.forEach(errorObj => { const li = document.createElement('li');
if (typeof errorObj === 'string') { li.textContent = 'You are not authorized to perform this action.';
const li = document.createElement('li'); errorList.appendChild(li);
li.textContent = errorObj; } else if (response.status === 400 && result.status === 'error') {
errorList.appendChild(li);
} else {
for (const [field, messages] of Object.entries(errorObj)) {
messages.forEach(message => {
const li = document.createElement('li');
li.textContent = `${field}: ${message}`;
errorList.appendChild(li);
});
}
}
});
} else if (typeof result.errors === 'object') {
for (const [field, messages] of Object.entries(result.errors)) { for (const [field, messages] of Object.entries(result.errors)) {
messages.forEach(message => { messages.forEach(message => {
const li = document.createElement('li'); const li = document.createElement('li');
...@@ -109,7 +94,7 @@ ...@@ -109,7 +94,7 @@
} }
} else { } else {
const li = document.createElement('li'); const li = document.createElement('li');
li.textContent = result.errors; li.textContent = 'An unexpected error occurred. Please try again.';
errorList.appendChild(li); errorList.appendChild(li);
} }
...@@ -117,7 +102,7 @@ ...@@ -117,7 +102,7 @@
} }
} catch (error) { } catch (error) {
const li = document.createElement('li'); const li = document.createElement('li');
li.textContent = 'An unexpected error occurred. Please try again.'; li.textContent = 'Failed to connect to the server. Please check your internet connection.';
errorList.appendChild(li); errorList.appendChild(li);
errorSection.classList.remove('hidden'); errorSection.classList.remove('hidden');
} }
......
"""All the tests for the file_validator app."""
...@@ -2,9 +2,10 @@ ...@@ -2,9 +2,10 @@
from django.urls import path from django.urls import path
from sage_validation.file_validator.views import CSVExportView, CSVUploadView from sage_validation.file_validator.views import CSVExportAPIView, CSVUploadAPIView, upload_page_view
urlpatterns = [ urlpatterns = [
path("upload/", CSVUploadView.as_view(), name="upload-file"), path("upload-page/", upload_page_view, name="upload-page"),
path("export/", CSVExportView.as_view(), name="export-file"), path("api/upload/", CSVUploadAPIView.as_view(), name="upload-file"),
path("api/export/", CSVExportAPIView.as_view(), name="export-file"),
] ]
"""Views for the file_validator app.""" """Views for the file_validator app."""
import csv import csv
import io import io
from typing import Any from typing import ClassVar
from django.http import HttpRequest, HttpResponse, JsonResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.views.generic.base import View from rest_framework import status
from django.views.generic.edit import FormView from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from sage_validation.accounts.models import UserActivityLog
from sage_validation.file_validator.forms import CSVUploadForm from sage_validation.file_validator.forms import CSVUploadForm
from sage_validation.file_validator.models import MeoCostCentres, XxData from sage_validation.file_validator.models import MeoCostCentres, XxData
...@@ -19,46 +23,44 @@ def index_view(request: HttpRequest) -> HttpResponse: ...@@ -19,46 +23,44 @@ def index_view(request: HttpRequest) -> HttpResponse:
return render(request, "index.html") return render(request, "index.html")
class CSVUploadView(FormView): def upload_page_view(request: HttpRequest) -> HttpResponse:
"""View for uploading a CSV file.""" """Render the file upload page."""
return render(request, "upload.html")
template_name = "upload.html"
form_class = CSVUploadForm
success_url = reverse_lazy("upload-file")
def get_context_data(self, **kwargs: dict[str, Any]) -> dict[str, Any]: class CSVUploadAPIView(APIView):
"""Render the form with no error message on GET request.""" """API view for uploading a CSV file."""
context = super().get_context_data(**kwargs)
context["error"] = None permission_classes: ClassVar[list] = [IsAuthenticated]
context["message"] = None
return context def post(self, request: Request) -> Response:
"""Handle CSV upload and validation."""
form = CSVUploadForm(data=request.data, files=request.FILES)
if not form.is_valid():
return Response({"status": "error", "errors": form.errors}, status=status.HTTP_400_BAD_REQUEST)
def form_valid(self, form: CSVUploadForm) -> JsonResponse:
"""Handle the CSV validation, store valid data, and prepare for export."""
csv_file = form.cleaned_data["file"] csv_file = form.cleaned_data["file"]
csv_file.seek(0) csv_file.seek(0)
decoded_file = csv_file.read().decode("utf-8").strip() decoded_file = csv_file.read().decode("utf-8").strip()
if not decoded_file: if not decoded_file:
return JsonResponse({"status": "error", "message": "Uploaded file is empty."}, status=400) return Response({"status": "error", "message": "Uploaded file is empty."},
status=status.HTTP_400_BAD_REQUEST)
reader = csv.DictReader(io.StringIO(decoded_file)) reader = csv.DictReader(io.StringIO(decoded_file))
csv_data: list[dict[str, str]] = list(reader) csv_data: list[dict[str, str]] = list(reader)
updated_data = self.update_fields(csv_data) updated_data = self.update_fields(csv_data)
request.session["validated_csv"] = updated_data
request.session["input_file_hash"] = UserActivityLog.generate_file_hash(csv_file)
request.session.modified = True
self.request.session["validated_csv"] = updated_data return Response({
self.request.session.modified = True
return JsonResponse({
"status": "success", "status": "success",
"message": "File successfully uploaded and processed.", "message": "File successfully uploaded and processed.",
"download_url": reverse_lazy("export-file") "download_url": reverse_lazy("export-file")
}) }, status=status.HTTP_200_OK)
def form_invalid(self, form: CSVUploadForm) -> JsonResponse:
"""Handle the form when it is invalid."""
return JsonResponse({"status": "error", "errors": form.errors}, status=400)
@staticmethod @staticmethod
def update_fields(csv_data: list[dict[str, str]]) -> list[dict[str, str]]: def update_fields(csv_data: list[dict[str, str]]) -> list[dict[str, str]]:
...@@ -91,27 +93,36 @@ class CSVUploadView(FormView): ...@@ -91,27 +93,36 @@ class CSVUploadView(FormView):
row[f"NominalAnalysisNominalAccountNumber/{repeat}"] = ( row[f"NominalAnalysisNominalAccountNumber/{repeat}"] = (
xx_data[0] if cc_type == "Project" else xx_data[1] xx_data[0] if cc_type == "Project" else xx_data[1]
) )
repeat += 1 repeat += 1
return csv_data return csv_data
class CSVExportView(View): class CSVExportAPIView(APIView):
"""View for exporting the updated CSV file.""" """API view for exporting the updated CSV file."""
def get(self, request: HttpRequest) -> HttpResponse: def get(self, request: Request) -> Response:
"""Generate a downloadable CSV file with updated values.""" """Return processed CSV as a downloadable response."""
csv_data: list[dict[str, str]] = request.session.get("validated_csv", []) csv_data: list[dict[str, str]] = request.session.get("validated_csv", [])
input_file_hash: str = request.session.get("input_file_hash", "")
if not csv_data: if not csv_data:
return HttpResponse("No data available for export.", status=400) return Response({"status": "error", "message": "No data available for export."},
status=status.HTTP_400_BAD_REQUEST)
file_name = f"Validated_{csv_data[0].get('TransactionReference', 'file')}.csv"
response = HttpResponse(content_type="text/csv") response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="updated_file.csv"' response["Content-Disposition"] = f"attachment; filename={file_name}"
writer = csv.DictWriter(response, fieldnames=csv_data[0].keys()) writer = csv.DictWriter(response, fieldnames=csv_data[0].keys())
writer.writeheader() writer.writeheader()
writer.writerows(csv_data) writer.writerows(csv_data)
# Log the user activity
UserActivityLog.objects.create(
user=request.user,
action="download",
name=file_name,
input_file_hash=input_file_hash,
output_file_hash=UserActivityLog.generate_file_hash(csv_data),
timestamp=timezone.now()
)
return response return response
...@@ -9,6 +9,7 @@ For the full list of settings and their values, see ...@@ -9,6 +9,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/ https://docs.djangoproject.com/en/5.1/ref/settings/
""" """
import os import os
from datetime import timedelta
from pathlib import Path from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
...@@ -18,7 +19,7 @@ BASE_DIR = Path(__file__).resolve().parent ...@@ -18,7 +19,7 @@ BASE_DIR = Path(__file__).resolve().parent
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv("SECRET_KEY") SECRET_KEY = os.getenv("SECRET_KEY", "test-secret-key")
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
...@@ -27,6 +28,9 @@ ALLOWED_HOSTS: list[str] = os.getenv("ALLOWED_HOSTS", "").split(",") ...@@ -27,6 +28,9 @@ ALLOWED_HOSTS: list[str] = os.getenv("ALLOWED_HOSTS", "").split(",")
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
"rest_framework",
"rest_framework.authtoken",
"social_django",
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
...@@ -36,6 +40,7 @@ INSTALLED_APPS = [ ...@@ -36,6 +40,7 @@ INSTALLED_APPS = [
] ]
LOCAL_APPS = [ LOCAL_APPS = [
"sage_validation.file_validator", "sage_validation.file_validator",
"sage_validation.accounts",
] ]
THIRD_PARTY_APPS: list[str] = [] THIRD_PARTY_APPS: list[str] = []
...@@ -48,6 +53,7 @@ MIDDLEWARE = [ ...@@ -48,6 +53,7 @@ MIDDLEWARE = [
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"social_django.middleware.SocialAuthExceptionMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
...@@ -64,6 +70,8 @@ TEMPLATES = [ ...@@ -64,6 +70,8 @@ TEMPLATES = [
"django.template.context_processors.request", "django.template.context_processors.request",
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"social_django.context_processors.backends",
"social_django.context_processors.login_redirect",
], ],
}, },
}, },
...@@ -76,20 +84,24 @@ WSGI_APPLICATION = "sage_validation.wsgi.application" ...@@ -76,20 +84,24 @@ WSGI_APPLICATION = "sage_validation.wsgi.application"
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.sqlite3", "ENGINE": "django.db.backends.postgresql",
"NAME": BASE_DIR / "db.sqlite3", "NAME": os.getenv("POSTGRES_DB_NAME", "sage_validation_db"),
"USER": os.getenv("POSTGRES_DB_USER", "sage_user"),
"PASSWORD": os.getenv("POSTGRES_DB_PASSWORD", "sage_password"),
"HOST": os.getenv("POSTGRES_DB_HOST", "localhost"),
"PORT": os.getenv("POSTGRES_DB_PORT", "5432"),
}, },
"meo": { "meo": {
'ENGINE': 'mssql', "ENGINE": "mssql",
'NAME': os.getenv("MSSQL_DB_NAME", ""), "NAME": os.getenv("MSSQL_DB_NAME", ""),
'USER': os.getenv("MSSQL_DB_USER", ""), "USER": os.getenv("MSSQL_DB_USER", ""),
'PASSWORD': os.getenv("MSSQL_DB_PASSWORD", ""), "PASSWORD": os.getenv("MSSQL_DB_PASSWORD", ""),
'HOST': os.getenv("MSSQL_DB_HOST", "localhost"), "HOST": os.getenv("MSSQL_DB_HOST", "localhost"),
'PORT': os.getenv("MSSQL_DB_PORT", ""), "PORT": os.getenv("MSSQL_DB_PORT", ""),
'OPTIONS': { "OPTIONS": {
'driver': 'ODBC Driver 18 for SQL Server', "driver": "ODBC Driver 18 for SQL Server",
'extra_params': 'TrustServerCertificate=Yes;' "extra_params": "TrustServerCertificate=Yes;"
}, },
}, },
...@@ -143,6 +155,19 @@ STATICFILES_DIRS = [ ...@@ -143,6 +155,19 @@ STATICFILES_DIRS = [
] ]
MEDIA_ROOT = os.getenv("MEDIA_ROOT", BASE_DIR / "media") MEDIA_ROOT = os.getenv("MEDIA_ROOT", BASE_DIR / "media")
MEDIA_URL = '/media/' MEDIA_URL = "/media/"
CSRF_TRUSTED_ORIGINS = os.getenv("CSRF_TRUSTED_ORIGINS", "").split(",") CSRF_TRUSTED_ORIGINS = os.getenv("CSRF_TRUSTED_ORIGINS", "").split(",")
AUTHENTICATION_BACKENDS = (
"social_core.backends.google.GoogleOAuth2",
"django.contrib.auth.backends.ModelBackend",
)
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = os.getenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", "")
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = os.getenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "")
SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = ["email", "profile"]
SOCIAL_AUTH_GOOGLE_OAUTH2_REDIRECT_URI = os.getenv("SOCIAL_AUTH_GOOGLE_OAUTH2_REDIRECT_URI", "")
SOCIAL_AUTH_JSONFIELD_ENABLED = True
SOCIAL_AUTH_URL_NAMESPACE = "social"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/"
\ No newline at end of file
...@@ -18,7 +18,18 @@ ...@@ -18,7 +18,18 @@
<a href="{% url "index" %}" class="text-white text-2xl font-bold tracking-wide">Sage Validation</a> <!-- Made title clickable --> <a href="{% url "index" %}" class="text-white text-2xl font-bold tracking-wide">Sage Validation</a> <!-- Made title clickable -->
</div> </div>
<div class="flex items-center"> <!-- Login link --> <div class="flex items-center"> <!-- Login link -->
<a href="/login" class="text-white hover:text-gray-300">Login</a> {% if user.is_authenticated %}
<p class="text-white">Welcome, {{ user.username }}!</p>
<form action="{% url 'logout' %}" method="post" class="inline-block ml-4">
{% csrf_token %}
<button type="submit"
class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 focus:outline-none focus:ring focus:ring-red-400">
Logout
</button>
</form>
{% else %}
<a href="{% url "social:begin" "google-oauth2" %}" class="text-white hover:text-gray-300">Login</a>
{% endif %}
<button id="mobile-menu-button" class="md:hidden text-white focus:outline-none ml-4"> <button id="mobile-menu-button" class="md:hidden text-white focus:outline-none ml-4">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7"></path>
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
<h1 class="text-5xl md:text-6xl font-bold mb-12 text-blue-900">Welcome to Sage Validation</h1> <h1 class="text-5xl md:text-6xl font-bold mb-12 text-blue-900">Welcome to Sage Validation</h1>
<p class="text-xl md:text-2xl mb-16 text-gray-700">Click the button below to upload your file for validation.</p> <p class="text-xl md:text-2xl mb-16 text-gray-700">Click the button below to upload your file for validation.</p>
<a href="{% url "upload-file" %}" <a href="{% url "upload-page" %}"
class="inline-flex py-4 px-16 bg-blue-600 text-white text-lg md:text-xl font-bold rounded-full shadow-lg transition-transform transform hover:scale-105 focus:outline-none focus:ring-4 focus:ring-blue-300 focus:ring-opacity-50"> class="inline-flex py-4 px-16 bg-blue-600 text-white text-lg md:text-xl font-bold rounded-full shadow-lg transition-transform transform hover:scale-105 focus:outline-none focus:ring-4 focus:ring-blue-300 focus:ring-opacity-50">
Upload File Upload File
</a> </a>
......
...@@ -9,5 +9,6 @@ urlpatterns = [ ...@@ -9,5 +9,6 @@ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("file-validator/", include("sage_validation.file_validator.urls")), path("file-validator/", include("sage_validation.file_validator.urls")),
path("", index_view, name="index"), path("", index_view, name="index"),
path("", include("social_django.urls", namespace="social")),
path("accounts/", include("sage_validation.accounts.urls")),
] ]
...@@ -3,12 +3,15 @@ from setuptools import find_packages, setup ...@@ -3,12 +3,15 @@ from setuptools import find_packages, setup
setup( setup(
name="sage-validation", name="sage-validation",
version="0.4", version="0.5",
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
install_requires=[ install_requires=[
"Django==5.0.11", "Django>=5.0,<5.1",
"mssql-django==1.5", "mssql-django==1.5",
"djangorestframework==3.15.2",
"social-auth-app-django==5.4.3",
"psycopg[binary,pool]",
], ],
extras_require={ extras_require={
"prod": [ "prod": [
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment