diff --git a/app/main/views/organizations.py b/app/main/views/organizations.py index 52acc144d..3baba4f52 100644 --- a/app/main/views/organizations.py +++ b/app/main/views/organizations.py @@ -76,6 +76,40 @@ def get_organization_message_allowance(org_id): } +def get_services_usage(organization, year): + + try: + services_and_usage = organization.services_and_usage(financial_year=year, include_all_services=True)["services"] + except Exception as e: + current_app.logger.error(f"Error fetching services and usage: {e}") + return [] + + services = [] + for service in services_and_usage: + service["id"] = service.get("service_id") + service["name"] = service.get("service_name") + + emails_sent = service.get("emails_sent", 0) + sms_sent = service.get("sms_billable_units", 0) + sms_remainder = service.get("sms_remainder", 0) + sms_cost = service.get("sms_cost", 0) + + usage_parts = [] + if emails_sent > 0: + usage_parts.append(f"{emails_sent:,} emails") + if sms_sent > 0 or sms_remainder > 0: + if sms_cost > 0: + usage_parts.append(f"{sms_sent:,} sms ({sms_remainder:,} remaining, ${sms_cost:,.2f})") + else: + usage_parts.append(f"{sms_sent:,} sms ({sms_remainder:,} remaining)") + + service["usage"] = ", ".join(usage_parts) if usage_parts else "No usage" + + services.append(service) + + return services + + @main.route("/organizations/", methods=["GET"]) @user_has_permissions() def organization_dashboard(org_id): @@ -86,9 +120,12 @@ def organization_dashboard(org_id): message_allowance = get_organization_message_allowance(org_id) + services_with_usage = get_services_usage(current_organization, year) + return render_template( "views/organizations/organization/index.html", selected_year=year, + services=services_with_usage, live_services=len(current_organization.live_services), trial_services=len(current_organization.trial_services), suspended_services=len(current_organization.suspended_services), diff --git a/app/models/organization.py b/app/models/organization.py index 33f5f3ec4..e7eb09f37 100644 --- a/app/models/organization.py +++ b/app/models/organization.py @@ -139,8 +139,8 @@ class Organization(JSONModel, SortByNameMixin): def associate_service(self, service_id): organizations_client.update_service_organization(service_id, self.id) - def services_and_usage(self, financial_year): - return organizations_client.get_services_and_usage(self.id, financial_year) + def services_and_usage(self, financial_year, include_all_services=False): + return organizations_client.get_services_and_usage(self.id, financial_year, include_all_services) class Organizations(SerialisedModelCollection): diff --git a/app/notify_client/organizations_api_client.py b/app/notify_client/organizations_api_client.py index e7cca58ef..105f6dce6 100644 --- a/app/notify_client/organizations_api_client.py +++ b/app/notify_client/organizations_api_client.py @@ -72,10 +72,10 @@ class OrganizationsClient(NotifyAdminAPIClient): def remove_user_from_organization(self, org_id, user_id): return self.delete(f"/organizations/{org_id}/users/{user_id}") - def get_services_and_usage(self, org_id, year): + def get_services_and_usage(self, org_id, year, include_all_services=False): return self.get( url=f"/organizations/{org_id}/services-with-usage", - params={"year": str(year)}, + params={"year": str(year), "include_all_services": str(include_all_services).lower()}, ) def get_organization_message_usage(self, org_id): diff --git a/app/templates/views/organizations/organization/index.html b/app/templates/views/organizations/organization/index.html index 88a2d2433..600405b7b 100644 --- a/app/templates/views/organizations/organization/index.html +++ b/app/templates/views/organizations/organization/index.html @@ -58,19 +58,65 @@ +
+ 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: +

+
    +
  1. Create and edit message templates
  2. +
  3. Send messages to recipients
  4. +
  5. View message status and history
  6. +
  7. Manage team members and permissions
  8. +
  9. Track usage and delivery statistics
  10. +
  11. If you work for multiple organizations, you may belong to multiple services and can switch between them.
  12. +
+
+
+ + +
-

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: -

-
    -
  1. Create and edit message templates
  2. -
  3. Send messages to recipients
  4. -
  5. View message status and history
  6. -
  7. Manage team members and permissions
  8. -
  9. Track usage and delivery statistics
  10. -
  11. If you work for multiple organizations, you may belong to multiple services and can switch between them.
  12. -
+

Services Overview

+
+ + + + + + + + + + + + {% if services %} + {% for service in services %} + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
NameStatusUsagePrimary ContactRecent Template Used
{{ service.name }} + {% if not service.active %} + Suspended + {% elif service.restricted %} + Trial + {% else %} + Live + {% endif %} + {{ service.usage|default('N/A') }}{{ service.primary_contact|default('N/A') }}{{ service.recent_template|default('N/A') }}
No services found within this organization
+
{% endblock %} diff --git a/tests/app/main/views/organizations/test_organizations.py b/tests/app/main/views/organizations/test_organizations.py index 3dc7e74d8..674f18da8 100644 --- a/tests/app/main/views/organizations/test_organizations.py +++ b/tests/app/main/views/organizations/test_organizations.py @@ -354,7 +354,7 @@ def test_organization_services_shows_live_services_and_usage( client_request.login(active_user_with_permissions) page = client_request.get(".organization_usage", org_id=ORGANISATION_ID) - mock.assert_called_once_with(ORGANISATION_ID, 2020) + mock.assert_called_once_with(ORGANISATION_ID, 2020, False) services = page.select("main h3") usage_rows = page.select("main .grid-col-6") @@ -447,7 +447,7 @@ def test_organization_services_filters_by_financial_year( org_id=ORGANISATION_ID, year=financial_year, ) - mock.assert_called_once_with(ORGANISATION_ID, financial_year) + mock.assert_called_once_with(ORGANISATION_ID, financial_year, False) assert normalize_spaces(page.select_one(".pill").text) == ( "2020 to 2021 fiscal year " "2019 to 2020 fiscal year " @@ -1538,6 +1538,11 @@ def test_organization_dashboard_shows_message_usage( "app.organizations_client.get_organization_services", return_value=[], ) + mocker.patch( + "app.organizations_client.get_services_and_usage", + return_value={"services": []}, + ) + client_request.login(active_user_with_permissions) page = client_request.get( ".organization_dashboard", @@ -1576,6 +1581,43 @@ def test_organization_dashboard_shows_service_counts( service_json(id_="3", name="Suspended", restricted=False, active=False), ], ) + mocker.patch( + "app.organizations_client.get_services_and_usage", + return_value={ + "services": [ + { + "service_id": "1", + "service_name": "Live Service", + "emails_sent": 1000, + "sms_billable_units": 300, + "sms_remainder": 249700, + "sms_cost": 25.50, + "active": True, + "restricted": False, + }, + { + "service_id": "2", + "service_name": "Trial Service", + "emails_sent": 500, + "sms_billable_units": 50, + "sms_remainder": 249950, + "sms_cost": 0, + "active": True, + "restricted": True, + }, + { + "service_id": "3", + "service_name": "Suspended", + "emails_sent": 0, + "sms_billable_units": 0, + "sms_remainder": 250000, + "sms_cost": 0, + "active": False, + "restricted": False, + }, + ] + }, + ) client_request.login(active_user_with_permissions) page = client_request.get( @@ -1592,3 +1634,79 @@ def test_organization_dashboard_shows_service_counts( 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) + + +def test_organization_dashboard_services_table_shows_usage( + client_request, + mock_get_organization, + mocker, + active_user_with_permissions, +): + mocker.patch( + "app.organizations_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="Service One", restricted=False, active=True), + service_json(id_="2", name="Service Two", restricted=True, active=True), + ], + ) + mocker.patch( + "app.organizations_client.get_services_and_usage", + return_value={ + "services": [ + { + "service_id": "1", + "service_name": "Service One", + "emails_sent": 1500, + "sms_billable_units": 500, + "sms_remainder": 249500, + "sms_cost": 42.75, + "active": True, + "restricted": False, + }, + { + "service_id": "2", + "service_name": "Service Two", + "emails_sent": 250, + "sms_billable_units": 100, + "sms_remainder": 249900, + "sms_cost": 0, + "active": True, + "restricted": True, + }, + ] + }, + ) + + client_request.login(active_user_with_permissions) + page = client_request.get( + ".organization_dashboard", + org_id=ORGANISATION_ID, + ) + + table = page.select_one("table.usa-table") + assert table is not None + + rows = table.select("tbody tr") + assert len(rows) == 2 + + first_row_cells = rows[0].select("td") + assert normalize_spaces(first_row_cells[0].text) == "Service One" + assert normalize_spaces(first_row_cells[1].text) == "Live" + assert "1,500 emails" in normalize_spaces(first_row_cells[2].text) + assert "500 sms" in normalize_spaces(first_row_cells[2].text) + assert "249,500 remaining" in normalize_spaces(first_row_cells[2].text) + + second_row_cells = rows[1].select("td") + assert normalize_spaces(second_row_cells[0].text) == "Service Two" + assert normalize_spaces(second_row_cells[1].text) == "Trial" + assert "250 emails" in normalize_spaces(second_row_cells[2].text) + assert "100 sms" in normalize_spaces(second_row_cells[2].text) + assert "249,900 remaining" in normalize_spaces(second_row_cells[2].text)