Hi everyone,

We are implementing a free-text search plugin as a part of  Apache™ Bloodhound. 
The approach we use is based on AdvancedSearch [1] and uses similar 
approach as TracAdvancedSearchPlugin [2] and FullTextSearchPlugin [3] 
plugins. In short: listening for ITicketChangeListener events and updating 
nonDb free-text-search index.

However,  there is a problem common for such plugins - renaming of a ticket 
related entity such as Version, Component Type, Severity etc. is not 
covered by the event and the plugin cannot reflect this change in its nonDb 
free-text-search index. 

As a part of Apache™ Bloodhound Trac fork, we added a new 
IResourceChangeListener interface, which is common for all resources (see 
full code in attached patch).  The main idea of the IResourceChangeListener 
interface is that it is similar to existing I*ChangeListener interfaces 
(existing interfaces are working as before), it is common for all resources 
(resource is sent as parameter) and that there is no need to introduce 
interfaces such as IVersionChangeListener, IComponentChangeListener etc. 
Listener may specificy resource types for which it has to be notified via 
method get_subscribed_resources. What do you think about such approach?

The IResourceChangeListener implementation was tested in Apache™ Bloodhound 
for a couple of months and it looks like it is stable enough and covers, at 
least, our requirements. I think that support of the IResourceChangeListener 
interface in Trac can be useful for Trac community and would like to suggest 
this 
patch for Trac. Please find the attached patch rebased against the Trac 
trunk.
Is the Trac community interested in applying something like 
IResourceChangeListener 
into the Trac codebase?

Cheers, Andrej

[1]  http://trac.edgewall.org/wiki/AdvancedSearch
[2] http://trac-hacks.org/wiki/TracAdvancedSearchPlugin
[3] http://trac-hacks.org/wiki/FullTextSearchPlugin

-- 
You received this message because you are subscribed to the Google Groups "Trac 
Development" group.
To unsubscribe from this group and stop receiving emails from it, send an email 
to [email protected].
To post to this group, send email to [email protected].
Visit this group at http://groups.google.com/group/trac-dev?hl=en.
For more options, visit https://groups.google.com/groups/opt_out.


Index: trac/resource.py
===================================================================
--- trac/resource.py	(revision 11766)
+++ trac/resource.py	(working copy)
@@ -15,6 +15,7 @@
 #
 # Author: Christian Boos <[email protected]>
 #         Alec Thomas <[email protected]>
+from collections import defaultdict
 
 from trac.core import *
 from trac.util.translation import _
@@ -217,7 +218,44 @@
         """
         return Resource(realm, id, version, self)
 
+class IResourceChangeListener(Interface):
+    """Extension point interface for components that require notification
+    when resources are created, modified, or deleted.
 
+    'resource' parameters is instance of the a resource e.g. ticket, milestone
+    etc.
+    'context' is an action context, may contain author, comment etc. Context
+    content depends on a resource type.
+    """
+
+    def get_subscribed_resources():
+        """
+        Implementation should return iterator of resource types for which
+        the listener has to be notified.
+
+        None or empty list means all types of resources.
+        """
+
+    def resource_created(resource, context):
+        """
+        Called when a resource is created.
+        """
+
+    def resource_changed(resource, old_values, context):
+        """Called when a resource is modified.
+
+        `old_values` is a dictionary containing the previous values of the
+        resource properties that changed. Properties are specific for resource
+        type.
+        """
+
+    def resource_deleted(resource, context):
+        """Called when a resource is deleted."""
+
+    def resource_version_deleted(resource, context):
+        """Called when a version of a resource has been deleted."""
+
+
 class ResourceSystem(Component):
     """Resource identification and description manager.
 
