Skip to content

Commit 6d7bfd7

Browse files
committed
Actually commit the statistics section.
- Legacy-Id: 12125
1 parent bf55237 commit 6d7bfd7

4 files changed

Lines changed: 380 additions & 0 deletions

File tree

ietf/stats/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

ietf/stats/tests.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from pyquery import PyQuery
2+
3+
from django.core.urlresolvers import reverse as urlreverse
4+
5+
from ietf.utils.test_data import make_test_data, make_review_data
6+
from ietf.utils.test_utils import login_testing_unauthorized, TestCase
7+
import ietf.stats.views
8+
9+
class StatisticsTests(TestCase):
10+
def test_stats_index(self):
11+
url = urlreverse(ietf.stats.views.stats_index)
12+
r = self.client.get(url)
13+
self.assertEqual(r.status_code, 200)
14+
15+
def test_review_stats(self):
16+
doc = make_test_data()
17+
review_req = make_review_data(doc)
18+
19+
# check redirect
20+
url = urlreverse(ietf.stats.views.review_stats)
21+
22+
login_testing_unauthorized(self, "secretary", url)
23+
24+
r = self.client.get(url)
25+
self.assertEqual(r.status_code, 302)
26+
self.assertTrue(urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": "completion" }) in r["Location"])
27+
28+
# check tabular
29+
for stats_type in ["completion", "results", "states"]:
30+
url = urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": stats_type })
31+
r = self.client.get(url)
32+
self.assertEqual(r.status_code, 200)
33+
q = PyQuery(r.content)
34+
if stats_type != "results":
35+
self.assertTrue(q('.review-stats td:contains("1")'))
36+
37+
# check chart
38+
url = urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": "time" })
39+
url += "?team={}".format(review_req.team.acronym)
40+
r = self.client.get(url)
41+
self.assertEqual(r.status_code, 200)
42+
q = PyQuery(r.content)
43+
self.assertTrue(q('.stats-time-graph'))
44+
45+
# check reviewer level
46+
url = urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": "completion", "acronym": review_req.team.acronym })
47+
r = self.client.get(url)
48+
self.assertEqual(r.status_code, 200)
49+
q = PyQuery(r.content)
50+
self.assertTrue(q('.review-stats td:contains("1")'))

ietf/stats/urls.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from django.conf.urls import patterns, url
2+
from django.conf import settings
3+
4+
import ietf.stats.views
5+
6+
urlpatterns = patterns('',
7+
url("^$", ietf.stats.views.stats_index),
8+
url("^review/(?:(?P<stats_type>completion|results|states|time)/)?(?:%(acronym)s/)?$" % settings.URL_REGEXPS, ietf.stats.views.review_stats),
9+
)

ietf/stats/views.py

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
import datetime, itertools, json, calendar
2+
3+
from django.shortcuts import render
4+
from django.contrib.auth.decorators import login_required
5+
from django.core.urlresolvers import reverse as urlreverse
6+
from django.http import HttpResponseRedirect
7+
8+
import dateutil.relativedelta
9+
10+
from ietf.review.utils import extract_review_request_data, aggregate_review_request_stats, ReviewRequestData
11+
from ietf.group.models import Role, Group
12+
from ietf.person.models import Person
13+
from ietf.name.models import ReviewRequestStateName, ReviewResultName
14+
from ietf.ietfauth.utils import has_role
15+
16+
def stats_index(request):
17+
return render(request, "stats/index.html")
18+
19+
@login_required
20+
def review_stats(request, stats_type=None, acronym=None):
21+
# This view is a bit complex because we want to show a bunch of
22+
# tables with various filtering options, and both a team overview
23+
# and a reviewers-within-team overview - and a time series chart.
24+
# And in order to make the UI quick to navigate, we're not using
25+
# one big form but instead presenting a bunch of immediate
26+
# actions, with a URL scheme where the most common options (level
27+
# and statistics type) are incorporated directly into the URL to
28+
# be a bit nicer.
29+
30+
def build_review_stats_url(stats_type_override=Ellipsis, acronym_override=Ellipsis, get_overrides={}):
31+
kwargs = {
32+
"stats_type": stats_type if stats_type_override is Ellipsis else stats_type_override,
33+
}
34+
acr = acronym if acronym_override is Ellipsis else acronym_override
35+
if acr:
36+
kwargs["acronym"] = acr
37+
38+
base_url = urlreverse(review_stats, kwargs=kwargs)
39+
query_part = u""
40+
41+
if request.GET or get_overrides:
42+
d = request.GET.copy()
43+
for k, v in get_overrides.iteritems():
44+
if type(v) in (list, tuple):
45+
if not v:
46+
if k in d:
47+
del d[k]
48+
else:
49+
d.setlist(k, v)
50+
else:
51+
if v is None or v == u"":
52+
if k in d:
53+
del d[k]
54+
else:
55+
d[k] = v
56+
57+
if d:
58+
query_part = u"?" + d.urlencode()
59+
60+
return base_url + query_part
61+
62+
def get_from_selection(get_parameter, possible_choices):
63+
val = request.GET.get(get_parameter)
64+
for slug, label, url in possible_choices:
65+
if slug == val:
66+
return slug
67+
return None
68+
69+
# which overview - team or reviewer
70+
if acronym:
71+
level = "reviewer"
72+
else:
73+
level = "team"
74+
75+
# statistics type - one of the tables or the chart
76+
possible_stats_types = [
77+
("completion", "Completion status"),
78+
("results", "Review results"),
79+
("states", "Request states"),
80+
]
81+
82+
if level == "team":
83+
possible_stats_types.append(("time", "Changes over time"))
84+
85+
possible_stats_types = [ (slug, label, build_review_stats_url(stats_type_override=slug))
86+
for slug, label in possible_stats_types ]
87+
88+
if not stats_type:
89+
return HttpResponseRedirect(build_review_stats_url(stats_type_override=possible_stats_types[0][0]))
90+
91+
# what to count
92+
possible_count_choices = [
93+
("", "Review requests"),
94+
("pages", "Reviewed pages"),
95+
]
96+
97+
possible_count_choices = [ (slug, label, build_review_stats_url(get_overrides={ "count": slug })) for slug, label in possible_count_choices ]
98+
99+
count = get_from_selection("count", possible_count_choices) or ""
100+
101+
# time range
102+
def parse_date(s):
103+
if not s:
104+
return None
105+
try:
106+
return datetime.datetime.strptime(s.strip(), "%Y-%m-%d").date()
107+
except ValueError:
108+
return None
109+
110+
today = datetime.date.today()
111+
from_date = parse_date(request.GET.get("from")) or today - dateutil.relativedelta.relativedelta(years=1)
112+
to_date = parse_date(request.GET.get("to")) or today
113+
114+
from_time = datetime.datetime.combine(from_date, datetime.time.min)
115+
to_time = datetime.datetime.combine(to_date, datetime.time.max)
116+
117+
# teams/reviewers
118+
teams = list(Group.objects.exclude(reviewrequest=None).distinct().order_by("name"))
119+
120+
reviewer_filter_args = {}
121+
122+
# - interlude: access control
123+
if has_role(request.user, ["Secretariat", "Area Director"]):
124+
pass
125+
else:
126+
secr_access = set()
127+
reviewer_only_access = set()
128+
129+
for r in Role.objects.filter(person__user=request.user, name__in=["secr", "reviewer"], group__in=teams).distinct():
130+
if r.name_id == "secr":
131+
secr_access.add(r.group_id)
132+
reviewer_only_access.discard(r.group_id)
133+
elif r.name_id == "reviewer":
134+
if not r.group_id in secr_access:
135+
reviewer_only_access.add(r.group_id)
136+
137+
teams = [t for t in teams if t.pk in secr_access or t.pk in reviewer_only_access]
138+
139+
for t in reviewer_only_access:
140+
reviewer_filter_args[t] = { "user": request.user }
141+
142+
reviewers_for_team = None
143+
144+
if level == "team":
145+
for t in teams:
146+
t.reviewer_stats_url = build_review_stats_url(acronym_override=t.acronym)
147+
148+
query_teams = teams
149+
query_reviewers = None
150+
151+
group_by_objs = { t.pk: t for t in query_teams }
152+
group_by_index = ReviewRequestData._fields.index("team")
153+
154+
elif level == "reviewer":
155+
for t in teams:
156+
if t.acronym == acronym:
157+
reviewers_for_team = t
158+
break
159+
else:
160+
return HttpResponseRedirect(urlreverse(review_stats))
161+
162+
query_reviewers = list(Person.objects.filter(
163+
email__reviewrequest__time__gte=from_time,
164+
email__reviewrequest__time__lte=to_time,
165+
email__reviewrequest__team=reviewers_for_team,
166+
**reviewer_filter_args.get(t.pk, {})
167+
).distinct())
168+
query_reviewers.sort(key=lambda p: p.last_name())
169+
170+
query_teams = [t]
171+
172+
group_by_objs = { r.pk: r for r in query_reviewers }
173+
group_by_index = ReviewRequestData._fields.index("reviewer")
174+
175+
# now aggregate the data
176+
possible_teams = possible_completion_types = possible_results = possible_states = None
177+
selected_team = selected_completion_type = selected_result = selected_state = None
178+
179+
if stats_type == "time":
180+
possible_teams = [(t.acronym, t.acronym, build_review_stats_url(get_overrides={ "team": t.acronym })) for t in teams]
181+
selected_team = get_from_selection("team", possible_teams)
182+
query_teams = [t for t in query_teams if t.acronym == selected_team]
183+
184+
extracted_data = extract_review_request_data(query_teams, query_reviewers, from_time, to_time, ordering=[level])
185+
186+
if stats_type == "time":
187+
req_time_index = ReviewRequestData._fields.index("req_time")
188+
189+
def time_key_fn(t):
190+
d = t[req_time_index].date()
191+
#d -= datetime.timedelta(days=d.weekday())
192+
d -= datetime.timedelta(days=d.day)
193+
return (t[group_by_index], d)
194+
195+
found_results = set()
196+
found_states = set()
197+
aggrs = []
198+
for (group_pk, d), request_data_items in itertools.groupby(extracted_data, key=time_key_fn):
199+
aggr = aggregate_review_request_stats(request_data_items, count=count)
200+
201+
aggrs.append((d, aggr))
202+
203+
for slug in aggr["result"]:
204+
found_results.add(slug)
205+
for slug in aggr["state"]:
206+
found_states.add(slug)
207+
208+
results = ReviewResultName.objects.filter(slug__in=found_results)
209+
states = ReviewRequestStateName.objects.filter(slug__in=found_states)
210+
211+
# choice
212+
213+
possible_completion_types = [
214+
("completed_in_time", "Completed in time"),
215+
("completed_late", "Completed late"),
216+
("not_completed", "Not completed"),
217+
("average_assignment_to_closure_days", "Avg. compl. days"),
218+
]
219+
220+
possible_completion_types = [
221+
(slug, label, build_review_stats_url(get_overrides={ "completion": slug, "result": None, "state": None }))
222+
for slug, label in possible_completion_types
223+
]
224+
225+
selected_completion_type = get_from_selection("completion", possible_completion_types)
226+
227+
possible_results = [
228+
(r.slug, r.name, build_review_stats_url(get_overrides={ "completion": None, "result": r.slug, "state": None }))
229+
for r in results
230+
]
231+
232+
selected_result = get_from_selection("result", possible_results)
233+
234+
possible_states = [
235+
(s.slug, s.name, build_review_stats_url(get_overrides={ "completion": None, "result": None, "state": s.slug }))
236+
for s in states
237+
]
238+
239+
selected_state = get_from_selection("state", possible_states)
240+
241+
if not selected_completion_type and not selected_result and not selected_state:
242+
selected_completion_type = "completed_in_time"
243+
244+
series_data = []
245+
for d, aggr in aggrs:
246+
v = 0
247+
if selected_completion_type is not None:
248+
v = aggr[selected_completion_type]
249+
elif selected_result is not None:
250+
v = aggr["result"][selected_result]
251+
elif selected_state is not None:
252+
v = aggr["state"][selected_state]
253+
254+
series_data.append((calendar.timegm(d.timetuple()) * 1000, v))
255+
256+
data = json.dumps([{
257+
"data": series_data
258+
}])
259+
260+
else: # tabular data
261+
262+
data = []
263+
264+
found_results = set()
265+
found_states = set()
266+
for group_pk, request_data_items in itertools.groupby(extracted_data, key=lambda t: t[group_by_index]):
267+
aggr = aggregate_review_request_stats(request_data_items, count=count)
268+
269+
# skip zero-valued rows
270+
if aggr["open"] == 0 and aggr["completed"] == 0 and aggr["not_completed"] == 0:
271+
continue
272+
273+
aggr["obj"] = group_by_objs.get(group_pk)
274+
275+
for slug in aggr["result"]:
276+
found_results.add(slug)
277+
for slug in aggr["state"]:
278+
found_states.add(slug)
279+
280+
data.append(aggr)
281+
282+
results = ReviewResultName.objects.filter(slug__in=found_results)
283+
states = ReviewRequestStateName.objects.filter(slug__in=found_states)
284+
285+
# massage states/results breakdowns for template rendering
286+
for aggr in data:
287+
aggr["state_list"] = [aggr["state"].get(x.slug, 0) for x in states]
288+
aggr["result_list"] = [aggr["result"].get(x.slug, 0) for x in results]
289+
290+
291+
return render(request, 'stats/review_stats.html', {
292+
"team_level_url": build_review_stats_url(acronym_override=None),
293+
"level": level,
294+
"reviewers_for_team": reviewers_for_team,
295+
"teams": teams,
296+
"data": data,
297+
"states": states,
298+
"results": results,
299+
300+
# options
301+
"possible_stats_types": possible_stats_types,
302+
"stats_type": stats_type,
303+
304+
"possible_count_choices": possible_count_choices,
305+
"count": count,
306+
307+
"from_date": from_date,
308+
"to_date": to_date,
309+
"today": today,
310+
311+
# time options
312+
"possible_teams": possible_teams,
313+
"selected_team": selected_team,
314+
"possible_completion_types": possible_completion_types,
315+
"selected_completion_type": selected_completion_type,
316+
"possible_results": possible_results,
317+
"selected_result": selected_result,
318+
"possible_states": possible_states,
319+
"selected_state": selected_state,
320+
})

0 commit comments

Comments
 (0)