Example #1
0
    def __init__(self):
        # Instantiate the PM component
        self.pm = TracPM(self.env)

        self.GanttID = 'g'

        # All the macro's options with default values.
        # Anything else passed to the macro is a TracQuery field.
        options = ('format', 'formats', 'sample', 'res', 'dur', 'comp',
                   'caption', 'startDate', 'endDate', 'dateDisplay',
                   'openLevel', 'expandClosedTickets', 'colorBy', 'lwidth',
                   'showdep', 'userMap', 'omitMilestones', 'schedule',
                   'hoursPerDay', 'doResourceLeveling', 'display', 'order',
                   'scrollTo', 'linkStyle')

        for opt in options:
            self.options[opt] = self.config.get('trac-jsgantt',
                                                'option.%s' % opt)
Example #2
0
    def __init__(self):
        # Instantiate the PM component
        self.pm = TracPM(self.env)

        self.GanttID = 'g'


        # All the macro's options with default values.
        # Anything else passed to the macro is a TracQuery field.
        options = ('format', 'formats', 'sample', 'res', 'dur', 'comp', 
                   'caption', 'startDate', 'endDate', 'dateDisplay', 
                   'openLevel', 'expandClosedTickets', 'colorBy', 'lwidth', 
                   'showdep', 'userMap', 'omitMilestones',
                   'schedule', 'hoursPerDay', 'doResourceLeveling',
                   'display', 'order', 'scrollTo')

        for opt in options:
            self.options[opt] = self.config.get('trac-jsgantt',
                                                'option.%s' % opt)
