[Python-checkins] r83963 - tracker/instances/python-dev/scripts/roundup-summary

ezio.melotti python-checkins at python.org
Thu Aug 12 17:44:10 CEST 2010


Author: ezio.melotti
Date: Thu Aug 12 17:44:10 2010
New Revision: 83963

Log:
#284: improve the roundup-summary script and make it easier to extend.

Modified:
   tracker/instances/python-dev/scripts/roundup-summary

Modified: tracker/instances/python-dev/scripts/roundup-summary
==============================================================================
--- tracker/instances/python-dev/scripts/roundup-summary	(original)
+++ tracker/instances/python-dev/scripts/roundup-summary	Thu Aug 12 17:44:10 2010
@@ -1,917 +1,715 @@
 #!/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/lib/python2.5/site-packages')
-#
-import roundup
-import roundup.date, roundup.instance
-import optparse , datetime
-import cStringIO, MimeWriter, smtplib
-from roundup.mailer import SMTPConnection
+# Authors: Ezio Melotti, Daniel Diniz, Richard Jones and Paul Dubois
+# Requires Python 2.5 or later.
 
-### 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="%F"   # ISO 8601: yyyy-mm-dd
-
-##### END CONFIGURATION; ALSO SEE CUSTOMIZATION BELOW
-
-usage = """%prog trackerHome [--mail mailTo] 
-                             [--dates 'date1;date2'] 
-                             [--brief]
-			     [--text]
-                        # 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('-t', '--text', dest='text', action='store_true',
-   default=False,
-   help="Write text report only, no HTML.")
-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
-columnWidth = 72  #for text report
-dateWidth = len(roundup.date.Date('2006-09-30').pretty(format=dateFormat))
-titleWidth = columnWidth - dateWidth #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 ""
+Create a tracker summary report.
 
-def F(**args):
-    "args is the dictionary to return, with additions if desired."
-    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
-        'Date', dateWidth
-        '<br>', 0
-        'Status', len('CLOSED')
-        'Link', linkWidth
-        'Action', actionWidth
-        'By', userNameWidth
-        '<br>', len('CLOSED')
-	'Keywords', columnWidth
-    records:
-        title
-        (reopenedDate or creation).pretty(format=dateFormat)
-        closedOrBlank(statusID)
-        link(issueNumber)
-        reopened or 'created'
-        reopener or creator
-	keywords
-"""
-# The table of closed issues, text mode.
-closedTable = """
-    titles:
-        'Title', titleWidth
-        'Duration', durationWidth
-        '<br>', len('CLOSED')
-        'Link', linkWidth 
-        'By', userNameWidth
-	'<br>', len('CLOSED')
-	'Keywords', columnWidth
-    records:
-        title
-        formatDuration(duration)
-        link(issueNumber) 
-        actor
-	keywords
-"""
-# The table of most discussed issues, text mode.
-discussedTable = """
-    titles:
-        'N', -3
-        'Title', columnWidth - durationWidth
-        'Duration', durationWidth
-        '<br>', 0
-        'Status', statusWidth
-        'Link', linkWidth
-    records:
-        numberOfMessages
-        title
-        formatDuration(duration)
-        status
-        link(issueNumber) 
-"""
+Usage: roundup-summary2 path_to_tracker [options]
 
