~ubuntu-branches/ubuntu/precise/trac/precise

« back to all changes in this revision

Viewing changes to trac/ticket/roadmap.py

  • Committer: Bazaar Package Importer
  • Author(s): Luis Matos
  • Date: 2008-07-13 23:46:20 UTC
  • mfrom: (1.1.13 upstream)
  • Revision ID: james.westby@ubuntu.com-20080713234620-13ynpdpkbaymfg1z
Tags: 0.11-2
* Re-added python-setup-tools to build dependences. Closes: #490320 #468705
* New upstream release Closes: 489727
* Added sugestion for other vcs support available: git bazaar mercurial 
* Added spamfilter plugin to sugests
* Moved packaging from python-support to python-central
* Added an entry to the NEWS about the cgi Closes: #490275
* Updated 10_remove_trac_suffix_from_title patch to be used in 0.11

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
1
# -*- coding: utf-8 -*-
2
2
#
3
 
# Copyright (C) 2004-2006 Edgewall Software
 
3
# Copyright (C) 2004-2008 Edgewall Software
4
4
# Copyright (C) 2004-2005 Christopher Lenz <cmlenz@gmx.de>
5
 
# Copyright (C) 2006 Christian Boos <cboos@neuf.fr>
 
5
# Copyright (C) 2006-2007 Christian Boos <cboos@neuf.fr>
6
6
# All rights reserved.
7
7
#
8
8
# This software is licensed as described in the file COPYING, which
15
15
#
16
16
# Author: Christopher Lenz <cmlenz@gmx.de>
17
17
 
 
18
from datetime import datetime
18
19
import re
19
20
from time import localtime, strftime, time
20
21
 
 
22
from genshi.builder import tag
 
23
 
21
24
from trac import __version__
 
25
from trac.attachment import AttachmentModule
 
26
from trac.config import ExtensionOption
22
27
from trac.core import *
 
28
from trac.mimeview import Context
23
29
from trac.perm import IPermissionRequestor
24
 
from trac.util.datefmt import format_date, format_datetime, parse_date, \
25
 
                               pretty_timedelta
26
 
from trac.util.html import html, unescape, Markup
 
30
from trac.resource import *
 
31
from trac.util.compat import set, sorted
 
32
from trac.util.datefmt import parse_date, utc, to_timestamp, to_datetime, \
 
33
                              get_date_format_hint, get_datetime_format_hint, \
 
34
                              format_date, format_datetime
27
35
from trac.util.text import shorten_line, CRLF, to_unicode
 
36
from trac.util.translation import _
28
37
from trac.ticket import Milestone, Ticket, TicketSystem
29
 
from trac.Timeline import ITimelineEventProvider
 
38
from trac.ticket.query import Query
 
39
from trac.timeline.api import ITimelineEventProvider
30
40
from trac.web import IRequestHandler
31
 
from trac.web.chrome import add_link, add_stylesheet, INavigationContributor
32
 
from trac.wiki import wiki_to_html, wiki_to_oneliner, IWikiSyntaxProvider
33
 
 
 
41
from trac.web.chrome import add_link, add_stylesheet, add_warning, \
 
42
                            INavigationContributor
 
43
from trac.wiki.api import IWikiSyntaxProvider
 
44
from trac.wiki.formatter import format_to_html
 
45
 
 
46
class ITicketGroupStatsProvider(Interface):
 
47
    def get_ticket_group_stats(ticket_ids):
 
48
        """ Gather statistics on a group of tickets.
 
49
 
 
50
        This method returns a valid TicketGroupStats object.
 
51
        """
 
52
 
 
53
class TicketGroupStats(object):
 
54
    """Encapsulates statistics on a group of tickets."""
 
55
 
 
56
    def __init__(self, title, unit):
 
57
        """Creates a new TicketGroupStats object.
 
58
        
 
59
        `title` is the display name of this group of stats (e.g.
 
60
          'ticket status').
 
61
        `unit` is the display name of the units for these stats (e.g. 'hour').
 
62
        """
 
63
        self.title = title
 
64
        self.unit = unit
 
65
        self.count = 0
 
66
        self.qry_args = {}
 
67
        self.intervals = []
 
68
        self.done_percent = 0
 
69
        self.done_count = 0
 
70
 
 
71
    def add_interval(self, title, count, qry_args, css_class,
 
72
                     overall_completion=None, countsToProg=0):
 
73
        """Adds a division to this stats' group's progress bar.
 
74
 
 
75
        `title` is the display name (eg 'closed', 'spent effort') of this
 
76
        interval that will be displayed in front of the unit name.
 
77
        `count` is the number of units in the interval.
 
78
        `qry_args` is a dict of extra params that will yield the subset of
 
79
          tickets in this interval on a query.
 
80
        `css_class` is the css class that will be used to display the division.
 
81
        `overall_completion` can be set to true to make this interval count
 
82
          towards overall completion of this group of tickets.
 
83
          
 
84
        (Warning: `countsToProg` argument will be removed in 0.12, use
 
85
        `overall_completion` instead)
 
86
        """
 
87
        if overall_completion is None:
 
88
            overall_completion = countsToProg
 
89
        self.intervals.append({
 
90
            'title': title,
 
91
            'count': count,
 
92
            'qry_args': qry_args,
 
93
            'css_class': css_class,
 
94
            'percent': None,
 
95
            'countsToProg': overall_completion,
 
96
            'overall_completion': overall_completion,
 
97
        })
 
98
        self.count = self.count + count
 
99
 
 
100
    def refresh_calcs(self):
 
101
        if self.count < 1:
 
102
            return
 
103
        total_percent = 0
 
104
        self.done_percent = 0
 
105
        self.done_count = 0
 
