Package trac ::
Package ticket ::
Module api
1 # -*- coding: utf-8 -*-
2 #
3 # Copyright (C) 2003-2009 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 http://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 http://trac.edgewall.org/log/.
14 #
15 # Author: Jonas Borgström <jonas@edgewall.com>
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
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.
44 That list may vary given the current state of the ticket and the
45 actual request 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
58 """Returns an iterable of all the possible values for the ''status''
59 field this action controller knows about.
60
61 This will be used to populate the query options and the like.
62 It is assumed that the initial status of a ticket is 'new' and
63 the terminal status of a ticket is 'closed'.
64 """
65
67 """Return a tuple in the form of `(label, control, hint)`
68
69 `label` is a short text that will be used when listing the action,
70 `control` is the markup for the action control and `hint` should
71 explain what will happen if this action is taken.
72
73 This method will only be called if the controller claimed to handle
74 the given `action` in the call to `get_ticket_actions`.
75
76 Note that the radio button for the action has an `id` of
77 `"action_%s" % action`. Any `id`s used in `control` need to be made
78 unique. The method used in the default ITicketActionController is to
79 use `"action_%s_something" % action`.
80 """
81
83 """Return a dictionary of ticket field changes.
84
85 This method must not have any side-effects because it will also
86 be called in preview mode (`req.args['preview']` will be set, then).
87 See `apply_action_side_effects` for that. If the latter indeed triggers
88 some side-effects, it is advised to emit a warning
89 (`trac.web.chrome.add_warning(req, reason)`) when this method is called
90 in preview mode.
91
92 This method will only be called if the controller claimed to handle
93 the given `action` in the call to `get_ticket_actions`.
94 """
95
97 """Perform side effects once all changes have been made to the ticket.
98
99 Multiple controllers might be involved, so the apply side-effects
100 offers a chance to trigger a side-effect based on the given `action`
101 after the new state of the ticket has been saved.
102
103 This method will only be called if the controller claimed to handle
104 the given `action` in the call to `get_ticket_actions`.
105 """
106
109 """Extension point interface for components that require notification
110 when tickets are created, modified, or deleted."""
111
113 """Called when a ticket is created."""
114
116 """Called when a ticket is modified.
117
118 `old_values` is a dictionary containing the previous values of the
119 fields that have changed.
120 """
121
123 """Called when a ticket is deleted."""
124
127 """Miscellaneous manipulation of ticket workflow features."""
128
130 """Not currently called, but should be provided for future
131 compatibility."""
132
134 """Validate a ticket after it's been populated from user input.
135
136 Must return a list of `(field, message)` tuples, one for each problem
137 detected. `field` can be `None` to indicate an overall problem with the
138 ticket. Therefore, a return value of `[]` means everything is OK."""
139
142 """Extension point interface for components that require notification
143 when milestones are created, modified, or deleted."""
144
146 """Called when a milestone is created."""
147
149 """Called when a milestone is modified.
150
151 `old_values` is a dictionary containing the previous values of the
152 milestone properties that changed. Currently those properties can be
153 'name', 'due', 'completed', or 'description'.
154 """
155
157 """Called when a milestone is deleted."""
158
161 implements (IPermissionRequestor , IWikiSyntaxProvider , IResourceManager )
162
163 change_listeners = ExtensionPoint (ITicketChangeListener )
164 milestone_change_listeners = ExtensionPoint (IMilestoneChangeListener )
165
166 action_controllers = OrderedExtensionsOption ('ticket', 'workflow',
167 ITicketActionController , default ='ConfigurableTicketWorkflow',
168 include_missing=False,
169 doc="""Ordered list of workflow controllers to use for ticket actions
170 (''since 0.11'').""")
171
172 restrict_owner = BoolOption ('ticket', 'restrict_owner', 'false',
173 """Make the owner field of tickets use a drop-down menu.
174 Be sure to understand the performance implications before activating
175 this option. See
176 [TracTickets#Assign-toasDrop-DownList Assign-to as Drop-Down List].
177
178 Please note that e-mail addresses are '''not''' obfuscated in the
179 resulting drop-down menu, so this option should not be used if
180 e-mail addresses must remain protected.
181 (''since 0.9'')""")
182
183 default_version = Option ('ticket', 'default_version', '',
184 """Default version for newly created tickets.""")
185
186 default_type = Option ('ticket', 'default_type', 'defect',
187 """Default type for newly created tickets (''since 0.9'').""")
188
189 default_priority = Option ('ticket', 'default_priority', 'major',
190 """Default priority for newly created tickets.""")
191
192 default_milestone = Option ('ticket', 'default_milestone', '',
193 """Default milestone for newly created tickets.""")
194
195 default_component = Option ('ticket', 'default_component', '',
196 """Default component for newly created tickets.""")
197
198 default_severity = Option ('ticket', 'default_severity', '',
199 """Default severity for newly created tickets.""")
200
201 default_summary = Option ('ticket', 'default_summary', '',
202 """Default summary (title) for newly created tickets.""")
203
204 default_description = Option ('ticket', 'default_description', '',
205 """Default description for newly created tickets.""")
206
207 default_keywords = Option ('ticket', 'default_keywords', '',
208 """Default keywords for newly created tickets.""")
209
210 default_owner = Option ('ticket', 'default_owner', '',
211 """Default owner for newly created tickets.""")
212
213 default_cc = Option ('ticket', 'default_cc', '',
214 """Default cc: list for newly created tickets.""")
215
216 default_resolution = Option ('ticket', 'default_resolution', 'fixed',
217 """Default resolution for resolving (closing) tickets
218 (''since 0.11'').""")
219
221 self.log .debug('action controllers for ticket workflow: %r' %
222 [c .__class__.__name__ for c in self.action_controllers ])
223
224 # Public API
225
227 """Returns a sorted list of available actions"""
228 # The list should not have duplicates.
229 actions = {}
230 for controller in self.action_controllers :
231 weighted_actions = controller.get_ticket_actions (req, ticket ) or []
232 for weight, action in weighted_actions:
233 if action in actions:
234 actions[action] = max(actions[action], weight)
235 else:
236 actions[action] = weight
237 all_weighted_actions = [(weight, action) for action, weight in
238 actions.items()]
239 return [x [1] for x in sorted(all_weighted_actions, reverse=True)]
240
242 """Returns a sorted list of all the states all of the action
243 controllers know about."""
244 valid_states = set ()
245 for controller in self.action_controllers :
246 valid_states.update (controller.get_all_status () or [])
247 return sorted(valid_states)
248
250 """Produce a (name,label) mapping from `get_ticket_fields`."""
251 labels = dict((f['name'], f['label'])
252 for f in self.get_ticket_fields ())
253 labels['attachment'] = _ ("Attachment")
254 return labels
255
257 """Returns list of fields available for tickets.
258
259 Each field is a dict with at least the 'name', 'label' (localized)
260 and 'type' keys.
261 It may in addition contain the 'custom' key, the 'optional' and the
262 'options' keys. When present 'custom' and 'optional' are always `True`.
263 """
264 fields = copy.deepcopy(self.fields )
265 label = 'label' # workaround gettext extraction bug
266 for f in fields :
267 f[label] = gettext(f[label])
268 return fields
269
271 """Invalidate ticket field cache."""
272 del self.fields
273
274 @cached
276 """Return the list of fields available for tickets."""
277 from trac .ticket import model
278
279 fields = []
280
281 # Basic text fields
282 fields .append({'name': 'summary', 'type': 'text',
283 'label': N_('Summary')})
284 fields .append({'name': 'reporter', 'type': 'text',
285 'label': N_('Reporter')})
286
287 # Owner field, by default text but can be changed dynamically
288 # into a drop-down depending on configuration (restrict_owner=true)
289 field = {'name': 'owner', 'label': N_('Owner')}
290 field['type'] = 'text'
291 fields .append(field)
292
293 # Description
294 fields .append({'name': 'description', 'type': 'textarea',
295 'label': N_('Description')})
296
297 # Default select and radio fields
298 selects = [('type', N_('Type'), model .Type ),
299 ('status', N_('Status'), model .Status ),
300 ('priority', N_('Priority'), model .Priority ),
301 ('milestone', N_('Milestone'), model .Milestone ),
302 ('component', N_('Component'), model .Component ),
303 ('version', N_('Version'), model .Version ),
304 ('severity', N_('Severity'), model .Severity ),
305 ('resolution', N_('Resolution'), model .Resolution )]
306 for name , label, cls in selects:
307 options = [val.name for val in cls.select (self.env , db =db )]
308 if not options :
309 # Fields without possible values are treated as if they didn't
310 # exist
311 continue
312 field = {'name': name , 'type': 'select', 'label': label,
313 'value': getattr(self, 'default_' + name , ''),
314 'options': options }
315 if name in ('status', 'resolution'):
316 field['type'] = 'radio'
317 field['optional'] = True
318 elif name in ('milestone', 'version'):
319 field['optional'] = True
320 fields .append(field)
321
322 # Advanced text fields
323 fields .append({'name': 'keywords', 'type': 'text',
324 'label': N_('Keywords')})
325 fields .append({'name': 'cc', 'type': 'text', 'label': N_('Cc')})
326
327 # Date/time fields
328 fields .append({'name': 'time', 'type': 'time',
329 'label': N_('Created')})
330 fields .append({'name': 'changetime', 'type': 'time',
331 'label': N_('Modified')})
332
333 for field in self.get_custom_fields ():
334 if field['name'] in [f['name'] for f in fields ]:
335 self.log .warning('Duplicate field name "%s" (ignoring)',
336 field['name'])
337 continue
338 if field['name'] in self.reserved_field_names :
339 self.log .warning('Field name "%s" is a reserved name '
340 '(ignoring)', field['name'])
341 continue
342 if not re.match('^[a-zA-Z][a-zA-Z0-9_]+$', field['name']):
343 self.log .warning('Invalid name for custom field: "%s" '
344 '(ignoring)', field['name'])
345 continue
346 field['custom'] = True
347 fields .append(field)
348
349 return fields
350
351 reserved_field_names = ['report', 'order', 'desc', 'group', 'groupdesc',
352 'col', 'row', 'format', 'max', 'page', 'verbose',
353 'comment', 'or']
354
357
358 @cached
360 """Return the list of custom ticket fields available for tickets."""
361 fields = []
362 config = self.config ['ticket-custom']
363 for name in [option for option, value in config .options ()
364 if '.' not in option]:
365 field = {
366 'name': name ,
367 'type': config .get (name ),
368 'order': config .getint (name + '.order', 0),
369 'label': config .get (name + '.label') or name .capitalize(),
370 'value': config .get (name + '.value', '')
371 }
372 if field['type'] == 'select' or field['type'] == 'radio':
373 field['options'] = config .getlist (name + '.options', sep='|')
374 if '' in field['options']:
375 field['optional'] = True
376 field['options'].remove ('')
377 elif field['type'] == 'text':
378 field['format'] = config .get (name + '.format', 'plain')
379 elif field['type'] == 'textarea':
380 field['format'] = config .get (name + '.format', 'plain')
381 field['width'] = config .getint (name + '.cols')
382 field['height'] = config .getint (name + '.rows')
383 fields .append(field)
384
385 fields .sort(lambda x , y: cmp((x ['order'], x ['name']),
386 (y['order'], y['name'])))
387 return fields
388
390 """Return a mapping from field name synonyms to field names.
391 The synonyms are supposed to be more intuitive for custom queries."""
392 # i18n TODO - translated keys
393 return {'created': 'time', 'modified': 'changetime'}
394
396 """Restrict given owner field to be a list of users having
397 the TICKET_MODIFY permission (for the given ticket)
398 """
399 if self.restrict_owner :
400 field['type'] = 'select'
401 possible_owners = []
402 for user in PermissionSystem (self.env ) \
403 .get_users_with_permission ('TICKET_MODIFY'):
404 if not ticket or \
405 'TICKET_MODIFY' in PermissionCache(self.env , user,
406 ticket .resource ):
407 possible_owners.append(user)
408 possible_owners.sort()
409 field['options'] = possible_owners
410 field['optional'] = True
411
412 # IPermissionRequestor methods
413
415 return ['TICKET_APPEND', 'TICKET_CREATE', 'TICKET_CHGPROP',
416 'TICKET_VIEW', 'TICKET_EDIT_CC', 'TICKET_EDIT_DESCRIPTION',
417 'TICKET_EDIT_COMMENT',
418 ('TICKET_MODIFY', ['TICKET_APPEND', 'TICKET_CHGPROP']),
419 ('TICKET_ADMIN', ['TICKET_CREATE', 'TICKET_MODIFY',
420 'TICKET_VIEW', 'TICKET_EDIT_CC',
421 'TICKET_EDIT_DESCRIPTION',
422 'TICKET_EDIT_COMMENT'])]
423
424 # IWikiSyntaxProvider methods
425
427 return [('bug', self._format_link),
428 ('ticket', self._format_link),
429 ('comment', self._format_comment_link)]
430
432 yield (
433 # matches #... but not &#... (HTML entity)
434 r"!?(?<!&)#"
435 # optional intertrac shorthand #T... + digits
436 r"(?P<it_ticket>%s)%s" % (WikiParser .INTERTRAC_SCHEME ,
437 Ranges .RE_STR ),
438 lambda x , y, z : self._format_link(x , 'ticket', y[1:], y, z ))
439
474
495
496 # IResourceManager methods
497
499 yield 'ticket'
500
512
523
525 """
526 >>> from trac.test import EnvironmentStub
527 >>> from trac.resource import Resource, resource_exists
528 >>> env = EnvironmentStub()
529
530 >>> resource_exists(env, Resource('ticket', 123456))
531 False
532
533 >>> from trac.ticket.model import Ticket
534 >>> t = Ticket(env)
535 >>> int(t.insert())
536 1
537 >>> resource_exists(env, t.resource)
538 True
539 """
540 db = self.env .get_read_db ()
541 cursor = db .cursor ()
542 cursor .execute ("SELECT id FROM ticket WHERE id=%s", (resource .id ,))
543 latest_exists = bool(cursor .fetchall ())
544 if latest_exists:
545 if resource .version is None:
546 return True
547 cursor .execute ("""
548 SELECT count(distinct time) FROM ticket_change WHERE ticket=%s
549 """, (resource .id ,))
550 return cursor .fetchone ()[0] >= resource .version
551 else:
552 return False
553