Updated reference files, refreshed ui/ux for report generation. Buttons toggle on and off based on if report exists

This commit is contained in:
alexjanousekGSA
2025-08-14 17:02:09 -04:00
parent 4f4d3a8f65
commit 2fb98af014
28 changed files with 211 additions and 106 deletions

View File

@@ -13,26 +13,37 @@ from app.utils.pagination import (
from app.utils.user import user_has_permissions from app.utils.user import user_has_permissions
def get_download_availability(service_id): def get_report_info(service_id, report_name):
jobs_1_day = job_api_client.get_page_of_jobs(service_id, page=1, limit_days=1) from app.s3_client import check_s3_file_exists
jobs_3_days = job_api_client.get_page_of_jobs(service_id, page=1, limit_days=3) from app.s3_client.s3_csv_client import get_csv_upload
jobs_5_days = job_api_client.get_page_of_jobs(service_id, page=1, limit_days=5)
jobs_7_days = job_api_client.get_immediate_jobs(service_id)
has_1_day_data = len(generate_job_dict(jobs_1_day)) > 0 try:
has_3_day_data = len(generate_job_dict(jobs_3_days)) > 0 obj = get_csv_upload(service_id, report_name)
has_5_day_data = len(generate_job_dict(jobs_5_days)) > 0 if check_s3_file_exists(obj):
has_7_day_data = len(jobs_7_days) > 0 size_bytes = obj.content_length
if size_bytes < 1024:
size_str = f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
size_str = f"{size_bytes / 1024:.1f} KB"
else:
size_str = f"{size_bytes / (1024 * 1024):.1f} MB"
return {"available": True, "size": size_str}
except Exception:
pass
return {"available": False, "size": None}
def get_download_availability(service_id):
report_1_day = get_report_info(service_id, "1-day-report")
report_3_day = get_report_info(service_id, "3-day-report")
report_5_day = get_report_info(service_id, "5-day-report")
report_7_day = get_report_info(service_id, "7-day-report")
return { return {
"has_1_day_data": has_1_day_data, "report_1_day": report_1_day,
"has_3_day_data": has_3_day_data, "report_3_day": report_3_day,
"has_5_day_data": has_5_day_data, "report_5_day": report_5_day,
"has_7_day_data": has_7_day_data, "report_7_day": report_7_day,
"has_any_download_data": has_1_day_data
or has_3_day_data
or has_5_day_data
or has_7_day_data,
} }

View File

@@ -4,8 +4,10 @@ from datetime import datetime
import pytz import pytz
from flask import ( from flask import (
Response, Response,
current_app,
flash, flash,
jsonify, jsonify,
redirect,
render_template, render_template,
request, request,
stream_with_context, stream_with_context,
@@ -147,20 +149,6 @@ PERIOD_TO_S3_FILENAME = {
} }
def generate_empty_report_csv():
headers = [
"Phone Number",
"Template",
"Sent by",
"Batch File",
"Carrier Response",
"Status",
"Time",
"Carrier",
]
yield ",".join(headers) + "\n"
@main.route("/services/<uuid:service_id>/download-notifications.csv") @main.route("/services/<uuid:service_id>/download-notifications.csv")
@user_has_permissions(ServicePermission.VIEW_ACTIVITY) @user_has_permissions(ServicePermission.VIEW_ACTIVITY)
def download_notifications_csv(service_id): def download_notifications_csv(service_id):
@@ -178,6 +166,9 @@ def download_notifications_csv(service_id):
if not job_id and number_of_days in PERIOD_TO_S3_FILENAME: if not job_id and number_of_days in PERIOD_TO_S3_FILENAME:
try: try:
s3_report_id = PERIOD_TO_S3_FILENAME[number_of_days] s3_report_id = PERIOD_TO_S3_FILENAME[number_of_days]
current_app.logger.info(
f"User is attempting to download {s3_report_id} for service {service_id}"
)
s3_file_content = s3download(service_id, s3_report_id) s3_file_content = s3download(service_id, s3_report_id)
return Response( return Response(
stream_with_context(convert_s3_csv_timestamps(s3_file_content)), stream_with_context(convert_s3_csv_timestamps(s3_file_content)),
@@ -191,16 +182,21 @@ def download_notifications_csv(service_id):
}, },
) )
except S3ObjectNotFound: except S3ObjectNotFound:
return Response( # Edge case: File was deleted between page load and download attempt
stream_with_context(generate_empty_report_csv()), current_app.logger.warning(
mimetype="text/csv", f"File {s3_report_id} was expected but not found for service {service_id}. "
headers={ "It may have been deleted after page load."
"Content-Disposition": 'inline; filename="{} - {} - {} report.csv"'.format( )
file_time, flash(
filter_args["message_type"][0], "The report is no longer available. Please refresh the page.", "default"
current_service.name, )
) return redirect(
}, url_for(
"main.view_notifications",
service_id=service_id,
message_type=filter_args["message_type"][0],
status="sending,delivered,failed",
)
) )
return Response( return Response(
stream_with_context( stream_with_context(

View File

@@ -50,6 +50,19 @@ def get_s3_object(
return obj return obj
def check_s3_file_exists(obj):
try:
obj.load()
return True
except botocore.exceptions.ClientError as client_error:
if client_error.response["Error"]["Code"] in ["404", "NoSuchKey"]:
return False
current_app.logger.error(
f"Error checking S3 file {obj.bucket_name}/{obj.key}: {client_error}"
)
return False
def get_s3_metadata(obj): def get_s3_metadata(obj):
try: try:
return obj.get()["Metadata"] return obj.get()["Metadata"]
@@ -73,9 +86,9 @@ def set_s3_metadata(obj, **kwargs):
def get_s3_contents(obj): def get_s3_contents(obj):
contents = ""
try: try:
contents = obj.get()["Body"].read().decode("utf-8") response = obj.get()
return response["Body"].read().decode("utf-8")
except botocore.exceptions.ClientError as client_error: except botocore.exceptions.ClientError as client_error:
current_app.logger.error( current_app.logger.error(
f"Unable to download s3 file {obj.bucket_name}/{obj.key}" f"Unable to download s3 file {obj.bucket_name}/{obj.key}"
@@ -83,4 +96,3 @@ def get_s3_contents(obj):
if client_error.response["Error"]["Code"] == "NoSuchKey": if client_error.response["Error"]["Code"] == "NoSuchKey":
raise S3ObjectNotFound(client_error.response, client_error.operation_name) raise S3ObjectNotFound(client_error.response, client_error.operation_name)
raise client_error raise client_error
return contents

View File

@@ -4,6 +4,7 @@ import uuid
from flask import current_app from flask import current_app
from app.s3_client import ( from app.s3_client import (
check_s3_file_exists,
get_s3_contents, get_s3_contents,
get_s3_metadata, get_s3_metadata,
get_s3_object, get_s3_object,
@@ -72,3 +73,7 @@ def set_metadata_on_csv_upload(service_id, upload_id, **kwargs):
def get_csv_metadata(service_id, upload_id): def get_csv_metadata(service_id, upload_id):
return get_s3_metadata(get_csv_upload(service_id, upload_id)) return get_s3_metadata(get_csv_upload(service_id, upload_id))
def check_s3_report_exists(service_id, upload_id):
return check_s3_file_exists(get_csv_upload(service_id, upload_id))

View File

@@ -59,6 +59,19 @@
{% endif %} {% endif %}
{% endset %} {% endset %}
{% block maincolumn_content %} {% block maincolumn_content %}
<style>
.download-reports-container .usa-button {
padding: 0.75rem 1rem;
min-height: 48px;
justify-content: flex-start;
}
.download-reports-container .usa-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
</style>
<div class="margin-bottom-8"> <div class="margin-bottom-8">
<h1 class="usa-sr-only">All activity</h1> <h1 class="usa-sr-only">All activity</h1>
<h2 class="font-body-2xl line-height-sans-2 margin-0">All activity</h2> <h2 class="font-body-2xl line-height-sans-2 margin-0">All activity</h2>
@@ -164,33 +177,100 @@
</div> </div>
{{show_pagination}} {{show_pagination}}
{% if current_user.has_permissions(ServicePermission.VIEW_ACTIVITY) %} {% if current_user.has_permissions(ServicePermission.VIEW_ACTIVITY) %}
{% if has_any_download_data %} <div class="usa-summary-box margin-top-4" role="region" aria-labelledby="download-reports-heading">
<h2 class="line-height-sans-2 margin-bottom-0 margin-top-4">Download recent reports</h2> <div class="usa-summary-box__body">
{% if has_1_day_data %} <h2 class="usa-summary-box__heading" id="download-reports-heading">Download recent reports</h2>
<p class="font-body-sm">
<a href="{{ download_link_one_day }}" download="download" class="usa-link">Download all data last 24 hours (<abbr title="Comma separated values">CSV</abbr>)</a> <div class="usa-summary-box__text">
</p> <p class="font-body-xs text-base margin-bottom-3">
{% endif %} Reports are automatically generated daily at midnight and include data through the previous day.
{% if has_3_day_data %} Today's activity will appear in tomorrow's report.
<p class="font-body-sm"> </p>
<a href="{{ download_link_three_day }}" download="download" class="usa-link">Download all data last 3 days (<abbr title="Comma separated values">CSV</abbr>)</a>
&emsp; <div class="download-reports-container maxw-tablet-lg">
</p> <div class="margin-bottom-2">
{% endif %} {% if report_1_day.available %}
{% if has_5_day_data %} <a href="{{ download_link_one_day }}"
<p class="font-body-sm"> class="usa-button width-full display-flex flex-align-center"
<a href="{{ download_link_five_day }}" download="download" class="usa-link">Download all data last 5 days (<abbr title="Comma separated values">CSV</abbr>)</a> download
</p> aria-label="Download yesterday's report, CSV format, {{ report_1_day.size }}">
{% endif %} <svg class="usa-icon margin-right-2" aria-hidden="true" focusable="false" role="img">
{% if has_7_day_data %} <use xlink:href="{{ asset_url('img/sprite.svg') }}#file_download"></use>
<p class="font-body-sm"> </svg>
<a href="{{ download_link_seven_day }}" download="download" class="usa-link">Download all data last 7 days (<abbr title="Comma separated values">CSV</abbr>)</a> <span>Yesterday&nbsp; - {{ report_1_day.size }}</span>
</p> </a>
{% endif %} {% else %}
{% else %} <button class="usa-button width-full"
<h2 class="line-height-sans-2 margin-bottom-0 margin-top-4">Download recent reports</h2> disabled
<p class="font-body-sm">No recent activity to download. Download links will appear when jobs are available.</p> aria-label="Yesterday's report not available - no messages were sent">
{% endif %} Yesterday&nbsp;&nbsp;&nbsp; - No messages sent
</button>
{% endif %}
</div>
<div class="margin-bottom-2">
{% if report_3_day.available %}
<a href="{{ download_link_three_day }}"
class="usa-button width-full display-flex flex-align-center"
download
aria-label="Download last 3 days report, CSV format, {{ report_3_day.size }}">
<svg class="usa-icon margin-right-2" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{{ asset_url('img/sprite.svg') }}#file_download"></use>
</svg>
<span>Last 3 days - {{ report_3_day.size }}</span>
</a>
{% else %}
<button class="usa-button width-full"
disabled
aria-label="Last 3 days report not available - no messages were sent">
Last 3 days - No messages sent
</button>
{% endif %}
</div>
<div class="margin-bottom-2">
{% if report_5_day.available %}
<a href="{{ download_link_five_day }}"
class="usa-button width-full display-flex flex-align-center"
download
aria-label="Download last 5 days report, CSV format, {{ report_5_day.size }}">
<svg class="usa-icon margin-right-2" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{{ asset_url('img/sprite.svg') }}#file_download"></use>
</svg>
<span>Last 5 days - {{ report_5_day.size }}</span>
</a>
{% else %}
<button class="usa-button width-full"
disabled
aria-label="Last 5 days report not available - no messages were sent">
Last 5 days - No messages sent
</button>
{% endif %}
</div>
<div class="margin-bottom-2">
{% if report_7_day.available %}
<a href="{{ download_link_seven_day }}"
class="usa-button width-full display-flex flex-align-center"
download
aria-label="Download last 7 days report, CSV format, {{ report_7_day.size }}">
<svg class="usa-icon margin-right-2" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{{ asset_url('img/sprite.svg') }}#file_download"></use>
</svg>
<span>Last 7 days - {{ report_7_day.size }}</span>
</a>
{% else %}
<button class="usa-button width-full"
disabled
aria-label="Last 7 days report not available - no messages were sent">
Last 7 days - No messages sent
</button>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 KiB

After

Width:  |  Height:  |  Size: 423 KiB

View File

@@ -302,6 +302,10 @@ def test_download_links_show_when_data_available(
"app.job_api_client.get_page_of_jobs", return_value=mock_jobs_with_data "app.job_api_client.get_page_of_jobs", return_value=mock_jobs_with_data
) )
mocker.patch("app.job_api_client.get_immediate_jobs", return_value=[{"id": "job1"}]) mocker.patch("app.job_api_client.get_immediate_jobs", return_value=[{"id": "job1"}])
mocker.patch("app.s3_client.check_s3_file_exists", return_value=True)
mock_obj = mocker.Mock()
mock_obj.content_length = 1024
mocker.patch("app.s3_client.s3_csv_client.get_csv_upload", return_value=mock_obj)
page = client_request.get( page = client_request.get(
"main.all_jobs_activity", "main.all_jobs_activity",
@@ -309,11 +313,10 @@ def test_download_links_show_when_data_available(
) )
assert "Download recent reports" in page.text assert "Download recent reports" in page.text
assert "Download all data last 24 hours" in page.text assert "Yesterday" in page.text
assert "Download all data last 3 days" in page.text assert "Last 3 days" in page.text
assert "Download all data last 5 days" in page.text assert "Last 5 days" in page.text
assert "Download all data last 7 days" in page.text assert "Last 7 days" in page.text
assert "No recent activity to download" not in page.text
def test_download_links_partial_data_available( def test_download_links_partial_data_available(
@@ -338,6 +341,10 @@ def test_download_links_partial_data_available(
"app.job_api_client.get_page_of_jobs", side_effect=mock_get_page_of_jobs "app.job_api_client.get_page_of_jobs", side_effect=mock_get_page_of_jobs
) )
mocker.patch("app.job_api_client.get_immediate_jobs", return_value=[]) mocker.patch("app.job_api_client.get_immediate_jobs", return_value=[])
mocker.patch("app.s3_client.check_s3_file_exists", return_value=True)
mock_obj = mocker.Mock()
mock_obj.content_length = 2048
mocker.patch("app.s3_client.s3_csv_client.get_csv_upload", return_value=mock_obj)
page = client_request.get( page = client_request.get(
"main.all_jobs_activity", "main.all_jobs_activity",
@@ -345,10 +352,10 @@ def test_download_links_partial_data_available(
) )
assert "Download recent reports" in page.text assert "Download recent reports" in page.text
assert "Download all data last 24 hours" in page.text assert "Yesterday" in page.text
assert "Download all data last 3 days" not in page.text assert "Last 3 days" in page.text
assert "Download all data last 5 days" in page.text assert "Last 5 days" in page.text
assert "Download all data last 7 days" not in page.text assert "Last 7 days" in page.text
assert "No recent activity to download" not in page.text assert "No recent activity to download" not in page.text
@@ -362,6 +369,7 @@ def test_download_links_no_data_available(
mocker.patch("app.job_api_client.get_page_of_jobs", return_value=mock_jobs_empty) mocker.patch("app.job_api_client.get_page_of_jobs", return_value=mock_jobs_empty)
mocker.patch("app.job_api_client.get_immediate_jobs", return_value=[]) mocker.patch("app.job_api_client.get_immediate_jobs", return_value=[])
mocker.patch("app.s3_client.check_s3_file_exists", return_value=False)
page = client_request.get( page = client_request.get(
"main.all_jobs_activity", "main.all_jobs_activity",
@@ -369,14 +377,11 @@ def test_download_links_no_data_available(
) )
assert "Download recent reports" in page.text assert "Download recent reports" in page.text
assert "Download all data last 24 hours" not in page.text assert "Yesterday" in page.text
assert "Download all data last 3 days" not in page.text assert "No messages sent" in page.text
assert "Download all data last 5 days" not in page.text assert "Last 3 days - No messages sent" in page.text
assert "Download all data last 7 days" not in page.text assert "Last 5 days - No messages sent" in page.text
assert ( assert "Last 7 days - No messages sent" in page.text
"No recent activity to download. Download links will appear when jobs are available."
in page.text
)
def test_download_not_available_to_users_without_dashboard( def test_download_not_available_to_users_without_dashboard(

View File

@@ -1,10 +1,6 @@
from unittest.mock import patch from unittest.mock import patch
from app.main.views.notifications import ( from app.main.views.notifications import PERIOD_TO_S3_FILENAME
PERIOD_TO_S3_FILENAME,
generate_empty_report_csv,
)
from notifications_utils.s3 import S3ObjectNotFound
from tests.conftest import SERVICE_ONE_ID from tests.conftest import SERVICE_ONE_ID
@@ -15,12 +11,6 @@ def test_period_to_s3_filename_mapping():
assert PERIOD_TO_S3_FILENAME["seven_day"] == "7-day-report" assert PERIOD_TO_S3_FILENAME["seven_day"] == "7-day-report"
def test_empty_csv_has_correct_headers():
result = list(generate_empty_report_csv())
assert len(result) == 1
assert "Phone Number,Template,Sent by,Batch File" in result[0]
@patch("app.main.views.notifications.s3download") @patch("app.main.views.notifications.s3download")
@patch("app.main.views.notifications.generate_notifications_csv") @patch("app.main.views.notifications.generate_notifications_csv")
def test_job_based_reports_dont_use_s3( def test_job_based_reports_dont_use_s3(
@@ -71,27 +61,30 @@ def test_general_reports_use_s3(
@patch("app.main.views.notifications.s3download") @patch("app.main.views.notifications.s3download")
def test_missing_s3_file_returns_headers_only( def test_missing_s3_file_redirects_gracefully(
mock_s3download, mock_s3download,
client_request, client_request,
service_one, service_one,
mock_get_service_data_retention, mock_get_service_data_retention,
): ):
from notifications_utils.s3 import S3ObjectNotFound
mock_s3download.side_effect = S3ObjectNotFound( mock_s3download.side_effect = S3ObjectNotFound(
{"Error": {"Code": "NoSuchKey"}}, "GetObject" {"Error": {"Code": "NoSuchKey"}}, "GetObject"
) )
response = client_request.get_response( # Verify that when an S3 file is missing, we redirect gracefully
# instead of showing a 500 error
client_request.get(
"main.download_notifications_csv", "main.download_notifications_csv",
service_id=SERVICE_ONE_ID, service_id=SERVICE_ONE_ID,
number_of_days="five_day", number_of_days="five_day",
message_type="sms", message_type="sms",
_test_page_title=False, _expected_redirect=f"/services/{SERVICE_ONE_ID}/notifications/sms?status=sending,delivered,failed",
) )
assert response.status_code == 200 # The redirect happens, which means no 500 error occurred
assert b"Phone Number,Template,Sent by" in response.data mock_s3download.assert_called_once_with(SERVICE_ONE_ID, "5-day-report")
assert response.data.count(b"\n") == 1
@patch("app.main.views.notifications.convert_s3_csv_timestamps") @patch("app.main.views.notifications.convert_s3_csv_timestamps")

View File

@@ -34,7 +34,10 @@ def test_should_200_for_tour_start(
"service one: ((one)) ((two)) ((three))" "service one: ((one)) ((two)) ((three))"
) )
assert page.select("a.usa-button")[0]["href"] == url_for( # Find the tour step button specifically, not just any usa-button
tour_buttons = [btn for btn in page.select("a.usa-button") if "tour" in btn.get("href", "")]
assert len(tour_buttons) > 0, "No tour button found"
assert tour_buttons[0]["href"] == url_for(
".tour_step", service_id=SERVICE_ONE_ID, template_id=fake_uuid, step_index=1 ".tour_step", service_id=SERVICE_ONE_ID, template_id=fake_uuid, step_index=1
) )