106
        for interval in self.intervals:
 
107
            interval['percent'] = round(float(interval['count'] / 
 
108
                                        float(self.count) * 100))
 
109
            total_percent = total_percent + interval['percent']
 
110
            if interval['overall_completion']:
 
111
                self.done_percent += interval['percent']
 
112
                self.done_count += interval['count']
 
113
 
 
114
        # We want the percentages to add up to 100%.  To do that, we fudge the
 
115
        # first interval that counts as "completed".  That interval is adjusted
 
116
        # by enough to make the intervals sum to 100%.
 
117
        if self.done_count and total_percent != 100:
 
118
            fudge_int = [i for i in self.intervals
 
119
                         if i['overall_completion']][0]
 
120
            fudge_amt = 100 - total_percent
 
121
            fudge_int['percent'] += fudge_amt
 
122
            self.done_percent += fudge_amt
 
123
 
 
124
 
 
125
class DefaultTicketGroupStatsProvider(Component):
 
126
    """Configurable ticket group statistics provider.
 
127
 
 
128
    Example configuration (which is also the default):
 
129
    {{{
 
130
    [milestone-groups]
 
131
 
 
132
    # Definition of a 'closed' group:
 
133
    
 
134
    closed = closed
 
135
 
 
136
    # The definition consists in a comma-separated list of accepted status.
 
137
    # Also, '*' means any status and could be used to associate all remaining
 
138
    # states to one catch-all group.
 
139
 
 
140
    # Qualifiers for the above group (the group must have been defined first):
 
141
    
 
142
    closed.order = 0                     # sequence number in the progress bar
 
143
    closed.query_args = group=resolution # optional extra param for the query
 
144
    closed.overall_completion = true     # count for overall completion
 
145
 
 
146
    # Definition of an 'active' group:
 
147
 
 
148
    active = *                           # one catch-all group is allowed
 
149
    active.order = 1
 
150
    active.css_class = open              # CSS class for this interval
 
151
    active.label = in progress           # Displayed name for the group,
 
152
                                         #  needed for non-ascii group names
 
153
 
 
154
    # The CSS class can be one of: new (yellow), open (no color) or
 
155
    # closed (green). New styles can easily be added using the following
 
156
    # selector:  `table.progress td.<class>`
 
157
    }}}
 
158
    """
 
159
 
 
160
    implements(ITicketGroupStatsProvider)
 
161
 
 
162
    default_milestone_groups =  [
 
163
        {'name': 'closed', 'status': 'closed',
 
164
         'query_args': 'group=resolution', 'overall_completion': 'true'},
 
165
        {'name': 'active', 'status': '*', 'css_class': 'open'}
 
166
        ]
 
167
 
 
168
    def _get_ticket_groups(self):
 
169
        """Returns a list of dict describing the ticket groups
 
170
        in the expected order of appearance in the milestone progress bars.
 
171
        """
 
172
        if 'milestone-groups' in self.config:
 
173
            groups = {}
 
174
            order = 0
 
175
            for groupname, value in self.config.options('milestone-groups'):
 
176
                qualifier = 'status'
 
177
                if '.' in groupname:
 
178
                    groupname, qualifier = groupname.split('.', 1)
 
179
                group = groups.setdefault(groupname, {'name': groupname,
 
180
                                                      'order': order})
 
181
                group[qualifier] = value
 
182
                order = max(order, int(group['order'])) + 1
 
183
            return [group for group in sorted(groups.values(),
 
184
                                              key=lambda g: int(g['order']))]
 
185
        else:
 
186
            return self.default_milestone_groups
 
187
 
 
188
    def get_ticket_group_stats(self, ticket_ids):
 
189
        total_cnt = len(ticket_ids)
 
190
        all_statuses = set(TicketSystem(self.env).get_all_status())
 
191
        status_cnt = {}
 
192
        for s in all_statuses:
 
193
            status_cnt[s] = 0
 
194
        if total_cnt:
 
195
            cursor = self.env.get_db_cnx().cursor()
 
196
            str_ids = [str(x) for x in sorted(ticket_ids)]
 
197
            cursor.execute("SELECT status, count(status) FROM ticket "
 
198
                           "WHERE id IN (%s) GROUP BY status" %
 
199
                           ",".join(str_ids))
 
200
            for s, cnt in cursor:
 
201
                status_cnt[s] = cnt
 
202
 
 
203
        stat = TicketGroupStats('ticket status', 'ticket')
 
204
        remaining_statuses = set(all_statuses)
 
205
        groups =  self._get_ticket_groups()
 
206
        catch_all_group = None
 
207
        # we need to go through the groups twice, so that the catch up group
 
208
        # doesn't need to be the last one in the sequence
 
209
        for group in groups:
 
210
            status_str = group['status'].strip()
 
211
            if status_str == '*':
 
212
                if catch_all_group:
 
213
                    raise TracError(_(
 
214
                        "'%(group1)s' and '%(group2)s' milestone groups "
 
215
                        "both are declared to be \"catch-all\" groups. "
 
216
                        "Please check your configuration.",
 
217
                        group1=group['name'], group2=catch_all_group['name']))
 
218
                catch_all_group = group
 
219
            else:
 
220
                group_statuses = set([s.strip()
 
221
                                      for s in status_str.split(',')]) \
 
222
                                      & all_statuses
 
223
                if group_statuses - remaining_statuses:
 
224
                    raise TracError(_(
 
225
                        "'%(groupname)s' milestone group reused status "
 
226
                        "'%(status)s' already taken by other groups. "
 
227
                        "Please check your configuration.",
 
228
                        groupname=group['name'],
 
229
                        status=', '.join(group_statuses - remaining_statuses)))
 
