corporate: Move support and activity views to /corporate.

View functions in `analytics/views/support.py` are moved to
`corporate/views/support.py`.

Shared activity functions in `analytics/views/activity_common.py`
are moved to `corporate/lib/activity.py`, which was also renamed
from `corporate/lib/analytics.py`.
This commit is contained in:
Lauryn Menard 2024-01-29 13:47:19 +01:00 committed by Tim Abbott
parent afba77300a
commit df2f4b6469
14 changed files with 774 additions and 802 deletions

View File

@ -5,11 +5,6 @@ from django.conf.urls import include
from django.urls import path from django.urls import path
from django.urls.resolvers import URLPattern, URLResolver from django.urls.resolvers import URLPattern, URLResolver
from analytics.views.installation_activity import (
get_installation_activity,
get_integrations_activity,
)
from analytics.views.realm_activity import get_realm_activity
from analytics.views.stats import ( from analytics.views.stats import (
get_chart_data, get_chart_data,
get_chart_data_for_installation, get_chart_data_for_installation,
@ -19,17 +14,10 @@ from analytics.views.stats import (
stats_for_installation, stats_for_installation,
stats_for_realm, stats_for_realm,
) )
from analytics.views.support import support
from analytics.views.user_activity import get_user_activity
from zerver.lib.rest import rest_path from zerver.lib.rest import rest_path
i18n_urlpatterns: List[Union[URLPattern, URLResolver]] = [ i18n_urlpatterns: List[Union[URLPattern, URLResolver]] = [
# Server admin (user_profile.is_staff) visible stats pages # Server admin (user_profile.is_staff) visible stats pages
path("activity", get_installation_activity),
path("activity/integrations", get_integrations_activity),
path("activity/support", support, name="support"),
path("realm_activity/<realm_str>/", get_realm_activity),
path("user_activity/<user_profile_id>/", get_user_activity),
path("stats/realm/<realm_str>/", stats_for_realm), path("stats/realm/<realm_str>/", stats_for_realm),
path("stats/installation", stats_for_installation), path("stats/installation", stats_for_installation),
# User-visible stats page # User-visible stats page
@ -37,18 +25,14 @@ i18n_urlpatterns: List[Union[URLPattern, URLResolver]] = [
] ]
if settings.ZILENCER_ENABLED: if settings.ZILENCER_ENABLED:
from analytics.views.remote_activity import get_remote_server_activity
from analytics.views.stats import stats_for_remote_installation, stats_for_remote_realm from analytics.views.stats import stats_for_remote_installation, stats_for_remote_realm
from analytics.views.support import remote_servers_support
i18n_urlpatterns += [ i18n_urlpatterns += [
path("activity/remote", get_remote_server_activity),
path("stats/remote/<int:remote_server_id>/installation", stats_for_remote_installation), path("stats/remote/<int:remote_server_id>/installation", stats_for_remote_installation),
path( path(
"stats/remote/<int:remote_server_id>/realm/<int:remote_realm_id>/", "stats/remote/<int:remote_server_id>/realm/<int:remote_realm_id>/",
stats_for_remote_realm, stats_for_remote_realm,
), ),
path("activity/remote/support", remote_servers_support, name="remote_servers_support"),
] ]
# These endpoints are a part of the API (V1), which uses: # These endpoints are a part of the API (V1), which uses:

View File

@ -1,139 +0,0 @@
import sys
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Sequence, Union
from urllib.parse import urlencode
from django.conf import settings
from django.db import connection
from django.db.backends.utils import CursorWrapper
from django.template import loader
from django.urls import reverse
from markupsafe import Markup
from psycopg2.sql import Composable
from zerver.lib.pysa import mark_sanitized
from zerver.lib.url_encoding import append_url_query_string
from zerver.models import Realm
if sys.version_info < (3, 9): # nocoverage
from backports import zoneinfo
else: # nocoverage
import zoneinfo
eastern_tz = zoneinfo.ZoneInfo("America/New_York")
if settings.BILLING_ENABLED:
pass
def make_table(
title: str,
cols: Sequence[str],
rows: Sequence[Any],
*,
totals: Optional[Any] = None,
stats_link: Optional[Markup] = None,
has_row_class: bool = False,
) -> str:
if not has_row_class:
def fix_row(row: Any) -> Dict[str, Any]:
return dict(cells=row, row_class=None)
rows = list(map(fix_row, rows))
data = dict(title=title, cols=cols, rows=rows, totals=totals, stats_link=stats_link)
content = loader.render_to_string(
"analytics/ad_hoc_query.html",
dict(data=data),
)
return content
def fix_rows(
rows: List[List[Any]],
i: int,
fixup_func: Union[Callable[[str], Markup], Callable[[datetime], str], Callable[[int], int]],
) -> None:
for row in rows:
row[i] = fixup_func(row[i])
def get_query_data(query: Composable) -> List[List[Any]]:
cursor = connection.cursor()
cursor.execute(query)
rows = cursor.fetchall()
rows = list(map(list, rows))
cursor.close()
return rows
def dictfetchall(cursor: CursorWrapper) -> List[Dict[str, Any]]:
"""Returns all rows from a cursor as a dict"""
desc = cursor.description
return [dict(zip((col[0] for col in desc), row)) for row in cursor.fetchall()]
def format_date_for_activity_reports(date: Optional[datetime]) -> str:
if date:
return date.astimezone(eastern_tz).strftime("%Y-%m-%d %H:%M")
else:
return ""
def format_none_as_zero(value: Optional[int]) -> int:
if value:
return value
else:
return 0
def user_activity_link(email: str, user_profile_id: int) -> Markup:
from analytics.views.user_activity import get_user_activity
url = reverse(get_user_activity, kwargs=dict(user_profile_id=user_profile_id))
return Markup('<a href="{url}">{email}</a>').format(url=url, email=email)
def realm_activity_link(realm_str: str) -> Markup:
from analytics.views.realm_activity import get_realm_activity
url = reverse(get_realm_activity, kwargs=dict(realm_str=realm_str))
return Markup('<a href="{url}">{realm_str}</a>').format(url=url, realm_str=realm_str)
def realm_stats_link(realm_str: str) -> Markup:
from analytics.views.stats import stats_for_realm
url = reverse(stats_for_realm, kwargs=dict(realm_str=realm_str))
return Markup('<a href="{url}"><i class="fa fa-pie-chart"></i></a>').format(url=url)
def realm_support_link(realm_str: str) -> Markup:
support_url = reverse("support")
query = urlencode({"q": realm_str})
url = append_url_query_string(support_url, query)
return Markup('<a href="{url}"><i class="fa fa-gear"></i></a>').format(url=url)
def realm_url_link(realm_str: str) -> Markup:
host = Realm.host_for_subdomain(realm_str)
url = settings.EXTERNAL_URI_SCHEME + mark_sanitized(host)
return Markup('<a href="{url}"><i class="fa fa-home"></i></a>').format(url=url)
def remote_installation_stats_link(server_id: int) -> Markup:
from analytics.views.stats import stats_for_remote_installation
url = reverse(stats_for_remote_installation, kwargs=dict(remote_server_id=server_id))
return Markup('<a href="{url}"><i class="fa fa-pie-chart"></i></a>').format(url=url)
def remote_installation_support_link(hostname: str) -> Markup:
support_url = reverse("remote_servers_support")
query = urlencode({"q": hostname})
url = append_url_query_string(support_url, query)
return Markup('<a href="{url}"><i class="fa fa-gear"></i></a>').format(url=url)

View File

@ -1,599 +0,0 @@
from contextlib import suppress
from dataclasses import dataclass
from datetime import timedelta
from decimal import Decimal
from typing import Any, Dict, Iterable, List, Optional, Union
from urllib.parse import urlencode, urlsplit
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 analytics.views.activity_common import remote_installation_stats_link
from confirmation.models import Confirmation, confirmation_url
from confirmation.settings import STATUS_USED
from zerver.actions.create_realm import do_change_realm_subdomain
from zerver.actions.realm_settings import (
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
from zerver.forms import check_subdomain_available
from zerver.lib.exceptions import JsonableError
from zerver.lib.realm_icon import realm_icon_url
from zerver.lib.request import REQ, has_request_variables
from zerver.lib.subdomains import get_subdomain_from_hostname
from zerver.lib.validator import (
check_bool,
check_date,
check_string_in,
to_decimal,
to_non_negative_int,
)
from zerver.models import (
MultiuseInvite,
PreregistrationRealm,
PreregistrationUser,
Realm,
RealmReactivationStatus,
UserProfile,
)
from zerver.models.realms import 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
if settings.ZILENCER_ENABLED:
from zilencer.lib.remote_counts import MissingDataError, compute_max_monthly_messages
from zilencer.models import RemoteRealm, RemoteZulipServer
if settings.BILLING_ENABLED:
from corporate.lib.stripe import (
RealmBillingSession,
RemoteRealmBillingSession,
RemoteServerBillingSession,
SupportRequestError,
SupportType,
SupportViewRequest,
cents_to_dollar_string,
format_discount_percentage,
)
from corporate.lib.support import (
PlanData,
SupportData,
get_current_plan_data_for_support_view,
get_customer_discount_for_support_view,
get_data_for_support_view,
)
from corporate.models import CustomerPlan
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: "Self-managed",
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: Optional[str] = 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 PlanTierOption:
name: str
value: int
def get_remote_plan_tier_options() -> List[PlanTierOption]:
remote_plan_tiers = [
PlanTierOption("None", 0),
PlanTierOption(
CustomerPlan.name_from_tier(CustomerPlan.TIER_SELF_HOSTED_BASIC),
CustomerPlan.TIER_SELF_HOSTED_BASIC,
),
PlanTierOption(
CustomerPlan.name_from_tier(CustomerPlan.TIER_SELF_HOSTED_BUSINESS),
CustomerPlan.TIER_SELF_HOSTED_BUSINESS,
),
]
return remote_plan_tiers
VALID_MODIFY_PLAN_METHODS = [
"downgrade_at_billing_cycle_end",
"downgrade_now_without_additional_licenses",
"downgrade_now_void_open_invoices",
"upgrade_plan_tier",
]
VALID_STATUS_VALUES = [
"active",
"deactivated",
]
VALID_BILLING_MODALITY_VALUES = [
"send_invoice",
"charge_automatically",
]
@require_server_admin
@has_request_variables
def support(
request: HttpRequest,
realm_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
plan_type: Optional[int] = REQ(default=None, converter=to_non_negative_int),
discount: Optional[Decimal] = REQ(default=None, converter=to_decimal),
new_subdomain: Optional[str] = REQ(default=None),
status: Optional[str] = REQ(default=None, str_validator=check_string_in(VALID_STATUS_VALUES)),
billing_modality: Optional[str] = REQ(
default=None, str_validator=check_string_in(VALID_BILLING_MODALITY_VALUES)
),
sponsorship_pending: Optional[bool] = REQ(default=None, json_validator=check_bool),
approve_sponsorship: bool = REQ(default=False, json_validator=check_bool),
modify_plan: Optional[str] = REQ(
default=None, str_validator=check_string_in(VALID_MODIFY_PLAN_METHODS)
),
scrub_realm: bool = REQ(default=False, json_validator=check_bool),
delete_user_by_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
query: Optional[str] = REQ("q", default=None),
org_type: Optional[int] = REQ(default=None, converter=to_non_negative_int),
) -> HttpResponse:
context: Dict[str, Any] = {}
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())
if "csrfmiddlewaretoken" in keys:
keys.remove("csrfmiddlewaretoken")
if len(keys) != 2:
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 discount is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.attach_discount,
discount=discount,
)
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"Org 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 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":
do_deactivate_realm(realm, acting_user=acting_user)
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]
)
plan_data: Dict[int, PlanData] = {}
for realm in all_realms:
billing_session = RealmBillingSession(user=None, realm=realm)
realm_plan_data = get_current_plan_data_for_support_view(billing_session)
plan_data[realm.id] = realm_plan_data
context["plan_data"] = plan_data
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["get_discount"] = get_customer_discount_for_support_view
context["get_org_type_display_name"] = get_org_type_display_name
context["format_discount"] = format_discount_percentage
context["dollar_amount"] = cents_to_dollar_string
context["realm_icon_url"] = realm_icon_url
context["Confirmation"] = Confirmation
context["sorted_realm_types"] = sorted(
Realm.ORG_TYPES.values(), key=lambda d: d["display_order"]
)
return render(request, "analytics/support.html", context=context)
def get_remote_servers_for_support(
email_to_search: Optional[str], hostname_to_search: Optional[str]
) -> List["RemoteZulipServer"]:
if not email_to_search and not hostname_to_search:
return []
remote_servers_query = (
RemoteZulipServer.objects.order_by("id")
.exclude(deactivated=True)
.prefetch_related("remoterealm_set")
)
if email_to_search:
remote_servers_query = remote_servers_query.filter(contact_email__iexact=email_to_search)
elif hostname_to_search:
remote_servers_query = remote_servers_query.filter(hostname__icontains=hostname_to_search)
return list(remote_servers_query)
@require_server_admin
@has_request_variables
def remote_servers_support(
request: HttpRequest,
query: Optional[str] = REQ("q", default=None),
remote_server_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
remote_realm_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
discount: Optional[Decimal] = REQ(default=None, converter=to_decimal),
minimum_licenses: Optional[int] = REQ(default=None, converter=to_non_negative_int),
required_plan_tier: Optional[int] = REQ(default=None, converter=to_non_negative_int),
fixed_price: Optional[int] = REQ(default=None, converter=to_non_negative_int),
sponsorship_pending: Optional[bool] = REQ(default=None, json_validator=check_bool),
approve_sponsorship: bool = REQ(default=False, json_validator=check_bool),
billing_modality: Optional[str] = REQ(
default=None, str_validator=check_string_in(VALID_BILLING_MODALITY_VALUES)
),
plan_end_date: Optional[str] = REQ(default=None, str_validator=check_date),
modify_plan: Optional[str] = REQ(
default=None, str_validator=check_string_in(VALID_MODIFY_PLAN_METHODS)
),
) -> HttpResponse:
context: Dict[str, Any] = {}
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:
# either the remote_server_id or a remote_realm_id,
# and a field to change.
keys = set(request.POST.keys())
if "csrfmiddlewaretoken" in keys:
keys.remove("csrfmiddlewaretoken")
if len(keys) != 2:
raise JsonableError(_("Invalid parameters"))
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 discount is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.attach_discount,
discount=discount,
)
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:
support_view_request = SupportViewRequest(
support_type=SupportType.configure_fixed_price_plan,
fixed_price=fixed_price,
)
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,
)
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
hostname_to_search = None
if query:
if "@" in query:
email_to_search = query
else:
hostname_to_search = query
remote_servers = get_remote_servers_for_support(
email_to_search=email_to_search, hostname_to_search=hostname_to_search
)
remote_server_to_max_monthly_messages: Dict[int, Union[int, str]] = dict()
server_support_data: Dict[int, SupportData] = {}
realm_support_data: Dict[int, SupportData] = {}
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[remote_server.id]:
realm_billing_session = RemoteRealmBillingSession(remote_realm=remote_realm)
remote_realm_data = get_data_for_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_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"
)
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["get_plan_type_name"] = get_plan_type_string
context["get_org_type_display_name"] = get_org_type_display_name
context["format_discount"] = format_discount_percentage
context["dollar_amount"] = cents_to_dollar_string
context["server_analytics_link"] = remote_installation_stats_link
context["REMOTE_PLAN_TIERS"] = get_remote_plan_tier_options()
context["SPONSORED_PLAN_TYPE"] = RemoteZulipServer.PLAN_TYPE_COMMUNITY
return render(
request,
"analytics/remote_server_support.html",
context=context,
)

