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
59 return name in self._map
60
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
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
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
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
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
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
138 """Extension point interface for components that require notification
139 when tickets are created, modified, or deleted."""
140
142 """Called when a ticket is created."""
143
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
152 """Called when a ticket is deleted."""
153
156
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
165 """Miscellaneous manipulation of ticket workflow features."""
166
168 """Not currently called, but should be provided for future
169 compatibility."""
170
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
186
189 """Extension point interface for components that require notification
190 when milestones are created, modified, or deleted."""
191
193 """Called when a milestone is created."""
194
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
204 """Called when a milestone is deleted."""
205
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
290 self.log.debug('action controllers for ticket workflow: %r',
291 [c.__class__.__name__ for c in self.action_controllers])
292
293
294
296 """Returns a sorted list of available actions"""
297
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
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
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
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'
334 for f in fields:
335 if not f.get('custom'):
336 f[label] = gettext(f[label])
337 return fields
338
340 """Invalidate ticket field cache."""
341 del self.fields
342
343 @cached
345 """Return the list of fields available for tickets."""
346 from trac.ticket import model
347
348 fields = TicketFieldList()
349
350
351 fields.append({'name': 'summary', 'type': 'text',
352 'label': N_('Summary')})
353 fields.append({'name': 'reporter', 'type': 'text',
354 'label': N_('Reporter')})
355
356
357
358 fields.append({'name': 'owner', 'type': 'text',
359 'label': N_('Owner')})
360
361
362 fields.append({'name': 'description', 'type': 'textarea',
363 'format': 'wiki', 'label': N_('Description')})
364
365
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
378
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
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
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
419
420 @cached
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
472 """Return a mapping from field name synonyms to field names.
473 The synonyms are supposed to be more intuitive for custom queries."""
474
475 return {'created': 'time', 'modified': 'changetime'}
476
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
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
508
511
513
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
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
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
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
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
565 ticket[field] = value
566 label = field_attrs.get('label')
567 yield label or field, to_unicode(e)
568
574
575
576
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
588
590 return [('bug', self._format_link),
591 ('issue', self._format_link),
592 ('ticket', self._format_link),
593 ('comment', self._format_comment_link)]
594
603
643
698
699
700
703
715
726
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
771