diff 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
line wrap: on
line diff
new file mode 100644
--- /dev/null
+++ b/examples/trac/trac/ticket/web_ui.py
@@ -0,0 +1,667 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2003-2006 Edgewall Software
+# Copyright (C) 2003-2005 Jonas Borgström <jonas@edgewall.com>
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+#
+# Author: Jonas Borgström <jonas@edgewall.com>
+
+import os
+import re
+import time
+from StringIO import StringIO
+
+from trac.attachment import attachments_to_hdf, Attachment, AttachmentModule
+from trac.config import BoolOption, Option
+from trac.core import *
+from trac.env import IEnvironmentSetupParticipant
+from trac.ticket import Milestone, Ticket, TicketSystem, ITicketManipulator
+from trac.ticket.notification import TicketNotifyEmail
+from trac.Timeline import ITimelineEventProvider
+from trac.util import get_reporter_id
+from trac.util.datefmt import format_datetime, pretty_timedelta, http_date
+from trac.util.text import CRLF
+from trac.util.markup import html, Markup
+from trac.web import IRequestHandler
+from trac.web.chrome import add_link, add_stylesheet, INavigationContributor
+from trac.wiki import wiki_to_html, wiki_to_oneliner
+from trac.mimeview.api import Mimeview, IContentConverter
+
+
+class InvalidTicket(TracError):
+    """Exception raised when a ticket fails validation."""
+
+
+class TicketModuleBase(Component):
+    # FIXME: temporary place-holder for unified ticket validation until
+    #        ticket controller unification is merged
+    abstract = True
+
+    ticket_manipulators = ExtensionPoint(ITicketManipulator)
+
+    def _validate_ticket(self, req, ticket):
+        for manipulator in self.ticket_manipulators:
+            for field, message in manipulator.validate_ticket(req, ticket):
+                if field:
+                    raise InvalidTicket("The ticket %s field is invalid: %s" %
+                                        (field, message))
+                else:
+                    raise InvalidTicket("Invalid ticket: %s" % message)
+
+
+class NewticketModule(TicketModuleBase):
+
+    implements(IEnvironmentSetupParticipant, INavigationContributor,
+               IRequestHandler)
+
+    # IEnvironmentSetupParticipant methods
+
+    def environment_created(self):
+        """Create the `site_newticket.cs` template file in the environment."""
+        if self.env.path:
+            templates_dir = os.path.join(self.env.path, 'templates')
+            if not os.path.exists(templates_dir):
+                os.mkdir(templates_dir)
+            template_name = os.path.join(templates_dir, 'site_newticket.cs')
+            template_file = file(template_name, 'w')
+            template_file.write("""<?cs
+####################################################################
+# New ticket prelude - Included directly above the new ticket form
+?>
+""")
+
+    def environment_needs_upgrade(self, db):
+        return False
+
+    def upgrade_environment(self, db):
+        pass
+
+    # INavigationContributor methods
+
+    def get_active_navigation_item(self, req):
+        return 'newticket'
+
+    def get_navigation_items(self, req):
+        if not req.perm.has_permission('TICKET_CREATE'):
+            return
+        yield ('mainnav', 'newticket', 
+               html.A('New Ticket', href=req.href.newticket(), accesskey=7))
+
+    # IRequestHandler methods
+
+    def match_request(self, req):
+        return re.match(r'/newticket/?', req.path_info) is not None
+
+    def process_request(self, req):
+        req.perm.assert_permission('TICKET_CREATE')
+
+        db = self.env.get_db_cnx()
+
+        if req.method == 'POST' and not req.args.has_key('preview'):
+            self._do_create(req, db)
+
+        ticket = Ticket(self.env, db=db)
+        ticket.populate(req.args)
+        ticket.values['reporter'] = get_reporter_id(req, 'reporter')
+
+        if ticket.values.has_key('description'):
+            description = wiki_to_html(ticket['description'], self.env, req, db)
+            req.hdf['newticket.description_preview'] = description
+
+        req.hdf['title'] = 'New Ticket'
+        req.hdf['newticket'] = ticket.values
+
+        field_names = [field['name'] for field in ticket.fields
+                       if not field.get('custom')]
+        if 'owner' in field_names:
+            curr_idx = field_names.index('owner')
+            if 'cc' in field_names:
+                insert_idx = field_names.index('cc')
+            else:
+                insert_idx = len(field_names)
+            if curr_idx < insert_idx:
+                ticket.fields.insert(insert_idx, ticket.fields[curr_idx])
+                del ticket.fields[curr_idx]
+
+        for field in ticket.fields:
+            name = field['name']
+            del field['name']
+            if name in ('summary', 'reporter', 'description', 'type', 'status',
+                        'resolution'):
+                field['skip'] = True
+            elif name == 'owner':
+                field['label'] = 'Assign to'
+            elif name == 'milestone':
+                # Don't make completed milestones available for selection
+                options = field['options'][:]
+                for option in field['options']:
+                    milestone = Milestone(self.env, option, db=db)
+                    if milestone.is_completed:
+                        options.remove(option)
+                field['options'] = options
+            req.hdf['newticket.fields.' + name] = field
+
+        if req.perm.has_permission('TICKET_APPEND'):
+            req.hdf['newticket.can_attach'] = True
+            req.hdf['newticket.attachment'] = req.args.get('attachment')
+
+        add_stylesheet(req, 'common/css/ticket.css')
+        return 'newticket.cs', None
+
+    # Internal methods
+
+    def _do_create(self, req, db):
+        if not req.args.get('summary'):
+            raise TracError('Tickets must contain a summary.')
+
+        ticket = Ticket(self.env, db=db)
+        ticket.populate(req.args)
+        ticket.values['reporter'] = get_reporter_id(req, 'reporter')
+        self._validate_ticket(req, ticket)
+
+        ticket.insert(db=db)
+        db.commit()
+
+        # Notify
+        try:
+            tn = TicketNotifyEmail(self.env)
+            tn.notify(ticket, req, newticket=True)
+        except Exception, e:
+            self.log.exception("Failure sending notification on creation of "
+                               "ticket #%s: %s" % (ticket.id, e))
+
+        # Redirect the user to the newly created ticket
+        if req.args.get('attachment'):
+            req.redirect(req.href.attachment('ticket', ticket.id, action='new'))
+        else:
+            req.redirect(req.href.ticket(ticket.id))
+
+
+class TicketModule(TicketModuleBase):
+
+    implements(INavigationContributor, IRequestHandler, ITimelineEventProvider,
+               IContentConverter)
+
+    default_version = Option('ticket', 'default_version', '',
+        """Default version for newly created tickets.""")
+
+    default_type = Option('ticket', 'default_type', 'defect',
+        """Default type for newly created tickets (''since 0.9'').""")
+
+    default_priority = Option('ticket', 'default_priority', 'major',
+        """Default priority for newly created tickets.""")
+
+    default_milestone = Option('ticket', 'default_milestone', '',
+        """Default milestone for newly created tickets.""")
+
+    default_component = Option('ticket', 'default_component', '',
+        """Default component for newly created tickets""")
+
+    timeline_details = BoolOption('timeline', 'ticket_show_details', 'false',
+        """Enable the display of all ticket changes in the timeline
+        (''since 0.9'').""")
+
+    # IContentConverter methods
+
+    def get_supported_conversions(self):
+        yield ('csv', 'Comma-delimited Text', 'csv',
+               'trac.ticket.Ticket', 'text/csv', 8)
+        yield ('tab', 'Tab-delimited Text', 'tsv',
+               'trac.ticket.Ticket', 'text/tab-separated-values', 8)
+        yield ('rss', 'RSS Feed', 'xml',
+               'trac.ticket.Ticket', 'application/rss+xml', 8)
+
+    def convert_content(self, req, mimetype, ticket, key):
+        if key == 'csv':
+            return self.export_csv(ticket, mimetype='text/csv')
+        elif key == 'tab':
+            return self.export_csv(ticket, sep='\t',
+                                   mimetype='text/tab-separated-values')
+        elif key == 'rss':
+            return self.export_rss(req, ticket)
+
+    # INavigationContributor methods
+
+    def get_active_navigation_item(self, req):
+        return 'tickets'
+
+    def get_navigation_items(self, req):
+        return []
+
+    # IRequestHandler methods
+
+    def match_request(self, req):
+        match = re.match(r'/ticket/([0-9]+)', req.path_info)
+        if match:
+            req.args['id'] = match.group(1)
+            return True
+
+    def process_request(self, req):
+        req.perm.assert_permission('TICKET_VIEW')
+
+        action = req.args.get('action', 'view')
+
+        db = self.env.get_db_cnx()
+        id = int(req.args.get('id'))
+
+        ticket = Ticket(self.env, id, db=db)
+
+        if req.method == 'POST':
+            if not req.args.has_key('preview'):
+                self._do_save(req, db, ticket)
+            else:
+                # Use user supplied values
+                ticket.populate(req.args)
+                self._validate_ticket(req, ticket)
+
+                req.hdf['ticket.action'] = action
+                req.hdf['ticket.ts'] = req.args.get('ts')
+                req.hdf['ticket.reassign_owner'] = req.args.get('reassign_owner') \
+                                                   or req.authname
+                req.hdf['ticket.resolve_resolution'] = req.args.get('resolve_resolution')
+                comment = req.args.get('comment')
+                if comment:
+                    req.hdf['ticket.comment'] = comment
+                    # Wiki format a preview of comment
+                    req.hdf['ticket.comment_preview'] = wiki_to_html(
+                        comment, self.env, req, db)
+        else:
+            req.hdf['ticket.reassign_owner'] = req.authname
+            # Store a timestamp in order to detect "mid air collisions"
+            req.hdf['ticket.ts'] = ticket.time_changed
+
+        self._insert_ticket_data(req, db, ticket,
+                                 get_reporter_id(req, 'author'))
+
+        mime = Mimeview(self.env)
+        format = req.args.get('format')
+        if format:
+            mime.send_converted(req, 'trac.ticket.Ticket', ticket, format,
+                                'ticket_%d' % ticket.id)
+
+        # If the ticket is being shown in the context of a query, add
+        # links to help navigate in the query result set
+        if 'query_tickets' in req.session:
+            tickets = req.session['query_tickets'].split()
+            if str(id) in tickets:
+                idx = tickets.index(str(ticket.id))
+                if idx > 0:
+                    add_link(req, 'first', req.href.ticket(tickets[0]),
+                             'Ticket #%s' % tickets[0])
+                    add_link(req, 'prev', req.href.ticket(tickets[idx - 1]),
+                             'Ticket #%s' % tickets[idx - 1])
+                if idx < len(tickets) - 1:
+                    add_link(req, 'next', req.href.ticket(tickets[idx + 1]),
+                             'Ticket #%s' % tickets[idx + 1])
+                    add_link(req, 'last', req.href.ticket(tickets[-1]),
+                             'Ticket #%s' % tickets[-1])
+                add_link(req, 'up', req.session['query_href'])
+
+        add_stylesheet(req, 'common/css/ticket.css')
+
+        # Add registered converters
+        for conversion in mime.get_supported_conversions('trac.ticket.Ticket'):
+            conversion_href = req.href.ticket(ticket.id, format=conversion[0])
+            add_link(req, 'alternate', conversion_href, conversion[1],
+                     conversion[3])
+
+        return 'ticket.cs', None
+
+    # ITimelineEventProvider methods
+
+    def get_timeline_filters(self, req):
+        if req.perm.has_permission('TICKET_VIEW'):
+            yield ('ticket', 'Ticket changes')
+            if self.timeline_details:
+                yield ('ticket_details', 'Ticket details', False)
+
+    def get_timeline_events(self, req, start, stop, filters):
+        format = req.args.get('format')
+
+        status_map = {'new': ('newticket', 'created'),
+                      'reopened': ('newticket', 'reopened'),
+                      'closed': ('closedticket', 'closed'),
+                      'edit': ('editedticket', 'updated')}
+
+        href = format == 'rss' and req.abs_href or req.href
+
+        def produce((id, t, author, type, summary), status, fields,
+                    comment, cid):
+            if status == 'edit':
+                if 'ticket_details' in filters:
+                    info = ''
+                    if len(fields) > 0:
+                        info = ', '.join(['<i>%s</i>' % f for f in \
+                                          fields.keys()]) + ' changed<br />'
+                else:
+                    return None
+            elif 'ticket' in filters:
+                if status == 'closed' and fields.has_key('resolution'):
+                    info = fields['resolution']
+                    if info and comment:
+                        info = '%s: ' % info
+                else:
+                    info = ''
+            else:
+                return None
+            kind, verb = status_map[status]
+            if format == 'rss':
+                title = 'Ticket #%s (%s %s): %s' % \
+                        (id, type.lower(), verb, summary)
+            else:
+                title = Markup('Ticket <em title="%s">#%s</em> (%s) %s by %s',
+                               summary, id, type, verb, author)
+            ticket_href = href.ticket(id)
+            if cid:
+                ticket_href += '#comment:' + cid
+            if status == 'new':
+                message = summary
+            else:
+                message = Markup(info)
+                if comment:
+                    if format == 'rss':
+                        message += wiki_to_html(comment, self.env, req, db,
+                                                absurls=True)
+                    else:
+                        message += wiki_to_oneliner(comment, self.env, db,
+                                                    shorten=True)
+            return kind, ticket_href, title, t, author, message
+
+        # Ticket changes
+        if 'ticket' in filters or 'ticket_details' in filters:
+            db = self.env.get_db_cnx()
+            cursor = db.cursor()
+
+            cursor.execute("SELECT t.id,tc.time,tc.author,t.type,t.summary, "
+                           "       tc.field,tc.oldvalue,tc.newvalue "
+                           "  FROM ticket_change tc "
+                           "    INNER JOIN ticket t ON t.id = tc.ticket "
+                           "      AND tc.time>=%s AND tc.time<=%s "
+                           "ORDER BY tc.time"
+                           % (start, stop))
+            previous_update = None
+            for id,t,author,type,summary,field,oldvalue,newvalue in cursor:
+                if not previous_update or (id,t,author) != previous_update[:3]:
+                    if previous_update:
+                        ev = produce(previous_update, status, fields,
+                                     comment, cid)
+                        if ev:
+                            yield ev
+                    status, fields, comment, cid = 'edit', {}, '', None
+                    previous_update = (id, t, author, type, summary)
+                if field == 'comment':
+                    comment = newvalue
+                    cid = oldvalue and oldvalue.split('.')[-1]
+                elif field == 'status' and newvalue in ('reopened', 'closed'):
+                    status = newvalue
+                else:
+                    fields[field] = newvalue
+            if previous_update:
+                ev = produce(previous_update, status, fields, comment, cid)
+                if ev:
+                    yield ev
+            
+            # New tickets
+            if 'ticket' in filters:
+                cursor.execute("SELECT id,time,reporter,type,summary"
+                               "  FROM ticket WHERE time>=%s AND time<=%s",
+                               (start, stop))
+                for row in cursor:
+                    yield produce(row, 'new', {}, None, None)
+
+            # Attachments
+            if 'ticket_details' in filters:
+                def display(id):
+                    return Markup('ticket %s', html.EM('#', id))
+                att = AttachmentModule(self.env)
+                for event in att.get_timeline_events(req, db, 'ticket',
+                                                     format, start, stop,
+                                                     display):
+                    yield event
+
+    # Internal methods
+
+    def export_csv(self, ticket, sep=',', mimetype='text/plain'):
+        content = StringIO()
+        content.write(sep.join(['id'] + [f['name'] for f in ticket.fields])
+                      + CRLF)
+        content.write(sep.join([unicode(ticket.id)] +
+                                [ticket.values.get(f['name'], '')
+                                 .replace(sep, '_').replace('\\', '\\\\')
+                                 .replace('\n', '\\n').replace('\r', '\\r')
+                                 for f in ticket.fields]) + CRLF)
+        return (content.getvalue(), '%s;charset=utf-8' % mimetype)
+        
+    def export_rss(self, req, ticket):
+        db = self.env.get_db_cnx()
+        changes = []
+        change_summary = {}
+
+        description = wiki_to_html(ticket['description'], self.env, req, db)
+        req.hdf['ticket.description.formatted'] = unicode(description)
+
+        for change in self.grouped_changelog_entries(ticket, db):
+            changes.append(change)
+            # compute a change summary
+            change_summary = {}
+            # wikify comment
+            if 'comment' in change:
+                comment = change['comment']
+                change['comment'] = unicode(wiki_to_html(
+                    comment, self.env, req, db, absurls=True))
+                change_summary['added'] = ['comment']
+            for field, values in change['fields'].iteritems():
+                if field == 'description':
+                    change_summary.setdefault('changed', []).append(field)
+                else:
+                    chg = 'changed'
+                    if not values['old']:
+                        chg = 'set'
+                    elif not values['new']:
+                        chg = 'deleted'
+                    change_summary.setdefault(chg, []).append(field)
+            change['title'] = '; '.join(['%s %s' % (', '.join(v), k) for k, v \
+                                         in change_summary.iteritems()])
+        req.hdf['ticket.changes'] = changes
+        return (req.hdf.render('ticket_rss.cs'), 'application/rss+xml')
+
+
+    def _do_save(self, req, db, ticket):
+        if req.perm.has_permission('TICKET_CHGPROP'):
+            # TICKET_CHGPROP gives permission to edit the ticket
+            if not req.args.get('summary'):
+                raise TracError('Tickets must contain summary.')
+
+            if req.args.has_key('description') or req.args.has_key('reporter'):
+                req.perm.assert_permission('TICKET_ADMIN')
+
+            ticket.populate(req.args)
+        else:
+            req.perm.assert_permission('TICKET_APPEND')
+
+        # Mid air collision?
+        if int(req.args.get('ts')) != ticket.time_changed:
+            raise TracError("Sorry, can not save your changes. "
+                            "This ticket has been modified by someone else "
+                            "since you started", 'Mid Air Collision')
+
+        self._validate_ticket(req, ticket)
+
+        # Do any action on the ticket?
+        action = req.args.get('action')
+        actions = TicketSystem(self.env).get_available_actions(ticket, req.perm)
+        if action not in actions:
+            raise TracError('Invalid action')
+
+        # TODO: this should not be hard-coded like this
+        if action == 'accept':
+            ticket['status'] =  'assigned'
+            ticket['owner'] = req.authname
+        if action == 'resolve':
+            ticket['status'] = 'closed'
+            ticket['resolution'] = req.args.get('resolve_resolution')
+        elif action == 'reassign':
+            ticket['owner'] = req.args.get('reassign_owner')
+            ticket['status'] = 'new'
+        elif action == 'reopen':
+            ticket['status'] = 'reopened'
+            ticket['resolution'] = ''
+
+        now = int(time.time())
+        cnum = req.args.get('cnum')        
+        replyto = req.args.get('replyto')
+        internal_cnum = cnum
+        if cnum and replyto: # record parent.child relationship
+            internal_cnum = '%s.%s' % (replyto, cnum)
+        ticket.save_changes(get_reporter_id(req, 'author'),
+                            req.args.get('comment'), when=now, db=db,
+                            cnum=internal_cnum)
+        db.commit()
+
+        try:
+            tn = TicketNotifyEmail(self.env)
+            tn.notify(ticket, req, newticket=False, modtime=now)
+        except Exception, e:
+            self.log.exception("Failure sending notification on change to "
+                               "ticket #%s: %s" % (ticket.id, e))
+
+        fragment = cnum and '#comment:'+cnum or ''
+        req.redirect(req.href.ticket(ticket.id) + fragment)
+
+    def _insert_ticket_data(self, req, db, ticket, reporter_id):
+        """Insert ticket data into the hdf"""
+        replyto = req.args.get('replyto')
+        req.hdf['title'] = '#%d (%s)' % (ticket.id, ticket['summary'])
+        req.hdf['ticket'] = ticket.values
+        req.hdf['ticket'] = {
+            'id': ticket.id,
+            'href': req.href.ticket(ticket.id),
+            'replyto': replyto
+            }
+
+        # -- Ticket fields
+        
+        for field in TicketSystem(self.env).get_ticket_fields():
+            if field['type'] in ('radio', 'select'):
+                value = ticket.values.get(field['name'])
+                options = field['options']
+                if value and not value in options:
+                    # Current ticket value must be visible even if its not in the
+                    # possible values
+                    options.append(value)
+                field['options'] = options
+            name = field['name']
+            del field['name']
+            if name in ('summary', 'reporter', 'description', 'type', 'status',
+                        'resolution', 'owner'):
+                field['skip'] = True
+            req.hdf['ticket.fields.' + name] = field
+
+        req.hdf['ticket.reporter_id'] = reporter_id
+        req.hdf['ticket.description.formatted'] = wiki_to_html(
+            ticket['description'], self.env, req, db)
+
+        req.hdf['ticket.opened'] = format_datetime(ticket.time_created)
+        req.hdf['ticket.opened_delta'] = pretty_timedelta(ticket.time_created)
+        if ticket.time_changed != ticket.time_created:
+            req.hdf['ticket'] = {
+                'lastmod': format_datetime(ticket.time_changed),
+                'lastmod_delta': pretty_timedelta(ticket.time_changed)
+                }
+
+        # -- Ticket Change History
+
+        def quote_original(author, original, link):
+            if not 'comment' in req.args: # i.e. the comment was not yet edited
+                req.hdf['ticket.comment'] = '\n'.join(
+                    ['Replying to [%s %s]:' % (link, author)] +
+                    ['> %s' % line for line in original.splitlines()] + [''])
+
+        if replyto == 'description':
+            quote_original(ticket['reporter'], ticket['description'],
+                           'ticket:%d' % ticket.id)
+        replies = {}
+        changes = []
+        cnum = 0
+        for change in self.grouped_changelog_entries(ticket, db):
+            changes.append(change)
+            # wikify comment
+            comment = ''
+            if 'comment' in change:
+                comment = change['comment']
+                change['comment'] = wiki_to_html(comment, self.env, req, db)
+            if change['permanent']:
+                cnum = change['cnum']
+                # keep track of replies threading
+                if 'replyto' in change:
+                    replies.setdefault(change['replyto'], []).append(cnum)
+                # eventually cite the replied to comment
+                if replyto == str(cnum):
+                    quote_original(change['author'], comment,
+                                   'comment:%s' % replyto)
+            if 'description' in change['fields']:
+                change['fields']['description'] = ''
+        req.hdf['ticket'] = {
+            'changes': changes,
+            'replies': replies,
+            'cnum': cnum + 1
+           }
+
+        # -- Ticket Attachments
+
+        req.hdf['ticket.attachments'] = attachments_to_hdf(self.env, req, db,
+                                                           'ticket', ticket.id)
+        if req.perm.has_permission('TICKET_APPEND'):
+            req.hdf['ticket.attach_href'] = req.href.attachment('ticket',
+                                                                ticket.id)
+
+        # Add the possible actions to hdf
+        actions = TicketSystem(self.env).get_available_actions(ticket, req.perm)
+        for action in actions:
+            req.hdf['ticket.actions.' + action] = '1'
+
+    def grouped_changelog_entries(self, ticket, db, when=0):
+        """Iterate on changelog entries, consolidating related changes
+        in a `dict` object.
+        """
+        changelog = ticket.get_changelog(when=when, db=db)
+        autonum = 0 # used for "root" numbers
+        last_uid = current = None
+        for date, author, field, old, new, permanent in changelog:
+            uid = date, author, permanent
+            if uid != last_uid:
+                if current:
+                    yield current
+                last_uid = uid
+                current = {
+                    'http_date': http_date(date),
+                    'date': format_datetime(date),
+                    'author': author,
+                    'fields': {},
+                    'permanent': permanent
+                }
+                if permanent and not when:
+                    autonum += 1
+                    current['cnum'] = autonum
+            # some common processing for fields
+            if field == 'comment':
+                current['comment'] = new
+                if old:
+                    if '.' in old: # retrieve parent.child relationship
+                        parent_num, this_num = old.split('.', 1)
+                        current['replyto'] = parent_num
+                    else:
+                        this_num = old
+                    current['cnum'] = int(this_num)
+            else:
+                current['fields'][field] = {'old': old, 'new': new}
+        if current:
+            yield current
Copyright (C) 2012-2017 Edgewall Software