View File

@ -1,11 +1,20 @@
import sys
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import Any, Dict, List from typing import Any, Callable, Dict, List, Optional, Sequence, Union
from urllib.parse import urlencode
from django.conf import settings
from django.db import connection
from django.db.backends.utils import CursorWrapper
from django.db.models import Prefetch from django.db.models import Prefetch
from django.template import loader
from django.urls import reverse
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from markupsafe import Markup
from psycopg2.sql import Composable
from corporate.lib.stripe import ( from corporate.lib.stripe import (
RealmBillingSession, RealmBillingSession,
@ -13,13 +22,23 @@ from corporate.lib.stripe import (
RemoteServerBillingSession, RemoteServerBillingSession,
) )
from corporate.models import Customer, CustomerPlan, LicenseLedger from corporate.models import Customer, CustomerPlan, LicenseLedger
from zerver.lib.pysa import mark_sanitized
from zerver.lib.url_encoding import append_url_query_string
from zerver.lib.utils import assert_is_not_none from zerver.lib.utils import assert_is_not_none
from zerver.models import Realm
from zilencer.models import ( from zilencer.models import (
RemoteCustomerUserCount, RemoteCustomerUserCount,
RemoteRealmAuditLog, RemoteRealmAuditLog,
get_remote_customer_user_count, get_remote_customer_user_count,
) )
if sys.version_info < (3, 9): # nocoverage
from backports import zoneinfo
else: # nocoverage
import zoneinfo
eastern_tz = zoneinfo.ZoneInfo("America/New_York")
@dataclass @dataclass
class RemoteActivityPlanData: class RemoteActivityPlanData:
@ -28,6 +47,118 @@ class RemoteActivityPlanData:
annual_revenue: int annual_revenue: int
def make_table(
title: str,
cols: Sequence[str],
rows: Sequence[Any],
*,
totals: Optional[Any] = None,
stats_link: Optional[Markup] = None,
has_row_class: bool = False,
) -> str:
if not has_row_class:
def fix_row(row: Any) -> Dict[str, Any]:
return dict(cells=row, row_class=None)
rows = list(map(fix_row, rows))
data = dict(title=title, cols=cols, rows=rows, totals=totals, stats_link=stats_link)
content = loader.render_to_string(
"analytics/ad_hoc_query.html",
dict(data=data),
)
return content
def fix_rows(
rows: List[List[Any]],
i: int,
fixup_func: Union[Callable[[str], Markup], Callable[[datetime], str], Callable[[int], int]],
) -> None:
for row in rows:
row[i] = fixup_func(row[i])
def get_query_data(query: Composable) -> List[List[Any]]:
cursor = connection.cursor()
cursor.execute(query)
rows = cursor.fetchall()
rows = list(map(list, rows))
cursor.close()
return rows
def dictfetchall(cursor: CursorWrapper) -> List[Dict[str, Any]]:
"""Returns all rows from a cursor as a dict"""
desc = cursor.description
return [dict(zip((col[0] for col in desc), row)) for row in cursor.fetchall()]
def format_date_for_activity_reports(date: Optional[datetime]) -> str:
if date:
return date.astimezone(eastern_tz).strftime("%Y-%m-%d %H:%M")
else:
return ""
def format_none_as_zero(value: Optional[int]) -> int:
if value:
return value
else:
return 0
def user_activity_link(email: str, user_profile_id: int) -> Markup:
from corporate.views.user_activity import get_user_activity
url = reverse(get_user_activity, kwargs=dict(user_profile_id=user_profile_id))
return Markup('<a href="{url}">{email}</a>').format(url=url, email=email)
def realm_activity_link(realm_str: str) -> Markup:
from corporate.views.realm_activity import get_realm_activity
url = reverse(get_realm_activity, kwargs=dict(realm_str=realm_str))
return Markup('<a href="{url}">{realm_str}</a>').format(url=url, realm_str=realm_str)
def realm_stats_link(realm_str: str) -> Markup:
from analytics.views.stats import stats_for_realm
url = reverse(stats_for_realm, kwargs=dict(realm_str=realm_str))
return Markup('<a href="{url}"><i class="fa fa-pie-chart"></i></a>').format(url=url)
def realm_support_link(realm_str: str) -> Markup:
support_url = reverse("support")
query = urlencode({"q": realm_str})
url = append_url_query_string(support_url, query)
return Markup('<a href="{url}"><i class="fa fa-gear"></i></a>').format(url=url)
def realm_url_link(realm_str: str) -> Markup:
host = Realm.host_for_subdomain(realm_str)
url = settings.EXTERNAL_URI_SCHEME + mark_sanitized(host)
return Markup('<a href="{url}"><i class="fa fa-home"></i></a>').format(url=url)
def remote_installation_stats_link(server_id: int) -> Markup:
from analytics.views.stats import stats_for_remote_installation
url = reverse(stats_for_remote_installation, kwargs=dict(remote_server_id=server_id))
return Markup('<a href="{url}"><i class="fa fa-pie-chart"></i></a>').format(url=url)
def remote_installation_support_link(hostname: str) -> Markup:
support_url = reverse("remote_servers_support")
query = urlencode({"q": hostname})
url = append_url_query_string(support_url, query)
return Markup('<a href="{url}"><i class="fa fa-gear"></i></a>').format(url=url)
def get_realms_with_default_discount_dict() -> Dict[str, Decimal]: def get_realms_with_default_discount_dict() -> Dict[str, Decimal]:
realms_with_default_discount: Dict[str, Any] = {} realms_with_default_discount: Dict[str, Any] = {}
customers = ( customers = (

View File

@ -5,7 +5,7 @@ from unittest import mock
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from corporate.lib.analytics import get_remote_server_audit_logs from corporate.lib.activity import get_remote_server_audit_logs
from corporate.lib.stripe import add_months from corporate.lib.stripe import add_months
from corporate.models import Customer, CustomerPlan, LicenseLedger from corporate.models import Customer, CustomerPlan, LicenseLedger
from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_classes import ZulipTestCase

View File

@ -40,7 +40,7 @@ from django.utils.crypto import get_random_string
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from typing_extensions import ParamSpec, override from typing_extensions import ParamSpec, override
from corporate.lib.analytics import get_realms_with_default_discount_dict from corporate.lib.activity import get_realms_with_default_discount_dict
from corporate.lib.stripe import ( from corporate.lib.stripe import (
DEFAULT_INVOICE_DAYS_UNTIL_DUE, DEFAULT_INVOICE_DAYS_UNTIL_DUE,
MAX_INVOICED_LICENSES, MAX_INVOICED_LICENSES,

View File

@ -304,7 +304,7 @@ class TestRemoteServerSupportEndpoint(ZulipTestCase):
check_remote_server_with_no_realms(result) check_remote_server_with_no_realms(result)
server = 2 server = 2
with mock.patch("analytics.views.support.compute_max_monthly_messages", return_value=1000): with mock.patch("corporate.views.support.compute_max_monthly_messages", return_value=1000):
result = self.client_get( result = self.client_get(
"/activity/remote/support", {"q": f"zulip-{server}.example.com"} "/activity/remote/support", {"q": f"zulip-{server}.example.com"}
) )
@ -314,7 +314,7 @@ class TestRemoteServerSupportEndpoint(ZulipTestCase):
check_sponsorship_request_no_website(result) check_sponsorship_request_no_website(result)
with mock.patch( with mock.patch(
"analytics.views.support.compute_max_monthly_messages", side_effect=MissingDataError "corporate.views.support.compute_max_monthly_messages", side_effect=MissingDataError
): ):
result = self.client_get( result = self.client_get(
"/activity/remote/support", {"q": f"zulip-{server}.example.com"} "/activity/remote/support", {"q": f"zulip-{server}.example.com"}
@ -875,7 +875,7 @@ class TestSupportEndpoint(ZulipTestCase):
iago = self.example_user("iago") iago = self.example_user("iago")
self.login_user(iago) self.login_user(iago)
with mock.patch("analytics.views.support.do_change_realm_plan_type") as m: with mock.patch("corporate.views.support.do_change_realm_plan_type") as m:
result = self.client_post( result = self.client_post(
"/activity/support", {"realm_id": f"{iago.realm_id}", "plan_type": "2"} "/activity/support", {"realm_id": f"{iago.realm_id}", "plan_type": "2"}
) )
@ -884,7 +884,7 @@ class TestSupportEndpoint(ZulipTestCase):
["Plan type of zulip changed from Self-hosted to Limited"], result ["Plan type of zulip changed from Self-hosted to Limited"], result
) )
with mock.patch("analytics.views.support.do_change_realm_plan_type") as m: with mock.patch("corporate.views.support.do_change_realm_plan_type") as m:
result = self.client_post( result = self.client_post(
"/activity/support", {"realm_id": f"{iago.realm_id}", "plan_type": "10"} "/activity/support", {"realm_id": f"{iago.realm_id}", "plan_type": "10"}
) )
@ -906,7 +906,7 @@ class TestSupportEndpoint(ZulipTestCase):
iago = self.example_user("iago") iago = self.example_user("iago")
self.login_user(iago) self.login_user(iago)
with mock.patch("analytics.views.support.do_change_realm_org_type") as m: with mock.patch("corporate.views.support.do_change_realm_org_type") as m:
result = self.client_post( result = self.client_post(
"/activity/support", {"realm_id": f"{iago.realm_id}", "org_type": "70"} "/activity/support", {"realm_id": f"{iago.realm_id}", "org_type": "70"}
) )
@ -941,7 +941,7 @@ class TestSupportEndpoint(ZulipTestCase):
self.assertEqual(customer.default_discount, Decimal(25)) self.assertEqual(customer.default_discount, Decimal(25))
self.assertEqual(plan.discount, Decimal(25)) self.assertEqual(plan.discount, Decimal(25))
start_next_billing_cycle = start_of_next_billing_cycle(plan, timezone_now()) start_next_billing_cycle = start_of_next_billing_cycle(plan, timezone_now())
biling_cycle_string = start_next_billing_cycle.strftime("%d %B %Y") billing_cycle_string = start_next_billing_cycle.strftime("%d %B %Y")
result = self.client_get("/activity/support", {"q": "lear"}) result = self.client_get("/activity/support", {"q": "lear"})
self.assert_in_success_response( self.assert_in_success_response(
@ -953,7 +953,7 @@ class TestSupportEndpoint(ZulipTestCase):
"<b>Licenses</b>: 2/10 (Manual)", "<b>Licenses</b>: 2/10 (Manual)",
"<b>Price per license</b>: $6.00", "<b>Price per license</b>: $6.00",
"<b>Annual recurring revenue</b>: $720.00", "<b>Annual recurring revenue</b>: $720.00",
f"<b>Start of next billing cycle</b>: {biling_cycle_string}", f"<b>Start of next billing cycle</b>: {billing_cycle_string}",
], ],
result, result,
) )
@ -1043,14 +1043,14 @@ class TestSupportEndpoint(ZulipTestCase):
self.login("iago") self.login("iago")
with mock.patch("analytics.views.support.do_deactivate_realm") as m: with mock.patch("corporate.views.support.do_deactivate_realm") as m:
result = self.client_post( result = self.client_post(
"/activity/support", {"realm_id": f"{lear_realm.id}", "status": "deactivated"} "/activity/support", {"realm_id": f"{lear_realm.id}", "status": "deactivated"}
) )
m.assert_called_once_with(lear_realm, acting_user=self.example_user("iago")) m.assert_called_once_with(lear_realm, acting_user=self.example_user("iago"))
self.assert_in_success_response(["lear deactivated"], result) self.assert_in_success_response(["lear deactivated"], result)
with mock.patch("analytics.views.support.do_send_realm_reactivation_email") as m: with mock.patch("corporate.views.support.do_send_realm_reactivation_email") as m:
result = self.client_post( result = self.client_post(
"/activity/support", {"realm_id": f"{lear_realm.id}", "status": "active"} "/activity/support", {"realm_id": f"{lear_realm.id}", "status": "active"}
) )
@ -1192,14 +1192,14 @@ class TestSupportEndpoint(ZulipTestCase):
self.login("iago") self.login("iago")
with mock.patch("analytics.views.support.do_scrub_realm") as m: with mock.patch("corporate.views.support.do_scrub_realm") as m:
result = self.client_post( result = self.client_post(
"/activity/support", {"realm_id": f"{lear_realm.id}", "scrub_realm": "true"} "/activity/support", {"realm_id": f"{lear_realm.id}", "scrub_realm": "true"}
) )
m.assert_called_once_with(lear_realm, acting_user=self.example_user("iago")) m.assert_called_once_with(lear_realm, acting_user=self.example_user("iago"))
self.assert_in_success_response(["lear scrubbed"], result) self.assert_in_success_response(["lear scrubbed"], result)
with mock.patch("analytics.views.support.do_scrub_realm") as m: with mock.patch("corporate.views.support.do_scrub_realm") as m:
result = self.client_post("/activity/support", {"realm_id": f"{lear_realm.id}"}) result = self.client_post("/activity/support", {"realm_id": f"{lear_realm.id}"})
self.assert_json_error(result, "Invalid parameters") self.assert_json_error(result, "Invalid parameters")
m.assert_not_called() m.assert_not_called()
@ -1219,7 +1219,7 @@ class TestSupportEndpoint(ZulipTestCase):
self.login("iago") self.login("iago")
with mock.patch("analytics.views.support.do_delete_user_preserving_messages") as m: with mock.patch("corporate.views.support.do_delete_user_preserving_messages") as m:
result = self.client_post( result = self.client_post(
"/activity/support", "/activity/support",
{"realm_id": f"{realm.id}", "delete_user_by_id": hamlet.id}, {"realm_id": f"{realm.id}", "delete_user_by_id": hamlet.id},

View File

@ -21,6 +21,10 @@ from corporate.views.event_status import (
remote_server_event_status, remote_server_event_status,
remote_server_event_status_page, remote_server_event_status_page,
) )
from corporate.views.installation_activity import (
get_installation_activity,
get_integrations_activity,
)
from corporate.views.portico import ( from corporate.views.portico import (
app_download_link_redirect, app_download_link_redirect,
apps_view, apps_view,
@ -32,6 +36,8 @@ from corporate.views.portico import (
remote_server_plans_page, remote_server_plans_page,
team_view, team_view,
) )
from corporate.views.realm_activity import get_realm_activity
from corporate.views.remote_activity import get_remote_server_activity
from corporate.views.remote_billing_page import ( from corporate.views.remote_billing_page import (
remote_billing_legacy_server_confirm_login, remote_billing_legacy_server_confirm_login,
remote_billing_legacy_server_from_login_confirmation_link, remote_billing_legacy_server_from_login_confirmation_link,
@ -56,7 +62,7 @@ from corporate.views.sponsorship import (
sponsorship, sponsorship,
sponsorship_page, sponsorship_page,
) )
from corporate.views.support import support_request from corporate.views.support import remote_servers_support, support, support_request
from corporate.views.upgrade import ( from corporate.views.upgrade import (
remote_realm_upgrade, remote_realm_upgrade,
remote_realm_upgrade_page, remote_realm_upgrade_page,
@ -65,6 +71,7 @@ from corporate.views.upgrade import (
upgrade, upgrade,
upgrade_page, upgrade_page,
) )
from corporate.views.user_activity import get_user_activity
from corporate.views.webhook import stripe_webhook from corporate.views.webhook import stripe_webhook
from zerver.lib.rest import rest_path from zerver.lib.rest import rest_path
from zerver.lib.url_redirects import LANDING_PAGE_REDIRECTS from zerver.lib.url_redirects import LANDING_PAGE_REDIRECTS
@ -81,6 +88,14 @@ i18n_urlpatterns: Any = [
path("support/", support_request), path("support/", support_request),
path("billing/event_status/", event_status_page, name="event_status_page"), path("billing/event_status/", event_status_page, name="event_status_page"),
path("stripe/webhook/", stripe_webhook, name="stripe_webhook"), path("stripe/webhook/", stripe_webhook, name="stripe_webhook"),
# Server admin (user_profile.is_staff) visible stats pages
path("activity", get_installation_activity),
path("activity/integrations", get_integrations_activity),
path("activity/support", support, name="support"),
path("realm_activity/<realm_str>/", get_realm_activity),
path("user_activity/<user_profile_id>/", get_user_activity),
path("activity/remote", get_remote_server_activity),
path("activity/remote/support", remote_servers_support, name="remote_servers_support"),
] ]
v1_api_and_json_patterns = [ v1_api_and_json_patterns = [

View File

@ -11,30 +11,26 @@ from markupsafe import Markup
from psycopg2.sql import SQL from psycopg2.sql import SQL
from analytics.lib.counts import COUNT_STATS from analytics.lib.counts import COUNT_STATS
from analytics.views.activity_common import ( from corporate.lib.activity import (
dictfetchall, dictfetchall,
estimate_annual_recurring_revenue_by_realm,
fix_rows, fix_rows,
format_date_for_activity_reports, format_date_for_activity_reports,
get_query_data, get_query_data,
get_realms_with_default_discount_dict,
make_table, make_table,
realm_activity_link, realm_activity_link,
realm_stats_link, realm_stats_link,
realm_support_link, realm_support_link,
realm_url_link, realm_url_link,
) )
from analytics.views.support import get_plan_type_string from corporate.lib.stripe import cents_to_dollar_string
from corporate.views.support import get_plan_type_string
from zerver.decorator import require_server_admin from zerver.decorator import require_server_admin
from zerver.lib.request import has_request_variables from zerver.lib.request import has_request_variables
from zerver.models import Realm from zerver.models import Realm
from zerver.models.realms import get_org_type_display_name from zerver.models.realms import get_org_type_display_name
if settings.BILLING_ENABLED:
from corporate.lib.analytics import (
estimate_annual_recurring_revenue_by_realm,
get_realms_with_default_discount_dict,
)
from corporate.lib.stripe import cents_to_dollar_string
def get_realm_day_counts() -> Dict[str, Dict[str, Markup]]: def get_realm_day_counts() -> Dict[str, Dict[str, Markup]]:
# To align with UTC days, we subtract an hour from end_time to # To align with UTC days, we subtract an hour from end_time to

View File

@ -10,7 +10,7 @@ from django.shortcuts import render
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from markupsafe import Markup from markupsafe import Markup
from analytics.views.activity_common import ( from corporate.lib.activity import (
format_date_for_activity_reports, format_date_for_activity_reports,
make_table, make_table,
realm_stats_link, realm_stats_link,

View File

@ -2,21 +2,19 @@ from django.http import HttpRequest, HttpResponse
from django.shortcuts import render from django.shortcuts import render
from psycopg2.sql import SQL from psycopg2.sql import SQL
from analytics.views.activity_common import ( from corporate.lib.activity import (
fix_rows, fix_rows,
format_date_for_activity_reports, format_date_for_activity_reports,
format_none_as_zero, format_none_as_zero,
get_plan_data_by_remote_realm,
get_plan_data_by_remote_server,
get_query_data, get_query_data,
get_remote_realm_user_counts,
get_remote_server_audit_logs,
make_table, make_table,
remote_installation_stats_link, remote_installation_stats_link,
remote_installation_support_link, remote_installation_support_link,
) )
from corporate.lib.analytics import (
get_plan_data_by_remote_realm,
get_plan_data_by_remote_server,
get_remote_realm_user_counts,
get_remote_server_audit_logs,
)
from corporate.lib.stripe import cents_to_dollar_string from corporate.lib.stripe import cents_to_dollar_string
from zerver.decorator import require_server_admin from zerver.decorator import require_server_admin
from zerver.models.realms import get_org_type_display_name from zerver.models.realms import get_org_type_display_name

View File

@ -1,11 +1,80 @@
from django import forms from contextlib import suppress
from django.http import HttpRequest, HttpResponse from dataclasses import dataclass
from django.shortcuts import render from datetime import timedelta
from decimal import Decimal
from typing import Any, Dict, Iterable, List, Optional, Union
from urllib.parse import urlencode, urlsplit
from corporate.lib.support import get_realm_support_url from django import forms
from zerver.decorator import zulip_login_required from django.conf import settings
from zerver.lib.request import has_request_variables 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 confirmation.models import Confirmation, confirmation_url
from confirmation.settings import STATUS_USED
from corporate.lib.activity import remote_installation_stats_link
from corporate.lib.stripe import (
RealmBillingSession,
RemoteRealmBillingSession,
RemoteServerBillingSession,
SupportRequestError,
SupportType,
SupportViewRequest,
cents_to_dollar_string,
format_discount_percentage,
)
from corporate.lib.support import (
PlanData,
SupportData,
get_current_plan_data_for_support_view,
get_customer_discount_for_support_view,
get_data_for_support_view,
get_realm_support_url,
)
from corporate.models import CustomerPlan
from zerver.actions.create_realm import do_change_realm_subdomain
from zerver.actions.realm_settings import (
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.realm_icon import realm_icon_url
from zerver.lib.request import REQ, has_request_variables
from zerver.lib.send_email import FromAddress, send_email from zerver.lib.send_email import FromAddress, send_email
from zerver.lib.subdomains import get_subdomain_from_hostname
from zerver.lib.validator import (
check_bool,
check_date,
check_string_in,
to_decimal,
to_non_negative_int,
)
from zerver.models import (
MultiuseInvite,
PreregistrationRealm,
PreregistrationUser,
Realm,
RealmReactivationStatus,
UserProfile,
)
from zerver.models.realms import 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, RemoteZulipServer
class SupportRequestForm(forms.Form): class SupportRequestForm(forms.Form):
@ -56,3 +125,524 @@ def support_request(request: HttpRequest) -> HttpResponse:
response = render(request, "corporate/support_request.html", context=context) response = render(request, "corporate/support_request.html", context=context)
return response 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: "Self-managed",
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: Optional[str] = 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 PlanTierOption:
name: str
value: int
def get_remote_plan_tier_options() -> List[PlanTierOption]:
remote_plan_tiers = [
PlanTierOption("None", 0),
PlanTierOption(
CustomerPlan.name_from_tier(CustomerPlan.TIER_SELF_HOSTED_BASIC),
CustomerPlan.TIER_SELF_HOSTED_BASIC,
),
PlanTierOption(
CustomerPlan.name_from_tier(CustomerPlan.TIER_SELF_HOSTED_BUSINESS),
CustomerPlan.TIER_SELF_HOSTED_BUSINESS,
),
]
return remote_plan_tiers
VALID_MODIFY_PLAN_METHODS = [
"downgrade_at_billing_cycle_end",
"downgrade_now_without_additional_licenses",
"downgrade_now_void_open_invoices",
"upgrade_plan_tier",
]
VALID_STATUS_VALUES = [
"active",
"deactivated",
]
VALID_BILLING_MODALITY_VALUES = [
"send_invoice",
"charge_automatically",
]
@require_server_admin
@has_request_variables
def support(
request: HttpRequest,
realm_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
plan_type: Optional[int] = REQ(default=None, converter=to_non_negative_int),
discount: Optional[Decimal] = REQ(default=None, converter=to_decimal),
new_subdomain: Optional[str] = REQ(default=None),
status: Optional[str] = REQ(default=None, str_validator=check_string_in(VALID_STATUS_VALUES)),
billing_modality: Optional[str] = REQ(
default=None, str_validator=check_string_in(VALID_BILLING_MODALITY_VALUES)
),
sponsorship_pending: Optional[bool] = REQ(default=None, json_validator=check_bool),
approve_sponsorship: bool = REQ(default=False, json_validator=check_bool),
modify_plan: Optional[str] = REQ(
default=None, str_validator=check_string_in(VALID_MODIFY_PLAN_METHODS)
),
scrub_realm: bool = REQ(default=False, json_validator=check_bool),
delete_user_by_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
query: Optional[str] = REQ("q", default=None),
org_type: Optional[int] = REQ(default=None, converter=to_non_negative_int),
) -> HttpResponse:
context: Dict[str, Any] = {}
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())
if "csrfmiddlewaretoken" in keys:
keys.remove("csrfmiddlewaretoken")
if len(keys) != 2:
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 discount is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.attach_discount,
discount=discount,
)
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"Org 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 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":
do_deactivate_realm(realm, acting_user=acting_user)
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]
)
plan_data: Dict[int, PlanData] = {}
for realm in all_realms:
billing_session = RealmBillingSession(user=None, realm=realm)
realm_plan_data = get_current_plan_data_for_support_view(billing_session)
plan_data[realm.id] = realm_plan_data
context["plan_data"] = plan_data
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["get_discount"] = get_customer_discount_for_support_view
context["get_org_type_display_name"] = get_org_type_display_name
context["format_discount"] = format_discount_percentage
context["dollar_amount"] = cents_to_dollar_string
context["realm_icon_url"] = realm_icon_url
context["Confirmation"] = Confirmation
context["sorted_realm_types"] = sorted(
Realm.ORG_TYPES.values(), key=lambda d: d["display_order"]
)
return render(request, "analytics/support.html", context=context)
def get_remote_servers_for_support(
email_to_search: Optional[str], hostname_to_search: Optional[str]
) -> List["RemoteZulipServer"]:
if not email_to_search and not hostname_to_search:
return []
remote_servers_query = (
RemoteZulipServer.objects.order_by("id")
.exclude(deactivated=True)
.prefetch_related("remoterealm_set")
)
if email_to_search:
remote_servers_query = remote_servers_query.filter(contact_email__iexact=email_to_search)
elif hostname_to_search:
remote_servers_query = remote_servers_query.filter(hostname__icontains=hostname_to_search)
return list(remote_servers_query)
@require_server_admin
@has_request_variables
def remote_servers_support(
request: HttpRequest,
query: Optional[str] = REQ("q", default=None),
remote_server_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
remote_realm_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
discount: Optional[Decimal] = REQ(default=None, converter=to_decimal),
minimum_licenses: Optional[int] = REQ(default=None, converter=to_non_negative_int),
required_plan_tier: Optional[int] = REQ(default=None, converter=to_non_negative_int),
fixed_price: Optional[int] = REQ(default=None, converter=to_non_negative_int),
sponsorship_pending: Optional[bool] = REQ(default=None, json_validator=check_bool),
approve_sponsorship: bool = REQ(default=False, json_validator=check_bool),
billing_modality: Optional[str] = REQ(
default=None, str_validator=check_string_in(VALID_BILLING_MODALITY_VALUES)
),
plan_end_date: Optional[str] = REQ(default=None, str_validator=check_date),
modify_plan: Optional[str] = REQ(
default=None, str_validator=check_string_in(VALID_MODIFY_PLAN_METHODS)
),
) -> HttpResponse:
context: Dict[str, Any] = {}
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:
# either the remote_server_id or a remote_realm_id,
# and a field to change.
keys = set(request.POST.keys())
if "csrfmiddlewaretoken" in keys:
keys.remove("csrfmiddlewaretoken")
if len(keys) != 2:
raise JsonableError(_("Invalid parameters"))
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 discount is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.attach_discount,
discount=discount,
)
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:
support_view_request = SupportViewRequest(
support_type=SupportType.configure_fixed_price_plan,
fixed_price=fixed_price,
)
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,
)
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
hostname_to_search = None
if query:
if "@" in query:
email_to_search = query
else:
hostname_to_search = query
remote_servers = get_remote_servers_for_support(
email_to_search=email_to_search, hostname_to_search=hostname_to_search
)
remote_server_to_max_monthly_messages: Dict[int, Union[int, str]] = dict()
server_support_data: Dict[int, SupportData] = {}
realm_support_data: Dict[int, SupportData] = {}
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[remote_server.id]:
realm_billing_session = RemoteRealmBillingSession(remote_realm=remote_realm)
remote_realm_data = get_data_for_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_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"
)
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["get_plan_type_name"] = get_plan_type_string
context["get_org_type_display_name"] = get_org_type_display_name
context["format_discount"] = format_discount_percentage
context["dollar_amount"] = cents_to_dollar_string
context["server_analytics_link"] = remote_installation_stats_link
context["REMOTE_PLAN_TIERS"] = get_remote_plan_tier_options()
context["SPONSORED_PLAN_TYPE"] = RemoteZulipServer.PLAN_TYPE_COMMUNITY
return render(
request,
"analytics/remote_server_support.html",
context=context,
)

View File

@ -1,18 +1,14 @@
from typing import Any, List from typing import Any, List
from django.conf import settings
from django.db.models import QuerySet from django.db.models import QuerySet
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import render from django.shortcuts import render
from analytics.views.activity_common import format_date_for_activity_reports, make_table from corporate.lib.activity import format_date_for_activity_reports, make_table
from zerver.decorator import require_server_admin from zerver.decorator import require_server_admin
from zerver.models import UserActivity, UserProfile from zerver.models import UserActivity, UserProfile
from zerver.models.users import get_user_profile_by_id from zerver.models.users import get_user_profile_by_id
if settings.BILLING_ENABLED:
pass
def get_user_activity_records( def get_user_activity_records(
user_profile: UserProfile, user_profile: UserProfile,

View File

@ -49,14 +49,14 @@ not_yet_fully_covered = [
# runs before tests. # runs before tests.
"analytics/lib/fixtures.py", "analytics/lib/fixtures.py",
# We have 100% coverage on the new stuff; need to refactor old stuff. # We have 100% coverage on the new stuff; need to refactor old stuff.
"analytics/views/activity_common.py",
"analytics/views/installation_activity.py",
"analytics/views/realm_activity.py",
"analytics/views/stats.py", "analytics/views/stats.py",
"analytics/views/support.py",
# TODO: This is a work in progress and therefore without # TODO: This is a work in progress and therefore without
# tests yet. # tests yet.
"corporate/views/installation_activity.py",
"corporate/views/realm_activity.py",
"corporate/views/remote_billing_page.py", "corporate/views/remote_billing_page.py",
"corporate/views/support.py",
"corporate/lib/activity.py",
"corporate/lib/remote_billing_util.py", "corporate/lib/remote_billing_util.py",
# Major lib files should have 100% coverage # Major lib files should have 100% coverage
"zerver/lib/addressee.py", "zerver/lib/addressee.py",