Mercurial > genshi > mirror
comparison examples/trac/trac/ticket/web_ui.py @ 39:93b4dcbafd7b trunk
Copy Trac to main branch.
author | cmlenz |
---|---|
date | Mon, 03 Jul 2006 18:53:27 +0000 |
parents | |
children | f8a5a6ee2097 |
comparison
equal
deleted
inserted
replaced
38:ee669cb9cccc | 39:93b4dcbafd7b |
---|---|
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 os | |
18 import re | |
19 import time | |
20 from StringIO import StringIO | |
21 | |
22 from trac.attachment import attachments_to_hdf, Attachment, AttachmentModule | |
23 from trac.config import BoolOption, Option | |
24 from trac.core import * | |
25 from trac.env import IEnvironmentSetupParticipant | |
26 from trac.ticket import Milestone, Ticket, TicketSystem, ITicketManipulator | |
27 from trac.ticket.notification import TicketNotifyEmail | |
28 from trac.Timeline import ITimelineEventProvider | |
29 from trac.util import get_reporter_id | |
30 from trac.util.datefmt import format_datetime, pretty_timedelta, http_date | |
31 from trac.util.text import CRLF | |
32 from trac.util.markup import html, Markup | |
33 from trac.web import IRequestHandler | |
34 from trac.web.chrome import add_link, add_stylesheet, INavigationContributor | |
35 from trac.wiki import wiki_to_html, wiki_to_oneliner | |
36 from trac.mimeview.api import Mimeview, IContentConverter | |
37 | |
38 | |
39 class InvalidTicket(TracError): | |
40 """Exception raised when a ticket fails validation.""" | |
41 | |
42 | |
43 class TicketModuleBase(Component): | |
44 # FIXME: temporary place-holder for unified ticket validation until | |
45 # ticket controller unification is merged | |
46 abstract = True | |
47 | |
48 ticket_manipulators = ExtensionPoint(ITicketManipulator) | |
49 | |
50 def _validate_ticket(self, req, ticket): | |
51 for manipulator in self.ticket_manipulators: | |
52 for field, message in manipulator.validate_ticket(req, ticket): | |
53 if field: | |
54 raise InvalidTicket("The ticket %s field is invalid: %s" % | |
55 (field, message)) | |
56 else: | |
57 raise InvalidTicket("Invalid ticket: %s" % message) | |
58 | |
59 | |
60 class NewticketModule(TicketModuleBase): | |
61 | |
62 implements(IEnvironmentSetupParticipant, INavigationContributor, | |
63 IRequestHandler) | |
64 | |
65 # IEnvironmentSetupParticipant methods | |
66 | |
67 def environment_created(self): | |
68 """Create the `site_newticket.cs` template file in the environment.""" | |
69 if self.env.path: | |
70 templates_dir = os.path.join(self.env.path, 'templates') | |
71 if not os.path.exists(templates_dir): | |
72 os.mkdir(templates_dir) | |
73 template_name = os.path.join(templates_dir, 'site_newticket.cs') | |
74 template_file = file(template_name, 'w') | |
75 template_file.write("""<?cs | |
76 #################################################################### | |
77 # New ticket prelude - Included directly above the new ticket form | |
78 ?> | |
79 """) | |
80 | |
81 def environment_needs_upgrade(self, db): | |
82 return False | |
83 | |
84 def upgrade_environment(self, db): | |
85 pass | |
86 | |
87 # INavigationContributor methods | |
88 | |
89 def get_active_navigation_item(self, req): | |
90 return 'newticket' | |
91 | |
92 def get_navigation_items(self, req): | |
93 if not req.perm.has_permission('TICKET_CREATE'): | |
94 return | |
95 yield ('mainnav', 'newticket', | |
96 html.A('New Ticket', href=req.href.newticket(), accesskey=7)) | |
97 | |
98 # IRequestHandler methods | |
99 | |
100 def match_request(self, req): | |
101 return re.match(r'/newticket/?', req.path_info) is not None | |
102 | |
103 def process_request(self, req): | |
104 req.perm.assert_permission('TICKET_CREATE') | |
105 | |
106 db = self.env.get_db_cnx() | |
107 | |
108 if req.method == 'POST' and not req.args.has_key('preview'): | |
109 self._do_create(req, db) | |
110 | |
111 ticket = Ticket(self.env, db=db) | |
112 ticket.populate(req.args) | |
113 ticket.values['reporter'] = get_reporter_id(req, 'reporter') | |
114 | |
115 if ticket.values.has_key('description'): | |
116 description = wiki_to_html(ticket['description'], self.env, req, db) | |
117 req.hdf['newticket.description_preview'] = description | |
118 | |
119 req.hdf['title'] = 'New Ticket' | |
120 req.hdf['newticket'] = ticket.values | |
121 | |
122 field_names = [field['name'] for field in ticket.fields | |
123 if not field.get('custom')] | |
124 if 'owner' in field_names: | |
125 curr_idx = field_names.index('owner') | |
126 if 'cc' in field_names: | |
127 insert_idx = field_names.index('cc') | |
128 else: | |
129 insert_idx = len(field_names) | |
130 if curr_idx < insert_idx: | |
131 ticket.fields.insert(insert_idx, ticket.fields[curr_idx]) | |
132 del ticket.fields[curr_idx] | |
133 | |
134 for field in ticket.fields: | |
135 name = field['name'] | |
136 del field['name'] | |
137 if name in ('summary', 'reporter', 'description', 'type', 'status', | |
138 'resolution'): | |
139 field['skip'] = True | |
140 elif name == 'owner': | |
141 field['label'] = 'Assign to' | |
142 elif name == 'milestone': | |
143 # Don't make completed milestones available for selection | |
144 options = field['options'][:] | |
145 for option in field['options']: | |
146 milestone = Milestone(self.env, option, db=db) | |
147 if milestone.is_completed: | |
148 options.remove(option) | |
149 field['options'] = options | |
150 req.hdf['newticket.fields.' + name] = field | |
151 | |
152 if req.perm.has_permission('TICKET_APPEND'): | |
153 req.hdf['newticket.can_attach'] = True | |
154 req.hdf['newticket.attachment'] = req.args.get('attachment') | |
155 | |
156 add_stylesheet(req, 'common/css/ticket.css') | |
157 return 'newticket.cs', None | |
158 | |
159 # Internal methods | |
160 | |
161 def _do_create(self, req, db): | |
162 if not req.args.get('summary'): | |
163 raise TracError('Tickets must contain a summary.') | |
164 | |
165 ticket = Ticket(self.env, db=db) | |
166 ticket.populate(req.args) | |
167 ticket.values['reporter'] = get_reporter_id(req, 'reporter') | |
168 self._validate_ticket(req, ticket) | |
169 | |
170 ticket.insert(db=db) | |
171 db.commit() | |
172 | |
173 # Notify | |
174 try: | |
175 tn = TicketNotifyEmail(self.env) | |
176 tn.notify(ticket, req, newticket=True) | |
177 except Exception, e: | |
178 self.log.exception("Failure sending notification on creation of " | |
179 "ticket #%s: %s" % (ticket.id, e)) | |
180 | |
181 # Redirect the user to the newly created ticket | |
182 if req.args.get('attachment'): | |
183 req.redirect(req.href.attachment('ticket', ticket.id, action='new')) | |
184 else: | |
185 req.redirect(req.href.ticket(ticket.id)) | |
186 | |
187 | |
188 class TicketModule(TicketModuleBase): | |
189 | |
190 implements(INavigationContributor, IRequestHandler, ITimelineEventProvider, | |
191 IContentConverter) | |
192 | |
193 default_version = Option('ticket', 'default_version', '', | |
194 """Default version for newly created tickets.""") | |
195 | |
196 default_type = Option('ticket', 'default_type', 'defect', | |
197 """Default type for newly created tickets (''since 0.9'').""") | |
198 | |
199 default_priority = Option('ticket', 'default_priority', 'major', | |
200 """Default priority for newly created tickets.""") | |
201 | |
202 default_milestone = Option('ticket', 'default_milestone', '', | |
203 """Default milestone for newly created tickets.""") | |
204 | |
205 default_component = Option('ticket', 'default_component', '', | |
206 """Default component for newly created tickets""") | |
207 | |
208 timeline_details = BoolOption('timeline', 'ticket_show_details', 'false', | |
209 """Enable the display of all ticket changes in the timeline | |
210 (''since 0.9'').""") | |
211 | |
212 # IContentConverter methods | |
213 | |
214 def get_supported_conversions(self): | |
215 yield ('csv', 'Comma-delimited Text', 'csv', | |
216 'trac.ticket.Ticket', 'text/csv', 8) | |
217 yield ('tab', 'Tab-delimited Text', 'tsv', | |
218 'trac.ticket.Ticket', 'text/tab-separated-values', 8) | |
219 yield ('rss', 'RSS Feed', 'xml', | |
220 'trac.ticket.Ticket', 'application/rss+xml', 8) | |
221 | |
222 def convert_content(self, req, mimetype, ticket, key): | |
223 if key == 'csv': | |
224 return self.export_csv(ticket, mimetype='text/csv') | |
225 elif key == 'tab': | |
226 return self.export_csv(ticket, sep='\t', | |
227 mimetype='text/tab-separated-values') | |
228 elif key == 'rss': | |
229 return self.export_rss(req, ticket) | |
230 | |
231 # INavigationContributor methods | |
232 | |
233 def get_active_navigation_item(self, req): | |
234 return 'tickets' | |
235 | |
236 def get_navigation_items(self, req): | |
237 return [] | |
238 | |
239 # IRequestHandler methods | |
240 | |
241 def match_request(self, req): | |
242 match = re.match(r'/ticket/([0-9]+)', req.path_info) | |
243 if match: | |
244 req.args['id'] = match.group(1) | |
245 return True | |
246 | |
247 def process_request(self, req): | |
248 req.perm.assert_permission('TICKET_VIEW') | |
249 | |
250 action = req.args.get('action', 'view') | |
251 | |
252 db = self.env.get_db_cnx() | |
253 id = int(req.args.get('id')) | |
254 | |
255 ticket = Ticket(self.env, id, db=db) | |
256 | |
257 if req.method == 'POST': | |
258 if not req.args.has_key('preview'): | |
259 self._do_save(req, db, ticket) | |
260 else: | |
261 # Use user supplied values | |
262 ticket.populate(req.args) | |
263 self._validate_ticket(req, ticket) | |
264 | |
265 req.hdf['ticket.action'] = action | |
266 req.hdf['ticket.ts'] = req.args.get('ts') | |
267 req.hdf['ticket.reassign_owner'] = req.args.get('reassign_owner') \ | |
268 or req.authname | |
269 req.hdf['ticket.resolve_resolution'] = req.args.get('resolve_resolution') | |
270 comment = req.args.get('comment') | |
271 if comment: | |
272 req.hdf['ticket.comment'] = comment | |
273 # Wiki format a preview of comment | |
274 req.hdf['ticket.comment_preview'] = wiki_to_html( | |
275 comment, self.env, req, db) | |
276 else: | |
277 req.hdf['ticket.reassign_owner'] = req.authname | |
278 # Store a timestamp in order to detect "mid air collisions" | |
279 req.hdf['ticket.ts'] = ticket.time_changed | |
280 | |
281 self._insert_ticket_data(req, db, ticket, | |
282 get_reporter_id(req, 'author')) | |
283 | |
284 mime = Mimeview(self.env) | |
285 format = req.args.get('format') | |
286 if format: | |
287 mime.send_converted(req, 'trac.ticket.Ticket', ticket, format, | |
288 'ticket_%d' % ticket.id) | |
289 | |
290 # If the ticket is being shown in the context of a query, add | |
291 # links to help navigate in the query result set | |
292 if 'query_tickets' in req.session: | |
293 tickets = req.session['query_tickets'].split() | |
294 if str(id) in tickets: | |
295 idx = tickets.index(str(ticket.id)) | |
296 if idx > 0: | |
297 add_link(req, 'first', req.href.ticket(tickets[0]), | |
298 'Ticket #%s' % tickets[0]) | |
299 add_link(req, 'prev', req.href.ticket(tickets[idx - 1]), | |
300 'Ticket #%s' % tickets[idx - 1]) | |
301 if idx < len(tickets) - 1: | |
302 add_link(req, 'next', req.href.ticket(tickets[idx + 1]), | |
303 'Ticket #%s' % tickets[idx + 1]) | |
304 add_link(req, 'last', req.href.ticket(tickets[-1]), | |
305 'Ticket #%s' % tickets[-1]) | |
306 add_link(req, 'up', req.session['query_href']) | |
307 | |
308 add_stylesheet(req, 'common/css/ticket.css') | |
309 | |
310 # Add registered converters | |
311 for conversion in mime.get_supported_conversions('trac.ticket.Ticket'): | |
312 conversion_href = req.href.ticket(ticket.id, format=conversion[0]) | |
313 add_link(req, 'alternate', conversion_href, conversion[1], | |
314 conversion[3]) | |
315 | |
316 return 'ticket.cs', None | |
317 | |
318 # ITimelineEventProvider methods | |
319 | |
320 def get_timeline_filters(self, req): | |
321 if req.perm.has_permission('TICKET_VIEW'): | |
322 yield ('ticket', 'Ticket changes') | |
323 if self.timeline_details: | |
324 yield ('ticket_details', 'Ticket details', False) | |
325 | |
326 def get_timeline_events(self, req, start, stop, filters): | |
327 format = req.args.get('format') | |
328 | |
329 status_map = {'new': ('newticket', 'created'), | |
330 'reopened': ('newticket', 'reopened'), | |
331 'closed': ('closedticket', 'closed'), | |
332 'edit': ('editedticket', 'updated')} | |
333 | |
334 href = format == 'rss' and req.abs_href or req.href | |
335 | |
336 def produce((id, t, author, type, summary), status, fields, | |
337 comment, cid): | |
338 if status == 'edit': | |
339 if 'ticket_details' in filters: | |
340 info = '' | |
341 if len(fields) > 0: | |
342 info = ', '.join(['<i>%s</i>' % f for f in \ | |
343 fields.keys()]) + ' changed<br />' | |
344 else: | |
345 return None | |
346 elif 'ticket' in filters: | |
347 if status == 'closed' and fields.has_key('resolution'): | |
348 info = fields['resolution'] | |
349 if info and comment: | |
350 info = '%s: ' % info | |
351 else: | |
352 info = '' | |
353 else: | |
354 return None | |
355 kind, verb = status_map[status] | |
356 if format == 'rss': | |
357 title = 'Ticket #%s (%s %s): %s' % \ | |
358 (id, type.lower(), verb, summary) | |
359 else: | |
360 title = Markup('Ticket <em title="%s">#%s</em> (%s) %s by %s', | |
361 summary, id, type, verb, author) | |
362 ticket_href = href.ticket(id) | |
363 if cid: | |
364 ticket_href += '#comment:' + cid | |
365 if status == 'new': | |
366 message = summary | |
367 else: | |
368 message = Markup(info) | |
369 if comment: | |
370 if format == 'rss': | |
371 message += wiki_to_html(comment, self.env, req, db, | |
372 absurls=True) | |
373 else: | |
374 message += wiki_to_oneliner(comment, self.env, db, | |
375 shorten=True) | |
376 return kind, ticket_href, title, t, author, message | |
377 | |
378 # Ticket changes | |
379 if 'ticket' in filters or 'ticket_details' in filters: | |
380 db = self.env.get_db_cnx() | |
381 cursor = db.cursor() | |
382 | |
383 cursor.execute("SELECT t.id,tc.time,tc.author,t.type,t.summary, " | |
384 " tc.field,tc.oldvalue,tc.newvalue " | |
385 " FROM ticket_change tc " | |
386 " INNER JOIN ticket t ON t.id = tc.ticket " | |
387 " AND tc.time>=%s AND tc.time<=%s " | |
388 "ORDER BY tc.time" | |
389 % (start, stop)) | |
390 previous_update = None | |
391 for id,t,author,type,summary,field,oldvalue,newvalue in cursor: | |
392 if not previous_update or (id,t,author) != previous_update[:3]: | |
393 if previous_update: | |
394 ev = produce(previous_update, status, fields, | |
395 comment, cid) | |
396 if ev: | |
397 yield ev | |
398 status, fields, comment, cid = 'edit', {}, '', None | |
399 previous_update = (id, t, author, type, summary) | |
400 if field == 'comment': | |
401 comment = newvalue | |
402 cid = oldvalue and oldvalue.split('.')[-1] | |
403 elif field == 'status' and newvalue in ('reopened', 'closed'): | |
404 status = newvalue | |
405 else: | |
406 fields[field] = newvalue | |
407 if previous_update: | |
408 ev = produce(previous_update, status, fields, comment, cid) | |
409 if ev: | |
410 yield ev | |
411 | |
412 # New tickets | |
413 if 'ticket' in filters: | |
414 cursor.execute("SELECT id,time,reporter,type,summary" | |
415 " FROM ticket WHERE time>=%s AND time<=%s", | |
416 (start, stop)) | |
417 for row in cursor: | |
418 yield produce(row, 'new', {}, None, None) | |
419 | |
420 # Attachments | |
421 if 'ticket_details' in filters: | |
422 def display(id): | |
423 return Markup('ticket %s', html.EM('#', id)) | |
424 att = AttachmentModule(self.env) | |
425 for event in att.get_timeline_events(req, db, 'ticket', | |
426 format, start, stop, | |
427 display): | |
428 yield event | |
429 | |
430 # Internal methods | |
431 | |
432 def export_csv(self, ticket, sep=',', mimetype='text/plain'): | |
433 content = StringIO() | |
434 content.write(sep.join(['id'] + [f['name'] for f in ticket.fields]) | |
435 + CRLF) | |
436 content.write(sep.join([unicode(ticket.id)] + | |
437 [ticket.values.get(f['name'], '') | |
438 .replace(sep, '_').replace('\\', '\\\\') | |
439 .replace('\n', '\\n').replace('\r', '\\r') | |
440 for f in ticket.fields]) + CRLF) | |
441 return (content.getvalue(), '%s;charset=utf-8' % mimetype) | |
442 | |
443 def export_rss(self, req, ticket): | |
444 db = self.env.get_db_cnx() | |
445 changes = [] | |
446 change_summary = {} | |
447 | |
448 description = wiki_to_html(ticket['description'], self.env, req, db) | |
449 req.hdf['ticket.description.formatted'] = unicode(description) | |
450 | |
451 for change in self.grouped_changelog_entries(ticket, db): | |
452 changes.append(change) | |
453 # compute a change summary | |
454 change_summary = {} | |
455 # wikify comment | |
456 if 'comment' in change: | |
457 comment = change['comment'] | |
458 change['comment'] = unicode(wiki_to_html( | |
459 comment, self.env, req, db, absurls=True)) | |
460 change_summary['added'] = ['comment'] | |
461 for field, values in change['fields'].iteritems(): | |
462 if field == 'description': | |
463 change_summary.setdefault('changed', []).append(field) | |
464 else: | |
465 chg = 'changed' | |
466 if not values['old']: | |
467 chg = 'set' | |
468 elif not values['new']: | |
469 chg = 'deleted' | |
470 change_summary.setdefault(chg, []).append(field) | |
471 change['title'] = '; '.join(['%s %s' % (', '.join(v), k) for k, v \ | |
472 in change_summary.iteritems()]) | |
473 req.hdf['ticket.changes'] = changes | |
474 return (req.hdf.render('ticket_rss.cs'), 'application/rss+xml') | |
475 | |
476 | |
477 def _do_save(self, req, db, ticket): | |
478 if req.perm.has_permission('TICKET_CHGPROP'): | |
479 # TICKET_CHGPROP gives permission to edit the ticket | |
480 if not req.args.get('summary'): | |
481 raise TracError('Tickets must contain summary.') | |
482 | |
483 if req.args.has_key('description') or req.args.has_key('reporter'): | |
484 req.perm.assert_permission('TICKET_ADMIN') | |
485 | |
486 ticket.populate(req.args) | |
487 else: | |
488 req.perm.assert_permission('TICKET_APPEND') | |
489 | |
490 # Mid air collision? | |
491 if int(req.args.get('ts')) != ticket.time_changed: | |
492 raise TracError("Sorry, can not save your changes. " | |
493 "This ticket has been modified by someone else " | |
494 "since you started", 'Mid Air Collision') | |
495 | |
496 self._validate_ticket(req, ticket) | |
497 | |
498 # Do any action on the ticket? | |
499 action = req.args.get('action') | |
500 actions = TicketSystem(self.env).get_available_actions(ticket, req.perm) | |
501 if action not in actions: | |
502 raise TracError('Invalid action') | |
503 | |
504 # TODO: this should not be hard-coded like this | |
505 if action == 'accept': | |
506 ticket['status'] = 'assigned' | |
507 ticket['owner'] = req.authname | |
508 if action == 'resolve': | |
509 ticket['status'] = 'closed' | |
510 ticket['resolution'] = req.args.get('resolve_resolution') | |
511 elif action == 'reassign': | |
512 ticket['owner'] = req.args.get('reassign_owner') | |
513 ticket['status'] = 'new' | |
514 elif action == 'reopen': | |
515 ticket['status'] = 'reopened' | |
516 ticket['resolution'] = '' | |
517 | |
518 now = int(time.time()) | |
519 cnum = req.args.get('cnum') | |
520 replyto = req.args.get('replyto') | |
521 internal_cnum = cnum | |
522 if cnum and replyto: # record parent.child relationship | |
523 internal_cnum = '%s.%s' % (replyto, cnum) | |
524 ticket.save_changes(get_reporter_id(req, 'author'), | |
525 req.args.get('comment'), when=now, db=db, | |
526 cnum=internal_cnum) | |
527 db.commit() | |
528 | |
529 try: | |
530 tn = TicketNotifyEmail(self.env) | |
531 tn.notify(ticket, req, newticket=False, modtime=now) | |
532 except Exception, e: | |
533 self.log.exception("Failure sending notification on change to " | |
534 "ticket #%s: %s" % (ticket.id, e)) | |
535 | |
536 fragment = cnum and '#comment:'+cnum or '' | |
537 req.redirect(req.href.ticket(ticket.id) + fragment) | |
538 | |
539 def _insert_ticket_data(self, req, db, ticket, reporter_id): | |
540 """Insert ticket data into the hdf""" | |
541 replyto = req.args.get('replyto') | |
542 req.hdf['title'] = '#%d (%s)' % (ticket.id, ticket['summary']) | |
543 req.hdf['ticket'] = ticket.values | |
544 req.hdf['ticket'] = { | |
545 'id': ticket.id, | |
546 'href': req.href.ticket(ticket.id), | |
547 'replyto': replyto | |
548 } | |
549 | |
550 # -- Ticket fields | |
551 | |
552 for field in TicketSystem(self.env).get_ticket_fields(): | |
553 if field['type'] in ('radio', 'select'): | |
554 value = ticket.values.get(field['name']) | |
555 options = field['options'] | |
556 if value and not value in options: | |
557 # Current ticket value must be visible even if its not in the | |
558 # possible values | |
559 options.append(value) | |
560 field['options'] = options | |
561 name = field['name'] | |
562 del field['name'] | |
563 if name in ('summary', 'reporter', 'description', 'type', 'status', | |
564 'resolution', 'owner'): | |
565 field['skip'] = True | |
566 req.hdf['ticket.fields.' + name] = field | |
567 | |
568 req.hdf['ticket.reporter_id'] = reporter_id | |
569 req.hdf['ticket.description.formatted'] = wiki_to_html( | |
570 ticket['description'], self.env, req, db) | |
571 | |
572 req.hdf['ticket.opened'] = format_datetime(ticket.time_created) | |
573 req.hdf['ticket.opened_delta'] = pretty_timedelta(ticket.time_created) | |
574 if ticket.time_changed != ticket.time_created: | |
575 req.hdf['ticket'] = { | |
576 'lastmod': format_datetime(ticket.time_changed), | |
577 'lastmod_delta': pretty_timedelta(ticket.time_changed) | |
578 } | |
579 | |
580 # -- Ticket Change History | |
581 | |
582 def quote_original(author, original, link): | |
583 if not 'comment' in req.args: # i.e. the comment was not yet edited | |
584 req.hdf['ticket.comment'] = '\n'.join( | |
585 ['Replying to [%s %s]:' % (link, author)] + | |
586 ['> %s' % line for line in original.splitlines()] + ['']) | |
587 | |
588 if replyto == 'description': | |
589 quote_original(ticket['reporter'], ticket['description'], | |
590 'ticket:%d' % ticket.id) | |
591 replies = {} | |
592 changes = [] | |
593 cnum = 0 | |
594 for change in self.grouped_changelog_entries(ticket, db): | |
595 changes.append(change) | |
596 # wikify comment | |
597 comment = '' | |
598 if 'comment' in change: | |
599 comment = change['comment'] | |
600 change['comment'] = wiki_to_html(comment, self.env, req, db) | |
601 if change['permanent']: | |
602 cnum = change['cnum'] | |
603 # keep track of replies threading | |
604 if 'replyto' in change: | |
605 replies.setdefault(change['replyto'], []).append(cnum) | |
606 # eventually cite the replied to comment | |
607 if replyto == str(cnum): | |
608 quote_original(change['author'], comment, | |
609 'comment:%s' % replyto) | |
610 if 'description' in change['fields']: | |
611 change['fields']['description'] = '' | |
612 req.hdf['ticket'] = { | |
613 'changes': changes, | |
614 'replies': replies, | |
615 'cnum': cnum + 1 | |
616 } | |
617 | |
618 # -- Ticket Attachments | |
619 | |
620 req.hdf['ticket.attachments'] = attachments_to_hdf(self.env, req, db, | |
621 'ticket', ticket.id) | |
622 if req.perm.has_permission('TICKET_APPEND'): | |
623 req.hdf['ticket.attach_href'] = req.href.attachment('ticket', | |
624 ticket.id) | |
625 | |
626 # Add the possible actions to hdf | |
627 actions = TicketSystem(self.env).get_available_actions(ticket, req.perm) | |
628 for action in actions: | |
629 req.hdf['ticket.actions.' + action] = '1' | |
630 | |
631 def grouped_changelog_entries(self, ticket, db, when=0): | |
632 """Iterate on changelog entries, consolidating related changes | |
633 in a `dict` object. | |
634 """ | |
635 changelog = ticket.get_changelog(when=when, db=db) | |
636 autonum = 0 # used for "root" numbers | |
637 last_uid = current = None | |
638 for date, author, field, old, new, permanent in changelog: | |
639 uid = date, author, permanent | |
640 if uid != last_uid: | |
641 if current: | |
642 yield current | |
643 last_uid = uid | |
644 current = { | |
645 'http_date': http_date(date), | |
646 'date': format_datetime(date), | |
647 'author': author, | |
648 'fields': {}, | |
649 'permanent': permanent | |
650 } | |
651 if permanent and not when: | |
652 autonum += 1 | |
653 current['cnum'] = autonum | |
654 # some common processing for fields | |
655 if field == 'comment': | |
656 current['comment'] = new | |
657 if old: | |
658 if '.' in old: # retrieve parent.child relationship | |
659 parent_num, this_num = old.split('.', 1) | |
660 current['replyto'] = parent_num | |
661 else: | |
662 this_num = old | |
663 current['cnum'] = int(this_num) | |
664 else: | |
665 current['fields'][field] = {'old': old, 'new': new} | |
666 if current: | |
667 yield current |