@@ -226,10 +264,36 @@
     """
 
     resource_managers = ExtensionPoint(IResourceManager)
+    changed_listeners = ExtensionPoint(IResourceChangeListener)
 
     def __init__(self):
         self._resource_managers_map = None
+        self._changed_listeners_map = self._map_changed_listeners()
 
+    def _map_changed_listeners(self):
+        changed_listeners_map = defaultdict(list)
+        for change_listener in self.changed_listeners:
+            subscribed_types = change_listener.get_subscribed_resources()
+            if subscribed_types:
+                for subscribed_type in subscribed_types:
+                    changed_listeners_map[subscribed_type].append(
+                        change_listener)
+            else:
+                #empty or None means - subscribe for all resources types
+                changed_listeners_map[None].append(change_listener)
+        return changed_listeners_map
+
+    def _get_listeners_for_resource(self, resource):
+        listeners = list(self._changed_listeners_map[None])
+        for type, subscribers in self._changed_listeners_map.iteritems():
+            if type is not None and isinstance(resource, type):
+                listeners.extend(subscribers)
+        return listeners
+
+    def _notify(self, method_name, resource, *args):
+        for listener in self._get_listeners_for_resource(resource):
+            getattr(listener, method_name)(resource, *args)
+
     # Public methods
 
     def get_resource_manager(self, realm):
@@ -255,7 +319,18 @@
                 realms.append(realm)
         return realms
 
+    def resource_created(self, resource, context=None):
+        self._notify('resource_created', resource, context)
 
+    def resource_changed(self, resource, old_values, context=None):
+        self._notify('resource_changed', resource, old_values, context)
+
+    def resource_deleted(self, resource, context=None):
+        self._notify('resource_deleted', resource, context)
+
+    def resource_version_deleted(self, resource, context=None):
+        self._notify('resource_version_deleted', resource, context)
+
 # -- Utilities for manipulating resources in a generic way
 
 def get_resource_url(env, resource, href, **kwargs):
Index: trac/wiki/tests/model.py
===================================================================
--- trac/wiki/tests/model.py	(revision 11766)
+++ trac/wiki/tests/model.py	(working copy)
@@ -12,6 +12,7 @@
 from trac.attachment import Attachment
 from trac.core import *
 from trac.test import EnvironmentStub
+from trac.tests.resource import TestResourceChangeListener
 from trac.util.datefmt import utc, to_utimestamp
 from trac.wiki import WikiPage, IWikiChangeListener
 
@@ -267,9 +268,77 @@
             page = WikiPage(self.env, 'TestPage')
             self.assertRaises(TracError, page.rename, name)
 
+class WikiResourceChangeListenerTestCase(unittest.TestCase):
+    INITIAL_NAME = "Wiki page 1"
+    INITIAL_TEXT = "some text"
+    INITIAL_AUTHOR = "anAuthor"
+    INITIAL_COMMENT = "some comment"
+    INITIAL_REMOTE_ADDRESS = "::1"
 
+    def setUp(self):
+        self.env = EnvironmentStub(default_data=True)
+        self.listener = TestResourceChangeListener(self.env)
+        self.listener.resource_type = WikiPage
+        self.listener.callback = self.listener_callback
+
+    def tearDown(self):
+        self.env.reset_db()
+
+    def test_change_listener_created(self):
+        self._create_wiki_page(self.INITIAL_NAME)
+        self.assertEqual('created', self.listener.action)
+        self.assertTrue(isinstance(self.listener.resource, WikiPage))
+        self.assertEqual(self.INITIAL_NAME, self.wiki_name)
+        self.assertEqual(self.INITIAL_TEXT, self.wiki_text)
+
+    def test_change_listener_text_changed(self):
+        wiki_page = self._create_wiki_page(self.INITIAL_NAME)
+        CHANGED_TEXT = "some other text"
+        wiki_page.text = CHANGED_TEXT
+        wiki_page.save("author1", "renamed_comment", "::2")
+        self.assertEqual('changed', self.listener.action)
+        self.assertTrue(isinstance(self.listener.resource, WikiPage))
+        self.assertEqual(self.INITIAL_NAME, self.wiki_name)
+        self.assertEqual(CHANGED_TEXT, self.wiki_text)
+        self.assertEqual({"text":self.INITIAL_TEXT}, self.listener.old_values)
+
+    def test_change_listener_renamed(self):
+        wiki_page = self._create_wiki_page(self.INITIAL_NAME)
+        CHANGED_NAME = "NewWikiName"
+        wiki_page.rename(CHANGED_NAME)
+        self.assertEqual('changed', self.listener.action)
+        self.assertTrue(isinstance(self.listener.resource, WikiPage))
+        self.assertEqual(CHANGED_NAME, self.wiki_name)
+        self.assertEqual(self.INITIAL_TEXT, self.wiki_text)
+        self.assertEqual({"name":self.INITIAL_NAME}, self.listener.old_values)
+
+    def test_change_listener_deleted(self):
+        wiki_page = self._create_wiki_page(self.INITIAL_NAME)
+        wiki_page.delete()
+        self.assertEqual('deleted', self.listener.action)
+        self.assertTrue(isinstance(self.listener.resource, WikiPage))
+        self.assertEqual(self.INITIAL_NAME, self.wiki_name)
+
+    def _create_wiki_page(self, name=None):
+        name = name or self.INITIAL_NAME
+        wiki_page = WikiPage(self.env, name)
+        wiki_page.text = self.INITIAL_TEXT
+        wiki_page.save(
+            self.INITIAL_AUTHOR,
+            self.INITIAL_COMMENT,
+            self.INITIAL_REMOTE_ADDRESS)
+        return wiki_page
+
+    def listener_callback(self, action, resource, context, old_values = None):
+        self.wiki_name = resource.name
+        self.wiki_text = resource.text
+
 def suite():
-    return unittest.makeSuite(WikiPageTestCase, 'test')
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(WikiPageTestCase, 'test'))
+    suite.addTest(unittest.makeSuite(
+        WikiResourceChangeListenerTestCase, 'test'))
+    return suite
 
 if __name__ == '__main__':
     unittest.main(defaultTest='suite')
Index: trac/wiki/model.py
===================================================================
--- trac/wiki/model.py	(revision 11766)
+++ trac/wiki/model.py	(working copy)
@@ -21,7 +21,7 @@
 from datetime import datetime
 
 from trac.core import *
-from trac.resource import Resource
+from trac.resource import Resource, ResourceSystem
 from trac.util.datefmt import from_utimestamp, to_utimestamp, utc
 from trac.util.translation import _
 from trac.wiki.api import WikiSystem, validate_page_name
@@ -112,10 +112,12 @@
         if not self.exists:
             for listener in WikiSystem(self.env).change_listeners:
                 listener.wiki_page_deleted(self)
+            ResourceSystem(self.env).resource_deleted(self)
         else:
             for listener in WikiSystem(self.env).change_listeners:
                 if hasattr(listener, 'wiki_page_version_deleted'):
                     listener.wiki_page_version_deleted(self)
+            ResourceSystem(self.env).resource_version_deleted(self)
 
     def save(self, author, comment, remote_addr, t=None, db=None):
         """Save a new version of a page.
