Merge lp:~work-items-tracker-hackers/launchpad-work-items-tracker/blueprints-api into lp:launchpad-work-items-tracker

Proposed by Martin Pitt
Status: Merged
Merged at revision: 264
Proposed branch: lp:~work-items-tracker-hackers/launchpad-work-items-tracker/blueprints-api
Merge into: lp:launchpad-work-items-tracker
Diff against target: 1283 lines (+563/-435)
9 files modified
.bzrignore (+1/-0)
.testr.conf (+3/-0)
collect (+134/-187)
generate-all (+10/-1)
html-report (+140/-247)
report_tools.py (+17/-0)
templates/burndown.html (+241/-0)
templates/test.html (+1/-0)
tests.py (+16/-0)
To merge this branch: bzr merge lp:~work-items-tracker-hackers/launchpad-work-items-tracker/blueprints-api
Reviewer Review Type Date Requested Status
Martin Pitt (community) Approve
Review via email: mp+49047@code.launchpad.net

Description of the change

This branch contains lp:~james-w/launchpad-work-items-tracker/blueprints-api, lp:~james-w/launchpad-work-items-tracker/templates, and the other excellent work from James. It requires installing python-mako on people.canonical.com before I can merge this into trunk and roll this out; RT is pending.

To post a comment you must log in.
Revision history for this message
Martin Pitt (pitti) :
review: Approve
Revision history for this message
James Westby (james-w) wrote :

Oh yeah, I forgot mako wasn't on people, sorry. I would have mentioned
this otherwise.

Thanks,

James

Revision history for this message
Martin Pitt (pitti) wrote :

