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

This commit is contained in:
upstream-mirror
2026-09-15 15:05:30 +05:00
commit 64898b2c79
8999 changed files with 1757358 additions and 0 deletions

View File

View File

@@ -0,0 +1,102 @@
from typing import Any
from django.http import HttpRequest, HttpResponse, HttpResponseNotFound
from django.shortcuts import render
from corporate.lib.activity import ActivityHeaderEntry, format_optional_datetime, make_table
from zerver.decorator import require_server_admin
from zerver.lib.typed_endpoint import PathOnly
from zerver.models.realm_audit_logs import AbstractRealmAuditLog, AuditLogEventType
from zilencer.models import RemoteRealmAuditLog, RemoteZulipServer, RemoteZulipServerAuditLog
USER_ROLES_KEY = "100: owner, 200: admin, 300: moderator, 400: member, 600: guest"
def get_remote_realm_host(audit_log: RemoteRealmAuditLog) -> str:
if audit_log.remote_realm is None:
# For pre-8.0 servers, we might only have the realm ID and thus
# no RemoteRealm object yet, so we show that information instead.
return f"N/A, realm ID: {audit_log.realm_id}"
return audit_log.remote_realm.host
def get_human_role_count_data(audit_log: RemoteRealmAuditLog | RemoteZulipServerAuditLog) -> str:
extra_data = audit_log.extra_data
role_count = extra_data.get(AbstractRealmAuditLog.ROLE_COUNT, {})
human_count_raw: dict[str, Any] = role_count.get(AbstractRealmAuditLog.ROLE_COUNT_HUMANS, {})
if human_count_raw == {}:
return "N/A"
human_count_string = ""
for role, count in human_count_raw.items():
if int(count) > 0:
human_count_string += f"{(role)}: {count}, "
return human_count_string.strip(", ")
@require_server_admin
def get_remote_server_logs(request: HttpRequest, *, uuid: PathOnly[str]) -> HttpResponse:
try:
remote_server = RemoteZulipServer.objects.get(uuid=uuid)
except RemoteZulipServer.DoesNotExist:
return HttpResponseNotFound()
remote_server_audit_logs = RemoteZulipServerAuditLog.objects.filter(
server=remote_server
).order_by("-id")
remote_realm_audit_logs = (
RemoteRealmAuditLog.objects.filter(server=remote_server)
.order_by("-id")
.select_related("remote_realm")
)
title = f"{remote_server.hostname}"
cols = [
"Event time",
"Event type",
"Audit log ID",
"Remote realm host",
"Role count: human",
]
def row(audit_log: RemoteRealmAuditLog | RemoteZulipServerAuditLog) -> list[Any]:
return [
audit_log.event_time,
AuditLogEventType(audit_log.event_type).name,
audit_log.id if isinstance(audit_log, RemoteRealmAuditLog) else f"S{audit_log.id}",
get_remote_realm_host(audit_log) if isinstance(audit_log, RemoteRealmAuditLog) else "",
get_human_role_count_data(audit_log)
if audit_log.event_type in AbstractRealmAuditLog.SYNCED_BILLING_EVENTS
else "",
]
remote_server_audit_log_rows = list(map(row, remote_server_audit_logs))
remote_realm_audit_log_rows = list(map(row, remote_realm_audit_logs))
rows = remote_server_audit_log_rows + remote_realm_audit_log_rows
header_entries = []
if remote_server.last_version is not None:
header_entries.append(
ActivityHeaderEntry(
name="Zulip version",
value=remote_server.last_version,
)
)
header_entries.append(
ActivityHeaderEntry(
name="Last audit log update",
value=format_optional_datetime(remote_server.last_audit_log_update),
)
)
header_entries.append(ActivityHeaderEntry(name="Role key", value=USER_ROLES_KEY))
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

@@ -0,0 +1,363 @@
import logging
from typing import TYPE_CHECKING, Annotated, Any, Literal
from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils.translation import gettext as _
from pydantic import AfterValidator, Json
from corporate.lib.decorator import (
authenticated_remote_realm_management_endpoint,
authenticated_remote_server_management_endpoint,
)
from corporate.models.customers import get_customer_by_realm
from corporate.models.plans import CustomerPlan, get_current_plan_by_customer
from zerver.decorator import process_as_post, require_billing_access, zulip_login_required
from zerver.lib.exceptions import JsonableError
from zerver.lib.response import json_success
from zerver.lib.typed_endpoint import typed_endpoint
from zerver.lib.typed_endpoint_validators import check_int_in
from zerver.models import UserProfile
from zilencer.lib.remote_counts import MissingDataError
from zilencer.models import RemoteRealm, RemoteZulipServer
if TYPE_CHECKING:
from corporate.lib.stripe import RemoteRealmBillingSession, RemoteServerBillingSession
billing_logger = logging.getLogger("corporate.stripe")
ALLOWED_PLANS_API_STATUS_VALUES = [
CustomerPlan.ACTIVE,
CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE,
CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE,
CustomerPlan.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE,
CustomerPlan.FREE_TRIAL,
CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL,
CustomerPlan.ENDED,
]
@zulip_login_required
@typed_endpoint
def billing_page(
request: HttpRequest,
*,
success_message: str = "",
) -> HttpResponse:
from corporate.lib.stripe import RealmBillingSession
user = request.user
assert user.is_authenticated
billing_session = RealmBillingSession(user=user, realm=user.realm)
context: dict[str, Any] = {
"admin_access": user.has_billing_access,
"has_active_plan": False,
"org_name": billing_session.org_name(),
"billing_base_url": "",
}
if not user.has_billing_access:
return render(request, "corporate/billing/billing.html", context=context)
if user.realm.plan_type == user.realm.PLAN_TYPE_STANDARD_FREE:
return HttpResponseRedirect(reverse("sponsorship_request"))
customer = get_customer_by_realm(user.realm)
if customer is not None and customer.sponsorship_pending:
# Don't redirect to sponsorship page if the realm is on a paid plan
if not billing_session.on_paid_plan():
return HttpResponseRedirect(reverse("sponsorship_request"))
# If the realm is on a paid plan, show the sponsorship pending message
context["sponsorship_pending"] = True
if user.realm.plan_type == user.realm.PLAN_TYPE_LIMITED:
return HttpResponseRedirect(reverse("plans"))
if customer is None or get_current_plan_by_customer(customer) is None:
return HttpResponseRedirect(reverse("upgrade_page"))
main_context = billing_session.get_billing_page_context()
if main_context:
if main_context.get("current_plan_downgraded") is True:
return HttpResponseRedirect(reverse("plans"))
context.update(main_context)
context["success_message"] = success_message
return render(request, "corporate/billing/billing.html", context=context)
@typed_endpoint
@authenticated_remote_realm_management_endpoint
def remote_realm_billing_page(
request: HttpRequest,
billing_session: "RemoteRealmBillingSession",
*,
success_message: str = "",
) -> HttpResponse:
realm_uuid = billing_session.remote_realm.uuid
context: dict[str, Any] = {
# We wouldn't be here if user didn't have access.
"admin_access": billing_session.has_billing_access(),
"has_active_plan": False,
"org_name": billing_session.org_name(),
"billing_base_url": billing_session.billing_base_url,
}
if billing_session.remote_realm.plan_type == RemoteRealm.PLAN_TYPE_COMMUNITY: # nocoverage
return HttpResponseRedirect(reverse("remote_realm_sponsorship_page", args=(realm_uuid,)))
customer = billing_session.get_customer()
if customer is not None and customer.sponsorship_pending: # nocoverage
# Don't redirect to sponsorship page if the remote realm is on a paid plan or
# has scheduled an upgrade for their current complimentary access plan.
if (
not billing_session.on_paid_plan()
and billing_session.get_complimentary_access_next_plan_name(customer) is None
):
return HttpResponseRedirect(
reverse("remote_realm_sponsorship_page", args=(realm_uuid,))
)
# If the realm is on a paid plan, show the sponsorship pending message
context["sponsorship_pending"] = True
if (
customer is None
or get_current_plan_by_customer(customer) is None
or (
billing_session.get_complimentary_access_next_plan_name(customer) is None
and billing_session.remote_realm.plan_type
in [
RemoteRealm.PLAN_TYPE_SELF_MANAGED,
RemoteRealm.PLAN_TYPE_SELF_MANAGED_LEGACY,
]
)
): # nocoverage
return HttpResponseRedirect(reverse("remote_realm_plans_page", args=(realm_uuid,)))
try:
main_context = billing_session.get_billing_page_context()
except MissingDataError: # nocoverage
return billing_session.missing_data_error_page(request)
if main_context:
if main_context.get("current_plan_downgraded") is True:
return HttpResponseRedirect(reverse("remote_realm_plans_page", args=(realm_uuid,)))
context.update(main_context)
context["success_message"] = success_message
return render(request, "corporate/billing/billing.html", context=context)
@typed_endpoint
@authenticated_remote_server_management_endpoint
def remote_server_billing_page(
request: HttpRequest,
billing_session: "RemoteServerBillingSession",
*,
success_message: str = "",
) -> HttpResponse:
context: dict[str, Any] = {
# We wouldn't be here if user didn't have access.
"admin_access": billing_session.has_billing_access(),
"has_active_plan": False,
"org_name": billing_session.org_name(),
"billing_base_url": billing_session.billing_base_url,
}
if (
billing_session.remote_server.plan_type == RemoteZulipServer.PLAN_TYPE_COMMUNITY
): # nocoverage
return HttpResponseRedirect(
reverse(
"remote_server_sponsorship_page",
kwargs={"server_uuid": billing_session.remote_server.uuid},
)
)
customer = billing_session.get_customer()
if customer is not None and customer.sponsorship_pending:
# Don't redirect to sponsorship page if the remote realm is on a paid plan or
# has scheduled an upgrade for their current complimentary access plan.
if (
not billing_session.on_paid_plan()
and billing_session.get_complimentary_access_next_plan_name(customer) is None
):
return HttpResponseRedirect(
reverse(
"remote_server_sponsorship_page",
kwargs={"server_uuid": billing_session.remote_server.uuid},
)
)
# If the realm is on a paid plan, show the sponsorship pending message
context["sponsorship_pending"] = True # nocoverage
if (
customer is None
or get_current_plan_by_customer(customer) is None
or (
billing_session.get_complimentary_access_next_plan_name(customer) is None
and billing_session.remote_server.plan_type
in [
RemoteZulipServer.PLAN_TYPE_SELF_MANAGED,
RemoteZulipServer.PLAN_TYPE_SELF_MANAGED_LEGACY,
]
)
):
return HttpResponseRedirect(
reverse(
"remote_server_upgrade_page",
kwargs={"server_uuid": billing_session.remote_server.uuid},
)
)
try:
main_context = billing_session.get_billing_page_context()
except MissingDataError: # nocoverage
return billing_session.missing_data_error_page(request)
if main_context:
if main_context.get("current_plan_downgraded") is True:
return HttpResponseRedirect(
reverse(
"remote_server_plans_page",
kwargs={"server_uuid": billing_session.remote_server.uuid},
)
)
context.update(main_context)
context["success_message"] = success_message
return render(request, "corporate/billing/billing.html", context=context)
@require_billing_access
@typed_endpoint
def update_plan(
request: HttpRequest,
user: UserProfile,
*,
status: Annotated[
Json[int], AfterValidator(lambda x: check_int_in(x, ALLOWED_PLANS_API_STATUS_VALUES))
]
| None = None,
licenses: Json[int] | None = None,
licenses_at_next_renewal: Json[int] | None = None,
schedule: Json[int] | None = None,
toggle_license_management: Json[bool] = False,
) -> HttpResponse:
from corporate.lib.stripe import RealmBillingSession, UpdatePlanRequest
update_plan_request = UpdatePlanRequest(
status=status,
licenses=licenses,
licenses_at_next_renewal=licenses_at_next_renewal,
schedule=schedule,
toggle_license_management=toggle_license_management,
)
billing_session = RealmBillingSession(user=user)
billing_session.do_update_plan(update_plan_request)
return json_success(request)
@process_as_post
@typed_endpoint
@authenticated_remote_realm_management_endpoint
def update_plan_for_remote_realm(
request: HttpRequest,
billing_session: "RemoteRealmBillingSession",
*,
status: Annotated[
Json[int], AfterValidator(lambda x: check_int_in(x, ALLOWED_PLANS_API_STATUS_VALUES))
]
| None = None,
licenses: Json[int] | None = None,
licenses_at_next_renewal: Json[int] | None = None,
schedule: Json[int] | None = None,
toggle_license_management: Json[bool] = False,
) -> HttpResponse:
from corporate.lib.stripe import UpdatePlanRequest
update_plan_request = UpdatePlanRequest(
status=status,
licenses=licenses,
licenses_at_next_renewal=licenses_at_next_renewal,
schedule=schedule,
toggle_license_management=toggle_license_management,
)
billing_session.do_update_plan(update_plan_request)
return json_success(request)
@process_as_post
@typed_endpoint
@authenticated_remote_server_management_endpoint
def update_plan_for_remote_server(
request: HttpRequest,
billing_session: "RemoteServerBillingSession",
*,
status: Annotated[
Json[int], AfterValidator(lambda x: check_int_in(x, ALLOWED_PLANS_API_STATUS_VALUES))
]
| None = None,
licenses: Json[int] | None = None,
licenses_at_next_renewal: Json[int] | None = None,
schedule: Json[int] | None = None,
toggle_license_management: Json[bool] = False,
) -> HttpResponse:
from corporate.lib.stripe import UpdatePlanRequest
update_plan_request = UpdatePlanRequest(
status=status,
licenses=licenses,
licenses_at_next_renewal=licenses_at_next_renewal,
schedule=schedule,
toggle_license_management=toggle_license_management,
)
billing_session.do_update_plan(update_plan_request)
return json_success(request)
@typed_endpoint
@authenticated_remote_server_management_endpoint
def remote_server_deactivate_page(
request: HttpRequest,
billing_session: "RemoteServerBillingSession",
*,
confirmed: Literal["true"] | None = None,
) -> HttpResponse:
from corporate.lib.stripe import ServerDeactivateWithExistingPlanError
if request.method not in ["GET", "POST"]: # nocoverage
return HttpResponseNotAllowed(["GET", "POST"])
remote_server = billing_session.remote_server
context = {
"server_hostname": remote_server.hostname,
"action_url": reverse(remote_server_deactivate_page, args=[str(remote_server.uuid)]),
}
if request.method == "GET":
return render(
request, "corporate/billing/remote_billing_server_deactivate.html", context=context
)
assert request.method == "POST"
if confirmed is None: # nocoverage
# Should be impossible if the user is using the UI.
raise JsonableError(_("Parameter 'confirmed' is required"))
try:
billing_session.do_deactivate_remote_server()
except ServerDeactivateWithExistingPlanError: # nocoverage
context["show_existing_plan_error"] = "true"
return render(
request, "corporate/billing/remote_billing_server_deactivate.html", context=context
)
return render(
request,
"corporate/billing/remote_billing_server_deactivated_success.html",
context={"server_hostname": remote_server.hostname},
)

