zulip/corporate/views/support.py

902 lines
36 KiB
Python

import uuid
from collections.abc import Iterable
from contextlib import suppress
from dataclasses import dataclass
from datetime import timedelta
from operator import attrgetter
from typing import Annotated, Any, Literal
from urllib.parse import urlencode, urlsplit
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
from django.db.models import Q
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils.timesince import timesince
from django.utils.timezone import now as timezone_now
from django.utils.translation import gettext as _
from pydantic import AfterValidator, Json, NonNegativeInt
from confirmation.models import Confirmation, confirmation_url
from confirmation.settings import STATUS_USED
from corporate.lib.activity import format_optional_datetime, remote_installation_stats_link
from corporate.lib.billing_types import BillingModality
from corporate.models import CustomerPlan
from zerver.actions.create_realm import do_change_realm_subdomain
from zerver.actions.realm_settings import (
do_change_realm_max_invites,
do_change_realm_org_type,
do_change_realm_plan_type,
do_deactivate_realm,
do_scrub_realm,
do_send_realm_reactivation_email,
)
from zerver.actions.users import do_delete_user_preserving_messages
from zerver.decorator import require_server_admin, zulip_login_required
from zerver.forms import check_subdomain_available
from zerver.lib.exceptions import JsonableError
from zerver.lib.rate_limiter import rate_limit_request_by_ip
from zerver.lib.realm_icon import realm_icon_url
from zerver.lib.send_email import FromAddress, send_email
from zerver.lib.subdomains import get_subdomain_from_hostname
from zerver.lib.typed_endpoint import (
ApiParamConfig,
typed_endpoint,
typed_endpoint_without_parameters,
)
from zerver.lib.validator import check_date
from zerver.models import (
MultiuseInvite,
PreregistrationRealm,
PreregistrationUser,
Realm,
RealmReactivationStatus,
UserProfile,
)
from zerver.models.realms import (
get_default_max_invites_for_realm_plan_type,
get_org_type_display_name,
get_realm,
)
from zerver.models.users import get_user_profile_by_id
from zerver.views.invite import get_invitee_emails_set
from zilencer.lib.remote_counts import MissingDataError, compute_max_monthly_messages
from zilencer.models import (
RemoteRealm,
RemoteRealmBillingUser,
RemoteServerBillingUser,
RemoteZulipServer,
)
class SupportRequestForm(forms.Form):
# We use the same subject length requirement as GitHub's
# contact support form.
MAX_SUBJECT_LENGTH = 50
request_subject = forms.CharField(max_length=MAX_SUBJECT_LENGTH)
request_message = forms.CharField(widget=forms.Textarea)
class DemoRequestForm(forms.Form):
MAX_INPUT_LENGTH = 50
SORTED_ORG_TYPE_NAMES = sorted(
([org_type["name"] for org_type in Realm.ORG_TYPES.values() if not org_type["hidden"]]),
)
full_name = forms.CharField(max_length=MAX_INPUT_LENGTH)
email = forms.EmailField()
role = forms.CharField(max_length=MAX_INPUT_LENGTH)
organization_name = forms.CharField(max_length=MAX_INPUT_LENGTH)
organization_type = forms.CharField()
organization_website = forms.URLField(required=True, assume_scheme="https")
expected_user_count = forms.CharField(max_length=MAX_INPUT_LENGTH)
message = forms.CharField(widget=forms.Textarea)
@zulip_login_required
@typed_endpoint_without_parameters
def support_request(request: HttpRequest) -> HttpResponse:
from corporate.lib.stripe import build_support_url
user = request.user
assert user.is_authenticated
context = {
"email": user.delivery_email,
"realm_name": user.realm.name,
"MAX_SUBJECT_LENGTH": SupportRequestForm.MAX_SUBJECT_LENGTH,
}
if request.POST:
post_data = request.POST.copy()
form = SupportRequestForm(post_data)
if form.is_valid():
email_context = {
"requested_by": user.full_name,
"realm_string_id": user.realm.string_id,
"request_subject": form.cleaned_data["request_subject"],
"request_message": form.cleaned_data["request_message"],
"support_url": build_support_url("support", user.realm.string_id),
"user_role": user.get_role_name(),
}
# Sent to the server's support team, so this email is not user-facing.
send_email(
"zerver/emails/support_request",
to_emails=[FromAddress.SUPPORT],
from_name="Zulip support request",
from_address=FromAddress.tokenized_no_reply_address(),
reply_to_email=user.delivery_email,
context=email_context,
)
response = render(
request, "corporate/support/support_request_thanks.html", context=context
)
return response
response = render(request, "corporate/support/support_request.html", context=context)
return response
@typed_endpoint_without_parameters
def demo_request(request: HttpRequest) -> HttpResponse:
from corporate.lib.stripe import BILLING_SUPPORT_EMAIL
context = {
"MAX_INPUT_LENGTH": DemoRequestForm.MAX_INPUT_LENGTH,
"SORTED_ORG_TYPE_NAMES": DemoRequestForm.SORTED_ORG_TYPE_NAMES,
}
if request.POST:
post_data = request.POST.copy()
form = DemoRequestForm(post_data)
if form.is_valid():
rate_limit_request_by_ip(request, domain="sends_email_by_ip")
email_context = {
"full_name": form.cleaned_data["full_name"],
"email": form.cleaned_data["email"],
"role": form.cleaned_data["role"],
"organization_name": form.cleaned_data["organization_name"],
"organization_type": form.cleaned_data["organization_type"],
"organization_website": form.cleaned_data["organization_website"],
"expected_user_count": form.cleaned_data["expected_user_count"],
"message": form.cleaned_data["message"],
}
# Sent to the server's sales team, so this email is not user-facing.
send_email(
"zerver/emails/demo_request",
to_emails=[BILLING_SUPPORT_EMAIL],
from_name="Zulip demo request",
from_address=FromAddress.tokenized_no_reply_address(),
reply_to_email=email_context["email"],
context=email_context,
)
response = render(
request, "corporate/support/support_request_thanks.html", context=context
)
return response
response = render(request, "corporate/support/demo_request.html", context=context)
return response
def get_plan_type_string(plan_type: int) -> str:
return {
Realm.PLAN_TYPE_SELF_HOSTED: "Self-hosted",
Realm.PLAN_TYPE_LIMITED: "Limited",
Realm.PLAN_TYPE_STANDARD: "Standard",
Realm.PLAN_TYPE_STANDARD_FREE: "Standard free",
Realm.PLAN_TYPE_PLUS: "Plus",
RemoteZulipServer.PLAN_TYPE_SELF_MANAGED: "Free",
RemoteZulipServer.PLAN_TYPE_SELF_MANAGED_LEGACY: CustomerPlan.name_from_tier(
CustomerPlan.TIER_SELF_HOSTED_LEGACY
),
RemoteZulipServer.PLAN_TYPE_COMMUNITY: "Community",
RemoteZulipServer.PLAN_TYPE_BASIC: "Basic",
RemoteZulipServer.PLAN_TYPE_BUSINESS: "Business",
RemoteZulipServer.PLAN_TYPE_ENTERPRISE: "Enterprise",
}[plan_type]
def get_confirmations(
types: list[int], object_ids: Iterable[int], hostname: str | None = None
) -> list[dict[str, Any]]:
lowest_datetime = timezone_now() - timedelta(days=30)
confirmations = Confirmation.objects.filter(
type__in=types, object_id__in=object_ids, date_sent__gte=lowest_datetime
)
confirmation_dicts = []
for confirmation in confirmations:
realm = confirmation.realm
content_object = confirmation.content_object
type = confirmation.type
expiry_date = confirmation.expiry_date
assert content_object is not None
if hasattr(content_object, "status"):
if content_object.status == STATUS_USED:
link_status = "Link has been used"
else:
link_status = "Link has not been used"
else:
link_status = ""
now = timezone_now()
if expiry_date is None:
expires_in = "Never"
elif now < expiry_date:
expires_in = timesince(now, expiry_date)
else:
expires_in = "Expired"
url = confirmation_url(confirmation.confirmation_key, realm, type)
confirmation_dicts.append(
{
"object": confirmation.content_object,
"url": url,
"type": type,
"link_status": link_status,
"expires_in": expires_in,
}
)
return confirmation_dicts
@dataclass
class SupportSelectOption:
name: str
value: int
def get_remote_plan_tier_options() -> list[SupportSelectOption]:
remote_plan_tiers = [
SupportSelectOption("None", 0),
SupportSelectOption(
CustomerPlan.name_from_tier(CustomerPlan.TIER_SELF_HOSTED_BASIC),
CustomerPlan.TIER_SELF_HOSTED_BASIC,
),
SupportSelectOption(
CustomerPlan.name_from_tier(CustomerPlan.TIER_SELF_HOSTED_BUSINESS),
CustomerPlan.TIER_SELF_HOSTED_BUSINESS,
),
]
return remote_plan_tiers
def get_realm_plan_type_options() -> list[SupportSelectOption]:
plan_types = [
SupportSelectOption(
get_plan_type_string(Realm.PLAN_TYPE_SELF_HOSTED), Realm.PLAN_TYPE_SELF_HOSTED
),
SupportSelectOption(get_plan_type_string(Realm.PLAN_TYPE_LIMITED), Realm.PLAN_TYPE_LIMITED),
SupportSelectOption(
get_plan_type_string(Realm.PLAN_TYPE_STANDARD), Realm.PLAN_TYPE_STANDARD
),
SupportSelectOption(
get_plan_type_string(Realm.PLAN_TYPE_STANDARD_FREE), Realm.PLAN_TYPE_STANDARD_FREE
),
SupportSelectOption(get_plan_type_string(Realm.PLAN_TYPE_PLUS), Realm.PLAN_TYPE_PLUS),
]
return plan_types
def get_realm_plan_type_options_for_discount() -> list[SupportSelectOption]:
plan_types = [
SupportSelectOption("None", 0),
SupportSelectOption(
CustomerPlan.name_from_tier(CustomerPlan.TIER_CLOUD_STANDARD),
CustomerPlan.TIER_CLOUD_STANDARD,
),
SupportSelectOption(
CustomerPlan.name_from_tier(CustomerPlan.TIER_CLOUD_PLUS),
CustomerPlan.TIER_CLOUD_PLUS,
),
]
return plan_types
def get_default_max_invites_for_plan_type(realm: Realm) -> int:
default_max = get_default_max_invites_for_realm_plan_type(realm.plan_type)
if default_max is None:
return settings.INVITES_DEFAULT_REALM_DAILY_MAX
return default_max
def check_update_max_invites(realm: Realm, new_max: int, default_max: int) -> bool:
if new_max in [0, default_max]:
return realm.max_invites != default_max
return new_max > default_max
ModifyPlan = Literal[
"downgrade_at_billing_cycle_end",
"downgrade_now_without_additional_licenses",
"downgrade_now_void_open_invoices",
"upgrade_plan_tier",
]
RemoteServerStatus = Literal["active", "deactivated"]
def shared_support_context() -> dict[str, object]:
from corporate.lib.stripe import cents_to_dollar_string
return {
"get_org_type_display_name": get_org_type_display_name,
"get_plan_type_name": get_plan_type_string,
"dollar_amount": cents_to_dollar_string,
}
@require_server_admin
@typed_endpoint
def support(
request: HttpRequest,
*,
realm_id: Json[NonNegativeInt] | None = None,
plan_type: Json[NonNegativeInt] | None = None,
monthly_discounted_price: Json[NonNegativeInt] | None = None,
annual_discounted_price: Json[NonNegativeInt] | None = None,
minimum_licenses: Json[NonNegativeInt] | None = None,
required_plan_tier: Json[NonNegativeInt] | None = None,
new_subdomain: str | None = None,
status: RemoteServerStatus | None = None,
billing_modality: BillingModality | None = None,
sponsorship_pending: Json[bool] | None = None,
approve_sponsorship: Json[bool] = False,
modify_plan: ModifyPlan | None = None,
scrub_realm: Json[bool] = False,
delete_user_by_id: Json[NonNegativeInt] | None = None,
query: Annotated[str | None, ApiParamConfig("q")] = None,
org_type: Json[NonNegativeInt] | None = None,
max_invites: Json[NonNegativeInt] | None = None,
) -> HttpResponse:
from corporate.lib.stripe import (
RealmBillingSession,
SupportRequestError,
SupportType,
SupportViewRequest,
)
from corporate.lib.support import CloudSupportData, get_data_for_cloud_support_view
context = shared_support_context()
if "success_message" in request.session:
context["success_message"] = request.session["success_message"]
del request.session["success_message"]
acting_user = request.user
assert isinstance(acting_user, UserProfile)
if settings.BILLING_ENABLED and request.method == "POST":
# We check that request.POST only has two keys in it: The
# realm_id and a field to change.
keys = set(request.POST.keys())
keys.discard("csrfmiddlewaretoken")
REQUIRED_KEYS = 2
if monthly_discounted_price is not None or annual_discounted_price is not None:
REQUIRED_KEYS = 3
if len(keys) != REQUIRED_KEYS:
raise JsonableError(_("Invalid parameters"))
assert realm_id is not None
realm = Realm.objects.get(id=realm_id)
support_view_request = None
if approve_sponsorship:
support_view_request = SupportViewRequest(support_type=SupportType.approve_sponsorship)
elif sponsorship_pending is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_sponsorship_status,
sponsorship_status=sponsorship_pending,
)
elif monthly_discounted_price is not None or annual_discounted_price is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.attach_discount,
monthly_discounted_price=monthly_discounted_price,
annual_discounted_price=annual_discounted_price,
)
elif minimum_licenses is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_minimum_licenses,
minimum_licenses=minimum_licenses,
)
elif required_plan_tier is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_required_plan_tier,
required_plan_tier=required_plan_tier,
)
elif billing_modality is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_billing_modality,
billing_modality=billing_modality,
)
elif modify_plan is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.modify_plan,
plan_modification=modify_plan,
)
if modify_plan == "upgrade_plan_tier":
support_view_request["new_plan_tier"] = CustomerPlan.TIER_CLOUD_PLUS
elif plan_type is not None:
current_plan_type = realm.plan_type
do_change_realm_plan_type(realm, plan_type, acting_user=acting_user)
msg = f"Plan type of {realm.string_id} changed from {get_plan_type_string(current_plan_type)} to {get_plan_type_string(plan_type)} "
context["success_message"] = msg
elif org_type is not None:
current_realm_type = realm.org_type
do_change_realm_org_type(realm, org_type, acting_user=acting_user)
msg = f"Organization type of {realm.string_id} changed from {get_org_type_display_name(current_realm_type)} to {get_org_type_display_name(org_type)} "
context["success_message"] = msg
elif max_invites is not None:
default_max = get_default_max_invites_for_plan_type(realm)
if check_update_max_invites(realm, max_invites, default_max):
do_change_realm_max_invites(realm, max_invites, acting_user=acting_user)
update_text = str(max_invites)
if max_invites == 0:
update_text = "the default for the current plan type"
msg = f"Maximum number of daily invitations for {realm.string_id} updated to {update_text}."
context["success_message"] = msg
else:
update_text = f"{max_invites} is less than the default for the current plan type"
if max_invites in [0, default_max]:
update_text = "the default for the current plan type is already set"
context["error_message"] = (
f"Cannot update maximum number of daily invitations for {realm.string_id}, because {update_text}."
)
elif new_subdomain is not None:
old_subdomain = realm.string_id
try:
check_subdomain_available(new_subdomain)
except ValidationError as error:
context["error_message"] = error.message
else:
do_change_realm_subdomain(realm, new_subdomain, acting_user=acting_user)
request.session["success_message"] = (
f"Subdomain changed from {old_subdomain} to {new_subdomain}"
)
return HttpResponseRedirect(
reverse("support") + "?" + urlencode({"q": new_subdomain})
)
elif status is not None:
if status == "active":
do_send_realm_reactivation_email(realm, acting_user=acting_user)
context["success_message"] = (
f"Realm reactivation email sent to admins of {realm.string_id}."
)
elif status == "deactivated":
# TODO: Add support for deactivation reason in the support UI that'll be passed
# here.
do_deactivate_realm(
realm,
acting_user=acting_user,
deactivation_reason="owner_request",
email_owners=True,
)
context["success_message"] = f"{realm.string_id} deactivated."
elif scrub_realm:
do_scrub_realm(realm, acting_user=acting_user)
context["success_message"] = f"{realm.string_id} scrubbed."
elif delete_user_by_id:
user_profile_for_deletion = get_user_profile_by_id(delete_user_by_id)
user_email = user_profile_for_deletion.delivery_email
assert user_profile_for_deletion.realm == realm
do_delete_user_preserving_messages(user_profile_for_deletion)
context["success_message"] = f"{user_email} in {realm.subdomain} deleted."
if support_view_request is not None:
billing_session = RealmBillingSession(
user=acting_user, realm=realm, support_session=True
)
try:
success_message = billing_session.process_support_view_request(support_view_request)
context["success_message"] = success_message
except SupportRequestError as error:
context["error_message"] = error.msg
if query:
key_words = get_invitee_emails_set(query)
case_insensitive_users_q = Q()
for key_word in key_words:
case_insensitive_users_q |= Q(delivery_email__iexact=key_word)
users = set(UserProfile.objects.filter(case_insensitive_users_q))
realms = set(Realm.objects.filter(string_id__in=key_words))
for key_word in key_words:
try:
URLValidator()(key_word)
parse_result = urlsplit(key_word)
hostname = parse_result.hostname
assert hostname is not None
if parse_result.port:
hostname = f"{hostname}:{parse_result.port}"
subdomain = get_subdomain_from_hostname(hostname)
with suppress(Realm.DoesNotExist):
realms.add(get_realm(subdomain))
except ValidationError:
users.update(UserProfile.objects.filter(full_name__iexact=key_word))
# full_names can have , in them
users.update(UserProfile.objects.filter(full_name__iexact=query))
context["users"] = users
context["realms"] = realms
confirmations: list[dict[str, Any]] = []
preregistration_user_ids = [
user.id for user in PreregistrationUser.objects.filter(email__in=key_words)
]
confirmations += get_confirmations(
[Confirmation.USER_REGISTRATION, Confirmation.INVITATION],
preregistration_user_ids,
hostname=request.get_host(),
)
preregistration_realm_ids = [
user.id for user in PreregistrationRealm.objects.filter(email__in=key_words)
]
confirmations += get_confirmations(
[Confirmation.REALM_CREATION],
preregistration_realm_ids,
hostname=request.get_host(),
)
multiuse_invite_ids = [
invite.id for invite in MultiuseInvite.objects.filter(realm__in=realms)
]
confirmations += get_confirmations([Confirmation.MULTIUSE_INVITE], multiuse_invite_ids)
realm_reactivation_status_objects = RealmReactivationStatus.objects.filter(realm__in=realms)
confirmations += get_confirmations(
[Confirmation.REALM_REACTIVATION], [obj.id for obj in realm_reactivation_status_objects]
)
context["confirmations"] = confirmations
# We want a union of all realms that might appear in the search result,
# but not necessary as a separate result item.
# Therefore, we do not modify the realms object in the context.
all_realms = realms.union(
[
confirmation["object"].realm
for confirmation in confirmations
# For confirmations, we only display realm details when the type is USER_REGISTRATION
# or INVITATION.
if confirmation["type"] in (Confirmation.USER_REGISTRATION, Confirmation.INVITATION)
]
+ [user.realm for user in users]
)
realm_support_data: dict[int, CloudSupportData] = {}
for realm in all_realms:
billing_session = RealmBillingSession(user=None, realm=realm)
realm_data = get_data_for_cloud_support_view(billing_session)
realm_support_data[realm.id] = realm_data
context["realm_support_data"] = realm_support_data
context["SPONSORED_PLAN_TYPE"] = Realm.PLAN_TYPE_STANDARD_FREE
def get_realm_owner_emails_as_string(realm: Realm) -> str:
return ", ".join(
realm.get_human_owner_users()
.order_by("delivery_email")
.values_list("delivery_email", flat=True)
)
def get_realm_admin_emails_as_string(realm: Realm) -> str:
return ", ".join(
realm.get_human_admin_users(include_realm_owners=False)
.order_by("delivery_email")
.values_list("delivery_email", flat=True)
)
context["get_realm_owner_emails_as_string"] = get_realm_owner_emails_as_string
context["get_realm_admin_emails_as_string"] = get_realm_admin_emails_as_string
context["realm_icon_url"] = realm_icon_url
context["Confirmation"] = Confirmation
context["REALM_PLAN_TYPES"] = get_realm_plan_type_options()
context["REALM_PLAN_TYPES_FOR_DISCOUNT"] = get_realm_plan_type_options_for_discount()
context["ORGANIZATION_TYPES"] = sorted(
Realm.ORG_TYPES.values(), key=lambda d: d["display_order"]
)
return render(request, "corporate/support/support.html", context=context)
def get_remote_servers_for_support(
email_to_search: str | None, uuid_to_search: str | None, hostname_to_search: str | None
) -> list["RemoteZulipServer"]:
remote_servers_query = RemoteZulipServer.objects.order_by("id")
if email_to_search:
remote_servers_set = {
*remote_servers_query.filter(contact_email__iexact=email_to_search),
*(
server_billing_user.remote_server
for server_billing_user in RemoteServerBillingUser.objects.filter(
email__iexact=email_to_search
).select_related("remote_server")
),
*(
realm_billing_user.remote_realm.server
for realm_billing_user in RemoteRealmBillingUser.objects.filter(
email__iexact=email_to_search
).select_related("remote_realm__server")
),
}
return sorted(remote_servers_set, key=attrgetter("deactivated"))
if uuid_to_search:
remote_servers_set = {
*remote_servers_query.filter(uuid__iexact=uuid_to_search),
*(
remote_realm.server
for remote_realm in RemoteRealm.objects.filter(
uuid__iexact=uuid_to_search
).select_related("server")
),
}
return sorted(remote_servers_set, key=attrgetter("deactivated"))
if hostname_to_search:
remote_servers_set = {
*remote_servers_query.filter(hostname__icontains=hostname_to_search),
*(
remote_realm.server
for remote_realm in (
RemoteRealm.objects.filter(host__icontains=hostname_to_search)
).select_related("server")
),
}
return sorted(remote_servers_set, key=attrgetter("deactivated"))
return []
@require_server_admin
@typed_endpoint
def remote_servers_support(
request: HttpRequest,
*,
query: Annotated[str | None, ApiParamConfig("q")] = None,
remote_server_id: Json[NonNegativeInt] | None = None,
remote_realm_id: Json[NonNegativeInt] | None = None,
monthly_discounted_price: Json[NonNegativeInt] | None = None,
annual_discounted_price: Json[NonNegativeInt] | None = None,
minimum_licenses: Json[NonNegativeInt] | None = None,
required_plan_tier: Json[NonNegativeInt] | None = None,
fixed_price: Json[NonNegativeInt] | None = None,
sent_invoice_id: str | None = None,
sponsorship_pending: Json[bool] | None = None,
approve_sponsorship: Json[bool] = False,
billing_modality: BillingModality | None = None,
plan_end_date: Annotated[str, AfterValidator(lambda x: check_date("plan_end_date", x))]
| None = None,
modify_plan: ModifyPlan | None = None,
delete_fixed_price_next_plan: Json[bool] = False,
remote_server_status: RemoteServerStatus | None = None,
temporary_courtesy_plan: Annotated[
str, AfterValidator(lambda x: check_date("temporary_courtesy_plan", x))
]
| None = None,
) -> HttpResponse:
from corporate.lib.stripe import (
RemoteRealmBillingSession,
RemoteServerBillingSession,
ServerDeactivateWithExistingPlanError,
SupportRequestError,
SupportType,
SupportViewRequest,
do_deactivate_remote_server,
do_reactivate_remote_server,
)
from corporate.lib.support import RemoteSupportData, get_data_for_remote_support_view
context = shared_support_context()
if "success_message" in request.session:
context["success_message"] = request.session["success_message"]
del request.session["success_message"]
acting_user = request.user
assert isinstance(acting_user, UserProfile)
if settings.BILLING_ENABLED and request.method == "POST":
keys = set(request.POST.keys())
keys.discard("csrfmiddlewaretoken")
if remote_realm_id is not None:
remote_realm_support_request = True
remote_realm = RemoteRealm.objects.get(id=remote_realm_id)
else:
assert remote_server_id is not None
remote_realm_support_request = False
remote_server = RemoteZulipServer.objects.get(id=remote_server_id)
support_view_request = None
if approve_sponsorship:
support_view_request = SupportViewRequest(support_type=SupportType.approve_sponsorship)
elif sponsorship_pending is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_sponsorship_status,
sponsorship_status=sponsorship_pending,
)
elif monthly_discounted_price is not None or annual_discounted_price is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.attach_discount,
monthly_discounted_price=monthly_discounted_price,
annual_discounted_price=annual_discounted_price,
)
elif minimum_licenses is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_minimum_licenses,
minimum_licenses=minimum_licenses,
)
elif required_plan_tier is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_required_plan_tier,
required_plan_tier=required_plan_tier,
)
elif fixed_price is not None:
# Treat empty field submitted as None.
if sent_invoice_id is not None and sent_invoice_id.strip() == "":
sent_invoice_id = None
support_view_request = SupportViewRequest(
support_type=SupportType.configure_fixed_price_plan,
fixed_price=fixed_price,
sent_invoice_id=sent_invoice_id,
)
elif temporary_courtesy_plan is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.configure_temporary_courtesy_plan,
plan_end_date=temporary_courtesy_plan,
)
elif billing_modality is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_billing_modality,
billing_modality=billing_modality,
)
elif plan_end_date is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_plan_end_date,
plan_end_date=plan_end_date,
)
elif modify_plan is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.modify_plan,
plan_modification=modify_plan,
)
elif delete_fixed_price_next_plan:
support_view_request = SupportViewRequest(
support_type=SupportType.delete_fixed_price_next_plan,
)
elif remote_server_status:
assert remote_server is not None
remote_server_status_billing_session = RemoteServerBillingSession(
support_staff=acting_user, remote_server=remote_server
)
if remote_server_status == "active":
do_reactivate_remote_server(remote_server)
context["success_message"] = (
f"Remote server ({remote_server.hostname}) reactivated."
)
else:
assert remote_server_status == "deactivated"
try:
do_deactivate_remote_server(remote_server, remote_server_status_billing_session)
context["success_message"] = (
f"Remote server ({remote_server.hostname}) deactivated."
)
except ServerDeactivateWithExistingPlanError:
context["error_message"] = (
f"Cannot deactivate remote server ({remote_server.hostname}) that has active or scheduled plans."
)
if support_view_request is not None:
if remote_realm_support_request:
try:
success_message = RemoteRealmBillingSession(
support_staff=acting_user, remote_realm=remote_realm
).process_support_view_request(support_view_request)
context["success_message"] = success_message
except SupportRequestError as error:
context["error_message"] = error.msg
else:
try:
success_message = RemoteServerBillingSession(
support_staff=acting_user, remote_server=remote_server
).process_support_view_request(support_view_request)
context["success_message"] = success_message
except SupportRequestError as error:
context["error_message"] = error.msg
email_to_search = None
uuid_to_search = None
hostname_to_search = None
if query:
search_text = query.strip()
if "@" in search_text:
email_to_search = search_text
else:
try:
uuid.UUID(search_text, version=4)
uuid_to_search = search_text
except ValueError:
hostname_to_search = search_text
remote_servers = get_remote_servers_for_support(
email_to_search=email_to_search,
uuid_to_search=uuid_to_search,
hostname_to_search=hostname_to_search,
)
remote_server_to_max_monthly_messages: dict[int, int | str] = dict()
server_support_data: dict[int, RemoteSupportData] = {}
realm_support_data: dict[int, RemoteSupportData] = {}
remote_realms: dict[int, list[RemoteRealm]] = {}
for remote_server in remote_servers:
# Get remote realms attached to remote server
remote_realms_for_server = list(
remote_server.remoterealm_set.exclude(is_system_bot_realm=True)
)
remote_realms[remote_server.id] = remote_realms_for_server
# Get plan data for remote realms
for remote_realm in remote_realms_for_server:
realm_billing_session = RemoteRealmBillingSession(remote_realm=remote_realm)
remote_realm_data = get_data_for_remote_support_view(realm_billing_session)
realm_support_data[remote_realm.id] = remote_realm_data
# Get plan data for remote server
server_billing_session = RemoteServerBillingSession(remote_server=remote_server)
remote_server_data = get_data_for_remote_support_view(server_billing_session)
server_support_data[remote_server.id] = remote_server_data
# Get max monthly messages
try:
remote_server_to_max_monthly_messages[remote_server.id] = compute_max_monthly_messages(
remote_server
)
except MissingDataError:
remote_server_to_max_monthly_messages[remote_server.id] = (
"Recent analytics data missing"
)
def get_remote_server_billing_user_emails_as_string(remote_server: RemoteZulipServer) -> str:
return ", ".join(
remote_server.get_remote_server_billing_users()
.order_by("email")
.values_list("email", flat=True)
)
def get_remote_realm_billing_user_emails_as_string(remote_realm: RemoteRealm) -> str:
return ", ".join(
remote_realm.get_remote_realm_billing_users()
.order_by("email")
.values_list("email", flat=True)
)
context["remote_servers"] = remote_servers
context["remote_servers_support_data"] = server_support_data
context["remote_server_to_max_monthly_messages"] = remote_server_to_max_monthly_messages
context["remote_realms"] = remote_realms
context["remote_realms_support_data"] = realm_support_data
context["format_optional_datetime"] = format_optional_datetime
context["server_analytics_link"] = remote_installation_stats_link
context["REMOTE_PLAN_TIERS"] = get_remote_plan_tier_options()
context["get_remote_server_billing_user_emails"] = (
get_remote_server_billing_user_emails_as_string
)
context["get_remote_realm_billing_user_emails"] = get_remote_realm_billing_user_emails_as_string
context["SPONSORED_PLAN_TYPE"] = RemoteZulipServer.PLAN_TYPE_COMMUNITY
return render(
request,
"corporate/support/remote_server_support.html",
context=context,
)