mirror of https://github.com/zulip/zulip.git
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:
parent
82e3d0388b
commit
030f899195
|
@ -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)
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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 %}
|
|
@ -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" %}
|
||||
|
|
Loading…
Reference in New Issue