Add client interface to taiga-events.
parent
68a5fe32d3
commit
4acb59e3cf
|
@ -78,6 +78,9 @@ DJMAIL_SEND_ASYNC = True
|
||||||
DJMAIL_MAX_RETRY_NUMBER = 3
|
DJMAIL_MAX_RETRY_NUMBER = 3
|
||||||
DJMAIL_TEMPLATE_EXTENSION = "jinja"
|
DJMAIL_TEMPLATE_EXTENSION = "jinja"
|
||||||
|
|
||||||
|
# Events backend
|
||||||
|
EVENTS_PUSH_BACKEND = "taiga.events.backends.postgresql.EventsPushBackend"
|
||||||
|
|
||||||
# Message System
|
# Message System
|
||||||
MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage"
|
MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage"
|
||||||
|
|
||||||
|
@ -158,6 +161,7 @@ INSTALLED_APPS = [
|
||||||
"taiga.base.notifications",
|
"taiga.base.notifications",
|
||||||
"taiga.base.searches",
|
"taiga.base.searches",
|
||||||
"taiga.base",
|
"taiga.base",
|
||||||
|
"taiga.events",
|
||||||
"taiga.domains",
|
"taiga.domains",
|
||||||
"taiga.projects",
|
"taiga.projects",
|
||||||
"taiga.projects.mixins.blocked",
|
"taiga.projects.mixins.blocked",
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .base import get_events_backend
|
||||||
|
|
||||||
|
__all__ = ["get_events_backend"]
|
|
@ -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)
|
|
@ -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()
|
|
@ -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")
|
||||||
|
|
|
@ -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")
|
|
@ -3,7 +3,12 @@ from django import test
|
||||||
from django.test.client import RequestFactory
|
from django.test.client import RequestFactory
|
||||||
from django.http import HttpResponse
|
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 middleware as mw
|
||||||
|
from . import changes as ch
|
||||||
|
|
||||||
|
|
||||||
class SessionIDMiddlewareTests(test.TestCase):
|
class SessionIDMiddlewareTests(test.TestCase):
|
||||||
|
@ -27,3 +32,19 @@ class SessionIDMiddlewareTests(test.TestCase):
|
||||||
mw_instance.process_request(request)
|
mw_instance.process_request(request)
|
||||||
|
|
||||||
self.assertEqual(mw.get_current_session_id(), "foobar")
|
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)
|
||||||
|
|
Loading…
Reference in New Issue