Merge lp:~jcsackett/charmworld/better-stats-window into lp:~juju-jitsu/charmworld/trunk

Proposed by j.c.sackett
Status: Merged
Approved by: j.c.sackett
Approved revision: 328
Merged at revision: 394
Proposed branch: lp:~jcsackett/charmworld/better-stats-window
Merge into: lp:~juju-jitsu/charmworld/trunk
Diff against target: 657 lines (+423/-77)
7 files modified
charmworld/jobs/review.py (+103/-60)
charmworld/jobs/tests/test_review.py (+110/-0)
charmworld/static/jspark.js (+107/-0)
charmworld/templates/review.pt (+15/-2)
charmworld/utils.py (+16/-13)
charmworld/views/tests/test_tools.py (+34/-1)
charmworld/views/tools.py (+38/-1)
To merge this branch: bzr merge lp:~jcsackett/charmworld/better-stats-window
Reviewer Review Type Date Requested Status
Aaron Bentley (community) Approve
Review via email: mp+186044@code.launchpad.net

Commit message

Gets and displays review latency statistics in the review queue

Description of the change

This branch updates the review job and the charm-review view to calculate and
display latency information for review items.

charmworld/jobs/review.py
-------------------------
The review job has been altered in several ways.
* The global log has been done away with; the log created in `main` is passed
  into the other functions.
* A new function, `get_tasks_and_proposals` does the work of pulling items from
  Launchpad. Rather than pulling only those items to be displayed in the queue,
  it pulls all items so that latency can be properly calculated.
* The loops that created entries out of tasks and proposals first calculates the
  latency of any item created in the last 6 months as well as any item that is
  still open, even if it is older than 6 months. It then determines if an item
  is still in an "open" status and if so adds it to the review queue. These new
  rules supersede the "not in progress" rule used for juju doc proposals.
* Average, min, and max latency across all items is stored in a new collection,
  `review_queue_latency`. This collection is used to display the current queue
  latency as well as the trend in the charm-review view.

`get_tasks_and_proposals` is greedy--it grabs any possibly related proposal or
task. This is a tradeoff so we don't have to make two fetches for each sort of
item; one for anything created in the last six months, and another for anything
that is still open. In theory our greatest delay is connection time between
charmworld and launchpad--I'm open to arguments refuting that and altering this
to do fetches and create a set of all unique items found in the pair of each
requests from LP.

charmworld/views/tools.py
-------------------------
The charm-review view is still pretty simple, though it grabs a bit more data.
* The latency stats for the last six months are pulled from the new collection
  created in the review job. The most recent item is used to display the current
  min, max, and average. The set of data is used to display sparklines showing
  the 6 month history.
* A new function, `get_sparklines`, uses the stat data to create data strings
  that can be used by a new sparkline library.

charmworld/templates/review.pt and charmworld/static/jspark.js
--------------------------------------------------------------
The template has been updated to show the stats and the sparklines, and to
correct an odd oversight in showing the age of items.
* A new javascript file has been added--this is from resig's set of jquery
  tools. While D3 provides richer visualizations, it is very heavyweight and
  likley unecessary unless/until we're doing a lot more reporting on this site.
* The review queue loads the stats at the top of the listing, along with the
  currently displayed item count. The sparkline spans are automatically turned
  into graphics by jspark. If there is no data or insufficient data (i.e. only
  one data point) jspark removes the span.
* Age is now from date_created, not date_modified, for the entry. As an example
  of why, there is a 22 month old item that was being shown as only a few weeks
  old.

Other
-----
* test_tools.py has been moved into the test directory.
* The portion of `pretty_timedelta` that actually creates a human readable display
  has been broken out as `prettify_timedelta`; this is used both in
  `pretty_timedelta` and in the charm_review view to prettify the min, max, and
  mean latencies.
* tests have been added

To post a comment you must log in.
Revision history for this message
Aaron Bentley (abentley) wrote :

Thanks. This looks good to me. In theory, I think we do want to do two fetches (open and <6 months old), so that the size of the response is proportional to the size of the data we want to evaluate. But I'm happy to wait until we need that.

review: Approve
Revision history for this message
j.c.sackett (jcsackett) wrote :

> Thanks. This looks good to me. In theory, I think we do want to do two
> fetches (open and <6 months old), so that the size of the response is
> proportional to the size of the data we want to evaluate. But I'm happy to
> wait until we need that.

