Skip to content

Commit 379913f

Browse files
authored
Merge pull request #1582 from GSA/main
03/05/2025 Production Deploy
2 parents b40c232 + df22caa commit 379913f

26 files changed

+1024
-145
lines changed

.ds.baseline

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@
209209
"filename": "tests/app/aws/test_s3.py",
210210
"hashed_secret": "67a74306b06d0c01624fe0d0249a570f4d093747",
211211
"is_verified": false,
212-
"line_number": 40,
212+
"line_number": 42,
213213
"is_secret": false
214214
}
215215
],
@@ -384,5 +384,5 @@
384384
}
385385
]
386386
},
387-
"generated_at": "2025-02-10T16:57:15Z"
387+
"generated_at": "2025-02-27T21:09:45Z"
388388
}

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,8 @@ clean:
148148
# cf unmap-route notify-api-failwhale ${DNS_NAME} --hostname api
149149
# cf stop notify-api-failwhale
150150
# @echo "Failwhale is disabled"
151+
152+
.PHONY: test-single
153+
test-single: export NEW_RELIC_ENVIRONMENT=test
154+
test-single: ## Run a single test file
155+
poetry run pytest -s $(TEST_FILE)

app/clients/cloudwatch/aws_cloudwatch.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,20 @@ def event_to_db_format(self, event):
107107
provider_response = self._aws_value_or_default(
108108
event, "delivery", "providerResponse"
109109
)
110+
message_cost = self._aws_value_or_default(event, "delivery", "priceInUSD")
111+
if message_cost is None or message_cost == "":
112+
message_cost = 0.0
113+
else:
114+
message_cost = float(message_cost)
115+
110116
my_timestamp = self._aws_value_or_default(event, "notification", "timestamp")
111117
return {
112118
"notification.messageId": event["notification"]["messageId"],
113119
"status": event["status"],
114120
"delivery.phoneCarrier": phone_carrier,
115121
"delivery.providerResponse": provider_response,
116122
"@timestamp": my_timestamp,
123+
"delivery.priceInUSD": message_cost,
117124
}
118125

119126
# Here is an example of how to get the events with log insights

app/dao/date_util.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,25 @@ def generate_date_range(start_date, end_date=None, days=0):
9393
current_date += timedelta(days=1)
9494
else:
9595
return "An end_date or number of days must be specified"
96+
97+
98+
def generate_hourly_range(start_date, end_date=None, hours=0):
99+
if end_date:
100+
current_time = start_date
101+
while current_time <= end_date:
102+
try:
103+
yield current_time
104+
except ValueError:
105+
pass
106+
current_time += timedelta(hours=1)
107+
elif hours > 0:
108+
end_time = start_date + timedelta(hours=hours)
109+
current_time = start_date
110+
while current_time < end_time:
111+
try:
112+
yield current_time
113+
except ValueError:
114+
pass
115+
current_time += timedelta(hours=1)
116+
else:
117+
return "An end_date or number of hours must be specified"

