corporate: Move support and activity views to /corporate.

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

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

View File

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

View File

@@ -1,357 +0,0 @@
from collections import defaultdict
from typing import Dict, Optional
from django.conf import settings
from django.db import connection
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.template import loader
from django.utils.timezone import now as timezone_now
from markupsafe import Markup
from psycopg2.sql import SQL
from analytics.lib.counts import COUNT_STATS
from analytics.views.activity_common import (
dictfetchall,
fix_rows,
format_date_for_activity_reports,
get_query_data,
make_table,
realm_activity_link,
realm_stats_link,
realm_support_link,
realm_url_link,
)
from analytics.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
# get the start_time, since the hour that starts at midnight was
# on the previous day.
query = SQL(
"""
select
r.string_id,
(now()::date - (end_time - interval '1 hour')::date) age,
coalesce(sum(value), 0) cnt
from zerver_realm r
join analytics_realmcount rc on r.id = rc.realm_id
where
property = 'messages_sent:is_bot:hour'
and
subgroup = 'false'
and
end_time > now()::date - interval '8 day' - interval '1 hour'
group by
r.string_id,
age
"""
)
cursor = connection.cursor()
cursor.execute(query)
rows = dictfetchall(cursor)
cursor.close()
counts: Dict[str, Dict[int, int]] = defaultdict(dict)
for row in rows:
counts[row["string_id"]][row["age"]] = row["cnt"]
def format_count(cnt: int, style: Optional[str] = None) -> Markup:
if style is not None:
good_bad = style
elif cnt == min_cnt:
good_bad = "bad"
elif cnt == max_cnt:
good_bad = "good"
else:
good_bad = "neutral"
return Markup('<td class="number {good_bad}">{cnt}</td>').format(good_bad=good_bad, cnt=cnt)
result = {}
for string_id in counts:
raw_cnts = [counts[string_id].get(age, 0) for age in range(8)]
min_cnt = min(raw_cnts[1:])
max_cnt = max(raw_cnts[1:])
cnts = format_count(raw_cnts[0], "neutral") + Markup().join(map(format_count, raw_cnts[1:]))
result[string_id] = dict(cnts=cnts)
return result
def realm_summary_table() -> str:
now = timezone_now()
query = SQL(
"""
SELECT
realm.string_id,
realm.date_created,
realm.plan_type,
realm.org_type,
coalesce(wau_table.value, 0) wau_count,
coalesce(dau_table.value, 0) dau_count,
coalesce(user_count_table.value, 0) user_profile_count,
coalesce(bot_count_table.value, 0) bot_count
FROM
zerver_realm as realm
LEFT OUTER JOIN (
SELECT
value _14day_active_humans,
realm_id
from
analytics_realmcount
WHERE
property = 'realm_active_humans::day'
AND end_time = %(realm_active_humans_end_time)s
) as _14day_active_humans_table ON realm.id = _14day_active_humans_table.realm_id
LEFT OUTER JOIN (
SELECT
value,
realm_id
from
analytics_realmcount
WHERE
property = '7day_actives::day'
AND end_time = %(seven_day_actives_end_time)s
) as wau_table ON realm.id = wau_table.realm_id
LEFT OUTER JOIN (
SELECT
value,
realm_id
from
analytics_realmcount
WHERE
property = '1day_actives::day'
AND end_time = %(one_day_actives_end_time)s
) as dau_table ON realm.id = dau_table.realm_id
LEFT OUTER JOIN (
SELECT
value,
realm_id
from
analytics_realmcount
WHERE
property = 'active_users_audit:is_bot:day'
AND subgroup = 'false'
AND end_time = %(active_users_audit_end_time)s
) as user_count_table ON realm.id = user_count_table.realm_id
LEFT OUTER JOIN (
SELECT
value,
realm_id
from
analytics_realmcount
WHERE
property = 'active_users_audit:is_bot:day'
AND subgroup = 'true'
AND end_time = %(active_users_audit_end_time)s
) as bot_count_table ON realm.id = bot_count_table.realm_id
WHERE
_14day_active_humans IS NOT NULL
or realm.plan_type = 3
ORDER BY
dau_count DESC,
string_id ASC
"""
)
cursor = connection.cursor()
cursor.execute(
query,
{
"realm_active_humans_end_time": COUNT_STATS[
"realm_active_humans::day"
].last_successful_fill(),
"seven_day_actives_end_time": COUNT_STATS["7day_actives::day"].last_successful_fill(),
"one_day_actives_end_time": COUNT_STATS["1day_actives::day"].last_successful_fill(),
"active_users_audit_end_time": COUNT_STATS[
"active_users_audit:is_bot:day"
].last_successful_fill(),
},
)
rows = dictfetchall(cursor)
cursor.close()
for row in rows:
row["date_created_day"] = row["date_created"].strftime("%Y-%m-%d")
row["age_days"] = int((now - row["date_created"]).total_seconds() / 86400)
row["is_new"] = row["age_days"] < 12 * 7
# get messages sent per day
counts = get_realm_day_counts()
for row in rows:
try:
row["history"] = counts[row["string_id"]]["cnts"]
except Exception:
row["history"] = ""
# estimate annual subscription revenue
total_arr = 0
if settings.BILLING_ENABLED:
estimated_arrs = estimate_annual_recurring_revenue_by_realm()
realms_with_default_discount = get_realms_with_default_discount_dict()
for row in rows:
row["plan_type_string"] = get_plan_type_string(row["plan_type"])
string_id = row["string_id"]
if string_id in estimated_arrs:
row["arr"] = f"${cents_to_dollar_string(estimated_arrs[string_id])}"
if row["plan_type"] in [Realm.PLAN_TYPE_STANDARD, Realm.PLAN_TYPE_PLUS]:
row["effective_rate"] = 100 - int(realms_with_default_discount.get(string_id, 0))
elif row["plan_type"] == Realm.PLAN_TYPE_STANDARD_FREE:
row["effective_rate"] = 0
elif (
row["plan_type"] == Realm.PLAN_TYPE_LIMITED
and string_id in realms_with_default_discount
):
row["effective_rate"] = 100 - int(realms_with_default_discount[string_id])
else:
row["effective_rate"] = ""
total_arr += sum(estimated_arrs.values())
for row in rows:
row["org_type_string"] = get_org_type_display_name(row["org_type"])
# formatting
for row in rows:
row["realm_url"] = realm_url_link(row["string_id"])
row["stats_link"] = realm_stats_link(row["string_id"])
row["support_link"] = realm_support_link(row["string_id"])
row["string_id"] = realm_activity_link(row["string_id"])
# Count active sites
num_active_sites = sum(row["dau_count"] >= 5 for row in rows)
# create totals
total_dau_count = 0
total_user_profile_count = 0
total_bot_count = 0
total_wau_count = 0
for row in rows:
total_dau_count += int(row["dau_count"])
total_user_profile_count += int(row["user_profile_count"])
total_bot_count += int(row["bot_count"])
total_wau_count += int(row["wau_count"])
total_row = [
"Total",
"",
"",
"",
f"${cents_to_dollar_string(total_arr)}",
"",
"",
total_dau_count,
total_wau_count,
total_user_profile_count,
total_bot_count,
"",
"",
"",
"",
"",
"",
"",
"",
"",
]
content = loader.render_to_string(
"analytics/realm_summary_table.html",
dict(
rows=rows,
totals=total_row,
num_active_sites=num_active_sites,
utctime=now.strftime("%Y-%m-%d %H:%M %Z"),
billing_enabled=settings.BILLING_ENABLED,
),
)
return content
@require_server_admin
@has_request_variables
def get_installation_activity(request: HttpRequest) -> HttpResponse:
content: str = realm_summary_table()
title = "Installation activity"
return render(
request,
"analytics/activity_details_template.html",
context=dict(data=content, title=title, is_home=True),
)
@require_server_admin
def get_integrations_activity(request: HttpRequest) -> HttpResponse:
title = "Integrations by client"
query = SQL(
"""
select
case
when query like '%%external%%' then split_part(query, '/', 5)
else client.name
end client_name,
realm.string_id,
sum(count) as hits,
max(last_visit) as last_time
from zerver_useractivity ua
join zerver_client client on client.id = ua.client_id
join zerver_userprofile up on up.id = ua.user_profile_id
join zerver_realm realm on realm.id = up.realm_id
where
(query in ('send_message_backend', '/api/v1/send_message')
and client.name not in ('Android', 'ZulipiOS')
and client.name not like 'test: Zulip%%'
)
or
query like '%%external%%'
group by client_name, string_id
having max(last_visit) > now() - interval '2 week'
order by client_name, string_id
"""
)
cols = [
"Client",
"Realm",
"Hits",
"Last time",
]
rows = get_query_data(query)
for i, col in enumerate(cols):
if col == "Realm":
fix_rows(rows, i, realm_activity_link)
elif col == "Last time":
fix_rows(rows, i, format_date_for_activity_reports)
content = make_table(title, cols, rows)
return render(
request,
"analytics/activity_details_template.html",
context=dict(
data=content,
title=title,
is_home=False,
),
)

