Updated reference files, refreshed ui/ux for report generation. Buttons toggle on and off based on if report exists
@@ -13,26 +13,37 @@ from app.utils.pagination import (
|
||||
from app.utils.user import user_has_permissions
|
||||
|
||||
|
||||
def get_download_availability(service_id):
|
||||
jobs_1_day = job_api_client.get_page_of_jobs(service_id, page=1, limit_days=1)
|
||||
jobs_3_days = job_api_client.get_page_of_jobs(service_id, page=1, limit_days=3)
|
||||
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)
|
||||
def get_report_info(service_id, report_name):
|
||||
from app.s3_client import check_s3_file_exists
|
||||
from app.s3_client.s3_csv_client import get_csv_upload
|
||||
|
||||
has_1_day_data = len(generate_job_dict(jobs_1_day)) > 0
|
||||
has_3_day_data = len(generate_job_dict(jobs_3_days)) > 0
|
||||
has_5_day_data = len(generate_job_dict(jobs_5_days)) > 0
|
||||
has_7_day_data = len(jobs_7_days) > 0
|
||||
try:
|
||||
obj = get_csv_upload(service_id, report_name)
|
||||
if check_s3_file_exists(obj):
|
||||
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 {
|
||||
"has_1_day_data": has_1_day_data,
|
||||
"has_3_day_data": has_3_day_data,
|
||||
"has_5_day_data": has_5_day_data,
|
||||
"has_7_day_data": has_7_day_data,
|
||||
"has_any_download_data": has_1_day_data
|
||||
or has_3_day_data
|
||||
or has_5_day_data
|
||||
or has_7_day_data,
|
||||
"report_1_day": report_1_day,
|
||||
"report_3_day": report_3_day,
|
||||
"report_5_day": report_5_day,
|
||||
"report_7_day": report_7_day,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ from datetime import datetime
|
||||
import pytz
|
||||
from flask import (
|
||||
Response,
|
||||
current_app,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
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")
|
||||
@user_has_permissions(ServicePermission.VIEW_ACTIVITY)
|
||||
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:
|
||||
try:
|
||||
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)
|
||||
return Response(
|
||||
stream_with_context(convert_s3_csv_timestamps(s3_file_content)),
|
||||
@@ -191,16 +182,21 @@ def download_notifications_csv(service_id):
|
||||
},
|
||||
)
|
||||
except S3ObjectNotFound:
|
||||
return Response(
|
||||
stream_with_context(generate_empty_report_csv()),
|
||||
mimetype="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": 'inline; filename="{} - {} - {} report.csv"'.format(
|
||||
file_time,
|
||||
filter_args["message_type"][0],
|
||||
current_service.name,
|
||||
)
|
||||
},
|
||||
# Edge case: File was deleted between page load and download attempt
|
||||
current_app.logger.warning(
|
||||
f"File {s3_report_id} was expected but not found for service {service_id}. "
|
||||
"It may have been deleted after page load."
|
||||
)
|
||||
flash(
|
||||
"The report is no longer available. Please refresh the page.", "default"
|
||||
)
|
||||
return redirect(
|
||||
url_for(
|
||||
"main.view_notifications",
|
||||
service_id=service_id,
|
||||
message_type=filter_args["message_type"][0],
|
||||
status="sending,delivered,failed",
|
||||
)
|
||||
)
|
||||
return Response(
|
||||
stream_with_context(
|
||||
|
||||
@@ -50,6 +50,19 @@ def get_s3_object(
|
||||
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):
|
||||
try:
|
||||
return obj.get()["Metadata"]
|
||||
@@ -73,9 +86,9 @@ def set_s3_metadata(obj, **kwargs):
|
||||
|
||||
|
||||
def get_s3_contents(obj):
|
||||
contents = ""
|
||||
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:
|
||||
current_app.logger.error(
|
||||
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":
|
||||
raise S3ObjectNotFound(client_error.response, client_error.operation_name)
|
||||
raise client_error
|
||||
return contents
|
||||
|
||||
@@ -4,6 +4,7 @@ import uuid
|
||||
from flask import current_app
|
||||
|
||||
from app.s3_client import (
|
||||
check_s3_file_exists,
|
||||
get_s3_contents,
|
||||
get_s3_metadata,
|
||||
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):
|
||||
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))
|
||||
|
||||
@@ -59,6 +59,19 @@
|
||||
{% endif %}
|
||||
{% endset %}
|
||||
{% 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">
|
||||
<h1 class="usa-sr-only">All activity</h1>
|
||||
<h2 class="font-body-2xl line-height-sans-2 margin-0">All activity</h2>
|
||||
@@ -164,33 +177,100 @@
|
||||
</div>
|
||||
{{show_pagination}}
|
||||
{% if current_user.has_permissions(ServicePermission.VIEW_ACTIVITY) %}
|
||||
{% if has_any_download_data %}
|
||||
<h2 class="line-height-sans-2 margin-bottom-0 margin-top-4">Download recent reports</h2>
|
||||
{% if has_1_day_data %}
|
||||
<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>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if has_3_day_data %}
|
||||
<p class="font-body-sm">
|
||||
<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>
|
||||
 
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if has_5_day_data %}
|
||||
<p class="font-body-sm">
|
||||
<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>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if has_7_day_data %}
|
||||
<p class="font-body-sm">
|
||||
<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>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<h2 class="line-height-sans-2 margin-bottom-0 margin-top-4">Download recent reports</h2>
|
||||
<p class="font-body-sm">No recent activity to download. Download links will appear when jobs are available.</p>
|
||||
{% endif %}
|
||||
<div class="usa-summary-box margin-top-4" role="region" aria-labelledby="download-reports-heading">
|
||||
<div class="usa-summary-box__body">
|
||||
<h2 class="usa-summary-box__heading" id="download-reports-heading">Download recent reports</h2>
|
||||
|
||||
<div class="usa-summary-box__text">
|
||||
<p class="font-body-xs text-base margin-bottom-3">
|
||||
Reports are automatically generated daily at midnight and include data through the previous day.
|
||||
Today's activity will appear in tomorrow's report.
|
||||
</p>
|
||||
|
||||
<div class="download-reports-container maxw-tablet-lg">
|
||||
<div class="margin-bottom-2">
|
||||
{% if report_1_day.available %}
|
||||
<a href="{{ download_link_one_day }}"
|
||||
class="usa-button width-full display-flex flex-align-center"
|
||||
download
|
||||
aria-label="Download yesterday's report, CSV format, {{ report_1_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>Yesterday - {{ report_1_day.size }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="usa-button width-full"
|
||||
disabled
|
||||
aria-label="Yesterday's report not available - no messages were sent">
|
||||
Yesterday - 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 %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 325 KiB After Width: | Height: | Size: 327 KiB |
|
Before Width: | Height: | Size: 442 KiB After Width: | Height: | Size: 451 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 422 KiB After Width: | Height: | Size: 423 KiB |
@@ -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
|
||||
)
|
||||
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(
|
||||
"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 all data last 24 hours" in page.text
|
||||
assert "Download all data last 3 days" in page.text
|
||||
assert "Download all data last 5 days" in page.text
|
||||
assert "Download all data last 7 days" in page.text
|
||||
assert "No recent activity to download" not in page.text
|
||||
assert "Yesterday" in page.text
|
||||
assert "Last 3 days" in page.text
|
||||
assert "Last 5 days" in page.text
|
||||
assert "Last 7 days" in page.text
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
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(
|
||||
"main.all_jobs_activity",
|
||||
@@ -345,10 +352,10 @@ def test_download_links_partial_data_available(
|
||||
)
|
||||
|
||||
assert "Download recent reports" in page.text
|
||||
assert "Download all data last 24 hours" in page.text
|
||||
assert "Download all data last 3 days" not in page.text
|
||||
assert "Download all data last 5 days" in page.text
|
||||
assert "Download all data last 7 days" not in page.text
|
||||
assert "Yesterday" in page.text
|
||||
assert "Last 3 days" in page.text
|
||||
assert "Last 5 days" in page.text
|
||||
assert "Last 7 days" 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_immediate_jobs", return_value=[])
|
||||
mocker.patch("app.s3_client.check_s3_file_exists", return_value=False)
|
||||
|
||||
page = client_request.get(
|
||||
"main.all_jobs_activity",
|
||||
@@ -369,14 +377,11 @@ def test_download_links_no_data_available(
|
||||
)
|
||||
|
||||
assert "Download recent reports" in page.text
|
||||
assert "Download all data last 24 hours" not in page.text
|
||||
assert "Download all data last 3 days" not in page.text
|
||||
assert "Download all data last 5 days" not in page.text
|
||||
assert "Download all data last 7 days" not in page.text
|
||||
assert (
|
||||
"No recent activity to download. Download links will appear when jobs are available."
|
||||
in page.text
|
||||
)
|
||||
assert "Yesterday" in page.text
|
||||
assert "No messages sent" in page.text
|
||||
assert "Last 3 days - No messages sent" in page.text
|
||||
assert "Last 5 days - No messages sent" in page.text
|
||||
assert "Last 7 days - No messages sent" in page.text
|
||||
|
||||
|
||||
def test_download_not_available_to_users_without_dashboard(
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.main.views.notifications import (
|
||||
PERIOD_TO_S3_FILENAME,
|
||||
generate_empty_report_csv,
|
||||
)
|
||||
from notifications_utils.s3 import S3ObjectNotFound
|
||||
from app.main.views.notifications import PERIOD_TO_S3_FILENAME
|
||||
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"
|
||||
|
||||
|
||||
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.generate_notifications_csv")
|
||||
def test_job_based_reports_dont_use_s3(
|
||||
@@ -71,27 +61,30 @@ def test_general_reports_use_s3(
|
||||
|
||||
|
||||
@patch("app.main.views.notifications.s3download")
|
||||
def test_missing_s3_file_returns_headers_only(
|
||||
def test_missing_s3_file_redirects_gracefully(
|
||||
mock_s3download,
|
||||
client_request,
|
||||
service_one,
|
||||
mock_get_service_data_retention,
|
||||
):
|
||||
from notifications_utils.s3 import S3ObjectNotFound
|
||||
|
||||
mock_s3download.side_effect = S3ObjectNotFound(
|
||||
{"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",
|
||||
service_id=SERVICE_ONE_ID,
|
||||
number_of_days="five_day",
|
||||
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
|
||||
assert b"Phone Number,Template,Sent by" in response.data
|
||||
assert response.data.count(b"\n") == 1
|
||||
# The redirect happens, which means no 500 error occurred
|
||||
mock_s3download.assert_called_once_with(SERVICE_ONE_ID, "5-day-report")
|
||||
|
||||
|
||||
@patch("app.main.views.notifications.convert_s3_csv_timestamps")
|
||||
|
||||
@@ -34,7 +34,10 @@ def test_should_200_for_tour_start(
|
||||
"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
|
||||
)
|
||||
|
||||
|
||||