From 7febff7628aa48f70f6993998775eab9fa62c9d0 Mon Sep 17 00:00:00 2001 From: Beverly Nguyen Date: Thu, 16 Oct 2025 12:37:44 -0700 Subject: [PATCH] Add organization message totals endpoint - Add GET /organizations//message-totals endpoint - Returns aggregated messages_sent, messages_remaining, and total_message_limit - Add dao_get_notification_counts_for_organization() for bulk queries --- app/dao/notifications_dao.py | 61 +++++++++++++++++++++++++++++ app/organization/rest.py | 37 +++++++++++++++++ tests/app/organization/test_rest.py | 54 +++++++++++++++++++++++++ 3 files changed, 152 insertions(+) diff --git a/app/dao/notifications_dao.py b/app/dao/notifications_dao.py index 94006b7b6..cc8c29424 100644 --- a/app/dao/notifications_dao.py +++ b/app/dao/notifications_dao.py @@ -340,6 +340,67 @@ def dao_get_notification_count_for_service_message_ratio(service_id, current_yea return recent_count + old_count +def dao_get_notification_counts_for_organization(service_ids, current_year): + """ + Get notification counts for multiple services in a single organization. + """ + if not service_ids: + return {} + + start_date = datetime(current_year, 6, 16) + end_date = datetime(current_year + 1, 6, 16) + + stmt1 = ( + select( + Notification.service_id, + func.count().label("count") + ) + .where( + Notification.service_id.in_(service_ids), + Notification.status + not in [ + NotificationStatus.CANCELLED, + NotificationStatus.CREATED, + NotificationStatus.SENDING, + ], + Notification.created_at >= start_date, + Notification.created_at < end_date, + ) + .group_by(Notification.service_id) + ) + + stmt2 = ( + select( + NotificationHistory.service_id, + func.count().label("count") + ) + .where( + NotificationHistory.service_id.in_(service_ids), + NotificationHistory.status + not in [ + NotificationStatus.CANCELLED, + NotificationStatus.CREATED, + NotificationStatus.SENDING, + ], + NotificationHistory.created_at >= start_date, + NotificationHistory.created_at < end_date, + ) + .group_by(NotificationHistory.service_id) + ) + + result_dict = {} + + recent_results = db.session.execute(stmt1).all() + for service_id, count in recent_results: + result_dict[service_id] = count + + history_results = db.session.execute(stmt2).all() + for service_id, count in history_results: + result_dict[service_id] = result_dict.get(service_id, 0) + count + + return result_dict + + def dao_get_failed_notification_count(): stmt = select(func.count(Notification.id)).where( Notification.status == NotificationStatus.FAILED diff --git a/app/organization/rest.py b/app/organization/rest.py index df20d3cf3..cec5e7de2 100644 --- a/app/organization/rest.py +++ b/app/organization/rest.py @@ -1,4 +1,6 @@ import json +from datetime import datetime +from zoneinfo import ZoneInfo from flask import Blueprint, abort, current_app, jsonify, request from sqlalchemy.exc import IntegrityError @@ -8,6 +10,7 @@ from app.config import QueueNames from app.dao.annual_billing_dao import set_default_free_allowance_for_service from app.dao.dao_utils import transaction from app.dao.fact_billing_dao import fetch_usage_year_for_organization +from app.dao.notifications_dao import dao_get_notification_counts_for_organization from app.dao.organization_dao import ( dao_add_service_to_organization, dao_add_user_to_organization, @@ -259,3 +262,37 @@ def send_notifications_on_mou_signed(organization_id): organization.agreement_signed_by.email_address, personalisation, ) + + +@organization_blueprint.route("//message-totals", methods=["GET"]) +def get_organization_message_totals(organization_id): + + check_suspicious_id(organization_id) + + dao_get_organization_by_id(organization_id) + + services = dao_get_organization_services(organization_id) + + if not services: + return jsonify({ + "messages_sent": 0, + "messages_remaining": 0, + "total_message_limit": 0, + }), 200 + + current_year = datetime.now(tz=ZoneInfo("UTC")).year + service_ids = [service.id for service in services] + + messages_by_service = dao_get_notification_counts_for_organization( + service_ids, current_year + ) + + total_messages_sent = sum(messages_by_service.get(s.id, 0) for s in services) + total_message_limit = sum(s.total_message_limit for s in services) + total_messages_remaining = total_message_limit - total_messages_sent + + return jsonify({ + "messages_sent": total_messages_sent, + "messages_remaining": total_messages_remaining, + "total_message_limit": total_message_limit, + }), 200 diff --git a/tests/app/organization/test_rest.py b/tests/app/organization/test_rest.py index e643625ec..3d1172ece 100644 --- a/tests/app/organization/test_rest.py +++ b/tests/app/organization/test_rest.py @@ -928,3 +928,57 @@ def test_missing_both(): {"org_id": ["Can't be empty"]}, {"name": ["Can't be empty"]}, ] + + +@freeze_time("2025-01-15 10:00:00") +def test_get_organization_message_totals(admin_request, sample_organization, mocker): + service_1 = create_service(service_name="Service 1") + service_2 = create_service(service_name="Service 2") + + service_1.total_message_limit = 100000 + service_2.total_message_limit = 50000 + + dao_add_service_to_organization(service_1, sample_organization.id) + dao_add_service_to_organization(service_2, sample_organization.id) + + mock_get_counts = mocker.patch( + "app.organization.rest.dao_get_notification_counts_for_organization" + ) + mock_get_counts.return_value = { + service_1.id: 30000, + service_2.id: 20000, + } + + response = admin_request.get( + "organization.get_organization_message_totals", + organization_id=sample_organization.id, + _expected_status=200, + ) + + assert response["messages_sent"] == 50000 + assert response["messages_remaining"] == 100000 + assert response["total_message_limit"] == 150000 + + assert mock_get_counts.call_count == 1 + mock_get_counts.assert_called_once_with([service_1.id, service_2.id], 2025) + + +def test_get_organization_message_totals_no_services(admin_request, sample_organization): + response = admin_request.get( + "organization.get_organization_message_totals", + organization_id=sample_organization.id, + _expected_status=200, + ) + + assert response["messages_sent"] == 0 + assert response["messages_remaining"] == 0 + assert response["total_message_limit"] == 0 + + +def test_get_organization_message_totals_invalid_org_id(admin_request): + fake_uuid = "00000000-0000-0000-0000-000000000000" + admin_request.get( + "organization.get_organization_message_totals", + organization_id=fake_uuid, + _expected_status=404, + )