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
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
37 """Improved ticket field list, allowing access by name."""
38 __slots__ = ['_map']
39
43
47
48 - def by_name(self, name, default=None):
50
53
56
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
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
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
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
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
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
134 """Extension point interface for components that require notification
135 when tickets are created, modified, or deleted."""
136
138 """Called when a ticket is created."""
139
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
148 """Called when a ticket is deleted."""
149
152
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
161 """Miscellaneous manipulation of ticket workflow features."""
162
164 """Not currently called, but should be provided for future
165 compatibility."""
166
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
176 """Extension point interface for components that require notification
177 when milestones are created, modified, or deleted."""
178
180 """Called when a milestone is created."""
181
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
191 """Called when a milestone is deleted."""
192
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
265 self.log.debug('action controllers for ticket workflow: %r',
266 [c.__class__.__name__ for c in self.action_controllers])
267
268
269
271 """Returns a sorted list of available actions"""
272
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
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
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
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'
310 for f in fields:
311 if not f.get('custom'):
312 f[label] = gettext(f[label])
313 return fields
314
316 """Invalidate ticket field cache."""
317 del self.fields
318
319 @cached
321 """Return the list of fields available for tickets."""
322 from trac.ticket import model
323
324 fields = TicketFieldList()
325
326
327 fields.append({'name': 'summary', 'type': 'text',
328 'label': N_('Summary')})
329 fields.append({'name': 'reporter', 'type': 'text',
330 'label': N_('Reporter')})
331
332
333
334 fields.append({'name': 'owner', 'type': 'text',
335 'label': N_('Owner')})
336
337
338 fields.append({'name': 'description', 'type': 'textarea',
339 'format': 'wiki', 'label': N_('Description')})
340
341
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
354
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
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
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
395
396 @cached
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
447 """Return a mapping from field name synonyms to field names.
448 The synonyms are supposed to be more intuitive for custom queries."""
449
450 return {'created': 'time', 'modified': 'changetime'}
451
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
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
483
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
495
497 return [('bug', self._format_link),
498 ('issue', self._format_link),
499 ('ticket', self._format_link),
500 ('comment', self._format_comment_link)]
501
510
550
605
606
607
610
622
633
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
678