mirror of
				https://github.com/zulip/zulip.git
				synced 2025-10-31 03:53:50 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			655 lines
		
	
	
		
			22 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			655 lines
		
	
	
		
			22 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| import logging
 | |
| from collections import defaultdict
 | |
| from datetime import datetime, timedelta, timezone
 | |
| from typing import Annotated, Any, Optional, TypeAlias, TypeVar, cast
 | |
| 
 | |
| from django.conf import settings
 | |
| from django.db.models import QuerySet
 | |
| from django.http import HttpRequest, HttpResponse, HttpResponseNotFound
 | |
| from django.shortcuts import render
 | |
| from django.utils import translation
 | |
| from django.utils.timezone import now as timezone_now
 | |
| from django.utils.translation import gettext as _
 | |
| from pydantic import BeforeValidator, Json, NonNegativeInt
 | |
| 
 | |
| from analytics.lib.counts import COUNT_STATS, CountStat
 | |
| from analytics.lib.time_utils import time_range
 | |
| from analytics.models import (
 | |
|     BaseCount,
 | |
|     InstallationCount,
 | |
|     RealmCount,
 | |
|     StreamCount,
 | |
|     UserCount,
 | |
|     installation_epoch,
 | |
| )
 | |
| from zerver.decorator import (
 | |
|     require_non_guest_user,
 | |
|     require_server_admin,
 | |
|     require_server_admin_api,
 | |
|     to_utc_datetime,
 | |
|     zulip_login_required,
 | |
| )
 | |
| from zerver.lib.exceptions import JsonableError
 | |
| from zerver.lib.i18n import get_and_set_request_language, get_language_translation_data
 | |
| from zerver.lib.response import json_success
 | |
| from zerver.lib.streams import access_stream_by_id
 | |
| from zerver.lib.timestamp import convert_to_UTC
 | |
| from zerver.lib.typed_endpoint import PathOnly, typed_endpoint
 | |
| from zerver.models import Client, Realm, Stream, UserProfile
 | |
| from zerver.models.realms import get_realm
 | |
| 
 | |
| if settings.ZILENCER_ENABLED:
 | |
|     from zilencer.models import RemoteInstallationCount, RemoteRealmCount, RemoteZulipServer
 | |
| 
 | |
| MAX_TIME_FOR_FULL_ANALYTICS_GENERATION = timedelta(days=1, minutes=30)
 | |
| 
 | |
| 
 | |
| def is_analytics_ready(realm: Realm) -> bool:
 | |
|     return (timezone_now() - realm.date_created) > MAX_TIME_FOR_FULL_ANALYTICS_GENERATION
 | |
| 
 | |
| 
 | |
| def render_stats(
 | |
|     request: HttpRequest,
 | |
|     data_url_suffix: str,
 | |
|     realm: Realm | None,
 | |
|     *,
 | |
|     title: str | None = None,
 | |
|     analytics_ready: bool = True,
 | |
| ) -> HttpResponse:
 | |
|     assert request.user.is_authenticated
 | |
| 
 | |
|     if realm is not None:
 | |
|         # Same query to get guest user count as in get_seat_count in corporate/lib/stripe.py.
 | |
|         guest_users = UserProfile.objects.filter(
 | |
|             realm=realm, is_active=True, is_bot=False, role=UserProfile.ROLE_GUEST
 | |
|         ).count()
 | |
|         space_used = realm.currently_used_upload_space_bytes()
 | |
|         if title:
 | |
|             pass
 | |
|         else:
 | |
|             title = realm.name or realm.string_id
 | |
|     else:
 | |
|         assert title
 | |
|         guest_users = None
 | |
|         space_used = None
 | |
| 
 | |
|     request_language = get_and_set_request_language(
 | |
|         request,
 | |
|         request.user.default_language,
 | |
|         translation.get_language_from_path(request.path_info),
 | |
|     )
 | |
| 
 | |
|     # Sync this with stats_params_schema in base_page_params.ts.
 | |
|     page_params = dict(
 | |
|         page_type="stats",
 | |
|         data_url_suffix=data_url_suffix,
 | |
|         upload_space_used=space_used,
 | |
|         guest_users=guest_users,
 | |
|         translation_data=get_language_translation_data(request_language),
 | |
|     )
 | |
| 
 | |
