[Python-checkins] r53164 - tracker/instances/python-dev/scripts/roundup-summary
paul.dubois
python-checkins at python.org
Wed Dec 27 18:59:08 CET 2006
Author: paul.dubois
Date: Wed Dec 27 18:59:07 2006
New Revision: 53164
Modified:
tracker/instances/python-dev/scripts/roundup-summary
Log:
Put in kludge for local execution.
Got it out of dos format.
Tested on tracker, ok. Ready to go.
Modified: tracker/instances/python-dev/scripts/roundup-summary
==============================================================================
--- tracker/instances/python-dev/scripts/roundup-summary (original)
+++ tracker/instances/python-dev/scripts/roundup-summary Wed Dec 27 18:59:07 2006
@@ -1,876 +1,879 @@
-#!/usr/bin/env python
-# Make sure you are using the python that has the roundup runtime used by the
-# tracker. Requires Python 2.3 or later.
-#
-# Script to create a tracker summary by Richard Jones and Paul Dubois
-import sys, math
-import roundup
-import roundup.date, roundup.instance
-import optparse , datetime
-import cStringIO, MimeWriter, smtplib
-from roundup.mailer import SMTPConnection
-
-### CONFIGURATION
-# List of statuses to treat as closed -- these don't have to all exist.
-resolvedStatusDefault = 'resolved,done,done-cbb,closed,faq'
-
-# Period of time for report. Uses roundup syntax for ranges.
-defaultDates = '-1w;'
-
-# Email address of recipient of report, if any.
-defaultMailTo = ''
-
-# Comma-delimited list of statuses not to include in report.
-ignoredStatusesDefault = ''
-
-# Number of most-discussed messages to display
-discussionMax = 10
-
-# Smallest number of messages to be eligible for 'most discussed'.
-discussionThreshold = 3
-
-# Date format per time.strftime
-dateFormat="%x" #locale-appropriate
-
-##### END CONFIGURATION; ALSO SEE CUSTOMIZATION BELOW
-
-usage = """%prog trackerHome [--mail mailTo]
- [--dates 'date1;date2']
- [--brief]
- # less likely
- [--resolved 'status1,status2']
- [--status 'status1,status2']
- [--output filename]
- [--errors filename]
- # for maintainers
- [--DEBUG]
- [--AUDIT recent|all]
- dates is a roundup date range such as:
- '-1w;' -- the last week (the default)
- '-3m;' -- the last 3 months
- 'from 2006-10-25 to 2006-12-25' -- Thanksgiving to Christmas, 2006
-
- mailTo is one or more email addresses, comma delimited.
- resolved is a list of statuses to treat as 'closed'.
- statuses are names of statuses to ignore.
-
- Be sure to protect commas and semicolons from the shell with quotes!
- Execute %prog --help for detailed help
-"""
-#### Options
-parser = optparse.OptionParser(usage=usage)
-parser.add_option('-b','--brief', dest='brief', action='store_true',
- default=False,
- help='Show summary only, no tables.')
-parser.add_option('-a','--audit', dest='audit',
- default='', metavar = 'all|recent',
- help='Print journal for "all" or "recent" transactions, then halt.')
-parser.add_option('-D','--DEBUG', dest='debug', action='store_true',
- default=False,
- help='Just print the result; if mailTo set, print email but do not send it.')
-parser.add_option('-d','--dates', dest='dates',
- default=defaultDates, metavar="'from;to'",
- help="""Specification for range of dates, such as: \
-'-1w;' -- previous week;
-'-1y;' -- previous year;
-'from 2006-11-1 to 2006-12-1' -- exact period""")
-parser.add_option('-m','--mail', dest='mailTo',
- default=defaultMailTo,
- help='Mail the report to the address(es) mailTo; if not given, print report.')
-parser.add_option('-s','--status', dest='ignore', metavar="STATUSES_TO_IGNORE",
- default=ignoredStatusesDefault,
- help="Comma-delimited list of statuses to ignore in report.")
-parser.add_option('-r','--resolved', dest='resolved',
- default=resolvedStatusDefault,
- help="Comma-delimited list of statuses that corresponds to being 'closed'.")
-parser.add_option('-o','--output', dest='output',
- default='', metavar='FILENAME',
- help='File name for output; default is stdout.')
-parser.add_option('-e','--errors', dest='errors',
- default='', metavar='FILENAME',
- help='File name for error output; default is stderr.')
-
-#### Get the command line args:
-(options, args) = parser.parse_args()
-if options.output:
- sys.stdout = open(options.output, 'w')
-if options.errors:
- sys.stderr = open(options.errors, 'w')
-
-if len(args) != 1:
- parser.error("""Incorrect number of arguments;
- you must supply a tracker home.""")
-instanceHome = args[0]
-# Open the instance
-instance = roundup.instance.open(instanceHome)
-db = instance.open('admin')
-
-# CUSTOMIZATION
-dateWidth = len(roundup.date.Date('2006-09-30').pretty(format=dateFormat))
-titleWidth = 72 #zero means no limit
-durationWidth = len('9999 days')
-tableStyle = 'border="1" cellspacing="0" cellpadding="3"'
-
-def formatDuration(duration):
- "Change a duration into text."
- return "%4.0f days" % (duration.as_seconds()/(3600.*24),)
-
-def closedOrBlank(statusID):
- "CLOSED or a blank"
- if statusID in resolvedStatusIDS:
- return "CLOSED"
- else:
- return ""
-
-# Additional filter(s) to use in picking out issues.
-# Depends on the schema.
-notThese = ('notices', 'faq', 'todo', 'feature', 'wish')
-try:
- priorityIDS = db.priority.getnodeids(None, retired=None)
- priorityNames = [db.priority.get(p, 'name') for p in priorityIDS]
- acceptablePriorities = [priorityIDS[i] for i in range(len(priorityIDS)) if \
- priorityNames[i] not in notThese]
-except: #for example, priority is not in the schema
- acceptablePriorities = []
-
-def F(**args):
- "args is the dictionary to return, with additions if desired."
- if acceptablePriorities:
- args['priority'] = acceptablePriorities
- return args
-
-
-# Set what fields show up in the different tables.
-# May need to modify getIssueAttribute to match your schema or add things
-# you want to compute.
-# Each field has a title and a field width.
-# In text mode:
-# a title of '<br>, indent' means a new line is started for each
-# record, and the integer indent indicates an indentation for the next line.
-# If used, titles are omitted.
-# In html mode, <br> is ignored.
-
-### Text Tables
-# The 'Created or Repopened' table, text mode.
-createdTable = """
- titles:
- 'Title', titleWidth - dateWidth
- 'Date', dateWidth
- '<br>', 0
- 'Status', len('CLOSED')
- 'Link', linkWidth
- 'Action', actionWidth
- 'By', userNameWidth
- records:
- title
- (reopenedDate or creation).pretty(format=dateFormat)
- closedOrBlank(statusID)
- link(issueNumber)
- reopened or 'created'
- reopener or creator
-"""
-# The table of closed issues, text mode.
-closedTable = """
- titles:
- 'Title', titleWidth - durationWidth
- 'Duration', durationWidth
- '<br>', len('CLOSED')
- 'Link', linkWidth
- 'By', userNameWidth
- records:
- title
- formatDuration(duration)
- link(issueNumber)
- actor
-"""
-# The table of most discussed issues, text mode.
-discussedTable = """
- titles:
- 'N', -3
- 'Title', titleWidth - durationWidth
- 'Duration', durationWidth
- '<br>', 0
- 'Status', statusWidth
- 'Link', linkWidth
- records:
- numberOfMessages
- title
- formatDuration(duration)
- status
- link(issueNumber)
-"""
-
-### HTML Tables
-# The 'Created or Repopened' table, html mode.
-# Titles are links, use zero on width; limit is applied in hlink
-htmlCreatedTable = """
- titles:
- 'Title', 0
- 'Status', len('CLOSED')
- 'Date', dateWidth
- 'Action', actionWidth
- 'By', userNameWidth
- records:
- hlink(issueNumber, title)
- closedOrBlank(statusID)
- (reopenedDate or creation).pretty(format=dateFormat)
- reopened or 'created'
- reopener or creator
-"""
-# The table of closed issues, html mode.
-htmlClosedTable = """
- titles:
- 'Title', 0
- 'By', userNameWidth
- 'Duration', durationWidth
- records:
- hlink(issueNumber, title)
- actor
- formatDuration(duration)
-"""
-# The table of most discussed issues, text mode.
-htmlDiscussedTable = """
- titles:
- 'N', -3
- 'Title', 0
- 'Status', statusWidth
- 'Duration', durationWidth
- records:
- numberOfMessages
- hlink(issueNumber, title)
- status
- formatDuration(duration)
-"""
-### END CUSTOMIZATION
-# well, except you might want to add to this...locals in this
-# routine are usable in the 'records' section of tables.
-
-def getIssueAttributes (issueID):
- """Return dictionaries with the issue's title, etc.
- Assumes issue was created no later than to_value
- """
- issueNumber = issueID
- reopened = ''
- reopener = ''
- reopenedDate = ''
- numberOfMessages = 0
- status2 = db.issue.get(issueID, 'status')
- status1 = status2
- actor2 = db.issue.get(issueID, 'actor')
- activity2 = db.issue.get(issueID, 'activity')
- statusID = None
- actorID = None
- activity = None
-
-# programming note:
-# the journal records the OLD value of the status in the data field
-# So we know the end value (status2) and can walk backwards through it
-# Want to set statusID = status in effect at to_value
-# Also discover any activity that changed a resolved to non-resolved.
- journal = db.issue.history(issueID)
- journal.reverse()
-# this trick catches the first time we are in the interval of interest
- if activity2 < to_value:
- statusID = statusID or status2
- actorID = actorID or actor2
- activity = activity or activity2
-
- for x,ts,userid,act,data in journal:
- inPeriod = ts >= from_value and ts < to_value
- if inPeriod:
- statusID = statusID or status2
- actorID = actorID or actor2
- activity = activity or activity2
- if act == 'set':
- if data.has_key('messages'):
- if inPeriod:
- numberOfMessages += 1
- if data.has_key('status'):
- status1 = data['status']
- if ts < to_value:
- if (status1 in resolvedStatusIDS) and \
- (status2 not in resolvedStatusIDS):
- if not reopened: # want the latest reopener only
- if inPeriod and issueID not in recentlyCreatedIssueIDS:
- reopenedIssueIDS.append(issueID)
- reopened = 'reopened'
- reopener = userMap[userid]
- reopenedDate = ts
- actor2 = userid
- activity2 = ts
- status2 = status1
-
- messages.append((numberOfMessages, issueID))
-# get these set if not done before
- statusID = statusID or status2
- activity = activity or activity2
- actorID = actorID or actor2
-# calculate the fields for the reports
- status = statusMap[statusID]
- actor = userMap[actorID]
- title = db.issue.get(issueID, 'title')
- creation = db.issue.get(issueID, 'creation')
- creatorID = db.issue.get(issueID, 'creator')
- creator = userMap[creatorID]
- if statusID in resolvedStatusIDS:
- duration = activity - (reopenedDate or creation)
- else:
- duration = to_value - (reopenedDate or creation)
- if options.debug:
- print >>bugfile, issueID, status, creator, actor, "%4.0f" %(duration.as_seconds()/(3600.24)), \
- activity.pretty(dateFormat), reopenedDate or 'created', creation.pretty(dateFormat)
-# out of a sense of neatness
- del status1, status2, actor2, activity2
- del x, ts, userid, act, data, journal
-# return the dictionary of local values for use in evals of table values.
- return locals()
-
-### Utility
-def link (issueID):
- "url of issue whose ID is issueID"
- return "%sissue%s" % (db.config.TRACKER_WEB, issueID)
-
-def hlink (issueID, title):
- "html link to url/title of issue whose ID is issueID"
- return '''<a href="%s">%s</a>''' %(link(issueID), title[:titleWidth])
-
-def statusCompare (x, y):
- """Compare two statusIDs by order."""
- xs = db.status.get(x, 'order')
- ys = db.status.get(y, 'order')
- c = float(xs) - float(ys)
- if c >= 0.0:
- return int(c)
- else:
- return -int(abs(c))
-
-def issueIdCompare (x, y):
- """Compare two issue ids numerically."""
- return int(x) - int(y)
-
-def issueStatus(issueID):
- """Get the status name from an issueID."""
- sid = db.issue.get(issueID, 'status')
- return db.status.get(sid, 'name')
-
-def statusUsable (statusID):
- """Is this status is one we want to deal with?"""
- if db.status.is_retired(statusID):
- return False
- if db.status.get(statusID, 'name') in ignoredStatuses:
- return False
- return True
-
-#Table writers
-
-class Table (object):
- def __init__ (self, caption, issueIDS, tableSpec):
- self.caption = caption + ' (%d)' % len(issueIDS)
- self.issueIDS = issueIDS
- self.breaks = []
- self.spec = self.parse(tableSpec)
-
- def parse(self, tableSpec):
- """Sample:
- titles:
- 'ID', -6
- 'Title', 25
- 'Status', 30
- records:
- id
- title
- status
- """
- titles = []
- fields = []
- fws = []
- for line in tableSpec.split('\n'):
- line = line.strip()
- if not line: continue
- if line.startswith("titles"):
- inTitles = True
- elif line.startswith("records"):
- inTitles = False
- elif inTitles:
- title, fw = eval(line)
- if title == '<br>':
- self.breaks.append((len(titles), fw))
- else:
- titles.append(title)
- if fw > 0:
- fw = max(fw, len(title))
- elif fw < 0:
- fw = min(fw, -len(title))
- fws.append(fw)
- else:
- fields.append(line)
- self.titles = titles
- self.fields = fields
- self.fws = fws
- if len(titles) != len(fields):
- raise ValueError, 'Number of titles and fields do not match.'
- if len(titles) == 0:
- raise ValueError, 'No titles or fields given'
-
- def write(self, device):
- if not self.issueIDS:
- return
- fields = self.fields
- fws = self.fws
- self.prologue(device)
- self.writeFields(device, fields, fws)
- self.epilogue(device)
-
- def writeTitles(self, device, titles, fws):
- self.startTitles(device, titles, fws)
- for i in range(len(titles)):
- t = titles[i]
- fw = fws[i]
- self.startTitle(device, t, fw)
- self.writeTitle(device, t, fw)
- self.endTitle(device, t, fw)
- self.endTitles(device, titles, fws)
-
- def writeFields(self, device, fields, fws):
- for issueID in self.issueIDS:
- d = issueAttributes[issueID]
- self.startFields(d, device, fields, fws)
- for i in range(len(fields)):
- f = fields[i]
- fw = fws[i]
- self.startField(d, device, f, fw)
- self.writeField(d, device, f, fw)
- self.endField(d, device, f, fw)
- for count, indent in self.breaks:
- if i+1 == count:
- print >>device
- if indent:
- print >>device, ' '.ljust(indent),
- self.endFields(d, device, fields, fws)
-
- def prologue(self, device):
- print >>device
- print >>device, self.caption
- print >>device, '_'*len(self.caption)
- print >>device
- if not self.breaks:
- self.writeTitles(device, self.titles, self.fws)
-
- def epilogue(self, device):
- print >>device
-
- def startTitles(self, device, titles, fws):
- pass
-
- def endTitles(self, device, titles, fws):
- print >>device
-
- def startTitle(self, device, title, fw):
- pass
-
- def writeTitle(self, device, title, fw):
- fwa = abs(fw)
- if fwa > 0:
- print >>device, title[0:fwa].ljust(fwa),
- elif fwa < 0:
- print >>device, title[0:fwa].rjust(fwa),
- else:
- print >>device, title,
-
- def endTitle(self, device, title, fw):
- pass
-
- def startFields(self, d, device, fields, fws):
- pass
-
- def endFields(self, d, device, fields, fws):
- print >>device
- if self.breaks: print >>device
-
- def startField(self, d, device, field, fw):
- pass
-
- def writeField(self, d, device, field, fw):
- fwa = abs(fw)
- value = str(eval(field, d, globals()))
- if fw > 0:
- print >>device, value[0:fwa].ljust(fwa),
- elif fw < 0:
- print >>device, value[0:fwa].rjust(fwa),
- else:
- print >>device, value,
-
- def endField(self, d, device, field, fw):
- pass
-
-class htmlTable (Table):
- def __init__ (self, caption, issueIDS, tableSpec):
- super(htmlTable, self).__init__(caption, issueIDS, tableSpec)
-
- def prologue(self, device):
- print >>device, """<br><table %s>
-<caption>%s</caption>""" % (tableStyle, self.caption)
- self.writeTitles(device, self.titles, self.fws)
-
- def epilogue(self, device):
- print >>device, '</table>'
-
- def startTitles(self, device, titles, fws):
- print >>device, ' <tr>'
-
- def endTitles(self, device, titles, fws):
- print >>device, ' </tr>'
-
- def startTitle(self, device, title, fw):
- print >>device, ' <th>',
-
- def writeTitle(self, device, title, fw):
- if not title:
- title = ' '
- fwa = abs(fw)
- if fw != 0:
- print >>device, title[0:fwa],
- else:
- print >>device, title,
-
- def endTitle(self, device, title, fw):
- print >>device, '</th>'
-
- def startFields(self, d, device, fields, fws):
- print >>device, ' <tr>'
-
- def endFields(self, d, device, fields, fws):
- print >>device, ' </tr>'
-
- def startField(self, d, device, field, fw):
- print >>device, ' <td>',
-
- def writeField(self, d, device, field, fw):
- if field == 'issueRef':
- field = 'issueHRef'
- fw = 0
- value = str(eval(field, d, globals()))
- fwa = abs(fw)
- if not value:
- value = ' '
- fwa = 0
- if fwa != 0:
- print >>device, value[0:fwa],
- else:
- print >>device, value,
-
- def endField(self, d, device, field, fw):
- print >>device, '</td>'
-
-# Summary writers
-def htmlSummary(body, caption):
- "Print html summary"
- print >>body, '<h2>ACTIVITY SUMMARY ('+ from_value.pretty(format=dateFormat) + \
- ' - ' + to_value.pretty(format=dateFormat) + ')</h2>'
- print >>body, '<h3>%s at %s</h3>' % (db.config.TRACKER_NAME,
- db.config.TRACKER_WEB)
- print >>body, '<p>'
- print >>body, '''To view or respond to any of the issues listed
- below, simply click on the issue ID. Do <b>not</b> respond to
- this message.'''
- print >>body, '</p>'
- print >>body, '<p>'
- fmt = "%5d open (%+3d) / %5d closed (%+3d) / %5d total (%+3d)"
- print >>body, '</p>'
- print >>body, '<p>'
- print >>body, fmt % \
- (nOpen, nOpen-nOpenOld, nClosed, nClosed-nClosedOld, (nOpen+nClosed),
- (nOpen+nClosed)-(nOpenOld+nClosedOld))
- print >>body, '</p>'
- print >>body, "<p>"
- print >>body, '<p></p>'
- print >>body, "Average duration of open issues:", "%3.0f" % averageDuration, "days."
- print >>body, '</p>'
- print >>body, "<p>"
- print >>body, "Median duration of open issues:", "%3.0f" % medianDuration, "days."
- print >>body, '</p>'
- print >>body, '<p>'
- print >>body, '''<table %s>
-<caption>%s</caption>
-<tr><th>STATUS</th> <th>Number</th><th>Change</th></tr>
-''' % (tableStyle, caption)
- for sid in statusIDS:
- if sid in resolvedStatusIDS:
- continue
- (ntot, nold) = statusReport[sid]
- name = statusMap[sid]
- print >>body, '''<tr><td>%s</td><td>%6d</td><td>%+6d</td></tr>'''% \
- (name, ntot, ntot-nold)
- print >>body, '</table>'
- print >>body, '</p>'
-
-
-def textSummary(body, caption):
- "Print text summary"
- print >>body
- print >>body, 'ACTIVITY SUMMARY ('+ from_value.pretty(format=dateFormat) + \
- ' - ' + to_value.pretty(format=dateFormat) + ')'
- print >>body, '%s at %s' % (db.config.TRACKER_NAME, db.config.TRACKER_WEB)
- print >>body, '''
-To view or respond to any of the issues listed below, click on the issue
-number. Do NOT respond to this message.
-'''
- print >>body
- fmt = "%5d open (%+3d) / %5d closed (%+3d) / %5d total (%+3d)"
- print >>body, fmt % \
- (nOpen, nOpen-nOpenOld, nClosed, nClosed-nClosedOld, (nOpen+nClosed),
- (nOpen+nClosed)-(nOpenOld+nClosedOld))
- print >>body
- print >>body, "Average duration of open issues:", "%3.0f" % averageDuration, "days."
- print >>body, "Median duration of open issues:", "%3.0f" % medianDuration, "days."
- print >>body
- print >>body, caption
- for sid in statusIDS:
- if sid in resolvedStatusIDS:
- continue
- (ntot, nold) = statusReport[sid]
- name = statusMap[sid]
- fmt = "%" + str(statusWidth) + "s %5d (%+3d)"
- print >>body, (fmt % (name, ntot, ntot-nold))
-
-
-def writeTextReport(device):
- "Write the report in text mode to device."
- textSummary(device, "Open Issues Breakdown")
-
- if not options.brief:
- messages.sort()
- messages.reverse()
- t = Table('Issues Created Or Reopened', createdOrReopenedIDS,
- createdTable)
- t.write(device)
-
- t = Table('Issues Now Closed', recentlyClosedIssueIDS, closedTable)
- t.write(device)
-
- t = Table('Top Issues Most Discussed', discussedIDS, discussedTable)
- t.write(device)
-
-def writeHtmlReport(device):
- "Write the report in html mode to device."
- htmlSummary(device, 'Open Issues Breakdown')
- if not options.brief:
- t = htmlTable('Issues Created Or Reopened', createdOrReopenedIDS,
- htmlCreatedTable)
- t.write(device)
-
- t = htmlTable('Issues Now Closed', recentlyClosedIssueIDS,
- htmlClosedTable)
- t.write(device)
-
- t = htmlTable('Top Issues Most Discussed', discussedIDS,
- htmlDiscussedTable)
- t.write(device)
-
-def writeAudit(device, issueIDS):
- "Write audit of given issues to device."
- for issueID in issueIDS:
- journal = db.issue.history(issueID)
- for x,ts,userid,act,data in journal:
- user = db.user.get(userid, 'username')
- print >>device, x, ts, '%s (%s)'%(userid, user), act
- if act == 'set':
- for d, v in data.items():
- print >>device, ' ', d, ': ', v
-
-# Options processing
-if options.debug:
- bugfile = open('bugfile', 'w')
- print >>bugfile, options
-# ...dates
-dates = roundup.date.Range(options.dates, roundup.date.Date)
-from_value = dates.from_value
-to_value = dates.to_value
-if to_value is None:
- to_value = roundup.date.Date('.')
-if from_value is None:
- from_value = roundup.date.Date('1980-1-1')
-early_period = ';%s'%from_value
-whole_period = ';%s'%to_value
-# ... statuses
-ignoredStatuses = [s.strip() for s in options.ignore.split(',')]
-if options.debug:
- print >>bugfile, "from,to", from_value, to_value
- print >>bugfile, 'acceptablePriorities', acceptablePriorities
- print >>bugfile, 'ignoredStatuses', ignoredStatuses
-
-# ...audit
-if options.audit:
-# Name of audit file (not usually used by normal users)
- auditFilename = 'audit.txt'
- if options.audit not in ['all', 'recent']:
- raise ValueError, "AUDIT option must be 'recent' or 'all'."
-
- audit = open(auditFilename, 'w')
- if options.audit == 'recent':
- IDS = db.issue.filter(None, F(activity=options.dates))
- elif options.audit == 'full':
- IDS = db.issue.filter(None, F(activity=whole_period))
- IDS.sort(issueIdCompare)
- writeAudit(audit, IDS)
- audit.close()
- sys.exit(0)
-
-
-# MAIN
-# Make lists and maps of things
-# Users
-adminUserID = db.user.lookup('admin')
-userIDS_ALL = db.user.getnodeids(retired=None)
-userMap = {None: adminUserID}
-userLookup = {}
-for userid in userIDS_ALL:
- name = db.user.get(userid, 'username')
- userLookup[name] = userid
- userMap[userid] = name
-userNames = userLookup.keys()
-userNameWidth = max([len(uname) for uname in userNames])
-
-# Make map of statuses / names, including retired ones.
-# Just doing lookup on names will miss retired ones.
-statusIDS_ALL = db.status.getnodeids(retired=None)
-statusLookup = {'None': None}
-statusMap = {None: 'None'}
-for sid in statusIDS_ALL:
- name = db.status.get(sid, 'name')
- statusLookup[name] = sid
- statusMap[sid] = name
-
-# Make a list of statuses considered closed (including retired ones)
-resolvedStatusOptions = options.resolved.split(',')
-resolvedStatusIDS = []
-for r in resolvedStatusOptions:
- try:
- sid = statusLookup[r]
- resolvedStatusIDS.append(sid)
- except KeyError:
- pass
-
-if options.debug:
- print >>bugfile, 'statusLookup', statusLookup
- print >>bugfile, 'resolvedStatusIDS', resolvedStatusIDS
-
-statusIDS = [sid for sid in statusIDS_ALL if statusUsable(sid)]
-statusIDS.sort(statusCompare)
-statusNames = [statusMap[sid] for sid in statusIDS]
-statusWidth = max([len(s) for s in statusLookup.keys()])
-actionWidth = len('reopened')
-
-# Important note: for 'all', get only issues created before to_value
-# Not just efficient, simplifies logic in getIssueAttribue
-
-# All issues
-allIssueIDS = db.issue.filter(None, F(creation=whole_period))
-allIssueIDS.sort(issueIdCompare)
-earlyIssueIDS = db.issue.filter(None, F(creation=early_period))
-
-# Closed issues
-oldClosedIssueIDS = db.issue.filter(None, F(creation=early_period,
- status= resolvedStatusIDS))
-closedIssueIDS = db.issue.filter(None,F(creation=whole_period,
- status=resolvedStatusIDS))
-recentlyClosedIssueIDS=db.issue.filter(None, F(activity=options.dates,
- status=resolvedStatusIDS))
-recentlyClosedIssueIDS.sort(issueIdCompare)
-
-# Created and reopened issues
-recentlyCreatedIssueIDS=db.issue.filter(None, F(creation=options.dates))
-reopenedIssueIDS = [] # gets accumulated in getIssueAttributes
-
-# Create more data needed for reports
-if allIssueIDS:
- maxIssueID = allIssueIDS[-1]
-else:
- maxIssueID = 1000
-linkWidth = len(link(maxIssueID))
-messages = []
-statusReport = {}
-issueAttributes = {}
-
-for issueID in allIssueIDS:
- issueAttributes[issueID] = getIssueAttributes(issueID)
-
-createdOrReopenedIDS = reopenedIssueIDS + recentlyCreatedIssueIDS
-createdOrReopenedIDS.sort(issueIdCompare)
-
-durations = [issueAttributes[id]['duration'].as_seconds()/(3600*24.) for id in allIssueIDS if \
- id not in closedIssueIDS]
-if durations:
- if options.debug:
- print >>bugfile, 'Min duration:', durations[-1], '; max', durations[0]
- averageDuration = sum(durations)/len(durations)
- medianDuration = durations[len(durations)/2]
-else:
- averageDuration = "N/A"
- medianDuration = "N/A"
-
-messages.sort()
-messages.reverse()
-discussedIDS = [id for (num, id) in messages[:discussionMax] \
- if num >= discussionThreshold]
-
-nTotal = len(allIssueIDS)
-nTotalOld = len(earlyIssueIDS)
-nClosed = len(closedIssueIDS)
-nClosedOld = len(oldClosedIssueIDS)
-nOpen = nTotal - nClosed
-nOpenOld = nTotalOld - nClosedOld
-
-for sid in statusIDS:
- IDS = db.issue.filter(None, F(creation=whole_period, status=sid))
- IDSOld = db.issue.filter(None, F(creation=early_period, status=sid))
- nNow, nThen = (len(IDS), len(IDSOld))
- statusReport[sid] = (nNow, nThen)
-
-
-# Now make the reports
-textReport = cStringIO.StringIO()
-writeTextReport(textReport)
-
-htmlReport = cStringIO.StringIO()
-writeHtmlReport(htmlReport)
-
-def sendReport (recipient):
- "Send the email message."
- message = cStringIO.StringIO()
- writer = MimeWriter.MimeWriter(message)
- writer.addheader('Subject', 'Summary of %s Issues'%db.config.TRACKER_NAME)
- writer.addheader('To', recipient)
- writer.addheader('From', '%s <%s>'%(db.config.TRACKER_NAME, db.config.ADMIN_EMAIL))
- writer.addheader('Reply-To', '%s <%s>'%(db.config.TRACKER_NAME, 'DONOTREPLY at NOWHERE.ORG'))
- writer.addheader('MIME-Version', '1.0')
- writer.addheader('X-Roundup-Name', db.config.TRACKER_NAME)
-
- # start the multipart
- part = writer.startmultipartbody('alternative')
- part = writer.nextpart()
- body = part.startbody('text/plain')
-
- # do the plain text bit
- print >>body, textReport.getvalue()
-
- # now the HTML one
- part = writer.nextpart()
- body = part.startbody('text/html')
- print >>body, htmlReport.getvalue()
- # finish off the multipart
- writer.lastpart()
- # all done, send!
- if options.debug:
- print message.getvalue()
- else:
- smtp = SMTPConnection(db.config)
- smtp.sendmail(db.config.ADMIN_EMAIL, options.mailTo, message.getvalue())
-
-# Email? Or just print it.
-if not options.mailTo:
- print textReport.getvalue()
-else:
- for recipient in options.mailTo.split(','):
- sendReport(recipient)
-
+#!/usr/bin/python
+# Make sure you are using the python that has the roundup runtime used by the
+# tracker. Requires Python 2.3 or later.
+#
+# Script to create a tracker summary by Richard Jones and Paul Dubois
+import sys, math
+# kludge
+sys.path.insert(1,'/home/roundup/roundup/lib/python2.4/site-packages')
+#
+import roundup
+import roundup.date, roundup.instance
+import optparse , datetime
+import cStringIO, MimeWriter, smtplib
+from roundup.mailer import SMTPConnection
+
+### CONFIGURATION
+# List of statuses to treat as closed -- these don't have to all exist.
+resolvedStatusDefault = 'resolved,done,done-cbb,closed,faq'
+
+# Period of time for report. Uses roundup syntax for ranges.
+defaultDates = '-1w;'
+
+# Email address of recipient of report, if any.
+defaultMailTo = ''
+
+# Comma-delimited list of statuses not to include in report.
+ignoredStatusesDefault = ''
+
+# Number of most-discussed messages to display
+discussionMax = 10
+
+# Smallest number of messages to be eligible for 'most discussed'.
+discussionThreshold = 3
+
+# Date format per time.strftime
+dateFormat="%x" #locale-appropriate
+
+##### END CONFIGURATION; ALSO SEE CUSTOMIZATION BELOW
+
+usage = """%prog trackerHome [--mail mailTo]
+ [--dates 'date1;date2']
+ [--brief]
+ # less likely
+ [--resolved 'status1,status2']
+ [--status 'status1,status2']
+ [--output filename]
+ [--errors filename]
+ # for maintainers
+ [--DEBUG]
+ [--AUDIT recent|all]
+ dates is a roundup date range such as:
+ '-1w;' -- the last week (the default)
+ '-3m;' -- the last 3 months
+ 'from 2006-10-25 to 2006-12-25' -- Thanksgiving to Christmas, 2006
+
+ mailTo is one or more email addresses, comma delimited.
+ resolved is a list of statuses to treat as 'closed'.
+ statuses are names of statuses to ignore.
+
+ Be sure to protect commas and semicolons from the shell with quotes!
+ Execute %prog --help for detailed help
+"""
+#### Options
+parser = optparse.OptionParser(usage=usage)
+parser.add_option('-b','--brief', dest='brief', action='store_true',
+ default=False,
+ help='Show summary only, no tables.')
+parser.add_option('-a','--audit', dest='audit',
+ default='', metavar = 'all|recent',
+ help='Print journal for "all" or "recent" transactions, then halt.')
+parser.add_option('-D','--DEBUG', dest='debug', action='store_true',
+ default=False,
+ help='Just print the result; if mailTo set, print email but do not send it.')
+parser.add_option('-d','--dates', dest='dates',
+ default=defaultDates, metavar="'from;to'",
+ help="""Specification for range of dates, such as: \
+'-1w;' -- previous week;
+'-1y;' -- previous year;
+'from 2006-11-1 to 2006-12-1' -- exact period""")
+parser.add_option('-m','--mail', dest='mailTo',
+ default=defaultMailTo,
+ help='Mail the report to the address(es) mailTo; if not given, print report.')
+parser.add_option('-s','--status', dest='ignore', metavar="STATUSES_TO_IGNORE",
+ default=ignoredStatusesDefault,
+ help="Comma-delimited list of statuses to ignore in report.")
+parser.add_option('-r','--resolved', dest='resolved',
+ default=resolvedStatusDefault,
+ help="Comma-delimited list of statuses that corresponds to being 'closed'.")
+parser.add_option('-o','--output', dest='output',
+ default='', metavar='FILENAME',
+ help='File name for output; default is stdout.')
+parser.add_option('-e','--errors', dest='errors',
+ default='', metavar='FILENAME',
+ help='File name for error output; default is stderr.')
+
+#### Get the command line args:
+(options, args) = parser.parse_args()
+if options.output:
+ sys.stdout = open(options.output, 'w')
+if options.errors:
+ sys.stderr = open(options.errors, 'w')
+
+if len(args) != 1:
+ parser.error("""Incorrect number of arguments;
+ you must supply a tracker home.""")
+instanceHome = args[0]
+# Open the instance
+instance = roundup.instance.open(instanceHome)
+db = instance.open('admin')
+
+# CUSTOMIZATION
+dateWidth = len(roundup.date.Date('2006-09-30').pretty(format=dateFormat))
+titleWidth = 72 #zero means no limit
+durationWidth = len('9999 days')
+tableStyle = 'border="1" cellspacing="0" cellpadding="3"'
+
+def formatDuration(duration):
+ "Change a duration into text."
+ return "%4.0f days" % (duration.as_seconds()/(3600.*24),)
+
+def closedOrBlank(statusID):
+ "CLOSED or a blank"
+ if statusID in resolvedStatusIDS:
+ return "CLOSED"
+ else:
+ return ""
+
+# Additional filter(s) to use in picking out issues.
+# Depends on the schema.
+notThese = ('notices', 'faq', 'todo', 'feature', 'wish')
+try:
+ priorityIDS = db.priority.getnodeids(None, retired=None)
+ priorityNames = [db.priority.get(p, 'name') for p in priorityIDS]
+ acceptablePriorities = [priorityIDS[i] for i in range(len(priorityIDS)) if \
+ priorityNames[i] not in notThese]
+except: #for example, priority is not in the schema
+ acceptablePriorities = []
+
+def F(**args):
+ "args is the dictionary to return, with additions if desired."
+ if acceptablePriorities:
+ args['priority'] = acceptablePriorities
+ return args
+
+
+# Set what fields show up in the different tables.
+# May need to modify getIssueAttribute to match your schema or add things
+# you want to compute.
+# Each field has a title and a field width.
+# In text mode:
+# a title of '<br>, indent' means a new line is started for each
+# record, and the integer indent indicates an indentation for the next line.
+# If used, titles are omitted.
+# In html mode, <br> is ignored.
+
+### Text Tables
+# The 'Created or Repopened' table, text mode.
+createdTable = """
+ titles:
+ 'Title', titleWidth - dateWidth
+ 'Date', dateWidth
+ '<br>', 0
+ 'Status', len('CLOSED')
+ 'Link', linkWidth
+ 'Action', actionWidth
+ 'By', userNameWidth
+ records:
+ title
+ (reopenedDate or creation).pretty(format=dateFormat)
+ closedOrBlank(statusID)
+ link(issueNumber)
+ reopened or 'created'
+ reopener or creator
+"""
+# The table of closed issues, text mode.
+closedTable = """
+ titles:
+ 'Title', titleWidth - durationWidth
+ 'Duration', durationWidth
+ '<br>', len('CLOSED')
+ 'Link', linkWidth
+ 'By', userNameWidth
+ records:
+ title
+ formatDuration(duration)
+ link(issueNumber)
+ actor
+"""
+# The table of most discussed issues, text mode.
+discussedTable = """
+ titles:
+ 'N', -3
+ 'Title', titleWidth - durationWidth
+ 'Duration', durationWidth
+ '<br>', 0
+ 'Status', statusWidth
+ 'Link', linkWidth
+ records:
+ numberOfMessages
+ title
+ formatDuration(duration)
+ status
+ link(issueNumber)
+"""
+
+### HTML Tables
+# The 'Created or Repopened' table, html mode.
+# Titles are links, use zero on width; limit is applied in hlink
+htmlCreatedTable = """
+ titles:
+ 'Title', 0
+ 'Status', len('CLOSED')
+ 'Date', dateWidth
+ 'Action', actionWidth
+ 'By', userNameWidth
+ records:
+ hlink(issueNumber, title)
+ closedOrBlank(statusID)
+ (reopenedDate or creation).pretty(format=dateFormat)
+ reopened or 'created'
+ reopener or creator
+"""
+# The table of closed issues, html mode.
+htmlClosedTable = """
+ titles:
+ 'Title', 0
+ 'By', userNameWidth
+ 'Duration', durationWidth
+ records:
+ hlink(issueNumber, title)
+ actor
+ formatDuration(duration)
+"""
+# The table of most discussed issues, text mode.
+htmlDiscussedTable = """
+ titles:
+ 'N', -3
+ 'Title', 0
+ 'Status', statusWidth
+ 'Duration', durationWidth
+ records:
+ numberOfMessages
+ hlink(issueNumber, title)
+ status
+ formatDuration(duration)
+"""
+### END CUSTOMIZATION
+# well, except you might want to add to this...locals in this
+# routine are usable in the 'records' section of tables.
+
+def getIssueAttributes (issueID):
+ """Return dictionaries with the issue's title, etc.
+ Assumes issue was created no later than to_value
+ """
+ issueNumber = issueID
+ reopened = ''
+ reopener = ''
+ reopenedDate = ''
+ numberOfMessages = 0
+ status2 = db.issue.get(issueID, 'status')
+ status1 = status2
+ actor2 = db.issue.get(issueID, 'actor')
+ activity2 = db.issue.get(issueID, 'activity')
+ statusID = None
+ actorID = None
+ activity = None
+
+# programming note:
+# the journal records the OLD value of the status in the data field
+# So we know the end value (status2) and can walk backwards through it
+# Want to set statusID = status in effect at to_value
+# Also discover any activity that changed a resolved to non-resolved.
+ journal = db.issue.history(issueID)
+ journal.reverse()
+# this trick catches the first time we are in the interval of interest
+ if activity2 < to_value:
+ statusID = statusID or status2
+ actorID = actorID or actor2
+ activity = activity or activity2
+
+ for x,ts,userid,act,data in journal:
+ inPeriod = ts >= from_value and ts < to_value
+ if inPeriod:
+ statusID = statusID or status2
+ actorID = actorID or actor2
+ activity = activity or activity2
+ if act == 'set':
+ if data.has_key('messages'):
+ if inPeriod:
+ numberOfMessages += 1
+ if data.has_key('status'):
+ status1 = data['status']
+ if ts < to_value:
+ if (status1 in resolvedStatusIDS) and \
+ (status2 not in resolvedStatusIDS):
+ if not reopened: # want the latest reopener only
+ if inPeriod and issueID not in recentlyCreatedIssueIDS:
+ reopenedIssueIDS.append(issueID)
+ reopened = 'reopened'
+ reopener = userMap[userid]
+ reopenedDate = ts
+ actor2 = userid
+ activity2 = ts
+ status2 = status1
+
+ messages.append((numberOfMessages, issueID))
+# get these set if not done before
+ statusID = statusID or status2
+ activity = activity or activity2
+ actorID = actorID or actor2
+# calculate the fields for the reports
+ status = statusMap[statusID]
+ actor = userMap[actorID]
+ title = db.issue.get(issueID, 'title')
+ creation = db.issue.get(issueID, 'creation')
+ creatorID = db.issue.get(issueID, 'creator')
+ creator = userMap[creatorID]
+ if statusID in resolvedStatusIDS:
+ duration = activity - (reopenedDate or creation)
+ else:
+ duration = to_value - (reopenedDate or creation)
+ if options.debug:
+ print >>bugfile, issueID, status, creator, actor, "%4.0f" %(duration.as_seconds()/(3600.24)), \
+ activity.pretty(dateFormat), reopenedDate or 'created', creation.pretty(dateFormat)
+# out of a sense of neatness
+ del status1, status2, actor2, activity2
+ del x, ts, userid, act, data, journal
+# return the dictionary of local values for use in evals of table values.
+ return locals()
+
+### Utility
+def link (issueID):
+ "url of issue whose ID is issueID"
+ return "%sissue%s" % (db.config.TRACKER_WEB, issueID)
+
+def hlink (issueID, title):
+ "html link to url/title of issue whose ID is issueID"
+ return '''<a href="%s">%s</a>''' %(link(issueID), title[:titleWidth])
+
+def statusCompare (x, y):
+ """Compare two statusIDs by order."""
+ xs = db.status.get(x, 'order')
+ ys = db.status.get(y, 'order')
+ c = float(xs) - float(ys)
+ if c >= 0.0:
+ return int(c)
+ else:
+ return -int(abs(c))
+
+def issueIdCompare (x, y):
+ """Compare two issue ids numerically."""
+ return int(x) - int(y)
+
+def issueStatus(issueID):
+ """Get the status name from an issueID."""
+ sid = db.issue.get(issueID, 'status')
+ return db.status.get(sid, 'name')
+
+def statusUsable (statusID):
+ """Is this status is one we want to deal with?"""
+ if db.status.is_retired(statusID):
+ return False
+ if db.status.get(statusID, 'name') in ignoredStatuses:
+ return False
+ return True
+
+#Table writers
+
+class Table (object):
+ def __init__ (self, caption, issueIDS, tableSpec):
+ self.caption = caption + ' (%d)' % len(issueIDS)
+ self.issueIDS = issueIDS
+ self.breaks = []
+ self.spec = self.parse(tableSpec)
+
+ def parse(self, tableSpec):
+ """Sample:
+ titles:
+ 'ID', -6
+ 'Title', 25
+ 'Status', 30
+ records:
+ id
+ title
+ status
+ """
+ titles = []
+ fields = []
+ fws = []
+ for line in tableSpec.split('\n'):
+ line = line.strip()
+ if not line: continue
+ if line.startswith("titles"):
+ inTitles = True
+ elif line.startswith("records"):
+ inTitles = False
+ elif inTitles:
+ title, fw = eval(line)
+ if title == '<br>':
+ self.breaks.append((len(titles), fw))
+ else:
+ titles.append(title)
+ if fw > 0:
+ fw = max(fw, len(title))
+ elif fw < 0:
+ fw = min(fw, -len(title))
+ fws.append(fw)
+ else:
+ fields.append(line)
+ self.titles = titles
+ self.fields = fields
+ self.fws = fws
+ if len(titles) != len(fields):
+ raise ValueError, 'Number of titles and fields do not match.'
+ if len(titles) == 0:
+ raise ValueError, 'No titles or fields given'
+
+ def write(self, device):
+ if not self.issueIDS:
+ return
+ fields = self.fields
+ fws = self.fws
+ self.prologue(device)
+ self.writeFields(device, fields, fws)
+ self.epilogue(device)
+
+ def writeTitles(self, device, titles, fws):
+ self.startTitles(device, titles, fws)
+ for i in range(len(titles)):
+ t = titles[i]
+ fw = fws[i]
+ self.startTitle(device, t, fw)
+ self.writeTitle(device, t, fw)
+ self.endTitle(device, t, fw)
+ self.endTitles(device, titles, fws)
+
+ def writeFields(self, device, fields, fws):
+ for issueID in self.issueIDS:
+ d = issueAttributes[issueID]
+ self.startFields(d, device, fields, fws)
+ for i in range(len(fields)):
+ f = fields[i]
+ fw = fws[i]
+ self.startField(d, device, f, fw)
+ self.writeField(d, device, f, fw)
+ self.endField(d, device, f, fw)
+ for count, indent in self.breaks:
+ if i+1 == count:
+ print >>device
+ if indent:
+ print >>device, ' '.ljust(indent),
+ self.endFields(d, device, fields, fws)
+
+ def prologue(self, device):
+ print >>device
+ print >>device, self.caption
+ print >>device, '_'*len(self.caption)
+ print >>device
+ if not self.breaks:
+ self.writeTitles(device, self.titles, self.fws)
+
+ def epilogue(self, device):
+ print >>device
+
+ def startTitles(self, device, titles, fws):
+ pass
+
+ def endTitles(self, device, titles, fws):
+ print >>device
+
+ def startTitle(self, device, title, fw):
+ pass
+
+ def writeTitle(self, device, title, fw):
+ fwa = abs(fw)
+ if fwa > 0:
+ print >>device, title[0:fwa].ljust(fwa),
+ elif fwa < 0:
+ print >>device, title[0:fwa].rjust(fwa),
+ else:
+ print >>device, title,
+
+ def endTitle(self, device, title, fw):
+ pass
+
+ def startFields(self, d, device, fields, fws):
+ pass
+
+ def endFields(self, d, device, fields, fws):
+ print >>device
+ if self.breaks: print >>device
+
+ def startField(self, d, device, field, fw):
+ pass
+
+ def writeField(self, d, device, field, fw):
+ fwa = abs(fw)
+ value = str(eval(field, d, globals()))
+ if fw > 0:
+ print >>device, value[0:fwa].ljust(fwa),
+ elif fw < 0:
+ print >>device, value[0:fwa].rjust(fwa),
+ else:
+ print >>device, value,
+
+ def endField(self, d, device, field, fw):
+ pass
+
+class htmlTable (Table):
+ def __init__ (self, caption, issueIDS, tableSpec):
+ super(htmlTable, self).__init__(caption, issueIDS, tableSpec)
+
+ def prologue(self, device):
+ print >>device, """<br><table %s>
+<caption>%s</caption>""" % (tableStyle, self.caption)
+ self.writeTitles(device, self.titles, self.fws)
+
+ def epilogue(self, device):
+ print >>device, '</table>'
+
+ def startTitles(self, device, titles, fws):
+ print >>device, ' <tr>'
+
+ def endTitles(self, device, titles, fws):
+ print >>device, ' </tr>'
+
+ def startTitle(self, device, title, fw):
+ print >>device, ' <th>',
+
+ def writeTitle(self, device, title, fw):
+ if not title:
+ title = ' '
+ fwa = abs(fw)
+ if fw != 0:
+ print >>device, title[0:fwa],
+ else:
+ print >>device, title,
+
+ def endTitle(self, device, title, fw):
+ print >>device, '</th>'
+
+ def startFields(self, d, device, fields, fws):
+ print >>device, ' <tr>'
+
+ def endFields(self, d, device, fields, fws):
+ print >>device, ' </tr>'
+
+ def startField(self, d, device, field, fw):
+ print >>device, ' <td>',
+
+ def writeField(self, d, device, field, fw):
+ if field == 'issueRef':
+ field = 'issueHRef'
+ fw = 0
+ value = str(eval(field, d, globals()))
+ fwa = abs(fw)
+ if not value:
+ value = ' '
+ fwa = 0
+ if fwa != 0:
+ print >>device, value[0:fwa],
+ else:
+ print >>device, value,
+
+ def endField(self, d, device, field, fw):
+ print >>device, '</td>'
+
+# Summary writers
+def htmlSummary(body, caption):
+ "Print html summary"
+ print >>body, '<h2>ACTIVITY SUMMARY ('+ from_value.pretty(format=dateFormat) + \
+ ' - ' + to_value.pretty(format=dateFormat) + ')</h2>'
+ print >>body, '<h3>%s at %s</h3>' % (db.config.TRACKER_NAME,
+ db.config.TRACKER_WEB)
+ print >>body, '<p>'
+ print >>body, '''To view or respond to any of the issues listed
+ below, simply click on the issue ID. Do <b>not</b> respond to
+ this message.'''
+ print >>body, '</p>'
+ print >>body, '<p>'
+ fmt = "%5d open (%+3d) / %5d closed (%+3d) / %5d total (%+3d)"
+ print >>body, '</p>'
+ print >>body, '<p>'
+ print >>body, fmt % \
+ (nOpen, nOpen-nOpenOld, nClosed, nClosed-nClosedOld, (nOpen+nClosed),
+ (nOpen+nClosed)-(nOpenOld+nClosedOld))
+ print >>body, '</p>'
+ print >>body, "<p>"
+ print >>body, '<p></p>'
+ print >>body, "Average duration of open issues:", "%3.0f" % averageDuration, "days."
+ print >>body, '</p>'
+ print >>body, "<p>"
+ print >>body, "Median duration of open issues:", "%3.0f" % medianDuration, "days."
+ print >>body, '</p>'
+ print >>body, '<p>'
+ print >>body, '''<table %s>
+<caption>%s</caption>
+<tr><th>STATUS</th> <th>Number</th><th>Change</th></tr>
+''' % (tableStyle, caption)
+ for sid in statusIDS:
+ if sid in resolvedStatusIDS:
+ continue
+ (ntot, nold) = statusReport[sid]
+ name = statusMap[sid]
+ print >>body, '''<tr><td>%s</td><td>%6d</td><td>%+6d</td></tr>'''% \
+ (name, ntot, ntot-nold)
+ print >>body, '</table>'
+ print >>body, '</p>'
+
+
+def textSummary(body, caption):
+ "Print text summary"
+ print >>body
+ print >>body, 'ACTIVITY SUMMARY ('+ from_value.pretty(format=dateFormat) + \
+ ' - ' + to_value.pretty(format=dateFormat) + ')'
+ print >>body, '%s at %s' % (db.config.TRACKER_NAME, db.config.TRACKER_WEB)
+ print >>body, '''
+To view or respond to any of the issues listed below, click on the issue
+number. Do NOT respond to this message.
+'''
+ print >>body
+ fmt = "%5d open (%+3d) / %5d closed (%+3d) / %5d total (%+3d)"
+ print >>body, fmt % \
+ (nOpen, nOpen-nOpenOld, nClosed, nClosed-nClosedOld, (nOpen+nClosed),
+ (nOpen+nClosed)-(nOpenOld+nClosedOld))
+ print >>body
+ print >>body, "Average duration of open issues:", "%3.0f" % averageDuration, "days."
+ print >>body, "Median duration of open issues:", "%3.0f" % medianDuration, "days."
+ print >>body
+ print >>body, caption
+ for sid in statusIDS:
+ if sid in resolvedStatusIDS:
+ continue
+ (ntot, nold) = statusReport[sid]
+ name = statusMap[sid]
+ fmt = "%" + str(statusWidth) + "s %5d (%+3d)"
+ print >>body, (fmt % (name, ntot, ntot-nold))
+
+
+def writeTextReport(device):
+ "Write the report in text mode to device."
+ textSummary(device, "Open Issues Breakdown")
+
+ if not options.brief:
+ messages.sort()
+ messages.reverse()
+ t = Table('Issues Created Or Reopened', createdOrReopenedIDS,
+ createdTable)
+ t.write(device)
+
+ t = Table('Issues Now Closed', recentlyClosedIssueIDS, closedTable)
+ t.write(device)
+
+ t = Table('Top Issues Most Discussed', discussedIDS, discussedTable)
+ t.write(device)
+
+def writeHtmlReport(device):
+ "Write the report in html mode to device."
+ htmlSummary(device, 'Open Issues Breakdown')
+ if not options.brief:
+ t = htmlTable('Issues Created Or Reopened', createdOrReopenedIDS,
+ htmlCreatedTable)
+ t.write(device)
+
+ t = htmlTable('Issues Now Closed', recentlyClosedIssueIDS,
+ htmlClosedTable)
+ t.write(device)
+
+ t = htmlTable('Top Issues Most Discussed', discussedIDS,
+ htmlDiscussedTable)
+ t.write(device)
+
+def writeAudit(device, issueIDS):
+ "Write audit of given issues to device."
+ for issueID in issueIDS:
+ journal = db.issue.history(issueID)
+ for x,ts,userid,act,data in journal:
+ user = db.user.get(userid, 'username')
+ print >>device, x, ts, '%s (%s)'%(userid, user), act
+ if act == 'set':
+ for d, v in data.items():
+ print >>device, ' ', d, ': ', v
+
+# Options processing
+if options.debug:
+ bugfile = open('bugfile', 'w')
+ print >>bugfile, options
+# ...dates
+dates = roundup.date.Range(options.dates, roundup.date.Date)
+from_value = dates.from_value
+to_value = dates.to_value
+if to_value is None:
+ to_value = roundup.date.Date('.')
+if from_value is None:
+ from_value = roundup.date.Date('1980-1-1')
+early_period = ';%s'%from_value
+whole_period = ';%s'%to_value
+# ... statuses
+ignoredStatuses = [s.strip() for s in options.ignore.split(',')]
+if options.debug:
+ print >>bugfile, "from,to", from_value, to_value
+ print >>bugfile, 'acceptablePriorities', acceptablePriorities
+ print >>bugfile, 'ignoredStatuses', ignoredStatuses
+
+# ...audit
+if options.audit:
+# Name of audit file (not usually used by normal users)
+ auditFilename = 'audit.txt'
+ if options.audit not in ['all', 'recent']:
+ raise ValueError, "AUDIT option must be 'recent' or 'all'."
+
+ audit = open(auditFilename, 'w')
+ if options.audit == 'recent':
+ IDS = db.issue.filter(None, F(activity=options.dates))
+ elif options.audit == 'full':
+ IDS = db.issue.filter(None, F(activity=whole_period))
+ IDS.sort(issueIdCompare)
+ writeAudit(audit, IDS)
+ audit.close()
+ sys.exit(0)
+
+
+# MAIN
+# Make lists and maps of things
+# Users
+adminUserID = db.user.lookup('admin')
+userIDS_ALL = db.user.getnodeids(retired=None)
+userMap = {None: adminUserID}
+userLookup = {}
+for userid in userIDS_ALL:
+ name = db.user.get(userid, 'username')
+ userLookup[name] = userid
+ userMap[userid] = name
+userNames = userLookup.keys()
+userNameWidth = max([len(uname) for uname in userNames])
+
+# Make map of statuses / names, including retired ones.
+# Just doing lookup on names will miss retired ones.
+statusIDS_ALL = db.status.getnodeids(retired=None)
+statusLookup = {'None': None}
+statusMap = {None: 'None'}
+for sid in statusIDS_ALL:
+ name = db.status.get(sid, 'name')
+ statusLookup[name] = sid
+ statusMap[sid] = name
+
+# Make a list of statuses considered closed (including retired ones)
+resolvedStatusOptions = options.resolved.split(',')
+resolvedStatusIDS = []
+for r in resolvedStatusOptions:
+ try:
+ sid = statusLookup[r]
+ resolvedStatusIDS.append(sid)
+ except KeyError:
+ pass
+
+if options.debug:
+ print >>bugfile, 'statusLookup', statusLookup
+ print >>bugfile, 'resolvedStatusIDS', resolvedStatusIDS
+
+statusIDS = [sid for sid in statusIDS_ALL if statusUsable(sid)]
+statusIDS.sort(statusCompare)
+statusNames = [statusMap[sid] for sid in statusIDS]
+statusWidth = max([len(s) for s in statusLookup.keys()])
+actionWidth = len('reopened')
+
+# Important note: for 'all', get only issues created before to_value
+# Not just efficient, simplifies logic in getIssueAttribue
+
+# All issues
+allIssueIDS = db.issue.filter(None, F(creation=whole_period))
+allIssueIDS.sort(issueIdCompare)
+earlyIssueIDS = db.issue.filter(None, F(creation=early_period))
+
+# Closed issues
+oldClosedIssueIDS = db.issue.filter(None, F(creation=early_period,
+ status= resolvedStatusIDS))
+closedIssueIDS = db.issue.filter(None,F(creation=whole_period,
+ status=resolvedStatusIDS))
+recentlyClosedIssueIDS=db.issue.filter(None, F(activity=options.dates,
+ status=resolvedStatusIDS))
+recentlyClosedIssueIDS.sort(issueIdCompare)
+
+# Created and reopened issues
+recentlyCreatedIssueIDS=db.issue.filter(None, F(creation=options.dates))
+reopenedIssueIDS = [] # gets accumulated in getIssueAttributes
+
+# Create more data needed for reports
+if allIssueIDS:
+ maxIssueID = allIssueIDS[-1]
+else:
+ maxIssueID = 1000
+linkWidth = len(link(maxIssueID))
+messages = []
+statusReport = {}
+issueAttributes = {}
+
+for issueID in allIssueIDS:
+ issueAttributes[issueID] = getIssueAttributes(issueID)
+
+createdOrReopenedIDS = reopenedIssueIDS + recentlyCreatedIssueIDS
+createdOrReopenedIDS.sort(issueIdCompare)
+
+durations = [issueAttributes[id]['duration'].as_seconds()/(3600*24.) for id in allIssueIDS if \
+ id not in closedIssueIDS]
+if durations:
+ if options.debug:
+ print >>bugfile, 'Min duration:', durations[-1], '; max', durations[0]
+ averageDuration = sum(durations)/len(durations)
+ medianDuration = durations[len(durations)/2]
+else:
+ averageDuration = "N/A"
+ medianDuration = "N/A"
+
+messages.sort()
+messages.reverse()
+discussedIDS = [id for (num, id) in messages[:discussionMax] \
+ if num >= discussionThreshold]
+
+nTotal = len(allIssueIDS)
+nTotalOld = len(earlyIssueIDS)
+nClosed = len(closedIssueIDS)
+nClosedOld = len(oldClosedIssueIDS)
+nOpen = nTotal - nClosed
+nOpenOld = nTotalOld - nClosedOld
+
+for sid in statusIDS:
+ IDS = db.issue.filter(None, F(creation=whole_period, status=sid))
+ IDSOld = db.issue.filter(None, F(creation=early_period, status=sid))
+ nNow, nThen = (len(IDS), len(IDSOld))
+ statusReport[sid] = (nNow, nThen)
+
+
+# Now make the reports
+textReport = cStringIO.StringIO()
+writeTextReport(textReport)
+
+htmlReport = cStringIO.StringIO()
+writeHtmlReport(htmlReport)
+
+def sendReport (recipient):
+ "Send the email message."
+ message = cStringIO.StringIO()
+ writer = MimeWriter.MimeWriter(message)
+ writer.addheader('Subject', 'Summary of %s Issues'%db.config.TRACKER_NAME)
+ writer.addheader('To', recipient)
+ writer.addheader('From', '%s <%s>'%(db.config.TRACKER_NAME, db.config.ADMIN_EMAIL))
+ writer.addheader('Reply-To', '%s <%s>'%(db.config.TRACKER_NAME, 'DONOTREPLY at NOWHERE.ORG'))
+ writer.addheader('MIME-Version', '1.0')
+ writer.addheader('X-Roundup-Name', db.config.TRACKER_NAME)
+
+ # start the multipart
+ part = writer.startmultipartbody('alternative')
+ part = writer.nextpart()
+ body = part.startbody('text/plain')
+
+ # do the plain text bit
+ print >>body, textReport.getvalue()
+
+ # now the HTML one
+ part = writer.nextpart()
+ body = part.startbody('text/html')
+ print >>body, htmlReport.getvalue()
+ # finish off the multipart
+ writer.lastpart()
+ # all done, send!
+ if options.debug:
+ print message.getvalue()
+ else:
+ smtp = SMTPConnection(db.config)
+ smtp.sendmail(db.config.ADMIN_EMAIL, options.mailTo, message.getvalue())
+
+# Email? Or just print it.
+if not options.mailTo:
+ print textReport.getvalue()
+else:
+ for recipient in options.mailTo.split(','):
+ sendReport(recipient)
+
More information about the Python-checkins
mailing list