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',