Author: astaric Date: Tue Jul 9 09:19:00 2013 New Revision: 1501152 URL: http://svn.apache.org/r1501152 Log: Integration of duplicate relations to close as duplicate workflow.
Refs: #588 Added: bloodhound/trunk/bloodhound_relations/bhrelations/tests/base.py bloodhound/trunk/bloodhound_relations/bhrelations/utils.py Modified: bloodhound/trunk/bloodhound_relations/bhrelations/api.py bloodhound/trunk/bloodhound_relations/bhrelations/tests/api.py bloodhound/trunk/bloodhound_relations/bhrelations/tests/notification.py bloodhound/trunk/bloodhound_relations/bhrelations/tests/search.py bloodhound/trunk/bloodhound_relations/bhrelations/tests/validation.py bloodhound/trunk/bloodhound_relations/bhrelations/tests/web_ui.py bloodhound/trunk/bloodhound_relations/bhrelations/web_ui.py bloodhound/trunk/bloodhound_relations/bhrelations/widgets/relations.py bloodhound/trunk/bloodhound_theme/bhtheme/htdocs/bloodhound.css bloodhound/trunk/bloodhound_theme/bhtheme/templates/bh_ticket.html bloodhound/trunk/installer/bloodhound_setup.py Modified: bloodhound/trunk/bloodhound_relations/bhrelations/api.py URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_relations/bhrelations/api.py?rev=1501152&r1=1501151&r2=1501152&view=diff ============================================================================== --- bloodhound/trunk/bloodhound_relations/bhrelations/api.py (original) +++ bloodhound/trunk/bloodhound_relations/bhrelations/api.py Tue Jul 9 09:19:00 2013 @@ -17,17 +17,24 @@ # 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 trac.config import OrderedExtensionsOption +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, +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 @@ -167,6 +174,12 @@ class RelationsSystem(Component): regardless of their type.""" ) + duplicate_relation_type = Option( + 'bhrelations', + 'duplicate_relation', + '', + "Relation type to be used with the resolve as duplicate workflow.") + def __init__(self): links, labels, validators, blockers, copy_fields, exclusive = \ self._parse_config() @@ -443,28 +456,46 @@ class ResourceIdSerializer(object): class TicketRelationsSpecifics(Component): implements(ITicketManipulator, ITicketChangeListener) - #ITicketChangeListener methods + 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): - pass + 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): - RelationsSystem(self.env).delete_resource_relations(ticket) + self.rls.delete_resource_relations(ticket) #ITicketManipulator methods - def prepare_ticket(self, req, ticket, fields, actions): pass def validate_ticket(self, req, ticket): - action = req.args.get('action') - if action == 'resolve': - rls = RelationsSystem(self.env) - blockers = rls.find_blockers( - ticket, self.is_blocker) + 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) @@ -474,14 +505,61 @@ class TicketRelationsSpecifics(Component % blockers_str) yield None, msg - for relation in [r for r in rls.get_relations(ticket) - if r['type'] == rls.CHILDREN_RELATION_TYPE]: + 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': @@ -573,14 +651,10 @@ class TicketChangeRecordUpdater(Componen new_value, product)) -# Copied from trac/utils.py, ticket-links-trunk branch -def unique(seq): - """Yield unique elements from sequence of hashables, preserving order. - (New in 0.13) - """ - seen = set() - return (x for x in seq if x not in seen and not seen.add(x)) - class UnknownRelationType(ValueError): pass + + +class NoSuchTicketError(ValueError): + pass Modified: bloodhound/trunk/bloodhound_relations/bhrelations/tests/api.py URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_relations/bhrelations/tests/api.py?rev=1501152&r1=1501151&r2=1501152&view=diff ============================================================================== --- bloodhound/trunk/bloodhound_relations/bhrelations/tests/api.py (original) +++ bloodhound/trunk/bloodhound_relations/bhrelations/tests/api.py Tue Jul 9 09:19:00 2013 @@ -18,99 +18,19 @@ # specific language governing permissions and limitations # under the License. from datetime import datetime -from _sqlite3 import OperationalError, IntegrityError +from _sqlite3 import IntegrityError import unittest -from bhrelations.api import (EnvironmentSetup, RelationsSystem, - TicketRelationsSpecifics) +from bhrelations.api import TicketRelationsSpecifics from bhrelations.tests.mocks import TestRelationChangingListener from bhrelations.validation import ValidationError +from bhrelations.tests.base import BaseRelationsTestCase from multiproduct.env import ProductEnvironment -from tests.env import MultiproductTestCase from trac.ticket.model import Ticket -from trac.test import EnvironmentStub, Mock, MockPerm from trac.core import TracError from trac.util.datefmt import utc -try: - from babel import Locale - locale_en = Locale.parse('en_US') -except ImportError: - locale_en = None - - -class BaseApiApiTestCase(MultiproductTestCase): - def setUp(self, enabled=()): - env = EnvironmentStub( - default_data=True, - enable=(['trac.*', 'multiproduct.*', 'bhrelations.*'] + - list(enabled)) - ) - env.config.set('bhrelations', 'global_validators', - 'NoSelfReferenceValidator,ExclusiveValidator,' - 'BlockerValidator') - config_name = RelationsSystem.RELATIONS_CONFIG_NAME - env.config.set(config_name, 'dependency', 'dependson,dependent') - env.config.set(config_name, 'dependency.validators', - 'NoCycles,SingleProduct') - env.config.set(config_name, 'dependson.blocks', 'true') - env.config.set(config_name, 'parent_children', 'parent,children') - env.config.set(config_name, 'parent_children.validators', - 'OneToMany,SingleProduct,NoCycles') - env.config.set(config_name, 'children.label', 'Overridden') - env.config.set(config_name, 'parent.copy_fields', - 'summary, foo') - env.config.set(config_name, 'parent.exclusive', 'true') - env.config.set(config_name, 'multiproduct_relation', 'mprel,mpbackrel') - env.config.set(config_name, 'oneway', 'refersto') - env.config.set(config_name, 'duplicate', 'duplicateof,duplicatedby') - env.config.set(config_name, 'duplicate.validators', 'ReferencesOlder') - env.config.set(config_name, 'duplicateof.label', 'Duplicate of') - env.config.set(config_name, 'duplicatedby.label', 'Duplicated by') - env.config.set(config_name, 'blocker', 'blockedby,blocks') - env.config.set(config_name, 'blockedby.blocks', 'true') - - self.global_env = env - self._upgrade_mp(self.global_env) - self._setup_test_log(self.global_env) - self._load_product_from_data(self.global_env, self.default_product) - self.env = ProductEnvironment(self.global_env, self.default_product) - - self.req = Mock(href=self.env.href, authname='anonymous', tz=utc, - args=dict(action='dummy'), - locale=locale_en, lc_time=locale_en) - self.req.perm = MockPerm() - self.relations_system = RelationsSystem(self.env) - self._upgrade_env() - - def tearDown(self): - self.global_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 - - @classmethod - def _insert_ticket(cls, env, summary, **kw): - """Helper for inserting a ticket into the database""" - ticket = Ticket(env) - ticket["Summary"] = summary - 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(self.env, summary, **kw)) - - def _insert_and_load_ticket_with_env(self, env, summary, **kw): - return Ticket(env, self._insert_ticket(env, summary, **kw)) - - -class ApiTestCase(BaseApiApiTestCase): +class ApiTestCase(BaseRelationsTestCase): def test_can_add_two_ways_relations(self): #arrange ticket = self._insert_and_load_ticket("A1") @@ -475,7 +395,7 @@ class ApiTestCase(BaseApiApiTestCase): ) def test_cannot_create_other_relations_between_descendants(self): - t1, t2, t3, t4, t5 = map(self._insert_and_load_ticket, range(5)) + t1, t2, t3, t4, t5 = map(self._insert_and_load_ticket, "12345") self.relations_system.add(t4, t2, "parent") # t1 -> t2 self.relations_system.add(t3, t2, "parent") # / \ self.relations_system.add(t2, t1, "parent") # t3 t4 @@ -503,7 +423,7 @@ class ApiTestCase(BaseApiApiTestCase): self.fail("Could not add valid relation.") def test_cannot_add_parent_if_this_would_cause_invalid_relations(self): - t1, t2, t3, t4, t5 = map(self._insert_and_load_ticket, range(5)) + t1, t2, t3, t4, t5 = map(self._insert_and_load_ticket, "12345") self.relations_system.add(t4, t2, "parent") # t1 -> t2 self.relations_system.add(t3, t2, "parent") # / \ self.relations_system.add(t2, t1, "parent") # t3 t4 t5 @@ -553,7 +473,7 @@ class ApiTestCase(BaseApiApiTestCase): self.relations_system.add(t2, t1, "duplicateof") def test_detects_blocker_cycles(self): - t1, t2, t3, t4, t5 = map(self._insert_and_load_ticket, range(5)) + t1, t2, t3, t4, t5 = map(self._insert_and_load_ticket, "12345") self.relations_system.add(t1, t2, "blocks") self.relations_system.add(t3, t2, "dependson") self.relations_system.add(t4, t3, "blockedby") @@ -577,7 +497,7 @@ class ApiTestCase(BaseApiApiTestCase): self.relations_system.add(t2, t1, "refersto") -class RelationChangingListenerTestCase(BaseApiApiTestCase): +class RelationChangingListenerTestCase(BaseRelationsTestCase): def test_can_sent_adding_event(self): #arrange ticket1 = self._insert_and_load_ticket("A1") @@ -608,7 +528,7 @@ class RelationChangingListenerTestCase(B self.assertEqual("dependent", relation.type) -class TicketChangeRecordUpdaterTestCase(BaseApiApiTestCase): +class TicketChangeRecordUpdaterTestCase(BaseRelationsTestCase): def test_can_update_ticket_history_on_relation_add_on(self): #arrange ticket1 = self._insert_and_load_ticket("A1") Added: bloodhound/trunk/bloodhound_relations/bhrelations/tests/base.py URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_relations/bhrelations/tests/base.py?rev=1501152&view=auto ============================================================================== --- bloodhound/trunk/bloodhound_relations/bhrelations/tests/base.py (added) +++ bloodhound/trunk/bloodhound_relations/bhrelations/tests/base.py Tue Jul 9 09:19:00 2013 @@ -0,0 +1,87 @@ +from _sqlite3 import OperationalError +from tests.env import MultiproductTestCase +from multiproduct.env import ProductEnvironment +from bhrelations.api import RelationsSystem, EnvironmentSetup +from trac.test import EnvironmentStub, Mock, MockPerm +from trac.ticket import Ticket +from trac.util.datefmt import utc + +try: + from babel import Locale + + locale_en = Locale.parse('en_US') +except ImportError: + locale_en = None + + +class BaseRelationsTestCase(MultiproductTestCase): + def setUp(self, enabled=()): + env = EnvironmentStub( + default_data=True, + enable=(['trac.*', 'multiproduct.*', 'bhrelations.*'] + + list(enabled)) + ) + env.config.set('bhrelations', 'global_validators', + 'NoSelfReferenceValidator,ExclusiveValidator,' + 'BlockerValidator') + env.config.set('bhrelations', 'duplicate_relation', + 'duplicateof') + config_name = RelationsSystem.RELATIONS_CONFIG_NAME + env.config.set(config_name, 'dependency', 'dependson,dependent') + env.config.set(config_name, 'dependency.validators', + 'NoCycles,SingleProduct') + env.config.set(config_name, 'dependson.blocks', 'true') + env.config.set(config_name, 'parent_children', 'parent,children') + env.config.set(config_name, 'parent_children.validators', + 'OneToMany,SingleProduct,NoCycles') + env.config.set(config_name, 'children.label', 'Overridden') + env.config.set(config_name, 'parent.copy_fields', + 'summary, foo') + env.config.set(config_name, 'parent.exclusive', 'true') + env.config.set(config_name, 'multiproduct_relation', 'mprel,mpbackrel') + env.config.set(config_name, 'oneway', 'refersto') + env.config.set(config_name, 'duplicate', 'duplicateof,duplicatedby') + env.config.set(config_name, 'duplicate.validators', 'ReferencesOlder') + env.config.set(config_name, 'duplicateof.label', 'Duplicate of') + env.config.set(config_name, 'duplicatedby.label', 'Duplicated by') + env.config.set(config_name, 'blocker', 'blockedby,blocks') + env.config.set(config_name, 'blockedby.blocks', 'true') + + self.global_env = env + self._upgrade_mp(self.global_env) + self._setup_test_log(self.global_env) + self._load_product_from_data(self.global_env, self.default_product) + self.env = ProductEnvironment(self.global_env, self.default_product) + + self.req = Mock(href=self.env.href, authname='anonymous', tz=utc, + args=dict(action='dummy'), + locale=locale_en, lc_time=locale_en) + self.req.perm = MockPerm() + self.relations_system = RelationsSystem(self.env) + self._upgrade_env() + + def tearDown(self): + self.global_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 + + @classmethod + def _insert_ticket(cls, env, summary, **kw): + """Helper for inserting a ticket into the database""" + ticket = Ticket(env) + ticket["summary"] = summary + 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(self.env, summary, **kw)) + + def _insert_and_load_ticket_with_env(self, env, summary, **kw): + return Ticket(env, self._insert_ticket(env, summary, **kw)) Modified: bloodhound/trunk/bloodhound_relations/bhrelations/tests/notification.py URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_relations/bhrelations/tests/notification.py?rev=1501152&r1=1501151&r2=1501152&view=diff ============================================================================== --- bloodhound/trunk/bloodhound_relations/bhrelations/tests/notification.py (original) +++ bloodhound/trunk/bloodhound_relations/bhrelations/tests/notification.py Tue Jul 9 09:19:00 2013 @@ -21,11 +21,11 @@ import unittest from trac.tests.notification import SMTPServerStore, SMTPThreadedServer from trac.ticket.tests.notification import ( SMTP_TEST_PORT, smtp_address, parse_smtp_message) +from bhrelations.tests.base import BaseRelationsTestCase from bhrelations.notification import RelationNotifyEmail -from bhrelations.tests.api import BaseApiApiTestCase -class NotificationTestCase(BaseApiApiTestCase): +class NotificationTestCase(BaseRelationsTestCase): @classmethod def setUpClass(cls): cls.smtpd = CustomSMTPThreadedServer(SMTP_TEST_PORT) Modified: bloodhound/trunk/bloodhound_relations/bhrelations/tests/search.py URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_relations/bhrelations/tests/search.py?rev=1501152&r1=1501151&r2=1501152&view=diff ============================================================================== --- bloodhound/trunk/bloodhound_relations/bhrelations/tests/search.py (original) +++ bloodhound/trunk/bloodhound_relations/bhrelations/tests/search.py Tue Jul 9 09:19:00 2013 @@ -21,25 +21,25 @@ import shutil import tempfile import unittest -from bhrelations.tests.api import BaseApiApiTestCase from bhsearch.api import BloodhoundSearchApi # TODO: Figure how to get trac to load components from these modules import bhsearch.query_parser, bhsearch.search_resources.ticket_search, \ bhsearch.whoosh_backend import bhrelations.search +from bhrelations.tests.base import BaseRelationsTestCase -class SearchIntegrationTestCase(BaseApiApiTestCase): +class SearchIntegrationTestCase(BaseRelationsTestCase): def setUp(self): - BaseApiApiTestCase.setUp(self, enabled=['bhsearch.*']) + BaseRelationsTestCase.setUp(self, enabled=['bhsearch.*']) self.global_env.path = tempfile.mkdtemp('bhrelations-tempenv') self.search_api = BloodhoundSearchApi(self.env) self.search_api.upgrade_environment(self.env.db_transaction) def tearDown(self): shutil.rmtree(self.env.path) - BaseApiApiTestCase.tearDown(self) + BaseRelationsTestCase.tearDown(self) def test_relations_are_indexed_on_creation(self): t1 = self._insert_and_load_ticket("Foo") Modified: bloodhound/trunk/bloodhound_relations/bhrelations/tests/validation.py URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_relations/bhrelations/tests/validation.py?rev=1501152&r1=1501151&r2=1501152&view=diff ============================================================================== --- bloodhound/trunk/bloodhound_relations/bhrelations/tests/validation.py (original) +++ bloodhound/trunk/bloodhound_relations/bhrelations/tests/validation.py Tue Jul 9 09:19:00 2013 @@ -20,10 +20,10 @@ import unittest from bhrelations.validation import Validator -from bhrelations.tests.api import BaseApiApiTestCase +from bhrelations.tests.base import BaseRelationsTestCase -class GraphFunctionsTestCase(BaseApiApiTestCase): +class GraphFunctionsTestCase(BaseRelationsTestCase): edges = [ ('A', 'B', 'p'), # A H ('A', 'C', 'p'), # / \ / @@ -35,7 +35,7 @@ class GraphFunctionsTestCase(BaseApiApiT ] def setUp(self): - BaseApiApiTestCase.setUp(self) + BaseRelationsTestCase.setUp(self) # bhrelations point from destination to source for destination, source, type in self.edges: self.env.db_direct_transaction( Modified: bloodhound/trunk/bloodhound_relations/bhrelations/tests/web_ui.py URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_relations/bhrelations/tests/web_ui.py?rev=1501152&r1=1501151&r2=1501152&view=diff ============================================================================== --- bloodhound/trunk/bloodhound_relations/bhrelations/tests/web_ui.py (original) +++ bloodhound/trunk/bloodhound_relations/bhrelations/tests/web_ui.py Tue Jul 9 09:19:00 2013 @@ -18,27 +18,31 @@ # specific language governing permissions and limitations # under the License. import unittest - +from bhrelations.api import ResourceIdSerializer from bhrelations.web_ui import RelationManagementModule -from bhrelations.tests.api import BaseApiApiTestCase +from bhrelations.tests.base import BaseRelationsTestCase + +from multiproduct.ticket.web_ui import TicketModule +from trac.ticket import Ticket +from trac.util.datefmt import to_utimestamp +from trac.web import RequestDone -class RelationManagementModuleTestCase(BaseApiApiTestCase): +class RelationManagementModuleTestCase(BaseRelationsTestCase): def setUp(self): - BaseApiApiTestCase.setUp(self) + BaseRelationsTestCase.setUp(self) ticket_id = self._insert_ticket(self.env, "Foo") - args=dict(action='add', id=ticket_id, dest_tid='', reltype='', comment='') - self.req.method = 'GET', + self.req.method = 'POST' self.req.args['id'] = ticket_id def test_can_process_empty_request(self): + self.req.method = 'GET' data = self.process_request() self.assertSequenceEqual(data['relations'], []) self.assertEqual(len(data['reltypes']), 11) def test_handles_missing_ticket_id(self): - self.req.method = "POST" self.req.args['add'] = 'add' data = self.process_request() @@ -46,8 +50,7 @@ class RelationManagementModuleTestCase(B self.assertIn("Invalid ticket", data["error"]) def test_handles_invalid_ticket_id(self): - self.req.method = "POST" - self.req.args['add'] = 'add' + self.req.args['add'] = True self.req.args['dest_tid'] = 'no such ticket' data = self.process_request() @@ -56,8 +59,7 @@ class RelationManagementModuleTestCase(B def test_handles_missing_relation_type(self): t2 = self._insert_ticket(self.env, "Bar") - self.req.method = "POST" - self.req.args['add'] = 'add' + self.req.args['add'] = True self.req.args['dest_tid'] = str(t2) data = self.process_request() @@ -66,8 +68,7 @@ class RelationManagementModuleTestCase(B def test_handles_invalid_relation_type(self): t2 = self._insert_ticket(self.env, "Bar") - self.req.method = "POST" - self.req.args['add'] = 'add' + self.req.args['add'] = True self.req.args['dest_tid'] = str(t2) self.req.args['reltype'] = 'no such relation' @@ -77,8 +78,7 @@ class RelationManagementModuleTestCase(B def test_shows_relation_that_was_just_added(self): t2 = self._insert_ticket(self.env, "Bar") - self.req.method = "POST" - self.req.args['add'] = 'add' + self.req.args['add'] = True self.req.args['dest_tid'] = str(t2) self.req.args['reltype'] = 'dependson' @@ -92,6 +92,102 @@ class RelationManagementModuleTestCase(B return data +class ResolveTicketIntegrationTestCase(BaseRelationsTestCase): + def setUp(self): + BaseRelationsTestCase.setUp(self) + + self.mock_request() + self.configure() + + self.req.redirect = self.redirect + self.redirect_url = None + self.redirect_permanent = None + + def test_creates_duplicate_relation_from_duplicate_id(self): + t1 = self._insert_and_load_ticket("Foo") + t2 = self._insert_and_load_ticket("Bar") + + self.assertRaises(RequestDone, + self.resolve_as_duplicate, + t2, self.get_id(t1)) + relations = self.relations_system.get_relations(t2) + self.assertEqual(len(relations), 1) + relation = relations[0] + self.assertEqual(relation['destination_id'], self.get_id(t1)) + self.assertEqual(relation['type'], 'duplicateof') + + def test_prefills_duplicate_id_if_relation_exists(self): + t1 = self._insert_and_load_ticket("Foo") + t2 = self._insert_and_load_ticket("Bar") + self.relations_system.add(t2, t1, 'duplicateof') + self.req.args['id'] = t2.id + self.req.path_info = '/ticket/%d' % t2.id + + data = self.process_request() + + self.assertIn('ticket_duplicate_of', data) + t1id = ResourceIdSerializer.get_resource_id_from_instance(self.env, t1) + self.assertEqual(data['ticket_duplicate_of'], t1id) + + def test_can_set_duplicate_resolution_even_if_relation_exists(self): + t1 = self._insert_and_load_ticket("Foo") + t2 = self._insert_and_load_ticket("Bar") + self.relations_system.add(t2, t1, 'duplicateof') + + self.assertRaises(RequestDone, + self.resolve_as_duplicate, + t2, self.get_id(t1)) + t2 = Ticket(self.env, t2.id) + self.assertEqual(t2['status'], 'closed') + self.assertEqual(t2['resolution'], 'duplicate') + + def resolve_as_duplicate(self, ticket, duplicate_id): + self.req.method = 'POST' + self.req.path_info = '/ticket/%d' % ticket.id + self.req.args['id'] = ticket.id + self.req.args['action'] = 'resolve' + self.req.args['action_resolve_resolve_resolution'] = 'duplicate' + self.req.args['duplicate_id'] = duplicate_id + self.req.args['view_time'] = str(to_utimestamp(ticket['changetime'])) + self.req.args['submit'] = True + + return self.process_request() + + def process_request(self): + template, data, content_type = \ + TicketModule(self.env).process_request(self.req) + template, data, content_type = \ + RelationManagementModule(self.env).post_process_request( + self.req, template, data, content_type) + return data + + def mock_request(self): + self.req.method = 'GET' + self.req.get_header = lambda x: None + self.req.authname = 'x' + self.req.session = {} + self.req.chrome = {'warnings': []} + self.req.form_token = '' + + def configure(self): + config = self.env.config + config['ticket-workflow'].set('resolve', 'new -> closed') + config['ticket-workflow'].set('resolve.operations', 'set_resolution') + config['ticket-workflow'].set('resolve.permissions', 'TICKET_MODIFY') + with self.env.db_transaction as db: + db("INSERT INTO enum VALUES " + "('resolution', 'duplicate', 'duplicate')") + + def redirect(self, url, permanent=False): + self.redirect_url = url + self.redirect_permanent = permanent + raise RequestDone + + def get_id(self, ticket): + return ResourceIdSerializer.get_resource_id_from_instance(self.env, + ticket) + + def suite(): test_suite = unittest.TestSuite() test_suite.addTest(unittest.makeSuite(RelationManagementModuleTestCase, 'test')) Added: bloodhound/trunk/bloodhound_relations/bhrelations/utils.py URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_relations/bhrelations/utils.py?rev=1501152&view=auto ============================================================================== --- bloodhound/trunk/bloodhound_relations/bhrelations/utils.py (added) +++ bloodhound/trunk/bloodhound_relations/bhrelations/utils.py Tue Jul 9 09:19:00 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. + + +# Copied from trac/utils.py, ticket-links-trunk branch +def unique(seq): + """Yield unique elements from sequence of hashables, preserving order. + (New in 0.13) + """ + seen = set() + return (x for x in seq if x not in seen and not seen.add(x)) Modified: bloodhound/trunk/bloodhound_relations/bhrelations/web_ui.py URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_relations/bhrelations/web_ui.py?rev=1501152&r1=1501151&r2=1501152&view=diff ============================================================================== --- bloodhound/trunk/bloodhound_relations/bhrelations/web_ui.py (original) +++ bloodhound/trunk/bloodhound_relations/bhrelations/web_ui.py Tue Jul 9 09:19:00 2013 @@ -28,22 +28,20 @@ import re import pkg_resources from trac.core import Component, implements, TracError -from trac.resource import get_resource_url, ResourceNotFound, Resource +from trac.resource import get_resource_url, Resource from trac.ticket.model import Ticket from trac.util.translation import _ -from trac.web import IRequestHandler +from trac.web import IRequestHandler, IRequestFilter from trac.web.chrome import ITemplateProvider, add_warning from bhrelations.api import RelationsSystem, ResourceIdSerializer, \ - TicketRelationsSpecifics, UnknownRelationType + TicketRelationsSpecifics, UnknownRelationType, NoSuchTicketError from bhrelations.model import Relation from bhrelations.validation import ValidationError -from multiproduct.model import Product -from multiproduct.env import ProductEnvironment class RelationManagementModule(Component): - implements(IRequestHandler, ITemplateProvider) + implements(IRequestFilter, IRequestHandler, ITemplateProvider) # IRequestHandler methods def match_request(self, req): @@ -88,22 +86,27 @@ class RelationManagementModule(Component comment=req.args.get('comment', ''), ) try: - dest_ticket = self.find_ticket(relation['destination']) - req.perm.require('TICKET_MODIFY', - Resource(dest_ticket.id)) - relsys.add(ticket, dest_ticket, - relation['type'], - relation['comment'], - req.authname) + trs = TicketRelationsSpecifics(self.env) + dest_ticket = trs.find_ticket(relation['destination']) except NoSuchTicketError: - data['error'] = _('Invalid ticket id.') - except UnknownRelationType: - data['error'] = _('Unknown relation type.') - except ValidationError as ex: - data['error'] = ex.message + data['error'] = _('Invalid ticket ID.') + else: + req.perm.require('TICKET_MODIFY', Resource(dest_ticket.id)) + + try: + relsys.add(ticket, dest_ticket, + relation['type'], + relation['comment'], + req.authname) + except NoSuchTicketError: + data['error'] = _('Invalid ticket ID.') + except UnknownRelationType: + data['error'] = _('Unknown relation type.') + except ValidationError as ex: + data['error'] = ex.message + if 'error' in data: data['relation'] = relation - else: raise TracError(_('Invalid operation.')) @@ -123,6 +126,25 @@ class RelationManagementModule(Component resource_filename = pkg_resources.resource_filename return [resource_filename('bhrelations', 'templates'), ] + # IRequestFilter methods + def pre_process_request(self, req, handler): + return handler + + def post_process_request(self, req, template, data, content_type): + if 'ticket' in data: + ticket = data['ticket'] + rls = RelationsSystem(self.env) + resid = ResourceIdSerializer.get_resource_id_from_instance( + self.env, ticket) + + if rls.duplicate_relation_type: + duplicate_relations = \ + rls._select_relations(resid, rls.duplicate_relation_type) + if duplicate_relations: + data['ticket_duplicate_of'] = \ + duplicate_relations[0].destination + return template, data, content_type + # utility functions def get_ticket_relations(self, ticket): grouped_relations = {} @@ -136,36 +158,6 @@ class RelationManagementModule(Component grouped_relations.setdefault(reltypes[r['type']], []).append(r) return grouped_relations - 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: - trs = TicketRelationsSpecifics(self.env) - try: - resource = ResourceIdSerializer.get_resource_by_id(tid) - ticket = trs._create_ticket_by_full_id(resource) - except: - raise NoSuchTicketError - return ticket - def remove_relations(self, req, rellist): relsys = RelationsSystem(self.env) for relid in rellist: @@ -177,7 +169,3 @@ class RelationManagementModule(Component else: add_warning(req, _('Not enough permissions to remove relation "%s"' % relid)) - - -class NoSuchTicketError(ValueError): - pass Modified: bloodhound/trunk/bloodhound_relations/bhrelations/widgets/relations.py URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_relations/bhrelations/widgets/relations.py?rev=1501152&r1=1501151&r2=1501152&view=diff ============================================================================== --- bloodhound/trunk/bloodhound_relations/bhrelations/widgets/relations.py (original) +++ bloodhound/trunk/bloodhound_relations/bhrelations/widgets/relations.py Tue Jul 9 09:19:00 2013 @@ -69,7 +69,7 @@ class TicketRelationsWidget(WidgetBase): RelationManagementModule(self.env).get_ticket_relations(ticket), } return 'widget_relations.html', \ - { 'title': title, 'data': data, }, context + {'title': title, 'data': data, }, context render_widget = pretty_wrapper(render_widget, check_widget_name) Modified: bloodhound/trunk/bloodhound_theme/bhtheme/htdocs/bloodhound.css URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_theme/bhtheme/htdocs/bloodhound.css?rev=1501152&r1=1501151&r2=1501152&view=diff ============================================================================== --- bloodhound/trunk/bloodhound_theme/bhtheme/htdocs/bloodhound.css (original) +++ bloodhound/trunk/bloodhound_theme/bhtheme/htdocs/bloodhound.css Tue Jul 9 09:19:00 2013 @@ -174,6 +174,11 @@ div.reports form { text-align: right; } +#duplicate_id { + margin-left: 10px; + margin-right: 10px; +} + #trac-ticket-title { margin-bottom: 5px; } Modified: bloodhound/trunk/bloodhound_theme/bhtheme/templates/bh_ticket.html URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_theme/bhtheme/templates/bh_ticket.html?rev=1501152&r1=1501151&r2=1501152&view=diff ============================================================================== --- bloodhound/trunk/bloodhound_theme/bhtheme/templates/bh_ticket.html (original) +++ bloodhound/trunk/bloodhound_theme/bhtheme/templates/bh_ticket.html Tue Jul 9 09:19:00 2013 @@ -160,6 +160,10 @@ } function install_workflow(){ + <py:if test="bhrelations"> + var act = $('#action_resolve_resolve_resolution').parent(); + act.append('<span id="duplicate_id" class="hide">Duplicate ID: <input name="duplicate_id" type="text" class="input-mini" value="${ticket_duplicate_of}"></input></span>'); + </py:if> var actions_box = $('#workflow-actions') .click(function(e) { e.stopPropagation(); }); $('#action').children('div').each(function() { @@ -180,7 +184,17 @@ else if (newowner) newlabel = newlabel + ' to ' + newowner; else if (newresolution) + { newlabel = newlabel + ' as ' + newresolution; + if (newresolution === 'duplicate') + { + $('#duplicate_id').show(); + } + else + { + $('#duplicate_id').hide(); + } + } $('#submit-action-label').text(newlabel); // Enable | disable action controls Modified: bloodhound/trunk/installer/bloodhound_setup.py URL: http://svn.apache.org/viewvc/bloodhound/trunk/installer/bloodhound_setup.py?rev=1501152&r1=1501151&r2=1501152&view=diff ============================================================================== --- bloodhound/trunk/installer/bloodhound_setup.py (original) +++ bloodhound/trunk/installer/bloodhound_setup.py Tue Jul 9 09:19:00 2013 @@ -93,6 +93,8 @@ BASE_CONFIG = {'components': {'bhtheme.* 'global_validators': 'NoSelfReferenceValidator,ExclusiveValidator,' 'BlockerValidator', + 'duplicate_relation': + 'duplicateof', }, 'bhrelations_links': { 'children.label': 'Child',