|     return render(
 | |
|         request,
 | |
|         "analytics/stats.html",
 | |
|         context=dict(
 | |
|             target_name=title,
 | |
|             page_params=page_params,
 | |
|             analytics_ready=analytics_ready,
 | |
|         ),
 | |
|     )
 | |
| 
 | |
| 
 | |
| @zulip_login_required
 | |
| def stats(request: HttpRequest) -> HttpResponse:
 | |
|     assert request.user.is_authenticated
 | |
|     realm = request.user.realm
 | |
|     if request.user.is_guest:
 | |
|         # TODO: Make @zulip_login_required pass the UserProfile so we
 | |
|         # can use @require_member_or_admin
 | |
|         raise JsonableError(_("Not allowed for guest users"))
 | |
|     return render_stats(request, "", realm, analytics_ready=is_analytics_ready(realm))
 | |
| 
 | |
| 
 | |
| @require_server_admin
 | |
| @typed_endpoint
 | |
| def stats_for_realm(request: HttpRequest, *, realm_str: PathOnly[str]) -> HttpResponse:
 | |
|     try:
 | |
|         realm = get_realm(realm_str)
 | |
|     except Realm.DoesNotExist:
 | |
|         return HttpResponseNotFound()
 | |
| 
 | |
|     return render_stats(
 | |
|         request,
 | |
|         f"/realm/{realm_str}",
 | |
|         realm,
 | |
|         analytics_ready=is_analytics_ready(realm),
 | |
|     )
 | |
| 
 | |
| 
 | |
| @require_server_admin
 | |
| @typed_endpoint
 | |
| def stats_for_remote_realm(
 | |
|     request: HttpRequest, *, remote_server_id: PathOnly[int], remote_realm_id: PathOnly[int]
 | |
| ) -> HttpResponse:
 | |
|     assert settings.ZILENCER_ENABLED
 | |
|     server = RemoteZulipServer.objects.get(id=remote_server_id)
 | |
|     return render_stats(
 | |
|         request,
 | |
|         f"/remote/{server.id}/realm/{remote_realm_id}",
 | |
|         None,
 | |
|         title=f"Realm {remote_realm_id} on server {server.hostname}",
 | |
|     )
 | |
| 
 | |
| 
 | |
| @require_server_admin_api
 | |
| @typed_endpoint
 | |
| def get_chart_data_for_realm(
 | |
|     request: HttpRequest,
 | |
|     user_profile: UserProfile,
 | |
|     /,
 | |
|     *,
 | |
|     realm_str: PathOnly[str],
 | |
|     chart_name: str,
 | |
|     min_length: Json[NonNegativeInt] | None = None,
 | |
|     start: Annotated[datetime | None, BeforeValidator(to_utc_datetime)] = None,
 | |
|     end: Annotated[datetime | None, BeforeValidator(to_utc_datetime)] = None,
 | |
| ) -> HttpResponse:
 | |
|     try:
 | |
|         realm = get_realm(realm_str)
 | |
|     except Realm.DoesNotExist:
 | |
|         raise JsonableError(_("Invalid organization"))
 | |
| 
 | |
|     return do_get_chart_data(
 | |
|         request,
 | |
|         user_profile,
 | |
|         realm=realm,
 | |
|         chart_name=chart_name,
 | |
|         min_length=min_length,
 | |
|         start=start,
 | |
|         end=end,
 | |
|     )
 | |
| 
 | |
| 
 | |
| @require_non_guest_user
 | |
| @typed_endpoint
 | |
| def get_chart_data_for_stream(
 | |
|     request: HttpRequest,
 | |
|     user_profile: UserProfile,
 | |
|     *,
 | |
|     stream_id: PathOnly[int],
 | |
|     chart_name: str,
 | |
|     min_length: Json[NonNegativeInt] | None = None,
 | |
|     start: Annotated[datetime | None, BeforeValidator(to_utc_datetime)] = None,
 | |
|     end: Annotated[datetime | None, BeforeValidator(to_utc_datetime)] = None,
 | |
| ) -> HttpResponse:
 | |
|     stream, _sub = access_stream_by_id(
 | |
|         user_profile,
 | |
|         stream_id,
 | |
|         require_content_access=False,
 | |
|     )
 | |
| 
 | |
|     return do_get_chart_data(
 | |
|         request,
 | |
|         user_profile,
 | |
|         stream=stream,
 | |
|         chart_name=chart_name,
 | |
|         min_length=min_length,
 | |
|         start=start,
 | |
|         end=end,
 | |
|     )
 | |