View File

@@ -0,0 +1,127 @@
import logging
from typing import TYPE_CHECKING
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from corporate.lib.decorator import (
authenticated_remote_realm_management_endpoint,
authenticated_remote_server_management_endpoint,
self_hosting_management_endpoint,
)
from zerver.decorator import require_billing_access, zulip_login_required
from zerver.lib.response import json_success
from zerver.lib.typed_endpoint import typed_endpoint
from zerver.models import UserProfile
if TYPE_CHECKING:
from corporate.lib.stripe import RemoteRealmBillingSession, RemoteServerBillingSession
billing_logger = logging.getLogger("corporate.stripe")
@require_billing_access
@typed_endpoint
def event_status(
request: HttpRequest,
user: UserProfile,
*,
stripe_session_id: str | None = None,
stripe_invoice_id: str | None = None,
) -> HttpResponse:
from corporate.lib.stripe import EventStatusRequest, RealmBillingSession
event_status_request = EventStatusRequest(
stripe_session_id=stripe_session_id, stripe_invoice_id=stripe_invoice_id
)
billing_session = RealmBillingSession(user)
data = billing_session.get_event_status(event_status_request)
return json_success(request, data)
@typed_endpoint
@authenticated_remote_realm_management_endpoint
def remote_realm_event_status(
request: HttpRequest,
billing_session: "RemoteRealmBillingSession",
*,
stripe_session_id: str | None = None,
stripe_invoice_id: str | None = None,
) -> HttpResponse:
from corporate.lib.stripe import EventStatusRequest
event_status_request = EventStatusRequest(
stripe_session_id=stripe_session_id, stripe_invoice_id=stripe_invoice_id
)
data = billing_session.get_event_status(event_status_request)
return json_success(request, data)
@typed_endpoint
@authenticated_remote_server_management_endpoint
def remote_server_event_status(
request: HttpRequest,
billing_session: "RemoteServerBillingSession",
*,
stripe_session_id: str | None = None,
stripe_invoice_id: str | None = None,
) -> HttpResponse: # nocoverage
from corporate.lib.stripe import EventStatusRequest
event_status_request = EventStatusRequest(
stripe_session_id=stripe_session_id, stripe_invoice_id=stripe_invoice_id
)
data = billing_session.get_event_status(event_status_request)
return json_success(request, data)
@zulip_login_required
@typed_endpoint
def event_status_page(
request: HttpRequest,
*,
stripe_session_id: str = "",
stripe_invoice_id: str = "",
) -> HttpResponse:
context = {
"stripe_session_id": stripe_session_id,
"stripe_invoice_id": stripe_invoice_id,
"billing_base_url": "",
}
return render(request, "corporate/billing/event_status.html", context=context)
@self_hosting_management_endpoint
@typed_endpoint
def remote_realm_event_status_page(
request: HttpRequest,
*,
realm_uuid: str = "",
server_uuid: str = "",
stripe_session_id: str = "",
stripe_invoice_id: str = "",
) -> HttpResponse: # nocoverage
context = {
"stripe_session_id": stripe_session_id,
"stripe_invoice_id": stripe_invoice_id,
"billing_base_url": f"/realm/{realm_uuid}",
}
return render(request, "corporate/billing/event_status.html", context=context)
@self_hosting_management_endpoint
@typed_endpoint
def remote_server_event_status_page(
request: HttpRequest,
*,
realm_uuid: str = "",
server_uuid: str = "",
stripe_session_id: str = "",
stripe_invoice_id: str = "",
) -> HttpResponse: # nocoverage
context = {
"stripe_session_id": stripe_session_id,
"stripe_invoice_id": stripe_invoice_id,
"billing_base_url": f"/server/{server_uuid}",
}
return render(request, "corporate/billing/event_status.html", context=context)

View File

@@ -0,0 +1,477 @@
from collections import defaultdict
from typing import Any, Literal
from django.conf import settings
from django.db import connection
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.template import loader
from django.utils.timezone import now as timezone_now
from markupsafe import Markup
from psycopg2.sql import SQL
from pydantic import Json
from analytics.lib.counts import COUNT_STATS
from corporate.lib.activity import (
dictfetchall,
fix_rows,
format_datetime_as_date,
format_optional_datetime,
get_estimated_arr_and_rate_by_realm,
get_query_data,
make_table,
realm_activity_link,
realm_stats_link,
realm_support_link,
realm_url_link,
)
from corporate.views.support import get_plan_type_string
from zerver.decorator import require_server_admin
from zerver.lib.typed_endpoint import typed_endpoint
from zerver.models import Realm
from zerver.models.realm_audit_logs import AuditLogEventType, RealmAuditLog
from zerver.models.realms import get_org_type_display_name
from zerver.models.users import UserProfile
def get_realm_day_counts() -> dict[str, dict[str, Markup]]:
# To align with UTC days, we subtract an hour from end_time to
# get the start_time, since the hour that starts at midnight was
# on the previous day.
query = SQL(
"""
select
r.string_id,
(now()::date - (end_time - interval '1 hour')::date) age,
coalesce(sum(value), 0) cnt
from zerver_realm r
join analytics_realmcount rc on r.id = rc.realm_id
where
property = 'messages_sent:is_bot:hour'
and
subgroup = 'false'
and
end_time > now()::date - interval '8 day' - interval '1 hour'
group by
r.string_id,
age
"""
)
cursor = connection.cursor()
cursor.execute(query)
rows = dictfetchall(cursor)
cursor.close()
counts: dict[str, dict[int, int]] = defaultdict(dict)
for row in rows:
counts[row["string_id"]][row["age"]] = row["cnt"]
def format_count(cnt: int, style: str | None = None) -> Markup:
if style is not None:
good_bad = style
elif cnt == min_cnt:
good_bad = "bad"
elif cnt == max_cnt:
good_bad = "good"
else:
good_bad = "neutral"
return Markup('<td class="number {good_bad}">{cnt}</td>').format(good_bad=good_bad, cnt=cnt)
result = {}
for string_id, realm_counts in counts.items():
raw_cnts = [realm_counts.get(age, 0) for age in range(8)]
min_cnt = min(raw_cnts[1:])
max_cnt = max(raw_cnts[1:])
cnts = format_count(raw_cnts[0], "neutral") + Markup().join(map(format_count, raw_cnts[1:]))
result[string_id] = dict(cnts=cnts)
return result
InstalationActivityFilter = Literal["all", "demo", "sponsored", "free", "paid"]
def get_where_clause_by_filter(filter: InstalationActivityFilter) -> SQL:
if filter == "demo":
return SQL(
"""
WHERE
_14day_active_humans IS NOT NULL
AND realm.demo_organization_scheduled_deletion_date IS NOT NULL
"""
)
elif filter == "sponsored":
return SQL(
"""
WHERE
_14day_active_humans IS NOT NULL
AND realm.plan_type = 4
"""
)
elif filter == "free":
return SQL(
"""
WHERE
_14day_active_humans IS NOT NULL
AND realm.demo_organization_scheduled_deletion_date IS NULL
AND realm.plan_type = 2
"""
)
elif filter == "paid":
return SQL(
"""
WHERE
realm.plan_type IN (3, 10)
"""
)
# Default to realms with a paid plan or with one active user in the last
# two weeks, i.e., "all" filter.
return SQL(
"""
WHERE
_14day_active_humans IS NOT NULL
OR realm.plan_type IN (3, 10)
"""
)
def realm_summary_table(export: bool, filter: InstalationActivityFilter) -> str:
from corporate.lib.stripe import cents_to_dollar_string
now = timezone_now()
where_clause = get_where_clause_by_filter(filter)
query = (
SQL(
"""
SELECT
realm.string_id,
realm.demo_organization_scheduled_deletion_date,
realm.date_created,
realm.plan_type,
realm.org_type,
coalesce(wau_table.value, 0) wau_count,
coalesce(dau_table.value, 0) dau_count,
coalesce(user_count_table.value, 0) user_profile_count,
coalesce(bot_count_table.value, 0) bot_count,
coalesce(realm_audit_log_table.how_realm_creator_found_zulip, '') how_realm_creator_found_zulip,
coalesce(realm_audit_log_table.how_realm_creator_found_zulip_extra_context, '') how_realm_creator_found_zulip_extra_context,
realm_admin_user.delivery_email admin_email
FROM
zerver_realm as realm
LEFT OUTER JOIN (
SELECT
value _14day_active_humans,
realm_id
from
analytics_realmcount
WHERE
property = 'realm_active_humans::day'
AND end_time = %(realm_active_humans_end_time)s
) as _14day_active_humans_table ON realm.id = _14day_active_humans_table.realm_id
LEFT OUTER JOIN (
SELECT
value,
realm_id
from
analytics_realmcount
WHERE
property = '7day_actives::day'
AND end_time = %(seven_day_actives_end_time)s
) as wau_table ON realm.id = wau_table.realm_id
LEFT OUTER JOIN (
SELECT
value,
realm_id
from
analytics_realmcount
WHERE
property = '1day_actives::day'
AND end_time = %(one_day_actives_end_time)s
) as dau_table ON realm.id = dau_table.realm_id
LEFT OUTER JOIN (
SELECT
value,
realm_id
from
analytics_realmcount
WHERE
property = 'active_users_audit:is_bot:day'
AND subgroup = 'false'
AND end_time = %(active_users_audit_end_time)s
) as user_count_table ON realm.id = user_count_table.realm_id
LEFT OUTER JOIN (
SELECT
value,
realm_id
from
analytics_realmcount
WHERE
property = 'active_users_audit:is_bot:day'
AND subgroup = 'true'
AND end_time = %(active_users_audit_end_time)s
) as bot_count_table ON realm.id = bot_count_table.realm_id
LEFT OUTER JOIN (
SELECT
extra_data->>'how_realm_creator_found_zulip' as how_realm_creator_found_zulip,
extra_data->>'how_realm_creator_found_zulip_extra_context' as how_realm_creator_found_zulip_extra_context,
realm_id
from
zerver_realmauditlog
WHERE
event_type = %(realm_creation_event_type)s
) as realm_audit_log_table ON realm.id = realm_audit_log_table.realm_id
LEFT OUTER JOIN (
SELECT
delivery_email,
realm_id
from
zerver_userprofile
WHERE
is_bot=False
AND is_active=True
AND role IN %(admin_roles)s
) as realm_admin_user ON realm.id = realm_admin_user.realm_id
"""
)
+ where_clause
+ SQL(
"""
ORDER BY
dau_count DESC,
string_id ASC
"""
)
)
cursor = connection.cursor()
cursor.execute(
query,
{
"realm_active_humans_end_time": COUNT_STATS[
"realm_active_humans::day"
].last_successful_fill(),
"seven_day_actives_end_time": COUNT_STATS["7day_actives::day"].last_successful_fill(),
"one_day_actives_end_time": COUNT_STATS["1day_actives::day"].last_successful_fill(),
"active_users_audit_end_time": COUNT_STATS[
"active_users_audit:is_bot:day"
].last_successful_fill(),
"realm_creation_event_type": AuditLogEventType.REALM_CREATED,
"admin_roles": (UserProfile.ROLE_REALM_ADMINISTRATOR, UserProfile.ROLE_REALM_OWNER),
},
)
raw_rows = dictfetchall(cursor)
cursor.close()
rows: list[dict[str, Any]] = []
admin_emails: dict[str, str] = {}
# Process duplicate realm rows due to multiple admin users,
# and collect all admin user emails into one string.
for row in raw_rows:
realm_string_id = row["string_id"]
admin_email = row.pop("admin_email")
if realm_string_id in admin_emails:
admin_emails[realm_string_id] = admin_emails[realm_string_id] + ", " + admin_email
else:
admin_emails[realm_string_id] = admin_email
rows.append(row)
realm_messages_per_day_counts = get_realm_day_counts()
total_arr = 0
num_active_sites = 0
total_dau_count = 0
total_user_profile_count = 0
total_bot_count = 0
total_wau_count = 0
if settings.BILLING_ENABLED:
estimated_arrs: dict[str, int] = {}
plan_rates: dict[str, str] = {}
if filter in ["all", "paid"]:
estimated_arrs, plan_rates = get_estimated_arr_and_rate_by_realm()
total_arr = sum(estimated_arrs.values())
else:
total_arr = 0
for row in rows:
realm_string_id = row.pop("string_id")
demo_organization_scheduled_deletion_date = row.pop(
"demo_organization_scheduled_deletion_date"
)
# Format fields and add links.
row["date_created_day"] = format_datetime_as_date(row["date_created"])
row["age_days"] = int((now - row["date_created"]).total_seconds() / 86400)
row["is_new"] = row["age_days"] < 12 * 7
row["org_type_string"] = get_org_type_display_name(row["org_type"])
row["realm_url"] = realm_url_link(realm_string_id)
row["stats_link"] = realm_stats_link(realm_string_id)
row["support_link"] = realm_support_link(realm_string_id)
row["activity_link"] = realm_activity_link(realm_string_id)
how_found = row["how_realm_creator_found_zulip"]
extra_context = row["how_realm_creator_found_zulip_extra_context"]
if how_found in (
RealmAuditLog.HOW_REALM_CREATOR_FOUND_ZULIP_OPTIONS["other"],
RealmAuditLog.HOW_REALM_CREATOR_FOUND_ZULIP_OPTIONS["ad"],
RealmAuditLog.HOW_REALM_CREATOR_FOUND_ZULIP_OPTIONS["review_site"],
RealmAuditLog.HOW_REALM_CREATOR_FOUND_ZULIP_OPTIONS["ai_chatbot"],
):
row["how_realm_creator_found_zulip"] += f": {extra_context}"
elif how_found == RealmAuditLog.HOW_REALM_CREATOR_FOUND_ZULIP_OPTIONS["existing_user"]:
row["how_realm_creator_found_zulip"] = f"Organization: {extra_context}"
# Get human messages sent per day.
try:
row["history"] = realm_messages_per_day_counts[realm_string_id]["cnts"]
except Exception:
row["history"] = ""
# Estimate annual recurring revenue.
if settings.BILLING_ENABLED:
if demo_organization_scheduled_deletion_date is None:
row["plan_type_string"] = get_plan_type_string(row["plan_type"])
else:
row["plan_type_string"] = "Demo"
if realm_string_id in estimated_arrs:
row["arr"] = f"${cents_to_dollar_string(estimated_arrs[realm_string_id])}"
if row["plan_type"] in [Realm.PLAN_TYPE_STANDARD, Realm.PLAN_TYPE_PLUS]:
row["effective_rate"] = plan_rates.get(realm_string_id, "")
elif row["plan_type"] == Realm.PLAN_TYPE_STANDARD_FREE:
row["effective_rate"] = 0
else:
row["effective_rate"] = ""
# Count active realms.
if row["dau_count"] >= 5:
num_active_sites += 1
# Get total row counts.
total_dau_count += int(row["dau_count"])
total_user_profile_count += int(row["user_profile_count"])
total_bot_count += int(row["bot_count"])
total_wau_count += int(row["wau_count"])
# Add admin users email string
if export:
row["admin_emails"] = admin_emails[realm_string_id]
total_row = [
"Total",
"",
"",
"",
f"${cents_to_dollar_string(total_arr)}",
"",
"",
"",
total_dau_count,
total_wau_count,
total_user_profile_count,
total_bot_count,
"",
"",
"",
"",
"",
"",
"",
"",
"",
]
if export:
total_row.pop(1)
content = loader.render_to_string(
"corporate/activity/installation_activity_table.html",
dict(
rows=rows,
totals=total_row,
num_active_sites=num_active_sites,
utctime=now.isoformat(" ", "minutes"),
billing_enabled=settings.BILLING_ENABLED,
export=export,
current_filter=filter,
),
)
return content
@require_server_admin
@typed_endpoint
def get_installation_activity(
request: HttpRequest,
*,
export: Json[bool] = False,
filter: InstalationActivityFilter = "all",
) -> HttpResponse:
content: str = realm_summary_table(export, filter)
title = "Installation activity"
return render(
request,
"corporate/activity/activity.html",
context=dict(data=content, title=title, is_home=True),
)
@require_server_admin
def get_integrations_activity(request: HttpRequest) -> HttpResponse:
title = "Integrations by client"
query = SQL(
"""
select
case
when query like '%%external%%' then split_part(query, '/', 5)
else client.name
end client_name,
realm.string_id,
sum(count) as hits,
max(last_visit) as last_time
from zerver_useractivity ua
join zerver_client client on client.id = ua.client_id
join zerver_userprofile up on up.id = ua.user_profile_id
join zerver_realm realm on realm.id = up.realm_id
where
(query in ('send_message_backend', '/api/v1/send_message')
and client.name not in ('Android', 'ZulipiOS')
and client.name not like 'test: Zulip%%'
)
or
query like '%%external%%'
group by client_name, string_id
having max(last_visit) > now() - interval '2 week'
order by client_name, string_id
"""
)
cols = ["Client", "Realm", "Hits", "Last time (UTC)", "Links"]
rows = get_query_data(query)
for row in rows:
realm_str = row[1]
activity = realm_activity_link(realm_str)
stats = realm_stats_link(realm_str)
row.append(activity + " " + stats)
for i, col in enumerate(cols):
if col == "Realm":
fix_rows(rows, i, realm_support_link)
elif col == "Last time (UTC)":
fix_rows(rows, i, format_optional_datetime)
content = make_table(title, cols, rows)
return render(
request,
"corporate/activity/activity.html",
context=dict(
data=content,
title=title,
is_home=False,
),
)