It's installed now, merged. Thanks James!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file '.bzrignore'
2--- .bzrignore 1970-01-01 00:00:00 +0000
3+++ .bzrignore 2011-02-09 12:26:14 +0000
4@@ -0,0 +1,1 @@
5+.testrepository
6
7=== added file '.testr.conf'
8--- .testr.conf 1970-01-01 00:00:00 +0000
9+++ .testr.conf 2011-02-09 12:26:14 +0000
10@@ -0,0 +1,3 @@
11+[DEFAULT]
12+test_command=python -m subunit.run $IDLIST
13+test_id_list_default=tests.test_suite
14
15=== modified file 'collect'
16--- collect 2011-01-29 09:08:54 +0000
17+++ collect 2011-02-09 12:26:14 +0000
18@@ -3,11 +3,11 @@
19 # Pull work items from various sources (blueprint whiteboards, linked blueprint
20 # bugs, wiki pages) and put them into a database.
21
22-import urllib, re, sys, optparse, smtplib, pwd, os
23+import urllib, re, sys, optparse, smtplib, pwd, os, urlparse
24 from email.mime.text import MIMEText
25 import sqlite3 as dbapi2
26
27-from launchpadlib.launchpad import Launchpad, EDGE_SERVICE_ROOT
28+from launchpadlib.launchpad import Launchpad
29
30 import report_tools
31
32@@ -59,51 +59,20 @@
33 dbg('Queueing data error: ' + s)
34
35
36+def web_link(item):
37+ """Get a link to the Launchpad API object on the website."""
38+ api_link = item.self_link
39+ parts = urlparse.urlparse(api_link)
40+ return parts.scheme + "://" + parts.netloc.replace("api.", "") + "/" + parts.path.split("/", 2)[2]
41+
42+
43 ########################################################################
44 #
45 # Functions for parsing Launchpad data
46 #
47 ########################################################################
48
49-def lp_blueprints_from_list(url, name_pattern = None):
50- '''Return blueprints from a LP list view.
51-
52- This can optionally specify a name pattern (which is mostly useful for
53- testing and debugging, to only parse a small subset of blueprints)
54-
55- Return a dictionary name -> (url, milestone).
56- '''
57- blueprint_name_filter = re.compile('href="(/[a-zA-Z0-9-]+/\+spec/%s[^"]+)"(?!.*class.*sprite)' %
58- (name_pattern or '.'))
59- milestone_re = re.compile('href=".*/[a-zA-Z0-9-]+/\+milestone/([^"]+)"')
60-
61- result = {}
62- scan_tr_end = False
63- milestone = None
64- bp = None
65- dbg("lp_blueprints_from_list(): Loading '%s'" % url)
66- for l in urllib.urlopen(url):
67- if scan_tr_end:
68- m = milestone_re.search(l)
69- if m:
70- milestone = m.group(1)
71- dbg('lp_blueprints_from_list(): current spec milestone: ' + milestone)
72- if '</tr>' in l:
73- scan_tr_end = False
74- if bp:
75- result[bp.split('/')[-1]] = (bp, milestone)
76- bp = None
77- milestone = None
78- else:
79- m = blueprint_name_filter.search(l)
80- if m:
81- bp = report_tools.blueprints_base_url + m.group(1)
82- dbg('lp_blueprints_from_list(): found BP: ' + bp)
83- scan_tr_end = True
84-
85- return result
86-
87-def lp_import_blueprint(db, name, url, contents):
88+def lp_import_blueprint(db, bp):
89 '''Import details of a blueprint into the specs table.
90
91 If a blueprint does not have a status, use the description.
92@@ -117,56 +86,50 @@
93 cur.execute('SELECT name FROM milestones')
94 valid_milestones = [m[0] for m in cur] + [None]
95
96- queries = {
97- 'priority': '<dt>Priority:</dt>\s*<dd>\s*([^\n]*)',
98- 'definition': '<dt>Definition:</dt>\s*<dd>\s*([^\n]*)',
99- 'implementation': '<dt>Implementation:</dt>\s*<dd>\s*([^\n]*)',
100- 'approver': '<dt>Approver:</dt>\s*<dd>\s*<a href="https://.*?launchpad.net/~([a-zA-Z0-9_-]+)" ',
101- 'drafter': '<dt>Drafter:</dt>\s*<dd>\s*<a href="https://.*?launchpad.net/~([a-zA-Z0-9_-]+)" ',
102- 'assignee': '<dt>Assignee:</dt>\s*<dd>\s*<a href="https://.*?launchpad.net/~([a-zA-Z0-9_-]+)" ',
103- 'milestone': '<dt>Milestone target:</dt>\s*<dd>\s*<a href="https://.*?launchpad.net/\w+/\+milestone/([a-zA-Z0-9_\.-]+)"',
104- 'description': '<div class="top-portlet">\s*<p>(.*?)</p>',
105- 'status': '(?:<p>|^)[Ss]tatus:\s*<br />(.*?)</p>',
106- 'details_url': 'href="([^"]*)">Read the full specification</a>',
107- 'roadmap_notes': '(?:<p>|^)[Rr]oadmap\s+[Nn]otes:\s*<br />(.*?)</p>',
108- }
109+ def get_whiteboard_section(heading):
110+ if bp.whiteboard is None:
111+ return None
112+ regex = '^' + heading + ':\s*\n(.*?)\n\n'
113+ m = re.search(regex, bp.whiteboard, re.S | re.I | re.M)
114+ if m is not None:
115+ section = m.group(1).strip()
116+ else:
117+ section = None
118+ return section
119
120 data = {}
121- for key, r in queries.iteritems():
122- m = re.search(r, contents, re.S)
123- if not m:
124- dbg('lp_import_blueprint(%s): did not find a match for key %s' % (name, key))
125- if key == 'description':
126- data[key] = '(no description)'
127- else:
128- data[key] = None
129- else:
130- data[key] = m.group(1).strip()
131+ data['priority'] = bp.priority
132+ data['definition'] = bp.definition_status
133+ data['implementation'] = bp.implementation_status
134+ data['approver'] = bp.approver and bp.approver.name or None
135+ data['drafter'] = bp.drafter and bp.drafter.name or None
136+ data['assignee'] = bp.assignee and bp.assignee.name or None
137+ data['milestone'] = bp.milestone and bp.milestone.name or None
138+ data['description'] = bp.summary or "(no description)"
139+ data['status'] = get_whiteboard_section('Status')
140+ data['details_url'] = bp.specification_url
141+ data['roadmap_notes'] = get_whiteboard_section('Roadmap\s+Notes')
142
143 # ignore "later" milestone"
144 if data['milestone'] == 'later':
145- dbg('lp_import_blueprint(%s): spec has "later" milestone, ignoring it' % name)
146+ dbg('lp_import_blueprint(%s): spec has "later" milestone, ignoring it' % bp.name)
147 return False
148
149 if data['milestone'] not in valid_milestones:
150- data_error(url, 'milestone "%s" is unknown/invalid' % data['milestone'], True)
151+ data_error(web_link(bp), 'milestone "%s" is unknown/invalid' % data['milestone'], True)
152
153 # if we have an explicit status: in the whiteboard, use that; otherwise use
154 # description as status text
155 if data['status']:
156- data['status'] = data['status'].replace('<br />',
157- '\n').replace('</div>', '').replace('&nbsp;', ' ').replace(
158- '<wbr></wbr>', '').strip()
159+ data['status'] = data['status'].strip()
160 else:
161- data['status'] = data['description'].replace('<br />',
162- '\n').replace('</div>', '').replace('&nbsp;', ' ').replace(
163- '<wbr></wbr>', '').strip()
164+ data['status'] = data['description'].strip()
165 del data['description']
166
167- dbg('lp_import_blueprint(%s): finished parsing; data: %s' % (name, str(data)))
168+ dbg('lp_import_blueprint(%s): finished parsing; data: %s' % (bp.name, str(data)))
169
170 db.cursor().execute('INSERT INTO specs VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?,?)',
171- (name, url, data['priority'], data['implementation'],
172+ (bp.name, web_link(bp), data['priority'], data['implementation'],
173 data['assignee'], data['status'], data['milestone'],
174 data['definition'], data['drafter'], data['approver'],
175 data['details_url'], data['roadmap_notes']))
176@@ -178,10 +141,7 @@
177
178 '''
179
180- ### cargo culted from parse_blueprint_workitem
181- line = line.replace('<br />', '').replace('</div>', '').replace('</p>',
182- '').replace('<wbr></wbr>', '').replace('&nbsp;', ' ').replace(
183- '<a href="/', '<a href="https://launchpad.net/').strip()
184+ line = line.strip()
185 if not line:
186 return
187
188@@ -201,14 +161,9 @@
189 db.commit()
190
191 def parse_complexity_item(lp, db, line, bp_name, bp_url, def_milestone, def_assignee):
192-
193- line = line.replace('<br />', '').replace('</div>', '').replace('</p>',
194- '').replace('<wbr></wbr>', '').replace('&nbsp;', ' ').replace(
195- '<a href="/', '<a href="https://launchpad.net/').strip()
196-
197- # remove special characters people tend to type
198+ line = line.strip()
199+ # remove special characters people tend to type
200 line = re.sub('[^\w -.]', '', line)
201-
202 if not line:
203 return
204
205@@ -219,17 +174,15 @@
206 assignee = None
207
208 try:
209- list = line.split()
210- list.reverse()
211+ complexity_list = line.split()
212+ complexity_list.reverse()
213
214 # we may not have any values in the list, so append our
215 # default values in the right order so they can be mapped
216 defs = [None, def_milestone, def_assignee]
217- for i in range(len(list), len(defs)):
218- list.append(defs[i])
219-
220- (num, milestone, assignee) = list
221-
222+ for i in range(len(complexity_list), len(defs)):
223+ complexity_list.append(defs[i])
224+ (num, milestone, assignee) = complexity_list
225 if not num:
226 data_error(bp_url, 'No complexity points defined for %s' % line)
227
228@@ -238,17 +191,17 @@
229 cur = db.cursor()
230 cur.execute('INSERT INTO complexity VALUES(?,?,?,?,date(CURRENT_TIMESTAMP))',
231 (assignee, num, milestone, bp_name))
232-
233 except ValueError:
234 data_error(bp_url, "\tComplexity line '%s' could not be parsed %s" % (line, ValueError))
235
236+
237 def parse_blueprint_workitem(line, default_assignee, milestone,
238 blueprint_url, launchpad, result_list):
239 '''Parse a work item line of a blueprint whiteboard.'''
240
241- line = line.replace('<br />', '').replace('</div>', '').replace('</p>',
242- '').replace('<wbr></wbr>', '').replace('&nbsp;', ' ').replace(
243- '<a href="/', '<a href="https://launchpad.net/').strip()
244+ # FIXME: we lose bug linking etc. that is done by the tales
245+ # formatters in LP here.
246+ line = line.strip()
247 if not line:
248 return
249 dbg("\tworkitem (clean): '%s'" % (line))
250@@ -291,13 +244,10 @@
251
252 result_list.append((desc, state, assignee, milestone))
253
254-def follow_blueprint_buglink(bugnum, default_assignee, blueprint_name,
255+def follow_blueprint_buglink(bug, default_assignee, blueprint_name,
256 launchpad, release, default_milestone, result_list):
257 '''Query launchpad for the information on a linked bug'''
258
259- bugnum = int(bugnum)
260- dbg('follow_blueprint_buglink(): processing bug %i (default milestone: %s)' % (bugnum, default_milestone))
261- bug = launchpad.bugs[bugnum]
262 for task in bug.bug_tasks:
263 if task.status == 'Unknown':
264 # this can happen for upstream tasks
265@@ -338,7 +288,7 @@
266 if better_task:
267 continue
268
269- desc = '<a href="https://launchpad.net/bugs/%d">LP: #%d</a>: ' % (bugnum, bugnum) + bug.title
270+ desc = '<a href="https://launchpad.net/bugs/%d">LP: #%d</a>: ' % (bug.id, bug.id) + bug.title
271 if rtype != 'distribution':
272 desc += ' (%s)' % target.name
273
274@@ -363,32 +313,27 @@
275 return word
276 return None
277
278-def lp_import_blueprint_workitems(lp, db, bp_name, bp_url, contents, release):
279+def lp_import_blueprint_workitems(lp, db, bp, release):
280 '''Collect work items from a Launchpad blueprint.
281
282 This includes work items from the whiteboard as well as linked bugs.
283 '''
284- linked_bugs_re = re.compile('<div id="bug_links".*>', re.I)
285- bugnum_re = re.compile('<a href="https://bugs\..*launchpad\.net/bugs/([0-9]+)" class="sprite bug">')
286- work_items_re = re.compile('(<p>|^)work items(.*)\s*:\s*<br />', re.I)
287- meta_re = re.compile( '(<p>|^)Meta.*?:<br />', re.I )
288- complexity_re = re.compile( '(<p>|^)Complexity.*?:<br />', re.I )
289+ work_items_re = re.compile('^work items(.*)\s*:\s*$', re.I)
290+ meta_re = re.compile('^Meta.*?:$', re.I)
291+ complexity_re = re.compile('^Complexity.*?:$', re.I)
292
293 in_workitems_block = False
294- in_linked_bugs_block = False
295 in_meta_block = False
296 in_complexity_block = False
297- found_linked_bugs = False
298 work_items = []
299- found_wb_workitems = False
300 milestone = None
301
302 cur = db.cursor()
303- cur.execute('SELECT assignee, milestone, implementation FROM specs WHERE name = ?', (bp_name,))
304+ cur.execute('SELECT assignee, milestone, implementation FROM specs WHERE name = ?', (bp.name,))
305 (spec_assignee, spec_milestone, spec_implementation) = cur.fetchone()
306
307 dbg('lp_import_blueprint_workitems(): processing %s (spec milestone: %s, spec assignee: %s, spec implementation: %s)' % (
308- bp_name, spec_milestone, spec_assignee, spec_implementation))
309+ bp.name, spec_milestone, spec_assignee, spec_implementation))
310
311 cur.execute('SELECT team FROM teams WHERE name = ?', (spec_assignee,))
312 assignee_teams = [t[0] for t in cur]
313@@ -396,69 +341,51 @@
314 cur.execute('SELECT name FROM milestones')
315 valid_milestones = [m[0] for m in cur]
316
317- for l in contents.splitlines():
318-
319- if not in_workitems_block and not in_linked_bugs_block \
320- and not in_meta_block and not in_complexity_block:
321- m = work_items_re.search(l)
322- if m:
323- in_workitems_block = True
324- found_wb_workitems = True
325- dbg('lp_import_blueprint_workitems(): starting work items block at ' + l)
326- if not milestone:
327- milestone = milestone_extract(m.group(2), valid_milestones)
328- dbg(' ... setting milestone to ' + str(milestone))
329- if linked_bugs_re.search(l):
330- in_linked_bugs_block = True
331- found_linked_bugs = True
332- if meta_re.search(l):
333- in_meta_block = True
334- if complexity_re.search(l):
335- in_complexity_block = True
336- continue
337-
338- if in_workitems_block:
339- dbg("\tworkitem (raw): '%s'" % (l.strip()))
340- parse_blueprint_workitem(l, spec_assignee, milestone or
341- spec_milestone, bp_url, lp, work_items)
342-
343- if '</p>' in l:
344- dbg('lp_import_blueprint_workitems(): closing work items block with line: ' + l)
345- in_workitems_block = False
346- milestone = None
347-
348- if in_linked_bugs_block:
349- dbg("\tbug block line (raw): '%s'" % (l.strip()))
350- m = bugnum_re.search(l)
351- if m and lp:
352- follow_blueprint_buglink(m.group(1), spec_assignee,
353- bp_name, lp, release,
354- milestone or spec_milestone, work_items)
355-
356- if '</div>' in l:
357- in_linked_bugs_block = False
358- continue
359-
360- if in_meta_block:
361- dbg("\tmeta line (raw): '%s'" % (l.strip()))
362- parse_meta_item( lp, db, l, bp_name )
363-
364- # done with the meta information?
365- if '</p>' in l:
366- in_meta_block = False
367- continue
368-
369- if in_complexity_block:
370- dbg("\tcomplexity block line (raw): '%s'" % (l.strip()))
371- parse_complexity_item(lp, db, l, bp_name, bp_url, spec_milestone, spec_assignee)
372-
373- # done with the meta information?
374- if '</p>' in l:
375- in_complexity_block = False
376- continue
377-
378- if found_wb_workitems and '</div>' in l:
379- break
380+ if bp.whiteboard:
381+ for l in bp.whiteboard.splitlines():
382+
383+ if (not in_workitems_block
384+ and not in_meta_block and not in_complexity_block):
385+ m = work_items_re.search(l)
386+ if m:
387+ in_workitems_block = True
388+ dbg('lp_import_blueprint_workitems(): starting work items block at ' + l)
389+ if not milestone:
390+ milestone = milestone_extract(m.group(1), valid_milestones)
391+ dbg(' ... setting milestone to ' + str(milestone))
392+ if meta_re.search(l):
393+ in_meta_block = True
394+ if complexity_re.search(l):
395+ in_complexity_block = True
396+ continue
397+
398+ if in_workitems_block:
399+ dbg("\tworkitem (raw): '%s'" % (l.strip()))
400+ if not l.strip():
401+ dbg('lp_import_blueprint_workitems(): closing work items block with line: ' + l)
402+ in_workitems_block = False
403+ milestone = None
404+ parse_blueprint_workitem(l, spec_assignee, milestone or
405+ spec_milestone, web_link(bp), lp, work_items)
406+
407+ if in_meta_block:
408+ dbg("\tmeta line (raw): '%s'" % (l.strip()))
409+ if not l.strip():
410+ in_meta_block = False
411+ continue
412+ parse_meta_item(lp, db, l, bp.name)
413+
414+ if in_complexity_block:
415+ dbg("\tcomplexity block line (raw): '%s'" % (l.strip()))
416+ if not l.strip():
417+ in_complexity_block = False
418+ continue
419+ parse_complexity_item(lp, db, l, bp.name, web_link(bp), spec_milestone, spec_assignee)
420+
421+ if lp:
422+ for bug in bp.bugs:
423+ follow_blueprint_buglink(bug, spec_assignee, bp.name, lp, release,
424+ spec_milestone, work_items)
425
426 if not work_items:
427 #data_error(bp_url, 'no work items defined', True)
428@@ -474,7 +401,8 @@
429
430 for (desc, status, assignee, milestone) in work_items:
431 db.cursor().execute('INSERT INTO work_items VALUES (?, ?, ?, ?, ?, date(CURRENT_TIMESTAMP))',
432- (desc, bp_name, status, assignee, milestone))
433+ (desc, bp.name, status, assignee, milestone))
434+
435
436 def lp_import_milestones(lp_project, db):
437 '''Import milestones from Launchpad into DB.
438@@ -550,29 +478,41 @@
439 db.cursor().execute('INSERT INTO work_items VALUES (?, ?, ?, ?, ?, date(CURRENT_TIMESTAMP))',
440 (title, name, state, assignee, milestone))
441
442-def lp_import(db, cfg, name_pattern = None):
443+def lp_import(db, cfg, name_pattern=None):
444 '''Collect blueprint work items and status from Launchpad into DB.'''
445
446- lp = Launchpad.login_with('ubuntu-work-items', service_root=EDGE_SERVICE_ROOT)
447+ lp = Launchpad.login_with('ubuntu-work-items', service_root="production", version="devel")
448+ projects = []
449
450 if 'release' in cfg:
451 lp_project = lp.distributions['ubuntu'].getSeries(name_or_version=cfg['release'])
452- url = '%s/ubuntu/%s/+specs?batch=300' % (
453- report_tools.blueprints_base_url, cfg['release'])
454+ projects.append(lp_project)
455 else:
456 assert 'project' in cfg, 'Configuration needs to specify project or release'
457 lp_project = lp.projects[cfg['project']]
458- url = '%s/%s/+specs?batch=300' % (
459- report_tools.blueprints_base_url, cfg['project'])
460+ projects.append(lp_project)
461
462 lp_import_milestones(lp_project, db)
463 lp_import_teams(lp, db, cfg)
464
465- for (bp, (url, milestone)) in lp_blueprints_from_list(url, name_pattern).iteritems():
466- dbg('lp_import(): downloading %s from %s' % (bp, url))
467- contents = urllib.urlopen(url).read().decode('UTF-8')
468- if lp_import_blueprint(db, bp, url, contents):
469- lp_import_blueprint_workitems(lp, db, bp, url, contents, cfg.get('release'))
470+ extra_projects = cfg.get('extra_projects', [])
471+ for extra_project_name in extra_projects:
472+ extra_project = lp.projects[extra_project_name]
473+ lp_import_milestones(extra_project, db)
474+ projects.append(extra_project)
475+
476+ if name_pattern:
477+ name_filter = re.compile(name_pattern)
478+
479+ for project in projects:
480+ # XXX: should this be valid_ or all_specifications?
481+ for bp in project.valid_specifications:
482+ if name_pattern:
483+ if not name_filter.search(bp.name):
484+ continue
485+ dbg('lp_import(): downloading %s from %s' % (bp.name, bp.self_link))
486+ if lp_import_blueprint(db, bp):
487+ lp_import_blueprint_workitems(lp, db, bp, cfg.get('release'))
488
489 lp_import_bug_workitems(lp_project, db, cfg)
490
491@@ -944,6 +884,12 @@
492 if 'bug_status_map' in cfg:
493 bug_wi_states.update(cfg['bug_status_map'])
494
495+ lock_path = opts.database + ".collect_lock"
496+ lock_f = open(lock_path, "wb")
497+ if report_tools.lock_file(lock_f) is None:
498+ print "Another instance is already running"
499+ sys.exit(0)
500+
501 db = get_db(opts.database)
502
503 # reset status for current day
504@@ -962,3 +908,4 @@
505 db.commit()
506 send_error_mails(cfg)
507
508+ os.unlink(lock_path)
509
510=== modified file 'generate-all'
511--- generate-all 2010-09-14 15:49:33 +0000
512+++ generate-all 2011-02-09 12:26:14 +0000
513@@ -39,6 +39,12 @@
514 trend_starts = {}
515 burnup_chart_teams = []
516
517+lock_path = opts.database + ".generate_lock"
518+lock_f = open(lock_path, "wb")
519+if report_tools.lock_file(lock_f) is None:
520+ print "Another instance is already running"
521+ sys.exit(0)
522+
523 db = report_tools.get_db(opts.database)
524
525 # get milestones and teams
526@@ -64,10 +70,12 @@
527
528 for u in users:
529 for m in milestones:
530- # entire cycle report for user
531 target = u + '-' + m
532 basename = os.path.join(usersubdir, target)
533 report_tools.run_reports(my_path, opts.database, basename, opts.config, milestone=m, user=u, trend_starts=trend_starts, burnup=u in burnup_chart_teams)
534+ # entire cycle report for user
535+ basename = os.path.join(usersubdir, u)
536+ report_tools.run_reports(my_path, opts.database, basename, opts.config, milestone=None, user=u, trend_starts=trend_starts, burnup=u in burnup_chart_teams)
537
538 # per team/milestone reports
539 for t in teams:
540@@ -103,3 +111,4 @@
541 src.close()
542 dest.close()
543
544+os.unlink(lock_path)
545
546=== modified file 'html-report'
547--- html-report 2011-01-11 15:09:44 +0000
548+++ html-report 2011-02-09 12:26:14 +0000
549@@ -8,116 +8,87 @@
550 from report_tools import escape_url, escape_html
551 import report_tools
552
553-def format_priority(priority):
554- prio_colors = {
555- 'Undefined': 'gray',
556- 'Low': 'blue',
557- 'Medium': '#f60',
558- 'High': 'red',
559- 'Essential': 'red'
560- }
561-
562- p = escape_html(priority or '')
563- col = prio_colors.get(p)
564- if col:
565- return '<span style="color: %s">%s</span>' % (col, p)
566- return p
567+
568+class WorkitemTarget(object):
569+
570+ def __init__(self, todo=0, blocked=0, inprogress=0, postponed=0,
571+ done=0):
572+ self.todo = todo
573+ self.blocked = blocked
574+ self.inprogress = inprogress
575+ self.postponed = postponed
576+ self.done = done
577+
578+ @property
579+ def percent_complete(self):
580+ completed = self.postponed + self.done
581+ total = completed + self.todo + self.blocked + self.inprogress
582+ return int((float(completed)/total)*100 + 0.5)
583+
584+
585+class Blueprint(WorkitemTarget):
586+
587+ def __init__(self, name, url, complexity=0,
588+ todo=0, blocked=0, inprogress=0, postponed=0,
589+ done=0, implementation=None, priority=None,
590+ status=None):
591+ super(Blueprint, self).__init__(
592+ todo=todo, blocked=blocked, inprogress=inprogress,
593+ postponed=postponed, done=done)
594+ self.name = name
595+ self.url = url
596+ self.complexity = complexity
597+ self.implementation = implementation
598+ self.priority = priority
599+ self.status = status
600+
601+class Assignee(WorkitemTarget):
602+
603+ def __init__(self, name, url, complexity=0, todo_wis=[],
604+ blocked_wis=[], inprogress_wis=[], postponed_wis=[],
605+ done_wis=[]):
606+ super(Assignee, self).__init__(
607+ todo=len(todo_wis), blocked=len(blocked_wis),
608+ inprogress=len(inprogress_wis), postponed=len(postponed_wis),
609+ done=len(done_wis))
610+ self.name = name
611+ self.url = url
612+ self.complexity = complexity
613+ self.todo_wis = todo_wis
614+ self.blocked_wis = blocked_wis
615+ self.inprogress_wis = inprogress_wis
616+ self.postponed_wis = postponed_wis
617+ self.done_wis = done_wis
618+
619
620 def spec_completion(db, team, milestone):
621 data = report_tools.blueprint_completion(db, team, milestone)
622
623- # avoid showing empty columns
624- has_priority = any([i['status'] for i in data.itervalues()])
625- has_complexity = any([i['complexity'] > 0 for i in data.itervalues()])
626-
627- print '''<h2>Status by specification</h2>
628-<p>(Click header to sort)</p>
629-<table id="byspecification">
630- <thead>
631- <tr><th>Specification</th><th>%s</th> <th>todo</th><th>blocked</th><th>inprogress</th><th>postponed</th><th>done</th> <th>Completion</th> <th>%s</th> <th>Status</th></tr>
632- </thead>
633-''' % (has_complexity and 'Complexity' or '',
634- has_priority and 'Priority' or '')
635-
636- completion = []
637- for (bp, i) in data.iteritems():
638- completion.append((bp,
639- int(float(i['postponed']+i['done'])/(i['todo']+i['blocked']+i['done']+i['postponed']+i['inprogress'])*100 + 0.5)))
640-
641- completion.sort(key=lambda k: k[1]*100+report_tools.priority_value(data[k[0]]['priority']), reverse=True)
642-
643- for (bp, percent) in completion:
644- print ' <tr><td>%s</td> <td>%s</td><td>%i</td><td>%i</td><td>%i</td><td>%i</td><td>%i</td> <td>%i%%<br/>' \
645- '<span style="font-size: 70%%">%s</span></td> <td>%s</td> <td>%s</td></tr>' % (
646- '<a href="%s">%s</a>' % (data[bp]['url'], escape_html(bp)),
647- data[bp]['complexity'] or '',
648- data[bp]['todo'], data[bp]['blocked'], data[bp]['inprogress'], data[bp]['postponed'], data[bp]['done'],
649- percent,
650- data[bp]['implementation'],
651- format_priority(data[bp]['priority']),
652- data[bp]['status'])
653-
654- print '</table>'
655-
656-def by_assignee(db, team, milestone):
657- data = report_tools.assignee_completion(db, team, milestone)
658-
659- # avoid showing empty columns
660- has_complexity = any([i['complexity'] > 0 for i in data.itervalues()])
661-
662- print '''
663-<h2>Status by assignee</h2>
664-<p>(Click header to sort)</p>
665-<table id="byassignee">
666- <thead>
667- <tr><th>Assignee</th> <th>%s</th><th>todo</th><th>blocked</th><th>inprogress</th><th>postponed</th><th>done</th> <th>Completion</th></tr>
668- </thead>
669-''' % (has_complexity and 'Complexity' or '', )
670-
671- completion = []
672- for a, i in data.iteritems():
673- (todo, blocked, inprogress, postponed, done) = (len(i['todo']), len(i['blocked']), len(i['inprogress']), len(i['postponed']), len(i['done']))
674- completion.append((a,
675- int(float(postponed+done) / (todo+blocked+inprogress+done+postponed)*100 + 0.5)))
676-
677- completion.sort(key=lambda k: k[0], reverse=False)
678-
679- for (a, percent) in completion:
680- a_html = escape_url(a or team or 'nobody')
681- # no user pages exist w/o milestones, and its silly to link back to ourselves, so fall back to launchpad specs page
682- if not milestone or isinstance(team, report_tools.user_string):
683- url = '%s/~%s/+specs?role=assignee' % (report_tools.blueprints_base_url, a_html)
684- else:
685- target = a_html + '-' + milestone
686- url = 'u/%s.html' % target
687- print ' <tr><td><a href="%s">%s</a></td> <td>%s</td><td>%i</td><td>%i</td><td>%i</td><td>%i</td><td>%i</td> <td>%i%%</td></tr>' % (
688- url, a_html, data[a]['complexity'] or '', len(data[a]['todo']), len(data[a]['blocked']), len(data[a]['inprogress']), len(data[a]['postponed']),
689- len(data[a]['done']), percent)
690- print '</table>'
691-
692-def work_item_details(db, team, milestone):
693- data = report_tools.assignee_completion(db, team, milestone)
694-
695- import sys
696-
697- # avoid showing empty columns
698- has_priority = False
699- for i in data.itervalues():
700- for status in report_tools.valid_states:
701- for wi in i[status]:
702- if wi[2]:
703- has_priority = True
704- break
705- if has_priority:
706- break
707-
708- print '''
709-<h2>Work item details</h2>
710-<table id="byworkitem">
711- <thead>
712- <tr><th>Assignee</th> <th>Status</th> <th>Blueprint</th> <th>%s</th> <th>Work item</th></tr>
713- </thead>
714-''' % (has_priority and 'Priority' or '')
715+ blueprints = []
716+ for bp, i in data.items():
717+ blueprints.append(
718+ Blueprint(bp, i['url'], complexity=i['complexity'],
719+ todo=i['todo'], blocked=i['blocked'],
720+ inprogress=i['inprogress'],
721+ postponed=i['postponed'], done=i['done'],
722+ implementation=i['implementation'],
723+ priority=i['priority'], status=i['status']))
724+
725+ # avoid showing empty columns
726+ specs_have_status = any([bp.status for bp in blueprints])
727+ specs_have_complexity = any([bp.complexity > 0 for bp in blueprints])
728+
729+ blueprints.sort(
730+ key=lambda bp: bp.percent_complete*100+report_tools.priority_value(bp.priority),
731+ reverse=True)
732+
733+ return dict(
734+ specs_have_status=specs_have_status,
735+ specs_have_complexity=specs_have_complexity,
736+ blueprints=blueprints)
737+
738+def get_assignee_completion(db, team, milestone):
739+ data = report_tools.assignee_completion(db, team, milestone)
740
741 def _sort(a, b):
742 # Takes [blueprint, workitem, priority, url] in a and b.
743@@ -128,38 +99,49 @@
744 # then by blueprint
745 return cmp(a[0], b[0])
746
747- for a, i in sorted(data.iteritems(), key=lambda k: k[0]):
748- todo_len = len(i['todo'])
749- blocked_len = len(i['blocked'])
750- postponed_len = len(i['postponed'])
751- done_len = len(i['done'])
752- inprogress_len = len(i['inprogress'])
753- a_html = escape_url(a or team or 'nobody')
754- a_url = '%s/~%s/+specs?role=assignee' % (report_tools.blueprints_base_url, a_html)
755- rows = {'todo': '<td rowspan="%s">todo</td>' % todo_len,
756- 'blocked': '<td rowspan="%s">blocked</td>' % blocked_len,
757- 'postponed': '<td rowspan="%s">postponed</td>' % postponed_len,
758- 'done': '<td rowspan="%s">done</td>' % done_len,
759- 'inprogress': '<td rowspan="%s">inprogress</td>' % inprogress_len}
760- printed_assignee = False
761+ assignees = []
762+ for name, info in data.items():
763+ name = name or team or 'nobody'
764+ name_html = escape_url(name or team or 'nobody')
765+ if not milestone or isinstance(team, report_tools.user_string):
766+ url = '%s/~%s/+specs?role=assignee' % (report_tools.blueprints_base_url, name_html)
767+ else:
768+ target = name_html + '-' + milestone
769+ url = 'u/%s.html' % target
770+ assignees.append(
771+ Assignee(name, url, complexity=info['complexity'],
772+ todo_wis=sorted(info['todo'], cmp=_sort),
773+ blocked_wis=info['blocked'],
774+ inprogress_wis=info['inprogress'],
775+ postponed_wis=info['postponed'],
776+ done_wis=info['done']))
777+ return assignees
778+
779+
780+def by_assignee(db, team, milestone):
781+ assignees = get_assignee_completion(db, team, milestone)
782+ assignees.sort(key=lambda a: a.name, reverse=False)
783+
784+ # avoid showing empty columns
785+ assignees_have_complexity = any([a.complexity > 0 for a in assignees])
786+
787+ # avoid showing empty columns
788+ workitems_have_priority = False
789+ for assignee in assignees:
790 for status in report_tools.valid_states:
791- printed_status = False
792- i[status].sort(cmp=_sort)
793- for (bp, item, priority, url) in i[status]:
794- print ' <tr>',
795- if not printed_assignee:
796- print '<td rowspan="%s"><a href="%s" name="%s">%s</a></td> ' % (
797- todo_len+blocked_len+postponed_len+done_len+inprogress_len, a_url, escape_url(a or team or 'nobody'),
798- a_html)
799- printed_assignee = True
800- if not printed_status:
801- print rows[status]
802- printed_status = True
803- print '<td>%s</td> <td>%s</td> <td>%s</td></tr>' % (
804- '<a href="%s">%s</a>' % (url, escape_html(bp)),
805- format_priority(priority),
806- item)
807- print '</table>'
808+ for wi in getattr(assignee, status+"_wis", []):
809+ if wi[2]:
810+ workitems_have_priority = True
811+ break
812+ if workitems_have_priority:
813+ break
814+
815+ return dict(
816+ assignees_have_complexity=assignees_have_complexity,
817+ assignees=assignees,
818+ workitems_have_priority=workitems_have_priority,
819+ valid_states=report_tools.valid_states)
820+
821
822 def html(db, team, milestone, chart_url, title=None):
823 '''Print work item status as HTML.'''
824@@ -173,118 +155,30 @@
825 if milestone:
826 title += ' (%s)' % milestone
827
828- print '''<html>
829-<head>
830- <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
831- <title>Work item status: %s</title>
832- <style type="text/css">
833- body { background: #F0F0E0; color: black; }
834- a { text-decoration: none; }
835- table { border-collapse: collapse; border-style: solid none;
836- border-width: 3px; margin-bottom: 3ex; empty-cells: show; }
837- table th { text-align: left; border-style: none none solid none;
838- border-width: 3px; padding-right: 10px; }
839- table td { text-align: left; border-style: none none dotted none;
840- border-width: 1px; padding-right: 10px; }
841- table#byworkitem td { vertical-align: top; }
842-
843- a { color: blue; }
844- </style>
845-
846-
847- <script type="text/javascript" src="jquery-1.4.min.js"></script>
848- <script type="text/javascript" src="jquery.tablesorter.min.js"></script>
849-
850- <script type="text/javascript">
851- // documentation at: http://tablesorter.com/
852-
853- $(document).ready( function() {
854- // add parser through the tablesorter addParser method
855- $.tablesorter.addParser({
856- // set a unique id
857- id: 'priority',
858- is: function(s) {
859- // return false so this parser is not auto detected
860- return false;
861- },
862- format: function(s) {
863- // format your data for normalization
864- return s.toLowerCase()
865- .replace(/essential/,5)
866- .replace(/high/,4)
867- .replace(/medium/,3)
868- .replace(/low/,2)
869- .replace(/undefined/,1)
870- .replace(/not/,0);
871-
872- },
873- // set type, either numeric or text
874- type: 'numeric'
875- });
876-
877- $.tablesorter.addParser({
878- // set a unique id
879- id: 'completion',
880- is: function(s) {
881- // return false so this parser is not auto detected
882- return false;
883- },
884- format: function(s) {
885- // format your data for normalization
886- return s.replace(/^(\d+).*/,'\1');
887-
888- },
889- // set type, either numeric or text
890- type: 'numeric'
891- });
892-
893- // order they appear on the page
894- $("#byspecification").tablesorter({
895- // sort on priority, spec ascending
896- sortList: [[5,1],[0,0]],
897- headers: {
898- 4: { sorter:'completion' },
899- 5: { sorter:'priority' }
900- }
901- });
902-
903- $("#byassignee").tablesorter({
904- // sort on name, ascending
905- sortList: [[0,0]]
906- });
907-
908- // with the 'todo/done' and assignee not appearing on every
909- // row, this does not lend itself well to sorting
910- //$("#byworkitem").tablesorter();
911-
912- });
913- </script>
914-</head>
915-
916-<body>
917-
918-<h1>Burndown for %s</h1>
919-
920-<p><object height="600" width="1000" data="%s" type="image/svg+xml">Burndown SVG</object></p>
921-''' % (title, title, chart_url)
922-
923- spec_completion(db, team, milestone)
924- by_assignee(db, team, milestone)
925- work_item_details(db, team, milestone)
926- time_stats(db, team, milestone)
927-
928- print '</body></html>'
929+ data = dict(title=title, chart_url=chart_url)
930+ data.update(spec_completion(db, team, milestone))
931+ data.update(by_assignee(db, team, milestone))
932+ data.update(time_stats(db, team, milestone))
933+ print report_tools.fill_template("burndown.html", data)
934+
935+
936+class WorkitemsOnDate(object):
937+
938+ def __init__(self, date, done, delta_done, todo, delta_todo):
939+ self.date = date
940+ self.done = done
941+ self.delta_done = delta_done
942+ self.todo = todo
943+ self.delta_todo = delta_todo
944+
945
946 def time_stats(db, team, milestone):
947 data = report_tools.workitems_over_time(db, team, milestone)
948
949- print '''
950-<h2>Development over time</h2>
951-<table id="time_stats">
952- <tr><th>Date</th><th>done</th><th>todo</th></tr>
953-'''
954 prev_done = None
955 prev_todo = None
956+
957+ workitems_by_day = []
958 for (date, states) in sorted(data.iteritems(), key=lambda k: k[0]):
959 todo = states.get('todo', 0) + states.get('inprogress', 0)
960 done = states.get('done', 0) + states.get('postponed', 0)
961@@ -306,10 +200,9 @@
962 delta_todo = ''
963 prev_todo = todo
964 prev_done = done
965- print ' <tr><td>%s</td><td>%i%s</td><td>%i%s</td></tr>' % (
966- date, done, delta_done, todo, delta_todo)
967-
968-print '</table>'
969+ workitems_by_day.append(WorkitemsOnDate(date, done, delta_done,
970+ todo, delta_todo))
971+ return dict(workitems_by_day=workitems_by_day)
972
973 #
974 # main
975
976=== modified file 'report_tools.py'
977--- report_tools.py 2010-12-03 12:41:46 +0000
978+++ report_tools.py 2011-02-09 12:26:14 +0000
979@@ -6,6 +6,8 @@
980 import sqlite3 as dbapi2
981 from subprocess import Popen
982 from cgi import escape
983+import errno
984+import fcntl
985
986 valid_states = ['inprogress','blocked','todo','done', 'postponed']
987 blueprints_base_url = 'https://blueprints.launchpad.net'
988@@ -547,6 +549,21 @@
989
990 return trans.get(name, 0)
991
992+def lock_file(f):
993+ try:
994+ fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
995+ return f
996+ except IOError, e:
997+ if e.errno in (errno.EACCES, errno.EAGAIN):
998+ return None
999+ raise
1000+
1001+def fill_template(name, data):
1002+ from mako.lookup import TemplateLookup
1003+ lookup = TemplateLookup(directories=['templates'])
1004+ template = lookup.get_template(name)
1005+ return template.render_unicode(**data)
1006+
1007 if __name__ == '__main__':
1008 # some test stuff
1009 db = get_db(sys.argv[1])
1010
1011=== added directory 'templates'
1012=== added file 'templates/burndown.html'
1013--- templates/burndown.html 1970-01-01 00:00:00 +0000
1014+++ templates/burndown.html 2011-02-09 12:26:14 +0000
1015@@ -0,0 +1,241 @@
1016+<html>
1017+<head>
1018+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
1019+ <title>Work item status: %{title}</title>
1020+ <style type="text/css">
1021+ body { background: #F0F0E0; color: black; }
1022+ a { text-decoration: none; }
1023+ table { border-collapse: collapse; border-style: solid none;
1024+ border-width: 3px; margin-bottom: 3ex; empty-cells: show; }
1025+ table th { text-align: left; border-style: none none solid none;
1026+ border-width: 3px; padding-right: 10px; }
1027+ table td { text-align: left; border-style: none none dotted none;
1028+ border-width: 1px; padding-right: 10px; }
1029+ table#byworkitem td { vertical-align: top; }
1030+
1031+ a { color: blue; }
1032+
1033+ td.priority_Undefined { color: gray; }
1034+ td.priority_Low { color: blue; }
1035+ td.priority_Medium { color: #f60; }
1036+ td.priority_High { color: red; }
1037+ td.priority_Essential { color: red; }
1038+ td span.implementation_status { font-size: 70%; }
1039+ </style>
1040+
1041+
1042+ <script type="text/javascript" src="jquery-1.4.min.js"></script>
1043+ <script type="text/javascript" src="jquery.tablesorter.min.js"></script>
1044+
1045+ <script type="text/javascript">
1046+ // documentation at: http://tablesorter.com/
1047+
1048+ $(document).ready( function() {
1049+ // add parser through the tablesorter addParser method
1050+ $.tablesorter.addParser({
1051+ // set a unique id
1052+ id: 'priority',
1053+ is: function(s) {
1054+ // return false so this parser is not auto detected
1055+ return false;
1056+ },
1057+ format: function(s) {
1058+ // format your data for normalization
1059+ return s.toLowerCase()
1060+ .replace(/essential/,5)
1061+ .replace(/high/,4)
1062+ .replace(/medium/,3)
1063+ .replace(/low/,2)
1064+ .replace(/undefined/,1)
1065+ .replace(/not/,0);
1066+
1067+ },
1068+ // set type, either numeric or text
1069+ type: 'numeric'
1070+ });
1071+
1072+ $.tablesorter.addParser({
1073+ // set a unique id
1074+ id: 'completion',
1075+ is: function(s) {
1076+ // return false so this parser is not auto detected
1077+ return false;
1078+ },
1079+ format: function(s) {
1080+ // format your data for normalization
1081+ return s.replace(/^(\d+).*/,'\1');
1082+
1083+ },
1084+ // set type, either numeric or text
1085+ type: 'numeric'
1086+ });
1087+
1088+ // order they appear on the page
1089+ $("#byspecification").tablesorter({
1090+ // sort on priority, spec ascending
1091+ sortList: [[5,1],[0,0]],
1092+ headers: {
1093+ 4: { sorter:'completion' },
1094+ 5: { sorter:'priority' }
1095+ }
1096+ });
1097+
1098+ $("#byassignee").tablesorter({
1099+ // sort on name, ascending
1100+ sortList: [[0,0]]
1101+ });
1102+
1103+ // with the 'todo/done' and assignee not appearing on every
1104+ // row, this does not lend itself well to sorting
1105+ //$("#byworkitem").tablesorter();
1106+
1107+ });
1108+ </script>
1109+</head>
1110+
1111+<body>
1112+
1113+<h1>Burndown for ${title}</h1>
1114+
1115+<p><object height="600" width="1000" data="${chart_url}" type="image/svg+xml">Burndown SVG</object></p>
1116+
1117+<h2>Status by specification</h2>
1118+<p>(Click header to sort)</p>
1119+<table id="byspecification">
1120+ <thead>
1121+ <tr>
1122+ <th>Specification</th>
1123+% if specs_have_complexity:
1124+ <th>Complexity</th>
1125+% endif
1126+ <th>todo</th>
1127+ <th>blocked</th>
1128+ <th>inprogress</th>
1129+ <th>postponed</th>
1130+ <th>done</th>
1131+ <th>Completion</th>
1132+ <th>Priority</th>
1133+% if specs_have_status:
1134+ <th>Status</th>
1135+% endif
1136+ </tr>
1137+ </thead>
1138+% for bp in blueprints:
1139+ <tr>
1140+ <td><a href="${bp.url}">${bp.name}</a></td>
1141+% if has_complexity:
1142+ <td>${bp.complexity}</td>
1143+% endif
1144+ <td>${bp.todo}</td>
1145+ <td>${bp.blocked}</td>
1146+ <td>${bp.inprogress}</td>
1147+ <td>${bp.postponed}</td>
1148+ <td>${bp.done}</td>
1149+ <td>${bp.percent_complete}%<br />
1150+ <span class="implementation_status">${bp.implementation}</span></td>
1151+ <td class="priority_${bp.priority}">${bp.priority}</td>
1152+ <td>${bp.status}</td>
1153+ </tr>
1154+% endfor
1155+</table>
1156+
1157+<h2>Status by assignee</h2>
1158+<p>(Click header to sort)</p>
1159+<table id="byassignee">
1160+<thead>
1161+ <tr>
1162+ <th>Assignee</th>
1163+% if assignees_have_complexity:
1164+ <th>Complexity</th>
1165+% endif
1166+ <th>todo</th>
1167+ <th>blocked</th>
1168+ <th>inprogress</th>
1169+ <th>postponed</th>
1170+ <th>done</th>
1171+ <th>Completion</th>
1172+ </tr>
1173+</thead>
1174+% for a in assignees:
1175+ <tr>
1176+ <td><a href="${a.url}">${a.name}</a></td>
1177+% if assignees_have_complexity:
1178+ <td>${a.complexity}</td>
1179+% endif
1180+ <td>${a.todo}</td>
1181+ <td>${a.blocked}</td>
1182+ <td>${a.inprogress}</td>
1183+ <td>${a.postponed}</td>
1184+ <td>${a.done}</td>
1185+ <td>${a.percent_complete}%</td>
1186+ </tr>
1187+% endfor
1188+</table>
1189+
1190+<h2>Work item details</h2>
1191+<table id="byworkitem">
1192+<thead>
1193+ <tr>
1194+ <th>Assignee</th>
1195+ <th>Status</th>
1196+ <th>Blueprint</th>
1197+% if workitems_have_priority:
1198+ <th>Priority</th>
1199+% endif
1200+ <th>Work item</th>
1201+ </tr>
1202+</thead>
1203+
1204+% for a in assignees:
1205+<%
1206+ printed_assignee = False
1207+%>
1208+% for status in valid_states:
1209+<%
1210+ printed_status = False
1211+%>
1212+% for bp, item, priority, url in getattr(a, status+"_wis"):
1213+ <tr>
1214+% if not printed_assignee:
1215+ <td rowspan="${a.todo+a.blocked+a.postponed+a.done+a.inprogress}"><a href="${a.url}" name="${a.name}">${a.name}</a></td>
1216+ <%
1217+ printed_assignee = True
1218+ %>
1219+% endif
1220+% if not printed_status:
1221+ <td rowspan="${getattr(a, status)}">${status}</td>
1222+ <%
1223+ printed_status = True
1224+ %>
1225+% endif
1226+ <td><a href="${url}">${bp}</a></td>
1227+% if workitems_have_priority:
1228+ <td class="priority_${priority}">${priority}</td>
1229+% endif
1230+ <td>${item}</td>
1231+ </tr>
1232+% endfor
1233+% endfor
1234+% endfor
1235+
1236+</table>
1237+
1238+<h2>Development over time</h2>
1239+<table id="time_stats">
1240+ <thead>
1241+ <tr>
1242+ <th>Date</th>
1243+ <th>done</th>
1244+ <th>todo</th>
1245+ </tr>
1246+ </thead>
1247+% for count_on_day in workitems_by_day:
1248+ <tr>
1249+ <td>${count_on_day.date}</td>
1250+ <td>${count_on_day.done}${count_on_day.delta_done}</td>
1251+ <td>${count_on_day.todo}${count_on_day.delta_todo}</td>
1252+ </tr>
1253+% endfor
1254+</table>
1255+</body>
1256+</html>
1257
1258=== added file 'templates/test.html'
1259--- templates/test.html 1970-01-01 00:00:00 +0000
1260+++ templates/test.html 2011-02-09 12:26:14 +0000
1261@@ -0,0 +1,1 @@
1262+Testing ${data}
1263
1264=== added file 'tests.py'
1265--- tests.py 1970-01-01 00:00:00 +0000
1266+++ tests.py 2011-02-09 12:26:14 +0000
1267@@ -0,0 +1,16 @@
1268+from testtools import TestCase
1269+import report_tools
1270+
1271+
1272+class FillTemplateTests(TestCase):
1273+
1274+ def test_finds_template(self):
1275+ output = report_tools.fill_template("test.html", dict(data="foo"))
1276+ self.assertEqual("Testing foo\n", output)
1277+
1278+
1279+def test_suite():
1280+ from unittest import TestLoader
1281+ loader = TestLoader()
1282+ suite = loader.loadTestsFromName(__name__)
1283+ return suite

Subscribers

People subscribed via source and target branches