Modified: bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/admin.py URL: http://svn.apache.org/viewvc/bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/admin.py?rev=1639823&r1=1639822&r2=1639823&view=diff ============================================================================== --- bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/admin.py (original) +++ bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/admin.py Sat Nov 15 01:14:46 2014 @@ -15,7 +15,9 @@ from __future__ import with_statement from datetime import datetime -from trac.admin import * +from trac.admin.api import AdminCommandError, IAdminCommandProvider, \ + IAdminPanelProvider, console_date_format, \ + console_datetime_format, get_console_locale from trac.core import * from trac.perm import PermissionSystem from trac.resource import ResourceNotFound @@ -40,11 +42,10 @@ class TicketAdminPanel(Component): # and don't use it whenever using them as field names (after # a call to `.lower()`) - # IAdminPanelProvider methods def get_admin_panels(self, req): - if 'TICKET_ADMIN' in req.perm: + if 'TICKET_ADMIN' in req.perm('admin', 'ticket/' + self._type): # in global scope show only products # in local scope everything but products parent = getattr(self.env, 'parent', None) @@ -54,7 +55,6 @@ class TicketAdminPanel(Component): gettext(self._label[1])) def render_admin_panel(self, req, cat, page, version): - req.perm.require('TICKET_ADMIN') # Trap AssertionErrors and convert them to TracErrors try: return self._render_admin_panel(req, cat, page, version) @@ -152,7 +152,7 @@ class ComponentAdminPanel(TicketAdminPan req.redirect(req.href.admin(cat, page)) data = {'view': 'list', - 'components': model.Component.select(self.env), + 'components': list(model.Component.select(self.env)), 'default': default} if self.config.getbool('ticket', 'restrict_owner'): @@ -175,7 +175,7 @@ class ComponentAdminPanel(TicketAdminPan yield ('component list', '', 'Show available components', None, self._do_list) - yield ('component add', '<name> <owner>', + yield ('component add', '<name> [owner]', 'Add a new component', self._complete_add, self._do_add) yield ('component rename', '<name> <newname>', @@ -214,7 +214,7 @@ class ComponentAdminPanel(TicketAdminPan for c in model.Component.select(self.env)], [_('Name'), _('Owner')]) - def _do_add(self, name, owner): + def _do_add(self, name, owner=None): component = model.Component(self.env) component.name = name component.owner = owner @@ -242,21 +242,19 @@ class MilestoneAdminPanel(TicketAdminPan # IAdminPanelProvider methods def get_admin_panels(self, req): - if 'MILESTONE_VIEW' in req.perm: + if 'MILESTONE_VIEW' in req.perm('admin', 'ticket/' + self._type): return TicketAdminPanel.get_admin_panels(self, req) - return iter([]) # TicketAdminPanel methods def _render_admin_panel(self, req, cat, page, milestone): - req.perm.require('MILESTONE_VIEW') - + perm = req.perm('admin', 'ticket/' + self._type) # Detail view? if milestone: mil = model.Milestone(self.env, milestone) if req.method == 'POST': if req.args.get('save'): - req.perm.require('MILESTONE_MODIFY') + perm.require('MILESTONE_MODIFY') mil.name = name = req.args.get('name') mil.due = mil.completed = None due = req.args.get('duedate', '') @@ -273,7 +271,7 @@ class MilestoneAdminPanel(TicketAdminPan _('Invalid Completion Date')) mil.description = req.args.get('description', '') try: - mil.update() + mil.update(author=req.authname) except self.env.db_exc.IntegrityError: raise TracError(_('The milestone "%(name)s" already ' 'exists.', name=name)) @@ -290,7 +288,7 @@ class MilestoneAdminPanel(TicketAdminPan if req.method == 'POST': # Add Milestone if req.args.get('add') and req.args.get('name'): - req.perm.require('MILESTONE_CREATE') + perm.require('MILESTONE_CREATE') name = req.args.get('name') try: mil = model.Milestone(self.env, name=name) @@ -313,7 +311,7 @@ class MilestoneAdminPanel(TicketAdminPan # Remove milestone elif req.args.get('remove'): - req.perm.require('MILESTONE_DELETE') + perm.require('MILESTONE_DELETE') sel = req.args.get('sel') if not sel: raise TracError(_('No milestone selected')) @@ -357,6 +355,10 @@ class MilestoneAdminPanel(TicketAdminPan # IAdminCommandProvider methods def get_admin_commands(self): + hints = { + 'datetime': get_datetime_format_hint(get_console_locale(self.env)), + 'iso8601': get_datetime_format_hint('iso8601'), + } yield ('milestone list', '', "Show milestones", None, self._do_list) @@ -369,20 +371,22 @@ class MilestoneAdminPanel(TicketAdminPan yield ('milestone due', '<name> <due>', """Set milestone due date - The <due> date must be specified in the "%s" format. + The <due> date must be specified in the "%(datetime)s" + or "%(iso8601)s" (ISO 8601) format. Alternatively, "now" can be used to set the due date to the current time. To remove the due date from a milestone, specify an empty string (""). - """ % console_date_format_hint, + """ % hints, self._complete_name, self._do_due) yield ('milestone completed', '<name> <completed>', """Set milestone complete date - The <completed> date must be specified in the "%s" format. + The <completed> date must be specified in the "%(datetime)s" + or "%(iso8601)s" (ISO 8601) format. Alternatively, "now" can be used to set the completion date to the current time. To remove the completion date from a milestone, specify an empty string (""). - """ % console_date_format_hint, + """ % hints, self._complete_name, self._do_completed) yield ('milestone remove', '<name>', "Remove milestone", @@ -396,10 +400,11 @@ class MilestoneAdminPanel(TicketAdminPan return self.get_milestone_list() def _do_list(self): - print_table([(m.name, m.due and - format_date(m.due, console_date_format), - m.completed and - format_datetime(m.completed, console_datetime_format)) + print_table([(m.name, + format_date(m.due, console_date_format) + if m.due else None, + format_datetime(m.completed, console_datetime_format) + if m.completed else None) for m in model.Milestone.select(self.env)], [_("Name"), _("Due"), _("Completed")]) @@ -407,23 +412,27 @@ class MilestoneAdminPanel(TicketAdminPan milestone = model.Milestone(self.env) milestone.name = name if due is not None: - milestone.due = parse_date(due, hint='datetime') + milestone.due = parse_date(due, hint='datetime', + locale=get_console_locale(self.env)) milestone.insert() def _do_rename(self, name, newname): milestone = model.Milestone(self.env, name) milestone.name = newname - milestone.update() + milestone.update(author=getuser()) def _do_due(self, name, due): milestone = model.Milestone(self.env, name) - milestone.due = due and parse_date(due, hint='datetime') + milestone.due = parse_date(due, hint='datetime', + locale=get_console_locale(self.env)) \ + if due else None milestone.update() def _do_completed(self, name, completed): milestone = model.Milestone(self.env, name) - milestone.completed = completed and parse_date(completed, - hint='datetime') + milestone.completed = parse_date(completed, hint='datetime', + locale=get_console_locale(self.env)) \ + if completed else None milestone.update() def _do_remove(self, name): @@ -515,7 +524,7 @@ class VersionAdminPanel(TicketAdminPanel req.redirect(req.href.admin(cat, page)) data = {'view': 'list', - 'versions': model.Version.select(self.env), + 'versions': list(model.Version.select(self.env)), 'default': default} Chrome(self.env).add_jquery_ui(req) @@ -528,6 +537,10 @@ class VersionAdminPanel(TicketAdminPanel # IAdminCommandProvider methods def get_admin_commands(self): + hints = { + 'datetime': get_datetime_format_hint(get_console_locale(self.env)), + 'iso8601': get_datetime_format_hint('iso8601'), + } yield ('version list', '', "Show versions", None, self._do_list) @@ -540,11 +553,12 @@ class VersionAdminPanel(TicketAdminPanel yield ('version time', '<name> <time>', """Set version date - The <time> must be specified in the "%s" format. Alternatively, - "now" can be used to set the version date to the current time. - To remove the date from a version, specify an empty string - (""). - """ % console_date_format_hint, + The <time> must be specified in the "%(datetime)s" + or "%(iso8601)s" (ISO 8601) format. + Alternatively, "now" can be used to set the version date to + the current time. To remove the date from a version, specify + an empty string (""). + """ % hints, self._complete_name, self._do_time) yield ('version remove', '<name>', "Remove version", @@ -559,15 +573,18 @@ class VersionAdminPanel(TicketAdminPanel def _do_list(self): print_table([(v.name, - v.time and format_date(v.time, console_date_format)) - for v in model.Version.select(self.env)], + format_date(v.time, console_date_format) + if v.time else None) + for v in model.Version.select(self.env)], [_("Name"), _("Time")]) def _do_add(self, name, time=None): version = model.Version(self.env) version.name = name if time is not None: - version.time = time and parse_date(time, hint='datetime') + version.time = parse_date(time, hint='datetime', + locale=get_console_locale(self.env)) \ + if time else None version.insert() def _do_rename(self, name, newname): @@ -577,7 +594,9 @@ class VersionAdminPanel(TicketAdminPanel def _do_time(self, name, time): version = model.Version(self.env, name) - version.time = time and parse_date(time, hint='datetime') + version.time = parse_date(time, hint='datetime', + locale=get_console_locale(self.env)) \ + if time else None version.update() def _do_remove(self, name):
Modified: bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/api.py URL: http://svn.apache.org/viewvc/bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/api.py?rev=1639823&r1=1639822&r2=1639823&view=diff ============================================================================== --- bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/api.py (original) +++ bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/api.py Sat Nov 15 01:14:46 2014 @@ -123,6 +123,15 @@ class ITicketChangeListener(Interface): def ticket_deleted(ticket): """Called when a ticket is deleted.""" + def ticket_comment_modified(ticket, cdate, author, comment, old_comment): + """Called when a ticket comment is modified.""" + + def ticket_change_deleted(ticket, cdate, changes): + """Called when a ticket change is deleted. + + `changes` is a dictionary of tuple `(oldvalue, newvalue)` + containing the ticket change of the fields that have changed.""" + class ITicketManipulator(Interface): """Miscellaneous manipulation of ticket workflow features.""" @@ -562,24 +571,44 @@ class TicketSystem(Component): cnum, realm, id = elts if cnum != 'description' and cnum and not cnum[0].isdigit(): realm, id, cnum = elts # support old comment: style + id = as_int(id, None) resource = formatter.resource(realm, id) else: resource = formatter.resource cnum = target - if resource and resource.realm == 'ticket': - id = as_int(resource.id, None) - if id is not None: - href = "%s#comment:%s" % (formatter.href.ticket(resource.id), - cnum) - title = _("Comment %(cnum)s for Ticket #%(id)s", cnum=cnum, - id=resource.id) - if 'TICKET_VIEW' in formatter.perm(resource): - for status, in self.env.db_query( - "SELECT status FROM ticket WHERE id=%s", (id,)): - return tag.a(label, href=href, title=title, - class_=status) - return tag.a(label, href=href, title=title) + if resource and resource.id and resource.realm == 'ticket' and \ + cnum and (all(c.isdigit() for c in cnum) or cnum == 'description'): + href = title = class_ = None + if self.resource_exists(resource): + from trac.ticket.model import Ticket + ticket = Ticket(self.env, resource.id) + if cnum != 'description' and not ticket.get_change(cnum): + title = _("ticket comment does not exist") + class_ = 'missing ticket' + elif 'TICKET_VIEW' in formatter.perm(resource): + href = formatter.href.ticket(resource.id) + \ + "#comment:%s" % cnum + if resource.id != formatter.resource.id: + if cnum == 'description': + title = _("Description for Ticket #%(id)s", + id=resource.id) + else: + title = _("Comment %(cnum)s for Ticket #%(id)s", + cnum=cnum, id=resource.id) + class_ = ticket['status'] + ' ticket' + else: + title = _("Description") if cnum == 'description' \ + else _("Comment %(cnum)s", + cnum=cnum) + class_ = 'ticket' + else: + title = _("no permission to view ticket") + class_ = 'forbidden ticket' + else: + title = _("ticket does not exist") + class_ = 'missing ticket' + return tag.a(label, class_=class_, href=href, title=title) return label # IResourceManager methods Modified: bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/batch.py URL: http://svn.apache.org/viewvc/bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/batch.py?rev=1639823&r1=1639822&r2=1639823&view=diff ============================================================================== --- bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/batch.py (original) +++ bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/batch.py Sat Nov 15 01:14:46 2014 @@ -29,6 +29,7 @@ from trac.util.translation import _, tag from trac.web import IRequestHandler from trac.web.chrome import add_warning, add_script_data + class BatchModifyModule(Component): """Ticket batch modification module. Modified: bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/default_workflow.py URL: http://svn.apache.org/viewvc/bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/default_workflow.py?rev=1639823&r1=1639822&r2=1639823&view=diff ============================================================================== --- bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/default_workflow.py (original) +++ bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/default_workflow.py Sat Nov 15 01:14:46 2014 @@ -20,6 +20,7 @@ import pkg_resources from ConfigParser import RawConfigParser from StringIO import StringIO +from functools import partial from genshi.builder import tag @@ -98,10 +99,12 @@ class ConfigurableTicketWorkflow(Compone """Ticket action controller which provides actions according to a workflow defined in trac.ini. - The workflow is idefined in the `[ticket-workflow]` section of the + The workflow is defined in the `[ticket-workflow]` section of the [wiki:TracIni#ticket-workflow-section trac.ini] configuration file. """ + implements(IEnvironmentSetupParticipant, ITicketActionController) + ticket_workflow_section = ConfigSection('ticket-workflow', """The workflow for tickets is controlled by plugins. By default, there's only a `ConfigurableTicketWorkflow` component in charge. @@ -129,7 +132,6 @@ class ConfigurableTicketWorkflow(Compone self.log.warning("Ticket workflow action '%s' doesn't define " "any transitions", name) - implements(ITicketActionController, IEnvironmentSetupParticipant) # IEnvironmentSetupParticipant methods @@ -226,18 +228,14 @@ Read TracWorkflow for more information ( this_action = self.actions[action] status = this_action['newstate'] operations = this_action['operations'] - current_owner = ticket._old.get('owner', ticket['owner'] or '(none)') - if not (Chrome(self.env).show_email_addresses - or 'EMAIL_VIEW' in req.perm(ticket.resource)): - format_user = obfuscate_email_address - else: - format_user = lambda address: address - current_owner = format_user(current_owner) + current_owner = ticket._old.get('owner', ticket['owner']) + format_author = partial(Chrome(self.env).format_author, req) + formatted_current_owner = format_author(current_owner or _("(none)")) control = [] # default to nothing hints = [] if 'reset_workflow' in operations: - control.append(tag("from invalid state ")) + control.append(_("from invalid state")) hints.append(_("Current state no longer exists")) if 'del_owner' in operations: hints.append(_("The ticket will be disowned")) @@ -245,7 +243,7 @@ Read TracWorkflow for more information ( id = 'action_%s_reassign_owner' % action selected_owner = req.args.get(id, req.authname) - if this_action.has_key('set_owner'): + if 'set_owner' in this_action: owners = [x.strip() for x in this_action['set_owner'].split(',')] elif self.config.getbool('ticket', 'restrict_owner'): @@ -255,41 +253,42 @@ Read TracWorkflow for more information ( else: owners = None - if owners == None: + if owners is None: owner = req.args.get(id, req.authname) - control.append(tag_('to %(owner)s', + control.append(tag_("to %(owner)s", owner=tag.input(type='text', id=id, name=id, value=owner))) hints.append(_("The owner will be changed from " - "%(current_owner)s", - current_owner=current_owner)) + "%(current_owner)s to the specified user", + current_owner=formatted_current_owner)) elif len(owners) == 1: owner = tag.input(type='hidden', id=id, name=id, value=owners[0]) - formatted_owner = format_user(owners[0]) - control.append(tag_('to %(owner)s ', - owner=tag(formatted_owner, owner))) + formatted_new_owner = format_author(owners[0]) + control.append(tag_("to %(owner)s", + owner=tag(formatted_new_owner, owner))) if ticket['owner'] != owners[0]: hints.append(_("The owner will be changed from " "%(current_owner)s to %(selected_owner)s", - current_owner=current_owner, - selected_owner=formatted_owner)) + current_owner=formatted_current_owner, + selected_owner=formatted_new_owner)) else: - control.append(tag_('to %(owner)s', owner=tag.select( + control.append(tag_("to %(owner)s", owner=tag.select( [tag.option(x, value=x, selected=(x == selected_owner or None)) for x in owners], id=id, name=id))) hints.append(_("The owner will be changed from " "%(current_owner)s to the selected user", - current_owner=current_owner)) + current_owner=formatted_current_owner)) elif 'set_owner_to_self' in operations and \ ticket._old.get('owner', ticket['owner']) != req.authname: hints.append(_("The owner will be changed from %(current_owner)s " - "to %(authname)s", current_owner=current_owner, - authname=req.authname)) + "to %(authname)s", + current_owner=formatted_current_owner, + authname=format_author(req.authname))) if 'set_resolution' in operations: - if this_action.has_key('set_resolution'): + if 'set_resolution' in this_action: resolutions = [x.strip() for x in this_action['set_resolution'].split(',')] else: @@ -302,7 +301,7 @@ Read TracWorkflow for more information ( if len(resolutions) == 1: resolution = tag.input(type='hidden', id=id, name=id, value=resolutions[0]) - control.append(tag_('as %(resolution)s', + control.append(tag_("as %(resolution)s", resolution=tag(resolutions[0], resolution))) hints.append(_("The resolution will be set to %(name)s", @@ -310,7 +309,7 @@ Read TracWorkflow for more information ( else: selected_option = req.args.get(id, TicketSystem(self.env).default_resolution) - control.append(tag_('as %(resolution)s', + control.append(tag_("as %(resolution)s", resolution=tag.select( [tag.option(x, value=x, selected=(x == selected_option or None)) @@ -320,13 +319,20 @@ Read TracWorkflow for more information ( if 'del_resolution' in operations: hints.append(_("The resolution will be deleted")) if 'leave_status' in operations: - control.append(_('as %(status)s ', + control.append(_("as %(status)s", status= ticket._old.get('status', ticket['status']))) + if len(operations) == 1: + hints.append(_("The owner will remain %(current_owner)s", + current_owner=formatted_current_owner) + if current_owner else + _("The ticket will remain with no owner")) else: if status != '*': hints.append(_("Next status will be '%(name)s'", name=status)) - return (this_action['name'], tag(*control), '. '.join(hints) + ".") + return (this_action['name'], + tag((' ' if i else None, c) for i, c in enumerate(control)), + '. '.join(hints) + '.' if hints else '') def get_ticket_changes(self, req, ticket, action): this_action = self.actions[action] Modified: bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/model.py URL: http://svn.apache.org/viewvc/bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/model.py?rev=1639823&r1=1639822&r2=1639823&view=diff ============================================================================== --- bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/model.py (original) +++ bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/model.py Sat Nov 15 01:14:46 2014 @@ -524,6 +524,12 @@ class Ticket(object): 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. @@ -548,8 +554,8 @@ class Ticket(object): # Find the next edit number fields = db("""SELECT field FROM ticket_change WHERE ticket=%%s AND time=%%s AND field %s - """ % db.like(), - (self.id, ts, db.like_escape('_comment') + '%')) + """ % 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) @@ -563,8 +569,8 @@ class Ticket(object): for old_author, in db(""" SELECT author FROM ticket_change WHERE ticket=%%s AND time=%%s AND NOT field %s LIMIT 1 - """ % db.like(), - (self.id, ts, db.like_escape('_') + '%')): + """ % 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) @@ -580,6 +586,12 @@ class Ticket(object): 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, db=None): """Retrieve the edit history of a comment identified by its number or date. @@ -607,8 +619,8 @@ class Ticket(object): 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.like(), - (self.id, ts0, db.like_escape('_') + '%')): + """ % db.prefix_match(), + (self.id, ts0, db.prefix_match_value('_'))): break else: return @@ -617,8 +629,8 @@ class Ticket(object): rows = db("""SELECT field, author, oldvalue, newvalue FROM ticket_change WHERE ticket=%%s AND time=%%s AND field %s - """ % db.like(), - (self.id, ts0, db.like_escape('_comment') + '%')) + """ % 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 = [] @@ -670,8 +682,8 @@ class Ticket(object): for author, in db(""" SELECT author FROM ticket_change WHERE ticket=%%s AND time=%%s AND NOT field %s LIMIT 1 - """ % db.like(), - (self.id, ts, db.like_escape('_') + '%')): + """ % db.prefix_match(), + (self.id, ts, db.prefix_match_value('_'))): break return (ts, author, comment) @@ -1040,23 +1052,20 @@ class Milestone(object): def delete(self, retarget_to=None, author=None, db=None): """Delete the milestone. + :since 1.0.2: the `retarget_to` and `author` parameters are + deprecated and will be removed in Trac 1.3.1. Tickets + should be moved to another milestone by calling + `move_tickets` before `delete`. + :since 1.0: the `db` parameter is no longer needed and will be removed in version 1.1.1 """ with self.env.db_transaction as db: self.env.log.info("Deleting milestone %s", self.name) db("DELETE FROM milestone WHERE name=%s", (self.name,)) - - # Retarget/reset tickets associated with this milestone - now = datetime.now(utc) - tkt_ids = [int(row[0]) for row in - db("SELECT id FROM ticket WHERE milestone=%s", - (self.name,))] - for tkt_id in tkt_ids: - ticket = Ticket(self.env, tkt_id, db) - ticket['milestone'] = retarget_to - comment = "Milestone %s deleted" % self.name # don't translate - ticket.save_changes(author, comment, now) + Attachment.delete_all(self.env, 'milestone', self.name) + # Don't translate ticket comment (comment:40:ticket:5658) + self.move_tickets(retarget_to, author, "Milestone deleted") self._old['name'] = None del self.cache.milestones TicketSystem(self.env).reset_ticket_fields() @@ -1088,7 +1097,7 @@ class Milestone(object): listener.milestone_created(self) ResourceSystem(self.env).resource_created(self) - def update(self, db=None): + def update(self, db=None, author=None): """Update the milestone. :since 1.0: the `db` parameter is no longer needed and will be removed @@ -1100,34 +1109,66 @@ class Milestone(object): old = self._old.copy() with self.env.db_transaction as db: - old_name = old['name'] - self.env.log.info("Updating milestone '%s'", self.name) + if self.name != old['name']: + # Update milestone field in tickets + self.move_tickets(self.name, author, "Milestone renamed") + TicketSystem(self.env).reset_ticket_fields() + # Reparent attachments + Attachment.reparent_all(self.env, 'milestone', old['name'], + 'milestone', self.name) + + self.env.log.info("Updating milestone '%s'", old['name']) db("""UPDATE milestone SET name=%s, due=%s, completed=%s, description=%s WHERE name=%s """, (self.name, to_utimestamp(self.due), to_utimestamp(self.completed), - self.description, old_name)) + self.description, old['name'])) self.checkin() - if self.name != old_name: - # Update milestone field in tickets - self.env.log.info("Updating milestone field of all tickets " - "associated with milestone '%s'", self.name) - db("UPDATE ticket SET milestone=%s WHERE milestone=%s", - (self.name, old_name)) - TicketSystem(self.env).reset_ticket_fields() - - # Reparent attachments - Attachment.reparent_all(self.env, 'milestone', old_name, - 'milestone', self.name) - old_values = dict((k, v) for k, v in old.iteritems() if getattr(self, k) != v) for listener in TicketSystem(self.env).milestone_change_listeners: listener.milestone_changed(self, old_values) ResourceSystem(self.env).resource_changed(self, old_values) + def move_tickets(self, new_milestone, author, comment=None, + exclude_closed=False): + """Move tickets associated with this milestone to another + milestone. + + :param new_milestone: milestone to which the tickets are moved + :param author: author of the change + :param comment: comment that is inserted into moved tickets. The + string should not be translated. + :param exclude_closed: whether tickets with status closed should be + excluded + + :return: a list of ids of tickets that were moved + """ + # Check if milestone exists, but if the milestone is being renamed + # the new milestone won't exist in the cache yet so skip the test + if new_milestone and new_milestone != self.name: + if not self.cache.fetchone(new_milestone): + raise ResourceNotFound( + _("Milestone %(name)s does not exist.", + name=new_milestone), _("Invalid milestone name")) + now = datetime.now(utc) + with self.env.db_transaction as db: + sql = "SELECT id FROM ticket WHERE milestone=%s" + if exclude_closed: + sql += " AND status != 'closed'" + tkt_ids = [int(row[0]) for row in db(sql, (self._old['name'],))] + if tkt_ids: + self.env.log.info("Moving tickets associated with milestone " + "'%s' to milestone '%s'", self._old['name'], + new_milestone) + for tkt_id in tkt_ids: + ticket = Ticket(self.env, tkt_id) + ticket['milestone'] = new_milestone + ticket.save_changes(author, comment, now) + return tkt_ids + @classmethod def select(cls, env, include_completed=True, db=None): """ Modified: bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/notification.py URL: http://svn.apache.org/viewvc/bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/notification.py?rev=1639823&r1=1639822&r2=1639823&view=diff ============================================================================== --- bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/notification.py (original) +++ bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/notification.py Sat Nov 15 01:14:46 2014 @@ -26,8 +26,10 @@ from trac.core import * from trac.config import * from trac.notification import NotifyEmail from trac.ticket.api import TicketSystem +from trac.ticket.model import Ticket from trac.util.datefmt import to_utimestamp -from trac.util.text import obfuscate_email_address, text_width, wrap +from trac.util.text import obfuscate_email_address, shorten_line, \ + text_width, wrap from trac.util.translation import deactivate, reactivate @@ -57,7 +59,7 @@ class TicketNotificationSystem(Component ''(since 0.11)''""") batch_subject_template = Option('notification', 'batch_subject_template', - '$prefix Batch modify: $tickets_descr', + '$prefix Batch modify: $tickets_descr', """Like ticket_subject_template but for batch modifications. By default, the template is `$prefix Batch modify: $tickets_descr`. @@ -73,60 +75,70 @@ class TicketNotificationSystem(Component US-ASCII characters. This is expected by CJK users. ''(since 0.12.2)''""") -def get_ticket_notification_recipients(env, config, tktid, prev_cc): - notify_reporter = config.getbool('notification', 'always_notify_reporter') - notify_owner = config.getbool('notification', 'always_notify_owner') - notify_updater = config.getbool('notification', 'always_notify_updater') - - ccrecipients = prev_cc - torecipients = [] - with env.db_query as db: - # Harvest email addresses from the cc, reporter, and owner fields - for row in db("SELECT cc, reporter, owner FROM ticket WHERE id=%s", - (tktid,)): - if row[0]: - ccrecipients += row[0].replace(',', ' ').split() - reporter = row[1] - owner = row[2] - if notify_reporter: - torecipients.append(row[1]) - if notify_owner: - torecipients.append(row[2]) - break - - # Harvest email addresses from the author field of ticket_change(s) - if notify_updater: - for author, ticket in db(""" - SELECT DISTINCT author, ticket FROM ticket_change - WHERE ticket=%s - """, (tktid,)): - torecipients.append(author) - - # Suppress the updater from the recipients - updater = None - for updater, in db(""" - SELECT author FROM ticket_change WHERE ticket=%s - ORDER BY time DESC LIMIT 1 - """, (tktid,)): - break - else: - for updater, in db("SELECT reporter FROM ticket WHERE id=%s", - (tktid,)): - break - - if not notify_updater: - filter_out = True - if notify_reporter and (updater == reporter): - filter_out = False - if notify_owner and (updater == owner): - filter_out = False - if filter_out: - torecipients = [r for r in torecipients - if r and r != updater] - elif updater: - torecipients.append(updater) - return (torecipients, ccrecipients, reporter, owner) +def get_ticket_notification_recipients(env, config, tktid, prev_cc=None, + modtime=None): + """Returns notifications recipients. + + :since 1.0.2: the `config` parameter is no longer used. + :since 1.0.2: the `prev_cc` parameter is deprecated. + """ + section = env.config['notification'] + always_notify_reporter = section.getbool('always_notify_reporter') + always_notify_owner = section.getbool('always_notify_owner') + always_notify_updater = section.getbool('always_notify_updater') + + cc_recipients = set(prev_cc or []) + to_recipients = set() + tkt = Ticket(env, tktid) + + # CC field is stored as comma-separated string. Parse to list. + to_list = lambda cc: cc.replace(',', ' ').split() + + # Backward compatibility + if not modtime: + modtime = tkt['changetime'] + + # Harvest email addresses from the cc, reporter, and owner fields + if tkt['cc']: + cc_recipients.update(to_list(tkt['cc'])) + if always_notify_reporter: + to_recipients.add(tkt['reporter']) + if always_notify_owner: + to_recipients.add(tkt['owner']) + + # Harvest email addresses from the author field of ticket_change(s) + if always_notify_updater: + for author, ticket in env.db_query(""" + SELECT DISTINCT author, ticket FROM ticket_change + WHERE ticket=%s + """, (tktid, )): + to_recipients.add(author) + + # Harvest previous owner and cc list + author = None + for changelog in tkt.get_changelog(modtime): + author, field, old = changelog[1:4] + if field == 'owner' and always_notify_owner: + to_recipients.add(old) + elif field == 'cc': + cc_recipients.update(to_list(old)) + + # Suppress the updater from the recipients if necessary + updater = author or tkt['reporter'] + if not always_notify_updater: + filter_out = True + if always_notify_reporter and updater == tkt['reporter']: + filter_out = False + if always_notify_owner and updater == tkt['owner']: + filter_out = False + if filter_out: + to_recipients.discard(updater) + elif updater: + to_recipients.add(updater) + + return list(to_recipients), list(cc_recipients), \ + tkt['reporter'], tkt['owner'] class TicketNotifyEmail(NotifyEmail): @@ -141,7 +153,6 @@ class TicketNotifyEmail(NotifyEmail): def __init__(self, env): NotifyEmail.__init__(self, env) - self.prev_cc = [] ambiguous_char_width = env.config.get('notification', 'ambiguous_char_width', 'single') @@ -219,7 +230,6 @@ class TicketNotifyEmail(NotifyEmail): self.ambiwidth) + '\n' if chgcc: changes_body += chgcc - self.prev_cc += self.parse_cc(old) if old else [] else: if field in ['owner', 'reporter']: old = self.obfuscate_email(old) @@ -306,13 +316,13 @@ class TicketNotifyEmail(NotifyEmail): width_l = self.COLS - width_r - 1 sep = width_l * '-' + '+' + width_r * '-' txt = sep + '\n' - cell_tmp = [u'', u''] + vals_lr = ([], []) big = [] i = 0 width_lr = [width_l, width_r] for f in [f for f in fields if f['name'] != 'description']: fname = f['name'] - if not tkt.values.has_key(fname): + if fname not in tkt.values: continue fval = tkt[fname] or '' if fname in ['owner', 'reporter']: @@ -324,15 +334,36 @@ class TicketNotifyEmail(NotifyEmail): # __str__ method won't be called. str_tmp = u'%s: %s' % (f['label'], unicode(fval)) idx = i % 2 - cell_tmp[idx] += wrap(str_tmp, width_lr[idx] - 2 + 2 * idx, - (width[2 * idx] - - self.get_text_width(f['label']) - + 2 * idx) * ' ', - 2 * ' ', '\n', self.ambiwidth) - cell_tmp[idx] += '\n' + initial_indent = ' ' * (width[2 * idx] - + self.get_text_width(f['label']) + + 2 * idx) + wrapped = wrap(str_tmp, width_lr[idx] - 2 + 2 * idx, + initial_indent, ' ', '\n', self.ambiwidth) + vals_lr[idx].append(wrapped.splitlines()) i += 1 - cell_l = cell_tmp[0].splitlines() - cell_r = cell_tmp[1].splitlines() + if len(vals_lr[0]) > len(vals_lr[1]): + vals_lr[1].append([]) + + cell_l = [] + cell_r = [] + for i in xrange(len(vals_lr[0])): + vals_l = vals_lr[0][i] + vals_r = vals_lr[1][i] + vals_diff = len(vals_l) - len(vals_r) + diff = len(cell_l) - len(cell_r) + if diff > 0: + # add padding to right side if needed + if vals_diff < 0: + diff += vals_diff + cell_r.extend([''] * max(diff, 0)) + elif diff < 0: + # add padding to left side if needed + if vals_diff > 0: + diff += vals_diff + cell_l.extend([''] * max(-diff, 0)) + cell_l.extend(vals_l) + cell_r.extend(vals_r) + for i in range(max(len(cell_l), len(cell_r))): if i >= len(cell_l): cell_l.append(width_l * ' ') @@ -383,9 +414,9 @@ class TicketNotifyEmail(NotifyEmail): return template.generate(**data).render('text', encoding=None).strip() def get_recipients(self, tktid): - (torecipients, ccrecipients, reporter, owner) = \ - get_ticket_notification_recipients(self.env, self.config, - tktid, self.prev_cc) + torecipients, ccrecipients, reporter, owner = \ + get_ticket_notification_recipients(self.env, self.config, tktid, + modtime=self.modtime) self.reporter = reporter self.owner = owner return (torecipients, ccrecipients) @@ -425,6 +456,7 @@ class TicketNotifyEmail(NotifyEmail): else: return obfuscate_email_address(text) + class BatchTicketNotifyEmail(NotifyEmail): """Notification of ticket batch modifications.""" @@ -443,7 +475,6 @@ class BatchTicketNotifyEmail(NotifyEmail def _notify(self, tickets, new_values, comment, action, author): self.tickets = tickets - changes_body = '' self.reporter = '' self.owner = '' changes_descr = '\n'.join(['%s to %s' % (prop, val) @@ -475,16 +506,15 @@ class BatchTicketNotifyEmail(NotifyEmail 'tickets_descr': tickets_descr, 'env': self.env, } - - return template.generate(**data).render('text', encoding=None).strip() + subj = template.generate(**data).render('text', encoding=None).strip() + return shorten_line(subj) def get_recipients(self, tktids): - alltorecipients = [] - allccrecipients = [] + alltorecipients = set() + allccrecipients = set() for t in tktids: - (torecipients, ccrecipients, reporter, owner) = \ - get_ticket_notification_recipients(self.env, self.config, - t, []) - alltorecipients.extend(torecipients) - allccrecipients.extend(ccrecipients) - return (list(set(alltorecipients)), list(set(allccrecipients))) + torecipients, ccrecipients, reporter, owner = \ + get_ticket_notification_recipients(self.env, self.config, t) + alltorecipients.update(torecipients) + allccrecipients.update(ccrecipients) + return list(alltorecipients), list(allccrecipients) Modified: bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/query.py URL: http://svn.apache.org/viewvc/bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/query.py?rev=1639823&r1=1639822&r2=1639823&view=diff ============================================================================== --- bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/query.py (original) +++ bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/query.py Sat Nov 15 01:14:46 2014 @@ -32,14 +32,15 @@ from trac.db import get_column_names from trac.mimeview.api import IContentConverter, Mimeview from trac.resource import Resource from trac.ticket.api import TicketSystem -from trac.ticket.model import Milestone, group_milestones, Ticket +from trac.ticket.model import Milestone, group_milestones from trac.util import Ranges, as_bool +from trac.util.compat import any from trac.util.datefmt import format_date, format_datetime, from_utimestamp, \ parse_date, to_timestamp, to_utimestamp, utc, \ user_time from trac.util.presentation import Paginator from trac.util.text import empty, shorten_line, quote_query_string -from trac.util.translation import _, tag_, cleandoc_ +from trac.util.translation import _, tag_, cleandoc_, ngettext from trac.web import arg_list_to_args, parse_arg_list, IRequestHandler from trac.web.href import Href from trac.web.chrome import (INavigationContributor, Chrome, @@ -434,11 +435,12 @@ class Query(object): enum_columns = ('resolution', 'priority', 'severity') # Build the list of actual columns to query - cols = self.cols[:] + cols = [] def add_cols(*args): for col in args: if not col in cols: cols.append(col) + add_cols(*self.cols) # remove duplicated cols if self.group and not self.group in cols: add_cols(self.group) if self.rows: @@ -456,14 +458,20 @@ class Query(object): if c not in custom_fields])) sql.append(",priority.value AS priority_value") for k in [db.quote(k) for k in cols if k in custom_fields]: - sql.append(",%s.value AS %s" % (k, k)) - sql.append("\nFROM ticket AS t") + sql.append(",t.%s AS %s" % (k, k)) - # Join with ticket_custom table as necessary - for k in [k for k in cols if k in custom_fields]: - qk = db.quote(k) - sql.append("\n LEFT OUTER JOIN ticket_custom AS %s ON " \ - "(id=%s.ticket AND %s.name='%s')" % (qk, qk, qk, k)) + # Use subquery of ticket_custom table as necessary + if any(k in custom_fields for k in cols): + sql.append('\nFROM (\n SELECT ' + + ','.join('t.%s AS %s' % (c, c) + for c in cols if c not in custom_fields)) + sql.extend(",\n (SELECT c.value FROM ticket_custom c " + "WHERE c.ticket=t.id AND c.name='%s') AS %s" + % (k, db.quote(k)) + for k in cols if k in custom_fields) + sql.append("\n FROM ticket AS t) AS t") + else: + sql.append("\nFROM ticket AS t") # Join with the enum table for proper sorting for col in [c for c in enum_columns @@ -490,7 +498,7 @@ class Query(object): if name not in custom_fields: col = 't.' + name else: - col = '%s.value' % db.quote(name) + col = 't.' + db.quote(name) value = value[len(mode) + neg:] if name in self.time_fields: @@ -579,11 +587,11 @@ class Query(object): if a == b: ids.append(str(a)) else: - id_clauses.append('id BETWEEN %s AND %s') + id_clauses.append('t.id BETWEEN %s AND %s') args.append(a) args.append(b) if ids: - id_clauses.append('id IN (%s)' % (','.join(ids))) + id_clauses.append('t.id IN (%s)' % (','.join(ids))) if id_clauses: clauses.append('%s(%s)' % ('NOT 'if neg else '', ' OR '.join(id_clauses))) @@ -592,7 +600,7 @@ class Query(object): if k not in custom_fields: col = 't.' + k else: - col = '%s.value' % db.quote(k) + col = 't.' + db.quote(k) clauses.append("COALESCE(%s,'') %sIN (%s)" % (col, 'NOT ' if neg else '', ','.join(['%s' for val in v]))) @@ -633,7 +641,7 @@ class Query(object): if name in enum_columns: col = name + '.value' elif name in custom_fields: - col = '%s.value' % db.quote(name) + col = 't.' + db.quote(name) else: col = 't.' + name desc = ' DESC' if desc else '' @@ -716,11 +724,17 @@ class Query(object): cols = self.get_columns() labels = TicketSystem(self.env).get_ticket_field_labels() wikify = set(f['name'] for f in self.fields - if f['type'] == 'text' and f.get('format') == 'wiki') + if f['type'] == 'text' and + f.get('format') == 'wiki') + wikifyblock = set(f['name'] for f in self.fields + if f['type'] == 'textarea' and + f.get('format') == 'wiki') + wikifyblock.add('description') headers = [{ 'name': col, 'label': labels.get(col, _('Ticket')), 'wikify': col in wikify, + 'wikifyblock': col in wikifyblock, 'href': self.get_href(context.href, order=col, desc=(col == self.order and not self.desc)) } for col in cols] @@ -872,7 +886,8 @@ class QueryModule(Component): def get_navigation_items(self, req): from trac.ticket.report import ReportModule if 'TICKET_VIEW' in req.perm and \ - not self.env.is_component_enabled(ReportModule): + not (self.env.is_component_enabled(ReportModule) and + 'REPORT_VIEW' in req.perm): yield ('mainnav', 'tickets', tag.a(_('View Tickets'), href=req.href.query())) @@ -883,6 +898,9 @@ class QueryModule(Component): def process_request(self, req): req.perm.assert_permission('TICKET_VIEW') + report_id = req.args.get('report') + if report_id: + req.perm('report', report_id).assert_permission('REPORT_VIEW') constraints = self._get_constraints(req) args = req.args @@ -937,7 +955,7 @@ class QueryModule(Component): max = args.get('max') if max is None and format in ('csv', 'tab'): max = 0 # unlimited unless specified explicitly - query = Query(self.env, req.args.get('report'), + query = Query(self.env, report_id, constraints, cols, args.get('order'), 'desc' in args, args.get('group'), 'groupdesc' in args, 'verbose' in args, @@ -1151,7 +1169,7 @@ class QueryModule(Component): values = [] for col in cols: value = result[col] - if col in ('cc', 'reporter'): + if col in ('cc', 'owner', 'reporter'): value = Chrome(self.env).format_emails( context.child(ticket), value) elif col in query.time_fields: @@ -1215,7 +1233,7 @@ class TicketQueryMacro(WikiMacroBase): can be included in field values by escaping them with a backslash (`\`). Groups of field constraints to be OR-ed together can be separated by a - litteral `or` argument. + literal `or` argument. In addition to filters, several other named parameters can be used to control how the results are presented. All of them are optional. @@ -1253,6 +1271,9 @@ class TicketQueryMacro(WikiMacroBase): The `rows` parameter can be used to specify which field(s) should be viewed as a row, e.g. `rows=description|summary` + The `col` parameter can be used to specify which fields should + be viewed as columns. For '''table''' format only. + For compatibility with Trac 0.10, if there's a last positional parameter given to the macro, it will be used to specify the `format`. Also, using "&" as a field separator still works (except for `order`) @@ -1313,8 +1334,10 @@ class TicketQueryMacro(WikiMacroBase): if format == 'count': cnt = query.count(req) - return tag.span(cnt, title='%d tickets for which %s' % - (cnt, query_string), class_='query_count') + title = ngettext("%(num)d ticket for which %(query)s", + "%(num)d tickets for which %(query)s", + cnt, query=query_string) + return tag.span(cnt, title=title, class_='query_count') tickets = query.execute(req) @@ -1336,14 +1359,21 @@ class TicketQueryMacro(WikiMacroBase): add_stylesheet(req, 'common/css/roadmap.css') def query_href(extra_args, group_value = None): - q = Query.from_string(self.env, query_string) + q = query_string + ''.join('&%s=%s' % (kw, v) + for kw in extra_args + if kw not in ['group', 'status'] + for v in extra_args[kw]) + q = Query.from_string(self.env, q) + args = {} if q.group: - extra_args[q.group] = group_value - q.group = None + args[q.group] = group_value + q.group = extra_args.get('group') + if 'status' in extra_args: + args['status'] = extra_args['status'] for constraint in q.constraints: - constraint.update(extra_args) + constraint.update(args) if not q.constraints: - q.constraints.append(extra_args) + q.constraints.append(args) return q.get_href(formatter.context) chrome = Chrome(self.env) tickets = apply_ticket_permissions(self.env, req, tickets) Modified: bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/report.py URL: http://svn.apache.org/viewvc/bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/report.py?rev=1639823&r1=1639822&r2=1639823&view=diff ============================================================================== --- bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/report.py (original) +++ bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/report.py Sat Nov 15 01:14:46 2014 @@ -43,7 +43,6 @@ from trac.web.chrome import (INavigation from trac.wiki import IWikiSyntaxProvider, WikiParser - SORT_COLUMN = '@SORT_COLUMN@' LIMIT_OFFSET = '@LIMIT_OFFSET@' @@ -63,9 +62,11 @@ _sql_re = re.compile(r''' | \([^()]+\) # parenthesis group ''', re.MULTILINE | re.VERBOSE) + def _expand_with_space(m): return ' ' * len(m.group(0)) + def sql_skeleton(sql): """Strip an SQL query to leave only its toplevel structure. @@ -89,6 +90,7 @@ def sql_skeleton(sql): _order_by_re = re.compile(r'ORDER\s+BY', re.MULTILINE) + def split_sql(sql, clause_re, skel=None): """Split an SQL query according to a toplevel clause regexp. @@ -107,7 +109,6 @@ def split_sql(sql, clause_re, skel=None) return sql, '' # no single clause separator - class ReportModule(Component): implements(INavigationContributor, IPermissionRequestor, IRequestHandler, @@ -148,13 +149,15 @@ class ReportModule(Component): return True def process_request(self, req): - req.perm.require('REPORT_VIEW') - # did the user ask for any special report? id = int(req.args.get('id', -1)) - action = req.args.get('action', 'view') + if id != -1: + req.perm('report', id).require('REPORT_VIEW') + else: + req.perm.require('REPORT_VIEW') data = {} + action = req.args.get('action', 'view') if req.method == 'POST': if action == 'new': self._do_create(req) @@ -164,7 +167,7 @@ class ReportModule(Component): self._do_save(req, id) elif action in ('copy', 'edit', 'new'): template = 'report_edit.html' - data = self._render_editor(req, id, action=='copy') + data = self._render_editor(req, id, action == 'copy') Chrome(self.env).add_wiki_toolbars(req) elif action == 'delete': template = 'report_delete.html' @@ -183,17 +186,19 @@ class ReportModule(Component): if content_type: # i.e. alternate format return template, data, content_type + from trac.ticket.query import QueryModule + show_query_link = 'TICKET_VIEW' in req.perm and \ + self.env.is_component_enabled(QueryModule) + if id != -1 or action == 'new': add_ctxtnav(req, _('Available Reports'), href=req.href.report()) add_link(req, 'up', req.href.report(), _('Available Reports')) - else: + elif show_query_link: add_ctxtnav(req, _('Available Reports')) # Kludge: only show link to custom query if the query module # is actually enabled - from trac.ticket.query import QueryModule - if 'TICKET_VIEW' in req.perm and \ - self.env.is_component_enabled(QueryModule): + if show_query_link: add_ctxtnav(req, _('Custom Query'), href=req.href.query()) data['query_href'] = req.href.query() data['saved_query_href'] = req.session.get('query_href') @@ -224,7 +229,7 @@ class ReportModule(Component): req.redirect(req.href.report(report_id)) def _do_delete(self, req, id): - req.perm.require('REPORT_DELETE') + req.perm('report', id).require('REPORT_DELETE') if 'cancel' in req.args: req.redirect(req.href.report(id)) @@ -235,7 +240,7 @@ class ReportModule(Component): def _do_save(self, req, id): """Save report changes to the database""" - req.perm.require('REPORT_MODIFY') + req.perm('report', id).require('REPORT_MODIFY') if 'cancel' not in req.args: title = req.args.get('title', '') @@ -249,29 +254,18 @@ class ReportModule(Component): req.redirect(req.href.report(id)) def _render_confirm_delete(self, req, id): - req.perm.require('REPORT_DELETE') + req.perm('report', id).require('REPORT_DELETE') - for title, in self.env.db_query(""" - SELECT title FROM report WHERE id=%s - """, (id,)): - return {'title': _("Delete Report {%(num)s} %(title)s", num=id, - title=title), - 'action': 'delete', - 'report': {'id': id, 'title': title}} - else: - raise TracError(_("Report {%(num)s} does not exist.", num=id), - _("Invalid Report Number")) + title = self.get_report(id)[0] + return {'title': _("Delete Report {%(num)s} %(title)s", num=id, + title=title), + 'action': 'delete', + 'report': {'id': id, 'title': title}} def _render_editor(self, req, id, copy): if id != -1: - req.perm.require('REPORT_MODIFY') - for title, description, query in self.env.db_query( - "SELECT title, description, query FROM report WHERE id=%s", - (id,)): - break - else: - raise TracError(_("Report {%(num)s} does not exist.", num=id), - _("Invalid Report Number")) + req.perm('report', id).require('REPORT_MODIFY') + title, description, query = self.get_report(id) else: req.perm.require('REPORT_CREATE') title = description = query = '' @@ -306,6 +300,8 @@ class ReportModule(Component): SELECT id, title, description FROM report ORDER BY %s %s """ % ('title' if sort == 'title' else 'id', '' if asc else 'DESC')) + rows = [(id, title, description) for id, title, description in rows + if 'REPORT_VIEW' in req.perm('report', id)] if format == 'rss': data = {'rows': rows} @@ -344,13 +340,7 @@ class ReportModule(Component): def _render_view(self, req, id): """Retrieve the report results and pre-process them for rendering.""" - for title, sql, description in self.env.db_query(""" - SELECT title, query, description from report WHERE id=%s - """, (id,)): - break - else: - raise ResourceNotFound(_("Report {%(num)s} does not exist.", - num=id), _("Invalid Report Number")) + title, description, sql = self.get_report(id) try: args = self.get_var_args(req) except ValueError, e: @@ -393,7 +383,7 @@ class ReportModule(Component): title = '{%i} %s' % (id, title) report_resource = Resource('report', id) - req.perm.require('REPORT_VIEW', report_resource) + req.perm(report_resource).require('REPORT_VIEW') context = web_context(req, report_resource) page = int(req.args.get('page', '1')) @@ -436,13 +426,13 @@ class ReportModule(Component): offset) 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"))) - return 'report_view.html', data, None + 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"))) + return 'report_view.html', data, None cols, results, num_items, missing_args, limit_offset = res need_paginator = limit > 0 and limit_offset @@ -469,8 +459,8 @@ class ReportModule(Component): 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} + 'string': str(paginator.page + 1), + 'title': None} numrows = paginator.num_items # Place retrieved columns in groups, according to naming conventions @@ -609,7 +599,7 @@ class ReportModule(Component): if format == 'rss': data['email_map'] = chrome.get_email_map() data['context'] = web_context(req, report_resource, - absurls=True) + absurls=True) return 'report.rss', data, 'application/rss+xml' elif format == 'csv': filename = 'report_%s.csv' % id if id else 'report.csv' @@ -629,7 +619,7 @@ class ReportModule(Component): _('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: + if 'REPORT_SQL_VIEW' in req.perm('report', id): add_link(req, 'alternate', req.href.report(id=id, format='sql'), _('SQL Query'), 'text/plain') @@ -692,6 +682,9 @@ class ReportModule(Component): try: cursor.execute(count_sql, args) except Exception, e: + self.log.warn('Exception caught while executing report: %r, ' + 'args %r%s', count_sql, args, + exception_to_unicode(e, traceback=True)) return e, count_sql num_items = cursor.fetchone()[0] @@ -701,6 +694,9 @@ class ReportModule(Component): try: cursor.execute(colnames_sql, args) except Exception, e: + self.log.warn('Exception caught while executing report: %r, ' + 'args %r%s', colnames_sql, args, + exception_to_unicode(e, traceback=True)) return e, colnames_sql cols = get_column_names(cursor) @@ -724,7 +720,7 @@ class ReportModule(Component): sql = sql.replace(SORT_COLUMN, sort_col or '1') elif sort_col: # Method 2: automagically insert sort_col (and __group__ - # before it, if __group__ was specified) as first criterions + # before it, if __group__ was specified) as first criteria if '__group__' in cols: order_by.append('__group__ ASC') order_by.append(sort_col) @@ -752,6 +748,9 @@ class ReportModule(Component): try: cursor.execute(sql, args) except Exception, e: + self.log.warn('Exception caught while executing report: %r, args ' + '%r%s', + sql, args, exception_to_unicode(e, traceback=True)) if order_by or limit_offset: add_notice(req, _("Hint: if the report failed due to automatic" " modification of the ORDER BY clause or the" @@ -766,6 +765,20 @@ class ReportModule(Component): cols = get_column_names(cursor) return cols, rows, num_items, missing_args, limit_offset + def get_report(self, id): + try: + number = int(id) + except (ValueError, TypeError): + pass + else: + for title, description, sql in self.env.db_query(""" + SELECT title, description, query from report WHERE id=%s + """, (number,)): + return title, description, sql + + raise ResourceNotFound(_("Report {%(num)s} does not exist.", num=id), + _("Invalid Report Number")) + def get_var_args(self, req): # reuse somehow for #9574 (wiki vars) report_args = {} @@ -874,7 +887,7 @@ class ReportModule(Component): raise RequestDone def _send_sql(self, req, id, title, description, sql): - req.perm.require('REPORT_SQL_VIEW') + req.perm('report', id).require('REPORT_SQL_VIEW') out = StringIO() out.write('-- ## %s: %s ## --\n\n' % (id, title.encode('utf-8'))) @@ -901,8 +914,8 @@ class ReportModule(Component): yield ('report', self._format_link) def get_wiki_syntax(self): - yield (r"!?\{(?P<it_report>%s\s*)[0-9]+\}" % \ - WikiParser.INTERTRAC_SCHEME, + yield (r"!?\{(?P<it_report>%s\s*)[0-9]+\}" % + WikiParser.INTERTRAC_SCHEME, lambda x, y, z: self._format_link(x, 'report', y[1:-1], y, z)) def _format_link(self, formatter, ns, target, label, fullmatch=None): @@ -910,6 +923,16 @@ class ReportModule(Component): fullmatch) if intertrac: return intertrac - report, args, fragment = formatter.split_link(target) - return tag.a(label, href=formatter.href.report(report) + args, - class_='report') + id, args, fragment = formatter.split_link(target) + try: + self.get_report(id) + except ResourceNotFound: + return tag.a(label, class_='missing report', + title=_("report does not exist")) + else: + if 'REPORT_VIEW' in formatter.req.perm('report', id): + return tag.a(label, href=formatter.href.report(id) + args, + class_='report') + else: + return tag.a(label, class_='forbidden report', + title=_("no permission to view report")) Modified: bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/roadmap.py URL: http://svn.apache.org/viewvc/bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/roadmap.py?rev=1639823&r1=1639822&r2=1639823&view=diff ============================================================================== --- bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/roadmap.py (original) +++ bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/roadmap.py Sat Nov 15 01:14:46 2014 @@ -31,12 +31,13 @@ from trac.perm import IPermissionRequest from trac.resource import * from trac.search import ISearchSource, search_to_regexps, shorten_result from trac.util import as_bool -from trac.util.datefmt import parse_date, utc, to_utimestamp, to_datetime, \ +from trac.util.datefmt import parse_date, utc, pretty_timedelta, to_datetime, \ get_datetime_format_hint, format_date, \ format_datetime, from_utimestamp, user_time -from trac.util.text import CRLF +from trac.util.text import CRLF, exception_to_unicode, to_unicode from trac.util.translation import _, tag_ from trac.ticket.api import TicketSystem +from trac.ticket.batch import BatchTicketNotifyEmail from trac.ticket.model import Milestone, MilestoneCache, Ticket, \ group_milestones from trac.timeline.api import ITimelineEventProvider @@ -349,6 +350,13 @@ def grouped_stats_data(env, stats_provid group_names = field['options'] if field.get('optional'): group_names.insert(0, '') + elif field.get('custom'): + group_names = [name for name, in env.db_query(""" + SELECT DISTINCT COALESCE(c.value, '') FROM ticket_custom c + WHERE c.name=%s ORDER BY COALESCE(c.value, '') + """, (by, ))] + if '' not in group_names: + group_names.insert(0, '') else: group_names = [name for name, in env.db_query(""" SELECT DISTINCT COALESCE(%s, '') FROM ticket @@ -415,7 +423,7 @@ class RoadmapModule(Component): return req.path_info == '/roadmap' def process_request(self, req): - req.perm.require('MILESTONE_VIEW') + req.perm.require('ROADMAP_VIEW') show = req.args.getlist('show') if 'all' in show: @@ -671,7 +679,7 @@ class MilestoneModule(Component): action = 'edit' # rather than 'new' so that it works for POST/save if req.method == 'POST': - if req.args.has_key('cancel'): + if 'cancel' in req.args: if milestone.exists: req.redirect(req.href.milestone(milestone.name)) else: @@ -695,12 +703,33 @@ class MilestoneModule(Component): def _do_delete(self, req, milestone): req.perm(milestone.resource).require('MILESTONE_DELETE') - retarget_to = None - if req.args.has_key('retarget'): - retarget_to = req.args.get('target') or None - milestone.delete(retarget_to, req.authname) + retarget_to = req.args.get('target') or None + # Don't translate ticket comment (comment:40:ticket:5658) + retargeted_tickets = \ + milestone.move_tickets(retarget_to, req.authname, + "Ticket retargeted after milestone deleted") + milestone.delete(author=req.authname) add_notice(req, _('The milestone "%(name)s" has been deleted.', name=milestone.name)) + if retargeted_tickets: + add_notice(req, _('The tickets associated with milestone ' + '"%(name)s" have been retargeted to milestone ' + '"%(retarget)s".', name=milestone.name, + retarget=retarget_to)) + new_values = {'milestone': retarget_to} + comment = _("Tickets retargeted after milestone deleted") + tn = BatchTicketNotifyEmail(self.env) + try: + tn.notify(retargeted_tickets, new_values, comment, None, + req.authname) + except Exception, e: + self.log.error("Failure sending notification on ticket batch " + "change: %s", exception_to_unicode(e)) + add_warning(req, tag_("The changes have been saved, but an " + "error occurred while sending " + "notifications: %(message)s", + message=to_unicode(e))) + req.redirect(req.href.roadmap()) def _do_save(self, req, milestone): @@ -722,7 +751,7 @@ class MilestoneModule(Component): milestone.due = None completed = req.args.get('completeddate', '') - retarget_to = req.args.get('target') + retarget_to = req.args.get('target') or None # Instead of raising one single error, check all the constraints and # let the user fix them by going back to edit mode showing the warnings @@ -764,15 +793,31 @@ class MilestoneModule(Component): # -- actually save changes if milestone.exists: - milestone.update() - # eventually retarget opened tickets associated with the milestone - if 'retarget' in req.args and completed: - self.env.db_transaction(""" - UPDATE ticket SET milestone=%s - WHERE milestone=%s and status != 'closed' - """, (retarget_to, old_name)) - self.log.info("Tickets associated with milestone %s " - "retargeted to %s" % (old_name, retarget_to)) + milestone.update(author=req.authname) + if completed and 'retarget' in req.args: + comment = req.args.get('comment', '') + retargeted_tickets = \ + milestone.move_tickets(retarget_to, req.authname, + comment, exclude_closed=True) + add_notice(req, _('The open tickets associated with ' + 'milestone "%(name)s" have been retargeted ' + 'to milestone "%(retarget)s".', + name=milestone.name, retarget=retarget_to)) + new_values = {'milestone': retarget_to} + comment = comment or \ + _("Open tickets retargeted after milestone closed") + tn = BatchTicketNotifyEmail(self.env) + try: + tn.notify(retargeted_tickets, new_values, comment, None, + req.authname) + except Exception, e: + self.log.error("Failure sending notification on ticket " + "batch change: %s", + exception_to_unicode(e)) + add_warning(req, tag_("The changes have been saved, but " + "an error occurred while sending " + "notifications: %(message)s", + message=to_unicode(e))) else: milestone.insert() @@ -816,6 +861,9 @@ class MilestoneModule(Component): 'TICKET_ADMIN' in req.perm) else: req.perm(milestone.resource).require('MILESTONE_CREATE') + if milestone.name: + add_notice(req, _("Milestone %(name)s does not exist. You can" + " create it here.", name=milestone.name)) chrome = Chrome(self.env) chrome.add_jquery_ui(req) @@ -910,7 +958,7 @@ class MilestoneModule(Component): def _render_link(self, context, name, label, extra=''): try: milestone = Milestone(self.env, name) - except TracError: + except ResourceNotFound: milestone = None # Note: the above should really not be needed, `Milestone.exists` # should simply be false if the milestone doesn't exist in the db @@ -918,9 +966,29 @@ class MilestoneModule(Component): href = context.href.milestone(name) if milestone and milestone.exists: if 'MILESTONE_VIEW' in context.perm(milestone.resource): + title = None + if hasattr(context, 'req'): + if milestone.is_completed: + title = _( + 'Completed %(duration)s ago (%(date)s)', + duration=pretty_timedelta(milestone.completed), + date=user_time(context.req, format_datetime, + milestone.completed)) + elif milestone.is_late: + title = _('%(duration)s late (%(date)s)', + duration=pretty_timedelta(milestone.due), + date=user_time(context.req, format_datetime, + milestone.due)) + elif milestone.due: + title = _('Due in %(duration)s (%(date)s)', + duration=pretty_timedelta(milestone.due), + date=user_time(context.req, format_datetime, + milestone.due)) + else: + title = _('No date set') closed = 'closed ' if milestone.is_completed else '' return tag.a(label, class_='%smilestone' % closed, - href=href + extra) + href=href + extra, title=title) elif 'MILESTONE_CREATE' in context.perm('milestone', name): return tag.a(label, class_='missing milestone', href=href + extra, rel='nofollow') @@ -972,7 +1040,7 @@ class MilestoneModule(Component): milestone_realm = Resource('milestone') for name, due, completed, description \ in MilestoneCache(self.env).milestones.itervalues(): - if any(r.search(description) or r.search(name) + if all(r.search(description) or r.search(name) for r in term_regexps): milestone = milestone_realm(id=name) if 'MILESTONE_VIEW' in req.perm(milestone): Modified: bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/templates/batch_modify.html URL: http://svn.apache.org/viewvc/bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/templates/batch_modify.html?rev=1639823&r1=1639822&r2=1639823&view=diff ============================================================================== --- bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/templates/batch_modify.html (original) +++ bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/templates/batch_modify.html Sat Nov 15 01:14:46 2014 @@ -1,3 +1,13 @@ +<!--! Copyright (C) 2012-2014 Edgewall Software + + 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://trac.edgewall.org/. +--> <form xmlns="http://www.w3.org/1999/xhtml" xmlns:py="http://genshi.edgewall.org/" xmlns:i18n="http://genshi.edgewall.org/i18n" @@ -12,7 +22,7 @@ <label for="batchmod_value_comment">Comment:</label> </th> <td class="fullrow"><textarea - id="batchmod_value_comment" name="batchmod_value_comment" cols="70" rows="5"/> + id="batchmod_value_comment" name="batchmod_value_comment" class="trac-fullwidth" cols="70" rows="5"/> </td> </tr> @@ -54,7 +64,7 @@ <div> <input type="hidden" name="selected_tickets" value=""/> <input type="hidden" name="query_href" value="${query_href}"/> - <input type="submit" id="batchmod_submit" name="batchmod_submit" value="${_('Change tickets')}" /> + <input type="submit" id="batchmod_submit" name="batchmod_submit" class="trac-disable-on-submit" value="${_('Change tickets')}" /> </div> </fieldset> Modified: bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/templates/milestone_delete.html URL: http://svn.apache.org/viewvc/bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/templates/milestone_delete.html?rev=1639823&r1=1639822&r2=1639823&view=diff ============================================================================== --- bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/templates/milestone_delete.html (original) +++ bloodhound/branches/trac-1.0.2-integration/trac/trac/ticket/templates/milestone_delete.html Sat Nov 15 01:14:46 2014 @@ -1,3 +1,13 @@ +<!--! Copyright (C) 2006-2014 Edgewall Software + + 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://trac.edgewall.org/. +--> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> @@ -10,11 +20,6 @@ <title i18n:msg="name">Delete Milestone ${milestone.name}</title> <link rel="stylesheet" type="text/css" href="${chrome.htdocs_location}css/roadmap.css" /> - <script type="text/javascript"> - jQuery(document).ready(function($) { - $("#retarget").click(function(){ $("#target").enable(this.checked) }); - }); - </script> </head> <body> @@ -25,7 +30,6 @@ <div> <input type="hidden" name="action" value="delete" /> <p><strong>Are you sure you want to delete this milestone?</strong></p> - <input type="checkbox" id="retarget" name="retarget" checked="checked" /> <label for="target">Retarget associated tickets to milestone</label> <select name="target" id="target"> <option value="">None</option> @@ -37,8 +41,8 @@ </select> </div> <div class="buttons"> + <input type="submit" id="delete" class="trac-disable-on-submit" value="${_('Delete milestone')}" /> <input type="submit" name="cancel" value="${_('Cancel')}" /> - <input type="submit" value="${_('Delete milestone')}" /> </div> </form>