View File

@@ -0,0 +1,74 @@
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.customers import Customer
from corporate.models.licenses import LicenseLedger
from corporate.models.plans import CustomerPlan
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="Start of next billing cycle (UTC)",
value=format_optional_datetime(plan.next_invoice_date, True),
)
)
if plan.invoiced_through is not None:
header_entries.append(
ActivityHeaderEntry(
name="Entry last checked during invoicing",
value=str(plan.invoiced_through),
)
)
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,
),
)

486
corporate/views/portico.py Normal file
View File

@@ -0,0 +1,486 @@
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING
import orjson
from django.conf import settings
from django.contrib.auth.views import redirect_to_login
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.template.response import TemplateResponse
from django.urls import reverse
from pydantic import Json
from corporate.lib.decorator import (
authenticated_remote_realm_management_endpoint,
authenticated_remote_server_management_endpoint,
)
from corporate.models.customers import get_customer_by_realm
from corporate.models.plans import CustomerPlan, get_current_plan_by_customer
from zerver.context_processors import get_realm_from_request, latest_info_context
from zerver.decorator import add_google_analytics, zulip_login_required
from zerver.lib.github import (
InvalidPlatformError,
get_latest_github_release_download_link_for_platform,
)
from zerver.lib.realm_description import get_realm_text_description
from zerver.lib.realm_icon import get_realm_icon_url
from zerver.lib.subdomains import is_subdomain_root_or_alias
from zerver.lib.typed_endpoint import typed_endpoint
from zerver.models import Realm
if TYPE_CHECKING:
from corporate.lib.stripe import RemoteRealmBillingSession, RemoteServerBillingSession
@add_google_analytics
def apps_view(request: HttpRequest, platform: str | None = None) -> HttpResponse:
if not settings.CORPORATE_ENABLED:
# This seems impossible (CORPORATE_ENABLED set to false when
# rendering a "corporate" view) -- but we add it to make
# testing possible. Tests default to running with the
# "corporate" app installed, and unsetting that is difficult,
# as one cannot easily reload the URL resolution -- so we add
# a redirect here, equivalent to the one zerver would have
# installed when "corporate" is not enabled, to make the
# behaviour testable with CORPORATE_ENABLED set to false.
return HttpResponseRedirect("https://zulip.com/apps/", status=301)
return TemplateResponse(
request,
"corporate/apps.html",
)
def app_download_link_redirect(request: HttpRequest, platform: str) -> HttpResponse:
try:
download_link = get_latest_github_release_download_link_for_platform(platform)
return HttpResponseRedirect(download_link, status=302)
except InvalidPlatformError:
return TemplateResponse(request, "404.html", status=404)
def is_customer_on_free_trial(customer_plan: CustomerPlan) -> bool:
return customer_plan.status in (
CustomerPlan.FREE_TRIAL,
CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL,
)
@dataclass
class PlansPageContext:
sponsorship_url: str
free_trial_days: int | None
on_free_trial: bool = False
sponsorship_pending: bool = False
is_sponsored: bool = False
is_cloud_realm: bool = False
is_self_hosted_realm: bool = False
is_new_customer: bool = False
on_free_tier: bool = False
customer_plan: CustomerPlan | None = None
has_scheduled_upgrade: bool = False
scheduled_upgrade_plan: CustomerPlan | None = None
requested_sponsorship_plan: str | None = None
billing_base_url: str = ""
tier_self_hosted_basic: int = CustomerPlan.TIER_SELF_HOSTED_BASIC
tier_self_hosted_business: int = CustomerPlan.TIER_SELF_HOSTED_BUSINESS
tier_cloud_standard: int = CustomerPlan.TIER_CLOUD_STANDARD
tier_cloud_plus: int = CustomerPlan.TIER_CLOUD_PLUS
@add_google_analytics
def plans_view(request: HttpRequest) -> HttpResponse:
from corporate.lib.stripe import get_free_trial_days
realm = get_realm_from_request(request)
context = PlansPageContext(
is_cloud_realm=True,
sponsorship_url=reverse("sponsorship_request"),
free_trial_days=get_free_trial_days(False),
is_sponsored=realm is not None and realm.plan_type == Realm.PLAN_TYPE_STANDARD_FREE,
)
if is_subdomain_root_or_alias(request):
# If we're on the root domain, we make this link first ask you which organization.
context.sponsorship_url = reverse("realm_redirect", query={"next": context.sponsorship_url})
if realm is not None:
if realm.plan_type == Realm.PLAN_TYPE_SELF_HOSTED and settings.PRODUCTION:
return HttpResponseRedirect("https://zulip.com/plans/")
if not request.user.is_authenticated:
return redirect_to_login(next="/plans/")
if request.user.is_guest:
return TemplateResponse(request, "404.html", status=404)
if not request.user.has_billing_access:
return HttpResponseRedirect(reverse("billing_page"))
customer = get_customer_by_realm(realm)
context.on_free_tier = customer is None and not context.is_sponsored
if customer is not None:
context.sponsorship_pending = customer.sponsorship_pending
context.customer_plan = get_current_plan_by_customer(customer)
if context.customer_plan is None:
# Cloud realms on free tier don't have active customer plan unless they are sponsored.
context.on_free_tier = not context.is_sponsored
else:
context.on_free_trial = is_customer_on_free_trial(context.customer_plan)
# TODO implement a complimentary access plan/tier for Zulip Cloud.
context.is_new_customer = (
not context.on_free_tier and context.customer_plan is None and not context.is_sponsored
)
return TemplateResponse(
request,
"corporate/plans.html",
context=asdict(context),
)
@add_google_analytics
@authenticated_remote_realm_management_endpoint
def remote_realm_plans_page(
request: HttpRequest, billing_session: "RemoteRealmBillingSession"
) -> HttpResponse:
from corporate.lib.stripe import get_configured_fixed_price_plan_offer, get_free_trial_days
customer = billing_session.get_customer()
context = PlansPageContext(
is_self_hosted_realm=True,
sponsorship_url=reverse(
"remote_realm_sponsorship_page", args=(billing_session.remote_realm.uuid,)
),
free_trial_days=get_free_trial_days(True),
billing_base_url=billing_session.billing_base_url,
is_sponsored=billing_session.is_sponsored(),
requested_sponsorship_plan=billing_session.get_sponsorship_plan_name(customer, True),
)
context.on_free_tier = customer is None and not context.is_sponsored
if customer is not None: # nocoverage
context.sponsorship_pending = customer.sponsorship_pending
context.customer_plan = get_current_plan_by_customer(customer)
if customer.required_plan_tier is not None:
configure_fixed_price_plan = get_configured_fixed_price_plan_offer(
customer, customer.required_plan_tier
)
# Free trial is disabled for customers with fixed-price plan configured.
if configure_fixed_price_plan is not None:
context.free_trial_days = None
if context.customer_plan is None:
context.on_free_tier = not context.is_sponsored
else:
context.on_free_tier = (
context.customer_plan.tier
in (
CustomerPlan.TIER_SELF_HOSTED_LEGACY,
CustomerPlan.TIER_SELF_HOSTED_BASE,
)
and not context.is_sponsored
)
context.on_free_trial = is_customer_on_free_trial(context.customer_plan)
if context.customer_plan.status == CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END:
assert context.customer_plan.end_date is not None
context.scheduled_upgrade_plan = CustomerPlan.objects.get(
customer=customer,
billing_cycle_anchor=context.customer_plan.end_date,
status=CustomerPlan.NEVER_STARTED,
)
# Fixed-price plan renewals have a CustomerPlan.status of
# SWITCH_PLAN_TIER_AT_PLAN_END, so we check to see if there is
# a CustomerPlan.tier change for the scheduled upgrade note.
context.has_scheduled_upgrade = (
context.customer_plan.tier != context.scheduled_upgrade_plan.tier
)
if billing_session.customer_plan_exists():
# Free trial is disabled for existing customers.
context.free_trial_days = None
context.is_new_customer = (
not context.on_free_tier and context.customer_plan is None and not context.is_sponsored
)
return TemplateResponse(
request,
"corporate/plans.html",
context=asdict(context),
)
@add_google_analytics
@authenticated_remote_server_management_endpoint
def remote_server_plans_page(
request: HttpRequest, billing_session: "RemoteServerBillingSession"
) -> HttpResponse:
from corporate.lib.stripe import get_configured_fixed_price_plan_offer, get_free_trial_days
customer = billing_session.get_customer()
context = PlansPageContext(
is_self_hosted_realm=True,
sponsorship_url=reverse(
"remote_server_sponsorship_page", args=(billing_session.remote_server.uuid,)
),
free_trial_days=get_free_trial_days(True),
billing_base_url=billing_session.billing_base_url,
is_sponsored=billing_session.is_sponsored(),
requested_sponsorship_plan=billing_session.get_sponsorship_plan_name(customer, True),
)
context.on_free_tier = customer is None and not context.is_sponsored
if customer is not None: # nocoverage
context.sponsorship_pending = customer.sponsorship_pending
context.customer_plan = get_current_plan_by_customer(customer)
if customer.required_plan_tier is not None:
configure_fixed_price_plan = get_configured_fixed_price_plan_offer(
customer, customer.required_plan_tier
)
# Free trial is disabled for customers with fixed-price plan configured.
if configure_fixed_price_plan is not None:
context.free_trial_days = None
if context.customer_plan is None:
context.on_free_tier = not context.is_sponsored
else:
context.on_free_tier = context.customer_plan.tier in (
CustomerPlan.TIER_SELF_HOSTED_LEGACY,
CustomerPlan.TIER_SELF_HOSTED_BASE,
)
context.on_free_trial = is_customer_on_free_trial(context.customer_plan)
if context.customer_plan.status == CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END:
assert context.customer_plan.end_date is not None
context.scheduled_upgrade_plan = CustomerPlan.objects.get(
customer=customer,
billing_cycle_anchor=context.customer_plan.end_date,
status=CustomerPlan.NEVER_STARTED,
)
# Fixed-price plan renewals have a CustomerPlan.status of
# SWITCH_PLAN_TIER_AT_PLAN_END, so we check to see if there is
# a CustomerPlan.tier change for the scheduled upgrade note.
context.has_scheduled_upgrade = (
context.customer_plan.tier != context.scheduled_upgrade_plan.tier
)
if billing_session.customer_plan_exists():
# Free trial is disabled for existing customers.
context.free_trial_days = None
context.is_new_customer = (
not context.on_free_tier and context.customer_plan is None and not context.is_sponsored
)
return TemplateResponse(
request,
"corporate/plans.html",
context=asdict(context),
)
@add_google_analytics
def team_view(request: HttpRequest) -> HttpResponse:
if not settings.ZILENCER_ENABLED:
return HttpResponseRedirect("https://zulip.com/team/", status=301)
try:
with open(settings.CONTRIBUTOR_DATA_FILE_PATH, "rb") as f:
data = orjson.loads(f.read())
except FileNotFoundError:
data = {"contributors": [], "date": "Never ran."}
return TemplateResponse(
request,
"corporate/team.html",
context={
# Sync this with team_params_schema in base_page_params.ts.
"page_params": {
"page_type": "team",
"contributors": data["contributors"],
},
"REL_CANONICAL_LINK": f"https://zulip.com{request.path}",
"date": data["date"],
},
)
@add_google_analytics
def landing_view(request: HttpRequest, template_name: str) -> HttpResponse:
context = latest_info_context()
context.update(
{
"billing_base_url": "",
"tier_cloud_standard": str(CustomerPlan.TIER_CLOUD_STANDARD),
"tier_cloud_plus": str(CustomerPlan.TIER_CLOUD_PLUS),
"REL_CANONICAL_LINK": f"https://zulip.com{request.path}",
}
)
return TemplateResponse(request, template_name, context)
@add_google_analytics
def hello_view(request: HttpRequest) -> HttpResponse:
context = latest_info_context()
context["REL_CANONICAL_LINK"] = "https://zulip.com/"
return TemplateResponse(request, "corporate/hello.html", context)
@add_google_analytics
def communities_view(request: HttpRequest) -> HttpResponse:
eligible_realms = []
unique_org_type_ids = set()
want_to_be_advertised_realms = (
Realm.objects.filter(
want_advertise_in_communities_directory=True,
)
.exclude(
# Filter out realms who haven't changed their description from the default.
description="",
)
.exclude(
# Filter out demo organizations.
demo_organization_scheduled_deletion_date__isnull=False,
)
.order_by("name")
)
for realm in want_to_be_advertised_realms:
open_to_public = not realm.invite_required and not realm.emails_restricted_to_domains
if realm.allow_web_public_streams_access() or open_to_public:
[org_type] = (
org_type
for org_type in Realm.ORG_TYPES
if Realm.ORG_TYPES[org_type]["id"] == realm.org_type
)
eligible_realms.append(
{
"name": realm.name,
"realm_url": realm.url,
"logo_url": get_realm_icon_url(realm),
"description": get_realm_text_description(realm),
"org_type_key": org_type,
}
)
unique_org_type_ids.add(realm.org_type)
# Custom list of org filters to show.
CATEGORIES_TO_OFFER = [
"opensource",
"research",
"community",
]
# Remove org_types for which there are no open organizations.
org_types = dict()
for org_type in CATEGORIES_TO_OFFER:
org_type_info = Realm.ORG_TYPES[org_type]
if org_type_info["id"] in unique_org_type_ids:
org_types[org_type] = {"name": org_type_info["name"]}
# This code is not required right now, but could be useful in the
# future if we ever decide to show all the ORG_TYPES.
# Remove `Unspecified` ORG_TYPE
# org_types.pop("unspecified", None)
# Change display name of non-profit orgs.
# if org_types.get("nonprofit"): # nocoverage
# org_types["nonprofit"]["name"] = "Non-profit"
return TemplateResponse(
request,
"corporate/communities.html",
context={
"REL_CANONICAL_LINK": f"https://zulip.com{request.path}",
"eligible_realms": eligible_realms,
"org_types": org_types,
},
)
@zulip_login_required
def invoices_page(request: HttpRequest) -> HttpResponseRedirect:
from corporate.lib.stripe import RealmBillingSession
user = request.user
assert user.is_authenticated
if not user.has_billing_access:
return HttpResponseRedirect(reverse("billing_page"))
billing_session = RealmBillingSession(user=user, realm=user.realm)
list_invoices_session_url = billing_session.get_past_invoices_session_url()
return HttpResponseRedirect(list_invoices_session_url)
@authenticated_remote_realm_management_endpoint
def remote_realm_invoices_page(
request: HttpRequest, billing_session: "RemoteRealmBillingSession"
) -> HttpResponseRedirect:
list_invoices_session_url = billing_session.get_past_invoices_session_url()
return HttpResponseRedirect(list_invoices_session_url)
@authenticated_remote_server_management_endpoint
def remote_server_invoices_page(
request: HttpRequest, billing_session: "RemoteServerBillingSession"
) -> HttpResponseRedirect:
list_invoices_session_url = billing_session.get_past_invoices_session_url()
return HttpResponseRedirect(list_invoices_session_url)
@zulip_login_required
@typed_endpoint
def customer_portal(
request: HttpRequest,
*,
return_to_billing_page: Json[bool] = False,
manual_license_management: Json[bool] = False,
tier: Json[int] | None = None,
setup_payment_by_invoice: Json[bool] = False,
) -> HttpResponseRedirect:
from corporate.lib.stripe import RealmBillingSession
user = request.user
assert user.is_authenticated
if not user.has_billing_access:
return HttpResponseRedirect(reverse("billing_page"))
billing_session = RealmBillingSession(user=user, realm=user.realm)
review_billing_information_url = billing_session.get_stripe_customer_portal_url(
return_to_billing_page, manual_license_management, tier, setup_payment_by_invoice
)
return HttpResponseRedirect(review_billing_information_url)
@typed_endpoint
@authenticated_remote_realm_management_endpoint
def remote_realm_customer_portal(
request: HttpRequest,
billing_session: "RemoteRealmBillingSession",
*,
return_to_billing_page: Json[bool] = False,
manual_license_management: Json[bool] = False,
tier: Json[int] | None = None,
setup_payment_by_invoice: Json[bool] = False,
) -> HttpResponseRedirect:
review_billing_information_url = billing_session.get_stripe_customer_portal_url(
return_to_billing_page, manual_license_management, tier, setup_payment_by_invoice
)
return HttpResponseRedirect(review_billing_information_url)
@typed_endpoint
@authenticated_remote_server_management_endpoint
def remote_server_customer_portal(
request: HttpRequest,
billing_session: "RemoteServerBillingSession",
*,
return_to_billing_page: Json[bool] = False,
manual_license_management: Json[bool] = False,
tier: Json[int] | None = None,
setup_payment_by_invoice: Json[bool] = False,
) -> HttpResponseRedirect:
review_billing_information_url = billing_session.get_stripe_customer_portal_url(
return_to_billing_page, manual_license_management, tier, setup_payment_by_invoice
)
return HttpResponseRedirect(review_billing_information_url)