| 
 | |
| 
 | |
| @require_server_admin_api
 | |
| @typed_endpoint
 | |
| def get_chart_data_for_remote_realm(
 | |
|     request: HttpRequest,
 | |
|     user_profile: UserProfile,
 | |
|     /,
 | |
|     *,
 | |
|     remote_server_id: PathOnly[int],
 | |
|     remote_realm_id: PathOnly[int],
 | |
|     chart_name: str,
 | |
|     min_length: Json[NonNegativeInt] | None = None,
 | |
|     start: Annotated[datetime | None, BeforeValidator(to_utc_datetime)] = None,
 | |
|     end: Annotated[datetime | None, BeforeValidator(to_utc_datetime)] = None,
 | |
| ) -> HttpResponse:
 | |
|     assert settings.ZILENCER_ENABLED
 | |
|     server = RemoteZulipServer.objects.get(id=remote_server_id)
 | |
|     return do_get_chart_data(
 | |
|         request,
 | |
|         user_profile,
 | |
|         server=server,
 | |
|         remote=True,
 | |
|         remote_realm_id=remote_realm_id,
 | |
|         chart_name=chart_name,
 | |
|         min_length=min_length,
 | |
|         start=start,
 | |
|         end=end,
 | |
|     )
 | |
| 
 | |
| 
 | |
| @require_server_admin
 | |
| def stats_for_installation(request: HttpRequest) -> HttpResponse:
 | |
|     assert request.user.is_authenticated
 | |
|     return render_stats(request, "/installation", None, title="installation")
 | |
| 
 | |
| 
 | |
| @require_server_admin
 | |
| def stats_for_remote_installation(request: HttpRequest, remote_server_id: int) -> HttpResponse:
 | |
|     assert settings.ZILENCER_ENABLED
 | |
|     server = RemoteZulipServer.objects.get(id=remote_server_id)
 | |
|     return render_stats(
 | |
|         request,
 | |
|         f"/remote/{server.id}/installation",
 | |
|         None,
 | |
|         title=f"remote installation {server.hostname}",
 | |
|     )
 | |
| 
 | |
| 
 | |
| @require_server_admin_api
 | |
| @typed_endpoint
 | |
| def get_chart_data_for_installation(
 | |
|     request: HttpRequest,
 | |
|     user_profile: UserProfile,
 | |
|     /,
 | |
|     *,
 | |
|     chart_name: str,
 | |
|     min_length: Json[NonNegativeInt] | None = None,
 | |
|     start: Annotated[datetime | None, BeforeValidator(to_utc_datetime)] = None,
 | |
|     end: Annotated[datetime | None, BeforeValidator(to_utc_datetime)] = None,
 | |
| ) -> HttpResponse:
 | |
|     return do_get_chart_data(
 | |
|         request,
 | |
|         user_profile,
 | |
|         for_installation=True,
 | |
|         chart_name=chart_name,
 | |
|         min_length=min_length,
 | |
|         start=start,
 | |
|         end=end,
 | |
|     )
 | |
| 
 | |
| 
 | |
| @require_server_admin_api
 | |
| @typed_endpoint
 | |
| def get_chart_data_for_remote_installation(
 | |
|     request: HttpRequest,
 | |
|     user_profile: UserProfile,
 | |
|     /,
 | |
|     *,
 | |
|     remote_server_id: PathOnly[int],
 | |
|     chart_name: str,
 | |
|     min_length: Json[NonNegativeInt] | None = None,
 | |
|     start: Annotated[datetime | None, BeforeValidator(to_utc_datetime)] = None,
 | |
|     end: Annotated[datetime | None, BeforeValidator(to_utc_datetime)] = None,
 | |
| ) -> HttpResponse:
 | |
|     assert settings.ZILENCER_ENABLED
 | |
|     server = RemoteZulipServer.objects.get(id=remote_server_id)
 | |
|     return do_get_chart_data(
 | |
|         request,
 | |
|         user_profile,
 | |
|         for_installation=True,
 | |
|         remote=True,
 | |
|         server=server,
 | |
|         chart_name=chart_name,
 | |
|         min_length=min_length,
 | |
|         start=start,
 | |
|         end=end,
 | |
|     )
 | |
| 
 | |
| 
 | |
| @require_non_guest_user
 | |
| @typed_endpoint
 | |
