diff --git a/backend/audit/file_downloads.py b/backend/audit/file_downloads.py
new file mode 100644
index 0000000000..0c57564c9a
--- /dev/null
+++ b/backend/audit/file_downloads.py
@@ -0,0 +1,74 @@
+import logging
+
+from django.conf import settings
+from django.http import Http404
+from django.shortcuts import get_object_or_404
+
+from boto3 import client as boto3_client
+from botocore.client import ClientError, Config
+
+from audit.models import ExcelFile, SingleAuditReportFile
+
+logger = logging.getLogger(__name__)
+
+
+def get_filename(sac, file_type):
+ if file_type == "report":
+ file_obj = get_object_or_404(SingleAuditReportFile, sac=sac)
+ return f"singleauditreport/{file_obj.filename}"
+ else:
+ file_obj = get_object_or_404(ExcelFile, sac=sac, form_section=file_type)
+ return f"excel/{file_obj.filename}"
+
+
+def file_exists(filename):
+ # this client uses the internal endpoint url because we're making a request to S3 from within the app
+ s3_client = boto3_client(
+ service_name="s3",
+ region_name=settings.AWS_S3_PRIVATE_REGION_NAME,
+ 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_PRIVATE_ENDPOINT,
+ config=Config(signature_version="s3v4"),
+ )
+
+ try:
+ s3_client.head_object(
+ Bucket=settings.AWS_PRIVATE_STORAGE_BUCKET_NAME,
+ Key=filename,
+ )
+
+ return True
+ except ClientError:
+ logger.warn(f"Unable to locate file {filename} in S3!")
+ return False
+
+
+def get_download_url(filename):
+ try:
+ # this client uses the external endpoint url because we're generating a request URL that is eventually triggered from outside the app
+ s3_client = boto3_client(
+ service_name="s3",
+ region_name=settings.AWS_S3_PRIVATE_REGION_NAME,
+ 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_PRIVATE_EXTERNAL_ENDPOINT,
+ config=Config(signature_version="s3v4"),
+ )
+
+ if file_exists(filename):
+ response = s3_client.generate_presigned_url(
+ ClientMethod="get_object",
+ Params={
+ "Bucket": settings.AWS_PRIVATE_STORAGE_BUCKET_NAME,
+ "Key": filename,
+ "ResponseContentDisposition": f"attachment;filename={filename}",
+ },
+ ExpiresIn=30,
+ )
+
+ return response
+ else:
+ raise Http404("File not found")
+ except ClientError:
+ raise Http404("File not found")
diff --git a/backend/config/settings.py b/backend/config/settings.py
index 0c4c26456a..a291be178a 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -257,6 +257,7 @@
)
AWS_S3_ENDPOINT_URL = AWS_S3_PRIVATE_ENDPOINT
+ AWS_S3_PRIVATE_EXTERNAL_ENDPOINT = "http://localhost:9001"
DISABLE_AUTH = env.bool("DISABLE_AUTH", default=False)
@@ -308,6 +309,7 @@
AWS_S3_PRIVATE_ENDPOINT = s3_creds["endpoint"]
AWS_S3_ENDPOINT_URL = f"https://{AWS_S3_PRIVATE_ENDPOINT}"
+ AWS_S3_PRIVATE_EXTERNAL_ENDPOINT = AWS_S3_ENDPOINT_URL
AWS_PRIVATE_LOCATION = "static"
AWS_PRIVATE_DEFAULT_ACL = "private"
diff --git a/backend/config/urls.py b/backend/config/urls.py
index cef595ada0..79e5a8eae8 100644
--- a/backend/config/urls.py
+++ b/backend/config/urls.py
@@ -79,6 +79,7 @@
name="sprite",
),
path("audit/", include("audit.urls")),
+ path("dissemination/", include("dissemination.urls")),
# Keep last so we can use short urls for content pages like home page etc.
path("", include("cms.urls")),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
diff --git a/backend/dissemination/forms.py b/backend/dissemination/forms.py
new file mode 100644
index 0000000000..bc3e41673e
--- /dev/null
+++ b/backend/dissemination/forms.py
@@ -0,0 +1,16 @@
+from django import forms
+
+
+class SearchForm(forms.Form):
+ AY_choices = (
+ (x, str(x)) for x in range(2016, 2024)
+ ) # ((2016, "2016"), (2017, "2017"), ..., (2023, "2023"))
+
+ entity_name = forms.CharField(required=False)
+ uei_or_ein = forms.CharField(required=False)
+ aln = forms.CharField(required=False)
+ start_date = forms.DateField(required=False)
+ end_date = forms.DateField(required=False)
+ cog_or_oversight = forms.CharField(required=False)
+ agency_name = forms.CharField(required=False)
+ audit_year = forms.MultipleChoiceField(choices=AY_choices, required=False)
diff --git a/backend/dissemination/search.py b/backend/dissemination/search.py
new file mode 100644
index 0000000000..17c0881afc
--- /dev/null
+++ b/backend/dissemination/search.py
@@ -0,0 +1,51 @@
+from django.db.models import Q
+
+from dissemination.models import General
+
+
+def search_general(
+ names=None,
+ uei_or_eins=None,
+ start_date=None,
+ end_date=None,
+ cog_or_oversight=None,
+ agency_name=None,
+ audit_years=None,
+):
+ query = Q(is_public=True)
+
+ # TODO: use something like auditee_name__contains
+ # SELECT * WHERE auditee_name LIKE '%SomeString%'
+ if names:
+ names_match = Q(Q(auditee_name__in=names) | Q(auditor_firm_name__in=names))
+ query.add(names_match, Q.AND)
+
+ if uei_or_eins:
+ uei_or_ein_match = Q(
+ Q(auditee_uei__in=uei_or_eins) | Q(auditee_ein__in=uei_or_eins)
+ )
+ query.add(uei_or_ein_match, Q.AND)
+
+ if start_date:
+ start_date_match = Q(fac_accepted_date__gte=start_date)
+ query.add(start_date_match, Q.AND)
+
+ if end_date:
+ end_date_match = Q(fac_accepted_date__lte=end_date)
+ query.add(end_date_match, Q.AND)
+
+ if cog_or_oversight:
+ if cog_or_oversight.lower() == "cog":
+ cog_match = Q(cognizant_agency__in=[agency_name])
+ query.add(cog_match, Q.AND)
+ elif cog_or_oversight.lower() == "oversight":
+ oversight_match = Q(oversight_agency__in=[agency_name])
+ query.add(oversight_match, Q.AND)
+
+ if audit_years:
+ fiscal_year_match = Q(audit_year__in=audit_years)
+ query.add(fiscal_year_match, Q.AND)
+
+ results = General.objects.filter(query)
+
+ return results
diff --git a/backend/dissemination/templates/search.html b/backend/dissemination/templates/search.html
new file mode 100644
index 0000000000..e0308a37c6
--- /dev/null
+++ b/backend/dissemination/templates/search.html
@@ -0,0 +1,252 @@
+{% extends "base.html" %}
+{% load static %}
+{% load sprite_helper %}
+{% block content %}
+
+
+
+
+
Search single audit reports
+ {% if results %}
+
+
+ Results {{ results|length }}
+
+
+
+ Name |
+ UEI or EIN |
+ Acc Date |
+ AY |
+ Cog or Over |
+ View |
+ PDF |
+
+
+
+ {% for result in results %}
+
+ {{ result.auditee_name }} |
+ {{ result.auditee_uei }} |
+ {{ result.fac_accepted_date }} |
+ {{ result.audit_year }} |
+
+ {% if result.oversight_agency %}
+ Oversight
+ {% else %}
+ Cognizant
+ {% endif %}
+ |
+
+
+
+
+ |
+
+
+
+
+ |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
+ Enter your filters and select Search to begin
+
+
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/backend/dissemination/templates/summary.html b/backend/dissemination/templates/summary.html
new file mode 100644
index 0000000000..29826ea9f2
--- /dev/null
+++ b/backend/dissemination/templates/summary.html
@@ -0,0 +1,78 @@
+{% extends "base.html" %}
+{% load static %}
+{% load field_name_to_label %}
+{% block content %}
+
+
+
+
+
+ {% comment %} General Information {% endcomment %}
+
+
{{ auditee_name }} ({{ auditee_uei }})
+
+ Report ID:{{ report_id }}
+
+
+ Submission date:{{ general.submitted_date }}
+
+
+ Fiscal Year:{{ general.fy_start_date }} to {{ general.fy_end_date }}
+
+
+
+ {% for section_key, section_list in data.items %}
+
+
+ {{ section_key }}
+ |
+ {{ section_list|length }}
+
+
+
+ {% endfor %}
+
+
+
+
+ {% include "audit-metadata.html" %}
+{% endblock content %}
diff --git a/backend/dissemination/templatetags/__init__.py b/backend/dissemination/templatetags/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/backend/dissemination/templatetags/field_name_to_label.py b/backend/dissemination/templatetags/field_name_to_label.py
new file mode 100644
index 0000000000..048930ba99
--- /dev/null
+++ b/backend/dissemination/templatetags/field_name_to_label.py
@@ -0,0 +1,13 @@
+"""
+Custom tag to filter underscores out of strings and capitalize the first character.
+Example: "report_id" to "Report id"
+"""
+from django import template
+
+register = template.Library()
+
+
+@register.filter()
+def field_name_to_label(value):
+ value = value.replace("_", " ")
+ return value.title()
diff --git a/backend/dissemination/test_search.py b/backend/dissemination/test_search.py
new file mode 100644
index 0000000000..57fc7ef725
--- /dev/null
+++ b/backend/dissemination/test_search.py
@@ -0,0 +1,229 @@
+from django.test import TestCase
+
+from dissemination.models import General
+from dissemination.search import search_general
+
+from model_bakery import baker
+
+import datetime
+import random
+
+
+def assert_all_results_public(cls, results):
+ for r in results:
+ cls.assertTrue(r.is_public)
+
+
+class SearchGeneralTests(TestCase):
+ def test_empty_query(self):
+ """
+ Given empty query parameters, search_general should return all public records
+ """
+ public_count = random.randint(50, 100)
+ private_count = random.randint(50, 100)
+
+ baker.make(General, is_public=True, _quantity=public_count)
+ baker.make(General, is_public=False, _quantity=private_count)
+
+ results = search_general()
+
+ assert_all_results_public(self, results)
+ self.assertEqual(len(results), public_count)
+
+ def test_name_matches_auditee_name(self):
+ """
+ Given an entity name, search_general should return records with a matching auditee_name
+ """
+ auditee_name = "auditeeeeeeee"
+ baker.make(General, is_public=True, auditee_name=auditee_name)
+
+ results = search_general(
+ names=[auditee_name],
+ )
+
+ assert_all_results_public(self, results)
+ self.assertEqual(len(results), 1)
+
+ def test_name_matches_auditor_firm_name(self):
+ """
+ Given an entity name, search_general should return records with a matching auditor_firm_name
+ """
+ auditor_firm_name = "auditoooooooor"
+ baker.make(General, is_public=True, auditor_firm_name=auditor_firm_name)
+
+ results = search_general(
+ names=[auditor_firm_name],
+ )
+
+ assert_all_results_public(self, results)
+ self.assertEqual(len(results), 1)
+
+ def test_name_multiple(self):
+ """
+ Given multiple names, search_general should return records that match either name
+ """
+ names = [
+ "auditee-01",
+ "auditor-firm-01",
+ "this-one-has-no-match",
+ ]
+
+ baker.make(General, is_public=True, auditee_name=names[0])
+ baker.make(General, is_public=True, auditor_firm_name=names[1])
+ baker.make(General, is_public=True, auditee_name="not-looking-for-this-auditee")
+ baker.make(
+ General, is_public=True, auditor_firm_name="not-looking-for-this-auditor"
+ )
+
+ results = search_general(
+ names=names,
+ )
+
+ assert_all_results_public(self, results)
+ self.assertEqual(len(results), 2)
+
+ def test_uei_or_ein_matches_uei(self):
+ """
+ Given a uei_or_ein, search_general should return records with a matching UEI
+ """
+ auditee_uei = "ABCDEFGHIJKL"
+ baker.make(General, is_public=True, auditee_uei=auditee_uei)
+
+ results = search_general(
+ uei_or_eins=[auditee_uei],
+ )
+
+ assert_all_results_public(self, results)
+ self.assertEqual(len(results), 1)
+
+ def test_uei_or_ein_matches_ein(self):
+ """
+ Given a uei_or_ein, search_general should return records with a matching EIN
+ """
+ auditee_ein = "ABCDEFGHIJKL"
+ baker.make(General, is_public=True, auditee_ein=auditee_ein)
+
+ results = search_general(
+ uei_or_eins=[auditee_ein],
+ )
+
+ assert_all_results_public(self, results)
+ self.assertEqual(len(results), 1)
+
+ def test_uei_or_ein_multiple(self):
+ """
+ Given multiple uei_or_eins, search_general should return records that match either UEI or EIN
+ """
+ uei_or_eins = [
+ "ABCDEFGH0001",
+ "ABCDEFGH0002",
+ "ABCDEFGH0003",
+ ]
+
+ baker.make(General, is_public=True, auditee_uei=uei_or_eins[0])
+ baker.make(General, is_public=True, auditee_ein=uei_or_eins[1])
+ baker.make(General, is_public=True, auditee_uei="not-looking-for-this-uei")
+ baker.make(General, is_public=True, auditee_ein="not-looking-for-this-ein")
+
+ results = search_general(
+ uei_or_eins=uei_or_eins,
+ )
+
+ assert_all_results_public(self, results)
+ self.assertEqual(len(results), 2)
+
+ def test_date_range(self):
+ """
+ Given a start and end date, search_general should return only records inside the date range
+ """
+
+ # seed the database with one record for each day of June
+ seed_start_date = datetime.date(2023, 6, 1)
+ seed_end_date = datetime.date(2023, 6, 30)
+
+ d = seed_start_date
+ while d <= seed_end_date:
+ baker.make(General, is_public=True, fac_accepted_date=d)
+ d += datetime.timedelta(days=1)
+
+ # search for records between June 10 and June 15
+ search_start_date = datetime.date(2023, 6, 10)
+ search_end_date = datetime.date(2023, 6, 15)
+
+ results = search_general(
+ start_date=search_start_date,
+ end_date=search_end_date,
+ )
+
+ assert_all_results_public(self, results)
+
+ # we should get 6 results, one for each day between June 10-15
+ self.assertEqual(len(results), 6)
+
+ for r in results:
+ self.assertGreaterEqual(r.fac_accepted_date, search_start_date)
+ self.assertLessEqual(r.fac_accepted_date, search_end_date)
+
+ def test_cognizant_agency(self):
+ """
+ Given a cognizant agency name, search_general should return only records with a matching cognizant agency name (not oversight)
+ """
+
+ baker.make(General, is_public=True, cognizant_agency="01")
+ baker.make(General, is_public=True, cognizant_agency="02")
+
+ baker.make(General, is_public=True, oversight_agency="01")
+
+ results = search_general(
+ cog_or_oversight="cog",
+ agency_name="01",
+ )
+
+ assert_all_results_public(self, results)
+ self.assertEqual(len(results), 1)
+ self.assertEqual(results[0].cognizant_agency, "01")
+
+ def test_oversight_agency(self):
+ """
+ Given an oversight agency name, search_general should return only records with a matching oversight agency name (not cognizant)
+ """
+
+ baker.make(General, is_public=True, cognizant_agency="01")
+
+ baker.make(General, is_public=True, oversight_agency="01")
+ baker.make(General, is_public=True, oversight_agency="02")
+
+ results = search_general(
+ cog_or_oversight="cog",
+ agency_name="01",
+ )
+
+ assert_all_results_public(self, results)
+ self.assertEqual(len(results), 1)
+ self.assertEqual(results[0].cognizant_agency, "01")
+
+ def test_audit_year(self):
+ """
+ Given a list of audit years, search_general should return only records where
+ audit_year is one of the given years.
+ """
+ baker.make(General, is_public=True, audit_year="2020")
+ baker.make(General, is_public=True, audit_year="2021")
+ baker.make(General, is_public=True, audit_year="2022")
+
+ results = search_general(
+ audit_years=[2016],
+ )
+ self.assertEqual(len(results), 0)
+
+ results = search_general(
+ audit_years=[2020],
+ )
+ self.assertEqual(len(results), 1)
+
+ results = search_general(
+ audit_years=[2020, 2021, 2022],
+ )
+ self.assertEqual(len(results), 3)
+
+ return
diff --git a/backend/dissemination/tests.py b/backend/dissemination/tests.py
index e1ff36cd3e..92b72c0807 100644
--- a/backend/dissemination/tests.py
+++ b/backend/dissemination/tests.py
@@ -1,13 +1,24 @@
-from django.test import TestCase
-
-import os
from datetime import datetime
-
-
import jwt
+import os
import requests
+from model_bakery import baker
+
+from django.test import Client, TestCase
+from django.urls import reverse
+
from config import settings
+from dissemination.templatetags.field_name_to_label import field_name_to_label
+from dissemination.models import (
+ General,
+ FederalAward,
+ Passthrough,
+ Finding,
+ FindingText,
+ CapText,
+ Note,
+)
class APIViewTests(TestCase):
@@ -60,3 +71,93 @@ def test_api_fails_with_wrong_role(self):
self.api_url, headers={"Authorization": f"Bearer {encoded_jwt}"}, timeout=10
)
self.assertEquals(response.status_code, 400)
+
+
+class TemplateTagTests(TestCase):
+ def test_field_name_to_label(self):
+ """
+ Given a field name with underscores like "report_id", it should be converted
+ to display like "Report Id"
+ """
+ sample_field = "report_id"
+ converted_sample_field = field_name_to_label(sample_field)
+ self.assertEquals(converted_sample_field, "Report Id")
+
+ sample_field = "auditee_contact_title"
+ converted_sample_field = field_name_to_label(sample_field)
+ self.assertEquals(converted_sample_field, "Auditee Contact Title")
+
+
+class SummaryViewTests(TestCase):
+ def setUp(self):
+ self.client = Client()
+
+ def test_public_summary(self):
+ """
+ A public audit should have a viewable summary, and returns 200.
+ """
+ baker.make(General, report_id="2022-12-GSAFAC-0000000001", is_public=True)
+ url = reverse(
+ "dissemination:Summary", kwargs={"report_id": "2022-12-GSAFAC-0000000001"}
+ )
+
+ response = self.client.get(url)
+ self.assertEquals(response.status_code, 200)
+
+ def test_private_summary(self):
+ """
+ A private audit should not have a viewable summary, and returns 404.
+ """
+ baker.make(General, report_id="2022-12-GSAFAC-0000000001", is_public=False)
+ url = reverse(
+ "dissemination:Summary", kwargs={"report_id": "2022-12-GSAFAC-0000000001"}
+ )
+
+ response = self.client.get(url)
+ self.assertEquals(response.status_code, 404)
+
+ def test_summary_context(self):
+ """
+ The summary context should include the same data that is in the models.
+ Create a bunch of fake DB data under the same report_id. Then, check a few
+ fields in the context for the summary page to verify that the fake data persists.
+ """
+ baker.make(General, report_id="2022-12-GSAFAC-0000000001", is_public=True)
+ award = baker.make(FederalAward, report_id="2022-12-GSAFAC-0000000001")
+ passthrough = baker.make(Passthrough, report_id="2022-12-GSAFAC-0000000001")
+ finding = baker.make(Finding, report_id="2022-12-GSAFAC-0000000001")
+ finding_text = baker.make(FindingText, report_id="2022-12-GSAFAC-0000000001")
+ cap_text = baker.make(CapText, report_id="2022-12-GSAFAC-0000000001")
+ note = baker.make(Note, report_id="2022-12-GSAFAC-0000000001")
+
+ url = reverse(
+ "dissemination:Summary", kwargs={"report_id": "2022-12-GSAFAC-0000000001"}
+ )
+
+ response = self.client.get(url)
+ self.assertEquals(
+ response.context["data"]["Awards"][0]["additional_award_identification"],
+ award.additional_award_identification,
+ )
+ self.assertEquals(
+ response.context["data"]["Passthrough Entities"][0]["award_reference"],
+ passthrough.award_reference,
+ )
+ self.assertEquals(
+ response.context["data"]["Audit Findings"][0]["reference_number"],
+ finding.reference_number,
+ )
+ self.assertEquals(
+ response.context["data"]["Audit Findings Text"][0]["finding_ref_number"],
+ finding_text.finding_ref_number,
+ )
+ self.assertEquals(
+ response.context["data"]["Corrective Action Plan"][0][
+ "contains_chart_or_table"
+ ],
+ cap_text.contains_chart_or_table,
+ )
+ self.assertEquals(
+ response.context["data"]["Notes"][0]["accounting_policies"],
+ note.accounting_policies,
+ )
diff --git a/backend/dissemination/urls.py b/backend/dissemination/urls.py
new file mode 100644
index 0000000000..aeddd8c803
--- /dev/null
+++ b/backend/dissemination/urls.py
@@ -0,0 +1,11 @@
+from django.urls import path
+
+from dissemination import views
+
+app_name = "dissemination"
+
+urlpatterns = [
+ path("pdf/", views.PdfDownloadView.as_view(), name="PdfDownload"),
+ path("search/", views.Search.as_view(), name="Search"),
+ path("summary/", views.AuditSummaryView.as_view(), name="Summary"),
+]
diff --git a/backend/dissemination/views.py b/backend/dissemination/views.py
index fd0e044955..9b8744ff40 100644
--- a/backend/dissemination/views.py
+++ b/backend/dissemination/views.py
@@ -1,3 +1,145 @@
-# from django.shortcuts import render
+from django.core.exceptions import PermissionDenied
+from django.http import Http404
+from django.shortcuts import get_object_or_404, redirect, render
+from django.views.generic import View
-# Create your views here.
+from audit.file_downloads import get_download_url, get_filename
+from audit.models import SingleAuditChecklist
+
+from dissemination.forms import SearchForm
+from dissemination.search import search_general
+from dissemination.models import (
+ General,
+ FederalAward,
+ Passthrough,
+ Finding,
+ FindingText,
+ CapText,
+ Note,
+)
+
+
+class Search(View):
+ def get(self, request, *args, **kwargs):
+ form = SearchForm()
+
+ return render(request, "search.html", {"form": form})
+
+ def post(self, request, *args, **kwargs):
+ form = SearchForm(request.POST)
+ results = []
+
+ if form.is_valid():
+ names = form.cleaned_data["entity_name"].splitlines()
+ uei_or_eins = form.cleaned_data["uei_or_ein"].splitlines()
+ start_date = form.cleaned_data["start_date"]
+ end_date = form.cleaned_data["end_date"]
+ cog_or_oversight = form.cleaned_data["cog_or_oversight"]
+ agency_name = form.cleaned_data["agency_name"]
+ audit_years = [
+ int(year) for year in form.cleaned_data["audit_year"]
+ ] # Cast strings from HTML to int
+
+ results = search_general(
+ names,
+ uei_or_eins,
+ start_date,
+ end_date,
+ cog_or_oversight,
+ agency_name,
+ audit_years,
+ )
+ # Reformat these so the date-picker elements in HTML prepopulate
+ if form.cleaned_data["start_date"]:
+ form.cleaned_data["start_date"] = start_date.strftime("%Y-%m-%d")
+ if form.cleaned_data["end_date"]:
+ form.cleaned_data["end_date"] = end_date.strftime("%Y-%m-%d")
+
+ return render(request, "search.html", {"form": form, "results": results})
+
+
+class AuditSummaryView(View):
+ def get(self, request, report_id):
+ """
+ Grab any information about the given report in the dissemination tables.
+ 1. See if this audit is available in the dissemination tables. If not, 404.
+ 2. Grab all relevant info from dissem tables. Some items may not exist if they had no findings.
+ 3. Wrap up the data all nice in a context object for display.
+ """
+
+ # Viewable audits __MUST__ be public.
+ general = General.objects.filter(report_id=report_id, is_public=True)
+ if not general.exists():
+ raise Http404(
+ "The report with this ID does not exist in the dissemination database."
+ )
+ general_data = general.values()[0]
+ del general_data["id"]
+
+ data = self.get_audit_content(report_id)
+
+ # Add entity name and UEI to the context, for the footer bit.
+ context = {
+ "report_id": report_id,
+ "auditee_name": general_data["auditee_name"],
+ "auditee_uei": general_data["auditee_uei"],
+ "general": general_data,
+ "data": data,
+ }
+
+ return render(request, "summary.html", context)
+
+ def get_audit_content(self, report_id):
+ """
+ Grab everything relevant from the dissemination tables.
+ Wrap that data into a dict, and return it.
+ We may want to define additional functions to squish this information down
+ further. I.e. remove DB ids or something.
+ """
+ awards = FederalAward.objects.filter(report_id=report_id)
+ passthrough_entities = Passthrough.objects.filter(report_id=report_id)
+ audit_findings = Finding.objects.filter(report_id=report_id)
+ audit_findings_text = FindingText.objects.filter(report_id=report_id)
+ corrective_action_plan = CapText.objects.filter(report_id=report_id)
+ notes_to_sefa = Note.objects.filter(report_id=report_id)
+
+ data = {}
+
+ data["Awards"] = [
+ x for x in awards.values()
+ ] # Take QuerySet to a list of objects
+
+ if passthrough_entities.exists():
+ data["Passthrough Entities"] = [x for x in passthrough_entities.values()]
+ if audit_findings.exists():
+ data["Audit Findings"] = [x for x in audit_findings.values()]
+ if audit_findings_text.exists():
+ data["Audit Findings Text"] = [x for x in audit_findings_text.values()]
+ if corrective_action_plan.exists():
+ data["Corrective Action Plan"] = [
+ x for x in corrective_action_plan.values()
+ ]
+ if notes_to_sefa.exists():
+ data["Notes"] = [x for x in notes_to_sefa.values()]
+
+ for key in data:
+ for item in data[key]:
+ del item["id"]
+ del item["report_id"]
+
+ return data
+
+
+class PdfDownloadView(View):
+ def get(self, request, report_id):
+ # only allow PDF downloads for disseminated submissions
+ disseminated = get_object_or_404(General, report_id=report_id)
+
+ # only allow PDF downloads for public submissions
+ if not disseminated.is_public:
+ raise PermissionDenied("You do not have access to this audit report.")
+
+ sac = get_object_or_404(SingleAuditChecklist, report_id=report_id)
+ filename = get_filename(sac, "report")
+
+ return redirect(get_download_url(filename))
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 076a6c367e..97858ef217 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -39,6 +39,7 @@ exclude_dirs = [
"audit/test_schemas.py",
"audit/test_validators.py",
"node_modules",
+ "dissemination/test_search.py",
"dissemination/tests.py",
]
diff --git a/backend/static/img/circle-arrow.svg b/backend/static/img/circle-arrow.svg
new file mode 100644
index 0000000000..8cda4f3e3a
--- /dev/null
+++ b/backend/static/img/circle-arrow.svg
@@ -0,0 +1,4 @@
+
diff --git a/backend/static/scss/_search.scss b/backend/static/scss/_search.scss
new file mode 100644
index 0000000000..c1ebae8234
--- /dev/null
+++ b/backend/static/scss/_search.scss
@@ -0,0 +1,64 @@
+.audit-search-form {
+ padding: 1em;
+ background-color: #efefef;
+
+ h3 {
+ background-image: url('../img/usa-icons/filter_alt.svg');
+ background-repeat: no-repeat;
+ color: #005ea2;
+ padding-left: 1.5em;
+ background-position: 0 75%;
+ }
+
+ .usa-accordion__button {
+ background-color: #efefef;
+ }
+
+ .search-submit,
+ .usa-accordion__button {
+ border-top: 1px solid #a9aeb1;
+ margin-top: 2em;
+ padding-top: 1em;
+ }
+
+ .usa-accordion__button,
+ .usa-accordion__button:hover {
+ background-image: url('../img/usa-icons/arrow_drop_down.svg');
+ }
+
+ .usa-accordion__button[aria-expanded='false'],
+ .usa-accordion__button[aria-expanded='false']:hover {
+ background-image: url('../img/usa-icons/arrow_drop_up.svg');
+ }
+}
+
+@media (min-width: 30em) {
+ .search-submit .usa-button {
+ display: block;
+ text-align: center;
+ width: 100%;
+ }
+}
+
+.audit-search-results {
+ padding: 1em 0 0 3em;
+
+ h2 {
+ border-bottom: 1px solid #a9aeb1;
+ padding-bottom: 1em;
+ }
+
+ .search-instructions {
+ color: #a4a7ac;
+ font-size: 2em;
+ margin: 0 auto;
+ padding-top: 3em;
+ text-align: center;
+ width: 60%;
+
+ em {
+ font-style: normal;
+ font-weight: 800;
+ }
+ }
+}
diff --git a/backend/static/scss/main.scss b/backend/static/scss/main.scss
index 450ce9df8d..7bebb9a9bc 100644
--- a/backend/static/scss/main.scss
+++ b/backend/static/scss/main.scss
@@ -11,4 +11,5 @@
@use '_home.scss';
@use '_header.scss';
@use '_form.scss';
+@use '_search.scss';
@use '_workbook-upload.scss';