View File

@@ -0,0 +1,189 @@
import itertools
import re
from collections.abc import Collection
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from django.db.models import QuerySet
from django.http import HttpRequest, HttpResponse, HttpResponseNotFound
from django.shortcuts import render
from django.utils.timezone import now as timezone_now
from markupsafe import Markup
from corporate.lib.activity import (
format_optional_datetime,
make_table,
realm_stats_link,
user_activity_link,
)
from zerver.decorator import require_server_admin
from zerver.models import Realm, UserActivity
from zerver.models.users import UserProfile
@dataclass
class UserActivitySummary:
user_name: str
user_id: int
user_type: str
messages_sent: int
last_heard_from: datetime | None
last_message_sent: datetime | None
bot_owner_id: int | None
def get_user_activity_records_for_realm(realm: str) -> QuerySet[UserActivity]:
fields = [
"user_profile__full_name",
"user_profile__delivery_email",
"user_profile__is_bot",
"user_profile__bot_type",
"query",
"count",
"last_visit",
]
records = (
UserActivity.objects.filter(
user_profile__realm__string_id=realm,
user_profile__is_active=True,
)
.order_by("user_profile__delivery_email", "-last_visit")
.select_related("user_profile")
.only(*fields)
)
return records
def get_user_activity_summary(records: Collection[UserActivity]) -> UserActivitySummary:
if records:
first_record = next(iter(records))
name = first_record.user_profile.full_name
user_profile_id = first_record.user_profile.id
bot_owner_id = None
if not first_record.user_profile.is_bot:
user_type = "Human"
else:
assert first_record.user_profile.bot_type is not None
bot_type = first_record.user_profile.bot_type
user_type = UserProfile.BOT_TYPES[bot_type]
if first_record.user_profile.bot_owner is not None:
bot_owner_id = first_record.user_profile.bot_owner.id
messages = 0
heard_from: datetime | None = None
last_sent: datetime | None = None
for record in records:
query = str(record.query)
visit = record.last_visit
if heard_from is None:
heard_from = visit
else:
heard_from = max(visit, heard_from)
if ("send_message" in query) or re.search(r"/api/.*/external/.*", query):
messages += record.count
if last_sent is None:
last_sent = visit
else:
last_sent = max(visit, last_sent)
return UserActivitySummary(
user_name=name,
user_id=user_profile_id,
user_type=user_type,
messages_sent=messages,
last_heard_from=heard_from,
last_message_sent=last_sent,
bot_owner_id=bot_owner_id,
)
def realm_user_summary_table(
all_records: QuerySet[UserActivity], admin_emails: set[str], title: str, stats_link: Markup
) -> str:
user_records: dict[str, UserActivitySummary] = {}
def by_email(record: UserActivity) -> str:
return record.user_profile.delivery_email
for email, records in itertools.groupby(all_records, by_email):
user_records[email] = get_user_activity_summary(list(records))
def is_recent(val: datetime) -> bool:
age = timezone_now() - val
return age.total_seconds() < 5 * 60
cols = [
"Name",
"Email",
"User type",
"Messages sent",
"Last heard from (UTC)",
"Last message sent (UTC)",
]
rows = []
for email, user_summary in user_records.items():
email_link = user_activity_link(email, user_summary.user_id)
if user_summary.bot_owner_id is not None:
bot_owner_link = user_activity_link("", user_summary.bot_owner_id)
user_name = user_summary.user_name + " " + bot_owner_link
cells = [
user_name,
email_link,
user_summary.user_type,
user_summary.messages_sent,
]
else:
cells = [
user_summary.user_name,
email_link,
user_summary.user_type,
user_summary.messages_sent,
]
cells.append(format_optional_datetime(user_summary.last_heard_from))
cells.append(format_optional_datetime(user_summary.last_message_sent))
row_class = ""
if user_summary.last_heard_from and is_recent(user_summary.last_heard_from):
row_class += " recently_active"
if email in admin_emails:
row_class += " admin"
row = dict(cells=cells, row_class=row_class)
rows.append(row)
def by_last_heard_from(row: dict[str, Any]) -> str:
return row["cells"][4]
rows = sorted(rows, key=by_last_heard_from, reverse=True)
content = make_table(title, cols, rows, title_link=stats_link, has_row_class=True)
return content
@require_server_admin
def get_realm_activity(request: HttpRequest, realm_str: str) -> HttpResponse:
try:
admins = Realm.objects.get(string_id=realm_str).get_human_admin_users()
except Realm.DoesNotExist:
return HttpResponseNotFound()
admin_emails = {admin.delivery_email for admin in admins}
all_records = get_user_activity_records_for_realm(realm_str)
realm_stats = realm_stats_link(realm_str)
title = realm_str
content = realm_user_summary_table(all_records, admin_emails, title, realm_stats)
return render(
request,
"corporate/activity/activity.html",
context=dict(
data=content,
title=title,
is_home=False,
),
)

View File

