Package trac :: Package ticket :: Module api

Source Code for Module trac.ticket.api

  1  # -*- coding: utf-8 -*- 
  2  # 
  3  # Copyright (C) 2003-2023 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  from datetime import datetime 
 21   
 22  from trac.cache import cached 
 23  from trac.config import ( 
 24      BoolOption, ConfigSection, IntOption, ListOption, Option, 
 25      OrderedExtensionsOption) 
 26  from trac.core import * 
 27  from trac.perm import IPermissionRequestor, PermissionCache, PermissionSystem 
 28  from trac.resource import IResourceManager 
 29  from trac.util import Ranges, as_bool, as_int 
 30  from trac.util.datefmt import parse_date, user_time 
 31  from trac.util.html import tag 
 32  from trac.util.text import shorten_line, to_unicode 
 33  from trac.util.translation import _, N_, deactivate, gettext, reactivate 
 34  from trac.wiki import IWikiSyntaxProvider, WikiParser 
35 36 37 -class TicketFieldList(list):
38 """Improved ticket field list, allowing access by name.""" 39 __slots__ = ['_map'] 40
41 - def __init__(self, *args):
42 super(TicketFieldList, self).__init__(*args) 43 self._map = {value['name']: value for value in self}
44
45 - def append(self, value):
46 super(TicketFieldList, self).append(value) 47 self._map[value['name']] = value
48
49 - def by_name(self, name, default=None):
50 return self._map.get(name, default)
51
52 - def __copy__(self):
53 return TicketFieldList(self)
54
55 - def __deepcopy__(self, memo):
56 return TicketFieldList(copy.deepcopy(value, memo) for value in self)
57
58 - def __contains__(self, name):
59 return name in self._map
60
61 62 -class ITicketActionController(Interface):
63 """Extension point interface for components willing to participate 64 in the ticket workflow. 65 66 This is mainly about controlling the changes to the ticket ''status'', 67 though not restricted to it. 68 """ 69
70 - def get_ticket_actions(req, ticket):
71 """Return an iterable of `(weight, action)` tuples corresponding to 72 the actions that are contributed by this component. The list is 73 dependent on the current state of the ticket and the actual request 74 parameter. 75 76 `action` is a key used to identify that particular action. 77 (note that 'history' and 'diff' are reserved and should not be used 78 by plugins) 79 80 The actions will be presented on the page in descending order of the 81 integer weight. The first action in the list is used as the default 82 action. 83 84 When in doubt, use a weight of 0. 85 """
86
87 - def get_all_status():
88 """Returns an iterable of all the possible values for the ''status'' 89 field this action controller knows about. 90 91 This will be used to populate the query options and the like. 92 It is assumed that the terminal status of a ticket is 'closed'. 93 """
94
95 - def render_ticket_action_control(req, ticket, action):
96 """Return a tuple in the form of `(label, control, hint)` 97 98 `label` is a short text that will be used when listing the action, 99 `control` is the markup for the action control and `hint` should 100 explain what will happen if this action is taken. 101 102 This method will only be called if the controller claimed to handle 103 the given `action` in the call to `get_ticket_actions`. 104 105 Note that the radio button for the action has an `id` of 106 `"action_%s" % action`. Any `id`s used in `control` need to be made 107 unique. The method used in the default ITicketActionController is to 108 use `"action_%s_something" % action`. 109 """
110
111 - def get_ticket_changes(req, ticket, action):
112 """Return a dictionary of ticket field changes. 113 114 This method must not have any side-effects because it will also 115 be called in preview mode (`req.args['preview']` will be set, then). 116 See `apply_action_side_effects` for that. If the latter indeed triggers 117 some side-effects, it is advised to emit a warning 118 (`trac.web.chrome.add_warning(req, reason)`) when this method is called 119 in preview mode. 120 121 This method will only be called if the controller claimed to handle 122 the given `action` in the call to `get_ticket_actions`. 123 """
124
125 - def apply_action_side_effects(req, ticket, action):
126 """Perform side effects once all changes have been made to the ticket. 127 128 Multiple controllers might be involved, so the apply side-effects 129 offers a chance to trigger a side-effect based on the given `action` 130 after the new state of the ticket has been saved. 131 132 This method will only be called if the controller claimed to handle 133 the given `action` in the call to `get_ticket_actions`. 134 """
135
136 137 -class ITicketChangeListener(Interface):
138 """Extension point interface for components that require notification 139 when tickets are created, modified, or deleted.""" 140
141 - def ticket_created(ticket):
142 """Called when a ticket is created."""
143
144 - def ticket_changed(ticket, comment, author, old_values):
145 """Called when a ticket is modified. 146 147 `old_values` is a dictionary containing the previous values of the 148 fields that have changed. 149 """
150
151 - def ticket_deleted(ticket):
152 """Called when a ticket is deleted."""
153
154 - def ticket_comment_modified(ticket, cdate, author, comment, old_comment):
155 """Called when a ticket comment is modified."""
156
157 - def ticket_change_deleted(ticket, cdate, changes):
158 """Called when a ticket change is deleted. 159 160 `changes` is a dictionary of tuple `(oldvalue, newvalue)` 161 containing the ticket change of the fields that have changed."""
162
163 164 -class ITicketManipulator(Interface):
165 """Miscellaneous manipulation of ticket workflow features.""" 166
167 - def prepare_ticket(req, ticket, fields, actions):
168 """Not currently called, but should be provided for future 169 compatibility."""
170
171 - def validate_ticket(req, ticket):
172 """Validate ticket properties when creating or modifying. 173 174 Must return a list of `(field, message)` tuples, one for each problem 175 detected. `field` can be `None` to indicate an overall problem with the 176 ticket. Therefore, a return value of `[]` means everything is OK."""
177
178 - def validate_comment(req, comment):
179 """Validate ticket comment when appending or editing. 180 181 Must return a list of messages, one for each problem detected. 182 The return value `[]` indicates no problems. 183 184 :since: 1.3.2 185 """
186
187 188 -class IMilestoneChangeListener(Interface):
189 """Extension point interface for components that require notification 190 when milestones are created, modified, or deleted.""" 191
192 - def milestone_created(milestone):
193 """Called when a milestone is created."""
194
195 - def milestone_changed(milestone, old_values):
196 """Called when a milestone is modified. 197 198 `old_values` is a dictionary containing the previous values of the 199 milestone properties that changed. Currently those properties can be 200 'name', 'due', 'completed', or 'description'. 201 """
202
203 - def milestone_deleted(milestone):
204 """Called when a milestone is deleted."""
205
206 207 -class TicketSystem(Component):
208 implements(IPermissionRequestor, IWikiSyntaxProvider, IResourceManager, 209 ITicketManipulator) 210 211 change_listeners = ExtensionPoint(ITicketChangeListener) 212 milestone_change_listeners = ExtensionPoint(IMilestoneChangeListener) 213 214 realm = 'ticket' 215 216 ticket_custom_section = ConfigSection('ticket-custom', 217 """In this section, you can define additional fields for tickets. See 218 TracTicketsCustomFields for more details.""") 219 220 action_controllers = OrderedExtensionsOption('ticket', 'workflow', 221 ITicketActionController, default='ConfigurableTicketWorkflow', 222 include_missing=False, 223 doc="""Ordered list of workflow controllers to use for ticket actions. 224 """) 225 226 restrict_owner = BoolOption('ticket', 'restrict_owner', 'false', 227 """Make the owner field of tickets use a drop-down menu. 228 Be sure to understand the performance implications before activating 229 this option. See 230 [TracTickets#Assign-toasDrop-DownList Assign-to as Drop-Down List]. 231 232 Please note that e-mail addresses are '''not''' obfuscated in the 233 resulting drop-down menu, so this option should not be used if 234 e-mail addresses must remain protected. 235 """) 236 237 default_version = Option('ticket', 'default_version', '', 238 """Default version for newly created tickets.""") 239 240 default_type = Option('ticket', 'default_type', 'defect', 241 """Default type for newly created tickets.""") 242 243 default_priority = Option('ticket', 'default_priority', 'major', 244 """Default priority for newly created tickets.""") 245 246 default_milestone = Option('ticket', 'default_milestone', '', 247 """Default milestone for newly created tickets.""") 248 249 default_component = Option('ticket', 'default_component', '', 250 """Default component for newly created tickets.""") 251 252 default_severity = Option('ticket', 'default_severity', '', 253 """Default severity for newly created tickets.""") 254 255 default_summary = Option('ticket', 'default_summary', '', 256 """Default summary (title) for newly created tickets.""") 257 258 default_description = Option('ticket', 'default_description', '', 259 """Default description for newly created tickets.""") 260 261 default_keywords = Option('ticket', 'default_keywords', '', 262 """Default keywords for newly created tickets.""") 263 264 default_owner = Option('ticket', 'default_owner', '< default >', 265 """Default owner for newly created tickets. The component owner 266 is used when set to the value `< default >`. 267 """) 268 269 default_cc = Option('ticket', 'default_cc', '', 270 """Default cc: list for newly created tickets.""") 271 272 default_resolution = Option('ticket', 'default_resolution', 'fixed', 273 """Default resolution for resolving (closing) tickets.""") 274 275 allowed_empty_fields = ListOption('ticket', 'allowed_empty_fields', 276 'milestone, version', doc= 277 """Comma-separated list of `select` fields that can have 278 an empty value. (//since 1.1.2//)""") 279 280 max_comment_size = IntOption('ticket', 'max_comment_size', 262144, 281 """Maximum allowed comment size in characters.""") 282 283 max_description_size = IntOption('ticket', 'max_description_size', 262144, 284 """Maximum allowed description size in characters.""") 285 286 max_summary_size = IntOption('ticket', 'max_summary_size', 262144, 287 """Maximum allowed summary size in characters. (//since 1.0.2//)""") 288
289 - def __init__(self):
290 self.log.debug('action controllers for ticket workflow: %r', 291 [c.__class__.__name__ for c in self.action_controllers])
292 293 # Public API 294
295 - def get_available_actions(self, req, ticket):
296 """Returns a sorted list of available actions""" 297 # The list should not have duplicates. 298 actions = {} 299 for controller in self.action_controllers: 300 weighted_actions = controller.get_ticket_actions(req, ticket) or [] 301 for weight, action in weighted_actions: 302 if action in actions: 303 actions[action] = max(actions[action], weight) 304 else: 305 actions[action] = weight 306 all_weighted_actions = [(weight, action) for action, weight in 307 actions.items()] 308 return [x[1] for x in sorted(all_weighted_actions, reverse=True)]
309
310 - def get_all_status(self):
311 """Returns a sorted list of all the states all of the action 312 controllers know about.""" 313 valid_states = set() 314 for controller in self.action_controllers: 315 valid_states.update(controller.get_all_status() or []) 316 return sorted(valid_states)
317
318 - def get_ticket_field_labels(self):
319 """Produce a (name,label) mapping from `get_ticket_fields`.""" 320 labels = {f['name']: f['label'] for f in self.get_ticket_fields()} 321 labels['attachment'] = _("Attachment") 322 return labels
323
324 - def get_ticket_fields(self):
325 """Returns list of fields available for tickets. 326 327 Each field is a dict with at least the 'name', 'label' (localized) 328 and 'type' keys. 329 It may in addition contain the 'custom' key, the 'optional' and the 330 'options' keys. When present 'custom' and 'optional' are always `True`. 331 """ 332 fields = copy.deepcopy(self.fields) 333 label = 'label' # workaround gettext extraction bug 334 for f in fields: 335 if not f.get('custom'): 336 f[label] = gettext(f[label]) 337 return fields
338
339 - def reset_ticket_fields(self):
340 """Invalidate ticket field cache.""" 341 del self.fields
342 343 @cached
344 - def fields(self):
345 """Return the list of fields available for tickets.""" 346 from trac.ticket import model 347 348 fields = TicketFieldList() 349 350 # Basic text fields 351 fields.append({'name': 'summary', 'type': 'text', 352 'label': N_('Summary')}) 353 fields.append({'name': 'reporter', 'type': 'text', 354 'label': N_('Reporter')}) 355 356 # Owner field, by default text but can be changed dynamically 357 # into a drop-down depending on configuration (restrict_owner=true) 358 fields.append({'name': 'owner', 'type': 'text', 359 'label': N_('Owner')}) 360 361 # Description 362 fields.append({'name': 'description', 'type': 'textarea', 363 'format': 'wiki', 'label': N_('Description')}) 364 365 # Default select and radio fields 366 selects = [('type', N_('Type'), model.Type), 367 ('status', N_('Status'), model.Status), 368 ('priority', N_('Priority'), model.Priority), 369 ('milestone', N_('Milestone'), model.Milestone), 370 ('component', N_('Component'), model.Component), 371 ('version', N_('Version'), model.Version), 372 ('severity', N_('Severity'), model.Severity), 373 ('resolution', N_('Resolution'), model.Resolution)] 374 for name, label, cls in selects: 375 options = [val.name for val in cls.select(self.env)] 376 if not options: 377 # Fields without possible values are treated as if they didn't 378 # exist 379 continue 380 field = {'name': name, 'type': 'select', 'label': label, 381 'value': getattr(self, 'default_' + name, ''), 382 'options': options} 383 if name in ('status', 'resolution'): 384 field['type'] = 'radio' 385 field['optional'] = True 386 elif name in self.allowed_empty_fields: 387 field['optional'] = True 388 fields.append(field) 389 390 # Advanced text fields 391 fields.append({'name': 'keywords', 'type': 'text', 'format': 'list', 392 'label': N_('Keywords')}) 393 fields.append({'name': 'cc', 'type': 'text', 'format': 'list', 394 'label': N_('Cc')}) 395 396 # Date/time fields 397 fields.append({'name': 'time', 'type': 'time', 398 'format': 'relative', 'label': N_('Created')}) 399 fields.append({'name': 'changetime', 'type': 'time', 400 'format': 'relative', 'label': N_('Modified')}) 401 402 for field in self.custom_fields: 403 if field['name'] in [f['name'] for f in fields]: 404 self.log.warning('Duplicate field name "%s" (ignoring)', 405 field['name']) 406 continue 407 fields.append(field) 408 409 return fields
410 411 reserved_field_names = ['report', 'order', 'desc', 'group', 'groupdesc', 412 'col', 'row', 'format', 'max', 'page', 'verbose', 413 'comment', 'or', 'id', 'time', 'changetime', 414 'owner', 'reporter', 'cc', 'summary', 415 'description', 'keywords'] 416
417 - def get_custom_fields(self):
418 return copy.deepcopy(self.custom_fields)
419 420 @cached
421 - def custom_fields(self):
422 """Return the list of custom ticket fields available for tickets.""" 423 fields = TicketFieldList() 424 config = self.ticket_custom_section 425 for name in [option for option, value in config.options() 426 if '.' not in option]: 427 field = { 428 'name': name, 429 'custom': True, 430 'type': config.get(name), 431 'order': config.getint(name + '.order', 0), 432 'label': config.get(name + '.label') or 433 name.replace("_", " ").strip().capitalize(), 434 'value': config.get(name + '.value', '') 435 } 436 if field['type'] == 'select' or field['type'] == 'radio': 437 field['options'] = config.getlist(name + '.options', sep='|') 438 if not field['options']: 439 continue 440 if '' in field['options'] or \ 441 field['name'] in self.allowed_empty_fields: 442 field['optional'] = True 443 if '' in field['options']: 444 field['options'].remove('') 445 elif field['type'] == 'checkbox': 446 field['value'] = '1' if as_bool(field['value']) else '0' 447 elif field['type'] == 'text': 448 field['format'] = config.get(name + '.format', 'plain') 449 field['max_size'] = config.getint(name + '.max_size', 0) 450 elif field['type'] == 'textarea': 451 field['format'] = config.get(name + '.format', 'plain') 452 field['max_size'] = config.getint(name + '.max_size', 0) 453 field['height'] = config.getint(name + '.rows') 454 elif field['type'] == 'time': 455 field['format'] = config.get(name + '.format', 'datetime') 456 457 if field['name'] in self.reserved_field_names: 458 self.log.warning('Field name "%s" is a reserved name ' 459 '(ignoring)', field['name']) 460 continue 461 if not re.match('^[a-zA-Z][a-zA-Z0-9_]+$', field['name']): 462 self.log.warning('Invalid name for custom field: "%s" ' 463 '(ignoring)', field['name']) 464 continue 465 466 fields.append(field) 467 468 fields.sort(key=lambda f: (f['order'], f['name'])) 469 return fields
470
471 - def get_field_synonyms(self):
472 """Return a mapping from field name synonyms to field names. 473 The synonyms are supposed to be more intuitive for custom queries.""" 474 # i18n TODO - translated keys 475 return {'created': 'time', 'modified': 'changetime'}
476
477 - def eventually_restrict_owner(self, field, ticket=None):
478 """Restrict given owner field to be a list of users having 479 the TICKET_MODIFY permission (for the given ticket) 480 """ 481 if self.restrict_owner: 482 field['type'] = 'select' 483 field['options'] = self.get_allowed_owners(ticket) 484 field['optional'] = True
485
486 - def get_allowed_owners(self, ticket=None):
487 """Returns a list of permitted ticket owners (those possessing the 488 TICKET_MODIFY permission). Returns `None` if the option `[ticket]` 489 `restrict_owner` is `False`. 490 491 If `ticket` is not `None`, fine-grained permission checks are used 492 to determine the allowed owners for the specified resource. 493 494 :since: 1.0.3 495 """ 496 if self.restrict_owner: 497 allowed_owners = [] 498 for user in PermissionSystem(self.env) \ 499 .get_users_with_permission('TICKET_MODIFY'): 500 if not ticket or \ 501 'TICKET_MODIFY' in PermissionCache(self.env, user, 502 ticket.resource): 503 allowed_owners.append(user) 504 allowed_owners.sort() 505 return allowed_owners
506 507 # ITicketManipulator methods 508
509 - def prepare_ticket(self, req, ticket, fields, actions):
510 pass
511
512 - def validate_ticket(self, req, ticket):
513 # Validate select fields for known values. 514 for field in ticket.fields: 515 if 'options' not in field: 516 continue 517 name = field['name'] 518 if name == 'status': 519 continue 520 if name in ticket and name in ticket._old: 521 value = ticket[name] 522 if value: 523 if value not in field['options']: 524 yield name, _('"%(value)s" is not a valid value', 525 value=value) 526 elif not field.get('optional', False): 527 yield name, _("field cannot be empty") 528 529 # Validate description length. 530 if len(ticket['description'] or '') > self.max_description_size: 531 yield 'description', _("Must be less than or equal to %(num)s " 532 "characters", 533 num=self.max_description_size) 534 535 # Validate summary length. 536 if not ticket['summary']: 537 yield 'summary', _("Tickets must contain a summary.") 538 elif len(ticket['summary'] or '') > self.max_summary_size: 539 yield 'summary', _("Must be less than or equal to %(num)s " 540 "characters", num=self.max_summary_size) 541 542 # Validate custom field length. 543 for field in ticket.custom_fields: 544 field_attrs = ticket.fields.by_name(field) 545 max_size = field_attrs.get('max_size', 0) 546 if 0 < max_size < len(ticket[field] or ''): 547 label = field_attrs.get('label') 548 yield label or field, _("Must be less than or equal to " 549 "%(num)s characters", num=max_size) 550 551 # Validate time field content. 552 for field in ticket.time_fields: 553 value = ticket[field] 554 if field in ticket.custom_fields and \ 555 field in ticket._old and \ 556 not isinstance(value, datetime): 557 field_attrs = ticket.fields.by_name(field) 558 format = field_attrs.get('format') 559 try: 560 ticket[field] = user_time(req, parse_date, value, 561 hint=format) \ 562 if value else None 563 except TracError as e: 564 # Degrade TracError to warning. 565 ticket[field] = value 566 label = field_attrs.get('label') 567 yield label or field, to_unicode(e)
568
569 - def validate_comment(self, req, comment):
570 # Validate comment length 571 if len(comment or '') > self.max_comment_size: 572 yield _("Must be less than or equal to %(num)s characters", 573 num=self.max_comment_size)
574 575 # IPermissionRequestor methods 576
577 - def get_permission_actions(self):
578 return ['TICKET_APPEND', 'TICKET_CREATE', 'TICKET_CHGPROP', 579 'TICKET_VIEW', 'TICKET_EDIT_CC', 'TICKET_EDIT_DESCRIPTION', 580 'TICKET_EDIT_COMMENT', 581 ('TICKET_MODIFY', ['TICKET_APPEND', 'TICKET_CHGPROP']), 582 ('TICKET_ADMIN', ['TICKET_CREATE', 'TICKET_MODIFY', 583 'TICKET_VIEW', 'TICKET_EDIT_CC', 584 'TICKET_EDIT_DESCRIPTION', 585 'TICKET_EDIT_COMMENT'])]
586 587 # IWikiSyntaxProvider methods 588 594
595 - def get_wiki_syntax(self):
596 yield ( 597 # matches #... but not &#... (HTML entity) 598 r"!?(?<!&)#" 599 # optional intertrac shorthand #T... + digits 600 r"(?P<it_ticket>%s)%s" % (WikiParser.INTERTRAC_SCHEME, 601 Ranges.RE_STR), 602 lambda x, y, z: self._format_link(x, 'ticket', y[1:], y, z))
603 643 698 699 # IResourceManager methods 700
701 - def get_resource_realms(self):
702 yield self.realm
703
704 - def get_resource_description(self, resource, format=None, context=None, 705 **kwargs):
706 if format == 'compact': 707 return '#%s' % resource.id 708 elif format == 'summary': 709 from trac.ticket.model import Ticket 710 ticket = Ticket(self.env, resource.id) 711 args = [ticket[f] for f in ('summary', 'status', 'resolution', 712 'type')] 713 return self.format_summary(*args) 714 return _("Ticket #%(shortname)s", shortname=resource.id)
715
716 - def format_summary(self, summary, status=None, resolution=None, type=None):
717 summary = shorten_line(summary) 718 if type: 719 summary = type + ': ' + summary 720 if status: 721 if status == 'closed' and resolution: 722 status += ': ' + resolution 723 return "%s (%s)" % (summary, status) 724 else: 725 return summary
726
727 - def resource_exists(self, resource):
728 """ 729 >>> from trac.test import EnvironmentStub 730 >>> from trac.resource import Resource, resource_exists 731 >>> env = EnvironmentStub() 732 733 >>> resource_exists(env, Resource('ticket', 123456)) 734 False 735 736 >>> from trac.ticket.model import Ticket 737 >>> t = Ticket(env) 738 >>> int(t.insert()) 739 1 740 >>> resource_exists(env, t.resource) 741 True 742 """ 743 try: 744 id_ = int(resource.id) 745 except (TypeError, ValueError): 746 return False 747 if self.env.db_query("SELECT id FROM ticket WHERE id=%s", (id_,)): 748 if resource.version is None: 749 return True 750 revcount = self.env.db_query(""" 751 SELECT count(DISTINCT time) FROM ticket_change WHERE ticket=%s 752 """, (id_,)) 753 return revcount[0][0] >= resource.version 754 else: 755 return False
756
757 758 @contextlib.contextmanager 759 -def translation_deactivated(ticket=None):
760 t = deactivate() 761 if ticket is not None: 762 ts = TicketSystem(ticket.env) 763 translated_fields = ticket.fields 764 ticket.fields = ts.get_ticket_fields() 765 try: 766 yield 767 finally: 768 if ticket is not None: 769 ticket.fields = translated_fields 770 reactivate(t)
771