app/dao/notifications_dao.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,29 @@ def get_notifications_for_job(
267267
return pagination
268268

269269

270+
def get_recent_notifications_for_job(
271+
service_id, job_id, filter_dict=None, page=1, page_size=None
272+
):
273+
if page_size is None:
274+
page_size = current_app.config["PAGE_SIZE"]
275+
276+
stmt = select(Notification).where(
277+
Notification.service_id == service_id,
278+
Notification.job_id == job_id,
279+
)
280+
281+
stmt = _filter_query(stmt, filter_dict)
282+
stmt = stmt.order_by(desc(Notification.job_row_number))
283+
results = db.session.execute(stmt).scalars().all()
284+
285+
page_size = current_app.config["PAGE_SIZE"]
286+
offset = (page - 1) * page_size
287+
paginated_results = results[offset : offset + page_size]
288+
289+
pagination = Pagination(paginated_results, page, page_size, len(results))
290+
return pagination
291+
292+
270293
def dao_get_notification_count_for_job_id(*, job_id):
271294
stmt = select(func.count(Notification.id)).where(Notification.job_id == job_id)
272295
return db.session.execute(stmt).scalar()
@@ -279,6 +302,44 @@ def dao_get_notification_count_for_service(*, service_id):
279302
return db.session.execute(stmt).scalar()
280303

281304

305+
def dao_get_notification_count_for_service_message_ratio(service_id, current_year):
306+
start_date = datetime(current_year, 1, 1)
307+
end_date = datetime(current_year + 1, 1, 1)
308+
stmt1 = (
309+
select(func.count())
310+
.select_from(Notification)
311+
.where(
312+
Notification.service_id == service_id,
313+
Notification.status
314+
not in [
315+
NotificationStatus.CANCELLED,
316+
NotificationStatus.CREATED,
317+
NotificationStatus.SENDING,
318+
],
319+
Notification.created_at >= start_date,
320+
Notification.created_at < end_date,
321+
)
322+
)
323+
stmt2 = (
324+
select(func.count())
325+
.select_from(NotificationHistory)
326+
.where(
327+
NotificationHistory.service_id == service_id,
328+
NotificationHistory.status
329+
not in [
330+
NotificationStatus.CANCELLED,
331+
NotificationStatus.CREATED,
332+
NotificationStatus.SENDING,
333+
],
334+
NotificationHistory.created_at >= start_date,
335+
NotificationHistory.created_at < end_date,
336+
)
337+
)
338+
recent_count = db.session.execute(stmt1).scalar_one()
339+
old_count = db.session.execute(stmt2).scalar_one()
340+
return recent_count + old_count
341+
342+
282343
def dao_get_failed_notification_count():
283344
stmt = select(func.count(Notification.id)).where(
284345
Notification.status == NotificationStatus.FAILED
@@ -446,7 +507,7 @@ def insert_notification_history_delete_notifications(
446507
SELECT id, job_id, job_row_number, service_id, template_id, template_version, api_key_id,
447508
key_type, notification_type, created_at, sent_at, sent_by, updated_at, reference, billable_units,
448509
client_reference, international, phone_prefix, rate_multiplier, notification_status,
449-
created_by_id, document_download_count
510+
created_by_id, document_download_count, message_cost
450511
FROM notifications
451512
WHERE service_id = :service_id
452513
AND notification_type = :notification_type
@@ -781,7 +842,6 @@ def dao_update_delivery_receipts(receipts, delivered):
781842
new_receipts.append(r)
782843

783844
receipts = new_receipts
784-
785845
id_to_carrier = {
786846
r["notification.messageId"]: r["delivery.phoneCarrier"] for r in receipts
787847
}
@@ -790,9 +850,13 @@ def dao_update_delivery_receipts(receipts, delivered):
790850
}
791851
id_to_timestamp = {r["notification.messageId"]: r["@timestamp"] for r in receipts}
792852

853+
id_to_message_cost = {
854+
r["notification.messageId"]: r["delivery.priceInUSD"] for r in receipts
855+
}
793856
status_to_update_with = NotificationStatus.DELIVERED
794857
if not delivered:
795858
status_to_update_with = NotificationStatus.FAILED
859+
796860
stmt = (
797861
update(Notification)
798862
.where(Notification.message_id.in_(id_to_carrier.keys()))
@@ -816,6 +880,12 @@ def dao_update_delivery_receipts(receipts, delivered):
816880
for key, value in id_to_provider_response.items()
817881
]
818882
),
883+
message_cost=case(
884+
*[
885+
(Notification.message_id == key, value)
886+
for key, value in id_to_message_cost.items()
887+
]
888+
),
819889
)
820890
)
821891
db.session.execute(stmt)
@@ -847,7 +917,6 @@ def dao_close_out_delivery_receipts():
847917

848918

849919
def dao_batch_insert_notifications(batch):
850-
851920
db.session.bulk_save_objects(batch)
852921
db.session.commit()
853922
current_app.logger.info(f"Batch inserted notifications: {len(batch)}")

app/dao/services_dao.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88

99
from app import db
1010
from app.dao.dao_utils import VersionOptions, autocommit, version_class
11-
from app.dao.date_util import generate_date_range, get_current_calendar_year
11+
from app.dao.date_util import (
12+
generate_date_range,
13+
generate_hourly_range,
14+
get_current_calendar_year,
15+
)
1216
from app.dao.organization_dao import dao_get_organization_by_email_address
1317
from app.dao.service_sms_sender_dao import insert_service_sms_sender
1418
from app.dao.service_user_dao import dao_get_service_user
@@ -523,6 +527,75 @@ def dao_fetch_stats_for_service_from_days(service_id, start_date, end_date):
523527
return total_notifications, data
524528

525529