@@ -0,0 +1,285 @@
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from psycopg2.sql import SQL
from corporate.lib.activity import (
fix_rows,
format_datetime_as_date,
format_none_as_zero,
format_optional_datetime,
get_plan_data_by_remote_realm,
get_plan_data_by_remote_server,
get_query_data,
get_remote_realm_user_counts,
get_remote_server_audit_logs,
make_table,
remote_installation_stats_link,
remote_installation_support_link,
)
from zerver.decorator import require_server_admin
from zerver.models.realms import get_org_type_display_name
from zilencer.models import get_remote_customer_user_count
@require_server_admin
def get_remote_server_activity(request: HttpRequest) -> HttpResponse:
from corporate.lib.stripe import cents_to_dollar_string
title = "Remote servers"
query = SQL(
"""
with remote_server_push_forwarded_count as (
select
server_id,
sum(coalesce(value, 0)) as server_push_forwarded_count
from zilencer_remoteinstallationcount
where
property = 'mobile_pushes_forwarded::day'
and end_time >= current_timestamp(0) - interval '7 days'
group by server_id
),
remote_server_push_devices as (
select
server_id,
count(distinct(user_id, user_uuid)) as server_push_user_count
from zilencer_remotepushdevicetoken
group by server_id
),
remote_server_audit_log as (
select
server_id,
event_time as server_created
from zilencer_remotezulipserverauditlog
where
event_type = 10215
group by server_id, event_time
),
remote_realms as (
select
server_id,
id as realm_id,
name as realm_name,
org_type as realm_type,
host as realm_host,
realm_date_created as realm_created
from zilencer_remoterealm
where
is_system_bot_realm = False
and realm_deactivated = False
group by server_id, id, name, org_type
),
remote_realm_push_devices as (
select
remote_realm_id,
count(distinct(user_id, user_uuid)) as realm_push_user_count
from zilencer_remotepushdevicetoken
group by remote_realm_id
),
remote_realm_push_forwarded_count as (
select
remote_realm_id,
sum(coalesce(value, 0)) as realm_push_forwarded_count
from zilencer_remoterealmcount
where
property = 'mobile_pushes_forwarded::day'
and end_time >= current_timestamp(0) - interval '7 days'
group by remote_realm_id
)
select
rserver.id,
realm_id,
server_created,
realm_created,
realm_name,
rserver.hostname,
realm_host,
rserver.contact_email,
rserver.last_version,
rserver.last_audit_log_update,
server_push_user_count,
realm_push_user_count,
server_push_forwarded_count,
realm_push_forwarded_count,
realm_type
from zilencer_remotezulipserver rserver
left join remote_server_push_forwarded_count on remote_server_push_forwarded_count.server_id = rserver.id
left join remote_server_push_devices on remote_server_push_devices.server_id = rserver.id
left join remote_realms on remote_realms.server_id = rserver.id
left join remote_server_audit_log on remote_server_audit_log.server_id = rserver.id
left join remote_realm_push_devices on remote_realm_push_devices.remote_realm_id = realm_id
left join remote_realm_push_forwarded_count on remote_realm_push_forwarded_count.remote_realm_id = realm_id
where not deactivated
order by server_push_user_count DESC NULLS LAST
"""
)
cols = [
"Links",
"IDs",
"Date created",
"Realm name",
"Realm host or server hostname",
"Server contact email",
"Server Zulip version",
"Server last audit log update (UTC)",
"Mobile users",
"Mobile pushes",
"Realm organization type",
"Plan name",
"Plan status",
"ARR",
"Rate",
"Total users",
"Guest users",
]
# If the query or column order above changes, update the constants below
# Query constants:
SERVER_AND_REALM_IDS = 0
SERVER_CREATED = 1
REALM_CREATED = 2
SERVER_HOST = 3
REALM_HOST = 4
SERVER_PUSH_USER_COUNT = 9
REALM_PUSH_USER_COUNT = 10
SERVER_PUSH_FORWARDED = 11
REALM_PUSH_FORWARDED = 12
# Column constants:
DATE_CREATED = 2
LAST_AUDIT_LOG_DATE = 7
MOBILE_USER_COUNT = 8
MOBILE_PUSH_COUNT = 9
ORG_TYPE = 10
ARR = 13
TOTAL_USER_COUNT = 15
GUEST_COUNT = 16
rows = get_query_data(query)
plan_data_by_remote_server = get_plan_data_by_remote_server()
plan_data_by_remote_server_and_realm = get_plan_data_by_remote_realm()
audit_logs_by_remote_server = get_remote_server_audit_logs()
remote_realm_user_counts = get_remote_realm_user_counts()
total_row = []
remote_server_mobile_data_counted = set()
total_revenue = 0
total_mobile_users = 0
total_pushes = 0
for row in rows:
# Create combined IDs column with server and realm IDs
server_id = row.pop(SERVER_AND_REALM_IDS)
realm_id = row.pop(SERVER_AND_REALM_IDS)
if realm_id is not None:
ids_string = f"{server_id}/{realm_id}"
else:
ids_string = f"{server_id}"
row.insert(SERVER_AND_REALM_IDS, ids_string)
# Remove extra mobile user/push data and set created date
# For remote realm row, remove server push data and created date;
# for remote server row, remove realm push data and created date.
if realm_id is not None:
row.pop(SERVER_PUSH_FORWARDED)
row.pop(SERVER_PUSH_USER_COUNT)
row.pop(SERVER_CREATED)
else:
row.pop(REALM_PUSH_FORWARDED)
row.pop(REALM_PUSH_USER_COUNT)
row.pop(REALM_CREATED)
# Get server_host for support link
# For remote realm row, remove server hostname value;
# for remote server row, remove None realm host value
if realm_id is not None:
server_host = row.pop(SERVER_HOST)
else:
row.pop(REALM_HOST)
server_host = row[SERVER_HOST]
# Add server links
stats = remote_installation_stats_link(server_id)
support = remote_installation_support_link(server_host)
links = stats + " " + support
row.insert(0, links)
# Count mobile users and pushes forwarded, once per server
if server_id not in remote_server_mobile_data_counted:
if row[MOBILE_USER_COUNT] is not None:
total_mobile_users += row[MOBILE_USER_COUNT] # nocoverage
if row[MOBILE_PUSH_COUNT] is not None:
total_pushes += row[MOBILE_PUSH_COUNT] # nocoverage
remote_server_mobile_data_counted.add(server_id)
# Get plan, revenue and user count data for row
if realm_id is None:
plan_data = plan_data_by_remote_server.get(server_id)
audit_log_list = audit_logs_by_remote_server.get(server_id)
if audit_log_list is None:
user_counts = None # nocoverage
else:
user_counts = get_remote_customer_user_count(audit_log_list)
else:
server_remote_realms_data = plan_data_by_remote_server_and_realm.get(server_id)
if server_remote_realms_data is not None:
plan_data = server_remote_realms_data.get(realm_id)
else:
plan_data = None # nocoverage
user_counts = remote_realm_user_counts.get(realm_id)
# Format organization type for realm
org_type = row[ORG_TYPE]
row[ORG_TYPE] = get_org_type_display_name(org_type)
# Add estimated annual revenue and plan data
if plan_data is None:
row.append("---")
row.append("---")
row.append("---")
row.append("---")
else:
total_revenue += plan_data.annual_revenue
revenue = cents_to_dollar_string(plan_data.annual_revenue)
row.append(plan_data.current_plan_name)
row.append(plan_data.current_status)
row.append(f"${revenue}")
row.append(plan_data.rate)
# Add user counts
if user_counts is None:
row.append(0)
row.append(0)
else:
total_users = user_counts.non_guest_user_count + user_counts.guest_user_count
row.append(total_users)
row.append(user_counts.guest_user_count)
# Format column data and add total row
for i in range(len(cols)):
if i == LAST_AUDIT_LOG_DATE:
fix_rows(rows, i, format_optional_datetime)
if i in [MOBILE_USER_COUNT, MOBILE_PUSH_COUNT]:
fix_rows(rows, i, format_none_as_zero)
if i == DATE_CREATED:
fix_rows(rows, i, format_datetime_as_date)
if i == SERVER_AND_REALM_IDS:
total_row.append("Total")
elif i == MOBILE_USER_COUNT:
total_row.append(str(total_mobile_users))
elif i == MOBILE_PUSH_COUNT:
total_row.append(str(total_pushes))
elif i == ARR:
total_revenue_string = f"${cents_to_dollar_string(total_revenue)}"
total_row.append(total_revenue_string)
elif i in [TOTAL_USER_COUNT, GUEST_COUNT]:
total_row.append(str(sum(row[i] for row in rows if row[i] is not None)))
else:
total_row.append("")
content = make_table(title, cols, rows, totals=total_row)
return render(
request,
"corporate/activity/activity.html",
context=dict(data=content, title=title, is_home=False),
)

View File

