Package trac :: Package ticket :: Module api

Source Code for Module trac.ticket.api

  1  # -*- coding: utf-8 -*- 
  2  # 
  3  # Copyright (C) 2003-2020 Edgewall Software 
  4  # Copyright (C) 2003-2005 Jonas Borgström <[email protected]> 
  5  # All rights reserved. 
  6  # 
  7  # This software is licensed as described in the file COPYING, which 
  8  # you should have received as part of this distribution. The terms 
  9  # are also available at https://trac.edgewall.org/wiki/TracLicense. 
 10  # 
 11  # This software consists of voluntary contributions made by many 
 12  # individuals. For the exact contribution history, see the revision 
 13  # history and logs, available at https://trac.edgewall.org/log/. 
 14  # 
 15  # Author: Jonas Borgström <[email protected]> 
 16   
 17  import copy 
 18  import re 
 19   
 20  from genshi.builder import tag 
 21   
 22  from trac.cache import cached 
 23  from trac.config import * 
 24  from trac.core import * 
 25  from trac.perm import IPermissionRequestor, PermissionCache, PermissionSystem 
 26  from trac.resource import IResourceManager 
 27  from trac.util import Ranges, as_int 
 28  from trac.util.text import shorten_line 
 29  from trac.util.translation import _, N_, gettext 
 30  from trac.wiki import IWikiSyntaxProvider, WikiParser 