-### 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
-	'Keywords', 15
-    records:
-        hlink(issueNumber, title)
-        closedOrBlank(statusID)
-        (reopenedDate or creation).pretty(format=dateFormat)
-        reopened or 'created'
-        reopener or creator
-	keywords
-"""
-# The table of closed issues, html mode.
-htmlClosedTable = """
-    titles:
-        'Title', 0
-        'By', userNameWidth
-        'Duration', durationWidth
-	'Keywords', 15
-    records:
-        hlink(issueNumber, title)
-        actor
-        formatDuration(duration)
-	keywords
-"""
-# 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)
+Options:
+  -h, --help            show this help message and exit
+  -m MAILTO, --mail=MAILTO
+                        Send the report to the comma-separated list of
+                        addresses or print it if the list is empty.
+  -b, --brief           Show only the summary without the lists of issues.
+  --html                Send also the HTML report via mail.
+  -d 'from;to', --dates='from;to'
+                        Specify the range of dates. Examples: "-1w;" = last
+                        week, previous week = "-2w;-1w", last 15 days =
+                        "-15d;", specific interval = "from 2010-06-19 to
+                        2010-07-04". Default: "-1w;".
+
+  Advanced options:
+    -r RESOLVED, --resolved=RESOLVED
+                        Comma-delimited list of statuses that corresponds to
+                        resolved (default: resolved,done,done-cbb,closed,faq).
+    -o FILENAME, --output=FILENAME
+                        File name for output; default is stdout.
+    -e FILENAME, --errors=FILENAME
+                        File name for error output; default is stderr.
+    -a 'from;to', --audit='from;to'
+                        Print journal for all the transactions in the given
+                        date range.
+    -D, --DEBUG         Print email content without sending it if -m is used.
 """
-### END CUSTOMIZATION
-# well, except you might want to add to this...locals in getIssueAttributes 
-# are usable in the 'records' section of tables.
-# Keywords
-patchID = db.keyword.lookup('patch')
-keywordIDS = db.keyword.getnodeids(False)
-keywordsDict = {}
-for id in keywordIDS:
-    keywordsDict[id] = db.keyword.get(id, 'name')
-
-def isPatch(issueID):
-    keywordIDS = db.issue.get(issueID, 'keywords')
-    return patchID in keywordIDS
-
-def getKeywordString (issueID):
-    keywordIDS = db.issue.get(issueID, 'keywords')
-    sep = ''
-    keywords = ''
-    for id in keywordIDS:
-        try:
-	    k = keywordsDict[id]
-            keywords += (sep + k)
-	    sep = ', '
-	except KeyError:  #retired kw
-	    pass
-    return keywords
-
-def getIssueAttributes (issueID):
-    """Return dictionaries with the issue's title, etc.
-       Assumes issue was created no later than to_value
+
+# This script has a class (Report) that filters the issues and generates txt
+# and HTML reports.  The issues_map function creates a list of all the issues,
+# with all the necessary information.
+
+import sys
+# hardcode the path to roundup
+sys.path.insert(1, '/home/roundup/lib/python2.5/site-packages')
+#sys.path.insert(1, '/opt/tracker-roundup/lib/python2.6/site-packages/')
+
+import cgi
+import optparse
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+
+import roundup.date, roundup.instance
+from roundup.mailer import SMTPConnection
+
+# summary headers
+HEADER_TXT = """
+ACTIVITY SUMMARY (%(timespan)s)
+%(tracker_name)s at %(tracker_url)s
+
+To view or respond to any of the issues listed below, click on the issue.
+Do NOT respond to this message.
+
+Issues stats:
+  open   %(open)5d (%(open_new)+3d)
+  closed %(closed)5d (%(closed_new)+3d)
+  total  %(total)5d (%(total_new)+3d)
+
+Open issues with patches: %(patches)-5d"""
+
+# this looks quite ugly, if you want it to look better write a CSS (or use
+# the plain text version)
+HEADER_HTML = """
+<h1>ACTIVITY SUMMARY</h1>
+<p>(%(timespan)s)</p>
+<p>%(tracker_name)s at <a href="%(tracker_url)s">%(tracker_url)s</a></p>
+
+<p>To view or respond to any of the issues listed below, click on the issue.
+Do NOT respond to this message.</p>
+
+<p>Issues stats:</p>
+<table border="1">
+  <tr><th>open</th><td>%(open)5d (%(open_new)+3d)</td></tr>
+  <tr><th>closed</th><td>%(closed)5d (%(closed_new)+3d)</td></tr>
+  <tr><th>total</th><td>%(total)5d (%(total_new)+3d)</td></tr>
+</table>
+
+<p>Open issues with patches: %(patches)-5d</p>"""
+
+
+
+def get_options_and_home():
     """
