diff --git a/requirements.txt b/requirements.txt index 4edebd6b0646bd3afbc9d5f613964dea13a26e24..897e939d650c08e567ef0b448ffcc406cdb7ddab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -Django==5.0.11 -django-rest-framework +Django>=5.0,<5.1 +djangorestframework==3.15.2 ruff mypy tox @@ -11,3 +11,4 @@ pytest-django pytest-mock faker coverage +social-auth-app-django==5.4.3 diff --git a/sage_validation/accounts/__init__.py b/sage_validation/accounts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/sage_validation/accounts/admin.py b/sage_validation/accounts/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..ae0e150fb8b9da27994b7720a6e93785ac716426 --- /dev/null +++ b/sage_validation/accounts/admin.py @@ -0,0 +1,13 @@ +"""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") diff --git a/sage_validation/accounts/apps.py b/sage_validation/accounts/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..97f433049e1d7e1c46c6d8bb42fa7f41213cb535 --- /dev/null +++ b/sage_validation/accounts/apps.py @@ -0,0 +1,9 @@ +"""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" diff --git a/sage_validation/accounts/migrations/0001_initial.py b/sage_validation/accounts/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..163c095f3f495857b641c7615319b1e8ec05e749 --- /dev/null +++ b/sage_validation/accounts/migrations/0001_initial.py @@ -0,0 +1,55 @@ +# 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, + ), + ), + ], + ), + ] diff --git a/sage_validation/accounts/migrations/__init__.py b/sage_validation/accounts/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/sage_validation/accounts/models.py b/sage_validation/accounts/models.py new file mode 100644 index 0000000000000000000000000000000000000000..40c3631433f95a58212f49014681ff6c53a75753 --- /dev/null +++ b/sage_validation/accounts/models.py @@ -0,0 +1,46 @@ +"""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() diff --git a/sage_validation/accounts/urls.py b/sage_validation/accounts/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..d2b6eada72dd075d384255e9b6bc90975172bc96 --- /dev/null +++ b/sage_validation/accounts/urls.py @@ -0,0 +1,7 @@ +"""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"), +] diff --git a/sage_validation/accounts/views.py b/sage_validation/accounts/views.py new file mode 100644 index 0000000000000000000000000000000000000000..73cfcebfbe2f5309c824cbc422921dd3d897e64d --- /dev/null +++ b/sage_validation/accounts/views.py @@ -0,0 +1 @@ +"""Views for accounts app.""" diff --git a/sage_validation/file_validator/templates/upload.html b/sage_validation/file_validator/templates/upload.html index ec8619dd40130d1555c59dc7a549c6c359c57edc..a9428905a06c54a7c936c2d18c6bcddee3c6d32a 100644 --- a/sage_validation/file_validator/templates/upload.html +++ b/sage_validation/file_validator/templates/upload.html @@ -77,19 +77,32 @@ successSection.classList.remove('hidden'); downloadLink.href = result.download_url; downloadSection.classList.remove('hidden'); - } 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'); - li.textContent = `${field}: ${message}`; - errorList.appendChild(li); - }); + } else { + errorList.innerHTML = ''; + + 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'); + li.textContent = `${field}: ${message}`; + errorList.appendChild(li); + }); + } + } else { + const li = document.createElement('li'); + li.textContent = 'An unexpected error occurred. Please try again.'; + errorList.appendChild(li); } + errorSection.classList.remove('hidden'); } } 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'); } diff --git a/sage_validation/file_validator/tests.py b/sage_validation/file_validator/tests.py deleted file mode 100644 index 676ebd605102ad796947e51e5e7b946f30420eb3..0000000000000000000000000000000000000000 --- a/sage_validation/file_validator/tests.py +++ /dev/null @@ -1 +0,0 @@ -"""All the tests for the file_validator app.""" diff --git a/sage_validation/file_validator/views.py b/sage_validation/file_validator/views.py index bfa80c2cde6e71b61c4849f7183a669d74b739c2..5eb98dddf158d5597bdad04b41b256d7b491aaad 100644 --- a/sage_validation/file_validator/views.py +++ b/sage_validation/file_validator/views.py @@ -1,16 +1,19 @@ """Views for the file_validator app.""" import csv import io +from typing import ClassVar from django.http import HttpRequest, HttpResponse from django.shortcuts import render from django.urls import reverse_lazy from django.utils import timezone 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 @@ -28,6 +31,8 @@ def upload_page_view(request: HttpRequest) -> HttpResponse: 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) @@ -48,6 +53,7 @@ class CSVUploadAPIView(APIView): 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 return Response({ @@ -98,16 +104,25 @@ class CSVExportAPIView(APIView): 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 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 diff --git a/sage_validation/settings.py b/sage_validation/settings.py index 353594efe25f8595b1a859dcd76d7c2649c6951c..697c7f1b598bb467723c52f704cf9a1cf468bdd4 100644 --- a/sage_validation/settings.py +++ b/sage_validation/settings.py @@ -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'. @@ -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', ], }, }, @@ -146,3 +154,16 @@ MEDIA_ROOT = os.getenv("MEDIA_ROOT", BASE_DIR / "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 diff --git a/sage_validation/templates/base.html b/sage_validation/templates/base.html index 20f3c23d95c69128f00d91f7a9e74061ec522528..71daa8e5dee039043a047c18e787b54780444c5f 100644 --- a/sage_validation/templates/base.html +++ b/sage_validation/templates/base.html @@ -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> diff --git a/sage_validation/urls.py b/sage_validation/urls.py index af6d6158c155dd64a21c7170bb90f05d1fc7020d..1146896db11939a8c6ddc3db541093f717b62166 100644 --- a/sage_validation/urls.py +++ b/sage_validation/urls.py @@ -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")), ] diff --git a/setup.py b/setup.py index 2eb77c2bc09675bf135b8f8a8217ed362ccaa753..027fc8403409a3f8bdbf6553a1939d215dc20946 100644 --- a/setup.py +++ b/setup.py @@ -7,8 +7,10 @@ setup( 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", ], extras_require={ "prod": [ diff --git a/test/conftest.py b/test/conftest.py index f32a5947d4e438df30201bec9d863bbd9e486ae5..94a62ed9ada92e49af102a8213b29e3866eb9b90 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock import pytest +from django.contrib.auth.models import User from django.core.files.uploadedfile import SimpleUploadedFile from faker import Faker from rest_framework.test import APIClient @@ -35,6 +36,7 @@ def sample_input_file() -> SimpleUploadedFile: "TaxValue", "SYSTraderGenerationReasonType", "GoodsValueInBaseCurrency", + "TransactionReference", # NominalAnalysis repeating columns (Example: /1 for first occurrence) "NominalAnalysisTransactionValue/1", @@ -76,6 +78,7 @@ def sample_input_file() -> SimpleUploadedFile: "10", # TaxValue "1000", # SYSTraderGenerationReasonType "1200", # GoodsValueInBaseCurrency + "BK123", # TransactionReference(Batch Number) # NominalAnalysis repeating values (Example: /1) "500.75", # NominalAnalysisTransactionValue/1 @@ -144,4 +147,8 @@ def mock_meo_database(mocker: MagicMock)-> None: @pytest.fixture def api_client() -> APIClient: """Fixture to return Django API test client.""" - return APIClient() + fake = Faker() + user = User.objects.create_user(username=fake.user_name(), password=fake.password()) + client = APIClient() + client.force_authenticate(user=user) + return client diff --git a/test/test_file_validator/test_file_validator_endpoints.py b/test/test_file_validator/test_file_validator_endpoints.py index 3193a62b63d3e73eb1bd9d5f46127f3adec51a49..1d3b79f811465f9ee982d0554d467270ff4941e4 100644 --- a/test/test_file_validator/test_file_validator_endpoints.py +++ b/test/test_file_validator/test_file_validator_endpoints.py @@ -5,9 +5,20 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.urls.base import reverse from rest_framework.test import APIClient +from sage_validation.accounts.models import UserActivityLog + UPLOAD_FILE_URL = reverse("upload-file") +@pytest.mark.django_db +def test_csv_upload_unauthenticated(sample_input_file: SimpleUploadedFile) -> None: + """Test that a valid CSV upload succeeds.""" + api_client = APIClient() + response = api_client.post(UPLOAD_FILE_URL, {"file": sample_input_file}, format="multipart") + + assert response.status_code == 403 + assert response.json()["detail"] == "Authentication credentials were not provided." + @pytest.mark.django_db def test_csv_upload_valid( api_client: APIClient, sample_input_file: SimpleUploadedFile, mock_meo_database: MagicMock @@ -40,13 +51,40 @@ def test_csv_export_with_data(api_client: APIClient) -> None: # Simulate session data session = api_client.session session["validated_csv"] = [ - {"AccountNumber": "12345", "TransactionDate": "01/03/2024", "NominalAnalysisNominalAccountNumber/1": "N100"} + {"AccountNumber": "12345", "TransactionDate": "01/03/2024", "TransactionReference": "BK1234"}, + ] + session["input_file_hash"] = "123456" + + session.save() + + response = api_client.get(url) + + assert UserActivityLog.objects.count() == 1 + assert response.status_code == 200 + assert response["Content-Disposition"] == "attachment; filename=Validated_BK1234.csv" + assert b"AccountNumber,TransactionDate,TransactionReference" in response.content + assert b"12345,01/03/2024,BK1234" in response.content + + +@pytest.mark.django_db +def test_activity_log_creation_on_csv_export(api_client: APIClient) -> None: + """Test that a UserActivityLog is created when exporting a CSV file.""" + url = reverse("export-file") + + # Simulate session data + session = api_client.session + session["validated_csv"] = [ + {"AccountNumber": "12345", "TransactionDate": "01/03/2024", "TransactionReference": "BK1234"}, ] + session["input_file_hash"] = "123456" + session.save() response = api_client.get(url) assert response.status_code == 200 - assert response["Content-Disposition"] == 'attachment; filename="updated_file.csv"' - assert b"AccountNumber,TransactionDate,NominalAnalysisNominalAccountNumber/1" in response.content - assert b"12345,01/03/2024,N100" in response.content + log = UserActivityLog.objects.first() + assert log.action == "download" + assert log.input_file_hash == "123456" + assert log.output_file_hash == UserActivityLog.generate_file_hash(session["validated_csv"]) + assert log.name == "Validated_BK1234.csv"