230
                else:
 
231
                    remaining_statuses -= group_statuses
 
232
                group['statuses'] = group_statuses
 
233
        if catch_all_group:
 
234
            catch_all_group['statuses'] = remaining_statuses
 
235
        for group in groups:
 
236
            group_cnt = 0
 
237
            query_args = {}
 
238
            for s, cnt in status_cnt.iteritems():
 
239
                if s in group['statuses']:
 
240
                    group_cnt += cnt
 
241
                    query_args.setdefault('status', []).append(s)
 
242
            for arg in [kv for kv in group.get('query_args', '').split(',')
 
243
                        if '=' in kv]:
 
244
                k, v = [a.strip() for a in arg.split('=', 1)]
 
245
                query_args[k] = v
 
246
            stat.add_interval(group.get('label', group['name']), 
 
247
                              group_cnt, query_args,
 
248
                              group.get('css_class', group['name']),
 
249
                              bool(group.get('overall_completion')))
 
250
        stat.refresh_calcs()
 
251
        return stat
 
252
 
 
253
 
 
254
def get_ticket_stats(provider, tickets):
 
255
    return provider.get_ticket_group_stats([t['id'] for t in tickets])
34
256
 
35
257
def get_tickets_for_milestone(env, db, milestone, field='component'):
36
258
    cursor = db.cursor()
47
269
        tickets.append({'id': tkt_id, 'status': status, field: fieldval})
48
270
    return tickets
49
271
 
50
 
def get_query_links(req, milestone, grouped_by='component', group=None):
51
 
    q = {}
52
 
    if not group:
53
 
        q['all_tickets'] = req.href.query(milestone=milestone)
54
 
        q['active_tickets'] = req.href.query(
55
 
            milestone=milestone, status=('new', 'assigned', 'reopened'))
56
 
        q['closed_tickets'] = req.href.query(
57
 
            milestone=milestone, status='closed')
58
 
    else:
59
 
        q['all_tickets'] = req.href.query(
60
 
            {grouped_by: group}, milestone=milestone)
61
 
        q['active_tickets'] = req.href.query(
62
 
            {grouped_by: group}, milestone=milestone,
63
 
            status=('new', 'assigned', 'reopened'))
64
 
        q['closed_tickets'] = req.href.query(
65
 
            {grouped_by: group}, milestone=milestone, status='closed')
66
 
    return q
67
 
 
68
 
def calc_ticket_stats(tickets):
69
 
    total_cnt = len(tickets)
70
 
    active = [ticket for ticket in tickets if ticket['status'] != 'closed']
71
 
    active_cnt = len(active)
72
 
    closed_cnt = total_cnt - active_cnt
73
 
 
74
 
    percent_active, percent_closed = 0, 0
75
 
    if total_cnt > 0:
76
 
        percent_active = round(float(active_cnt) / float(total_cnt) * 100)
77
 
        percent_closed = round(float(closed_cnt) / float(total_cnt) * 100)
78
 
        if percent_active + percent_closed > 100:
79
 
            percent_closed -= 1
80
 
 
81
 
    return {
82
 
        'total_tickets': total_cnt,
83
 
        'active_tickets': active_cnt,
84
 
        'percent_active': percent_active,
85
 
        'closed_tickets': closed_cnt,
86
 
        'percent_closed': percent_closed
87
 
    }
88
 
 
89
 
def milestone_to_hdf(env, db, req, milestone):
90
 
    hdf = {'name': milestone.name,
91
 
           'href': req.href.milestone(milestone.name)}
92
 
    if milestone.description:
93
 
        hdf['description_source'] = milestone.description
94
 
        hdf['description'] = wiki_to_html(milestone.description, env, req, db)
95
 
    if milestone.due:
96
 
        hdf['due'] = milestone.due
97
 
        hdf['due_date'] = format_date(milestone.due)
98
 
        hdf['due_delta'] = pretty_timedelta(milestone.due + 86400)
99
 
        hdf['late'] = milestone.is_late
100
 
    if milestone.completed:
101
 
        hdf['completed'] = milestone.completed
102
 
        hdf['completed_date'] = format_datetime(milestone.completed)
103
 
        hdf['completed_delta'] = pretty_timedelta(milestone.completed)
104
 
    return hdf
105
 
 
106
 
def _get_groups(env, db, by='component'):
107
 
    for field in TicketSystem(env).get_ticket_fields():
108
 
        if field['name'] == by:
109
 
            if field.has_key('options'):
110
 
                return field['options']
111
 
            else:
112
 
                cursor = db.cursor()
113
 
                cursor.execute("SELECT DISTINCT %s FROM ticket ORDER BY %s"
114
 
                               % (by, by))
115
 
                return [row[0] for row in cursor]
116
 
    return []
 
272
def apply_ticket_permissions(env, req, tickets):
 
273
    """Apply permissions to a set of milestone tickets as returned by
 
274
    get_tickets_for_milestone()."""
 
275
    return [t for t in tickets
 
276
            if 'TICKET_VIEW' in req.perm('ticket', t['id'])]
 
277
 
 
278
def milestone_stats_data(req, stat, name, grouped_by='component', group=None):
 
279
    def query_href(extra_args):
 
280
        args = {'milestone': name, grouped_by: group, 'group': 'status'}
 
281
        args.update(extra_args)
 
282
        return req.href.query(args)
 
283
    return {'stats': stat,
 
284
            'stats_href': query_href(stat.qry_args),
 
285
            'interval_hrefs': [query_href(interval['qry_args'])
 
286
                               for interval in stat.intervals]}
 
287
 
117
288
 
118
289
 
119
290
class RoadmapModule(Component):
120
291
 