| def get_chart_data(
 | |
|     request: HttpRequest,
 | |
|     user_profile: UserProfile,
 | |
|     *,
 | |
|     chart_name: str,
 | |
|     min_length: Json[NonNegativeInt] | None = None,
 | |
|     start: Annotated[datetime | None, BeforeValidator(to_utc_datetime)] = None,
 | |
|     end: Annotated[datetime | None, BeforeValidator(to_utc_datetime)] = None,
 | |
| ) -> HttpResponse:
 | |
|     return do_get_chart_data(
 | |
|         request,
 | |
|         user_profile,
 | |
|         chart_name=chart_name,
 | |
|         min_length=min_length,
 | |
|         start=start,
 | |
|         end=end,
 | |
|     )
 | |
| 
 | |
| 
 | |
| @require_non_guest_user
 | |
| def do_get_chart_data(
 | |
|     request: HttpRequest,
 | |
|     user_profile: UserProfile,
 | |
|     *,
 | |
|     # Common parameters supported by all stats endpoints.
 | |
|     chart_name: str,
 | |
|     min_length: NonNegativeInt | None = None,
 | |
|     start: datetime | None = None,
 | |
|     end: datetime | None = None,
 | |
|     # The following parameters are only used by wrapping functions for
 | |
|     # various contexts; the callers are responsible for validating them.
 | |
|     realm: Realm | None = None,
 | |
|     for_installation: bool = False,
 | |
|     remote: bool = False,
 | |
|     remote_realm_id: int | None = None,
 | |
|     server: Optional["RemoteZulipServer"] = None,
 | |
|     stream: Stream | None = None,
 | |
| ) -> HttpResponse:
 | |
|     TableType: TypeAlias = (
 | |
|         type["RemoteInstallationCount"]
 | |
|         | type[InstallationCount]
 | |
|         | type["RemoteRealmCount"]
 | |
|         | type[RealmCount]
 | |
|     )
 | |
|     if for_installation:
 | |
|         if remote:
 | |
|             assert settings.ZILENCER_ENABLED
 | |
|             aggregate_table: TableType = RemoteInstallationCount
 | |
|             assert server is not None
 | |
|         else:
 | |
|             aggregate_table = InstallationCount
 | |
|     else:
 | |
|         if remote:
 | |
|             assert settings.ZILENCER_ENABLED
 | |
|             aggregate_table = RemoteRealmCount
 | |
|             assert server is not None
 | |
|             assert remote_realm_id is not None
 | |
|         else:
 | |
|             aggregate_table = RealmCount
 | |
| 
 | |
|     tables: (
 | |
|         tuple[TableType] | tuple[TableType, type[UserCount]] | tuple[TableType, type[StreamCount]]
 | |
|     )
 | |
| 
 | |
|     if chart_name == "number_of_humans":
 | |
|         stats = [
 | |
|             COUNT_STATS["1day_actives::day"],
 | |
|             COUNT_STATS["realm_active_humans::day"],
 | |
|             COUNT_STATS["active_users_audit:is_bot:day"],
 | |
|         ]
 | |
|         tables = (aggregate_table,)
 | |
|         subgroup_to_label: dict[CountStat, dict[str | None, str]] = {
 | |
|             stats[0]: {None: "_1day"},
 | |
|             stats[1]: {None: "_15day"},
 | |
|             stats[2]: {"false": "all_time"},
 | |
|         }
 | |
|         labels_sort_function = None
 | |
|         include_empty_subgroups = True
 | |
|     elif chart_name == "messages_sent_over_time":
 | |
|         stats = [COUNT_STATS["messages_sent:is_bot:hour"]]
 | |
|         tables = (aggregate_table, UserCount)
 | |
|         subgroup_to_label = {stats[0]: {"false": "human", "true": "bot"}}
 | |
|         labels_sort_function = None
 | |
|         include_empty_subgroups = True
 | |
|     elif chart_name == "messages_sent_by_message_type":
 | |
|         stats = [COUNT_STATS["messages_sent:message_type:day"]]
 | |
|         tables = (aggregate_table, UserCount)
 | |
|         subgroup_to_label = {
 | |
|             stats[0]: {
 | |
|                 "public_stream": _("Public channels"),
 | |
|                 "private_stream": _("Private channels"),
 | |
|                 "private_message": _("Direct messages"),
 | |
|                 "huddle_message": _("Group direct messages"),
 | |
|             }
 | |
|         }
 | |
