Merge pull request #16 from trac-hacks/more_config_opts
Added more configuration options and made administration possible through the admin panel
This commit is contained in:
commit
2dde228b93
5
setup.py
5
setup.py
|
@ -32,7 +32,7 @@ from setuptools import find_packages, setup
|
|||
|
||||
setup(
|
||||
name = 'TracSubTicketsPlugin',
|
||||
version = '0.4.1',
|
||||
version = '0.5.0',
|
||||
keywords = 'trac plugin ticket subticket',
|
||||
author = 'Takashi Ito',
|
||||
author_email = 'TakashiC.Ito@gmail.com',
|
||||
|
@ -43,10 +43,9 @@ setup(
|
|||
long_description = """
|
||||
This plugin for Trac 1.0 and later provides Sub-Tickets functionality.
|
||||
|
||||
The association is done by adding parent tickets number to a custom field.
|
||||
The association is done by adding parent tickets' number to a custom field.
|
||||
Checks ensure i.e. resolving of sub-tickets before closing the parent.
|
||||
Babel is required to display localized texts.
|
||||
Currently only translation for de_DE is provided.
|
||||
""",
|
||||
license = 'BSD',
|
||||
|
||||
|
|
|
@ -31,6 +31,7 @@ import re
|
|||
|
||||
import pkg_resources
|
||||
|
||||
from trac.config import Option, BoolOption, IntOption, ChoiceOption, ListOption
|
||||
from trac.core import *
|
||||
from trac.env import IEnvironmentSetupParticipant
|
||||
from trac.db import DatabaseManager
|
||||
|
@ -59,6 +60,18 @@ class SubTicketsSystem(Component):
|
|||
ITicketChangeListener,
|
||||
ITicketManipulator)
|
||||
|
||||
opt_no_modif_w_p_c = BoolOption \
|
||||
('subtickets', 'no_modif_when_parent_closed', default='false',
|
||||
doc = _("""
|
||||
If `True`, any modification of a child whose parent is `closed`
|
||||
will be blocked. If `False`, status changes will be blocked as
|
||||
controlled by the setting of `skip_closure_validation`.
|
||||
|
||||
For compatibility with plugin versions prior to 0.5 that blocked
|
||||
any modification unconditionally.
|
||||
""")
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self._version = None
|
||||
self.ui = None
|
||||
|
@ -191,62 +204,64 @@ class SubTicketsSystem(Component):
|
|||
pass
|
||||
|
||||
def validate_ticket(self, req, ticket):
|
||||
with self.env.db_query as db:
|
||||
cursor = db.cursor()
|
||||
|
||||
try:
|
||||
invalid_ids = set()
|
||||
_ids = set(NUMBERS_RE.findall(ticket['parents'] or ''))
|
||||
myid = str(ticket.id)
|
||||
for id in _ids:
|
||||
if id == myid:
|
||||
invalid_ids.add(id)
|
||||
yield 'parents', _("A ticket cannot be a parent of itself")
|
||||
else:
|
||||
# check if the id exists
|
||||
cursor.execute("""
|
||||
SELECT id FROM ticket WHERE id=%s
|
||||
""", (id, ))
|
||||
row = cursor.fetchone()
|
||||
if row is None:
|
||||
invalid_ids.add(id)
|
||||
yield 'parents', _("Ticket #%(id)s does not exist", id=id)
|
||||
|
||||
# circularity check function
|
||||
def _check_parents(id, all_parents):
|
||||
all_parents = all_parents + [id]
|
||||
errors = []
|
||||
cursor.execute("""
|
||||
SELECT parent FROM subtickets WHERE child=%s
|
||||
try:
|
||||
invalid_ids = set()
|
||||
_ids = set(NUMBERS_RE.findall(ticket['parents'] or ''))
|
||||
myid = str(ticket.id)
|
||||
for id in _ids:
|
||||
if id == myid:
|
||||
invalid_ids.add(id)
|
||||
yield 'parents', _("A ticket cannot be a parent of itself")
|
||||
else:
|
||||
# check if the id exists
|
||||
tkt_id = self.env.db_query("""
|
||||
SELECT id FROM ticket WHERE id=%s
|
||||
""", (id, ))
|
||||
for x in [int(x[0]) for x in cursor]:
|
||||
if x in all_parents:
|
||||
invalid_ids.add(x)
|
||||
error = ' > '.join(['#%s' % n for n in all_parents + [x]])
|
||||
errors.append(('parents', _('Circularity error: %(e)s', e=error)))
|
||||
else:
|
||||
errors += _check_parents(x, all_parents)
|
||||
return errors
|
||||
if not tkt_id:
|
||||
invalid_ids.add(id)
|
||||
yield 'parents', _("Ticket #%(id)s does not exist",
|
||||
id=id)
|
||||
|
||||
for x in [i for i in _ids if i not in invalid_ids]:
|
||||
# Refuse modification if parent closed or if parentship is circular
|
||||
try:
|
||||
parent = Ticket(self.env, x)
|
||||
if parent and parent['status'] == 'closed' :
|
||||
invalid_ids.add(x)
|
||||
yield None, _("Cannot modify ticket because parent ticket #%(id)s is closed. Comments allowed, though.", id=x)
|
||||
else:
|
||||
# check circularity
|
||||
all_parents = ticket.id and [ticket.id] or []
|
||||
for error in _check_parents(int(x), all_parents):
|
||||
yield error
|
||||
except ResourceNotFound, e:
|
||||
# circularity check function
|
||||
def _check_parents(id, all_parents):
|
||||
all_parents = all_parents + [id]
|
||||
errors = []
|
||||
parents = self.env.db_query("""
|
||||
SELECT parent FROM subtickets WHERE child=%s
|
||||
""", (id, ))
|
||||
for x in [int(x[0]) for x in parents]:
|
||||
if x in all_parents:
|
||||
invalid_ids.add(x)
|
||||
error = ' > '.join(['#%s' % n for n in all_parents+[x]])
|
||||
errors.append(('parents', _('Circularity error: %(e)s',
|
||||
e=error)))
|
||||
else:
|
||||
errors += _check_parents(x, all_parents)
|
||||
return errors
|
||||
|
||||
valid_ids = _ids.difference(invalid_ids)
|
||||
ticket['parents'] = valid_ids and ', '.join(sorted(valid_ids, key=lambda x: int(x))) or ''
|
||||
except Exception, e:
|
||||
import traceback
|
||||
self.log.error(traceback.format_exc())
|
||||
yield 'parents', _('Not a valid list of ticket IDs.')
|
||||
for x in [i for i in _ids if i not in invalid_ids]:
|
||||
# Refuse modification if parent closed
|
||||
# or if parentship is to be made circular
|
||||
try:
|
||||
parent = Ticket(self.env, x)
|
||||
if parent and parent['status'] == 'closed' \
|
||||
and self.opt_no_modif_w_p_c:
|
||||
invalid_ids.add(x)
|
||||
yield None, _("""Cannot modify ticket because
|
||||
parent ticket #%(id)s is closed.
|
||||
Comments allowed, though.""",
|
||||
id=x)
|
||||
# check circularity
|
||||
all_parents = ticket.id and [ticket.id] or []
|
||||
for error in _check_parents(int(x), all_parents):
|
||||
yield error
|
||||
except ResourceNotFound, e:
|
||||
invalid_ids.add(x)
|
||||
|
||||
valid_ids = _ids.difference(invalid_ids)
|
||||
ticket['parents'] = valid_ids and ', '.join(sorted(valid_ids, key=lambda x: int(x))) or ''
|
||||
except Exception, e:
|
||||
import traceback
|
||||
self.log.error(traceback.format_exc())
|
||||
yield 'parents', _('Not a valid list of ticket IDs.')
|
||||
|
||||
|
|
|
@ -27,18 +27,19 @@
|
|||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
from trac.config import Option, IntOption, ChoiceOption, ListOption
|
||||
from trac.core import *
|
||||
from trac.web.api import IRequestFilter, ITemplateStreamFilter
|
||||
from trac.web.chrome import ITemplateProvider, add_stylesheet
|
||||
from trac.ticket.api import ITicketManipulator
|
||||
from trac.ticket.model import Ticket
|
||||
from trac.ticket.model import Type as TicketType
|
||||
from trac.resource import ResourceNotFound
|
||||
from genshi.builder import tag
|
||||
from genshi.filters import Transformer
|
||||
|
||||
from api import NUMBERS_RE, _
|
||||
|
||||
|
||||
class SubTicketsModule(Component):
|
||||
|
||||
implements(ITemplateProvider,
|
||||
|
@ -46,6 +47,77 @@ class SubTicketsModule(Component):
|
|||
ITicketManipulator,
|
||||
ITemplateStreamFilter)
|
||||
|
||||
### Simple Options
|
||||
|
||||
opt_skip_validation = ListOption \
|
||||
('subtickets', 'skip_closure_validation',
|
||||
default=[],
|
||||
doc = _("""
|
||||
Normally, reopening a child with a `closed` parent will be
|
||||
refused and closing a parent with non-`closed` children will also
|
||||
be refused. Adding either of `reopen` or `resolve` to this option will
|
||||
make Subtickets skip this validation for the respective action.
|
||||
Separate by comma if both actions are listed.
|
||||
|
||||
Caveat: This functionality will be made workflow-independent in a
|
||||
future release of !SubTicketsPlugin.
|
||||
""")
|
||||
)
|
||||
|
||||
opt_recursion_depth = IntOption \
|
||||
('subtickets', 'recursion_depth', default=-1,
|
||||
doc = _("""
|
||||
Limit the number of recursive levels when listing subtickets.
|
||||
Default is infinity, represented by`-1`. The value zero (0)
|
||||
limits the listing to immediate children.
|
||||
""")
|
||||
)
|
||||
opt_add_style = ChoiceOption \
|
||||
('subtickets', 'add_style', ['button', 'link'],
|
||||
doc = _("""
|
||||
Choose whether to make `Add` look like a button (default) or a link
|
||||
""")
|
||||
)
|
||||
|
||||
opt_owner_url = Option('subtickets', 'owner_url',
|
||||
doc = _("""
|
||||
Currently undocumented.
|
||||
""")
|
||||
)
|
||||
|
||||
### Per-ticket type options -- all initialised in __init__()
|
||||
|
||||
opt_inherit_fields = dict()
|
||||
opt_columns = dict()
|
||||
|
||||
def _add_per_ticket_type_option(self, ticket_type):
|
||||
self.opt_inherit_fields[ticket_type] = ListOption \
|
||||
('subtickets','type.%s.child_inherits' % ticket_type,
|
||||
default='',
|
||||
doc = _("""
|
||||
Comma-separated list of ticket fields whose values are
|
||||
to be copied from a parent ticket into a newly created
|
||||
child ticket
|
||||
""")
|
||||
)
|
||||
self.opt_columns[ticket_type] = ListOption \
|
||||
('subtickets', 'type.%s.table_columns' % ticket_type,
|
||||
default='status,owner',
|
||||
doc = _("""
|
||||
Comma-separated list of ticket fields whose values are to be
|
||||
shown for each child ticket in the subtickets list
|
||||
""")
|
||||
)
|
||||
|
||||
|
||||
###
|
||||
|
||||
def __init__(self):
|
||||
# The following initialisations must happen inside init()
|
||||
# in order to be able to access self.env
|
||||
for tt in TicketType.select(self.env):
|
||||
self._add_per_ticket_type_option(tt.name)
|
||||
|
||||
# ITemplateProvider methods
|
||||
def get_htdocs_dirs(self):
|
||||
from pkg_resources import resource_filename
|
||||
|
@ -60,6 +132,7 @@ class SubTicketsModule(Component):
|
|||
|
||||
def post_process_request(self, req, template, data, content_type):
|
||||
path = req.path_info
|
||||
|
||||
if path.startswith('/ticket/') or path.startswith('/newticket'):
|
||||
# get parent ticket's data
|
||||
if data and 'ticket' in data:
|
||||
|
@ -71,10 +144,15 @@ class SubTicketsModule(Component):
|
|||
self._append_parent_links(req, data, ids)
|
||||
|
||||
children = self.get_children(ticket.id)
|
||||
|
||||
if children:
|
||||
data['subtickets'] = children
|
||||
|
||||
elif path.startswith('/admin/ticket/type') \
|
||||
and data \
|
||||
and set(['add', 'name']).issubset(data.keys()) \
|
||||
and data['add'] == 'Add':
|
||||
self._add_per_ticket_type_option(data['name'])
|
||||
|
||||
return template, data, content_type
|
||||
|
||||
def _append_parent_links(self, req, data, ids):
|
||||
|
@ -100,39 +178,41 @@ class SubTicketsModule(Component):
|
|||
pass
|
||||
|
||||
def get_children(self, parent_id):
|
||||
with self.env.db_query as db:
|
||||
children = {}
|
||||
cursor = db.cursor()
|
||||
cursor.execute("""
|
||||
SELECT parent, child FROM subtickets WHERE parent=%s
|
||||
""", (parent_id, ))
|
||||
children = {}
|
||||
|
||||
for parent, child in cursor:
|
||||
children[child] = None
|
||||
for parent, child in self.env.db_query("""
|
||||
SELECT parent, child FROM subtickets WHERE parent=%s
|
||||
""", (parent_id, )):
|
||||
children[child] = None
|
||||
|
||||
for id in children:
|
||||
children[id] = self.get_children(id)
|
||||
for id in children:
|
||||
children[id] = self.get_children(id)
|
||||
|
||||
return children
|
||||
return children
|
||||
|
||||
def validate_ticket(self, req, ticket):
|
||||
action = req.args.get('action')
|
||||
if action == 'resolve':
|
||||
with self.env.db_query as db:
|
||||
cursor = db.cursor()
|
||||
cursor.execute("""
|
||||
SELECT parent, child FROM subtickets WHERE parent=%s
|
||||
""", (ticket.id, ))
|
||||
|
||||
for parent, child in cursor:
|
||||
if Ticket(self.env, child)['status'] != 'closed':
|
||||
yield None, _("Cannot close/resolve because child ticket #%(child)s is still open", child=child)
|
||||
if action in self.opt_skip_validation:
|
||||
return
|
||||
|
||||
if action == 'resolve':
|
||||
|
||||
for parent, child in self.env.db_query("""
|
||||
SELECT parent, child FROM subtickets WHERE parent=%s
|
||||
""", (ticket.id, )):
|
||||
if Ticket(self.env, child)['status'] != 'closed':
|
||||
yield None, _("""Cannot close/resolve because child
|
||||
ticket #%(child)s is still open""",
|
||||
child=child)
|
||||
|
||||
elif action == 'reopen':
|
||||
ids = set(NUMBERS_RE.findall(ticket['parents'] or ''))
|
||||
for id in ids:
|
||||
if Ticket(self.env, id)['status'] == 'closed':
|
||||
yield None, _("Cannot reopen because parent ticket #%(id)s is closed", id=id)
|
||||
yield None,
|
||||
_("Cannot reopen because parent ticket #%(id)s is closed",
|
||||
id=id)
|
||||
|
||||
# ITemplateStreamFilter method
|
||||
def filter_stream(self, req, method, filename, stream, data):
|
||||
|
@ -143,19 +223,20 @@ class SubTicketsModule(Component):
|
|||
ticket = data['ticket']
|
||||
# title
|
||||
div = tag.div(class_='description')
|
||||
add_style = self.config.get('subtickets', 'add_style', default='link')
|
||||
inherit_fields = self.config.getlist('subtickets','%s.child_inherits' % ticket['type'])
|
||||
if 'TICKET_CREATE' in req.perm(ticket.resource) and ticket['status'] != 'closed':
|
||||
if add_style == 'link':
|
||||
inh = {f: ticket[f] for f in inherit_fields}
|
||||
if 'TICKET_CREATE' in req.perm(ticket.resource) \
|
||||
and ticket['status'] != 'closed':
|
||||
opt_inherit = self.env.config.getlist('subtickets',
|
||||
'type.%(type)s.child_inherits' % ticket)
|
||||
if self.opt_add_style == 'link':
|
||||
inh = {f: ticket[f] for f in opt_inherit}
|
||||
link = tag.a('add',
|
||||
href=req.href.newticket(parents=ticket.id, *inh))
|
||||
href=req.href.newticket(parents=ticket.id, **inh))
|
||||
link = tag.span('(', link, ')', class_='addsubticket')
|
||||
button = None
|
||||
else:
|
||||
inh = [tag.input(type = 'hidden',
|
||||
name = f,
|
||||
value = ticket[f]) for f in inherit_fields]
|
||||
value = ticket[f]) for f in opt_inherit]
|
||||
|
||||
link = None
|
||||
button = tag.form(tag.div(tag.input(type="submit",
|
||||
|
@ -177,10 +258,6 @@ class SubTicketsModule(Component):
|
|||
# table
|
||||
tbody = tag.tbody()
|
||||
div.append(tag.table(tbody, class_='subtickets'))
|
||||
columns = self.config.getlist('subtickets',
|
||||
'%s.table_columns' % ticket['type'],
|
||||
default=['status','owner'])
|
||||
owner_url = self.config.get('subtickets', 'owner_url')
|
||||
# tickets
|
||||
def _func(children, depth=0):
|
||||
for id in sorted(children, key=lambda x: int(x)):
|
||||
|
@ -198,21 +275,28 @@ class SubTicketsModule(Component):
|
|||
r.append(summary)
|
||||
|
||||
# Add other columns as configured.
|
||||
for column in columns:
|
||||
for column in self.env.config.getlist('subtickets',
|
||||
'type.%(type)s.table_columns' % ticket):
|
||||
if column == 'owner':
|
||||
if owner_url:
|
||||
href = req.href(owner_url % ticket['owner'])
|
||||
if self.opt_owner_url:
|
||||
href = req.href(self.opt_owner_url % ticket['owner'])
|
||||
else:
|
||||
href = req.href.query(status='!closed',
|
||||
owner=ticket['owner'])
|
||||
e = tag.td(tag.a(ticket['owner'], href=href))
|
||||
elif column == 'milestone':
|
||||
href = req.href.query(status='!closed',
|
||||
milestone=ticket['milestone'])
|
||||
e = tag.td(tag.a(ticket['milestone'], href=href))
|
||||
else:
|
||||
e = tag.td(ticket[column])
|
||||
|
||||
r.append(e)
|
||||
|
||||
tbody.append(tag.tr(*r))
|
||||
_func(children[id], depth + 1)
|
||||
|
||||
if self.opt_recursion_depth > depth or self.opt_recursion_depth == -1 :
|
||||
_func(children[id], depth + 1)
|
||||
|
||||
_func(data['subtickets'])
|
||||
|
||||
|
|
Reference in New Issue