@@ -159,6 +161,24 @@
             else:
                 listener.wiki_page_changed(self, self.version, t, comment,
                                            author, remote_addr)
+        context=dict(
+            version=self.version,
+            time=t,
+            comment=comment,
+            author=author,
+            remote_addr=remote_addr)
+        if self.version == 1:
+            ResourceSystem(self.env).resource_created(self, context)
+        else:
+            old_values = dict()
+            if self.readonly != self.old_readonly:
+                old_values["readonly"] = self.old_readonly
+            if self.text != self.old_text:
+                old_values["text"] = self.old_text
+            ResourceSystem(self.env).resource_changed(
+                self,
+                old_values,
+                context)
 
         self.old_readonly = self.readonly
         self.old_text = self.text
@@ -196,6 +216,11 @@
             if hasattr(listener, 'wiki_page_renamed'):
                 listener.wiki_page_renamed(self, old_name)
 
+        ResourceSystem(self.env).resource_changed(
+            self,
+            dict(name=old_name)
+        )
+
     def get_history(self, db=None):
         """Retrieve the edit history of a wiki page.
 
Index: trac/attachment.py
===================================================================
--- trac/attachment.py	(revision 11766)
+++ trac/attachment.py	(working copy)
@@ -237,6 +237,7 @@
 
         for listener in AttachmentModule(self.env).change_listeners:
             listener.attachment_deleted(self)
+        ResourceSystem(self.env).resource_deleted(self)
 
     def reparent(self, new_realm, new_id):
         assert self.filename, "Cannot reparent non-existent attachment"
@@ -287,6 +288,12 @@
         for listener in AttachmentModule(self.env).change_listeners:
             if hasattr(listener, 'attachment_reparented'):
                 listener.attachment_reparented(self, old_realm, old_id)
+        old_values = dict()
+        if self.parent_realm != old_realm:
+            old_values["parent_realm"] = old_realm
+        if self.parent_id != old_id:
+            old_values["parent_id"] = old_id
+        ResourceSystem(self.env).resource_changed(self, old_values=old_values)
 
     def insert(self, filename, fileobj, size, t=None, db=None):
         """Create a new Attachment record and save the file content.