530+
def dao_fetch_stats_for_service_from_hours(service_id, start_date, end_date):
531+
start_date = get_midnight_in_utc(start_date)
532+
end_date = get_midnight_in_utc(end_date + timedelta(days=1))
533+
534+
# Update to group by HOUR instead of DAY
535+
total_substmt = (
536+
select(
537+
func.date_trunc("hour", NotificationAllTimeView.created_at).label(
538+
"hour"
539+
), # UPDATED
540+
Job.notification_count.label("notification_count"),
541+
)
542+
.join(Job, NotificationAllTimeView.job_id == Job.id)
543+
.where(
544+
NotificationAllTimeView.service_id == service_id,
545+
NotificationAllTimeView.key_type != KeyType.TEST,
546+
NotificationAllTimeView.created_at >= start_date,
547+
NotificationAllTimeView.created_at < end_date,
548+
)
549+
.group_by(
550+
Job.id,
551+
Job.notification_count,
552+
func.date_trunc("hour", NotificationAllTimeView.created_at), # UPDATED
553+
)
554+
.subquery()
555+
)
556+
557+
# Also update this to group by hour
558+
total_stmt = select(
559+
total_substmt.c.hour, # UPDATED
560+
func.sum(total_substmt.c.notification_count).label("total_notifications"),
561+
).group_by(
562+
total_substmt.c.hour
563+
) # UPDATED
564+
565+
# Ensure we're using hourly timestamps in the response
566+
total_notifications = {
567+
row.hour: row.total_notifications
568+
for row in db.session.execute(total_stmt).all()
569+
}
570+
571+
# Update the second query to also use "hour"
572+
stmt = (
573+
select(
574+
NotificationAllTimeView.notification_type,
575+
NotificationAllTimeView.status,
576+
func.date_trunc("hour", NotificationAllTimeView.created_at).label(
577+
"hour"
578+
), # UPDATED
579+
func.count(NotificationAllTimeView.id).label("count"),
580+
)
581+
.where(
582+
NotificationAllTimeView.service_id == service_id,
583+
NotificationAllTimeView.key_type != KeyType.TEST,
584+
NotificationAllTimeView.created_at >= start_date,
585+
NotificationAllTimeView.created_at < end_date,
586+
)
587+
.group_by(
588+
NotificationAllTimeView.notification_type,
589+
NotificationAllTimeView.status,
590+
func.date_trunc("hour", NotificationAllTimeView.created_at), # UPDATED
591+
)
592+
)
593+
594+
data = db.session.execute(stmt).all()
595+
596+
return total_notifications, data
597+
598+
526599
def dao_fetch_stats_for_service_from_days_for_user(
527600
service_id, start_date, end_date, user_id
528601
):
@@ -827,3 +900,38 @@ def get_specific_days_stats(
827900
for day, rows in grouped_data.items()
828901
}
829902
return stats
903+
904+
905+
def get_specific_hours_stats(
906+
data, start_date, hours=None, end_date=None, total_notifications=None
907+
):
908+
if hours is not None and end_date is not None:
909+
raise ValueError("Only set hours OR set end_date, not both.")
910+
elif hours is not None:
911+
gen_range = [start_date + timedelta(hours=i) for i in range(hours)]
912+
elif end_date is not None:
913+
gen_range = generate_hourly_range(start_date, end_date=end_date)
914+
else:
915+
raise ValueError("Either hours or end_date must be set.")
916+
917+
# Ensure all hours exist in the output (even if empty)
918+
grouped_data = {hour: [] for hour in gen_range}
919+
920+
# Group notifications based on full hour timestamps
921+
for row in data:
922+
notification_type, status, timestamp, count = row
923+
924+
row_hour = timestamp.replace(minute=0, second=0, microsecond=0)
925+
if row_hour in grouped_data:
926+
grouped_data[row_hour].append(row)
927+
928+
# Format statistics, returning only hours with results
929+
stats = {
930+
hour.strftime("%Y-%m-%dT%H:00:00Z"): statistics.format_statistics(
931+
rows, total_notifications.get(hour, 0) if total_notifications else None
932+
)
933+
for hour, rows in grouped_data.items()
934+
if rows
935+
}
936+
937+
return stats

app/delivery/send_to_providers.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,13 @@ def send_sms_to_provider(notification):
118118
"international": notification.international,
119119
}
120120
db.session.close() # no commit needed as no changes to objects have been made above
121-
121+
real_sender_number = notification.reply_to_text
122+
# interleave spaces to bypass PII scrubbing since sender number is not PII
123+
arr = list(real_sender_number)
124+
real_sender_number = " ".join(arr)
125+
current_app.logger.info(
126+
f"#notify-debug-api-1701 real sender number going to AWS is {real_sender_number}"
127+
)
122128
message_id = provider.send_sms(**send_sms_kwargs)
123129

124130
update_notification_message_id(notification.id, message_id)

0 commit comments

Comments
 (0)