mirror of https://github.com/zulip/zulip.git
Add /stats page with basic stats graph.
Adds a new url route and a new json endpoint.
This commit is contained in:
parent
7b057392c6
commit
9e5325a164
|
@ -87,7 +87,8 @@
|
||||||
"current_msg_list": true,
|
"current_msg_list": true,
|
||||||
"home_msg_list": false,
|
"home_msg_list": false,
|
||||||
"pm_list": false,
|
"pm_list": false,
|
||||||
"unread_ui": false
|
"unread_ui": false,
|
||||||
|
"Plotly": false
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-syntax": 1,
|
"no-restricted-syntax": 1,
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
from django.utils.timezone import get_fixed_timezone
|
||||||
|
from zerver.lib.test_classes import ZulipTestCase
|
||||||
|
|
||||||
|
from analytics.lib.counts import CountStat
|
||||||
|
from analytics.views import time_range
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
class TestTimeRange(ZulipTestCase):
|
||||||
|
def test_time_range(self):
|
||||||
|
# type: () -> None
|
||||||
|
HOUR = timedelta(hours=1)
|
||||||
|
DAY = timedelta(days=1)
|
||||||
|
TZINFO = get_fixed_timezone(-100) # 100 minutes west of UTC
|
||||||
|
|
||||||
|
# Using 22:59 so that converting to UTC and applying ceiling_to_{hour,day} do not commute
|
||||||
|
a_time = datetime(2016, 3, 14, 22, 59).replace(tzinfo=TZINFO)
|
||||||
|
# Round up to hour and day
|
||||||
|
ceiling_hour = datetime(2016, 3, 14, 23).replace(tzinfo=TZINFO)
|
||||||
|
ceiling_day = datetime(2016, 3, 15).replace(tzinfo=TZINFO)
|
||||||
|
|
||||||
|
# test start == end
|
||||||
|
self.assertEqual(time_range(a_time, a_time, CountStat.HOUR, None), [ceiling_hour])
|
||||||
|
self.assertEqual(time_range(a_time, a_time, CountStat.DAY, None), [ceiling_day])
|
||||||
|
# test start == end == boundary, and min_length == 0
|
||||||
|
self.assertEqual(time_range(ceiling_hour, ceiling_hour, CountStat.HOUR, 0), [ceiling_hour])
|
||||||
|
self.assertEqual(time_range(ceiling_day, ceiling_day, CountStat.DAY, 0), [ceiling_day])
|
||||||
|
# test start and end on different boundaries
|
||||||
|
self.assertEqual(time_range(ceiling_hour, ceiling_hour+HOUR, CountStat.HOUR, None),
|
||||||
|
[ceiling_hour, ceiling_hour+HOUR])
|
||||||
|
self.assertEqual(time_range(ceiling_day, ceiling_day+DAY, CountStat.DAY, None),
|
||||||
|
[ceiling_day, ceiling_day+DAY])
|
||||||
|
# test min_length
|
||||||
|
self.assertEqual(time_range(ceiling_hour, ceiling_hour+HOUR, CountStat.HOUR, 4),
|
||||||
|
[ceiling_hour-2*HOUR, ceiling_hour-HOUR, ceiling_hour, ceiling_hour+HOUR])
|
||||||
|
self.assertEqual(time_range(ceiling_day, ceiling_day+DAY, CountStat.DAY, 4),
|
||||||
|
[ceiling_day-2*DAY, ceiling_day-DAY, ceiling_day, ceiling_day+DAY])
|
|
@ -1,4 +1,6 @@
|
||||||
from django.conf.urls import url
|
from django.conf.urls import url, include
|
||||||
|
from zerver.lib.rest import rest_dispatch
|
||||||
|
|
||||||
import analytics.views
|
import analytics.views
|
||||||
|
|
||||||
i18n_urlpatterns = [
|
i18n_urlpatterns = [
|
||||||
|
@ -8,6 +10,29 @@ i18n_urlpatterns = [
|
||||||
name='analytics.views.get_realm_activity'),
|
name='analytics.views.get_realm_activity'),
|
||||||
url(r'^user_activity/(?P<email>[\S]+)/$', analytics.views.get_user_activity,
|
url(r'^user_activity/(?P<email>[\S]+)/$', analytics.views.get_user_activity,
|
||||||
name='analytics.views.get_user_activity'),
|
name='analytics.views.get_user_activity'),
|
||||||
|
|
||||||
|
# User-visible stats page
|
||||||
|
url(r'^stats$', analytics.views.stats,
|
||||||
|
name='analytics.views.stats'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# These endpoints are a part of the API (V1), which uses:
|
||||||
|
# * REST verbs
|
||||||
|
# * Basic auth (username:password is email:apiKey)
|
||||||
|
# * Takes and returns json-formatted data
|
||||||
|
#
|
||||||
|
# See rest_dispatch in zerver.lib.rest for an explanation of auth methods used
|
||||||
|
#
|
||||||
|
# All of these paths are accessed by either a /json or /api prefix
|
||||||
|
v1_api_and_json_patterns = [
|
||||||
|
# get data for the graphs at /stats
|
||||||
|
url(r'^analytics/chart_data$', rest_dispatch,
|
||||||
|
{'GET': 'analytics.views.get_chart_data'}),
|
||||||
|
]
|
||||||
|
|
||||||
|
i18n_urlpatterns += [
|
||||||
|
url(r'^api/v1/', include(v1_api_and_json_patterns)),
|
||||||
|
url(r'^json/', include(v1_api_and_json_patterns)),
|
||||||
]
|
]
|
||||||
|
|
||||||
urlpatterns = i18n_urlpatterns
|
urlpatterns = i18n_urlpatterns
|
||||||
|
|
|
@ -5,16 +5,25 @@ from django.db import connection
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from django.http import HttpResponseNotFound, HttpRequest, HttpResponse
|
from django.http import HttpResponseNotFound, HttpRequest, HttpResponse
|
||||||
from django.template import RequestContext, loader
|
from django.template import RequestContext, loader
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
from jinja2 import Markup as mark_safe
|
from jinja2 import Markup as mark_safe
|
||||||
|
|
||||||
from zerver.decorator import has_request_variables, REQ, zulip_internal
|
from analytics.lib.counts import CountStat, process_count_stat, COUNT_STATS
|
||||||
from zerver.lib.timestamp import timestamp_to_datetime
|
from analytics.models import RealmCount, UserCount
|
||||||
from zerver.models import UserActivity, UserActivityInterval, Realm
|
|
||||||
|
from zerver.decorator import has_request_variables, REQ, zulip_internal, \
|
||||||
|
zulip_login_required, to_non_negative_int, to_utc_datetime
|
||||||
|
from zerver.lib.request import JsonableError
|
||||||
|
from zerver.lib.response import json_success
|
||||||
|
from zerver.lib.timestamp import ceiling_to_hour, ceiling_to_day, timestamp_to_datetime
|
||||||
|
from zerver.models import Realm, UserProfile, UserActivity, UserActivityInterval
|
||||||
from zproject.jinja2 import render_to_response
|
from zproject.jinja2 import render_to_response
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import itertools
|
import itertools
|
||||||
|
import json
|
||||||
import pytz
|
import pytz
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
@ -22,6 +31,75 @@ import time
|
||||||
from six.moves import filter, map, range, zip
|
from six.moves import filter, map, range, zip
|
||||||
from typing import Any, Dict, List, Tuple, Optional, Sequence, Callable, Union, Text
|
from typing import Any, Dict, List, Tuple, Optional, Sequence, Callable, Union, Text
|
||||||
|
|
||||||
|
@zulip_login_required
|
||||||
|
def stats(request):
|
||||||
|
# type: (HttpRequest) -> HttpResponse
|
||||||
|
return render_to_response('analytics/stats.html')
|
||||||
|
|
||||||
|
@has_request_variables
|
||||||
|
def get_chart_data(request, user_profile, chart_name=REQ(),
|
||||||
|
min_length=REQ(converter=to_non_negative_int, default=None),
|
||||||
|
start=REQ(converter=to_utc_datetime, default=None),
|
||||||
|
end=REQ(converter=to_utc_datetime, default=None)):
|
||||||
|
# type: (HttpRequest, UserProfile, Text, Optional[int], Optional[datetime], Optional[datetime]) -> HttpResponse
|
||||||
|
realm = user_profile.realm
|
||||||
|
if chart_name == 'messages_sent_to_realm':
|
||||||
|
data = get_messages_sent_to_realm(realm, min_length=min_length, start=start, end=end)
|
||||||
|
else:
|
||||||
|
raise JsonableError(_("Unknown chart name: %s") % (chart_name,))
|
||||||
|
return json_success(data=data)
|
||||||
|
|
||||||
|
def get_messages_sent_to_realm(realm, min_length=None, start=None, end=None):
|
||||||
|
# type: (Realm, Optional[int], Optional[datetime], Optional[datetime]) -> Dict[str, Any]
|
||||||
|
# These are implicitly relying on realm.date_created and timezone.now being in UTC.
|
||||||
|
if start is None:
|
||||||
|
start = realm.date_created
|
||||||
|
if end is None:
|
||||||
|
end = timezone.now()
|
||||||
|
if start > end:
|
||||||
|
raise JsonableError(_("Start time is later than end time. Start: %s, End: %s") % (start, end))
|
||||||
|
interval = CountStat.DAY
|
||||||
|
end_times = time_range(start, end, interval, min_length)
|
||||||
|
indices = {}
|
||||||
|
for i, end_time in enumerate(end_times):
|
||||||
|
indices[end_time] = i
|
||||||
|
|
||||||
|
filter_set = RealmCount.objects.filter(
|
||||||
|
realm=realm, property='messages_sent:is_bot', interval=interval) \
|
||||||
|
.values_list('end_time', 'value')
|
||||||
|
humans = [0]*len(end_times)
|
||||||
|
for end_time, value in filter_set.filter(subgroup=False):
|
||||||
|
humans[indices[end_time]] = value
|
||||||
|
bots = [0]*len(end_times)
|
||||||
|
for end_time, value in filter_set.filter(subgroup=True):
|
||||||
|
bots[indices[end_time]] = value
|
||||||
|
|
||||||
|
return {'end_times': end_times, 'humans': humans, 'bots': bots, 'interval': interval}
|
||||||
|
|
||||||
|
# If min_length is None, returns end_times from ceiling(start) to ceiling(end), inclusive.
|
||||||
|
# If min_length is greater than 0, pads the list to the left.
|
||||||
|
# So informally, time_range(Sep 20, Sep 22, day, None) returns [Sep 20, Sep 21, Sep 22],
|
||||||
|
# and time_range(Sep 20, Sep 22, day, 5) returns [Sep 18, Sep 19, Sep 20, Sep 21, Sep 22]
|
||||||
|
def time_range(start, end, interval, min_length):
|
||||||
|
# type: (datetime, datetime, str, Optional[int]) -> List[datetime]
|
||||||
|
if interval == CountStat.HOUR:
|
||||||
|
end = ceiling_to_hour(end)
|
||||||
|
step = timedelta(hours=1)
|
||||||
|
elif interval == CountStat.DAY:
|
||||||
|
end = ceiling_to_day(end)
|
||||||
|
step = timedelta(days=1)
|
||||||
|
else:
|
||||||
|
raise ValueError(_("Unknown interval."))
|
||||||
|
|
||||||
|
times = []
|
||||||
|
if min_length is not None:
|
||||||
|
start = min(start, end - (min_length-1)*step)
|
||||||
|
current = end
|
||||||
|
while current >= start:
|
||||||
|
times.append(current)
|
||||||
|
current -= step
|
||||||
|
return list(reversed(times))
|
||||||
|
|
||||||
eastern_tz = pytz.timezone('US/Eastern')
|
eastern_tz = pytz.timezone('US/Eastern')
|
||||||
|
|
||||||
def make_table(title, cols, rows, has_row_class=False):
|
def make_table(title, cols, rows, has_row_class=False):
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
function populate_messages_sent_to_realm(data) {
|
||||||
|
var trace_humans = {
|
||||||
|
x: data.end_times.map(function (timestamp) {
|
||||||
|
return new Date(timestamp*1000);
|
||||||
|
}),
|
||||||
|
y: data.humans,
|
||||||
|
mode: 'lines',
|
||||||
|
name: 'Messages from humans',
|
||||||
|
hoverinfo: 'y'
|
||||||
|
};
|
||||||
|
|
||||||
|
var trace_bots = {
|
||||||
|
x: data.end_times.map(function (timestamp) {
|
||||||
|
return new Date(timestamp*1000);
|
||||||
|
}),
|
||||||
|
y: data.bots,
|
||||||
|
mode: 'lines',
|
||||||
|
name: 'Messages from bots',
|
||||||
|
hoverinfo: 'y'
|
||||||
|
};
|
||||||
|
|
||||||
|
var layout = {
|
||||||
|
title: 'Messages sent by humans and bots',
|
||||||
|
xaxis: {
|
||||||
|
type: 'date',
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
fixedrange: true,
|
||||||
|
rangemode: 'tozero',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Plotly.newPlot('id_messages_sent_to_realm', [trace_humans, trace_bots], layout, {displayModeBar: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
$.get({
|
||||||
|
url: '/json/analytics/chart_data',
|
||||||
|
data: {chart_name: 'messages_sent_to_realm', min_length: '10'},
|
||||||
|
idempotent: true,
|
||||||
|
success: function (data) {
|
||||||
|
populate_messages_sent_to_realm(data);
|
||||||
|
},
|
||||||
|
error: function (xhr) {
|
||||||
|
$('#id_stats_errors').text($.parseJSON(xhr.responseText).msg);
|
||||||
|
}
|
||||||
|
});
|
|
@ -0,0 +1,11 @@
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
{{ minified_js('stats')|safe }}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="id_stats_errors" class="alert alert-error"></div>
|
||||||
|
<div id="id_messages_sent_to_realm" style="width: 800px; height: 400px;">
|
||||||
|
<!-- Plotly chart will be drawn inside this DIV -->
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -62,6 +62,7 @@ class TemplateTestCase(ZulipTestCase):
|
||||||
]
|
]
|
||||||
|
|
||||||
logged_in = [
|
logged_in = [
|
||||||
|
'analytics/stats.html',
|
||||||
'zerver/home.html',
|
'zerver/home.html',
|
||||||
'zerver/invite_user.html',
|
'zerver/invite_user.html',
|
||||||
'zerver/keyboard_shortcuts.html',
|
'zerver/keyboard_shortcuts.html',
|
||||||
|
|
|
@ -848,6 +848,14 @@ JS_SPECS = {
|
||||||
),
|
),
|
||||||
'output_filename': 'min/activity.js'
|
'output_filename': 'min/activity.js'
|
||||||
},
|
},
|
||||||
|
'stats': {
|
||||||
|
'source_filenames': (
|
||||||
|
'node_modules/plotly.js/dist/plotly.js',
|
||||||
|
'node_modules/jquery/dist/jquery.js',
|
||||||
|
'js/stats.js'
|
||||||
|
),
|
||||||
|
'output_filename': 'min/stats.js'
|
||||||
|
},
|
||||||
# We also want to minify sockjs separately for the sockjs iframe transport
|
# We also want to minify sockjs separately for the sockjs iframe transport
|
||||||
'sockjs': {
|
'sockjs': {
|
||||||
'source_filenames': ('node_modules/sockjs-client/sockjs.js',),
|
'source_filenames': ('node_modules/sockjs-client/sockjs.js',),
|
||||||
|
|
Loading…
Reference in New Issue