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 (9)
# Changelog # Changelog
## [0.2] - 2025-01-27 ## [0.4] - 2025-02-13
- Added CSV Export and Modify Functionality
## [0.3] - 2025-01-27
- Added new validations - Added new validations
## [0.2] - 2025-01-09 ## [0.2] - 2025-01-09
......
"""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, XxData, MeoValidSuppliers, \ from sage_validation.file_validator.models import (
MeoNominal MeoCostCentres,
MeoNominal,
MeoValidSageAccounts,
MeoValidSuppliers,
XxData,
)
class CSVUploadForm(forms.Form): class CSVUploadForm(forms.Form):
...@@ -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.
...@@ -175,15 +180,18 @@ class CSVUploadForm(forms.Form): ...@@ -175,15 +180,18 @@ class CSVUploadForm(forms.Form):
return errors return errors
def _validate_nc_cc_dep_combination_against_meo_sage_account(self, data: Iterable[dict]) -> list[str]: def _validate_nc_cc_dep_combination_against_meo_sage_account(self, data: list[dict]) -> list[str]:
"""Validate that all occurrences of 'NominalAnalysisNominalCostCentre/{N}', """Validate that all nominal analysis fields exist in MEO.
'NominalAnalysisNominalDepartment/{N}', and 'NominalAnalysisNominalAccountNumber/{N}' 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.
""" """
errors = [] errors = []
......
...@@ -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
...@@ -3,7 +3,7 @@ from setuptools import find_packages, setup ...@@ -3,7 +3,7 @@ from setuptools import find_packages, setup
setup( setup(
name="sage-validation", name="sage-validation",
version="0.4", version="0.5",
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
install_requires=[ install_requires=[
......
[tox] [tox]
envlist = py312 envlist = py311
[testenv] [testenv]
deps = deps =
......