31 32 33 -class ITicketActionController(Interface):
34 """Extension point interface for components willing to participate 35 in the ticket workflow. 36 37 This is mainly about controlling the changes to the ticket ''status'', 38 though not restricted to it. 39 """ 40
41 - def get_ticket_actions(req, ticket):
42 """Return an iterable of `(weight, action)` tuples corresponding to 43 the actions that are contributed by this component. The list is 44 dependent on the current state of the ticket and the actual request 45 parameter. 46 47 `action` is a key used to identify that particular action. 48 (note that 'history' and 'diff' are reserved and should not be used 49 by plugins) 50 51 The actions will be presented on the page in descending order of the 52 integer weight. The first action in the list is used as the default 53 action. 54 55 When in doubt, use a weight of 0. 56 """
57
58 - def get_all_status():
59 """Returns an iterable of all the possible values for the ''status'' 60 field this action controller knows about. 61 62 This will be used to populate the query options and the like. 63 It is assumed that the initial status of a ticket is 'new' and 64 the terminal status of a ticket is 'closed'. 65 """
66
67 - def render_ticket_action_control(req, ticket, action):
68 """Return a tuple in the form of `(label, control, hint)` 69 70 `label` is a short text that will be used when listing the action, 71 `control` is the markup for the action control and `hint` should 72 explain what will happen if this action is taken. 73 74 This method will only be called if the controller claimed to handle 75 the given `action` in the call to `get_ticket_actions`. 76 77 Note that the radio button for the action has an `id` of 78 `"action_%s" % action`. Any `id`s used in `control` need to be made 79 unique. The method used in the default ITicketActionController is to 80 use `"action_%s_something" % action`. 81 """
82
83 - def get_ticket_changes(req, ticket, action):
84 """Return a dictionary of ticket field changes. 85 86 This method must not have any side-effects because it will also 87 be called in preview mode (`req.args['preview']` will be set, then). 88 See `apply_action_side_effects` for that. If the latter indeed triggers 89 some side-effects, it is advised to emit a warning 90 (`trac.web.chrome.add_warning(req, reason)`) when this method is called 91 in preview mode. 92 93 This method will only be called if the controller claimed to handle 94 the given `action` in the call to `get_ticket_actions`. 95 """
96
97 - def apply_action_side_effects(req, ticket, action):
98 """Perform side effects once all changes have been made to the ticket. 99 100 Multiple controllers might be involved, so the apply side-effects 101 offers a chance to trigger a side-effect based on the given `action` 102 after the new state of the ticket has been saved. 103 104 This method will only be called if the controller claimed to handle 105 the given `action` in the call to `get_ticket_actions`. 106 """
107
108 109 -class ITicketChangeListener(Interface):
110 """Extension point interface for components that require notification 111 when tickets are created, modified, or deleted.""" 112
113 - def ticket_created(ticket):
114 """Called when a ticket is created."""
115
116 - def ticket_changed(ticket, comment, author, old_values):
117 """Called when a ticket is modified. 118 119 `old_values` is a dictionary containing the previous values of the 120 fields that have changed. 121 """
122
123 - def ticket_deleted(ticket):
124 """Called when a ticket is deleted."""
125
126 - def ticket_comment_modified(ticket, cdate, author, comment, old_comment):
127 """Called when a ticket comment is modified."""
128
129 - def ticket_change_deleted(ticket, cdate, changes):
130 """Called when a ticket change is deleted. 131 132 `changes` is a dictionary of tuple `(oldvalue, newvalue)` 133 containing the ticket change of the fields that have changed."""
134
135 136 -class ITicketManipulator(Interface):
137 """Miscellaneous manipulation of ticket workflow features.""" 138
139 - def prepare_ticket(req, ticket, fields, actions):
140 """Not currently called, but should be provided for future 141 compatibility."""
142
143 - def validate_ticket(req, ticket):
144 """Validate a ticket after it's been populated from user input. 145 146 Must return a list of `(field, message)` tuples, one for each problem 147 detected. `field` can be `None` to indicate an overall problem with the 148 ticket. Therefore, a return value of `[]` means everything is OK."""
149
150 151 -class IMilestoneChangeListener(Interface):
152 """Extension point interface for components that require notification 153 when milestones are created, modified, or deleted.""" 154
155 - def milestone_created(milestone):
156 """Called when a milestone is created."""
157
158 - def milestone_changed(milestone, old_values):
159 """Called when a milestone is modified. 160 161 `old_values` is a dictionary containing the previous values of the 162 milestone properties that changed. Currently those properties can be 163 'name', 'due', 'completed', or 'description'. 164 """
165
166 - def milestone_deleted(milestone):
167 """Called when a milestone is deleted."""
168
169 170 -class TicketSystem(Component):
171 implements(IPermissionRequestor, IWikiSyntaxProvider, IResourceManager) 172 173 change_listeners = ExtensionPoint(ITicketChangeListener) 174 milestone_change_listeners = ExtensionPoint(IMilestoneChangeListener) 175 176 ticket_custom_section = ConfigSection('ticket-custom', 177 """In this section, you can define additional fields for tickets. See 178 TracTicketsCustomFields for more details.""") 179 180 action_controllers = OrderedExtensionsOption('ticket', 'workflow', 181 ITicketActionController, default='ConfigurableTicketWorkflow', 182 include_missing=False, 183 doc="""Ordered list of workflow controllers to use for ticket actions 184 (''since 0.11'').""") 185 186 restrict_owner = BoolOption('ticket', 'restrict_owner', 'false', 187 """Make the owner field of tickets use a drop-down menu. 188 Be sure to understand the performance implications before activating 189 this option. See 190 [TracTickets#Assign-toasDrop-DownList Assign-to as Drop-Down List]. 191 192 Please note that e-mail addresses are '''not''' obfuscated in the 193 resulting drop-down menu, so this option should not be used if 194 e-mail addresses must remain protected. 195 (''since 0.9'')""") 196 197 default_version = Option('ticket', 'default_version', '', 198 """Default version for newly created tickets.""") 199 200 default_type = Option('ticket', 'default_type', 'defect', 201 """Default type for newly created tickets (''since 0.9'').""") 202 203 default_priority = Option('ticket', 'default_priority', 'major', 204 """Default priority for newly created tickets.""") 205 206 default_milestone = Option('ticket', 'default_milestone', '', 207 """Default milestone for newly created tickets.""") 208 209 default_component = Option('ticket', 'default_component', '', 210 """Default component for newly created tickets.""") 211 212 default_severity = Option('ticket', 'default_severity', '', 213 """Default severity for newly created tickets.""") 214 215 default_summary = Option('ticket', 'default_summary', '', 216 """Default summary (title) for newly created tickets.""") 217 218 default_description = Option('ticket', 'default_description', '', 219 """Default description for newly created tickets.""") 220 221 default_keywords = Option('ticket', 'default_keywords', '', 222 """Default keywords for newly created tickets.""") 223 224 default_owner = Option('ticket', 'default_owner', '< default >', 225 """Default owner for newly created tickets.""") 226 227 default_cc = Option('ticket', 'default_cc', '', 228 """Default cc: list for newly created tickets.""") 229 230 default_resolution = Option('ticket', 'default_resolution', 'fixed', 231 """Default resolution for resolving (closing) tickets 232 (''since 0.11'').""") 233
234 - def __init__(self):
235 self.log.debug('action controllers for ticket workflow: %r', 236 [c.__class__.__name__ for c in self.action_controllers])
237 238 # Public API 239
240 - def get_available_actions(self, req, ticket):
241 """Returns a sorted list of available actions""" 242 # The list should not have duplicates. 243 actions = {} 244 for controller in self.action_controllers: 245 weighted_actions = controller.get_ticket_actions(req, ticket) or [] 246 for weight, action in weighted_actions: 247 if action in actions: 248 actions[action] = max(actions[action], weight) 249 else: 250 actions[action] = weight 251 all_weighted_actions = [(weight, action) for action, weight in 252 actions.items()] 253 return [x[1] for x in sorted(all_weighted_actions, reverse=True)]
254
255 - def get_all_status(self):
256 """Returns a sorted list of all the states all of the action 257 controllers know about.""" 258 valid_states = set() 259 for controller in self.action_controllers: 260 valid_states.update(controller.get_all_status() or []) 261 return sorted(valid_states)
262
263 - def get_ticket_field_labels(self):
264 """Produce a (name,label) mapping from `get_ticket_fields`.""" 265 labels = dict((f['name'], f['label']) 266 for f in self.get_ticket_fields()) 267 labels['attachment'] = _("Attachment") 268 return labels
269
270 - def get_ticket_fields(self):
271 """Returns list of fields available for tickets. 272 273 Each field is a dict with at least the 'name', 'label' (localized) 274 and 'type' keys. 275 It may in addition contain the 'custom' key, the 'optional' and the 276 'options' keys. When present 'custom' and 'optional' are always `True`. 277 """ 278 fields = copy.deepcopy(self.fields) 279 label = 'label' # workaround gettext extraction bug 280 for f in fields: 281 f[label] = gettext(f[label]) 282 return fields
283
284 - def reset_ticket_fields(self):
285 """Invalidate ticket field cache.""" 286 del self.fields
287 288 @cached
289 - def fields(self, db):
290 """Return the list of fields available for tickets.""" 291 from trac.ticket import model 292 293 fields = [] 294 295 # Basic text fields 296 fields.append({'name': 'summary', 'type': 'text', 297 'label': N_('Summary')}) 298 fields.append({'name': 'reporter', 'type': 'text', 299 'label': N_('Reporter')}) 300 301 # Owner field, by default text but can be changed dynamically 302 # into a drop-down depending on configuration (restrict_owner=true) 303 fields.append({'name': 'owner', 'type': 'text', 304 'label': N_('Owner')}) 305 306 # Description 307 fields.append({'name': 'description', 'type': 'textarea', 308 'format': 'wiki', 'label': N_('Description')}) 309 310 # Default select and radio fields 311 selects = [('type', N_('Type'), model.Type), 312 ('status', N_('Status'), model.Status), 313 ('priority', N_('Priority'), model.Priority), 314 ('milestone', N_('Milestone'), model.Milestone), 315 ('component', N_('Component'), model.Component), 316 ('version', N_('Version'), model.Version), 317 ('severity', N_('Severity'), model.Severity), 318 ('resolution', N_('Resolution'), model.Resolution)] 319 for name, label, cls in selects: 320 options = [val.name for val in cls.select(self.env, db=db)] 321 if not options: 322 # Fields without possible values are treated as if they didn't 323 # exist 324 continue 325 field = {'name': name, 'type': 'select', 'label': label, 326 'value': getattr(self, 'default_' + name, ''), 327 'options': options} 328 if name in ('status', 'resolution'): 329 field['type'] = 'radio' 330 field['optional'] = True 331 elif name in ('milestone', 'version'): 332 field['optional'] = True 333 fields.append(field) 334 335 # Advanced text fields 336 fields.append({'name': 'keywords', 'type': 'text', 'format': 'list', 337 'label': N_('Keywords')}) 338 fields.append({'name': 'cc', 'type': 'text', 'format': 'list', 339 'label': N_('Cc')}) 340 341 # Date/time fields 342 fields.append({'name': 'time', 'type': 'time', 343 'label': N_('Created')}) 344 fields.append({'name': 'changetime', 'type': 'time', 345 'label': N_('Modified')}) 346 347 for field in self.get_custom_fields(): 348 if field['name'] in [f['name'] for f in fields]: 349 self.log.warning('Duplicate field name "%s" (ignoring)', 350 field['name']) 351 continue 352 field['custom'] = True 353 fields.append(field) 354 355 return fields
356 357 reserved_field_names = ['report', 'order', 'desc', 'group', 'groupdesc', 358 'col', 'row', 'format', 'max', 'page', 'verbose', 359 'comment', 'or', 'id', 'time', 'changetime', 360 'owner', 'reporter', 'cc', 'summary', 361 'description', 'keywords'] 362
363 - def get_custom_fields(self):
364 return copy.deepcopy(self.custom_fields)
365 366 @cached
367 - def custom_fields(self, db):
368 """Return the list of custom ticket fields available for tickets.""" 369 fields = [] 370 config = self.ticket_custom_section 371 for name in [option for option, value in config.options() 372 if '.' not in option]: 373 field = { 374 'name': name, 375 'type': config.get(name), 376 'order': config.getint(name + '.order', 0), 377 'label': config.get(name + '.label') or name.capitalize(), 378 'value': config.get(name + '.value', '') 379 } 380 if field['type'] == 'select' or field['type'] == 'radio': 381 field['options'] = config.getlist(name + '.options', sep='|') 382 if not field['options']: 383 continue 384 if '' in field['options']: 385 field['optional'] = True 386 field['options'].remove('') 387 elif field['type'] == 'text': 388 field['format'] = config.get(name + '.format', 'plain') 389 elif field['type'] == 'textarea': 390 field['format'] = config.get(name + '.format', 'plain') 391 field['width'] = config.getint(name + '.cols') 392 field['height'] = config.getint(name + '.rows') 393 394 if field['name'] in self.reserved_field_names: 395 self.log.warning('Field name "%s" is a reserved name ' 396 '(ignoring)', field['name']) 397 continue 398 if not re.match('^[a-zA-Z][a-zA-Z0-9_]+$', field['name']): 399 self.log.warning('Invalid name for custom field: "%s" ' 400 '(ignoring)', field['name']) 401 continue 402 403 fields.append(field) 404 405 fields.sort(lambda x, y: cmp((x['order'], x['name']), 406 (y['order'], y['name']))) 407 return fields
408
409 - def get_field_synonyms(self):
410 """Return a mapping from field name synonyms to field names. 411 The synonyms are supposed to be more intuitive for custom queries.""" 412 # i18n TODO - translated keys 413 return {'created': 'time', 'modified': 'changetime'}
414
415 - def eventually_restrict_owner(self, field, ticket=None):
416 """Restrict given owner field to be a list of users having 417 the TICKET_MODIFY permission (for the given ticket) 418 """ 419 if self.restrict_owner: 420 field['type'] = 'select' 421 allowed_owners = self.get_allowed_owners(ticket) 422 allowed_owners.insert(0, '< default >') 423 field['options'] = allowed_owners 424 field['optional'] = True
425
426 - def get_allowed_owners(self, ticket=None):
427 """Returns a list of permitted ticket owners (those possessing the 428 TICKET_MODIFY permission). Returns `None` if the option `[ticket]` 429 `restrict_owner` is `False`. 430 431 If `ticket` is not `None`, fine-grained permission checks are used 432 to determine the allowed owners for the specified resource. 433 434 :since: 1.0.3 435 """ 436 if self.restrict_owner: 437 allowed_owners = [] 438 for user in PermissionSystem(self.env) \ 439 .get_users_with_permission('TICKET_MODIFY'): 440 if not ticket or \ 441 'TICKET_MODIFY' in PermissionCache(self.env, user, 442 ticket.resource): 443 allowed_owners.append(user) 444 allowed_owners.sort() 445 return allowed_owners
446 447 # IPermissionRequestor methods 448
449 - def get_permission_actions(self):
450 return ['TICKET_APPEND', 'TICKET_CREATE', 'TICKET_CHGPROP', 451 'TICKET_VIEW', 'TICKET_EDIT_CC', 'TICKET_EDIT_DESCRIPTION', 452 'TICKET_EDIT_COMMENT', 453 ('TICKET_MODIFY', ['TICKET_APPEND', 'TICKET_CHGPROP']), 454 ('TICKET_ADMIN', ['TICKET_CREATE', 'TICKET_MODIFY', 455 'TICKET_VIEW', 'TICKET_EDIT_CC', 456 'TICKET_EDIT_DESCRIPTION', 457 'TICKET_EDIT_COMMENT'])]
458 459 # IWikiSyntaxProvider methods 460 465
466 - def get_wiki_syntax(self):
467 yield ( 468 # matches #... but not &#... (HTML entity) 469 r"!?(?<!&)#" 470 # optional intertrac shorthand #T... + digits 471 r"(?P<it_ticket>%s)%s" % (WikiParser.INTERTRAC_SCHEME, 472 Ranges.RE_STR), 473 lambda x, y, z: self._format_link(x, 'ticket', y[1:], y, z))
474 513 561 562 # IResourceManager methods 563
564 - def get_resource_realms(self):
565 yield 'ticket'
566
567 - def get_resource_description(self, resource, format=None, context=None, 568 **kwargs):
569 if format == 'compact': 570 return '#%s' % resource.id 571 elif format == 'summary': 572 from trac.ticket.model import Ticket 573 ticket = Ticket(self.env, resource.id) 574 args = [ticket[f] for f in ('summary', 'status', 'resolution', 575 'type')] 576 return self.format_summary(*args) 577 return _("Ticket #%(shortname)s", shortname=resource.id)
578
579 - def format_summary(self, summary, status=None, resolution=None, type=None):
580 summary = shorten_line(summary) 581 if type: 582 summary = type + ': ' + summary 583 if status: 584 if status == 'closed' and resolution: 585 status += ': ' + resolution 586 return "%s (%s)" % (summary, status) 587 else: 588 return summary
589
590 - def resource_exists(self, resource):
591 """ 592 >>> from trac.test import EnvironmentStub 593 >>> from trac.resource import Resource, resource_exists 594 >>> env = EnvironmentStub() 595 596 >>> resource_exists(env, Resource('ticket', 123456)) 597 False 598 599 >>> from trac.ticket.model import Ticket 600 >>> t = Ticket(env) 601 >>> int(t.insert()) 602 1 603 >>> resource_exists(env, t.resource) 604 True 605 """ 606 try: 607 id_ = int(resource.id) 608 except (TypeError, ValueError): 609 return False 610 if self.env.db_query("SELECT id FROM ticket WHERE id=%s", (id_,)): 611 if resource.version is None: 612 return True 613 revcount = self.env.db_query(""" 614 SELECT count(DISTINCT time) FROM ticket_change WHERE ticket=%s 615 """, (id_,)) 616 return revcount[0][0] >= resource.version 617 else: 618 return False
619