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