From 4acb59e3cf6c3d42a4be80ff9e9cf590113e9e74 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 13 Mar 2014 21:06:14 +0100 Subject: [PATCH] Add client interface to taiga-events. --- settings/common.py | 4 +++ taiga/events/__init__.py | 0 taiga/events/backends/__init__.py | 3 ++ taiga/events/backends/base.py | 45 +++++++++++++++++++++++++++++ taiga/events/backends/postgresql.py | 13 +++++++++ taiga/events/changes.py | 42 +++++++++++++++++++++++++++ taiga/events/models.py | 36 +++++++++++++++++++++++ taiga/events/tests.py | 21 ++++++++++++++ 8 files changed, 164 insertions(+) create mode 100644 taiga/events/__init__.py create mode 100644 taiga/events/backends/__init__.py create mode 100644 taiga/events/backends/base.py create mode 100644 taiga/events/backends/postgresql.py create mode 100644 taiga/events/changes.py create mode 100644 taiga/events/models.py diff --git a/settings/common.py b/settings/common.py index 6e57f44e..4c64750c 100644 --- a/settings/common.py +++ b/settings/common.py @@ -78,6 +78,9 @@ DJMAIL_SEND_ASYNC = True DJMAIL_MAX_RETRY_NUMBER = 3 DJMAIL_TEMPLATE_EXTENSION = "jinja" +# Events backend +EVENTS_PUSH_BACKEND = "taiga.events.backends.postgresql.EventsPushBackend" + # Message System MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage" @@ -158,6 +161,7 @@ INSTALLED_APPS = [ "taiga.base.notifications", "taiga.base.searches", "taiga.base", + "taiga.events", "taiga.domains", "taiga.projects", "taiga.projects.mixins.blocked", diff --git a/taiga/events/__init__.py b/taiga/events/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/events/backends/__init__.py b/taiga/events/backends/__init__.py new file mode 100644 index 00000000..c70958a0 --- /dev/null +++ b/taiga/events/backends/__init__.py @@ -0,0 +1,3 @@ +from .base import get_events_backend + +__all__ = ["get_events_backend"] diff --git a/taiga/events/backends/base.py b/taiga/events/backends/base.py new file mode 100644 index 00000000..a64125a5 --- /dev/null +++ b/taiga/events/backends/base.py @@ -0,0 +1,45 @@ +import abc +import importlib + +from django.core.exceptions import ImproperlyConfigured +from django.conf import settings + + +class BaseEventsPushBackend(object, metaclass=abc.ABCMeta): + @abc.abstractmethod + def emit_event(self, message:str, *, channel:str="events"): + pass + + +def load_class(path): + """ + Load class from path. + """ + + mod_name, klass_name = path.rsplit('.', 1) + + try: + mod = importlib.import_module(mod_name) + except AttributeError as e: + raise ImproperlyConfigured('Error importing {0}: "{1}"'.format(mod_name, e)) + + try: + klass = getattr(mod, klass_name) + except AttributeError: + raise ImproperlyConfigured('Module "{0}" does not define a "{1}" class'.format(mod_name, klass_name)) + + return klass + + +def get_events_backend(path:str=None, options:dict=None): + if path is None: + path = getattr(settings, "EVENTS_PUSH_BACKEND", None) + + if path is None: + raise ImproperlyConfigured("Events push system not configured") + + if options is None: + options = getattr(settings, "EVENTS_PUSH_BACKEND_OPTIONS", {}) + + cls = load_class(path) + return cls(**options) diff --git a/taiga/events/backends/postgresql.py b/taiga/events/backends/postgresql.py new file mode 100644 index 00000000..a7d79439 --- /dev/null +++ b/taiga/events/backends/postgresql.py @@ -0,0 +1,13 @@ +from django.db import transaction +from django.db import connection + +from . import base + + +class EventsPushBackend(base.BaseEventsPushBackend): + @transaction.atomic + def emit_event(self, message:str, *, channel:str="events"): + sql = "NOTIFY {channel}, %s".format(channel=channel) + cursor = connection.cursor() + cursor.execute(sql, [message]) + cursor.close() diff --git a/taiga/events/changes.py b/taiga/events/changes.py new file mode 100644 index 00000000..71a80547 --- /dev/null +++ b/taiga/events/changes.py @@ -0,0 +1,42 @@ +import json + +from django.contrib.contenttypes.models import ContentType +from . import backends + +watched_types = ( + ("userstories", "userstory"), + ("issues", "issue"), +) + + +def _get_type_for_model(model_instance): + ct = ContentType.objects.get_for_model(model_instance) + return (ct.app_label, ct.model) + + +def emit_change_event_for_model(model_instance, sessionid:str, *, + type:str="change", channel:str="events"): + """ + Emit change event for notify of model change to + all connected frontends. + """ + content_type = _get_type_for_model(model_instance) + + assert hasattr(model_instance, "project_id") + assert content_type in watched_types + assert type in ("create", "change", "delete") + + project_id = model_instance.project_id + routing_key = "project.{0}".format(project_id) + + data = {"type": "model-changes", + "routing_key": routing_key, + "session_id": sessionid, + "data": { + "type": type, + "matches": ".".join(content_type), + "pk": model_instance.pk}} + + backend = backends.get_events_backend() + return backend.emit_event(json.dumps(data), channel="events") + diff --git a/taiga/events/models.py b/taiga/events/models.py new file mode 100644 index 00000000..93f19179 --- /dev/null +++ b/taiga/events/models.py @@ -0,0 +1,36 @@ + +from django.db.models import signals +from django.dispatch import receiver + +from . import middleware as mw +from . import changes + + +@receiver(signals.post_save, dispatch_uid="events_dispatcher_on_change") +def on_save_any_model(sender, instance, created, **kwargs): + # Ignore any object that can not have project_id + content_type = changes._get_type_for_model(instance) + + # Ignore any other changes + if content_type not in changes.watched_types: + return + + sesionid = mw.get_current_session_id() + + if created: + changes.emit_change_event_for_model(instance, sesionid, type="create") + else: + changes.emit_change_event_for_model(instance, sesionid, type="change") + + +@receiver(signals.post_delete, dispatch_uid="events_dispatcher_on_delete") +def on_delete_any_model(sender, instance, **kwargs): + # Ignore any object that can not have project_id + content_type = changes._get_type_for_model(instance) + + # Ignore any other changes + if content_type not in changes.watched_types: + return + + sesionid = mw.get_current_session_id() + changes.emit_change_event_for_model(instance, sesionid, type="delete") diff --git a/taiga/events/tests.py b/taiga/events/tests.py index 87ce318c..71a5bd47 100644 --- a/taiga/events/tests.py +++ b/taiga/events/tests.py @@ -3,7 +3,12 @@ from django import test from django.test.client import RequestFactory from django.http import HttpResponse +from taiga.projects.tests import create_project +from taiga.projects.issues.tests import create_issue +from taiga.base.users.tests import create_user + from . import middleware as mw +from . import changes as ch class SessionIDMiddlewareTests(test.TestCase): @@ -27,3 +32,19 @@ class SessionIDMiddlewareTests(test.TestCase): mw_instance.process_request(request) self.assertEqual(mw.get_current_session_id(), "foobar") + + +from unittest.mock import MagicMock +from unittest.mock import patch + +class ChangesTest(test.TestCase): + fixtures = ["initial_domains.json"] + + def test_emit_change_for_model(self): + user = create_user(1) # Project owner + project = create_project(1, user) + issue = create_issue(1, user, project) + + with patch("taiga.events.backends.get_events_backend") as mock_instance: + ch.emit_change_event_for_model(issue, "sessionid") + self.assertTrue(mock_instance.return_value.emit_event.called)