-    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
-    keywords = getKeywordString(issueID)
-
-# 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))
+    Return a list of options and the instance home.
+    """
+    # list of statuses to treat as closed -- these don't have to all exist.
+    resolved_status_def = 'resolved,done,done-cbb,closed,faq'
+    # period of time for report. Uses roundup syntax for ranges.
+    default_dates = '-1w;' # last week
+    # email address of recipient of report, if any.
+    default_mailto = ''
+    parser = optparse.OptionParser(usage='Usage: %prog path_to_tracker [options]')
+    parser.add_option(
+        '-m', '--mail', dest='mailTo', default=default_mailto,
+        help='Send the report to the comma-separated list of addresses '
+             'or print it if the list is empty.')
+    parser.add_option(
+        '-b', '--brief', dest='brief', action='store_true', default=False,
+        help='Show only the summary without the lists of issues.')
+    parser.add_option(
+        '', '--html', dest='html', action='store_true', default=False,
+        help='Send also the HTML report via mail.')
+    parser.add_option(
+        '-d', '--dates', dest='dates', default=default_dates, metavar="'from;to'",
+        help='Specify the range of dates. Examples: "-1w;" = last week, '
+             'previous week = "-2w;-1w", last 15 days = "-15d;", specific '
+             'interval = "from 2010-06-19 to 2010-07-04". Default: "-1w;".')
+
+    advanced = optparse.OptionGroup(parser, 'Advanced options')
+    advanced.add_option(
+        '-r', '--resolved', dest='resolved', default=resolved_status_def,
+        help='Comma-delimited list of statuses that corresponds to resolved '
+             '(default: %default).')
+    advanced.add_option(
+        '-o', '--output', dest='output', metavar='FILENAME', default='',
+        help='File name for output; default is stdout.')
+    advanced.add_option(
+        '-e', '--errors', dest='errors', default='', metavar='FILENAME',
+        help='File name for error output; default is stderr.')
+    advanced.add_option(
+        '-a', '--audit', dest='audit', metavar="'from;to'",
+        help='Print journal for all the transactions in the given date range.')
+    advanced.add_option(
+        '-D', '--DEBUG', dest='debug', action='store_true', default=False,
+        help='Print email content without sending it if -m is used.')
+    parser.add_option_group(advanced)
+
+    # 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 the path to a tracker home.')
+    options.dates = get_dates(options.dates)
+    options.resolved = [s.strip() for s in options.resolved.split(',')]
+    instance_home = args[0]
+    return options, instance_home
+
+
+def get_dates(dates):
+    dates = roundup.date.Range(dates, roundup.date.Date)
+    if dates.to_value is None:
+        dates.to_value = roundup.date.Date('.')
+    if dates.from_value is None:
+        dates.from_value = roundup.date.Date('1980-1-1')
+    return dates
 
-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)
+
+
+class Report(object):
+    """
+    Class that stores and filters all the issues and provides a method
+    to generate plain text or HTML reports.
+    """
+    def __init__(self, issues):
+        self.issues = issues
+        self.start_date = OPTIONS.dates.from_value
+        self.end_date = OPTIONS.dates.to_value
+        self.header_content = None
+        # create a list with the filtered issues
+        # a table will be generated for each of this lists
+        self.issues_lists = [
+            self.opened_issues(),
+            self.unreplied(),
+            self.need_review(),
+            self.most_discussed(),
+            self.closed_issues(),
+        ]
+
+
+    def make_report(self, type='txt'):
+        """
+        Return the report as a string. The report can be either in 'txt' or
+        'html' format, depending on the value of the *type* attribute.
+        """
+        if type not in ('txt', 'html'):
+            raise ValueError('The type should be either "txt" or "html".')
+        report = self.make_header(type)
+        if not OPTIONS.brief:
+            report += self.make_tables(type)
+        return report
+
+
+    def make_header(self, type):
+        """Calculate the number of open, closed and total issues and now many
+           have been created in the given period."""
+        if type == 'txt':
+            header = HEADER_TXT
+        elif type == 'html':
+            header = HEADER_HTML
+
+        # if we have already calculated the values just return the header
+        if self.header_content is not None:
+            return header % self.header_content
+
+        start_date, end_date = self.start_date, self.end_date
+        start_str = start_date.pretty(format='%F') # %F -> yyyy-mm-dd
+        end_str = end_date.pretty(format='%F')
+        open_tot = closed_tot = all_tot = 0
+        open_new = closed_new = all_old = 0
+        with_patch = 0
+        patch_id = DB.keyword.lookup('patch')
+        for id, issue in self.issues.iteritems():
+            # don't include issues created after the end date
+            if issue['creation'] > end_date:
+                continue
+            if issue['creation'] < start_date:
+                all_old += 1
+            all_tot += 1
+            if issue['closed']:
+                closed_tot += 1
+                if issue['closed_date'] >= start_date:
+                    closed_new += 1
             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,
+                open_tot += 1
+                if ((issue['creation'] >= start_date) or
+                    (issue['reopened_date'] >= start_date)):
+                    open_new += 1
+                if patch_id in issue['keyword_ids']:
+                    with_patch += 1
+        all_new = all_tot - all_old
+        # save the values in an attribute to avoid calculating it twice
+        # when both the txt and the HTML header are needed (i.e. when sending
+        # HTML mails)
+        self.header_content = dict(
+            timespan='%s - %s' % (start_str, end_str),
+            tracker_url=DB.config.TRACKER_WEB,
+            tracker_name=DB.config.TRACKER_NAME,
+            open=open_tot, open_new=open_new,
+            closed=closed_tot, closed_new=closed_new,
+            total=all_tot, total_new=all_new,
+            patches=with_patch,
+        )
+        return header % self.header_content
+
+
+    def make_tables(self, type):
+        """Return all the tables as a single string."""
+        return ''.join(self.make_table(t, type) for t in self.issues_lists)
+
+
+    # these methods are currently not used
+    def average_duration(self):
+        durations = [issue['duration'] for issue in self.issues.values()
+                                       if issue['status'] == 'closed']
+        return sum(durations) / len(durations)
+
+    def median_duration(self):
+        durations = sorted(issue['duration'] for issue in self.issues.values()
+                                             if issue['status'] == 'closed')
+        return durations[len(durations)//2]
 
-    def endTitle(self, device, title, fw):
-        pass
 
-    def startFields(self, d, device, fields, fws):
-        pass
+    def opened_issues(self):
+        """
+        Return a list of all the issues created or reopened during the
+        specified period.
+        """
+        title = 'Issues opened'
+        template = ('#%(id)d: %(title)s\n'
+                    '%(url)s  %(re)sopened by %(opener)s\n')
+        headers = ['ID', 'Title', 'Opener']
+        issues_list = []
+        # XXX: sort issues by date? id?
+        for id,issue in self.issues.iteritems():
+            if issue['closed']:
+                continue  # list only issues that are still open
+            reopened = ''
+            opener = issue['creator']
+            if not self._in_period(issue['creation']):
+                if (not issue['reopened_date'] or
+                    not self._in_period(issue['reopened_date'])):
+                    continue
+                else:
+                    reopened = 're'
+                    opener = issue['reopener']
+            issues_list.append({
+                'id': id,
+                'title': issue['title'][:62],
+                #'date': issue['creation'].pretty('%F'),
+                'url': issue['url'],
+                're': reopened,
+                'opener': opener
+            })
+        return dict(title=title, issues=issues_list,
+                    template=template, headers=headers)
 
-    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,
+    # XXX pending issues?
 
-    def endField(self, d, device, field, fw):
-        pass
+    def closed_issues(self):
+        """
+        Return a list of all the issues closed during the specified period
+        that are still closed.
+        """
+        title = 'Issues closed'
+        template = ('#%(id)d: %(title)s\n'
+                    '%(url)s  closed by %(closedby)s\n')
+        headers = ['ID', 'Title', 'Closed by']
+        issues_list = []
+        for id,issue in self.issues.iteritems():
+            if not issue['closed'] or not self._in_period(issue['closed_date']):
+                continue
+            issues_list.append({
+                'id': id,
+                'title': issue['title'][:62],
+                #'date': issue['closed_date'].pretty('%F'),
+                'url': issue['url'],
+                'closedby': issue['closer']
+            })
+        return dict(title=title, issues=issues_list,
+                    template=template, headers=headers)
 
-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 most_discussed(self):
+        """
+        Return a table of the ten most discussed issues of the specified
+        period, sorted by number of messages.
+        """
+        title = 'Top 10 most discussed issues'
+        template = ('#%(id)d: %(title)s\n'
+                    '%(url)s %(msgs)3d msgs\n')
+        headers = ['ID', 'Title', 'Msgs']
+        issues_list = []
+        def newmsg(issue): return issue['msgs_in_period']
+        for issue in sorted(self.issues.itervalues(), key=newmsg, reverse=True):
+            if issue['closed'] or newmsg(issue) < 3:
+                continue  # only open issues with at least 3 new msgs
+            issues_list.append({
+                'id': issue['issue_num'],
+                'title': issue['title'][:62],
+                'url': issue['url'],
+                'msgs': issue['msgs_in_period']
+            })
+            if len(issues_list) == 10:
+                break
+        return dict(title=title, issues=issues_list,
+                    template=template, headers=headers)
+
+
+    def unreplied(self):
+        """
+        Return a table that lists the issues that haven't received any
+        reply.
+        """
+        title = 'Most recent 15 issues with no replies'
+        template = ('#%(id)d: %(title)s\n'
+                    '%(url)s\n')
+        headers = ['ID', 'Title']
+        issues_list = []
+        def creation(issue): return issue['creation']
+        for issue in sorted(self.issues.itervalues(),
+                            key=creation, reverse=True):
+            # check only the end_date, otherwise older issues might get lost
+            if (issue['closed'] or issue['msgs_num'] > 1 or
+                issue['creation'] > self.end_date):
+                continue  # only open issues with no replies
+            issues_list.append({
+                'id': issue['issue_num'],
+                'title': issue['title'][:62],
+                'url': issue['url'],
+            })
+            if len(issues_list) == 15:
+                break
+        return dict(title=title, issues=issues_list,
+                    template=template, headers=headers)
+
+
+    def need_review(self):
+        """
+        Return a table that lists the issues active during the specified
+        period that need a review (i.e. they have 'patch' or 'needs review'
+        as keywords or 'patch review' as stage).
+        """
+        title = 'Most recent 15 issues waiting for review'
+        template = ('#%(id)d: %(title)s\n'
+                    '%(url)s\n')
+        headers = ['ID', 'Title']
+        issues_list = []
+        def creation(issue): return issue['creation']
+        for issue in sorted(self.issues.itervalues(),
+                            key=creation, reverse=True):
+            # it would be better to check if the 'needs review' or
+            # 'patch' keyword have been added in the last period
+            # or if the stage changed to 'patch review' instead of
+            # checking the most recent
+            if issue['closed'] or issue['creation'] > self.end_date:
+                continue
+            if ('needs review' in issue['keywords'] or
+                'patch' in issue['keywords'] or
+                issue['stage'] == 'patch review'):
+                issues_list.append({
+                    'id': issue['issue_num'],
+                    'title': issue['title'][:62],
+                    'url': issue['url'],
+                })
+            if len(issues_list) == 15:
+                break
+        return dict(title=title, issues=issues_list,
+                    template=template, headers=headers)
+
 
-    def startFields(self, d, device, fields, fws):
-        print >>device, '        <tr>'
+    def make_table(self, data, type):
+        """
+        Create a text or HTML table depending on the settings.
+
+        *title* is the title of the table, *issues* a list of dicts that
+        contains the information about the issues, *template* is the format
+        string used to generate the entries in the text table, *headers*
+        is a list of strings used for the headers of the HTML table.
+        """
 
-    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],
+        # if there are no issues skip the whole table
+        if not data['issues']:
+            return ''
+        if type == 'html':
+            return self._make_html_table(data['title'], data['issues'],
+                                         data['headers'])
         else:
-            print >>device, value,
+            return self._make_text_table(data['title'], data['issues'],
+                                         data['template'])
 
-    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, "Open issues with patches: %5d" % nPatch
-    print >>body, '</p>'
-    print >>body, '<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, "Open issues with patches: %5d" % nPatch
-    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
+    def _make_text_table(self, title, issues, template):
+        """
+        Make a text table. *title* is the title of the table,
+        *issues* a lidt of dicts with the information for each issue,
+        *template* is a format string used for each entry.
+        """
+        lnum = len(issues)
+        table = [
+            title + ' (%d)' % lnum,
+            '=' * (len(title) + len(str(lnum)) + 3) + '\n'
+        ]
+        table.extend(template % issue for issue in issues)
+        return '\n\n\n' + '\n'.join(table)
+
+
+    def _make_html_table(self, title, issues, headers):
+        # seriously, HTML tables?
+        lines = [
+            '<table border="1">',
+            '  <caption style="border: 1px solid black; margin-top: 2em">%s'
+            '</caption>' % title,
+            '  <tr>%s</tr>' % ''.join('<th>%s</th>' % h for h in headers),
+        ]
+        for issue in issues:
+            lines.append('  <tr>')
+            for header in headers:
+                column = header.replace(' ', '').lower()
+                if column == 'title':
+                    # 'title' is special-cased to be turned in a link
+                    url, title = issue['url'], cgi.escape(issue['title'])
+                    lines.append('     <td><a href="%s">%s</a></td>'
+                                 % (url, title))
+                else:
+                    lines.append('     <td>%s</td>' % issue[column])
+            lines.append('  </tr>')
+        lines.append('</table>')
+        return '\n'.join(lines) + '\n\n'
+
+    def _in_period(self, date):
+        """Check that the given date is between the start and end_date"""
+        return self.start_date <= date <= self.end_date
+
+
+
+def get_issue(issue_id, kws_dict, stages_dict):
+    """Get issue attributes and journal from the database."""
+    attrs = DB.getnode('issue', issue_id)  # get the current attributes
+    kwds = set(attrs['keywords'])
+    if attrs.get('stage', None) is not None:
+        stage = stages_dict[attrs['stage']]
+    else:
+        stage = None
+
+    # if you need more attributes add them here
+    issue = dict(
+        issue_num = issue_id,
+        reopened = '',
+        new = False, closed = False,
+        reopener = '',
+        reopened_date = '',
+        closer = '',
+        closed_date = '',
+        msgs_num = len(attrs['messages']),
+        msgs_in_period = 0,
+        status = None,
+        real_status = sid2name(attrs['status']), # Avoid a bug in get_issue_attrs
+        actor = None,
+        activity = None,
+        keyword_ids = kwds,
+        keywords =  ', '.join([kws_dict[id] for id in kwds if id in kws_dict]),
+        stage = stage,
+        title = attrs['title'],
+        creation = attrs['creation'],
+        creator = attrs['creator'],
+        url = DB.config.TRACKER_WEB + 'issue%d' % issue_id
+    )
+    # The journal records the OLD value of the status in the data field
+    journal = DB.issue.history(issue['issue_num'])
+
+    helper = dict(
+        issue_id = issue_id,
+        status2 = sid2name(attrs['status']),
+        status1 = sid2name(attrs['status']),
+        actor2 = attrs['actor'],
+        activity2 = attrs['activity'],
+        journal = journal
+    )
+    return issue, helper
+
 
-# 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, '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()
+def get_issue_attrs(issue, helper):
+    """Calculate and set issue attributes from journal.
+
+     Return dictionaries with the issue's title, etc. Assumes issue was
+     created no later than to_value.
+    """
+    dates = OPTIONS.dates
+    def reopened(issue):
+        status1 = issue['status1'] in OPTIONS.resolved
+        status2 = issue['status2'] not in OPTIONS.resolved
+        return status1 and status2
+
+    def not_new(issue):
+        return dates.to_value > issue['creation'] <= dates.from_value
+
+    def update(issue, helper):
+        for key in ('status', 'actor', 'activity'):
+            issue[key] = issue[key] or helper[key + '2']
+    # I'm not sure all this stuff is necessary, but it seems to work...
+    # this trick catches the first time we are in the interval of interest
+    if helper['activity2'] < dates.to_value:
+        update(issue, helper)
+    for _, time, userid, act, data in helper['journal']:
+        in_period =  dates.to_value > time >= dates.from_value
+        if in_period:
+            update(issue, helper)
+            issue['new'] = issue['new'] or act == 'create'
+        if act == 'set':
+            if 'messages' in data and in_period:
+                # find the number of messages added/removed in the period
+                msg = data['messages'][0]
+                if msg[0] == '+':
+                    issue['msgs_in_period'] += 1
+                elif msg[0] == '-':
+                    issue['msgs_in_period'] -= 1
+            if 'status' in data:
+                helper['status1'] = sid2name(data['status'])
+                if time < dates.to_value:
+                    # want the last reopener only
+                    if reopened(helper) and not issue['reopened']:
+                        issue['new'] = in_period and not_new(issue)
+                        issue['reopened'] = 'reopened'
+                        issue['reopener'] = userid
+                        issue['reopened_date'] = time
+                    helper['actor2'] = userid
+                    helper['activity2'] = time
+                    helper['status2'] = helper['status1']
+                if (not issue['closer'] and
+                    sid2name(data['status']) not in OPTIONS.resolved and
+                    issue['real_status'] in OPTIONS.resolved):
+                    issue['closer'] = userid
+                    issue['closed_date'] = time
+    # get these set if not done before
+    update(issue, helper)
+    last_opened = issue['reopened_date'] or issue['creation']
+    issue['closed'] = issue['status'] in OPTIONS.resolved
+    duration = issue['activity'] if issue['closed'] else dates.to_value
+    issue['duration'] = int((duration - last_opened).as_seconds())
+    return issue
+
+
+def format_issue_attrs(issue):
+    """Format issue fields for the reports."""
+    # XXX: this shouldn't be a separate function
+    issue['status_id'] = issue['status']
+    #issue['status'] = sid2name(issue['status'])
+    issue['actor'] = uid2name(issue['actor'])
+    issue['creator'] = uid2name(issue['creator'])
+    if issue['reopener']:
+        issue['reopener'] = uid2name(issue['reopener'])
+    if issue['closer']:
+        issue['closer'] = uid2name(issue['closer'])
+    return issue
+
+
+def issues_map():
+    """Issue data collection and formatting driver."""
+    all_issue_ids = set(DB.issue.filter(None, {}))
+    # Fetch, compute and format issue attributes
+    issue_attrs = {}
+    keywords = get_keywords_dict()
+    stages = get_stages_dict()
+    for issue_id in sorted(map(int,all_issue_ids)):
+        issue, helper = get_issue(issue_id, keywords, stages)
+        issue = get_issue_attrs(issue, helper)
+        issue_attrs[issue_id] = format_issue_attrs(issue)
+    return issue_attrs
+
+
+
+def get_keywords_dict():
+    keyword_ids = DB.keyword.getnodeids(False)
+    kws_dict = {}
+    for id in keyword_ids:
+        kws_dict[id] = DB.keyword.get(id, 'name')
+    return kws_dict
+
+def get_stages_dict():
+    # some instances (e.g. Jython) don't have a 'stage' field
+    if not hasattr(DB, 'stage'):
+        return {}
+    stages_ids = DB.stage.getnodeids(False)
+    stages_dict = {}
+    for id in stages_ids:
+        stages_dict[id] = DB.stage.get(id, 'name')
+    return stages_dict
+
+
+def sid2name(status_id, cache={None:'none'}):
+    if status_id in cache:
+        return cache[status_id]
+    name = DB.status.get(status_id, 'name')
+    cache[status_id] = name
+    return name
+
+def uid2name(user_id, cache={None:'none'}):
+    if user_id in cache:
+        return cache[user_id]
+    name = DB.user.get(user_id, 'username')
+    cache[user_id] = name
+    return name
+
+
+
+def write_audit():
+    """Print audit for the given period."""
+    issue = DB.issue
+    ids = issue.filter(None, dict(activity=OPTIONS.audit))
+    ids.sort(key=int)
+    output = []
+    for issue_id in ids:
+        journal = issue.history(issue_id)
+        for id_, time, user, act, data in journal:
+            user = '%s (%s)' % (user, uid2name(user))
+            output.append('%s %s %s %s' %  (id_, time, user, act))
+            if act == 'set':
+                for dat, val in data.items():
+                    output.append('     %s :  %s' %  (dat, val))
+    print '\n'.join(output) + '\n'
     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)
-    durations.sort()
-    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
-
-nPatch = 0
-for id in allIssueIDS:
-    if id in closedIssueIDS: continue
-    if isPatch(id):
-        nPatch += 1
-
-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, 'status at bugs.python.org'))
-    writer.addheader('Reply-To', '%s <%s>'%(db.config.TRACKER_NAME, 'status at bugs.python.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
-    if not options.text:
-       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()
+def send_report(recipient, txt_report, html_report=None):
+    """Send the email message."""
+    tracker_name = DB.config.TRACKER_NAME
+    hdrs = [('Subject', 'Summary of %s Issues' % tracker_name)]
+    # XXX: maybe this email shouldn't be hardcoded...
+    email = '%s <%s>'  % (tracker_name, 'status at bugs.python.org')
+    hdrs += [('To', recipient), ('From', email), ('Reply-To', email),
+           ('MIME-Version', '1.0'), ('X-Roundup-Name', tracker_name)]
+    # create the message, using a multipart if --html is passed
+    if html_report is not None:
+        msg = MIMEMultipart('alternative')
     else:
-        smtp = SMTPConnection(db.config)
-        smtp.sendmail(db.config.ADMIN_EMAIL, options.mailTo, message.getvalue())
+        msg = MIMEText(txt_report)
+    for name, content in hdrs:
+        msg[name] = content
+    # if it's html create the two parts
+    if OPTIONS.html:
+        msg.attach(MIMEText(txt_report, 'plain'))
+        msg.attach(MIMEText(html_report, 'html'))
+
+    if OPTIONS.debug:
+        print msg.as_string()
+    else:
+        config = DB.config
+        smtp = SMTPConnection(config)
+        smtp.sendmail(config.ADMIN_EMAIL, recipient, msg.as_string())
+
+
+def main():
+    """Create the report and print or send it."""
+    issue_attrs = issues_map()
+    report = Report(issue_attrs)
+    if OPTIONS.audit:
+        write_audit()
+
+    if not OPTIONS.mailTo:
+        print report.make_report('html' if OPTIONS.html else 'txt')
+    else:
+        txt_report = report.make_report('txt')
+        html_report = report.make_report('html') if OPTIONS.html else None
+        for recipient in OPTIONS.mailTo.split(','):
+            send_report(recipient, txt_report, html_report)
+
 
-# Email? Or just print it.
-if not options.mailTo:
-    print textReport.getvalue()
-else:  
-    for recipient in options.mailTo.split(','):
-        sendReport(recipient)
 
+if __name__ == '__main__':
+    OPTIONS, instance_home = get_options_and_home()
+    instance = roundup.instance.open(instance_home)
+    DB = instance.open('admin')
+    main()


More information about the Python-checkins mailing list