|         labels_sort_function = lambda data: sort_by_totals(data["everyone"])
 | |
|         include_empty_subgroups = True
 | |
|     elif chart_name == "messages_sent_by_client":
 | |
|         stats = [COUNT_STATS["messages_sent:client:day"]]
 | |
|         tables = (aggregate_table, UserCount)
 | |
|         # Note that the labels are further re-written by client_label_map
 | |
|         subgroup_to_label = {
 | |
|             stats[0]: {str(id): name for id, name in Client.objects.values_list("id", "name")}
 | |
|         }
 | |
|         labels_sort_function = sort_client_labels
 | |
|         include_empty_subgroups = False
 | |
|     elif chart_name == "messages_read_over_time":
 | |
|         stats = [COUNT_STATS["messages_read::hour"]]
 | |
|         tables = (aggregate_table, UserCount)
 | |
|         subgroup_to_label = {stats[0]: {None: "read"}}
 | |
|         labels_sort_function = None
 | |
|         include_empty_subgroups = True
 | |
|     elif chart_name == "messages_sent_by_stream":
 | |
|         if stream is None:
 | |
|             raise JsonableError(
 | |
|                 _("Missing channel for chart: {chart_name}").format(chart_name=chart_name)
 | |
|             )
 | |
|         stats = [COUNT_STATS["messages_in_stream:is_bot:day"]]
 | |
|         tables = (aggregate_table, StreamCount)
 | |
|         subgroup_to_label = {stats[0]: {"false": "human", "true": "bot"}}
 | |
|         labels_sort_function = None
 | |
|         include_empty_subgroups = True
 | |
|     else:
 | |
|         raise JsonableError(_("Unknown chart name: {chart_name}").format(chart_name=chart_name))
 | |
| 
 | |
|     # Most likely someone using our API endpoint. The /stats page does not
 | |
|     # pass a start or end in its requests.
 | |
|     if start is not None:
 | |
|         start = convert_to_UTC(start)
 | |
|     if end is not None:
 | |
|         end = convert_to_UTC(end)
 | |
|     if start is not None and end is not None and start > end:
 | |
|         raise JsonableError(
 | |
|             _("Start time is later than end time. Start: {start}, End: {end}").format(
 | |
|                 start=start,
 | |
|                 end=end,
 | |
|             )
 | |
|         )
 | |
| 
 | |
|     if realm is None:
 | |
|         # Note that this value is invalid for Remote tables; be
 | |
|         # careful not to access it in those code paths.
 | |
|         realm = user_profile.realm
 | |
| 
 | |
|     if remote:
 | |
|         # For remote servers, we don't have fillstate data, and thus
 | |
|         # should simply use the first and last data points for the
 | |
|         # table.
 | |
|         assert server is not None
 | |
|         assert aggregate_table is RemoteInstallationCount or aggregate_table is RemoteRealmCount
 | |
|         aggregate_table_remote = cast(
 | |
|             type[RemoteInstallationCount] | type[RemoteRealmCount], aggregate_table
 | |
|         )  # https://stackoverflow.com/questions/68540528/mypy-assertions-on-the-types-of-types
 | |
|         if not aggregate_table_remote.objects.filter(server=server).exists():
 | |
|             raise JsonableError(
 | |
|                 _("No analytics data available. Please contact your server administrator.")
 | |
|             )
 | |
|         if start is None:
 | |
|             first = (
 | |
|                 aggregate_table_remote.objects.filter(server=server).order_by("remote_id").first()
 | |
|             )
 | |
|             assert first is not None
 | |
|             start = first.end_time
 | |
|         if end is None:
 | |
|             last = aggregate_table_remote.objects.filter(server=server).order_by("remote_id").last()
 | |
|             assert last is not None
 | |
|             end = last.end_time
 | |
|     else:
 | |
|         # Otherwise, we can use tables on the current server to
 | |
|         # determine a nice range, and some additional validation.
 | |
|         if start is None:
 | |
|             if for_installation:
 | |
|                 start = installation_epoch()
 | |
|             else:
 | |
|                 start = realm.date_created
 | |
|         if end is None:
 | |
|             end = max(
 | |
|                 stat.last_successful_fill() or datetime.min.replace(tzinfo=timezone.utc)
 | |
|                 for stat in stats
 | |
|             )
 | |
