From 5bd06de1cb4b7f575a488b1c08e8cbfb4b167411 Mon Sep 17 00:00:00 2001 From: Bart van der Schoor Date: Tue, 9 Jan 2024 11:26:34 +0100 Subject: [PATCH 1/4] [#1760] Implemented User Feed app & CMS plugin --- src/open_inwoner/cms/cases/views/status.py | 6 + .../cms/plugins/cms_plugins/__init__.py | 1 + .../cms/plugins/cms_plugins/userfeed.py | 26 +++ .../cms/plugins/migrations/0002_userfeed.py | 36 ++++ .../cms/plugins/models/__init__.py | 1 + .../cms/plugins/models/userfeed.py | 6 + .../cms/plugins/tests/test_userfeed.py | 50 +++++ src/open_inwoner/cms/utils/page_display.py | 14 ++ .../components/UserFeed/UserFeed.html | 58 ------ src/open_inwoner/conf/base.py | 2 + src/open_inwoner/openzaak/notifications.py | 9 + .../openzaak/tests/test_case_detail.py | 11 +- .../openzaak/tests/test_notification_data.py | 18 ++ .../test_notification_zaak_infoobject.py | 12 +- .../tests/test_notification_zaak_status.py | 8 +- src/open_inwoner/openzaak/utils.py | 20 +- .../plans/management/commands/plans_expire.py | 5 + .../plans/tests/test_plans_expire.py | 7 +- src/open_inwoner/plans/tests/test_views.py | 15 ++ src/open_inwoner/plans/views.py | 6 + .../cms/plugins/userfeed/userfeed.html | 46 +++++ src/open_inwoner/userfeed/__init__.py | 0 src/open_inwoner/userfeed/adapter.py | 53 ++++++ src/open_inwoner/userfeed/adapters.py | 40 ++++ src/open_inwoner/userfeed/admin.py | 33 ++++ src/open_inwoner/userfeed/apps.py | 29 +++ src/open_inwoner/userfeed/choices.py | 9 + src/open_inwoner/userfeed/feed.py | 82 +++++++++ src/open_inwoner/userfeed/hooks/__init__.py | 0 .../userfeed/hooks/case_document.py | 83 +++++++++ .../userfeed/hooks/case_status.py | 79 ++++++++ src/open_inwoner/userfeed/hooks/common.py | 18 ++ src/open_inwoner/userfeed/hooks/plan.py | 74 ++++++++ .../userfeed/management/__init__.py | 0 .../userfeed/management/commands/__init__.py | 0 .../management/commands/add_feed_message.py | 51 ++++++ .../commands/process_feed_updates.py | 29 +++ .../userfeed/migrations/0001_initial.py | 144 +++++++++++++++ .../userfeed/migrations/__init__.py | 0 src/open_inwoner/userfeed/models.py | 172 ++++++++++++++++++ src/open_inwoner/userfeed/summarize.py | 61 +++++++ src/open_inwoner/userfeed/tests/__init__.py | 0 src/open_inwoner/userfeed/tests/factories.py | 24 +++ .../userfeed/tests/hooks/__init__.py | 0 .../tests/hooks/test_case_document.py | 89 +++++++++ .../userfeed/tests/hooks/test_case_status.py | 102 +++++++++++ .../userfeed/tests/hooks/test_plan.py | 68 +++++++ .../userfeed/tests/test_commands.py | 83 +++++++++ src/open_inwoner/userfeed/tests/test_feed.py | 64 +++++++ 49 files changed, 1678 insertions(+), 66 deletions(-) create mode 100644 src/open_inwoner/cms/plugins/cms_plugins/userfeed.py create mode 100644 src/open_inwoner/cms/plugins/migrations/0002_userfeed.py create mode 100644 src/open_inwoner/cms/plugins/models/userfeed.py create mode 100644 src/open_inwoner/cms/plugins/tests/test_userfeed.py delete mode 100644 src/open_inwoner/components/templates/components/UserFeed/UserFeed.html create mode 100644 src/open_inwoner/templates/cms/plugins/userfeed/userfeed.html create mode 100644 src/open_inwoner/userfeed/__init__.py create mode 100644 src/open_inwoner/userfeed/adapter.py create mode 100644 src/open_inwoner/userfeed/adapters.py create mode 100644 src/open_inwoner/userfeed/admin.py create mode 100644 src/open_inwoner/userfeed/apps.py create mode 100644 src/open_inwoner/userfeed/choices.py create mode 100644 src/open_inwoner/userfeed/feed.py create mode 100644 src/open_inwoner/userfeed/hooks/__init__.py create mode 100644 src/open_inwoner/userfeed/hooks/case_document.py create mode 100644 src/open_inwoner/userfeed/hooks/case_status.py create mode 100644 src/open_inwoner/userfeed/hooks/common.py create mode 100644 src/open_inwoner/userfeed/hooks/plan.py create mode 100644 src/open_inwoner/userfeed/management/__init__.py create mode 100644 src/open_inwoner/userfeed/management/commands/__init__.py create mode 100644 src/open_inwoner/userfeed/management/commands/add_feed_message.py create mode 100644 src/open_inwoner/userfeed/management/commands/process_feed_updates.py create mode 100644 src/open_inwoner/userfeed/migrations/0001_initial.py create mode 100644 src/open_inwoner/userfeed/migrations/__init__.py create mode 100644 src/open_inwoner/userfeed/models.py create mode 100644 src/open_inwoner/userfeed/summarize.py create mode 100644 src/open_inwoner/userfeed/tests/__init__.py create mode 100644 src/open_inwoner/userfeed/tests/factories.py create mode 100644 src/open_inwoner/userfeed/tests/hooks/__init__.py create mode 100644 src/open_inwoner/userfeed/tests/hooks/test_case_document.py create mode 100644 src/open_inwoner/userfeed/tests/hooks/test_case_status.py create mode 100644 src/open_inwoner/userfeed/tests/hooks/test_plan.py create mode 100644 src/open_inwoner/userfeed/tests/test_commands.py create mode 100644 src/open_inwoner/userfeed/tests/test_feed.py diff --git a/src/open_inwoner/cms/cases/views/status.py b/src/open_inwoner/cms/cases/views/status.py index d559677093..66e2acf748 100644 --- a/src/open_inwoner/cms/cases/views/status.py +++ b/src/open_inwoner/cms/cases/views/status.py @@ -55,6 +55,8 @@ ZaakTypeStatusTypeConfig, ) from open_inwoner.openzaak.utils import get_role_name_display, is_info_object_visible +from open_inwoner.userfeed.hooks.case_document import case_documents_seen +from open_inwoner.userfeed.hooks.case_status import case_status_seen from open_inwoner.utils.time import has_new_elements from open_inwoner.utils.translate import TranslationLookup from open_inwoner.utils.views import CommonPageMixin, LogMixin @@ -218,6 +220,10 @@ def get_context_data(self, **kwargs): self.case, self.resulttype_config_mapping ) + # flag case seen in user feed + case_status_seen(self.request.user, self.case) + case_documents_seen(self.request.user, self.case) + context["case"] = { "id": str(self.case.uuid), "identification": self.case.identification, diff --git a/src/open_inwoner/cms/plugins/cms_plugins/__init__.py b/src/open_inwoner/cms/plugins/cms_plugins/__init__.py index 7bddc35454..c0440f6a04 100644 --- a/src/open_inwoner/cms/plugins/cms_plugins/__init__.py +++ b/src/open_inwoner/cms/plugins/cms_plugins/__init__.py @@ -1 +1,2 @@ +from .userfeed import UserFeedPlugin from .videoplayer import VideoPlayerPlugin diff --git a/src/open_inwoner/cms/plugins/cms_plugins/userfeed.py b/src/open_inwoner/cms/plugins/cms_plugins/userfeed.py new file mode 100644 index 0000000000..8af52d9381 --- /dev/null +++ b/src/open_inwoner/cms/plugins/cms_plugins/userfeed.py @@ -0,0 +1,26 @@ +from django.utils.translation import gettext as _ + +from cms.plugin_base import CMSPluginBase +from cms.plugin_pool import plugin_pool + +from open_inwoner.cms.plugins.models.userfeed import UserFeed +from open_inwoner.userfeed.feed import get_feed + + +@plugin_pool.register_plugin +class UserFeedPlugin(CMSPluginBase): + model = UserFeed + module = _("General") + name = _("User Feed") + render_template = "cms/plugins/userfeed/userfeed.html" + + def render(self, context, instance, placeholder): + request = context["request"] + feed = get_feed(request.user, with_history=True) + context.update( + { + "instance": instance, + "userfeed": feed, + } + ) + return context diff --git a/src/open_inwoner/cms/plugins/migrations/0002_userfeed.py b/src/open_inwoner/cms/plugins/migrations/0002_userfeed.py new file mode 100644 index 0000000000..ca89faa8b3 --- /dev/null +++ b/src/open_inwoner/cms/plugins/migrations/0002_userfeed.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2.23 on 2024-01-05 08:26 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cms", "0022_auto_20180620_1551"), + ("plugins", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="UserFeed", + fields=[ + ( + "cmsplugin_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + related_name="plugins_userfeed", + serialize=False, + to="cms.cmsplugin", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("cms.cmsplugin",), + ), + ] diff --git a/src/open_inwoner/cms/plugins/models/__init__.py b/src/open_inwoner/cms/plugins/models/__init__.py index 5c2207220c..dfb2044a9e 100644 --- a/src/open_inwoner/cms/plugins/models/__init__.py +++ b/src/open_inwoner/cms/plugins/models/__init__.py @@ -1 +1,2 @@ +from .userfeed import UserFeed from .videoplayer import VideoPlayer diff --git a/src/open_inwoner/cms/plugins/models/userfeed.py b/src/open_inwoner/cms/plugins/models/userfeed.py new file mode 100644 index 0000000000..101779041e --- /dev/null +++ b/src/open_inwoner/cms/plugins/models/userfeed.py @@ -0,0 +1,6 @@ +from cms.models import CMSPlugin + + +class UserFeed(CMSPlugin): + # TODO add options + pass diff --git a/src/open_inwoner/cms/plugins/tests/test_userfeed.py b/src/open_inwoner/cms/plugins/tests/test_userfeed.py new file mode 100644 index 0000000000..27fc38770c --- /dev/null +++ b/src/open_inwoner/cms/plugins/tests/test_userfeed.py @@ -0,0 +1,50 @@ +from django.test import TestCase +from django.utils.html import strip_tags +from django.utils.translation import ugettext as _ + +from pyquery import PyQuery as PQ + +from open_inwoner.accounts.tests.factories import UserFactory +from open_inwoner.cms.tests import cms_tools +from open_inwoner.userfeed.hooks.common import simple_message + +from ..cms_plugins import UserFeedPlugin + + +class TestUserFeedPlugin(TestCase): + def test_plugin(self): + user = UserFactory() + simple_message(user, "Hello", title="Test message", url="http://foo.bar") + + html, context = cms_tools.render_plugin( + UserFeedPlugin, plugin_data={}, user=user + ) + + feed = context["userfeed"] + self.assertEqual(feed.total_items, 1) + + self.assertIn("Test message", html) + self.assertIn("Hello", html) + + pyquery = PQ(html) + + # test summary + summaries = pyquery.find(".userfeed__summary .userfeed__list-item") + self.assertEqual(len(summaries), 1) + + summary = summaries.text() + expected = _("There is {count} message").format(count=1) + self.assertEqual(strip_tags(summary), expected) + + # test item + items = pyquery.find(".card-container .card") + self.assertEqual(len(items), 1) + + title = items.find("p.tabled__value").text() + self.assertEqual(title, "Test message") + + message = items.find(".userfeed__heading").text() + self.assertEqual(message, "Hello") + + action_url = items[0].attrib["href"] + self.assertEqual(action_url, "http://foo.bar") diff --git a/src/open_inwoner/cms/utils/page_display.py b/src/open_inwoner/cms/utils/page_display.py index de37847f43..8b4283cd87 100644 --- a/src/open_inwoner/cms/utils/page_display.py +++ b/src/open_inwoner/cms/utils/page_display.py @@ -1,6 +1,8 @@ """Utilities for determining whether CMS pages are published""" +from django.db.models import Q + from cms.models import Page from open_inwoner.cms.benefits.cms_apps import SSDApphook @@ -52,3 +54,15 @@ def benefits_page_is_published() -> bool: :returns: True if the social benefits page published, False otherwise """ return _is_published("ssd") + + +def get_active_app_names() -> list[str]: + return list( + Page.objects.published() + .exclude( + Q(application_urls="") + | Q(application_urls__isnull=True) + | Q(application_namespace="") + ) + .values_list("application_namespace", flat=True) + ) diff --git a/src/open_inwoner/components/templates/components/UserFeed/UserFeed.html b/src/open_inwoner/components/templates/components/UserFeed/UserFeed.html deleted file mode 100644 index 1b2582dec2..0000000000 --- a/src/open_inwoner/components/templates/components/UserFeed/UserFeed.html +++ /dev/null @@ -1,58 +0,0 @@ -{% load i18n icon_tags %} - -{# if userfeed data is true then show component #} -
-

- {% trans "Aanvragen updates" %} -

-
-
    -
  • -

    - Bij {# num actions #} aanvra(a)g(en) is uw actie nodig -

    -
  • -
  • -

    - Bij {# num statuses #} aanvra(a)g(en) is de status gewijzigd -

    -
  • -
  • -

    - Bij {# num statuses #} aanvra(a)g(en) is de status gewijzigd -

    -
  • -
  • -

    - Bij {# num statuses #} aanvra(a)g(en) is de status gewijzigd -

    -
  • -
-
- -
-{# endif - if userfeed data is false then hide component #} diff --git a/src/open_inwoner/conf/base.py b/src/open_inwoner/conf/base.py index 9cecbcef98..9bd254abb3 100644 --- a/src/open_inwoner/conf/base.py +++ b/src/open_inwoner/conf/base.py @@ -216,6 +216,7 @@ "open_inwoner.extended_sessions", "open_inwoner.custom_csp", "open_inwoner.media", + "open_inwoner.userfeed", "open_inwoner.cms.profile", "open_inwoner.cms.cases", "open_inwoner.cms.inbox", @@ -553,6 +554,7 @@ "QuestionnairePlugin", "ProductFinderPlugin", "ProductLocationPlugin", + "UserFeedPlugin", ], "text_only_plugins": ["LinkPlugin"], "name": _("Content"), diff --git a/src/open_inwoner/openzaak/notifications.py b/src/open_inwoner/openzaak/notifications.py index a87a701c76..f6b4cceeea 100644 --- a/src/open_inwoner/openzaak/notifications.py +++ b/src/open_inwoner/openzaak/notifications.py @@ -44,6 +44,8 @@ from open_inwoner.utils.logentry import system_action as log_system_action from open_inwoner.utils.url import build_absolute_url +from ..userfeed.hooks.case_document import case_document_added_notification_received +from ..userfeed.hooks.case_status import case_status_notification_received from .models import ZaakTypeStatusTypeConfig logger = logging.getLogger(__name__) @@ -215,6 +217,9 @@ def _handle_zaakinformatieobject_notification( def handle_zaakinformatieobject_update( user: User, case: Zaak, zaak_info_object: ZaakInformatieObject ): + # hook into userfeed + case_document_added_notification_received(user, case, zaak_info_object) + note = UserCaseInfoObjectNotification.objects.record_if_unique_notification( user, case.uuid, @@ -344,6 +349,10 @@ def _handle_status_notification(notification: Notification, case: Zaak, inform_u def handle_status_update(user: User, case: Zaak, status: Status): + # hook into userfeed + case_status_notification_received(user, case, status) + + # email notification note = UserCaseStatusNotification.objects.record_if_unique_notification( user, case.uuid, diff --git a/src/open_inwoner/openzaak/tests/test_case_detail.py b/src/open_inwoner/openzaak/tests/test_case_detail.py index 8afc613e7b..336052c381 100644 --- a/src/open_inwoner/openzaak/tests/test_case_detail.py +++ b/src/open_inwoner/openzaak/tests/test_case_detail.py @@ -1,5 +1,5 @@ import datetime -from unittest.mock import patch +from unittest.mock import Mock, patch from django.conf import settings from django.contrib.auth.models import AnonymousUser @@ -585,7 +585,11 @@ def _setUpMocks(self, m, use_eindstatus=True): ), ) - def test_status_is_retrieved_when_user_logged_in_via_digid(self, m): + @patch("open_inwoner.cms.cases.views.status.case_status_seen") + @patch("open_inwoner.cms.cases.views.status.case_documents_seen") + def test_status_is_retrieved_when_user_logged_in_via_digid( + self, m, mock_hook_status: Mock, mock_hook_documents: Mock + ): self.maxDiff = None ZaakTypeStatusTypeConfigFactory.create( @@ -663,6 +667,9 @@ def test_status_is_retrieved_when_user_logged_in_via_digid(self, m): "new_docs": False, }, ) + # check userfeed hooks + mock_hook_status.assert_called_once() + mock_hook_documents.assert_called_once() def test_pass_endstatus_type_data_if_endstatus_not_reached(self, m): self.maxDiff = None diff --git a/src/open_inwoner/openzaak/tests/test_notification_data.py b/src/open_inwoner/openzaak/tests/test_notification_data.py index 417f657a9d..994eff571e 100644 --- a/src/open_inwoner/openzaak/tests/test_notification_data.py +++ b/src/open_inwoner/openzaak/tests/test_notification_data.py @@ -148,6 +148,22 @@ def __init__(self): zaak=self.zaak2["url"], ) + self.informatie_object_extra = generate_oas_component( + "drc", + "schemas/EnkelvoudigInformatieObject", + url=f"{DOCUMENTEN_ROOT}enkelvoudiginformatieobjecten/aaaaaaaa-0002-bbbb-aaaa-aaaaaaaaaaaa", + informatieobjecttype=f"{CATALOGI_ROOT}informatieobjecttypen/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + status="definitief", + vertrouwelijkheidaanduiding=VertrouwelijkheidsAanduidingen.openbaar, + ) + self.zaak_informatie_object_extra = generate_oas_component( + "zrc", + "schemas/ZaakInformatieObject", + url=f"{ZAKEN_ROOT}zaakinformatieobjecten/aaaaaaaa-0002-aaaa-aaaa-aaaaaaaaaaaa", + informatieobject=self.informatie_object_extra["url"], + zaak=self.zaak["url"], + ) + self.role_initiator = generate_oas_component( "zrc", "schemas/Rol", @@ -250,6 +266,8 @@ def install_mocks(self, m, *, res404: Optional[List[str]] = None) -> "MockAPIDat "informatie_object", "zaak_informatie_object", "zaak_informatie_object2", + "informatie_object_extra", + "zaak_informatie_object_extra", ]: resource = getattr(self, resource_attr) if resource_attr in res404: diff --git a/src/open_inwoner/openzaak/tests/test_notification_zaak_infoobject.py b/src/open_inwoner/openzaak/tests/test_notification_zaak_infoobject.py index 95f864486c..aabc2cbf0a 100644 --- a/src/open_inwoner/openzaak/tests/test_notification_zaak_infoobject.py +++ b/src/open_inwoner/openzaak/tests/test_notification_zaak_infoobject.py @@ -343,9 +343,14 @@ def test_zio_bails_when_zaak_type_info_object_type_config_is_found_not_marked_fo @override_settings(ZGW_LIMIT_NOTIFICATIONS_FREQUENCY=3600) @freeze_time("2023-01-01 01:00:00") -class NotificationHandlerEmailTestCase(AssertTimelineLogMixin, TestCase): +class NotificationHandlerUserMessageTestCase(AssertTimelineLogMixin, TestCase): + @patch( + "open_inwoner.openzaak.notifications.case_document_added_notification_received" + ) @patch("open_inwoner.openzaak.notifications.send_case_update_email") - def test_handle_zaak_info_object_update(self, mock_send: Mock): + def test_handle_zaak_info_object_update( + self, mock_send: Mock, mock_feed_hook: Mock + ): """ note this test matches with a similar test from `test_notification_zaak_status.py` """ @@ -363,6 +368,9 @@ def test_handle_zaak_info_object_update(self, mock_send: Mock): mock_send.assert_called_once() + # check if userfeed hook was called + mock_feed_hook.assert_called_once() + # check call arguments args = mock_send.call_args.args self.assertEqual(args[0], user) diff --git a/src/open_inwoner/openzaak/tests/test_notification_zaak_status.py b/src/open_inwoner/openzaak/tests/test_notification_zaak_status.py index 0327c6a047..12e45a031a 100644 --- a/src/open_inwoner/openzaak/tests/test_notification_zaak_status.py +++ b/src/open_inwoner/openzaak/tests/test_notification_zaak_status.py @@ -472,9 +472,10 @@ def test_status_bails_when_skip_informeren_is_set_and_zaaktypeconfig_is_not_foun @override_settings(ZGW_LIMIT_NOTIFICATIONS_FREQUENCY=3600) @freeze_time("2023-01-01 01:00:00") -class NotificationHandlerEmailTestCase(AssertTimelineLogMixin, TestCase): +class NotificationHandlerUserMessageTestCase(AssertTimelineLogMixin, TestCase): + @patch("open_inwoner.openzaak.notifications.case_status_notification_received") @patch("open_inwoner.openzaak.notifications.send_case_update_email") - def test_handle_status_update(self, mock_send: Mock): + def test_handle_status_update(self, mock_send: Mock, mock_feed_hook: Mock): """ note this test matches with a similar test from `test_notification_zaak_infoobject.py` """ @@ -492,6 +493,9 @@ def test_handle_status_update(self, mock_send: Mock): mock_send.assert_called_once() + # check if userfeed hook was called + mock_feed_hook.assert_called_once() + # check call arguments args = mock_send.call_args.args self.assertEqual(args[0], user) diff --git a/src/open_inwoner/openzaak/utils.py b/src/open_inwoner/openzaak/utils.py index 842928a358..310abbb175 100644 --- a/src/open_inwoner/openzaak/utils.py +++ b/src/open_inwoner/openzaak/utils.py @@ -7,7 +7,12 @@ from open_inwoner.openzaak.api_models import InformatieObject, Rol, Zaak, ZaakType -from .models import OpenZaakConfig, ZaakTypeConfig, ZaakTypeInformatieObjectTypeConfig +from .models import ( + OpenZaakConfig, + StatusTranslation, + ZaakTypeConfig, + ZaakTypeInformatieObjectTypeConfig, +) logger = logging.getLogger(__name__) @@ -140,3 +145,16 @@ def get_retrieve_resource_by_uuid_url( client.schema, operation_id, base_url=client.base_url, **path_kwargs ) return url + + +def translate_single_status(status_text: str) -> str: + if not status_text: + return "" + + # in most cases try to cache with StatusTranslation.objects.get_lookup() + try: + return StatusTranslation.objects.values_list("translation", flat=True).get( + status=status_text + ) + except StatusTranslation.DoesNotExist: + return "" diff --git a/src/open_inwoner/plans/management/commands/plans_expire.py b/src/open_inwoner/plans/management/commands/plans_expire.py index 122abf1e46..1b2eb62eea 100644 --- a/src/open_inwoner/plans/management/commands/plans_expire.py +++ b/src/open_inwoner/plans/management/commands/plans_expire.py @@ -10,6 +10,7 @@ from open_inwoner.accounts.models import User from open_inwoner.plans.models import Plan +from open_inwoner.userfeed.hooks.plan import plan_expiring from open_inwoner.utils.url import build_absolute_url logger = logging.getLogger(__name__) @@ -48,6 +49,10 @@ def handle(self, *args, **options): f"The email was sent to the user {receiver} about {plans.count()} expiring plans" ) + # hook into userfeed + for p in plans: + plan_expiring(receiver, p) + def send_email(self, receiver: User, plans: List[Plan]): plan_list_link = build_absolute_url(reverse("collaborate:plan_list")) template = find_template("expiring_plan") diff --git a/src/open_inwoner/plans/tests/test_plans_expire.py b/src/open_inwoner/plans/tests/test_plans_expire.py index 1ac3f6794a..ea5ef9cd00 100644 --- a/src/open_inwoner/plans/tests/test_plans_expire.py +++ b/src/open_inwoner/plans/tests/test_plans_expire.py @@ -1,4 +1,5 @@ from datetime import date, timedelta +from unittest.mock import Mock, patch from django.core import mail from django.core.management import call_command @@ -55,7 +56,8 @@ def test_notify_about_expiring_plan_inactive_user(self): call_command("plans_expire") self.assertEqual(len(mail.outbox), 0) - def test_notify_about_expiring_plan(self): + @patch("open_inwoner.plans.management.commands.plans_expire.plan_expiring") + def test_notify_about_expiring_plan(self, mock_plan_expiring: Mock): user = UserFactory() contact = UserFactory() plan = PlanFactory(end_date=date.today(), created_by=user) @@ -87,6 +89,9 @@ def test_notify_about_expiring_plan(self): self.assertIn(plan.goal, html_body2) self.assertIn(reverse("collaborate:plan_list"), html_body2) + # check userfeed hook was called + self.assertEqual(mock_plan_expiring.call_count, 2) + def test_notify_only_user_with_active_notifications(self): user = UserFactory() contact = UserFactory(plans_notifications=False) diff --git a/src/open_inwoner/plans/tests/test_views.py b/src/open_inwoner/plans/tests/test_views.py index 6cdcb1c389..edf316232f 100644 --- a/src/open_inwoner/plans/tests/test_views.py +++ b/src/open_inwoner/plans/tests/test_views.py @@ -1,4 +1,5 @@ from datetime import date +from unittest.mock import Mock, patch from django.contrib.messages import get_messages from django.core import mail @@ -147,6 +148,20 @@ def test_plan_detail_contacts(self): self.assertContains(response, self.contact.get_full_name()) self.assertNotContains(response, new_contact.get_full_name()) + @patch("open_inwoner.plans.views.plan_completed") + def test_plan_detail_userfeed_hook(self, mock_plan_completed: Mock): + self.plan.end_date = date.today() + self.plan.save() + + self.app.get(self.detail_url, user=self.user) + mock_plan_completed.assert_not_called() + + self.plan.end_date = date(2000, 1, 1) + self.plan.save() + + self.app.get(self.detail_url, user=self.user) + mock_plan_completed.assert_called_once() + def test_plan_contact_can_access(self): response = self.app.get(self.list_url, user=self.contact) self.assertEqual(response.status_code, 200) diff --git a/src/open_inwoner/plans/views.py b/src/open_inwoner/plans/views.py index 0764fd20d9..45da0f8f38 100644 --- a/src/open_inwoner/plans/views.py +++ b/src/open_inwoner/plans/views.py @@ -28,6 +28,7 @@ from open_inwoner.utils.mixins import ExportMixin from open_inwoner.utils.views import CommonPageMixin, LogMixin +from ..userfeed.hooks.plan import plan_completed from .forms import PlanForm, PlanGoalForm, PlanListFilterForm from .models import Plan @@ -223,6 +224,11 @@ def get_context_data(self, **kwargs): data=self.request.GET, users=actions.values_list("is_for_id", flat=True) ) context["actions"] = self.get_actions(actions) + + # hook into userfeed + if obj.end_date < date.today(): + plan_completed(self.request.user, obj) + return context diff --git a/src/open_inwoner/templates/cms/plugins/userfeed/userfeed.html b/src/open_inwoner/templates/cms/plugins/userfeed/userfeed.html new file mode 100644 index 0000000000..c78606cf01 --- /dev/null +++ b/src/open_inwoner/templates/cms/plugins/userfeed/userfeed.html @@ -0,0 +1,46 @@ +{% load i18n icon_tags %} + + +
+

+ {% trans "Aanvragen updates" %} +

+
+
    + {% for line in userfeed.summary %} +
  • +

    {{ line }}

    +
  • + {% endfor %} +
+
+ +
diff --git a/src/open_inwoner/userfeed/__init__.py b/src/open_inwoner/userfeed/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/open_inwoner/userfeed/adapter.py b/src/open_inwoner/userfeed/adapter.py new file mode 100644 index 0000000000..33d8228597 --- /dev/null +++ b/src/open_inwoner/userfeed/adapter.py @@ -0,0 +1,53 @@ +from open_inwoner.userfeed.models import FeedItemData + + +class FeedItem: + """ + base class and default for FeedItemData adapter + + use `register_item_adapter(MyFeedItemAdapter, FeedType.my_feed_type)` to register your subclass + """ + + base_title = "" + base_message = "" + base_action_text = "" + base_action_url = "" + + cms_apps: list[str] = [] + + def __init__(self, data: FeedItemData): + self.data = data + + @property + def type(self) -> str: + return self.data.type + + def get_data(self, key: str, default=None): + return self.data.type_data.get(key, default) + + @property + def action_required(self) -> bool: + return self.data.action_required and not self.is_completed + + @property + def is_completed(self) -> bool: + return self.data.is_completed + + @property + def title(self) -> str: + return self.get_data("title") or self.base_title + + @property + def message(self) -> str: + return self.get_data("message") or self.base_message + + @property + def action_text(self) -> str: + return self.base_action_text + + @property + def action_url(self) -> str: + return self.get_data("action_url") or self.base_action_url + + def mark_completed(self): + self.data.mark_completed() diff --git a/src/open_inwoner/userfeed/adapters.py b/src/open_inwoner/userfeed/adapters.py new file mode 100644 index 0000000000..22acb20608 --- /dev/null +++ b/src/open_inwoner/userfeed/adapters.py @@ -0,0 +1,40 @@ +from open_inwoner.userfeed.adapter import FeedItem + + +def get_item_adapter_class(type: str) -> type[FeedItem]: + return feed_adapter_map.get(type, feed_adapter_default) + + +feed_adapter_default = FeedItem +feed_adapter_map = dict() + + +def register_item_adapter(adapter_class: type[FeedItem], feed_type: str): + # NOTE this function could be upgraded to work as class decorator + + if feed_type in feed_adapter_map and adapter_class != feed_adapter_map[feed_type]: + raise KeyError( + f"mismatching duplicate registration of '{feed_type}': {adapter_class} != {feed_adapter_map[feed_type]}" + ) + + feed_adapter_map[feed_type] = adapter_class + + +def get_types_for_unpublished_cms_apps(published_app_names: list[str]) -> set[str]: + """ + determine item types for which the adapter depends on non-published cms apps + """ + exclude_types = set() + + for type, adapter_class in feed_adapter_map.items(): + apps = adapter_class.cms_apps + if not apps: + continue + if isinstance(apps, str): + apps = [apps] + + for name in apps: + if name not in published_app_names: + exclude_types.add(type) + + return exclude_types diff --git a/src/open_inwoner/userfeed/admin.py b/src/open_inwoner/userfeed/admin.py new file mode 100644 index 0000000000..c8c7c2c38e --- /dev/null +++ b/src/open_inwoner/userfeed/admin.py @@ -0,0 +1,33 @@ +from django.contrib import admin + +from .models import FeedItemData + + +@admin.register(FeedItemData) +class FeedItemDataAdmin(admin.ModelAdmin): + raw_id_fields = [ + "user", + ] + list_display = [ + "user", + "type", + "display_at", + "completed_at", + ] + list_filter = [ + "type", + ] + search_fields = [ + "user__email", + "user__first_name", + "user__last_name", + ] + ordering = [ + "display_at", + ] + + def has_change_permission(self, request, obj=None): + return False + + def has_add_permission(self, request): + pass diff --git a/src/open_inwoner/userfeed/apps.py b/src/open_inwoner/userfeed/apps.py new file mode 100644 index 0000000000..384cf25942 --- /dev/null +++ b/src/open_inwoner/userfeed/apps.py @@ -0,0 +1,29 @@ +import os +from importlib import import_module +from pathlib import Path + +from django.apps import AppConfig + + +class UserFeedConfig(AppConfig): + name = "open_inwoner.userfeed" + label = "userfeed" + verbose_name = "User Feed" + + def ready(self): + auto_import_hooks() + + +def auto_import_hooks(): + """ + import files from hooks directory + + this expects things calling their register_xyz() function at module level + """ + + hooks_dir = Path(__file__).parent / "hooks" + for f in os.listdir(hooks_dir): + name, ext = os.path.splitext(f) + if ext == ".py" and name != "__init__": + path = f"open_inwoner.userfeed.hooks.{name}" + import_module(path) diff --git a/src/open_inwoner/userfeed/choices.py b/src/open_inwoner/userfeed/choices.py new file mode 100644 index 0000000000..6c78a4085c --- /dev/null +++ b/src/open_inwoner/userfeed/choices.py @@ -0,0 +1,9 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ + + +class FeedItemType(models.TextChoices): + message_simple = "message_simple", _("Simple text message") + case_status_changed = "case_status_change", _("Case status changed") + case_document_added = "case_document_added", _("Case document added") + plan_expiring = "plan_expiring", _("Plan nears deadline") diff --git a/src/open_inwoner/userfeed/feed.py b/src/open_inwoner/userfeed/feed.py new file mode 100644 index 0000000000..4570adb154 --- /dev/null +++ b/src/open_inwoner/userfeed/feed.py @@ -0,0 +1,82 @@ +import dataclasses +from datetime import timedelta +from typing import Iterable + +from django.db.models import Q +from django.utils import timezone +from django.utils.html import escape, format_html + +from open_inwoner.accounts.models import User +from open_inwoner.cms.utils.page_display import get_active_app_names +from open_inwoner.userfeed.adapter import FeedItem +from open_inwoner.userfeed.adapters import ( + get_item_adapter_class, + get_types_for_unpublished_cms_apps, +) +from open_inwoner.userfeed.models import FeedItemData +from open_inwoner.userfeed.summarize import SUMMARIES + +ACTION_COMPLETED_HISTORY_RANGE = timedelta(minutes=10) + + +@dataclasses.dataclass() +class Feed: + items: list[FeedItem] = dataclasses.field(default_factory=list) + summary: list[str] = dataclasses.field(default_factory=list) + + def has_display(self) -> int: + return self.total_items > 0 + + def action_required(self) -> int: + return any(i.action_required for i in self.items) + + @property + def total_items(self) -> int: + return len(self.items) + + def __bool__(self): + return self.has_display() + + +def wrap_items(items: Iterable[FeedItemData]) -> Iterable[FeedItem]: + for item in items: + yield get_item_adapter_class(item.type)(item) + + +def get_feed(user: User, with_history: bool = False) -> Feed: + if not user or user.is_anonymous: + # empty feed + return Feed() + + # core filters + display_filter = Q(completed_at__isnull=True) + if with_history: + display_filter |= Q( + completed_at__gt=timezone.now() - ACTION_COMPLETED_HISTORY_RANGE + ) + + data_items = FeedItemData.objects.filter( + display_filter, + user=user, + ).order_by("display_at") + + # filter cms apps + inactive_types = get_types_for_unpublished_cms_apps(get_active_app_names()) + if inactive_types: + data_items = data_items.exclude(type__in=inactive_types) + + # wrap + items = list(wrap_items(data_items)) + feed = Feed(items=items) + + # add summary lines + for summarize in SUMMARIES: + line = summarize(items) + if line: + html = escape(line.text) + if "{count}" in html: + count = format_html("{}", str(line.count)) + html = format_html(html, count=count) + feed.summary.append(html) + + return feed diff --git a/src/open_inwoner/userfeed/hooks/__init__.py b/src/open_inwoner/userfeed/hooks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/open_inwoner/userfeed/hooks/case_document.py b/src/open_inwoner/userfeed/hooks/case_document.py new file mode 100644 index 0000000000..9b687cfcb6 --- /dev/null +++ b/src/open_inwoner/userfeed/hooks/case_document.py @@ -0,0 +1,83 @@ +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ + +from open_inwoner.accounts.models import User +from open_inwoner.openzaak.api_models import Zaak, ZaakInformatieObject +from open_inwoner.userfeed.adapter import FeedItem +from open_inwoner.userfeed.adapters import register_item_adapter +from open_inwoner.userfeed.choices import FeedItemType +from open_inwoner.userfeed.models import FeedItemData + + +def case_document_added_notification_received( + user: User, case: Zaak, case_info_object: ZaakInformatieObject +): + data = { + "case_uuid": case.uuid, + "case_identificatie": case.identificatie, + "case_omschrijving": case.omschrijving, + "case_info_object_uuid": case_info_object.uuid, + "case_info_object_titel": case_info_object.titel, + "info_object_uuid": case_info_object.informatieobject.uuid, + "info_object_titel": case_info_object.informatieobject.titel, + } + + # use a ref_key to de-duplicate per info object + ref_key = str(case_info_object.uuid) + + qs = FeedItemData.objects.filter( + user=user, + type=FeedItemType.case_document_added, + ref_uuid=case.uuid, + ref_key=ref_key, + ) + + # try update as notifications can be delivered multiple times + if not qs.update( + display_at=timezone.now(), + completed_at=None, + type_data=data, + ): + FeedItemData.objects.create( + user=user, + type=FeedItemType.case_document_added, + ref_uuid=case.uuid, + ref_key=ref_key, + type_data=data, + ) + + +def case_documents_seen(user: User, case: Zaak): + # mark all document items for this case as completed + FeedItemData.objects.mark_uuid_completed( + user, FeedItemType.case_document_added, case.uuid + ) + + +class CaseDocumentAddedFeedItem(FeedItem): + base_title = _("Case document added") + base_message = _("Case document '{title}' has been added") + + cms_apps = ["cases"] + + @property + def title(self) -> str: + return self.get_data("case_omschrijving", super().title) + + @property + def message(self) -> str: + title = ( + self.get_data("info_object_titel") + or self.get_data("case_info_object_titel") + or _("onbekend") + ) + return self.base_message.format(title=title) + + @property + def action_url(self) -> str: + uuid = self.get_data("case_uuid") + return reverse("cases:case_detail", kwargs={"object_id": uuid}) + + +register_item_adapter(CaseDocumentAddedFeedItem, FeedItemType.case_document_added) diff --git a/src/open_inwoner/userfeed/hooks/case_status.py b/src/open_inwoner/userfeed/hooks/case_status.py new file mode 100644 index 0000000000..4625d2d6db --- /dev/null +++ b/src/open_inwoner/userfeed/hooks/case_status.py @@ -0,0 +1,79 @@ +from datetime import timedelta + +from django.db.models import Q +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ + +from open_inwoner.accounts.models import User +from open_inwoner.openzaak.api_models import Status, Zaak +from open_inwoner.openzaak.utils import translate_single_status +from open_inwoner.userfeed.adapter import FeedItem +from open_inwoner.userfeed.adapters import register_item_adapter +from open_inwoner.userfeed.choices import FeedItemType +from open_inwoner.userfeed.models import FeedItemData + +STATUS_REUSE_TIME = timedelta(minutes=10) + + +def case_status_notification_received(user: User, case: Zaak, status: Status): + data = { + "case_uuid": case.uuid, + "case_identificatie": case.identificatie, + "case_omschrijving": case.omschrijving, + "status_omschrijving": status.statustype.omschrijving, + } + + # let's try to update last record if change happened recently + qs = FeedItemData.objects.filter( + user=user, + type=FeedItemType.case_status_changed, + ref_uuid=case.uuid, + ) + qs = qs.filter(Q(display_at__gte=timezone.now() - STATUS_REUSE_TIME)) + + # try update as notifications can be delivered multiple times + if not qs.update( + display_at=timezone.now(), + completed_at=None, + type_data=data, + ): + FeedItemData.objects.create( + user=user, + type=FeedItemType.case_status_changed, + ref_uuid=case.uuid, + type_data=data, + ) + + +def case_status_seen(user: User, case: Zaak): + FeedItemData.objects.mark_uuid_completed( + user, FeedItemType.case_status_changed, case.uuid + ) + + +class CaseStatusUpdateFeedItem(FeedItem): + base_title = _("Case status update") + base_message = _("Case status has been changed to '{status}'") + + cms_apps = ["cases"] + + @property + def title(self) -> str: + return self.get_data("case_omschrijving", super().title) + + @property + def message(self) -> str: + status_text = self.get_data("status_omschrijving") + status_text = ( + translate_single_status(status_text) or status_text or _("onbekend") + ) + return self.base_message.format(status=status_text) + + @property + def action_url(self) -> str: + uuid = self.get_data("case_uuid") + return reverse("cases:case_detail", kwargs={"object_id": uuid}) + + +register_item_adapter(CaseStatusUpdateFeedItem, FeedItemType.case_status_changed) diff --git a/src/open_inwoner/userfeed/hooks/common.py b/src/open_inwoner/userfeed/hooks/common.py new file mode 100644 index 0000000000..9f625a9df9 --- /dev/null +++ b/src/open_inwoner/userfeed/hooks/common.py @@ -0,0 +1,18 @@ +from open_inwoner.accounts.models import User +from open_inwoner.userfeed.choices import FeedItemType +from open_inwoner.userfeed.models import FeedItemData + + +def simple_message(user: User, message: str, title: str = "", url=""): + """ + functional but mostly used for development and debugging purposes + """ + FeedItemData.objects.create( + user=user, + type=FeedItemType.message_simple, + type_data={ + "message": message, + "title": title, + "action_url": url, + }, + ) diff --git a/src/open_inwoner/userfeed/hooks/plan.py b/src/open_inwoner/userfeed/hooks/plan.py new file mode 100644 index 0000000000..94f8677ae3 --- /dev/null +++ b/src/open_inwoner/userfeed/hooks/plan.py @@ -0,0 +1,74 @@ +from datetime import datetime + +from django.urls import reverse +from django.utils import timezone +from django.utils.dateparse import parse_date +from django.utils.timezone import make_aware +from django.utils.translation import ugettext_lazy as _ + +from open_inwoner.accounts.models import User +from open_inwoner.plans.models import Plan +from open_inwoner.userfeed.adapter import FeedItem +from open_inwoner.userfeed.adapters import register_item_adapter +from open_inwoner.userfeed.choices import FeedItemType +from open_inwoner.userfeed.models import FeedItemData + + +def plan_expiring(user: User, plan: Plan): + data = { + "plan_title": plan.title, + "plan_uuid": plan.uuid, + "plan_end_date": plan.end_date, + } + + qs = FeedItemData.objects.filter( + user=user, + type=FeedItemType.plan_expiring, + ).filter_ref_object(plan) + + auto_expire = make_aware( + datetime(plan.end_date.year, plan.end_date.month, plan.end_date.day) + ) + if not qs.update( + display_at=timezone.now(), + completed_at=None, + type_data=data, + auto_expire_at=auto_expire, + ): + FeedItemData.objects.create( + user=user, + type=FeedItemType.plan_expiring, + ref_object_field=plan, + type_data=data, + action_required=True, + auto_expire_at=auto_expire, + ) + + +def plan_completed(user: User, plan: Plan): + FeedItemData.objects.mark_object_completed(user, FeedItemType.plan_expiring, plan) + + +class PlanExpiresFeedItem(FeedItem): + base_title = _("Plan expiring") + base_message = _("Plan deadline expires at {expire}") + + cms_apps = ["collaborate"] + + @property + def title(self) -> str: + return self.get_data("plan_title", super().title) + + @property + def message(self) -> str: + date_text = parse_date(self.get_data("plan_end_date")).strftime("%x") + return self.base_message.format(expire=date_text) + + @property + def action_url(self) -> str: + # TODO harden for missing CMS app + uuid = self.get_data("plan_uuid") + return reverse("collaborate:plan_detail", kwargs={"uuid": uuid}) + + +register_item_adapter(PlanExpiresFeedItem, FeedItemType.plan_expiring) diff --git a/src/open_inwoner/userfeed/management/__init__.py b/src/open_inwoner/userfeed/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/open_inwoner/userfeed/management/commands/__init__.py b/src/open_inwoner/userfeed/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/open_inwoner/userfeed/management/commands/add_feed_message.py b/src/open_inwoner/userfeed/management/commands/add_feed_message.py new file mode 100644 index 0000000000..41b3f10218 --- /dev/null +++ b/src/open_inwoner/userfeed/management/commands/add_feed_message.py @@ -0,0 +1,51 @@ +from django.core.management import BaseCommand + +from open_inwoner.accounts.models import User +from open_inwoner.userfeed.hooks.common import simple_message + + +class Command(BaseCommand): + help = "Development tool to add items to UserFeed (for safety only enabled for active staff users)" + + def add_arguments(self, parser): + parser.add_argument( + "--user", + help="User ID.", + default=0, + ) + parser.add_argument( + "--message", + help="Message text", + default="", + ) + parser.add_argument( + "--title", + help="Message title", + default="", + ) + parser.add_argument( + "--url", + help="Action URL", + default="", + ) + + def handle(self, *args, **options): + try: + user = User.objects.get(pk=options["user"]) + except User.DoesNotExist: + self.stdout.write("user_id not found, use one off:") + for user in User.objects.filter(is_active=True, is_staff=True).order_by( + "id" + ): + self.stdout.write(f"{user.id} - {user}") + return + + print(user) + + if not options["message"] or not options["title"]: + self.stdout.write("specify --title and/or --message") + return + + simple_message( + user, options["message"], title=options["title"], url=options["url"] + ) diff --git a/src/open_inwoner/userfeed/management/commands/process_feed_updates.py b/src/open_inwoner/userfeed/management/commands/process_feed_updates.py new file mode 100644 index 0000000000..c05786bb37 --- /dev/null +++ b/src/open_inwoner/userfeed/management/commands/process_feed_updates.py @@ -0,0 +1,29 @@ +import logging + +from django.core.management import BaseCommand +from django.utils import timezone + +from open_inwoner.userfeed.models import FeedItemData + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Periodically update UserFeed items that need it (use from daily cronjob)" + + def handle(self, *args, **options): + auto_expire_items() + + +def auto_expire_items(): + """ + mark uncompleted auto_expiring items as completed + """ + qs = FeedItemData.objects.filter( + auto_expire_at__lte=timezone.now(), + completed_at__isnull=True, + ) + for data in qs: + logger.info(f"automatically expired feed item: {data.id} {data}") + + qs.mark_completed() diff --git a/src/open_inwoner/userfeed/migrations/0001_initial.py b/src/open_inwoner/userfeed/migrations/0001_initial.py new file mode 100644 index 0000000000..15d058d765 --- /dev/null +++ b/src/open_inwoner/userfeed/migrations/0001_initial.py @@ -0,0 +1,144 @@ +# Generated by Django 3.2.23 on 2024-01-09 10:20 + +import django.core.serializers.json +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="FeedItemData", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "display_at", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="Feed time" + ), + ), + ( + "completed_at", + models.DateTimeField( + blank=True, + null=True, + verbose_name="Information seen or action completed", + ), + ), + ( + "auto_expire_at", + models.DateTimeField( + blank=True, + null=True, + verbose_name="Automatically mark as completed after", + ), + ), + ( + "action_required", + models.BooleanField(default=False, verbose_name="Action required"), + ), + ( + "type", + models.CharField( + choices=[ + ("message_simple", "Simple text message"), + ("case_status_change", "Case status changed"), + ("case_document_added", "Case document added"), + ("plan_expiring", "Plan nears deadline"), + ], + max_length=64, + verbose_name="Type", + ), + ), + ( + "type_data", + models.JSONField( + blank=True, + default=dict, + encoder=django.core.serializers.json.DjangoJSONEncoder, + verbose_name="Data for type", + ), + ), + ( + "ref_uuid", + models.UUIDField( + blank=True, null=True, verbose_name="External object UUID" + ), + ), + ( + "ref_key", + models.CharField( + blank=True, + max_length=64, + verbose_name="External object de-duplication", + ), + ), + ("ref_object_id", models.PositiveIntegerField(blank=True, null=True)), + ( + "ref_object_type", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["display_at"], + }, + ), + migrations.AddIndex( + model_name="feeditemdata", + index=models.Index(fields=["user", "type"], name="user_type"), + ), + migrations.AddConstraint( + model_name="feeditemdata", + constraint=models.CheckConstraint( + check=models.Q( + models.Q( + ("ref_object_id__isnull", True), + ("ref_object_type__isnull", True), + ("ref_uuid__isnull", False), + ), + models.Q( + ("ref_object_id__isnull", False), + ("ref_object_type__isnull", False), + ("ref_uuid__isnull", True), + ), + models.Q( + ("ref_object_id__isnull", True), + ("ref_object_type__isnull", True), + ("ref_uuid__isnull", True), + ), + _connector="OR", + ), + name="enforce_single_main_ref_field", + ), + ), + ] diff --git a/src/open_inwoner/userfeed/migrations/__init__.py b/src/open_inwoner/userfeed/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/open_inwoner/userfeed/models.py b/src/open_inwoner/userfeed/models.py new file mode 100644 index 0000000000..f4d7bf1df5 --- /dev/null +++ b/src/open_inwoner/userfeed/models.py @@ -0,0 +1,172 @@ +from typing import Optional + +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.serializers.json import DjangoJSONEncoder +from django.db import models +from django.db.models import CheckConstraint, Q +from django.utils import timezone +from django.utils.functional import cached_property +from django.utils.translation import ugettext_lazy as _ + +from open_inwoner.accounts.models import User +from open_inwoner.userfeed.choices import FeedItemType + + +class FeedItemManager(models.Manager): + def mark_uuid_completed( + self, + user: User, + type: str, + ref_uuid, + ref_key: Optional[str] = None, + force=False, + ): + qs = self.filter( + user=user, + type=type, + ref_uuid=ref_uuid, + ) + if ref_key is not None: + qs = qs.filter(ref_key=ref_key) + return qs.mark_completed(force=force) + + def mark_object_completed( + self, + user: User, + type: str, + ref_object, + ref_key: Optional[str] = None, + force=False, + ): + qs = self.filter( + user=user, + type=type, + ).filter_ref_object(ref_object) + + if ref_key is not None: + qs = qs.filter(ref_key=ref_key) + return qs.mark_completed(force=force) + + +class FeedItemQueryset(models.QuerySet): + def mark_completed(self, force: bool = False): + qs = self + if not force: + qs = qs.filter( + completed_at__isnull=True, + ) + # note: this must match with the instance method + return qs.update( + completed_at=timezone.now(), + ) + + def filter_ref_object(self, ref_object): + return self.filter( + ref_object_id=ref_object.id, + ref_object_type=ContentType.objects.get_for_model(ref_object), + ) + + +class FeedItemData(models.Model): + user = models.ForeignKey( + "accounts.User", + on_delete=models.CASCADE, + ) + display_at = models.DateTimeField( + verbose_name=_("Feed time"), + default=timezone.now, + ) + completed_at = models.DateTimeField( + verbose_name=_("Information seen or action completed"), + blank=True, + null=True, + ) + + @property + def is_completed(self): + return self.completed_at is not None + + auto_expire_at = models.DateTimeField( + verbose_name=_("Automatically mark as completed after"), + blank=True, + null=True, + ) + + action_required = models.BooleanField( + verbose_name=_("Action required"), + default=False, + ) + + type = models.CharField( + _("Type"), + max_length=64, + choices=FeedItemType.choices, + ) + type_data = models.JSONField( + verbose_name=_("Data for type"), + default=dict, + blank=True, + encoder=DjangoJSONEncoder, + ) + + # for lookup and de-duplication + ref_uuid = models.UUIDField(_("External object UUID"), null=True, blank=True) + ref_key = models.CharField( + _("External object de-duplication"), blank=True, max_length=64 + ) + ref_object_field = GenericForeignKey( + ct_field="ref_object_type", fk_field="ref_object_id" + ) + ref_object_type = models.ForeignKey( + ContentType, on_delete=models.CASCADE, blank=True, null=True + ) + ref_object_id = models.PositiveIntegerField(blank=True, null=True) + + @cached_property + def ref_object(self): + # don't raise but return None + if self.ref_object_type_id and self.ref_object_id: + return self.ref_object_field + + objects = FeedItemManager.from_queryset(FeedItemQueryset)() + + def mark_completed(self, force: bool = False, save: bool = True): + # note: this must match with the queryset method + if self.completed_at is None or force: + self.completed_at = timezone.now() + if save: + self.save() + + class Meta: + ordering = ["display_at"] + indexes = [ + models.Index(fields=["user", "type"], name="user_type"), + ] + constraints = [ + CheckConstraint( + # don't allow setting both uuid and generic object for now + check=Q( + # uuid + ref_uuid__isnull=False, + ref_object_type__isnull=True, + ref_object_id__isnull=True, + ) + | Q( + # object + ref_uuid__isnull=True, + ref_object_type__isnull=False, + ref_object_id__isnull=False, + ) + | Q( + # none + ref_uuid__isnull=True, + ref_object_type__isnull=True, + ref_object_id__isnull=True, + ), + name="enforce_single_main_ref_field", + ), + ] + + def __str__(self): + return f"{self.type} {self.user} @ {self.display_at}" diff --git a/src/open_inwoner/userfeed/summarize.py b/src/open_inwoner/userfeed/summarize.py new file mode 100644 index 0000000000..3c271ed127 --- /dev/null +++ b/src/open_inwoner/userfeed/summarize.py @@ -0,0 +1,61 @@ +from typing import Iterable, NamedTuple, Optional + +from django.utils.translation import ngettext + +from open_inwoner.userfeed.adapter import FeedItem +from open_inwoner.userfeed.choices import FeedItemType + + +class SummaryLine(NamedTuple): + text: str + count: int + + +def summarize_case_status_changed( + items: Iterable[FeedItem], +) -> Optional[SummaryLine]: + num_items = sum( + 1 for item in items if item.type == FeedItemType.case_status_changed + ) + if num_items: + return SummaryLine( + ngettext( + "In {count} case the status has changed", + "In {count} cases the status has changed", + num_items, + ), + num_items, + ) + + +def summarize_action_required(items: Iterable[FeedItem]) -> Optional[SummaryLine]: + num_items = sum(1 for item in items if item.action_required) + if num_items: + return SummaryLine( + ngettext( + "In {count} case your action is required", + "In {count} cases your action is required", + num_items, + ), + num_items, + ) + + +def summarize_simple_message(items: Iterable[FeedItem]) -> Optional[SummaryLine]: + num_items = sum(1 for item in items if item.type == FeedItemType.message_simple) + if num_items: + return SummaryLine( + ngettext( + "There is {count} message", + "There are {count} messages", + num_items, + ), + num_items, + ) + + +SUMMARIES = [ + summarize_simple_message, + summarize_case_status_changed, + summarize_action_required, +] diff --git a/src/open_inwoner/userfeed/tests/__init__.py b/src/open_inwoner/userfeed/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/open_inwoner/userfeed/tests/factories.py b/src/open_inwoner/userfeed/tests/factories.py new file mode 100644 index 0000000000..afa4759d19 --- /dev/null +++ b/src/open_inwoner/userfeed/tests/factories.py @@ -0,0 +1,24 @@ +import factory.fuzzy + +from open_inwoner.accounts.tests.factories import UserFactory +from open_inwoner.userfeed.choices import FeedItemType + + +class FeedItemDataFactory(factory.django.DjangoModelFactory): + """ + NOTE in general it is safer to use the hooks to create records for testing + """ + + class Meta: + model = "userfeed.FeedItemData" + + user = factory.SubFactory(UserFactory) + + type = FeedItemType.message_simple + type_data = factory.Dict( + { + "message": "test message", + "title": "test title", + "action_url": "http://example.com", + } + ) diff --git a/src/open_inwoner/userfeed/tests/hooks/__init__.py b/src/open_inwoner/userfeed/tests/hooks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/open_inwoner/userfeed/tests/hooks/test_case_document.py b/src/open_inwoner/userfeed/tests/hooks/test_case_document.py new file mode 100644 index 0000000000..0f6bae22ec --- /dev/null +++ b/src/open_inwoner/userfeed/tests/hooks/test_case_document.py @@ -0,0 +1,89 @@ +from unittest.mock import Mock, patch + +from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils.translation import ugettext as _ + +from zgw_consumers.api_models.base import factory + +from open_inwoner.openzaak.api_models import ( + InformatieObject, + Zaak, + ZaakInformatieObject, + ZaakType, +) +from open_inwoner.openzaak.tests.test_notification_data import MockAPIData +from open_inwoner.userfeed.choices import FeedItemType +from open_inwoner.userfeed.feed import get_feed +from open_inwoner.userfeed.hooks.case_document import ( + case_document_added_notification_received, + case_documents_seen, +) + + +@override_settings(ROOT_URLCONF="open_inwoner.cms.tests.urls") +class FeedHookTest(TestCase): + @patch("open_inwoner.userfeed.feed.get_active_app_names", return_value=["cases"]) + def test_document_added(self, mock_get_active_app_names: Mock): + data = MockAPIData() + user = data.user_initiator + + case = factory(Zaak, data.zaak) + case.zaaktype = factory(ZaakType, data.zaak_type) + + zio = factory(ZaakInformatieObject, data.zaak_informatie_object) + zio.informatieobject = factory(InformatieObject, data.informatie_object) + + case_document_added_notification_received(user, case, zio) + + # check feed + feed = get_feed(user) + self.assertEqual(feed.total_items, 1) + self.assertEqual(len(feed.summary), 0) + + # check item + item = feed.items[0] + self.assertEqual(item.type, FeedItemType.case_document_added) + self.assertEqual(item.action_required, False) + self.assertEqual(item.is_completed, False) + self.assertEqual( + item.message, + _("Case document '{title}' has been added").format( + title=zio.informatieobject.titel + ), + ) + self.assertEqual(item.title, case.omschrijving) + self.assertEqual( + item.action_url, + reverse("cases:case_detail", kwargs={"object_id": case.uuid}), + ) + + # send duplicate notification + case_document_added_notification_received(user, case, zio) + + feed = get_feed(user) + + # still only one item + self.assertEqual(feed.total_items, 1) + + # add another document + zio2 = factory(ZaakInformatieObject, data.zaak_informatie_object_extra) + zio2.informatieobject = factory(InformatieObject, data.informatie_object_extra) + + case_document_added_notification_received(user, case, zio2) + + feed = get_feed(user) + + # got two items + self.assertEqual(feed.total_items, 2) + + # mark as seen + case_documents_seen(user, case) + + # no longer visible + feed = get_feed(user) + self.assertEqual(feed.total_items, 0) + + # doesn't break on repeat + case_documents_seen(user, case) + self.assertEqual(get_feed(user).total_items, 0) diff --git a/src/open_inwoner/userfeed/tests/hooks/test_case_status.py b/src/open_inwoner/userfeed/tests/hooks/test_case_status.py new file mode 100644 index 0000000000..a727f89695 --- /dev/null +++ b/src/open_inwoner/userfeed/tests/hooks/test_case_status.py @@ -0,0 +1,102 @@ +from unittest.mock import Mock, patch + +from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils.html import strip_tags +from django.utils.translation import ugettext as _ + +from zgw_consumers.api_models.base import factory + +from open_inwoner.openzaak.api_models import Status, StatusType, Zaak, ZaakType +from open_inwoner.openzaak.tests.factories import StatusTranslationFactory +from open_inwoner.openzaak.tests.test_notification_data import MockAPIData +from open_inwoner.userfeed.choices import FeedItemType +from open_inwoner.userfeed.feed import get_feed +from open_inwoner.userfeed.hooks.case_status import ( + case_status_notification_received, + case_status_seen, +) + + +@override_settings(ROOT_URLCONF="open_inwoner.cms.tests.urls") +class FeedHookTest(TestCase): + @patch("open_inwoner.userfeed.feed.get_active_app_names", return_value=["cases"]) + def test_status_update(self, mock_get_active_app_names: Mock): + data = MockAPIData() + user = data.user_initiator + + case = factory(Zaak, data.zaak) + case.zaaktype = factory(ZaakType, data.zaak_type) + + status = factory(Status, data.status_initial) + status.statustype = factory(StatusType, data.status_type_initial) + + case_status_notification_received(user, case, status) + + # check feed + feed = get_feed(user) + self.assertEqual(feed.total_items, 1) + self.assertEqual(len(feed.summary), 1) + + # check item + item = feed.items[0] + self.assertEqual(item.type, FeedItemType.case_status_changed) + self.assertEqual(item.action_required, False) + self.assertEqual(item.is_completed, False) + self.assertEqual(item.message, _("Case status has been changed to 'initial'")) + self.assertEqual(item.title, case.omschrijving) + self.assertEqual( + item.action_url, + reverse("cases:case_detail", kwargs={"object_id": case.uuid}), + ) + + summary = feed.summary[0] + expected = _("In {count} case the status has changed").format(count=1) + self.assertEqual(strip_tags(summary), expected) + + # send duplicate notification + case_status_notification_received(user, case, status) + + feed = get_feed(user) + + # still only one item + self.assertEqual(feed.total_items, 1) + + # update status + status2 = factory(Status, data.status_final) + status2.statustype = factory(StatusType, data.status_type_final) + + # lets test status translation + status2.statustype.omschrijving = "not_translated" + StatusTranslationFactory( + status="not_translated", translation="translated status" + ) + + # receive status update + case_status_notification_received(user, case, status2) + + feed = get_feed(user) + + # still only one item + self.assertEqual(feed.total_items, 1) + + # check item changed + item = feed.items[0] + self.assertEqual( + item.message, + _("Case status has been changed to '{status}'").format( + status="translated status" + ), + ) + self.assertEqual(item.title, case.omschrijving) + + # mark as seen + case_status_seen(user, case) + + # no longer visible + feed = get_feed(user) + self.assertEqual(feed.total_items, 0) + + # doesn't break on repeat + case_status_seen(user, case) + self.assertEqual(get_feed(user).total_items, 0) diff --git a/src/open_inwoner/userfeed/tests/hooks/test_plan.py b/src/open_inwoner/userfeed/tests/hooks/test_plan.py new file mode 100644 index 0000000000..8cd26950ed --- /dev/null +++ b/src/open_inwoner/userfeed/tests/hooks/test_plan.py @@ -0,0 +1,68 @@ +from datetime import date +from unittest.mock import Mock, patch + +from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils.translation import ugettext as _ + +from open_inwoner.accounts.tests.factories import UserFactory +from open_inwoner.plans.tests.factories import PlanFactory +from open_inwoner.userfeed.choices import FeedItemType +from open_inwoner.userfeed.feed import get_feed +from open_inwoner.userfeed.hooks.plan import plan_completed, plan_expiring + + +@override_settings(ROOT_URLCONF="open_inwoner.cms.tests.urls") +class FeedHookTest(TestCase): + @patch( + "open_inwoner.userfeed.feed.get_active_app_names", return_value=["collaborate"] + ) + def test_plan_expires(self, mock_get_active_app_names: Mock): + user = UserFactory() + plan = PlanFactory(end_date=date.today()) + + plan_expiring(user, plan) + + # check feed + feed = get_feed(user) + self.assertEqual(feed.total_items, 1) + self.assertEqual(len(feed.summary), 1) + + # check item + item = feed.items[0] + self.assertEqual(item.type, FeedItemType.plan_expiring) + self.assertEqual(item.action_required, True) + self.assertEqual(item.is_completed, False) + self.assertEqual( + item.message, + _("Plan deadline expires at {expire}").format( + expire=plan.end_date.strftime("%x") + ), + ) + self.assertEqual(item.title, plan.title) + self.assertEqual( + item.action_url, + reverse("collaborate:plan_detail", kwargs={"uuid": plan.uuid}), + ) + + # send duplicate notification + plan_expiring(user, plan) + + feed = get_feed(user) + + # still only one item + self.assertEqual(feed.total_items, 1) + + # mark as seen + plan_completed(user, plan) + + # removed from feed + self.assertEqual(get_feed(user).total_items, 0) + + # doesn't break on repeat + plan_completed(user, plan) + self.assertEqual(get_feed(user).total_items, 0) + + # plan got reactivated + plan_expiring(user, plan) + self.assertEqual(get_feed(user).total_items, 1) diff --git a/src/open_inwoner/userfeed/tests/test_commands.py b/src/open_inwoner/userfeed/tests/test_commands.py new file mode 100644 index 0000000000..6314b6998e --- /dev/null +++ b/src/open_inwoner/userfeed/tests/test_commands.py @@ -0,0 +1,83 @@ +from datetime import datetime +from unittest.mock import Mock, patch + +from django.core.management import call_command +from django.test import TestCase +from django.utils.timezone import make_aware + +from freezegun import freeze_time + +from open_inwoner.accounts.tests.factories import UserFactory +from open_inwoner.userfeed.choices import FeedItemType +from open_inwoner.userfeed.feed import get_feed +from open_inwoner.userfeed.management.commands.process_feed_updates import ( + auto_expire_items, +) +from open_inwoner.userfeed.tests.factories import FeedItemDataFactory + + +class CommandTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = UserFactory() + + def test_command_process_feed_updates(self): + """ + check if all processors are called but do the actual testing individually + """ + with patch( + "open_inwoner.userfeed.management.commands.process_feed_updates.auto_expire_items" + ) as mock_auto_expire_items: + call_command("process_feed_updates") + + mock_auto_expire_items.assert_called_once() + + @freeze_time("2000-01-01") + def test_auto_expire(self): + user = UserFactory() + + d = make_aware(datetime(2001, 1, 1)) + item_do_expire = FeedItemDataFactory(user=user, auto_expire_at=d) + item_not_expire = FeedItemDataFactory(user=user) + + auto_expire_items() + + item_do_expire.refresh_from_db() + item_not_expire.refresh_from_db() + + # no changes + self.assertEqual(item_do_expire.is_completed, False) + self.assertEqual(item_not_expire.is_completed, False) + + # move past expiry + with freeze_time("2002-01-01"): + auto_expire_items() + + item_do_expire.refresh_from_db() + item_not_expire.refresh_from_db() + + # expiring item expired + self.assertEqual(item_do_expire.is_completed, True) + self.assertEqual(item_not_expire.is_completed, False) + + def test_command_add_feed_message_test(self): + # sanity check + call_command( + "add_feed_message", + "--user", + self.user.id, + "--message", + "Hello", + "--title", + "World", + "--url", + "http://foo.bar", + ) + feed = get_feed(self.user) + self.assertEqual(feed.total_items, 1) + + item = feed.items[0] + self.assertEqual(item.type, FeedItemType.message_simple) + self.assertEqual(item.message, "Hello") + self.assertEqual(item.title, "World") + self.assertEqual(item.action_url, "http://foo.bar") diff --git a/src/open_inwoner/userfeed/tests/test_feed.py b/src/open_inwoner/userfeed/tests/test_feed.py new file mode 100644 index 0000000000..6a8ca12d31 --- /dev/null +++ b/src/open_inwoner/userfeed/tests/test_feed.py @@ -0,0 +1,64 @@ +from django.test import TestCase +from django.utils import timezone +from django.utils.html import strip_tags +from django.utils.translation import ugettext as _ + +from freezegun import freeze_time + +from open_inwoner.accounts.tests.factories import UserFactory +from open_inwoner.userfeed.choices import FeedItemType +from open_inwoner.userfeed.feed import ACTION_COMPLETED_HISTORY_RANGE, get_feed +from open_inwoner.userfeed.hooks.common import simple_message +from open_inwoner.userfeed.models import FeedItemData +from open_inwoner.userfeed.tests.factories import FeedItemDataFactory + + +class FeedTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = UserFactory() + + def test_get_feed__and__simple_message(self): + # our record + simple_message(self.user, "Hello", title="Test message", url="http://foo.bar") + data = FeedItemData.objects.get() + + # extra data + FeedItemDataFactory(user=UserFactory()) + + feed = get_feed(self.user) + self.assertEqual(feed.total_items, 1) + self.assertEqual(feed.has_display(), True) + self.assertEqual(len(feed.summary), 1) + + # check item + item = feed.items[0] + self.assertEqual(item.type, FeedItemType.message_simple) + self.assertEqual(item.action_required, False) + self.assertEqual(item.is_completed, False) + self.assertEqual(item.message, "Hello") + self.assertEqual(item.title, "Test message") + self.assertEqual(item.action_url, "http://foo.bar") + + # check summary + summary = feed.summary[0] + expected = _("There is {count} message").format(count=1) + self.assertEqual(strip_tags(summary), expected) + + # forward in time past range + with freeze_time(timezone.now() + ACTION_COMPLETED_HISTORY_RANGE): + # not completed so still visible in the future + feed = get_feed(self.user, with_history=True) + self.assertEqual(feed.total_items, 1) + + # mark as completed + item.mark_completed() + self.assertEqual(item.is_completed, True) + + # not visible, neither now nor in the future + feed = get_feed(self.user) + self.assertEqual(feed.total_items, 0) + + with freeze_time(timezone.now() + ACTION_COMPLETED_HISTORY_RANGE): + feed = get_feed(self.user, with_history=True) + self.assertEqual(feed.total_items, 0) From d36eef4464d521d95002a3e0778a7bfb8dbd56f4 Mon Sep 17 00:00:00 2001 From: Bart van der Schoor Date: Thu, 11 Jan 2024 10:51:19 +0100 Subject: [PATCH 2/4] [#1760] PR feedback: changed hooks import/export structure & patches, fixed type annotations --- src/open_inwoner/cms/cases/views/status.py | 8 +++---- src/open_inwoner/openzaak/notifications.py | 7 +++--- .../openzaak/tests/test_case_detail.py | 4 ++-- .../tests/test_notification_zaak_status.py | 2 +- .../plans/management/commands/plans_expire.py | 5 ++-- .../plans/tests/test_plans_expire.py | 15 ++++++++---- src/open_inwoner/plans/tests/test_views.py | 2 +- src/open_inwoner/plans/views.py | 5 ++-- src/open_inwoner/userfeed/adapters.py | 3 ++- src/open_inwoner/userfeed/apps.py | 16 ++++--------- src/open_inwoner/userfeed/feed.py | 2 +- src/open_inwoner/userfeed/hooks/__init__.py | 12 ++++++++++ .../management/commands/add_feed_message.py | 2 +- src/open_inwoner/userfeed/models.py | 23 +++++++++++-------- 14 files changed, 57 insertions(+), 49 deletions(-) diff --git a/src/open_inwoner/cms/cases/views/status.py b/src/open_inwoner/cms/cases/views/status.py index 66e2acf748..f3965094fd 100644 --- a/src/open_inwoner/cms/cases/views/status.py +++ b/src/open_inwoner/cms/cases/views/status.py @@ -55,8 +55,7 @@ ZaakTypeStatusTypeConfig, ) from open_inwoner.openzaak.utils import get_role_name_display, is_info_object_visible -from open_inwoner.userfeed.hooks.case_document import case_documents_seen -from open_inwoner.userfeed.hooks.case_status import case_status_seen +from open_inwoner.userfeed import hooks from open_inwoner.utils.time import has_new_elements from open_inwoner.utils.translate import TranslationLookup from open_inwoner.utils.views import CommonPageMixin, LogMixin @@ -220,9 +219,8 @@ def get_context_data(self, **kwargs): self.case, self.resulttype_config_mapping ) - # flag case seen in user feed - case_status_seen(self.request.user, self.case) - case_documents_seen(self.request.user, self.case) + hooks.case_status_seen(self.request.user, self.case) + hooks.case_documents_seen(self.request.user, self.case) context["case"] = { "id": str(self.case.uuid), diff --git a/src/open_inwoner/openzaak/notifications.py b/src/open_inwoner/openzaak/notifications.py index f6b4cceeea..dd5255436c 100644 --- a/src/open_inwoner/openzaak/notifications.py +++ b/src/open_inwoner/openzaak/notifications.py @@ -41,11 +41,10 @@ is_info_object_visible, is_zaak_visible, ) +from open_inwoner.userfeed import hooks from open_inwoner.utils.logentry import system_action as log_system_action from open_inwoner.utils.url import build_absolute_url -from ..userfeed.hooks.case_document import case_document_added_notification_received -from ..userfeed.hooks.case_status import case_status_notification_received from .models import ZaakTypeStatusTypeConfig logger = logging.getLogger(__name__) @@ -218,7 +217,7 @@ def handle_zaakinformatieobject_update( user: User, case: Zaak, zaak_info_object: ZaakInformatieObject ): # hook into userfeed - case_document_added_notification_received(user, case, zaak_info_object) + hooks.case_document_added_notification_received(user, case, zaak_info_object) note = UserCaseInfoObjectNotification.objects.record_if_unique_notification( user, @@ -350,7 +349,7 @@ def _handle_status_notification(notification: Notification, case: Zaak, inform_u def handle_status_update(user: User, case: Zaak, status: Status): # hook into userfeed - case_status_notification_received(user, case, status) + hooks.case_status_notification_received(user, case, status) # email notification note = UserCaseStatusNotification.objects.record_if_unique_notification( diff --git a/src/open_inwoner/openzaak/tests/test_case_detail.py b/src/open_inwoner/openzaak/tests/test_case_detail.py index 336052c381..ae056c73df 100644 --- a/src/open_inwoner/openzaak/tests/test_case_detail.py +++ b/src/open_inwoner/openzaak/tests/test_case_detail.py @@ -585,8 +585,8 @@ def _setUpMocks(self, m, use_eindstatus=True): ), ) - @patch("open_inwoner.cms.cases.views.status.case_status_seen") - @patch("open_inwoner.cms.cases.views.status.case_documents_seen") + @patch("open_inwoner.userfeed.hooks.case_status_seen") + @patch("open_inwoner.userfeed.hooks.case_documents_seen") def test_status_is_retrieved_when_user_logged_in_via_digid( self, m, mock_hook_status: Mock, mock_hook_documents: Mock ): diff --git a/src/open_inwoner/openzaak/tests/test_notification_zaak_status.py b/src/open_inwoner/openzaak/tests/test_notification_zaak_status.py index 12e45a031a..89d2d93cd6 100644 --- a/src/open_inwoner/openzaak/tests/test_notification_zaak_status.py +++ b/src/open_inwoner/openzaak/tests/test_notification_zaak_status.py @@ -473,7 +473,7 @@ def test_status_bails_when_skip_informeren_is_set_and_zaaktypeconfig_is_not_foun @override_settings(ZGW_LIMIT_NOTIFICATIONS_FREQUENCY=3600) @freeze_time("2023-01-01 01:00:00") class NotificationHandlerUserMessageTestCase(AssertTimelineLogMixin, TestCase): - @patch("open_inwoner.openzaak.notifications.case_status_notification_received") + @patch("open_inwoner.userfeed.hooks.case_status_notification_received") @patch("open_inwoner.openzaak.notifications.send_case_update_email") def test_handle_status_update(self, mock_send: Mock, mock_feed_hook: Mock): """ diff --git a/src/open_inwoner/plans/management/commands/plans_expire.py b/src/open_inwoner/plans/management/commands/plans_expire.py index 1b2eb62eea..b268a897cb 100644 --- a/src/open_inwoner/plans/management/commands/plans_expire.py +++ b/src/open_inwoner/plans/management/commands/plans_expire.py @@ -10,7 +10,7 @@ from open_inwoner.accounts.models import User from open_inwoner.plans.models import Plan -from open_inwoner.userfeed.hooks.plan import plan_expiring +from open_inwoner.userfeed import hooks from open_inwoner.utils.url import build_absolute_url logger = logging.getLogger(__name__) @@ -49,9 +49,8 @@ def handle(self, *args, **options): f"The email was sent to the user {receiver} about {plans.count()} expiring plans" ) - # hook into userfeed for p in plans: - plan_expiring(receiver, p) + hooks.plan_expiring(receiver, p) def send_email(self, receiver: User, plans: List[Plan]): plan_list_link = build_absolute_url(reverse("collaborate:plan_list")) diff --git a/src/open_inwoner/plans/tests/test_plans_expire.py b/src/open_inwoner/plans/tests/test_plans_expire.py index ea5ef9cd00..4ee02cd9e2 100644 --- a/src/open_inwoner/plans/tests/test_plans_expire.py +++ b/src/open_inwoner/plans/tests/test_plans_expire.py @@ -32,6 +32,15 @@ def test_notify_about_expiring_plan(self): self.assertIn(plan.goal, html_body) self.assertIn(reverse("collaborate:plan_list"), html_body) + @patch("open_inwoner.userfeed.hooks.plan_expiring") + def test_notify_about_expiring_plan_userfeed_hook(self, mock_plan_expiring: Mock): + user = UserFactory() + plan = PlanFactory(end_date=date.today(), created_by=user) + + call_command("plans_expire") + + mock_plan_expiring.assert_called_once() + def test_no_notification_about_expiring_plan_when_disabled(self): user = UserFactory(plans_notifications=False) plan = PlanFactory(end_date=date.today(), created_by=user) @@ -56,8 +65,7 @@ def test_notify_about_expiring_plan_inactive_user(self): call_command("plans_expire") self.assertEqual(len(mail.outbox), 0) - @patch("open_inwoner.plans.management.commands.plans_expire.plan_expiring") - def test_notify_about_expiring_plan(self, mock_plan_expiring: Mock): + def test_notify_about_expiring_plan_email_contact(self): user = UserFactory() contact = UserFactory() plan = PlanFactory(end_date=date.today(), created_by=user) @@ -89,9 +97,6 @@ def test_notify_about_expiring_plan(self, mock_plan_expiring: Mock): self.assertIn(plan.goal, html_body2) self.assertIn(reverse("collaborate:plan_list"), html_body2) - # check userfeed hook was called - self.assertEqual(mock_plan_expiring.call_count, 2) - def test_notify_only_user_with_active_notifications(self): user = UserFactory() contact = UserFactory(plans_notifications=False) diff --git a/src/open_inwoner/plans/tests/test_views.py b/src/open_inwoner/plans/tests/test_views.py index edf316232f..bb05b08f95 100644 --- a/src/open_inwoner/plans/tests/test_views.py +++ b/src/open_inwoner/plans/tests/test_views.py @@ -148,7 +148,7 @@ def test_plan_detail_contacts(self): self.assertContains(response, self.contact.get_full_name()) self.assertNotContains(response, new_contact.get_full_name()) - @patch("open_inwoner.plans.views.plan_completed") + @patch("open_inwoner.userfeed.hooks.plan_completed") def test_plan_detail_userfeed_hook(self, mock_plan_completed: Mock): self.plan.end_date = date.today() self.plan.save() diff --git a/src/open_inwoner/plans/views.py b/src/open_inwoner/plans/views.py index 45da0f8f38..7751efa45e 100644 --- a/src/open_inwoner/plans/views.py +++ b/src/open_inwoner/plans/views.py @@ -24,11 +24,11 @@ ActionUpdateView, BaseActionFilter, ) +from open_inwoner.userfeed import hooks from open_inwoner.utils.logentry import get_change_message from open_inwoner.utils.mixins import ExportMixin from open_inwoner.utils.views import CommonPageMixin, LogMixin -from ..userfeed.hooks.plan import plan_completed from .forms import PlanForm, PlanGoalForm, PlanListFilterForm from .models import Plan @@ -225,9 +225,8 @@ def get_context_data(self, **kwargs): ) context["actions"] = self.get_actions(actions) - # hook into userfeed if obj.end_date < date.today(): - plan_completed(self.request.user, obj) + hooks.plan_completed(self.request.user, obj) return context diff --git a/src/open_inwoner/userfeed/adapters.py b/src/open_inwoner/userfeed/adapters.py index 22acb20608..8b7166b754 100644 --- a/src/open_inwoner/userfeed/adapters.py +++ b/src/open_inwoner/userfeed/adapters.py @@ -1,4 +1,5 @@ from open_inwoner.userfeed.adapter import FeedItem +from open_inwoner.userfeed.choices import FeedItemType def get_item_adapter_class(type: str) -> type[FeedItem]: @@ -9,7 +10,7 @@ def get_item_adapter_class(type: str) -> type[FeedItem]: feed_adapter_map = dict() -def register_item_adapter(adapter_class: type[FeedItem], feed_type: str): +def register_item_adapter(adapter_class: type[FeedItem], feed_type: FeedItemType): # NOTE this function could be upgraded to work as class decorator if feed_type in feed_adapter_map and adapter_class != feed_adapter_map[feed_type]: diff --git a/src/open_inwoner/userfeed/apps.py b/src/open_inwoner/userfeed/apps.py index 384cf25942..e68053282f 100644 --- a/src/open_inwoner/userfeed/apps.py +++ b/src/open_inwoner/userfeed/apps.py @@ -11,19 +11,11 @@ class UserFeedConfig(AppConfig): verbose_name = "User Feed" def ready(self): - auto_import_hooks() + auto_import_adapters() -def auto_import_hooks(): +def auto_import_adapters(): """ - import files from hooks directory - - this expects things calling their register_xyz() function at module level + import files from hooks directory to register adapters """ - - hooks_dir = Path(__file__).parent / "hooks" - for f in os.listdir(hooks_dir): - name, ext = os.path.splitext(f) - if ext == ".py" and name != "__init__": - path = f"open_inwoner.userfeed.hooks.{name}" - import_module(path) + import open_inwoner.userfeed.hooks # noqa diff --git a/src/open_inwoner/userfeed/feed.py b/src/open_inwoner/userfeed/feed.py index 4570adb154..b84d2ace87 100644 --- a/src/open_inwoner/userfeed/feed.py +++ b/src/open_inwoner/userfeed/feed.py @@ -24,7 +24,7 @@ class Feed: items: list[FeedItem] = dataclasses.field(default_factory=list) summary: list[str] = dataclasses.field(default_factory=list) - def has_display(self) -> int: + def has_display(self) -> bool: return self.total_items > 0 def action_required(self) -> int: diff --git a/src/open_inwoner/userfeed/hooks/__init__.py b/src/open_inwoner/userfeed/hooks/__init__.py index e69de29bb2..5ffb0d9fb4 100644 --- a/src/open_inwoner/userfeed/hooks/__init__.py +++ b/src/open_inwoner/userfeed/hooks/__init__.py @@ -0,0 +1,12 @@ +from .case_document import ( + CaseDocumentAddedFeedItem, + case_document_added_notification_received, + case_documents_seen, +) +from .case_status import ( + CaseStatusUpdateFeedItem, + case_status_notification_received, + case_status_seen, +) +from .common import simple_message +from .plan import PlanExpiresFeedItem, plan_completed, plan_expiring diff --git a/src/open_inwoner/userfeed/management/commands/add_feed_message.py b/src/open_inwoner/userfeed/management/commands/add_feed_message.py index 41b3f10218..b0ca04edb1 100644 --- a/src/open_inwoner/userfeed/management/commands/add_feed_message.py +++ b/src/open_inwoner/userfeed/management/commands/add_feed_message.py @@ -33,7 +33,7 @@ def handle(self, *args, **options): try: user = User.objects.get(pk=options["user"]) except User.DoesNotExist: - self.stdout.write("user_id not found, use one off:") + self.stdout.write("user_id not found, use one of:") for user in User.objects.filter(is_active=True, is_staff=True).order_by( "id" ): diff --git a/src/open_inwoner/userfeed/models.py b/src/open_inwoner/userfeed/models.py index f4d7bf1df5..74af825711 100644 --- a/src/open_inwoner/userfeed/models.py +++ b/src/open_inwoner/userfeed/models.py @@ -1,4 +1,5 @@ -from typing import Optional +from typing import Optional, Union +from uuid import UUID from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType @@ -17,10 +18,10 @@ class FeedItemManager(models.Manager): def mark_uuid_completed( self, user: User, - type: str, - ref_uuid, + type: FeedItemType, + ref_uuid: Union[str, UUID], ref_key: Optional[str] = None, - force=False, + force: bool = False, ): qs = self.filter( user=user, @@ -34,10 +35,10 @@ def mark_uuid_completed( def mark_object_completed( self, user: User, - type: str, - ref_object, + type: FeedItemType, + ref_object: models.Model, ref_key: Optional[str] = None, - force=False, + force: bool = False, ): qs = self.filter( user=user, @@ -61,7 +62,9 @@ def mark_completed(self, force: bool = False): completed_at=timezone.now(), ) - def filter_ref_object(self, ref_object): + def filter_ref_object( + self, ref_object: models.Model + ) -> models.QuerySet["FeedItemData"]: return self.filter( ref_object_id=ref_object.id, ref_object_type=ContentType.objects.get_for_model(ref_object), @@ -84,7 +87,7 @@ class FeedItemData(models.Model): ) @property - def is_completed(self): + def is_completed(self) -> bool: return self.completed_at is not None auto_expire_at = models.DateTimeField( @@ -124,7 +127,7 @@ def is_completed(self): ref_object_id = models.PositiveIntegerField(blank=True, null=True) @cached_property - def ref_object(self): + def ref_object(self) -> Optional[models.Model]: # don't raise but return None if self.ref_object_type_id and self.ref_object_id: return self.ref_object_field From 7c91b42c887040f72afe9dd789f44171c357e0ca Mon Sep 17 00:00:00 2001 From: Bart van der Schoor Date: Thu, 11 Jan 2024 11:14:04 +0100 Subject: [PATCH 3/4] [#1760] PR feedback: fixed plugin to not show when empty or not logged-in, fixed html of case_status_changed message --- .../templates/cms/plugins/userfeed/userfeed.html | 3 ++- src/open_inwoner/userfeed/hooks/case_status.py | 7 ++++++- .../userfeed/tests/hooks/test_case_status.py | 15 ++++++++++----- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/open_inwoner/templates/cms/plugins/userfeed/userfeed.html b/src/open_inwoner/templates/cms/plugins/userfeed/userfeed.html index c78606cf01..0bd37c8cfe 100644 --- a/src/open_inwoner/templates/cms/plugins/userfeed/userfeed.html +++ b/src/open_inwoner/templates/cms/plugins/userfeed/userfeed.html @@ -1,6 +1,6 @@ {% load i18n icon_tags %} - +{% if userfeed %}

{% trans "Aanvragen updates" %} @@ -44,3 +44,4 @@

{# #}

+{% endif %} diff --git a/src/open_inwoner/userfeed/hooks/case_status.py b/src/open_inwoner/userfeed/hooks/case_status.py index 4625d2d6db..68861941e8 100644 --- a/src/open_inwoner/userfeed/hooks/case_status.py +++ b/src/open_inwoner/userfeed/hooks/case_status.py @@ -3,6 +3,7 @@ from django.db.models import Q from django.urls import reverse from django.utils import timezone +from django.utils.html import escape, format_html from django.utils.translation import ugettext_lazy as _ from open_inwoner.accounts.models import User @@ -68,7 +69,11 @@ def message(self) -> str: status_text = ( translate_single_status(status_text) or status_text or _("onbekend") ) - return self.base_message.format(status=status_text) + html = escape(self.base_message) + status = format_html('{}', status_text) + html = format_html(html, status=status) + + return html @property def action_url(self) -> str: diff --git a/src/open_inwoner/userfeed/tests/hooks/test_case_status.py b/src/open_inwoner/userfeed/tests/hooks/test_case_status.py index a727f89695..2b0228ef17 100644 --- a/src/open_inwoner/userfeed/tests/hooks/test_case_status.py +++ b/src/open_inwoner/userfeed/tests/hooks/test_case_status.py @@ -2,7 +2,7 @@ from django.test import TestCase, override_settings from django.urls import reverse -from django.utils.html import strip_tags +from django.utils.html import escape, strip_tags from django.utils.translation import ugettext as _ from zgw_consumers.api_models.base import factory @@ -43,7 +43,10 @@ def test_status_update(self, mock_get_active_app_names: Mock): self.assertEqual(item.type, FeedItemType.case_status_changed) self.assertEqual(item.action_required, False) self.assertEqual(item.is_completed, False) - self.assertEqual(item.message, _("Case status has been changed to 'initial'")) + self.assertEqual( + strip_tags(item.message), + escape(_("Case status has been changed to 'initial'")), + ) self.assertEqual(item.title, case.omschrijving) self.assertEqual( item.action_url, @@ -83,9 +86,11 @@ def test_status_update(self, mock_get_active_app_names: Mock): # check item changed item = feed.items[0] self.assertEqual( - item.message, - _("Case status has been changed to '{status}'").format( - status="translated status" + strip_tags(item.message), + escape( + _("Case status has been changed to '{status}'").format( + status="translated status" + ) ), ) self.assertEqual(item.title, case.omschrijving) From a2c9d695462e4b5e392b51e2034869c87dc15830 Mon Sep 17 00:00:00 2001 From: Bart van der Schoor Date: Tue, 16 Jan 2024 10:08:17 +0100 Subject: [PATCH 4/4] [#1760] Fixed notification handler filtering too early on emailable users --- src/open_inwoner/openzaak/notifications.py | 34 ++++--- .../openzaak/tests/test_notification_data.py | 2 +- .../openzaak/tests/test_notification_utils.py | 51 +++++------ .../test_notification_zaak_infoobject.py | 88 ++++++++++++++----- .../tests/test_notification_zaak_status.py | 70 +++++++++++---- 5 files changed, 160 insertions(+), 85 deletions(-) diff --git a/src/open_inwoner/openzaak/notifications.py b/src/open_inwoner/openzaak/notifications.py index dd5255436c..ae2e85345c 100644 --- a/src/open_inwoner/openzaak/notifications.py +++ b/src/open_inwoner/openzaak/notifications.py @@ -94,10 +94,10 @@ def handle_zaken_notification(notification: Notification): ) return - inform_users = get_emailable_initiator_users_from_roles(roles) + inform_users = get_initiator_users_from_roles(roles) if not inform_users: log_system_action( - f"ignored {r} notification: no users with bsn, valid email or with enabled notifications as (mede)initiators in case {case_url}", + f"ignored {r} notification: no users with bsn/nnp_id as (mede)initiators in case {case_url}", log_level=logging.INFO, ) return @@ -219,6 +219,13 @@ def handle_zaakinformatieobject_update( # hook into userfeed hooks.case_document_added_notification_received(user, case, zaak_info_object) + if not user.cases_notifications or not user.get_contact_email(): + log_system_action( + f"ignored user-disabled notification delivery for user '{user}' zaakinformatieobject {zaak_info_object.url} case {case.url}", + log_level=logging.INFO, + ) + return + note = UserCaseInfoObjectNotification.objects.record_if_unique_notification( user, case.uuid, @@ -351,6 +358,13 @@ def handle_status_update(user: User, case: Zaak, status: Status): # hook into userfeed hooks.case_status_notification_received(user, case, status) + if not user.cases_notifications or not user.get_contact_email(): + log_system_action( + f"ignored user-disabled notification delivery for user '{user}' status {status.url} case {case.url}", + log_level=logging.INFO, + ) + return + # email notification note = UserCaseStatusNotification.objects.record_if_unique_notification( user, @@ -451,19 +465,15 @@ def get_nnp_initiator_nnp_id_from_roles(roles: List[Rol]) -> List[str]: return list(ret) -def get_emailable_initiator_users_from_roles(roles: List[Rol]) -> List[User]: +def get_initiator_users_from_roles(roles: List[Rol]) -> List[User]: """ - iterate over Rollen and return User objects for all natural-person initiators we can notify + iterate over Rollen and return User objects for initiators """ users = [] bsn_list = get_np_initiator_bsns_from_roles(roles) if bsn_list: - users += list( - User.objects.filter( - bsn__in=bsn_list, is_active=True, cases_notifications=True - ).having_usable_email() - ) + users += list(User.objects.filter(bsn__in=bsn_list, is_active=True)) nnp_id_list = get_nnp_initiator_nnp_id_from_roles(roles) if nnp_id_list: @@ -472,10 +482,6 @@ def get_emailable_initiator_users_from_roles(roles: List[Rol]) -> List[User]: id_filter = {"rsin__in": nnp_id_list} else: id_filter = {"kvk__in": nnp_id_list} - users += list( - User.objects.filter( - is_active=True, cases_notifications=True, **id_filter - ).having_usable_email() - ) + users += list(User.objects.filter(is_active=True, **id_filter)) return users diff --git a/src/open_inwoner/openzaak/tests/test_notification_data.py b/src/open_inwoner/openzaak/tests/test_notification_data.py index 994eff571e..31d6c8e23a 100644 --- a/src/open_inwoner/openzaak/tests/test_notification_data.py +++ b/src/open_inwoner/openzaak/tests/test_notification_data.py @@ -159,7 +159,7 @@ def __init__(self): self.zaak_informatie_object_extra = generate_oas_component( "zrc", "schemas/ZaakInformatieObject", - url=f"{ZAKEN_ROOT}zaakinformatieobjecten/aaaaaaaa-0002-aaaa-aaaa-aaaaaaaaaaaa", + url=f"{ZAKEN_ROOT}zaakinformatieobjecten/aaaaaaaa-0003-aaaa-aaaa-aaaaaaaaaaaa", informatieobject=self.informatie_object_extra["url"], zaak=self.zaak["url"], ) diff --git a/src/open_inwoner/openzaak/tests/test_notification_utils.py b/src/open_inwoner/openzaak/tests/test_notification_utils.py index f628f32642..66ef357a28 100644 --- a/src/open_inwoner/openzaak/tests/test_notification_utils.py +++ b/src/open_inwoner/openzaak/tests/test_notification_utils.py @@ -12,7 +12,7 @@ from open_inwoner.accounts.tests.factories import DigidUserFactory, UserFactory from open_inwoner.configurations.models import SiteConfiguration from open_inwoner.openzaak.notifications import ( - get_emailable_initiator_users_from_roles, + get_initiator_users_from_roles, get_np_initiator_bsns_from_roles, send_case_update_email, ) @@ -58,6 +58,7 @@ def test_send_case_update_email(self): self.assertIn(case_url, body_html) self.assertIn(config.name, body_html) + # TODO we're missing a similar test for get_nnp_initiator_nnp_id_from_roles() def test_get_np_initiator_bsns_from_roles(self): # roles we're interested in find_rol_1 = generate_rol( @@ -108,35 +109,39 @@ def test_get_np_initiator_bsns_from_roles(self): actual = get_np_initiator_bsns_from_roles(roles) self.assertEqual(set(actual), expected) - def test_get_emailable_initiator_users_from_roles(self): + def test_get_initiator_users_from_roles(self): # users we're interested in - user_1 = DigidUserFactory(bsn="100000001", email="user_1@example.com") - user_2 = DigidUserFactory(bsn="100000002", email="user_2@example.com") + user_1 = DigidUserFactory( + bsn="100000001", first_name="user_1", email="user_1@example.com" + ) + user_2 = DigidUserFactory( + bsn="100000002", first_name="user_2", email="user_2@example.com" + ) # not active user_not_active = DigidUserFactory( - bsn="404000003", is_active=False, email="user_not_active@example.com" + bsn="404000003", + is_active=False, + first_name="not_active", + email="user_not_active@example.com", ) - # no email - user_no_email = DigidUserFactory(bsn="404000004", email="") - - # placeholder email - user_placeholder_email = DigidUserFactory( - bsn="404000005", email="user_placeholder_email@example.org" - ) # bad role user_bad_role = DigidUserFactory( - bsn="404000006", email="user_bad_role@example.com" + bsn="404000006", first_name="bad_role", email="user_bad_role@example.com" ) # not part of roles user_not_a_role = DigidUserFactory( - bsn="404000007", email="user_not_a_role@example.com" + bsn="404000007", + first_name="not_a_role", + email="user_not_a_role@example.com", ) # not a digid user - user_no_bsn = UserFactory(bsn="", email="user_no_bsn@example.com") + user_no_bsn = UserFactory( + bsn="", first_name="no_bsn", email="user_no_bsn@example.com" + ) # good roles role_1 = generate_rol( @@ -158,16 +163,6 @@ def test_get_emailable_initiator_users_from_roles(self): {"inpBsn": user_not_active.bsn}, RolOmschrijving.initiator, ), - generate_rol( - RolTypes.natuurlijk_persoon, - {"inpBsn": user_no_email.bsn}, - RolOmschrijving.initiator, - ), - generate_rol( - RolTypes.natuurlijk_persoon, - {"inpBsn": user_placeholder_email.bsn}, - RolOmschrijving.initiator, - ), generate_rol( RolTypes.natuurlijk_persoon, {"inpBsn": user_bad_role.bsn}, @@ -187,13 +182,11 @@ def test_get_emailable_initiator_users_from_roles(self): user_1.bsn, user_2.bsn, user_not_active.bsn, - user_no_email.bsn, - user_placeholder_email.bsn, } self.assertEqual(set(check_roles), expected_roles) - # of all the Users with Roles only these match all conditions to actually get notified + # of all the Users with Roles only these match all conditions expected = {user_1, user_2} - actual = get_emailable_initiator_users_from_roles(roles) + actual = get_initiator_users_from_roles(roles) self.assertEqual(set(actual), expected) diff --git a/src/open_inwoner/openzaak/tests/test_notification_zaak_infoobject.py b/src/open_inwoner/openzaak/tests/test_notification_zaak_infoobject.py index aabc2cbf0a..0bbcf05929 100644 --- a/src/open_inwoner/openzaak/tests/test_notification_zaak_infoobject.py +++ b/src/open_inwoner/openzaak/tests/test_notification_zaak_infoobject.py @@ -160,22 +160,7 @@ def test_zio_bails_when_no_emailable_users_are_found_for_roles( mock_handle.assert_not_called() self.assertTimelineLog( - "ignored zaakinformatieobject notification: no users with bsn, valid email or with enabled notifications as (mede)initiators in case https://", - lookup=Lookups.startswith, - level=logging.INFO, - ) - - def test_zio_bails_when_user_notifications_disabled(self, m, mock_handle: Mock): - data = MockAPIData() - data.user_initiator.cases_notifications = False - data.user_initiator.save() - data.install_mocks(m) - - handle_zaken_notification(data.zio_notification) - - mock_handle.assert_not_called() - self.assertTimelineLog( - "ignored zaakinformatieobject notification: no users with bsn, valid email or with enabled notifications as (mede)initiators in case https://", + "ignored zaakinformatieobject notification: no users with bsn/nnp_id as (mede)initiators in case https://", lookup=Lookups.startswith, level=logging.INFO, ) @@ -344,16 +329,75 @@ def test_zio_bails_when_zaak_type_info_object_type_config_is_found_not_marked_fo @override_settings(ZGW_LIMIT_NOTIFICATIONS_FREQUENCY=3600) @freeze_time("2023-01-01 01:00:00") class NotificationHandlerUserMessageTestCase(AssertTimelineLogMixin, TestCase): - @patch( - "open_inwoner.openzaak.notifications.case_document_added_notification_received" - ) + """ + note these tests match with a similar test from `test_notification_zaak_status.py` + """ + + @patch("open_inwoner.userfeed.hooks.case_document_added_notification_received") + @patch("open_inwoner.openzaak.notifications.send_case_update_email") + def test_handle_zaak_info_object_update_filters_disabled_notifications( + self, mock_send: Mock, mock_feed_hook: Mock + ): + data = MockAPIData() + user = data.user_initiator + user.cases_notifications = False # opt-out + user.save() + + case = factory(Zaak, data.zaak) + case.zaaktype = factory(ZaakType, data.zaak_type) + + zio = factory(ZaakInformatieObject, data.zaak_informatie_object) + zio.informatieobject = factory(InformatieObject, data.informatie_object) + + handle_zaakinformatieobject_update(user, case, zio) + + # not called because disabled notifications + mock_send.assert_not_called() + + # check if userfeed hook was called + mock_feed_hook.assert_called_once() + + self.assertTimelineLog( + "ignored user-disabled notification delivery for user ", + lookup=Lookups.startswith, + level=logging.INFO, + ) + + @patch("open_inwoner.userfeed.hooks.case_document_added_notification_received") + @patch("open_inwoner.openzaak.notifications.send_case_update_email") + def test_handle_zaak_info_object_update_filters_bad_email( + self, mock_send: Mock, mock_feed_hook: Mock + ): + data = MockAPIData() + user = data.user_initiator + user.email = "user@example.org" + user.save() + + case = factory(Zaak, data.zaak) + case.zaaktype = factory(ZaakType, data.zaak_type) + + zio = factory(ZaakInformatieObject, data.zaak_informatie_object) + zio.informatieobject = factory(InformatieObject, data.informatie_object) + + handle_zaakinformatieobject_update(user, case, zio) + + # not called because bad email + mock_send.assert_not_called() + + # check if userfeed hook was called + mock_feed_hook.assert_called_once() + + self.assertTimelineLog( + "ignored user-disabled notification delivery for user ", + lookup=Lookups.startswith, + level=logging.INFO, + ) + + @patch("open_inwoner.userfeed.hooks.case_document_added_notification_received") @patch("open_inwoner.openzaak.notifications.send_case_update_email") def test_handle_zaak_info_object_update( self, mock_send: Mock, mock_feed_hook: Mock ): - """ - note this test matches with a similar test from `test_notification_zaak_status.py` - """ data = MockAPIData() user = data.user_initiator diff --git a/src/open_inwoner/openzaak/tests/test_notification_zaak_status.py b/src/open_inwoner/openzaak/tests/test_notification_zaak_status.py index 89d2d93cd6..3e93f1209b 100644 --- a/src/open_inwoner/openzaak/tests/test_notification_zaak_status.py +++ b/src/open_inwoner/openzaak/tests/test_notification_zaak_status.py @@ -129,22 +129,7 @@ def test_status_bails_when_no_emailable_users_are_found_for_roles( mock_handle.assert_not_called() self.assertTimelineLog( - "ignored status notification: no users with bsn, valid email or with enabled notifications as (mede)initiators in case https://", - lookup=Lookups.startswith, - level=logging.INFO, - ) - - def test_zio_bails_when_user_notifications_disabled(self, m, mock_handle: Mock): - data = MockAPIData() - data.user_initiator.cases_notifications = False - data.user_initiator.save() - data.install_mocks(m) - - handle_zaken_notification(data.zio_notification) - - mock_handle.assert_not_called() - self.assertTimelineLog( - "ignored zaakinformatieobject notification: no users with bsn, valid email or with enabled notifications as (mede)initiators in case https://", + "ignored status notification: no users with bsn/nnp_id as (mede)initiators in case https://", lookup=Lookups.startswith, level=logging.INFO, ) @@ -473,12 +458,59 @@ def test_status_bails_when_skip_informeren_is_set_and_zaaktypeconfig_is_not_foun @override_settings(ZGW_LIMIT_NOTIFICATIONS_FREQUENCY=3600) @freeze_time("2023-01-01 01:00:00") class NotificationHandlerUserMessageTestCase(AssertTimelineLogMixin, TestCase): + """ + note these tests match with a similar test from `test_notification_zaak_infoobject.py` + """ + + @patch("open_inwoner.userfeed.hooks.case_status_notification_received") + @patch("open_inwoner.openzaak.notifications.send_case_update_email") + def test_handle_status_update_filters_disabled_notifications( + self, mock_send: Mock, mock_feed_hook: Mock + ): + data = MockAPIData() + user = data.user_initiator + user.cases_notifications = False # opt-out + user.save() + + case = factory(Zaak, data.zaak) + case.zaaktype = factory(ZaakType, data.zaak_type) + + status = factory(Status, data.status_final) + status.statustype = factory(StatusType, data.status_type_final) + + handle_status_update(user, case, status) + + mock_send.assert_not_called() + + # check if userfeed hook was called + mock_feed_hook.assert_called_once() + + @patch("open_inwoner.userfeed.hooks.case_status_notification_received") + @patch("open_inwoner.openzaak.notifications.send_case_update_email") + def test_handle_status_update_filters_bad_email( + self, mock_send: Mock, mock_feed_hook: Mock + ): + data = MockAPIData() + user = data.user_initiator + user.email = "user@example.org" + user.save() + + case = factory(Zaak, data.zaak) + case.zaaktype = factory(ZaakType, data.zaak_type) + + status = factory(Status, data.status_final) + status.statustype = factory(StatusType, data.status_type_final) + + handle_status_update(user, case, status) + + mock_send.assert_not_called() + + # check if userfeed hook was called + mock_feed_hook.assert_called_once() + @patch("open_inwoner.userfeed.hooks.case_status_notification_received") @patch("open_inwoner.openzaak.notifications.send_case_update_email") def test_handle_status_update(self, mock_send: Mock, mock_feed_hook: Mock): - """ - note this test matches with a similar test from `test_notification_zaak_infoobject.py` - """ data = MockAPIData() user = data.user_initiator