diff --git a/backend/api/test_views.py b/backend/api/test_views.py index d1d5428807..0ff0caa874 100644 --- a/backend/api/test_views.py +++ b/backend/api/test_views.py @@ -141,8 +141,8 @@ def test_success_and_failure(self): class UEIValidationViewTests(TestCase): PATH = reverse("api-uei-validation") - SUCCESS = {"auditee_uei": "ZQGGHJH74DW7"} - INELIGIBLE = {"auditee_uei": "000000000OI*"} + SUCCESS = {"auditee_uei": "ZQGGHJH74DW7", "audit_year": "2024"} + INELIGIBLE = {"auditee_uei": "000000000OI*", "audit_year": "2024"} def test_auth_required(self): """ diff --git a/backend/api/views.py b/backend/api/views.py index 275328a1f6..9a9b0a561c 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -12,6 +12,7 @@ from config.settings import AUDIT_SCHEMA_DIR, BASE_DIR from audit.models import Access, SingleAuditChecklist, SubmissionEvent +from audit.models.models import is_resubmission from audit.permissions import SingleAuditChecklistPermission from .serializers import ( AccessAndSubmissionSerializer, @@ -22,6 +23,8 @@ UEISerializer, ) +from dissemination.models import General + UserModel = get_user_model() AUDITEE_INFO_PREVIOUS_STEP_DATA_WE_NEED = [ @@ -242,6 +245,27 @@ def post(self, request): data["auditee_uei"] = data["auditee_uei"].upper() serializer = UEISerializer(data=data) + # Before checking the UEI, we want to see if this is a duplicate submission + auditee_uei = data["auditee_uei"].upper() + audit_year = data.get("audit_year") + + # verify that there is an audit year. + if not audit_year: + return Response({"valid": False, "errors": ["invalid-year"]}) + + duplicates = General.objects.filter( + audit_year=audit_year, auditee_uei=auditee_uei + ).values("report_id") + + if is_resubmission(auditee_uei, audit_year): + return Response( + { + "valid": False, + "response": {"duplicates": duplicates}, + "errors": ["duplicate-submission"], + } + ) + if serializer.is_valid(): return Response( { diff --git a/backend/audit/admin.py b/backend/audit/admin.py index a5a27c8744..09a35f4c21 100644 --- a/backend/audit/admin.py +++ b/backend/audit/admin.py @@ -12,7 +12,7 @@ SacValidationWaiver, UeiValidationWaiver, ) -from audit.models.models import STATUS +from audit.models.models import STATUS, ResubmissionWaiver from audit.models.viewflow import sac_transition from audit.validators import ( validate_auditee_certification_json, @@ -314,6 +314,32 @@ def save_model(self, request, obj, form, change): ) +class ResubmissionWaiverAdmin(admin.ModelAdmin): + list_display = ( + "timestamp", + "uei", + "audit_year", + "assigned_by", + "expiration", + ) + search_fields = ( + "uei", + "audit_year", + "assigned_by", + "timestamp", + "expiration", + ) + fields = ("uei", "audit_year", "expiration") + readonly_fields = ("timestamp",) + + def save_model(self, request, obj, form, change): + obj.assigned_by = request.user.email + super().save_model(request, obj, form, change) + logger.info( + f'Resubmission Waiver for UEI "{obj.uei}" for the year "{obj.audit_year}" successfully added by user: {request.user.email}.' + ) + + admin.site.register(Access, AccessAdmin) admin.site.register(DeletedAccess, DeletedAccessAdmin) admin.site.register(ExcelFile, ExcelFileAdmin) @@ -322,3 +348,4 @@ def save_model(self, request, obj, form, change): admin.site.register(SubmissionEvent, SubmissionEventAdmin) admin.site.register(SacValidationWaiver, SacValidationWaiverAdmin) admin.site.register(UeiValidationWaiver, UeiValidationWaiverAdmin) +admin.site.register(ResubmissionWaiver, ResubmissionWaiverAdmin) diff --git a/backend/audit/cross_validation/__init__.py b/backend/audit/cross_validation/__init__.py index f8dcd2c542..868bce5660 100644 --- a/backend/audit/cross_validation/__init__.py +++ b/backend/audit/cross_validation/__init__.py @@ -64,6 +64,7 @@ from .check_finding_prior_references import check_finding_prior_references from .check_biennial_low_risk import check_biennial_low_risk from .check_certifying_contacts import check_certifying_contacts +from .check_duplicate_submission import check_duplicate_submission from .check_finding_reference_uniqueness import check_finding_reference_uniqueness from .check_findings_count_consistency import check_findings_count_consistency from .check_has_federal_awards import check_has_federal_awards @@ -82,6 +83,7 @@ check_award_reference_uniqueness, check_biennial_low_risk, check_certifying_contacts, + check_duplicate_submission, check_finding_reference_uniqueness, check_finding_prior_references, check_findings_count_consistency, diff --git a/backend/audit/cross_validation/check_duplicate_submission.py b/backend/audit/cross_validation/check_duplicate_submission.py new file mode 100644 index 0000000000..89d4957928 --- /dev/null +++ b/backend/audit/cross_validation/check_duplicate_submission.py @@ -0,0 +1,36 @@ +from datetime import datetime + +from dissemination.models import General +from .errors import err_duplicate_submission + + +def check_duplicate_submission(sac_dict, *_args, **_kwargs): + """ + Check that there isn't already a submission for this UEI and audit year. + If a duplicate is allowed by an admin, this is a legitimate resbmission and does not throw an error. + """ + # Importing here to avoid circular import + from audit.models import is_resubmission + + all_sections = sac_dict["sf_sac_sections"] + general_information = all_sections.get("general_information", {}) + + fiscal_period_start = general_information.get("auditee_fiscal_period_start") + uei = general_information.get("auditee_uei") + + if not (fiscal_period_start and uei): + return [] + + audit_year = datetime.strptime(fiscal_period_start, "%Y-%m-%d").year + + # Check for a waiver + if not is_resubmission(uei, audit_year): + return [] + + # Check disseminated for a duplicate + duplicates = General.objects.filter(audit_year=audit_year, auditee_uei=uei) + + if duplicates: + return [{"error": err_duplicate_submission()}] + + return [] diff --git a/backend/audit/cross_validation/errors.py b/backend/audit/cross_validation/errors.py index 43acef80ac..9e6f33c4bf 100644 --- a/backend/audit/cross_validation/errors.py +++ b/backend/audit/cross_validation/errors.py @@ -45,6 +45,10 @@ def err_certifying_contacts_should_not_match(): return "The certifying auditor and auditee should not have the same email address." +def err_duplicate_submission(): + return "This is a duplicate submission." + + def err_biennial_low_risk(): return ( "According to Uniform Guidance section 200.520(a), biennial audits cannot " diff --git a/backend/audit/cross_validation/test_check_duplicate_submission.py b/backend/audit/cross_validation/test_check_duplicate_submission.py new file mode 100644 index 0000000000..5f9bdf92c0 --- /dev/null +++ b/backend/audit/cross_validation/test_check_duplicate_submission.py @@ -0,0 +1,91 @@ +from datetime import timedelta +from model_bakery import baker + +from django.test import TestCase +from django.utils import timezone as django_timezone + +from audit.models import SingleAuditChecklist, ResubmissionWaiver +from dissemination.models import General + +from .errors import err_duplicate_submission +from .check_duplicate_submission import check_duplicate_submission +from .sac_validation_shape import sac_validation_shape + + +class CheckDuplicateSubmissionTests(TestCase): + """ + Tests for check_duplicate_submission validation + """ + + def test_empty_sections(self): + """ + Empty general information sections should validate + """ + sac_empty = baker.make(SingleAuditChecklist) + sac_empty.general_information = {} + + validation_result_empty_gen = check_duplicate_submission( + sac_validation_shape(sac_empty) + ) + self.assertEqual(validation_result_empty_gen, []) + + def test_non_duplicate(self): + """ + A non duplicate should always validate + """ + sac = baker.make(SingleAuditChecklist) + sac.general_information = { + "auditee_fiscal_period_start": "2024-01-01", + "auditee_uei": "SUPERC00LUE1", + } + + # Create some disseminated audits that don't conflict, but have the same audit year or UEI. + baker.make(General, audit_year="2024", auditee_uei="N0TUSEFULUE1") + baker.make(General, audit_year="2022", auditee_uei="SUPERC00LUE1") + + validation_result = check_duplicate_submission(sac_validation_shape(sac)) + + self.assertEqual(validation_result, []) + + def test_duplicate(self): + """ + A duplicate should throw an error + """ + sac = baker.make(SingleAuditChecklist) + sac.general_information = { + "auditee_fiscal_period_start": "2024-01-01", + "auditee_uei": "0LDANDSADUE1", + } + + # Create a disseminated audit that does conflict. + baker.make(General, audit_year="2024", auditee_uei="0LDANDSADUE1") + + validation_result = check_duplicate_submission(sac_validation_shape(sac)) + + self.assertEqual(validation_result, [{"error": err_duplicate_submission()}]) + + def test_waived_duplicate(self): + """ + A duplicate with a waiver should validate + """ + sac = baker.make(SingleAuditChecklist) + sac.general_information = { + "auditee_fiscal_period_start": "2024-01-01", + "auditee_uei": "0LDANDSADUE1", + } + + # Create a disseminated audit that does conflict. + baker.make(General, audit_year="2024", auditee_uei="0LDANDSADUE1") + + # Create a waiver + one_month_from_today = django_timezone.now() + timedelta(days=30) + baker.make( + ResubmissionWaiver, + audit_year="2024", + uei="0LDANDSADUE1", + expiration=one_month_from_today, + ) + + validation_result = check_duplicate_submission(sac_validation_shape(sac)) + + self.assertEqual(validation_result, [{"error": err_duplicate_submission()}]) diff --git a/backend/audit/migrations/0015_resubmissionwaiver.py b/backend/audit/migrations/0015_resubmissionwaiver.py new file mode 100644 index 0000000000..83e47099c7 --- /dev/null +++ b/backend/audit/migrations/0015_resubmissionwaiver.py @@ -0,0 +1,45 @@ +# Generated by Django 5.1.2 on 2024-12-19 17:36 + +import audit.models.models +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("audit", "0014_alter_sacvalidationwaiver_waiver_types"), + ] + + operations = [ + migrations.CreateModel( + name="ResubmissionWaiver", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("uei", models.TextField(null=True, verbose_name="UEI")), + ("audit_year", models.TextField(null=True, verbose_name="Audit Year")), + ("assigned_by", models.TextField(verbose_name="Assigned by")), + ( + "expiration", + models.DateTimeField( + default=audit.models.models.one_month_from_today, + verbose_name="Expiration", + ), + ), + ( + "timestamp", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="Created" + ), + ), + ], + ), + ] diff --git a/backend/audit/models/__init__.py b/backend/audit/models/__init__.py index f5eeacc1d1..24f0c5e218 100644 --- a/backend/audit/models/__init__.py +++ b/backend/audit/models/__init__.py @@ -9,6 +9,7 @@ ExcelFile, GeneralInformationMixin, LateChangeError, + ResubmissionWaiver, SingleAuditChecklist, SingleAuditChecklistManager, SingleAuditReportFile, @@ -17,6 +18,7 @@ User, excel_file_path, generate_sac_report_id, + is_resubmission, single_audit_report_path, ) from .submission_event import SubmissionEvent @@ -29,6 +31,7 @@ GeneralInformationMixin, SubmissionEvent, LateChangeError, + ResubmissionWaiver, SingleAuditChecklist, SingleAuditChecklistManager, SingleAuditReportFile, @@ -40,6 +43,7 @@ delete_access_and_create_record, excel_file_path, generate_sac_report_id, + is_resubmission, remove_email_from_submission_access, single_audit_report_path, ] diff --git a/backend/audit/models/models.py b/backend/audit/models/models.py index e35b1d06e2..8387177154 100644 --- a/backend/audit/models/models.py +++ b/backend/audit/models/models.py @@ -1,5 +1,6 @@ from datetime import timedelta from itertools import chain +import datetime import json import logging @@ -35,6 +36,7 @@ validate_component_page_numbers, ) from audit.utils import FORM_SECTION_HANDLERS +from dissemination.models import General from support.cog_over import compute_cog_over, record_cog_assignment from .submission_event import SubmissionEvent @@ -76,6 +78,22 @@ def one_month_from_today(): return django_timezone.now() + timedelta(days=30) +def is_resubmission(uei, ay): + """ + Validate that there is a disseminated record with matching UEI and audit year. + """ + + # resubmission was permitted by a staff user. + if ResubmissionWaiver.objects.filter( + uei=uei, audit_year=ay, expiration__gte=datetime.datetime.today() + ).exists(): + return False + + # check if a duplicate disseminated record already exists. + else: + return General.objects.filter(audit_year=ay, auditee_uei=uei).exists() + + class SingleAuditChecklistManager(models.Manager): """Manager for SAC""" @@ -239,6 +257,23 @@ def disseminate(self): return None + def is_duplicate(self): + """ + Validates whether the SAC contains identical information to disseminated records. + """ + general_information = self.general_information + + # extract audit year from fiscal period start date. + fiscal_period_start = general_information.get("auditee_fiscal_period_start") + audit_year = datetime.datetime.strptime(fiscal_period_start, "%Y-%m-%d").year + + uei = general_information.get("auditee_uei") + + if audit_year and uei: + return is_resubmission(uei, audit_year) + + return False + def assign_cog_over(self): """ Function that the FAC app uses when a submission is completed and cog_over needs to be assigned. @@ -718,3 +753,21 @@ class TYPES: verbose_name="The waiver type", default=list, ) + + +class ResubmissionWaiver(models.Model): + """Enables a disseminated record to be re-submitted.""" + + uei = models.TextField("UEI", null=True) + audit_year = models.TextField("Audit Year", null=True) + assigned_by = models.TextField( + "Assigned by", + ) + expiration = models.DateTimeField( + "Expiration", + default=one_month_from_today, + ) + timestamp = models.DateTimeField( + "Created", + default=django_timezone.now, + ) diff --git a/backend/cypress/support/auditee-info.js b/backend/cypress/support/auditee-info.js index d7f53698d9..c0b6c5a054 100644 --- a/backend/cypress/support/auditee-info.js +++ b/backend/cypress/support/auditee-info.js @@ -5,6 +5,10 @@ export function testValidAuditeeInfo() { fixture: 'sam-gov-api-mock.json', }).as('uei_check_success'); + // Fill in the audit dates + cy.get('#auditee_fiscal_period_start').type('01/01/2023'); + cy.get('#auditee_fiscal_period_end').type('12/31/2023'); + // Hard-coding some UEI which may eventually become unregistered // This UEI needs to match up with the UEI in the workbooks. cy.get('#auditee_uei').type('D7A4J33FUMJ1'); @@ -13,10 +17,6 @@ export function testValidAuditeeInfo() { // modal search result box needs "Continue" to be clicked cy.get('button[data-close-modal]').contains('Continue').click(); - // Now fill in the audit dates - cy.get('#auditee_fiscal_period_start').type('01/01/2023'); - cy.get('#auditee_fiscal_period_end').type('12/31/2023'); - // and click continue cy.get('.usa-button').contains('Continue').click(); diff --git a/backend/report_submission/templates/report_submission/step-2.html b/backend/report_submission/templates/report_submission/step-2.html index b98251f840..474e58e6f5 100644 --- a/backend/report_submission/templates/report_submission/step-2.html +++ b/backend/report_submission/templates/report_submission/step-2.html @@ -33,34 +33,32 @@