diff --git a/sage_validation/file_validator/forms.py b/sage_validation/file_validator/forms.py index 0e22efedb8e46f10a7bd5799f11702a555996ecc..42343429d8d0a5e7fb4fcf71122fc137b1597326 100644 --- a/sage_validation/file_validator/forms.py +++ b/sage_validation/file_validator/forms.py @@ -7,8 +7,13 @@ from typing import ClassVar from django import forms from django.core.files.uploadedfile import UploadedFile -from sage_validation.file_validator.models import MeoCostCentres, MeoValidSageAccounts, XxData, MeoValidSuppliers, \ - MeoNominal +from sage_validation.file_validator.models import ( + MeoCostCentres, + MeoNominal, + MeoValidSageAccounts, + MeoValidSuppliers, + XxData, +) class CSVUploadForm(forms.Form): @@ -176,14 +181,17 @@ class CSVUploadForm(forms.Form): def _validate_nc_cc_dep_combination_against_meo_sage_account(self, data: Iterable[dict]) -> list[str]: - """Validate that all occurrences of 'NominalAnalysisNominalCostCentre/{N}', - 'NominalAnalysisNominalDepartment/{N}', and 'NominalAnalysisNominalAccountNumber/{N}' exist in MEO. + """Validate that all nominal analysis fields exist in MEO. + + This includes 'NominalAnalysisNominalCostCentre/{N}', 'NominalAnalysisNominalDepartment/{N}', + and 'NominalAnalysisNominalAccountNumber/{N}'. Args: data (Iterable[dict]): The rows of data to validate. Returns: List[str]: A list of error messages, if any. + """ errors = [] diff --git a/sage_validation/file_validator/templates/upload.html b/sage_validation/file_validator/templates/upload.html index 129121ef05336a83485f0533f17cf4cac7afc749..4a595da4794ec9281508073a490bc54daa4549f5 100644 --- a/sage_validation/file_validator/templates/upload.html +++ b/sage_validation/file_validator/templates/upload.html @@ -26,12 +26,21 @@ </div> <!-- Success Message --> - <div id="successSection" class="hidden mt-4 bg-green-100 border border-green-400 text-green-700 p-4 rounded-lg"> - <strong class="font-bold">Success:</strong> - <span id="successMessage" class="block mt-2"></span> + <div id="successSection" + class="hidden mt-4 mb-8 bg-green-100 border border-green-400 text-green-700 p-4 rounded-lg text-center"> + <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> + <script> const form = document.getElementById('uploadForm'); const fileInput = document.getElementById('fileInput'); @@ -39,6 +48,8 @@ const errorList = document.getElementById('errorList'); const successSection = document.getElementById('successSection'); const successMessage = document.getElementById('successMessage'); + const downloadSection = document.getElementById('downloadSection'); + const downloadLink = document.getElementById('downloadLink'); form.addEventListener('submit', async function (e) { e.preventDefault(); @@ -48,6 +59,7 @@ successMessage.innerHTML = ''; errorSection.classList.add('hidden'); successSection.classList.add('hidden'); + downloadSection.classList.add('hidden'); const formData = new FormData(); formData.append('file', fileInput.files[0]); @@ -66,18 +78,35 @@ if (response.ok && result.status === 'success') { successMessage.innerText = result.message; successSection.classList.remove('hidden'); + downloadLink.href = result.download_url; + downloadSection.classList.remove('hidden'); } else if (response.status === 400 && result.status === 'error') { - // Handle form errors from the backend + errorList.innerHTML = ''; + if (Array.isArray(result.errors)) { result.errors.forEach(errorObj => { - for (const [field, messages] of Object.entries(errorObj)) { - messages.forEach(message => { - const li = document.createElement('li'); - li.textContent = `${field}: ${message}`; - errorList.appendChild(li); - }); + if (typeof errorObj === 'string') { + const li = document.createElement('li'); + li.textContent = errorObj; + 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 { const li = document.createElement('li'); li.textContent = result.errors; @@ -87,7 +116,6 @@ errorSection.classList.remove('hidden'); } } catch (error) { - // Handle unexpected errors const li = document.createElement('li'); li.textContent = 'An unexpected error occurred. Please try again.'; errorList.appendChild(li); diff --git a/sage_validation/file_validator/urls.py b/sage_validation/file_validator/urls.py index 12e1520494e4f3bda7dfb0b9e7b2dd8b0ead954a..a5c0d9fd7840097233aabed1184a392037d7bcc1 100644 --- a/sage_validation/file_validator/urls.py +++ b/sage_validation/file_validator/urls.py @@ -2,8 +2,9 @@ from django.urls import path -from sage_validation.file_validator.views import CSVUploadView +from sage_validation.file_validator.views import CSVExportView, CSVUploadView urlpatterns = [ path("upload/", CSVUploadView.as_view(), name="upload-file"), + path("export/", CSVExportView.as_view(), name="export-file"), ] diff --git a/sage_validation/file_validator/views.py b/sage_validation/file_validator/views.py index 33ec72f6cc93b6db8ad5aafa505629af0e2d6677..99efd175b0fa9498eb15127578cd85e272e21ac1 100644 --- a/sage_validation/file_validator/views.py +++ b/sage_validation/file_validator/views.py @@ -1,11 +1,17 @@ """Views for the file_validator app.""" +import csv +import io +from typing import Any from django.http import HttpRequest, HttpResponse, JsonResponse from django.shortcuts import render 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 sage_validation.file_validator.forms import CSVUploadForm +from sage_validation.file_validator.models import MeoCostCentres, XxData def index_view(request: HttpRequest) -> HttpResponse: @@ -20,17 +26,92 @@ class CSVUploadView(FormView): form_class = CSVUploadForm 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.""" context = super().get_context_data(**kwargs) context["error"] = None context["message"] = None return context - def form_valid(self, form: CSVUploadForm) -> JsonResponse: # noqa: ARG002 - """Handle the CSV validation and passes appropriate success messages to the template.""" - return JsonResponse({"status": "success", "message": "File is valid"}) + def form_valid(self, form: CSVUploadForm) -> JsonResponse: + """Handle the CSV validation, store valid data, and prepare for export.""" + 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: - """Handle the form when it is invalid (e.g., wrong file type or validation errors).""" - return JsonResponse({"status": "error", "errors": [form.errors]}, status=400) + """Handle the form when it is invalid.""" + 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