@@ -332,6 +339,7 @@
 
         for listener in AttachmentModule(self.env).change_listeners:
             listener.attachment_added(self)
+        ResourceSystem(self.env).resource_created(self)
 
 
     @classmethod
Index: trac/ticket/model.py
===================================================================
--- trac/ticket/model.py	(revision 11766)
+++ trac/ticket/model.py	(working copy)
@@ -26,7 +26,7 @@
 from trac import core
 from trac.cache import cached
 from trac.core import TracError
-from trac.resource import Resource, ResourceNotFound
+from trac.resource import Resource, ResourceNotFound, ResourceSystem
 from trac.ticket.api import TicketSystem
 from trac.util import embedded_numbers, partition
 from trac.util.text import empty
@@ -286,6 +286,7 @@
 
         for listener in TicketSystem(self.env).change_listeners:
             listener.ticket_created(self)
+        ResourceSystem(self.env).resource_created(self)
 
         return self.id
 
@@ -399,6 +400,9 @@
 
         for listener in TicketSystem(self.env).change_listeners:
             listener.ticket_changed(self, comment, author, old_values)
+        context = dict(comment=comment, author=author)
+        ResourceSystem(self.env).resource_changed(self, old_values, context)
+
         return int(cnum.rsplit('.', 1)[-1])
 
     def _to_db_types(self, values):
@@ -476,6 +480,7 @@
 
         for listener in TicketSystem(self.env).change_listeners:
             listener.ticket_deleted(self)
+        ResourceSystem(self.env).resource_deleted(self)
 
     def get_change(self, cnum=None, cdate=None, db=None):
         """Return a ticket change by its number or date.
@@ -761,6 +766,8 @@
                 except ValueError:
                     pass # Ignore cast error for this non-essential operation
             TicketSystem(self.env).reset_ticket_fields()
+
+        ResourceSystem(self.env).resource_deleted(self)
         self.value = self._old_value = None
         self.name = self._old_name = None
 
@@ -788,6 +795,7 @@
 
         self._old_name = self.name
         self._old_value = self.value
+        ResourceSystem(self.env).resource_created(self)
 
     def update(self, db=None):
         """Update the enum value.
@@ -811,8 +819,14 @@
                    (self.name, self._old_name))
             TicketSystem(self.env).reset_ticket_fields()
 
+        old_values = dict()
+        if self.name != self._old_name:
+            old_values["name"] = self._old_name
+        if self.value != self._old_value:
+            old_values["value"] = self._old_value
         self._old_name = self.name
         self._old_value = self.value
+        ResourceSystem(self.env).resource_changed(self, old_values)
 
     @classmethod
     def select(cls, env, db=None):
@@ -893,9 +907,11 @@
         with self.env.db_transaction as db:
             self.env.log.info("Deleting component %s", self.name)
             db("DELETE FROM component WHERE name=%s", (self.name,))
-            self.name = self._old_name = None
             TicketSystem(self.env).reset_ticket_fields()
 
+        ResourceSystem(self.env).resource_deleted(self)
+        self.name = self._old_name = None
+
     def insert(self, db=None):
         """Insert a new component.
 
@@ -915,6 +931,8 @@
             self._old_name = self.name
             TicketSystem(self.env).reset_ticket_fields()
 
+        ResourceSystem(self.env).resource_created(self)
+
     def update(self, db=None):
         """Update the component.
 
@@ -926,6 +944,7 @@
         if not self.name:
             raise TracError(_("Invalid component name."))
 