| 
 | |
|         if start > end and (timezone_now() - start > MAX_TIME_FOR_FULL_ANALYTICS_GENERATION):
 | |
|             logging.warning(
 | |
|                 "User from realm %s attempted to access /stats, but the computed "
 | |
|                 "start time: %s (creation of realm or installation) is later than the computed "
 | |
|                 "end time: %s (last successful analytics update). Is the "
 | |
|                 "analytics cron job running?",
 | |
|                 realm.string_id,
 | |
|                 start,
 | |
|                 end,
 | |
|             )
 | |
|             raise JsonableError(
 | |
|                 _("No analytics data available. Please contact your server administrator.")
 | |
|             )
 | |
| 
 | |
|     assert len({stat.frequency for stat in stats}) == 1
 | |
|     end_times = time_range(start, end, stats[0].frequency, min_length)
 | |
|     data: dict[str, Any] = {
 | |
|         "end_times": [int(end_time.timestamp()) for end_time in end_times],
 | |
|         "frequency": stats[0].frequency,
 | |
|     }
 | |
| 
 | |
|     aggregation_level = {
 | |
|         InstallationCount: "everyone",
 | |
|         RealmCount: "everyone",
 | |
|         UserCount: "user",
 | |
|         StreamCount: "everyone",
 | |
|     }
 | |
|     if settings.ZILENCER_ENABLED:
 | |
|         aggregation_level[RemoteInstallationCount] = "everyone"
 | |
|         aggregation_level[RemoteRealmCount] = "everyone"
 | |
| 
 | |
|     # -1 is a placeholder value, since there is no relevant filtering on InstallationCount
 | |
|     id_value = {
 | |
|         InstallationCount: -1,
 | |
|         RealmCount: realm.id,
 | |
|         UserCount: user_profile.id,
 | |
|     }
 | |
|     if stream is not None:
 | |
|         id_value[StreamCount] = stream.id
 | |
| 
 | |
|     if settings.ZILENCER_ENABLED:
 | |
|         if server is not None:
 | |
|             id_value[RemoteInstallationCount] = server.id
 | |
|         # TODO: RemoteRealmCount logic doesn't correctly handle
 | |
|         # filtering by server_id as well.
 | |
|         if remote_realm_id is not None:
 | |
|             id_value[RemoteRealmCount] = remote_realm_id
 | |
| 
 | |
|     for table in tables:
 | |
|         data[aggregation_level[table]] = {}
 | |
|         for stat in stats:
 | |
|             data[aggregation_level[table]].update(
 | |
|                 get_time_series_by_subgroup(
 | |
|                     stat,
 | |
|                     table,
 | |
|                     id_value[table],
 | |
|                     end_times,
 | |
|                     subgroup_to_label[stat],
 | |
|                     include_empty_subgroups,
 | |
|                 )
 | |
|             )
 | |
| 
 | |
|     if labels_sort_function is not None:
 | |
|         data["display_order"] = labels_sort_function(data)
 | |
|     else:
 | |
|         data["display_order"] = None
 | |
|     return json_success(request, data=data)
 | |
| 
 | |
| 
 | |
| def sort_by_totals(value_arrays: dict[str, list[int]]) -> list[str]:
 | |
|     totals = sorted(((sum(values), label) for label, values in value_arrays.items()), reverse=True)
 | |
|     return [label for total, label in totals]
 | |
| 
 | |
| 
 | |
| # For any given user, we want to show a fixed set of clients in the chart,
 | |
| # regardless of the time aggregation or whether we're looking at realm or
 | |
| # user data. This fixed set ideally includes the clients most important in
 | |
| # understanding the realm's traffic and the user's traffic. This function
 | |
| # tries to rank the clients so that taking the first N elements of the
 | |
| # sorted list has a reasonable chance of doing so.
 | |
| def sort_client_labels(data: dict[str, dict[str, list[int]]]) -> list[str]:
 | |
|     realm_order = sort_by_totals(data["everyone"])
 | |
|     user_order = sort_by_totals(data["user"])
 | |
|     label_sort_values: dict[str, float] = {label: i for i, label in enumerate(realm_order)}
 | |
|     for i, label in enumerate(user_order):
 | |
|         label_sort_values[label] = min(i - 0.1, label_sort_values.get(label, i))
 | |
|     return [label for label, sort_value in sorted(label_sort_values.items(), key=lambda x: x[1])]
 | |
