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
## [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
## [0.2] - 2025-01-27
## [0.3] - 2025-01-27
- Added new validations
## [0.2] - 2025-01-09
......
......@@ -37,4 +37,10 @@ line-length = 120
[tool.ruff.lint.flake8-tidy-imports]
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 @@
</div>
</div>
<script>
const form = document.getElementById('uploadForm');
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) {
document.getElementById('uploadForm').addEventListener('submit', async function (e) {
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 = '';
successMessage.innerHTML = '';
errorSection.classList.add('hidden');
......@@ -65,7 +62,7 @@
formData.append('file', fileInput.files[0]);
try {
const response = await fetch('', {
const response = await fetch("{% url 'upload-file' %}", {
method: 'POST',
body: formData,
headers: {
......@@ -80,26 +77,14 @@
successSection.classList.remove('hidden');
downloadLink.href = result.download_url;
downloadSection.classList.remove('hidden');
} else if (response.status === 400 && result.status === 'error') {
} else {
errorList.innerHTML = '';
if (Array.isArray(result.errors)) {
result.errors.forEach(errorObj => {
if (typeof errorObj === 'string') {
const li = document.createElement('li');
li.textContent = errorObj;
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') {
if (response.status === 403) {
const li = document.createElement('li');
li.textContent = 'You are not authorized to perform this action.';
errorList.appendChild(li);
} else if (response.status === 400 && result.status === 'error') {
for (const [field, messages] of Object.entries(result.errors)) {
messages.forEach(message => {
const li = document.createElement('li');
......@@ -109,7 +94,7 @@
}
} else {
const li = document.createElement('li');
li.textContent = result.errors;
li.textContent = 'An unexpected error occurred. Please try again.';
errorList.appendChild(li);
}
......@@ -117,7 +102,7 @@
}
} catch (error) {
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);
errorSection.classList.remove('hidden');
}
......
"""All the tests for the file_validator app."""
......@@ -2,9 +2,10 @@
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 = [
path("upload/", CSVUploadView.as_view(), name="upload-file"),
path("export/", CSVExportView.as_view(), name="export-file"),
path("upload-page/", upload_page_view, name="upload-page"),
path("api/upload/", CSVUploadAPIView.as_view(), name="upload-file"),
path("api/export/", CSVExportAPIView.as_view(), name="export-file"),
]
"""Views for the file_validator app."""
import csv
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.urls import reverse_lazy
from django.utils import timezone
from django.views.generic.base import View
from django.views.generic.edit import FormView
from rest_framework import status
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.models import MeoCostCentres, XxData
......@@ -19,46 +23,44 @@ def index_view(request: HttpRequest) -> HttpResponse:
return render(request, "index.html")
class CSVUploadView(FormView):
"""View for uploading a CSV file."""
def upload_page_view(request: HttpRequest) -> HttpResponse:
"""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]:
"""Render the form with no error message on GET request."""
context = super().get_context_data(**kwargs)
context["error"] = None
context["message"] = None
return context
class CSVUploadAPIView(APIView):
"""API view for uploading a CSV file."""
permission_classes: ClassVar[list] = [IsAuthenticated]
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.seek(0)
decoded_file = csv_file.read().decode("utf-8").strip()
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))
csv_data: list[dict[str, str]] = list(reader)
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
self.request.session.modified = True
return JsonResponse({
return Response({
"status": "success",
"message": "File successfully uploaded and processed.",
"download_url": reverse_lazy("export-file")
})
def form_invalid(self, form: CSVUploadForm) -> JsonResponse:
"""Handle the form when it is invalid."""
return JsonResponse({"status": "error", "errors": form.errors}, status=400)
}, status=status.HTTP_200_OK)
@staticmethod
def update_fields(csv_data: list[dict[str, str]]) -> list[dict[str, str]]:
......@@ -91,27 +93,36 @@ class CSVUploadView(FormView):
row[f"NominalAnalysisNominalAccountNumber/{repeat}"] = (
xx_data[0] if cc_type == "Project" else xx_data[1]
)
repeat += 1
return csv_data
class CSVExportView(View):
"""View for exporting the updated CSV file."""
class CSVExportAPIView(APIView):
"""API view for exporting the updated CSV file."""
def get(self, request: HttpRequest) -> HttpResponse:
"""Generate a downloadable CSV file with updated values."""
def get(self, request: Request) -> Response:
"""Return processed CSV as a downloadable response."""
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:
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["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.writeheader()
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
......@@ -9,6 +9,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/
"""
import os
from datetime import timedelta
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
......@@ -18,7 +19,7 @@ BASE_DIR = Path(__file__).resolve().parent
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
# 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!
DEBUG = True
......@@ -27,6 +28,9 @@ ALLOWED_HOSTS: list[str] = os.getenv("ALLOWED_HOSTS", "").split(",")
# Application definition
INSTALLED_APPS = [
"rest_framework",
"rest_framework.authtoken",
"social_django",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
......@@ -36,6 +40,7 @@ INSTALLED_APPS = [
]
LOCAL_APPS = [
"sage_validation.file_validator",
"sage_validation.accounts",
]
THIRD_PARTY_APPS: list[str] = []
......@@ -48,6 +53,7 @@ MIDDLEWARE = [
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"social_django.middleware.SocialAuthExceptionMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
......@@ -64,6 +70,8 @@ TEMPLATES = [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"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"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
"ENGINE": "django.db.backends.postgresql",
"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": {
'ENGINE': 'mssql',
'NAME': os.getenv("MSSQL_DB_NAME", ""),
'USER': os.getenv("MSSQL_DB_USER", ""),
'PASSWORD': os.getenv("MSSQL_DB_PASSWORD", ""),
'HOST': os.getenv("MSSQL_DB_HOST", "localhost"),
'PORT': os.getenv("MSSQL_DB_PORT", ""),
"ENGINE": "mssql",
"NAME": os.getenv("MSSQL_DB_NAME", ""),
"USER": os.getenv("MSSQL_DB_USER", ""),
"PASSWORD": os.getenv("MSSQL_DB_PASSWORD", ""),
"HOST": os.getenv("MSSQL_DB_HOST", "localhost"),
"PORT": os.getenv("MSSQL_DB_PORT", ""),
'OPTIONS': {
'driver': 'ODBC Driver 18 for SQL Server',
'extra_params': 'TrustServerCertificate=Yes;'
"OPTIONS": {
"driver": "ODBC Driver 18 for SQL Server",
"extra_params": "TrustServerCertificate=Yes;"
},
},
......@@ -143,6 +155,19 @@ STATICFILES_DIRS = [
]
MEDIA_ROOT = os.getenv("MEDIA_ROOT", BASE_DIR / "media")
MEDIA_URL = '/media/'
MEDIA_URL = "/media/"
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 @@
<a href="{% url "index" %}" class="text-white text-2xl font-bold tracking-wide">Sage Validation</a> <!-- Made title clickable -->
</div>
<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">
<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>
......
......@@ -8,7 +8,7 @@
<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>
<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">
Upload File
</a>
......
......@@ -9,5 +9,6 @@ urlpatterns = [
path("admin/", admin.site.urls),
path("file-validator/", include("sage_validation.file_validator.urls")),
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
setup(
name="sage-validation",
version="0.4",
version="0.5",
packages=find_packages(),
include_package_data=True,
install_requires=[
"Django==5.0.11",
"Django>=5.0,<5.1",
"mssql-django==1.5",
"djangorestframework==3.15.2",
"social-auth-app-django==5.4.3",
"psycopg[binary,pool]",
],
extras_require={
"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