Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • geant-swd/sage-validation
1 result
Show changes
Commits on Source (27)
Showing
with 263 additions and 38 deletions
# Changelog
## [0.6] - 2025-03-14
-Added SOCIAL_AUTH_REDIRECT_IS_HTTPS
## [0.5] - 2025-03-14
- Improved CSV eport functionality
- Added Google authentication support
- Set up PstgreSQL as the default DB
- Imporved file export name
- Added unittests
## [0.4] - 2025-02-13
- Added CSV Export and Modify Functionality
......
Django==5.0.11
django-rest-framework
Django>=5.0,<5.1
djangorestframework==3.15.2
ruff
mypy
tox
......@@ -11,3 +11,5 @@ pytest-django
pytest-mock
faker
coverage
social-auth-app-django==5.4.3
psycopg[binary,pool]
\ No newline at end of file
"""Admin configuration for the UserActivityLog model."""
from django.contrib import admin
from sage_validation.accounts.models import UserActivityLog
@admin.register(UserActivityLog)
class UserActivityLogAdmin(admin.ModelAdmin):
"""Admin configuration for the UserActivityLog model."""
list_display = ("user", "action", "name", "input_file_hash", "output_file_hash", "timestamp")
search_fields = ("user__username", "name", "action")
list_filter = ("action", "timestamp")
"""Django app configuration for the accounts' app."""
from django.apps import AppConfig
class AccountsConfig(AppConfig):
"""App configuration for the accounts' app."""
default_auto_field = "django.db.models.BigAutoField"
name = "sage_validation.accounts"
# Generated by Django 5.0.11 on 2025-03-03 09:09
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="UserActivityLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"action",
models.CharField(
choices=[("upload", "Upload"), ("download", "Download")],
max_length=10,
),
),
("name", models.CharField(max_length=255)),
(
"input_file_hash",
models.CharField(blank=True, max_length=64, null=True),
),
(
"output_file_hash",
models.CharField(blank=True, max_length=64, null=True),
),
("timestamp", models.DateTimeField(auto_now_add=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="activity_logs",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]
"""Models for the accounts app."""
import hashlib
from typing import ClassVar
from django.contrib.auth.models import User
from django.db import models
class UserActivityLog(models.Model):
"""Model to log user activities."""
ACTION_CHOICES: ClassVar[list[tuple[str, str]]] = [
("upload", "Upload"),
("download", "Download"),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="activity_logs")
action = models.CharField(max_length=10, choices=ACTION_CHOICES)
name = models.CharField(max_length=255)
input_file_hash = models.CharField(max_length=64, blank=True, null=True)
output_file_hash = models.CharField(max_length=64, blank=True, null=True)
timestamp = models.DateTimeField(auto_now_add=True)
def __str__(self) -> str:
"""Return a string representation of the UserActivityLog object."""
return f"{self.user.username} - {self.action} - {self.name} ({self.timestamp})"
@staticmethod
def generate_file_hash(file_obj: object) -> str:
"""Generate SHA-256 hash for a file-like object."""
hasher = hashlib.sha256()
if hasattr(file_obj, "chunks"):
for chunk in file_obj.chunks():
hasher.update(chunk)
elif isinstance(file_obj, str):
hasher.update(file_obj.encode("utf-8"))
elif isinstance(file_obj, list):
hasher.update(str(file_obj).encode("utf-8"))
else:
err = "generate_file_hash() expected a file, string, or list."
raise TypeError(err)
return hasher.hexdigest()
"""URL configuration for the accounts' app."""
from django.contrib.auth.views import LogoutView
from django.urls import path
urlpatterns = [
path("logout/", LogoutView.as_view(), name="logout"),
]
"""Views for accounts app."""
"""Forms for the file_validator app."""
import csv
import re
from collections.abc import Sequence
from typing import ClassVar
......@@ -169,13 +170,18 @@ class CSVUploadForm(forms.Form):
continue
pl_account_name = account_code_map.get(account_code)
if pl_account_name is None:
errors.append(f"Row {index}: 'AccountNumber' {account_code} does not exist in PL Account Codes.")
elif pl_account_name not in nominal:
errors.append(
f"Row {index}: 'AccountNumber' must match '{pl_account_name}' in "
f"'NominalAnalysisNominalAnalysisNarrative/1', but found '{nominal}'."
)
else:
# Remove 'Soldo' and any hyphens from the PL account name. This is for credit card accounts.
revised_pl_account_name = re.sub(
r"\bSoldo\b|\s*-\s*", "", pl_account_name, flags=re.IGNORECASE).strip()
if revised_pl_account_name not in nominal:
errors.append(
f"Row {index}: 'AccountNumber' must match '{revised_pl_account_name}' in "
f"'NominalAnalysisNominalAnalysisNarrative/1', but found '{nominal}'."
)
return errors
......@@ -241,7 +247,7 @@ class CSVUploadForm(forms.Form):
errors.append(
f"Row {index}: The combination of '{cc_field}' ({cc}), "
f"'{dep_field}' ({dep}), and '{nominal_account_field}' "
f"({nominal_account_name}) does not exist in MEO valid Sage accounts."
f"({nc}) does not exist in MEO valid Sage accounts."
)
return errors
......@@ -38,6 +38,7 @@ class MeoNominal(models.Model):
class MeoValidSageAccounts(models.Model):
"""View for MEO valid Sage accounts."""
id = models.UUIDField(db_column="ID", primary_key=True)
account_name = models.CharField(db_column="AccountName", max_length=60)
account_number = models.CharField(db_column="AccountNumber", max_length=8, blank=True, null=True)
account_cost_centre = models.CharField(db_column="AccountCostCentre", max_length=3, blank=True, null=True)
......
......@@ -3,7 +3,7 @@
{% block title %}File Upload{% endblock %}
{% block content %}
<div class="bg-white p-10 rounded-lg shadow-lg w-11/12 md:w-1/2 lg:w-1/3 mx-auto">
<div class="bg-white p-10 rounded-lg shadow-lg w-11/12 md:w-3/4 lg:w-3/4 mx-auto">
<h2 class="text-2xl font-bold mb-6 text-gray-800 text-center">Upload CSV File</h2>
<form id="uploadForm" enctype="multipart/form-data" class="space-y-6">
......@@ -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",
],
},
},
......@@ -76,20 +84,24 @@ WSGI_APPLICATION = "sage_validation.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
"ENGINE": "django.db.backends.postgresql",
"NAME": os.getenv("POSTGRES_DB_NAME", "sage_validation_db"),
"USER": os.getenv("POSTGRES_DB_USER", "sage_user"),
"PASSWORD": os.getenv("POSTGRES_DB_PASSWORD", "sage_password"),
"HOST": os.getenv("POSTGRES_DB_HOST", "localhost"),
"PORT": os.getenv("POSTGRES_DB_PORT", "5432"),
},
"meo": {
'ENGINE': 'mssql',
'NAME': os.getenv("MSSQL_DB_NAME", ""),
'USER': os.getenv("MSSQL_DB_USER", ""),
'PASSWORD': os.getenv("MSSQL_DB_PASSWORD", ""),
'HOST': os.getenv("MSSQL_DB_HOST", "localhost"),
'PORT': os.getenv("MSSQL_DB_PORT", ""),
"ENGINE": "mssql",
"NAME": os.getenv("MSSQL_DB_NAME", ""),
"USER": os.getenv("MSSQL_DB_USER", ""),
"PASSWORD": os.getenv("MSSQL_DB_PASSWORD", ""),
"HOST": os.getenv("MSSQL_DB_HOST", "localhost"),
"PORT": os.getenv("MSSQL_DB_PORT", ""),
'OPTIONS': {
'driver': 'ODBC Driver 18 for SQL Server',
'extra_params': 'TrustServerCertificate=Yes;'
"OPTIONS": {
"driver": "ODBC Driver 18 for SQL Server",
"extra_params": "TrustServerCertificate=Yes;"
},
},
......@@ -143,6 +155,20 @@ STATICFILES_DIRS = [
]
MEDIA_ROOT = os.getenv("MEDIA_ROOT", BASE_DIR / "media")
MEDIA_URL = '/media/'
MEDIA_URL = "/media/"
CSRF_TRUSTED_ORIGINS = os.getenv("CSRF_TRUSTED_ORIGINS", "").split(",")
AUTHENTICATION_BACKENDS = (
"social_core.backends.google.GoogleOAuth2",
"django.contrib.auth.backends.ModelBackend",
)
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = os.getenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", "")
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = os.getenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "")
SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = ["email", "profile"]
SOCIAL_AUTH_GOOGLE_OAUTH2_REDIRECT_URI = os.getenv("SOCIAL_AUTH_GOOGLE_OAUTH2_REDIRECT_URI", "")
SOCIAL_AUTH_REDIRECT_IS_HTTPS = os.getenv('SOCIAL_AUTH_FORCE_HTTPS', 'true').lower() == 'true'
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")),
]
......@@ -3,12 +3,15 @@ from setuptools import find_packages, setup
setup(
name="sage-validation",
version="0.5",
version="0.7",
packages=find_packages(),
include_package_data=True,
install_requires=[
"Django==5.0.11",
"Django>=5.0,<5.1",
"mssql-django==1.5",
"djangorestframework==3.15.2",
"social-auth-app-django==5.4.3",
"psycopg[binary,pool]",
],
extras_require={
"prod": [
......
......@@ -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