class Ticket(object): realm = 'ticket' # Fields that must not be modified directly by the user # 'owner' should eventually be a protected field (#2045) protected_fields = 'resolution', 'status', 'time', 'changetime' @staticmethod def id_is_valid(num): return 0 < int(num) <= 1L << 31 @property def resource(self): return Resource(self.realm, self.id, self.version) # 0.11 compatibility. Will be removed in 1.3.1. time_created = property(lambda self: self.values.get('time')) time_changed = property(lambda self: self.values.get('changetime')) def __init__(self, env, tkt_id=None, version=None): self.env = env self.fields = TicketSystem(self.env).get_ticket_fields() self.editable_fields = \ set(f['name'] for f in self.fields if f['name'] not in self.protected_fields) self.std_fields, self.custom_fields, self.time_fields = [], [], [] for f in self.fields: if f.get('custom'): self.custom_fields.append(f['name']) else: self.std_fields.append(f['name']) if f['type'] == 'time': self.time_fields.append(f['name']) self.values = {} self._old = {} if tkt_id is not None: tkt_id = int(tkt_id) self._fetch_ticket(tkt_id) else: self._init_defaults() self.id = None self.version = version def __repr__(self): return '<%s %r>' % (self.__class__.__name__, self.id) exists = property(lambda self: self.id is not None) def _init_defaults(self): for field in self.fields: default = None if field['name'] in self.protected_fields: # Ignore for new - only change through workflow pass elif not field.get('custom'): default = self.env.config.get('ticket', 'default_' + field['name']) else: default = self._custom_field_default(field) if default: self.values.setdefault(field['name'], default) def _custom_field_default(self, field): default = field.get('value') options = field.get('options') if default and options and default not in options: try: default = options[int(default)] except (ValueError, IndexError): self.env.log.warning( 'Invalid default value "%s" ' 'for custom field "%s"', default, field['name']) if default and field.get('type') == 'time': try: default = parse_date(default, hint=field.get('format')) except TracError as e: self.env.log.warning( 'Invalid default value "%s" ' 'for custom field "%s": %s', default, field['name'], e) default = None return default def _fetch_ticket(self, tkt_id): row = None if self.id_is_valid(tkt_id): # Fetch the standard ticket fields for row in self.env.db_query( "SELECT %s FROM ticket WHERE id=%%s" % ','.join(self.std_fields), (tkt_id, )): break if not row: raise ResourceNotFound( _("Ticket %(id)s does not exist.", id=tkt_id), _("Invalid ticket number")) self.id = tkt_id for i, field in enumerate(self.std_fields): value = row[i] if field in self.time_fields: self.values[field] = from_utimestamp(value) elif value is None: self.values[field] = empty else: self.values[field] = value # Fetch custom fields if available for name, value in self.env.db_query( """ SELECT name, value FROM ticket_custom WHERE ticket=%s """, (tkt_id, )): if name in self.custom_fields: if name in self.time_fields: self.values[name] = _db_str_to_datetime(value) elif value is None: self.values[name] = empty else: self.values[name] = value # Set defaults for custom fields that haven't been fetched. for field in self.fields: name = field['name'] if field.get('custom') and name not in self.values: default = self._custom_field_default(field) if default: self[name] = default def __getitem__(self, name): return self.values.get(name) def __setitem__(self, name, value): """Log ticket modifications so the table ticket_change can be updated """ if value and name not in self.time_fields: if isinstance(value, list): raise TracError(_("Multi-values fields not supported yet")) if self.fields.by_name(name, {}).get('type') != 'textarea': value = value.strip() if name in self.values and self.values[name] == value: return if name not in self._old: # Changed field self._old[name] = self.values.get(name) elif self._old[name] == value: # Change of field reverted del self._old[name] self.values[name] = value def __contains__(self, item): return item in self.values def get_value_or_default(self, name): """Return the value of a field or the default value if it is undefined """ try: value = self.values[name] return value if value is not empty else self.get_default(name) except KeyError: pass def get_default(self, name): """Return the default value of a field.""" return self.fields.by_name(name, {}).get('value', '') def populate(self, values): """Populate the ticket with 'suitable' values from a dictionary""" field_names = [f['name'] for f in self.fields] for name in [name for name in values.keys() if name in field_names]: self[name] = values[name] # We have to do an extra trick to catch unchecked checkboxes for name in [ name for name in values.keys() if name[9:] in field_names and name.startswith('checkbox_') ]: if name[9:] not in values: self[name[9:]] = '0' def insert(self, when=None): """Add ticket to database. """ assert not self.exists, 'Cannot insert an existing ticket' if 'cc' in self.values: self['cc'] = _fixup_cc_list(self.values['cc']) # Add a timestamp if when is None: when = datetime.now(utc) self.values['time'] = self.values['changetime'] = when # Perform type conversions db_values = self._to_db_types(self.values) # Insert ticket record std_fields = [] custom_fields = [] for f in self.fields: fname = f['name'] if fname in self.values: if f.get('custom'): custom_fields.append(fname) else: std_fields.append(fname) with self.env.db_transaction as db: cursor = db.cursor() cursor.execute( "INSERT INTO ticket (%s) VALUES (%s)" % (','.join(std_fields), ','.join(['%s'] * len(std_fields))), [db_values.get(name) for name in std_fields]) tkt_id = db.get_last_id(cursor, 'ticket') # Insert custom fields if custom_fields: db.executemany( """INSERT INTO ticket_custom (ticket, name, value) VALUES (%s, %s, %s) """, [(tkt_id, c, db_values.get(c)) for c in custom_fields]) self.id = int(tkt_id) self._old = {} for listener in TicketSystem(self.env).change_listeners: listener.ticket_created(self) return self.id def get_comment_number(self, cdate): """Return a comment number by its date.""" ts = to_utimestamp(cdate) for cnum, in self.env.db_query( """\ SELECT oldvalue FROM ticket_change WHERE ticket=%s AND time=%s AND field='comment' """, (self.id, ts)): try: return int(cnum.rsplit('.', 1)[-1]) except ValueError: break def save_changes(self, author=None, comment=None, when=None, cnum='', replyto=None): """ Store ticket changes in the database. The ticket must already exist in the database. Returns False if there were no changes to save, True otherwise. :since 1.0: the `cnum` parameter is deprecated, and threading should be controlled with the `replyto` argument """ assert self.exists, "Cannot update a new ticket" if 'cc' in self.values: self['cc'] = _fixup_cc_list(self.values['cc']) props_unchanged = all( self.values.get(k) == v for k, v in self._old.iteritems()) if (not comment or not comment.strip()) and props_unchanged: return False # Not modified if when is None: when = datetime.now(utc) when_ts = to_utimestamp(when) # Perform type conversions db_values = self._to_db_types(self.values) old_db_values = self._to_db_types(self._old) with self.env.db_transaction as db: db("UPDATE ticket SET changetime=%s WHERE id=%s", (when_ts, self.id)) # find cnum if it isn't provided if not cnum: num = 0 for ts, old in db( """ SELECT DISTINCT tc1.time, COALESCE(tc2.oldvalue,'') FROM ticket_change AS tc1 LEFT OUTER JOIN ticket_change AS tc2 ON tc2.ticket=%s AND tc2.time=tc1.time AND tc2.field='comment' WHERE tc1.ticket=%s ORDER BY tc1.time DESC """, (self.id, self.id)): # Use oldvalue if available, else count edits try: num += int(old.rsplit('.', 1)[-1]) break except ValueError: num += 1 cnum = str(num + 1) if replyto: cnum = '%s.%s' % (replyto, cnum) # store fields for name in self._old.keys(): if name in self.custom_fields: for row in db( """SELECT * FROM ticket_custom WHERE ticket=%s and name=%s """, (self.id, name)): db( """UPDATE ticket_custom SET value=%s WHERE ticket=%s AND name=%s """, (db_values.get(name), self.id, name)) break else: db( """INSERT INTO ticket_custom (ticket,name,value) VALUES(%s,%s,%s) """, (self.id, name, db_values.get(name))) else: db("UPDATE ticket SET %s=%%s WHERE id=%%s" % name, (db_values.get(name), self.id)) db( """INSERT INTO ticket_change (ticket,time,author,field,oldvalue,newvalue) VALUES (%s, %s, %s, %s, %s, %s) """, (self.id, when_ts, author, name, old_db_values.get(name), db_values.get(name))) # always save comment, even if empty # (numbering support for timeline) db( """INSERT INTO ticket_change (ticket,time,author,field,oldvalue,newvalue) VALUES (%s,%s,%s,'comment',%s,%s) """, (self.id, when_ts, author, cnum, comment)) old_values = self._old self._old = {} self.values['changetime'] = when for listener in TicketSystem(self.env).change_listeners: listener.ticket_changed(self, comment, author, old_values) return int(cnum.rsplit('.', 1)[-1]) def _to_db_types(self, values): values = values.copy() for field, value in values.iteritems(): if field in self.time_fields: is_custom_field = field in self.custom_fields values[field] = _datetime_to_db_str(value, is_custom_field) else: values[field] = value if value else None return values def get_changelog(self, when=None): """Return the changelog as a list of tuples of the form (time, author, field, oldvalue, newvalue, permanent). While the other tuple elements are quite self-explanatory, the `permanent` flag is used to distinguish collateral changes that are not yet immutable (like attachments, currently). """ sid = str(self.id) when_ts = to_utimestamp(when) if when_ts: sql = """ SELECT time, author, field, oldvalue, newvalue, 1 AS permanent FROM ticket_change WHERE ticket=%s AND time=%s UNION SELECT time, author, 'attachment', null, filename, 0 AS permanent FROM attachment WHERE type='ticket' AND id=%s AND time=%s UNION SELECT time, author, 'comment', null, description, 0 AS permanent FROM attachment WHERE type='ticket' AND id=%s AND time=%s ORDER BY time,permanent,author """ args = (self.id, when_ts, sid, when_ts, sid, when_ts) else: sql = """ SELECT time, author, field, oldvalue, newvalue, 1 AS permanent FROM ticket_change WHERE ticket=%s UNION SELECT time, author, 'attachment', null, filename, 0 AS permanent FROM attachment WHERE type='ticket' AND id=%s UNION SELECT time, author, 'comment', null, description, 0 AS permanent FROM attachment WHERE type='ticket' AND id=%s ORDER BY time,permanent,author """ args = (self.id, sid, sid) log = [] for t, author, field, oldvalue, newvalue, permanent \ in self.env.db_query(sql, args): if field in self.time_fields: oldvalue = _db_str_to_datetime(oldvalue) newvalue = _db_str_to_datetime(newvalue) log.append((from_utimestamp(t), author, field, oldvalue or '', newvalue or '', permanent)) return log def delete(self): """Delete the ticket. """ with self.env.db_transaction as db: Attachment.delete_all(self.env, self.realm, self.id) db("DELETE FROM ticket WHERE id=%s", (self.id, )) db("DELETE FROM ticket_change WHERE ticket=%s", (self.id, )) db("DELETE FROM ticket_custom WHERE ticket=%s", (self.id, )) for listener in TicketSystem(self.env).change_listeners: listener.ticket_deleted(self) def get_change(self, cnum=None, cdate=None): """Return a ticket change by its number or date. """ if cdate is None: row = self._find_change(cnum) if not row: return cdate = from_utimestamp(row[0]) ts = to_utimestamp(cdate) fields = {} change = {'date': cdate, 'fields': fields} for field, author, old, new in self.env.db_query( """ SELECT field, author, oldvalue, newvalue FROM ticket_change WHERE ticket=%s AND time=%s """, (self.id, ts)): fields[field] = {'author': author, 'old': old, 'new': new} if field == 'comment': change['author'] = author elif not field.startswith('_'): change.setdefault('author', author) if fields: return change def delete_change(self, cnum=None, cdate=None, when=None): """Delete a ticket change identified by its number or date.""" if cdate is None: row = self._find_change(cnum) if not row: return cdate = from_utimestamp(row[0]) ts = to_utimestamp(cdate) if when is None: when = datetime.now(utc) when_ts = to_utimestamp(when) with self.env.db_transaction as db: # Find modified fields and their previous value fields = [(field, old, new) for field, old, new in db( """ SELECT field, oldvalue, newvalue FROM ticket_change WHERE ticket=%s AND time=%s """, (self.id, ts)) if field != 'comment' and not field.startswith('_')] for field, oldvalue, newvalue in fields: # Find the next change for next_ts, in db( """SELECT time FROM ticket_change WHERE ticket=%s AND time>%s AND field=%s LIMIT 1 """, (self.id, ts, field)): # Modify the old value of the next change if it is equal # to the new value of the deleted change db( """UPDATE ticket_change SET oldvalue=%s WHERE ticket=%s AND time=%s AND field=%s AND oldvalue=%s """, (oldvalue, self.id, next_ts, field, newvalue)) break else: # No next change, edit ticket field if field in self.std_fields: db("UPDATE ticket SET %s=%%s WHERE id=%%s" % field, (oldvalue, self.id)) else: db( """UPDATE ticket_custom SET value=%s WHERE ticket=%s AND name=%s """, (oldvalue, self.id, field)) # Delete the change db("DELETE FROM ticket_change WHERE ticket=%s AND time=%s", (self.id, ts)) # Update last changed time db("UPDATE ticket SET changetime=%s WHERE id=%s", (when_ts, self.id)) self._fetch_ticket(self.id) changes = dict((field, (oldvalue, newvalue)) for field, oldvalue, newvalue in fields) for listener in TicketSystem(self.env).change_listeners: if hasattr(listener, 'ticket_change_deleted'): listener.ticket_change_deleted(self, cdate, changes) def modify_comment(self, cdate, author, comment, when=None): """Modify a ticket comment specified by its date, while keeping a history of edits. """ ts = to_utimestamp(cdate) if when is None: when = datetime.now(utc) when_ts = to_utimestamp(when) with self.env.db_transaction as db: # Find the current value of the comment old_comment = False for old_comment, in db( """ SELECT newvalue FROM ticket_change WHERE ticket=%s AND time=%s AND field='comment' """, (self.id, ts)): break if comment == (old_comment or ''): return # Comment history is stored in fields named "_comment%d" # Find the next edit number fields = db( """SELECT field FROM ticket_change WHERE ticket=%%s AND time=%%s AND field %s """ % db.prefix_match(), (self.id, ts, db.prefix_match_value('_comment'))) rev = max(int(field[8:]) for field, in fields) + 1 if fields else 0 db( """INSERT INTO ticket_change (ticket,time,author,field,oldvalue,newvalue) VALUES (%s,%s,%s,%s,%s,%s) """, (self.id, ts, author, '_comment%d' % rev, old_comment or '', str(when_ts))) if old_comment is False: # There was no comment field, add one, find the # original author in one of the other changed fields for old_author, in db( """ SELECT author FROM ticket_change WHERE ticket=%%s AND time=%%s AND NOT field %s LIMIT 1 """ % db.prefix_match(), (self.id, ts, db.prefix_match_value('_'))): db( """INSERT INTO ticket_change (ticket,time,author,field,oldvalue,newvalue) VALUES (%s,%s,%s,'comment','',%s) """, (self.id, ts, old_author, comment)) else: db( """UPDATE ticket_change SET newvalue=%s WHERE ticket=%s AND time=%s AND field='comment' """, (comment, self.id, ts)) # Update last changed time db("UPDATE ticket SET changetime=%s WHERE id=%s", (when_ts, self.id)) self.values['changetime'] = when old_comment = old_comment or '' for listener in TicketSystem(self.env).change_listeners: if hasattr(listener, 'ticket_comment_modified'): listener.ticket_comment_modified(self, cdate, author, comment, old_comment) def get_comment_history(self, cnum=None, cdate=None): """Retrieve the edit history of a comment identified by its number or date. """ if cdate is None: row = self._find_change(cnum) if not row: return ts0, author0, last_comment = row else: ts0, author0, last_comment = to_utimestamp(cdate), None, None with self.env.db_query as db: # Get last comment and author if not available if last_comment is None: last_comment = '' for author0, last_comment in db( """ SELECT author, newvalue FROM ticket_change WHERE ticket=%s AND time=%s AND field='comment' """, (self.id, ts0)): break if author0 is None: for author0, last_comment in db( """ SELECT author, newvalue FROM ticket_change WHERE ticket=%%s AND time=%%s AND NOT field %s LIMIT 1 """ % db.prefix_match(), (self.id, ts0, db.prefix_match_value('_'))): break else: return # Get all fields of the form "_comment%d" rows = db( """SELECT field, author, oldvalue, newvalue FROM ticket_change WHERE ticket=%%s AND time=%%s AND field %s """ % db.prefix_match(), (self.id, ts0, db.prefix_match_value('_comment'))) rows = sorted((int(field[8:]), author, old, new) for field, author, old, new in rows) history = [] for rev, author, comment, ts in rows: history.append( (rev, from_utimestamp(long(ts0)), author0, comment)) ts0, author0 = ts, author history.sort() rev = history[-1][0] + 1 if history else 0 history.append( (rev, from_utimestamp(long(ts0)), author0, last_comment)) return history def _find_change(self, cnum): """Find a comment by its number.""" scnum = str(cnum) with self.env.db_query as db: for row in db( """ SELECT time, author, newvalue FROM ticket_change WHERE ticket=%%s AND field='comment' AND (oldvalue=%%s OR oldvalue %s) """ % db.like(), (self.id, scnum, '%' + db.like_escape('.' + scnum))): return row # Fallback when comment number is not available in oldvalue num = 0 for ts, old, author, comment in db( """ SELECT DISTINCT tc1.time, COALESCE(tc2.oldvalue,''), tc2.author, COALESCE(tc2.newvalue,'') FROM ticket_change AS tc1 LEFT OUTER JOIN ticket_change AS tc2 ON tc2.ticket=%s AND tc2.time=tc1.time AND tc2.field='comment' WHERE tc1.ticket=%s ORDER BY tc1.time """, (self.id, self.id)): # Use oldvalue if available, else count edits try: num = int(old.rsplit('.', 1)[-1]) except ValueError: num += 1 if num == cnum: break else: return # Find author if NULL if author is None: for author, in db( """ SELECT author FROM ticket_change WHERE ticket=%%s AND time=%%s AND NOT field %s LIMIT 1 """ % db.prefix_match(), (self.id, ts, db.prefix_match_value('_'))): break return ts, author, comment
def _render_view(self, req, id): """Retrieve the report results and pre-process them for rendering.""" r = Report(self.env, id) title, description, sql = r.title, r.description, r.query # If this is a saved custom query, redirect to the query module # # A saved query is either an URL query (?... or query:?...), # or a query language expression (query:...). # # It may eventually contain newlines, for increased clarity. # query = ''.join(line.strip() for line in sql.splitlines()) if query and (query[0] == '?' or query.startswith('query:?')): query = query if query[0] == '?' else query[6:] report_id = 'report=%s' % id if 'report=' in query: if report_id not in query: err = _('When specified, the report number should be ' '"%(num)s".', num=id) req.redirect(req.href.report(id, action='edit', error=err)) else: if query[-1] != '?': query += '&' query += report_id req.redirect(req.href.query() + quote_query_string(query)) elif query.startswith('query:'): from trac.ticket.query import Query, QuerySyntaxError try: query = Query.from_string(self.env, query[6:], report=id) except QuerySyntaxError as e: req.redirect(req.href.report(id, action='edit', error=to_unicode(e))) else: req.redirect(query.get_href(req.href)) format = req.args.get('format') if format == 'sql': self._send_sql(req, id, title, description, sql) title = '{%i} %s' % (id, title) report_resource = Resource(self.realm, id) req.perm(report_resource).require('REPORT_VIEW') context = web_context(req, report_resource) page = req.args.getint('page', 1) default_max = {'rss': self.items_per_page_rss, 'csv': 0, 'tab': 0}.get(format, self.items_per_page) max = req.args.getint('max') limit = as_int(max, default_max, min=0) # explict max takes precedence offset = (page - 1) * limit sort_col = req.args.get('sort', '') asc = req.args.getint('asc', 0, min=0, max=1) args = {} def report_href(**kwargs): """Generate links to this report preserving user variables, and sorting and paging variables. """ params = args.copy() if sort_col: params['sort'] = sort_col if page != 1: params['page'] = page if max != default_max: params['max'] = max params.update(kwargs) params['asc'] = 1 if params.get('asc', asc) else None return req.href.report(id, params) data = {'action': 'view', 'report': {'id': id, 'resource': report_resource}, 'context': context, 'title': title, 'description': description, 'max': limit, 'args': args, 'show_args_form': False, 'message': None, 'paginator': None, 'report_href': report_href} try: args = self.get_var_args(req) sql = self.get_default_var_args(args, sql) except ValueError as e: data['message'] = _("Report failed: %(error)s", error=e) return 'report_view.html', data, None data.update({'args': args, 'title': sub_vars(title, args), 'description': sub_vars(description or '', args)}) try: res = self.execute_paginated_report(req, id, sql, args, limit, offset) except TracError as e: data['message'] = _("Report failed: %(error)s", error=e) else: if len(res) == 2: e, sql = res data['message'] = \ tag_("Report execution failed: %(error)s %(sql)s", error=tag.pre(exception_to_unicode(e)), sql=tag(tag.hr(), tag.pre(sql, style="white-space: pre"))) if data['message']: return 'report_view.html', data, None cols, results, num_items, missing_args, limit_offset = res need_paginator = limit > 0 and limit_offset need_reorder = limit_offset is None results = [list(row) for row in results] numrows = len(results) paginator = None if need_paginator: paginator = Paginator(results, page - 1, limit, num_items) data['paginator'] = paginator if paginator.has_next_page: add_link(req, 'next', report_href(page=page + 1), _('Next Page')) if paginator.has_previous_page: add_link(req, 'prev', report_href(page=page - 1), _('Previous Page')) pagedata = [] shown_pages = paginator.get_shown_pages(21) for p in shown_pages: pagedata.append([report_href(page=p), None, str(p), _('Page %(num)d', num=p)]) fields = ['href', 'class', 'string', 'title'] paginator.shown_pages = [dict(zip(fields, p)) for p in pagedata] paginator.current_page = {'href': None, 'class': 'current', 'string': str(paginator.page + 1), 'title': None} numrows = paginator.num_items # Place retrieved columns in groups, according to naming conventions # * _col_ means fullrow, i.e. a group with one header # * col_ means finish the current group and start a new one field_labels = TicketSystem(self.env).get_ticket_field_labels() header_groups = [[]] for idx, col in enumerate(cols): if col in field_labels: title = field_labels[col] else: title = col.strip('_').capitalize() header = { 'col': col, 'title': title, 'hidden': False, 'asc': None, } if col == sort_col: if asc: data['asc'] = asc data['sort'] = sort_col header['asc'] = bool(asc) if not paginator and need_reorder: # this dict will have enum values for sorting # and will be used in sortkey(), if non-empty: sort_values = {} if sort_col in ('status', 'resolution', 'priority', 'severity'): # must fetch sort values for that columns # instead of comparing them as strings with self.env.db_query as db: for name, value in db( "SELECT name, %s FROM enum WHERE type=%%s" % db.cast('value', 'int'), (sort_col,)): sort_values[name] = value def sortkey(row): val = row[idx] # check if we have sort_values, then use them as keys. if sort_values: return sort_values.get(val) # otherwise, continue with string comparison: if isinstance(val, basestring): val = val.lower() return val results = sorted(results, key=sortkey, reverse=not asc) header_group = header_groups[-1] if col.startswith('__') and col.endswith('__'): # __col__ header['hidden'] = True elif col[0] == '_' and col[-1] == '_': # _col_ header_group = [] header_groups.append(header_group) header_groups.append([]) elif col[0] == '_': # _col header['hidden'] = True elif col[-1] == '_': # col_ header_groups.append([]) header_group.append(header) # Structure the rows and cells: # - group rows according to __group__ value, if defined # - group cells the same way headers are grouped chrome = Chrome(self.env) row_groups = [] authorized_results = [] prev_group_value = None for row_idx, result in enumerate(results): col_idx = 0 cell_groups = [] row = {'cell_groups': cell_groups} realm = TicketSystem.realm parent_realm = '' parent_id = '' email_cells = [] for header_group in header_groups: cell_group = [] for header in header_group: value = cell_value(result[col_idx]) cell = {'value': value, 'header': header, 'index': col_idx} col = header['col'] col_idx += 1 # Detect and create new group if col == '__group__' and value != prev_group_value: prev_group_value = value # Brute force handling of email in group by header row_groups.append( (value and chrome.format_author(req, value), [])) # Other row properties row['__idx__'] = row_idx if col in self._html_cols: row[col] = value if col in ('report', 'ticket', 'id', '_id'): row['id'] = value # Special casing based on column name col = col.strip('_') if col in ('reporter', 'cc', 'owner'): email_cells.append(cell) elif col == 'realm': realm = value elif col == 'parent_realm': parent_realm = value elif col == 'parent_id': parent_id = value cell_group.append(cell) cell_groups.append(cell_group) if parent_realm: resource = Resource(realm, row.get('id'), parent=Resource(parent_realm, parent_id)) else: resource = Resource(realm, row.get('id')) # FIXME: for now, we still need to hardcode the realm in the action if resource.realm.upper() + '_VIEW' not in req.perm(resource): continue authorized_results.append(result) if email_cells: for cell in email_cells: emails = chrome.format_emails(context.child(resource), cell['value']) result[cell['index']] = cell['value'] = emails row['resource'] = resource if row_groups: row_group = row_groups[-1][1] else: row_group = [] row_groups = [(None, row_group)] row_group.append(row) data.update({'header_groups': header_groups, 'row_groups': row_groups, 'numrows': numrows}) if format == 'rss': data['context'] = web_context(req, report_resource, absurls=True) return 'report.rss', data, 'application/rss+xml' elif format == 'csv': filename = 'report_%s.csv' % id if id else 'report.csv' self._send_csv(req, cols, authorized_results, mimetype='text/csv', filename=filename) elif format == 'tab': filename = 'report_%s.tsv' % id if id else 'report.tsv' self._send_csv(req, cols, authorized_results, '\t', mimetype='text/tab-separated-values', filename=filename) else: p = page if max is not None else None add_link(req, 'alternate', auth_link(req, report_href(format='rss', page=None)), _('RSS Feed'), 'application/rss+xml', 'rss') add_link(req, 'alternate', report_href(format='csv', page=p), _('Comma-delimited Text'), 'text/plain') add_link(req, 'alternate', report_href(format='tab', page=p), _('Tab-delimited Text'), 'text/plain') if 'REPORT_SQL_VIEW' in req.perm(self.realm, id): add_link(req, 'alternate', req.href.report(id=id, format='sql'), _('SQL Query'), 'text/plain') # reuse the session vars of the query module so that # the query navigation links on the ticket can be used to # navigate report results as well try: req.session['query_tickets'] = \ ' '.join(str(int(row['id'])) for rg in row_groups for row in rg[1]) req.session['query_href'] = \ req.session['query_href'] = report_href() # Kludge: we have to clear the other query session # variables, but only if the above succeeded for var in ('query_constraints', 'query_time'): if var in req.session: del req.session[var] except (ValueError, KeyError): pass if set(data['args']) - {'USER'}: data['show_args_form'] = True # Add values of all select-type ticket fields for autocomplete. fields = TicketSystem(self.env).get_ticket_fields() arg_values = {} for arg in set(data['args']) - {'USER'}: attrs = fields.by_name(arg.lower()) if attrs and 'options' in attrs: arg_values[attrs['name']] = attrs['options'] if arg_values: add_script_data(req, arg_values=arg_values) Chrome(self.env).add_jquery_ui(req) if missing_args: add_warning(req, _( 'The following arguments are missing: %(args)s', args=", ".join(missing_args))) return 'report_view.html', data, None
class Ticket(object): realm = 'ticket' # Fields that must not be modified directly by the user # 'owner' should eventually be a protected field (#2045) protected_fields = 'resolution', 'status', 'time', 'changetime' @staticmethod def id_is_valid(num): return 0 < int(num) <= 1L << 31 @property def resource(self): return Resource(self.realm, self.id, self.version) # 0.11 compatibility. Will be removed in 1.3.1. time_created = property(lambda self: self.values.get('time')) time_changed = property(lambda self: self.values.get('changetime')) def __init__(self, env, tkt_id=None, version=None): self.env = env self.fields = TicketSystem(self.env).get_ticket_fields() self.editable_fields = \ set(f['name'] for f in self.fields if f['name'] not in self.protected_fields) self.std_fields, self.custom_fields, self.time_fields = [], [], [] for f in self.fields: if f.get('custom'): self.custom_fields.append(f['name']) else: self.std_fields.append(f['name']) if f['type'] == 'time': self.time_fields.append(f['name']) self.values = {} if tkt_id is not None: tkt_id = int(tkt_id) self._fetch_ticket(tkt_id) else: self._init_defaults() self.id = None self.version = version self._old = {} def __repr__(self): return '<%s %r>' % (self.__class__.__name__, self.id) exists = property(lambda self: self.id is not None) def _init_defaults(self): for field in self.fields: default = None if field['name'] in self.protected_fields: # Ignore for new - only change through workflow pass elif not field.get('custom'): default = self.env.config.get('ticket', 'default_' + field['name']) else: default = field.get('value') options = field.get('options') if default and options and default not in options: try: default = options[int(default)] except (ValueError, IndexError): self.env.log.warning('Invalid default value "%s" ' 'for custom field "%s"', default, field['name']) if default and field.get('type') == 'time': try: default = parse_date(default, hint=field.get('format')) except TracError as e: self.env.log.warning('Invalid default value "%s" ' 'for custom field "%s": %s', default, field['name'], e) default = None if default: self.values.setdefault(field['name'], default) def _fetch_ticket(self, tkt_id): row = None if self.id_is_valid(tkt_id): # Fetch the standard ticket fields for row in self.env.db_query("SELECT %s FROM ticket WHERE id=%%s" % ','.join(self.std_fields), (tkt_id,)): break if not row: raise ResourceNotFound(_("Ticket %(id)s does not exist.", id=tkt_id), _("Invalid ticket number")) self.id = tkt_id for i, field in enumerate(self.std_fields): value = row[i] if field in self.time_fields: self.values[field] = from_utimestamp(value) elif value is None: self.values[field] = empty else: self.values[field] = value # Fetch custom fields if available for name, value in self.env.db_query(""" SELECT name, value FROM ticket_custom WHERE ticket=%s """, (tkt_id,)): if name in self.custom_fields: if name in self.time_fields: self.values[name] = _db_str_to_datetime(value) elif value is None: self.values[name] = empty else: self.values[name] = value def __getitem__(self, name): return self.values.get(name) def __setitem__(self, name, value): """Log ticket modifications so the table ticket_change can be updated """ if value and name not in self.time_fields: if isinstance(value, list): raise TracError(_("Multi-values fields not supported yet")) if self.fields.by_name(name, {}).get('type') != 'textarea': value = value.strip() if name in self.values and self.values[name] == value: return if name not in self._old: # Changed field self._old[name] = self.values.get(name) elif self._old[name] == value: # Change of field reverted del self._old[name] self.values[name] = value def __contains__(self, item): return item in self.values def get_value_or_default(self, name): """Return the value of a field or the default value if it is undefined """ try: value = self.values[name] return value if value is not empty else self.get_default(name) except KeyError: pass def get_default(self, name): """Return the default value of a field.""" return self.fields.by_name(name, {}).get('value', '') def populate(self, values): """Populate the ticket with 'suitable' values from a dictionary""" field_names = [f['name'] for f in self.fields] for name in [name for name in values.keys() if name in field_names]: self[name] = values[name] # We have to do an extra trick to catch unchecked checkboxes for name in [name for name in values.keys() if name[9:] in field_names and name.startswith('checkbox_')]: if name[9:] not in values: self[name[9:]] = '0' def insert(self, when=None): """Add ticket to database. """ assert not self.exists, 'Cannot insert an existing ticket' if 'cc' in self.values: self['cc'] = _fixup_cc_list(self.values['cc']) # Add a timestamp if when is None: when = datetime.now(utc) self.values['time'] = self.values['changetime'] = when # Perform type conversions db_values = self._to_db_types(self.values) # Insert ticket record std_fields = [] custom_fields = [] for f in self.fields: fname = f['name'] if fname in self.values: if f.get('custom'): custom_fields.append(fname) else: std_fields.append(fname) with self.env.db_transaction as db: cursor = db.cursor() cursor.execute("INSERT INTO ticket (%s) VALUES (%s)" % (','.join(std_fields), ','.join(['%s'] * len(std_fields))), [db_values.get(name) for name in std_fields]) tkt_id = db.get_last_id(cursor, 'ticket') # Insert custom fields if custom_fields: db.executemany( """INSERT INTO ticket_custom (ticket, name, value) VALUES (%s, %s, %s) """, [(tkt_id, c, db_values.get(c)) for c in custom_fields]) self.id = int(tkt_id) self._old = {} for listener in TicketSystem(self.env).change_listeners: listener.ticket_created(self) return self.id def get_comment_number(self, cdate): """Return a comment number by its date.""" ts = to_utimestamp(cdate) for cnum, in self.env.db_query("""\ SELECT oldvalue FROM ticket_change WHERE ticket=%s AND time=%s AND field='comment' """, (self.id, ts)): try: return int(cnum.rsplit('.', 1)[-1]) except ValueError: break def save_changes(self, author=None, comment=None, when=None, cnum='', replyto=None): """ Store ticket changes in the database. The ticket must already exist in the database. Returns False if there were no changes to save, True otherwise. :since 1.0: the `cnum` parameter is deprecated, and threading should be controlled with the `replyto` argument """ assert self.exists, "Cannot update a new ticket" if 'cc' in self.values: self['cc'] = _fixup_cc_list(self.values['cc']) props_unchanged = all(self.values.get(k) == v for k, v in self._old.iteritems()) if (not comment or not comment.strip()) and props_unchanged: return False # Not modified if when is None: when = datetime.now(utc) when_ts = to_utimestamp(when) # Perform type conversions db_values = self._to_db_types(self.values) old_db_values = self._to_db_types(self._old) with self.env.db_transaction as db: db("UPDATE ticket SET changetime=%s WHERE id=%s", (when_ts, self.id)) # find cnum if it isn't provided if not cnum: num = 0 for ts, old in db(""" SELECT DISTINCT tc1.time, COALESCE(tc2.oldvalue,'') FROM ticket_change AS tc1 LEFT OUTER JOIN ticket_change AS tc2 ON tc2.ticket=%s AND tc2.time=tc1.time AND tc2.field='comment' WHERE tc1.ticket=%s ORDER BY tc1.time DESC """, (self.id, self.id)): # Use oldvalue if available, else count edits try: num += int(old.rsplit('.', 1)[-1]) break except ValueError: num += 1 cnum = str(num + 1) if replyto: cnum = '%s.%s' % (replyto, cnum) # store fields for name in self._old.keys(): if name in self.custom_fields: for row in db("""SELECT * FROM ticket_custom WHERE ticket=%s and name=%s """, (self.id, name)): db("""UPDATE ticket_custom SET value=%s WHERE ticket=%s AND name=%s """, (db_values.get(name), self.id, name)) break else: db("""INSERT INTO ticket_custom (ticket,name,value) VALUES(%s,%s,%s) """, (self.id, name, db_values.get(name))) else: db("UPDATE ticket SET %s=%%s WHERE id=%%s" % name, (db_values.get(name), self.id)) db("""INSERT INTO ticket_change (ticket,time,author,field,oldvalue,newvalue) VALUES (%s, %s, %s, %s, %s, %s) """, (self.id, when_ts, author, name, old_db_values.get(name), db_values.get(name))) # always save comment, even if empty # (numbering support for timeline) db("""INSERT INTO ticket_change (ticket,time,author,field,oldvalue,newvalue) VALUES (%s,%s,%s,'comment',%s,%s) """, (self.id, when_ts, author, cnum, comment)) old_values = self._old self._old = {} self.values['changetime'] = when for listener in TicketSystem(self.env).change_listeners: listener.ticket_changed(self, comment, author, old_values) return int(cnum.rsplit('.', 1)[-1]) def _to_db_types(self, values): values = values.copy() for field, value in values.iteritems(): if field in self.time_fields: is_custom_field = field in self.custom_fields values[field] = _datetime_to_db_str(value, is_custom_field) else: values[field] = value if value else None return values def get_changelog(self, when=None): """Return the changelog as a list of tuples of the form (time, author, field, oldvalue, newvalue, permanent). While the other tuple elements are quite self-explanatory, the `permanent` flag is used to distinguish collateral changes that are not yet immutable (like attachments, currently). """ sid = str(self.id) when_ts = to_utimestamp(when) if when_ts: sql = """ SELECT time, author, field, oldvalue, newvalue, 1 AS permanent FROM ticket_change WHERE ticket=%s AND time=%s UNION SELECT time, author, 'attachment', null, filename, 0 AS permanent FROM attachment WHERE type='ticket' AND id=%s AND time=%s UNION SELECT time, author, 'comment', null, description, 0 AS permanent FROM attachment WHERE type='ticket' AND id=%s AND time=%s ORDER BY time,permanent,author """ args = (self.id, when_ts, sid, when_ts, sid, when_ts) else: sql = """ SELECT time, author, field, oldvalue, newvalue, 1 AS permanent FROM ticket_change WHERE ticket=%s UNION SELECT time, author, 'attachment', null, filename, 0 AS permanent FROM attachment WHERE type='ticket' AND id=%s UNION SELECT time, author, 'comment', null, description, 0 AS permanent FROM attachment WHERE type='ticket' AND id=%s ORDER BY time,permanent,author """ args = (self.id, sid, sid) log = [] for t, author, field, oldvalue, newvalue, permanent \ in self.env.db_query(sql, args): if field in self.time_fields: oldvalue = _db_str_to_datetime(oldvalue) newvalue = _db_str_to_datetime(newvalue) log.append((from_utimestamp(t), author, field, oldvalue or '', newvalue or '', permanent)) return log def delete(self): """Delete the ticket. """ with self.env.db_transaction as db: Attachment.delete_all(self.env, self.realm, self.id) db("DELETE FROM ticket WHERE id=%s", (self.id,)) db("DELETE FROM ticket_change WHERE ticket=%s", (self.id,)) db("DELETE FROM ticket_custom WHERE ticket=%s", (self.id,)) for listener in TicketSystem(self.env).change_listeners: listener.ticket_deleted(self) def get_change(self, cnum=None, cdate=None): """Return a ticket change by its number or date. """ if cdate is None: row = self._find_change(cnum) if not row: return cdate = from_utimestamp(row[0]) ts = to_utimestamp(cdate) fields = {} change = {'date': cdate, 'fields': fields} for field, author, old, new in self.env.db_query(""" SELECT field, author, oldvalue, newvalue FROM ticket_change WHERE ticket=%s AND time=%s """, (self.id, ts)): fields[field] = {'author': author, 'old': old, 'new': new} if field == 'comment': change['author'] = author elif not field.startswith('_'): change.setdefault('author', author) if fields: return change def delete_change(self, cnum=None, cdate=None, when=None): """Delete a ticket change identified by its number or date.""" if cdate is None: row = self._find_change(cnum) if not row: return cdate = from_utimestamp(row[0]) ts = to_utimestamp(cdate) if when is None: when = datetime.now(utc) when_ts = to_utimestamp(when) with self.env.db_transaction as db: # Find modified fields and their previous value fields = [(field, old, new) for field, old, new in db(""" SELECT field, oldvalue, newvalue FROM ticket_change WHERE ticket=%s AND time=%s """, (self.id, ts)) if field != 'comment' and not field.startswith('_')] for field, oldvalue, newvalue in fields: # Find the next change for next_ts, in db("""SELECT time FROM ticket_change WHERE ticket=%s AND time>%s AND field=%s LIMIT 1 """, (self.id, ts, field)): # Modify the old value of the next change if it is equal # to the new value of the deleted change db("""UPDATE ticket_change SET oldvalue=%s WHERE ticket=%s AND time=%s AND field=%s AND oldvalue=%s """, (oldvalue, self.id, next_ts, field, newvalue)) break else: # No next change, edit ticket field if field in self.std_fields: db("UPDATE ticket SET %s=%%s WHERE id=%%s" % field, (oldvalue, self.id)) else: db("""UPDATE ticket_custom SET value=%s WHERE ticket=%s AND name=%s """, (oldvalue, self.id, field)) # Delete the change db("DELETE FROM ticket_change WHERE ticket=%s AND time=%s", (self.id, ts)) # Update last changed time db("UPDATE ticket SET changetime=%s WHERE id=%s", (when_ts, self.id)) self._fetch_ticket(self.id) changes = dict((field, (oldvalue, newvalue)) for field, oldvalue, newvalue in fields) for listener in TicketSystem(self.env).change_listeners: if hasattr(listener, 'ticket_change_deleted'): listener.ticket_change_deleted(self, cdate, changes) def modify_comment(self, cdate, author, comment, when=None): """Modify a ticket comment specified by its date, while keeping a history of edits. """ ts = to_utimestamp(cdate) if when is None: when = datetime.now(utc) when_ts = to_utimestamp(when) with self.env.db_transaction as db: # Find the current value of the comment old_comment = False for old_comment, in db(""" SELECT newvalue FROM ticket_change WHERE ticket=%s AND time=%s AND field='comment' """, (self.id, ts)): break if comment == (old_comment or ''): return # Comment history is stored in fields named "_comment%d" # Find the next edit number fields = db("""SELECT field FROM ticket_change WHERE ticket=%%s AND time=%%s AND field %s """ % db.prefix_match(), (self.id, ts, db.prefix_match_value('_comment'))) rev = max(int(field[8:]) for field, in fields) + 1 if fields else 0 db("""INSERT INTO ticket_change (ticket,time,author,field,oldvalue,newvalue) VALUES (%s,%s,%s,%s,%s,%s) """, (self.id, ts, author, '_comment%d' % rev, old_comment or '', str(when_ts))) if old_comment is False: # There was no comment field, add one, find the # original author in one of the other changed fields for old_author, in db(""" SELECT author FROM ticket_change WHERE ticket=%%s AND time=%%s AND NOT field %s LIMIT 1 """ % db.prefix_match(), (self.id, ts, db.prefix_match_value('_'))): db("""INSERT INTO ticket_change (ticket,time,author,field,oldvalue,newvalue) VALUES (%s,%s,%s,'comment','',%s) """, (self.id, ts, old_author, comment)) else: db("""UPDATE ticket_change SET newvalue=%s WHERE ticket=%s AND time=%s AND field='comment' """, (comment, self.id, ts)) # Update last changed time db("UPDATE ticket SET changetime=%s WHERE id=%s", (when_ts, self.id)) self.values['changetime'] = when old_comment = old_comment or '' for listener in TicketSystem(self.env).change_listeners: if hasattr(listener, 'ticket_comment_modified'): listener.ticket_comment_modified(self, cdate, author, comment, old_comment) def get_comment_history(self, cnum=None, cdate=None): """Retrieve the edit history of a comment identified by its number or date. """ if cdate is None: row = self._find_change(cnum) if not row: return ts0, author0, last_comment = row else: ts0, author0, last_comment = to_utimestamp(cdate), None, None with self.env.db_query as db: # Get last comment and author if not available if last_comment is None: last_comment = '' for author0, last_comment in db(""" SELECT author, newvalue FROM ticket_change WHERE ticket=%s AND time=%s AND field='comment' """, (self.id, ts0)): break if author0 is None: for author0, last_comment in db(""" SELECT author, newvalue FROM ticket_change WHERE ticket=%%s AND time=%%s AND NOT field %s LIMIT 1 """ % db.prefix_match(), (self.id, ts0, db.prefix_match_value('_'))): break else: return # Get all fields of the form "_comment%d" rows = db("""SELECT field, author, oldvalue, newvalue FROM ticket_change WHERE ticket=%%s AND time=%%s AND field %s """ % db.prefix_match(), (self.id, ts0, db.prefix_match_value('_comment'))) rows = sorted((int(field[8:]), author, old, new) for field, author, old, new in rows) history = [] for rev, author, comment, ts in rows: history.append((rev, from_utimestamp(long(ts0)), author0, comment)) ts0, author0 = ts, author history.sort() rev = history[-1][0] + 1 if history else 0 history.append((rev, from_utimestamp(long(ts0)), author0, last_comment)) return history def _find_change(self, cnum): """Find a comment by its number.""" scnum = str(cnum) with self.env.db_query as db: for row in db(""" SELECT time, author, newvalue FROM ticket_change WHERE ticket=%%s AND field='comment' AND (oldvalue=%%s OR oldvalue %s) """ % db.like(), (self.id, scnum, '%' + db.like_escape('.' + scnum))): return row # Fallback when comment number is not available in oldvalue num = 0 for ts, old, author, comment in db(""" SELECT DISTINCT tc1.time, COALESCE(tc2.oldvalue,''), tc2.author, COALESCE(tc2.newvalue,'') FROM ticket_change AS tc1 LEFT OUTER JOIN ticket_change AS tc2 ON tc2.ticket=%s AND tc2.time=tc1.time AND tc2.field='comment' WHERE tc1.ticket=%s ORDER BY tc1.time """, (self.id, self.id)): # Use oldvalue if available, else count edits try: num = int(old.rsplit('.', 1)[-1]) except ValueError: num += 1 if num == cnum: break else: return # Find author if NULL if author is None: for author, in db(""" SELECT author FROM ticket_change WHERE ticket=%%s AND time=%%s AND NOT field %s LIMIT 1 """ % db.prefix_match(), (self.id, ts, db.prefix_match_value('_'))): break return ts, author, comment