Skip to content
Snippets Groups Projects
Commit e3f82aac authored by Neda Moeini's avatar Neda Moeini
Browse files

Merge branch 'feature/activity-log' into 'develop'

Feature/activity log

See merge request !4
parents eedc72df 5b1da7cd
No related branches found
No related tags found
1 merge request!4Feature/activity log
Showing
with 261 additions and 22 deletions
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
"""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."""
......@@ -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');
}
......
"""All the tests for the file_validator app."""
"""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
......@@ -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
......@@ -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>
......
......@@ -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")),
]
......@@ -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": [
......
......@@ -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
......@@ -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"
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