@@ -0,0 +1,873 @@
import logging
from typing import Any, Literal, TypeAlias, cast
from urllib.parse import urlsplit, urlunsplit
from django.conf import settings
from django.core import signing
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Exists, OuterRef
from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils.crypto import constant_time_compare
from django.utils.timezone import now as timezone_now
from django.utils.translation import get_language
from django.utils.translation import gettext as _
from django.views.decorators.csrf import csrf_exempt
from pydantic import Json
from confirmation.models import (
Confirmation,
ConfirmationKeyError,
create_confirmation_link,
get_object_from_key,
render_confirmation_key_error,
)
from corporate.lib.decorator import self_hosting_management_endpoint
from corporate.lib.remote_billing_util import (
REMOTE_BILLING_SESSION_VALIDITY_SECONDS,
LegacyServerIdentityDict,
RemoteBillingIdentityDict,
RemoteBillingIdentityExpiredError,
RemoteBillingUserDict,
get_remote_server_and_user_from_session,
)
from corporate.lib.stripe import BILLING_SUPPORT_EMAIL, RemoteRealmBillingSession
from corporate.models.customers import get_customer_by_remote_server
from corporate.models.plans import CustomerPlan, get_current_plan_by_customer
from zerver.lib.exceptions import (
JsonableError,
MissingRemoteRealmError,
RateLimitedError,
RemoteBillingAuthenticationError,
RemoteRealmServerMismatchError,
)
from zerver.lib.rate_limiter import rate_limit_request_by_ip, readable_expiry_string_for_html
from zerver.lib.remote_server import RealmDataForAnalytics, UserDataForRemoteBilling
from zerver.lib.response import json_success
from zerver.lib.send_email import FromAddress, send_email
from zerver.lib.timestamp import datetime_to_timestamp
from zerver.lib.typed_endpoint import PathOnly, typed_endpoint
from zilencer.auth import rate_limit_remote_server
from zilencer.models import (
PreregistrationRemoteRealmBillingUser,
PreregistrationRemoteServerBillingUser,
RemoteRealm,
RemoteRealmBillingUser,
RemoteServerBillingUser,
RemoteZulipServer,
get_remote_server_by_uuid,
)
from zilencer.views import handle_customer_migration_from_server_to_realm
billing_logger = logging.getLogger("corporate.stripe")
VALID_NEXT_PAGES = [None, "sponsorship", "upgrade", "billing", "plans", "deactivate"]
VALID_NEXT_PAGES_TYPE: TypeAlias = (
Literal["sponsorship", "upgrade", "billing", "plans", "deactivate"] | None
)
REMOTE_BILLING_SIGNED_ACCESS_TOKEN_VALIDITY_IN_SECONDS = 2 * 60 * 60
# We use units of hours here so that we can pass this through to the
# email template that tells the recipient how long these will last.
LOGIN_CONFIRMATION_EMAIL_DURATION_HOURS = 24
@csrf_exempt
@typed_endpoint
def remote_realm_billing_entry(
request: HttpRequest,
remote_server: RemoteZulipServer,
*,
user: Json[UserDataForRemoteBilling],
realm: Json[RealmDataForAnalytics],
uri_scheme: Literal["http://", "https://"] = "https://",
next_page: VALID_NEXT_PAGES_TYPE = None,
) -> HttpResponse:
try:
remote_realm = RemoteRealm.objects.get(uuid=realm.uuid, server=remote_server)
except RemoteRealm.DoesNotExist:
if RemoteRealm.objects.filter(uuid=realm.uuid).exists(): # nocoverage
billing_logger.warning(
"%s: Realm %s exists, but not registered to server %s",
request.path,
realm.uuid,
remote_server.id,
)
raise RemoteRealmServerMismatchError
else:
# This error will prod the remote server to submit its realm info, which
# should lead to the creation of this missing RemoteRealm registration.
raise MissingRemoteRealmError
identity_dict = RemoteBillingIdentityDict(
user=RemoteBillingUserDict(
user_email=user.email, user_uuid=str(user.uuid), user_full_name=user.full_name
),
remote_server_uuid=str(remote_server.uuid),
remote_realm_uuid=str(remote_realm.uuid),
remote_billing_user_id=None,
authenticated_at=datetime_to_timestamp(timezone_now()),
uri_scheme=uri_scheme,
next_page=next_page,
)
signed_identity_dict = signing.dumps(identity_dict)
billing_access_url = (
f"{settings.EXTERNAL_URI_SCHEME}{settings.SELF_HOSTING_MANAGEMENT_SUBDOMAIN}.{settings.EXTERNAL_HOST}"
+ reverse(remote_realm_billing_finalize_login, args=[signed_identity_dict])
)
return json_success(request, data={"billing_access_url": billing_access_url})
def get_identity_dict_from_signed_access_token(
signed_billing_access_token: str,
) -> RemoteBillingIdentityDict:
try:
identity_dict: RemoteBillingIdentityDict = signing.loads(
signed_billing_access_token,
max_age=REMOTE_BILLING_SIGNED_ACCESS_TOKEN_VALIDITY_IN_SECONDS,
)
except signing.SignatureExpired:
raise JsonableError(_("Billing access token expired."))
except signing.BadSignature:
raise JsonableError(_("Invalid billing access token."))
return identity_dict
def is_tos_consent_needed_for_user(
remote_user: RemoteRealmBillingUser | RemoteServerBillingUser,
) -> bool:
assert settings.TERMS_OF_SERVICE_VERSION is not None
return int(settings.TERMS_OF_SERVICE_VERSION.split(".")[0]) > int(
remote_user.tos_version.split(".")[0]
)
@self_hosting_management_endpoint
@typed_endpoint
def remote_realm_billing_finalize_login(
request: HttpRequest,
*,
signed_billing_access_token: PathOnly[str],
full_name: str | None = None,
tos_consent: Literal["true"] | None = None,
enable_major_release_emails: Literal["true", "false"] | None = None,
enable_maintenance_release_emails: Literal["true", "false"] | None = None,
) -> HttpResponse:
"""
This is the endpoint accessed via the billing_access_url, generated by
remote_realm_billing_entry entry.
"""
if request.method not in ["GET", "POST"]:
return HttpResponseNotAllowed(["GET", "POST"])
# Sanity assert, because otherwise these make no sense.
assert (
REMOTE_BILLING_SIGNED_ACCESS_TOKEN_VALIDITY_IN_SECONDS
<= REMOTE_BILLING_SESSION_VALIDITY_SECONDS
)
identity_dict = get_identity_dict_from_signed_access_token(signed_billing_access_token)
return remote_realm_billing_finalize_login_core(
request,
identity_dict=identity_dict,
full_name=full_name,
tos_consent_given=tos_consent == "true",
enable_major_release_emails=enable_major_release_emails == "true",
enable_maintenance_release_emails=enable_maintenance_release_emails == "true",
)
def remote_realm_billing_finalize_login_core(
request: HttpRequest,
*,
identity_dict: RemoteBillingIdentityDict,
full_name: str | None = None,
tos_consent_given: bool = False,
enable_major_release_emails: bool = False,
enable_maintenance_release_emails: bool = False,
) -> HttpResponse:
# Now we want to fetch the RemoteRealmBillingUser object implied
# by the IdentityDict. We'll use this:
# (1) If the user came here via just GET, we want to show them a confirmation
# page with the relevant info details before finalizing login. If they wish
# to proceed, they'll approve the form, causing a POST, bring us to case (2).
# (2) If the user came here via POST, we finalize login, using the info from the
# IdentityDict to update the RemoteRealmBillingUser object if needed.
# Finally, if the user is logging in for the first time, we'll need to create
# their account first. This will happen by making them fill out a form to confirm
# their email first. Only after clicking the confirmation link in the email,
# they will have their account created and finally be redirected back here.
remote_realm_uuid = identity_dict["remote_realm_uuid"]
remote_server_uuid = identity_dict["remote_server_uuid"]
try:
remote_server = get_remote_server_by_uuid(remote_server_uuid)
remote_realm = RemoteRealm.objects.get(uuid=remote_realm_uuid, server=remote_server)
except ObjectDoesNotExist:
# These should definitely still exist, since the access token was signed
# pretty recently. (And we generally don't delete these at all.)
raise AssertionError
try:
handle_customer_migration_from_server_to_realm(server=remote_server)
except JsonableError:
# JsonableError should be propagated up, as they are meant to convey
# a json error response to be returned.
raise
except Exception: # nocoverage
billing_logger.exception(
"%s: Failed to migrate customer from server (id: %s) to realm",
request.path,
remote_server.id,
stack_info=True,
)
raise JsonableError(
_(
"Couldn't reconcile billing data between server and realm. Please contact {support_email}"
).format(support_email=BILLING_SUPPORT_EMAIL)
)
# Redirect to error page if server is on an active plan
server_customer = get_customer_by_remote_server(remote_server)
if server_customer is not None:
server_plan = get_current_plan_by_customer(server_customer)
if server_plan is not None:
return render(
request,
"corporate/billing/remote_realm_login_error_for_server_on_active_plan.html",
context={
"server_plan_name": server_plan.name,
},
)
user_dict = identity_dict["user"]
user_email = user_dict["user_email"]
user_uuid = user_dict["user_uuid"]
assert settings.TERMS_OF_SERVICE_VERSION is not None, (
"This is only run on the bouncer, which has ToS"
)
try:
remote_user = RemoteRealmBillingUser.objects.get(
remote_realm=remote_realm,
user_uuid=user_uuid,
)
tos_consent_needed = is_tos_consent_needed_for_user(remote_user)
except RemoteRealmBillingUser.DoesNotExist:
# This is the first time this user is logging in.
remote_user = None
tos_consent_needed = True
if request.method == "GET":
signed_billing_access_token = signing.dumps(identity_dict)
if remote_user is not None:
# Render a template where the user will just confirm their info,
# possibly accept ToS if needed, POST back here and will get fully
# authenticated.
context = {
"remote_server_uuid": remote_server_uuid,
"remote_realm_uuid": remote_realm_uuid,
"host": remote_realm.host,
"user_email": remote_user.email,
"user_full_name": remote_user.full_name,
"tos_consent_needed": tos_consent_needed,
"action_url": reverse(
remote_realm_billing_finalize_login, args=(signed_billing_access_token,)
),
}
return render(
request,
"corporate/billing/remote_billing_finalize_login_confirmation.html",
context=context,
)
else:
# This user is logging in for the first time, so we need to create their
# RemoteRealmBillingUser object. Render a template where they'll
# enter their email address - we'll send a verification email to it.
context = {
"email": user_email,
"action_url": reverse(
remote_realm_billing_confirm_email, args=(signed_billing_access_token,)
),
}
return render(
request,
"corporate/billing/remote_billing_confirm_email_form.html",
context=context,
)
assert request.method == "POST"
if remote_user is None:
# Users logging in for the first time need to be created and follow
# a different path - they should not be POSTing here. It should be impossible
# to get here with a remote_user that is None without tampering with the form
# or manually crafting a POST request.
raise JsonableError(_("User account doesn't exist yet."))
if tos_consent_needed and not tos_consent_given:
# This shouldn't be possible without tampering with the form, so we
# don't need a pretty error.
raise JsonableError(_("You must accept the Terms of Service to proceed."))
# The current approach is to update the full_name and email preferences
# only when the user first logs in.
if full_name is not None:
remote_user.full_name = full_name
remote_user.enable_major_release_emails = enable_major_release_emails
remote_user.enable_maintenance_release_emails = enable_maintenance_release_emails
remote_user.tos_version = settings.TERMS_OF_SERVICE_VERSION
remote_user.last_login = timezone_now()
remote_user.save(
update_fields=[
"full_name",
"tos_version",
"last_login",
"enable_maintenance_release_emails",
"enable_major_release_emails",
]
)
identity_dict["remote_billing_user_id"] = remote_user.id
request.session["remote_billing_identities"] = {}
request.session["remote_billing_identities"][f"remote_realm:{remote_realm_uuid}"] = (
identity_dict
)
next_page = identity_dict["next_page"]
assert next_page in VALID_NEXT_PAGES
if next_page is not None:
return HttpResponseRedirect(
reverse(f"remote_realm_{next_page}_page", args=(remote_realm_uuid,))
)
elif remote_realm.plan_type in [
RemoteRealm.PLAN_TYPE_SELF_MANAGED,
RemoteRealm.PLAN_TYPE_SELF_MANAGED_LEGACY,
]:
# If they have a scheduled upgrade while on a complimentary access plan,
# redirect to billing page.
billing_session = RemoteRealmBillingSession(remote_realm)
customer = billing_session.get_customer()
if (
customer is not None
and billing_session.get_complimentary_access_next_plan_name(customer) is not None
):
return HttpResponseRedirect(
reverse("remote_realm_billing_page", args=(remote_realm_uuid,))
)
return HttpResponseRedirect(reverse("remote_realm_plans_page", args=(remote_realm_uuid,)))
elif remote_realm.plan_type == RemoteRealm.PLAN_TYPE_COMMUNITY:
return HttpResponseRedirect(
reverse("remote_realm_sponsorship_page", args=(remote_realm_uuid,))
)
else:
return HttpResponseRedirect(reverse("remote_realm_billing_page", args=(remote_realm_uuid,)))
@self_hosting_management_endpoint
@typed_endpoint
def remote_realm_billing_confirm_email(
request: HttpRequest,
*,
signed_billing_access_token: PathOnly[str],
email: str,
) -> HttpResponse:
"""
Endpoint for users in the RemoteRealm flow that are logging in for the first time
and still have to have their RemoteRealmBillingUser object created.
Takes the POST from the above form asking for their email address
and sends confirmation email to the provided
email address in order to verify. Only the confirmation link will grant
a fully authenticated session.
"""
from corporate.lib.stripe import BILLING_SUPPORT_EMAIL
identity_dict = get_identity_dict_from_signed_access_token(signed_billing_access_token)
try:
remote_server = get_remote_server_by_uuid(identity_dict["remote_server_uuid"])
remote_realm = RemoteRealm.objects.get(
uuid=identity_dict["remote_realm_uuid"], server=remote_server
)
except ObjectDoesNotExist:
raise AssertionError
rate_limit_error_response = check_rate_limits(request, remote_server)
if rate_limit_error_response is not None:
return rate_limit_error_response
obj = PreregistrationRemoteRealmBillingUser.objects.create(
email=email,
remote_realm=remote_realm,
user_uuid=identity_dict["user"]["user_uuid"],
next_page=identity_dict["next_page"],
uri_scheme=identity_dict["uri_scheme"],
)
url = create_remote_billing_confirmation_link(
obj,
Confirmation.REMOTE_REALM_BILLING_LEGACY_LOGIN,
validity_in_minutes=LOGIN_CONFIRMATION_EMAIL_DURATION_HOURS * 60,
)
context = {
"remote_realm_host": remote_realm.host,
"confirmation_url": url,
"billing_help_link": "https://zulip.com/help/self-hosted-billing",
"billing_contact_email": BILLING_SUPPORT_EMAIL,
"validity_in_hours": LOGIN_CONFIRMATION_EMAIL_DURATION_HOURS,
}
send_email(
"zerver/emails/remote_realm_billing_confirm_login",
to_emails=[email],
from_address=FromAddress.tokenized_no_reply_address(),
language=get_language(),
context=context,
)
return render(
request,
"corporate/billing/remote_billing_email_confirmation_sent.html",
context={"email": email},
)
@self_hosting_management_endpoint
@typed_endpoint
def remote_realm_billing_from_login_confirmation_link(
request: HttpRequest,
*,
confirmation_key: PathOnly[str],
) -> HttpResponse:
"""
The user comes here via the confirmation link they received via email.
Creates the RemoteRealmBillingUser object and redirects to
remote_realm_billing_finalize_login with a new signed access token,
where they will finally be logged in now that they have an account.
"""
try:
prereg_object = get_object_from_key(
confirmation_key,
[Confirmation.REMOTE_REALM_BILLING_LEGACY_LOGIN],
# These links aren't reusable. The user just clicks it
# to get their account created. Afterwards, they're not
# subject to the confirmation link part of the flow anymore.
mark_object_used=True,
)
except ConfirmationKeyError as exception:
return render_confirmation_key_error(request, exception)
assert isinstance(prereg_object, PreregistrationRemoteRealmBillingUser)
remote_realm = prereg_object.remote_realm
uri_scheme = prereg_object.uri_scheme
next_page = prereg_object.next_page
assert next_page in VALID_NEXT_PAGES
assert uri_scheme in ["http://", "https://"]
# Mypy is not satisfied by the above assert, so we need to cast.
uri_scheme = cast(Literal["http://", "https://"], uri_scheme)
remote_billing_user, created = RemoteRealmBillingUser.objects.get_or_create(
remote_realm=remote_realm,
user_uuid=prereg_object.user_uuid,
defaults={"email": prereg_object.email},
)
if not created:
billing_logger.info(
"Matching RemoteRealmBillingUser already exists for "
"PreregistrationRemoteRealmBillingUser %s",
prereg_object.id,
)
prereg_object.created_user = remote_billing_user
prereg_object.save(update_fields=["created_user"])
identity_dict = RemoteBillingIdentityDict(
user=RemoteBillingUserDict(
user_email=remote_billing_user.email,
user_uuid=str(remote_billing_user.user_uuid),
user_full_name=remote_billing_user.full_name,
),
remote_server_uuid=str(remote_realm.server.uuid),
remote_realm_uuid=str(remote_realm.uuid),
# This will be figured out by the next endpoint in the flow anyway.
remote_billing_user_id=None,
authenticated_at=datetime_to_timestamp(timezone_now()),
uri_scheme=uri_scheme,
next_page=next_page,
)
return remote_realm_billing_finalize_login_core(request, identity_dict=identity_dict)
def create_remote_billing_confirmation_link(
obj: PreregistrationRemoteRealmBillingUser | PreregistrationRemoteServerBillingUser,
confirmation_type: int,
validity_in_minutes: int,
) -> str:
url = create_confirmation_link(
obj,
confirmation_type,
validity_in_minutes=validity_in_minutes,
no_associated_realm_object=True,
)
# create_confirmation_link will create the url on the root subdomain, so we need to
# do a hacky approach to change it into the self hosting management subdomain.
new_hostname = f"{settings.SELF_HOSTING_MANAGEMENT_SUBDOMAIN}.{settings.EXTERNAL_HOST}"
split_url = urlsplit(url)
modified_url = split_url._replace(netloc=new_hostname)
final_url = urlunsplit(modified_url)
return final_url
@self_hosting_management_endpoint
@typed_endpoint
def remote_billing_legacy_server_login(
request: HttpRequest,
*,
zulip_org_id: str | None = None,
zulip_org_key: str | None = None,
next_page: VALID_NEXT_PAGES_TYPE = None,
) -> HttpResponse:
context: dict[str, Any] = {"next_page": next_page}
if zulip_org_id is None or zulip_org_key is None:
context.update({"error_message": False})
return render(request, "corporate/billing/legacy_server_login.html", context)
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
try:
remote_server = get_remote_server_by_uuid(zulip_org_id)
except RemoteZulipServer.DoesNotExist:
context.update(
{
"error_message": _(
"This zulip_org_id is not registered with Zulip's billing management system."
)
}
)
return render(request, "corporate/billing/legacy_server_login.html", context)
if not constant_time_compare(zulip_org_key, remote_server.api_key):
context.update({"error_message": _("Invalid zulip_org_key for this zulip_org_id.")})
return render(request, "corporate/billing/legacy_server_login.html", context)
if remote_server.deactivated:
context.update({"error_message": _("Your server registration has been deactivated.")})
return render(request, "corporate/billing/legacy_server_login.html", context)
remote_server_uuid = str(remote_server.uuid)
# We will want to render a page with a form that POSTs user-filled data to
# the next endpoint in the flow. That endpoint needs to know the user is already
# authenticated as a billing admin for this remote server, so we need to store
# our usual IdentityDict structure in the session.
request.session["remote_billing_identities"] = {}
request.session["remote_billing_identities"][f"remote_server:{remote_server_uuid}"] = (
LegacyServerIdentityDict(
remote_server_uuid=remote_server_uuid,
authenticated_at=datetime_to_timestamp(timezone_now()),
# The lack of remote_billing_user_id indicates the auth hasn't been completed.
# This means access to authenticated endpoints will be denied. Only proceeding
# to the next step in the flow is permitted with this.
remote_billing_user_id=None,
)
)
context = {
"remote_server_hostname": remote_server.hostname,
"next_page": next_page,
"action_url": reverse(
remote_billing_legacy_server_confirm_login, args=(str(remote_server.uuid),)
),
}
return render(
request,
"corporate/billing/remote_billing_confirm_email_form.html",
context=context,
)
@self_hosting_management_endpoint
@typed_endpoint
def remote_billing_legacy_server_confirm_login(
request: HttpRequest,
*,
server_uuid: PathOnly[str],
email: str,
next_page: VALID_NEXT_PAGES_TYPE = None,
) -> HttpResponse:
"""
Takes the POST from the above form and sends confirmation email to the provided
email address in order to verify. Only the confirmation link will grant
a fully authenticated session.
"""
from corporate.lib.stripe import BILLING_SUPPORT_EMAIL
try:
remote_server, remote_billing_user = get_remote_server_and_user_from_session(
request, server_uuid=server_uuid
)
if remote_billing_user is not None:
# This session is already fully authenticated, it doesn't make sense for
# the user to be here. Just raise an exception so it's immediately caught
# and the user is redirected to the beginning of the login flow where
# they can re-auth.
raise RemoteBillingAuthenticationError
except (RemoteBillingIdentityExpiredError, RemoteBillingAuthenticationError):
return HttpResponseRedirect(
reverse("remote_billing_legacy_server_login", query={"next_page": next_page})
)
rate_limit_error_response = check_rate_limits(request, remote_server)
if rate_limit_error_response is not None:
return rate_limit_error_response
obj = PreregistrationRemoteServerBillingUser.objects.create(
email=email,
remote_server=remote_server,
next_page=next_page,
)
url = create_remote_billing_confirmation_link(
obj,
Confirmation.REMOTE_SERVER_BILLING_LEGACY_LOGIN,
validity_in_minutes=LOGIN_CONFIRMATION_EMAIL_DURATION_HOURS * 60,
)
context = {
"remote_server_hostname": remote_server.hostname,
"confirmation_url": url,
"billing_help_link": "https://zulip.com/help/self-hosted-billing",
"billing_contact_email": BILLING_SUPPORT_EMAIL,
"validity_in_hours": LOGIN_CONFIRMATION_EMAIL_DURATION_HOURS,
}
send_email(
"zerver/emails/remote_billing_legacy_server_confirm_login",
to_emails=[email],
from_address=FromAddress.tokenized_no_reply_address(),
language=get_language(),
context=context,
)
return render(
request,
"corporate/billing/remote_billing_email_confirmation_sent.html",
context={"email": email, "remote_server_hostname": remote_server.hostname},
)
def has_live_plan_for_any_remote_realm_on_server(server: RemoteZulipServer) -> bool:
has_plan_with_status_lt_live_threshold = CustomerPlan.objects.filter(
customer__remote_realm__server=server,
status__lt=CustomerPlan.LIVE_STATUS_THRESHOLD,
customer__remote_realm=OuterRef("pk"),
)
return (
RemoteRealm.objects.filter(server=server)
.alias(has_plan=Exists(has_plan_with_status_lt_live_threshold))
.filter(has_plan=True)
.exists()
)
@self_hosting_management_endpoint
@typed_endpoint
def remote_billing_legacy_server_from_login_confirmation_link(
request: HttpRequest,
*,
confirmation_key: PathOnly[str],
full_name: str | None = None,
tos_consent: Literal["true"] | None = None,
enable_major_release_emails: Literal["true", "false"] | None = None,
enable_maintenance_release_emails: Literal["true", "false"] | None = None,
) -> HttpResponse:
"""
The user comes here via the confirmation link they received via email.
"""
from corporate.lib.stripe import RemoteServerBillingSession
if request.method not in ["GET", "POST"]:
return HttpResponseNotAllowed(["GET", "POST"])
try:
prereg_object = get_object_from_key(
confirmation_key,
[Confirmation.REMOTE_SERVER_BILLING_LEGACY_LOGIN],
# These links are reusable. The legacy server login flow requires
# credentials only available to the server admin. They might want to
# just do the authentication step, obtain the confirmation link
# for login and pass it on to a different person responsible for
# setting up the billing.
mark_object_used=False,
)
except ConfirmationKeyError as exception:
return render_confirmation_key_error(request, exception)
assert isinstance(prereg_object, PreregistrationRemoteServerBillingUser)
remote_server = prereg_object.remote_server
remote_server_uuid = str(remote_server.uuid)
remote_billing_user = RemoteServerBillingUser.objects.filter(
remote_server=remote_server, email=prereg_object.email
).first()
tos_consent_needed = remote_billing_user is None or is_tos_consent_needed_for_user(
remote_billing_user
)
if request.method == "GET":
context = {
"remote_server_uuid": remote_server_uuid,
"host": remote_server.hostname,
"user_full_name": getattr(remote_billing_user, "full_name", None),
"user_email": prereg_object.email,
"tos_consent_needed": tos_consent_needed,
"action_url": reverse(
remote_billing_legacy_server_from_login_confirmation_link,
args=(confirmation_key,),
),
"legacy_server_confirmation_flow": True,
"next_page": prereg_object.next_page,
}
return render(
request,
"corporate/billing/remote_billing_finalize_login_confirmation.html",
context=context,
)
assert request.method == "POST"
if tos_consent_needed and not tos_consent:
# This shouldn't be possible without tampering with the form, so we
# don't need a pretty error.
raise JsonableError(_("You must accept the Terms of Service to proceed."))
if (
has_live_plan_for_any_remote_realm_on_server(remote_server)
and prereg_object.next_page != "deactivate"
):
return render(
request,
"corporate/billing/remote_server_login_error_for_any_realm_on_active_plan.html",
)
if remote_billing_user is None:
assert full_name is not None
assert settings.TERMS_OF_SERVICE_VERSION is not None
remote_billing_user = RemoteServerBillingUser.objects.create(
full_name=full_name,
email=prereg_object.email,
remote_server=remote_server,
tos_version=settings.TERMS_OF_SERVICE_VERSION,
enable_major_release_emails=enable_major_release_emails == "true",
enable_maintenance_release_emails=enable_maintenance_release_emails == "true",
)
prereg_object.created_user = remote_billing_user
prereg_object.save(update_fields=["created_user"])
remote_billing_user.last_login = timezone_now()
remote_billing_user.save(update_fields=["last_login"])
# Refresh IdentityDict in the session. (Or create it
# if the user came here e.g. in a different browser than they
# started the login flow in.)
request.session["remote_billing_identities"] = {}
request.session["remote_billing_identities"][f"remote_server:{remote_server_uuid}"] = (
LegacyServerIdentityDict(
remote_server_uuid=remote_server_uuid,
authenticated_at=datetime_to_timestamp(timezone_now()),
# Having a remote_billing_user_id indicates the auth has been completed.
# The user will now be granted access to authenticated endpoints.
remote_billing_user_id=remote_billing_user.id,
)
)
next_page = prereg_object.next_page
assert next_page in VALID_NEXT_PAGES
if next_page is not None:
return HttpResponseRedirect(
reverse(f"remote_server_{next_page}_page", args=(remote_server_uuid,))
)
elif remote_server.plan_type in [
RemoteZulipServer.PLAN_TYPE_SELF_MANAGED,
RemoteZulipServer.PLAN_TYPE_SELF_MANAGED_LEGACY,
]:
# If they have a scheduled upgrade while on a complimentary access plan,
# redirect to billing page.
billing_session = RemoteServerBillingSession(remote_server)
customer = billing_session.get_customer()
if (
customer is not None
and billing_session.get_complimentary_access_next_plan_name(customer) is not None
):
return HttpResponseRedirect(
reverse("remote_server_billing_page", args=(remote_server_uuid,))
)
return HttpResponseRedirect(reverse("remote_server_plans_page", args=(remote_server_uuid,)))
elif remote_server.plan_type == RemoteZulipServer.PLAN_TYPE_COMMUNITY:
return HttpResponseRedirect(
reverse("remote_server_sponsorship_page", args=(remote_server_uuid,))
)
else:
return HttpResponseRedirect(
reverse("remote_server_billing_page", args=(remote_server_uuid,))
)
def generate_confirmation_link_for_server_deactivation(
remote_server: RemoteZulipServer, validity_in_minutes: int
) -> str:
obj = PreregistrationRemoteServerBillingUser.objects.create(
email=remote_server.contact_email,
remote_server=remote_server,
next_page="deactivate",
)
url = create_remote_billing_confirmation_link(
obj,
Confirmation.REMOTE_SERVER_BILLING_LEGACY_LOGIN,
validity_in_minutes=validity_in_minutes,
)
return url
def check_rate_limits(
request: HttpRequest, remote_server: RemoteZulipServer
) -> HttpResponse | None:
try:
rate_limit_request_by_ip(request, domain="sends_email_by_ip")
except RateLimitedError as e:
# Our generic error response is good enough here, since this is
# about the user's IP address, not their entire server.
assert e.secs_to_freedom is not None
retry_after_string = readable_expiry_string_for_html(int(e.secs_to_freedom))
return render(
request,
"zerver/portico_error_pages/rate_limit_exceeded.html",
context={"retry_after_string": retry_after_string},
status=429,
)
try:
rate_limit_remote_server(request, remote_server, "sends_email_by_remote_server")
except RateLimitedError as e:
# In this case it's the limit for the entire server the user belongs to
# that was exceeded, so we need to show an error page explaining
# that specific situation.
assert e.secs_to_freedom is not None
retry_after_string = readable_expiry_string_for_html(int(e.secs_to_freedom))
return render(
request,
"corporate/billing/remote_server_rate_limit_exceeded.html",
context={"retry_after_string": retry_after_string},
status=429,
)
return None

