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 %} +
+
+
+

Filters

+
+ {% csrf_token %} + {% comment %} Submission {% endcomment %} +
+ + +
+
+ {% comment %} UEI/EIN {% endcomment %} + + + {% comment %} ALN/CFDA {% endcomment %} + + + {% comment %} Name {% endcomment %} + + + {% comment %} Fiscal Year {% endcomment %} + +
+ {% for value, text in form.audit_year.field.choices %} +
+ + +
+ {% endfor %} +
+ {% comment %} Release Date(s) {% endcomment %} + +
+
+ +
mm/dd/yyyy
+
+ +
+
+
+ +
mm/dd/yyyy
+
+ +
+
+
+ {% comment %} Cog/Over {% endcomment %} + +
+ + + + +
+
+ {% comment %} Submission {% endcomment %} +
+ + +
+
+
+
+

Search single audit reports

+ {% if results %} + + + + + + + + + + + + + + + {% for result in results %} + + + + + + + + + + {% endfor %} + +
+ Results {{ results|length }} +
NameUEI or EINAcc DateAYCog or OverViewPDF
{{ result.auditee_name }}{{ result.auditee_uei }}{{ result.fac_accepted_date }}{{ result.audit_year }} + {% if result.oversight_agency %} + Oversight + {% else %} + Cognizant + {% endif %} + + + + + + + + +
+ + {% else %} +
+ an arrow points left, toward the search form +

+ 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 }} +

+
+
+

General Information

+
+
+ {% for k, v in general.items %} +

+ {{ k|field_name_to_label }}: {{ v }} +

+ {% endfor %} +
+
+
+ {% for section_key, section_list in data.items %} +
+ +

{{ section_key }}

+ | + {{ section_list|length }} +
+
+ + + + {% for k in section_list.0 %}{% endfor %} + + + + {% for item in section_list %} + + {% for k, v in item.items %} + {% if v == '' %} + + {% else %} + + {% endif %} + {% endfor %} + + {% endfor %} + +
{{ k|field_name_to_label }}
-{{ v }}
+
+
+ {% 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';