Author: andrej Date: Fri Apr 26 12:17:58 2013 New Revision: 1476162 URL: http://svn.apache.org/r1476162 Log: adding bhrelation api draft implementation, removing trac-ticket-links dependencies
Added: bloodhound/trunk/bloodhound_relations/bhrelations/db_default.py bloodhound/trunk/bloodhound_relations/bhrelations/model.py bloodhound/trunk/bloodhound_relations/bhrelations/tests/api.py Removed: bloodhound/trunk/bloodhound_relations/bhrelations/tests/bhrelations_links.py bloodhound/trunk/bloodhound_relations/bhrelations/ticket_links_other.py bloodhound/trunk/bloodhound_relations/trac_ticket_links/ Modified: bloodhound/trunk/.rat-ignore bloodhound/trunk/bloodhound_relations/README bloodhound/trunk/bloodhound_relations/bhrelations/api.py bloodhound/trunk/bloodhound_relations/setup.py Modified: bloodhound/trunk/.rat-ignore URL: http://svn.apache.org/viewvc/bloodhound/trunk/.rat-ignore?rev=1476162&r1=1476161&r2=1476162&view=diff ============================================================================== --- bloodhound/trunk/.rat-ignore (original) +++ bloodhound/trunk/.rat-ignore Fri Apr 26 12:17:58 2013 @@ -10,7 +10,6 @@ doc/html-templates/js/jquery-1.8.2.js doc/wireframes/src/ installer/README.rst trac/ -bloodhound_relations/bhrelations/trac/ bloodhound_relations/bhrelations/default-pages/ bloodhound_multiproduct/tests/admin/*.txt bloodhound_multiproduct/tests/*.txt \ No newline at end of file Modified: bloodhound/trunk/bloodhound_relations/README URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_relations/README?rev=1476162&r1=1476161&r2=1476162&view=diff ============================================================================== --- bloodhound/trunk/bloodhound_relations/README (original) +++ bloodhound/trunk/bloodhound_relations/README Fri Apr 26 12:17:58 2013 @@ -28,8 +28,4 @@ If you have any issues, please create a == The Trac ticket-links branch Bloodhound Relations plugin contains the code from the Trac ticket-links branch, which -is licensed under the same license as Trac (http://trac.edgewall.org/wiki/TracLicense). -The plugin trac_ticket_links directory represents an updated copy of the combined vendor branch -located on https://svn.apache.org/repos/asf/bloodhound/vendor/trac-ticket-links. -The combined vendor branch represents a source tree merged from the several original -vendor branches. For more information on the original vendor branches, see //svn.apache.org/repos/asf/bloodhound/vendor/README +is licensed under the same license as Trac (http://trac.edgewall.org/wiki/TracLicense). \ No newline at end of file Modified: bloodhound/trunk/bloodhound_relations/bhrelations/api.py URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_relations/bhrelations/api.py?rev=1476162&r1=1476161&r2=1476162&view=diff ============================================================================== --- bloodhound/trunk/bloodhound_relations/bhrelations/api.py (original) +++ bloodhound/trunk/bloodhound_relations/bhrelations/api.py Fri Apr 26 12:17:58 2013 @@ -17,5 +17,280 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from bhrelations import db_default +from bhrelations.model import Relation +from multiproduct.env import ProductEnvironment +from trac.core import Component, implements, TracError +from trac.env import IEnvironmentSetupParticipant +from trac.db import DatabaseManager +from trac.resource import manager_for_neighborhood, ResourceSystem +from trac.ticket import Ticket +PLUGIN_NAME = 'Bloodhound Relations Plugin' + + +class EnvironmentSetup(Component): + implements(IEnvironmentSetupParticipant) + + def environment_created(self): + self.found_db_version = 0 + 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_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 + 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() + with self.env.db_direct_transaction as db: + if db_installed_version < 1: + self._initialize_db(db) + self._update_db_version(db, 1) + #add upgrade logic later if needed + + def _get_version(self): + """Finds the current version of the bloodhound database schema""" + rows = self.env.db_direct_query(""" + 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() + 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): + 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) + + +class RelationsSystem(Component): + + RELATIONS_CONFIG_NAME = 'bhrelations_links' + RESOURCE_ID_DELIMITER = u":" + RELATION_ID_DELIMITER = u"," + + def __init__(self): + self._links, self._labels, \ + self._validators, self._blockers, \ + self._copy_fields = self._get_links_config() + + 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_relation( + self, + source_resource_instance, + destination_resource_instance, + relation_type, + comment = None, + ): + source = self.get_resource_id(source_resource_instance) + destination = self.get_resource_id(destination_resource_instance) + relation = Relation(self.env) + relation.source = source + relation.destination = destination + relation.type = relation_type + relation.comment = comment + self._add_relation_instance(relation) + + def _add_relation_instance(self, relation): + #TBD: add changes in source and destination ticket history + with self.env.db_transaction: + relation.insert() + other_end = self.link_ends_map[relation.type] + if other_end: + reverted_relation = relation.clone_reverted(other_end) + reverted_relation.insert() + + def delete_relation_by_id( + self, + relation_id, + ): + source, destination, relation_type = self._parse_relation_id( + relation_id) + #TODO: some optimization can be introduced here to not load relations + #before actual DELETE SQL + relation = Relation(self.env, keys=dict( + source=source, + destination=destination, + type=relation_type + )) + self._delete_relation_instance(relation) + + def _delete_relation_instance(self, relation): + source = relation.source + destination = relation.destination + relation_type = relation.type + with self.env.db_transaction: + 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() + + 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_by_resource(self, resource): + source = self.get_resource_id(resource) + return self.get_relations_by_resource_id(source) + + def get_relations_by_resource_id(self, resource): + #todo: add optional paging for possible umbrella tickets with + #a lot of child tickets + source = self.get_resource_id(resource) + return Relation.select( + self.env, + where=dict(source=source), + order_by=["type", "destination"] + ) + + def get_relations(self, resource_instance): + source = self.get_resource_id(resource_instance) + relations = Relation.select(self.env, where=dict(source=source)) + relation_list = [] + for relation in relations: + relation_list.append(dict( + relation_id = self._create_relation_id(relation), + destination_id = relation.destination, + destination=self.get_resource_by_id(relation.destination), + type = relation.type, + comment = relation.comment + )) + return relation_list + + def _create_relation_id(self, relation): + return self.RELATION_ID_DELIMITER.join(( + relation.source, + relation.destination, + relation.type)) + + def _parse_relation_id(self, relation_id): + source, destination, relation_type = relation_id.split( + self.RELATION_ID_DELIMITER) + return source, destination, relation_type + + # Copied from trac/ticket/links.py, ticket-links-trunk branch + def _get_links_config(self): + links = [] + labels = {} + validators = {} + blockers = {} + copy_fields = {} + + config = self.config[self.RELATIONS_CONFIG_NAME] + for name in [option for option, _ in config.options() + if '.' not in option]: + ends = [e.strip() for e in config.get(name).split(',')] + if not ends: + continue + end1 = ends[0] + end2 = None + if len(ends) > 1: + end2 = ends[1] + links.append((end1, end2)) + + label1 = config.get(end1 + '.label') or end1.capitalize() + labels[end1] = label1 + if end2: + label2 = config.get(end2 + '.label') or end2.capitalize() + labels[end2] = label2 + + validator = config.get(name + '.validator') + if validator: + validators[end1] = validator + if end2: + validators[end2] = validator + + blockers[end1] = config.getbool(end1 + '.blocks', default=False) + if end2: + blockers[end2] = config.getbool(end2 + '.blocks', default=False) + + # <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 + for end in [end1, end2]: + if end: + cf_key = '%s.copy_fields' % end + if cf_key in config: + copy_fields[end] = config.getlist(cf_key) + + return links, labels, validators, blockers, copy_fields + + def get_resource_id(self, resource_instance): + resource = resource_instance.resource + rsys = ResourceSystem(manager_for_neighborhood( + self.env, resource.neighborhood)) + nbhprefix = rsys.neighborhood_prefix(resource.neighborhood) + resource_full_id = self.RESOURCE_ID_DELIMITER.join( + (nbhprefix, resource.realm, unicode(resource.id)) + ) + return resource_full_id + + def get_resource_by_id(self, resource_full_id): + """ + Expects resource_full_id in format "product:ticket:123". In case of + global environment: ":ticket:123" + """ + nbhprefix, realm, id = resource_full_id.split( + self.RESOURCE_ID_DELIMITER) + return self._get_resource_instance(nbhprefix, realm, id) + + def _get_resource_instance(self, nbhprefix, realm, id): + env = self._get_env_by_prefix(nbhprefix) + if realm == "ticket": + return Ticket(env, id) + else: + raise TracError("Resource type %s is not supported by " + + "Bloodhound Relations" % realm) + + def _get_env_by_prefix(self, nbhprefix): + if nbhprefix: + env = ProductEnvironment(nbhprefix) + elif hasattr(self.env, "parent") and self.env.parent: + env = self.env.parent + else: + env = self.env + return env Added: bloodhound/trunk/bloodhound_relations/bhrelations/db_default.py URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_relations/bhrelations/db_default.py?rev=1476162&view=auto ============================================================================== --- bloodhound/trunk/bloodhound_relations/bhrelations/db_default.py (added) +++ bloodhound/trunk/bloodhound_relations/bhrelations/db_default.py Fri Apr 26 12:17:58 2013 @@ -0,0 +1,28 @@ +#!/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. +from bhrelations.model import Relation + +DB_SYSTEM_KEY = 'bhrelations' +DB_VERSION = 1 + +SCHEMA = [mcls._get_schema() for mcls in (Relation, )] + +migrations = [ +] \ No newline at end of file Added: bloodhound/trunk/bloodhound_relations/bhrelations/model.py URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_relations/bhrelations/model.py?rev=1476162&view=auto ============================================================================== --- bloodhound/trunk/bloodhound_relations/bhrelations/model.py (added) +++ bloodhound/trunk/bloodhound_relations/bhrelations/model.py Fri Apr 26 12:17:58 2013 @@ -0,0 +1,56 @@ +#!/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. +from bhdashboard.model import ModelBase +from trac.resource import Resource + + +class Relation(ModelBase): + """The Relation table""" + _meta = {'table_name':'bloodhound_relations', + 'object_name':'Relation', + 'key_fields':['source', 'destination', 'type'], + 'non_key_fields':['comment'], + 'no_change_fields':['source', 'destination', 'type'], + 'unique_fields':[], + # 'auto_inc_fields': ['id'], + } + + # _meta = {'table_name':'bloodhound_relations', + # 'object_name':'Relation', + # 'key_fields':['id'], + # 'non_key_fields':['source', 'destination', 'type', 'comment'], + # 'no_change_fields':['source', 'destination', 'type'], + # 'unique_fields':['source', 'destination', 'type'], + # 'auto_inc_fields': ['id'], + # } + + @property + def resource(self): + """Allow Relation to be treated as a Resource""" + return Resource('relation', self.prefix) + + def clone_reverted(self, type): + relation = Relation(self._env) + relation.source = self.destination + relation.destination = self.source + relation.comment = self.comment + relation.type = type + return relation + Added: bloodhound/trunk/bloodhound_relations/bhrelations/tests/api.py URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_relations/bhrelations/tests/api.py?rev=1476162&view=auto ============================================================================== --- bloodhound/trunk/bloodhound_relations/bhrelations/tests/api.py (added) +++ bloodhound/trunk/bloodhound_relations/bhrelations/tests/api.py Fri Apr 26 12:17:58 2013 @@ -0,0 +1,225 @@ +#!/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. +from _sqlite3 import OperationalError, IntegrityError +from bhrelations.api import EnvironmentSetup, RelationsSystem +from trac.ticket.model import Ticket +from trac.test import EnvironmentStub, Mock +from trac.core import TracError +from trac.util.datefmt import utc +import unittest + +try: + from babel import Locale + locale_en = Locale.parse('en_US') +except ImportError: + locale_en = None + +class ApiTestCase(unittest.TestCase): + def setUp(self): + self.env = EnvironmentStub( + default_data=True, + enable=['trac.*', 'bhrelations.*'] + ) + config_name = RelationsSystem.RELATIONS_CONFIG_NAME + self.env.config.set(config_name, 'dependency', 'dependson,dependent') + self.env.config.set(config_name, 'dependency.validator', 'no_cycle') + self.env.config.set(config_name, 'parent_children','parent,children') + self.env.config.set(config_name, 'parent_children.validator', + 'parent_child') + self.env.config.set(config_name, 'children.blocks', 'true') + self.env.config.set(config_name, 'children.label', 'Overridden') + self.env.config.set(config_name, 'parent.copy_fields', + 'summary, foo') + self.env.config.set(config_name, 'oneway', 'refersto') + self.req = Mock(href=self.env.href, authname='anonymous', tz=utc, + args=dict(action='dummy'), + locale=locale_en, lc_time=locale_en) + self.relations_system = RelationsSystem(self.env) + self._upgrade_env() + + def tearDown(self): + # shutil.rmtree(self.env.path) + self.env.reset_db() + + def _upgrade_env(self): + environment_setup = EnvironmentSetup(self.env) + try: + environment_setup.upgrade_environment(self.env.db_transaction) + except OperationalError: + # table remains but database version is deleted + pass + + def _insert_ticket(self, summary, **kw): + """Helper for inserting a ticket into the database""" + ticket = Ticket(self.env) + for k,v in kw.items(): + ticket[k] = v + return ticket.insert() + + def _insert_and_load_ticket(self, summary, **kw): + return Ticket(self.env, self._insert_ticket(summary, **kw)) + + def test_can_add_two_ways_relations(self): + #arrange + ticket = self._insert_and_load_ticket("A1") + dependent = self._insert_and_load_ticket("A2") + #act + relations_system = self.relations_system + relations_system.add_relation( + ticket, dependent, "dependent") + #assert + relations = relations_system.get_relations(ticket) + self.assertEqual("dependent", relations[0]["type"]) + self.assertEqual(dependent.id, relations[0]["destination"].id) + + relations = relations_system.get_relations(dependent) + self.assertEqual("dependson", relations[0]["type"]) + self.assertEqual(ticket.id, relations[0]["destination"].id) + + def test_can_add_single_way_relations(self): + #arrange + ticket = self._insert_and_load_ticket("A1") + referred = self._insert_and_load_ticket("A2") + #act + relations_system = self.relations_system + relations_system.add_relation(ticket, referred, "refersto") + #assert + relations = relations_system.get_relations(ticket) + self.assertEqual("refersto", relations[0]["type"]) + self.assertEqual(referred.id, relations[0]["destination"].id) + + relations = relations_system.get_relations(referred) + self.assertEqual(0, len(relations)) + + def test_can_add_multiple_relations(self): + #arrange + ticket = self._insert_and_load_ticket("A1") + dependent1 = self._insert_and_load_ticket("A2") + dependent2 = self._insert_and_load_ticket("A3") + #act + relations_system = self.relations_system + relations_system.add_relation( + ticket, dependent1, "dependent") + relations_system.add_relation( + ticket, dependent2, "dependent") + #assert + relations = relations_system.get_relations(ticket) + self.assertEqual(2, len(relations)) + + def test_will_not_create_more_than_one_identical_relations(self): + #arrange + ticket = self._insert_and_load_ticket("A1") + dependent1 = self._insert_and_load_ticket("A2") + #act + relations_system = self.relations_system + relations_system.add_relation( + ticket, dependent1, "dependent") + self.assertRaisesRegexp( + TracError, + "already exists", + relations_system.add_relation, + ticket, dependent1, "dependent") + + def test_will_not_create_more_than_one_identical_relations_db_level(self): + sql = """INSERT INTO bloodhound_relations (source, destination, type) + VALUES (%s, %s, %s)""" + with self.env.db_transaction as db: + db(sql, ["1", "2", "dependson"]) + self.assertRaises( + IntegrityError, db, sql, ["1", "2", "dependson"]) + + def test_can_add_one_way_relations(self): + #arrange + ticket = self._insert_and_load_ticket("A1") + referred_ticket = self._insert_and_load_ticket("A2") + #act + relations_system = self.relations_system + relations_system.add_relation( + ticket, referred_ticket, "refersto") + #assert + relations = relations_system.get_relations(ticket) + self.assertEqual("refersto", relations[0]["type"]) + self.assertEqual(referred_ticket.id, relations[0]["destination"].id) + + relations = relations_system.get_relations(referred_ticket) + self.assertEqual(0, len(relations)) + + def test_can_delete_two_ways_relation(self): + #arrange + ticket = self._insert_and_load_ticket("A1") + dependent_ticket = self._insert_and_load_ticket("A2") + relations_system = self.relations_system + relations_system.add_relation( + ticket, dependent_ticket, "dependson") + relations = relations_system.get_relations(ticket) + self.assertEqual(1, len(relations)) + #act + relation_to_delete = relations[0] + relations_system.delete_relation_by_id(relation_to_delete["relation_id"]) + #assert + relations = relations_system.get_relations(ticket) + self.assertEqual(0, len(relations)) + + def test_can_delete_single_way_relation(self): + #arrange + ticket = self._insert_and_load_ticket("A1") + referred = self._insert_and_load_ticket("A2") + #act + relations_system = self.relations_system + relations_system.add_relation(ticket, referred, "refersto") + + + ticket = self._insert_and_load_ticket("A1") + dependent_ticket = self._insert_and_load_ticket("A2") + relations_system = self.relations_system + relations_system.add_relation( + ticket, dependent_ticket, "dependson") + relations = relations_system.get_relations(ticket) + + self.assertEqual(1, len(relations)) + reverted_relations = relations_system.get_relations(dependent_ticket) + self.assertEqual(1, len(reverted_relations)) + #act + # self._debug_select() + relation_to_delete = relations[0] + relations_system.delete_relation_by_id(relation_to_delete["relation_id"]) + #assert + relations = relations_system.get_relations(ticket) + self.assertEqual(0, len(relations)) + reverted_relations = relations_system.get_relations(dependent_ticket) + self.assertEqual(0, len(reverted_relations)) + + # def _find_relation(self, relations, destination, relation_type): + # destination_id = self.relations_system.get_resource_id(destination) + # for relation in relations: + # if relation["destination_id"] == destination_id and \ + # relation["type"] == relation_type: + # return relation + # raise Exception("Relation was not found for destination_id: %s,"+ + # " relation_type: %s" % (destination_id, relation_type)) + + def _debug_select(self): + # sql = "SELECT source, destination, type FROM bloodhound_relations" + print " id, source, destination, type" + sql = "SELECT id, source, destination, type FROM bloodhound_relations" + with self.env.db_query as db: + # for row in db(sql, ("source", "destination", "type")): + for row in db(sql): + print row Modified: bloodhound/trunk/bloodhound_relations/setup.py URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_relations/setup.py?rev=1476162&r1=1476161&r2=1476162&view=diff ============================================================================== --- bloodhound/trunk/bloodhound_relations/setup.py (original) +++ bloodhound/trunk/bloodhound_relations/setup.py Fri Apr 26 12:17:58 2013 @@ -99,10 +99,6 @@ PKG_INFO = {'bhrelations': ('bhrelations ), 'bhrelations.tests': ( 'bhrelations/tests', ['data/*.*']), - 'bhrelations.trac.ticket': ( - 'bhrelations/trac/ticket', []), - 'bhrelations.trac.ticket.tests' : ( - 'bhrelations/trac/ticket/tests', []), } ENTRY_POINTS = {