121
292
    implements(INavigationContributor, IPermissionRequestor, IRequestHandler)
 
293
    stats_provider = ExtensionOption('roadmap', 'stats_provider',
 
294
                                     ITicketGroupStatsProvider,
 
295
                                     'DefaultTicketGroupStatsProvider',
 
296
        """Name of the component implementing `ITicketGroupStatsProvider`, 
 
297
        which is used to collect statistics on groups of tickets for display
 
298
        in the roadmap views.""")
122
299
 
123
300
    # INavigationContributor methods
124
301
 
126
303
        return 'roadmap'
127
304
 
128
305
    def get_navigation_items(self, req):
129
 
        if not req.perm.has_permission('ROADMAP_VIEW'):
130
 
            return
131
 
        yield ('mainnav', 'roadmap',
132
 
               html.a('Roadmap', href=req.href.roadmap(), accesskey=3))
 
306
        if 'ROADMAP_VIEW' in req.perm:
 
307
            yield ('mainnav', 'roadmap',
 
308
                   tag.a(_('Roadmap'), href=req.href.roadmap(), accesskey=3))
133
309
 
134
310
    # IPermissionRequestor methods
135
311
 
142
318
        return re.match(r'/roadmap/?', req.path_info) is not None
143
319
 
144
320
    def process_request(self, req):
145
 
        req.perm.assert_permission('ROADMAP_VIEW')
146
 
        req.hdf['title'] = 'Roadmap'
 
321
        milestone_realm = Resource('milestone')
 
322
        req.perm.require('MILESTONE_VIEW')
147
323
 
148
324
        showall = req.args.get('show') == 'all'
149
 
        req.hdf['roadmap.showall'] = showall
150
325
 
151
326
        db = self.env.get_db_cnx()
152
 
        milestones = [milestone_to_hdf(self.env, db, req, m)
153
 
                      for m in Milestone.select(self.env, showall, db)]
154
 
        req.hdf['roadmap.milestones'] = milestones        
 
327
        milestones = [m for m in Milestone.select(self.env, showall, db)
 
328
                      if 'MILESTONE_VIEW' in req.perm(m.resource)]
 
329
        stats = []
 
330
        queries = []
155
331
 
156
 
        for idx, milestone in enumerate(milestones):
157
 
            milestone_name = unescape(milestone['name']) # Kludge
158
 
            prefix = 'roadmap.milestones.%d.' % idx
159
 
            tickets = get_tickets_for_milestone(self.env, db, milestone_name,
 
332
        for milestone in milestones:
 
333
            tickets = get_tickets_for_milestone(self.env, db, milestone.name,
160
334
                                                'owner')
161
 
            req.hdf[prefix + 'stats'] = calc_ticket_stats(tickets)
162
 
            for k, v in get_query_links(req, milestone_name).items():
163
 
                req.hdf[prefix + 'queries.' + k] = v
164
 
            milestone['tickets'] = tickets # for the iCalendar view
 
335
            tickets = apply_ticket_permissions(self.env, req, tickets)
 
336
            stat = get_ticket_stats(self.stats_provider, tickets)
 
337
            stats.append(milestone_stats_data(req, stat, milestone.name))
 
338
            #milestone['tickets'] = tickets # for the iCalendar view
165
339
 
166
340
        if req.args.get('format') == 'ics':
167
341
            self.render_ics(req, db, milestones)
168
342
            return
169
343
 
170
 
        add_stylesheet(req, 'common/css/roadmap.css')
171
 
 
172
344
        # FIXME should use the 'webcal:' scheme, probably
173
345
        username = None
174
346
        if req.authname and req.authname != 'anonymous':
175
347
            username = req.authname
176
 
        icshref = req.href.roadmap(show=req.args.get('show'),
177
 
                                        user=username, format='ics')
178
 
        add_link(req, 'alternate', icshref, 'iCalendar', 'text/calendar', 'ics')
 
348
        icshref = req.href.roadmap(show=req.args.get('show'), user=username,
 
349
                                   format='ics')
 
350
        add_link(req, 'alternate', icshref, _('iCalendar'), 'text/calendar',
 
351
                 'ics')
179
352
 
180
 
        return 'roadmap.cs', None
 
353
        data = {
 
354
            'milestones': milestones,
 
355
            'milestone_stats': stats,
 
356
            'queries': queries,
 
357
            'showall': showall,
 
358
        }
 
359
        return 'roadmap.html', data, None
181
360
 
182
361
    # Internal methods
183
362
 
222
401
 
223
402
        def write_date(name, value, params={}):
224
403
            params['VALUE'] = 'DATE'
225
 
            write_prop(name, strftime('%Y%m%d', value), params)
 
404
            write_prop(name, format_date(value, '%Y%m%d', req.tz), params)
226
405
 
227
406
        def write_utctime(name, value, params={}):
228
 
            write_prop(name, strftime('%Y%m%dT%H%M%SZ', value), params)
 
407
            write_prop(name, format_datetime(value, '%Y%m%dT%H%M%SZ', utc),
 
408
                       params)
229
409
 
230
410
        host = req.base_url[req.base_url.find('://') + 3:]
231
411
        user = req.args.get('user', 'anonymous')
236
416
                   % __version__)
237
417
        write_prop('METHOD', 'PUBLISH')
238
418
        write_prop('X-WR-CALNAME',
239
 
                   self.config.get('project', 'name') + ' - Roadmap')
 
419
                   self.config.get('project', 'name') + ' - ' + _('Roadmap'))
240
420
        for milestone in milestones:
