mirror of https://github.com/zulip/zulip.git
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:
parent
afba77300a
commit
df2f4b6469
|
@ -5,11 +5,6 @@ from django.conf.urls import include
|
|||
from django.urls import path
|
||||
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 (
|
||||
get_chart_data,
|
||||
get_chart_data_for_installation,
|
||||
|
@ -19,17 +14,10 @@ from analytics.views.stats import (
|
|||
stats_for_installation,
|
||||
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
|
||||
|
||||
i18n_urlpatterns: List[Union[URLPattern, URLResolver]] = [
|
||||
# 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/installation", stats_for_installation),
|
||||
# User-visible stats page
|
||||
|
@ -37,18 +25,14 @@ i18n_urlpatterns: List[Union[URLPattern, URLResolver]] = [
|
|||
]
|
||||
|
||||
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.support import remote_servers_support
|
||||
|
||||
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>/realm/<int:remote_realm_id>/",
|
||||
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:
|
||||
|
|
|
@ -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)
|
|
@ -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,
|
||||
)
|
|
@ -1,11 +1,20 @@
|
|||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
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.template import loader
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from markupsafe import Markup
|
||||
from psycopg2.sql import Composable
|
||||
|
||||
from corporate.lib.stripe import (
|
||||
RealmBillingSession,
|
||||
|
@ -13,13 +22,23 @@ from corporate.lib.stripe import (
|
|||
RemoteServerBillingSession,
|
||||
)
|
||||
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.models import Realm
|
||||
from zilencer.models import (
|
||||
RemoteCustomerUserCount,
|
||||
RemoteRealmAuditLog,
|
||||
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
|
||||
class RemoteActivityPlanData:
|
||||
|
@ -28,6 +47,118 @@ class RemoteActivityPlanData:
|
|||
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]:
|
||||
realms_with_default_discount: Dict[str, Any] = {}
|
||||
customers = (
|
|
@ -5,7 +5,7 @@ from unittest import mock
|
|||
|
||||
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.models import Customer, CustomerPlan, LicenseLedger
|
||||
from zerver.lib.test_classes import ZulipTestCase
|
|
@ -40,7 +40,7 @@ from django.utils.crypto import get_random_string
|
|||
from django.utils.timezone import now as timezone_now
|
||||
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 (
|
||||
DEFAULT_INVOICE_DAYS_UNTIL_DUE,
|
||||
MAX_INVOICED_LICENSES,
|
||||
|
|
|
@ -304,7 +304,7 @@ class TestRemoteServerSupportEndpoint(ZulipTestCase):
|
|||
check_remote_server_with_no_realms(result)
|
||||
|
||||
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(
|
||||
"/activity/remote/support", {"q": f"zulip-{server}.example.com"}
|
||||
)
|
||||
|
@ -314,7 +314,7 @@ class TestRemoteServerSupportEndpoint(ZulipTestCase):
|
|||
check_sponsorship_request_no_website(result)
|
||||
|
||||
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(
|
||||
"/activity/remote/support", {"q": f"zulip-{server}.example.com"}
|
||||
|
@ -875,7 +875,7 @@ class TestSupportEndpoint(ZulipTestCase):
|
|||
iago = self.example_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(
|
||||
"/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
|
||||
)
|
||||
|
||||
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(
|
||||
"/activity/support", {"realm_id": f"{iago.realm_id}", "plan_type": "10"}
|
||||
)
|
||||
|
@ -906,7 +906,7 @@ class TestSupportEndpoint(ZulipTestCase):
|
|||
iago = self.example_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(
|
||||
"/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(plan.discount, Decimal(25))
|
||||
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"})
|
||||
self.assert_in_success_response(
|
||||
|
@ -953,7 +953,7 @@ class TestSupportEndpoint(ZulipTestCase):
|
|||
"<b>Licenses</b>: 2/10 (Manual)",
|
||||
"<b>Price per license</b>: $6.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,
|
||||
)
|
||||
|
@ -1043,14 +1043,14 @@ class TestSupportEndpoint(ZulipTestCase):
|
|||
|
||||
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(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "status": "deactivated"}
|
||||
)
|
||||
m.assert_called_once_with(lear_realm, acting_user=self.example_user("iago"))
|
||||
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(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "status": "active"}
|
||||
)
|
||||
|
@ -1192,14 +1192,14 @@ class TestSupportEndpoint(ZulipTestCase):
|
|||
|
||||
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(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "scrub_realm": "true"}
|
||||
)
|
||||
m.assert_called_once_with(lear_realm, acting_user=self.example_user("iago"))
|
||||
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}"})
|
||||
self.assert_json_error(result, "Invalid parameters")
|
||||
m.assert_not_called()
|
||||
|
@ -1219,7 +1219,7 @@ class TestSupportEndpoint(ZulipTestCase):
|
|||
|
||||
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(
|
||||
"/activity/support",
|
||||
{"realm_id": f"{realm.id}", "delete_user_by_id": hamlet.id},
|
|
@ -21,6 +21,10 @@ from corporate.views.event_status import (
|
|||
remote_server_event_status,
|
||||
remote_server_event_status_page,
|
||||
)
|
||||
from corporate.views.installation_activity import (
|
||||
get_installation_activity,
|
||||
get_integrations_activity,
|
||||
)
|
||||
from corporate.views.portico import (
|
||||
app_download_link_redirect,
|
||||
apps_view,
|
||||
|
@ -32,6 +36,8 @@ from corporate.views.portico import (
|
|||
remote_server_plans_page,
|
||||
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 (
|
||||
remote_billing_legacy_server_confirm_login,
|
||||
remote_billing_legacy_server_from_login_confirmation_link,
|
||||
|
@ -56,7 +62,7 @@ from corporate.views.sponsorship import (
|
|||
sponsorship,
|
||||
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 (
|
||||
remote_realm_upgrade,
|
||||
remote_realm_upgrade_page,
|
||||
|
@ -65,6 +71,7 @@ from corporate.views.upgrade import (
|
|||
upgrade,
|
||||
upgrade_page,
|
||||
)
|
||||
from corporate.views.user_activity import get_user_activity
|
||||
from corporate.views.webhook import stripe_webhook
|
||||
from zerver.lib.rest import rest_path
|
||||
from zerver.lib.url_redirects import LANDING_PAGE_REDIRECTS
|
||||
|
@ -81,6 +88,14 @@ i18n_urlpatterns: Any = [
|
|||
path("support/", support_request),
|
||||
path("billing/event_status/", event_status_page, name="event_status_page"),
|
||||
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 = [
|
||||
|
|
|
@ -11,30 +11,26 @@ from markupsafe import Markup
|
|||
from psycopg2.sql import SQL
|
||||
|
||||
from analytics.lib.counts import COUNT_STATS
|
||||
from analytics.views.activity_common import (
|
||||
from corporate.lib.activity import (
|
||||
dictfetchall,
|
||||
estimate_annual_recurring_revenue_by_realm,
|
||||
fix_rows,
|
||||
format_date_for_activity_reports,
|
||||
get_query_data,
|
||||
get_realms_with_default_discount_dict,
|
||||
make_table,
|
||||
realm_activity_link,
|
||||
realm_stats_link,
|
||||
realm_support_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.lib.request import has_request_variables
|
||||
from zerver.models import Realm
|
||||
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]]:
|
||||
# To align with UTC days, we subtract an hour from end_time to
|
|
@ -10,7 +10,7 @@ from django.shortcuts import render
|
|||
from django.utils.timezone import now as timezone_now
|
||||
from markupsafe import Markup
|
||||
|
||||
from analytics.views.activity_common import (
|
||||
from corporate.lib.activity import (
|
||||
format_date_for_activity_reports,
|
||||
make_table,
|
||||
realm_stats_link,
|
|
@ -2,21 +2,19 @@ from django.http import HttpRequest, HttpResponse
|
|||
from django.shortcuts import render
|
||||
from psycopg2.sql import SQL
|
||||
|
||||
from analytics.views.activity_common import (
|
||||
from corporate.lib.activity import (
|
||||
fix_rows,
|
||||
format_date_for_activity_reports,
|
||||
format_none_as_zero,
|
||||
get_plan_data_by_remote_realm,
|
||||
get_plan_data_by_remote_server,
|
||||
get_query_data,
|
||||
get_remote_realm_user_counts,
|
||||
get_remote_server_audit_logs,
|
||||
make_table,
|
||||
remote_installation_stats_link,
|
||||
remote_installation_support_link,
|
||||
)
|
||||
from 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 zerver.decorator import require_server_admin
|
||||
from zerver.models.realms import get_org_type_display_name
|
|
@ -1,11 +1,80 @@
|
|||
from django import forms
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import render
|
||||
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 corporate.lib.support import get_realm_support_url
|
||||
from zerver.decorator import zulip_login_required
|
||||
from zerver.lib.request import has_request_variables
|
||||
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 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.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):
|
||||
|
@ -56,3 +125,524 @@ def support_request(request: HttpRequest) -> HttpResponse:
|
|||
|
||||
response = render(request, "corporate/support_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: "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,
|
||||
)
|
||||
|
|
|
@ -1,18 +1,14 @@
|
|||
from typing import Any, List
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import QuerySet
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
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.models import UserActivity, UserProfile
|
||||
from zerver.models.users import get_user_profile_by_id
|
||||
|
||||
if settings.BILLING_ENABLED:
|
||||
pass
|
||||
|
||||
|
||||
def get_user_activity_records(
|
||||
user_profile: UserProfile,
|
|
@ -49,14 +49,14 @@ not_yet_fully_covered = [
|
|||
# runs before tests.
|
||||
"analytics/lib/fixtures.py",
|
||||
# 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/support.py",
|
||||
# TODO: This is a work in progress and therefore without
|
||||
# tests yet.
|
||||
"corporate/views/installation_activity.py",
|
||||
"corporate/views/realm_activity.py",
|
||||
"corporate/views/remote_billing_page.py",
|
||||
"corporate/views/support.py",
|
||||
"corporate/lib/activity.py",
|
||||
"corporate/lib/remote_billing_util.py",
|
||||
# Major lib files should have 100% coverage
|
||||
"zerver/lib/addressee.py",
|
||||
|
|
Loading…
Reference in New Issue