Example #3
0
class TracJSGanttChart(WikiMacroBase):
    """
Displays a Gantt chart for the specified tickets.

The chart display can be controlled with a number of macro arguments:

||'''Argument'''||'''Description'''||'''Default'''||
|| `formats`||What to display in the format control.  A pipe-separated list of `minute`, `hour`, `day`, `week`, `month`, and `quarter` (though `minute` may not be very useful). ||'day|week|month|quarter'||
|| `format`||Initial display format, one of those listed in `formats` || First format ||
|| `sample`||Display sample tasks (1) or not (0) || 0 ||
|| `res`||Show resource column (1) or not (0) || 1 ||
|| `dur`||Show duration colunn (1) or not (0) || 1 ||
|| `comp`||Show percent complete column (1) or not (0) || 1 ||
|| `caption`||Caption to place to right of tasks: None, Caption, Resource, Duration, %Complete || Resource ||
|| `startDate`||Show start date column (1) or not (0) || 1 ||
|| `endDate`||Show end date column (1) or not (0) || 1 ||
|| `dateDisplay`||Date display format: 'mm/dd/yyyy', 'dd/mm/yyyy', or 'yyyy-mm-dd' || 'mm/dd/yyyy' ||
|| `openLevel`||Number of levels of tasks to show.  1 = only top level task.  || 999 ||
|| `colorBy`||Field to use to choose task colors.  Each unique value of the field will have a different color task.  Other likely useful values are owner and milestone but any field can be used. || priority ||
|| `root`||When using something like Subtickets plugin to maintain a tree of tickets and subtickets, you may create a Gantt showing a ticket and all of its descendants with `root=<ticket#>`.  The macro uses the configured `parent` field to find all descendant tasks and build an `id=` argument for Trac's native query handler.[[br]][[br]]Multiple roots may be provided like `root=1|12|32`.[[br]][[br]]When used in a ticket description or comment, `root=self` will display the current ticket's descendants.||None||
|| `goal`||When using something like MasterTickets plugin to maintain ticket dependencies, you may create a Gantt showing a ticket and all of its predecessors with `goal=<ticket#>`.  The macro uses the configured `succ` field to find all predecessor tasks and build an `id=` argument for Trac's native query handler.[[br]][[br]]Multiple goals may be provided like `goal=1|12|32`.[[br]][[br]]When used in a ticket description or comment, `goal=self` will display the current ticket's predecessors.||None||
|| `lwidth`||The width, in pixels, of the table of task names, etc. on the left of the Gantt. || ||
|| `showdep`||Show dependencies (1) or not (0)||1||
|| `userMap`||Map user !IDs to full names (1) or not (0).||1||
|| `omitMilestones`||Show milestones for displayed tickets (0) or only those specified by `milestone=` (1)||0||
|| `schedule`||Schedule tasks based on dependenies and estimates.  Either as soon as possible (asap) or as late as possible (alap)||alap||
||`doResourceLeveling`||Resolve resource conflicts (1) or not (0) when scheduling tickets.||0||
||`display`||Filter for limiting display of tickets.  `owner:fred` shows only tickets owned by fred. `status:closed` shows only closed tickets.||None||
||`order`||Order of fields used to sort tickets before display. `order=milestone` sorts by milestone.  May include ticket fields, including custom fields, or "wbs" (work breakdown structure).||wbs||

Site-wide defaults for macro arguments may be set in the `trac-jsgantt` section of `trac.ini`.  `option.<opt>` overrides the built-in default for `<opt>` from the table above.

All other macro arguments are treated as TracQuery specification (e.g., milestone=ms1|ms2) to control which tickets are displayed.

    """

    pm = None
    options = {}

    # The date part of these formats has to be in sync.  Including
    # hour and minute in the pyDateFormat makes the plugin easier to
    # debug at times because that's how the date shows up in page
    # source.
    #
    # jsDateFormat is the date format that the JavaScript expects
    # dates in.  It can be one of 'mm/dd/yyyy', 'dd/mm/yyyy', or
    # 'yyyy-mm-dd'.  pyDateFormat is a strptime() format that matches
    # jsDateFormat.  As long as they are in sync, there's no real
    # reason to change them.
    jsDateFormat = 'yyyy-mm-dd'
    pyDateFormat = '%Y-%m-%d %H:%M'

    # User map (login -> realname) is loaded on demand, once.
    # Initialization to None means it is not yet initialized.
    user_map = None

    def __init__(self):
        # Instantiate the PM component
        self.pm = TracPM(self.env)

        self.GanttID = 'g'

        # All the macro's options with default values.
        # Anything else passed to the macro is a TracQuery field.
        options = ('format', 'formats', 'sample', 'res', 'dur', 'comp',
                   'caption', 'startDate', 'endDate', 'dateDisplay',
                   'openLevel', 'expandClosedTickets', 'colorBy', 'lwidth',
                   'showdep', 'userMap', 'omitMilestones', 'schedule',
                   'hoursPerDay', 'doResourceLeveling', 'display', 'order',
                   'scrollTo', 'linkStyle')

        for opt in options:
            self.options[opt] = self.config.get('trac-jsgantt',
                                                'option.%s' % opt)

    def _begin_gantt(self, options):
        if options['format']:
            defaultFormat = options['format']
        else:
            defaultFormat = options['formats'].split('|')[0]
        showdep = options['showdep']
        text = ''
        text += '<div style="position:relative" class="gantt" ' + \
            'id="GanttChartDIV_'+self.GanttID+'"></div>\n'
        text += '<script language="javascript">\n'
        text += 'var '+self.GanttID+' = new JSGantt.GanttChart("'+ \
            self.GanttID+'",document.getElementById("GanttChartDIV_'+ \
            self.GanttID+'"), "%s", "%s");\n' % \
            (javascript_quote(defaultFormat), showdep)
        text += 'var t;\n'
        text += 'if (window.addEventListener){\n'
        text += '  window.addEventListener("resize", ' + \
            'function() { ' + self.GanttID+'.Draw(); '
        if options['showdep']:
            text += self.GanttID + '.DrawDependencies();'
        text += '}, false);\n'
        text += '} else {\n'
        text += '  window.attachEvent("onresize", ' + \
            'function() { '+self.GanttID+'.Draw(); '
        if options['showdep']:
            text += self.GanttID + '.DrawDependencies();'
        text += '});\n'
        text += '}\n'
        return text

    def _end_gantt(self, options):
        chart = ''
        chart += self.GanttID + '.Draw();\n'
        if options['showdep']:
            chart += self.GanttID + '.DrawDependencies();\n'
        chart += '</script>\n'
        return chart

    def _gantt_options(self, options):
        opt = ''
        if (options['linkStyle']):
            linkStyle = options['linkStyle']
        else:
            linkStyle = 'standard'
        opt += self.GanttID + '.setLinkStyle("%s")\n' % linkStyle
        opt += self.GanttID + '.setShowRes(%s);\n' % options['res']
        opt += self.GanttID + '.setShowDur(%s);\n' % options['dur']
        opt += self.GanttID + '.setShowComp(%s);\n' % options['comp']
        if (options['scrollTo']):
            opt += self.GanttID + '.setScrollDate("%s");\n' % options[
                'scrollTo']
        w = options['lwidth']
        if w:
            opt += self.GanttID + '.setLeftWidth(%s);\n' % w


        opt += self.GanttID+'.setCaptionType("%s");\n' % \
            javascript_quote(options['caption'])

        opt += self.GanttID + '.setShowStartDate(%s);\n' % options['startDate']
        opt += self.GanttID + '.setShowEndDate(%s);\n' % options['endDate']

        opt += self.GanttID+'.setDateInputFormat("%s");\n' % \
            javascript_quote(self.jsDateFormat)

        opt += self.GanttID+'.setDateDisplayFormat("%s");\n' % \
            javascript_quote(options['dateDisplay'])

        opt += self.GanttID + '.setFormatArr(%s);\n' % ','.join(
            '"%s"' % javascript_quote(f)
            for f in options['formats'].split('|'))
        opt += self.GanttID + '.setPopupFeatures("location=1,scrollbars=1");\n'
        return opt

    # TODO - use ticket-classN styles instead of colors?
    def _add_sample_tasks(self):
        task = ''
        tasks = self.GanttID + '.setDateInputFormat("mm/dd/yyyy");\n'

        #                                                                         ID    Name                   Start        End          Display    Link                    MS Res         Pct  Gr Par Open Dep Cap
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',1,   "Define Chart API",     "",          "",          "#ff0000", "http://help.com",      0, "Brian",     0,  1, 0,  1));\n'
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',11,  "Chart Object",         "2/20/2011", "2/20/2011", "#ff00ff", "http://www.yahoo.com", 1, "Shlomy",  100,  0, 1,  1));\n'
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',12,  "Task Objects",         "",          "",          "#00ff00", "",                     0, "Shlomy",   40,  1, 1,  1));\n'
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',121, "Constructor Proc",     "2/21/2011", "3/9/2011",  "#00ffff", "http://www.yahoo.com", 0, "Brian T.", 60,  0, 12, 1));\n'
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',122, "Task Variables",       "3/6/2011",  "3/11/2011", "#ff0000", "http://help.com",      0, "",         60,  0, 12, 1,121));\n'
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',123, "Task Functions",       "3/9/2011",  "3/29/2011", "#ff0000", "http://help.com",      0, "Anyone",   60,  0, 12, 1, 0, "This is another caption"));\n'
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',2,   "Create HTML Shell",    "3/24/2011", "3/25/2011", "#ffff00", "http://help.com",      0, "Brian",    20,  0, 0,  1,122));\n'
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',3,   "Code Javascript",      "",          "",          "#ff0000", "http://help.com",      0, "Brian",     0,  1, 0,  1));\n'
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',31,  "Define Variables",     "2/25/2011", "3/17/2011", "#ff00ff", "http://help.com",      0, "Brian",    30,  0, 3,  1, 0,"Caption 1"));\n'
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',32,  "Calculate Chart Size", "3/15/2011", "3/24/2011", "#00ff00", "http://help.com",      0, "Shlomy",   40,  0, 3,  1));\n'
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',33,  "Draw Taks Items",      "",          "",          "#00ff00", "http://help.com",      0, "Someone",  40,  1, 3,  1));\n'
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',332, "Task Label Table",     "3/6/2011",  "3/11/2011", "#0000ff", "http://help.com",      0, "Brian",    60,  0, 33, 1));\n'
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',333, "Task Scrolling Grid",  "3/9/2011",  "3/20/2011", "#0000ff", "http://help.com",      0, "Brian",    60,  0, 33, 1));\n'
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',34,  "Draw Task Bars",       "",          "",          "#990000", "http://help.com",      0, "Anybody",  60,  1, 3,  1));\n'
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',341, "Loop each Task",       "3/26/2011", "4/11/2011", "#ff0000", "http://help.com",      0, "Brian",    60,  0, 34, 1, "332,333"));\n'
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',342, "Calculate Start/Stop", "4/12/2011", "5/18/2011", "#ff6666", "http://help.com",      0, "Brian",    60,  0, 34, 1));\n'
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',343, "Draw Task Div",        "5/13/2011", "5/17/2011", "#ff0000", "http://help.com",      0, "Brian",    60,  0, 34, 1));\n'
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',344, "Draw Completion Div",  "5/17/2011", "6/04/2011", "#ff0000", "http://help.com",      0, "Brian",    60,  0, 34, 1));\n'
        tasks += self.GanttID + '.AddTaskItem(new JSGantt.TaskItem(' + self.GanttID + ',35,  "Make Updates",         "10/17/2011","12/04/2011","#f600f6", "http://help.com",      0, "Brian",    30,  0, 3,  1));\n'
        return tasks

    # Get the required columns for the tickets which match the
    # criteria in options.
    def _query_tickets(self, options):
        query_options = {}
        for key in options.keys():
            if not key in self.options:
                query_options[key] = options[key]

        # The fields always needed by the Gantt
        fields = set([
            'description', 'owner', 'type', 'status', 'summary', 'milestone',
            'priority'
        ])

        # Make sure the coloring field is included
        if 'colorBy' in options:
            fields.add(str(options['colorBy']))

        rawtickets = self.pm.query(query_options, fields, self.req)

        # Do permissions check on tickets
        tickets = [
            t for t in rawtickets
            if 'TICKET_VIEW' in self.req.perm('ticket', t['id'])
        ]

        return tickets

    def _compare_tickets(self, t1, t2):
        # If t2 depends on t1, t2 is first
        if t1['id'] in self.pm.successors(t2):
            return 1
        # If t1 depends on t2, t1 is first
        elif t2['id'] in self.pm.successors(t1):
            return -1
        # If t1 ends first, it's first
        elif self.pm.finish(t1) < self.pm.finish(t2):
            return -1
        # If t2 ends first, it's first
        elif self.pm.finish(t1) > self.pm.finish(t2):
            return 1
        # End dates are same. If t1 starts later, it's later
        elif self.pm.start(t1) > self.pm.start(t2):
            return 1
        # Otherwise, preserve order (assume t1 is before t2 when called)
        else:
            return 0

    # Compute WBS for sorting and figure out the tickets' levels for
    # controlling how many levels are open.
    #
    # WBS is a list like [ 2, 4, 1] (the first child of the fourth
    # child of the second top-level element).
    def _compute_wbs(self):
        # Set the ticket's level and wbs then recurse to children.
        def _setLevel(tid, wbs, level):
            # Update this node
            self.ticketsByID[tid]['level'] = level
            self.ticketsByID[tid]['wbs'] = copy.copy(wbs)

            # Recurse to children
            childIDs = self.pm.children(self.ticketsByID[tid])
            if childIDs:
                childTickets = [self.ticketsByID[cid] for cid in childIDs]
                childTickets.sort(self._compare_tickets)
                childIDs = [ct['id'] for ct in childTickets]

                # Add another level
                wbs.append(1)
                for c in childIDs:
                    wbs = _setLevel(c, wbs, level + 1)
                # Remove the level we added
                wbs.pop()

            # Increment last element of wbs
            wbs[len(wbs) - 1] += 1

            return wbs

        # Set WBS and level on all top level tickets (and recurse) If
        # a ticket's parent is not in the viewed tickets, consider it
        # top-level
        wbs = [1]
        roots = self.pm.roots(self.ticketsByID)
        for t in self.tickets:
            if t['id'] in roots:
                wbs = _setLevel(t['id'], wbs, 1)

    def _task_display(self, t, options):
        def _buildMap(field):
            self.classMap = {}
            i = 0
            for t in self.tickets:
                if t[field] not in self.classMap:
                    i = i + 1
                    self.classMap[t[field]] = i

        def _buildEnumMap(field):
            self.classMap = {}
            db = self.env.get_db_cnx()
            cursor = db.cursor()
            cursor.execute(
                "SELECT name," + db.cast('value', 'int') +
                " FROM enum WHERE type=%s", (field, ))
            for name, value in cursor:
                self.classMap[name] = value

        display = None
        colorBy = options['colorBy']

        # Build the map the first time we need it
        if self.classMap == None:
            # Enums (TODO: what others should I list?)
            if options['colorBy'] in ['priority', 'severity']:
                _buildEnumMap(colorBy)
            else:
                _buildMap(colorBy)

        # Set display based on class map
        if t[colorBy] in self.classMap:
            display = 'class=ticket-class%d' % self.classMap[t[colorBy]]

        # Add closed status for strike through
        if t['status'] == 'closed':
            if display == None:
                display = 'class=ticket-closed'
            else:
                display += ' ticket-closed'

        if display == None:
            display = '#ff7f3f'
        return display

    # Format a ticket into JavaScript source to display the
    # task. ticket is expected to have:
    #   children - child ticket IDs or None
    #   description - ticket description.
    #   id - ticket ID, an integer
    #   level - levels from root (0)
    #   link - What to link to
    #   owner - Used as resource name.
    #   percent - integer percent complete, 0..100 (or "act/est")
    #   priority - used to color the task
    #   calc_finish - end date (ignored if children is not None)
    #   self.fields[parent] - parent ticket ID
    #   self.fields[pred] - predecessor ticket IDs
    #   calc_start - start date (ignored if children is not None)
    #   status - string displayed in tool tip ; FIXME - not displayed yet
    #   summary - ticket summary
    #   type - string displayed in tool tip FIXME - not displayed yet
    def _format_ticket(self, ticket, options):
        # Translate owner to full name
        def _owner(ticket):
            if self.pm.isMilestone(ticket):
                owner_name = ''
            else:
                owner_name = ticket['owner']
                if options['userMap']:
                    # Build the map the first time we use it
                    if self.user_map is None:
                        self.user_map = {}
                        for username, name, email in self.env.get_known_users(
                        ):
                            self.user_map[username] = name
                    # Map the user name
                    if self.user_map.get(owner_name):
                        owner_name = self.user_map[owner_name]
            return owner_name

        task = ''

        # pID, pName
        if self.pm.isMilestone(ticket):
            if ticket['id'] > 0:
                # Put ID number on inchpebbles
                name = 'MS:%s (#%s)' % (ticket['summary'], ticket['id'])
            else:
                # Don't show bogus ID of milestone pseudo tickets.
                name = 'MS:%s' % ticket['summary']
        else:
            name = "#%d:%s (%s %s)" % \
                   (ticket['id'], ticket['summary'],
                    ticket['status'], ticket['type'])
        task += 't = new JSGantt.TaskItem(%s,%d,"%s",' % \
            (self.GanttID, ticket['id'], javascript_quote(name))

        # pStart, pEnd
        task += '"%s",' % self.pm.start(ticket).strftime(self.pyDateFormat)
        task += '"%s",' % self.pm.finish(ticket).strftime(self.pyDateFormat)

        # pDisplay
        task += '"%s",' % javascript_quote(self._task_display(ticket, options))

        # pLink
        task += '"%s",' % javascript_quote(ticket['link'])

        # pMile
        if self.pm.isMilestone(ticket):
            task += '1,'
        else:
            task += '0,'

        # pRes (owner)
        task += '"%s",' % javascript_quote(_owner(ticket))

        # pComp (percent complete); integer 0..100
        task += '"%s",' % self.pm.percentComplete(ticket)

        # pGroup (has children)
        if self.pm.children(ticket):
            task += '%s,' % 1
        else:
            task += '%s,' % 0

        # pParent (parent task ID)
        # If there's no parent, don't link to it
        if self.pm.parent(ticket) == None:
            task += '%s,' % 0
        else:
            task += '%s,' % self.pm.parent(ticket)

        # open
        if int(ticket['level']) < int(options['openLevel']) and \
                ((options['expandClosedTickets'] != 0) or \
                     (ticket['status'] != 'closed')):
            openGroup = 1
        else:
            openGroup = 0
        task += '%d,' % openGroup

        # predecessors
        pred = [str(s) for s in self.pm.predecessors(ticket)]
        if len(pred):
            task += '"%s",' % javascript_quote(','.join(pred))
        else:
            task += '"%s",' % javascript_quote(','.join(''))

        # caption
        # FIXME - if caption isn't set to caption, use "" because the
        # description could be quite long and take a long time to make
        # safe and display.
        task += '"%s (%s %s)"' % (javascript_quote(
            ticket['description']), javascript_quote(
                ticket['status']), javascript_quote(ticket['type']))
        task += ');\n'
        task += self.GanttID + '.AddTaskItem(t);\n'
        return task

    def _filter_tickets(self, options, tickets):
        # Build the list of display filters from the configured value
        if not options.get('display') or options['display'] == '':
            displayFilter = {}
        else:
            # The general form is
            # 'display=field:value|field:value...'. Split on pipe to
            # get each part
            displayList = options['display'].split('|')

            # Process each part into the display filter
            displayFilter = {}
            field = None
            for f in displayList:
                parts = f.split(':')
                # Just one part, it's a value for the previous field
                if len(parts) == 1:
                    if field == None:
                        raise TracError(
                            ('display option error in "%s".' +
                             ' Should be "display=f1:v1|f2:v2"' +
                             ' or "display=f:v1|v2".') % options['display'])
                    else:
                        value = parts[0]
                else:
                    field = parts[0]
                    value = parts[1]

                if field in displayFilter:
                    displayFilter[field].append(value)
                else:
                    displayFilter[field] = [value]

        # If present and 1, true, otherwise false.
        if options.get('omitMilestones') \
                and int(options['omitMilestones']) == 1:
            omitMilestones = True
        else:
            omitMilestones = False

        # Filter the tickets
        filteredTickets = []
        for ticket in tickets:
            # Default to showing every ticket
            fieldDisplay = True

            if omitMilestones and \
                    self.pm.isTracMilestone(ticket):
                fieldDisplay = False
            else:
                # Process each element and disable display if all
                # filters fail to match. ((or) disjunction)
                for f in displayFilter:
                    display = True
                    for v in displayFilter[f]:
                        if ticket[f] == v:
                            display = True
                            break
                        display = False
                    fieldDisplay = fieldDisplay & display

            if fieldDisplay:
                filteredTickets.append(ticket)

        return filteredTickets

    # Sort tickets by options['order'].  For example,
    # order=milestone|wbs sorts by wbs within milestone.
    #
    # http://wiki.python.org/moin/HowTo/Sorting (at
    # #Sort_Stability_and_Complex_Sorts) notes that Python list
    # sorting is stable so you can sort by increasing priority of keys
    # (tertiary, then secondary, then primary) to get a multi-key
    # sort.
    #
    # FIXME - this sorts enums by text, not value.
    def _sortTickets(self, tickets, options):
        # Force milestones to the end
        def msSorter(t1, t2):
            # If t1 is a not milestone and t2 is, t1 comes first
            if not self.pm.isMilestone(t1) and self.pm.isMilestone(t2):
                result = -1
            elif self.pm.isMilestone(t1) and not self.pm.isMilestone(t2):
                result = 1
            else:
                result = 0
            return result

        # Get all the sort fields
        sortFields = options['order'].split('|')

        # If sorting by milestone, force milestone type tickets to the
        # end before any other sort.  The stability of the other sorts
        # will keep them at the end of the milestone group (unless
        # overridden by other fields listed in `order`).
        if 'milestone' in sortFields:
            tickets.sort(msSorter)

        # Reverse sort fields so lowest priority is first
        sortFields.reverse()

        # Do the sort by each field
        for field in sortFields:
            tickets.sort(key=itemgetter(field))

        return tickets

    def _add_tasks(self, options):
        if options.get('sample') and int(options['sample']) != 0:
            tasks = self._add_sample_tasks()
        else:
            tasks = ''
            self.tickets = self._query_tickets(options)

            # Faster lookups for WBS and scheduling.
            self.ticketsByID = {}
            for t in self.tickets:
                self.ticketsByID[t['id']] = t

            # Schedule the tasks
            self.pm.computeSchedule(options, self.tickets)

            # Sort tickets by date for computing WBS
            self.tickets.sort(self._compare_tickets)

            # Compute the WBS
            self._compute_wbs()

            # Set the link for clicking through the Gantt chart
            for t in self.tickets:
                if t['id'] > 0:
                    t['link'] = self.req.href.ticket(t['id'])
                else:
                    t['link'] = self.req.href.milestone(t['summary'])

            # Filter tickets based on options (omitMilestones, display, etc.)
            displayTickets = self._filter_tickets(options, self.tickets)

            # Sort the remaining tickets for display (based on order option).
            displayTickets = self._sortTickets(displayTickets, options)

            for ticket in displayTickets:
                tasks += self._format_ticket(ticket, options)

        return tasks

    def _parse_options(self, content):
        _, options = parse_args(content, strict=False)

        for opt in self.options.keys():
            if opt in options:
                # FIXME - test for success, log on failure
                if isinstance(self.options[opt], (int, long)):
                    options[opt] = int(options[opt])
            else:
                options[opt] = self.options[opt]

        # FIXME - test for success, log on failure
        options['hoursPerDay'] = float(options['hoursPerDay'])

        # Make sure we get all the tickets.  (For complex Gantts,
        # there can be a lot of tickets, easily more than the default
        # max.)
        if 'max' not in options:
            options['max'] = 999

        return options

    def expand_macro(self, formatter, name, content):
        self.req = formatter.req

        # Each invocation needs to build its own map.
        self.classMap = None

        options = self._parse_options(content)

        # Surely we can't create two charts in one microsecond.
        self.GanttID = 'g_' + str(to_utimestamp(datetime.now(localtz)))
        chart = ''
        tasks = self._add_tasks(options)
        if len(tasks) == 0:
            chart += 'No tasks selected.'
        else:
            chart += self._begin_gantt(options)
            chart += self._gantt_options(options)
            chart += tasks
            chart += self._end_gantt(options)

        return chart