241
 
            uid = '<%s/milestone/%s@%s>' % (req.base_path, milestone['name'],
 
421
            uid = '<%s/milestone/%s@%s>' % (req.base_path, milestone.name,
242
422
                                            host)
243
 
            if milestone.has_key('due'):
 
423
            if milestone.due:
244
424
                write_prop('BEGIN', 'VEVENT')
245
425
                write_prop('UID', uid)
246
 
                write_utctime('DTSTAMP', localtime(milestone['due']))
247
 
                write_date('DTSTART', localtime(milestone['due']))
248
 
                write_prop('SUMMARY', 'Milestone %s' % milestone['name'])
 
426
                write_utctime('DTSTAMP', milestone.due)
 
427
                write_date('DTSTART', milestone.due)
 
428
                write_prop('SUMMARY', _('Milestone %(name)s') % {
 
429
                    'name': milestone.name
 
430
                })
249
431
                write_prop('URL', req.base_url + '/milestone/' +
250
 
                           milestone['name'])
251
 
                if milestone.has_key('description_source'):
252
 
                    write_prop('DESCRIPTION', milestone['description_source'])
 
432
                           milestone.name)
 
433
                if milestone.description:
 
434
                    write_prop('DESCRIPTION', milestone.description)
253
435
                write_prop('END', 'VEVENT')
254
 
            for tkt_id in [ticket['id'] for ticket in milestone['tickets']
 
436
            tickets = get_tickets_for_milestone(self.env, db, milestone.name,
 
437
                                                field='owner')
 
438
            tickets = apply_ticket_permissions(self.env, req, tickets)
 
439
            for tkt_id in [ticket['id'] for ticket in tickets
255
440
                           if ticket['owner'] == user]:
256
441
                ticket = Ticket(self.env, tkt_id)
257
442
                write_prop('BEGIN', 'VTODO')
258
443
                write_prop('UID', '<%s/ticket/%s@%s>' % (req.base_path,
259
444
                                                         tkt_id, host))
260
 
                if milestone.has_key('due'):
 
445
                if milestone.due:
261
446
                    write_prop('RELATED-TO', uid)
262
 
                    write_date('DUE', localtime(milestone['due']))
263
 
                write_prop('SUMMARY', 'Ticket #%i: %s' % (ticket.id,
264
 
                                                          ticket['summary']))
 
447
                    write_date('DUE', milestone.due)
 
448
                write_prop('SUMMARY', _('Ticket #%(num)s: %(summary)s') % {
 
449
                    'num': ticket.id, 'summary': ticket['summary']
 
450
                })
265
451
                write_prop('URL', req.abs_href.ticket(ticket.id))
266
452
                write_prop('DESCRIPTION', ticket['description'])
267
453
                priority = get_priority(ticket)
276
462
                                   (ticket.id,))
277
463
                    row = cursor.fetchone()
278
464
                    if row:
279
 
                        write_utctime('COMPLETED', localtime(row[0]))
 
465
                        write_utctime('COMPLETED', to_datetime(row[0], utc))
280
466
                write_prop('END', 'VTODO')
281
467
        write_prop('END', 'VCALENDAR')
282
468
 
283
469
 
 
470
 
284
471
class MilestoneModule(Component):
285
472
 
286
473
    implements(INavigationContributor, IPermissionRequestor, IRequestHandler,
287
 
               ITimelineEventProvider, IWikiSyntaxProvider)
 
474
               ITimelineEventProvider, IWikiSyntaxProvider, IResourceManager)
 
475
 
 
476
    stats_provider = ExtensionOption('milestone', 'stats_provider',
 
477
                                     ITicketGroupStatsProvider,
 
478
                                     'DefaultTicketGroupStatsProvider',
 
479
        """Name of the component implementing `ITicketGroupStatsProvider`, 
 
480
        which is used to collect statistics on groups of tickets for display
 
481
        in the milestone views.""")
 
482
    
288
483
 
289
484
    # INavigationContributor methods
290
485
 
305
500
    # ITimelineEventProvider methods
306
501
 
307
502
    def get_timeline_filters(self, req):
308
 
        if req.perm.has_permission('MILESTONE_VIEW'):
309
 
            yield ('milestone', 'Milestones')
 
503
        if 'MILESTONE_VIEW' in req.perm:
 
504
            yield ('milestone', _('Milestones'))
310
505
 
311
506
    def get_timeline_events(self, req, start, stop, filters):
312
507
        if 'milestone' in filters:
313
 
            format = req.args.get('format')
 
508
            milestone_realm = Resource('milestone')
314
509
            db = self.env.get_db_cnx()
315
510
            cursor = db.cursor()
 
511
            # TODO: creation and (later) modifications should also be reported
316
512
            cursor.execute("SELECT completed,name,description FROM milestone "
317
513
                           "WHERE completed>=%s AND completed<=%s",
318
 
                           (start, stop,))
 
514
                           (to_timestamp(start), to_timestamp(stop)))
319
515
            for completed, name, description in cursor:
320
 
                title = Markup('Milestone <em>%s</em> completed', name)
321
 
                if format == 'rss':
322
 
                    href = req.abs_href.milestone(name)
323
 
                    message = wiki_to_html(description, self.env, req, db,
324
 
                                           absurls=True)
325
 
                else:
326
 
                    href = req.href.milestone(name)
327
 
                    message = wiki_to_oneliner(description, self.env, db,
328
 
                                               shorten=True)
329
 
                yield 'milestone', href, title, completed, None, message or '--'
 
516
                milestone = milestone_realm(id=name)
 
517
                if 'MILESTONE_VIEW' in req.perm(milestone):
 
518
                    yield('milestone', datetime.fromtimestamp(completed, utc),
 
519
                          '', (milestone, description)) # FIXME: author?
 
520
 
 
521
            # Attachments
 
