mirror of https://github.com/zulip/zulip.git
corporate: Add prototype authentication system for self-hosters.
This makes it possible for a self-hosted realm administrator to directly access a logged-page on the push notifications bouncer service, enabling billing, support contacts, and other administrator for enterprise customers to be managed without manual setup.
This commit is contained in:
parent
1ec0d5bd9d
commit
3958743b33
|
@ -15,6 +15,13 @@ from corporate.views.portico import (
|
||||||
plans_view,
|
plans_view,
|
||||||
team_view,
|
team_view,
|
||||||
)
|
)
|
||||||
|
from corporate.views.remote_billing_page import (
|
||||||
|
remote_billing_page_realm,
|
||||||
|
remote_billing_page_server,
|
||||||
|
remote_billing_plans_realm,
|
||||||
|
remote_billing_plans_server,
|
||||||
|
remote_server_billing_finalize_login,
|
||||||
|
)
|
||||||
from corporate.views.session import (
|
from corporate.views.session import (
|
||||||
start_card_update_stripe_session,
|
start_card_update_stripe_session,
|
||||||
start_card_update_stripe_session_for_realm_upgrade,
|
start_card_update_stripe_session_for_realm_upgrade,
|
||||||
|
@ -147,3 +154,18 @@ urlpatterns += [
|
||||||
path("api/v1/", include(v1_api_and_json_patterns)),
|
path("api/v1/", include(v1_api_and_json_patterns)),
|
||||||
path("json/", include(v1_api_and_json_patterns)),
|
path("json/", include(v1_api_and_json_patterns)),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
urlpatterns += [
|
||||||
|
path(
|
||||||
|
"remote-billing-login/<signed_billing_access_token>", remote_server_billing_finalize_login
|
||||||
|
),
|
||||||
|
# Remote server billling endpoints.
|
||||||
|
path("realm/<realm_uuid>/plans", remote_billing_plans_realm, name="remote_billing_plans_realm"),
|
||||||
|
path(
|
||||||
|
"server/<server_uuid>/plans",
|
||||||
|
remote_billing_plans_server,
|
||||||
|
name="remote_billing_plans_server",
|
||||||
|
),
|
||||||
|
path("realm/<realm_uuid>/billing", remote_billing_page_realm, name="remote_billing_page_realm"),
|
||||||
|
path("server/<server_uuid>/", remote_billing_page_server, name="remote_billing_page_server"),
|
||||||
|
]
|
||||||
|
|
|
@ -0,0 +1,196 @@
|
||||||
|
import logging
|
||||||
|
from typing import Optional, TypedDict
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core import signing
|
||||||
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from pydantic import Json
|
||||||
|
|
||||||
|
from zerver.decorator import self_hosting_management_endpoint
|
||||||
|
from zerver.lib.exceptions import JsonableError, MissingRemoteRealmError
|
||||||
|
from zerver.lib.remote_server import RealmDataForAnalytics, UserDataForRemoteBilling
|
||||||
|
from zerver.lib.response import json_success
|
||||||
|
from zerver.lib.typed_endpoint import PathOnly, typed_endpoint
|
||||||
|
from zilencer.models import RemoteRealm, RemoteZulipServer
|
||||||
|
|
||||||
|
billing_logger = logging.getLogger("corporate.stripe")
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteBillingIdentityDict(TypedDict):
|
||||||
|
user_uuid: str
|
||||||
|
user_email: str
|
||||||
|
user_full_name: str
|
||||||
|
remote_server_uuid: str
|
||||||
|
remote_realm_uuid: str
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@typed_endpoint
|
||||||
|
def remote_server_billing_entry(
|
||||||
|
request: HttpRequest,
|
||||||
|
remote_server: RemoteZulipServer,
|
||||||
|
*,
|
||||||
|
user: Json[UserDataForRemoteBilling],
|
||||||
|
realm: Json[RealmDataForAnalytics],
|
||||||
|
) -> HttpResponse:
|
||||||
|
if not settings.DEVELOPMENT:
|
||||||
|
return render(request, "404.html", status=404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
remote_realm = RemoteRealm.objects.get(uuid=realm.uuid, server=remote_server)
|
||||||
|
except RemoteRealm.DoesNotExist:
|
||||||
|
# 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_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),
|
||||||
|
)
|
||||||
|
|
||||||
|
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_server_billing_finalize_login, args=[signed_identity_dict])
|
||||||
|
)
|
||||||
|
return json_success(request, data={"billing_access_url": billing_access_url})
|
||||||
|
|
||||||
|
|
||||||
|
@self_hosting_management_endpoint
|
||||||
|
def remote_server_billing_finalize_login(
|
||||||
|
request: HttpRequest, signed_billing_access_token: str
|
||||||
|
) -> HttpResponse:
|
||||||
|
try:
|
||||||
|
identity_dict: RemoteBillingIdentityDict = signing.loads(
|
||||||
|
signed_billing_access_token, max_age=settings.SIGNED_ACCESS_TOKEN_VALIDITY_IN_SECONDS
|
||||||
|
)
|
||||||
|
except signing.SignatureExpired:
|
||||||
|
raise JsonableError(_("Billing access token expired."))
|
||||||
|
except signing.BadSignature:
|
||||||
|
raise JsonableError(_("Invalid billing access token."))
|
||||||
|
|
||||||
|
remote_realm_uuid = identity_dict["remote_realm_uuid"]
|
||||||
|
|
||||||
|
request.session["remote_billing_identities"] = {}
|
||||||
|
request.session["remote_billing_identities"][remote_realm_uuid] = identity_dict
|
||||||
|
|
||||||
|
# TODO: Figure out redirects based on whether the realm/server already has a plan
|
||||||
|
# and should be taken to /billing or doesn't have and should be taken to /plans.
|
||||||
|
# For now we're only implemented the case where we have the RemoteRealm, and we take
|
||||||
|
# to /plans.
|
||||||
|
|
||||||
|
return HttpResponseRedirect(reverse("remote_billing_plans_realm", args=(remote_realm_uuid,)))
|
||||||
|
|
||||||
|
|
||||||
|
def render_tmp_remote_billing_page(
|
||||||
|
request: HttpRequest, realm_uuid: Optional[str], server_uuid: Optional[str]
|
||||||
|
) -> HttpResponse:
|
||||||
|
authed_uuid = realm_uuid or server_uuid
|
||||||
|
assert authed_uuid is not None
|
||||||
|
|
||||||
|
identity_dict = None
|
||||||
|
|
||||||
|
identity_dicts = request.session.get("remote_billing_identities")
|
||||||
|
if identity_dicts is not None:
|
||||||
|
identity_dict = identity_dicts.get(authed_uuid)
|
||||||
|
|
||||||
|
if identity_dict is None:
|
||||||
|
raise JsonableError(_("User not authenticated"))
|
||||||
|
|
||||||
|
user_email = identity_dict["user_email"]
|
||||||
|
user_full_name = identity_dict["user_full_name"]
|
||||||
|
remote_server_uuid = identity_dict["remote_server_uuid"]
|
||||||
|
remote_realm_uuid = identity_dict["remote_realm_uuid"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
remote_server = RemoteZulipServer.objects.get(uuid=remote_server_uuid)
|
||||||
|
except RemoteZulipServer.DoesNotExist:
|
||||||
|
raise JsonableError(_("Invalid remote server."))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Checking for the (uuid, server) is sufficient to be secure here, since the server
|
||||||
|
# is authenticated. the uuid_owner_secret is not needed here, it'll be used for
|
||||||
|
# for validating transfers of a realm to a different RemoteZulipServer (in the
|
||||||
|
# export-import process).
|
||||||
|
remote_realm = RemoteRealm.objects.get(uuid=remote_realm_uuid, server=remote_server)
|
||||||
|
except RemoteRealm.DoesNotExist:
|
||||||
|
raise AssertionError(
|
||||||
|
"The remote realm is missing despite being in the RemoteBillingIdentityDict"
|
||||||
|
)
|
||||||
|
|
||||||
|
remote_server_and_realm_info = {
|
||||||
|
"remote_server_uuid": remote_server_uuid,
|
||||||
|
"remote_server_hostname": remote_server.hostname,
|
||||||
|
"remote_server_contact_email": remote_server.contact_email,
|
||||||
|
"remote_server_plan_type": remote_server.plan_type,
|
||||||
|
"remote_realm_uuid": remote_realm_uuid,
|
||||||
|
"remote_realm_host": remote_realm.host,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"corporate/remote_billing.html",
|
||||||
|
context={
|
||||||
|
"user_email": user_email,
|
||||||
|
"user_full_name": user_full_name,
|
||||||
|
"remote_server_and_realm_info": remote_server_and_realm_info,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remote_billing_plans_common(
|
||||||
|
request: HttpRequest, realm_uuid: Optional[str], server_uuid: Optional[str]
|
||||||
|
) -> HttpResponse:
|
||||||
|
"""
|
||||||
|
Once implemented, this function, shared between remote_billing_plans_realm
|
||||||
|
and remote_billing_plans_server, will return a Plans page, adjusted depending
|
||||||
|
on whether the /realm/... or /server/... endpoint is being used
|
||||||
|
"""
|
||||||
|
|
||||||
|
return render_tmp_remote_billing_page(request, realm_uuid=realm_uuid, server_uuid=server_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
@self_hosting_management_endpoint
|
||||||
|
@typed_endpoint
|
||||||
|
def remote_billing_plans_realm(request: HttpRequest, *, realm_uuid: PathOnly[str]) -> HttpResponse:
|
||||||
|
return remote_billing_plans_common(request, realm_uuid=realm_uuid, server_uuid=None)
|
||||||
|
|
||||||
|
|
||||||
|
@self_hosting_management_endpoint
|
||||||
|
@typed_endpoint
|
||||||
|
def remote_billing_plans_server(
|
||||||
|
request: HttpRequest, *, server_uuid: PathOnly[str]
|
||||||
|
) -> HttpResponse:
|
||||||
|
return remote_billing_plans_common(request, server_uuid=server_uuid, realm_uuid=None)
|
||||||
|
|
||||||
|
|
||||||
|
def remote_billing_page_common(
|
||||||
|
request: HttpRequest, realm_uuid: Optional[str], server_uuid: Optional[str]
|
||||||
|
) -> HttpResponse:
|
||||||
|
"""
|
||||||
|
Once implemented, this function, shared between remote_billing_page_realm
|
||||||
|
and remote_billing_page_server, will return a Billing page, adjusted depending
|
||||||
|
on whether the /realm/... or /server/... endpoint is being used
|
||||||
|
"""
|
||||||
|
|
||||||
|
return render_tmp_remote_billing_page(request, realm_uuid=realm_uuid, server_uuid=server_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
@self_hosting_management_endpoint
|
||||||
|
@typed_endpoint
|
||||||
|
def remote_billing_page_server(request: HttpRequest, *, server_uuid: PathOnly[str]) -> HttpResponse:
|
||||||
|
return remote_billing_page_common(request, server_uuid=server_uuid, realm_uuid=None)
|
||||||
|
|
||||||
|
|
||||||
|
@self_hosting_management_endpoint
|
||||||
|
@typed_endpoint
|
||||||
|
def remote_billing_page_realm(request: HttpRequest, *, realm_uuid: PathOnly[str]) -> HttpResponse:
|
||||||
|
return remote_billing_page_common(request, realm_uuid=realm_uuid, server_uuid=None)
|
|
@ -0,0 +1,22 @@
|
||||||
|
{% extends "zerver/portico.html" %}
|
||||||
|
{% set entrypoint = "upgrade" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
<title>{{ _("Billing") }} | Zulip</title>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block portico_content %}
|
||||||
|
<div id="remote-billing-page" class="register-account flex full-page">
|
||||||
|
<div class="center-block new-style">
|
||||||
|
<div class="pitch">
|
||||||
|
<h1>Your remote user info: </h1>
|
||||||
|
</div>
|
||||||
|
<div class="white-box">
|
||||||
|
Email: {{ user_email }}<br />
|
||||||
|
Full name: {{ user_full_name }}<br />
|
||||||
|
Remote server hostname: {{ remote_server_and_realm_info["remote_server_hostname"] }}<br />
|
||||||
|
Remote realm host: {{ remote_server_and_realm_info.get("remote_realm_host") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -54,6 +54,9 @@ not_yet_fully_covered = [
|
||||||
"analytics/views/installation_activity.py",
|
"analytics/views/installation_activity.py",
|
||||||
"analytics/views/stats.py",
|
"analytics/views/stats.py",
|
||||||
"analytics/views/support.py",
|
"analytics/views/support.py",
|
||||||
|
# TODO: This is a work in progress and therefore without
|
||||||
|
# tests yet.
|
||||||
|
"corporate/views/remote_billing_page.py",
|
||||||
# Major lib files should have 100% coverage
|
# Major lib files should have 100% coverage
|
||||||
"zerver/actions/presence.py",
|
"zerver/actions/presence.py",
|
||||||
"zerver/lib/addressee.py",
|
"zerver/lib/addressee.py",
|
||||||
|
|
|
@ -196,6 +196,7 @@ export function get_gear_menu_content_context() {
|
||||||
is_owner: page_params.is_owner,
|
is_owner: page_params.is_owner,
|
||||||
is_admin: page_params.is_admin,
|
is_admin: page_params.is_admin,
|
||||||
is_self_hosted: page_params.realm_plan_type === 1,
|
is_self_hosted: page_params.realm_plan_type === 1,
|
||||||
|
is_development_environment: page_params.development_environment,
|
||||||
is_plan_limited: page_params.realm_plan_type === 2,
|
is_plan_limited: page_params.realm_plan_type === 2,
|
||||||
is_plan_standard,
|
is_plan_standard,
|
||||||
is_plan_standard_sponsored_for_free: page_params.realm_plan_type === 4,
|
is_plan_standard_sponsored_for_free: page_params.realm_plan_type === 4,
|
||||||
|
@ -213,6 +214,7 @@ export function get_gear_menu_content_context() {
|
||||||
login_link: page_params.development_environment ? "/devlogin/" : "/login/",
|
login_link: page_params.development_environment ? "/devlogin/" : "/login/",
|
||||||
promote_sponsoring_zulip: page_params.promote_sponsoring_zulip,
|
promote_sponsoring_zulip: page_params.promote_sponsoring_zulip,
|
||||||
show_billing: page_params.show_billing,
|
show_billing: page_params.show_billing,
|
||||||
|
show_remote_billing: page_params.show_remote_billing,
|
||||||
show_plans: page_params.show_plans,
|
show_plans: page_params.show_plans,
|
||||||
show_webathena: page_params.show_webathena,
|
show_webathena: page_params.show_webathena,
|
||||||
sponsorship_pending: page_params.sponsorship_pending,
|
sponsorship_pending: page_params.sponsorship_pending,
|
||||||
|
|
|
@ -119,6 +119,14 @@
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
{{#if (and is_development_environment show_remote_billing) }}
|
||||||
|
{{! This is only shown in development environment until the UI is ready.}}
|
||||||
|
<li class="link-item navbar-dropdown-menu-inner-list-item">
|
||||||
|
<a href="/self-hosted-billing/" target="_blank" rel="noopener noreferrer" class="navigate-link-on-enter navbar-dropdown-menu-link">
|
||||||
|
<i class="navbar-dropdown-icon zulip-icon zulip-icon-credit-card" aria-hidden="true"></i> {{t 'Billing' }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{/if}}
|
||||||
{{#if promote_sponsoring_zulip}}
|
{{#if promote_sponsoring_zulip}}
|
||||||
<li class="link-item navbar-dropdown-menu-inner-list-item">
|
<li class="link-item navbar-dropdown-menu-inner-list-item">
|
||||||
<a href="https://zulip.com/help/support-zulip-project" target="_blank" rel="noopener noreferrer" class="navigate-link-on-enter navbar-dropdown-menu-link">
|
<a href="https://zulip.com/help/support-zulip-project" target="_blank" rel="noopener noreferrer" class="navigate-link-on-enter navbar-dropdown-menu-link">
|
||||||
|
|
|
@ -65,6 +65,7 @@ from zerver.models import UserProfile, get_client, get_user_profile_by_api_key
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.http.request import _ImmutableQueryDict
|
from django.http.request import _ImmutableQueryDict
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
webhook_logger = logging.getLogger("zulip.zerver.webhooks")
|
webhook_logger = logging.getLogger("zulip.zerver.webhooks")
|
||||||
webhook_unsupported_events_logger = logging.getLogger("zulip.zerver.webhooks.unsupported")
|
webhook_unsupported_events_logger = logging.getLogger("zulip.zerver.webhooks.unsupported")
|
||||||
|
@ -74,6 +75,21 @@ ParamT = ParamSpec("ParamT")
|
||||||
ReturnT = TypeVar("ReturnT")
|
ReturnT = TypeVar("ReturnT")
|
||||||
|
|
||||||
|
|
||||||
|
def self_hosting_management_endpoint(
|
||||||
|
view_func: Callable[Concatenate[HttpRequest, ParamT], HttpResponse]
|
||||||
|
) -> Callable[Concatenate[HttpRequest, ParamT], HttpResponse]: # nocoverage
|
||||||
|
@wraps(view_func)
|
||||||
|
def _wrapped_view_func(
|
||||||
|
request: HttpRequest, /, *args: ParamT.args, **kwargs: ParamT.kwargs
|
||||||
|
) -> HttpResponse:
|
||||||
|
subdomain = get_subdomain(request)
|
||||||
|
if not settings.DEVELOPMENT or subdomain != settings.SELF_HOSTING_MANAGEMENT_SUBDOMAIN:
|
||||||
|
return render(request, "404.html", status=404)
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return _wrapped_view_func
|
||||||
|
|
||||||
|
|
||||||
def update_user_activity(
|
def update_user_activity(
|
||||||
request: HttpRequest, user_profile: UserProfile, query: Optional[str]
|
request: HttpRequest, user_profile: UserProfile, query: Optional[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
@ -46,6 +46,7 @@ class ErrorCode(Enum):
|
||||||
REACTION_ALREADY_EXISTS = auto()
|
REACTION_ALREADY_EXISTS = auto()
|
||||||
REACTION_DOES_NOT_EXIST = auto()
|
REACTION_DOES_NOT_EXIST = auto()
|
||||||
SERVER_NOT_READY = auto()
|
SERVER_NOT_READY = auto()
|
||||||
|
MISSING_REMOTE_REALM = auto()
|
||||||
|
|
||||||
|
|
||||||
class JsonableError(Exception):
|
class JsonableError(Exception):
|
||||||
|
@ -570,3 +571,16 @@ class ApiParamValidationError(JsonableError):
|
||||||
class ServerNotReadyError(JsonableError):
|
class ServerNotReadyError(JsonableError):
|
||||||
code = ErrorCode.SERVER_NOT_READY
|
code = ErrorCode.SERVER_NOT_READY
|
||||||
http_status_code = 500
|
http_status_code = 500
|
||||||
|
|
||||||
|
|
||||||
|
class MissingRemoteRealmError(JsonableError): # nocoverage
|
||||||
|
code: ErrorCode = ErrorCode.MISSING_REMOTE_REALM
|
||||||
|
http_status_code = 403
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@override
|
||||||
|
def msg_format() -> str:
|
||||||
|
return _("Organization not registered")
|
||||||
|
|
|
@ -16,6 +16,7 @@ from zerver.lib.i18n import (
|
||||||
get_language_translation_data,
|
get_language_translation_data,
|
||||||
)
|
)
|
||||||
from zerver.lib.narrow_helpers import NarrowTerm
|
from zerver.lib.narrow_helpers import NarrowTerm
|
||||||
|
from zerver.lib.push_notifications import uses_notification_bouncer
|
||||||
from zerver.lib.realm_description import get_realm_rendered_description
|
from zerver.lib.realm_description import get_realm_rendered_description
|
||||||
from zerver.lib.request import RequestNotes
|
from zerver.lib.request import RequestNotes
|
||||||
from zerver.models import Message, Realm, Stream, UserProfile
|
from zerver.models import Message, Realm, Stream, UserProfile
|
||||||
|
@ -28,6 +29,7 @@ class BillingInfo:
|
||||||
show_billing: bool
|
show_billing: bool
|
||||||
show_plans: bool
|
show_plans: bool
|
||||||
sponsorship_pending: bool
|
sponsorship_pending: bool
|
||||||
|
show_remote_billing: bool
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
@ -80,6 +82,10 @@ def get_billing_info(user_profile: Optional[UserProfile]) -> BillingInfo:
|
||||||
show_billing = False
|
show_billing = False
|
||||||
show_plans = False
|
show_plans = False
|
||||||
sponsorship_pending = False
|
sponsorship_pending = False
|
||||||
|
show_remote_billing = (
|
||||||
|
user_profile is not None and user_profile.has_billing_access and uses_notification_bouncer()
|
||||||
|
)
|
||||||
|
|
||||||
# This query runs on home page load, so we want to avoid
|
# This query runs on home page load, so we want to avoid
|
||||||
# hitting the database if possible. So, we only run it for the user
|
# hitting the database if possible. So, we only run it for the user
|
||||||
# types that can actually see the billing info.
|
# types that can actually see the billing info.
|
||||||
|
@ -101,6 +107,7 @@ def get_billing_info(user_profile: Optional[UserProfile]) -> BillingInfo:
|
||||||
show_billing=show_billing,
|
show_billing=show_billing,
|
||||||
show_plans=show_plans,
|
show_plans=show_plans,
|
||||||
sponsorship_pending=sponsorship_pending,
|
sponsorship_pending=sponsorship_pending,
|
||||||
|
show_remote_billing=show_remote_billing,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -207,6 +214,7 @@ def build_page_params_for_home_page_load(
|
||||||
two_fa_enabled=two_fa_enabled,
|
two_fa_enabled=two_fa_enabled,
|
||||||
apps_page_url=get_apps_page_url(),
|
apps_page_url=get_apps_page_url(),
|
||||||
show_billing=billing_info.show_billing,
|
show_billing=billing_info.show_billing,
|
||||||
|
show_remote_billing=billing_info.show_remote_billing,
|
||||||
promote_sponsoring_zulip=promote_sponsoring_zulip_in_realm(realm),
|
promote_sponsoring_zulip=promote_sponsoring_zulip_in_realm(realm),
|
||||||
show_plans=billing_info.show_plans,
|
show_plans=billing_info.show_plans,
|
||||||
sponsorship_pending=billing_info.sponsorship_pending,
|
sponsorship_pending=billing_info.sponsorship_pending,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
import urllib
|
import urllib
|
||||||
from typing import Any, Dict, List, Mapping, Tuple, Union
|
from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
|
||||||
|
|
||||||
import orjson
|
import orjson
|
||||||
import requests
|
import requests
|
||||||
|
@ -11,7 +11,7 @@ from pydantic import UUID4, BaseModel, ConfigDict
|
||||||
|
|
||||||
from analytics.models import InstallationCount, RealmCount
|
from analytics.models import InstallationCount, RealmCount
|
||||||
from version import ZULIP_VERSION
|
from version import ZULIP_VERSION
|
||||||
from zerver.lib.exceptions import JsonableError
|
from zerver.lib.exceptions import JsonableError, MissingRemoteRealmError
|
||||||
from zerver.lib.export import floatify_datetime_fields
|
from zerver.lib.export import floatify_datetime_fields
|
||||||
from zerver.lib.outgoing_http import OutgoingSession
|
from zerver.lib.outgoing_http import OutgoingSession
|
||||||
from zerver.models import Realm, RealmAuditLog
|
from zerver.models import Realm, RealmAuditLog
|
||||||
|
@ -43,6 +43,12 @@ class RealmDataForAnalytics(BaseModel):
|
||||||
uuid_owner_secret: str
|
uuid_owner_secret: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserDataForRemoteBilling(BaseModel):
|
||||||
|
uuid: UUID4
|
||||||
|
email: str
|
||||||
|
full_name: str
|
||||||
|
|
||||||
|
|
||||||
def send_to_push_bouncer(
|
def send_to_push_bouncer(
|
||||||
method: str,
|
method: str,
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
|
@ -117,6 +123,14 @@ def send_to_push_bouncer(
|
||||||
from zerver.lib.push_notifications import InvalidRemotePushDeviceTokenError
|
from zerver.lib.push_notifications import InvalidRemotePushDeviceTokenError
|
||||||
|
|
||||||
raise InvalidRemotePushDeviceTokenError
|
raise InvalidRemotePushDeviceTokenError
|
||||||
|
elif (
|
||||||
|
endpoint == "server/billing"
|
||||||
|
and "code" in result_dict
|
||||||
|
and result_dict["code"] == "MISSING_REMOTE_REALM"
|
||||||
|
): # nocoverage
|
||||||
|
# The callers requesting this endpoint want the exception to propagate
|
||||||
|
# so they can catch it.
|
||||||
|
raise MissingRemoteRealmError
|
||||||
else:
|
else:
|
||||||
# But most other errors coming from the push bouncer
|
# But most other errors coming from the push bouncer
|
||||||
# server are client errors (e.g. never-registered token)
|
# server are client errors (e.g. never-registered token)
|
||||||
|
@ -186,8 +200,11 @@ def build_analytics_data(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_realms_info_for_push_bouncer() -> List[RealmDataForAnalytics]:
|
def get_realms_info_for_push_bouncer(realm_id: Optional[int] = None) -> List[RealmDataForAnalytics]:
|
||||||
realms = Realm.objects.order_by("id")
|
realms = Realm.objects.order_by("id")
|
||||||
|
if realm_id is not None: # nocoverage
|
||||||
|
realms = realms.filter(id=realm_id)
|
||||||
|
|
||||||
realm_info_list = [
|
realm_info_list = [
|
||||||
RealmDataForAnalytics(
|
RealmDataForAnalytics(
|
||||||
id=realm.id,
|
id=realm.id,
|
||||||
|
|
|
@ -531,6 +531,9 @@ def write_instrumentation_reports(full_suite: bool, include_webhooks: bool) -> N
|
||||||
"scim/v2/ServiceProviderConfig",
|
"scim/v2/ServiceProviderConfig",
|
||||||
"scim/v2/Groups(?:/(?P<uuid>[^/]+))?",
|
"scim/v2/Groups(?:/(?P<uuid>[^/]+))?",
|
||||||
"scim/v2/Groups/.search",
|
"scim/v2/Groups/.search",
|
||||||
|
# TODO: This endpoint and the rest of its system are a work in progress,
|
||||||
|
# we are not testing it yet.
|
||||||
|
"self-hosted-billing/",
|
||||||
*(webhook.url for webhook in WEBHOOK_INTEGRATIONS if not include_webhooks),
|
*(webhook.url for webhook in WEBHOOK_INTEGRATIONS if not include_webhooks),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -212,6 +212,7 @@ class HomeTest(ZulipTestCase):
|
||||||
"settings_send_digest_emails",
|
"settings_send_digest_emails",
|
||||||
"show_billing",
|
"show_billing",
|
||||||
"show_plans",
|
"show_plans",
|
||||||
|
"show_remote_billing",
|
||||||
"show_webathena",
|
"show_webathena",
|
||||||
"sponsorship_pending",
|
"sponsorship_pending",
|
||||||
"starred_messages",
|
"starred_messages",
|
||||||
|
@ -364,6 +365,7 @@ class HomeTest(ZulipTestCase):
|
||||||
"server_sentry_dsn",
|
"server_sentry_dsn",
|
||||||
"show_billing",
|
"show_billing",
|
||||||
"show_plans",
|
"show_plans",
|
||||||
|
"show_remote_billing",
|
||||||
"show_webathena",
|
"show_webathena",
|
||||||
"sponsorship_pending",
|
"sponsorship_pending",
|
||||||
"test_suite",
|
"test_suite",
|
||||||
|
@ -831,6 +833,7 @@ class HomeTest(ZulipTestCase):
|
||||||
self.assertEqual(page_params["narrow"], [dict(operator="stream", operand=stream_name)])
|
self.assertEqual(page_params["narrow"], [dict(operator="stream", operand=stream_name)])
|
||||||
self.assertEqual(page_params["max_message_id"], -1)
|
self.assertEqual(page_params["max_message_id"], -1)
|
||||||
|
|
||||||
|
@override_settings(PUSH_NOTIFICATION_BOUNCER_URL="https://push.zulip.org.example.com")
|
||||||
def test_get_billing_info(self) -> None:
|
def test_get_billing_info(self) -> None:
|
||||||
user = self.example_user("desdemona")
|
user = self.example_user("desdemona")
|
||||||
user.role = UserProfile.ROLE_REALM_OWNER
|
user.role = UserProfile.ROLE_REALM_OWNER
|
||||||
|
@ -841,6 +844,7 @@ class HomeTest(ZulipTestCase):
|
||||||
self.assertFalse(billing_info.show_billing)
|
self.assertFalse(billing_info.show_billing)
|
||||||
self.assertFalse(billing_info.show_plans)
|
self.assertFalse(billing_info.show_plans)
|
||||||
self.assertFalse(billing_info.sponsorship_pending)
|
self.assertFalse(billing_info.sponsorship_pending)
|
||||||
|
self.assertTrue(billing_info.show_remote_billing)
|
||||||
|
|
||||||
# realm owner, with inactive CustomerPlan and realm plan_type SELF_HOSTED -> show only billing link
|
# realm owner, with inactive CustomerPlan and realm plan_type SELF_HOSTED -> show only billing link
|
||||||
customer = Customer.objects.create(realm=get_realm("zulip"), stripe_customer_id="cus_id")
|
customer = Customer.objects.create(realm=get_realm("zulip"), stripe_customer_id="cus_id")
|
||||||
|
@ -857,6 +861,7 @@ class HomeTest(ZulipTestCase):
|
||||||
self.assertTrue(billing_info.show_billing)
|
self.assertTrue(billing_info.show_billing)
|
||||||
self.assertFalse(billing_info.show_plans)
|
self.assertFalse(billing_info.show_plans)
|
||||||
self.assertFalse(billing_info.sponsorship_pending)
|
self.assertFalse(billing_info.sponsorship_pending)
|
||||||
|
self.assertTrue(billing_info.show_remote_billing)
|
||||||
|
|
||||||
# realm owner, with inactive CustomerPlan and realm plan_type LIMITED -> show billing link and plans
|
# realm owner, with inactive CustomerPlan and realm plan_type LIMITED -> show billing link and plans
|
||||||
do_change_realm_plan_type(user.realm, Realm.PLAN_TYPE_LIMITED, acting_user=None)
|
do_change_realm_plan_type(user.realm, Realm.PLAN_TYPE_LIMITED, acting_user=None)
|
||||||
|
@ -865,6 +870,7 @@ class HomeTest(ZulipTestCase):
|
||||||
self.assertTrue(billing_info.show_billing)
|
self.assertTrue(billing_info.show_billing)
|
||||||
self.assertTrue(billing_info.show_plans)
|
self.assertTrue(billing_info.show_plans)
|
||||||
self.assertFalse(billing_info.sponsorship_pending)
|
self.assertFalse(billing_info.sponsorship_pending)
|
||||||
|
self.assertTrue(billing_info.show_remote_billing)
|
||||||
|
|
||||||
# Always false without CORPORATE_ENABLED
|
# Always false without CORPORATE_ENABLED
|
||||||
with self.settings(CORPORATE_ENABLED=False):
|
with self.settings(CORPORATE_ENABLED=False):
|
||||||
|
@ -872,6 +878,8 @@ class HomeTest(ZulipTestCase):
|
||||||
self.assertFalse(billing_info.show_billing)
|
self.assertFalse(billing_info.show_billing)
|
||||||
self.assertFalse(billing_info.show_plans)
|
self.assertFalse(billing_info.show_plans)
|
||||||
self.assertFalse(billing_info.sponsorship_pending)
|
self.assertFalse(billing_info.sponsorship_pending)
|
||||||
|
# show_remote_billing is independent of CORPORATE_ENABLED
|
||||||
|
self.assertTrue(billing_info.show_remote_billing)
|
||||||
|
|
||||||
# Always false without a UserProfile
|
# Always false without a UserProfile
|
||||||
with self.settings(CORPORATE_ENABLED=True):
|
with self.settings(CORPORATE_ENABLED=True):
|
||||||
|
@ -879,6 +887,7 @@ class HomeTest(ZulipTestCase):
|
||||||
self.assertFalse(billing_info.show_billing)
|
self.assertFalse(billing_info.show_billing)
|
||||||
self.assertFalse(billing_info.show_plans)
|
self.assertFalse(billing_info.show_plans)
|
||||||
self.assertFalse(billing_info.sponsorship_pending)
|
self.assertFalse(billing_info.sponsorship_pending)
|
||||||
|
self.assertFalse(billing_info.show_remote_billing)
|
||||||
|
|
||||||
# realm admin, with CustomerPlan and realm plan_type LIMITED -> don't show any links
|
# realm admin, with CustomerPlan and realm plan_type LIMITED -> don't show any links
|
||||||
# Only billing admin and realm owner have access to billing.
|
# Only billing admin and realm owner have access to billing.
|
||||||
|
@ -889,6 +898,7 @@ class HomeTest(ZulipTestCase):
|
||||||
self.assertFalse(billing_info.show_billing)
|
self.assertFalse(billing_info.show_billing)
|
||||||
self.assertFalse(billing_info.show_plans)
|
self.assertFalse(billing_info.show_plans)
|
||||||
self.assertFalse(billing_info.sponsorship_pending)
|
self.assertFalse(billing_info.sponsorship_pending)
|
||||||
|
self.assertFalse(billing_info.show_remote_billing)
|
||||||
|
|
||||||
# billing admin, with CustomerPlan and realm plan_type STANDARD -> show only billing link
|
# billing admin, with CustomerPlan and realm plan_type STANDARD -> show only billing link
|
||||||
user.role = UserProfile.ROLE_MEMBER
|
user.role = UserProfile.ROLE_MEMBER
|
||||||
|
@ -900,6 +910,7 @@ class HomeTest(ZulipTestCase):
|
||||||
self.assertTrue(billing_info.show_billing)
|
self.assertTrue(billing_info.show_billing)
|
||||||
self.assertFalse(billing_info.show_plans)
|
self.assertFalse(billing_info.show_plans)
|
||||||
self.assertFalse(billing_info.sponsorship_pending)
|
self.assertFalse(billing_info.sponsorship_pending)
|
||||||
|
self.assertTrue(billing_info.show_remote_billing)
|
||||||
|
|
||||||
# billing admin, with CustomerPlan and realm plan_type PLUS -> show only billing link
|
# billing admin, with CustomerPlan and realm plan_type PLUS -> show only billing link
|
||||||
do_change_realm_plan_type(user.realm, Realm.PLAN_TYPE_PLUS, acting_user=None)
|
do_change_realm_plan_type(user.realm, Realm.PLAN_TYPE_PLUS, acting_user=None)
|
||||||
|
@ -909,6 +920,7 @@ class HomeTest(ZulipTestCase):
|
||||||
self.assertTrue(billing_info.show_billing)
|
self.assertTrue(billing_info.show_billing)
|
||||||
self.assertFalse(billing_info.show_plans)
|
self.assertFalse(billing_info.show_plans)
|
||||||
self.assertFalse(billing_info.sponsorship_pending)
|
self.assertFalse(billing_info.sponsorship_pending)
|
||||||
|
self.assertTrue(billing_info.show_remote_billing)
|
||||||
|
|
||||||
# member, with CustomerPlan and realm plan_type STANDARD -> neither billing link or plans
|
# member, with CustomerPlan and realm plan_type STANDARD -> neither billing link or plans
|
||||||
do_change_realm_plan_type(user.realm, Realm.PLAN_TYPE_STANDARD, acting_user=None)
|
do_change_realm_plan_type(user.realm, Realm.PLAN_TYPE_STANDARD, acting_user=None)
|
||||||
|
@ -919,6 +931,7 @@ class HomeTest(ZulipTestCase):
|
||||||
self.assertFalse(billing_info.show_billing)
|
self.assertFalse(billing_info.show_billing)
|
||||||
self.assertFalse(billing_info.show_plans)
|
self.assertFalse(billing_info.show_plans)
|
||||||
self.assertFalse(billing_info.sponsorship_pending)
|
self.assertFalse(billing_info.sponsorship_pending)
|
||||||
|
self.assertFalse(billing_info.show_remote_billing)
|
||||||
|
|
||||||
# guest, with CustomerPlan and realm plan_type SELF_HOSTED -> neither billing link or plans
|
# guest, with CustomerPlan and realm plan_type SELF_HOSTED -> neither billing link or plans
|
||||||
user.role = UserProfile.ROLE_GUEST
|
user.role = UserProfile.ROLE_GUEST
|
||||||
|
@ -929,6 +942,7 @@ class HomeTest(ZulipTestCase):
|
||||||
self.assertFalse(billing_info.show_billing)
|
self.assertFalse(billing_info.show_billing)
|
||||||
self.assertFalse(billing_info.show_plans)
|
self.assertFalse(billing_info.show_plans)
|
||||||
self.assertFalse(billing_info.sponsorship_pending)
|
self.assertFalse(billing_info.sponsorship_pending)
|
||||||
|
self.assertFalse(billing_info.show_remote_billing)
|
||||||
|
|
||||||
# billing admin, but no CustomerPlan and realm plan_type SELF_HOSTED -> neither billing link or plans
|
# billing admin, but no CustomerPlan and realm plan_type SELF_HOSTED -> neither billing link or plans
|
||||||
user.role = UserProfile.ROLE_MEMBER
|
user.role = UserProfile.ROLE_MEMBER
|
||||||
|
@ -940,6 +954,7 @@ class HomeTest(ZulipTestCase):
|
||||||
self.assertFalse(billing_info.show_billing)
|
self.assertFalse(billing_info.show_billing)
|
||||||
self.assertFalse(billing_info.show_plans)
|
self.assertFalse(billing_info.show_plans)
|
||||||
self.assertFalse(billing_info.sponsorship_pending)
|
self.assertFalse(billing_info.sponsorship_pending)
|
||||||
|
self.assertTrue(billing_info.show_remote_billing)
|
||||||
|
|
||||||
# billing admin, with sponsorship pending and realm plan_type SELF_HOSTED -> show only sponsorship pending link
|
# billing admin, with sponsorship pending and realm plan_type SELF_HOSTED -> show only sponsorship pending link
|
||||||
customer.sponsorship_pending = True
|
customer.sponsorship_pending = True
|
||||||
|
@ -949,6 +964,7 @@ class HomeTest(ZulipTestCase):
|
||||||
self.assertFalse(billing_info.show_billing)
|
self.assertFalse(billing_info.show_billing)
|
||||||
self.assertFalse(billing_info.show_plans)
|
self.assertFalse(billing_info.show_plans)
|
||||||
self.assertTrue(billing_info.sponsorship_pending)
|
self.assertTrue(billing_info.sponsorship_pending)
|
||||||
|
self.assertTrue(billing_info.show_remote_billing)
|
||||||
|
|
||||||
# billing admin, no customer object and realm plan_type SELF_HOSTED -> no links
|
# billing admin, no customer object and realm plan_type SELF_HOSTED -> no links
|
||||||
customer.delete()
|
customer.delete()
|
||||||
|
@ -957,6 +973,13 @@ class HomeTest(ZulipTestCase):
|
||||||
self.assertFalse(billing_info.show_billing)
|
self.assertFalse(billing_info.show_billing)
|
||||||
self.assertFalse(billing_info.show_plans)
|
self.assertFalse(billing_info.show_plans)
|
||||||
self.assertFalse(billing_info.sponsorship_pending)
|
self.assertFalse(billing_info.sponsorship_pending)
|
||||||
|
self.assertTrue(billing_info.show_remote_billing)
|
||||||
|
|
||||||
|
# If the server doesn't have the push bouncer configured,
|
||||||
|
# don't show remote billing.
|
||||||
|
with self.settings(PUSH_NOTIFICATION_BOUNCER_URL=None):
|
||||||
|
billing_info = get_billing_info(user)
|
||||||
|
self.assertFalse(billing_info.show_remote_billing)
|
||||||
|
|
||||||
def test_promote_sponsoring_zulip_in_realm(self) -> None:
|
def test_promote_sponsoring_zulip_in_realm(self) -> None:
|
||||||
realm = get_realm("zulip")
|
realm = get_realm("zulip")
|
||||||
|
|
|
@ -1,17 +1,30 @@
|
||||||
import re
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.conf import settings
|
||||||
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
|
from django.shortcuts import render
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from zerver.decorator import human_users_only
|
from zerver.decorator import human_users_only, zulip_login_required
|
||||||
from zerver.lib.exceptions import JsonableError
|
from zerver.lib.exceptions import (
|
||||||
|
JsonableError,
|
||||||
|
MissingRemoteRealmError,
|
||||||
|
OrganizationOwnerRequiredError,
|
||||||
|
)
|
||||||
from zerver.lib.push_notifications import (
|
from zerver.lib.push_notifications import (
|
||||||
InvalidPushDeviceTokenError,
|
InvalidPushDeviceTokenError,
|
||||||
add_push_device_token,
|
add_push_device_token,
|
||||||
b64_to_hex,
|
b64_to_hex,
|
||||||
remove_push_device_token,
|
remove_push_device_token,
|
||||||
send_test_push_notification,
|
send_test_push_notification,
|
||||||
|
uses_notification_bouncer,
|
||||||
|
)
|
||||||
|
from zerver.lib.remote_server import (
|
||||||
|
UserDataForRemoteBilling,
|
||||||
|
get_realms_info_for_push_bouncer,
|
||||||
|
send_realms_only_to_push_bouncer,
|
||||||
|
send_to_push_bouncer,
|
||||||
)
|
)
|
||||||
from zerver.lib.request import REQ, has_request_variables
|
from zerver.lib.request import REQ, has_request_variables
|
||||||
from zerver.lib.response import json_success
|
from zerver.lib.response import json_success
|
||||||
|
@ -101,3 +114,45 @@ def send_test_push_notification_api(
|
||||||
send_test_push_notification(user_profile, devices)
|
send_test_push_notification(user_profile, devices)
|
||||||
|
|
||||||
return json_success(request)
|
return json_success(request)
|
||||||
|
|
||||||
|
|
||||||
|
@zulip_login_required
|
||||||
|
def self_hosting_auth_redirect(request: HttpRequest) -> HttpResponse: # nocoverage
|
||||||
|
if not settings.DEVELOPMENT or not uses_notification_bouncer():
|
||||||
|
return render(request, "404.html", status=404)
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
assert user.is_authenticated
|
||||||
|
assert isinstance(user, UserProfile)
|
||||||
|
if not user.has_billing_access:
|
||||||
|
# We may want to replace this with an html error page at some point,
|
||||||
|
# but this endpoint shouldn't be accessible via the UI to an unauthorized
|
||||||
|
# user - and they need to directly enter the URL in their browser. So a json
|
||||||
|
# error may be sufficient.
|
||||||
|
raise OrganizationOwnerRequiredError
|
||||||
|
|
||||||
|
realm_info = get_realms_info_for_push_bouncer(user.realm_id)[0]
|
||||||
|
|
||||||
|
user_info = UserDataForRemoteBilling(
|
||||||
|
uuid=user.uuid,
|
||||||
|
email=user.delivery_email,
|
||||||
|
full_name=user.full_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
post_data = {
|
||||||
|
"user": user_info.model_dump_json(),
|
||||||
|
"realm": realm_info.model_dump_json(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = send_to_push_bouncer("POST", "server/billing", post_data)
|
||||||
|
except MissingRemoteRealmError:
|
||||||
|
# Upload realm info and re-try. It should work now.
|
||||||
|
send_realms_only_to_push_bouncer()
|
||||||
|
result = send_to_push_bouncer("POST", "server/billing", post_data)
|
||||||
|
|
||||||
|
if result["result"] != "success":
|
||||||
|
raise JsonableError(_("Error returned by the bouncer: {result}").format(result=result))
|
||||||
|
|
||||||
|
redirect_url = result["billing_access_url"]
|
||||||
|
assert isinstance(redirect_url, str)
|
||||||
|
return HttpResponseRedirect(redirect_url)
|
||||||
|
|
|
@ -3,6 +3,7 @@ from typing import Any
|
||||||
from django.conf.urls import include
|
from django.conf.urls import include
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
|
from corporate.views.remote_billing_page import remote_server_billing_entry
|
||||||
from zilencer.auth import remote_server_path
|
from zilencer.auth import remote_server_path
|
||||||
from zilencer.views import (
|
from zilencer.views import (
|
||||||
deactivate_remote_server,
|
deactivate_remote_server,
|
||||||
|
@ -33,6 +34,9 @@ push_bouncer_patterns = [
|
||||||
remote_server_path("remotes/server/analytics/status", GET=remote_server_check_analytics),
|
remote_server_path("remotes/server/analytics/status", GET=remote_server_check_analytics),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
billing_patterns = [remote_server_path("remotes/server/billing", POST=remote_server_billing_entry)]
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("api/v1/", include(push_bouncer_patterns)),
|
path("api/v1/", include(push_bouncer_patterns)),
|
||||||
|
path("api/v1/", include(billing_patterns)),
|
||||||
]
|
]
|
||||||
|
|
|
@ -92,6 +92,7 @@ from zerver.views.push_notifications import (
|
||||||
add_apns_device_token,
|
add_apns_device_token,
|
||||||
remove_android_reg_id,
|
remove_android_reg_id,
|
||||||
remove_apns_device_token,
|
remove_apns_device_token,
|
||||||
|
self_hosting_auth_redirect,
|
||||||
send_test_push_notification_api,
|
send_test_push_notification_api,
|
||||||
)
|
)
|
||||||
from zerver.views.reactions import add_reaction, remove_reaction
|
from zerver.views.reactions import add_reaction, remove_reaction
|
||||||
|
@ -820,6 +821,10 @@ urls += [
|
||||||
path("policies/<slug:article>", policy_documentation_view),
|
path("policies/<slug:article>", policy_documentation_view),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
urls += [
|
||||||
|
path("self-hosted-billing/", self_hosting_auth_redirect, name="self_hosting_auth_redirect"),
|
||||||
|
]
|
||||||
|
|
||||||
if not settings.CORPORATE_ENABLED: # nocoverage
|
if not settings.CORPORATE_ENABLED: # nocoverage
|
||||||
# This conditional behavior cannot be tested directly, since
|
# This conditional behavior cannot be tested directly, since
|
||||||
# urls.py is not readily reloaded in Django tests. See the block
|
# urls.py is not readily reloaded in Django tests. See the block
|
||||||
|
|
Loading…
Reference in New Issue