From d54ca85de24a8a384551ba4435a93bee37154725 Mon Sep 17 00:00:00 2001 From: Lauryn Menard Date: Wed, 28 Aug 2024 16:27:10 +0200 Subject: [PATCH] activity: Add view to see the ledger entries for a customer plan. Adds a link to the plan ledger view in the current plan information shown in the support views. Link is not shown if the plan is 100% sponsored, e.g., the Community plan. Adds a formatted header area to the activity table template so that it's easy to add useful information to these activity views. --- corporate/lib/activity.py | 11 +++- corporate/tests/test_activity_views.py | 4 ++ corporate/urls.py | 2 + corporate/views/plan_activity.py | 64 +++++++++++++++++++ .../corporate/activity/activity_table.html | 8 +++ .../support/current_plan_details.html | 1 + tools/test-backend | 1 + web/styles/portico/activity.css | 12 ++++ 8 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 corporate/views/plan_activity.py diff --git a/corporate/lib/activity.py b/corporate/lib/activity.py index 9ade58731f..01cd25e940 100644 --- a/corporate/lib/activity.py +++ b/corporate/lib/activity.py @@ -42,11 +42,18 @@ class RemoteActivityPlanData: rate: str +@dataclass +class ActivityHeaderEntry: + name: str + value: str | Markup + + def make_table( title: str, cols: Sequence[str], rows: Sequence[Any], *, + header: list[ActivityHeaderEntry] | None = None, totals: Any | None = None, stats_link: Markup | None = None, has_row_class: bool = False, @@ -58,7 +65,9 @@ def make_table( rows = list(map(fix_row, rows)) - data = dict(title=title, cols=cols, rows=rows, totals=totals, stats_link=stats_link) + data = dict( + title=title, cols=cols, rows=rows, header=header, totals=totals, stats_link=stats_link + ) content = loader.render_to_string( "corporate/activity/activity_table.html", diff --git a/corporate/tests/test_activity_views.py b/corporate/tests/test_activity_views.py index 9fe6c3d514..c2afcfc972 100644 --- a/corporate/tests/test_activity_views.py +++ b/corporate/tests/test_activity_views.py @@ -208,6 +208,10 @@ class ActivityTest(ZulipTestCase): result = self.client_get(f"/user_activity/{iago.id}/") self.assertEqual(result.status_code, 200) + with self.assert_database_query_count(8): + result = self.client_get(f"/activity/plan_ledger/{plan.id}/") + self.assertEqual(result.status_code, 200) + def test_get_remote_server_guest_and_non_guest_count(self) -> None: RemoteRealmAuditLog.objects.bulk_create([RemoteRealmAuditLog(**data) for data in data_list]) server_id = 1 diff --git a/corporate/urls.py b/corporate/urls.py index 3d6f359bd9..b61ccb60f3 100644 --- a/corporate/urls.py +++ b/corporate/urls.py @@ -25,6 +25,7 @@ from corporate.views.installation_activity import ( get_installation_activity, get_integrations_activity, ) +from corporate.views.plan_activity import get_plan_ledger from corporate.views.portico import ( app_download_link_redirect, apps_view, @@ -105,6 +106,7 @@ i18n_urlpatterns: Any = [ path("user_activity//", get_user_activity), path("activity/remote", get_remote_server_activity), path("activity/remote/support", remote_servers_support, name="remote_servers_support"), + path("activity/plan_ledger//", get_plan_ledger), ] v1_api_and_json_patterns = [ diff --git a/corporate/views/plan_activity.py b/corporate/views/plan_activity.py new file mode 100644 index 0000000000..b555b20d76 --- /dev/null +++ b/corporate/views/plan_activity.py @@ -0,0 +1,64 @@ +from typing import Any + +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render + +from corporate.lib.activity import ActivityHeaderEntry, format_optional_datetime, make_table +from corporate.models import Customer, CustomerPlan, LicenseLedger +from zerver.decorator import require_server_admin + + +def get_plan_billing_entity_name(customer: Customer) -> str: + if customer.realm: + return customer.realm.name + elif customer.remote_realm: + return customer.remote_realm.name + assert customer.remote_server is not None + return customer.remote_server.hostname + + +@require_server_admin +def get_plan_ledger(request: HttpRequest, plan_id: int) -> HttpResponse: + plan = CustomerPlan.objects.get(id=plan_id) + ledger_entries = LicenseLedger.objects.filter(plan=plan).order_by("-event_time") + + name = get_plan_billing_entity_name(plan.customer) + title = f"{name}" + cols = [ + "Event time (UTC)", + "Renewal", + "License count", + "Renewal count", + ] + + def row(record: LicenseLedger) -> list[Any]: + return [ + format_optional_datetime(record.event_time), + record.is_renewal, + record.licenses, + record.licenses_at_next_renewal, + ] + + rows = list(map(row, ledger_entries)) + + header_entries = [] + header_entries.append( + ActivityHeaderEntry(name="Plan name", value=CustomerPlan.name_from_tier(plan.tier)) + ) + header_entries.append( + ActivityHeaderEntry( + name="Next invoice (UTC)", value=format_optional_datetime(plan.next_invoice_date, True) + ) + ) + + content = make_table(title, cols, rows, header=header_entries) + + return render( + request, + "corporate/activity/activity.html", + context=dict( + data=content, + title=title, + is_home=False, + ), + ) diff --git a/templates/corporate/activity/activity_table.html b/templates/corporate/activity/activity_table.html index 7657901f3a..2fba6b71f1 100644 --- a/templates/corporate/activity/activity_table.html +++ b/templates/corporate/activity/activity_table.html @@ -4,6 +4,14 @@ {% include "corporate/activity/remote_activity_key.html" %} {% endif %} +{% if data.header %} +
+ {% for entry in data.header %} +

