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