Add /stats page with basic stats graph.

Adds a new url route and a new json endpoint.
This commit is contained in:
Rishi Gupta 2016-12-19 17:30:08 -08:00 committed by Tim Abbott
parent 7b057392c6
commit 9e5325a164
8 changed files with 212 additions and 5 deletions

View File

@ -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,

View File

@ -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])

View File

@ -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

View File

@ -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):

46
static/js/stats.js Normal file
View File

@ -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);
}
});

View File

@ -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>

View File

@ -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',

View File

@ -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',),