110
corporate/views/session.py Normal file
View File

@@ -0,0 +1,110 @@
import logging
from typing import TYPE_CHECKING
from django.http import HttpRequest, HttpResponse
from pydantic import Json
from corporate.lib.decorator import (
authenticated_remote_realm_management_endpoint,
authenticated_remote_server_management_endpoint,
)
from zerver.decorator import require_billing_access
from zerver.lib.response import json_success
from zerver.lib.typed_endpoint import typed_endpoint
from zerver.models import UserProfile
if TYPE_CHECKING:
from corporate.lib.stripe import RemoteRealmBillingSession, RemoteServerBillingSession
billing_logger = logging.getLogger("corporate.stripe")
@require_billing_access
def start_card_update_stripe_session(request: HttpRequest, user: UserProfile) -> HttpResponse:
from corporate.lib.stripe import RealmBillingSession
billing_session = RealmBillingSession(user)
session_data = billing_session.create_card_update_session()
return json_success(
request,
data=session_data,
)
@authenticated_remote_realm_management_endpoint
def start_card_update_stripe_session_for_remote_realm(
request: HttpRequest, billing_session: "RemoteRealmBillingSession"
) -> HttpResponse: # nocoverage
session_data = billing_session.create_card_update_session()
return json_success(
request,
data=session_data,
)
@authenticated_remote_server_management_endpoint
def start_card_update_stripe_session_for_remote_server(
request: HttpRequest, billing_session: "RemoteServerBillingSession"
) -> HttpResponse: # nocoverage
session_data = billing_session.create_card_update_session()
return json_success(
request,
data=session_data,
)
@require_billing_access
@typed_endpoint
def start_card_update_stripe_session_for_realm_upgrade(
request: HttpRequest,
user: UserProfile,
*,
manual_license_management: Json[bool] = False,
tier: Json[int],
) -> HttpResponse:
from corporate.lib.stripe import RealmBillingSession
billing_session = RealmBillingSession(user)
session_data = billing_session.create_card_update_session_for_upgrade(
manual_license_management, tier
)
return json_success(
request,
data=session_data,
)
@typed_endpoint
@authenticated_remote_realm_management_endpoint
def start_card_update_stripe_session_for_remote_realm_upgrade(
request: HttpRequest,
billing_session: "RemoteRealmBillingSession",
*,
manual_license_management: Json[bool] = False,
tier: Json[int],
) -> HttpResponse:
session_data = billing_session.create_card_update_session_for_upgrade(
manual_license_management, tier
)
return json_success(
request,
data=session_data,
)
@typed_endpoint
@authenticated_remote_server_management_endpoint
def start_card_update_stripe_session_for_remote_server_upgrade(
request: HttpRequest,
billing_session: "RemoteServerBillingSession",
*,
manual_license_management: Json[bool] = False,
tier: Json[int],
) -> HttpResponse:
session_data = billing_session.create_card_update_session_for_upgrade(
manual_license_management, tier
)
return json_success(
request,
data=session_data,
)

View File

@@ -0,0 +1,107 @@
from typing import TYPE_CHECKING
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from corporate.lib.decorator import (
authenticated_remote_realm_management_endpoint,
authenticated_remote_server_management_endpoint,
)
from zerver.decorator import require_billing_access, zulip_login_required
from zerver.lib.response import json_success
from zerver.models import UserProfile
if TYPE_CHECKING:
from corporate.lib.stripe import RemoteRealmBillingSession, RemoteServerBillingSession
@zulip_login_required
def sponsorship_page(request: HttpRequest) -> HttpResponse:
from corporate.lib.stripe import RealmBillingSession
user = request.user
assert user.is_authenticated
billing_session = RealmBillingSession(user)
if billing_session.realm.demo_organization_scheduled_deletion_date is not None:
return render(
request,
"corporate/billing/demo_organization_billing_disabled.html",
context={
"sponsorship_request": True,
},
)
context = billing_session.get_sponsorship_request_context()
if context is None or not user.has_billing_access:
return HttpResponseRedirect(reverse("billing_page"))
return render(request, "corporate/billing/sponsorship.html", context=context)
@authenticated_remote_realm_management_endpoint
def remote_realm_sponsorship_page(
request: HttpRequest,
billing_session: "RemoteRealmBillingSession",
) -> HttpResponse: # nocoverage
context = billing_session.get_sponsorship_request_context()
if context is None:
return HttpResponseRedirect(
reverse("remote_realm_billing_page", args=(billing_session.remote_realm.uuid,))
)
return render(request, "corporate/billing/sponsorship.html", context=context)
@authenticated_remote_server_management_endpoint
def remote_server_sponsorship_page(
request: HttpRequest,
billing_session: "RemoteServerBillingSession",
) -> HttpResponse: # nocoverage
context = billing_session.get_sponsorship_request_context()
if context is None:
return HttpResponseRedirect(
reverse("remote_server_billing_page", args=(billing_session.remote_server.uuid,))
)
return render(request, "corporate/billing/sponsorship.html", context=context)
@require_billing_access
def sponsorship(
request: HttpRequest,
user: UserProfile,
) -> HttpResponse:
from corporate.lib.stripe import RealmBillingSession, SponsorshipRequestForm
billing_session = RealmBillingSession(user)
post_data = request.POST.copy()
form = SponsorshipRequestForm(post_data)
billing_session.request_sponsorship(form)
return json_success(request)
@authenticated_remote_realm_management_endpoint
def remote_realm_sponsorship(
request: HttpRequest,
billing_session: "RemoteRealmBillingSession",
) -> HttpResponse: # nocoverage
from corporate.lib.stripe import SponsorshipRequestForm
post_data = request.POST.copy()
form = SponsorshipRequestForm(post_data)
billing_session.request_sponsorship(form)
return json_success(request)
@authenticated_remote_server_management_endpoint
def remote_server_sponsorship(
request: HttpRequest,
billing_session: "RemoteServerBillingSession",
) -> HttpResponse: # nocoverage
from corporate.lib.stripe import SponsorshipRequestForm
post_data = request.POST.copy()
form = SponsorshipRequestForm(post_data)
billing_session.request_sponsorship(form)
return json_success(request)

1011
corporate/views/support.py Normal file

File diff suppressed because it is too large Load Diff

293
corporate/views/upgrade.py Normal file
View File

