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