+        old_name = self._old_name
         with self.env.db_transaction as db:
             self.env.log.info("Updating component '%s'", self.name)
             db("""UPDATE component SET name=%s,owner=%s, description=%s
@@ -939,6 +958,12 @@
                 self._old_name = self.name
             TicketSystem(self.env).reset_ticket_fields()
 
+        #todo:add support of old_values for owner and description fields
+        old_values = dict()
+        if self.name != old_name:
+            old_values["name"] = old_name
+        ResourceSystem(self.env).resource_changed(self, old_values)
+
     @classmethod
     def select(cls, env, db=None):
         """
@@ -1075,6 +1100,7 @@
 
         for listener in TicketSystem(self.env).milestone_change_listeners:
             listener.milestone_deleted(self)
+        ResourceSystem(self.env).resource_deleted(self)
 
     def insert(self, db=None):
         """Insert a new milestone.
@@ -1097,6 +1123,7 @@
 
         for listener in TicketSystem(self.env).milestone_change_listeners:
             listener.milestone_created(self)
+        ResourceSystem(self.env).resource_created(self)
 
     def update(self, db=None):
         """Update the milestone.
@@ -1136,6 +1163,7 @@
                           if getattr(self, k) != v)
         for listener in TicketSystem(self.env).milestone_change_listeners:
             listener.milestone_changed(self, old_values)
+        ResourceSystem(self.env).resource_changed(self, old_values)
 
     @classmethod
     def select(cls, env, include_completed=True, db=None):
@@ -1199,9 +1227,11 @@
         with self.env.db_transaction as db:
             self.env.log.info("Deleting version %s", self.name)
             db("DELETE FROM version WHERE name=%s", (self.name,))
-            self.name = self._old_name = None
             TicketSystem(self.env).reset_ticket_fields()
 
+        ResourceSystem(self.env).resource_deleted(self)
+        self.name = self._old_name = None
+
     def insert(self, db=None):
         """Insert a new version.
 
@@ -1220,6 +1250,8 @@
             self._old_name = self.name
             TicketSystem(self.env).reset_ticket_fields()
 
+        ResourceSystem(self.env).resource_created(self)
+
     def update(self, db=None):
         """Update the version.
 
@@ -1231,6 +1263,7 @@
         if not self.name:
             raise TracError(_("Invalid version name."))
 
+        old_name=self._old_name
         with self.env.db_transaction as db:
             self.env.log.info("Updating version '%s'", self.name)
             db("""UPDATE version
@@ -1244,6 +1277,12 @@
                 self._old_name = self.name
             TicketSystem(self.env).reset_ticket_fields()
 
+        #todo: add support of old_values for time and description fields
+        old_values = dict()
+        if self.name != old_name:
+            old_values["name"] = old_name
+        ResourceSystem(self.env).resource_changed(self, old_values)
+
     @classmethod
     def select(cls, env, db=None):
         """
Index: trac/ticket/tests/model.py
===================================================================
--- trac/ticket/tests/model.py	(revision 11766)
+++ trac/ticket/tests/model.py	(working copy)
@@ -18,6 +18,7 @@
     IMilestoneChangeListener, ITicketChangeListener, TicketSystem
 )
 from trac.test import EnvironmentStub
+from trac.tests.resource import TestResourceChangeListener
 from trac.util.datefmt import from_utimestamp, to_utimestamp, utc
 
 
@@ -1097,7 +1098,108 @@
         self.assertEqual([('Test', 0, 'Some text')], self.env.db_query(
             "SELECT name, time, description FROM version WHERE name='Test'"))
 
+class BaseResourceChangeListenerTestCase(unittest.TestCase):
+    DUMMY_RESOURCE_NAME = "Resource 1"
+    resource_type = None
+    name_field = "name"
 
