diff --git a/backend/audit/fixtures/federalist-2.pdf b/backend/audit/fixtures/federalist-2.pdf
new file mode 100644
index 0000000000..2bf72a6412
Binary files /dev/null and b/backend/audit/fixtures/federalist-2.pdf differ
diff --git a/backend/audit/fixtures/federalist.pdf b/backend/audit/fixtures/federalist.pdf
new file mode 100644
index 0000000000..9332d7379b
Binary files /dev/null and b/backend/audit/fixtures/federalist.pdf differ
diff --git a/backend/audit/fixtures/o-captain-2.pdf b/backend/audit/fixtures/o-captain-2.pdf
new file mode 100644
index 0000000000..59689a2fff
Binary files /dev/null and b/backend/audit/fixtures/o-captain-2.pdf differ
diff --git a/backend/audit/fixtures/o-captain.pdf b/backend/audit/fixtures/o-captain.pdf
new file mode 100644
index 0000000000..59689a2fff
Binary files /dev/null and b/backend/audit/fixtures/o-captain.pdf differ
diff --git a/backend/audit/fixtures/permanent-markers-2.pdf b/backend/audit/fixtures/permanent-markers-2.pdf
new file mode 100644
index 0000000000..9f51183a1c
Binary files /dev/null and b/backend/audit/fixtures/permanent-markers-2.pdf differ
diff --git a/backend/audit/fixtures/permanent-markers.pdf b/backend/audit/fixtures/permanent-markers.pdf
new file mode 100644
index 0000000000..f5b0483039
Binary files /dev/null and b/backend/audit/fixtures/permanent-markers.pdf differ
diff --git a/backend/audit/management/commands/compare_with_previous.py b/backend/audit/management/commands/compare_with_previous.py
new file mode 100644
index 0000000000..a2e4366505
--- /dev/null
+++ b/backend/audit/management/commands/compare_with_previous.py
@@ -0,0 +1,26 @@
+from django.core.management.base import BaseCommand
+
+import logging
+from django.contrib.auth import get_user_model
+from pprint import pprint
+
+from audit.viewlib.compare_two_submissions import compare_with_prev
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.INFO)
+
+User = get_user_model()
+
+
+class Command(BaseCommand):
+ def add_arguments(self, parser):
+ parser.add_argument("report_id", type=str)
+
+ def handle(self, *args, **options):
+ rid = options["report_id"]
+
+ # This is actually sneaky. Given a report ID, it either
+ # compares to the previous (if one exists), or if no prev
+ # exists, but a *next* DOES exist, it flips the order and
+ # compares next to this one.
+ pprint(compare_with_prev(rid))
diff --git a/backend/audit/management/commands/generate_resubmissions.py b/backend/audit/management/commands/generate_resubmissions.py
index 088097393b..406ae3856a 100644
--- a/backend/audit/management/commands/generate_resubmissions.py
+++ b/backend/audit/management/commands/generate_resubmissions.py
@@ -1,72 +1,416 @@
-from audit.models import (
- SingleAuditChecklist,
- SingleAuditReportFile,
-)
+##########################################################################
+# THIS SHOULD NEVER BE RUN IN PRODUCTION
+##########################################################################
+from audit.models import SingleAuditChecklist, SingleAuditReportFile, Access
from django.core.management.base import BaseCommand
from audit.models.constants import STATUS, RESUBMISSION_STATUS
-
+from audit.cross_validation.naming import SECTION_NAMES
+from django.contrib.auth import get_user_model
+from django.conf import settings
+from dissemination.models import (
+ AdditionalEin,
+ AdditionalUei,
+ CapText,
+ FederalAward,
+ Finding,
+ FindingText,
+ General,
+ Note,
+ Passthrough,
+ SecondaryAuditor,
+ Resubmission,
+)
from datetime import datetime
import pytz
+import os
+import sys
+import boto3
+from copy import deepcopy
+from inspect import currentframe
import logging
-import sys
-from django.contrib.auth import get_user_model
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
User = get_user_model()
+if "PROD" in os.getenv("ENV", "NOENV"):
+ # https://en.wikipedia.org/wiki/Long-term_nuclear_waste_warning_messages
+ logger.error("The danger is still present, in your time, as it was in ours.")
+ logger.error("DO NOT RUN THIS IN PRODUCTION")
+ logger.error("IT WILL DESTROY PRODUCTION DATA")
+ sys.exit(-1)
+
+# Using the new tools to generate data for local testing,
+# we want to make sure these are selected from our
+# 20K record subset, so that the command always works.
+REPORTIDS_TO_MODIFIERS = lambda: {
+ # Lets rewrite the auditor's address
+ "2023-06-GSAFAC-0000000697": [
+ modify_auditor_address,
+ # These two files have no changes that are visible, but
+ # the PDFs were generated at two different times. Invisible to the user.
+ upload_pdfs("o-captain.pdf", "o-captain-2.pdf"),
+ ],
+ "2023-06-GSAFAC-0000002166": [
+ modify_auditee_ein,
+ # The second file contains an image; the second does not
+ upload_pdfs("federalist.pdf", "federalist-2.pdf"),
+ ],
+ # Modifying the workbook requires there to be additional EINs
+ "2022-12-GSAFAC-0000001787": [
+ modify_additional_eins_workbook,
+ change_first_award,
+ add_an_award,
+ # The second file had its XML metadata changed. Instead of
+ # being created by Acrobat Distiller 6.0, the second
+ # was created by Acrobat Distiller 9.0. Invisible to the user.
+ upload_pdfs("permanent-markers.pdf", "permanent-markers-2.pdf"),
+ ],
+ "2023-06-GSAFAC-0000002901": [
+ modify_auditor_address,
+ modify_additional_eins_workbook,
+ ],
+ # Same modifications as above, but will make a new resub for each
+ # See REPORTIDS_TO_RESUBMIT_MODIFIERS_SEPARATELY below
+ "2023-06-GSAFAC-0000013043": [
+ modify_auditor_address,
+ modify_additional_eins_workbook,
+ ],
+ # modify_pdf simulates a new PDF submission.
+ # The report on the left has its SAR pointer modified so that it instead
+ # points to the report for the entity on the right. That way,
+ # it looks like a completely different PDF is attached to (say) 19157.
+ "2023-06-GSAFAC-0000005147": [
+ modify_pdf_point_at_this_instead("2023-06-GSAFAC-0000002901")
+ ],
+ "2023-06-GSAFAC-0000001699": [modify_total_amount_expended],
+ # Do all the things
+ "2022-12-GSAFAC-0000007921": [
+ modify_auditor_address,
+ modify_auditee_ein,
+ modify_additional_eins_workbook,
+ modify_total_amount_expended,
+ modify_pdf_point_at_this_instead("2023-06-GSAFAC-0000000697"),
+ ],
+ # Because we randomly fake tribal audits, this is tricky.
+ # We may need to intentionally set one in here, and then flip it.
+ # "2019-06-GSAFAC-0000005301": [flip_tribal_consent_status],
+}
+
+REPORTIDS_TO_RESUBMIT_MODIFIERS_SEPARATELY = ["2023-06-GSAFAC-0000013043"]
+
+# These are reports that get modified after the submission is complete.
+# This is useful for "rolling back" a resubmission into a IN_PROGRESS state or similar.
+# These must appear in the above table.
+POST_SUBMISSION_MODIFIERS = lambda: {
+ "2023-06-GSAFAC-0000000697": [
+ delete_disseminated_data_new_sac,
+ move_to_in_progress_new_sac,
+ ]
+}
+
+#########################################################
+# WARNING WARNING WARNING
+#########################################################
+#
+# When copying records, we *must* be super-duper careful. That is really
+# technical language for "hypervigilant."
+#
+# SACs are objects. Therefore, an assignment like
+#
+# new_sac = old_sac
+#
+# is a pointer assignment. The new_sac and the old_sac variables are now
+# pointing to the exact same location in memory---the same object.
+#
+# We can get around this by creating a new SAC (we do). But, then,
+# in this script, data is copied from the old SAC to the new.
+#
+# This is fine for constants that are interned by the interpreter: integers
+# and strings that we put in the code. (E.g. x="hi" and y="hi" both point to
+# the same string in memory.) This is not an issue for us at this time.
+#
+# It is an issue for elements in the SAC that are *lists*. For example
+# everything inside of every workbook field.
+#
+# If we copy the `general_information` over like this:
+#
+# new_sac.general_information = old_sac.general_information
+#
+# We have just set a pointer in the new_sac object to a dictionary inside
+# the old_sac object.
+#
+# I have *no idea* why the old_sac objects are saving in this script. But, the old_sac
+# objects are being modified. So, I have sprinkled two things throughout the code:
+#
+# * APNE
+# * deepcopy
+#
+# APNE is "assert_pointer_not_equal." It makes sure two variables are pointing
+# at different things in memory. It throws an assertion---forcing an exit---if the pointers
+# are the same. It correctly (?) skips ints and strings.
+#
+# deepcopy should be copying values and not pointers. However, I've seen it not work.
+# So, you'll see places where I walk a dictionary, and copy the k/v pairs over
+# the hard way... with a deepcopy on the value.
+#
+# No idea. It's all bonkers. It shouldn't be this hard to create a clone of a SAC, but it is.
+# This will matter a great deal in the future if we're going to allow users to edit live records.
+# We have to be very, very careful to make sure that we are not modifying the original record
+# through some kind of pointer confusion.
+#
+# For all I know, something like this is at play: https://www.reddit.com/r/django/comments/14vq6s7/comment/jrdxttz
+# There are times when a Django model will auto-save itself. So, that may have been happening to the old_sac
+# somewhere in this code. I wouldn't know. But I do know that the original was changing.
+#
+# MCJ 20250822
+
+
+def get_linenumber():
+ cf = currentframe()
+ return cf.f_back.f_lineno
+
+
+# asert_pointer_not_equal
+def APNE(a, b, loc=None):
+ if id(a) == id(b):
+ if isinstance(a, int) and isinstance(b, int) and a == b:
+ # We don't assert for integers; they always have the same pointer.
+ return
+ elif isinstance(a, str) and isinstance(b, str) and a == b:
+ # Same for strings and other builtin types.
+ return
+ elif isinstance(a, bool) and isinstance(b, bool) and a == b:
+ return
+ else:
+ logger.info(f"POINTERS EQUAL: {id(a)}")
+ logger.info(f"VALUE: {a}")
+ if loc is not None:
+ logger.info(f"LOCATION: {loc}")
+ # Leave this; this command is for testing/dev only, and never runs
+ # in a production environment. We want the assert.
+ assert id(a) != id(b) # nosec: B101
+
+
+#############################################
+# upload_pdfs
+#############################################
+# This uploads the orig to the old sac, and the second
+# to the new sac. We need to stuff real PDFs into the
+# S3/Minio bucket in order to compare them.
+def upload_pdfs(orig_pdf, revised_pdf):
+ def _fun(old_sac, new_sac, user_obj):
+ logger.info("MODIFIER: upload_pdfs")
+ APNE(old_sac, new_sac)
+
+ orig_sar = (
+ SingleAuditReportFile.objects.filter(sac=old_sac)
+ .order_by("date_created")
+ .first()
+ )
+
+ revised_sar = (
+ SingleAuditReportFile.objects.filter(sac=new_sac)
+ .order_by("date_created")
+ .first()
+ )
+
+ # These SARs will provide the filenames that we upload *to*.
+ # The filenames given are the filenames in the fixtures directory.
+ client = boto3.client(
+ "s3",
+ aws_access_key_id=settings.AWS_PRIVATE_ACCESS_KEY_ID,
+ aws_secret_access_key=settings.AWS_PRIVATE_SECRET_ACCESS_KEY,
+ endpoint_url=settings.AWS_S3_ENDPOINT_URL,
+ )
+ # In the event that the transfer mode doesn't work, this is another
+ # way to do it.
+ # transfer = S3Transfer(client)
+ # transfer.upload_file('/tmp/myfile', 'bucket', 'key')
+
+ fixture_1 = f"audit/fixtures/{orig_pdf}"
+ with open(fixture_1, "rb") as file:
+ logger.info(f"Uploading {fixture_1} to {orig_sar.filename}")
+ client.upload_fileobj(
+ file,
+ settings.AWS_PRIVATE_STORAGE_BUCKET_NAME,
+ f"singleauditreport/{orig_sar.filename}",
+ )
+
+ fixture_2 = f"audit/fixtures/{revised_pdf}"
+ with open(fixture_2, "rb") as file:
+ logger.info(f"Uploading {fixture_2} to {revised_sar.filename}")
+ client.upload_fileobj(
+ file,
+ settings.AWS_PRIVATE_STORAGE_BUCKET_NAME,
+ f"singleauditreport/{revised_sar.filename}",
+ )
+
+ return new_sac
+
+ return _fun
+
+
+#############################################
+# move_to_in_progress_new_sac
+#############################################
+def move_to_in_progress_new_sac(
+ old_sac: SingleAuditChecklist, new_sac: SingleAuditChecklist, user_obj
+):
+ new_sac.submission_status = STATUS.IN_PROGRESS
+ new_sac.transition_date = new_sac.transition_date[0:-1]
+ new_sac.transition_name = new_sac.transition_name[0:-1]
+ new_sac.save()
+ return new_sac
+
+
+#############################################
+# delete_disseminated_data_new_sac
+#############################################
+# Only deletes the disseminated data for the new_sac
+def delete_disseminated_data_new_sac(old_sac, new_sac, user_obj):
+ logger.info("MODIFIER: delete_disseminated_data_new_sac")
+ named_models = {
+ "AdditionalEins": AdditionalEin,
+ "AdditionalUeis": AdditionalUei,
+ "CorrectiveActionPlan": CapText,
+ "FederalAwards": FederalAward,
+ "FindingsText": FindingText,
+ "FindingsUniformGuidance": Finding,
+ "NotesToSefa": Note,
+ "SecondaryAuditors": SecondaryAuditor,
+ "General": General,
+ "Passthrough": Passthrough,
+ "Resubmission": Resubmission,
+ }
+ for model in named_models.values():
+ objs = model.objects.filter(report_id=new_sac.report_id)
+ for o in objs:
+ o.delete()
+ return new_sac
+
+
+#############################################
+# flip_tribal_consent_status
+#############################################
+def flip_tribal_consent_status(old_sac, new_sac, user_obj):
+ logger.info("MODIFIER: flip_tribal_consent_status")
+ APNE(old_sac, new_sac)
+ new_sac.general_information["user_provided_organization_type"] = "tribal"
+ new_sac.tribal_data_consent["is_tribal_information_authorized_to_be_public"] = True
+ return new_sac
+
+
+#############################################
+# remove_an_award
+#############################################
+def remove_an_award(old_sac, new_sac, user_obj):
+ logger.info("MODIFIER: remove_an_award")
+ APNE(old_sac, new_sac)
+ new_sac.federal_awards["FederalAwards"]["federal_awards"] = new_sac.federal_awards[
+ "FederalAwards"
+ ]["federal_awards"][:-1]
+ return new_sac
+
+
+#############################################
+# add_an_award
+#############################################
+def add_an_award(old_sac, new_sac, user_obj):
+ logger.info("MODIFIER: add_an_award")
+ APNE(old_sac, new_sac)
+ new_sac.federal_awards["FederalAwards"]["federal_awards"].append(
+ deepcopy(new_sac.federal_awards["FederalAwards"]["federal_awards"][0])
+ )
+ new_sac.federal_awards["FederalAwards"]["federal_awards"][1][
+ "award_reference"
+ ] = "AWARD-0002"
+
+ return new_sac
+
+
+#############################################
+# change_first_award
+#############################################
+def change_first_award(old_sac, new_sac, user_obj):
+ logger.info("MODIFIER: change_first_award")
+ APNE(old_sac, new_sac)
+ new_sac.federal_awards["FederalAwards"]["federal_awards"][0]["program"][
+ "amount_expended"
+ ] = 1_000_000
+ return new_sac
+
#############################################
# modify_total_amount_expended
#############################################
-def modify_total_amount_expended(sac, user_obj):
+def modify_total_amount_expended(old_sac, new_sac, user_obj):
logger.info("MODIFIER: modify_total_amount_expended")
- current_amount = sac.federal_awards["FederalAwards"]["total_amount_expended"]
+ APNE(old_sac, new_sac)
+ current_amount = old_sac.federal_awards["FederalAwards"]["total_amount_expended"]
new_amount = current_amount - 100000
- sac.federal_awards["FederalAwards"]["total_amount_expended"] = new_amount
+ new_sac.federal_awards["FederalAwards"]["total_amount_expended"] = new_amount
+ return new_sac
#############################################
# modify_auditee_ein
#############################################
-def modify_auditee_ein(sac, user_obj):
+def modify_auditee_ein(old_sac, new_sac, user_obj):
logger.info("MODIFIER: modify_auditee_ein")
- sac.general_information["ein"] = "999888777"
+ APNE(old_sac, new_sac)
+ new_sac.general_information["ein"] = "999888777"
+ return new_sac
#############################################
# modify_auditor_address
#############################################
-def modify_auditor_address(sac, user_obj):
+def modify_auditor_address(old_sac, new_sac, user_obj):
"""
Modify a resubmission with a new address
"""
logger.info("MODIFIER: modify_address")
- sac.general_information["auditor_address_line_1"] = "123 Across the street"
- sac.general_information["auditor_zip"] = "17921"
- sac.general_information["auditor_city"] = "Centralia"
- sac.general_information["auditor_state"] = "PA"
- sac.general_information["auditor_phone"] = "7777777777"
- sac.general_information["auditor_contact_name"] = "Jill Doe"
- sac.general_information["auditor_firm_name"] = "Other House of Auditor"
- sac.general_information["auditor_email"] = (
+ APNE(old_sac, new_sac)
+ new_sac.general_information["auditor_address_line_1"] = "123 Across the street"
+ new_sac.general_information["auditor_zip"] = "17921"
+ new_sac.general_information["auditor_city"] = "Centralia"
+ new_sac.general_information["auditor_state"] = "PA"
+ new_sac.general_information["auditor_phone"] = "7777777777"
+ new_sac.general_information["auditor_contact_name"] = "Jill Doe"
+ new_sac.general_information["auditor_firm_name"] = "Other House of Auditor"
+ new_sac.general_information["auditor_email"] = (
"other.qualified.human.accountant@auditor.com"
)
+ return new_sac
#############################################
# modify_additional_eins_workbook
#############################################
-def modify_additional_eins_workbook(sac, user_obj):
+def modify_additional_eins_workbook(old_sac, new_sac, user_obj):
"""
Modify a resubmission with a changed workbook
"""
- logger.info("MODIFIER: modify_workbook")
- sac.additional_eins["AdditionalEINs"]["additional_eins_entries"].append(
+ logger.info("MODIFIER: modify_eins_workbook")
+ APNE(old_sac, new_sac)
+ if not old_sac.additional_eins:
+ logger.info(f"AUDIT {old_sac.report_id} HAS NO ADDITIONAL EINS TO MODIFY")
+ logger.info("Fakin' it till I make it.")
+ new_sac.additional_eins = {
+ "Meta": {"section_name": "AdditionalEins"},
+ "AdditionalEINs": {
+ "auditee_uei": old_sac.auditee_uei,
+ "additional_eins_entries": [],
+ },
+ }
+
+ new_sac.additional_eins["AdditionalEINs"]["additional_eins_entries"].append(
{"additional_ein": "111222333"},
)
+ return new_sac
#############################################
@@ -79,8 +423,9 @@ def modify_pdf_point_at_this_instead(old_report_id):
so we can pass in a report id for the PDF that we want to *point to*.
"""
- def _do_modify(new_sac, user_obj):
+ def _do_modify(old_sac, new_sac, user_obj):
logger.info("MODIFIER: modify_pdf")
+ APNE(old_sac, new_sac)
logger.info(
f"old_report_id: {old_report_id} new_report_id: {new_sac.report_id}"
)
@@ -122,50 +467,11 @@ def _do_modify(new_sac, user_obj):
event_type="bogus-event-generate-test-pdf",
)
logger.info(f"Saved as id {new_sar.id}")
+ return new_sac
return _do_modify
-# Using the new tools to generate data for local testing,
-# we want to make sure these are selected from our
-# 20K record subset, so that the command always works.
-REPORTIDS_TO_MODIFIERS = lambda: {
- # Lets rewrite the auditor's address
- "2023-06-GSAFAC-0000000697": [modify_auditor_address],
- "2023-06-GSAFAC-0000002166": [modify_auditee_ein],
- # Modifying the workbook requires there to be additional EINs
- "2022-12-GSAFAC-0000001787": [modify_additional_eins_workbook],
- "2023-06-GSAFAC-0000002901": [
- modify_auditor_address,
- modify_additional_eins_workbook,
- ],
- # Same modifications as above, but will make a new resub for each
- # See REPORTIDS_TO_RESUBMIT_MODIFIERS_SEPARATELY below
- "2022-12-GSAFAC-0000001117": [
- modify_auditor_address,
- modify_additional_eins_workbook,
- ],
- # modify_pdf simulates a new PDF submission.
- # The report on the left has its SAR pointer modified so that it instead
- # points to the report for the entity on the right. That way,
- # it looks like a completely different PDF is attached to (say) 19157.
- "2023-06-GSAFAC-0000005147": [
- modify_pdf_point_at_this_instead("2023-06-GSAFAC-0000002901")
- ],
- "2023-06-GSAFAC-0000001699": [modify_total_amount_expended],
- # Do all the things
- "2022-12-GSAFAC-0000007921": [
- modify_auditor_address,
- modify_auditee_ein,
- modify_additional_eins_workbook,
- modify_total_amount_expended,
- modify_pdf_point_at_this_instead("2023-06-GSAFAC-0000000697"),
- ],
-}
-
-REPORTIDS_TO_RESUBMIT_MODIFIERS_SEPARATELY = ["2022-12-GSAFAC-0000001117"]
-
-
def complete_resubmission(
source_sac: SingleAuditChecklist, resubmitted_sac: SingleAuditChecklist, USER_OBJ
):
@@ -205,6 +511,26 @@ def complete_resubmission(
# Finally, redisseminate the old and new SAC records.
new_status = source_sac.redisseminate()
old_status = resubmitted_sac.redisseminate()
+
+ # Grant access to the audits
+ for a in Access.objects.filter(sac_id=source_sac.id):
+ a.delete()
+ for a in Access.objects.filter(sac_id=resubmitted_sac.id):
+ a.delete()
+
+ if not Access.objects.filter(sac_id=source_sac.id).exists():
+ a = Access(user=USER_OBJ, sac=source_sac, email=USER_OBJ.email)
+ a.save()
+ logger.info(
+ f"ACTION creating access: {a.sac.report_id} for user {a.user.email}"
+ )
+ if not Access.objects.filter(sac_id=resubmitted_sac.id).exists():
+ a = Access(user=USER_OBJ, sac=resubmitted_sac, email=USER_OBJ.email)
+ a.save()
+ logger.info(
+ f"ACTION creating access: {a.sac.report_id} for user {a.user.email}"
+ )
+
return old_status and new_status
@@ -226,6 +552,207 @@ def append_transition_names(sac: SingleAuditChecklist):
return sac
+def delete_prior_resubs(sacs):
+ """
+ Delete any prior resubmissions, so we regenerate this data clean each time
+ """
+ for sac in sacs:
+ resubs = SingleAuditChecklist.objects.filter(
+ resubmission_meta__previous_report_id=sac.report_id
+ )
+ recursively_delete_resubs_for_sac(sac, resubs)
+
+
+def recursively_delete_resubs_for_sac(sac, resubs):
+ """
+ Since resubmissions can have resubmissions, we have to keep digging
+ down for SACs that have previous_report_ids
+ """
+ if not resubs:
+ return
+
+ for resub in resubs:
+ re_resubs = SingleAuditChecklist.objects.filter(
+ resubmission_meta__previous_report_id=resub.report_id
+ )
+ recursively_delete_resubs_for_sac(resub, re_resubs)
+
+ logger.info(f"Deleting {resub.report_id}, a resub of {sac.report_id}")
+ resub.delete()
+
+
+def generate_resubmissions(
+ sacs, reportids_to_modifiers, post_submission_modifiers, options
+):
+ """
+ Generates all the resubmissions
+ """
+ for old_sac in sacs:
+ logger.info("-------------------------------")
+
+ if old_sac.report_id in REPORTIDS_TO_RESUBMIT_MODIFIERS_SEPARATELY:
+ logger.info(f"Generating resubmission chain for {old_sac.report_id}")
+ previous_sac = deepcopy(old_sac)
+ APNE(previous_sac, old_sac)
+ for modifier in reportids_to_modifiers[old_sac.report_id]:
+ new_sac = generate_resubmission(previous_sac, options, [modifier], [])
+ previous_sac = deepcopy(new_sac)
+ APNE(previous_sac, new_sac)
+ else:
+ logger.info(f"Generating resubmission for {old_sac.report_id}")
+ generate_resubmission(
+ old_sac,
+ options,
+ reportids_to_modifiers[old_sac.report_id],
+ post_submission_modifiers.get(old_sac.report_id, []),
+ )
+
+
+def create_resubmitted_pdf(sac, new_sac):
+ # Get the PDF report associated with this SAC, and
+ # create a "resubmitted" PDF
+ sac_sar = SingleAuditReportFile.objects.filter(sac=sac).first()
+ sac_sar.filename = f"singleauditreport/{new_sac.report_id}.pdf"
+ new_sac_sar = SingleAuditReportFile.objects.create(
+ file=sac_sar.file,
+ sac=new_sac,
+ audit=sac_sar.audit, # We're not bothering to make a new audit
+ user=sac_sar.user,
+ component_page_numbers=sac_sar.component_page_numbers,
+ )
+ logger.info(f"Created new SAR with ID: {new_sac_sar.id}")
+
+
+# {"ein": "222474723", "audit_type": "single-audit", "auditee_uei": "HXC9E9MTFLB6", "auditee_zip": "06851", "auditor_ein": "060530830", "auditor_zip": "06473", "auditee_city": "Norwalk", "auditee_name": "Miss Laura M. Raymond Homes, Inc.", "auditor_city": "North Haven", "is_usa_based": true, "auditee_email": "scox@ehmchm.org", "auditee_phone": "2032304809", "auditee_state": "CT", "auditor_email": "mloso@sewardmonde.com", "auditor_phone": "2032489341", "auditor_state": "CT", "auditor_country": "USA", "auditor_firm_name": "Seward and Monde, CPAs", "audit_period_covered": "annual", "auditee_contact_name": "Sabine Cox", "auditor_contact_name": "Michele Loso Boisvert", "auditee_contact_title": "Accounting Director", "auditor_contact_title": "Partner", "multiple_eins_covered": false, "multiple_ueis_covered": false, "auditee_address_line_1": "290 Main Avenue", "auditor_address_line_1": "296 State Street", "met_spending_threshold": true, "secondary_auditors_exist": false, "audit_period_other_months": "", "auditee_fiscal_period_end": "2023-06-30", "ein_not_an_ssn_attestation": true, "auditee_fiscal_period_start": "2022-07-01", "auditor_international_address": "", "user_provided_organization_type": "non-profit", "auditor_ein_not_an_ssn_attestation": true}
+
+
+def get_user_object(options):
+ # We need a user.
+ try:
+ THE_USER_OBJ = User.objects.get(email=options["email"])
+ except User.MultipleObjectsReturned:
+ # Mangled local DB's may have duplicate users.
+ THE_USER_OBJ = User.objects.filter(email=options["email"]).first()
+ except User.DoesNotExist:
+ THE_USER_OBJ = User.objects.first()
+ logger.info(f"ERROR: USING FIRST USER IN DB FOR TESTING: {THE_USER_OBJ.email}")
+ logger.info(f"Passed email: {options['email']}, Using user {THE_USER_OBJ}")
+ return THE_USER_OBJ
+
+
+def copy_data_over(old_sac, new_sac):
+ # This simulates the auditee/auditor completing the submission.
+ # So, we'll copy everything over from the old to the new.
+ # In a real submission, they'd have to upload stuff.
+ logger.info("ACTION: copy_data_over")
+ APNE(old_sac, new_sac)
+ sac_fields = SECTION_NAMES.keys()
+ for field in sac_fields:
+ if field not in ["single_audit_report"]:
+ # Don't clobber what was created when we initialized the audit resubmission.
+ if field == "general_information":
+ for k, v in old_sac.general_information.items():
+ if k not in [
+ "auditee_uei",
+ "auditee_fiscal_period_start",
+ "auditee_fiscal_period_end",
+ ]:
+ new_v = deepcopy(v)
+ APNE(new_v, v, loc=get_linenumber())
+ new_sac.general_information[k] = new_v
+
+ else:
+ # This is a convoluted way to copy the section objects.
+ # deepcopy(section) is not working. It is carrying pointers forward.
+ # So, I go through the section, copying the keys/values the hard way.
+ # At every step, assert that pointers are not equal.
+ # This literally fixed a bug in this script, where lists of entries in
+ # workbook sections were *pointer* copies, and as a result, modifying the
+ # new_sac was also modifying the old_sac.
+ # What I don't understand is why the old_sac was saving to the DB...
+ section = getattr(old_sac, field)
+ if section is None:
+ # Should this be None, or dict()?
+ # IT SHOULD BE NONE. That will match the prior DB row.
+ setattr(new_sac, field, None)
+ else:
+ d = dict()
+ for k, v in section.items():
+ new_v = deepcopy(v)
+ APNE(new_v, v, loc=get_linenumber())
+ d[k] = new_v
+ setattr(new_sac, field, d)
+ APNE(
+ getattr(new_sac, field),
+ getattr(old_sac, field),
+ loc=get_linenumber(),
+ )
+
+
+def generate_resubmission(
+ old_sac: SingleAuditChecklist, options, modifiers, post_modifiers
+): #
+ """
+ Generates a single resubmission
+ """
+ # Get a user. Preferably us.
+ THE_USER_OBJ = get_user_object(options)
+
+ # Start by taking a record and duplicating it, save for
+ # some of the state around the transitions.
+ new_sac = old_sac.initiate_resubmission(user=THE_USER_OBJ)
+ APNE(old_sac, new_sac)
+
+ logger.info(f"New SAC: {new_sac.report_id}")
+ logger.info(f"Created new SAC with ID: {new_sac.id}")
+
+ # Copy a PDF and create a new SAR record for it.
+ create_resubmitted_pdf(old_sac, new_sac)
+ APNE(old_sac, new_sac)
+
+ # Now, copy a lot of data over from the old to the new.
+ # This is not how we would normally do it, but we get a lot of errors otherwise.
+ copy_data_over(old_sac, new_sac)
+ APNE(old_sac, new_sac)
+
+ # Perform modifications on the new resubmission
+ # Invokes one or more modification functions (below)
+ for modification in modifiers:
+ new_sac = modification(old_sac, new_sac, THE_USER_OBJ)
+ APNE(old_sac, new_sac)
+
+ # Make sure we created a valid SAC entry.
+ # If not, error out.
+ errors = new_sac.validate_full()
+ if errors:
+ logger.error(
+ f"Unable to disseminate report with validation errors: {new_sac.report_id}."
+ )
+ logger.info(errors["errors"])
+ else:
+ # If we're here, we make sure the new SAC (which is a resubmission)
+ # has all the right data/fields to be used for resubmission testing.
+ # Need to be in the disseminated state in order to re-disseminated
+ disseminated = complete_resubmission(old_sac, new_sac, THE_USER_OBJ)
+ APNE(old_sac, new_sac)
+
+ if disseminated:
+ logger.info(f"DISSEMINATED REPORT: {new_sac.report_id}")
+
+ # Now that we're done, run the post-dissemination actions, if they exist:
+ for modification in post_modifiers:
+ new_sac = modification(old_sac, new_sac, THE_USER_OBJ)
+
+ return new_sac
+ else:
+ logger.error(
+ "{} is a `not None` value report_id[{}] for `disseminated`".format(
+ disseminated, new_sac.report_id
+ )
+ )
+ return None
+
+
class Command(BaseCommand):
"""
Django management command for generating resubmission test data. Only run
@@ -239,6 +766,7 @@ def add_arguments(self, parser):
def handle(self, *args, **options):
reportids_to_modifiers = REPORTIDS_TO_MODIFIERS()
+ post_submission_modifiers = POST_SUBMISSION_MODIFIERS()
logger.info("Modifying the following report IDs")
for rid in reportids_to_modifiers:
logger.info(f"\t{rid}")
@@ -256,120 +784,7 @@ def handle(self, *args, **options):
logger.error(f"Missing: {diff}")
sys.exit(1)
- self.delete_prior_resubs(sacs_for_resubs)
- self.generate_resubmissions(sacs_for_resubs, reportids_to_modifiers, options)
-
- def delete_prior_resubs(self, sacs):
- """
- Delete any prior resubmissions, so we regenerate this data clean each time
- """
- for sac in sacs:
- resubs = SingleAuditChecklist.objects.filter(
- resubmission_meta__previous_report_id=sac.report_id
- )
- self.recursively_delete_resubs_for_sac(sac, resubs)
-
- def recursively_delete_resubs_for_sac(self, sac, resubs):
- """
- Since resubmissions can have resubmissions, we have to keep digging
- down for SACs that have previous_report_ids
- """
- if not resubs:
- return
-
- for resub in resubs:
- re_resubs = SingleAuditChecklist.objects.filter(
- resubmission_meta__previous_report_id=resub.report_id
- )
- self.recursively_delete_resubs_for_sac(resub, re_resubs)
-
- logger.info(f"Deleting {resub.report_id}, a resub of {sac.report_id}")
- resub.delete()
-
- def generate_resubmissions(self, sacs, reportids_to_modifiers, options):
- """
- Generates all the resubmissions
- """
- for sac in sacs:
- logger.info("-------------------------------")
-
- if sac.report_id in REPORTIDS_TO_RESUBMIT_MODIFIERS_SEPARATELY:
- logger.info(f"Generating resubmission chain for {sac.report_id}")
- previous_sac = sac
- for modifier in reportids_to_modifiers[sac.report_id]:
- new_sac = self.generate_resubmission(
- previous_sac, options, [modifier]
- )
- previous_sac = new_sac
- else:
- logger.info(f"Generating resubmission for {sac.report_id}")
- self.generate_resubmission(
- sac, options, reportids_to_modifiers[sac.report_id]
- )
-
- def generate_resubmission(self, sac: SingleAuditChecklist, options, modifiers):
- """
- Generates a single resubmission
- """
- # We need a user.
- # FIXME: Allow an email address to be passed in, so we can see these things
- # in our dashboards. For now: any user will do.
- try:
- THE_USER_OBJ = User.objects.get(email=options["email"])
- except User.MultipleObjectsReturned:
- # Mangled local DB's may have duplicate users.
- THE_USER_OBJ = User.objects.filter(email=options["email"]).first()
- except User.DoesNotExist:
- THE_USER_OBJ = User.objects.first()
- logger.info(f"Passed email: {options['email']}, Using user {THE_USER_OBJ}")
-
- # Start by taking a record and duplicating it, save for
- # some of the state around the transitions.
- new_sac = sac.initiate_resubmission(user=THE_USER_OBJ)
-
- logger.info(f"New SAC: {new_sac.report_id}")
- logger.info(f"Created new SAC with ID: {new_sac.id}")
-
- # Get the PDF report associated with this SAC, and
- # create a "resubmitted" PDF
- sac_sar = SingleAuditReportFile.objects.filter(sac=sac).first()
- sac_sar.filename = f"singleauditreport/{new_sac.report_id}.pdf"
- new_sac_sar = SingleAuditReportFile.objects.create(
- file=sac_sar.file,
- sac=new_sac,
- audit=sac_sar.audit, # We're not bothering to make a new audit
- user=sac_sar.user,
- component_page_numbers=sac_sar.component_page_numbers,
+ delete_prior_resubs(sacs_for_resubs)
+ generate_resubmissions(
+ sacs_for_resubs, reportids_to_modifiers, post_submission_modifiers, options
)
- logger.info(f"Created new SAR with ID: {new_sac_sar.id}")
-
- # Perform modifications on the new resubmission
- # Invokes one or more modification functions (below)
- for modification in modifiers:
- modification(new_sac, THE_USER_OBJ)
-
- # Make sure we created a valid SAC entry.
- # If not, error out.
- errors = new_sac.validate_full()
- if errors:
- logger.error(
- f"Unable to disseminate report with validation errors: {new_sac.report_id}."
- )
- logger.info(errors["errors"])
-
- # If we're here, we make sure the new SAC (which is a resubmission)
- # has all the right data/fields to be used for resubmission testing.
- # Need to be in the disseminated state in order to re-disseminated
- disseminated = complete_resubmission(sac, new_sac, THE_USER_OBJ)
-
- if disseminated:
- # TODO: Change submission_status to disseminated once Matt's
- # "late change" workaround is merged
- logger.info(f"DISSEMINATED REPORT: {new_sac.report_id}")
- return new_sac
- else:
- logger.error(
- "{} is a `not None` value report_id[{}] for `disseminated`".format(
- disseminated, new_sac.report_id
- )
- )
diff --git a/backend/audit/models/models.py b/backend/audit/models/models.py
index 1f3ad6efb7..9c20b890b0 100644
--- a/backend/audit/models/models.py
+++ b/backend/audit/models/models.py
@@ -3,7 +3,6 @@
import logging
from django.db import models
-from django.forms.models import model_to_dict
from django.db import transaction
from django.db.transaction import TransactionManagementError
from django.conf import settings
@@ -309,65 +308,72 @@ def redisseminate(self):
# END ATOMIC BLOCK
return False
+ def _raise_if_resubmission_already_exists(self):
+ if SingleAuditChecklist.objects.filter(
+ # If I am someone's previous report
+ resubmission_meta__previous_report_id=self.report_id,
+ # And my submission status is either disseminated or resubmitted
+ submission_status__in=[STATUS.DISSEMINATED, STATUS.RESUBMITTED],
+ ).exists():
+ # Then we should throw an exception.
+ raise ValidationError(
+ f"A resubmission already exists for report_id {self.report_id}."
+ )
+
# Resubmission SAC Creations
- # Atomically create a new SAC row as a resubmission of this SAC. Assert that a resubmission does not already exist
+ # Atomically create a new SAC row as a resubmission of this SAC.
+ # Assert that a resubmission does not already exist
def initiate_resubmission(self, user=None):
- # BEGIN ATOMIC BLOCK
- with transaction.atomic():
- if SingleAuditChecklist.objects.filter(
- resubmission_meta__previous_report_id=self.report_id,
- submission_status__in=[STATUS.DISSEMINATED, STATUS.RESUBMITTED],
- ).exists():
- raise ValidationError(
- f"A resubmission already exists for report_id {self.report_id}."
- )
-
- # Clone the record
- data = model_to_dict(self)
+ # First, we raise an error if a resubmission already exists for this SAC.
+ self._raise_if_resubmission_already_exists()
+ # If we get past that, we then can do the work of initializing a resubmission.
- # Update individual fields
- data["general_information"]["auditee_uei"] = self.auditee_uei
- data["general_information"][
- "auditee_fiscal_period_start"
- ] = self.auditee_fiscal_period_start
- data["general_information"][
- "auditee_fiscal_period_end"
- ] = self.auditee_fiscal_period_end
-
- # Manually add back foreign key as instance
- data["submitted_by"] = self.submitted_by
-
- # We always need to update the data source on a resubmission.
- # It is GSAFAC.
- data["data_source"] = DATA_SOURCE_GSAFAC
-
- # By default, this is version 1. This means all resubmissions will be of at least version 2.
- if data.get("resubmission_meta"):
- old_version = data.get("resubmission_meta").get("version", 1)
+ with transaction.atomic():
+ # We know what the resubmission metadata will be.
+ # By default, this is version 1.
+ # This means all resubmissions will be of at least version 2.
+ if self.resubmission_meta:
+ old_version = self.resubmission_meta.get("version", 1)
else:
old_version = 1
- # Add/override fields
- data.update(
- {
- "submission_status": STATUS.IN_PROGRESS,
- "resubmission_meta": {
- "previous_report_id": self.report_id,
- "previous_row_id": self.id,
- "resubmission_status": RESUBMISSION_STATUS.MOST_RECENT,
- "version": old_version + 1,
- },
- "transition_name": [STATUS.IN_PROGRESS],
- "transition_date": [now()],
- }
- )
-
- resub = SingleAuditChecklist.objects.create(**data)
+ creation_data = {
+ "general_information": {
+ "auditee_uei": self.auditee_uei,
+ "auditee_fiscal_period_start": self.general_information[
+ "auditee_fiscal_period_start"
+ ],
+ "auditee_fiscal_period_end": self.general_information[
+ "auditee_fiscal_period_end"
+ ],
+ },
+ "submitted_by": self.submitted_by,
+ "data_source": DATA_SOURCE_GSAFAC,
+ }
+ # Start by creating a clean, new SAC.
+ # FIXME: What should the user be here?
+ new_sac = SingleAuditChecklist.objects.create(**creation_data)
+
+ # Go ahead and add the metadata for resubmission
+ new_sac.submission_status = STATUS.IN_PROGRESS
+ new_sac.resubmission_meta = {
+ "previous_report_id": self.report_id,
+ "previous_row_id": self.id,
+ "resubmission_status": RESUBMISSION_STATUS.MOST_RECENT,
+ "version": old_version + 1,
+ }
+ new_sac.transition_name = [STATUS.IN_PROGRESS]
+ # FIXME: Do we have to do anything else datewise?
+ new_sac.transition_date = [now()]
+
+ ########
+ # Save the new SAC
+ new_sac.save()
if user:
# Event on the new RESUB
SubmissionEvent.objects.create(
- sac=resub,
+ sac=new_sac,
user=user,
event=SubmissionEvent.EventType.RESUBMISSION_STARTED,
)
@@ -378,7 +384,7 @@ def initiate_resubmission(self, user=None):
event=SubmissionEvent.EventType.RESUBMISSION_INITIATED,
)
- return resub
+ return new_sac
# END ATOMIC BLOCK
def assign_cog_over(self):
diff --git a/backend/audit/templates/audit/compare_submissions.html b/backend/audit/templates/audit/compare_submissions.html
new file mode 100644
index 0000000000..dc9f350344
--- /dev/null
+++ b/backend/audit/templates/audit/compare_submissions.html
@@ -0,0 +1,32 @@
+{% extends "base.html" %}
+{% load static %}
+{% block content %}
+
+{% comment %} Perhaps only show the text at the top if this is a public/non-federal user? {% endcomment %}
+
+
+
+
Resubmission comparison
+
To assist auditees and auditors in completing the resubmission of audits, the Clearinghouse provides an automated comparison between {{ r1 }} and {{ r2 }}.
+
This comparison is not comprehensive; it is a summary of changes detected between sections of the SF-SAC.
+
+
+
This comparison tool does not absolve submitters from their responsibility to independently validate and assert the correctness of their submission to the FAC.
+
+
+
+
+
+
+
+ {% include "./comparison_sections_only.html" %}
+
+
+
+
+ {% comment %} FIXME: This could be a blue button on the right? Also, which should we redirect them to? {% endcomment %}
+ Return to the submission checklist.
+
+{% endblock content %}
diff --git a/backend/audit/templates/audit/comparison_sections_only.html b/backend/audit/templates/audit/comparison_sections_only.html
new file mode 100644
index 0000000000..a1786d6de5
--- /dev/null
+++ b/backend/audit/templates/audit/comparison_sections_only.html
@@ -0,0 +1,135 @@
+{% comment %}
+ each key is a section, mapped to what changed
+ each section contains a tag "status" (same, changed) and "in_r1", "in_r2" as lists
+ this is broken out so that it can be embedded in a Federal view without the trappings,
+ if we decide that is desirable/necessary.
+{% endcomment %}
+{% for section, changed in comparison.items %}
+ {% if changed.status == "same" %}
+
+ {% elif changed.status == "error" %}
+
+
+
Changes in {{ nice_names | KV:section }}
+
+
+
Error: {{ changed.message }}
+ {% else %}
+
+
+
Changes in {{ nice_names | KV:section }}
+
+
+ {% if changed.in_r1|length > 0 %}
+
+
+
+
Changes only in {{ r1 }}
+
+
+
Element
+
{{ r1 | slice:"15:" }}
+
{{ r2 | slice:"15:" }}
+
+
+ {% for d in changed.in_r1|dictsort:"key" %}
+
+
+ {{ d.key|repl:"_| "|title }}
+
+
+ {% if d.from is None %}
+ No value
+ {% else %}
+ {{ d.from }}
+ {% endif %}
+
+
+ {% if d.to is None %}
+ No value
+ {% else %}
+ {{ d.to }}
+ {% endif %}
+
+ This resubmission comparison does not absolve the auditor or auditee from their responsibility to verify and assert the correctness of their submission to the FAC.
+
+
+
+{% endif %}
diff --git a/backend/audit/templates/audit/submission_checklist/submission-checklist.html b/backend/audit/templates/audit/submission_checklist/submission-checklist.html
index c2791d1b93..a368a9e5ba 100644
--- a/backend/audit/templates/audit/submission_checklist/submission-checklist.html
+++ b/backend/audit/templates/audit/submission_checklist/submission-checklist.html
@@ -14,9 +14,7 @@
{% include "./title.html" with auditee_name=auditee_name %}
- {% if previous_report_id %}
- {% include "includes/resubmission_banner.html" %}
- {% endif %}
+ {% include "./resubmission_banner.html" %}
@@ -35,6 +33,7 @@
{% section_block report_id secondary_auditors %}
{% section_block report_id additional_eins %}
{% include "./key-info-pre-validation.html" %}
+ {% include "./resubmission_change_check.html" %}
{% include "./tribal-data-release.html" %}
{% include "./validation.html" %}
{% else %}
diff --git a/backend/audit/templatetags/fac_tags.py b/backend/audit/templatetags/fac_tags.py
index 1a2580bdc3..78d0e1bf87 100644
--- a/backend/audit/templatetags/fac_tags.py
+++ b/backend/audit/templatetags/fac_tags.py
@@ -1,6 +1,10 @@
from django import template
from config import settings
+from django.template.defaulttags import register as reg
+import logging
+
+logger = logging.getLogger(__name__)
register = template.Library()
@@ -8,3 +12,27 @@
@register.simple_tag
def do_DAP():
return "Y" if settings.DEBUG is not True else "N"
+
+
+# https://stackoverflow.com/a/8000091
+@reg.filter
+def KV(dict, key):
+ # logger.info(f"Looking up [{key}] in {dict} ")
+ return dict.get(key)
+
+
+@reg.filter
+def repl(string, orig_new):
+ # logger.info(f"Looking up [{key}] in {dict} ")
+ s = orig_new.split("|")
+ return string.replace(s[0], s[1])
+
+
+@register.filter
+def get_type(value):
+ return type(value)
+
+
+@register.filter
+def split_string(value, char):
+ return value.split(char)
diff --git a/backend/audit/test_initiate_resubmission_create.py b/backend/audit/test_initiate_resubmission_create.py
index 8110368a4e..b9ba39914b 100644
--- a/backend/audit/test_initiate_resubmission_create.py
+++ b/backend/audit/test_initiate_resubmission_create.py
@@ -12,14 +12,15 @@
class ResubmissionTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="resubuser")
- self.general_info = {
+ general_information = {
+ "auditee_fiscal_period_start": "2024-01-01",
"auditee_fiscal_period_end": "2024-12-31",
- "uei": "TESTUEI999999",
+ "auditee_uei": "TESTUEI999999",
}
self.orig = SingleAuditChecklist.objects.create(
submitted_by=self.user,
submission_status=STATUS.IN_PROGRESS,
- general_information=self.general_info,
+ general_information=general_information,
)
def test_resubmission_is_created_atomically_and_correctly(self):
diff --git a/backend/audit/test_viewlib/test_compare_submissions.py b/backend/audit/test_viewlib/test_compare_submissions.py
new file mode 100644
index 0000000000..b274cce2e6
--- /dev/null
+++ b/backend/audit/test_viewlib/test_compare_submissions.py
@@ -0,0 +1,462 @@
+from django.test import TestCase
+from audit.viewlib.compare_two_submissions import (
+ compare_report_ids,
+ in_first_not_second,
+ in_second_not_first,
+ in_both,
+ analyze_pair,
+ getattr_default,
+ deep_getattr,
+ compare_lists_of_objects,
+ are_two_sacs_identical,
+)
+from audit.models import SingleAuditChecklist
+from model_bakery import baker
+from copy import deepcopy
+
+
+def setup_mock_db():
+ # so I can copy-paste data out of a test DB
+ true = True
+ false = False
+
+ report_ids = [
+ "2025-01-FAKEDB-0000000001",
+ "2025-01-FAKEDB-0000000002",
+ "2025-01-FAKEDB-0000000003",
+ ]
+ rids = {}
+ for ndx, rid in enumerate(report_ids):
+ baker.make(SingleAuditChecklist, report_id=rid)
+ rids[f"rid_{ndx + 1}"] = rid
+
+ sac_r1 = SingleAuditChecklist.objects.get(report_id=rids["rid_1"])
+ sac_r2 = SingleAuditChecklist.objects.get(report_id=rids["rid_2"])
+
+ sac_r1.general_information = {
+ "ein": "370906335",
+ "audit_type": "single-audit",
+ "auditee_uei": "G9LDNMFWZHY7",
+ "auditee_zip": "62930",
+ "auditor_ein": "430352985",
+ "auditor_zip": "62711",
+ "auditee_city": "Eldorado",
+ "auditee_name": "Eldorado Community Unit School District No. 4",
+ "auditor_city": "Springfield",
+ "is_usa_based": True,
+ "auditee_email": "rhobbs@eld4.org",
+ "auditee_phone": "6182739312",
+ "auditee_state": "IL",
+ "auditor_email": "amandaw@kebcpa.com",
+ "auditor_phone": "2177890960",
+ "auditor_state": "IL",
+ "auditor_country": "USA",
+ "auditor_firm_name": "Kerber, Eck & Braeckel LLP",
+ "audit_period_covered": "annual",
+ "auditee_contact_name": "Ryan Hobbs",
+ "auditor_contact_name": "Amanda Wells",
+ "auditee_contact_title": "Superintendent",
+ "auditor_contact_title": "Senior Manager",
+ "multiple_eins_covered": False,
+ "multiple_ueis_covered": False,
+ "auditee_address_line_1": "2200 Illinois Avenue",
+ "auditor_address_line_1": "3200 Robbins Rd, Suite 200A",
+ "met_spending_threshold": True,
+ "secondary_auditors_exist": False,
+ "audit_period_other_months": "",
+ "auditee_fiscal_period_end": "2021-06-30",
+ "ein_not_an_ssn_attestation": True,
+ "auditee_fiscal_period_start": "2020-07-01",
+ "auditor_international_address": "",
+ "user_provided_organization_type": "local",
+ "auditor_ein_not_an_ssn_attestation": True,
+ }
+
+ sac_r1.federal_awards = {
+ "Meta": {"section_name": "FederalAwardsExpended"},
+ "FederalAwards": {
+ "auditee_uei": "R5MQB3PSN2E3",
+ "federal_awards": [
+ {
+ "cluster": {"cluster_name": "N/A", "cluster_total": 0},
+ "program": {
+ "is_major": "Y",
+ "program_name": "MORTGAGE INSURANCE FOR THE PURCHASE OR REFINANCING OF EXISTING MULTIFAMILY HOUSING PROJECTS",
+ "amount_expended": 1814238,
+ "audit_report_type": "U",
+ "federal_agency_prefix": "14",
+ "federal_program_total": 1814238,
+ "three_digit_extension": "155",
+ "number_of_audit_findings": 0,
+ },
+ "subrecipients": {"is_passed": "N"},
+ "award_reference": "AWARD-0001",
+ "loan_or_loan_guarantee": {
+ "is_guaranteed": "Y",
+ "loan_balance_at_audit_period_end": "1758128",
+ },
+ "direct_or_indirect_award": {"is_direct": "Y"},
+ },
+ {
+ "cluster": {"cluster_name": "N/A", "cluster_total": 0},
+ "program": {
+ "is_major": "N",
+ "program_name": "SUPPORTIVE HOUSING FOR THE ELDERLY",
+ "amount_expended": 122505,
+ "federal_agency_prefix": "14",
+ "federal_program_total": 122505,
+ "three_digit_extension": "157",
+ "number_of_audit_findings": 0,
+ },
+ "subrecipients": {"is_passed": "N"},
+ "award_reference": "AWARD-0002",
+ "loan_or_loan_guarantee": {
+ "is_guaranteed": "Y",
+ "loan_balance_at_audit_period_end": "117445",
+ },
+ "direct_or_indirect_award": {"is_direct": "Y"},
+ },
+ {
+ "cluster": {"cluster_name": "N/A", "cluster_total": 0},
+ "program": {
+ "is_major": "N",
+ "program_name": "SECTION 8 NEW CONSTRUCTION AND SUBSTANTIAL REHABILITATION",
+ "amount_expended": 272623,
+ "federal_agency_prefix": "14",
+ "federal_program_total": 272623,
+ "three_digit_extension": "182",
+ "number_of_audit_findings": 0,
+ },
+ "subrecipients": {"is_passed": "N"},
+ "award_reference": "AWARD-0003",
+ "loan_or_loan_guarantee": {"is_guaranteed": "N"},
+ "direct_or_indirect_award": {"is_direct": "Y"},
+ },
+ ],
+ "total_amount_expended": 2209366,
+ },
+ }
+
+ sac_r1.findings_uniform_guidance = {
+ "Meta": {"section_name": "FindingsUniformGuidance"},
+ "FindingsUniformGuidance": {
+ "auditee_uei": "MCQEFMM2LJA4",
+ "findings_uniform_guidance_entries": [
+ {
+ "program": {
+ "award_reference": "AWARD-0008",
+ "compliance_requirement": "I",
+ },
+ "findings": {
+ "is_valid": "Y",
+ "prior_references": "N/A",
+ "reference_number": "2022-001",
+ "repeat_prior_reference": "N",
+ },
+ "other_matters": "Y",
+ "other_findings": "N",
+ "modified_opinion": "N",
+ "questioned_costs": "N",
+ "material_weakness": "Y",
+ "significant_deficiency": "N",
+ },
+ {
+ "program": {
+ "award_reference": "AWARD-0009",
+ "compliance_requirement": "I",
+ },
+ "findings": {
+ "is_valid": "Y",
+ "prior_references": "N/A",
+ "reference_number": "2022-001",
+ "repeat_prior_reference": "N",
+ },
+ "other_matters": "Y",
+ "other_findings": "N",
+ "modified_opinion": "N",
+ "questioned_costs": "N",
+ "material_weakness": "Y",
+ "significant_deficiency": "N",
+ },
+ ],
+ },
+ }
+
+ sac_r1.save()
+
+ sac_r2.general_information = {
+ "ein": "316000427",
+ "audit_type": "single-audit",
+ "auditee_uei": "RHVRCYWNTFX3",
+ "auditee_zip": "45177",
+ "auditor_ein": "311334820",
+ "auditor_zip": "43215",
+ "auditee_city": "Wilmington",
+ "auditee_name": "Clinton County",
+ "auditor_city": "Columbus",
+ "is_usa_based": True,
+ "auditee_email": "habermehltg@clintoncountyohio.us",
+ "auditee_phone": "9373822250",
+ "auditee_state": "OH",
+ "auditor_email": "dcf-southwest@ohioauditor.gov",
+ "auditor_phone": "5133618550",
+ "auditor_state": "OH",
+ "auditor_country": "USA",
+ "auditor_firm_name": "Ohio Auditor of State",
+ "audit_period_covered": "annual",
+ "auditee_contact_name": "Terence Habermehl",
+ "auditor_contact_name": "Cristal Jones",
+ "auditee_contact_title": "County Auditor",
+ "auditor_contact_title": "Chief Auditor",
+ "multiple_eins_covered": false,
+ "multiple_ueis_covered": false,
+ "auditee_address_line_1": "1850 David's Drive",
+ "auditor_address_line_1": "88 East Broad Street",
+ "met_spending_threshold": true,
+ "secondary_auditors_exist": false,
+ "audit_period_other_months": "",
+ "auditee_fiscal_period_end": "2021-12-31",
+ "ein_not_an_ssn_attestation": true,
+ "auditee_fiscal_period_start": "2021-01-01",
+ "auditor_international_address": "",
+ "user_provided_organization_type": "local",
+ "auditor_ein_not_an_ssn_attestation": true,
+ }
+
+ sac_r2.federal_awards = {
+ "Meta": {"section_name": "FederalAwardsExpended"},
+ "FederalAwards": {
+ "auditee_uei": "JRRVW2KT4U71",
+ "federal_awards": [
+ {
+ "cluster": {"cluster_name": "N/A", "cluster_total": 0},
+ "program": {
+ "is_major": "Y",
+ "program_name": "PROVIDER RELIEF FUND",
+ "amount_expended": 1709125,
+ "audit_report_type": "U",
+ "federal_agency_prefix": "93",
+ "federal_program_total": 1709125,
+ "three_digit_extension": "498",
+ "number_of_audit_findings": 1,
+ },
+ "subrecipients": {"is_passed": "N"},
+ "award_reference": "AWARD-0001",
+ "loan_or_loan_guarantee": {"is_guaranteed": "N"},
+ "direct_or_indirect_award": {"is_direct": "Y"},
+ }
+ ],
+ "total_amount_expended": 1709125,
+ },
+ }
+
+ sac_r2.save()
+
+ # Make R3 the same as R1, but with one difference
+ sac_r3 = SingleAuditChecklist.objects.get(report_id=rids["rid_3"])
+ sac_r3.general_information = sac_r1.general_information | {
+ "ein": "123456789",
+ }
+ # Simulate changing row 3
+ sac_r3.federal_awards = deepcopy(sac_r1.federal_awards)
+ sac_r3.federal_awards["FederalAwards"]["federal_awards"] = sac_r1.federal_awards[
+ "FederalAwards"
+ ]["federal_awards"][0:-1] + [
+ {
+ "cluster": {"cluster_name": "N/A", "cluster_total": 0},
+ "program": {
+ "is_major": "N",
+ "program_name": "SECTION 8 NEW CONSTRUCTION AND SUBSTANTIAL REHABILITATION",
+ "amount_expended": 1272623,
+ "federal_agency_prefix": "14",
+ "federal_program_total": 1272623,
+ "three_digit_extension": "222",
+ "number_of_audit_findings": 0,
+ },
+ "subrecipients": {"is_passed": "N"},
+ "award_reference": "AWARD-0003",
+ "loan_or_loan_guarantee": {"is_guaranteed": "N"},
+ "direct_or_indirect_award": {"is_direct": "Y"},
+ }
+ ]
+ sac_r3.federal_awards["FederalAwards"]["total_amount_expended"] = 3209366
+ sac_r3.findings_uniform_guidance = deepcopy(sac_r1.findings_uniform_guidance)
+ # Remove the last finding
+ sac_r3.findings_uniform_guidance["FindingsUniformGuidance"][
+ "findings_uniform_guidance_entries"
+ ] = sac_r3.findings_uniform_guidance["FindingsUniformGuidance"][
+ "findings_uniform_guidance_entries"
+ ][
+ 0:-1
+ ]
+
+ sac_r3.save()
+
+ # Make these a resubmission sequence
+ sac_r1.resubmission_meta = {"next_report_id": sac_r2.report_id}
+
+ sac_r2.resubmission_meta = {
+ "previous_report_id": sac_r1.report_id,
+ "next_report_id": sac_r3.report_id,
+ }
+
+ sac_r3.resubmission_meta = {"previous_report_id": sac_r2.report_id}
+
+ sac_r1.save()
+ sac_r2.save()
+ sac_r3.save()
+
+ return [sac_r1, sac_r2, sac_r3]
+
+
+class CompareSubmissionTests(TestCase):
+
+ def test_helpers(self):
+ d1 = {"a": 1, "b": 2, "c": 3}
+ d2 = {"a": 1, "b": 3, "d": 4}
+
+ self.assertEqual(
+ in_first_not_second(d1, d2), [{"key": "c", "from": 3, "to": None}]
+ )
+
+ self.assertEqual(
+ in_second_not_first(d1, d2), [{"key": "d", "from": 4, "to": None}]
+ )
+
+ self.assertEqual(in_both(d1, d2), [{"key": "b", "from": 2, "to": 3}])
+
+ self.assertEqual(
+ analyze_pair(d1, d2),
+ {
+ "status": "changed",
+ "in_r1": [{"key": "c", "from": 3, "to": None}],
+ "in_r2": [{"key": "d", "from": 4, "to": None}],
+ "in_both": [{"key": "b", "from": 2, "to": 3}],
+ },
+ )
+
+ def test_getattrs(self):
+ class TO:
+ pass
+
+ testObj = TO()
+ deepObj = TO()
+ deepObj.x = 16
+ deepObj.y = 32
+
+ deepDict = {}
+ deepDict["g"] = 128
+ deepDict["h"] = 256
+ deepObj.d = deepDict
+
+ testObj.a = 3
+ testObj.b = 5
+ testObj.deep = deepObj
+
+ self.assertEqual(getattr_default(testObj, "b", 8), 5)
+ self.assertEqual(getattr_default(testObj, "x", 8), 8)
+ self.assertEqual(deep_getattr(testObj, ["deep", "x"], 8), 16)
+ self.assertEqual(deep_getattr(testObj, ["deep", "z"], 8), 8)
+ self.assertEqual(deep_getattr(testObj, ["deep", "d", "g"], 8), 128)
+ self.assertEqual(deep_getattr(testObj, ["deep", "d", "z"], 8), 8)
+
+ def test_compare_lists_of_objects(self):
+ d1 = {"a": 1, "b": [{"name": "one", "value": 2}], "c": 3}
+ d2 = {"a": 1, "b": [{"name": "two", "value": 3}], "d": 4}
+ d3 = {
+ "a": 1,
+ "b": [{"name": "one", "value": 2}, {"name": "three", "value": 4}],
+ "e": 5,
+ }
+ res = compare_lists_of_objects(d1, d2, ["b"], lambda o: o["value"])
+ self.assertEqual(
+ res, {"status": "changed", "in_r1": ["2"], "in_r2": ["3"], "in_both": []}
+ )
+ res = compare_lists_of_objects(d1, d3, ["b"], lambda o: o["value"])
+ self.assertEqual(
+ res, {"status": "changed", "in_r1": [], "in_r2": ["4"], "in_both": ["2"]}
+ )
+
+ def test_identical_sacs(self):
+ setup_mock_db()
+ summary = are_two_sacs_identical(
+ "2025-01-FAKEDB-0000000001", "2025-01-FAKEDB-0000000001"
+ )
+ self.assertEqual(summary, True)
+
+ def test_same_audit_no_change(self):
+ setup_mock_db()
+ # Comparing the same report should yield no change/difference.
+ summary = compare_report_ids(
+ "2025-01-FAKEDB-0000000001", "2025-01-FAKEDB-0000000001"
+ )
+ self.assertEqual("identical", summary.get("status", "should not get default"))
+
+ def test_diff_audit_changes(self):
+ # We should see that the status says "changed"
+ # when sections are not the same
+ setup_mock_db()
+
+ summary = compare_report_ids(
+ "2025-01-FAKEDB-0000000001", "2025-01-FAKEDB-0000000002"
+ )
+
+ self.assertEqual(
+ "changed", summary.get("general_information", {}).get("status", None)
+ )
+ # Things changed from one to the other; there are no repeated values.
+ self.assertEqual([], summary.get("general_information", {}).get("in_r1", None))
+ self.assertEqual([], summary.get("general_information", {}).get("in_r2", None))
+ # There should be a lot of things in the `in_both` key
+ self.assertGreater(
+ len(summary.get("general_information", {}).get("in_both", [])), 3
+ )
+
+ def test_one_difference(self):
+ setup_mock_db()
+ # We should see a difference of one field shows up in both r1 - r2 and r2 - r1.
+ summary = compare_report_ids(
+ "2025-01-FAKEDB-0000000001", "2025-01-FAKEDB-0000000003"
+ )
+
+ # There should be one key in both: ein
+ self.assertEqual(
+ 1, len(summary.get("general_information", {}).get("in_both", {}))
+ )
+ # The "from" should be "370..." and the "to" should be "123..."
+ self.assertEqual(
+ "370906335",
+ summary.get("general_information", {}).get("in_both", None)[0].get("from"),
+ )
+ self.assertEqual(
+ "123456789",
+ summary.get("general_information", {}).get("in_both", None)[0].get("to"),
+ )
+
+ # def test_federal_award_difference(self):
+ # # This should show us that an object *changed* between two reports.
+ # # Therefore, it will show up in r1 and r2.
+ # setup_mock_db()
+ # summary = compare_report_ids(
+ # "2025-01-FAKEDB-0000000001", "2025-01-FAKEDB-0000000003"
+ # )
+ # for key in ["in_r1", "in_r2"]:
+ # self.assertEqual(["AWARD-0003"], summary.get("federal_awards").get(key))
+
+ # def test_findings_ug(self):
+ # # This shows us when something is missing in R2. It was present in R1, but
+ # # not in R2, so it only shows up in the R1 list. It also shows a compound-key
+ # # that is constructed from multiple parts of an object.
+ # setup_mock_db()
+ # summary = compare_report_ids(
+ # "2025-01-FAKEDB-0000000001", "2025-01-FAKEDB-0000000003"
+ # )
+
+ # self.assertEqual(
+ # ["AWARD-0009/2022-001"],
+ # summary.get("findings_uniform_guidance", {}).get("in_r1", None),
+ # )
+ # self.assertEqual(
+ # [],
+ # summary.get("findings_uniform_guidance", {}).get("in_r2", None),
+ # )
diff --git a/backend/audit/test_viewlib/test_compare_submissions_view.py b/backend/audit/test_viewlib/test_compare_submissions_view.py
new file mode 100644
index 0000000000..ce1a4bd4d6
--- /dev/null
+++ b/backend/audit/test_viewlib/test_compare_submissions_view.py
@@ -0,0 +1,141 @@
+from django.contrib.auth import get_user_model
+from django.test import Client, TestCase
+from django.urls import reverse
+from model_bakery import baker
+from audit.test_viewlib.test_compare_submissions import setup_mock_db
+from audit.models import Access, SingleAuditChecklist
+from users.models import UserPermission, Permission
+
+User = get_user_model()
+
+
+class Params:
+ pass
+
+
+def setup_test(is_federal=False):
+ # Clean up the test db
+ # I've found that it caches things in some contexts, so
+ # this is a rude/forceful way to make sure the only objects are the ones
+ # created during this run. Also makes sure that `.get()` operations do not fail
+ # on repeated testing runs.
+ for p in Permission.objects.all():
+ p.delete()
+ for a in Access.objects.all():
+ a.delete()
+ for up in UserPermission.objects.all():
+ up.delete()
+ for sac in SingleAuditChecklist.objects.all():
+ sac.delete()
+
+ if is_federal:
+ try:
+ rtp = Permission.objects.get(slug=Permission.PermissionType.READ_TRIBAL)
+ except Permission.DoesNotExist:
+ rtp = baker.make(Permission, slug=Permission.PermissionType.READ_TRIBAL)
+
+ # I need to be a valid user...
+ p = Params()
+ p.user = baker.make(User)
+
+ if is_federal:
+ print("Granting federal access")
+ baker.make(
+ UserPermission,
+ user=p.user,
+ permission=rtp,
+ )
+
+ p.sacs = setup_mock_db()
+
+ for sac in p.sacs:
+ # print(f"Creating access for user {p.user.id} report {sac.report_id}")
+ baker.make(Access, user=p.user, sac=sac)
+
+ p.client = Client()
+ p.audit_range = len(p.sacs) + 1
+ return p
+
+
+class CompareSubmissionsViewTests(TestCase):
+ view = "audit:CompareSubmissions"
+
+ def test_login_required(self):
+ """Check that login is required"""
+ p = setup_test()
+ p.client.force_login(user=p.user)
+ response = p.client.get(
+ reverse(
+ self.view,
+ kwargs={"report_id": "2025-01-FAKEDB-0000000002"},
+ ),
+ follow=True,
+ )
+ self.assertTemplateUsed(response, "audit/compare_submissions.html")
+
+ def test_phrase_in_page(self):
+ """Check for report ID in form."""
+ p = setup_test()
+ p.client.force_login(user=p.user)
+
+ # Three tests
+ # If we look for #2, we will default to comparing with its prev, which is 1
+ # If we look for 3, we default to its prev, which is 2
+ # If we look for #1, we have no prev, but we have a next, so we compare against #2
+ # This helps users get *something* back in most/all cases when in a resubmission chain.
+ # EG if you look for the first or last, you get *something* compared as a result.
+ for pair in [[1, 2], [2, 3], [2, 1]]:
+ res = p.client.get(
+ reverse(
+ self.view,
+ kwargs={"report_id": f"2025-01-FAKEDB-000000000{pair[1]}"},
+ ),
+ follow=True,
+ )
+ content = res.content.decode("utf-8")
+ self.assertIn(f"000000000{pair[0]}", content)
+ self.assertIn(f"000000000{pair[1]}", content)
+
+ def test_fail_without_access_to_audit(self):
+ """Check I cannot access a report if I don't have an access object"""
+ p = setup_test()
+
+ # This should delete the access objects
+ # That way, I cannot access anything in this test.
+ for a in Access.objects.all():
+ a.delete()
+
+ p.client.force_login(user=p.user)
+
+ # All of the test audits should fail, as we wiped out the access objects.
+ for counter in range(1, p.audit_range):
+ res = p.client.get(
+ reverse(
+ self.view,
+ kwargs={"report_id": f"2025-01-FAKEDB-000000000{counter}"},
+ ),
+ follow=True,
+ )
+ self.assertEqual(res.status_code, 403)
+
+ def test_feds_have_access(self):
+ """Check I cannot access a report if I don't have an access object"""
+ p = setup_test(is_federal=True)
+
+ # This should delete the access objects
+ # The only way I can have access is if it recognizes my Federal status
+ for a in Access.objects.all():
+ a.delete()
+
+ p.client.force_login(user=p.user)
+
+ # All of the audits should pass, becuase I am now a Federal user.
+ for counter in range(1, p.audit_range):
+ res = p.client.get(
+ reverse(
+ self.view,
+ kwargs={"report_id": f"2025-01-FAKEDB-000000000{counter}"},
+ ),
+ follow=True,
+ )
+ self.assertEqual(res.status_code, 200)
diff --git a/backend/audit/urls.py b/backend/audit/urls.py
index b1cdcb4f5b..a9376c8e75 100644
--- a/backend/audit/urls.py
+++ b/backend/audit/urls.py
@@ -135,6 +135,11 @@ def camel_to_hyphen(raw: str) -> str:
views.PredisseminationSummaryReportDownloadView.as_view(),
name="PredisseminationSummaryReportDownload",
),
+ path(
+ "compare/",
+ views.CompareSubmissionsView.as_view(),
+ name="CompareSubmissions",
+ ),
]
for form_section in FORM_SECTIONS:
diff --git a/backend/audit/viewlib/compare_two_submissions.py b/backend/audit/viewlib/compare_two_submissions.py
new file mode 100644
index 0000000000..9dff93d80f
--- /dev/null
+++ b/backend/audit/viewlib/compare_two_submissions.py
@@ -0,0 +1,537 @@
+from audit.models import SingleAuditChecklist, SingleAuditReportFile
+from copy import deepcopy
+import logging
+import boto3
+from io import BytesIO
+from botocore.exceptions import ClientError
+from django.conf import settings
+from hashlib import sha256
+from typing import Callable, Any, Union
+
+logger = logging.getLogger(__name__)
+
+#########################################
+# The first set of functions compare dictionary-based
+# fields from the SAC. This would be general_info and
+# audit_info, for example.
+
+
+# s1 ^ s2 is roughly (s1 - s2) U (s2 - s1) if needed.
+def in_first_not_second(d1: dict, d2: dict):
+ differences = []
+ d1 = d1 or {}
+ d2 = d2 or {}
+ for k, v in d1.items():
+ if k in d2:
+ continue
+ else: # k not in d2:
+ differences.append({"key": k, "from": v, "to": None})
+ return differences
+
+
+def in_second_not_first(d1: dict, d2: dict):
+ differences = []
+ d1 = d1 or {}
+ d2 = d2 or {}
+ for k, v in d2.items():
+ if k in d1:
+ continue
+ else: # k not in d1
+ differences.append({"key": k, "from": v, "to": None})
+ return differences
+
+
+def in_both(d1: dict, d2: dict):
+ both = []
+ d1 = d1 or {}
+ d2 = d2 or {}
+ for k in d1.keys():
+ if k in d2 and d1.get(k, None) == d2.get(k, None):
+ # Unchanged; skip
+ continue
+ elif k in d2 and d1.get(k, None) != d2.get(k, None):
+ both.append({"key": k, "from": d1[k], "to": d2[k]})
+ else:
+ continue
+ return both
+
+
+# These are dictionaries
+def analyze_pair(d1, d2):
+ # First, we find what is in one or the other.
+ fns = in_first_not_second(d1, d2)
+ snf = in_second_not_first(d1, d2)
+ both = in_both(d1, d2)
+ if fns == {} and snf == {} and both == {}:
+ return {"status": "same"}
+ return {
+ "status": "changed",
+ "in_r1": sorted(fns, key=lambda d: d["key"]),
+ "in_r2": sorted(snf, key=lambda d: d["key"]),
+ "in_both": sorted(both, key=lambda d: d["key"]),
+ }
+
+
+# Compare a given JSON field in the SAC object.
+def compare_dictionary_fields(
+ sac1: SingleAuditChecklist,
+ sac2: SingleAuditChecklist,
+ column: str,
+):
+ if getattr(sac1, column) == getattr(sac2, column):
+ return {"status": "same"}
+ else:
+ res = analyze_pair(
+ getattr_default(sac1, column, {}), getattr_default(sac2, column, {})
+ )
+ return res
+
+
+def getattr_default(obj, key, default=None):
+ try:
+ res = getattr(obj, key)
+ return res
+ except AttributeError:
+ return default
+
+
+def deep_getattr(o, lok, default=None):
+ oprime = deepcopy(o)
+ for ndx, key in enumerate(lok):
+ # print(f"{ndx+1} of {len(lok)} getting {key} in {oprime} {type(oprime)}")
+ if oprime is None:
+ return default
+ else:
+ if isinstance(oprime, dict):
+ oprime = oprime.get(key, default)
+ else:
+ try:
+ oprime = getattr(oprime, key)
+ except AttributeError:
+ oprime = default
+ return oprime
+
+
+def _get_keysets(sac1, sac2, keys):
+ # Use a list of keys to dive into an object.
+ # Expect a list of objects to come back, in this case.
+ # print(f"deep on {sac1}")
+ ls1 = deep_getattr(sac1, keys, [])
+ # print(f"deep on {sac2}")
+ ls2 = deep_getattr(sac2, keys, [])
+
+ # Hash the objects.
+ # Key order will matter. Here's to hoping
+ # our system is very consistent.
+ loh1 = map(lambda o: hash(str(o)), ls1)
+ loh2 = map(lambda o: hash(str(o)), ls2)
+
+ # Build a dict map of hashes to objects
+ map1 = {}
+ for h, o in zip(loh1, ls1):
+ map1[h] = o
+ map2 = {}
+ for h, o in zip(loh2, ls2):
+ map2[h] = o
+
+ # The keys can be sets
+ ks1 = set(map1.keys())
+ ks2 = set(map2.keys())
+ return ks1, ks2, map1, map2
+
+
+def _only_in(ks1, ks2, map1, map2, extract_fun, keys):
+ in_r1 = in_r2 = list()
+ # Keys only in ks1
+ only_in_1 = ks1 - ks2
+ # Keys only in ks2
+ only_in_2 = ks2 - ks1
+
+ res: dict[str, Union[str, list]] = {"status": "changed"}
+
+ for k in only_in_1:
+ in_r1.append({"from": None, "to": extract_fun(map1[k]), "key": keys[0]})
+ for k in only_in_2:
+ in_r2.append({"from": None, "to": extract_fun(map2[k]), "key": keys[0]})
+ return in_r1, in_r2, res
+
+
+def _filter_r1_r2(map1, map2, in_r1, in_r2, extract_fun, keys):
+ in_both = list()
+
+ # Finally, to find everything "in both", that means we need to find the things that changed
+ # from one to the other. To do that, we need two lists of the values.
+ ib_r1 = map1.values()
+ ib_r2 = map2.values()
+ # Now, I want to highlight where something was in both, but changed. Thsi is a list of objects,
+ # so the question is which *objects* changed.
+ for obj1, obj2 in zip(ib_r1, ib_r2):
+ if obj1 == obj2:
+ pass
+ else:
+ in_both.append(
+ {"from": extract_fun(obj1), "to": extract_fun(obj2), "key": keys[0]}
+ )
+
+ # At this point, if an object changed, we're going to have it in R1, R2, and in both.
+ # look for situations where the "to" value is the same in all three, and remove it from R1/R2
+ filter_out = list()
+ for v in in_both:
+ if v["to"] in map(lambda o: o["to"], in_r1) and v["to"] in map(
+ lambda o: o["to"], in_r1
+ ):
+ filter_out.append(v["to"])
+
+ in_r1 = list(filter(lambda v: v["to"] not in filter_out, in_r1))
+ in_r2 = list(filter(lambda v: v["to"] not in filter_out, in_r2))
+ return in_r1, in_r2, in_both
+
+
+def compare_lists_of_objects(
+ sac1: SingleAuditChecklist,
+ sac2: SingleAuditChecklist,
+ keys: list,
+ extract_fun: Callable[
+ [dict],
+ Any,
+ ],
+):
+ ks1, ks2, map1, map2 = _get_keysets(sac1, sac2, keys)
+
+ # If the maps are identical, we can just return now.
+ if map1 == map2:
+ return {"status": "same"}
+
+ in_r1, in_r2, res = _only_in(ks1, ks2, map1, map2, extract_fun, keys)
+ in_r1, in_r2, in_both = _filter_r1_r2(map1, map2, in_r1, in_r2, extract_fun, keys)
+
+ # Now, a final mangling of "in_both".
+ # This lets us present the data differently when something changes from one to the other and we're dealing with objects.
+ in_both = list()
+ for obj1, obj2 in zip(map1.values(), map2.values()):
+ if obj1 == obj2:
+ pass
+ else:
+ fields_different = list()
+ difference_count = 0
+ for k, v in obj1.items():
+ if obj1.get(k, 0) != obj2.get(k, 1):
+ difference_count += 1
+ fields_different.append(k)
+ in_both.append(
+ {
+ "from": "Related to: " + ", ".join(fields_different),
+ "to": (
+ f"{difference_count} difference"
+ if difference_count == 1
+ else f"{difference_count} difference"
+ ),
+ "key": extract_fun(obj1),
+ }
+ )
+
+ res["in_r1"] = in_r1
+ res["in_r2"] = in_r2
+ res["in_both"] = in_both
+
+ return res
+
+
+def get_s3_object(client, bucket_name, key):
+ file = BytesIO()
+ try:
+ client.download_fileobj(Bucket=bucket_name, Key=key, Fileobj=file)
+ except ClientError:
+ logger.error("Could not download {}".format(key))
+ return None
+ return file
+
+
+def compare_single_audit_reports(
+ sac1: SingleAuditChecklist, sac2: SingleAuditChecklist
+):
+ # I don't think we've ever grabbed PDFs from within an app/command before.
+ # FIXME: Should these be ordered, if there is more than one?
+ sar1 = SingleAuditReportFile.objects.filter(sac=sac1)
+ sar2 = SingleAuditReportFile.objects.filter(sac=sac2)
+
+ # Make sure they both exist/have PDFs associated with them.
+ if len(sar1) == 0:
+ return {"status": f"no single audit report found for {sac1.report_id}"}
+ if len(sar2) == 0:
+ return {"status": f"no single audit report found for {sac2.report_id}"}
+
+ # Use the first. Why? Why are there possibly multiple?
+ # I think there shouldn't be.
+ sar1 = sar1.first()
+ sar2 = sar2.first()
+
+ client = boto3.client(
+ "s3",
+ aws_access_key_id=settings.AWS_PRIVATE_ACCESS_KEY_ID,
+ aws_secret_access_key=settings.AWS_PRIVATE_SECRET_ACCESS_KEY,
+ endpoint_url=settings.AWS_S3_ENDPOINT_URL,
+ )
+
+ # logger.info(f"FETCHING PDF: {sar1.filename}")
+ pdf1 = get_s3_object(
+ client,
+ settings.AWS_PRIVATE_STORAGE_BUCKET_NAME,
+ f"singleauditreport/{sar1.filename}",
+ )
+
+ # logger.info(f"FETCHING PDF: {sar2.filename}")
+ pdf2 = get_s3_object(
+ client,
+ settings.AWS_PRIVATE_STORAGE_BUCKET_NAME,
+ f"singleauditreport/{sar2.filename}",
+ )
+
+ if pdf1 and pdf2:
+ pdf1_bytes = pdf1.getvalue()
+ pdf2_bytes = pdf2.getvalue()
+ sha1 = sha256(pdf1_bytes).hexdigest()
+ sha2 = sha256(pdf2_bytes).hexdigest()
+ # logger.info(f"SHA1: {sha1}:{len(pdf1_bytes)} SHA2: {sha2}:{len(pdf2_bytes)}")
+ if sha1 == sha2:
+ return {"status": "same"}
+ else:
+ return {
+ "status": "changed",
+ "in_r1": list(),
+ "in_r2": list(),
+ "in_both": [
+ {
+ "from": f"{sha1[0:4]}...{sha1[-4:]}",
+ "to": f"{sha2[0:4]}...{sha2[-4:]}",
+ "key": "SHA256",
+ },
+ {"from": len(pdf1_bytes), "to": len(pdf2_bytes), "key": "length"},
+ ],
+ }
+ elif not pdf1 and not pdf2:
+ return {
+ "status": "error",
+ "message": f"Could not retrieve report for {sac1.report_id} or {sac2.report_id}. Possibly contact the FAC helpdesk.",
+ }
+ elif not pdf1:
+ return {
+ "status": "error",
+ "message": f"Could not retrieve report for {sac1.report_id}. Possibly contact the FAC helpdesk.",
+ }
+ elif not pdf2:
+ return {
+ "status": "error",
+ "message": f"Could not retrieve report for {sac2.report_id}. Possibly contact the FAC helpdesk.",
+ }
+
+ return {
+ "status": "error",
+ "message": "Uknown error comparing PDF objects. Please contact the FAC helpdesk.",
+ }
+
+
+def report_id_to_sac(rid):
+ if isinstance(rid, str):
+ return SingleAuditChecklist.objects.get(report_id=rid)
+ elif isinstance(rid, SingleAuditChecklist):
+ return rid
+ else:
+ logger.error(f"{rid} is not a report_id string")
+ return None
+
+
+def are_two_sacs_identical(sac1, sac2):
+ fields = [
+ "submission_status",
+ "data_source",
+ # "transition_name",
+ # "transition_date",
+ "report_id",
+ "audit_type",
+ "general_information",
+ "audit_information",
+ "federal_awards",
+ "corrective_action_plan",
+ "findings_text",
+ "findings_uniform_guidance",
+ "additional_ueis",
+ "additional_eins",
+ "secondary_auditors",
+ "notes_to_sefa",
+ "tribal_data_consent",
+ "cognizant_agency",
+ "oversight_agency",
+ ]
+ they_are_the_same = True
+ for field in fields:
+ if getattr_default(sac1, field, None) != getattr_default(sac2, field, None):
+ they_are_the_same = False
+ break
+ return they_are_the_same
+
+
+# We want to take two report IDs, and return something that looks like
+#
+# {
+# "general": { "status": "same" }
+# "federal_awards": {
+# "status": "changed",
+# "r1_minus_r2": [...], # What is in (or changed in) r1 that is not in r2?
+# "r2_minus_r1": [...] # What is in (or changed in) r2 that is not in r1?
+# }
+#
+# Consider using deepdiff
+# https://miguendes.me/the-best-way-to-compare-two-dictionaries-in-python
+# This walks a JSON tree and finds the differences, and nicely spells them out.
+def compare_report_ids(rid_1, rid_2):
+ sac_r1 = report_id_to_sac(rid_1)
+ sac_r2 = report_id_to_sac(rid_2)
+ if sac_r1 is None or sac_r2 is None:
+ logger.error(
+ f"compare_report_ids expects two report ID strings or two SAC objects, given {sac_r1} and {sac_r2}"
+ )
+ return {
+ "status": "error",
+ "message": f"Could not compare {sac_r1} and {sac_r2} as given. Contact the FAC helpdesk.",
+ }
+
+ # Do an early check, and bail if the same.
+ if are_two_sacs_identical(sac_r1, sac_r2):
+ return {"status": "identical"}
+
+ summary = {}
+ ###############
+ # general_information
+ res = compare_dictionary_fields(sac_r1, sac_r2, "general_information")
+ summary["general_information"] = res
+
+ ###############
+ # audit_information
+ res = compare_dictionary_fields(sac_r1, sac_r2, "audit_information")
+ summary["audit_information"] = res
+
+ ###############
+ # all the forms that have lists of things.
+ accessors = [
+ [
+ ["federal_awards", "FederalAwards", "federal_awards"],
+ lambda entry: entry["award_reference"],
+ ],
+ [
+ [
+ "corrective_action_plan",
+ "CorrectiveActionPlan",
+ "corrective_action_plan_entries",
+ ],
+ lambda entry: entry["reference_number"],
+ ],
+ [
+ [
+ "findings_text",
+ "FindingsText",
+ "findings_text_entries",
+ ],
+ lambda entry: entry["reference_number"],
+ ],
+ [
+ [
+ "findings_uniform_guidance",
+ "FindingsUniformGuidance",
+ "findings_uniform_guidance_entries",
+ ],
+ lambda entry: entry["program"]["award_reference"]
+ + "/"
+ + entry["findings"]["reference_number"],
+ ],
+ [
+ [
+ "additional_ueis",
+ "AdditionalUeis",
+ "additional_ueis_entries",
+ ],
+ lambda entry: entry["additional_uei"],
+ ],
+ [
+ [
+ "additional_eins",
+ "AdditionalEINs",
+ "additional_eins_entries",
+ ],
+ lambda entry: entry["additional_ein"],
+ ],
+ [
+ [
+ "secondary_auditors",
+ "SecondaryAuditors",
+ "secondary_auditors_entries",
+ ],
+ lambda entry: entry["secondary_auditor_name"],
+ ],
+ [
+ [
+ "notes_to_sefa",
+ "NotesToSefa",
+ "notes_to_sefa_entries",
+ ],
+ lambda entry: str(entry["seq_number"]) + ": " + entry["note_title"],
+ ],
+ ]
+
+ for ls in accessors:
+ # logger.info(f"{ls[0][0]}")
+ res = compare_lists_of_objects(sac_r1, sac_r2, ls[0], ls[1])
+ summary[ls[0][0]] = res
+
+ ###############
+ # tribal_data_consent
+ res = compare_dictionary_fields(sac_r1, sac_r2, "tribal_data_consent")
+ summary["tribal_data_consent"] = res
+
+ ###############
+ # the SAR (single audit report, or PDF)
+ res = compare_single_audit_reports(sac_r1, sac_r2)
+ summary["single_audit_report"] = res
+
+ return summary
+
+
+def report_id_as_string(o):
+ if isinstance(o, SingleAuditChecklist):
+ return o.report_id
+ else:
+ return o
+
+
+def compare_with_prev(rid):
+ if isinstance(rid, str):
+ sac = SingleAuditChecklist.objects.get(report_id=rid)
+ elif isinstance(rid, SingleAuditChecklist):
+ sac = rid
+ else:
+ logger.error(f"{rid} is not a report ID or SAC object")
+ return {
+ "status": "error",
+ "message": f"It seems {rid} is not a report; if you think this is an error, please contact the FAC helpdesk.",
+ }
+
+ if sac.resubmission_meta:
+ if "previous_report_id" in sac.resubmission_meta:
+ prev = sac.resubmission_meta["previous_report_id"]
+ elif "next_report_id" in sac.resubmission_meta:
+ prev = sac.report_id
+ rid = sac.resubmission_meta["next_report_id"]
+ else:
+ logger.error(f"No previous report ID for {rid}")
+ return {
+ "status": "error",
+ "message": f"No previous report for {rid}. If this seems to be an error, contact the FAC helpdesk.",
+ }
+ logger.info(f"[DIFF] {prev} <-> {rid}")
+ return (
+ report_id_as_string(prev),
+ report_id_as_string(rid),
+ compare_report_ids(prev, rid),
+ )
+
+ return {"status": "error", "message": "No resubmission_meta in sac."}
diff --git a/backend/audit/views/__init__.py b/backend/audit/views/__init__.py
index 40741e46d2..e46dbd9098 100644
--- a/backend/audit/views/__init__.py
+++ b/backend/audit/views/__init__.py
@@ -36,6 +36,7 @@
from .tribal_data_consent import TribalDataConsent
from .upload_report_view import UploadReportView
from .unlock_after_certification import UnlockAfterCertificationView
+from .compare_submissions_view import CompareSubmissionsView
# In case we want to iterate through all the views for some reason:
views_list = [
@@ -70,4 +71,5 @@
TribalDataConsent,
UnlockAfterCertificationView,
UploadReportView,
+ CompareSubmissionsView,
]
diff --git a/backend/audit/views/compare_submissions_view.py b/backend/audit/views/compare_submissions_view.py
new file mode 100644
index 0000000000..1a72749f30
--- /dev/null
+++ b/backend/audit/views/compare_submissions_view.py
@@ -0,0 +1,98 @@
+from django.shortcuts import render
+from django.views import generic
+from django.core.exceptions import PermissionDenied
+from audit.viewlib.compare_two_submissions import compare_with_prev
+from audit.models import (
+ SingleAuditChecklist,
+ Access,
+)
+from audit.mixins import (
+ LoginRequiredMixin,
+)
+from users.models import UserPermission
+
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+# http://localhost:8000/audit/compare/2023-06-GSAFAC-0000000697
+# http://localhost:8000/audit/compare/2023-06-GSAFAC-0000002166
+# http://localhost:8000/audit/compare/2022-12-GSAFAC-0000001787
+# http://localhost:8000/audit/compare/2023-06-GSAFAC-0000002901
+# http://localhost:8000/audit/compare/2023-06-GSAFAC-0000013043
+# http://localhost:8000/audit/compare/2023-06-GSAFAC-0000005147
+# http://localhost:8000/audit/compare/2023-06-GSAFAC-0000001699
+# http://localhost:8000/audit/compare/2022-12-GSAFAC-0000007921
+
+
+class CompareSubmissionsView(LoginRequiredMixin, generic.View):
+
+ def get(self, request, *args, **kwargs):
+ report_id = kwargs["report_id"]
+ current_user = request.user
+
+ try:
+ sac_1 = SingleAuditChecklist.objects.get(report_id=report_id)
+ except SingleAuditChecklist.DoesNotExist as err:
+ raise PermissionDenied(f"Cannot find report id {report_id}") from err
+
+ # First, find out if we should bother.
+ # FIXME: Can we pass more information back? The 403 does not answer "why."
+ if sac_1.resubmission_meta is None:
+ raise PermissionDenied("There is no prior submission for this audit.")
+ # We will accept the "next" audit, because compare_with_prev can figure out
+ # which audit to compare to which, in order to be more forgiving.
+ if (
+ "previous_report_id" not in sac_1.resubmission_meta
+ and "next_report_id" not in sac_1.resubmission_meta
+ ):
+ raise PermissionDenied("There is nothing to compare this audit with.")
+
+ #############################################
+ # We are doing the permissions checking here instead of a mixin.
+ # Why? Becuase we do not have conditional mixins.
+ # To start, you must be logged in.
+ #
+ # Once logged in, all
+ # federal users can see all differences.
+ #
+ # For all other users, they must be associated with the audit.
+ # Get the accesses for this SAC
+
+ accesses = Access.objects.filter(sac=sac_1)
+ user_ids_on_audit = list(map(lambda acc: acc.user_id, accesses))
+
+ is_authenticated = current_user.is_authenticated
+ is_on_audit = current_user.id in user_ids_on_audit
+ is_federal_user = UserPermission.objects.filter(
+ user_id=current_user.id
+ ).exists()
+
+ logger.info(
+ f"[DIFF] {current_user.id} {sac_1.report_id} {is_authenticated} AND ({is_on_audit} OR {is_federal_user})"
+ )
+
+ # If I am attached to this report, I can see the diff.
+ if is_authenticated and is_on_audit:
+ logger.debug("Authenticated as a user on the audit")
+ pass
+ # If I am a Federal user, I can see the diff
+ elif is_authenticated and is_federal_user:
+ logger.debug("Authenticated as a federal user")
+ pass
+ else:
+ raise PermissionDenied("You do not have access to this comparison page.")
+
+ # We get here if we passed one of the above conditions.
+ report_id_1, report_id_2, compared = compare_with_prev(sac_1)
+ context = {"comparison": compared}
+ nice_names = {}
+ for k in compared.keys():
+ nice_names[k] = k.replace("_", " ").title()
+
+ context = context | {"nice_names": nice_names}
+ context = context | {"r1": report_id_1}
+ context = context | {"r2": report_id_2}
+
+ return render(request, "audit/compare_submissions.html", context)
diff --git a/backend/audit/views/submission_progress_view.py b/backend/audit/views/submission_progress_view.py
index 549e8afea9..b992df6e71 100644
--- a/backend/audit/views/submission_progress_view.py
+++ b/backend/audit/views/submission_progress_view.py
@@ -198,6 +198,7 @@ def get(self, request, *args, **kwargs):
"previous_report_id": (
sac.resubmission_meta["previous_report_id"]
if sac.resubmission_meta
+ and "previous_report_id" in sac.resubmission_meta
else None
),
}
diff --git a/backend/templates/includes/resubmission_banner.html b/backend/templates/includes/resubmission_banner.html
deleted file mode 100644
index b43cae8298..0000000000
--- a/backend/templates/includes/resubmission_banner.html
+++ /dev/null
@@ -1,9 +0,0 @@
-
-