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.
This commit is contained in:
Lauryn Menard 2024-08-28 16:27:10 +02:00 committed by Tim Abbott
parent 704423787b
commit d54ca85de2
8 changed files with 102 additions and 1 deletions

View File

@ -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",

View File

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

View File

@ -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/<user_profile_id>/", 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/<plan_id>/", get_plan_ledger),
]
v1_api_and_json_patterns = [

View File

@ -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,
),
)

View File

@ -4,6 +4,14 @@
{% include "corporate/activity/remote_activity_key.html" %}
{% endif %}
{% if data.header %}
<div class="activity-header-information">
{% for entry in data.header %}
<p class="activity-header-entry"><b>{{ entry.name }}</b>: {{ entry.value }}</p>
{% endfor %}
</div>
{% endif %}
{{ data.rows|length}} rows
<table class="table sortable table-striped table-bordered analytics-table">

View File

@ -33,5 +33,6 @@
<b>Annual recurring revenue</b>: ${{ dollar_amount(plan_data.annual_recurring_revenue) }}<br />
<b>Start of next billing cycle</b>: {{ plan_data.next_billing_cycle_start.strftime('%d %B %Y') }}<br />
{% endif %}
<a target="_blank" rel="noopener noreferrer" href="/activity/plan_ledger/{{ plan_data.current_plan.id }}/">License ledger entries</a><br />
{% endif %}
</div>

View File

@ -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",

View File

@ -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%);