1. Robot Mirror
  2. bugs.python.org

Commits

paul.dubois  committed 48a29c9

Put in kludge for local execution.
Got it out of dos format.
Tested on tracker, ok. Ready to go.

  • Participants
  • Parent commits 8cfba29
  • Branches default

Comments (0)

Files changed (1)

File scripts/roundup-summary

View file
-#!/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 = '&nbsp;'
-        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 = '&nbsp;'
-            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@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 = '&nbsp;'
+        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 = '&nbsp;'
+            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@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)
+