{{ entry.name }}: {{ entry.value }}

+ {% endfor %} +
+{% endif %} + {{ data.rows|length}} rows diff --git a/templates/corporate/support/current_plan_details.html b/templates/corporate/support/current_plan_details.html index 4a91ef2f79..285c0ec48f 100644 --- a/templates/corporate/support/current_plan_details.html +++ b/templates/corporate/support/current_plan_details.html @@ -33,5 +33,6 @@ Annual recurring revenue: ${{ dollar_amount(plan_data.annual_recurring_revenue) }}
Start of next billing cycle: {{ plan_data.next_billing_cycle_start.strftime('%d %B %Y') }}
{% endif %} + License ledger entries
{% endif %} diff --git a/tools/test-backend b/tools/test-backend index 204c102ee2..c48bff0918 100755 --- a/tools/test-backend +++ b/tools/test-backend @@ -54,6 +54,7 @@ not_yet_fully_covered = [ # TODO: This is a work in progress and therefore without # tests yet. "corporate/views/installation_activity.py", + "corporate/views/plan_activity.py", "corporate/views/realm_activity.py", "corporate/views/remote_billing_page.py", "corporate/views/support.py", diff --git a/web/styles/portico/activity.css b/web/styles/portico/activity.css index 64bbb271a3..933a7e28fb 100644 --- a/web/styles/portico/activity.css +++ b/web/styles/portico/activity.css @@ -463,6 +463,7 @@ tr.admin td:first-child { padding-bottom: 25px; } +.activity-header-information, .push-notification-status, .realm-management-actions, .next-plan-container, @@ -473,6 +474,17 @@ tr.admin td:first-child { margin: 10px 0; } +.activity-header-information { + border: 2px solid hsl(330deg 3% 40%); + background-color: hsl(60deg 12% 90%); + width: fit-content; +} + +.activity-header-entry { + margin: 0; + padding: 2px 0; +} + .push-notification-status, .realm-management-actions { border: 2px solid hsl(186deg 76% 36%);