snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
This commit is contained in:
422
corporate/lib/activity.py
Normal file
422
corporate/lib/activity.py
Normal file
@@ -0,0 +1,422 @@
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
from django.db.backends.utils import CursorWrapper
|
||||
from django.db.models import Prefetch
|
||||
from django.template import loader
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from markupsafe import Markup
|
||||
from psycopg2.sql import Composable
|
||||
|
||||
from corporate.models.licenses import LicenseLedger
|
||||
from corporate.models.plans import CustomerPlan
|
||||
from zerver.lib.pysa import mark_sanitized
|
||||
from zerver.models import Realm
|
||||
from zilencer.models import (
|
||||
RemoteCustomerUserCount,
|
||||
RemoteRealm,
|
||||
RemoteRealmAuditLog,
|
||||
RemoteZulipServer,
|
||||
get_remote_customer_user_count,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemoteActivityPlanData:
|
||||
current_status: str
|
||||
current_plan_name: str
|
||||
annual_revenue: int
|
||||
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,
|
||||
title_link: Markup | None = None,
|
||||
has_row_class: bool = False,
|
||||
) -> str:
|
||||
if not has_row_class:
|
||||
|
||||
def fix_row(row: Any) -> dict[str, Any]:
|
||||
return dict(cells=row, row_class=None)
|
||||
|
||||
rows = list(map(fix_row, rows))
|
||||
|
||||
data = dict(
|
||||
title=title, cols=cols, rows=rows, header=header, totals=totals, title_link=title_link
|
||||
)
|
||||
|
||||
content = loader.render_to_string(
|
||||
"corporate/activity/activity_table.html",
|
||||
dict(data=data),
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def fix_rows(
|
||||
rows: list[list[Any]],
|
||||
i: int,
|
||||
fixup_func: Callable[[str], Markup] | Callable[[datetime], str] | Callable[[int], int],
|
||||
) -> None:
|
||||
for row in rows:
|
||||
row[i] = fixup_func(row[i])
|
||||
|
||||
|
||||
def get_query_data(query: Composable) -> list[list[Any]]:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(query)
|
||||
rows = cursor.fetchall()
|
||||
rows = list(map(list, rows))
|
||||
cursor.close()
|
||||
return rows
|
||||
|
||||
|
||||
def dictfetchall(cursor: CursorWrapper) -> list[dict[str, Any]]:
|
||||
"""Returns all rows from a cursor as a dict"""
|
||||
desc = cursor.description
|
||||
return [dict(zip((col[0] for col in desc), row, strict=False)) for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def format_optional_datetime(date: datetime | None, display_none: bool = False) -> str:
|
||||
if date:
|
||||
return date.replace(tzinfo=None).isoformat(" ", "minutes")
|
||||
elif display_none:
|
||||
return "None"
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
def format_datetime_as_date(date: datetime) -> str:
|
||||
return date.date().isoformat()
|
||||
|
||||
|
||||
def format_none_as_zero(value: int | None) -> int:
|
||||
if value:
|
||||
return value
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
def user_activity_link(link_text: str, user_profile_id: int) -> Markup:
|
||||
from corporate.views.user_activity import get_user_activity
|
||||
|
||||
url = reverse(get_user_activity, kwargs=dict(user_profile_id=user_profile_id))
|
||||
if link_text == "":
|
||||
return Markup('<a href="{url}"><i class="fa fa-user-circle"></i></a>').format(url=url)
|
||||
return Markup('<a href="{url}">{link_text}</a>').format(url=url, link_text=link_text)
|
||||
|
||||
|
||||
def realm_activity_link(realm_str: str) -> Markup:
|
||||
from corporate.views.realm_activity import get_realm_activity
|
||||
|
||||
url = reverse(get_realm_activity, kwargs=dict(realm_str=realm_str))
|
||||
return Markup('<a href="{url}"><i class="fa fa-table"></i></a>').format(url=url)
|
||||
|
||||
|
||||
def realm_stats_link(realm_str: str) -> Markup:
|
||||
from analytics.views.stats import stats_for_realm
|
||||
|
||||
url = reverse(stats_for_realm, kwargs=dict(realm_str=realm_str))
|
||||
return Markup('<a href="{url}"><i class="fa fa-pie-chart"></i></a>').format(url=url)
|
||||
|
||||
|
||||
def user_support_link(email: str) -> Markup:
|
||||
url = reverse("support", query={"q": email})
|
||||
return Markup('<a href="{url}"><i class="fa fa-gear"></i></a>').format(url=url)
|
||||
|
||||
|
||||
def realm_support_link(realm_str: str) -> Markup:
|
||||
url = reverse("support", query={"q": realm_str})
|
||||
return Markup('<a href="{url}">{realm}</i></a>').format(url=url, realm=realm_str)
|
||||
|
||||
|
||||
def realm_url_link(realm_str: str) -> Markup:
|
||||
host = Realm.host_for_subdomain(realm_str)
|
||||
url = settings.EXTERNAL_URI_SCHEME + mark_sanitized(host)
|
||||
return Markup('<a href="{url}"><i class="fa fa-home"></i></a>').format(url=url)
|
||||
|
||||
|
||||
def remote_installation_stats_link(server_id: int) -> Markup:
|
||||
from analytics.views.stats import stats_for_remote_installation
|
||||
|
||||
url = reverse(stats_for_remote_installation, kwargs=dict(remote_server_id=server_id))
|
||||
return Markup('<a href="{url}"><i class="fa fa-pie-chart"></i></a>').format(url=url)
|
||||
|
||||
|
||||
def remote_installation_support_link(hostname: str) -> Markup:
|
||||
url = reverse("remote_servers_support", query={"q": hostname})
|
||||
return Markup('<a href="{url}"><i class="fa fa-gear"></i></a>').format(url=url)
|
||||
|
||||
|
||||
def get_plan_rate_percentage(discount: str | None, has_fixed_price: bool) -> str:
|
||||
# We want to clearly note plans with a fixed price, and not show
|
||||
# them as paying 100%, as they are usually a special, negotiated
|
||||
# rate with the customer.
|
||||
if has_fixed_price:
|
||||
return "Fixed"
|
||||
|
||||
# CustomerPlan.discount is a string field that stores the discount.
|
||||
if discount is None or discount == "0":
|
||||
return "100%"
|
||||
|
||||
rate = 100 - Decimal(discount)
|
||||
if rate * 100 % 100 == 0:
|
||||
precision = 0
|
||||
else:
|
||||
precision = 2
|
||||
return f"{rate:.{precision}f}%"
|
||||
|
||||
|
||||
def get_remote_activity_plan_data(
|
||||
plan: CustomerPlan,
|
||||
license_ledger: LicenseLedger,
|
||||
*,
|
||||
remote_realm: RemoteRealm | None = None,
|
||||
remote_server: RemoteZulipServer | None = None,
|
||||
) -> RemoteActivityPlanData:
|
||||
from corporate.lib.stripe import RemoteRealmBillingSession, RemoteServerBillingSession
|
||||
|
||||
has_fixed_price = plan.fixed_price is not None
|
||||
if plan.tier == CustomerPlan.TIER_SELF_HOSTED_LEGACY or plan.status in (
|
||||
CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL,
|
||||
CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE,
|
||||
):
|
||||
renewal_cents = 0
|
||||
current_rate = "---"
|
||||
elif plan.tier == CustomerPlan.TIER_SELF_HOSTED_COMMUNITY:
|
||||
renewal_cents = 0
|
||||
current_rate = "0%"
|
||||
elif remote_realm is not None:
|
||||
renewal_cents = RemoteRealmBillingSession(
|
||||
remote_realm=remote_realm
|
||||
).get_annual_recurring_revenue_for_support_data(plan, license_ledger)
|
||||
current_rate = get_plan_rate_percentage(plan.discount, has_fixed_price)
|
||||
else:
|
||||
assert remote_server is not None
|
||||
renewal_cents = RemoteServerBillingSession(
|
||||
remote_server=remote_server
|
||||
).get_annual_recurring_revenue_for_support_data(plan, license_ledger)
|
||||
current_rate = get_plan_rate_percentage(plan.discount, has_fixed_price)
|
||||
|
||||
return RemoteActivityPlanData(
|
||||
current_status=plan.get_plan_status_as_text(),
|
||||
current_plan_name=plan.name,
|
||||
annual_revenue=renewal_cents,
|
||||
rate=current_rate,
|
||||
)
|
||||
|
||||
|
||||
def get_estimated_arr_and_rate_by_realm() -> tuple[dict[str, int], dict[str, str]]: # nocoverage
|
||||
from corporate.lib.stripe import RealmBillingSession
|
||||
|
||||
# NOTE: Customers without a plan might still have a discount attached to them which
|
||||
# are not included in `plan_rate`.
|
||||
annual_revenue = {}
|
||||
plan_rate = {}
|
||||
plans = (
|
||||
CustomerPlan.objects.filter(
|
||||
status=CustomerPlan.ACTIVE,
|
||||
customer__remote_realm__isnull=True,
|
||||
customer__remote_server__isnull=True,
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"licenseledger_set",
|
||||
queryset=LicenseLedger.objects.order_by("plan", "-id").distinct("plan"),
|
||||
to_attr="latest_ledger_entry",
|
||||
)
|
||||
)
|
||||
.select_related("customer__realm")
|
||||
)
|
||||
|
||||
for plan in plans:
|
||||
assert plan.customer.realm is not None
|
||||
latest_ledger_entry = plan.latest_ledger_entry[0]
|
||||
assert latest_ledger_entry is not None
|
||||
renewal_cents = RealmBillingSession(
|
||||
realm=plan.customer.realm
|
||||
).get_annual_recurring_revenue_for_support_data(plan, latest_ledger_entry)
|
||||
annual_revenue[plan.customer.realm.string_id] = renewal_cents
|
||||
has_fixed_price = plan.fixed_price is not None
|
||||
plan_rate[plan.customer.realm.string_id] = get_plan_rate_percentage(
|
||||
plan.discount, has_fixed_price
|
||||
)
|
||||
return annual_revenue, plan_rate
|
||||
|
||||
|
||||
def get_plan_data_by_remote_server() -> dict[int, RemoteActivityPlanData]: # nocoverage
|
||||
remote_server_plan_data: dict[int, RemoteActivityPlanData] = {}
|
||||
plans = (
|
||||
CustomerPlan.objects.filter(
|
||||
status__lt=CustomerPlan.LIVE_STATUS_THRESHOLD,
|
||||
customer__realm__isnull=True,
|
||||
customer__remote_realm__isnull=True,
|
||||
customer__remote_server__deactivated=False,
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"licenseledger_set",
|
||||
queryset=LicenseLedger.objects.order_by("plan", "-id").distinct("plan"),
|
||||
to_attr="latest_ledger_entry",
|
||||
)
|
||||
)
|
||||
.select_related("customer__remote_server")
|
||||
)
|
||||
|
||||
for plan in plans:
|
||||
server_id = None
|
||||
assert plan.customer.remote_server is not None
|
||||
server_id = plan.customer.remote_server.id
|
||||
assert server_id is not None
|
||||
|
||||
latest_ledger_entry = plan.latest_ledger_entry[0]
|
||||
assert latest_ledger_entry is not None
|
||||
|
||||
plan_data = get_remote_activity_plan_data(
|
||||
plan, latest_ledger_entry, remote_server=plan.customer.remote_server
|
||||
)
|
||||
|
||||
current_data = remote_server_plan_data.get(server_id)
|
||||
if current_data is not None:
|
||||
current_revenue = remote_server_plan_data[server_id].annual_revenue
|
||||
current_plans = remote_server_plan_data[server_id].current_plan_name
|
||||
# There should only ever be one CustomerPlan for a remote server with
|
||||
# a status that is less than the CustomerPlan.LIVE_STATUS_THRESHOLD.
|
||||
remote_server_plan_data[server_id] = RemoteActivityPlanData(
|
||||
current_status="ERROR: MULTIPLE PLANS",
|
||||
current_plan_name=f"{current_plans}, {plan_data.current_plan_name}",
|
||||
annual_revenue=current_revenue + plan_data.annual_revenue,
|
||||
rate="",
|
||||
)
|
||||
else:
|
||||
remote_server_plan_data[server_id] = plan_data
|
||||
return remote_server_plan_data
|
||||
|
||||
|
||||
def get_plan_data_by_remote_realm() -> dict[int, dict[int, RemoteActivityPlanData]]: # nocoverage
|
||||
remote_server_plan_data_by_realm: dict[int, dict[int, RemoteActivityPlanData]] = {}
|
||||
plans = (
|
||||
CustomerPlan.objects.filter(
|
||||
status__lt=CustomerPlan.LIVE_STATUS_THRESHOLD,
|
||||
customer__realm__isnull=True,
|
||||
customer__remote_server__isnull=True,
|
||||
customer__remote_realm__is_system_bot_realm=False,
|
||||
customer__remote_realm__realm_deactivated=False,
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"licenseledger_set",
|
||||
queryset=LicenseLedger.objects.order_by("plan", "-id").distinct("plan"),
|
||||
to_attr="latest_ledger_entry",
|
||||
)
|
||||
)
|
||||
.select_related("customer__remote_realm")
|
||||
)
|
||||
|
||||
for plan in plans:
|
||||
server_id = None
|
||||
assert plan.customer.remote_realm is not None
|
||||
server_id = plan.customer.remote_realm.server_id
|
||||
assert server_id is not None
|
||||
|
||||
latest_ledger_entry = plan.latest_ledger_entry[0]
|
||||
assert latest_ledger_entry is not None
|
||||
|
||||
plan_data = get_remote_activity_plan_data(
|
||||
plan, latest_ledger_entry, remote_realm=plan.customer.remote_realm
|
||||
)
|
||||
|
||||
current_server_data = remote_server_plan_data_by_realm.get(server_id)
|
||||
realm_id = plan.customer.remote_realm.id
|
||||
|
||||
if current_server_data is None:
|
||||
realm_dict = {realm_id: plan_data}
|
||||
remote_server_plan_data_by_realm[server_id] = realm_dict
|
||||
else:
|
||||
assert current_server_data is not None
|
||||
current_realm_data = current_server_data.get(realm_id)
|
||||
if current_realm_data is not None:
|
||||
# There should only ever be one CustomerPlan for a remote realm with
|
||||
# a status that is less than the CustomerPlan.LIVE_STATUS_THRESHOLD.
|
||||
current_revenue = current_realm_data.annual_revenue
|
||||
current_plans = current_realm_data.current_plan_name
|
||||
current_server_data[realm_id] = RemoteActivityPlanData(
|
||||
current_status="ERROR: MULTIPLE PLANS",
|
||||
current_plan_name=f"{current_plans}, {plan_data.current_plan_name}",
|
||||
annual_revenue=current_revenue + plan_data.annual_revenue,
|
||||
rate="",
|
||||
)
|
||||
else:
|
||||
current_server_data[realm_id] = plan_data
|
||||
|
||||
return remote_server_plan_data_by_realm
|
||||
|
||||
|
||||
def get_remote_realm_user_counts(
|
||||
event_time: datetime | None = None,
|
||||
) -> dict[int, RemoteCustomerUserCount]: # nocoverage
|
||||
user_counts_by_realm: dict[int, RemoteCustomerUserCount] = {}
|
||||
for log in (
|
||||
RemoteRealmAuditLog.objects.filter(
|
||||
event_type__in=RemoteRealmAuditLog.SYNCED_BILLING_EVENTS,
|
||||
event_time__lte=timezone_now() if event_time is None else event_time,
|
||||
remote_realm__isnull=False,
|
||||
)
|
||||
# Important: extra_data is empty for some pre-2020 audit logs
|
||||
# prior to the introduction of realm_user_count_by_role
|
||||
# logging. Meanwhile, modern Zulip servers using
|
||||
# bulk_create_users to create the users in the system bot
|
||||
# realm also generate such audit logs. Such audit logs should
|
||||
# never be the latest in a normal realm.
|
||||
.exclude(extra_data={})
|
||||
.order_by("remote_realm", "-event_time")
|
||||
.distinct("remote_realm")
|
||||
.select_related("remote_realm")
|
||||
):
|
||||
assert log.remote_realm is not None
|
||||
user_counts_by_realm[log.remote_realm.id] = get_remote_customer_user_count([log])
|
||||
|
||||
return user_counts_by_realm
|
||||
|
||||
|
||||
def get_remote_server_audit_logs(
|
||||
event_time: datetime | None = None,
|
||||
) -> dict[int, list[RemoteRealmAuditLog]]:
|
||||
logs_per_server: dict[int, list[RemoteRealmAuditLog]] = defaultdict(list)
|
||||
for log in (
|
||||
RemoteRealmAuditLog.objects.filter(
|
||||
event_type__in=RemoteRealmAuditLog.SYNCED_BILLING_EVENTS,
|
||||
event_time__lte=timezone_now() if event_time is None else event_time,
|
||||
)
|
||||
# Important: extra_data is empty for some pre-2020 audit logs
|
||||
# prior to the introduction of realm_user_count_by_role
|
||||
# logging. Meanwhile, modern Zulip servers using
|
||||
# bulk_create_users to create the users in the system bot
|
||||
# realm also generate such audit logs. Such audit logs should
|
||||
# never be the latest in a normal realm.
|
||||
.exclude(extra_data={})
|
||||
.order_by("server_id", "realm_id", "-event_time")
|
||||
.distinct("server_id", "realm_id")
|
||||
.select_related("server")
|
||||
):
|
||||
logs_per_server[log.server.id].append(log)
|
||||
|
||||
return logs_per_server
|
||||
Reference in New Issue
Block a user