blob: 29bdc144abd84c37d0cc9f38bc4122f735f2ffcf [file] [log] [blame]
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import itertools
import re
from datetime import datetime
from pkg_resources import resource_filename
from bhrelations import db_default
from bhrelations.model import Relation
from bhrelations.utils import unique
from multiproduct.api import ISupportMultiProductEnvironment
from multiproduct.model import Product
from multiproduct.env import ProductEnvironment
from trac.config import OrderedExtensionsOption, Option
from trac.core import (Component, implements, TracError, Interface,
ExtensionPoint)
from trac.env import IEnvironmentSetupParticipant
from trac.db import DatabaseManager
from trac.resource import (ResourceSystem, Resource, ResourceNotFound,
get_resource_shortname, Neighborhood)
from trac.ticket import Ticket, ITicketManipulator, ITicketChangeListener
from trac.util.datefmt import utc, to_utimestamp
from trac.web.chrome import ITemplateProvider
PLUGIN_NAME = 'Bloodhound Relations Plugin'
RELATIONS_CONFIG_NAME = 'bhrelations_links'
default_bhrelations_links = {
'children.label': 'is a child of',
'dependency': 'dependson,dependent',
'dependency.validators': 'NoCycles,SingleProduct',
'dependson.blocks': 'true',
'dependson.label': 'depends on',
'dependent.label': 'is a dependency of',
'oneway': 'refersto',
'parent_children': 'parent,children',
'parent.exclusive': 'true',
'parent.label': 'is a parent of',
'parent_children.validators': 'OneToMany,SingleProduct,NoCycles',
'refersto.label': 'refers to',
'duplicate': 'duplicateof,duplicatedby',
'duplicate.validators': 'ReferencesOlder',
'duplicateof.label': 'is a duplicate of',
'duplicatedby.label': 'is duplicated by',
}
#TODO: consider making the interface part of future
# I[*|Resource]ChangingListener approach based on output from the
# correlated discussion in Trac community
# (http://trac.edgewall.org/ticket/11148)
class IRelationChangingListener(Interface):
"""
Extension point interface for components that require notification
when relations are created or deleted and database transaction is not
yet committed. The interface can be used when database actions have to be
made by listener must be performed within the same transaction as
relations modification.
Caution:
Because the database transaction is not yet committed during the event
notification, a long running listener activity may
influence overall database performance or raise lock
or transaction timeout errors. If component have to perform non-transaction
activity, use IRelationChanged interface instead.
If a listener raises an exception, all changes that were made within the
transaction will not be applied.
"""
def adding_relation(relation):
"""
Called when a relation was added but transaction was not committed.
"""
def deleting_relation(relation, when):
"""
Called when a relation was added but transaction was not committed.
"""
class IRelationValidator(Interface):
"""
Extension point interface for relation validators.
"""
def validate(relation):
"""
Validate the relation. If relation is not valid, raise appropriate
exception.
"""
class EnvironmentSetup(Component):
implements(IEnvironmentSetupParticipant, ISupportMultiProductEnvironment,
ITemplateProvider)
def environment_created(self):
self.upgrade_environment(self.env.db_transaction)
def environment_needs_upgrade(self, db):
"""Detects if the installed db version matches the running system"""
db_installed_version = self._get_version(db)
db_version = db_default.DB_VERSION
if db_installed_version > db_version:
raise TracError('''Current db version (%d) newer than supported by
this version of the %s (%d).''' % (db_installed_version,
PLUGIN_NAME,
db_version))
needs_upgrade = db_installed_version < db_version or \
not list(self.config.options(RELATIONS_CONFIG_NAME))
return needs_upgrade
def upgrade_environment(self, db):
self.log.debug("upgrading existing environment for %s plugin." %
PLUGIN_NAME)
db_installed_version = self._get_version(db)
if db_installed_version < 1:
self._initialize_db(db)
self._update_db_version(db, db_default.DB_VERSION)
#add upgrade logic later if needed
if not list(self.config.options(RELATIONS_CONFIG_NAME)):
for option, value in default_bhrelations_links.iteritems():
self.config.set(RELATIONS_CONFIG_NAME, option, value)
self.config.save()
print("Your environment has been upgraded with the default "
"[bhrelations_links] configuration.")
def _get_version(self, db):
"""Finds the current version of the bloodhound database schema"""
rows = db("""
SELECT value FROM system WHERE name = %s
""", (db_default.DB_SYSTEM_KEY,))
return int(rows[0][0]) if rows else -1
def _update_db_version(self, db, version):
old_version = self._get_version(db)
if old_version != -1:
self.log.info(
"Updating %s database schema from version %d to %d",
PLUGIN_NAME, old_version, version)
db("""UPDATE system SET value=%s
WHERE name=%s""", (version, db_default.DB_SYSTEM_KEY))
else:
self.log.info(
"Initial %s database schema set to version %d",
PLUGIN_NAME, version)
db("""
INSERT INTO system (name, value) VALUES ('%s','%s')
""" % (db_default.DB_SYSTEM_KEY, version))
return version
def _initialize_db(self, db):
# pylint: disable=protected-access
self.log.debug("creating initial db schema for %s.", PLUGIN_NAME)
db_connector, dummy = DatabaseManager(self.env)._get_connector()
for table in db_default.SCHEMA:
for statement in db_connector.to_sql(table):
db(statement)
# ITemplateProviderMethods
def get_templates_dirs(self):
"""provide the plugin templates"""
return [resource_filename(__name__, 'templates')]
def get_htdocs_dirs(self):
return None
class RelationsSystem(Component):
PARENT_RELATION_TYPE = 'parent'
CHILDREN_RELATION_TYPE = 'children'
changing_listeners = ExtensionPoint(IRelationChangingListener)
all_validators = ExtensionPoint(IRelationValidator)
global_validators = OrderedExtensionsOption(
'bhrelations', 'global_validators',
IRelationValidator,
'NoSelfReferenceValidator, ExclusiveValidator, BlockerValidator',
include_missing=False,
doc="""Validators used to validate all relations,
regardless of their type."""
)
duplicate_relation_type = Option(
'bhrelations',
'duplicate_relation',
'duplicateof',
"Relation type to be used with the resolve as duplicate workflow.")
def __init__(self):
links, labels, validators, blockers, copy_fields, exclusive = \
self._parse_config()
self._links = links
self._labels = labels
self._validators = validators
self._blockers = blockers
self._copy_fields = copy_fields
self._exclusive = exclusive
self.link_ends_map = {}
for end1, end2 in self.get_ends():
self.link_ends_map[end1] = end2
if end2 is not None:
self.link_ends_map[end2] = end1
def get_ends(self):
return self._links
def add(self,
source_resource_instance,
destination_resource_instance,
relation_type,
comment=None,
author=None,
when=None):
source = ResourceIdSerializer.get_resource_id_from_instance(
self.env, source_resource_instance)
destination = ResourceIdSerializer.get_resource_id_from_instance(
self.env, destination_resource_instance)
if relation_type not in self.link_ends_map:
raise UnknownRelationType(relation_type)
if when is None:
when = datetime.now(utc)
relation = Relation(self.env)
relation.source = source
relation.destination = destination
relation.type = relation_type
relation.comment = comment
relation.author = author
relation.when = when
self.add_relation(relation)
return relation
def get_reverted_relation(self, relation):
"""Return None if relation is one way"""
other_end = self.link_ends_map[relation.type]
if other_end:
return relation.clone_reverted(other_end)
def add_relation(self, relation):
self.validate(relation)
with self.env.db_transaction:
relation.insert()
reverted_relation = self.get_reverted_relation(relation)
if reverted_relation:
reverted_relation.insert()
for listener in self.changing_listeners:
listener.adding_relation(relation)
from bhrelations.notification import RelationNotifyEmail
RelationNotifyEmail(self.env).notify(relation)
def delete(self, relation_id, when=None):
if when is None:
when = datetime.now(utc)
relation = Relation.load_by_relation_id(self.env, relation_id)
source = relation.source
destination = relation.destination
relation_type = relation.type
with self.env.db_transaction:
cloned_relation = relation.clone()
relation.delete()
other_end = self.link_ends_map[relation_type]
if other_end:
reverted_relation = Relation(self.env, keys=dict(
source=destination,
destination=source,
type=other_end,
))
reverted_relation.delete()
for listener in self.changing_listeners:
listener.deleting_relation(cloned_relation, when)
from bhrelations.notification import RelationNotifyEmail
RelationNotifyEmail(self.env).notify(cloned_relation, deleted=when)
def delete_resource_relations(self, resource_instance):
sql = "DELETE FROM " + Relation.get_table_name() + \
" WHERE source=%s OR destination=%s"
full_resource_id = ResourceIdSerializer.get_resource_id_from_instance(
self.env, resource_instance)
with self.env.db_transaction as db:
db(sql, (full_resource_id, full_resource_id))
def _debug_select(self):
"""The method is used for debug purposes"""
sql = "SELECT id, source, destination, type FROM bloodhound_relations"
with self.env.db_query as db:
return [db(sql)]
def get_relations(self, resource_instance):
relation_list = []
for relation in self._select_relations_for_resource_instance(
resource_instance):
relation_list.append(dict(
relation_id=relation.get_relation_id(),
destination_id=relation.destination,
destination=ResourceIdSerializer.get_resource_by_id(
relation.destination),
type=relation.type,
comment=relation.comment,
when=relation.when,
author=relation.author,
))
return relation_list
def _select_relations_for_resource_instance(self, resource):
resource_full_id = ResourceIdSerializer.get_resource_id_from_instance(
self.env, resource)
return self._select_relations(resource_full_id)
def _select_relations(
self, source, resource_type=None):
#todo: add optional paging for possible umbrella tickets with
#a lot of child tickets
where = dict(source=source)
if resource_type:
where["type"] = resource_type
order_by = ["destination"]
else:
order_by = ["type", "destination"]
return Relation.select(
self.env,
where=where,
order_by=order_by
)
def _parse_config(self):
links = []
labels = {}
validators = {}
blockers = {}
copy_fields = {}
exclusive = set()
config = self.config[RELATIONS_CONFIG_NAME]
for name in [option for option, _ in config.options()
if '.' not in option]:
reltypes = config.getlist(name)
if not reltypes:
continue
if len(reltypes) == 1:
reltypes += [None]
links.append(tuple(reltypes))
custom_validators = self._parse_validators(config, name)
for rel in filter(None, reltypes):
labels[rel] = \
config.get(rel + '.label') or rel.capitalize()
blockers[rel] = \
config.getbool(rel + '.blocks', default=False)
if config.getbool(rel + '.exclusive'):
exclusive.add(rel)
validators[rel] = custom_validators
# <end>.copy_fields may be absent or intentionally set empty.
# config.getlist() will return [] in either case, so check that
# the key is present before assigning the value
cf_key = '%s.copy_fields' % rel
if cf_key in config:
copy_fields[rel] = config.getlist(cf_key)
return links, labels, validators, blockers, copy_fields, exclusive
def _parse_validators(self, section, name):
custom_validators = set(
'%sValidator' % validator for validator in
set(section.getlist(name + '.validators', [], ',', True)))
validators = []
if custom_validators:
for impl in self.all_validators:
if impl.__class__.__name__ in custom_validators:
validators.append(impl)
return validators
def validate(self, relation):
"""
Validate the relation using the configured validators. Validation is
always run on the relation with master type.
"""
backrel = self.get_reverted_relation(relation)
if backrel and (backrel.type, relation.type) in self._links:
relation = backrel
for validator in self.global_validators:
validator.validate(relation)
for validator in self._validators.get(relation.type, ()):
validator.validate(relation)
def is_blocker(self, relation_type):
return self._blockers[relation_type]
def render_relation_type(self, end):
return self._labels[end]
def get_relation_types(self):
return self._labels
def find_blockers(self, resource_instance, is_blocker_method):
# tbd: do we blocker finding to be recursive
all_blockers = []
for relation in self._select_relations_for_resource_instance(
resource_instance):
if self.is_blocker(relation.type):
resource = ResourceIdSerializer.get_resource_by_id(
relation.destination)
resource_instance = is_blocker_method(resource)
if resource_instance is not None:
all_blockers.append(resource_instance)
# blockers = self._recursive_find_blockers(
# relation, is_blocker_method)
# if blockers:
# all_blockers.extend(blockers)
return all_blockers
def get_resource_name(self, resource_id):
resource = ResourceIdSerializer.get_resource_by_id(resource_id)
return get_resource_shortname(self.env, resource)
class ResourceIdSerializer(object):
RESOURCE_ID_DELIMITER = u":"
@classmethod
def get_resource_by_id(cls, resource_full_id):
"""
* resource_full_id: fully qualified resource id in format
"product:ticket:123". In case of global environment it is ":ticket:123"
"""
nbhprefix, realm, resource_id = cls.split_full_id(resource_full_id)
if nbhprefix:
neighborhood = Neighborhood('product', nbhprefix)
return neighborhood.child(realm, id=resource_id)
else:
return Resource(realm, id=resource_id)
@classmethod
def split_full_id(cls, resource_full_id):
return resource_full_id.split(cls.RESOURCE_ID_DELIMITER)
@classmethod
def get_resource_id_from_instance(cls, env, resource_instance):
"""
* resource_instance: can be instance of a ticket, wiki page etc.
"""
resource = resource_instance.resource
# nbhprefix = ResourceSystem(env).neighborhood_prefix(
# resource.neighborhood)
#TODO: temporary workaround for the ticket specific behavior
#change it to generic resource behaviour
ticket = resource_instance
if ticket.id is None:
raise ValueError("Cannot get resource id for ticket "
"that does not exist yet.")
nbhprefix = ticket["product"]
resource_full_id = cls.RESOURCE_ID_DELIMITER.join(
(nbhprefix, resource.realm, unicode(resource.id))
)
return resource_full_id
class TicketRelationsSpecifics(Component):
implements(ITicketManipulator, ITicketChangeListener)
def __init__(self):
self.rls = RelationsSystem(self.env)
#ITicketChangeListener methods
def ticket_created(self, ticket):
pass
def ticket_changed(self, ticket, comment, author, old_values):
if (
self._closed_as_duplicate(ticket) and
self.rls.duplicate_relation_type
):
try:
self.rls.add(ticket, ticket.duplicate,
self.rls.duplicate_relation_type,
comment, author)
except TracError:
pass
def _closed_as_duplicate(self, ticket):
return (ticket['status'] == 'closed' and
ticket['resolution'] == 'duplicate')
def ticket_deleted(self, ticket):
self.rls.delete_resource_relations(ticket)
#ITicketManipulator methods
def prepare_ticket(self, req, ticket, fields, actions):
pass
def validate_ticket(self, req, ticket):
return itertools.chain(
self._check_blockers(req, ticket),
self._check_open_children(req, ticket),
self._check_duplicate_id(req, ticket),
)
def _check_blockers(self, req, ticket):
if req.args.get('action') == 'resolve':
blockers = self.rls.find_blockers(ticket, self.is_blocker)
if blockers:
blockers_str = ', '.join(
get_resource_shortname(self.env, blocker_ticket.resource)
for blocker_ticket in unique(blockers))
msg = ("Cannot resolve this ticket because it is "
"blocked by tickets [%s]"
% blockers_str)
yield None, msg
def _check_open_children(self, req, ticket):
if req.args.get('action') == 'resolve':
for relation in [r for r in self.rls.get_relations(ticket)
if r['type'] == self.rls.CHILDREN_RELATION_TYPE]:
ticket = self._create_ticket_by_full_id(relation['destination'])
if ticket['status'] != 'closed':
msg = ("Cannot resolve this ticket because it has open"
"child tickets.")
yield None, msg
def _check_duplicate_id(self, req, ticket):
if req.args.get('action') == 'resolve':
resolution = req.args.get('action_resolve_resolve_resolution')
if resolution == 'duplicate':
duplicate_id = req.args.get('duplicate_id')
if not duplicate_id:
yield None, "Duplicate ticket ID must be provided."
try:
duplicate_ticket = self.find_ticket(duplicate_id)
req.perm.require('TICKET_MODIFY',
Resource(duplicate_ticket.id))
ticket.duplicate = duplicate_ticket
except NoSuchTicketError:
yield None, "Invalid duplicate ticket ID."
def find_ticket(self, ticket_spec):
ticket = None
m = re.match(r'#?(?P<tid>\d+)', ticket_spec)
if m:
tid = m.group('tid')
try:
ticket = Ticket(self.env, tid)
except ResourceNotFound:
# ticket not found in current product, try all other products
for p in Product.select(self.env):
if p.prefix != self.env.product.prefix:
# TODO: check for PRODUCT_VIEW permissions
penv = ProductEnvironment(self.env.parent, p.prefix)
try:
ticket = Ticket(penv, tid)
except ResourceNotFound:
pass
else:
break
# ticket still not found, use fallback for <prefix>:ticket:<id> syntax
if ticket is None:
try:
resource = ResourceIdSerializer.get_resource_by_id(ticket_spec)
ticket = self._create_ticket_by_full_id(resource)
except:
raise NoSuchTicketError
return ticket
def is_blocker(self, resource):
ticket = self._create_ticket_by_full_id(resource)
if ticket['status'] != 'closed':
return ticket
return None
def _create_ticket_by_full_id(self, resource):
env = self._get_env_for_resource(resource)
if resource.realm == "ticket":
return Ticket(env, resource.id)
else:
raise TracError("Resource type %s is not supported by " +
"Bloodhound Relations" % resource.realm)
def _get_env_for_resource(self, resource):
if hasattr(resource, "neighborhood"):
env = ResourceSystem(self.env).load_component_manager(
resource.neighborhood)
else:
env = self.env
return env
class TicketChangeRecordUpdater(Component):
implements(IRelationChangingListener)
def adding_relation(self, relation):
self.update_tickets_change_records(
relation, False, relation.time)
def deleting_relation(self, relation, when):
when_ts = to_utimestamp(when)
self.update_tickets_change_records(relation, True, when_ts)
def update_tickets_change_records(self, relation, is_delete, when_ts):
relation_system = RelationsSystem(self.env)
with self.env.db_direct_transaction as db:
self._add_ticket_change_record(
db,
relation,
relation_system,
is_delete,
when_ts
)
reverted_relation = relation_system.get_reverted_relation(relation)
if reverted_relation:
self._add_ticket_change_record(
db,
reverted_relation,
relation_system,
is_delete,
when_ts
)
def _get_ticket_id_and_product(self, resource_full_id):
nbhprefix, realm, resource_id = ResourceIdSerializer.split_full_id(
resource_full_id)
ticket_id = None
if realm == "ticket":
ticket_id = int(resource_id)
return ticket_id, nbhprefix
def _add_ticket_change_record(
self, db, relation, relation_system, is_delete, when_ts):
ticket_id, product = self._get_ticket_id_and_product(relation.source)
if ticket_id is None:
return
related_resource_name = relation_system.get_resource_name(
relation.destination)
if is_delete:
old_value = related_resource_name
new_value = None
else:
old_value = None
new_value = related_resource_name
description = 'Relation "%s"' % (
relation_system.render_relation_type(relation.type),)
db("""INSERT INTO ticket_change
(ticket, time, author, field, oldvalue, newvalue, product)
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
(ticket_id,
when_ts,
relation.author,
description,
old_value,
new_value,
product))
class UnknownRelationType(ValueError):
pass
class NoSuchTicketError(ValueError):
pass