Cool; I'll put in a comment in that function suggesting we want to re-evaluate it in time so the TODO doesn't get lost. Thanks!

327. By j.c.sackett

Link to bug

328. By j.c.sackett

Comment capturing future TODO in get_tasks_and_proposals

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'charmworld/jobs/review.py'
2--- charmworld/jobs/review.py 2013-06-12 20:49:47 +0000
3+++ charmworld/jobs/review.py 2013-09-17 15:22:38 +0000
4@@ -13,13 +13,13 @@
5 - use dateutil date parsing instead of string comparsion.
6 - merge proposals show target with series/branch.
7
8-"""
9+"""
10+import datetime
11+import itertools
12+import logging
13
14 from launchpadlib.launchpad import Launchpad
15-
16-import itertools
17 import pymongo
18-import logging
19
20 from charmworld.utils import (
21 configure_logging,
22@@ -30,10 +30,14 @@
23 from utils import lock
24 from utils import LockHeld
25
26-log = None
27-
28-
29-def update_review_queue(db):
30+
31+def get_tasks_and_proposals(log):
32+ '''Gets review items (bugs and proposals) from launchpad.'''
33+ # TODO: Eventually these single requests should be broken into two each:
34+ # one to get anything created within the window, and one to get anything
35+ # older than the window that is still open. Right now this is probably the
36+ # more performant way to go about it, but that's going to change as the data
37+ # outside of our window gets larger.
38 with Timer() as timer:
39 lp = Launchpad.login_anonymously('charm-tools',
40 'production',
41@@ -45,66 +49,91 @@
42 charm_contributors = lp.people['charm-contributors']
43
44 log.debug("lp login time %0.2f", timer.duration())
45- bug_statuses = [
46- 'New', 'Confirmed', 'Triaged', 'In Progress', 'Fix Committed']
47
48 with Timer() as timer:
49 bugs = charm.searchTasks(
50 tags=['new-formula', 'new-charm'],
51- tags_combinator="Any",
52- status=bug_statuses)
53+ tags_combinator="Any")
54 # Best practice dictates that charmers be subscribed to all bugs
55 # and merge proposals for official source package branches.
56- charmers_bugs = charmers.searchTasks(status=bug_statuses)
57- proposals = charmers.getRequestedReviews(status="Needs review")
58- contributor_proposals = charm_contributors.getRequestedReviews(
59- status="Needs review")
60+ charmers_bugs = charmers.searchTasks()
61+ proposals = charmers.getRequestedReviews()
62+ contributor_proposals = charm_contributors.getRequestedReviews()
63
64 juju = lp.projects['juju']
65 doc_proposals = [mp for mp in juju.getMergeProposals()
66 if mp.target_branch_link.endswith("~juju/juju/docs")]
67
68 log.debug("lp query time %0.2f", timer.duration())
69- doc_proposals = [mp for mp in doc_proposals if not mp.queue_status in (
70- 'Work in progress',)]
71-
72- r_queue = list()
73- for task in itertools.chain(bugs, charmers_bugs):
74- entry = {
75- 'date_modified': task.bug.date_last_updated,
76- 'date_created': task.date_created,
77- 'summary': task.title.split('"')[1].strip(),
78- 'item': task.web_link,
79- 'status': task.status,
80- }
81- r_queue.append(entry)
82-
83- for proposal in itertools.chain(proposals,
84- contributor_proposals,
85- doc_proposals):
86-
87- parts = proposal.target_branch_link.rsplit('/', 3)
88- if parts[-1] == "trunk":
89- parts.pop(-1)
90-
91- target = "/".join(parts[2:])
92- origin = "/".join(proposal.source_branch_link.rsplit('/', 3)[1:])
93-
94- num_comments = proposal.all_comments.total_size
95- if num_comments != 0:
96- last_modified = proposal.all_comments[
97- num_comments - 1].date_created
98- else:
99- last_modified = proposal.date_created
100-
101- entry = {
102- 'date_modified': last_modified,
103- 'date_created': proposal.date_created,
104- 'summary': "Merge for %s from %s" % (target, origin),
105- 'item': proposal.web_link,
106- 'status': proposal.queue_status,
107- }
108- r_queue.append(entry)
109+ return (itertools.chain(bugs, charmers_bugs),
110+ itertools.chain(proposals, contributor_proposals, doc_proposals))
111+
112+
113+def update_review_queue(db, tasks, proposals, log):
114+ '''Updates the review queue information.'''
115+ bug_statuses = [
116+ 'New', 'Confirmed', 'Triaged', 'In Progress', 'Fix Committed']
117+ proposal_statuses = ['Needs review', 'Needs fixing', 'Needs information']
118+ now = datetime.datetime.utcnow()
119+ window = now - datetime.timedelta(days=180) # 6 month window for latency
120+
121+ r_queue = []
122+ latencies = []
123+ for task in tasks:
124+ if (task.date_created.replace(tzinfo=None) >= window or
125+ task.status in bug_statuses):
126+ if task.date_confirmed:
127+ latency = task.date_confirmed - task.date_created
128+ else:
129+ # Now has been calculated in UTC, and LP returns UTC TZ datetimes.
130+ # We can safely remove the TZ, letting us get the timedelta.
131+ latency = now - task.date_created.replace(tzinfo=None)
132+ latencies.append(latency.total_seconds())
133+
134+ if task.status in bug_statuses:
135+ entry = {
136+ 'date_modified': task.bug.date_last_updated,
137+ 'date_created': task.date_created,
138+ 'summary': task.title.split('"')[1].strip(),
139+ 'item': task.web_link,
140+ 'status': task.status,
141+ }
142+ r_queue.append(entry)
143+
144+ for proposal in proposals:
145+ if (proposal.date_created.replace(tzinfo=None) >= window or
146+ proposal.queue_status in proposal_statuses):
147+ if proposal.date_reviewed:
148+ latency = proposal.date_reviewed - proposal.date_review_requested
149+ else:
150+ # Now has been calculated in UTC, and LP returns UTC TZ datetimes.
151+ # We can safely remove the TZ, letting us get the timedelta.
152+ latency = now - proposal.date_review_requested.replace(tzinfo=None)
153+ latencies.append(latency.total_seconds())
154+
155+ if proposal.queue_status in proposal_statuses:
156+ parts = proposal.target_branch_link.rsplit('/', 3)
157+ if parts[-1] == "trunk":
158+ parts.pop(-1)
159+ target = "/".join(parts[2:])
160+ origin = "/".join(proposal.source_branch_link.rsplit('/', 3)[1:])
161+
162+ num_comments = proposal.all_comments.total_size
163+ if num_comments != 0:
164+ last_modified = proposal.all_comments[
165+ num_comments - 1].date_created
166+ else:
167+ last_modified = proposal.date_created
168+
169+ entry = {
170+ 'latency': latency.total_seconds(),
171+ 'date_modified': last_modified,
172+ 'date_created': proposal.date_created,
173+ 'summary': "Merge for %s from %s" % (target, origin),
174+ 'item': proposal.web_link,
175+ 'status': proposal.queue_status,
176+ }
177+ r_queue.append(entry)
178
179 # Drop old collection
180 log.info(
181@@ -113,11 +142,24 @@
182
183 # Bulk insert
184 log.info("Updating review queue (%d items)" % len(r_queue))
185- db['review_queue'].insert(r_queue)
186-
187+ if (r_queue != []):
188+ # Needs check as bulk insert of an empty list is prohibited
189+ db['review_queue'].insert(r_queue)
190+
191+ # Get stats:
192+ stats = latency_stats(latencies)
193+ if stats:
194+ stats['date'] = now
195+ db.review_queue_latency.insert(stats)
196+
197+def latency_stats(queue):
198+ if queue != []:
199+ min_latency = min(queue)
200+ mean_latency = sum(queue)/len(queue)
201+ max_latency = max(queue)
202+ return {'min': min_latency, 'max': max_latency, 'mean': mean_latency}
203
204 def main():
205- global log
206 configure_logging()
207 log = logging.getLogger("charm.review")
208 c = pymongo.Connection(MONGO_URL, safe=True)
209@@ -126,7 +168,8 @@
210 try:
211 with lock('ingest-review',
212 int(settings['script_lease_time']) * 60, db, log):
213- update_review_queue(db)
214+ tasks, proposals = get_tasks_and_proposals(log)
215+ update_review_queue(db, tasks, proposals, log)
216 except LockHeld, error:
217 log.warn(str(error))
218
219
220=== added file 'charmworld/jobs/tests/test_review.py'
221--- charmworld/jobs/tests/test_review.py 1970-01-01 00:00:00 +0000
222+++ charmworld/jobs/tests/test_review.py 2013-09-17 15:22:38 +0000
223@@ -0,0 +1,110 @@
224+# Copyright 2012, 2013 Canonical Ltd. This software is licensed under the
225+# GNU Affero General Public License version 3 (see the file LICENSE).
226+import datetime
227+import logging
228+
229+from charmworld.jobs.review import update_review_queue
230+from charmworld.testing import factory
231+from charmworld.testing import MongoTestBase
232+
233+
234+NOW = datetime.datetime.now()
235+YESTERDAY = NOW - datetime.timedelta(1)
236+TWO_DAYS = NOW - datetime.timedelta(2)
237+
238+
239+class MockBug:
240+ date_last_updated = NOW
241+
242+
243+class MockTask:
244+
245+ def __init__(self, confirmed=False):
246+ self.date_created = TWO_DAYS
247+ self.title = u'Bug #0 in a collection: "A bug task"'
248+ self.web_link = "http://example.com"
249+ self.bug = MockBug()
250+ if confirmed:
251+ self.status = "Confirmed"
252+ self.date_confirmed = YESTERDAY
253+ else:
254+ self.date_confirmed = None
255+ self.status = "New"
256+
257+
258+class MockCommentCollection:
259+ total_size = 0
260+
261+
262+class MockProposal:
263+
264+ def __init__(self, reviewed=False):
265+ base = 'https://api.launchpad.net/devel/'
266+ self.target_branch_link = base + '~charmers/charms/precise/foo/trunk'
267+ self.source_branch_link = base + '~bar/charms/precise/foo/fnord'
268+ self.all_comments = MockCommentCollection()
269+ self.date_review_requested = TWO_DAYS
270+ self.date_created = TWO_DAYS
271+ self.web_link = 'http://example.com'
272+ if reviewed:
273+ self.date_reviewed = YESTERDAY
274+ self.queue_status = "Approved"
275+ else:
276+ self.date_reviewed = None
277+ self.queue_status = "Needs review"
278+
279+
280+class ReviewTest(MongoTestBase):
281+
282+ def test_review_handles_empty_queue(self):
283+ self.db.review_queue.insert([{'foo': 'bar'}])
284+ log = logging.getLogger('foo')
285+ update_review_queue(self.db, [], [], log)
286+ self.assertEqual(0, self.db.review_queue.count())
287+
288+ def test_review_handles_bugs(self):
289+ log = logging.getLogger('foo')
290+ update_review_queue(self.db, [MockTask()], [], log)
291+ self.assertEqual(1, self.db.review_queue.count())
292+ item = self.db.review_queue.find_one()
293+ self.assertEqual(NOW.date(), item['date_modified'].date())
294+ self.assertEqual(TWO_DAYS.date(), item['date_created'].date())
295+ self.assertEqual('A bug task', item['summary'])
296+ self.assertEqual('http://example.com', item['item'])
297+ self.assertEqual('New', item['status'])
298+
299+ def test_review_handles_proposals(self):
300+ log = logging.getLogger('foo')
301+ update_review_queue(self.db, [], [MockProposal()], log)
302+ self.assertEqual(1, self.db.review_queue.count())
303+ item = self.db.review_queue.find_one()
304+ self.assertEqual(TWO_DAYS.date(), item['date_modified'].date())
305+ self.assertEqual(TWO_DAYS.date(), item['date_created'].date())
306+ self.assertEqual('Merge for foo from precise/foo/fnord', item['summary'])
307+ self.assertEqual('http://example.com', item['item'])
308+ self.assertEqual('Needs review', item['status'])
309+
310+ def test_review_calculates_latency(self):
311+ log = logging.getLogger('foo')
312+ self.assertEqual(0, self.db.review_queue_latency.count())
313+ update_review_queue(self.db, [], [MockProposal(True)], log)
314+ self.assertEqual(1, self.db.review_queue_latency.count())
315+ item = self.db.review_queue_latency.find_one()
316+ self.assertEqual(NOW.date(), item['date'].date())
317+ self.assertEqual(1, datetime.timedelta(seconds=item['max']).days)
318+ self.assertEqual(1, datetime.timedelta(seconds=item['mean']).days)
319+ self.assertEqual(1, datetime.timedelta(seconds=item['min']).days)
320+
321+ def test_review_discards_closed_bugs(self):
322+ tasks = [MockTask(), MockTask()]
323+ tasks[0].status = 'Fix released'
324+ log = logging.getLogger('foo')
325+ update_review_queue(self.db, tasks, [], log)
326+ self.assertEqual(1, self.db.review_queue.count())
327+
328+ def test_review_discards_approved_proposals(self):
329+ proposals = [MockProposal(), MockProposal()]
330+ proposals[0].queue_status = 'Approved'
331+ log = logging.getLogger('foo')
332+ update_review_queue(self.db, [], proposals, log)
333+ self.assertEqual(1, self.db.review_queue.count())
334
335=== added file 'charmworld/static/jspark.js'
336--- charmworld/static/jspark.js 1970-01-01 00:00:00 +0000
337+++ charmworld/static/jspark.js 2013-09-17 15:22:38 +0000
338@@ -0,0 +1,107 @@
339+/**
340+ * Javascript Sparklines Library
341+ * Written By John Resig
342+ * http://ejohn.org/projects/jspark/
343+ *
344+ * This work is tri-licensed under the MPL, GPL, and LGPL:
345+ * http://www.mozilla.org/MPL/
346+ *
347+ * To use, place your data points within your HTML, like so:
348+ * <span class="sparkline">10,8,20,5...</span>
349+ *
350+ * in your CSS you might want to have the rule:
351+ * .sparkline { display: none }
352+ * so that non-compatible browsers don't see a huge pile of numbers.
353+ *
354+ * Finally, include this library in your header, like so:
355+ * <script language="javascript" src="jspark.js"></script>
356+ */
357+
358+addEvent( window, "load", function() {
359+ var a = document.getElementsByTagName("*") || document.all;
360+
361+ for ( var i = 0; i < a.length; i++ )
362+ if ( has( a[i].className, "sparkline" ) )
363+ sparkline( a[i] );
364+} );
365+
366+function has(s,c) {
367+ var r = new RegExp("(^| )" + c + "\W*");
368+ return ( r.test(s) ? true : false );
369+}
370+
371+function addEvent( obj, type, fn ) {
372+ if ( obj.attachEvent ) {
373+ obj['e'+type+fn] = fn;
374+ obj[type+fn] = function(){obj['e'+type+fn]( window.event );}
375+ obj.attachEvent( 'on'+type, obj[type+fn] );
376+ } else
377+ obj.addEventListener( type, fn, false );
378+}
379+
380+function removeEvent( obj, type, fn ) {
381+ if ( obj.detachEvent ) {
382+ obj.detachEvent( 'on'+type, obj[type+fn] );
383+ obj[type+fn] = null;
384+ } else
385+ obj.removeEventListener( type, fn, false );
386+}
387+
388+
389+function sparkline(o) {
390+ var p = o.innerHTML.split(',');
391+ while ( o.childNodes.length > 0 )
392+ o.removeChild( o.firstChild );
393+
394+ var nw = "auto";
395+ var nh = "auto";
396+ if ( window.getComputedStyle ) {
397+ nw = window.getComputedStyle( o, null ).width;
398+ nh = window.getComputedStyle( o, null ).height;
399+ }
400+
401+ if ( nw != "auto" ) nw = nw.substr( 0, nw.length - 2 );
402+ if ( nh != "auto" ) nh = nh.substr( 0, nh.length - 2 );
403+
404+ var f = 2;
405+ var w = ( nw == "auto" || nw == 0 ? p.length * f : nw - 0 );
406+ var h = ( nh == "auto" || nh == 0 ? "1em" : nh );
407+
408+ var co = document.createElement("canvas");
409+
410+ if ( co.getContext ) o.style.display = 'inline';
411+ else return false;
412+
413+ co.style.height = h;
414+ co.style.width = w;
415+ co.width = w;
416+ o.appendChild( co );
417+
418+ var h = co.offsetHeight;
419+ co.height = h;
420+
421+ var min = 9999;
422+ var max = -1;
423+
424+ for ( var i = 0; i < p.length; i++ ) {
425+ p[i] = p[i] - 0;
426+ if ( p[i] < min ) min = p[i];
427+ if ( p[i] > max ) max = p[i];
428+ }
429+
430+ if ( co.getContext ) {
431+ var c = co.getContext("2d");
432+ c.strokeStyle = "red";
433+ c.lineWidth = 1.0;
434+ c.beginPath();
435+
436+ for ( var i = 0; i < p.length; i++ ) {
437+ if ( i == 0 )
438+ c.moveTo( (w / p.length) * i, h - (((p[i] - min) / (max - min)) * h) );
439+ c.lineTo( (w / p.length) * i, h - (((p[i] - min) / (max - min)) * h) );
440+ }
441+
442+ c.stroke();
443+ o.style.display = 'inline';
444+ }
445+}
446
447=== modified file 'charmworld/templates/review.pt'
448--- charmworld/templates/review.pt 2013-09-05 09:47:11 +0000
449+++ charmworld/templates/review.pt 2013-09-17 15:22:38 +0000
450@@ -1,9 +1,22 @@
451 <html tal:define="main load: main.pt" metal:use-macro="main.macros['page']">
452 <body>
453+ <metal:block fill-slot="js_include">
454+ <script type="text/javascript"
455+ src="${request.static_url('charmworld:static/jspark.js')}">
456+ </script>
457+ </metal:block>
458 <metal:block metal:fill-slot="content">
459 <div class="row no-border">
460 <h1>Charmers Review Queue</h1>
461- <p class="note">${len(queue)} reviews</p>
462+ <p class="note">
463+ <strong>Items:</strong> ${len(queue)}
464+ <strong>Max wait time:</strong> ${max}
465+ <span class="sparklinesline">${sparklines['maxes']}</span>
466+ <strong>Average wait time:</strong> ${mean}
467+ <span class="sparklinesline">${sparklines['means']}</span>
468+ <strong>Min wait time:</strong> ${min}
469+ <span class="sparklinesline">${sparklines['mins']}</span>
470+ </p>
471 <table>
472 <thead>
473 <tr>
474@@ -15,7 +28,7 @@
475 </tr>
476 </thead>
477 <tr tal:repeat="item queue">
478- <td tal:content="age_format(item['date_modified'])"></td>
479+ <td tal:content="age_format(item['date_created'])"></td>
480 <td tal:content="item['date_created'].strftime('%Y/%m/%d')"></td>
481 <td tal:content="item['date_modified'].strftime('%Y/%m/%d')"></td>
482 <td>
483
484=== renamed file 'charmworld/test_utils.py' => 'charmworld/tests/test_utils.py'
485=== modified file 'charmworld/utils.py'
486--- charmworld/utils.py 2013-09-09 13:45:45 +0000
487+++ charmworld/utils.py 2013-09-17 15:22:38 +0000
488@@ -28,6 +28,21 @@
489 )
490
491
492+def prettify_timedelta(diff):
493+ """Return a prettified time delta, suitable for pretty printing."""
494+ age_s = int(diff.days * 86400 + diff.seconds)
495+
496+ for u, template in _units:
497+ r = float(age_s) / float(u)
498+ if r >= 1.9:
499+ r = int(round(r))
500+ v = template % r
501+ if r > 1:
502+ v += "s"
503+ return v
504+ return ''
505+
506+
507 def pretty_timedelta(time1, time2=None):
508 """Calculate time delta between two `datetime` objects.
509 (the result is somewhat imprecise, only use for prettyprinting).
510@@ -41,19 +56,7 @@
511 raise ValueError('The start date is before the end date.')
512
513 diff = time2 - time1
514- age_s = int(diff.days * 86400 + diff.seconds)
515-
516-# if age_s <= 60 * 1.9:
517-# return ngettext('%(num)i second', '%(num)i seconds', age_s)
518- for u, template in _units:
519- r = float(age_s) / float(u)
520- if r >= 1.9:
521- r = int(round(r))
522- v = template % r
523- if r > 1:
524- v += "s"
525- return v
526- return ''
527+ return prettify_timedelta(diff)
528
529
530 def configure_logging():
531
532=== modified file 'charmworld/views/tests/test_tools.py'
533--- charmworld/views/tests/test_tools.py 2013-09-05 09:47:11 +0000
534+++ charmworld/views/tests/test_tools.py 2013-09-17 15:22:38 +0000
535@@ -4,11 +4,15 @@
536 __metaclass__ = type
537
538
539-from datetime import datetime
540+from datetime import (
541+ datetime,
542+ timedelta,
543+)
544
545 from charmworld.models import Charm
546 from charmworld.views.tools import (
547 charm_store_missing,
548+ get_sparklines,
549 proof_errors,
550 )
551 from charmworld.testing import factory
552@@ -31,6 +35,16 @@
553
554 class TestReviews(WebTestBase):
555
556+ def test_sparklines(self):
557+ stats = [
558+ {'min': 0, 'max':10, 'mean': 5},
559+ {'min': 4, 'max': 6, 'mean': 5.5},
560+ {'min': 2, 'max': 20, 'mean': 12}]
561+ sparklines = get_sparklines(stats)
562+ self.assertEqual('0,4,2', stats['mins'])
563+ self.assertEqual('10,6,20', stats['maxes'])
564+ self.assertEqual('5,5.5,12', stats['mins'])
565+
566 def test_last_modified_formatting(self):
567 self.db.review_queue.save(
568 factory.make_review_entry(date_modified=datetime(2012, 1, 2)))
569@@ -52,6 +66,25 @@
570 for heading, col in zip(headings, columns)]
571 self.assertEqual(['2012/01/02', '2012/01/03'], colmap['Last Modified'])
572
573+ def test_empty_sparklines(self):
574+ sparklines = get_sparklines([])
575+ self.assertEqual('', sparklines['maxes'])
576+ self.assertEqual('', sparklines['mins'])
577+ self.assertEqual('', sparklines['means'])
578+
579+ def test_single_sparklines(self):
580+ sparklines = get_sparklines([{'max': 1, 'min': 2, 'mean': 3}])
581+ self.assertEqual('1', sparklines['maxes'])
582+ self.assertEqual('2', sparklines['mins'])
583+ self.assertEqual('3', sparklines['means'])
584+
585+ def test_sparklines(self):
586+ sparklines = get_sparklines([
587+ {'max': 1, 'min': 1, 'mean': 1},
588+ {'max': 1, 'min': 2, 'mean': 3}])
589+ self.assertEqual('1,1', sparklines['maxes'])
590+ self.assertEqual('1,2', sparklines['mins'])
591+ self.assertEqual('1,3', sparklines['means'])
592
593 class CharmStoreMissingTestCase(ViewTestBase):
594
595
596=== modified file 'charmworld/views/tools.py'
597--- charmworld/views/tools.py 2013-09-03 14:15:49 +0000
598+++ charmworld/views/tools.py 2013-09-17 15:22:38 +0000
599@@ -1,5 +1,7 @@
600 # Copyright 2012, 2013 Canonical Ltd. This software is licensed under the
601 # GNU Affero General Public License version 3 (see the file LICENSE).
602+import datetime
603+import math
604
605 import pymongo
606 from pyramid.view import view_config
607@@ -15,6 +17,21 @@
608 REPORT_CACHE = 180
609
610
611+def get_sparklines(stats):
612+ mins = []
613+ maxes = []
614+ means = []
615+ for s in stats:
616+ mins.append(str(s['min']))
617+ means.append(str(s['mean']))
618+ maxes.append(str(s['max']))
619+
620+ return {
621+ 'mins': ','.join(mins),
622+ 'means': ','.join(means),
623+ 'maxes': ','.join(maxes)}
624+
625+
626 @cached_view_config(
627 route_name="tools",
628 renderer="charmworld:templates/tools.pt")
629@@ -43,7 +60,27 @@
630 queue = list(
631 request.db.review_queue.find(
632 sort=[('date_modified', pymongo.ASCENDING)]))
633- return {'queue': queue, 'age_format': utils.pretty_timedelta}
634+ stats = list(request.db.review_queue_latency.find(
635+ date={"$gte": datetime.datetime.utcnow() - datetime.timedelta(180)},
636+ sort=[('date_modified', pymongo.ASCENDING)]))
637+ sparklines = get_sparklines(stats)
638+ if stats:
639+ current_stats = stats[0]
640+ max_latency = utils.prettify_timedelta(
641+ datetime.timedelta(seconds=current_stats['max']))
642+ min_latency = utils.prettify_timedelta(
643+ datetime.timedelta(seconds=current_stats['min']))
644+ mean_latency = utils.prettify_timedelta(
645+ datetime.timedelta(seconds=current_stats['mean']))
646+ else:
647+ max_latency = min_latency = mean_latency = 'N/A'
648+ return {
649+ 'queue': queue,
650+ 'age_format': utils.pretty_timedelta,
651+ 'max': max_latency,
652+ 'mean': mean_latency,
653+ 'min': min_latency,
654+ 'sparklines': sparklines}
655
656
657 @view_config(

Subscribers

People subscribed via source and target branches