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