1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 import copy
18 import re
19
20 from genshi.builder import tag
21
22 from trac.cache import cached
23 from trac.config import *
24 from trac.core import *
25 from trac.perm import IPermissionRequestor, PermissionCache, PermissionSystem
26 from trac.resource import IResourceManager
27 from trac.util import Ranges, as_int
28 from trac.util.text import shorten_line
29 from trac.util.translation import _, N_, gettext
30 from trac.wiki import IWikiSyntaxProvider, WikiParser
34 """Extension point interface for components willing to participate
35 in the ticket workflow.
36
37 This is mainly about controlling the changes to the ticket ''status'',
38 though not restricted to it.
39 """
40
42 """Return an iterable of `(weight, action)` tuples corresponding to
43 the actions that are contributed by this component. The list is
44 dependent on the current state of the ticket and the actual request
45 parameter.
46
47 `action` is a key used to identify that particular action.
48 (note that 'history' and 'diff' are reserved and should not be used
49 by plugins)
50
51 The actions will be presented on the page in descending order of the
52 integer weight. The first action in the list is used as the default
53 action.
54
55 When in doubt, use a weight of 0.
56 """
57
59 """Returns an iterable of all the possible values for the ''status''
60 field this action controller knows about.
61
62 This will be used to populate the query options and the like.
63 It is assumed that the initial status of a ticket is 'new' and
64 the terminal status of a ticket is 'closed'.
65 """
66
68 """Return a tuple in the form of `(label, control, hint)`
69
70 `label` is a short text that will be used when listing the action,
71 `control` is the markup for the action control and `hint` should
72 explain what will happen if this action is taken.
73
74 This method will only be called if the controller claimed to handle
75 the given `action` in the call to `get_ticket_actions`.
76
77 Note that the radio button for the action has an `id` of
78 `"action_%s" % action`. Any `id`s used in `control` need to be made
79 unique. The method used in the default ITicketActionController is to
80 use `"action_%s_something" % action`.
81 """
82
84 """Return a dictionary of ticket field changes.
85
86 This method must not have any side-effects because it will also
87 be called in preview mode (`req.args['preview']` will be set, then).
88 See `apply_action_side_effects` for that. If the latter indeed triggers
89 some side-effects, it is advised to emit a warning
90 (`trac.web.chrome.add_warning(req, reason)`) when this method is called
91 in preview mode.
92
93 This method will only be called if the controller claimed to handle
94 the given `action` in the call to `get_ticket_actions`.
95 """
96
98 """Perform side effects once all changes have been made to the ticket.
99
100 Multiple controllers might be involved, so the apply side-effects
101 offers a chance to trigger a side-effect based on the given `action`
102 after the new state of the ticket has been saved.
103
104 This method will only be called if the controller claimed to handle
105 the given `action` in the call to `get_ticket_actions`.
106 """
107
110 """Extension point interface for components that require notification
111 when tickets are created, modified, or deleted."""
112
114 """Called when a ticket is created."""
115
117 """Called when a ticket is modified.
118
119 `old_values` is a dictionary containing the previous values of the
120 fields that have changed.
121 """
122
124 """Called when a ticket is deleted."""
125
128
130 """Called when a ticket change is deleted.
131
132 `changes` is a dictionary of tuple `(oldvalue, newvalue)`
133 containing the ticket change of the fields that have changed."""
134
137 """Miscellaneous manipulation of ticket workflow features."""
138
140 """Not currently called, but should be provided for future
141 compatibility."""
142
144 """Validate a ticket after it's been populated from user input.
145
146 Must return a list of `(field, message)` tuples, one for each problem
147 detected. `field` can be `None` to indicate an overall problem with the
148 ticket. Therefore, a return value of `[]` means everything is OK."""
149
152 """Extension point interface for components that require notification
153 when milestones are created, modified, or deleted."""
154
156 """Called when a milestone is created."""
157
159 """Called when a milestone is modified.
160
161 `old_values` is a dictionary containing the previous values of the
162 milestone properties that changed. Currently those properties can be
163 'name', 'due', 'completed', or 'description'.
164 """
165
167 """Called when a milestone is deleted."""
168
171 implements(IPermissionRequestor, IWikiSyntaxProvider, IResourceManager)
172
173 change_listeners = ExtensionPoint(ITicketChangeListener)
174 milestone_change_listeners = ExtensionPoint(IMilestoneChangeListener)
175
176 ticket_custom_section = ConfigSection('ticket-custom',
177 """In this section, you can define additional fields for tickets. See
178 TracTicketsCustomFields for more details.""")
179
180 action_controllers = OrderedExtensionsOption('ticket', 'workflow',
181 ITicketActionController, default='ConfigurableTicketWorkflow',
182 include_missing=False,
183 doc="""Ordered list of workflow controllers to use for ticket actions
184 (''since 0.11'').""")
185
186 restrict_owner = BoolOption('ticket', 'restrict_owner', 'false',
187 """Make the owner field of tickets use a drop-down menu.
188 Be sure to understand the performance implications before activating
189 this option. See
190 [TracTickets#Assign-toasDrop-DownList Assign-to as Drop-Down List].
191
192 Please note that e-mail addresses are '''not''' obfuscated in the
193 resulting drop-down menu, so this option should not be used if
194 e-mail addresses must remain protected.
195 (''since 0.9'')""")
196
197 default_version = Option('ticket', 'default_version', '',
198 """Default version for newly created tickets.""")
199
200 default_type = Option('ticket', 'default_type', 'defect',
201 """Default type for newly created tickets (''since 0.9'').""")
202
203 default_priority = Option('ticket', 'default_priority', 'major',
204 """Default priority for newly created tickets.""")
205
206 default_milestone = Option('ticket', 'default_milestone', '',
207 """Default milestone for newly created tickets.""")
208
209 default_component = Option('ticket', 'default_component', '',
210 """Default component for newly created tickets.""")
211
212 default_severity = Option('ticket', 'default_severity', '',
213 """Default severity for newly created tickets.""")
214
215 default_summary = Option('ticket', 'default_summary', '',
216 """Default summary (title) for newly created tickets.""")
217
218 default_description = Option('ticket', 'default_description', '',
219 """Default description for newly created tickets.""")
220
221 default_keywords = Option('ticket', 'default_keywords', '',
222 """Default keywords for newly created tickets.""")
223
224 default_owner = Option('ticket', 'default_owner', '< default >',
225 """Default owner for newly created tickets.""")
226
227 default_cc = Option('ticket', 'default_cc', '',
228 """Default cc: list for newly created tickets.""")
229
230 default_resolution = Option('ticket', 'default_resolution', 'fixed',
231 """Default resolution for resolving (closing) tickets
232 (''since 0.11'').""")
233
235 self.log.debug('action controllers for ticket workflow: %r',
236 [c.__class__.__name__ for c in self.action_controllers])
237
238
239
241 """Returns a sorted list of available actions"""
242
243 actions = {}
244 for controller in self.action_controllers:
245 weighted_actions = controller.get_ticket_actions(req, ticket) or []
246 for weight, action in weighted_actions:
247 if action in actions:
248 actions[action] = max(actions[action], weight)
249 else:
250 actions[action] = weight
251 all_weighted_actions = [(weight, action) for action, weight in
252 actions.items()]
253 return [x[1] for x in sorted(all_weighted_actions, reverse=True)]
254
256 """Returns a sorted list of all the states all of the action
257 controllers know about."""
258 valid_states = set()
259 for controller in self.action_controllers:
260 valid_states.update(controller.get_all_status() or [])
261 return sorted(valid_states)
262
264 """Produce a (name,label) mapping from `get_ticket_fields`."""
265 labels = dict((f['name'], f['label'])
266 for f in self.get_ticket_fields())
267 labels['attachment'] = _("Attachment")
268 return labels
269
271 """Returns list of fields available for tickets.
272
273 Each field is a dict with at least the 'name', 'label' (localized)
274 and 'type' keys.
275 It may in addition contain the 'custom' key, the 'optional' and the
276 'options' keys. When present 'custom' and 'optional' are always `True`.
277 """
278 fields = copy.deepcopy(self.fields)
279 label = 'label'
280 for f in fields:
281 f[label] = gettext(f[label])
282 return fields
283
285 """Invalidate ticket field cache."""
286 del self.fields
287
288 @cached
290 """Return the list of fields available for tickets."""
291 from trac.ticket import model
292
293 fields = []
294
295
296 fields.append({'name': 'summary', 'type': 'text',
297 'label': N_('Summary')})
298 fields.append({'name': 'reporter', 'type': 'text',
299 'label': N_('Reporter')})
300
301
302
303 fields.append({'name': 'owner', 'type': 'text',
304 'label': N_('Owner')})
305
306
307 fields.append({'name': 'description', 'type': 'textarea',
308 'format': 'wiki', 'label': N_('Description')})
309
310
311 selects = [('type', N_('Type'), model.Type),
312 ('status', N_('Status'), model.Status),
313 ('priority', N_('Priority'), model.Priority),
314 ('milestone', N_('Milestone'), model.Milestone),
315 ('component', N_('Component'), model.Component),
316 ('version', N_('Version'), model.Version),
317 ('severity', N_('Severity'), model.Severity),
318 ('resolution', N_('Resolution'), model.Resolution)]
319 for name, label, cls in selects:
320 options = [val.name for val in cls.select(self.env, db=db)]
321 if not options:
322
323
324 continue
325 field = {'name': name, 'type': 'select', 'label': label,
326 'value': getattr(self, 'default_' + name, ''),
327 'options': options}
328 if name in ('status', 'resolution'):
329 field['type'] = 'radio'
330 field['optional'] = True
331 elif name in ('milestone', 'version'):
332 field['optional'] = True
333 fields.append(field)
334
335
336 fields.append({'name': 'keywords', 'type': 'text', 'format': 'list',
337 'label': N_('Keywords')})
338 fields.append({'name': 'cc', 'type': 'text', 'format': 'list',
339 'label': N_('Cc')})
340
341
342 fields.append({'name': 'time', 'type': 'time',
343 'label': N_('Created')})
344 fields.append({'name': 'changetime', 'type': 'time',
345 'label': N_('Modified')})
346
347 for field in self.get_custom_fields():
348 if field['name'] in [f['name'] for f in fields]:
349 self.log.warning('Duplicate field name "%s" (ignoring)',
350 field['name'])
351 continue
352 field['custom'] = True
353 fields.append(field)
354
355 return fields
356
357 reserved_field_names = ['report', 'order', 'desc', 'group', 'groupdesc',
358 'col', 'row', 'format', 'max', 'page', 'verbose',
359 'comment', 'or', 'id', 'time', 'changetime',
360 'owner', 'reporter', 'cc', 'summary',
361 'description', 'keywords']
362
365
366 @cached
368 """Return the list of custom ticket fields available for tickets."""
369 fields = []
370 config = self.ticket_custom_section
371 for name in [option for option, value in config.options()
372 if '.' not in option]:
373 field = {
374 'name': name,
375 'type': config.get(name),
376 'order': config.getint(name + '.order', 0),
377 'label': config.get(name + '.label') or name.capitalize(),
378 'value': config.get(name + '.value', '')
379 }
380 if field['type'] == 'select' or field['type'] == 'radio':
381 field['options'] = config.getlist(name + '.options', sep='|')
382 if not field['options']:
383 continue
384 if '' in field['options']:
385 field['optional'] = True
386 field['options'].remove('')
387 elif field['type'] == 'text':
388 field['format'] = config.get(name + '.format', 'plain')
389 elif field['type'] == 'textarea':
390 field['format'] = config.get(name + '.format', 'plain')
391 field['width'] = config.getint(name + '.cols')
392 field['height'] = config.getint(name + '.rows')
393
394 if field['name'] in self.reserved_field_names:
395 self.log.warning('Field name "%s" is a reserved name '
396 '(ignoring)', field['name'])
397 continue
398 if not re.match('^[a-zA-Z][a-zA-Z0-9_]+$', field['name']):
399 self.log.warning('Invalid name for custom field: "%s" '
400 '(ignoring)', field['name'])
401 continue
402
403 fields.append(field)
404
405 fields.sort(lambda x, y: cmp((x['order'], x['name']),
406 (y['order'], y['name'])))
407 return fields
408
410 """Return a mapping from field name synonyms to field names.
411 The synonyms are supposed to be more intuitive for custom queries."""
412
413 return {'created': 'time', 'modified': 'changetime'}
414
416 """Restrict given owner field to be a list of users having
417 the TICKET_MODIFY permission (for the given ticket)
418 """
419 if self.restrict_owner:
420 field['type'] = 'select'
421 allowed_owners = self.get_allowed_owners(ticket)
422 allowed_owners.insert(0, '< default >')
423 field['options'] = allowed_owners
424 field['optional'] = True
425
427 """Returns a list of permitted ticket owners (those possessing the
428 TICKET_MODIFY permission). Returns `None` if the option `[ticket]`
429 `restrict_owner` is `False`.
430
431 If `ticket` is not `None`, fine-grained permission checks are used
432 to determine the allowed owners for the specified resource.
433
434 :since: 1.0.3
435 """
436 if self.restrict_owner:
437 allowed_owners = []
438 for user in PermissionSystem(self.env) \
439 .get_users_with_permission('TICKET_MODIFY'):
440 if not ticket or \
441 'TICKET_MODIFY' in PermissionCache(self.env, user,
442 ticket.resource):
443 allowed_owners.append(user)
444 allowed_owners.sort()
445 return allowed_owners
446
447
448
450 return ['TICKET_APPEND', 'TICKET_CREATE', 'TICKET_CHGPROP',
451 'TICKET_VIEW', 'TICKET_EDIT_CC', 'TICKET_EDIT_DESCRIPTION',
452 'TICKET_EDIT_COMMENT',
453 ('TICKET_MODIFY', ['TICKET_APPEND', 'TICKET_CHGPROP']),
454 ('TICKET_ADMIN', ['TICKET_CREATE', 'TICKET_MODIFY',
455 'TICKET_VIEW', 'TICKET_EDIT_CC',
456 'TICKET_EDIT_DESCRIPTION',
457 'TICKET_EDIT_COMMENT'])]
458
459
460
462 return [('bug', self._format_link),
463 ('ticket', self._format_link),
464 ('comment', self._format_comment_link)]
465
474
513
561
562
563
566
578
589
591 """
592 >>> from trac.test import EnvironmentStub
593 >>> from trac.resource import Resource, resource_exists
594 >>> env = EnvironmentStub()
595
596 >>> resource_exists(env, Resource('ticket', 123456))
597 False
598
599 >>> from trac.ticket.model import Ticket
600 >>> t = Ticket(env)
601 >>> int(t.insert())
602 1
603 >>> resource_exists(env, t.resource)
604 True
605 """
606 try:
607 id_ = int(resource.id)
608 except (TypeError, ValueError):
609 return False
610 if self.env.db_query("SELECT id FROM ticket WHERE id=%s", (id_,)):
611 if resource.version is None:
612 return True
613 revcount = self.env.db_query("""
614 SELECT count(DISTINCT time) FROM ticket_change WHERE ticket=%s
615 """, (id_,))
616 return revcount[0][0] >= resource.version
617 else:
618 return False
619