522
            for event in AttachmentModule(self.env).get_timeline_events(
 
523
                req, milestone_realm, start, stop):
 
524
                yield event
 
525
                
 
526
    def render_timeline_event(self, context, field, event):
 
527
        milestone, description = event[3]
 
528
        if field == 'url':
 
529
            return context.href.milestone(milestone.id)
 
530
        elif field == 'title':
 
531
            return tag('Milestone ', tag.em(milestone.id), ' completed')
 
532
        elif field == 'description':
 
533
            return format_to_html(self.env, context(resource=milestone),
 
534
                                  shorten_line(description))
330
535
 
331
536
    # IRequestHandler methods
332
537
 
340
545
 
341
546
    def process_request(self, req):
342
547
        milestone_id = req.args.get('id')
343
 
            
344
 
        req.perm.assert_permission('MILESTONE_VIEW')
345
 
 
346
 
        add_link(req, 'up', req.href.roadmap(), 'Roadmap')
347
 
 
348
 
        db = self.env.get_db_cnx()
 
548
        req.perm('milestone', milestone_id).require('MILESTONE_VIEW')
 
549
        
 
550
        add_link(req, 'up', req.href.roadmap(), _('Roadmap'))
 
551
 
 
552
        db = self.env.get_db_cnx() # TODO: db can be removed
349
553
        milestone = Milestone(self.env, milestone_id, db)
350
554
        action = req.args.get('action', 'view')
351
555
 
356
560
                else:
357
561
                    req.redirect(req.href.roadmap())
358
562
            elif action == 'edit':
359
 
                self._do_save(req, db, milestone)
 
563
                return self._do_save(req, db, milestone)
360
564
            elif action == 'delete':
361
565
                self._do_delete(req, db, milestone)
362
566
        elif action in ('new', 'edit'):
363
 
            self._render_editor(req, db, milestone)
 
567
            return self._render_editor(req, db, milestone)
364
568
        elif action == 'delete':
365
 
            self._render_confirm(req, db, milestone)
366
 
        else:
367
 
            self._render_view(req, db, milestone)
 
569
            return self._render_confirm(req, db, milestone)
368
570
 
369
 
        if not milestone_id and action != 'new':
 
571
        if not milestone.name:
370
572
            req.redirect(req.href.roadmap())
371
573
 
372
 
        add_stylesheet(req, 'common/css/roadmap.css')
373
 
        return 'milestone.cs', None
 
574
        return self._render_view(req, db, milestone)
374
575
 
375
576
    # Internal methods
376
577
 
377
578
    def _do_delete(self, req, db, milestone):
378
 
        req.perm.assert_permission('MILESTONE_DELETE')
 
579
        req.perm(milestone.resource).require('MILESTONE_DELETE')
379
580
 
380
581
        retarget_to = None
381
582
        if req.args.has_key('retarget'):
386
587
 
387
588
    def _do_save(self, req, db, milestone):
388
589
        if milestone.exists:
389
 
            req.perm.assert_permission('MILESTONE_MODIFY')
 
590
            req.perm(milestone.resource).require('MILESTONE_MODIFY')
390
591
        else:
391
 
            req.perm.assert_permission('MILESTONE_CREATE')
 
592
            req.perm(milestone.resource).require('MILESTONE_CREATE')
392
593
 
393
 
        if not req.args.has_key('name'):
394
 
            raise TracError('You must provide a name for the milestone.',
395
 
                            'Required Field Missing')
 
594
        old_name = milestone.name
 
595
        new_name = req.args.get('name')
 
596
        
 
597
        milestone.name = new_name
 
598
        milestone.description = req.args.get('description', '')
396
599
 
397
600
        due = req.args.get('duedate', '')
398
 
        try:
399
 
            milestone.due = due and parse_date(due) or 0
400
 
        except ValueError, e:
401
 
            raise TracError(to_unicode(e), 'Invalid Date Format')
402
 
        if req.args.has_key('completed'):
403
 
            completed = req.args.get('completeddate', '')
 
601
        milestone.due = due and parse_date(due, tzinfo=req.tz) or 0
 
602
 
 
603
        completed = req.args.get('completeddate', '')
 
604
        retarget_to = req.args.get('target')
 
605
 
 
606
        # Instead of raising one single error, check all the constraints and
 
607
        # let the user fix them by going back to edit mode showing the warnings
 
608
        warnings = []
 
609
        def warn(msg):
 
610
            add_warning(req, msg)
 
611
            warnings.append(msg)
 
612
 
 
613
        # -- check the name
 
614
        if new_name:
 
615
            # check that the milestone doesn't already exists
 
616
            # FIXME: the whole .exists business needs to be clarified (#4130)
 
617
            #        and should behave like a WikiPage does in this respect.
404
618
            try:
405
 
                milestone.completed = completed and parse_date(completed) or 0
406
 
            except ValueError, e:
407
 
                raise TracError(to_unicode(e), 'Invalid Date Format')
408
 
            if milestone.completed > time():
409
 
                raise TracError('Completion date may not be in the future',
410
 
                                'Invalid Completion Date')
411
 
            retarget_to = req.args.get('target')
412
 
            if req.args.has_key('retarget'):
 
619
                test_milestone = Milestone(self.env, new_name, db)
 
620
                if not test_milestone.exists:
 
621
                    # then an exception should have been raised
 
622
                    warn(_('Milestone "%(name)s" already exists, please '
 
623
                           'choose another name', name=new_name))
 
624
            except TracError:
 
625
                pass
 
626
        else:
 
627
            warn(_('You must provide a name for the milestone.'))
 
628
 
 
629
        # -- check completed date
 
630
        if 'completed' in req.args:
 
631
            completed = completed and parse_date(completed, req.tz) or None
 
