# -*- coding: utf-8 -*- # # Copyright (C) 2005-2021 Edgewall Software # All rights reserved. # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. The terms # are also available at https://trac.edgewall.org/wiki/TracLicense. # # This software consists of voluntary contributions made by many # individuals. For the exact contribution history, see the revision # history and logs, available at https://trac.edgewall.org/. from trac.admin.api import AdminCommandError, IAdminCommandProvider, \ IAdminPanelProvider, console_date_format, \ console_datetime_format, get_console_locale from trac.core import * from trac.resource import ResourceNotFound from trac.ticket import model from trac.ticket.api import TicketSystem from trac.ticket.roadmap import ( MilestoneModule, get_num_tickets_for_milestone, group_milestones) from trac.util import as_int, getuser from trac.util.datefmt import format_date, format_datetime, \ get_datetime_format_hint, parse_date, user_time from trac.util.text import exception_to_unicode, print_table, printout from trac.util.translation import _, N_, gettext from trac.web.chrome import Chrome, add_ctxtnav, add_notice, add_script, \ add_warning class TicketAdminPanel(Component): implements(IAdminPanelProvider, IAdminCommandProvider) abstract = True _type = 'undefined' _label = N_("(Undefined)"), N_("(Undefined)") # i18n note: use gettext() whenever referring to the above as text labels, # 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('admin', 'ticket/' + self._type): yield ('ticket', _('Ticket System'), self._type, gettext(self._label[1])) def render_admin_panel(self, req, cat, page, path_info): # Trap AssertionErrors and convert them to TracErrors try: return self._render_admin_panel(req, cat, page, path_info) except AssertionError as e: raise TracError(e) from e def _save_config(self, req): """Try to save the config, and display either a success notice or a failure warning. """ try: self.config.save() except EnvironmentError as e: self.log.error("Error writing to trac.ini: %s", exception_to_unicode(e)) add_warning(req, _("Error writing to trac.ini, make sure it is " "writable by the web server. Your changes " "have not been saved.")) else: add_notice(req, _("Your changes have been saved.")) def _render_admin_panel(self, req, cat, page, path_info): raise NotImplemented("Class inheriting from TicketAdminPanel has not " "implemented the _render_admin_panel method.") class ComponentAdminPanel(TicketAdminPanel): _type = 'components' _label = N_("Component"), N_("Components") # TicketAdminPanel methods def _render_admin_panel(self, req, cat, page, component): # Detail view? if component: comp = model.Component(self.env, component) if req.method == 'POST': if req.args.get('save'): comp.name = req.args.get('name') comp.owner = req.args.get('owner') comp.description = req.args.get('description') comp.update() add_notice(req, _("Your changes have been saved.")) req.redirect(req.href.admin(cat, page)) elif req.args.get('cancel'): req.redirect(req.href.admin(cat, page)) chrome = Chrome(self.env) chrome.add_wiki_toolbars(req) chrome.add_auto_preview(req) data = {'view': 'detail', 'component': comp} else: default = self.config.get('ticket', 'default_component') if req.method == 'POST': # Add Component if req.args.get('add') and req.args.get('name'): comp = model.Component(self.env) comp.name = req.args.get('name') comp.owner = req.args.get('owner') comp.insert() add_notice(req, _('The component "%(name)s" has been ' 'added.', name=comp.name)) # Remove components elif req.args.get('remove'): sel = req.args.getlist('sel') if not sel: raise TracError(_("No component selected")) with self.env.db_transaction: for name in sel: model.Component(self.env, name).delete() if name == default: self.config.set('ticket', 'default_component', '') self._save_config(req) add_notice(req, _("The selected components have been " "removed.")) # Set default component elif req.args.get('apply'): name = req.args.get('default') if name and name != default: self.log.info("Setting default component to %s", name) self.config.set('ticket', 'default_component', name) self._save_config(req) # Clear default component elif req.args.get('clear'): self.log.info("Clearing default component") self.config.set('ticket', 'default_component', '') self._save_config(req) req.redirect(req.href.admin(cat, page)) data = {'view': 'list', 'components': list(model.Component.select(self.env)), 'default': default} owners = TicketSystem(self.env).get_allowed_owners() if owners is not None: owners.insert(0, '') data.update({'owners': owners}) return 'admin_components.html', data # IAdminCommandProvider methods def get_admin_commands(self): yield ('component list', '', "Show components", None, self._do_list) yield ('component add', ' [owner]', "Add component", self._complete_add, self._do_add) yield ('component rename', ' ', "Rename component", self._complete_name, self._do_rename) yield ('component remove', '', "Remove component", self._complete_name, self._do_remove) yield ('component chown', ' ', "Change component owner", self._complete_chown, self._do_chown) def get_component_list(self): return [c.name for c in model.Component.select(self.env)] def get_user_list(self): return TicketSystem(self.env).get_allowed_owners() def _complete_add(self, args): if len(args) == 2: return self.get_user_list() def _complete_name(self, args): if len(args) == 1: return self.get_component_list() def _complete_chown(self, args): if len(args) == 1: return self.get_component_list() elif len(args) == 2: return self.get_user_list() def _do_list(self): print_table([(c.name, c.owner) for c in model.Component.select(self.env)], [_("Name"), _("Owner")]) def _do_add(self, name, owner=None): component = model.Component(self.env) component.name = name component.owner = owner component.insert() def _do_rename(self, name, newname): component = model.Component(self.env, name) component.name = newname component.update() def _do_remove(self, name): model.Component(self.env, name).delete() def _do_chown(self, name, owner): component = model.Component(self.env, name) component.owner = owner component.update() class MilestoneAdminPanel(TicketAdminPanel): _type = 'milestones' _label = N_("Milestone"), N_("Milestones") # TicketAdminPanel methods def get_admin_panels(self, req): perm = req.perm('admin', 'ticket/' + self._type) if 'MILESTONE_ADMIN' in perm or \ 'MILESTONE_VIEW' in perm and 'TICKET_ADMIN' in perm: yield ('ticket', _('Ticket System'), self._type, gettext(self._label[1])) def _render_admin_panel(self, req, cat, page, milestone_name): perm_cache = req.perm('admin', 'ticket/' + self._type) # Detail view if milestone_name: milestone = model.Milestone(self.env, milestone_name) milestone_module = MilestoneModule(self.env) if req.method == 'POST': if 'save' in req.args: perm_cache.require('MILESTONE_MODIFY') if milestone_module.save_milestone(req, milestone): req.redirect(req.href.admin(cat, page)) elif 'cancel' in req.args: req.redirect(req.href.admin(cat, page)) data = { 'view': 'detail', 'milestone': milestone, 'default_due': milestone_module.get_default_due(req), 'retarget_to': milestone_module.default_retarget_to } milestones = [m for m in model.Milestone.select(self.env) if m.name != milestone.name and 'MILESTONE_VIEW' in req.perm(m.resource)] data['milestone_groups'] = \ group_milestones(milestones, 'TICKET_ADMIN' in req.perm) data['num_open_tickets'] = \ get_num_tickets_for_milestone(self.env, milestone, exclude_closed=True) chrome = Chrome(self.env) chrome.add_wiki_toolbars(req) chrome.add_auto_preview(req) add_ctxtnav(req, _("View Milestone"), req.href.milestone(milestone_name)) # List view else: ticket_default = self.config.get('ticket', 'default_milestone') retarget_default = self.config.get('milestone', 'default_retarget_to') if req.method == 'POST': # Add milestone if 'add' in req.args and req.args.get('name'): perm_cache.require('MILESTONE_CREATE') name = req.args.get('name') try: model.Milestone(self.env, name=name) except ResourceNotFound: milestone = model.Milestone(self.env) milestone.name = name MilestoneModule(self.env).save_milestone(req, milestone) else: add_warning(req, _('Milestone "%(name)s" already ' 'exists, please choose another ' 'name.', name=name)) # Remove milestone elif 'remove' in req.args: save = False perm_cache.require('MILESTONE_DELETE') sel = req.args.getlist('sel') if not sel: raise TracError(_("No milestone selected")) with self.env.db_transaction: for name in sel: milestone = model.Milestone(self.env, name) milestone.move_tickets(None, req.authname, "Milestone deleted") milestone.delete() if name == ticket_default: self.config.set('ticket', 'default_milestone', '') save = True if name == retarget_default: self.config.set('milestone', 'default_retarget_to', '') save = True if save: self._save_config(req) add_notice(req, _("The selected milestones have been " "removed.")) # Set default ticket milestone and retarget milestone elif 'apply' in req.args: save = False perm_cache.require('TICKET_ADMIN') name = req.args.get('ticket_default') if name and name != ticket_default: self.log.info("Setting default ticket " "milestone to %s", name) self.config.set('ticket', 'default_milestone', name) save = True retarget = req.args.get('retarget_default') if retarget and retarget != retarget_default: self.log.info("Setting default retargeting " "milestone to %s", retarget) self.config.set('milestone', 'default_retarget_to', retarget) save = True if save: self._save_config(req) # Clear default ticket milestone and retarget milestone elif 'clear' in req.args: perm_cache.require('TICKET_ADMIN') self.log.info("Clearing default ticket milestone " "and default retarget milestone") self.config.set('ticket', 'default_milestone', '') self.config.set('milestone', 'default_retarget_to', '') self._save_config(req) req.redirect(req.href.admin(cat, page)) # Get ticket count num_tickets = dict(self.env.db_query(""" SELECT milestone, COUNT(milestone) FROM ticket WHERE milestone != '' GROUP BY milestone """)) query_href = lambda name: req.href.query([('group', 'status'), ('milestone', name)]) data = {'view': 'list', 'milestones': model.Milestone.select(self.env), 'query_href': query_href, 'num_tickets': lambda m: num_tickets.get(m.name, 0), 'ticket_default': ticket_default, 'retarget_default': retarget_default} Chrome(self.env).add_jquery_ui(req) data.update({ 'datetime_hint': get_datetime_format_hint(req.lc_time), }) return 'admin_milestones.html', data # IAdminCommandProvider methods def get_admin_commands(self): locale = get_console_locale(self.env) hints = { 'datetime': get_datetime_format_hint(locale), 'iso8601': get_datetime_format_hint('iso8601'), } yield ('milestone list', '', "Show milestones", None, self._do_list) yield ('milestone add', ' [due]', "Add milestone", None, self._do_add) yield ('milestone rename', ' ', "Rename milestone", self._complete_name, self._do_rename) yield ('milestone due', ' ', """Set milestone due date The 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 (""). """ % hints, self._complete_name, self._do_due) yield ('milestone completed', ' ', """Set milestone complete date The 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 (""). """ % hints, self._complete_name, self._do_completed) yield ('milestone remove', '', "Remove milestone", self._complete_name, self._do_remove) def get_milestone_list(self): return [m.name for m in model.Milestone.select(self.env)] def _complete_name(self, args): if len(args) == 1: return self.get_milestone_list() def _do_list(self): 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")]) def _do_add(self, name, due=None): milestone = model.Milestone(self.env) milestone.name = name if due is not None: 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(author=getuser()) def _do_due(self, name, due): milestone = model.Milestone(self.env, name) 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 = parse_date(completed, hint='datetime', locale=get_console_locale(self.env)) \ if completed else None milestone.update() def _do_remove(self, name): model.Milestone(self.env, name).delete() class VersionAdminPanel(TicketAdminPanel): _type = 'versions' _label = N_("Version"), N_("Versions") # TicketAdminPanel methods def _render_admin_panel(self, req, cat, page, version): # Detail view? if version: ver = model.Version(self.env, version) if req.method == 'POST': if req.args.get('save'): ver.name = req.args.get('name') ver.time = self._get_user_time(req) ver.description = req.args.get('description') ver.update() add_notice(req, _("Your changes have been saved.")) req.redirect(req.href.admin(cat, page)) elif req.args.get('cancel'): req.redirect(req.href.admin(cat, page)) chrome = Chrome(self.env) chrome.add_wiki_toolbars(req) chrome.add_auto_preview(req) data = {'view': 'detail', 'version': ver} else: default = self.config.get('ticket', 'default_version') if req.method == 'POST': # Add Version if req.args.get('add') and req.args.get('name'): ver = model.Version(self.env) ver.name = req.args.get('name') ver.time = self._get_user_time(req) ver.insert() add_notice(req, _('The version "%(name)s" has been ' 'added.', name=ver.name)) # Remove versions elif req.args.get('remove'): sel = req.args.getlist('sel') if not sel: raise TracError(_("No version selected")) with self.env.db_transaction: for name in sel: model.Version(self.env, name).delete() if name == default: self.config.set('ticket', 'default_version', '') self._save_config(req) add_notice(req, _("The selected versions have been " "removed.")) # Set default version elif req.args.get('apply'): name = req.args.get('default') if name and name != default: self.log.info("Setting default version to %s", name) self.config.set('ticket', 'default_version', name) self._save_config(req) # Clear default version elif req.args.get('clear'): self.log.info("Clearing default version") self.config.set('ticket', 'default_version', '') self._save_config(req) req.redirect(req.href.admin(cat, page)) data = {'view': 'list', 'versions': list(model.Version.select(self.env)), 'default': default} Chrome(self.env).add_jquery_ui(req) data.update({'datetime_hint': get_datetime_format_hint(req.lc_time)}) return 'admin_versions.html', data @classmethod def _get_user_time(cls, req): time = req.args.get('time') return user_time(req, parse_date, time, hint='datetime') \ if time else None # IAdminCommandProvider methods def get_admin_commands(self): locale = get_console_locale(self.env) hints = { 'datetime': get_datetime_format_hint(locale), 'iso8601': get_datetime_format_hint('iso8601'), } yield ('version list', '', "Show versions", None, self._do_list) yield ('version add', ' [time]', "Add version", None, self._do_add) yield ('version rename', ' ', "Rename version", self._complete_name, self._do_rename) yield ('version remove', '', "Remove version", self._complete_name, self._do_remove) yield ('version time', '