From 5362fe2b874a6779a9b6d4383227a1c5f176e228 Mon Sep 17 00:00:00 2001 From: Neda Moeini <neda.moeini@geant.org> Date: Mon, 3 Mar 2025 11:00:44 +0100 Subject: [PATCH 01/10] Add activity log --- sage_validation/accounts/__init__.py | 0 sage_validation/accounts/admin.py | 8 +++ sage_validation/accounts/apps.py | 6 ++ .../accounts/migrations/0001_initial.py | 55 +++++++++++++++++++ .../accounts/migrations/__init__.py | 0 sage_validation/accounts/views.py | 3 + sage_validation/file_validator/views.py | 11 +++- sage_validation/settings.py | 1 + 8 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 sage_validation/accounts/__init__.py create mode 100644 sage_validation/accounts/admin.py create mode 100644 sage_validation/accounts/apps.py create mode 100644 sage_validation/accounts/migrations/0001_initial.py create mode 100644 sage_validation/accounts/migrations/__init__.py create mode 100644 sage_validation/accounts/views.py diff --git a/sage_validation/accounts/__init__.py b/sage_validation/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sage_validation/accounts/admin.py b/sage_validation/accounts/admin.py new file mode 100644 index 0000000..8d326a8 --- /dev/null +++ b/sage_validation/accounts/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin +from .models import UserActivityLog + +@admin.register(UserActivityLog) +class UserActivityLogAdmin(admin.ModelAdmin): + list_display = ("user", "action", "name", "input_file_hash", "output_file_hash", "timestamp") + search_fields = ("user__username", "name", "action") + list_filter = ("action", "timestamp") \ No newline at end of file diff --git a/sage_validation/accounts/apps.py b/sage_validation/accounts/apps.py new file mode 100644 index 0000000..3b83649 --- /dev/null +++ b/sage_validation/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + 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 0000000..163c095 --- /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 0000000..e69de29 diff --git a/sage_validation/accounts/views.py b/sage_validation/accounts/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/sage_validation/accounts/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/sage_validation/file_validator/views.py b/sage_validation/file_validator/views.py index bfa80c2..e7c4002 100644 --- a/sage_validation/file_validator/views.py +++ b/sage_validation/file_validator/views.py @@ -11,6 +11,7 @@ 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 @@ -49,7 +50,15 @@ class CSVUploadAPIView(APIView): updated_data = self.update_fields(csv_data) request.session["validated_csv"] = updated_data request.session.modified = True - + # Log the user activity + UserActivityLog.objects.create( + user=request.user, + action="upload", + name=csv_file.name, + input_file_hash=UserActivityLog.generate_file_hash(csv_file), + output_file_hash=UserActivityLog.generate_file_hash(updated_data), + timestamp=timezone.now() + ) return Response({ "status": "success", "message": "File successfully uploaded and processed.", diff --git a/sage_validation/settings.py b/sage_validation/settings.py index 353594e..8e1a0ff 100644 --- a/sage_validation/settings.py +++ b/sage_validation/settings.py @@ -36,6 +36,7 @@ INSTALLED_APPS = [ ] LOCAL_APPS = [ "sage_validation.file_validator", + "sage_validation.accounts", ] THIRD_PARTY_APPS: list[str] = [] -- GitLab From b1cab1e6f096fc397fe3a5e0e2f59cdd38101ed2 Mon Sep 17 00:00:00 2001 From: Neda Moeini <neda.moeini@geant.org> Date: Mon, 3 Mar 2025 11:01:12 +0100 Subject: [PATCH 02/10] Add missing models.py --- sage_validation/accounts/models.py | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 sage_validation/accounts/models.py diff --git a/sage_validation/accounts/models.py b/sage_validation/accounts/models.py new file mode 100644 index 0000000..bb02ea1 --- /dev/null +++ b/sage_validation/accounts/models.py @@ -0,0 +1,42 @@ +import hashlib + +from django.contrib.auth.models import User +from django.db import models + + +class UserActivityLog(models.Model): + """Model to log user activities.""" + + ACTION_CHOICES = [ + ("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): + 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: + raise TypeError("generate_file_hash() expected a file, BytesIO, string, or list.") + + return hasher.hexdigest() -- GitLab From 94e13f8ed3bd1b4fe0ec0f0c3426bf88bf525c87 Mon Sep 17 00:00:00 2001 From: Neda Moeini <neda.moeini@geant.org> Date: Mon, 3 Mar 2025 11:45:31 +0100 Subject: [PATCH 03/10] Make linter happy. --- sage_validation/accounts/admin.py | 9 +++++++-- sage_validation/accounts/apps.py | 3 +++ sage_validation/accounts/models.py | 10 +++++++--- sage_validation/accounts/views.py | 4 +--- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/sage_validation/accounts/admin.py b/sage_validation/accounts/admin.py index 8d326a8..ae0e150 100644 --- a/sage_validation/accounts/admin.py +++ b/sage_validation/accounts/admin.py @@ -1,8 +1,13 @@ +"""Admin configuration for the UserActivityLog model.""" from django.contrib import admin -from .models import UserActivityLog + +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") \ No newline at end of file + list_filter = ("action", "timestamp") diff --git a/sage_validation/accounts/apps.py b/sage_validation/accounts/apps.py index 3b83649..97f4330 100644 --- a/sage_validation/accounts/apps.py +++ b/sage_validation/accounts/apps.py @@ -1,6 +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/models.py b/sage_validation/accounts/models.py index bb02ea1..40c3631 100644 --- a/sage_validation/accounts/models.py +++ b/sage_validation/accounts/models.py @@ -1,4 +1,6 @@ +"""Models for the accounts app.""" import hashlib +from typing import ClassVar from django.contrib.auth.models import User from django.db import models @@ -7,7 +9,7 @@ from django.db import models class UserActivityLog(models.Model): """Model to log user activities.""" - ACTION_CHOICES = [ + ACTION_CHOICES: ClassVar[list[tuple[str, str]]] = [ ("upload", "Upload"), ("download", "Download"), ] @@ -19,7 +21,8 @@ class UserActivityLog(models.Model): output_file_hash = models.CharField(max_length=64, blank=True, null=True) timestamp = models.DateTimeField(auto_now_add=True) - def __str__(self): + def __str__(self) -> str: + """Return a string representation of the UserActivityLog object.""" return f"{self.user.username} - {self.action} - {self.name} ({self.timestamp})" @staticmethod @@ -37,6 +40,7 @@ class UserActivityLog(models.Model): hasher.update(str(file_obj).encode("utf-8")) else: - raise TypeError("generate_file_hash() expected a file, BytesIO, string, or list.") + err = "generate_file_hash() expected a file, string, or list." + raise TypeError(err) return hasher.hexdigest() diff --git a/sage_validation/accounts/views.py b/sage_validation/accounts/views.py index 91ea44a..73cfceb 100644 --- a/sage_validation/accounts/views.py +++ b/sage_validation/accounts/views.py @@ -1,3 +1 @@ -from django.shortcuts import render - -# Create your views here. +"""Views for accounts app.""" -- GitLab From fc025137aa86ff61edcfd897ef3978218184b375 Mon Sep 17 00:00:00 2001 From: Neda Moeini <neda.moeini@geant.org> Date: Mon, 3 Mar 2025 16:30:51 +0100 Subject: [PATCH 04/10] Add GoogleOAuth authentication- WIP --- requirements.txt | 1 + sage_validation/file_validator/views.py | 2 ++ sage_validation/settings.py | 21 +++++++++++++++++++++ sage_validation/templates/base.html | 11 ++++++++++- sage_validation/urls.py | 1 + 5 files changed, 35 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4edebd6..a91f63d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ pytest-django pytest-mock faker coverage +social-auth-app-django diff --git a/sage_validation/file_validator/views.py b/sage_validation/file_validator/views.py index e7c4002..9bea9c6 100644 --- a/sage_validation/file_validator/views.py +++ b/sage_validation/file_validator/views.py @@ -7,6 +7,7 @@ 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 @@ -28,6 +29,7 @@ def upload_page_view(request: HttpRequest) -> HttpResponse: class CSVUploadAPIView(APIView): """API view for uploading a CSV file.""" + permission_classes = [IsAuthenticated] def post(self, request: Request) -> Response: """Handle CSV upload and validation.""" diff --git a/sage_validation/settings.py b/sage_validation/settings.py index 8e1a0ff..dea87df 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", @@ -49,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", ] @@ -65,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', ], }, }, @@ -147,3 +154,17 @@ 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 = "323196535105-k0dqcfcusmrvb99iok1pui978age9bb8.apps.googleusercontent.com" +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = "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" + +SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = ["email", "profile"] +SOCIAL_AUTH_GOOGLE_OAUTH2_REDIRECT_URI = "http://localhost:8000/complete/google-oauth2/" +SOCIAL_AUTH_JSONFIELD_ENABLED = True +SOCIAL_AUTH_URL_NAMESPACE = 'social' +LOGIN_REDIRECT_URL = "/" +SOCIAL_AUTH_ALLOW_DISCONNECT = True \ No newline at end of file diff --git a/sage_validation/templates/base.html b/sage_validation/templates/base.html index 20f3c23..38f87f4 100644 --- a/sage_validation/templates/base.html +++ b/sage_validation/templates/base.html @@ -18,7 +18,16 @@ <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> + {# <a href="{% url 'social:disconnect' 'google-oauth2' %}" class="text-white hover:text-gray-300">Logout</a>#} +{# <form action="{% url 'social:disconnect' 'google-oauth2' %}" method="post">#} +{# {% csrf_token %}#} +{# <button type="submit">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 af6d615..f839b99 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")) ] -- GitLab From b7456861c6a5be02fb17ae3d4cc96c4024a83044 Mon Sep 17 00:00:00 2001 From: Neda Moeini <neda.moeini@geant.org> Date: Tue, 4 Mar 2025 11:59:30 +0100 Subject: [PATCH 05/10] Add logout functionality --- sage_validation/accounts/urls.py | 7 +++++ .../file_validator/templates/upload.html | 29 ++++++++++++++----- sage_validation/file_validator/views.py | 4 ++- sage_validation/settings.py | 2 +- sage_validation/templates/base.html | 12 ++++---- sage_validation/urls.py | 4 +-- 6 files changed, 41 insertions(+), 17 deletions(-) create mode 100644 sage_validation/accounts/urls.py diff --git a/sage_validation/accounts/urls.py b/sage_validation/accounts/urls.py new file mode 100644 index 0000000..d2b6ead --- /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/file_validator/templates/upload.html b/sage_validation/file_validator/templates/upload.html index ec8619d..a942890 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/views.py b/sage_validation/file_validator/views.py index 9bea9c6..a041fbc 100644 --- a/sage_validation/file_validator/views.py +++ b/sage_validation/file_validator/views.py @@ -1,6 +1,7 @@ """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 @@ -29,7 +30,8 @@ def upload_page_view(request: HttpRequest) -> HttpResponse: class CSVUploadAPIView(APIView): """API view for uploading a CSV file.""" - permission_classes = [IsAuthenticated] + + permission_classes: ClassVar[list] = [IsAuthenticated] def post(self, request: Request) -> Response: """Handle CSV upload and validation.""" diff --git a/sage_validation/settings.py b/sage_validation/settings.py index dea87df..2b9bf7d 100644 --- a/sage_validation/settings.py +++ b/sage_validation/settings.py @@ -167,4 +167,4 @@ SOCIAL_AUTH_GOOGLE_OAUTH2_REDIRECT_URI = "http://localhost:8000/complete/google- SOCIAL_AUTH_JSONFIELD_ENABLED = True SOCIAL_AUTH_URL_NAMESPACE = 'social' LOGIN_REDIRECT_URL = "/" -SOCIAL_AUTH_ALLOW_DISCONNECT = True \ No newline at end of file +LOGOUT_REDIRECT_URL = "/" \ No newline at end of file diff --git a/sage_validation/templates/base.html b/sage_validation/templates/base.html index 38f87f4..71daa8e 100644 --- a/sage_validation/templates/base.html +++ b/sage_validation/templates/base.html @@ -20,11 +20,13 @@ <div class="flex items-center"> <!-- Login link --> {% if user.is_authenticated %} <p class="text-white">Welcome, {{ user.username }}!</p> - {# <a href="{% url 'social:disconnect' 'google-oauth2' %}" class="text-white hover:text-gray-300">Logout</a>#} -{# <form action="{% url 'social:disconnect' 'google-oauth2' %}" method="post">#} -{# {% csrf_token %}#} -{# <button type="submit">Logout</button>#} -{# </form>#} + <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 %} diff --git a/sage_validation/urls.py b/sage_validation/urls.py index f839b99..1146896 100644 --- a/sage_validation/urls.py +++ b/sage_validation/urls.py @@ -9,6 +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("", include("social_django.urls", namespace="social")), + path("accounts/", include("sage_validation.accounts.urls")), ] -- GitLab From 1a7d601dc08df0dd18d8a4aba06e922af891d719 Mon Sep 17 00:00:00 2001 From: Neda Moeini <neda.moeini@geant.org> Date: Tue, 4 Mar 2025 13:33:31 +0100 Subject: [PATCH 06/10] Update unit tests to support authentication. --- sage_validation/file_validator/tests.py | 1 - test/conftest.py | 7 ++++++- .../test_file_validator/test_file_validator_endpoints.py | 9 +++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) delete mode 100644 sage_validation/file_validator/tests.py diff --git a/sage_validation/file_validator/tests.py b/sage_validation/file_validator/tests.py deleted file mode 100644 index 676ebd6..0000000 --- a/sage_validation/file_validator/tests.py +++ /dev/null @@ -1 +0,0 @@ -"""All the tests for the file_validator app.""" diff --git a/test/conftest.py b/test/conftest.py index f32a594..9f0053b 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 @@ -144,4 +145,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 3193a62..ac78502 100644 --- a/test/test_file_validator/test_file_validator_endpoints.py +++ b/test/test_file_validator/test_file_validator_endpoints.py @@ -8,6 +8,15 @@ from rest_framework.test import APIClient 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 -- GitLab From 3502bacd7550065fa5da179c97e93d7cfde06208 Mon Sep 17 00:00:00 2001 From: Neda Moeini <neda.moeini@geant.org> Date: Tue, 4 Mar 2025 13:37:37 +0100 Subject: [PATCH 07/10] Add the Google OAUTH parameters to ENV --- sage_validation/settings.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/sage_validation/settings.py b/sage_validation/settings.py index 2b9bf7d..697c7f1 100644 --- a/sage_validation/settings.py +++ b/sage_validation/settings.py @@ -159,11 +159,10 @@ AUTHENTICATION_BACKENDS = ( "django.contrib.auth.backends.ModelBackend", ) -SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = "323196535105-k0dqcfcusmrvb99iok1pui978age9bb8.apps.googleusercontent.com" -SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = "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" - +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 = "http://localhost:8000/complete/google-oauth2/" +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 = "/" -- GitLab From 7264217a825f72ce9a74aeb862003c58dbe9e32c Mon Sep 17 00:00:00 2001 From: Neda Moeini <neda.moeini@geant.org> Date: Tue, 4 Mar 2025 15:37:48 +0100 Subject: [PATCH 08/10] Improved the export file name and log the user activity in download endpoint. --- sage_validation/file_validator/views.py | 26 ++++++++++--------- test/conftest.py | 2 ++ .../test_file_validator_endpoints.py | 10 ++++--- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/sage_validation/file_validator/views.py b/sage_validation/file_validator/views.py index a041fbc..5eb98dd 100644 --- a/sage_validation/file_validator/views.py +++ b/sage_validation/file_validator/views.py @@ -53,16 +53,9 @@ 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 - # Log the user activity - UserActivityLog.objects.create( - user=request.user, - action="upload", - name=csv_file.name, - input_file_hash=UserActivityLog.generate_file_hash(csv_file), - output_file_hash=UserActivityLog.generate_file_hash(updated_data), - timestamp=timezone.now() - ) + return Response({ "status": "success", "message": "File successfully uploaded and processed.", @@ -111,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/test/conftest.py b/test/conftest.py index 9f0053b..94a62ed 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -36,6 +36,7 @@ def sample_input_file() -> SimpleUploadedFile: "TaxValue", "SYSTraderGenerationReasonType", "GoodsValueInBaseCurrency", + "TransactionReference", # NominalAnalysis repeating columns (Example: /1 for first occurrence) "NominalAnalysisTransactionValue/1", @@ -77,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 diff --git a/test/test_file_validator/test_file_validator_endpoints.py b/test/test_file_validator/test_file_validator_endpoints.py index ac78502..7eb76d1 100644 --- a/test/test_file_validator/test_file_validator_endpoints.py +++ b/test/test_file_validator/test_file_validator_endpoints.py @@ -49,13 +49,15 @@ 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 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 + 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 -- GitLab From 5b082ef2886d4014addb3a8946e5246ba3951596 Mon Sep 17 00:00:00 2001 From: Neda Moeini <neda.moeini@geant.org> Date: Tue, 4 Mar 2025 16:33:12 +0100 Subject: [PATCH 09/10] Add unit test for activity log. --- .../test_file_validator_endpoints.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/test_file_validator/test_file_validator_endpoints.py b/test/test_file_validator/test_file_validator_endpoints.py index 7eb76d1..1d3b79f 100644 --- a/test/test_file_validator/test_file_validator_endpoints.py +++ b/test/test_file_validator/test_file_validator_endpoints.py @@ -5,6 +5,8 @@ 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") @@ -57,7 +59,32 @@ def test_csv_export_with_data(api_client: APIClient) -> None: 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 + 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" -- GitLab From 5b1da7cdac8c2227e438ceca1ef31a81e275ace8 Mon Sep 17 00:00:00 2001 From: Neda Moeini <neda.moeini@geant.org> Date: Tue, 4 Mar 2025 17:04:25 +0100 Subject: [PATCH 10/10] Pin versions for django, djangorestframework and django social auth. --- requirements.txt | 6 +++--- setup.py | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index a91f63d..897e939 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,4 +11,4 @@ pytest-django pytest-mock faker coverage -social-auth-app-django +social-auth-app-django==5.4.3 diff --git a/setup.py b/setup.py index 2eb77c2..027fc84 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": [ -- GitLab