632
            if completed and completed > datetime.now(utc):
 
633
                warn(_('Completion date may not be in the future'))
 
634
        else:
 
635
            completed = None
 
636
        milestone.completed = completed
 
637
 
 
638
        if warnings:
 
639
            return self._render_editor(req, db, milestone)
 
640
        
 
641
        # -- actually save changes
 
642
        if milestone.exists:
 
643
            milestone.update()
 
644
            # eventually retarget opened tickets associated with the milestone
 
645
            if 'retarget' in req.args:
413
646
                cursor = db.cursor()
414
647
                cursor.execute("UPDATE ticket SET milestone=%s WHERE "
415
648
                               "milestone=%s and status != 'closed'",
416
 
                                (retarget_to, milestone.name))
 
649
                                (retarget_to, old_name))
417
650
                self.env.log.info('Tickets associated with milestone %s '
418
 
                                  'retargeted to %s' % 
419
 
                                  (milestone.name, retarget_to))
420
 
        else:
421
 
            milestone.completed = 0
422
 
 
423
 
        # don't update the milestone name until after retargetting open tickets
424
 
        milestone.name = req.args.get('name')
425
 
        milestone.description = req.args.get('description', '')
426
 
 
427
 
        if milestone.exists:
428
 
            milestone.update()
 
651
                                  'retargeted to %s' % (old_name, retarget_to))
429
652
        else:
430
653
            milestone.insert()
431
654
        db.commit()
433
656
        req.redirect(req.href.milestone(milestone.name))
434
657
 
435
658
    def _render_confirm(self, req, db, milestone):
436
 
        req.perm.assert_permission('MILESTONE_DELETE')
437
 
 
438
 
        req.hdf['title'] = 'Milestone %s' % milestone.name
439
 
        req.hdf['milestone'] = milestone_to_hdf(self.env, db, req, milestone)
440
 
        req.hdf['milestone.mode'] = 'delete'
441
 
 
442
 
        for idx,other in enumerate(Milestone.select(self.env, False, db)):
443
 
            if other.name == milestone.name:
444
 
                continue
445
 
            req.hdf['milestones.%d' % idx] = other.name
 
659
        req.perm(milestone.resource).require('MILESTONE_DELETE')
 
660
 
 
661
        data = {
 
662
            'milestone': milestone,
 
663
            'milestones': Milestone.select(self.env, False, db)
 
664
        }
 
665
        return 'milestone_delete.html', data, None
446
666
 
447
667
    def _render_editor(self, req, db, milestone):
 
668
        data = {
 
669
            'milestone': milestone,
 
670
            'date_hint': get_date_format_hint(),
 
671
            'datetime_hint': get_datetime_format_hint(),
 
672
            'milestones': [],
 
673
        }
 
674
 
448
675
        if milestone.exists:
449
 
            req.perm.assert_permission('MILESTONE_MODIFY')
450
 
            req.hdf['title'] = 'Milestone %s' % milestone.name
451
 
            req.hdf['milestone.mode'] = 'edit'
452
 
            req.hdf['milestones'] = [m.name for m in
453
 
                                     Milestone.select(self.env)
454
 
                                     if m.name != milestone.name]
 
676
            req.perm(milestone.resource).require('MILESTONE_MODIFY')
 
677
            data['milestones'] = [m for m in
 
678
                                  Milestone.select(self.env, False, db)
 
679
                                  if m.name != milestone.name]
455
680
        else:
456
 
            req.perm.assert_permission('MILESTONE_CREATE')
457
 
            req.hdf['title'] = 'New Milestone'
458
 
            req.hdf['milestone.mode'] = 'new'
 
681
            req.perm(milestone.resource).require('MILESTONE_CREATE')
459
682
 
460
 
        from trac.util.datefmt import get_date_format_hint, \
461
 
                                       get_datetime_format_hint
462
 
        req.hdf['milestone'] = milestone_to_hdf(self.env, db, req, milestone)
463
 
        req.hdf['milestone.date_hint'] = get_date_format_hint()
464
 
        req.hdf['milestone.datetime_hint'] = get_datetime_format_hint()
465
 
        req.hdf['milestone.datetime_now'] = format_datetime()
 
683
        return 'milestone_edit.html', data, None
466
684
 
467
685
    def _render_view(self, req, db, milestone):
468
 
        req.hdf['title'] = 'Milestone %s' % milestone.name
469
 
        req.hdf['milestone.mode'] = 'view'
470
 
 
471
 
        req.hdf['milestone'] = milestone_to_hdf(self.env, db, req, milestone)
472
 
 
 
686
        milestone_groups = []
473
687
        available_groups = []
474
688
        component_group_available = False
475
 
        for field in TicketSystem(self.env).get_ticket_fields():
 
689
        ticket_fields = TicketSystem(self.env).get_ticket_fields()
 
690
 
 
691
        # collect fields that can be used for grouping
 
692
        for field in ticket_fields:
476
693
            if field['type'] == 'select' and field['name'] != 'milestone' \
477
 
                    or field['name'] == 'owner':
 
694
                    or field['name'] in ('owner', 'reporter'):
478
695
                available_groups.append({'name': field['name'],
479
696
                                         'label': field['label']})
480
697
                if field['name'] == 'component':
481
698
                    component_group_available = True
482
 
        req.hdf['milestone.stats.available_groups'] = available_groups
483
699
 
 
700
        # determine the field currently used for grouping
 
701
        by = None
484
702
        if component_group_available:
485
 
            by = req.args.get('by', 'component')
486
 
        else:
487
 
            by = req.args.get('by', available_groups[0]['name'])
488
 
        req.hdf['milestone.stats.grouped_by'] = by
 