View File

@@ -1,173 +0,0 @@
import itertools
import re
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Collection, Dict, Optional, Set
from django.db.models import QuerySet
from django.http import HttpRequest, HttpResponse, HttpResponseNotFound
from django.shortcuts import render
from django.utils.timezone import now as timezone_now
from markupsafe import Markup
from analytics.views.activity_common import (
format_date_for_activity_reports,
make_table,
realm_stats_link,
user_activity_link,
)
from zerver.decorator import require_server_admin
from zerver.models import Realm, UserActivity
from zerver.models.users import UserProfile
@dataclass
class UserActivitySummary:
user_name: str
user_id: int
user_type: str
messages_sent: int
last_heard_from: Optional[datetime]
last_message_sent: Optional[datetime]
def get_user_activity_records_for_realm(realm: str) -> QuerySet[UserActivity]:
fields = [
"user_profile__full_name",
"user_profile__delivery_email",
"user_profile__is_bot",
"user_profile__bot_type",
"query",
"count",
"last_visit",
]
records = (
UserActivity.objects.filter(
user_profile__realm__string_id=realm,
user_profile__is_active=True,
)
.order_by("user_profile__delivery_email", "-last_visit")
.select_related("user_profile")
.only(*fields)
)
return records
def get_user_activity_summary(records: Collection[UserActivity]) -> UserActivitySummary:
if records:
first_record = next(iter(records))
name = first_record.user_profile.full_name
user_profile_id = first_record.user_profile.id
if not first_record.user_profile.is_bot:
user_type = "Human"
else:
assert first_record.user_profile.bot_type is not None
bot_type = first_record.user_profile.bot_type
user_type = UserProfile.BOT_TYPES[bot_type]
messages = 0
heard_from: Optional[datetime] = None
last_sent: Optional[datetime] = None
for record in records:
query = str(record.query)
visit = record.last_visit
if heard_from is None:
heard_from = visit
else:
heard_from = max(visit, heard_from)
if ("send_message" in query) or re.search("/api/.*/external/.*", query):
messages += 1
if last_sent is None:
last_sent = visit
else:
last_sent = max(visit, last_sent)
return UserActivitySummary(
user_name=name,
user_id=user_profile_id,
user_type=user_type,
messages_sent=messages,
last_heard_from=heard_from,
last_message_sent=last_sent,
)
def realm_user_summary_table(
all_records: QuerySet[UserActivity], admin_emails: Set[str], title: str, stats_link: Markup
) -> str:
user_records: Dict[str, UserActivitySummary] = {}
def by_email(record: UserActivity) -> str:
return record.user_profile.delivery_email
for email, records in itertools.groupby(all_records, by_email):
user_records[email] = get_user_activity_summary(list(records))
def is_recent(val: datetime) -> bool:
age = timezone_now() - val
return age.total_seconds() < 5 * 60
cols = [
"Name",
"Email",
"User type",
"Messages sent",
"Last heard from",
"Last message sent",
]
rows = []
for email, user_summary in user_records.items():
email_link = user_activity_link(email, user_summary.user_id)
cells = [
user_summary.user_name,
email_link,
user_summary.user_type,
user_summary.messages_sent,
]
cells.append(format_date_for_activity_reports(user_summary.last_heard_from))
cells.append(format_date_for_activity_reports(user_summary.last_message_sent))
row_class = ""
if user_summary.last_heard_from and is_recent(user_summary.last_heard_from):
row_class += " recently_active"
if email in admin_emails:
row_class += " admin"
row = dict(cells=cells, row_class=row_class)
rows.append(row)
def by_last_heard_from(row: Dict[str, Any]) -> str:
return row["cells"][4]
rows = sorted(rows, key=by_last_heard_from, reverse=True)
content = make_table(title, cols, rows, stats_link=stats_link, has_row_class=True)
return content
@require_server_admin
def get_realm_activity(request: HttpRequest, realm_str: str) -> HttpResponse:
try:
admins = Realm.objects.get(string_id=realm_str).get_human_admin_users()
except Realm.DoesNotExist:
return HttpResponseNotFound()
admin_emails = {admin.delivery_email for admin in admins}
all_records = get_user_activity_records_for_realm(realm_str)
realm_stats = realm_stats_link(realm_str)
title = realm_str
content = realm_user_summary_table(all_records, admin_emails, title, realm_stats)
return render(
request,
"analytics/activity_details_template.html",
context=dict(
data=content,
title=title,
is_home=False,
),
)