+    def setUp(self):
+        self.env = EnvironmentStub(default_data=True)
+        self.listener = TestResourceChangeListener(self.env)
+        self.listener.resource_type = self.resource_type
+        self.listener.callback = self.listener_callback
+
+    def tearDown(self):
+        self.env.reset_db()
+
+    def test_change_listener_created(self):
+        self._create_resource(self.DUMMY_RESOURCE_NAME)
+        self.assertEqual('created', self.listener.action)
+        self.assertTrue(isinstance(self.listener.resource, self.resource_type))
+        self.assertEqual(
+            self.DUMMY_RESOURCE_NAME,
+            self.resource_name)
+
+    def test_change_listener_changed(self):
+        resource = self._create_resource(self.DUMMY_RESOURCE_NAME)
+        self._rename_resource(resource, "UpdatedName")
+        self.assertEqual('changed', self.listener.action)
+        self.assertTrue(isinstance(self.listener.resource, self.resource_type))
+        self.assertEqual("UpdatedName", self.resource_name)
+        self.assertEqual(
+            self.DUMMY_RESOURCE_NAME,
+            self.listener.old_values[self.name_field])
+
+    def test_change_listener_deleted(self):
+        resource = self._create_resource(self.DUMMY_RESOURCE_NAME)
+        resource.delete()
+        self.assertEqual('deleted', self.listener.action)
+        self.assertTrue(isinstance(self.listener.resource, self.resource_type))
+        self.assertEqual(self.DUMMY_RESOURCE_NAME, self.resource_name)
+
+    def _create_resource(self, name):
+        resource = self.resource_type(self.env)
+        resource.name = name
+        resource.insert()
+        return resource
+
+    def _rename_resource(self, resource, new_name):
+        resource.name = new_name
+        resource.update()
+        return resource
+
+    def _get_resource_name(self, resource):
+        return resource.name
+
+    def listener_callback(self, action, resource, context, old_values = None):
+        self.resource_name = self._get_resource_name(resource)
+
+class ComponentResourceChangeListenerTestCase(
+    BaseResourceChangeListenerTestCase):
+    resource_type = Component
+
+class VersionResourceChangeListenerTestCase(
+    BaseResourceChangeListenerTestCase):
+    resource_type = Version
+
+class PriorityResourceChangeListenerTestCase(
+    BaseResourceChangeListenerTestCase):
+    resource_type = Priority
+
+class MilestoneResourceChangeListenerTestCase(
+    BaseResourceChangeListenerTestCase):
+    resource_type = Milestone
+
+class TicketResourceChangeListenerTestCase(
+    BaseResourceChangeListenerTestCase):
+    resource_type = Ticket
+    name_field = "summary"
+    dummy_author = "anAuthor"
+    dummy_comment = "some comment"
+
+    def test_change_listener_changed(self):
+        super(
+            TicketResourceChangeListenerTestCase,
+            self).test_change_listener_changed()
+
+        self.assertEqual(self.dummy_author, self.listener.context["author"])
+        self.assertEqual(self.dummy_comment, self.listener.context["comment"])
+
+
+    def _create_resource(self, name):
+        ticket = Ticket(self.env)
+        ticket["summary"] = name
+        ticket.insert()
+        return ticket
+
+    def _rename_resource(self, resource, new_name):
+        resource["summary"] = new_name
+        resource.save_changes(self.dummy_author, self.dummy_comment)
+        return resource
+
+    def _get_resource_name(self, resource):
+        return resource["summary"]
+
 def suite():
     suite = unittest.TestSuite()
     suite.addTest(unittest.makeSuite(TicketTestCase, 'test'))
@@ -1107,6 +1209,16 @@
     suite.addTest(unittest.makeSuite(MilestoneTestCase, 'test'))
     suite.addTest(unittest.makeSuite(ComponentTestCase, 'test'))
     suite.addTest(unittest.makeSuite(VersionTestCase, 'test'))
+    suite.addTest(unittest.makeSuite(
+        ComponentResourceChangeListenerTestCase, 'test'))
+    suite.addTest(unittest.makeSuite(
+        VersionResourceChangeListenerTestCase, 'test'))
+    suite.addTest(unittest.makeSuite(
+        PriorityResourceChangeListenerTestCase, 'test'))
+    suite.addTest(unittest.makeSuite(
+        MilestoneResourceChangeListenerTestCase, 'test'))
+    suite.addTest(unittest.makeSuite(
+        TicketResourceChangeListenerTestCase, 'test'))
     return suite
 
 if __name__ == '__main__':
Index: trac/tests/attachment.py
===================================================================
--- trac/tests/attachment.py	(revision 11766)
+++ trac/tests/attachment.py	(working copy)
@@ -11,6 +11,7 @@
 from trac.perm import IPermissionPolicy, PermissionCache
 from trac.resource import Resource, resource_exists
 from trac.test import EnvironmentStub
