trac/ticket/admin.py
# -*- coding: utf-8 -*-
#
# Copyright (C) 2005-2023 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', '<name> [owner]',
"Add component",
self._complete_add, self._do_add)
yield ('component rename', '<name> <newname>',
"Rename component",
self._complete_name, self._do_rename)
yield ('component remove', '<name>',
"Remove component",
self._complete_name, self._do_remove)
yield ('component chown', '<name> <owner>',
"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', '<name> [due]',
"Add milestone",
None, self._do_add)
yield ('milestone rename', '<name> <newname>',
"Rename milestone",
self._complete_name, self._do_rename)
yield ('milestone due', '<name> <due>',
"""Set milestone due date
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 ("").
""" % hints,
self._complete_name, self._do_due)
yield ('milestone completed', '<name> <completed>',
"""Set milestone complete date
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 ("").
""" % hints,
self._complete_name, self._do_completed)
yield ('milestone remove', '<name>',
"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', '<name> [time]',
"Add version",
None, self._do_add)
yield ('version rename', '<name> <newname>',
"Rename version",
self._complete_name, self._do_rename)
yield ('version remove', '<name>',
"Remove version",
self._complete_name, self._do_remove)
yield ('version time', '<name> <time>',
"""Set version date
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)
def get_version_list(self):
return [v.name for v in model.Version.select(self.env)]
def _complete_name(self, args):
if len(args) == 1:
return self.get_version_list()
def _do_list(self):
print_table([(v.name,
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
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):
version = model.Version(self.env, name)
version.name = newname
version.update()
def _do_remove(self, name):
model.Version(self.env, name).delete()
def _do_time(self, name, time):
version = model.Version(self.env, name)
version.time = parse_date(time, hint='datetime',
locale=get_console_locale(self.env)) \
if time else None
version.update()
class AbstractEnumAdminPanel(TicketAdminPanel):
abstract = True
_type = 'unknown'
_enum_cls = None
# TicketAdminPanel methods
def _render_admin_panel(self, req, cat, page, path_info):
label = [gettext(each) for each in self._label]
data = {'label_singular': label[0], 'label_plural': label[1],
'type': self._type}
# Detail view?
if path_info:
enum = self._enum_cls(self.env, path_info)
if req.method == 'POST':
if req.args.get('save'):
enum.name = req.args.get('name')
enum.description = req.args.get('description')
enum.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.update({'view': 'detail', 'enum': enum})
else:
default = self.config.get('ticket', 'default_%s' % self._type)
if req.method == 'POST':
# Add enum
if req.args.get('add') and req.args.get('name'):
enum = self._enum_cls(self.env)
enum.name = req.args.get('name')
enum.insert()
add_notice(req, _('The %(field)s value "%(name)s" '
'has been added.',
field=label[0], name=enum.name))
# Remove enums
elif req.args.get('remove'):
sel = req.args.getlist('sel')
if not sel:
raise TracError(_("No %s selected") % self._type)
with self.env.db_transaction:
for name in sel:
self._enum_cls(self.env, name).delete()
if name == default:
self.config.set('ticket',
'default_%s' % self._type, '')
self.config.save()
add_notice(req, _("The selected %(field)s values have "
"been removed.", field=label[0]))
# Apply changes
elif req.args.get('apply'):
# Set default value
name = req.args.get('default')
if name and name != default:
self.log.info("Setting default %s to %s",
self._type, name)
self.config.set('ticket', 'default_%s' % self._type,
name)
self._save_config(req)
# Change enum values
order = {str(int(key[6:])): str(req.args.getint(key))
for key in req.args
if key.startswith('value_')}
values = {val: True for val in order.values()}
if len(order) != len(values):
raise TracError(_("Order numbers must be unique"))
changed = False
with self.env.db_transaction:
for enum in self._enum_cls.select(self.env):
new_value = order[enum.value]
if new_value != enum.value:
enum.value = new_value
enum.update()
changed = True
if changed:
add_notice(req, _("Your changes have been saved."))
# Clear default
elif req.args.get('clear'):
self.log.info("Clearing default %s", self._type)
self.config.set('ticket', 'default_%s' % self._type, '')
self._save_config(req)
req.redirect(req.href.admin(cat, page))
Chrome(self.env).add_jquery_ui(req)
add_script(req, 'common/js/admin_enums.js')
data.update(dict(enums=list(self._enum_cls.select(self.env)),
default=default, view='list'))
return 'admin_enums.html', data
# IAdminCommandProvider methods
_command_help = {
'list': "Show possible ticket %s",
'add': "Add a %s value option",
'change': "Change a %s value",
'remove': "Remove a %s value",
'order': "Move a %s value up or down in the list",
}
def get_admin_commands(self):
enum_type = getattr(self, '_command_type', self._type)
label = tuple(each.lower() for each in self._label)
yield ('%s list' % enum_type, '',
self._command_help['list'] % label[1],
None, self._do_list)
yield ('%s add' % enum_type, '<value>',
self._command_help['add'] % label[0],
None, self._do_add)
yield ('%s change' % enum_type, '<value> <newvalue>',
self._command_help['change'] % label[0],
self._complete_change_remove, self._do_change)
yield ('%s remove' % enum_type, '<value>',
self._command_help['remove'] % label[0],
self._complete_change_remove, self._do_remove)
yield ('%s order' % enum_type, '<value> up|down',
self._command_help['order'] % label[0],
self._complete_order, self._do_order)
def get_enum_list(self):
return [e.name for e in self._enum_cls.select(self.env)]
def _complete_change_remove(self, args):
if len(args) == 1:
return self.get_enum_list()
def _complete_order(self, args):
if len(args) == 1:
return self.get_enum_list()
elif len(args) == 2:
return ['up', 'down']
def _do_list(self):
print_table([(e.name,) for e in self._enum_cls.select(self.env)],
[_("Possible Values")])
def _do_add(self, name):
enum = self._enum_cls(self.env)
enum.name = name
enum.insert()
def _do_change(self, name, newname):
enum = self._enum_cls(self.env, name)
enum.name = newname
enum.update()
def _do_remove(self, value):
self._enum_cls(self.env, value).delete()
def _do_order(self, name, up_down):
if up_down not in ('up', 'down'):
raise AdminCommandError(_("Invalid up/down value: %(value)s",
value=up_down))
direction = -1 if up_down == 'up' else 1
enum1 = self._enum_cls(self.env, name)
enum1.value = int(float(enum1.value) + direction)
for enum2 in self._enum_cls.select(self.env):
if int(float(enum2.value)) == enum1.value:
enum2.value = int(float(enum2.value) - direction)
break
else:
return
with self.env.db_transaction:
enum1.update()
enum2.update()
class PriorityAdminPanel(AbstractEnumAdminPanel):
_type = 'priority'
_enum_cls = model.Priority
_label = model.Priority.label
class ResolutionAdminPanel(AbstractEnumAdminPanel):
_type = 'resolution'
_enum_cls = model.Resolution
_label = model.Resolution.label
class SeverityAdminPanel(AbstractEnumAdminPanel):
_type = 'severity'
_enum_cls = model.Severity
_label = model.Severity.label
class TicketTypeAdminPanel(AbstractEnumAdminPanel):
_type = 'type'
_enum_cls = model.Type
_label = model.Type.label
_command_type = 'ticket_type'
_command_help = {
'list': 'Show possible %s',
'add': 'Add a %s',
'change': 'Change a %s',
'remove': 'Remove a %s',
'order': 'Move a %s up or down in the list',
}
class TicketAdmin(Component):
"""trac-admin command provider for ticket administration."""
implements(IAdminCommandProvider)
# IAdminCommandProvider methods
def get_admin_commands(self):
yield ('ticket remove', '<ticket#>',
'Remove ticket', None, self._do_remove)
yield ('ticket remove_comment', '<ticket#> <comment#>',
'Remove ticket comment', None, self._do_remove_comment)
def _do_remove(self, number):
number = as_int(number, None)
if number is None:
raise AdminCommandError(_("<ticket#> must be a number"))
with self.env.db_transaction:
model.Ticket(self.env, number).delete()
printout(_("Ticket #%(num)s and all associated data removed.",
num=number))
def _do_remove_comment(self, ticket_number, comment_number):
ticket_number = as_int(ticket_number, None)
if ticket_number is None:
raise AdminCommandError(_('<ticket#> must be a number'))
comment_number = as_int(comment_number, None)
if comment_number is None:
raise AdminCommandError(_('<comment#> must be a number'))
with self.env.db_transaction:
ticket = model.Ticket(self.env, ticket_number)
change = ticket.get_change(comment_number)
if not change:
raise AdminCommandError(_("Comment %(num)s not found",
num=comment_number))
ticket.delete_change(comment_number)
printout(_("The ticket comment %(num)s on ticket #%(id)s has been "
"deleted.", num=comment_number, id=ticket_number))