View File

@@ -1,223 +0,0 @@
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from psycopg2.sql import SQL
from analytics.views.activity_common import (
fix_rows,
format_date_for_activity_reports,
format_none_as_zero,
get_query_data,
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
from zilencer.models import get_remote_customer_user_count
@require_server_admin
def get_remote_server_activity(request: HttpRequest) -> HttpResponse:
title = "Remote servers"
query = SQL(
"""
with mobile_push_forwarded_count as (
select
server_id,
sum(coalesce(value, 0)) as push_forwarded_count
from zilencer_remoteinstallationcount
where
property = 'mobile_pushes_forwarded::day'
and end_time >= current_timestamp(0) - interval '7 days'
group by server_id
),
remote_push_devices as (
select
server_id,
count(distinct(user_id, user_uuid)) as push_user_count
from zilencer_remotepushdevicetoken
group by server_id
),
remote_realms as (
select
server_id,
id as realm_id,
name as realm_name,
org_type as realm_type,
host as realm_host
from zilencer_remoterealm
where
is_system_bot_realm = False
and realm_deactivated = False
group by server_id, id, name, org_type
)
select
rserver.id,
realm_id,
realm_name,
rserver.hostname,
realm_host,
rserver.contact_email,
rserver.last_version,
rserver.last_audit_log_update,
push_user_count,
push_forwarded_count,
realm_type
from zilencer_remotezulipserver rserver
left join mobile_push_forwarded_count on mobile_push_forwarded_count.server_id = rserver.id
left join remote_push_devices on remote_push_devices.server_id = rserver.id
left join remote_realms on remote_realms.server_id = rserver.id
where not deactivated
order by push_user_count DESC NULLS LAST
"""
)
cols = [
"IDs",
"Realm name",
"Realm host or server hostname",
"Server contact email",
"Server Zulip version",
"Server last audit log update",
"Server mobile users",
"Server mobile pushes",
"Realm organization type",
"Plan name",
"Plan status",
"ARR",
"Total users",
"Guest users",
"Links",
]
# If the query or column order above changes, update the constants below
SERVER_AND_REALM_IDS = 0
SERVER_HOST = 2
REALM_HOST = 3
LAST_AUDIT_LOG_DATE = 5
MOBILE_USER_COUNT = 6
MOBILE_PUSH_COUNT = 7
ORG_TYPE = 8
ARR = 11
TOTAL_USER_COUNT = 12
GUEST_COUNT = 13
rows = get_query_data(query)
plan_data_by_remote_server = get_plan_data_by_remote_server()
plan_data_by_remote_server_and_realm = get_plan_data_by_remote_realm()
audit_logs_by_remote_server = get_remote_server_audit_logs()
remote_realm_user_counts = get_remote_realm_user_counts()
total_row = []
remote_server_mobile_data_counted = set()
total_revenue = 0
total_mobile_users = 0
total_pushes = 0
for row in rows:
# Create combined IDs column with server and realm IDs
server_id = row.pop(SERVER_AND_REALM_IDS)
realm_id = row.pop(SERVER_AND_REALM_IDS)
if realm_id is not None:
ids_string = f"{server_id}/{realm_id}"
else:
ids_string = f"{server_id}"
row.insert(SERVER_AND_REALM_IDS, ids_string)
# Get server_host for support link
# For remote realm row, remove server hostname value;
# for remote server row, remove None realm host value
if realm_id is not None:
server_host = row.pop(SERVER_HOST)
else:
row.pop(REALM_HOST)
server_host = row[SERVER_HOST]
# Count mobile users and pushes forwarded, once per server
if server_id not in remote_server_mobile_data_counted:
if row[MOBILE_USER_COUNT] is not None:
total_mobile_users += row[MOBILE_USER_COUNT] # nocoverage
if row[MOBILE_PUSH_COUNT] is not None:
total_pushes += row[MOBILE_PUSH_COUNT] # nocoverage
remote_server_mobile_data_counted.add(server_id)
# Get plan, revenue and user count data for row
if realm_id is None:
plan_data = plan_data_by_remote_server.get(server_id)
audit_log_list = audit_logs_by_remote_server.get(server_id)
if audit_log_list is None:
user_counts = None # nocoverage
else:
user_counts = get_remote_customer_user_count(audit_log_list)
else:
server_remote_realms_data = plan_data_by_remote_server_and_realm.get(server_id)
if server_remote_realms_data is not None:
plan_data = server_remote_realms_data.get(realm_id)
else:
plan_data = None # nocoverage
user_counts = remote_realm_user_counts.get(realm_id)
# Format organization type for realm
org_type = row[ORG_TYPE]
row[ORG_TYPE] = get_org_type_display_name(org_type)
# Add estimated annual revenue and plan data
if plan_data is None:
row.append("---")
row.append("---")
row.append("---")
else:
total_revenue += plan_data.annual_revenue
revenue = cents_to_dollar_string(plan_data.annual_revenue)
row.append(plan_data.current_plan_name)
row.append(plan_data.current_status)
row.append(f"${revenue}")
# Add user counts
if user_counts is None:
row.append(0)
row.append(0)
else:
total_users = user_counts.non_guest_user_count + user_counts.guest_user_count
row.append(total_users)
row.append(user_counts.guest_user_count)
# Add server links
stats = remote_installation_stats_link(server_id)
support = remote_installation_support_link(server_host)
links = stats + " " + support
row.append(links)
# Format column data and add total row
for i, col in enumerate(cols):
if i == LAST_AUDIT_LOG_DATE:
fix_rows(rows, i, format_date_for_activity_reports)
if i in [MOBILE_USER_COUNT, MOBILE_PUSH_COUNT]:
fix_rows(rows, i, format_none_as_zero)
if i == SERVER_AND_REALM_IDS:
total_row.append("Total")
elif i == MOBILE_USER_COUNT:
total_row.append(str(total_mobile_users))
elif i == MOBILE_PUSH_COUNT:
total_row.append(str(total_pushes))
elif i == ARR:
total_revenue_string = f"${cents_to_dollar_string(total_revenue)}"
total_row.append(total_revenue_string)
elif i in [TOTAL_USER_COUNT, GUEST_COUNT]:
total_row.append(str(sum(row[i] for row in rows if row[i] is not None)))
else:
total_row.append("")
content = make_table(title, cols, rows, totals=total_row)
return render(
request,
"analytics/activity_details_template.html",
context=dict(data=content, title=title, is_home=False),
)

View File

@@ -1,599 +0,0 @@
from contextlib import suppress
from dataclasses import dataclass
from datetime import timedelta
from decimal import Decimal
from typing import Any, Dict, Iterable, List, Optional, Union
from urllib.parse import urlencode, urlsplit
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
from django.db.models import Q
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils.timesince import timesince
from django.utils.timezone import now as timezone_now
from django.utils.translation import gettext as _
from analytics.views.activity_common import remote_installation_stats_link
from confirmation.models import Confirmation, confirmation_url
from confirmation.settings import STATUS_USED
from zerver.actions.create_realm import do_change_realm_subdomain
from zerver.actions.realm_settings import (
do_change_realm_org_type,
do_change_realm_plan_type,
do_deactivate_realm,
do_scrub_realm,
do_send_realm_reactivation_email,
)
from zerver.actions.users import do_delete_user_preserving_messages
from zerver.decorator import require_server_admin
from zerver.forms import check_subdomain_available
from zerver.lib.exceptions import JsonableError
from zerver.lib.realm_icon import realm_icon_url
from zerver.lib.request import REQ, has_request_variables
from zerver.lib.subdomains import get_subdomain_from_hostname
from zerver.lib.validator import (
check_bool,
check_date,
check_string_in,
to_decimal,
to_non_negative_int,
)
from zerver.models import (
MultiuseInvite,
PreregistrationRealm,
PreregistrationUser,
Realm,
RealmReactivationStatus,
UserProfile,
)
from zerver.models.realms import get_org_type_display_name, get_realm
from zerver.models.users import get_user_profile_by_id
from zerver.views.invite import get_invitee_emails_set
if settings.ZILENCER_ENABLED:
from zilencer.lib.remote_counts import MissingDataError, compute_max_monthly_messages
from zilencer.models import RemoteRealm, RemoteZulipServer
if settings.BILLING_ENABLED:
from corporate.lib.stripe import (
RealmBillingSession,
RemoteRealmBillingSession,
RemoteServerBillingSession,
SupportRequestError,
SupportType,
SupportViewRequest,
cents_to_dollar_string,
format_discount_percentage,
)
from corporate.lib.support import (
PlanData,
SupportData,
get_current_plan_data_for_support_view,
get_customer_discount_for_support_view,
get_data_for_support_view,
)
from corporate.models import CustomerPlan
def get_plan_type_string(plan_type: int) -> str:
return {
Realm.PLAN_TYPE_SELF_HOSTED: "Self-hosted",
Realm.PLAN_TYPE_LIMITED: "Limited",
Realm.PLAN_TYPE_STANDARD: "Standard",
Realm.PLAN_TYPE_STANDARD_FREE: "Standard free",
Realm.PLAN_TYPE_PLUS: "Plus",
RemoteZulipServer.PLAN_TYPE_SELF_MANAGED: "Self-managed",
RemoteZulipServer.PLAN_TYPE_SELF_MANAGED_LEGACY: CustomerPlan.name_from_tier(
CustomerPlan.TIER_SELF_HOSTED_LEGACY
),
RemoteZulipServer.PLAN_TYPE_COMMUNITY: "Community",
RemoteZulipServer.PLAN_TYPE_BASIC: "Basic",
RemoteZulipServer.PLAN_TYPE_BUSINESS: "Business",
RemoteZulipServer.PLAN_TYPE_ENTERPRISE: "Enterprise",
}[plan_type]
def get_confirmations(
types: List[int], object_ids: Iterable[int], hostname: Optional[str] = None
) -> List[Dict[str, Any]]:
lowest_datetime = timezone_now() - timedelta(days=30)
confirmations = Confirmation.objects.filter(
type__in=types, object_id__in=object_ids, date_sent__gte=lowest_datetime
)
confirmation_dicts = []
for confirmation in confirmations:
realm = confirmation.realm
content_object = confirmation.content_object
type = confirmation.type
expiry_date = confirmation.expiry_date
assert content_object is not None
if hasattr(content_object, "status"):
if content_object.status == STATUS_USED:
link_status = "Link has been used"
else:
link_status = "Link has not been used"
else:
link_status = ""
now = timezone_now()
if expiry_date is None:
expires_in = "Never"
elif now < expiry_date:
expires_in = timesince(now, expiry_date)
else:
expires_in = "Expired"
url = confirmation_url(confirmation.confirmation_key, realm, type)
confirmation_dicts.append(
{
"object": confirmation.content_object,
"url": url,
"type": type,
"link_status": link_status,
"expires_in": expires_in,
}
)
return confirmation_dicts
@dataclass
class PlanTierOption:
name: str
value: int
def get_remote_plan_tier_options() -> List[PlanTierOption]:
remote_plan_tiers = [
PlanTierOption("None", 0),
PlanTierOption(
CustomerPlan.name_from_tier(CustomerPlan.TIER_SELF_HOSTED_BASIC),
CustomerPlan.TIER_SELF_HOSTED_BASIC,
),
PlanTierOption(
CustomerPlan.name_from_tier(CustomerPlan.TIER_SELF_HOSTED_BUSINESS),
CustomerPlan.TIER_SELF_HOSTED_BUSINESS,
),
]
return remote_plan_tiers
VALID_MODIFY_PLAN_METHODS = [
"downgrade_at_billing_cycle_end",
"downgrade_now_without_additional_licenses",
"downgrade_now_void_open_invoices",
"upgrade_plan_tier",
]
VALID_STATUS_VALUES = [
"active",
"deactivated",
]
VALID_BILLING_MODALITY_VALUES = [
"send_invoice",
"charge_automatically",
]
@require_server_admin
@has_request_variables
def support(
request: HttpRequest,
realm_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
plan_type: Optional[int] = REQ(default=None, converter=to_non_negative_int),
discount: Optional[Decimal] = REQ(default=None, converter=to_decimal),
new_subdomain: Optional[str] = REQ(default=None),
status: Optional[str] = REQ(default=None, str_validator=check_string_in(VALID_STATUS_VALUES)),
billing_modality: Optional[str] = REQ(
default=None, str_validator=check_string_in(VALID_BILLING_MODALITY_VALUES)
),
sponsorship_pending: Optional[bool] = REQ(default=None, json_validator=check_bool),
approve_sponsorship: bool = REQ(default=False, json_validator=check_bool),
modify_plan: Optional[str] = REQ(
default=None, str_validator=check_string_in(VALID_MODIFY_PLAN_METHODS)
),
scrub_realm: bool = REQ(default=False, json_validator=check_bool),
delete_user_by_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
query: Optional[str] = REQ("q", default=None),
org_type: Optional[int] = REQ(default=None, converter=to_non_negative_int),
) -> HttpResponse:
context: Dict[str, Any] = {}
if "success_message" in request.session:
context["success_message"] = request.session["success_message"]
del request.session["success_message"]
acting_user = request.user
assert isinstance(acting_user, UserProfile)
if settings.BILLING_ENABLED and request.method == "POST":
# We check that request.POST only has two keys in it: The
# realm_id and a field to change.
keys = set(request.POST.keys())
if "csrfmiddlewaretoken" in keys:
keys.remove("csrfmiddlewaretoken")
if len(keys) != 2:
raise JsonableError(_("Invalid parameters"))
assert realm_id is not None
realm = Realm.objects.get(id=realm_id)
support_view_request = None
if approve_sponsorship:
support_view_request = SupportViewRequest(support_type=SupportType.approve_sponsorship)
elif sponsorship_pending is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_sponsorship_status,
sponsorship_status=sponsorship_pending,
)
elif discount is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.attach_discount,
discount=discount,
)
elif billing_modality is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_billing_modality,
billing_modality=billing_modality,
)
elif modify_plan is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.modify_plan,
plan_modification=modify_plan,
)
if modify_plan == "upgrade_plan_tier":
support_view_request["new_plan_tier"] = CustomerPlan.TIER_CLOUD_PLUS
elif plan_type is not None:
current_plan_type = realm.plan_type
do_change_realm_plan_type(realm, plan_type, acting_user=acting_user)
msg = f"Plan type of {realm.string_id} changed from {get_plan_type_string(current_plan_type)} to {get_plan_type_string(plan_type)} "
context["success_message"] = msg
elif org_type is not None:
current_realm_type = realm.org_type
do_change_realm_org_type(realm, org_type, acting_user=acting_user)
msg = f"Org type of {realm.string_id} changed from {get_org_type_display_name(current_realm_type)} to {get_org_type_display_name(org_type)} "
context["success_message"] = msg
elif new_subdomain is not None:
old_subdomain = realm.string_id
try:
check_subdomain_available(new_subdomain)
except ValidationError as error:
context["error_message"] = error.message
else:
do_change_realm_subdomain(realm, new_subdomain, acting_user=acting_user)
request.session["success_message"] = (
f"Subdomain changed from {old_subdomain} to {new_subdomain}"
)
return HttpResponseRedirect(
reverse("support") + "?" + urlencode({"q": new_subdomain})
)
elif status is not None:
if status == "active":
do_send_realm_reactivation_email(realm, acting_user=acting_user)
context["success_message"] = (
f"Realm reactivation email sent to admins of {realm.string_id}."
)
elif status == "deactivated":
do_deactivate_realm(realm, acting_user=acting_user)
context["success_message"] = f"{realm.string_id} deactivated."
elif scrub_realm:
do_scrub_realm(realm, acting_user=acting_user)
context["success_message"] = f"{realm.string_id} scrubbed."
elif delete_user_by_id:
user_profile_for_deletion = get_user_profile_by_id(delete_user_by_id)
user_email = user_profile_for_deletion.delivery_email
assert user_profile_for_deletion.realm == realm
do_delete_user_preserving_messages(user_profile_for_deletion)
context["success_message"] = f"{user_email} in {realm.subdomain} deleted."
if support_view_request is not None:
billing_session = RealmBillingSession(
user=acting_user, realm=realm, support_session=True
)
try:
success_message = billing_session.process_support_view_request(support_view_request)
context["success_message"] = success_message
except SupportRequestError as error:
context["error_message"] = error.msg
if query:
key_words = get_invitee_emails_set(query)
case_insensitive_users_q = Q()
for key_word in key_words:
case_insensitive_users_q |= Q(delivery_email__iexact=key_word)
users = set(UserProfile.objects.filter(case_insensitive_users_q))
realms = set(Realm.objects.filter(string_id__in=key_words))
for key_word in key_words:
try:
URLValidator()(key_word)
parse_result = urlsplit(key_word)
hostname = parse_result.hostname
assert hostname is not None
if parse_result.port:
hostname = f"{hostname}:{parse_result.port}"
subdomain = get_subdomain_from_hostname(hostname)
with suppress(Realm.DoesNotExist):
realms.add(get_realm(subdomain))
except ValidationError:
users.update(UserProfile.objects.filter(full_name__iexact=key_word))
# full_names can have , in them
users.update(UserProfile.objects.filter(full_name__iexact=query))
context["users"] = users
context["realms"] = realms
confirmations: List[Dict[str, Any]] = []
preregistration_user_ids = [
user.id for user in PreregistrationUser.objects.filter(email__in=key_words)
]
confirmations += get_confirmations(
[Confirmation.USER_REGISTRATION, Confirmation.INVITATION],
preregistration_user_ids,
hostname=request.get_host(),
)
preregistration_realm_ids = [
user.id for user in PreregistrationRealm.objects.filter(email__in=key_words)
]
confirmations += get_confirmations(
[Confirmation.REALM_CREATION],
preregistration_realm_ids,
hostname=request.get_host(),
)
multiuse_invite_ids = [
invite.id for invite in MultiuseInvite.objects.filter(realm__in=realms)
]
confirmations += get_confirmations([Confirmation.MULTIUSE_INVITE], multiuse_invite_ids)
realm_reactivation_status_objects = RealmReactivationStatus.objects.filter(realm__in=realms)
confirmations += get_confirmations(
[Confirmation.REALM_REACTIVATION], [obj.id for obj in realm_reactivation_status_objects]
)
context["confirmations"] = confirmations
# We want a union of all realms that might appear in the search result,
# but not necessary as a separate result item.
# Therefore, we do not modify the realms object in the context.
all_realms = realms.union(
[
confirmation["object"].realm
for confirmation in confirmations
# For confirmations, we only display realm details when the type is USER_REGISTRATION
# or INVITATION.
if confirmation["type"] in (Confirmation.USER_REGISTRATION, Confirmation.INVITATION)
]
+ [user.realm for user in users]
)
plan_data: Dict[int, PlanData] = {}
for realm in all_realms:
billing_session = RealmBillingSession(user=None, realm=realm)
realm_plan_data = get_current_plan_data_for_support_view(billing_session)
plan_data[realm.id] = realm_plan_data
context["plan_data"] = plan_data
def get_realm_owner_emails_as_string(realm: Realm) -> str:
return ", ".join(
realm.get_human_owner_users()
.order_by("delivery_email")
.values_list("delivery_email", flat=True)
)
def get_realm_admin_emails_as_string(realm: Realm) -> str:
return ", ".join(
realm.get_human_admin_users(include_realm_owners=False)
.order_by("delivery_email")
.values_list("delivery_email", flat=True)
)
context["get_realm_owner_emails_as_string"] = get_realm_owner_emails_as_string
context["get_realm_admin_emails_as_string"] = get_realm_admin_emails_as_string
context["get_discount"] = get_customer_discount_for_support_view
context["get_org_type_display_name"] = get_org_type_display_name
context["format_discount"] = format_discount_percentage
context["dollar_amount"] = cents_to_dollar_string
context["realm_icon_url"] = realm_icon_url
context["Confirmation"] = Confirmation
context["sorted_realm_types"] = sorted(
Realm.ORG_TYPES.values(), key=lambda d: d["display_order"]
)
return render(request, "analytics/support.html", context=context)
def get_remote_servers_for_support(
email_to_search: Optional[str], hostname_to_search: Optional[str]
) -> List["RemoteZulipServer"]:
if not email_to_search and not hostname_to_search:
return []
remote_servers_query = (
RemoteZulipServer.objects.order_by("id")
.exclude(deactivated=True)
.prefetch_related("remoterealm_set")
)
if email_to_search:
remote_servers_query = remote_servers_query.filter(contact_email__iexact=email_to_search)
elif hostname_to_search:
remote_servers_query = remote_servers_query.filter(hostname__icontains=hostname_to_search)
return list(remote_servers_query)
@require_server_admin
@has_request_variables
def remote_servers_support(
request: HttpRequest,
query: Optional[str] = REQ("q", default=None),
remote_server_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
remote_realm_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
discount: Optional[Decimal] = REQ(default=None, converter=to_decimal),
minimum_licenses: Optional[int] = REQ(default=None, converter=to_non_negative_int),
required_plan_tier: Optional[int] = REQ(default=None, converter=to_non_negative_int),
fixed_price: Optional[int] = REQ(default=None, converter=to_non_negative_int),
sponsorship_pending: Optional[bool] = REQ(default=None, json_validator=check_bool),
approve_sponsorship: bool = REQ(default=False, json_validator=check_bool),
billing_modality: Optional[str] = REQ(
default=None, str_validator=check_string_in(VALID_BILLING_MODALITY_VALUES)
),
plan_end_date: Optional[str] = REQ(default=None, str_validator=check_date),
modify_plan: Optional[str] = REQ(
default=None, str_validator=check_string_in(VALID_MODIFY_PLAN_METHODS)
),
) -> HttpResponse:
context: Dict[str, Any] = {}
if "success_message" in request.session:
context["success_message"] = request.session["success_message"]
del request.session["success_message"]
acting_user = request.user
assert isinstance(acting_user, UserProfile)
if settings.BILLING_ENABLED and request.method == "POST":
# We check that request.POST only has two keys in it:
# either the remote_server_id or a remote_realm_id,
# and a field to change.
keys = set(request.POST.keys())
if "csrfmiddlewaretoken" in keys:
keys.remove("csrfmiddlewaretoken")
if len(keys) != 2:
raise JsonableError(_("Invalid parameters"))
if remote_realm_id is not None:
remote_realm_support_request = True
remote_realm = RemoteRealm.objects.get(id=remote_realm_id)
else:
assert remote_server_id is not None
remote_realm_support_request = False
remote_server = RemoteZulipServer.objects.get(id=remote_server_id)
support_view_request = None
if approve_sponsorship:
support_view_request = SupportViewRequest(support_type=SupportType.approve_sponsorship)
elif sponsorship_pending is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_sponsorship_status,
sponsorship_status=sponsorship_pending,
)
elif discount is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.attach_discount,
discount=discount,
)
elif minimum_licenses is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_minimum_licenses,
minimum_licenses=minimum_licenses,
)
elif required_plan_tier is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_required_plan_tier,
required_plan_tier=required_plan_tier,
)
elif fixed_price is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.configure_fixed_price_plan,
fixed_price=fixed_price,
)
elif billing_modality is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_billing_modality,
billing_modality=billing_modality,
)
elif plan_end_date is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_plan_end_date,
plan_end_date=plan_end_date,
)
elif modify_plan is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.modify_plan,
plan_modification=modify_plan,
)
if support_view_request is not None:
if remote_realm_support_request:
try:
success_message = RemoteRealmBillingSession(
support_staff=acting_user, remote_realm=remote_realm
).process_support_view_request(support_view_request)
context["success_message"] = success_message
except SupportRequestError as error:
context["error_message"] = error.msg
else:
try:
success_message = RemoteServerBillingSession(
support_staff=acting_user, remote_server=remote_server
).process_support_view_request(support_view_request)
context["success_message"] = success_message
except SupportRequestError as error:
context["error_message"] = error.msg
email_to_search = None
hostname_to_search = None
if query:
if "@" in query:
email_to_search = query
else:
hostname_to_search = query
remote_servers = get_remote_servers_for_support(
email_to_search=email_to_search, hostname_to_search=hostname_to_search
)
remote_server_to_max_monthly_messages: Dict[int, Union[int, str]] = dict()
server_support_data: Dict[int, SupportData] = {}
realm_support_data: Dict[int, SupportData] = {}
remote_realms: Dict[int, List[RemoteRealm]] = {}
for remote_server in remote_servers:
# Get remote realms attached to remote server
remote_realms_for_server = list(
remote_server.remoterealm_set.exclude(is_system_bot_realm=True)
)
remote_realms[remote_server.id] = remote_realms_for_server
# Get plan data for remote realms
for remote_realm in remote_realms[remote_server.id]:
realm_billing_session = RemoteRealmBillingSession(remote_realm=remote_realm)
remote_realm_data = get_data_for_support_view(realm_billing_session)
realm_support_data[remote_realm.id] = remote_realm_data
# Get plan data for remote server
server_billing_session = RemoteServerBillingSession(remote_server=remote_server)
remote_server_data = get_data_for_support_view(server_billing_session)
server_support_data[remote_server.id] = remote_server_data
# Get max monthly messages
try:
remote_server_to_max_monthly_messages[remote_server.id] = compute_max_monthly_messages(
remote_server
)
except MissingDataError:
remote_server_to_max_monthly_messages[remote_server.id] = (
"Recent analytics data missing"
)
context["remote_servers"] = remote_servers
context["remote_servers_support_data"] = server_support_data
context["remote_server_to_max_monthly_messages"] = remote_server_to_max_monthly_messages
context["remote_realms"] = remote_realms
context["remote_realms_support_data"] = realm_support_data
context["get_plan_type_name"] = get_plan_type_string
context["get_org_type_display_name"] = get_org_type_display_name
context["format_discount"] = format_discount_percentage
context["dollar_amount"] = cents_to_dollar_string
context["server_analytics_link"] = remote_installation_stats_link
context["REMOTE_PLAN_TIERS"] = get_remote_plan_tier_options()
context["SPONSORED_PLAN_TYPE"] = RemoteZulipServer.PLAN_TYPE_COMMUNITY
return render(
request,
"analytics/remote_server_support.html",
context=context,
)

View File

@@ -1,70 +0,0 @@
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 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,
) -> QuerySet[UserActivity]:
fields = [
"query",
"client__name",
"count",
"last_visit",
]
records = (
UserActivity.objects.filter(
user_profile=user_profile,
)
.order_by("-last_visit")
.select_related("client")
.only(*fields)
)
return records
@require_server_admin
def get_user_activity(request: HttpRequest, user_profile_id: int) -> HttpResponse:
user_profile = get_user_profile_by_id(user_profile_id)
records = get_user_activity_records(user_profile)
cols = [
"Query",
"Client",
"Count",
"Last visit",
]
def row(record: UserActivity) -> List[Any]:
return [
record.query,
record.client.name,
record.count,
format_date_for_activity_reports(record.last_visit),
]
rows = list(map(row, records))
title = f"{user_profile.delivery_email} ({user_profile.realm.name})"
content = make_table(title, cols, rows)
return render(
request,
"analytics/activity_details_template.html",
context=dict(
data=content,
title=title,
is_home=False,
),
)