+from trac.tests.resource import TestResourceChangeListener
 
 
 hashes = {
@@ -222,9 +223,63 @@
         self.assertTrue(resource_exists(self.env, att.resource))
 
 
+class AttachmentResourceChangeListenerTestCase(unittest.TestCase):
+    DUMMY_PARENT_REALM = "wiki"
+    DUMMY_PARENT_ID = "WikiStart"
+
+    def setUp(self):
+        self.env = EnvironmentStub(default_data=True)
+        self.listener = TestResourceChangeListener(self.env)
+        self.listener.resource_type = Attachment
+        self.listener.callback = self.listener_callback
+
+    def tearDown(self):
+        self.env.reset_db()
+
+    def test_change_listener_created(self):
+        attachment = self._create_attachment()
+        self.assertEqual('created', self.listener.action)
+        self.assertTrue(isinstance(self.listener.resource, Attachment))
+        self.assertEqual(attachment.filename, self.filename)
+        self.assertEqual(attachment.parent_realm, self.parent_realm)
+        self.assertEqual(attachment.parent_id, self.parent_id)
+
+    def test_change_listener_reparent(self):
+        attachment = self._create_attachment()
+        attachment.reparent(self.DUMMY_PARENT_REALM, "SomePage")
+
+        self.assertEqual('changed', self.listener.action)
+        self.assertTrue(isinstance(self.listener.resource, Attachment))
+        self.assertEqual(attachment.filename, self.filename)
+        self.assertEqual(attachment.parent_realm, self.parent_realm)
+        self.assertEqual("SomePage", self.parent_id)
+        self.assertNotIn("parent_realm", self.listener.old_values)
+        self.assertEqual(
+            self.DUMMY_PARENT_ID, self.listener.old_values["parent_id"])
+
+    def test_change_listener_deleted(self):
+        attachment = self._create_attachment()
+        attachment.delete()
+        self.assertEqual('deleted', self.listener.action)
+        self.assertTrue(isinstance(self.listener.resource, Attachment))
+        self.assertEqual(attachment.filename, self.filename)
+
+    def _create_attachment(self):
+        attachment = Attachment(
+            self.env, self.DUMMY_PARENT_REALM, self.DUMMY_PARENT_ID)
+        attachment.insert('file.txt', StringIO(''), 1)
+        return attachment
+
+    def listener_callback(self, action, resource, context, old_values = None):
+        self.parent_realm = resource.parent_realm
+        self.parent_id = resource.parent_id
+        self.filename = resource.filename
+
 def suite():
     suite = unittest.TestSuite()
     suite.addTest(unittest.makeSuite(AttachmentTestCase, 'test'))
+    suite.addTest(unittest.makeSuite(
+        AttachmentResourceChangeListenerTestCase, 'test'))
     return suite
 
 if __name__ == '__main__':
Index: trac/tests/resource.py
===================================================================
--- trac/tests/resource.py	(revision 11766)
+++ trac/tests/resource.py	(working copy)
@@ -15,6 +15,8 @@
 import unittest
 
 from trac import resource
+from trac.resource import IResourceChangeListener
+from trac.core import implements, Component
 
 
 class ResourceTestCase(unittest.TestCase):
@@ -42,6 +44,44 @@
         r2.parent = r2.parent(version=42)
         self.assertNotEqual(r1, r2)
 
+class TestResourceChangeListener(Component):
+    implements(IResourceChangeListener)
+
+    def __init__(self):
+        self.resource_type = None
+
+    def callback(self, action, resource, context, old_values = None):
+        pass
+
+    def get_subscribed_resources(self):
+        return (self.resource_type, )
+
+    def resource_created(self, resource, context):
+        self.action = "created"
+        self.resource = resource
+        self.context = context
+        self.callback(self.action, resource, context)
+
+    def resource_changed(self, resource, old_values, context):
+        self.action = "changed"
+        self.resource = resource
+        self.old_values = old_values
+        self.context = context
+        self.callback(
+            self.action, resource, context, old_values=self.old_values)
+
+    def resource_deleted(self, resource, context):
+        self.action = "deleted"
+        self.resource = resource
+        self.context = context
+        self.callback(self.action, resource, context)
+
+    def resource_version_deleted(self, resource, context):
+        self.action = "version_deleted"
+        self.resource = resource
+        self.context = context
+        self.callback(self.action, resource, context)
+
 def suite():
     suite = unittest.TestSuite()
     suite.addTest(doctest.DocTestSuite(resource))

Reply via email to