@@ -0,0 +1,293 @@
import logging
from typing import TYPE_CHECKING
from django.conf import settings
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from pydantic import Json
from corporate.lib.billing_types import BillingModality, BillingSchedule, LicenseManagement
from corporate.lib.decorator import (
authenticated_remote_realm_management_endpoint,
authenticated_remote_server_management_endpoint,
)
from corporate.models.plans import CustomerPlan
from zerver.decorator import require_billing_access, zulip_login_required
from zerver.lib.response import json_success
from zerver.lib.typed_endpoint import typed_endpoint
from zerver.models import UserProfile
from zilencer.lib.remote_counts import MissingDataError
if TYPE_CHECKING:
from corporate.lib.stripe import RemoteRealmBillingSession, RemoteServerBillingSession
billing_logger = logging.getLogger("corporate.stripe")
@require_billing_access
@typed_endpoint
def upgrade(
request: HttpRequest,
user: UserProfile,
*,
billing_modality: BillingModality,
schedule: BillingSchedule,
signed_seat_count: str,
salt: str,
license_management: LicenseManagement | None = None,
licenses: Json[int] | None = None,
tier: Json[int] = CustomerPlan.TIER_CLOUD_STANDARD,
) -> HttpResponse:
from corporate.lib.stripe import BillingError, RealmBillingSession, UpgradeRequest
try:
upgrade_request = UpgradeRequest(
billing_modality=billing_modality,
schedule=schedule,
signed_seat_count=signed_seat_count,
salt=salt,
license_management=license_management,
licenses=licenses,
tier=tier,
remote_server_plan_start_date=None,
)
billing_session = RealmBillingSession(user)
data = billing_session.do_upgrade(upgrade_request)
return json_success(request, data)
except BillingError as e:
billing_logger.warning(
"BillingError during upgrade: %s. user=%s, realm=%s (%s), billing_modality=%s, "
"schedule=%s, license_management=%s, licenses=%s",
e.error_description,
user.id,
user.realm.id,
user.realm.string_id,
billing_modality,
schedule,
license_management,
licenses,
)
raise
except Exception:
billing_logger.exception("Uncaught exception in billing:", stack_info=True)
error_message = BillingError.CONTACT_SUPPORT.format(email=settings.ZULIP_ADMINISTRATOR)
error_description = "uncaught exception during upgrade"
raise BillingError(error_description, error_message)
@typed_endpoint
@authenticated_remote_realm_management_endpoint
def remote_realm_upgrade(
request: HttpRequest,
billing_session: "RemoteRealmBillingSession",
*,
billing_modality: BillingModality,
schedule: BillingSchedule,
signed_seat_count: str,
salt: str,
license_management: LicenseManagement | None = None,
licenses: Json[int] | None = None,
remote_server_plan_start_date: str | None = None,
tier: Json[int] = CustomerPlan.TIER_SELF_HOSTED_BUSINESS,
) -> HttpResponse:
from corporate.lib.stripe import BillingError, UpgradeRequest
try:
upgrade_request = UpgradeRequest(
billing_modality=billing_modality,
schedule=schedule,
signed_seat_count=signed_seat_count,
salt=salt,
license_management=license_management,
licenses=licenses,
tier=tier,
remote_server_plan_start_date=remote_server_plan_start_date,
)
data = billing_session.do_upgrade(upgrade_request)
return json_success(request, data)
except BillingError as e: # nocoverage
billing_logger.warning(
"BillingError during upgrade: %s. remote_realm=%s (%s), billing_modality=%s, "
"schedule=%s, license_management=%s, licenses=%s",
e.error_description,
billing_session.remote_realm.id,
billing_session.remote_realm.host,
billing_modality,
schedule,
license_management,
licenses,
)
raise
except Exception: # nocoverage
billing_logger.exception("Uncaught exception in billing:", stack_info=True)
error_message = BillingError.CONTACT_SUPPORT.format(email=settings.ZULIP_ADMINISTRATOR)
error_description = "uncaught exception during upgrade"
raise BillingError(error_description, error_message)
@typed_endpoint
@authenticated_remote_server_management_endpoint
def remote_server_upgrade(
request: HttpRequest,
billing_session: "RemoteServerBillingSession",
*,
billing_modality: BillingModality,
schedule: BillingSchedule,
signed_seat_count: str,
salt: str,
license_management: LicenseManagement | None = None,
licenses: Json[int] | None = None,
remote_server_plan_start_date: str | None = None,
tier: Json[int] = CustomerPlan.TIER_SELF_HOSTED_BUSINESS,
) -> HttpResponse:
from corporate.lib.stripe import BillingError, UpgradeRequest
try:
upgrade_request = UpgradeRequest(
billing_modality=billing_modality,
schedule=schedule,
signed_seat_count=signed_seat_count,
salt=salt,
license_management=license_management,
licenses=licenses,
tier=tier,
remote_server_plan_start_date=remote_server_plan_start_date,
)
data = billing_session.do_upgrade(upgrade_request)
return json_success(request, data)
except BillingError as e: # nocoverage
billing_logger.warning(
"BillingError during upgrade: %s. remote_server=%s (%s), billing_modality=%s, "
"schedule=%s, license_management=%s, licenses=%s",
e.error_description,
billing_session.remote_server.id,
billing_session.remote_server.hostname,
billing_modality,
schedule,
license_management,
licenses,
)
raise
except Exception: # nocoverage
billing_logger.exception("Uncaught exception in billing:", stack_info=True)
error_message = BillingError.CONTACT_SUPPORT.format(email=settings.ZULIP_ADMINISTRATOR)
error_description = "uncaught exception during upgrade"
raise BillingError(error_description, error_message)
@zulip_login_required
@typed_endpoint
def upgrade_page(
request: HttpRequest,
*,
manual_license_management: Json[bool] = False,
tier: Json[int] = CustomerPlan.TIER_CLOUD_STANDARD,
setup_payment_by_invoice: Json[bool] = False,
) -> HttpResponse:
from corporate.lib.stripe import InitialUpgradeRequest, RealmBillingSession
user = request.user
assert user.is_authenticated
if not settings.BILLING_ENABLED or user.is_guest:
return render(request, "404.html", status=404)
billing_modality = "charge_automatically"
if setup_payment_by_invoice:
billing_modality = "send_invoice"
initial_upgrade_request = InitialUpgradeRequest(
manual_license_management=manual_license_management,
tier=tier,
billing_modality=billing_modality,
)
billing_session = RealmBillingSession(user)
if billing_session.realm.demo_organization_scheduled_deletion_date is not None:
return render(
request,
"corporate/billing/demo_organization_billing_disabled.html",
context={
"upgrade_request": True,
},
)
redirect_url, context = billing_session.get_initial_upgrade_context(initial_upgrade_request)
if redirect_url:
return HttpResponseRedirect(redirect_url)
if not user.has_billing_access:
return HttpResponseRedirect(reverse("billing_page"))
response = render(request, "corporate/billing/upgrade.html", context=context)
return response
@typed_endpoint
@authenticated_remote_realm_management_endpoint
def remote_realm_upgrade_page(
request: HttpRequest,
billing_session: "RemoteRealmBillingSession",
*,
manual_license_management: Json[bool] = False,
success_message: str = "",
tier: str = str(CustomerPlan.TIER_SELF_HOSTED_BUSINESS),
setup_payment_by_invoice: Json[bool] = False,
) -> HttpResponse:
from corporate.lib.stripe import InitialUpgradeRequest
billing_modality = "charge_automatically"
if setup_payment_by_invoice: # nocoverage
billing_modality = "send_invoice"
initial_upgrade_request = InitialUpgradeRequest(
manual_license_management=manual_license_management,
tier=int(tier),
success_message=success_message,
billing_modality=billing_modality,
)
try:
redirect_url, context = billing_session.get_initial_upgrade_context(initial_upgrade_request)
except MissingDataError: # nocoverage
return billing_session.missing_data_error_page(request)
if redirect_url: # nocoverage
return HttpResponseRedirect(redirect_url)
response = render(request, "corporate/billing/upgrade.html", context=context)
return response
@typed_endpoint
@authenticated_remote_server_management_endpoint
def remote_server_upgrade_page(
request: HttpRequest,
billing_session: "RemoteServerBillingSession",
*,
manual_license_management: Json[bool] = False,
success_message: str = "",
tier: str = str(CustomerPlan.TIER_SELF_HOSTED_BUSINESS),
setup_payment_by_invoice: Json[bool] = False,
) -> HttpResponse:
from corporate.lib.stripe import InitialUpgradeRequest
billing_modality = "charge_automatically"
if setup_payment_by_invoice: # nocoverage
billing_modality = "send_invoice"
initial_upgrade_request = InitialUpgradeRequest(
manual_license_management=manual_license_management,
tier=int(tier),
success_message=success_message,
billing_modality=billing_modality,
)
try:
redirect_url, context = billing_session.get_initial_upgrade_context(initial_upgrade_request)
except MissingDataError: # nocoverage
return billing_session.missing_data_error_page(request)
if redirect_url: # nocoverage
return HttpResponseRedirect(redirect_url)
response = render(request, "corporate/billing/upgrade.html", context=context)
return response

View File

@@ -0,0 +1,85 @@
from typing import Any
from django.db.models import QuerySet
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from corporate.lib.activity import (
ActivityHeaderEntry,
format_optional_datetime,
make_table,
user_activity_link,
user_support_link,
)
from zerver.decorator import require_server_admin
from zerver.models import UserActivity, UserProfile
from zerver.models.users import get_user_profile_by_id
def get_user_activity_records(
user_profile: UserProfile,
) -> QuerySet[UserActivity]:
fields = [
"query",
"client__name",
"count",
"last_visit",
]
records = (
UserActivity.objects.filter(
user_profile=user_profile,
)
.order_by("-last_visit")
.select_related("client")
.only(*fields)
)
return records
@require_server_admin
def get_user_activity(request: HttpRequest, user_profile_id: int) -> HttpResponse:
user_profile = get_user_profile_by_id(user_profile_id)
records = get_user_activity_records(user_profile)
title = f"{user_profile.full_name}"
cols = [
"Query",
"Client",
"Count",
"Last visit (UTC)",
]
def row(record: UserActivity) -> list[Any]:
return [
record.query,
record.client.name,
record.count,
format_optional_datetime(record.last_visit),
]
rows = list(map(row, records))
header_entries = []
header_entries.append(ActivityHeaderEntry(name="Email", value=user_profile.delivery_email))
header_entries.append(ActivityHeaderEntry(name="Realm", value=user_profile.realm.name))
if user_profile.is_bot and user_profile.bot_owner is not None:
bot_owner_link = user_activity_link(
user_profile.bot_owner.delivery_email, user_profile.bot_owner.id
)
header_entries.append(ActivityHeaderEntry(name="Bot owner", value=bot_owner_link))
user_support = user_support_link(user_profile.delivery_email)
content = make_table(title, cols, rows, header=header_entries, title_link=user_support)
return render(
request,
"corporate/activity/activity.html",
context=dict(
data=content,
title=title,
is_home=False,
),
)

View File

@@ -0,0 +1,96 @@
import json
import logging
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.http import HttpRequest, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from corporate.models.stripe_state import Event, Invoice, Session
from zproject.config import get_secret
billing_logger = logging.getLogger("corporate.stripe")
@csrf_exempt
def stripe_webhook(request: HttpRequest) -> HttpResponse:
import stripe
from corporate.lib.stripe import STRIPE_API_VERSION
from corporate.lib.stripe_event_handler import (
handle_checkout_session_completed_event,
handle_invoice_paid_event,
handle_invoice_voided_event,
)
stripe_webhook_endpoint_secret = get_secret("stripe_webhook_endpoint_secret", "")
if (
stripe_webhook_endpoint_secret and not settings.TEST_SUITE
): # nocoverage: We can't verify the signature in test suite since we fetch the events
# from Stripe events API and manually post to the webhook endpoint.
stripe_signature = request.headers.get("Stripe-Signature")
if stripe_signature is None:
return HttpResponse(status=400)
try:
stripe_event = stripe.Webhook.construct_event(
request.body,
stripe_signature,
stripe_webhook_endpoint_secret,
)
except ValueError:
return HttpResponse(status=400)
except stripe.SignatureVerificationError:
return HttpResponse(status=400)
else:
assert not settings.PRODUCTION
try:
stripe_event = stripe.Event.construct_from(json.loads(request.body), stripe.api_key)
except Exception:
return HttpResponse(status=400)
if stripe_event.api_version != STRIPE_API_VERSION:
error_message = f"Mismatch between billing system Stripe API version({STRIPE_API_VERSION}) and Stripe webhook event API version({stripe_event.api_version})."
billing_logger.error(error_message)
return HttpResponse(status=400)
if stripe_event.type not in [
"checkout.session.completed",
"invoice.paid",
"invoice.voided",
]:
return HttpResponse(status=200)
if Event.objects.filter(stripe_event_id=stripe_event.id).exists():
return HttpResponse(status=200)
event = Event(stripe_event_id=stripe_event.id, type=stripe_event.type)
if stripe_event.type == "checkout.session.completed":
stripe_session = stripe_event.data.object
assert isinstance(stripe_session, stripe.checkout.Session)
try:
session = Session.objects.get(stripe_session_id=stripe_session.id)
except Session.DoesNotExist:
return HttpResponse(status=200)
event.content_type = ContentType.objects.get_for_model(Session)
event.object_id = session.id
event.save()
handle_checkout_session_completed_event(stripe_session, event)
elif stripe_event.type in ("invoice.paid", "invoice.voided"):
stripe_invoice = stripe_event.data.object
assert isinstance(stripe_invoice, stripe.Invoice)
try:
invoice = Invoice.objects.get(stripe_invoice_id=stripe_invoice.id)
except Invoice.DoesNotExist:
return HttpResponse(status=200)
event.content_type = ContentType.objects.get_for_model(Invoice)
event.object_id = invoice.id
event.save()
if stripe_event.type == "invoice.paid":
handle_invoice_paid_event(stripe_invoice, event)
else:
handle_invoice_voided_event(stripe_invoice, event)
# We don't need to process failed payments via webhooks since we directly charge users
# when they click on "Purchase" button and immediately provide feedback for failed payments.
# If the feedback is not immediate, our event_status handler checks for payment status and informs the user.
return HttpResponse(status=200)