703
            by = 'component'
 
704
        elif available_groups:
 
705
            by = available_groups[0]['name']
 
706
        by = req.args.get('by', by)
489
707
 
490
708
        tickets = get_tickets_for_milestone(self.env, db, milestone.name, by)
491
 
        stats = calc_ticket_stats(tickets)
492
 
        req.hdf['milestone.stats'] = stats
493
 
        for key, value in get_query_links(req, milestone.name).items():
494
 
            req.hdf['milestone.queries.' + key] = value
495
 
 
496
 
        groups = _get_groups(self.env, db, by)
497
 
        group_no = 0
498
 
        max_percent_total = 0
499
 
        for group in groups:
500
 
            group_tickets = [t for t in tickets if t[by] == group]
501
 
            if not group_tickets:
502
 
                continue
503
 
            prefix = 'milestone.stats.groups.%s' % group_no
504
 
            req.hdf['%s.name' % prefix] = group
505
 
            percent_total = 0
506
 
            if len(tickets) > 0:
507
 
                percent_total = float(len(group_tickets)) / float(len(tickets))
508
 
                if percent_total > max_percent_total:
509
 
                    max_percent_total = percent_total
510
 
            req.hdf['%s.percent_total' % prefix] = percent_total * 100
511
 
            stats = calc_ticket_stats(group_tickets)
512
 
            req.hdf[prefix] = stats
513
 
            for key, value in \
514
 
                    get_query_links(req, milestone.name, by, group).items():
515
 
                req.hdf['%s.queries.%s' % (prefix, key)] = value
516
 
            group_no += 1
517
 
        req.hdf['milestone.stats.max_percent_total'] = max_percent_total * 100
 
709
        tickets = apply_ticket_permissions(self.env, req, tickets)
 
710
        stat = get_ticket_stats(self.stats_provider, tickets)
 
711
 
 
712
        context = Context.from_request(req, milestone.resource)
 
713
        data = {
 
714
            'context': context,
 
715
            'milestone': milestone,
 
716
            'attachments': AttachmentModule(self.env).attachment_data(context),
 
717
            'available_groups': available_groups, 
 
718
            'grouped_by': by,
 
719
            'groups': milestone_groups
 
720
            }
 
721
        data.update(milestone_stats_data(req, stat, milestone.name))
 
722
 
 
723
        if by:
 
724
            groups = []
 
725
            for field in ticket_fields:
 
726
                if field['name'] == by:
 
727
                    if field.has_key('options'):
 
728
                        groups = field['options']
 
729
                    else:
 
730
                        cursor = db.cursor()
 
731
                        cursor.execute("SELECT DISTINCT %s FROM ticket "
 
732
                                       "ORDER BY %s" % (by, by))
 
733
                        groups = [row[0] for row in cursor]
 
734
 
 
735
            max_count = 0
 
736
            group_stats = []
 
737
 
 
738
            for group in groups:
 
739
                group_tickets = [t for t in tickets if t[by] == group]
 
740
                if not group_tickets:
 
741
                    continue
 
742
 
 
743
                gstat = get_ticket_stats(self.stats_provider, group_tickets)
 
744
                if gstat.count > max_count:
 
745
                    max_count = gstat.count
 
746
 
 
747
                group_stats.append(gstat) 
 
748
 
 
749
                gs_dict = {'name': group}
 
750
                gs_dict.update(milestone_stats_data(req, gstat, milestone.name,
 
751
                                                    by, group))
 
752
                milestone_groups.append(gs_dict)
 
753
 
 
754
            for idx, gstat in enumerate(group_stats):
 
755
                gs_dict = milestone_groups[idx]
 
756
                percent = 1.0
 
757
                if max_count:
 
758
                    percent = float(gstat.count) / float(max_count) * 100
 
759
                gs_dict['percent_of_max_total'] = percent
 
760
 
 
761
        return 'milestone_view.html', data, None
518
762
 
519
763
    # IWikiSyntaxProvider methods
520
764
 
525
769
        yield ('milestone', self._format_link)
526
770
 
527
771
    def _format_link(self, formatter, ns, name, label):
528
 
        return html.A(label, href=formatter.href.milestone(name),
529
 
                      class_='milestone')
 
772
        name, query, fragment = formatter.split_link(name)
 
773
        return self._render_link(formatter.context, name, label,
 
774
                                 query + fragment)
 
775
 
 
776
    def _render_link(self, context, name, label, extra=''):
 
777
        try:
 
778
            milestone = Milestone(self.env, name)
 
779
        except TracError:
 
780
            milestone = None
 
781
        # Note: the above should really not be needed, `Milestone.exists`
 
782
        # should simply be false if the milestone doesn't exist in the db
 
783
        # (related to #4130)
 
784
        href = context.href.milestone(name)
 
785
        if milestone and milestone.exists and \
 
786
           'MILESTONE_VIEW' in context.perm(milestone.resource):
 
787
            closed = milestone.is_completed and 'closed ' or ''
 
788
            return tag.a(label, class_='%smilestone' % closed, href=href+extra)
 
789
        else: 
 
790
            return tag.a(label, class_='missing milestone', href=href+extra,
 
791
                         rel="nofollow")
 
792
        
 
793
    # IResourceManager methods
 
794
 
 
795
    def get_resource_realms(self):
 
796
        yield 'milestone'
 
797
 
 
798
    def get_resource_description(self, resource, format=None, context=None,
 
799
                                 **kwargs):
 
800
        desc = resource.id
 
801
        if format != 'compact':
 
802
            desc =  _('Milestone %(name)s', name=resource.id)
 
803
        if context:
 
804
            return self._render_link(context, resource.id, desc)
 
805
        else:
 
806
            return desc