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:&nbsp;<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',


Reply via email to