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