From df2f4b6469fa103430939ffa67b8bd18e19619ce Mon Sep 17 00:00:00 2001 From: Lauryn Menard Date: Mon, 29 Jan 2024 13:47:19 +0100 Subject: [PATCH] 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`. --- analytics/urls.py | 16 - analytics/views/activity_common.py | 139 ---- analytics/views/support.py | 599 ----------------- corporate/lib/{analytics.py => activity.py} | 133 +++- .../tests/test_activity_views.py | 2 +- corporate/tests/test_stripe.py | 2 +- .../tests/test_support_views.py | 24 +- corporate/urls.py | 17 +- .../views/installation_activity.py | 14 +- .../views/realm_activity.py | 2 +- .../views/remote_activity.py | 12 +- corporate/views/support.py | 602 +++++++++++++++++- .../views/user_activity.py | 6 +- tools/test-backend | 8 +- 14 files changed, 774 insertions(+), 802 deletions(-) delete mode 100644 analytics/views/activity_common.py delete mode 100644 analytics/views/support.py rename corporate/lib/{analytics.py => activity.py} (71%) rename {analytics => corporate}/tests/test_activity_views.py (99%) rename {analytics => corporate}/tests/test_support_views.py (98%) rename {analytics => corporate}/views/installation_activity.py (97%) rename {analytics => corporate}/views/realm_activity.py (99%) rename {analytics => corporate}/views/remote_activity.py (98%) rename {analytics => corporate}/views/user_activity.py (90%) diff --git a/analytics/urls.py b/analytics/urls.py index d6975467ba..0a68f49c97 100644 --- a/analytics/urls.py +++ b/analytics/urls.py @@ -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//", get_realm_activity), - path("user_activity//", get_user_activity), path("stats/realm//", 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//installation", stats_for_remote_installation), path( "stats/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: diff --git a/analytics/views/activity_common.py b/analytics/views/activity_common.py deleted file mode 100644 index 60a1c4c02c..0000000000 --- a/analytics/views/activity_common.py +++ /dev/null @@ -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('{email}').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('{realm_str}').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('').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('').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('').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('').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('').format(url=url) diff --git a/analytics/views/support.py b/analytics/views/support.py deleted file mode 100644 index 182f85bfa2..0000000000 --- a/analytics/views/support.py +++ /dev/null @@ -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, - ) diff --git a/corporate/lib/analytics.py b/corporate/lib/activity.py similarity index 71% rename from corporate/lib/analytics.py rename to corporate/lib/activity.py index c006edf25d..b6ddb91bb9 100644 --- a/corporate/lib/analytics.py +++ b/corporate/lib/activity.py @@ -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('{email}').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('{realm_str}').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('').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('').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('').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('').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('').format(url=url) + + def get_realms_with_default_discount_dict() -> Dict[str, Decimal]: realms_with_default_discount: Dict[str, Any] = {} customers = ( diff --git a/analytics/tests/test_activity_views.py b/corporate/tests/test_activity_views.py similarity index 99% rename from analytics/tests/test_activity_views.py rename to corporate/tests/test_activity_views.py index b18bf397f4..2828f74649 100644 --- a/analytics/tests/test_activity_views.py +++ b/corporate/tests/test_activity_views.py @@ -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 diff --git a/corporate/tests/test_stripe.py b/corporate/tests/test_stripe.py index b1bb2cfc9c..c327fde962 100644 --- a/corporate/tests/test_stripe.py +++ b/corporate/tests/test_stripe.py @@ -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, diff --git a/analytics/tests/test_support_views.py b/corporate/tests/test_support_views.py similarity index 98% rename from analytics/tests/test_support_views.py rename to corporate/tests/test_support_views.py index c177d38cb8..53067b079b 100644 --- a/analytics/tests/test_support_views.py +++ b/corporate/tests/test_support_views.py @@ -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): "Licenses: 2/10 (Manual)", "Price per license: $6.00", "Annual recurring revenue: $720.00", - f"Start of next billing cycle: {biling_cycle_string}", + f"Start of next billing cycle: {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}, diff --git a/corporate/urls.py b/corporate/urls.py index ad9cf24647..c9a09f76b9 100644 --- a/corporate/urls.py +++ b/corporate/urls.py @@ -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//", get_realm_activity), + path("user_activity//", 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 = [ diff --git a/analytics/views/installation_activity.py b/corporate/views/installation_activity.py similarity index 97% rename from analytics/views/installation_activity.py rename to corporate/views/installation_activity.py index 68fd8ab72a..6a1b839940 100644 --- a/analytics/views/installation_activity.py +++ b/corporate/views/installation_activity.py @@ -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 diff --git a/analytics/views/realm_activity.py b/corporate/views/realm_activity.py similarity index 99% rename from analytics/views/realm_activity.py rename to corporate/views/realm_activity.py index 948b9500d3..a83e8249f7 100644 --- a/analytics/views/realm_activity.py +++ b/corporate/views/realm_activity.py @@ -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, diff --git a/analytics/views/remote_activity.py b/corporate/views/remote_activity.py similarity index 98% rename from analytics/views/remote_activity.py rename to corporate/views/remote_activity.py index 0dac8d2f54..74f5b78c34 100644 --- a/analytics/views/remote_activity.py +++ b/corporate/views/remote_activity.py @@ -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 diff --git a/corporate/views/support.py b/corporate/views/support.py index b3a5d8ef88..0dd19a6a82 100644 --- a/corporate/views/support.py +++ b/corporate/views/support.py @@ -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, + ) diff --git a/analytics/views/user_activity.py b/corporate/views/user_activity.py similarity index 90% rename from analytics/views/user_activity.py rename to corporate/views/user_activity.py index 61bd3fcbbb..4f46d784f8 100644 --- a/analytics/views/user_activity.py +++ b/corporate/views/user_activity.py @@ -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, diff --git a/tools/test-backend b/tools/test-backend index 4c3df886d0..a82c1731e7 100755 --- a/tools/test-backend +++ b/tools/test-backend @@ -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",