39
|
1 # -*- coding: utf-8 -*-
|
|
2 #
|
|
3 # Copyright (C) 2003-2006 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.com/license.html.
|
|
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://projects.edgewall.com/trac/.
|
|
14 #
|
|
15 # Author: Jonas Borgström <jonas@edgewall.com>
|
|
16
|
|
17 import re
|
|
18
|
|
19 from trac.config import *
|
|
20 from trac.core import *
|
|
21 from trac.perm import IPermissionRequestor, PermissionSystem
|
|
22 from trac.Search import ISearchSource, search_to_sql, shorten_result
|
|
23 from trac.util.text import shorten_line
|
|
24 from trac.util.markup import html, Markup
|
|
25 from trac.wiki import IWikiSyntaxProvider, Formatter
|
|
26
|
|
27
|
|
28 class ITicketChangeListener(Interface):
|
|
29 """Extension point interface for components that require notification when
|
|
30 tickets are created, modified, or deleted."""
|
|
31
|
|
32 def ticket_created(ticket):
|
|
33 """Called when a ticket is created."""
|
|
34
|
|
35 def ticket_changed(ticket, comment, old_values):
|
|
36 """Called when a ticket is modified.
|
|
37
|
|
38 `old_values` is a dictionary containing the previous values of the
|
|
39 fields that have changed.
|
|
40 """
|
|
41
|
|
42 def ticket_deleted(ticket):
|
|
43 """Called when a ticket is deleted."""
|
|
44
|
|
45
|
|
46 class ITicketManipulator(Interface):
|
|
47 """Miscellaneous manipulation of ticket workflow features."""
|
|
48
|
|
49 def prepare_ticket(req, ticket, fields, actions):
|
|
50 """Not currently called, but should be provided for future
|
|
51 compatibility."""
|
|
52
|
|
53 def validate_ticket(req, ticket):
|
|
54 """Validate a ticket after it's been populated from user input.
|
|
55
|
|
56 Must return a list of `(field, message)` tuples, one for each problem
|
|
57 detected. `field` can be `None` to indicate an overall problem with the
|
|
58 ticket. Therefore, a return value of `[]` means everything is OK."""
|
|
59
|
|
60
|
|
61 class TicketSystem(Component):
|
|
62 implements(IPermissionRequestor, IWikiSyntaxProvider, ISearchSource)
|
|
63
|
|
64 change_listeners = ExtensionPoint(ITicketChangeListener)
|
|
65
|
|
66 restrict_owner = BoolOption('ticket', 'restrict_owner', 'false',
|
|
67 """Make the owner field of tickets use a drop-down menu. See
|
|
68 [wiki:TracTickets#AssigntoasDropDownList AssignToAsDropDownList]
|
|
69 (''since 0.9'').""")
|
|
70
|
|
71 # Public API
|
|
72
|
|
73 def get_available_actions(self, ticket, perm_):
|
|
74 """Returns the actions that can be performed on the ticket."""
|
|
75 actions = {
|
|
76 'new': ['leave', 'resolve', 'reassign', 'accept'],
|
|
77 'assigned': ['leave', 'resolve', 'reassign' ],
|
|
78 'reopened': ['leave', 'resolve', 'reassign' ],
|
|
79 'closed': ['leave', 'reopen']
|
|
80 }
|
|
81 perms = {'resolve': 'TICKET_MODIFY', 'reassign': 'TICKET_MODIFY',
|
|
82 'accept': 'TICKET_MODIFY', 'reopen': 'TICKET_CREATE'}
|
|
83 return [action for action in actions.get(ticket['status'], ['leave'])
|
|
84 if action not in perms or perm_.has_permission(perms[action])]
|
|
85
|
|
86 def get_ticket_fields(self):
|
|
87 """Returns the list of fields available for tickets."""
|
|
88 from trac.ticket import model
|
|
89
|
|
90 db = self.env.get_db_cnx()
|
|
91 fields = []
|
|
92
|
|
93 # Basic text fields
|
|
94 for name in ('summary', 'reporter'):
|
|
95 field = {'name': name, 'type': 'text', 'label': name.title()}
|
|
96 fields.append(field)
|
|
97
|
|
98 # Owner field, can be text or drop-down depending on configuration
|
|
99 field = {'name': 'owner', 'label': 'Owner'}
|
|
100 if self.restrict_owner:
|
|
101 field['type'] = 'select'
|
|
102 users = []
|
|
103 perm = PermissionSystem(self.env)
|
|
104 for username, name, email in self.env.get_known_users(db):
|
|
105 if perm.get_user_permissions(username).get('TICKET_MODIFY'):
|
|
106 users.append(username)
|
|
107 field['options'] = users
|
|
108 field['optional'] = True
|
|
109 else:
|
|
110 field['type'] = 'text'
|
|
111 fields.append(field)
|
|
112
|
|
113 # Description
|
|
114 fields.append({'name': 'description', 'type': 'textarea',
|
|
115 'label': 'Description'})
|
|
116
|
|
117 # Default select and radio fields
|
|
118 selects = [('type', model.Type), ('status', model.Status),
|
|
119 ('priority', model.Priority), ('milestone', model.Milestone),
|
|
120 ('component', model.Component), ('version', model.Version),
|
|
121 ('severity', model.Severity), ('resolution', model.Resolution)]
|
|
122 for name, cls in selects:
|
|
123 options = [val.name for val in cls.select(self.env, db=db)]
|
|
124 if not options:
|
|
125 # Fields without possible values are treated as if they didn't
|
|
126 # exist
|
|
127 continue
|
|
128 field = {'name': name, 'type': 'select', 'label': name.title(),
|
|
129 'value': self.config.get('ticket', 'default_' + name),
|
|
130 'options': options}
|
|
131 if name in ('status', 'resolution'):
|
|
132 field['type'] = 'radio'
|
|
133 elif name in ('milestone', 'version'):
|
|
134 field['optional'] = True
|
|
135 fields.append(field)
|
|
136
|
|
137 # Advanced text fields
|
|
138 for name in ('keywords', 'cc', ):
|
|
139 field = {'name': name, 'type': 'text', 'label': name.title()}
|
|
140 fields.append(field)
|
|
141
|
|
142 for field in self.get_custom_fields():
|
|
143 if field['name'] in [f['name'] for f in fields]:
|
|
144 self.log.warning('Duplicate field name "%s" (ignoring)',
|
|
145 field['name'])
|
|
146 continue
|
|
147 if not re.match('^[a-zA-Z][a-zA-Z0-9_]+$', field['name']):
|
|
148 self.log.warning('Invalid name for custom field: "%s" '
|
|
149 '(ignoring)', field['name'])
|
|
150 continue
|
|
151 field['custom'] = True
|
|
152 fields.append(field)
|
|
153
|
|
154 return fields
|
|
155
|
|
156 def get_custom_fields(self):
|
|
157 fields = []
|
|
158 config = self.config['ticket-custom']
|
|
159 for name in [option for option, value in config.options()
|
|
160 if '.' not in option]:
|
|
161 field = {
|
|
162 'name': name,
|
|
163 'type': config.get(name),
|
|
164 'order': config.getint(name + '.order', 0),
|
|
165 'label': config.get(name + '.label') or name.capitalize(),
|
|
166 'value': config.get(name + '.value', '')
|
|
167 }
|
|
168 if field['type'] == 'select' or field['type'] == 'radio':
|
|
169 field['options'] = config.getlist(name + '.options', sep='|')
|
|
170 elif field['type'] == 'textarea':
|
|
171 field['width'] = config.getint(name + '.cols')
|
|
172 field['height'] = config.getint(name + '.rows')
|
|
173 fields.append(field)
|
|
174
|
|
175 fields.sort(lambda x, y: cmp(x['order'], y['order']))
|
|
176 return fields
|
|
177
|
|
178 # IPermissionRequestor methods
|
|
179
|
|
180 def get_permission_actions(self):
|
|
181 return ['TICKET_APPEND', 'TICKET_CREATE', 'TICKET_CHGPROP',
|
|
182 'TICKET_VIEW',
|
|
183 ('TICKET_MODIFY', ['TICKET_APPEND', 'TICKET_CHGPROP']),
|
|
184 ('TICKET_ADMIN', ['TICKET_CREATE', 'TICKET_MODIFY',
|
|
185 'TICKET_VIEW'])]
|
|
186
|
|
187 # IWikiSyntaxProvider methods
|
|
188
|
|
189 def get_link_resolvers(self):
|
|
190 return [('bug', self._format_link),
|
|
191 ('ticket', self._format_link),
|
|
192 ('comment', self._format_comment_link)]
|
|
193
|
|
194 def get_wiki_syntax(self):
|
|
195 yield (
|
|
196 # matches #... but not &#... (HTML entity)
|
|
197 r"!?(?<!&)#"
|
|
198 # optional intertrac shorthand #T... + digits
|
|
199 r"(?P<it_ticket>%s)\d+" % Formatter.INTERTRAC_SCHEME,
|
|
200 lambda x, y, z: self._format_link(x, 'ticket', y[1:], y, z))
|
|
201
|
|
202 def _format_link(self, formatter, ns, target, label, fullmatch=None):
|
|
203 intertrac = formatter.shorthand_intertrac_helper(ns, target, label,
|
|
204 fullmatch)
|
|
205 if intertrac:
|
|
206 return intertrac
|
|
207 try:
|
|
208 cursor = formatter.db.cursor()
|
|
209 cursor.execute("SELECT summary,status FROM ticket WHERE id=%s",
|
|
210 (str(int(target)),))
|
|
211 row = cursor.fetchone()
|
|
212 if row:
|
|
213 return html.A(label, class_='%s ticket' % row[1],
|
|
214 title=shorten_line(row[0]) + ' (%s)' % row[1],
|
|
215 href=formatter.href.ticket(target))
|
|
216 except ValueError:
|
|
217 pass
|
|
218 return html.A(label, class_='missing ticket', rel='nofollow',
|
|
219 href=formatter.href.ticket(target))
|
|
220
|
|
221 def _format_comment_link(self, formatter, ns, target, label):
|
|
222 type, id, cnum = 'ticket', '1', 0
|
|
223 href = None
|
|
224 if ':' in target:
|
|
225 elts = target.split(':')
|
|
226 if len(elts) == 3:
|
|
227 type, id, cnum = elts
|
|
228 href = formatter.href(type, id)
|
|
229 else:
|
|
230 # FIXME: the formatter should know which object the text being
|
|
231 # formatted belongs to
|
|
232 if formatter.req:
|
|
233 path_info = formatter.req.path_info.strip('/').split('/', 2)
|
|
234 if len(path_info) == 2:
|
|
235 type, id = path_info[:2]
|
|
236 href = formatter.href(type, id)
|
|
237 cnum = target
|
|
238 if href:
|
|
239 return html.A(label, href="%s#comment:%s" % (href, cnum),
|
|
240 title="Comment %s for %s:%s" % (cnum, type, id))
|
|
241 else:
|
|
242 return label
|
|
243
|
|
244 # ISearchSource methods
|
|
245
|
|
246 def get_search_filters(self, req):
|
|
247 if req.perm.has_permission('TICKET_VIEW'):
|
|
248 yield ('ticket', 'Tickets')
|
|
249
|
|
250 def get_search_results(self, req, terms, filters):
|
|
251 if not 'ticket' in filters:
|
|
252 return
|
|
253 db = self.env.get_db_cnx()
|
|
254 sql, args = search_to_sql(db, ['b.newvalue'], terms)
|
|
255 sql2, args2 = search_to_sql(db, ['summary', 'keywords', 'description',
|
|
256 'reporter', 'cc'], terms)
|
|
257 cursor = db.cursor()
|
|
258 cursor.execute("SELECT DISTINCT a.summary,a.description,a.reporter, "
|
|
259 "a.keywords,a.id,a.time,a.status FROM ticket a "
|
|
260 "LEFT JOIN ticket_change b ON a.id = b.ticket "
|
|
261 "WHERE (b.field='comment' AND %s ) OR %s" % (sql, sql2),
|
|
262 args + args2)
|
|
263 for summary, desc, author, keywords, tid, date, status in cursor:
|
|
264 ticket = '#%d: ' % tid
|
|
265 if status == 'closed':
|
|
266 ticket = Markup('<span style="text-decoration: line-through">'
|
|
267 '#%s</span>: ', tid)
|
|
268 yield (req.href.ticket(tid),
|
|
269 ticket + shorten_line(summary),
|
|
270 date, author, shorten_result(desc, terms))
|