Merge pull request #3022 from GSA/3021-create-aggregate-table-of-services-per-org

3021 create aggregate table of services per org
This commit is contained in:
ccostino
2025-10-29 11:46:13 -04:00
committed by GitHub
4 changed files with 197 additions and 12 deletions

View File

@@ -76,6 +76,39 @@ def get_organization_message_allowance(org_id):
}
def get_services_dashboard_data(organization, year):
try:
dashboard_data = organizations_client.get_organization_dashboard(organization.id, year)
services = dashboard_data.get("services", [])
except Exception as e:
current_app.logger.error(f"Error fetching dashboard data: {e}")
return []
for service in services:
service["id"] = service.get("service_id")
service["name"] = service.get("service_name")
service["recent_template"] = service.get("recent_sms_template_name") or "N/A"
service["primary_contact"] = service.get("primary_contact") or "N/A"
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"
return services
@main.route("/organizations/<uuid:org_id>", methods=["GET"])
@user_has_permissions()
def organization_dashboard(org_id):
@@ -86,9 +119,12 @@ def organization_dashboard(org_id):
message_allowance = get_organization_message_allowance(org_id)
services_with_usage = get_services_dashboard_data(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),

View File

@@ -83,5 +83,11 @@ class OrganizationsClient(NotifyAdminAPIClient):
url="/organizations/{}/message-allowance".format(org_id),
)
def get_organization_dashboard(self, org_id, year):
return self.get(
url=f"/organizations/{org_id}/dashboard",
params={"year": str(year)},
)
organizations_client = OrganizationsClient()

View File

@@ -58,19 +58,65 @@
</div>
</div>
<details class="usa-details">
<summary class="usa-details__summary font-heading-lg ">What is a service?</summary>
<div class="usa-details__content">
<p class="usa-body">
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:
</p>
<ol class="usa-list">
<li>Create and edit message templates</li>
<li>Send messages to recipients</li>
<li>View message status and history</li>
<li>Manage team members and permissions</li>
<li>Track usage and delivery statistics</li>
<li>If you work for multiple organizations, you may belong to multiple services and can switch between them.</li>
</ol>
</div>
</details>
<div class="margin-bottom-5">
<h2 class="font-heading-lg">What is a service?</h2>
<p class="usa-body">
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:
</p>
<ol class="usa-list">
<li>Create and edit message templates</li>
<li>Send messages to recipients</li>
<li>View message status and history</li>
<li>Manage team members and permissions</li>
<li>Track usage and delivery statistics</li>
<li>If you work for multiple organizations, you may belong to multiple services and can switch between them.</li>
</ol>
<h2 class="font-heading-lg margin-bottom-2">Services Overview</h2>
<div class="table-overflow-x-auto">
<table class="usa-table">
<thead>
<tr>
<th scope="col" role="columnheader">Name</th>
<th scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader">Usage</th>
<th scope="col" role="columnheader">Primary Contact</th>
<th scope="col" role="columnheader">Recent Template Used</th>
</tr>
</thead>
<tbody>
{% if services %}
{% for service in services %}
<tr>
<td><a href="{{ url_for('main.service_dashboard', service_id=service.id) }}" class="usa-link">{{ service.name }}</a></td>
<td>
{% if not service.active %}
Suspended
{% elif service.restricted %}
Trial
{% else %}
Live
{% endif %}
</td>
<td>{{ service.usage }}</td>
<td>{{ service.primary_contact }}</td>
<td>{{ service.recent_template }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="5" class="table-empty-message">No services found within this organization</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -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_organization_dashboard",
return_value={"services": []},
)
client_request.login(active_user_with_permissions)
page = client_request.get(
".organization_dashboard",
@@ -1576,6 +1581,16 @@ def test_organization_dashboard_shows_service_counts(
service_json(id_="3", name="Suspended", restricted=False, active=False),
],
)
mocker.patch(
"app.organizations_client.get_organization_dashboard",
return_value={
"services": [
{"service_id": "1", "service_name": "Live Service", "active": True, "restricted": False},
{"service_id": "2", "service_name": "Trial Service", "active": True, "restricted": True},
{"service_id": "3", "service_name": "Suspended", "active": False, "restricted": False},
]
},
)
client_request.login(active_user_with_permissions)
page = client_request.get(
@@ -1592,3 +1607,85 @@ 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(
client_request,
mock_get_organization,
mocker,
active_user_with_permissions,
):
mocker.patch.dict("flask.current_app.config", {"ORGANIZATION_DASHBOARD_ENABLED": True})
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_organization_dashboard",
return_value={
"services": [
{
"service_id": "1",
"service_name": "Service One",
"active": True,
"restricted": False,
"emails_sent": 1500,
"sms_billable_units": 500,
"sms_remainder": 249500,
"sms_cost": 42.75,
"recent_sms_template_name": "Welcome SMS",
"primary_contact": None,
},
{
"service_id": "2",
"service_name": "Service Two",
"active": True,
"restricted": True,
"emails_sent": 250,
"sms_billable_units": 100,
"sms_remainder": 249900,
"sms_cost": 0,
"recent_sms_template_name": "Reminder SMS",
"primary_contact": None,
},
]
},
)
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)
assert normalize_spaces(first_row_cells[3].text) == "N/A"
assert normalize_spaces(first_row_cells[4].text) == "Welcome SMS"
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)
assert normalize_spaces(second_row_cells[3].text) == "N/A"
assert normalize_spaces(second_row_cells[4].text) == "Reminder SMS"