From 6453910fffea3e4ac12bb2facafa5784d2755ee5 Mon Sep 17 00:00:00 2001 From: Matt Cottingham Date: Fri, 1 Nov 2019 14:56:15 +0000 Subject: [PATCH 1/7] Initial events API implementation --- response/apps.py | 2 ++ response/core/admin.py | 3 ++- response/core/models/__init__.py | 2 ++ response/core/models/event.py | 12 +++++++++ response/core/serializers.py | 10 +++++++- response/core/signals.py | 42 ++++++++++++++++++++++++++++++++ response/core/urls.py | 2 ++ response/core/views.py | 6 +++++ response/models.py | 10 +++++++- response/serializers.py | 6 +++-- 10 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 response/core/models/event.py create mode 100644 response/core/signals.py diff --git a/response/apps.py b/response/apps.py index 61637925..a82a4f2b 100644 --- a/response/apps.py +++ b/response/apps.py @@ -16,6 +16,8 @@ def ready(self): dialog_handlers, ) + from .core import signals + site_settings.RESPONSE_LOGIN_REQUIRED = getattr( site_settings, "RESPONSE_LOGIN_REQUIRED", True ) diff --git a/response/core/admin.py b/response/core/admin.py index 55b92acf..5755ac97 100644 --- a/response/core/admin.py +++ b/response/core/admin.py @@ -1,7 +1,8 @@ from django.contrib import admin -from response.core.models import Action, ExternalUser, Incident +from response.core.models import Action, Event, ExternalUser, Incident admin.site.register(Action) +admin.site.register(Event) admin.site.register(Incident) admin.site.register(ExternalUser) diff --git a/response/core/models/__init__.py b/response/core/models/__init__.py index ff4959b8..91b49d1e 100644 --- a/response/core/models/__init__.py +++ b/response/core/models/__init__.py @@ -1,10 +1,12 @@ from .action import Action from .incident import Incident from .timeline import TimelineEvent, add_incident_update_event +from .event import Event from .user_external import ExternalUser __all__ = ( "Action", + "Event", "Incident", "TimelineEvent", "ExternalUser", diff --git a/response/core/models/event.py b/response/core/models/event.py new file mode 100644 index 00000000..d84dfcd3 --- /dev/null +++ b/response/core/models/event.py @@ -0,0 +1,12 @@ +from django.contrib.postgres.fields import JSONField + +from django.db import models + + +class Event(models.Model): + ACTION_EVENT_TYPE= "action_event" + INCIDENT_EVENT_TYPE = "incident_event" + + timestamp = models.DateTimeField() + event_type = models.CharField(max_length=50) + payload = JSONField() diff --git a/response/core/serializers.py b/response/core/serializers.py index a2e7f795..f451f0f1 100644 --- a/response/core/serializers.py +++ b/response/core/serializers.py @@ -1,7 +1,7 @@ import emoji_data_python from rest_framework import serializers -from response.core.models import Action, ExternalUser, Incident, TimelineEvent +from response.core.models import Action, Event, ExternalUser, Incident, TimelineEvent from response.slack.models import CommsChannel from response.slack.reference_utils import slack_to_human_readable @@ -131,3 +131,11 @@ def update(self, instance, validated_data): instance.save() return instance + + +class EventSerializer(serializers.ModelSerializer): + + class Meta: + model = Event + fields = ("id", "timestamp", "event_type", "payload") + read_only_fields = ("id", "timestamp", "event_type", "payload") diff --git a/response/core/signals.py b/response/core/signals.py new file mode 100644 index 00000000..e13565b8 --- /dev/null +++ b/response/core/signals.py @@ -0,0 +1,42 @@ +from datetime import datetime + +from response.core.models import Action, Event, Incident +from response.core.serializers import ActionSerializer, IncidentSerializer + +from django.db.models.signals import post_save +from django.dispatch import receiver + +import logging +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender=Action) +def emit_action_event(sender, instance: Action, **kwargs): + logger.info(f"Handling post_save for action: {instance}") + + event = Event() + event.event_type = Event.ACTION_EVENT_TYPE + + # Event payload should be a dict for serializing to JSON. + event.payload = ActionSerializer(instance).data + event.payload["incident_id"] = instance.incident.pk + # TODO: define a serializer that elides this field (otherwise + # it is returned in incident responses too) + if "details_ui" in event.payload: del event.payload["details_ui"] + + event.timestamp = datetime.now(tz=None) + event.save() + + +@receiver(post_save, sender=Incident) +def emit_incident_event(sender, instance: Incident, **kwargs): + logger.info(f"Handling post_save for incident: {instance}") + + event = Event() + event.event_type = Event.INCIDENT_EVENT_TYPE + + # Event payload should be a dict for serializing to JSON. + event.payload = IncidentSerializer(instance).data + + event.timestamp = datetime.now(tz=None) + event.save() diff --git a/response/core/urls.py b/response/core/urls.py index 678141f1..bd7e5ff7 100644 --- a/response/core/urls.py +++ b/response/core/urls.py @@ -3,6 +3,7 @@ from response.core.views import ( ActionViewSet, + EventsViewSet, ExternalUserViewSet, IncidentActionViewSet, IncidentsByMonthViewSet, @@ -30,6 +31,7 @@ ) router.register(r"actions", ActionViewSet) router.register(r"users", ExternalUserViewSet) +router.register(r"events", EventsViewSet) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. diff --git a/response/core/views.py b/response/core/views.py index 0a0cfde4..ff25b07e 100644 --- a/response/core/views.py +++ b/response/core/views.py @@ -2,6 +2,7 @@ from response.core import serializers from response.core.models.action import Action +from response.core.models.event import Event from response.core.models.incident import Incident from response.core.models.timeline import TimelineEvent from response.core.models.user_external import ExternalUser @@ -77,3 +78,8 @@ def get_queryset(self): def perform_create(self, serializer): incident_pk = self.kwargs["incident_pk"] serializer.save(incident_id=incident_pk) + + +class EventsViewSet(viewsets.ModelViewSet): + queryset = Event.objects.all() + serializer_class = serializers.EventSerializer diff --git a/response/models.py b/response/models.py index ab421198..61d9efc0 100644 --- a/response/models.py +++ b/response/models.py @@ -1,4 +1,11 @@ -from .core.models import Action, ExternalUser, Incident, TimelineEvent +from .core.models import ( + Action, + Event, + ExternalUser, + Incident, + TimelineEvent, +) + from .slack.models import ( CommsChannel, HeadlinePost, @@ -9,6 +16,7 @@ __all__ = ( "Action", + "Event", "Incident", "TimelineEvent", "ExternalUser", diff --git a/response/serializers.py b/response/serializers.py index 2b9ce2f3..a74bf7d1 100644 --- a/response/serializers.py +++ b/response/serializers.py @@ -1,15 +1,17 @@ from .core.serializers import ( ActionSerializer, CommsChannelSerializer, + EventSerializer, ExternalUserSerializer, IncidentSerializer, TimelineEventSerializer, ) __all__ = ( - "ExternalUserSerializer", - "TimelineEventSerializer", "ActionSerializer", "CommsChannelSerializer", + "EventSerializer", + "ExternalUserSerializer", "IncidentSerializer", + "TimelineEventSerializer", ) From 5e6eca7e5effba7862c38a903f76e0aaa52d2f53 Mon Sep 17 00:00:00 2001 From: Matt Cottingham Date: Wed, 6 Nov 2019 14:07:29 +0000 Subject: [PATCH 2/7] Allow event signals to be configured --- response/core/signals.py | 67 ++++++++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 24 deletions(-) diff --git a/response/core/signals.py b/response/core/signals.py index e13565b8..2ef27ca4 100644 --- a/response/core/signals.py +++ b/response/core/signals.py @@ -1,42 +1,61 @@ from datetime import datetime +import logging +logger = logging.getLogger(__name__) + +import os + from response.core.models import Action, Event, Incident from response.core.serializers import ActionSerializer, IncidentSerializer +from django.conf import settings from django.db.models.signals import post_save from django.dispatch import receiver +from django.utils.module_loading import import_string -import logging -logger = logging.getLogger(__name__) +class ActionEventHandler(): + + def handle(sender, instance: Action, **kwargs): + logger.info(f"Handling post_save for action: {instance}") + + event = Event() + event.event_type = Event.ACTION_EVENT_TYPE + + # Event payload should be a dict for serializing to JSON. + event.payload = ActionSerializer(instance).data + event.payload["incident_id"] = instance.incident.pk + if "details_ui" in event.payload: del event.payload["details_ui"] + + event.timestamp = datetime.now(tz=None) + event.save() -@receiver(post_save, sender=Action) -def emit_action_event(sender, instance: Action, **kwargs): - logger.info(f"Handling post_save for action: {instance}") - event = Event() - event.event_type = Event.ACTION_EVENT_TYPE +class IncidentEventHandler(): - # Event payload should be a dict for serializing to JSON. - event.payload = ActionSerializer(instance).data - event.payload["incident_id"] = instance.incident.pk - # TODO: define a serializer that elides this field (otherwise - # it is returned in incident responses too) - if "details_ui" in event.payload: del event.payload["details_ui"] + def handle(sender, instance: Incident, **kwargs): + logger.info(f"Handling post_save for incident: {instance}") - event.timestamp = datetime.now(tz=None) - event.save() + event = Event() + event.event_type = Event.INCIDENT_EVENT_TYPE + # Event payload should be a dict for serializing to JSON. + event.payload = IncidentSerializer(instance).data + # Actions generate their own events, no need to duplicate them here. + if "action_items" in event.payload: del event.payload["action_items"] -@receiver(post_save, sender=Incident) -def emit_incident_event(sender, instance: Incident, **kwargs): - logger.info(f"Handling post_save for incident: {instance}") + event.timestamp = datetime.now(tz=None) + event.save() - event = Event() - event.event_type = Event.INCIDENT_EVENT_TYPE - # Event payload should be a dict for serializing to JSON. - event.payload = IncidentSerializer(instance).data +if hasattr(settings, "ACTION_EVENT_HANDLER_CLASS"): + cls = import_string(settings.ACTION_EVENT_HANDLER_CLASS) + post_save.connect(cls.handle, sender=Action) +else: + post_save.connect(ActionEventHandler.handle, sender=Action) - event.timestamp = datetime.now(tz=None) - event.save() +if hasattr(settings, "INCIDENT_EVENT_HANDLER_CLASS"): + cls = import_string(settings.INCIDENT_EVENT_HANDLER_CLASS) + post_save.connect(cls.handle, sender=Incident) +else: + post_save.connect(IncidentEventHandler.handle, sender=Incident) From 32a9d88bc284fff144c0c38a3f9fe61ad04e3ee0 Mon Sep 17 00:00:00 2001 From: Matt Cottingham Date: Fri, 8 Nov 2019 10:37:51 +0000 Subject: [PATCH 3/7] Add migration for events --- response/migrations/0014_event.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 response/migrations/0014_event.py diff --git a/response/migrations/0014_event.py b/response/migrations/0014_event.py new file mode 100644 index 00000000..ea20f7ec --- /dev/null +++ b/response/migrations/0014_event.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2.3 on 2019-11-07 17:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('response', '0013_incident_private'), + ] + + operations = [ + migrations.CreateModel( + name='Event', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timestamp', models.DateTimeField()), + ('event_type', models.CharField(max_length=50)), + ('payload', models.TextField()), + ], + ), + ] \ No newline at end of file From 1e88561b7300f32eacd81f2dccc5f341ae1de7f5 Mon Sep 17 00:00:00 2001 From: Matt Cottingham Date: Thu, 7 Nov 2019 16:35:01 +0000 Subject: [PATCH 4/7] API tests --- response/core/models/event.py | 10 +++++++--- tests/api/test_events.py | 31 +++++++++++++++++++++++++++++++ tests/factories/__init__.py | 2 ++ tests/factories/action.py | 3 +++ tests/factories/event.py | 26 ++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 tests/api/test_events.py create mode 100644 tests/factories/event.py diff --git a/response/core/models/event.py b/response/core/models/event.py index d84dfcd3..1d6aadb2 100644 --- a/response/core/models/event.py +++ b/response/core/models/event.py @@ -1,12 +1,16 @@ -from django.contrib.postgres.fields import JSONField +import json from django.db import models class Event(models.Model): - ACTION_EVENT_TYPE= "action_event" + ACTION_EVENT_TYPE = "action_event" INCIDENT_EVENT_TYPE = "incident_event" timestamp = models.DateTimeField() event_type = models.CharField(max_length=50) - payload = JSONField() + payload = models.TextField() + + def save(self, *args, **kwargs): + self.payload = json.dumps(self.payload) + super().save(*args, **kwargs) diff --git a/tests/api/test_events.py b/tests/api/test_events.py new file mode 100644 index 00000000..022b3f92 --- /dev/null +++ b/tests/api/test_events.py @@ -0,0 +1,31 @@ +import json + +from tests.factories.event import EventFactory + +from django.urls import reverse +from rest_framework.test import force_authenticate + +from response.core.views import EventsViewSet + + +def test_list_events(arf, api_user): + persisted_events = EventFactory.create_batch(10) + + req = arf.get(reverse("event-list")) + force_authenticate(req, user=api_user) + response = EventsViewSet.as_view({"get": "list"})(req) + + assert response.status_code == 200, "Got non-200 response from API" + content = json.loads(response.rendered_content) + + assert "results" in content, "Response didn't have results key" + events = content["results"] + assert len(events) == len( + persisted_events, + ), "Didn't get expected number of events back" + + for event in events: + assert event["timestamp"] + assert event["event_type"] + payload = json.loads(event["payload"]) + assert payload["report"] diff --git a/tests/factories/__init__.py b/tests/factories/__init__.py index 69c3b8f4..2accd465 100644 --- a/tests/factories/__init__.py +++ b/tests/factories/__init__.py @@ -2,6 +2,7 @@ from .incident import IncidentFactory from .timeline import TimelineEventFactory from .user import ExternalUserFactory, UserFactory +from .event import EventFactory __all__ = ( "IncidentFactory", @@ -9,4 +10,5 @@ "UserFactory", "ActionFactory", "ExternalUserFactory", + "EventFactory", ) diff --git a/tests/factories/action.py b/tests/factories/action.py index bce399c3..f2773f17 100644 --- a/tests/factories/action.py +++ b/tests/factories/action.py @@ -1,11 +1,14 @@ import factory from faker import Factory +from django.db.models.signals import post_save + from response.core.models import Action faker = Factory.create() +@factory.django.mute_signals(post_save) class ActionFactory(factory.DjangoModelFactory): class Meta: model = Action diff --git a/tests/factories/event.py b/tests/factories/event.py new file mode 100644 index 00000000..b0e9453d --- /dev/null +++ b/tests/factories/event.py @@ -0,0 +1,26 @@ +import factory +import random + +from faker import Factory + +from response.core.models import Event + +faker = Factory.create() + + +class EventFactory(factory.DjangoModelFactory): + class Meta: + model = Event + + timestamp = factory.LazyFunction( + lambda: faker.date_time_between(start_date="-6m", end_date="now", tzinfo=None) + ) + event_type = random.choice([Event.INCIDENT_EVENT_TYPE]) + + # Using an Incident/Event factory here fails with a mysterious error: + # https://github.com/pytest-dev/pytest-django/issues/713 (using @pytest.mark... + # didn't resolve it). For now, a static fixture suffices. + payload = { + "report": "we're out of milk", + "impact": "making tea is difficult", + } From d5083d4546284280e2888570b00f088df4a5fa25 Mon Sep 17 00:00:00 2001 From: Matt Cottingham Date: Fri, 8 Nov 2019 10:38:49 +0000 Subject: [PATCH 5/7] Autoformat --- response/core/models/__init__.py | 2 +- response/core/serializers.py | 1 - response/core/signals.py | 26 ++++++++++++++------------ response/migrations/0014_event.py | 26 ++++++++++++++++---------- response/models.py | 9 +-------- tests/api/test_events.py | 5 ++--- tests/factories/__init__.py | 2 +- tests/factories/action.py | 3 +-- tests/factories/event.py | 7 ++----- 9 files changed, 38 insertions(+), 43 deletions(-) diff --git a/response/core/models/__init__.py b/response/core/models/__init__.py index 91b49d1e..cf58fe47 100644 --- a/response/core/models/__init__.py +++ b/response/core/models/__init__.py @@ -1,7 +1,7 @@ from .action import Action +from .event import Event from .incident import Incident from .timeline import TimelineEvent, add_incident_update_event -from .event import Event from .user_external import ExternalUser __all__ = ( diff --git a/response/core/serializers.py b/response/core/serializers.py index f451f0f1..e360d036 100644 --- a/response/core/serializers.py +++ b/response/core/serializers.py @@ -134,7 +134,6 @@ def update(self, instance, validated_data): class EventSerializer(serializers.ModelSerializer): - class Meta: model = Event fields = ("id", "timestamp", "event_type", "payload") diff --git a/response/core/signals.py b/response/core/signals.py index 2ef27ca4..fb61d611 100644 --- a/response/core/signals.py +++ b/response/core/signals.py @@ -1,21 +1,22 @@ -from datetime import datetime - import logging -logger = logging.getLogger(__name__) - import os - -from response.core.models import Action, Event, Incident -from response.core.serializers import ActionSerializer, IncidentSerializer +from datetime import datetime from django.conf import settings from django.db.models.signals import post_save from django.dispatch import receiver from django.utils.module_loading import import_string +from response.core.models import Action, Event, Incident +from response.core.serializers import ActionSerializer, IncidentSerializer + +logger = logging.getLogger(__name__) -class ActionEventHandler(): + + + +class ActionEventHandler: def handle(sender, instance: Action, **kwargs): logger.info(f"Handling post_save for action: {instance}") @@ -25,14 +26,14 @@ def handle(sender, instance: Action, **kwargs): # Event payload should be a dict for serializing to JSON. event.payload = ActionSerializer(instance).data event.payload["incident_id"] = instance.incident.pk - if "details_ui" in event.payload: del event.payload["details_ui"] + if "details_ui" in event.payload: + del event.payload["details_ui"] event.timestamp = datetime.now(tz=None) event.save() -class IncidentEventHandler(): - +class IncidentEventHandler: def handle(sender, instance: Incident, **kwargs): logger.info(f"Handling post_save for incident: {instance}") @@ -42,7 +43,8 @@ def handle(sender, instance: Incident, **kwargs): # Event payload should be a dict for serializing to JSON. event.payload = IncidentSerializer(instance).data # Actions generate their own events, no need to duplicate them here. - if "action_items" in event.payload: del event.payload["action_items"] + if "action_items" in event.payload: + del event.payload["action_items"] event.timestamp = datetime.now(tz=None) event.save() diff --git a/response/migrations/0014_event.py b/response/migrations/0014_event.py index ea20f7ec..666a28c9 100644 --- a/response/migrations/0014_event.py +++ b/response/migrations/0014_event.py @@ -5,18 +5,24 @@ class Migration(migrations.Migration): - dependencies = [ - ('response', '0013_incident_private'), - ] + dependencies = [("response", "0013_incident_private")] operations = [ migrations.CreateModel( - name='Event', + name="Event", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('timestamp', models.DateTimeField()), - ('event_type', models.CharField(max_length=50)), - ('payload', models.TextField()), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("timestamp", models.DateTimeField()), + ("event_type", models.CharField(max_length=50)), + ("payload", models.TextField()), ], - ), - ] \ No newline at end of file + ) + ] diff --git a/response/models.py b/response/models.py index 61d9efc0..2d5a1452 100644 --- a/response/models.py +++ b/response/models.py @@ -1,11 +1,4 @@ -from .core.models import ( - Action, - Event, - ExternalUser, - Incident, - TimelineEvent, -) - +from .core.models import Action, Event, ExternalUser, Incident, TimelineEvent from .slack.models import ( CommsChannel, HeadlinePost, diff --git a/tests/api/test_events.py b/tests/api/test_events.py index 022b3f92..545a0b84 100644 --- a/tests/api/test_events.py +++ b/tests/api/test_events.py @@ -1,11 +1,10 @@ import json -from tests.factories.event import EventFactory - from django.urls import reverse from rest_framework.test import force_authenticate from response.core.views import EventsViewSet +from tests.factories.event import EventFactory def test_list_events(arf, api_user): @@ -21,7 +20,7 @@ def test_list_events(arf, api_user): assert "results" in content, "Response didn't have results key" events = content["results"] assert len(events) == len( - persisted_events, + persisted_events ), "Didn't get expected number of events back" for event in events: diff --git a/tests/factories/__init__.py b/tests/factories/__init__.py index 2accd465..51ce8ce4 100644 --- a/tests/factories/__init__.py +++ b/tests/factories/__init__.py @@ -1,8 +1,8 @@ from .action import ActionFactory +from .event import EventFactory from .incident import IncidentFactory from .timeline import TimelineEventFactory from .user import ExternalUserFactory, UserFactory -from .event import EventFactory __all__ = ( "IncidentFactory", diff --git a/tests/factories/action.py b/tests/factories/action.py index f2773f17..917d877f 100644 --- a/tests/factories/action.py +++ b/tests/factories/action.py @@ -1,7 +1,6 @@ import factory -from faker import Factory - from django.db.models.signals import post_save +from faker import Factory from response.core.models import Action diff --git a/tests/factories/event.py b/tests/factories/event.py index b0e9453d..f6832459 100644 --- a/tests/factories/event.py +++ b/tests/factories/event.py @@ -1,6 +1,6 @@ -import factory import random +import factory from faker import Factory from response.core.models import Event @@ -20,7 +20,4 @@ class Meta: # Using an Incident/Event factory here fails with a mysterious error: # https://github.com/pytest-dev/pytest-django/issues/713 (using @pytest.mark... # didn't resolve it). For now, a static fixture suffices. - payload = { - "report": "we're out of milk", - "impact": "making tea is difficult", - } + payload = {"report": "we're out of milk", "impact": "making tea is difficult"} From fe22e36f95586bde7464c16a2d233958b4e20218 Mon Sep 17 00:00:00 2001 From: Matt Cottingham Date: Fri, 8 Nov 2019 10:50:21 +0000 Subject: [PATCH 6/7] Make the linter happy --- response/apps.py | 2 +- response/core/signals.py | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/response/apps.py b/response/apps.py index a82a4f2b..08e97999 100644 --- a/response/apps.py +++ b/response/apps.py @@ -16,7 +16,7 @@ def ready(self): dialog_handlers, ) - from .core import signals + from .core import signals # noqa: F401 site_settings.RESPONSE_LOGIN_REQUIRED = getattr( site_settings, "RESPONSE_LOGIN_REQUIRED", True diff --git a/response/core/signals.py b/response/core/signals.py index fb61d611..35a4615a 100644 --- a/response/core/signals.py +++ b/response/core/signals.py @@ -1,10 +1,8 @@ import logging -import os from datetime import datetime from django.conf import settings from django.db.models.signals import post_save -from django.dispatch import receiver from django.utils.module_loading import import_string from response.core.models import Action, Event, Incident @@ -13,9 +11,6 @@ logger = logging.getLogger(__name__) - - - class ActionEventHandler: def handle(sender, instance: Action, **kwargs): logger.info(f"Handling post_save for action: {instance}") From 9553106f5747a706431515a27c2c4761c6c0162c Mon Sep 17 00:00:00 2001 From: Matt Cottingham Date: Fri, 8 Nov 2019 10:51:50 +0000 Subject: [PATCH 7/7] Avoid clobbering previous import --- response/apps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/response/apps.py b/response/apps.py index 08e97999..612f1325 100644 --- a/response/apps.py +++ b/response/apps.py @@ -16,7 +16,7 @@ def ready(self): dialog_handlers, ) - from .core import signals # noqa: F401 + from .core import signals as core_signals # noqa: F401 site_settings.RESPONSE_LOGIN_REQUIRED = getattr( site_settings, "RESPONSE_LOGIN_REQUIRED", True