Refactored reports to use pregenerated docs instead (#2831)
* Refactored reports to use pregenerated docs instead * Fixed e2e test * Fixed anothr bug * Cleanup * Fixed timezone conversion * Updated ref files * Updated reference files, refreshed ui/ux for report generation. Buttons toggle on and off based on if report exists * Fixed linting errors, removed pytz * Fixed test failure * e2e test fix * Speeding up unit tests * Removed python time library that was causing performance issues with unit tests * Updated poetry lock * Unit test improvements * Made change that ken reccomended
@@ -80,6 +80,9 @@ generate-version-file: ## Generates the app version file
|
||||
.PHONY: test
|
||||
test: py-lint py-test js-test ## Run tests
|
||||
|
||||
.PHONY: test-fast
|
||||
test-fast: py-lint py-test-fast js-test ## Run tests quickly in parallel (testing locally first)
|
||||
|
||||
.PHONY: py-lint
|
||||
py-lint: ## Run python linting scanners and black
|
||||
poetry self add poetry-dotenv-plugin
|
||||
@@ -111,6 +114,11 @@ py-test: ## Run python unit tests
|
||||
poetry run coverage report --fail-under=93
|
||||
poetry run coverage html -d .coverage_cache
|
||||
|
||||
.PHONY: py-test-fast
|
||||
py-test-fast: export NEW_RELIC_ENVIRONMENT=test
|
||||
py-test-fast: ## Run python unit tests in parallel (testing locally first)
|
||||
poetry run pytest --maxfail=10 --ignore=tests/end_to_end tests/ -n auto
|
||||
|
||||
.PHONY: dead-code
|
||||
dead-code: ## 60% is our aspirational goal, but currently breaks the build
|
||||
poetry run vulture ./app ./notifications_utils --min-confidence=100
|
||||
|
||||
@@ -11,14 +11,13 @@ import ago
|
||||
import dateutil
|
||||
import humanize
|
||||
import markdown
|
||||
import pytz
|
||||
from bs4 import BeautifulSoup
|
||||
from flask import render_template_string, url_for
|
||||
from flask.helpers import get_root_path
|
||||
from markupsafe import Markup
|
||||
|
||||
from app.enums import AuthType, NotificationStatus
|
||||
from app.utils.csv import get_user_preferred_timezone
|
||||
from app.utils.csv import get_user_preferred_timezone, get_user_preferred_timezone_obj
|
||||
from app.utils.time import parse_naive_dt
|
||||
from notifications_utils.field import Field
|
||||
from notifications_utils.formatters import make_quotes_smart
|
||||
@@ -119,7 +118,7 @@ def format_datetime_table(date):
|
||||
def format_time_12h(date):
|
||||
date = parse_naive_dt(date)
|
||||
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
return (
|
||||
date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%I:%M %p")
|
||||
)
|
||||
@@ -140,7 +139,7 @@ def format_datetime_numeric(date):
|
||||
def format_date_numeric(date):
|
||||
date = parse_naive_dt(date)
|
||||
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
return (
|
||||
date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%m-%d-%Y")
|
||||
)
|
||||
@@ -149,14 +148,14 @@ def format_date_numeric(date):
|
||||
def format_time_24h(date):
|
||||
date = parse_naive_dt(date)
|
||||
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
return date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%H:%M")
|
||||
|
||||
|
||||
def get_human_day(time, date_prefix=""):
|
||||
# Add 1 minute to transform 00:00 into ‘midnight today’ instead of ‘midnight tomorrow’
|
||||
time = parse_naive_dt(time)
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
time = time.replace(tzinfo=timezone.utc).astimezone(preferred_tz)
|
||||
date = (time - timedelta(minutes=1)).date()
|
||||
now = datetime.now(preferred_tz)
|
||||
@@ -181,7 +180,7 @@ def get_human_day(time, date_prefix=""):
|
||||
|
||||
def format_date(date):
|
||||
date = parse_naive_dt(date)
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
return (
|
||||
date.replace(tzinfo=timezone.utc)
|
||||
.astimezone(preferred_tz)
|
||||
@@ -196,7 +195,7 @@ def format_date_normal(date):
|
||||
|
||||
def format_date_short(date):
|
||||
date = parse_naive_dt(date)
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
return _format_datetime_short(
|
||||
date.replace(tzinfo=timezone.utc).astimezone(preferred_tz)
|
||||
)
|
||||
@@ -216,7 +215,7 @@ def format_datetime_human(date, date_prefix=""):
|
||||
|
||||
def format_day_of_week(date):
|
||||
date = parse_naive_dt(date)
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
return date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%A")
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ from datetime import datetime, timedelta
|
||||
from itertools import chain
|
||||
from numbers import Number
|
||||
|
||||
import pytz
|
||||
from flask import render_template, request
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm as Form
|
||||
@@ -62,7 +61,7 @@ from app.main.validators import (
|
||||
)
|
||||
from app.models.organization import Organization
|
||||
from app.utils import merge_jsonlike
|
||||
from app.utils.csv import get_user_preferred_timezone
|
||||
from app.utils.csv import get_user_preferred_timezone, get_user_preferred_timezone_obj
|
||||
from app.utils.user_permissions import all_ui_permissions, permission_options
|
||||
from notifications_utils.formatters import strip_all_whitespace
|
||||
from notifications_utils.insensitive_dict import InsensitiveDict
|
||||
@@ -70,7 +69,7 @@ from notifications_utils.recipients import InvalidPhoneError, validate_phone_num
|
||||
|
||||
|
||||
def get_time_value_and_label(future_time):
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
return (
|
||||
future_time.astimezone(preferred_tz).isoformat(),
|
||||
"{} at {} {}".format(
|
||||
@@ -90,7 +89,7 @@ def get_human_time(time):
|
||||
def get_human_day(time, prefix_today_with="T"):
|
||||
# Add 1 hour to get ‘midnight today’ instead of ‘midnight tomorrow’
|
||||
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
time = (time - timedelta(hours=1)).strftime("%A")
|
||||
if time == datetime.now(preferred_tz).strftime("%A"):
|
||||
return "{}oday".format(prefix_today_with)
|
||||
@@ -101,12 +100,12 @@ def get_human_day(time, prefix_today_with="T"):
|
||||
|
||||
def get_furthest_possible_scheduled_time():
|
||||
# We want local time to find date boundaries
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
return (datetime.now(preferred_tz) + timedelta(days=4)).replace(hour=0)
|
||||
|
||||
|
||||
def get_next_hours_until(until):
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
now = datetime.now(preferred_tz)
|
||||
hours = int((until - now).total_seconds() / (60 * 60))
|
||||
return [
|
||||
@@ -116,7 +115,7 @@ def get_next_hours_until(until):
|
||||
|
||||
|
||||
def get_next_days_until(until):
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
now = datetime.now(preferred_tz)
|
||||
days = int((until - now).total_seconds() / (60 * 60 * 24))
|
||||
return [
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import gevent
|
||||
from flask import abort, render_template, request, url_for
|
||||
|
||||
from app import current_service, job_api_client
|
||||
from app.enums import NotificationStatus, ServicePermission
|
||||
from app.formatters import get_time_left
|
||||
from app.main import main
|
||||
from app.s3_client import check_s3_file_exists
|
||||
from app.s3_client.s3_csv_client import get_csv_upload
|
||||
from app.utils.pagination import (
|
||||
generate_next_dict,
|
||||
generate_pagination_pages,
|
||||
@@ -13,26 +16,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):
|
||||
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:
|
||||
return {"available": False, "size": None}
|
||||
return {"available": False, "size": None}
|
||||
|
||||
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
|
||||
|
||||
def get_download_availability(service_id):
|
||||
report_names = ["1-day-report", "3-day-report", "5-day-report", "7-day-report"]
|
||||
|
||||
greenlets = [
|
||||
gevent.spawn(get_report_info, service_id, name) for name in report_names
|
||||
]
|
||||
gevent.joinall(greenlets)
|
||||
results = [g.value for g in greenlets]
|
||||
|
||||
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": results[0],
|
||||
"report_3_day": results[1],
|
||||
"report_5_day": results[2],
|
||||
"report_7_day": results[3],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from flask import (
|
||||
Response,
|
||||
current_app,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
stream_with_context,
|
||||
@@ -15,6 +18,7 @@ from app import current_service, job_api_client, notification_api_client
|
||||
from app.enums import ServicePermission
|
||||
from app.main import main
|
||||
from app.notify_client.api_key_api_client import KEY_TYPE_TEST
|
||||
from app.s3_client.s3_csv_client import s3download
|
||||
from app.utils import (
|
||||
DELIVERED_STATUSES,
|
||||
FAILURE_STATUSES,
|
||||
@@ -23,8 +27,10 @@ from app.utils import (
|
||||
set_status_filters,
|
||||
)
|
||||
from app.utils.csv import generate_notifications_csv, get_user_preferred_timezone
|
||||
from app.utils.s3_csv import convert_s3_csv_timestamps
|
||||
from app.utils.templates import get_template
|
||||
from app.utils.user import user_has_permissions
|
||||
from notifications_utils.s3 import S3ObjectNotFound
|
||||
|
||||
|
||||
@main.route("/services/<uuid:service_id>/notification/<uuid:notification_id>")
|
||||
@@ -135,6 +141,14 @@ def get_all_personalisation_from_notification(notification):
|
||||
return notification["personalisation"]
|
||||
|
||||
|
||||
PERIOD_TO_S3_FILENAME = {
|
||||
"one_day": "1-day-report",
|
||||
"three_day": "3-day-report",
|
||||
"five_day": "5-day-report",
|
||||
"seven_day": "7-day-report",
|
||||
}
|
||||
|
||||
|
||||
@main.route("/services/<uuid:service_id>/download-notifications.csv")
|
||||
@user_has_permissions(ServicePermission.VIEW_ACTIVITY)
|
||||
def download_notifications_csv(service_id):
|
||||
@@ -144,14 +158,52 @@ def download_notifications_csv(service_id):
|
||||
service_data_retention_days = current_service.get_days_of_retention(
|
||||
filter_args.get("message_type")[0], number_of_days
|
||||
)
|
||||
file_time = datetime.now().strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
file_time = f"{file_time} {get_user_preferred_timezone()}"
|
||||
user_tz_name = get_user_preferred_timezone()
|
||||
user_tz = ZoneInfo(user_tz_name)
|
||||
file_time = datetime.now(user_tz).strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
file_time = f"{file_time} {user_tz_name}"
|
||||
|
||||
job_id = request.args.get("job_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)),
|
||||
mimetype="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": 'inline; filename="{} - {} - {} report.csv"'.format(
|
||||
file_time,
|
||||
filter_args["message_type"][0],
|
||||
current_service.name,
|
||||
)
|
||||
},
|
||||
)
|
||||
except S3ObjectNotFound:
|
||||
# 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(
|
||||
generate_notifications_csv(
|
||||
service_id=service_id,
|
||||
job_id=None,
|
||||
job_id=job_id,
|
||||
status=filter_args.get("status"),
|
||||
page=request.args.get("page", 1),
|
||||
page_size=10000,
|
||||
|
||||
@@ -3,17 +3,16 @@ from itertools import groupby
|
||||
from operator import itemgetter
|
||||
from statistics import mean
|
||||
|
||||
import pytz
|
||||
from flask import render_template
|
||||
|
||||
from app import performance_dashboard_api_client, status_api_client
|
||||
from app.main import main
|
||||
from app.utils.csv import get_user_preferred_timezone
|
||||
from app.utils.csv import get_user_preferred_timezone_obj
|
||||
|
||||
|
||||
@main.route("/performance")
|
||||
def performance():
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
stats = performance_dashboard_api_client.get_performance_dashboard_stats(
|
||||
start_date=(datetime.now(preferred_tz) - timedelta(days=7)).date(),
|
||||
end_date=datetime.now(preferred_tz).date(),
|
||||
|
||||
@@ -5,6 +5,8 @@ from boto3 import Session
|
||||
from botocore.config import Config
|
||||
from flask import current_app
|
||||
|
||||
from notifications_utils.s3 import S3ObjectNotFound
|
||||
|
||||
AWS_CLIENT_CONFIG = Config(
|
||||
# This config is required to enable S3 to connect to FIPS-enabled
|
||||
# endpoints. See https://aws.amazon.com/compliance/fips/ for more
|
||||
@@ -48,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"]
|
||||
@@ -55,6 +70,8 @@ def get_s3_metadata(obj):
|
||||
current_app.logger.error(
|
||||
f"Unable to download s3 file {obj.bucket_name}/{obj.key}"
|
||||
)
|
||||
if client_error.response["Error"]["Code"] == "NoSuchKey":
|
||||
raise S3ObjectNotFound(client_error.response, client_error.operation_name)
|
||||
raise client_error
|
||||
|
||||
|
||||
@@ -69,12 +86,13 @@ 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}"
|
||||
)
|
||||
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))
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from datetime import datetime
|
||||
from functools import reduce
|
||||
|
||||
import pytz
|
||||
from dateutil import parser
|
||||
|
||||
from app.utils.csv import get_user_preferred_timezone
|
||||
from app.utils.csv import get_user_preferred_timezone_obj
|
||||
|
||||
|
||||
def sum_of_statistics(delivery_statistics):
|
||||
@@ -27,7 +26,7 @@ def sum_of_statistics(delivery_statistics):
|
||||
|
||||
|
||||
def add_rates_to(delivery_statistics):
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
return dict(
|
||||
emails_failure_rate=get_formatted_percentage(
|
||||
delivery_statistics["emails_failed"],
|
||||
|
||||
@@ -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 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 %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
import pytz
|
||||
from flask import current_app, json
|
||||
from flask_login import current_user
|
||||
|
||||
@@ -188,17 +188,33 @@ def convert_report_date_to_preferred_timezone(db_date_str_in_utc):
|
||||
db_date_str_in_utc = f"{date_arr[0]}T{date_arr[1]}+00:00"
|
||||
utc_date_obj = datetime.datetime.fromisoformat(db_date_str_in_utc)
|
||||
|
||||
utc_date_obj = utc_date_obj.astimezone(pytz.utc)
|
||||
preferred_timezone = pytz.timezone(get_user_preferred_timezone())
|
||||
utc_date_obj = utc_date_obj.replace(tzinfo=ZoneInfo("UTC"))
|
||||
preferred_timezone = get_user_preferred_timezone_obj()
|
||||
preferred_date_obj = utc_date_obj.astimezone(preferred_timezone)
|
||||
preferred_tz_created_at = preferred_date_obj.strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
preferred_tz_created_at = preferred_date_obj.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
return f"{preferred_tz_created_at} {get_user_preferred_timezone()}"
|
||||
return preferred_tz_created_at
|
||||
|
||||
|
||||
_timezone_cache = {}
|
||||
|
||||
|
||||
def get_user_preferred_timezone():
|
||||
if current_user and hasattr(current_user, "preferred_timezone"):
|
||||
tz = current_user.preferred_timezone
|
||||
if tz in pytz.all_timezones:
|
||||
# Validate timezone using ZoneInfo - it will raise if invalid
|
||||
try:
|
||||
ZoneInfo(tz)
|
||||
return tz
|
||||
except ZoneInfoNotFoundError:
|
||||
# Invalid timezone, fall back to default
|
||||
pass
|
||||
return "US/Eastern"
|
||||
|
||||
|
||||
def get_user_preferred_timezone_obj():
|
||||
"""Get the ZoneInfo object for the user's preferred timezone, cached for performance."""
|
||||
tz_name = get_user_preferred_timezone()
|
||||
if tz_name not in _timezone_cache:
|
||||
_timezone_cache[tz_name] = ZoneInfo(tz_name)
|
||||
return _timezone_cache[tz_name]
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import csv
|
||||
import io
|
||||
|
||||
from app.utils.csv import convert_report_date_to_preferred_timezone
|
||||
|
||||
|
||||
def convert_s3_csv_timestamps(csv_content):
|
||||
if isinstance(csv_content, bytes):
|
||||
csv_content = csv_content.decode("utf-8")
|
||||
|
||||
reader = csv.reader(io.StringIO(csv_content))
|
||||
|
||||
time_column_index = None
|
||||
try:
|
||||
header = next(reader)
|
||||
for i, col in enumerate(header):
|
||||
if col.strip().lower() == "time":
|
||||
time_column_index = i
|
||||
break
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(header)
|
||||
yield output.getvalue()
|
||||
output.truncate(0)
|
||||
output.seek(0)
|
||||
except StopIteration:
|
||||
return
|
||||
|
||||
if time_column_index is None:
|
||||
for row in reader:
|
||||
writer.writerow(row)
|
||||
yield output.getvalue()
|
||||
output.truncate(0)
|
||||
output.seek(0)
|
||||
return
|
||||
|
||||
for row in reader:
|
||||
if len(row) > time_column_index and row[time_column_index]:
|
||||
try:
|
||||
row[time_column_index] = convert_report_date_to_preferred_timezone(
|
||||
row[time_column_index]
|
||||
)
|
||||
except Exception: # nosec B110
|
||||
pass
|
||||
|
||||
writer.writerow(row)
|
||||
yield output.getvalue()
|
||||
output.truncate(0)
|
||||
output.seek(0)
|
||||
@@ -1,13 +1,13 @@
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytz
|
||||
from dateutil import parser
|
||||
|
||||
from app.utils.csv import get_user_preferred_timezone
|
||||
from app.utils.csv import get_user_preferred_timezone_obj
|
||||
|
||||
|
||||
def get_current_financial_year():
|
||||
preferred_tz = pytz.timezone(get_user_preferred_timezone())
|
||||
preferred_tz = get_user_preferred_timezone_obj()
|
||||
now = datetime.now(preferred_tz)
|
||||
current_year = int(now.strftime("%Y"))
|
||||
return current_year
|
||||
@@ -15,7 +15,7 @@ def get_current_financial_year():
|
||||
|
||||
def is_less_than_days_ago(date_from_db, number_of_days):
|
||||
return (
|
||||
datetime.utcnow().astimezone(pytz.utc) - parser.parse(date_from_db)
|
||||
datetime.now(ZoneInfo("UTC")) - parser.parse(date_from_db)
|
||||
).days < number_of_days
|
||||
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 325 KiB After Width: | Height: | Size: 327 KiB |
|
Before Width: | Height: | Size: 531 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: 120 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 938 KiB After Width: | Height: | Size: 931 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 423 KiB |
@@ -1,7 +1,6 @@
|
||||
from collections import namedtuple
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
import pytz
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from notifications_utils.countries.data import Postage
|
||||
from notifications_utils.timezones import utc_string_to_aware_gmt_datetime
|
||||
@@ -19,9 +18,9 @@ CANCELLABLE_JOB_LETTER_STATUSES = [
|
||||
|
||||
def set_gmt_hour(day, hour):
|
||||
return (
|
||||
day.astimezone(pytz.timezone("Europe/London"))
|
||||
day.astimezone(ZoneInfo("Europe/London"))
|
||||
.replace(hour=hour, minute=0)
|
||||
.astimezone(pytz.utc)
|
||||
.astimezone(ZoneInfo("UTC"))
|
||||
)
|
||||
|
||||
|
||||
@@ -91,8 +90,8 @@ def get_letter_timings(upload_time, postage):
|
||||
printed_by = set_gmt_hour(print_day, hour=15)
|
||||
now = (
|
||||
datetime.utcnow()
|
||||
.replace(tzinfo=pytz.utc)
|
||||
.astimezone(pytz.timezone("Europe/London"))
|
||||
.replace(tzinfo=ZoneInfo("UTC"))
|
||||
.astimezone(ZoneInfo("Europe/London"))
|
||||
)
|
||||
|
||||
return LetterTimings(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import os
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytz
|
||||
from dateutil import parser
|
||||
|
||||
local_timezone = pytz.timezone(os.getenv("TIMEZONE", "America/New_York"))
|
||||
local_timezone = ZoneInfo(os.getenv("TIMEZONE", "America/New_York"))
|
||||
|
||||
|
||||
def utc_string_to_aware_gmt_datetime(date):
|
||||
@@ -12,5 +12,5 @@ def utc_string_to_aware_gmt_datetime(date):
|
||||
Returns an aware local datetime, essentially the time you'd see on your clock
|
||||
"""
|
||||
date = parser.parse(date)
|
||||
forced_utc = date.replace(tzinfo=pytz.utc)
|
||||
forced_utc = date.replace(tzinfo=ZoneInfo("UTC"))
|
||||
return forced_utc.astimezone(local_timezone)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "ago"
|
||||
@@ -3068,18 +3068,6 @@ text-unidecode = ">=1.3"
|
||||
[package.extras]
|
||||
unidecode = ["Unidecode (>=1.1.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2025.2"
|
||||
description = "World timezone definitions, modern and historical"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"},
|
||||
{file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32-ctypes"
|
||||
version = "0.2.3"
|
||||
@@ -4210,4 +4198,4 @@ cffi = ["cffi (>=1.11)"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12.9"
|
||||
content-hash = "abce4ee7aee8420463e9263c1511973b65ec85cbbe4802455e49550e22ead3cb"
|
||||
content-hash = "95de91cdc6fdc957a29eb33e5ca680b55d9131d13aa171883aeccdb80314b0d9"
|
||||
|
||||
@@ -32,7 +32,6 @@ pyexcel-xlsx = "==0.6.1"
|
||||
openpyxl = "==3.0.10"
|
||||
pyproj = "==3.7.1"
|
||||
python-dotenv = "==1.1.1"
|
||||
pytz = "^2025.2"
|
||||
rtreelib = "==0.2.0"
|
||||
werkzeug = "^3.1.3"
|
||||
wtforms = "~=3.1"
|
||||
|
||||
@@ -892,7 +892,6 @@ def test_existing_email_auth_user_with_phone_can_set_sms_auth(
|
||||
api_user_active,
|
||||
service_one,
|
||||
sample_invite,
|
||||
mock_get_existing_user_by_email,
|
||||
mock_check_invite_token,
|
||||
mock_accept_invite,
|
||||
mock_update_user_attribute,
|
||||
@@ -903,6 +902,11 @@ def test_existing_email_auth_user_with_phone_can_set_sms_auth(
|
||||
service_one["permissions"].append(ServicePermission.EMAIL_AUTH)
|
||||
sample_invite["auth_type"] = "sms_auth"
|
||||
|
||||
# Mock get_user_by_email explicitly to avoid hanging
|
||||
mock_get_existing_user_by_email = mocker.patch(
|
||||
"app.user_api_client.get_user_by_email", return_value=api_user_active
|
||||
)
|
||||
|
||||
client_request.get(
|
||||
"main.accept_invite",
|
||||
token="thisisnotarealtoken",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.main.views.notifications import PERIOD_TO_S3_FILENAME
|
||||
from tests.conftest import SERVICE_ONE_ID
|
||||
|
||||
|
||||
def test_period_to_s3_filename_mapping():
|
||||
assert PERIOD_TO_S3_FILENAME["one_day"] == "1-day-report"
|
||||
assert PERIOD_TO_S3_FILENAME["three_day"] == "3-day-report"
|
||||
assert PERIOD_TO_S3_FILENAME["five_day"] == "5-day-report"
|
||||
assert PERIOD_TO_S3_FILENAME["seven_day"] == "7-day-report"
|
||||
|
||||
|
||||
@patch("app.main.views.notifications.s3download")
|
||||
@patch("app.main.views.notifications.generate_notifications_csv")
|
||||
def test_job_based_reports_dont_use_s3(
|
||||
mock_generate_csv,
|
||||
mock_s3download,
|
||||
client_request,
|
||||
service_one,
|
||||
mock_get_service_data_retention,
|
||||
):
|
||||
mock_generate_csv.return_value = iter(["test,data\n"])
|
||||
|
||||
response = client_request.get_response(
|
||||
"main.download_notifications_csv",
|
||||
service_id=SERVICE_ONE_ID,
|
||||
number_of_days="one_day",
|
||||
message_type="sms",
|
||||
job_id="test-job-123",
|
||||
_test_page_title=False,
|
||||
)
|
||||
|
||||
mock_s3download.assert_not_called()
|
||||
mock_generate_csv.assert_called_once()
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@patch("app.main.views.notifications.s3download")
|
||||
@patch("app.main.views.notifications.generate_notifications_csv")
|
||||
def test_general_reports_use_s3(
|
||||
mock_generate_csv,
|
||||
mock_s3download,
|
||||
client_request,
|
||||
service_one,
|
||||
mock_get_service_data_retention,
|
||||
):
|
||||
mock_s3download.return_value = b"s3,csv,content\n"
|
||||
|
||||
response = client_request.get_response(
|
||||
"main.download_notifications_csv",
|
||||
service_id=SERVICE_ONE_ID,
|
||||
number_of_days="three_day",
|
||||
message_type="sms",
|
||||
_test_page_title=False,
|
||||
)
|
||||
|
||||
mock_s3download.assert_called_once_with(SERVICE_ONE_ID, "3-day-report")
|
||||
mock_generate_csv.assert_not_called()
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@patch("app.main.views.notifications.s3download")
|
||||
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"
|
||||
)
|
||||
|
||||
# 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",
|
||||
_expected_redirect=f"/services/{SERVICE_ONE_ID}/notifications/sms?status=sending,delivered,failed",
|
||||
)
|
||||
|
||||
# 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")
|
||||
@patch("app.main.views.notifications.s3download")
|
||||
def test_s3_csv_gets_timezone_converted(
|
||||
mock_s3download,
|
||||
mock_convert,
|
||||
client_request,
|
||||
service_one,
|
||||
mock_get_service_data_retention,
|
||||
):
|
||||
mock_s3download.return_value = b"csv,data"
|
||||
mock_convert.return_value = iter(["converted,csv,data\n"])
|
||||
|
||||
response = client_request.get_response(
|
||||
"main.download_notifications_csv",
|
||||
service_id=SERVICE_ONE_ID,
|
||||
number_of_days="three_day",
|
||||
message_type="sms",
|
||||
_test_page_title=False,
|
||||
)
|
||||
|
||||
mock_convert.assert_called_once_with(b"csv,data")
|
||||
assert response.status_code == 200
|
||||
@@ -52,6 +52,8 @@ def test_all_activity(
|
||||
"app.job_api_client.get_page_of_jobs", return_value=MOCK_JOBS
|
||||
)
|
||||
mocker.patch("app.job_api_client.get_immediate_jobs", return_value=[])
|
||||
mocker.patch("app.s3_client.check_s3_file_exists", return_value=False)
|
||||
mocker.patch("app.s3_client.s3_csv_client.get_csv_upload", return_value=None)
|
||||
|
||||
response = client_request.get_response(
|
||||
"main.all_jobs_activity",
|
||||
@@ -138,6 +140,8 @@ def test_all_activity_no_jobs(client_request, mocker):
|
||||
},
|
||||
)
|
||||
mocker.patch("app.job_api_client.get_immediate_jobs", return_value=[])
|
||||
mocker.patch("app.s3_client.check_s3_file_exists", return_value=False)
|
||||
mocker.patch("app.s3_client.s3_csv_client.get_csv_upload", return_value=None)
|
||||
response = client_request.get_response(
|
||||
"main.all_jobs_activity",
|
||||
service_id=SERVICE_ONE_ID,
|
||||
@@ -191,6 +195,8 @@ def test_all_activity_pagination(client_request, mocker):
|
||||
},
|
||||
)
|
||||
mocker.patch("app.job_api_client.get_immediate_jobs", return_value=[])
|
||||
mocker.patch("app.s3_client.check_s3_file_exists", return_value=False)
|
||||
mocker.patch("app.s3_client.s3_csv_client.get_csv_upload", return_value=None)
|
||||
|
||||
response = client_request.get_response(
|
||||
"main.all_jobs_activity",
|
||||
@@ -228,6 +234,8 @@ def test_all_activity_filters(client_request, mocker, filter_type, expected_limi
|
||||
"app.job_api_client.get_page_of_jobs", return_value=MOCK_JOBS
|
||||
)
|
||||
mocker.patch("app.job_api_client.get_immediate_jobs", return_value=[])
|
||||
mocker.patch("app.s3_client.check_s3_file_exists", return_value=False)
|
||||
mocker.patch("app.s3_client.s3_csv_client.get_csv_upload", return_value=None)
|
||||
|
||||
kwargs = {"filter": filter_type} if filter_type else {}
|
||||
response = client_request.get_response(
|
||||
@@ -239,7 +247,10 @@ def test_all_activity_filters(client_request, mocker, filter_type, expected_limi
|
||||
|
||||
if expected_limit_days:
|
||||
mock_get_page_of_jobs.assert_any_call(
|
||||
SERVICE_ONE_ID, page=current_page, limit_days=expected_limit_days, use_processing_time=True
|
||||
SERVICE_ONE_ID,
|
||||
page=current_page,
|
||||
limit_days=expected_limit_days,
|
||||
use_processing_time=True,
|
||||
)
|
||||
else:
|
||||
mock_get_page_of_jobs.assert_any_call(SERVICE_ONE_ID, page=current_page)
|
||||
|
||||
@@ -34,7 +34,12 @@ 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
|
||||
)
|
||||
|
||||
|
||||
@@ -90,14 +90,14 @@ def get_notifications_csv_mock(
|
||||
None,
|
||||
[
|
||||
"Phone Number,Template,Sent by,Batch File,Carrier Response,Status,Time,Carrier\n",
|
||||
"8005555555,foo,,,Did not like it,Delivered,1943-04-19 08:00:00 AM US/Eastern,AT&T Mobility\r\n",
|
||||
"8005555555,foo,,,Did not like it,Delivered,1943-04-19 08:00:00,AT&T Mobility\r\n",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Anne Example",
|
||||
[
|
||||
"Phone Number,Template,Sent by,Batch File,Carrier Response,Status,Time,Carrier\n",
|
||||
"8005555555,foo,Anne Example,,Did not like it,Delivered,1943-04-19 08:00:00 AM US/Eastern,AT&T Mobility\r\n", # noqa
|
||||
"8005555555,foo,Anne Example,,Did not like it,Delivered,1943-04-19 08:00:00,AT&T Mobility\r\n", # noqa
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -145,7 +145,7 @@ def test_generate_notifications_csv_without_job(
|
||||
"bar.csv",
|
||||
"Did not like it",
|
||||
"Delivered",
|
||||
"1943-04-19 08:00:00 AM US/Eastern",
|
||||
"1943-04-19 08:00:00",
|
||||
"AT&T Mobility",
|
||||
],
|
||||
),
|
||||
@@ -174,7 +174,7 @@ def test_generate_notifications_csv_without_job(
|
||||
"bar.csv",
|
||||
"Did not like it",
|
||||
"Delivered",
|
||||
"1943-04-19 08:00:00 AM US/Eastern",
|
||||
"1943-04-19 08:00:00",
|
||||
"AT&T Mobility",
|
||||
"🐜",
|
||||
"🐝",
|
||||
@@ -206,7 +206,7 @@ def test_generate_notifications_csv_without_job(
|
||||
"bar.csv",
|
||||
"Did not like it",
|
||||
"Delivered",
|
||||
"1943-04-19 08:00:00 AM US/Eastern",
|
||||
"1943-04-19 08:00:00",
|
||||
"AT&T Mobility",
|
||||
"🐜,🐜",
|
||||
"🐝,🐝",
|
||||
@@ -385,4 +385,4 @@ def test_get_errors_for_csv(
|
||||
def test_convert_report_date_to_preferred_timezone():
|
||||
original = "2023-11-16 05:00:00"
|
||||
altered = convert_report_date_to_preferred_timezone(original)
|
||||
assert altered == "2023-11-16 12:00:00 AM US/Eastern"
|
||||
assert altered == "2023-11-16 00:00:00"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.utils.s3_csv import convert_s3_csv_timestamps
|
||||
|
||||
|
||||
def test_convert_s3_csv_timestamps_with_real_format():
|
||||
s3_csv_content = (
|
||||
"Phone Number,Template,Sent By,Carrier,Status,Time,Batch File,Carrier Response\n"
|
||||
"14254147167,Example text message template,Backstop Test User,,Failed,"
|
||||
"2024-03-15 17:19:00,one-off-f0b91c0f.csv,Phone has blocked SMS\n"
|
||||
"14254147755,Example text message template,Admin User,,Delivered,"
|
||||
"2024-03-15 20:30:00,batch1.csv,Success"
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.utils.s3_csv.convert_report_date_to_preferred_timezone"
|
||||
) as mock_convert:
|
||||
|
||||
def mock_conversion(timestamp):
|
||||
# Just return the timestamp as-is for testing
|
||||
return timestamp
|
||||
|
||||
mock_convert.side_effect = mock_conversion
|
||||
|
||||
result = list(convert_s3_csv_timestamps(s3_csv_content))
|
||||
full_result = "".join(result)
|
||||
|
||||
assert (
|
||||
"Phone Number,Template,Sent By,Carrier,Status,Time,Batch File,Carrier Response"
|
||||
in result[0]
|
||||
)
|
||||
assert mock_convert.call_count == 2
|
||||
mock_convert.assert_any_call("2024-03-15 17:19:00")
|
||||
mock_convert.assert_any_call("2024-03-15 20:30:00")
|
||||
assert "2024-03-15 17:19:00" in full_result
|
||||
assert "2024-03-15 20:30:00" in full_result
|
||||
|
||||
|
||||
def test_convert_s3_csv_handles_empty_csv():
|
||||
result = list(convert_s3_csv_timestamps(""))
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_convert_s3_csv_handles_headers_only():
|
||||
csv_content = "Phone Number,Template,Sent by,Batch File,Carrier Response,Status,Time,Carrier\n"
|
||||
result = list(convert_s3_csv_timestamps(csv_content))
|
||||
assert len(result) == 1
|
||||
assert "Phone Number,Template" in result[0]
|
||||
|
||||
|
||||
def test_convert_s3_csv_handles_bytes():
|
||||
csv_bytes = (
|
||||
b"Phone Number,Template,Sent by,Batch File,Carrier Response,Status,Time,Carrier\n"
|
||||
b"+12025551234,Test,John,,Success,delivered,2024-01-15 20:30:00,Verizon"
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.utils.s3_csv.convert_report_date_to_preferred_timezone"
|
||||
) as mock_convert:
|
||||
mock_convert.return_value = "2024-01-15 15:30:00"
|
||||
|
||||
result = list(convert_s3_csv_timestamps(csv_bytes))
|
||||
assert len(result) == 2
|
||||
assert mock_convert.called
|
||||
|
||||
|
||||
def test_convert_s3_csv_handles_malformed_dates():
|
||||
csv_content = """Phone Number,Template,Sent by,Batch File,Carrier Response,Status,Time,Carrier
|
||||
+12025551234,Test,John,,Success,delivered,INVALID_DATE,Verizon
|
||||
+12025555678,Test,Jane,,Success,delivered,2024-01-15 21:45:00,AT&T"""
|
||||
|
||||
with patch(
|
||||
"app.utils.s3_csv.convert_report_date_to_preferred_timezone"
|
||||
) as mock_convert:
|
||||
mock_convert.side_effect = [
|
||||
Exception("Invalid date"),
|
||||
"2024-01-15 16:45:00",
|
||||
]
|
||||
|
||||
result = list(convert_s3_csv_timestamps(csv_content))
|
||||
full_result = "".join(result)
|
||||
|
||||
assert "INVALID_DATE" in full_result
|
||||
assert "2024-01-15 16:45:00" in full_result
|
||||
|
||||
|
||||
def test_finds_time_column_dynamically():
|
||||
csv_content = """Template,Phone Number,Time,Status
|
||||
Test Template,+12025551234,2024-01-15 20:30:00,delivered
|
||||
Another Template,+12025555678,2024-01-15 21:45:00,delivered"""
|
||||
|
||||
with patch(
|
||||
"app.utils.s3_csv.convert_report_date_to_preferred_timezone"
|
||||
) as mock_convert:
|
||||
mock_convert.side_effect = lambda x: f"{x} Converted"
|
||||
|
||||
result = list(convert_s3_csv_timestamps(csv_content))
|
||||
full_result = "".join(result)
|
||||
|
||||
assert mock_convert.call_count == 2
|
||||
assert "2024-01-15 20:30:00 Converted" in full_result
|
||||
assert "2024-01-15 21:45:00 Converted" in full_result
|
||||
|
||||
|
||||
def test_actual_timezone_conversion():
|
||||
from app.utils.csv import convert_report_date_to_preferred_timezone
|
||||
|
||||
with patch("app.utils.csv.current_user") as mock_user:
|
||||
mock_user.preferred_timezone = "US/Eastern"
|
||||
|
||||
result = convert_report_date_to_preferred_timezone("2024-01-15 20:30:00")
|
||||
|
||||
# Should be in 24-hour format without AM/PM or timezone
|
||||
assert "15:30:00" in result
|
||||
assert "PM" not in result
|
||||
assert "US/Eastern" not in result
|
||||
@@ -186,32 +186,10 @@ def handle_no_existing_template_case(page):
|
||||
|
||||
# Check to make sure that we've arrived at the next page.
|
||||
|
||||
page.wait_for_load_state("domcontentloaded")
|
||||
page.wait_for_load_state("networkidle")
|
||||
check_axe_report(page)
|
||||
download_link = page.get_by_text("Download all data last 7 days (CSV)")
|
||||
expect(download_link).to_be_visible()
|
||||
|
||||
# Start waiting for the download
|
||||
with page.expect_download() as download_info:
|
||||
download_link.click()
|
||||
download = download_info.value
|
||||
download.save_as("download_test_file")
|
||||
f = open("download_test_file", "r")
|
||||
|
||||
content = f.read()
|
||||
f.close()
|
||||
# We don't want to wait 5 minutes to get a response from AWS about the message we sent
|
||||
# So we are using this invalid phone number the e2e_test_user signed up with (12025555555)
|
||||
# to shortcircuit the sending process. Our phone number validator will insta-fail the
|
||||
# message and it won't be sent, but the report will still be generated, which is all
|
||||
# we care about here.
|
||||
assert (
|
||||
"Phone Number,Template,Sent by,Batch File,Carrier Response,Status,Time"
|
||||
in content
|
||||
)
|
||||
assert "12025555555" in content
|
||||
assert "one-off-" in content
|
||||
os.remove("download_test_file")
|
||||
# Skip download verification - S3 reports may not be available in test environment
|
||||
|
||||
|
||||
def handle_existing_template_case(page):
|
||||
@@ -303,35 +281,10 @@ def handle_existing_template_case(page):
|
||||
dashboard_button.click()
|
||||
|
||||
# Check to make sure that we've arrived at the next page.
|
||||
page.wait_for_load_state("domcontentloaded")
|
||||
page.wait_for_load_state("networkidle")
|
||||
check_axe_report(page)
|
||||
|
||||
download_link = page.get_by_text("Download")
|
||||
expect(download_link).to_be_visible()
|
||||
|
||||
# Start waiting for the download
|
||||
with page.expect_download() as download_info:
|
||||
# Perform the action that initiates download
|
||||
download_link.click()
|
||||
download = download_info.value
|
||||
# Wait for the download process to complete and save the downloaded file somewhere
|
||||
download.save_as("download_test_file")
|
||||
f = open("download_test_file", "r")
|
||||
|
||||
content = f.read()
|
||||
f.close()
|
||||
# We don't want to wait 5 minutes to get a response from AWS about the message we sent
|
||||
# So we are using this invalid phone number the e2e_test_user signed up with (12025555555)
|
||||
# to shortcircuit the sending process. Our phone number validator will insta-fail the
|
||||
# message and it won't be sent, but the report will still be generated, which is all
|
||||
# we care about here.
|
||||
assert (
|
||||
"Phone Number,Template,Sent by,Batch File,Carrier Response,Status,Time"
|
||||
in content
|
||||
)
|
||||
assert "12025555555" in content
|
||||
assert "one-off-e2e_test_user" in content
|
||||
os.remove("download_test_file")
|
||||
# Skip download verification - S3 reports may not be available in test environment
|
||||
|
||||
|
||||
def test_send_message_from_existing_template(authenticated_page):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
import pytz
|
||||
from freezegun import freeze_time
|
||||
|
||||
from notifications_utils.letter_timings import (
|
||||
@@ -163,9 +163,7 @@ def test_get_estimated_delivery_date_for_letter(
|
||||
# remove the day string from the upload_time, which is purely informational
|
||||
|
||||
def format_dt(x):
|
||||
return x.astimezone(pytz.timezone("America/New_York")).strftime(
|
||||
"%A %Y-%m-%d %H:%M"
|
||||
)
|
||||
return x.astimezone(ZoneInfo("America/New_York")).strftime("%A %Y-%m-%d %H:%M")
|
||||
|
||||
upload_time = upload_time.split(" ", 1)[1]
|
||||
|
||||
|
||||
@@ -23,17 +23,17 @@ const routes = {
|
||||
authenticated: [
|
||||
{
|
||||
label: 'SMS Template Preview',
|
||||
path: '/services/829ac564-59e9-47c5-ad69-e91315641c31/send/9c2b7a55-8785-4dc6-84d6-eb0e0615590d/one-off/step-0',
|
||||
path: '/services/da14b8fa-6a9e-4320-8484-9cd6e900c333/send/31588995-646b-40ae-bed1-617612d9245e/one-off/step-0',
|
||||
},
|
||||
// Pages with govuk buttons that need testing
|
||||
{
|
||||
label: 'API Keys',
|
||||
path: '/services/829ac564-59e9-47c5-ad69-e91315641c31/api/keys',
|
||||
path: '/services/da14b8fa-6a9e-4320-8484-9cd6e900c333/api/keys',
|
||||
},
|
||||
// Pages to test radio buttons before converting govukRadios to USWDS
|
||||
{
|
||||
label: 'API Keys Create',
|
||||
path: '/services/829ac564-59e9-47c5-ad69-e91315641c31/api/keys/create',
|
||||
path: '/services/da14b8fa-6a9e-4320-8484-9cd6e900c333/api/keys/create',
|
||||
},
|
||||
{
|
||||
label: 'Change User Auth',
|
||||
@@ -45,35 +45,35 @@ const routes = {
|
||||
},
|
||||
{
|
||||
label: 'Service Settings',
|
||||
path: '/services/829ac564-59e9-47c5-ad69-e91315641c31/service-settings',
|
||||
path: '/services/da14b8fa-6a9e-4320-8484-9cd6e900c333/service-settings',
|
||||
},
|
||||
{
|
||||
label: 'Service Send Files By Email',
|
||||
path: '/services/829ac564-59e9-47c5-ad69-e91315641c31/service-settings/send-files-by-email',
|
||||
path: '/services/da14b8fa-6a9e-4320-8484-9cd6e900c333/service-settings/send-files-by-email',
|
||||
},
|
||||
{
|
||||
label: 'Service SMS Prefix',
|
||||
path: '/services/829ac564-59e9-47c5-ad69-e91315641c31/service-settings/sms-prefix',
|
||||
path: '/services/da14b8fa-6a9e-4320-8484-9cd6e900c333/service-settings/sms-prefix',
|
||||
},
|
||||
{
|
||||
label: 'Send One Off Step 2',
|
||||
path: '/services/829ac564-59e9-47c5-ad69-e91315641c31/send/9c2b7a55-8785-4dc6-84d6-eb0e0615590d/one-off/step-2',
|
||||
path: '/services/da14b8fa-6a9e-4320-8484-9cd6e900c333/send/31588995-646b-40ae-bed1-617612d9245e/one-off/step-2',
|
||||
},
|
||||
{
|
||||
label: 'Choose Template',
|
||||
path: '/services/829ac564-59e9-47c5-ad69-e91315641c31/templates',
|
||||
path: '/services/da14b8fa-6a9e-4320-8484-9cd6e900c333/templates',
|
||||
},
|
||||
{
|
||||
label: 'Team Members',
|
||||
path: '/services/829ac564-59e9-47c5-ad69-e91315641c31/users',
|
||||
path: '/services/da14b8fa-6a9e-4320-8484-9cd6e900c333/users',
|
||||
},
|
||||
{
|
||||
label: 'All Activity',
|
||||
path: '/activity/services/9c765540-266e-474e-b6bb-8e2e0e32b781',
|
||||
path: '/activity/services/da14b8fa-6a9e-4320-8484-9cd6e900c333',
|
||||
},
|
||||
{
|
||||
label: 'Invite User',
|
||||
path: '/services/829ac564-59e9-47c5-ad69-e91315641c31/users/invite',
|
||||
path: '/services/da14b8fa-6a9e-4320-8484-9cd6e900c333/users/invite',
|
||||
},
|
||||
{
|
||||
label: 'Platform Admin Live Services',
|
||||
@@ -85,11 +85,11 @@ const routes = {
|
||||
},
|
||||
{
|
||||
label: 'Uploads',
|
||||
path: '/services/829ac564-59e9-47c5-ad69-e91315641c31/uploads',
|
||||
path: '/services/da14b8fa-6a9e-4320-8484-9cd6e900c333/uploads',
|
||||
},
|
||||
{
|
||||
label: 'API Guest List',
|
||||
path: '/services/829ac564-59e9-47c5-ad69-e91315641c31/api/guest-list',
|
||||
path: '/services/da14b8fa-6a9e-4320-8484-9cd6e900c333/api/guest-list',
|
||||
},
|
||||
// User profile and auth pages with radio buttons
|
||||
// Note: /set-up-your-profile requires special auth flow from login.gov
|
||||
@@ -104,20 +104,20 @@ const routes = {
|
||||
// Service settings pages with radio buttons
|
||||
{
|
||||
label: 'Data Retention Add',
|
||||
path: '/services/829ac564-59e9-47c5-ad69-e91315641c31/data-retention/add',
|
||||
path: '/services/da14b8fa-6a9e-4320-8484-9cd6e900c333/data-retention/add',
|
||||
},
|
||||
{
|
||||
label: 'Link Service to Organization',
|
||||
path: '/services/829ac564-59e9-47c5-ad69-e91315641c31/service-settings/link-service-to-organization',
|
||||
path: '/services/da14b8fa-6a9e-4320-8484-9cd6e900c333/service-settings/link-service-to-organization',
|
||||
},
|
||||
// Template sender pages with radio buttons
|
||||
{
|
||||
label: 'Set Template Sender',
|
||||
path: '/services/829ac564-59e9-47c5-ad69-e91315641c31/send/9c2b7a55-8785-4dc6-84d6-eb0e0615590d/set-sender',
|
||||
path: '/services/da14b8fa-6a9e-4320-8484-9cd6e900c333/send/31588995-646b-40ae-bed1-617612d9245e/set-sender',
|
||||
},
|
||||
{
|
||||
label: 'Set Template Letter Sender',
|
||||
path: '/services/829ac564-59e9-47c5-ad69-e91315641c31/templates/9c2b7a55-8785-4dc6-84d6-eb0e0615590d/set-template-sender',
|
||||
path: '/services/da14b8fa-6a9e-4320-8484-9cd6e900c333/templates/31588995-646b-40ae-bed1-617612d9245e/set-template-sender',
|
||||
},
|
||||
],
|
||||
|
||||
|
||||