realm-activity: Merge chart for human and bot users.

Merges the two charts remaining to have just one chart for the
realm activity view.

Removes the columns for different clients and adds a column to
show/sort by the user type (human or bot type).

Deletes templates/analytics/activity.html because it is no
longer used for any activity pages/views.
This commit is contained in:
Lauryn Menard 2024-01-16 15:11:10 +01:00 committed by Tim Abbott
parent 82e3d0388b
commit 030f899195
5 changed files with 106 additions and 151 deletions

View File

@ -139,7 +139,7 @@ class ActivityTest(ZulipTestCase):
result = self.client_get("/activity/integrations")
self.assertEqual(result.status_code, 200)
with self.assert_database_query_count(7):
with self.assert_database_query_count(6):
result = self.client_get("/realm_activity/zulip/")
self.assertEqual(result.status_code, 200)

View File

@ -28,7 +28,11 @@ if settings.BILLING_ENABLED:
def make_table(
title: str, cols: Sequence[str], rows: Sequence[Any], has_row_class: bool = False
title: str,
cols: Sequence[str],
rows: Sequence[Any],
stats_link: Optional[Markup] = None,
has_row_class: bool = False,
) -> str:
if not has_row_class:
@ -37,7 +41,7 @@ def make_table(
rows = list(map(fix_row, rows))
data = dict(title=title, cols=cols, rows=rows)
data = dict(title=title, cols=cols, rows=rows, stats_link=stats_link)
content = loader.render_to_string(
"analytics/ad_hoc_query.html",

View File

@ -1,12 +1,14 @@
import itertools
import re
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Collection, Dict, List, Optional, Set, Tuple
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,
@ -16,86 +18,88 @@ from analytics.views.activity_common import (
)
from zerver.decorator import require_server_admin
from zerver.models import Realm, UserActivity
from zerver.models.users import UserProfile
def get_user_activity_records_for_realm(realm: str, is_bot: bool) -> QuerySet[UserActivity]:
@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",
"client__name",
"count",
"last_visit",
]
records = UserActivity.objects.filter(
user_profile__realm__string_id=realm,
user_profile__is_active=True,
user_profile__is_bot=is_bot,
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)
)
records = records.order_by("user_profile__delivery_email", "-last_visit")
records = records.select_related("user_profile", "client").only(*fields)
return records
def get_user_activity_summary(records: Collection[UserActivity]) -> Dict[str, Any]:
#: The type annotation used above is clearly overly permissive.
#: We should perhaps use TypedDict to clearly lay out the schema
#: for the user activity summary.
summary: Dict[str, Any] = {}
def update(action: str, record: UserActivity) -> None:
if action not in summary:
summary[action] = dict(
count=record.count,
last_visit=record.last_visit,
)
else:
summary[action]["count"] += record.count
summary[action]["last_visit"] = max(
summary[action]["last_visit"],
record.last_visit,
)
def get_user_activity_summary(records: Collection[UserActivity]) -> UserActivitySummary:
if records:
first_record = next(iter(records))
summary["name"] = first_record.user_profile.full_name
summary["user_profile_id"] = first_record.user_profile.id
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:
client = record.client.name
query = str(record.query)
visit = record.last_visit
update("use", record)
if heard_from is None:
heard_from = visit
else:
heard_from = max(visit, heard_from)
if client == "API":
m = re.match("/api/.*/external/(.*)", query)
if m:
client = m.group(1)
update(client, record)
if client.startswith("desktop"):
update("desktop", record)
if client == "website":
update("website", record)
if ("send_message" in query) or re.search("/api/.*/external/.*", query):
update("send", record)
if query in [
"/json/update_pointer",
"/json/users/me/pointer",
"/api/v1/update_pointer",
"update_pointer_backend",
]:
update("pointer", record)
update(client, record)
messages += 1
if last_sent is None:
last_sent = visit
else:
last_sent = max(visit, last_sent)
return summary
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]
) -> Tuple[Dict[str, Any], str]:
user_records = {}
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
@ -103,88 +107,67 @@ def realm_user_summary_table(
for email, records in itertools.groupby(all_records, by_email):
user_records[email] = get_user_activity_summary(list(records))
def get_last_visit(user_summary: Dict[str, Dict[str, datetime]], k: str) -> Optional[datetime]:
if k in user_summary:
return user_summary[k]["last_visit"]
else:
return None
def get_count(user_summary: Dict[str, Dict[str, str]], k: str) -> str:
if k in user_summary:
return user_summary[k]["count"]
else:
return ""
def is_recent(val: datetime) -> bool:
age = timezone_now() - val
return age.total_seconds() < 5 * 60
rows = []
for email, user_summary in user_records.items():
email_link = user_activity_link(email, user_summary["user_profile_id"])
sent_count = get_count(user_summary, "send")
cells = [user_summary["name"], email_link, sent_count]
row_class = ""
for field in ["use", "send", "pointer", "desktop", "ZulipiOS", "Android"]:
visit = get_last_visit(user_summary, field)
if field == "use":
if visit and is_recent(visit):
row_class += " recently_active"
if email in admin_emails:
row_class += " admin"
val = format_date_for_activity_reports(visit)
cells.append(val)
row = dict(cells=cells, row_class=row_class)
rows.append(row)
def by_used_time(row: Dict[str, Any]) -> str:
return row["cells"][3]
rows = sorted(rows, key=by_used_time, reverse=True)
cols = [
"Name",
"Email",
"Total sent",
"Heard from",
"Message sent",
"Pointer motion",
"Desktop",
"ZulipiOS",
"Android",
"User type",
"Messages sent",
"Last heard from",
"Last message sent",
]
title = "Summary"
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))
content = make_table(title, cols, rows, has_row_class=True)
return user_records, content
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, has_row_class=True)
return content
@require_server_admin
def get_realm_activity(request: HttpRequest, realm_str: str) -> HttpResponse:
data: List[Tuple[str, str]] = []
all_user_records: Dict[str, Any] = {}
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}
for is_bot, page_title in [(False, "Humans"), (True, "Bots")]:
all_records = get_user_activity_records_for_realm(realm_str, is_bot)
user_records, content = realm_user_summary_table(all_records, admin_emails)
all_user_records.update(user_records)
data += [(page_title, content)]
title = realm_str
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.html",
context=dict(data=data, realm_stats_link=realm_stats, title=title),
"analytics/activity_details_template.html",
context=dict(
data=content,
title=title,
is_home=False,
),
)

View File

@ -1,32 +0,0 @@
{% extends "zerver/base.html" %}
{% set entrypoint = "activity" %}
{# User activity. #}
{% block title %}
<title>{{ title }} | Zulip analytics</title>
{% endblock %}
{% block content %}
<div class="activity-page">
<a class="show-all" href="/activity">Home</a>
<br />
<h4>{{ title }} {% if realm_stats_link %}{{ realm_stats_link }}{% endif %}</h4>
<ul class="nav nav-tabs">
{% for name, activity in data %}
<li {% if loop.first %} class="active" {% endif %}>
<a href="#{{ name|slugify }}" data-toggle="tab">{{ name }}</a>
</li>
{% endfor %}
</ul>
<div class="tab-content">
{% for name, activity in data %}
<div class="tab-pane {% if loop.first %} active {% endif %}" id="{{ name|slugify }}">
{{ activity|safe }}
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@ -1,4 +1,4 @@
<h3>{{ data.title }}</h3>
<h3>{{ data.title }} {% if data.stats_link %}{{ data.stats_link }}{% endif %}</h3>
{% if data.title == "Remote servers" %}
{% include "analytics/remote_activity_key.html" %}