snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
This commit is contained in:
0
corporate/views/__init__.py
Normal file
0
corporate/views/__init__.py
Normal file
102
corporate/views/audit_logs.py
Normal file
102
corporate/views/audit_logs.py
Normal 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,
|
||||
),
|
||||
)
|
||||
363
corporate/views/billing_page.py
Normal file
363
corporate/views/billing_page.py
Normal 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},
|
||||
)
|
||||
127
corporate/views/event_status.py
Normal file
127
corporate/views/event_status.py
Normal 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)
|
||||
477
corporate/views/installation_activity.py
Normal file
477
corporate/views/installation_activity.py
Normal 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,
|
||||
),
|
||||
)
|
||||
74
corporate/views/plan_activity.py
Normal file
74
corporate/views/plan_activity.py
Normal 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
486
corporate/views/portico.py
Normal 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)
|
||||
189
corporate/views/realm_activity.py
Normal file
189
corporate/views/realm_activity.py
Normal 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,
|
||||
),
|
||||
)
|
||||
285
corporate/views/remote_activity.py
Normal file
285
corporate/views/remote_activity.py
Normal 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),
|
||||
)
|
||||
873
corporate/views/remote_billing_page.py
Normal file
873
corporate/views/remote_billing_page.py
Normal 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
110
corporate/views/session.py
Normal 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,
|
||||
)
|
||||
107
corporate/views/sponsorship.py
Normal file
107
corporate/views/sponsorship.py
Normal 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
1011
corporate/views/support.py
Normal file
File diff suppressed because it is too large
Load Diff
293
corporate/views/upgrade.py
Normal file
293
corporate/views/upgrade.py
Normal 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
|
||||
85
corporate/views/user_activity.py
Normal file
85
corporate/views/user_activity.py
Normal 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,
|
||||
),
|
||||
)
|
||||
96
corporate/views/webhook.py
Normal file
96
corporate/views/webhook.py
Normal 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)
|
||||
Reference in New Issue
Block a user