From 11d7f5839db1065ebaddbb4cd0f04be502e774dc Mon Sep 17 00:00:00 2001 From: Beverly Nguyen Date: Tue, 14 Oct 2025 15:28:58 -0700 Subject: [PATCH 01/11] Add Service Status and message usage count --- app/main/views/organizations.py | 32 ++++++++++++++++++- .../organizations/organization/index.html | 16 ++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/app/main/views/organizations.py b/app/main/views/organizations.py index c6d50b5f2..7c3bbde33 100644 --- a/app/main/views/organizations.py +++ b/app/main/views/organizations.py @@ -1,4 +1,4 @@ -from collections import OrderedDict +from collections import Counter, OrderedDict from datetime import datetime from functools import partial @@ -34,6 +34,28 @@ from app.utils.user import user_has_permissions, user_is_platform_admin from notifications_python_client.errors import HTTPError +def get_service_counts_by_status(services): + """ + Calculate service counts by status. + """ + def get_status(service): + if not service.get("active"): + return "suspended" + elif service.get("restricted"): + return "trial" + else: + return "live" + + status_counts = Counter(get_status(service) for service in services) + + return { + "live": status_counts["live"], + "trial": status_counts["trial"], + "suspended": status_counts["suspended"], + "total": len(services), + } + + @main.route("/organizations", methods=["GET"]) @user_is_platform_admin def organizations(): @@ -77,6 +99,9 @@ def organization_dashboard(org_id): services = current_organization.services_and_usage(financial_year=year)["services"] + all_services = current_organization.services + service_counts = get_service_counts_by_status(all_services) + total_messages_sent = 0 total_messages_remaining = 0 @@ -92,6 +117,11 @@ def organization_dashboard(org_id): selected_year=year, messages_sent=total_messages_sent, messages_remaining=total_messages_remaining, + total_services=service_counts["total"], + live_services=service_counts["live"], + trial_services=service_counts["trial"], + suspended_services=service_counts["suspended"], + all_services=all_services ) diff --git a/app/templates/views/organizations/organization/index.html b/app/templates/views/organizations/organization/index.html index baabccb73..33051839e 100644 --- a/app/templates/views/organizations/organization/index.html +++ b/app/templates/views/organizations/organization/index.html @@ -26,6 +26,22 @@
+
+
+
+
+
+

+ Services: {{ total_services }} (Live: {{ live_services }} - Trial: {{ trial_services }} - Suspended: {{ suspended_services }}) +

+

+ Agreement: Jan 1 - Dec 31 +

+
+
+
+
+

What is a service?

