diff --git a/src/open_inwoner/apimock/apis/esuite-read/formulieren-api/openstaande-taken.json b/src/open_inwoner/apimock/apis/esuite-read/formulieren-api/openstaande-taken.json new file mode 100644 index 0000000000..72f1cea5c2 --- /dev/null +++ b/src/open_inwoner/apimock/apis/esuite-read/formulieren-api/openstaande-taken.json @@ -0,0 +1,18 @@ +[ + { + "url": "https://maykinmedia.nl", + "uuid": "fb72d8db-c3ee-4aa0-96c1-260b202cb208", + "identificatie": "1234-2023", + "naam": "Aanvullende informatie gewenst", + "startdatum": "2023-11-14", + "formulierLink": "https://maykinmedia.nl" + }, + { + "url": "https://maykinmedia.nl", + "uuid": "d74f6a5c-297d-43a3-a923-1774164d852d", + "identificatie": "4321-2023", + "naam": "Aanvullende informatie gewenst", + "startdatum": "2023-10-11", + "formulierLink": "https://maykinmedia.nl" + } +] diff --git a/src/open_inwoner/apimock/views.py b/src/open_inwoner/apimock/views.py index 88b8c5a0d6..fa94f0a4b7 100644 --- a/src/open_inwoner/apimock/views.py +++ b/src/open_inwoner/apimock/views.py @@ -50,7 +50,7 @@ def get(self, request, *args, **kwargs): with open(file_path, "r") as f: data = json.load(f) process_urls(data, prefix, self.url_replacers) - return JsonResponse(data) + return JsonResponse(data, safe=False) def process_urls(data, prefix, url_replacers): diff --git a/src/open_inwoner/openzaak/api_models.py b/src/open_inwoner/openzaak/api_models.py index f8858a2a26..bf2c46ae51 100644 --- a/src/open_inwoner/openzaak/api_models.py +++ b/src/open_inwoner/openzaak/api_models.py @@ -302,3 +302,13 @@ def process_data(self) -> dict: "eind_datum_geldigheid": self.eind_datum_geldigheid or "Geen", "case_type": "OpenSubmission", } + + +@dataclass +class OpenTask(Model): + url: str + uuid: str + identificatie: str + naam: str + startdatum: date + formulier_link: str diff --git a/src/open_inwoner/openzaak/clients.py b/src/open_inwoner/openzaak/clients.py index 1569d4dd54..2286b1918a 100644 --- a/src/open_inwoner/openzaak/clients.py +++ b/src/open_inwoner/openzaak/clients.py @@ -22,6 +22,7 @@ from .api_models import ( InformatieObjectType, OpenSubmission, + OpenTask, Resultaat, ResultaatType, Rol, @@ -654,6 +655,24 @@ def fetch_open_submissions(self, bsn: str) -> List[OpenSubmission]: return results + def fetch_open_tasks(self, bsn: str) -> List[OpenTask]: + if not bsn: + return [] + + try: + response = self.get( + "openstaande-taken", + params={"bsn": bsn}, + ) + data = get_json_response(response) + except (RequestException, ClientError) as e: + logger.exception("exception while making request", exc_info=e) + return [] + + results = factory(OpenTask, data) + + return results + def build_client(type_) -> Optional[APIClient]: config = OpenZaakConfig.get_solo() diff --git a/src/open_inwoner/openzaak/tests/mocks.py b/src/open_inwoner/openzaak/tests/mocks.py index 0ba9c19007..e29674853f 100644 --- a/src/open_inwoner/openzaak/tests/mocks.py +++ b/src/open_inwoner/openzaak/tests/mocks.py @@ -1,7 +1,7 @@ from open_inwoner.openzaak.tests.shared import FORMS_ROOT -class ESuiteData: +class ESuiteSubmissionData: def __init__(self): self.submission_1 = { "url": "https://dmidoffice2.esuite-development.net/formulieren-provider/api/v1/8e3ae29c-7bc5-4f7d-a27c-b0c83c13328e", @@ -34,3 +34,30 @@ def install_mocks(self, m): json=self.response, ) return self + + +class ESuiteTaskData: + def __init__(self): + self.task1 = { + "url": "https://maykinmedia.nl", + "uuid": "fb72d8db-c3ee-4aa0-96c1-260b202cb208", + "identificatie": "1234-2023", + "naam": "Aanvullende informatie gewenst", + "startdatum": "2023-11-14", + "formulierLink": "https://maykinmedia.nl", + } + self.task2 = { + "url": "https://maykinmedia.nl", + "uuid": "d74f6a5c-297d-43a3-a923-1774164d852d", + "identificatie": "4321-2023", + "naam": "Aanvullende informatie gewenst", + "startdatum": "2023-10-11", + "formulierLink": "https://maykinmedia.nl", + } + + def install_mocks(self, m): + m.get( + f"{FORMS_ROOT}openstaande-taken", + json=[self.task1, self.task2], + ) + return self diff --git a/src/open_inwoner/openzaak/tests/test_cases.py b/src/open_inwoner/openzaak/tests/test_cases.py index 4179e3913a..7e1cf92496 100644 --- a/src/open_inwoner/openzaak/tests/test_cases.py +++ b/src/open_inwoner/openzaak/tests/test_cases.py @@ -37,7 +37,7 @@ ZaakTypeStatusTypeConfigFactory, ) from .helpers import generate_oas_component_cached -from .mocks import ESuiteData +from .mocks import ESuiteSubmissionData from .shared import CATALOGI_ROOT, ZAKEN_ROOT # Avoid redirects through `KvKLoginMiddleware` @@ -868,7 +868,7 @@ def test_case_submission(self, m): login_type=LoginTypeChoices.digid, bsn="900222086", email="john@smith.nl" ) - data = ESuiteData().install_mocks(m) + data = ESuiteSubmissionData().install_mocks(m) response = self.app.get( self.inner_url, user=user, headers={"HX-Request": "true"} diff --git a/src/open_inwoner/userfeed/admin.py b/src/open_inwoner/userfeed/admin.py index c8c7c2c38e..0951d69c88 100644 --- a/src/open_inwoner/userfeed/admin.py +++ b/src/open_inwoner/userfeed/admin.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.contrib import admin from .models import FeedItemData @@ -27,7 +28,10 @@ class FeedItemDataAdmin(admin.ModelAdmin): ] def has_change_permission(self, request, obj=None): + if settings.DEBUG: + return super().has_change_permission(request, obj=obj) return False def has_add_permission(self, request): - pass + if settings.DEBUG: + return super().has_add_permission(request) diff --git a/src/open_inwoner/userfeed/choices.py b/src/open_inwoner/userfeed/choices.py index 6c78a4085c..d4d831eea9 100644 --- a/src/open_inwoner/userfeed/choices.py +++ b/src/open_inwoner/userfeed/choices.py @@ -7,3 +7,4 @@ class FeedItemType(models.TextChoices): 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") + external_task = "external_task", _("External task") diff --git a/src/open_inwoner/userfeed/feed.py b/src/open_inwoner/userfeed/feed.py index b84d2ace87..f1f6b57a79 100644 --- a/src/open_inwoner/userfeed/feed.py +++ b/src/open_inwoner/userfeed/feed.py @@ -13,6 +13,7 @@ get_item_adapter_class, get_types_for_unpublished_cms_apps, ) +from open_inwoner.userfeed.hooks.external_task import update_user_tasks from open_inwoner.userfeed.models import FeedItemData from open_inwoner.userfeed.summarize import SUMMARIES @@ -48,6 +49,8 @@ def get_feed(user: User, with_history: bool = False) -> Feed: # empty feed return Feed() + update_user_tasks(user) + # core filters display_filter = Q(completed_at__isnull=True) if with_history: diff --git a/src/open_inwoner/userfeed/hooks/external_task.py b/src/open_inwoner/userfeed/hooks/external_task.py new file mode 100644 index 0000000000..15df3ea981 --- /dev/null +++ b/src/open_inwoner/userfeed/hooks/external_task.py @@ -0,0 +1,86 @@ +from typing import List + +from django.utils.translation import gettext_lazy as _ + +from open_inwoner.accounts.choices import LoginTypeChoices +from open_inwoner.accounts.models import User +from open_inwoner.openzaak.api_models import OpenTask +from open_inwoner.openzaak.clients import build_client +from open_inwoner.userfeed.adapter import FeedItem +from open_inwoner.userfeed.choices import FeedItemType +from open_inwoner.userfeed.models import FeedItemData + +from ..adapters import register_item_adapter + + +class OpenTaskFeedItem(FeedItem): + base_title = _("Open task") + base_message = _("Open task that is yet to be completed") + + @property + def title(self) -> str: + return f"{self.base_title} ({self.get_data('task_identificatie')})" + + @property + def message(self) -> str: + return self.get_data("task_name", super().message) + + +def update_external_task_items(user: User, openstaande_taken: List[OpenTask]): + """ + Creates items for OpenTasks if they do not exist yet, updates existing items if the + data changed and marks existing items as complete if no OpenTask exists for that + uuid anymore + """ + existing_uuid_mapping = { + str(item.ref_uuid): item + for item in FeedItemData.objects.filter( + type=FeedItemType.external_task, + user=user, + ) + } + existing_uuids = set(existing_uuid_mapping.keys()) + + update_data = [] + create_data = [] + for task in openstaande_taken: + type_data = { + "action_url": task.formulier_link, + "task_name": task.naam, + "task_identificatie": task.identificatie, + } + if existing_item := existing_uuid_mapping.get(task.uuid): + if existing_item.type_data != type_data: + existing_item.type_data = type_data + update_data.append(existing_item) + continue + + create_data.append( + FeedItemData( + user=user, + type=FeedItemType.external_task, + ref_uuid=task.uuid, + action_required=True, + type_data=type_data, + ) + ) + + # TODO we could maybe use `bulk_create` once we upgraded to Django 4.x + FeedItemData.objects.bulk_update(update_data, ["type_data"], batch_size=100) + FeedItemData.objects.bulk_create(create_data) + + # Mark all tasks with UUIDs not occurring in the fetched results as completed + completed_uuids = existing_uuids - set(task.uuid for task in openstaande_taken) + FeedItemData.objects.filter( + type=FeedItemType.external_task, user=user, ref_uuid__in=completed_uuids + ).mark_completed() + + +def update_user_tasks(user: User): + if user.login_type == LoginTypeChoices.digid: + if client := build_client("form"): + tasks = client.fetch_open_tasks(user.bsn) + update_external_task_items(user, tasks) + + +register_item_adapter(OpenTaskFeedItem, FeedItemType.external_task) diff --git a/src/open_inwoner/userfeed/migrations/0002_alter_feeditemdata_type.py b/src/open_inwoner/userfeed/migrations/0002_alter_feeditemdata_type.py new file mode 100644 index 0000000000..fd2f8eaf89 --- /dev/null +++ b/src/open_inwoner/userfeed/migrations/0002_alter_feeditemdata_type.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.24 on 2024-02-27 11:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("userfeed", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="feeditemdata", + name="type", + field=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"), + ("external_task", "External task"), + ], + max_length=64, + verbose_name="Type", + ), + ), + ] diff --git a/src/open_inwoner/userfeed/tests/test_external_tasks.py b/src/open_inwoner/userfeed/tests/test_external_tasks.py new file mode 100644 index 0000000000..217b0dcb79 --- /dev/null +++ b/src/open_inwoner/userfeed/tests/test_external_tasks.py @@ -0,0 +1,159 @@ +from unittest.mock import call, patch + +from django.test import TestCase +from django.utils.translation import gettext as _ + +import requests_mock +from zgw_consumers.test.factories import ServiceFactory + +from open_inwoner.accounts.tests.factories import DigidUserFactory +from open_inwoner.cms.plugins.cms_plugins import UserFeedPlugin +from open_inwoner.cms.tests import cms_tools +from open_inwoner.openzaak.models import OpenZaakConfig +from open_inwoner.openzaak.tests.mocks import ESuiteTaskData +from open_inwoner.openzaak.tests.shared import FORMS_ROOT +from open_inwoner.userfeed.choices import FeedItemType +from open_inwoner.userfeed.hooks.external_task import update_user_tasks +from open_inwoner.userfeed.models import FeedItemData +from open_inwoner.userfeed.tests.factories import FeedItemDataFactory + + +class UserFeedExternalTasksTestCase(TestCase): + def setUp(self): + super().setUp() + + self.user = DigidUserFactory.create(bsn="111111110") + + self.config = OpenZaakConfig.get_solo() + self.config.form_service = ServiceFactory(api_root=FORMS_ROOT) + self.config.save() + + def test_userfeed_plugin_render_triggers_update_open_tasks(self): + FeedItemDataFactory.create( + type=FeedItemType.external_task, + user=self.user, + ref_uuid="f3100eea-bef4-44bb-b55b-8715d23fa77f", + type_data={ + "task_name": "Aanvullende informatie gewenst", + "task_identificatie": "4321-2023", + "action_url": "https://maykinmedia.nl", + }, + ) + + with patch("open_inwoner.userfeed.feed.update_user_tasks") as mock: + html, context = cms_tools.render_plugin( + UserFeedPlugin, plugin_data={}, user=self.user + ) + + # `cms_tools.render_plugin` renders twice + mock.assert_has_calls([call(self.user), call(self.user)]) + + self.assertIn(f"{_('Open task')} (4321-2023)", html) + self.assertIn("Aanvullende informatie gewenst", html) + + @requests_mock.Mocker() + def test_update_user_tasks_create(self, m): + ESuiteTaskData().install_mocks(m) + + with self.subTest("feed item data created on initial login"): + update_user_tasks(self.user) + + items_after_first_login = list(FeedItemData.objects.all()) + + self.assertEqual(len(items_after_first_login), 2) + + item1, item2 = items_after_first_login + + self.assertEqual(item1.user, self.user) + self.assertEqual(item1.action_required, True) + self.assertEqual(item1.type, FeedItemType.external_task) + self.assertEqual( + item1.type_data, + { + "task_name": "Aanvullende informatie gewenst", + "task_identificatie": "1234-2023", + "action_url": "https://maykinmedia.nl", + }, + ) + self.assertEqual( + str(item1.ref_uuid), "fb72d8db-c3ee-4aa0-96c1-260b202cb208" + ) + + self.assertEqual(item2.user, self.user) + self.assertEqual(item2.action_required, True) + self.assertEqual(item2.type, FeedItemType.external_task) + self.assertEqual( + item2.type_data, + { + "task_name": "Aanvullende informatie gewenst", + "task_identificatie": "4321-2023", + "action_url": "https://maykinmedia.nl", + }, + ) + self.assertEqual( + str(item2.ref_uuid), "d74f6a5c-297d-43a3-a923-1774164d852d" + ) + + with self.subTest("import is idempotent"): + update_user_tasks(self.user) + + qs_after_second_login = FeedItemData.objects.all() + + self.assertEqual(set(items_after_first_login), set(qs_after_second_login)) + + @requests_mock.Mocker() + def test_update_user_tasks_complete_items(self, m): + ESuiteTaskData().install_mocks(m) + + old_feed_item = FeedItemDataFactory.create( + type=FeedItemType.external_task, + user=self.user, + ref_uuid="f3100eea-bef4-44bb-b55b-8715d23fa77f", + type_data={ + "task_name": "Aanvullende informatie gewenst", + "task_identificatie": "4321-2023", + "action_url": "https://maykinmedia.nl", + }, + ) + + update_user_tasks(self.user) + + qs_after_first_login = FeedItemData.objects.all() + + self.assertEqual(qs_after_first_login.count(), 3) + + old_feed_item.refresh_from_db() + + self.assertTrue(old_feed_item.is_completed) + + @requests_mock.Mocker() + def test_update_user_tasks_update_type_data(self, m): + ESuiteTaskData().install_mocks(m) + + outdated_feed_item = FeedItemDataFactory.create( + type=FeedItemType.external_task, + user=self.user, + ref_uuid="fb72d8db-c3ee-4aa0-96c1-260b202cb208", + type_data={ + "task_name": "outdated_title", + "task_identificatie": "outdated_id", + "action_url": "https://outdated.url", + }, + ) + + update_user_tasks(self.user) + + qs_after_first_login = FeedItemData.objects.all() + + self.assertEqual(qs_after_first_login.count(), 2) + + outdated_feed_item.refresh_from_db() + + self.assertEqual( + outdated_feed_item.type_data, + { + "task_name": "Aanvullende informatie gewenst", + "task_identificatie": "1234-2023", + "action_url": "https://maykinmedia.nl", + }, + )