Skip to content
Snippets Groups Projects
Commit 28d3faca authored by geant-release-service's avatar geant-release-service
Browse files

Finished release 0.4.

parents 7f1a6fe2 d5714d60
No related branches found
No related tags found
No related merge requests found
# Changelog # Changelog
## [0.3] - 2025-02-13
- Added CSV Export and Modify Functionality
## [0.2] - 2025-01-27 ## [0.2] - 2025-01-27
- Added new validations - Added new validations
......
"""Forms for the file_validator app.""" """Forms for the file_validator app."""
import csv import csv
from collections.abc import Iterable, Sequence from collections.abc import Sequence
from typing import ClassVar from typing import ClassVar
from django import forms from django import forms
from django.core.files.uploadedfile import UploadedFile from django.core.files.uploadedfile import UploadedFile
from sage_validation.file_validator.models import Meocostcentres, Meovalidsageaccounts, PlAccountCodes, XxData from sage_validation.file_validator.models import (
MeoCostCentres,
MeoNominal,
MeoValidSageAccounts,
MeoValidSuppliers,
XxData,
)
class CSVUploadForm(forms.Form): class CSVUploadForm(forms.Form):
...@@ -72,7 +78,6 @@ class CSVUploadForm(forms.Form): ...@@ -72,7 +78,6 @@ class CSVUploadForm(forms.Form):
self._validate_headers(fieldnames) self._validate_headers(fieldnames)
error_list = [] error_list = []
# Step 3: Validate 'Source' and 'SYSTraderTranType' values
data = list(reader) data = list(reader)
error_list.extend(self._validate_source_and_trader_type(data)) error_list.extend(self._validate_source_and_trader_type(data))
error_list.extend(self._validate_nominal_analysis_account(data)) error_list.extend(self._validate_nominal_analysis_account(data))
...@@ -121,7 +126,7 @@ class CSVUploadForm(forms.Form): ...@@ -121,7 +126,7 @@ class CSVUploadForm(forms.Form):
raise forms.ValidationError(msg) raise forms.ValidationError(msg)
@staticmethod @staticmethod
def _validate_source_and_trader_type(data: Iterable[dict]) -> list: def _validate_source_and_trader_type(data: list[dict]) -> list:
"""Validate that 'Source' is always 80 and 'SYSTraderTranType' is always 4.""" """Validate that 'Source' is always 80 and 'SYSTraderTranType' is always 4."""
errors = [] errors = []
...@@ -135,14 +140,14 @@ class CSVUploadForm(forms.Form): ...@@ -135,14 +140,14 @@ class CSVUploadForm(forms.Form):
return errors return errors
@staticmethod @staticmethod
def _validate_nominal_analysis_account(data: Iterable[dict]) -> list[str]: def _validate_nominal_analysis_account(data: list[dict]) -> list[str]:
"""Validate that 'AccountNumber' matches the name in 'NominalAnalysisNominalAnalysisNarrative/1'. """Validate that 'AccountNumber' matches the name in 'NominalAnalysisNominalAnalysisNarrative/1'.
This only checks the first group of NominalAnalysis columns. A list of codes/names This only checks the first group of NominalAnalysis columns. A list of codes/names
is fetched from the database for validation (from the 'PL Account Codes' table). is fetched from the database for validation (from the 'PL Account Codes' table).
Args: Args:
data (Iterable[dict]): The rows of data to validate. data (list[dict]): The rows of data to validate.
Returns: Returns:
List[str]: A list of error messages, if any. List[str]: A list of error messages, if any.
...@@ -151,8 +156,8 @@ class CSVUploadForm(forms.Form): ...@@ -151,8 +156,8 @@ class CSVUploadForm(forms.Form):
errors = [] errors = []
account_code_map = { account_code_map = {
obj.pl_account_code: obj.pl_account_name obj.supplier_account_number: obj.supplier_account_name
for obj in PlAccountCodes.objects.using("meo").all() # type: ignore[attr-defined] for obj in MeoValidSuppliers.objects.using("meo").all() # type: ignore[attr-defined]
} }
for index, row in enumerate(data, start=1): for index, row in enumerate(data, start=1):
...@@ -174,12 +179,15 @@ class CSVUploadForm(forms.Form): ...@@ -174,12 +179,15 @@ class CSVUploadForm(forms.Form):
return errors return errors
@staticmethod
def _validate_nc_cc_dep_combination_against_meo_sage_account(data: Iterable[dict]) -> list[str]: def _validate_nc_cc_dep_combination_against_meo_sage_account(self, data: list[dict]) -> list[str]:
"""Validate that the combination of 'AccountCostCentre', 'AccountDepartment', and 'AccountNumber' exists in MEO. """Validate that all nominal analysis fields exist in MEO.
This includes 'NominalAnalysisNominalCostCentre/{N}', 'NominalAnalysisNominalDepartment/{N}',
and 'NominalAnalysisNominalAccountNumber/{N}'.
Args: Args:
data (Iterable[dict]): The rows of data to validate. data (list[dict]): The rows of data to validate.
Returns: Returns:
List[str]: A list of error messages, if any. List[str]: A list of error messages, if any.
...@@ -188,41 +196,52 @@ class CSVUploadForm(forms.Form): ...@@ -188,41 +196,52 @@ class CSVUploadForm(forms.Form):
errors = [] errors = []
cost_centre_map = { cost_centre_map = {
obj.cc: obj.cctype for obj in Meocostcentres.objects.using("meo").all() obj.cc: obj.cc_type for obj in MeoCostCentres.objects.using("meo").all()
} }
xx_data_map = { xx_data_map = {
obj.xx_value: (obj.project, obj.overhead) for obj in XxData.objects.using("meo").all() obj.xx_value: (obj.project, obj.overhead) for obj in XxData.objects.using("meo").all()
} }
fieldnames = list(data[0].keys())
max_repeat = self._get_max_repeat(fieldnames, "NominalAnalysisNominalCostCentre")
for index, row in enumerate(data, start=1): for index, row in enumerate(data, start=1):
cc = row.get("NominalAnalysisNominalCostCentre/1") for repeat in range(1, max_repeat + 1):
dep = row.get("NominalAnalysisNominalDepartment/1") cc_field = f"NominalAnalysisNominalCostCentre/{repeat}"
nominal_account_name = row.get("NominalAnalysisNominalAccountNumber/1") dep_field = f"NominalAnalysisNominalDepartment/{repeat}"
nominal_account_field = f"NominalAnalysisNominalAccountNumber/{repeat}"
if not cc or not dep or not nominal_account_name: cc = row.get(cc_field)
continue dep = row.get(dep_field)
nominal_account_name = row.get(nominal_account_field)
cc_type = cost_centre_map.get(cc) if not cc or not dep or not nominal_account_name:
if not cc_type: continue
errors.append(f"Row {index}: 'NominalAnalysisNominalCostCentre/1' ({cc}) is not a valid cost centre.")
continue
xx_data = xx_data_map.get(nominal_account_name) cc_type = cost_centre_map.get(cc)
if not xx_data: if not cc_type:
errors.append( errors.append(f"Row {index}: '{cc_field}' ({cc}) is not a valid cost centre.")
f"Row {index}: 'NominalAnalysisNominalAccountNumber/1' ({nominal_account_name}) is not valid.") continue
continue
nc = xx_data[0] if cc_type == "Project" else xx_data[1] xx_data = xx_data_map.get(nominal_account_name)
if not Meovalidsageaccounts.objects.using("meo").filter( if xx_data:
accountcostcentre=cc, accountdepartment=dep, accountnumber=nc nc = xx_data[0] if cc_type == "Project" else xx_data[1]
).exists(): elif MeoNominal.objects.using("meo").filter(nom=nominal_account_name).exists():
errors.append( nc = nominal_account_name
f"Row {index}: The combination of 'NominalAnalysisNominalCostCentre/1' ({cc}), " else:
f"'NominalAnalysisNominalDepartment/1' ({dep}), and 'NominalAnalysisNominalAccountNumber/1' " errors.append(f"Row {index}: '{nominal_account_field}' ({nominal_account_name}) is not valid.")
f"({nominal_account_name}) does not exist in MEO valid Sage accounts." continue
)
if not MeoValidSageAccounts.objects.using("meo").filter(
account_cost_centre=cc, account_department=dep, account_number=nc
).exists():
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."
)
return errors return errors
...@@ -2,24 +2,7 @@ ...@@ -2,24 +2,7 @@
from django.db import models from django.db import models
class PlAccountCodes(models.Model): class MeoCostCentres(models.Model):
"""Represents profit and loss (PL) account codes.
This model maps the PL account names to their respective codes.
"""
objects = None
pl_account_name = models.TextField(db_column="PL_Account_Name")
pl_account_code = models.CharField(db_column="PL_Account_Code", primary_key=True, max_length=50)
class Meta:
"""Metaclass for the PlAccountCodes model."""
managed = False
db_table = "PL_Account_Codes"
class Meocostcentres(models.Model):
"""Represents cost centres. """Represents cost centres.
This model contains data related to cost centres, This model contains data related to cost centres,
...@@ -28,68 +11,68 @@ class Meocostcentres(models.Model): ...@@ -28,68 +11,68 @@ class Meocostcentres(models.Model):
cc = models.CharField(db_column="CC", max_length=3) cc = models.CharField(db_column="CC", max_length=3)
cc_name = models.CharField(db_column="CC_Name", max_length=50, blank=True, null=True) cc_name = models.CharField(db_column="CC_Name", max_length=50, blank=True, null=True)
cctype = models.CharField(db_column="CCType", max_length=8) cc_type = models.CharField(db_column="CCType", max_length=8)
id = models.IntegerField(db_column="ID", primary_key=True) id = models.IntegerField(db_column="ID", primary_key=True)
class Meta: class Meta:
"""Metaclass for the Meocostcentres model.""" """Metaclass for the MeoCostCentres model."""
managed = False managed = False
db_table = "meoCostCentres" db_table = "meoCostCentres"
class Meonominal(models.Model): class MeoNominal(models.Model):
"""View for MEO nominal codes.""" """View for MEO nominal codes."""
nom = models.CharField(db_column="Nom", max_length=5) nom = models.CharField(db_column="Nom", max_length=5)
nomname = models.CharField(db_column="NomName", max_length=60, blank=True, null=True) nom_name = models.CharField(db_column="NomName", max_length=60, blank=True, null=True)
nomid = models.IntegerField(db_column="NomID") nom_id = models.IntegerField(db_column="NomID")
class Meta: class Meta:
"""Metaclass for the Meonominal model.""" """Metaclass for the MeoNominal model."""
managed = False managed = False
db_table = "meoNominal" db_table = "meoNominal"
class Meovalidsageaccounts(models.Model): class MeoValidSageAccounts(models.Model):
"""View for MEO valid Sage accounts.""" """View for MEO valid Sage accounts."""
accountname = models.CharField(db_column="AccountName", max_length=60) account_name = models.CharField(db_column="AccountName", max_length=60)
accountnumber = models.CharField(db_column="AccountNumber", max_length=8, blank=True, null=True) account_number = models.CharField(db_column="AccountNumber", max_length=8, blank=True, null=True)
accountcostcentre = models.CharField(db_column="AccountCostCentre", max_length=3, blank=True, null=True) account_cost_centre = models.CharField(db_column="AccountCostCentre", max_length=3, blank=True, null=True)
accountdepartment = models.CharField(db_column="AccountDepartment", max_length=3, blank=True, null=True) account_department = models.CharField(db_column="AccountDepartment", max_length=3, blank=True, null=True)
class Meta: class Meta:
"""Metaclass for the Meovalidsageaccounts model.""" """Metaclass for the MeoValidSageAccounts model."""
managed = False managed = False
db_table = "meoValidSageAccounts" db_table = "meoValidSageAccounts"
class Meovalidsuppliers(models.Model): class MeoValidSuppliers(models.Model):
"""View for MEO valid suppliers.""" """View for MEO valid suppliers."""
supplieraccountnumber = models.CharField(db_column="SupplierAccountNumber", max_length=8) supplier_account_number = models.CharField(db_column="SupplierAccountNumber", max_length=8, primary_key=True)
supplieraccountname = models.CharField(db_column="SupplierAccountName", max_length=60) supplier_account_name = models.CharField(db_column="SupplierAccountName", max_length=60)
class Meta: class Meta:
"""Metaclass for the Meovalidsuppliers model.""" """Metaclass for the MeoValidSuppliers model."""
managed = False managed = False
db_table = "meoValidSuppliers" db_table = "meoValidSuppliers"
class Meovalidvat(models.Model): class MeoValidVat(models.Model):
"""View for MEO valid VAT codes.""" """View for MEO valid VAT codes."""
tax_code = models.SmallIntegerField(db_column="Tax code") tax_code = models.SmallIntegerField(db_column="Tax code")
tax_code_label = models.CharField(db_column="tax code label", max_length=60) tax_code_label = models.CharField(db_column="tax code label", max_length=60)
rate = models.DecimalField(db_column="Rate", max_digits=18, decimal_places=2) rate = models.DecimalField(db_column="Rate", max_digits=18, decimal_places=2)
inputnominalaccount = models.CharField(db_column="InputNominalAccount", max_length=8, blank=True, null=True) input_nominal_account = models.CharField(db_column="InputNominalAccount", max_length=8, blank=True, null=True)
class Meta: class Meta:
"""Metaclass for the Meovalidvat model.""" """Metaclass for the MeoValidVat model."""
managed = False managed = False
db_table = "meoValidVAT" db_table = "meoValidVAT"
......
...@@ -26,12 +26,21 @@ ...@@ -26,12 +26,21 @@
</div> </div>
<!-- Success Message --> <!-- Success Message -->
<div id="successSection" class="hidden mt-4 bg-green-100 border border-green-400 text-green-700 p-4 rounded-lg"> <div id="successSection"
<strong class="font-bold">Success:</strong> class="hidden mt-4 mb-8 bg-green-100 border border-green-400 text-green-700 p-4 rounded-lg text-center">
<span id="successMessage" class="block mt-2"></span> <strong class="font-bold text-lg">Success!</strong>
<p id="successMessage" class="block mt-2 text-md"></p>
</div>
<div id="downloadSection" class="hidden mt-16 flex justify-center">
<a id="downloadLink" href="#"
class="w-full max-w-xs text-center bg-blue-600 text-white py-4 px-12 rounded-lg shadow-lg hover:bg-blue-700
focus:outline-none focus:ring focus:ring-blue-400 transition text-lg font-semibold block">
📥 Download Updated CSV
</a>
</div> </div>
</div> </div>
<script> <script>
const form = document.getElementById('uploadForm'); const form = document.getElementById('uploadForm');
const fileInput = document.getElementById('fileInput'); const fileInput = document.getElementById('fileInput');
...@@ -39,6 +48,8 @@ ...@@ -39,6 +48,8 @@
const errorList = document.getElementById('errorList'); const errorList = document.getElementById('errorList');
const successSection = document.getElementById('successSection'); const successSection = document.getElementById('successSection');
const successMessage = document.getElementById('successMessage'); const successMessage = document.getElementById('successMessage');
const downloadSection = document.getElementById('downloadSection');
const downloadLink = document.getElementById('downloadLink');
form.addEventListener('submit', async function (e) { form.addEventListener('submit', async function (e) {
e.preventDefault(); e.preventDefault();
...@@ -48,6 +59,7 @@ ...@@ -48,6 +59,7 @@
successMessage.innerHTML = ''; successMessage.innerHTML = '';
errorSection.classList.add('hidden'); errorSection.classList.add('hidden');
successSection.classList.add('hidden'); successSection.classList.add('hidden');
downloadSection.classList.add('hidden');
const formData = new FormData(); const formData = new FormData();
formData.append('file', fileInput.files[0]); formData.append('file', fileInput.files[0]);
...@@ -66,18 +78,35 @@ ...@@ -66,18 +78,35 @@
if (response.ok && result.status === 'success') { if (response.ok && result.status === 'success') {
successMessage.innerText = result.message; successMessage.innerText = result.message;
successSection.classList.remove('hidden'); successSection.classList.remove('hidden');
downloadLink.href = result.download_url;
downloadSection.classList.remove('hidden');
} else if (response.status === 400 && result.status === 'error') { } else if (response.status === 400 && result.status === 'error') {
// Handle form errors from the backend errorList.innerHTML = '';
if (Array.isArray(result.errors)) { if (Array.isArray(result.errors)) {
result.errors.forEach(errorObj => { result.errors.forEach(errorObj => {
for (const [field, messages] of Object.entries(errorObj)) { if (typeof errorObj === 'string') {
messages.forEach(message => { const li = document.createElement('li');
const li = document.createElement('li'); li.textContent = errorObj;
li.textContent = `${field}: ${message}`; errorList.appendChild(li);
errorList.appendChild(li); } else {
}); for (const [field, messages] of Object.entries(errorObj)) {
messages.forEach(message => {
const li = document.createElement('li');
li.textContent = `${field}: ${message}`;
errorList.appendChild(li);
});
}
} }
}); });
} else if (typeof result.errors === 'object') {
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 { } else {
const li = document.createElement('li'); const li = document.createElement('li');
li.textContent = result.errors; li.textContent = result.errors;
...@@ -87,7 +116,6 @@ ...@@ -87,7 +116,6 @@
errorSection.classList.remove('hidden'); errorSection.classList.remove('hidden');
} }
} catch (error) { } catch (error) {
// Handle unexpected errors
const li = document.createElement('li'); const li = document.createElement('li');
li.textContent = 'An unexpected error occurred. Please try again.'; li.textContent = 'An unexpected error occurred. Please try again.';
errorList.appendChild(li); errorList.appendChild(li);
......
...@@ -2,8 +2,9 @@ ...@@ -2,8 +2,9 @@
from django.urls import path from django.urls import path
from sage_validation.file_validator.views import CSVUploadView from sage_validation.file_validator.views import CSVExportView, CSVUploadView
urlpatterns = [ urlpatterns = [
path("upload/", CSVUploadView.as_view(), name="upload-file"), path("upload/", CSVUploadView.as_view(), name="upload-file"),
path("export/", CSVExportView.as_view(), name="export-file"),
] ]
"""Views for the file_validator app.""" """Views for the file_validator app."""
import csv
import io
from typing import Any
from django.http import HttpRequest, HttpResponse, JsonResponse from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils import timezone
from django.views.generic.base import View
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from sage_validation.file_validator.forms import CSVUploadForm from sage_validation.file_validator.forms import CSVUploadForm
from sage_validation.file_validator.models import MeoCostCentres, XxData
def index_view(request: HttpRequest) -> HttpResponse: def index_view(request: HttpRequest) -> HttpResponse:
...@@ -20,17 +26,92 @@ class CSVUploadView(FormView): ...@@ -20,17 +26,92 @@ class CSVUploadView(FormView):
form_class = CSVUploadForm form_class = CSVUploadForm
success_url = reverse_lazy("upload-file") success_url = reverse_lazy("upload-file")
def get_context_data(self, **kwargs: dict) -> dict: def get_context_data(self, **kwargs: dict[str, Any]) -> dict[str, Any]:
"""Render the form with no error message on GET request.""" """Render the form with no error message on GET request."""
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["error"] = None context["error"] = None
context["message"] = None context["message"] = None
return context return context
def form_valid(self, form: CSVUploadForm) -> JsonResponse: # noqa: ARG002 def form_valid(self, form: CSVUploadForm) -> JsonResponse:
"""Handle the CSV validation and passes appropriate success messages to the template.""" """Handle the CSV validation, store valid data, and prepare for export."""
return JsonResponse({"status": "success", "message": "File is valid"}) csv_file = form.cleaned_data["file"]
csv_file.seek(0)
decoded_file = csv_file.read().decode("utf-8").strip()
if not decoded_file:
return JsonResponse({"status": "error", "message": "Uploaded file is empty."}, status=400)
reader = csv.DictReader(io.StringIO(decoded_file))
csv_data: list[dict[str, str]] = list(reader)
updated_data = self.update_fields(csv_data)
self.request.session["validated_csv"] = updated_data
self.request.session.modified = True
return JsonResponse({
"status": "success",
"message": "File successfully uploaded and processed.",
"download_url": reverse_lazy("export-file")
})
def form_invalid(self, form: CSVUploadForm) -> JsonResponse: def form_invalid(self, form: CSVUploadForm) -> JsonResponse:
"""Handle the form when it is invalid (e.g., wrong file type or validation errors).""" """Handle the form when it is invalid."""
return JsonResponse({"status": "error", "errors": [form.errors]}, status=400) return JsonResponse({"status": "error", "errors": form.errors}, status=400)
@staticmethod
def update_fields(csv_data: list[dict[str, str]]) -> list[dict[str, str]]:
"""Automatically update specific fields before export."""
current_date: str = timezone.now().strftime("%d/%m/%Y")
xx_data_map: dict[str, tuple] = {
obj.xx_value: (obj.project, obj.overhead) for obj in XxData.objects.using("meo").all()
}
cost_centre_map: dict[str, str] = {
obj.cc: obj.cc_type for obj in MeoCostCentres.objects.using("meo").all()
}
for row in csv_data:
row["TransactionDate"] = current_date
repeat = 1
while f"NominalAnalysisNominalCostCentre/{repeat}" in row:
cc = row.get(f"NominalAnalysisNominalCostCentre/{repeat}", "")
nominal_account_name = row.get(f"NominalAnalysisNominalAccountNumber/{repeat}", "")
if not cc or not nominal_account_name:
repeat += 1
continue
cc_type = cost_centre_map.get(cc, "")
xx_data = xx_data_map.get(nominal_account_name)
if xx_data:
row[f"NominalAnalysisNominalAccountNumber/{repeat}"] = (
xx_data[0] if cc_type == "Project" else xx_data[1]
)
repeat += 1
return csv_data
class CSVExportView(View):
"""View for exporting the updated CSV file."""
def get(self, request: HttpRequest) -> HttpResponse:
"""Generate a downloadable CSV file with updated values."""
csv_data: list[dict[str, str]] = request.session.get("validated_csv", [])
if not csv_data:
return HttpResponse("No data available for export.", status=400)
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="updated_file.csv"'
writer = csv.DictWriter(response, fieldnames=csv_data[0].keys())
writer.writeheader()
writer.writerows(csv_data)
return response
...@@ -89,6 +89,8 @@ DATABASES = { ...@@ -89,6 +89,8 @@ DATABASES = {
'OPTIONS': { 'OPTIONS': {
'driver': 'ODBC Driver 18 for SQL Server', 'driver': 'ODBC Driver 18 for SQL Server',
'extra_params': 'TrustServerCertificate=Yes;'
}, },
}, },
} }
......
...@@ -3,12 +3,12 @@ from setuptools import find_packages, setup ...@@ -3,12 +3,12 @@ from setuptools import find_packages, setup
setup( setup(
name="sage-validation", name="sage-validation",
version="0.3", version="0.4",
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
install_requires=[ install_requires=[
"Django==5.0.11", "Django==5.0.11",
"mssql-django", "mssql-django==1.5",
], ],
extras_require={ extras_require={
"prod": [ "prod": [
......
[tox] [tox]
envlist = py312 envlist = py311
[testenv] [testenv]
deps = deps =
......
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