From fb528f03a8e3ef934ed26f608abd09d399651122 Mon Sep 17 00:00:00 2001 From: Beverly Nguyen Date: Thu, 16 Oct 2025 14:39:01 -0700 Subject: [PATCH 02/11] Add organization message usage and service counts to dashboard - Fetch organization message allowance from API endpoint - Display messages sent, remaining, and total limit on dashboard - Add service count statistics (total, live, trial, suspended) - Pass message usage data to template for chart visualization --- app/main/views/organizations.py | 28 +++++++++++++++++-- app/notify_client/service_api_client.py | 5 ++++ .../organizations/organization/index.html | 2 +- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/app/main/views/organizations.py b/app/main/views/organizations.py index a1fa0d9f1..f067a94be 100644 --- a/app/main/views/organizations.py +++ b/app/main/views/organizations.py @@ -5,7 +5,12 @@ from functools import partial from flask import current_app, flash, redirect, render_template, request, url_for from flask_login import current_user -from app import current_organization, org_invite_api_client, organizations_client +from app import ( + current_organization, + org_invite_api_client, + organizations_client, + service_api_client, +) from app.main import main from app.main.forms import ( AdminBillingDetailsForm, @@ -92,10 +97,29 @@ def organization_dashboard(org_id): year = requested_and_current_financial_year(request)[0] - # TODO: total message allowance + try: + message_usage = service_api_client.get_organization_message_usage(org_id) + except Exception as e: + current_app.logger.error(f"Error fetching organization message usage: {e}") + message_usage = {} + + messages_sent = message_usage.get("messages_sent", 0) + messages_remaining = message_usage.get("messages_remaining", 0) + total_message_limit = message_usage.get("total_message_limit", 0) + + services = current_organization.services + service_counts = get_service_counts_by_status(services) + return render_template( "views/organizations/organization/index.html", selected_year=year, + messages_sent=messages_sent, + messages_remaining=messages_remaining, + total_message_limit=total_message_limit, + total_services=service_counts["total"], + live_services=service_counts["live"], + trial_services=service_counts["trial"], + suspended_services=service_counts["suspended"], ) diff --git a/app/notify_client/service_api_client.py b/app/notify_client/service_api_client.py index 97d94cae4..3b238acbf 100644 --- a/app/notify_client/service_api_client.py +++ b/app/notify_client/service_api_client.py @@ -561,6 +561,11 @@ class ServiceAPIClient(NotifyAdminAPIClient): url="service/get-service-message-ratio?service_id={0}".format(service_id), ) + def get_organization_message_usage(self, org_id): + return self.get( + url="/organizations/{0}/message-allowance".format(org_id), + ) + service_api_client = ServiceAPIClient() diff --git a/app/templates/views/organizations/organization/index.html b/app/templates/views/organizations/organization/index.html index 33051839e..e054421a4 100644 --- a/app/templates/views/organizations/organization/index.html +++ b/app/templates/views/organizations/organization/index.html @@ -9,7 +9,7 @@ {{ page_header('Organization Dashboard', size='large') }} -
+

Overall {{ selected_year }} Total Message Allowance

-
-
-
-
-
-

- Services: {{ total_services }} (Live: {{ live_services }} - Trial: {{ trial_services }} - Suspended: {{ suspended_services }}) -

-

- Agreement: Jan 1 - Dec 31 -

+
+
+
+
+
+
+
+
Total Services
+
{{ total_services }}
+
+
+ {{ live_services }} Live + {{ trial_services }} Trial + {{ suspended_services }} Suspended +
+
+
+
+
+
+
+
+
+
+
Agreement Period
+
Jan 1 - Dec 31
-
+

What is a service?

When you join Notify, you're added to a service. This is your organization's workspace for sending text messages and emails. Within your service, you can: From 847101fbf631b2db6cc492e70864f7a4e1671169 Mon Sep 17 00:00:00 2001 From: Beverly Nguyen Date: Thu, 16 Oct 2025 16:56:29 -0700 Subject: [PATCH 06/11] add test --- .../organizations/organization/index.html | 4 +- .../views/organizations/test_organizations.py | 75 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/app/templates/views/organizations/organization/index.html b/app/templates/views/organizations/organization/index.html index 5f280b052..88a2d2433 100644 --- a/app/templates/views/organizations/organization/index.html +++ b/app/templates/views/organizations/organization/index.html @@ -34,7 +34,7 @@

Total Services
-
{{ total_services }}
+
{{ total_services }}
{{ live_services }} Live @@ -51,7 +51,7 @@
Agreement Period
-
Jan 1 - Dec 31
+
Jan 1 - Dec 31
diff --git a/tests/app/main/views/organizations/test_organizations.py b/tests/app/main/views/organizations/test_organizations.py index bca1bf2f8..857ffa5da 100644 --- a/tests/app/main/views/organizations/test_organizations.py +++ b/tests/app/main/views/organizations/test_organizations.py @@ -1522,3 +1522,78 @@ def test_organization_billing_page_not_accessible_if_not_platform_admin( client_request.get( ".organization_billing", org_id=ORGANISATION_ID, _expected_status=403 ) + + +def test_organization_dashboard_shows_message_usage( + client_request, + mock_get_organization, + mocker, + active_user_with_permissions, +): + mock_message_usage = mocker.patch( + "app.service_api_client.get_organization_message_usage", + return_value={ + "messages_sent": 1000, + "messages_remaining": 2000, + "total_message_limit": 3000, + }, + ) + mocker.patch( + "app.organizations_client.get_organization_services", + return_value=[], + ) + + client_request.login(active_user_with_permissions) + page = client_request.get( + ".organization_dashboard", + org_id=ORGANISATION_ID, + ) + + mock_message_usage.assert_called_once_with(ORGANISATION_ID) + + assert normalize_spaces(page.select_one("h1").text) == "Organization Dashboard" + + chart_container = page.select_one("#totalMessageChartContainer") + assert chart_container["data-messages-sent"] == "1000" + assert chart_container["data-messages-remaining"] == "2000" + assert chart_container["data-total-message-limit"] == "3000" + + +def test_organization_dashboard_shows_service_counts( + client_request, + mock_get_organization, + mocker, + active_user_with_permissions, +): + mocker.patch( + "app.service_api_client.get_organization_message_usage", + return_value={ + "messages_sent": 0, + "messages_remaining": 0, + "total_message_limit": 0, + }, + ) + mocker.patch( + "app.organizations_client.get_organization_services", + return_value=[ + service_json(id_="1", name="Live Service", restricted=False, active=True), + service_json(id_="2", name="Trial Service", restricted=True, active=True), + service_json(id_="3", name="Suspended", restricted=False, active=False), + ], + ) + + client_request.login(active_user_with_permissions) + page = client_request.get( + ".organization_dashboard", + org_id=ORGANISATION_ID, + ) + + summary_boxes = page.select(".usa-summary-box") + assert len(summary_boxes) == 2 + + service_box = summary_boxes[0] + assert "Total Services" in normalize_spaces(service_box.text) + assert "3" in normalize_spaces(service_box.text) + assert "1 Live" in normalize_spaces(service_box.text) + assert "1 Trial" in normalize_spaces(service_box.text) + assert "1 Suspended" in normalize_spaces(service_box.text) From d73d4d17d0afb128becee8b79c9a34b9ece5c8a1 Mon Sep 17 00:00:00 2001 From: Beverly Nguyen Date: Fri, 17 Oct 2025 13:11:38 -0700 Subject: [PATCH 07/11] trigger action --- tests/app/main/views/organizations/test_organizations.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/app/main/views/organizations/test_organizations.py b/tests/app/main/views/organizations/test_organizations.py index 857ffa5da..ae6bc9043 100644 --- a/tests/app/main/views/organizations/test_organizations.py +++ b/tests/app/main/views/organizations/test_organizations.py @@ -1542,7 +1542,6 @@ def test_organization_dashboard_shows_message_usage( "app.organizations_client.get_organization_services", return_value=[], ) - client_request.login(active_user_with_permissions) page = client_request.get( ".organization_dashboard", From afef4978f9ed811ab1be42b1e54a6c5d9f0078cd Mon Sep 17 00:00:00 2001 From: Beverly Nguyen Date: Mon, 20 Oct 2025 12:11:01 -0700 Subject: [PATCH 08/11] refactor org model and removed enum --- app/enums.py | 6 ------ app/main/views/organizations.py | 28 +++++----------------------- app/models/organization.py | 6 +++++- 3 files changed, 10 insertions(+), 30 deletions(-) diff --git a/app/enums.py b/app/enums.py index 2b5f2d603..4336375a5 100644 --- a/app/enums.py +++ b/app/enums.py @@ -106,9 +106,3 @@ class VerificationStatus(StrEnum): class AuthType(StrEnum): EMAIL_AUTH = "email_auth" SMS_AUTH = "sms_auth" - - -class ServiceStatus(StrEnum): - LIVE = "live" - TRIAL = "trial" - SUSPENDED = "suspended" diff --git a/app/main/views/organizations.py b/app/main/views/organizations.py index 01cdf498f..f6505e828 100644 --- a/app/main/views/organizations.py +++ b/app/main/views/organizations.py @@ -1,4 +1,4 @@ -from collections import Counter, OrderedDict +from collections import OrderedDict from datetime import datetime from functools import partial @@ -11,7 +11,6 @@ from app import ( organizations_client, service_api_client, ) -from app.enums import ServiceStatus from app.main import main from app.main.forms import ( AdminBillingDetailsForm, @@ -68,25 +67,6 @@ def add_organization(): return render_template("views/organizations/add-organization.html", form=form) -def get_service_counts_by_status(services): - def get_status(service): - if not service.get("active"): - return ServiceStatus.SUSPENDED - elif service.get("restricted"): - return ServiceStatus.TRIAL - else: - return ServiceStatus.LIVE - - status_counts = Counter(get_status(service) for service in services) - - return { - "live_services": status_counts[ServiceStatus.LIVE], - "trial_services": status_counts[ServiceStatus.TRIAL], - "suspended_services": status_counts[ServiceStatus.SUSPENDED], - "total_services": len(services), - } - - def get_organization_message_allowance(org_id): try: message_usage = service_api_client.get_organization_message_usage(org_id) @@ -110,13 +90,15 @@ def organization_dashboard(org_id): year = requested_and_current_financial_year(request)[0] message_allowance = get_organization_message_allowance(org_id) - service_counts = get_service_counts_by_status(current_organization.services) return render_template( "views/organizations/organization/index.html", selected_year=year, + live_services=len(current_organization.live_services), + trial_services=len(current_organization.trial_services), + suspended_services=len(current_organization.suspended_services), + total_services=len(current_organization.services), **message_allowance, - **service_counts, ) diff --git a/app/models/organization.py b/app/models/organization.py index f2dc1bf07..33f5f3ec4 100644 --- a/app/models/organization.py +++ b/app/models/organization.py @@ -103,7 +103,11 @@ class Organization(JSONModel, SortByNameMixin): @property def trial_services(self): - return [s for s in self.services if not s["active"] or s["restricted"]] + return [s for s in self.services if s["active"] and s["restricted"]] + + @property + def suspended_services(self): + return [s for s in self.services if not s["active"]] @cached_property def invited_users(self): From 3127de7711736977c5a24a7cf505d06c140d6a7d Mon Sep 17 00:00:00 2001 From: Beverly Nguyen Date: Mon, 20 Oct 2025 12:26:07 -0700 Subject: [PATCH 09/11] refactor --- app/main/views/organizations.py | 9 ++------- app/notify_client/organizations_api_client.py | 5 +++++ app/notify_client/service_api_client.py | 5 ----- tests/app/main/views/organizations/test_organizations.py | 2 +- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/app/main/views/organizations.py b/app/main/views/organizations.py index f6505e828..52acc144d 100644 --- a/app/main/views/organizations.py +++ b/app/main/views/organizations.py @@ -5,12 +5,7 @@ from functools import partial from flask import current_app, flash, redirect, render_template, request, url_for from flask_login import current_user -from app import ( - current_organization, - org_invite_api_client, - organizations_client, - service_api_client, -) +from app import current_organization, org_invite_api_client, organizations_client from app.main import main from app.main.forms import ( AdminBillingDetailsForm, @@ -69,7 +64,7 @@ def add_organization(): def get_organization_message_allowance(org_id): try: - message_usage = service_api_client.get_organization_message_usage(org_id) + message_usage = organizations_client.get_organization_message_usage(org_id) except Exception as e: current_app.logger.error(f"Error fetching organization message usage: {e}") message_usage = {} diff --git a/app/notify_client/organizations_api_client.py b/app/notify_client/organizations_api_client.py index 10d121a41..e7cca58ef 100644 --- a/app/notify_client/organizations_api_client.py +++ b/app/notify_client/organizations_api_client.py @@ -78,5 +78,10 @@ class OrganizationsClient(NotifyAdminAPIClient): params={"year": str(year)}, ) + def get_organization_message_usage(self, org_id): + return self.get( + url="/organizations/{}/message-allowance".format(org_id), + ) + organizations_client = OrganizationsClient() diff --git a/app/notify_client/service_api_client.py b/app/notify_client/service_api_client.py index 3b238acbf..97d94cae4 100644 --- a/app/notify_client/service_api_client.py +++ b/app/notify_client/service_api_client.py @@ -561,11 +561,6 @@ class ServiceAPIClient(NotifyAdminAPIClient): url="service/get-service-message-ratio?service_id={0}".format(service_id), ) - def get_organization_message_usage(self, org_id): - return self.get( - url="/organizations/{0}/message-allowance".format(org_id), - ) - service_api_client = ServiceAPIClient() diff --git a/tests/app/main/views/organizations/test_organizations.py b/tests/app/main/views/organizations/test_organizations.py index ae6bc9043..c243d940f 100644 --- a/tests/app/main/views/organizations/test_organizations.py +++ b/tests/app/main/views/organizations/test_organizations.py @@ -1531,7 +1531,7 @@ def test_organization_dashboard_shows_message_usage( active_user_with_permissions, ): mock_message_usage = mocker.patch( - "app.service_api_client.get_organization_message_usage", + "app.organizations_client.get_organization_message_usage", return_value={ "messages_sent": 1000, "messages_remaining": 2000, From fa7e7f239b6ec83dfe83e0f85aef3e839d82afb3 Mon Sep 17 00:00:00 2001 From: Beverly Nguyen Date: Mon, 20 Oct 2025 12:28:34 -0700 Subject: [PATCH 10/11] refactor --- tests/app/main/views/organizations/test_organizations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/app/main/views/organizations/test_organizations.py b/tests/app/main/views/organizations/test_organizations.py index c243d940f..ea1202fbb 100644 --- a/tests/app/main/views/organizations/test_organizations.py +++ b/tests/app/main/views/organizations/test_organizations.py @@ -1565,7 +1565,7 @@ def test_organization_dashboard_shows_service_counts( active_user_with_permissions, ): mocker.patch( - "app.service_api_client.get_organization_message_usage", + "app.organizations_client.get_organization_message_usage", return_value={ "messages_sent": 0, "messages_remaining": 0, From 8fdd1feb089cd5edaa03dee9f67d36a1d4cdcc80 Mon Sep 17 00:00:00 2001 From: Beverly Nguyen Date: Mon, 20 Oct 2025 13:09:21 -0700 Subject: [PATCH 11/11] fix test --- tests/app/main/views/organizations/test_organizations.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/app/main/views/organizations/test_organizations.py b/tests/app/main/views/organizations/test_organizations.py index ea1202fbb..3dc7e74d8 100644 --- a/tests/app/main/views/organizations/test_organizations.py +++ b/tests/app/main/views/organizations/test_organizations.py @@ -655,16 +655,12 @@ def test_organization_trial_mode_services_shows_all_non_live_services( ) services = page.select(".browse-list-item") - assert len(services) == 2 + assert len(services) == 1 assert normalize_spaces(services[0].text) == "2" - assert normalize_spaces(services[1].text) == "3" assert services[0].find("a")["href"] == url_for( "main.service_dashboard", service_id="2" ) - assert services[1].find("a")["href"] == url_for( - "main.service_dashboard", service_id="3" - ) def test_organization_trial_mode_services_doesnt_work_if_not_platform_admin(