| 
 | |
| 
 | |
| CountT = TypeVar("CountT", bound=BaseCount)
 | |
| 
 | |
| 
 | |
| def table_filtered_to_id(table: type[CountT], key_id: int) -> QuerySet[CountT]:
 | |
|     if table == RealmCount:
 | |
|         return table._default_manager.filter(realm_id=key_id)
 | |
|     elif table == UserCount:
 | |
|         return table._default_manager.filter(user_id=key_id)
 | |
|     elif table == StreamCount:
 | |
|         return table._default_manager.filter(stream_id=key_id)
 | |
|     elif table == InstallationCount:
 | |
|         return table._default_manager.all()
 | |
|     elif settings.ZILENCER_ENABLED and table == RemoteInstallationCount:
 | |
|         return table._default_manager.filter(server_id=key_id)
 | |
|     elif settings.ZILENCER_ENABLED and table == RemoteRealmCount:
 | |
|         return table._default_manager.filter(realm_id=key_id)
 | |
|     else:
 | |
|         raise AssertionError(f"Unknown table: {table}")
 | |
| 
 | |
| 
 | |
| def client_label_map(name: str) -> str:
 | |
|     if name == "website":
 | |
|         return "Web app"
 | |
|     if name.startswith("desktop app"):
 | |
|         return "Old desktop app"
 | |
|     if name == "ZulipElectron":
 | |
|         return "Desktop app"
 | |
|     if name == "ZulipTerminal":
 | |
|         return "Terminal app"
 | |
|     if name == "ZulipAndroid":
 | |
|         return "Ancient Android app"
 | |
|     if name == "ZulipiOS":
 | |
|         return "Ancient iOS app"
 | |
|     if name == "ZulipMobile":
 | |
|         return "Old mobile app (React Native)"
 | |
|     if name in ["ZulipFlutter", "ZulipMobile/flutter"]:
 | |
|         return "Mobile app (Flutter)"
 | |
|     if name in ["ZulipPython", "API: Python"]:
 | |
|         return "Python API"
 | |
|     if name.startswith("Zulip") and name.endswith("Webhook"):
 | |
|         return name.removeprefix("Zulip").removesuffix("Webhook") + " webhook"
 | |
|     return name
 | |
| 
 | |
| 
 | |
| def rewrite_client_arrays(value_arrays: dict[str, list[int]]) -> dict[str, list[int]]:
 | |
|     mapped_arrays: dict[str, list[int]] = {}
 | |
|     for label, array in value_arrays.items():
 | |
|         mapped_label = client_label_map(label)
 | |
|         if mapped_label in mapped_arrays:
 | |
|             for i in range(len(array)):
 | |
|                 mapped_arrays[mapped_label][i] += array[i]
 | |
|         else:
 | |
|             mapped_arrays[mapped_label] = array.copy()
 | |
|     return mapped_arrays
 | |
| 
 | |
| 
 | |
| def get_time_series_by_subgroup(
 | |
|     stat: CountStat,
 | |
|     table: type[BaseCount],
 | |
|     key_id: int,
 | |
|     end_times: list[datetime],
 | |
|     subgroup_to_label: dict[str | None, str],
 | |
|     include_empty_subgroups: bool,
 | |
| ) -> dict[str, list[int]]:
 | |
|     queryset = (
 | |
|         table_filtered_to_id(table, key_id)
 | |
|         .filter(property=stat.property)
 | |
|         .values_list("subgroup", "end_time", "value")
 | |
|     )
 | |
|     value_dicts: dict[str | None, dict[datetime, int]] = defaultdict(lambda: defaultdict(int))
 | |
|     for subgroup, end_time, value in queryset:
 | |
|         value_dicts[subgroup][end_time] = value
 | |
|     value_arrays = {}
 | |
|     for subgroup, label in subgroup_to_label.items():
 | |
|         if (subgroup in value_dicts) or include_empty_subgroups:
 | |
|             value_arrays[label] = [value_dicts[subgroup][end_time] for end_time in end_times]
 | |
| 
 | |
|     if stat == COUNT_STATS["messages_sent:client:day"]:
 | |
|         # HACK: We rewrite these arrays to collapse the Client objects
 | |
|         # with similar names into a single sum, and generally give
 | |
|         # them better names
 | |
|         return rewrite_client_arrays(value_arrays)
 | |
|     return value_arrays
 |