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
Copyright (C) 2012-2017 Edgewall Software