Example #4
0
class TracJSGanttChart(WikiMacroBase):
    """
Displays a Gantt chart for the specified tickets.

The chart display can be controlled with a number of macro arguments:

||'''Argument'''||'''Description'''||'''Default'''||
|| `formats`||What to display in the format control.  A pipe-separated list of `minute`, `hour`, `day`, `week`, `month`, and `quarter` (though `minute` may not be very useful). ||'day|week|month|quarter'||
|| `format`||Initial display format, one of those listed in `formats` || First format ||
|| `sample`||Display sample tasks (1) or not (0) || 0 ||
|| `res`||Show resource column (1) or not (0) || 1 ||
|| `dur`||Show duration colunn (1) or not (0) || 1 ||
|| `comp`||Show percent complete column (1) or not (0) || 1 ||
|| `caption`||Caption to place to right of tasks: None, Caption, Resource, Duration, %Complete || Resource ||
|| `startDate`||Show start date column (1) or not (0) || 1 ||
|| `endDate`||Show end date column (1) or not (0) || 1 ||
|| `dateDisplay`||Date display format: 'mm/dd/yyyy', 'dd/mm/yyyy', or 'yyyy-mm-dd' || 'mm/dd/yyyy' ||
|| `openLevel`||Number of levels of tasks to show.  1 = only top level task.  || 999 ||
|| `colorBy`||Field to use to choose task colors.  Each unique value of the field will have a different color task.  Other likely useful values are owner and milestone but any field can be used. || priority ||
|| `root`||When using something like Subtickets plugin to maintain a tree of tickets and subtickets, you may create a Gantt showing a ticket and all of its descendants with `root=<ticket#>`.  The macro uses the configured `parent` field to find all descendant tasks and build an `id=` argument for Trac's native query handler.[[br]][[br]]Multiple roots may be provided like `root=1|12|32`.[[br]][[br]]When used in a ticket description or comment, `root=self` will display the current ticket's descendants.||None||
|| `goal`||When using something like MasterTickets plugin to maintain ticket dependencies, you may create a Gantt showing a ticket and all of its predecessors with `goal=<ticket#>`.  The macro uses the configured `succ` field to find all predecessor tasks and build an `id=` argument for Trac's native query handler.[[br]][[br]]Multiple goals may be provided like `goal=1|12|32`.[[br]][[br]]When used in a ticket description or comment, `goal=self` will display the current ticket's predecessors.||None||
|| `lwidth`||The width, in pixels, of the table of task names, etc. on the left of the Gantt. || ||
|| `showdep`||Show dependencies (1) or not (0)||1||
|| `userMap`||Map user !IDs to full names (1) or not (0).||1||
|| `omitMilestones`||Show milestones for displayed tickets (0) or only those specified by `milestone=` (1)||0||
|| `schedule`||Schedule tasks based on dependenies and estimates.  Either as soon as possible (asap) or as late as possible (alap)||alap||
||`doResourceLeveling`||Resolve resource conflicts (1) or not (0) when scheduling tickets.||0||
||`display`||Filter for limiting display of tickets.  `owner:fred` shows only tickets owned by fred. `status:closed` shows only closed tickets.||None||
||`order`||Order of fields used to sort tickets before display. `order=milestone` sorts by milestone.  May include ticket fields, including custom fields, or "wbs" (work breakdown structure).||wbs||

Site-wide defaults for macro arguments may be set in the `trac-jsgantt` section of `trac.ini`.  `option.<opt>` overrides the built-in default for `<opt>` from the table above.

All other macro arguments are treated as TracQuery specification (e.g., milestone=ms1|ms2) to control which tickets are displayed.

    """

    pm = None
    options = {}

    # The date part of these formats has to be in sync.  Including
    # hour and minute in the pyDateFormat makes the plugin easier to
    # debug at times because that's how the date shows up in page
    # source.
    #
    # jsDateFormat is the date format that the JavaScript expects
    # dates in.  It can be one of 'mm/dd/yyyy', 'dd/mm/yyyy', or
    # 'yyyy-mm-dd'.  pyDateFormat is a strptime() format that matches
    # jsDateFormat.  As long as they are in sync, there's no real
    # reason to change them.
    jsDateFormat = 'yyyy-mm-dd'
    pyDateFormat = '%Y-%m-%d %H:%M'

    # User map (login -> realname) is loaded on demand, once.
    # Initialization to None means it is not yet initialized.
    user_map = None

    def __init__(self):
        # Instantiate the PM component
        self.pm = TracPM(self.env)

        self.GanttID = 'g'


        # All the macro's options with default values.
        # Anything else passed to the macro is a TracQuery field.
        options = ('format', 'formats', 'sample', 'res', 'dur', 'comp', 
                   'caption', 'startDate', 'endDate', 'dateDisplay', 
                   'openLevel', 'expandClosedTickets', 'colorBy', 'lwidth', 
                   'showdep', 'userMap', 'omitMilestones',
                   'schedule', 'hoursPerDay', 'doResourceLeveling',
                   'display', 'order', 'scrollTo')

        for opt in options:
            self.options[opt] = self.config.get('trac-jsgantt',
                                                'option.%s' % opt)


    def _begin_gantt(self, options):
        if options['format']:
            defaultFormat = options['format']
        else:
            defaultFormat = options['formats'].split('|')[0]
        showdep = options['showdep']
        text = ''
        text += '<div style="position:relative" class="gantt" ' + \
            'id="GanttChartDIV_'+self.GanttID+'"></div>\n'
        text += '<script language="javascript">\n'
        text += 'var '+self.GanttID+' = new JSGantt.GanttChart("'+ \
            self.GanttID+'",document.getElementById("GanttChartDIV_'+ \
            self.GanttID+'"), "%s", "%s");\n' % \
            (javascript_quote(defaultFormat), showdep)
        text += 'var t;\n'
        text += 'if (window.addEventListener){\n'
        text += '  window.addEventListener("resize", ' + \
            'function() { ' + self.GanttID+'.Draw(); '
        if options['showdep']:
            text += self.GanttID+'.DrawDependencies();'
        text += '}, false);\n'
        text += '} else {\n'
        text += '  window.attachEvent("onresize", ' + \
            'function() { '+self.GanttID+'.Draw(); '
        if options['showdep']:
            text += self.GanttID+'.DrawDependencies();'
        text += '});\n'
        text += '}\n'
        return text

    def _end_gantt(self, options):
        chart = ''
        chart += self.GanttID+'.Draw();\n' 
        if options['showdep']:
            chart += self.GanttID+'.DrawDependencies();\n'
        chart += '</script>\n'
        return chart

    def _gantt_options(self, options):
        opt = ''
        opt += self.GanttID+'.setShowRes(%s);\n' % options['res']
        opt += self.GanttID+'.setShowDur(%s);\n' % options['dur']
        opt += self.GanttID+'.setShowComp(%s);\n' % options['comp']
        if (options['scrollTo']):
            opt += self.GanttID+'.setScrollDate("%s");\n' % options['scrollTo']
        w = options['lwidth']
        if w:
            opt += self.GanttID+'.setLeftWidth(%s);\n' % w
            

        opt += self.GanttID+'.setCaptionType("%s");\n' % \
            javascript_quote(options['caption'])

        opt += self.GanttID+'.setShowStartDate(%s);\n' % options['startDate']
        opt += self.GanttID+'.setShowEndDate(%s);\n' % options['endDate']

        opt += self.GanttID+'.setDateInputFormat("%s");\n' % \
            javascript_quote(self.jsDateFormat)

        opt += self.GanttID+'.setDateDisplayFormat("%s");\n' % \
            javascript_quote(options['dateDisplay'])

        opt += self.GanttID+'.setFormatArr(%s);\n' % ','.join(
            '"%s"' % javascript_quote(f) for f in options['formats'].split('|'))
        opt += self.GanttID+'.setPopupFeatures("location=1,scrollbars=1");\n'
        return opt

    # TODO - use ticket-classN styles instead of colors?
    def _add_sample_tasks(self):
        task= ''
        tasks = self.GanttID+'.setDateInputFormat("mm/dd/yyyy");\n'

        #                                                                         ID    Name                   Start        End          Display    Link                    MS Res         Pct  Gr Par Open Dep Cap
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',1,   "Define Chart API",     "",          "",          "#ff0000", "http://help.com",      0, "Brian",     0,  1, 0,  1));\n'
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',11,  "Chart Object",         "2/20/2011", "2/20/2011", "#ff00ff", "http://www.yahoo.com", 1, "Shlomy",  100,  0, 1,  1));\n'
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',12,  "Task Objects",         "",          "",          "#00ff00", "",                     0, "Shlomy",   40,  1, 1,  1));\n'
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',121, "Constructor Proc",     "2/21/2011", "3/9/2011",  "#00ffff", "http://www.yahoo.com", 0, "Brian T.", 60,  0, 12, 1));\n'
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',122, "Task Variables",       "3/6/2011",  "3/11/2011", "#ff0000", "http://help.com",      0, "",         60,  0, 12, 1,121));\n'
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',123, "Task Functions",       "3/9/2011",  "3/29/2011", "#ff0000", "http://help.com",      0, "Anyone",   60,  0, 12, 1, 0, "This is another caption"));\n'
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',2,   "Create HTML Shell",    "3/24/2011", "3/25/2011", "#ffff00", "http://help.com",      0, "Brian",    20,  0, 0,  1,122));\n'
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',3,   "Code Javascript",      "",          "",          "#ff0000", "http://help.com",      0, "Brian",     0,  1, 0,  1));\n'
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',31,  "Define Variables",     "2/25/2011", "3/17/2011", "#ff00ff", "http://help.com",      0, "Brian",    30,  0, 3,  1, 0,"Caption 1"));\n'
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',32,  "Calculate Chart Size", "3/15/2011", "3/24/2011", "#00ff00", "http://help.com",      0, "Shlomy",   40,  0, 3,  1));\n'
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',33,  "Draw Taks Items",      "",          "",          "#00ff00", "http://help.com",      0, "Someone",  40,  1, 3,  1));\n'
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',332, "Task Label Table",     "3/6/2011",  "3/11/2011", "#0000ff", "http://help.com",      0, "Brian",    60,  0, 33, 1));\n'
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',333, "Task Scrolling Grid",  "3/9/2011",  "3/20/2011", "#0000ff", "http://help.com",      0, "Brian",    60,  0, 33, 1));\n'
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',34,  "Draw Task Bars",       "",          "",          "#990000", "http://help.com",      0, "Anybody",  60,  1, 3,  1));\n'
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',341, "Loop each Task",       "3/26/2011", "4/11/2011", "#ff0000", "http://help.com",      0, "Brian",    60,  0, 34, 1, "332,333"));\n'
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',342, "Calculate Start/Stop", "4/12/2011", "5/18/2011", "#ff6666", "http://help.com",      0, "Brian",    60,  0, 34, 1));\n'
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',343, "Draw Task Div",        "5/13/2011", "5/17/2011", "#ff0000", "http://help.com",      0, "Brian",    60,  0, 34, 1));\n'
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',344, "Draw Completion Div",  "5/17/2011", "6/04/2011", "#ff0000", "http://help.com",      0, "Brian",    60,  0, 34, 1));\n'
        tasks += self.GanttID+'.AddTaskItem(new JSGantt.TaskItem('+self.GanttID+',35,  "Make Updates",         "10/17/2011","12/04/2011","#f600f6", "http://help.com",      0, "Brian",    30,  0, 3,  1));\n'
        return tasks

    # Get the required columns for the tickets which match the
    # criteria in options.
    def _query_tickets(self, options):
        query_options = {}
        for key in options.keys():
            if not key in self.options:
                query_options[key] = options[key]

        # The fields always needed by the Gantt
        fields = set([
            'description', 
            'owner', 
            'type', 
            'status', 
            'summary', 
            'milestone', 
            'priority'])

        # Make sure the coloring field is included
        if 'colorBy' in options:
            fields.add(str(options['colorBy']))

        rawtickets = self.pm.query(query_options, fields, self.req)

        # Do permissions check on tickets
        tickets = [t for t in rawtickets
                   if 'TICKET_VIEW' in self.req.perm('ticket', t['id'])] 

        return tickets

    def _compare_tickets(self, t1, t2):
        # If t2 depends on t1, t2 is first
        if t1['id'] in self.pm.successors(t2):
            return 1
        # If t1 depends on t2, t1 is first
        elif t2['id'] in self.pm.successors(t1):
            return -1
        # If t1 ends first, it's first
        elif self.pm.finish(t1) < self.pm.finish(t2):
            return -1
        # If t2 ends first, it's first
        elif self.pm.finish(t1) > self.pm.finish(t2):
            return 1
        # End dates are same. If t1 starts later, it's later
        elif self.pm.start(t1) > self.pm.start(t2):
            return 1
        # Otherwise, preserve order (assume t1 is before t2 when called)
        else:
            return 0

    # Compute WBS for sorting and figure out the tickets' levels for
    # controlling how many levels are open.  
    #
    # WBS is a list like [ 2, 4, 1] (the first child of the fourth
    # child of the second top-level element).
    def _compute_wbs(self):
        # Set the ticket's level and wbs then recurse to children.
        def _setLevel(tid, wbs, level):
            # Update this node
            self.ticketsByID[tid]['level'] = level
            self.ticketsByID[tid]['wbs'] = copy.copy(wbs)

            # Recurse to children
            childIDs = self.pm.children(self.ticketsByID[tid])
            if childIDs:
                childTickets = [self.ticketsByID[cid] for cid in childIDs]
                childTickets.sort(self._compare_tickets)
                childIDs = [ct['id'] for ct in childTickets]
                
                # Add another level
                wbs.append(1)
                for c in childIDs:
                    wbs = _setLevel(c, wbs, level+1)
                # Remove the level we added
                wbs.pop()
            

            # Increment last element of wbs
            wbs[len(wbs)-1] += 1

            return wbs

        # Set WBS and level on all top level tickets (and recurse) If
        # a ticket's parent is not in the viewed tickets, consider it
        # top-level
        wbs = [ 1 ]
        roots = self.pm.roots(self.ticketsByID)
        for t in self.tickets:
            if t['id'] in roots:
                wbs = _setLevel(t['id'], wbs, 1)


    def _task_display(self, t, options):
        def _buildMap(field):
            self.classMap = {}
            i = 0
            for t in self.tickets:
                if t[field] not in self.classMap:
                    i = i + 1
                    self.classMap[t[field]] = i

        def _buildEnumMap(field):
            self.classMap = {}
            db = self.env.get_db_cnx()
            cursor = db.cursor()
            cursor.execute("SELECT name," + 
                           db.cast('value', 'int') + 
                           " FROM enum WHERE type=%s", (field,))
            for name, value in cursor:
                self.classMap[name] = value

        display = None
        colorBy = options['colorBy']

        # Build the map the first time we need it
        if self.classMap == None:
            # Enums (TODO: what others should I list?)
            if options['colorBy'] in ['priority', 'severity']:
                _buildEnumMap(colorBy)
            else:
                _buildMap(colorBy)

        # Set display based on class map
        if t[colorBy] in self.classMap:
            display = 'class=ticket-class%d' % self.classMap[t[colorBy]]

        # Add closed status for strike through
        if t['status'] == 'closed':
            if display == None:
                display = 'class=ticket-closed'
            else:
                display += ' ticket-closed'

        if display == None:
            display = '#ff7f3f'
        return display
        

    # Format a ticket into JavaScript source to display the
    # task. ticket is expected to have:
    #   children - child ticket IDs or None
    #   description - ticket description.
    #   id - ticket ID, an integer
    #   level - levels from root (0)
    #   link - What to link to
    #   owner - Used as resource name.
    #   percent - integer percent complete, 0..100 (or "act/est")
    #   priority - used to color the task
    #   calc_finish - end date (ignored if children is not None)
    #   self.fields[parent] - parent ticket ID
    #   self.fields[pred] - predecessor ticket IDs
    #   calc_start - start date (ignored if children is not None)
    #   status - string displayed in tool tip ; FIXME - not displayed yet
    #   summary - ticket summary
    #   type - string displayed in tool tip FIXME - not displayed yet
    def _format_ticket(self, ticket, options):
        # Translate owner to full name
        def _owner(ticket):
            if self.pm.isMilestone(ticket):
                owner_name = ''
            else:
                owner_name = ticket['owner']
                if options['userMap']:
                    # Build the map the first time we use it
                    if self.user_map is None:
                        self.user_map = {}
                        for username, name, email in self.env.get_known_users():
                            self.user_map[username] = name
                    # Map the user name
                    if self.user_map.get(owner_name):
                        owner_name = self.user_map[owner_name]
            return owner_name
            
        task = ''

        # pID, pName
        if self.pm.isMilestone(ticket):
            if ticket['id'] > 0:
                # Put ID number on inchpebbles
                name = 'MS:%s (#%s)' % (ticket['summary'], ticket['id'])
            else:
                # Don't show bogus ID of milestone pseudo tickets.
                name = 'MS:%s' % ticket['summary']
        else:
            name = "#%d:%s (%s %s)" % \
                   (ticket['id'], ticket['summary'],
                    ticket['status'], ticket['type'])
        task += 't = new JSGantt.TaskItem(%s,%d,"%s",' % \
            (self.GanttID, ticket['id'], javascript_quote(name))

        # pStart, pEnd
        task += '"%s",' % self.pm.start(ticket).strftime(self.pyDateFormat)
        task += '"%s",' % self.pm.finish(ticket).strftime(self.pyDateFormat)

        # pDisplay
        task += '"%s",' % javascript_quote(self._task_display(ticket, options))

        # pLink
        task += '"%s",' % javascript_quote(ticket['link'])

        # pMile
        if self.pm.isMilestone(ticket):
            task += '1,'
        else:
            task += '0,'

        # pRes (owner)
        task += '"%s",' % javascript_quote(_owner(ticket))

        # pComp (percent complete); integer 0..100
        task += '"%s",' % self.pm.percentComplete(ticket)

        # pGroup (has children)
        if self.pm.children(ticket):
            task += '%s,' % 1
        else:
            task += '%s,' % 0
        
        # pParent (parent task ID) 
        # If there's no parent, don't link to it
        if self.pm.parent(ticket) == None:
            task += '%s,' % 0
        else:
            task += '%s,' % self.pm.parent(ticket)

        # open
        if ticket['level'] < options['openLevel'] and \
                ((options['expandClosedTickets'] != 0) or \
                     (ticket['status'] != 'closed')):
            openGroup = 1
        else:
            openGroup = 0
        task += '%d,' % openGroup

        # predecessors
        pred = [str(s) for s in self.pm.predecessors(ticket)]
        if len(pred):
            task += '"%s",' % javascript_quote(','.join(pred))
        else:
            task += '"%s",' % javascript_quote(','.join(''))
        
        # caption 
        # FIXME - if caption isn't set to caption, use "" because the
        # description could be quite long and take a long time to make
        # safe and display.
        task += '"%s (%s %s)"' % (javascript_quote(ticket['description']),
                                  javascript_quote(ticket['status']),
                                  javascript_quote(ticket['type']))
        task += ');\n'
        task += self.GanttID+'.AddTaskItem(t);\n'
        return task

    def _filter_tickets(self, options, tickets):
        # Build the list of display filters from the configured value
        if not options.get('display') or options['display'] == '':
            displayFilter = {}
        else:
            # The general form is
            # 'display=field:value|field:value...'. Split on pipe to
            # get each part
            displayList = options['display'].split('|')

            # Process each part into the display filter
            displayFilter = {}
            field = None
            for f in displayList:
                parts = f.split(':')
                # Just one part, it's a value for the previous field
                if len(parts) == 1:
                    if field == None:
                        raise TracError(('display option error in "%s".' +
                                         ' Should be "display=f1:v1|f2:v2"' +
                                         ' or "display=f:v1|v2".') %
                                        options['display'])
                    else:
                        value = parts[0]
                else:
                    field = parts[0]
                    value = parts[1]

                if field in displayFilter:
                    displayFilter[field].append(value)
                else:
                    displayFilter[field] = [ value ]

        # If present and 1, true, otherwise false.
        if options.get('omitMilestones') \
                and int(options['omitMilestones']) == 1:
            omitMilestones = True
        else:
            omitMilestones = False
        
        # Filter the tickets
        filteredTickets = []
        for ticket in tickets:
            # Default to showing every ticket
            fieldDisplay = True

            if omitMilestones and \
                    self.pm.isTracMilestone(ticket):
                fieldDisplay = False
            else:
                # Process each element and disable display if all
                # filters fail to match. ((or) disjunction)
                for f in displayFilter:
                    display = True
                    for v in displayFilter[f]:
                        if ticket[f] == v:
                            display = True
                            break
                        display = False
                    fieldDisplay = fieldDisplay & display

            if fieldDisplay:
                filteredTickets.append(ticket)

        
        return filteredTickets

    # Sort tickets by options['order'].  For example,
    # order=milestone|wbs sorts by wbs within milestone.
    #
    # http://wiki.python.org/moin/HowTo/Sorting (at
    # #Sort_Stability_and_Complex_Sorts) notes that Python list
    # sorting is stable so you can sort by increasing priority of keys
    # (tertiary, then secondary, then primary) to get a multi-key
    # sort.  
    #
    # FIXME - this sorts enums by text, not value.
    def _sortTickets(self, tickets, options):
        # Force milestones to the end
        def msSorter(t1, t2):
            # If t1 is a not milestone and t2 is, t1 comes first
            if not self.pm.isMilestone(t1) and self.pm.isMilestone(t2):
                result = -1
            elif self.pm.isMilestone(t1) and not self.pm.isMilestone(t2):
                result = 1
            else:
                result = 0
            return result

        # Get all the sort fields
        sortFields = options['order'].split('|')

        # If sorting by milestone, force milestone type tickets to the
        # end before any other sort.  The stability of the other sorts
        # will keep them at the end of the milestone group (unless
        # overridden by other fields listed in `order`).
        if 'milestone' in sortFields:
            tickets.sort(msSorter)

        # Reverse sort fields so lowest priority is first
        sortFields.reverse()

        # Do the sort by each field
        for field in sortFields:
            tickets.sort(key=itemgetter(field))

        return tickets


    def _add_tasks(self, options):
        if options.get('sample') and int(options['sample']) != 0:
            tasks = self._add_sample_tasks()
        else:
            tasks = ''
            self.tickets = self._query_tickets(options)

            # Faster lookups for WBS and scheduling.
            self.ticketsByID = {}
            for t in self.tickets:
                self.ticketsByID[t['id']] = t

            # Schedule the tasks
            self.pm.computeSchedule(options, self.tickets)

            # Sort tickets by date for computing WBS
            self.tickets.sort(self._compare_tickets)

            # Compute the WBS
            self._compute_wbs()                

            # Set the link for clicking through the Gantt chart
            for t in self.tickets:
                if t['id'] > 0:
                    t['link'] = self.req.href.ticket(t['id'])
                else:
                    t['link'] = self.req.href.milestone(t['summary'])

            # Filter tickets based on options (omitMilestones, display, etc.)
            displayTickets = self._filter_tickets(options, self.tickets)

            # Sort the remaining tickets for display (based on order option).
            displayTickets = self._sortTickets(displayTickets, options)

            for ticket in displayTickets:
                tasks += self._format_ticket(ticket, options)

        return tasks

    def _parse_options(self, content):
        _, options = parse_args(content, strict=False)

        for opt in self.options.keys():
            if opt in options:
                # FIXME - test for success, log on failure
                if isinstance(self.options[opt], (int, long)):
                    options[opt] = int(options[opt])
            else:
                options[opt] = self.options[opt]

        # FIXME - test for success, log on failure
        options['hoursPerDay'] = float(options['hoursPerDay'])

        # Make sure we get all the tickets.  (For complex Gantts,
        # there can be a lot of tickets, easily more than the default
        # max.)
        if 'max' not in options:
            options['max'] = 999

        return options
 
    def expand_macro(self, formatter, name, content):
        self.req = formatter.req

        # Each invocation needs to build its own map.
        self.classMap = None

        options = self._parse_options(content)

        # Surely we can't create two charts in one microsecond.
        self.GanttID = 'g_'+str(to_utimestamp(datetime.now(localtz)))
        chart = ''
        tasks = self._add_tasks(options)
        if len(tasks) == 0:
            chart += 'No tasks selected.'
        else:
            chart += self._begin_gantt(options)
            chart += self._gantt_options(options)
            chart += tasks
            chart += self._end_gantt(options)

        return chart