stats: Support fetching StreamCount based on stream_id.

As a preliminary step for including graphs of StreamCount data in our
analytics pages, add API support for fetching the chart data.

Care is taken to limit access to streams that the current user has
access to, which isn't necessary in similar views where the current
user is a server administrator by assumption.

Fixes part of #19653.

Co-authored-by: Tim Abbott <tabbott@zulip.com>
This commit is contained in:
Lakshay Mittal 2023-03-01 01:34:11 +05:30 committed by Tim Abbott
parent 8a837320a9
commit d94bdb3900
3 changed files with 96 additions and 3 deletions

View File

@ -6,7 +6,7 @@ from typing_extensions import override
from analytics.lib.counts import COUNT_STATS, CountStat
from analytics.lib.time_utils import time_range
from analytics.models import FillState, RealmCount, UserCount
from analytics.models import FillState, RealmCount, StreamCount, UserCount
from analytics.views.stats import rewrite_client_arrays, sort_by_totals, sort_client_labels
from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.timestamp import ceiling_to_day, ceiling_to_hour, datetime_to_timestamp
@ -74,6 +74,7 @@ class TestGetChartData(ZulipTestCase):
super().setUp()
self.realm = get_realm("zulip")
self.user = self.example_user("hamlet")
self.stream_id = self.get_stream_id(self.get_streams(self.user)[0])
self.login_user(self.user)
self.end_times_hour = [
ceiling_to_hour(self.realm.date_created) + timedelta(hours=i) for i in range(4)
@ -116,6 +117,17 @@ class TestGetChartData(ZulipTestCase):
)
for i, subgroup in enumerate(user_subgroups)
)
StreamCount.objects.bulk_create(
StreamCount(
property=stat.property,
subgroup=subgroup,
end_time=insert_time,
value=100 + i,
stream_id=self.stream_id,
realm=self.realm,
)
for i, subgroup in enumerate(realm_subgroups)
)
FillState.objects.create(property=stat.property, end_time=fill_time, state=FillState.DONE)
def test_number_of_humans(self) -> None:
@ -252,6 +264,49 @@ class TestGetChartData(ZulipTestCase):
},
)
def test_messages_sent_by_stream(self) -> None:
stat = COUNT_STATS["messages_in_stream:is_bot:day"]
self.insert_data(stat, ["true", "false"], [])
result = self.client_get(
f"/json/analytics/chart_data/stream/{self.stream_id}",
{
"chart_name": "messages_sent_by_stream",
},
)
data = self.assert_json_success(result)
self.assertEqual(
data,
{
"msg": "",
"end_times": [datetime_to_timestamp(dt) for dt in self.end_times_day],
"frequency": CountStat.DAY,
"everyone": {"bot": self.data(100), "human": self.data(101)},
"display_order": None,
"result": "success",
},
)
result = self.api_get(
self.example_user("polonius"),
f"/api/v1/analytics/chart_data/stream/{self.stream_id}",
{
"chart_name": "messages_sent_by_stream",
},
)
self.assert_json_error(result, "Not allowed for guest users")
# Verify we correctly forbid access to stats of streams in other realms.
result = self.api_get(
self.mit_user("sipbtest"),
f"/api/v1/analytics/chart_data/stream/{self.stream_id}",
{
"chart_name": "messages_sent_by_stream",
},
subdomain="zephyr",
)
self.assert_json_error(result, "Invalid stream ID")
def test_include_empty_subgroups(self) -> None:
FillState.objects.create(
property="realm_active_humans::day",

View File

@ -16,6 +16,7 @@ from analytics.views.stats import (
get_chart_data_for_realm,
get_chart_data_for_remote_installation,
get_chart_data_for_remote_realm,
get_chart_data_for_stream,
stats,
stats_for_installation,
stats_for_realm,
@ -56,6 +57,7 @@ i18n_urlpatterns: List[Union[URLPattern, URLResolver]] = [
v1_api_and_json_patterns = [
# get data for the graphs at /stats
rest_path("analytics/chart_data", GET=get_chart_data),
rest_path("analytics/chart_data/stream/<stream_id>", GET=get_chart_data_for_stream),
rest_path("analytics/chart_data/realm/<realm_str>", GET=get_chart_data_for_realm),
rest_path("analytics/chart_data/installation", GET=get_chart_data_for_installation),
rest_path(

View File

@ -33,9 +33,10 @@ from zerver.lib.exceptions import JsonableError
from zerver.lib.i18n import get_and_set_request_language, get_language_translation_data
from zerver.lib.request import REQ, has_request_variables
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.validator import to_non_negative_int
from zerver.models import Client, Realm, UserProfile, get_realm
from zerver.models import Client, Realm, Stream, UserProfile, get_realm
if settings.ZILENCER_ENABLED:
from zilencer.models import RemoteInstallationCount, RemoteRealmCount, RemoteZulipServer
@ -156,6 +157,21 @@ def get_chart_data_for_realm(
return get_chart_data(request, user_profile, realm=realm, **kwargs)
@require_non_guest_user
@has_request_variables
def get_chart_data_for_stream(
request: HttpRequest, /, user_profile: UserProfile, stream_id: int
) -> HttpResponse:
stream, ignored_sub = access_stream_by_id(
user_profile,
stream_id,
require_active=True,
allow_realm_admin=True,
)
return get_chart_data(request, user_profile, stream=stream)
@require_server_admin_api
@has_request_variables
def get_chart_data_for_remote_realm(
@ -237,11 +253,15 @@ def get_chart_data(
min_length: Optional[int] = REQ(converter=to_non_negative_int, default=None),
start: Optional[datetime] = REQ(converter=to_utc_datetime, default=None),
end: Optional[datetime] = REQ(converter=to_utc_datetime, default=None),
# These last several parameters are only used by functions
# wrapping get_chart_data; the callers are responsible for
# parsing/validation/authorization for them.
realm: Optional[Realm] = None,
for_installation: bool = False,
remote: bool = False,
remote_realm_id: Optional[int] = None,
server: Optional["RemoteZulipServer"] = None,
stream: Optional[Stream] = None,
) -> HttpResponse:
TableType: TypeAlias = Union[
Type["RemoteInstallationCount"],
@ -265,7 +285,9 @@ def get_chart_data(
else:
aggregate_table = RealmCount
tables: Union[Tuple[TableType], Tuple[TableType, Type[UserCount]]]
tables: Union[
Tuple[TableType], Tuple[TableType, Type[UserCount]], Tuple[TableType, Type[StreamCount]]
]
if chart_name == "number_of_humans":
stats = [
@ -315,6 +337,16 @@ def get_chart_data(
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 stream 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))
@ -397,6 +429,7 @@ def get_chart_data(
InstallationCount: "everyone",
RealmCount: "everyone",
UserCount: "user",
StreamCount: "everyone",
}
if settings.ZILENCER_ENABLED:
aggregation_level[RemoteInstallationCount] = "everyone"
@ -408,6 +441,9 @@ def get_chart_data(
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