1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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
38 """Improved ticket field list, allowing access by name."""
39 __slots__ = ['_map']
40
44
48
49 - def by_name(self, name, default=None):
51
54
57
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
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
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
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
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
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
135 """Extension point interface for components that require notification
136 when tickets are created, modified, or deleted."""
137
139 """Called when a ticket is created."""
140
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
149 """Called when a ticket is deleted."""
150
153
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
162 """Miscellaneous manipulation of ticket workflow features."""
163
165 """Not currently called, but should be provided for future
166 compatibility."""
167
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
183
186 """Extension point interface for components that require notification
187 when milestones are created, modified, or deleted."""
188
190 """Called when a milestone is created."""
191
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
201 """Called when a milestone is deleted."""
202
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
287 self.log.debug('action controllers for ticket workflow: %r',
288 [c.__class__.__name__ for c in self.action_controllers])
289
290
291
293 """Returns a sorted list of available actions"""
294
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
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
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
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'
331 for f in fields:
332 if not f.get('custom'):
333 f[label] = gettext(f[label])
334 return fields
335
337 """Invalidate ticket field cache."""
338 del self.fields
339
340 @cached
342 """Return the list of fields available for tickets."""
343 from trac.ticket import model
344
345 fields = TicketFieldList()
346
347
348 fields.append({'name': 'summary', 'type': 'text',
349 'label': N_('Summary')})
350 fields.append({'name': 'reporter', 'type': 'text',
351 'label': N_('Reporter')})
352
353
354
355 fields.append({'name': 'owner', 'type': 'text',
356 'label': N_('Owner')})
357
358
359 fields.append({'name': 'description', 'type': 'textarea',
360 'format': 'wiki', 'label': N_('Description')})
361
362
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
375
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
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
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
416
417 @cached
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
469 """Return a mapping from field name synonyms to field names.
470 The synonyms are supposed to be more intuitive for custom queries."""
471
472 return {'created': 'time', 'modified': 'changetime'}
473
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
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
505
508
510
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
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
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
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
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
562 ticket[field] = value
563 label = field_attrs.get('label')
564 yield label or field, to_unicode(e)
565
571
572
573
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
585
587 return [('bug', self._format_link),
588 ('issue', self._format_link),
589 ('ticket', self._format_link),
590 ('comment', self._format_comment_link)]
591
600
640
695
696
697
700
712
723
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
768