diff --git a/requirements/base.in b/requirements/base.in index 0855ed7173..24febabde8 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -94,6 +94,7 @@ git+https://github.com/maykinmedia/django-celery-monitor@513dc28#egg=django_cele zgw-consumers zgw-consumers-oas notifications-api-common +git+https://github.com/maykinmedia/objects-api-client-django.git@f499caf#egg=objects-api-client-django # 2FA SMS verification oath diff --git a/requirements/base.txt b/requirements/base.txt index cb1abc7756..878fae0e28 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -16,6 +16,8 @@ asgiref==3.7.2 # via django asn1crypto==1.5.1 # via webauthn +async-timeout==4.0.3 + # via redis attrs==21.2.0 # via # glom @@ -146,6 +148,7 @@ django==4.2.16 # mozilla-django-oidc # mozilla-django-oidc-db # notifications-api-common + # objects-api-client-django # zgw-consumers # zgw-consumers-oas django-admin-index==3.1.0 @@ -272,6 +275,7 @@ django-solo==2.2.0 # django-open-forms-client # mozilla-django-oidc-db # notifications-api-common + # objects-api-client-django # zgw-consumers django-timeline-logger==3.0.0 # via -r requirements/base.in @@ -340,9 +344,7 @@ faker==27.0.0 fontawesomefree==6.4.2 # via -r requirements/base.in fonttools[woff]==4.43.0 - # via - # fonttools - # weasyprint + # via weasyprint furl==2.1.3 # via # -r requirements/base.in @@ -411,6 +413,8 @@ notifications-api-common==0.2.2 # via -r requirements/base.in oath==1.4.4 # via -r requirements/base.in +objects-api-client-django @ git+https://github.com/maykinmedia/objects-api-client-django.git@f499caf + # via -r requirements/base.in odfpy==1.4.1 # via tablib openpyxl==3.0.9 @@ -508,6 +512,7 @@ requests==2.31.0 # maykin-python3-saml # messagebird # mozilla-django-oidc + # objects-api-client-django # zgw-consumers sentry-sdk==1.38.0 # via -r requirements/base.in @@ -532,9 +537,7 @@ sqlparse==0.4.4 svglib==1.5.1 # via easy-thumbnails tablib[html,ods,xls,xlsx,yaml]==3.1.0 - # via - # django-import-export - # tablib + # via django-import-export tinycss2==1.1.1 # via # -r requirements/base.in @@ -594,6 +597,7 @@ zgw-consumers==0.35.1 # via # -r requirements/base.in # notifications-api-common + # objects-api-client-django zgw-consumers-oas==1.0.0 # via -r requirements/base.in zopfli==0.1.9 diff --git a/requirements/ci.txt b/requirements/ci.txt index 19c6e61162..5ca18f3a8f 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -37,6 +37,11 @@ asn1crypto==1.5.1 # webauthn astroid==2.15.8 # via pylint +async-timeout==4.0.3 + # via + # -c requirements/base.txt + # -r requirements/base.txt + # redis attrs==21.2.0 # via # -c requirements/base.txt @@ -238,6 +243,7 @@ django==4.2.16 # mozilla-django-oidc # mozilla-django-oidc-db # notifications-api-common + # objects-api-client-django # zgw-consumers # zgw-consumers-oas django-admin-index==3.1.0 @@ -460,6 +466,7 @@ django-solo==2.2.0 # django-open-forms-client # mozilla-django-oidc-db # notifications-api-common + # objects-api-client-django # zgw-consumers django-timeline-logger==3.0.0 # via @@ -480,7 +487,6 @@ django-two-factor-auth[phonenumberslite,webauthn]==1.16.0 # via # -c requirements/base.txt # -r requirements/base.txt - # django-two-factor-auth # maykin-2fa django-view-breadcrumbs==2.5.1 # via @@ -553,7 +559,6 @@ easy-thumbnails[svg]==2.8.5 # -r requirements/base.txt # django-filer # djangocms-picture - # easy-thumbnails ecs-logging==2.1.0 # via # -c requirements/base.txt @@ -607,7 +612,6 @@ fonttools[woff]==4.43.0 # via # -c requirements/base.txt # -r requirements/base.txt - # fonttools # weasyprint freezegun==1.1.0 # via -r requirements/test-tools.in @@ -764,6 +768,10 @@ oath==1.4.4 # via # -c requirements/base.txt # -r requirements/base.txt +objects-api-client-django @ git+https://github.com/maykinmedia/objects-api-client-django.git@f499caf + # via + # -c requirements/base.txt + # -r requirements/base.txt odfpy==1.4.1 # via # -c requirements/base.txt @@ -839,7 +847,6 @@ pydantic[email]==2.6.4 # via # -c requirements/base.txt # -r requirements/base.txt - # pydantic pydantic-core==2.16.3 # via # -c requirements/base.txt @@ -973,6 +980,7 @@ requests==2.31.0 # maykin-python3-saml # messagebird # mozilla-django-oidc + # objects-api-client-django # requests-mock # sphinx # zgw-consumers @@ -1050,7 +1058,6 @@ tablib[html,ods,xls,xlsx,yaml]==3.1.0 # -c requirements/base.txt # -r requirements/base.txt # django-import-export - # tablib tblib==1.7.0 # via -r requirements/test-tools.in tinycss2==1.1.1 @@ -1167,6 +1174,7 @@ zgw-consumers==0.35.1 # -c requirements/base.txt # -r requirements/base.txt # notifications-api-common + # objects-api-client-django zgw-consumers-oas==1.0.0 # via # -c requirements/base.txt diff --git a/requirements/dev.txt b/requirements/dev.txt index 3da20986db..b433cb3df2 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -43,6 +43,11 @@ astroid==2.15.8 # -c requirements/ci.txt # -r requirements/ci.txt # pylint +async-timeout==4.0.3 + # via + # -c requirements/ci.txt + # -r requirements/ci.txt + # redis attrs==21.2.0 # via # -c requirements/ci.txt @@ -278,6 +283,7 @@ django==4.2.16 # mozilla-django-oidc # mozilla-django-oidc-db # notifications-api-common + # objects-api-client-django # zgw-consumers # zgw-consumers-oas django-admin-index==3.1.0 @@ -506,6 +512,7 @@ django-solo==2.2.0 # django-open-forms-client # mozilla-django-oidc-db # notifications-api-common + # objects-api-client-django # zgw-consumers django-timeline-logger==3.0.0 # via @@ -526,7 +533,6 @@ django-two-factor-auth[phonenumberslite,webauthn]==1.16.0 # via # -c requirements/ci.txt # -r requirements/ci.txt - # django-two-factor-auth # maykin-2fa django-view-breadcrumbs==2.5.1 # via @@ -603,7 +609,6 @@ easy-thumbnails[svg]==2.8.5 # -r requirements/ci.txt # django-filer # djangocms-picture - # easy-thumbnails ecs-logging==2.1.0 # via # -c requirements/ci.txt @@ -670,7 +675,6 @@ fonttools[woff]==4.43.0 # via # -c requirements/ci.txt # -r requirements/ci.txt - # fonttools # weasyprint freezegun==1.1.0 # via @@ -874,6 +878,10 @@ oath==1.4.4 # via # -c requirements/ci.txt # -r requirements/ci.txt +objects-api-client-django @ git+https://github.com/maykinmedia/objects-api-client-django.git@f499caf + # via + # -c requirements/ci.txt + # -r requirements/ci.txt odfpy==1.4.1 # via # -c requirements/ci.txt @@ -974,7 +982,6 @@ pydantic[email]==2.6.4 # via # -c requirements/ci.txt # -r requirements/ci.txt - # pydantic pydantic-core==2.16.3 # via # -c requirements/ci.txt @@ -1125,6 +1132,7 @@ requests==2.31.0 # maykin-python3-saml # messagebird # mozilla-django-oidc + # objects-api-client-django # requests-mock # sphinx # zgw-consumers @@ -1244,7 +1252,6 @@ tablib[html,ods,xls,xlsx,yaml]==3.1.0 # -c requirements/ci.txt # -r requirements/ci.txt # django-import-export - # tablib tblib==1.7.0 # via # -c requirements/ci.txt @@ -1390,6 +1397,7 @@ zgw-consumers==0.35.1 # -c requirements/ci.txt # -r requirements/ci.txt # notifications-api-common + # objects-api-client-django zgw-consumers-oas==1.0.0 # via # -c requirements/ci.txt diff --git a/src/open_inwoner/berichten/__init__.py b/src/open_inwoner/berichten/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/open_inwoner/berichten/api_models.py b/src/open_inwoner/berichten/api_models.py new file mode 100644 index 0000000000..42f78d11d3 --- /dev/null +++ b/src/open_inwoner/berichten/api_models.py @@ -0,0 +1,50 @@ +# generated by datamodel-codegen: +# filename: bericht.json +# timestamp: 2024-10-07T14:27:37+00:00 + +from __future__ import annotations + +from datetime import date +from typing import List, Optional + +from pydantic import BaseModel, Field, constr +from typing_extensions import Literal + + +class Identificatie(BaseModel): + type: Literal["bsn", "kvk"] = "bsn" + value: Optional[constr(pattern=r"^[0-9]+$")] = None # noqa: F722 + + +class Bericht(BaseModel): + object_uuid: str = Field(..., description="UUID van het onderliggende object") + onderwerp: str = Field(..., description="Onderwerp van het bericht") + bericht_tekst: str = Field( + ..., + description="Tekst van het bericht. Mag URL bevatten en /r/n voor newline. Geen verdere opmaak mogelijk.", + ) + publicatiedatum: date = Field( + ..., + description="Tijdstip van verwerken van het bericht of de PublicatieDatum indien deze is ingevuld", + ) + einddatum_handelingstermijn: Optional[date] = Field( + None, description="Termijn waarbinnen de geadresseerde moet reageren" + ) + referentie: Optional[str] = Field(None, description="TODO") + handelingsperspectief: Optional[ + Literal["betalen", "informatie verstrekken", "informatie ontvangen", "TODO"] + ] = Field(None, description="TODO: Benodigde reactie van de geadresseerde") + geopend: bool = Field( + ..., + description="Het bericht is door de geadresseerde geopend of nog niet geopend", + ) + bericht_type: Literal[ + "notificatie", "betaalverzoek", "uitnodiging", "verzoek", "TODO" + ] = Field(..., description="Type bericht") + identificatie: Identificatie = Field(..., description="TODO") + # TODO: Should be AnyUrl + bijlages: List[str] = Field( + ..., + description="TODO", + examples=[["https://documenten.nl/api/v1/enkelvoudiginformatieobjecten/1"]], + ) diff --git a/src/open_inwoner/berichten/services.py b/src/open_inwoner/berichten/services.py new file mode 100644 index 0000000000..05c85c5392 --- /dev/null +++ b/src/open_inwoner/berichten/services.py @@ -0,0 +1,66 @@ +import datetime +from typing import Literal + +from objectsapiclient.client import Client as ObjectenClient +from objectsapiclient.models import Configuration + +from open_inwoner.berichten.api_models import Bericht + + +class BerichtenService: + + client: ObjectenClient + + def __init__(self, client: ObjectenClient | None = None): + self.client = client or Configuration.get_solo().client + + def fetch_berichten_for_bsn(self, bsn: str): + return self.fetch_berichten_for_identificatie("bsn", bsn) + + def fetch_berichten_for_kvk(self, kvk: str): + return self.fetch_berichten_for_identificatie("kvk", kvk) + + def fetch_berichten_for_identificatie( + self, identificatie_type: Literal["bsn", "kvk"], identificatie_value: str + ): + objects = self.client.get_objects( + object_type_uuid="98b9b5dd-9c2c-44ba-b5bf-13edaed668f9", + data_attrs=[ + f"identificatie__type__exact__{identificatie_type}", + f"identificatie__value__exact__{identificatie_value}", + ], + ) + + return [ + Bericht.model_validate(obj.record["data"] | {"object_uuid": obj.uuid}) + for obj in objects + ] + + def fetch_bericht(self, uuid: str): + obj = self.client.get_object(uuid) + + return Bericht.model_validate(obj.record["data"] | {"object_uuid": obj.uuid}) + + def update_object(self, uuid: str, updated_data: dict): + + # TODO: the PATCH method in the Objects API does not appear to work as + # expected. It validates the partial against the JSON Schema, so in this + # case you have to supply an object that is valid according to the + # schema. We thus have to do our own merging. + + # Also: we are usign the underlying API directly to avoid going back and + # forth between camel and snake case. + existing_obj = self.client.objects_api.retrieve("object", uuid=uuid) + existing_data = existing_obj["record"]["data"] + self.client.objects_api.partial_update( + "object", + { + "record": { + "startAt": datetime.date.today().isoformat(), + "data": existing_data | updated_data, + } + }, + uuid=uuid, + ) + # Refresh the object and build the Bericht model + return self.client.get_object(uuid) diff --git a/src/open_inwoner/berichten/urls.py b/src/open_inwoner/berichten/urls.py new file mode 100644 index 0000000000..27b5d77231 --- /dev/null +++ b/src/open_inwoner/berichten/urls.py @@ -0,0 +1,22 @@ +from django.urls import path + +from open_inwoner.berichten.views.bericht_detail import BerichtDownloadView + +from .views import BerichtDetailView, BerichtListView, MarkBerichtUnreadView + +app_name = "berichten" + +urlpatterns = [ + path("/", BerichtDetailView.as_view(), name="detail"), + path( + "/mark-unread", + MarkBerichtUnreadView.as_view(), + name="mark-bericht-unread", + ), + path( + "/download", + BerichtDownloadView.as_view(), + name="download-bericht-attachment", + ), + path("", BerichtListView.as_view(), name="list"), +] diff --git a/src/open_inwoner/berichten/views/__init__.py b/src/open_inwoner/berichten/views/__init__.py new file mode 100644 index 0000000000..aff99e9005 --- /dev/null +++ b/src/open_inwoner/berichten/views/__init__.py @@ -0,0 +1,13 @@ +from .bericht_detail import ( + BerichtDetailView, + BerichtDownloadView, + MarkBerichtUnreadView, +) +from .bericht_list import BerichtListView + +__all__ = [ + "BerichtDetailView", + "BerichtListView", + "MarkBerichtUnreadView", + "BerichtDownloadView", +] diff --git a/src/open_inwoner/berichten/views/bericht_detail.py b/src/open_inwoner/berichten/views/bericht_detail.py new file mode 100644 index 0000000000..813ba6268b --- /dev/null +++ b/src/open_inwoner/berichten/views/bericht_detail.py @@ -0,0 +1,97 @@ +import logging + +from django.http import Http404, HttpResponseRedirect, StreamingHttpResponse +from django.urls import reverse +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ +from django.views.generic import TemplateView + +from view_breadcrumbs import BaseBreadcrumbMixin + +from open_inwoner.berichten.services import BerichtenService +from open_inwoner.berichten.views.mixins import BerichtAccessMixin +from open_inwoner.openzaak.models import ZGWApiGroupConfig +from open_inwoner.utils.views import CommonPageMixin + +logger = logging.getLogger(__name__) + + +class BerichtDetailView( + CommonPageMixin, + BaseBreadcrumbMixin, + TemplateView, + BerichtAccessMixin, +): + + template_name = "pages/berichten/detail.html" + + @cached_property + def crumbs(self): + return [ + (_("Mijn berichten"), reverse("berichten:list")), + (_("Bericht"), reverse("berichten:detail", kwargs=self.kwargs)), + ] + + def page_title(self): + return _("Mijn berichten") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + service = BerichtenService() + context["bericht"] = self.bericht + if not self.bericht.geopend: + service.update_object(self.kwargs["object_uuid"], {"geopend": True}) + + return context + + +class MarkBerichtUnreadView(BerichtAccessMixin): + def get(self, *args, **kwargs): + service = BerichtenService() + service.update_object(self.kwargs["object_uuid"], {"geopend": False}) + return HttpResponseRedirect(reverse("berichten:list")) + + +class BerichtDownloadView(BerichtAccessMixin): + def get(self, *args, **kwargs): + url = self.request.GET.get("url") + if not url: + logger.error("No URL provided") + raise Http404 + + if url not in self.bericht.bijlages: + logger.error("URL provided that is not a bijlage for the specified bericht") + raise Http404 + + try: + api_group = ZGWApiGroupConfig.objects.resolve_group_from_hints(url=url) + except ZGWApiGroupConfig.DoesNotExist: + logger.exception("Unable to resolve API group from bericht attachment url") + raise Http404 + + documenten_client = api_group.documenten_client + info_object = documenten_client._fetch_single_information_object(url=url) + logger.info(f"{info_object=}") + if not info_object: + logger.error("Could not find info object for bericht attachment") + raise Http404 + + content_stream = api_group.documenten_client.download_document( + info_object.inhoud + ) + logger.info(f"{content_stream=} {content_stream.headers=}") + + if not content_stream: + logger.error("Could not build content stream for bericht attachment") + raise Http404 + + headers = { + "Content-Disposition": f'attachment; filename="{info_object.bestandsnaam}"', + "Content-Type": info_object.formaat, + # TODO: We should be able to use info_object.bestandsomvang here, but we've seen instances + # in the wild where this is incorrectly set, which can trip up the browser. Better to just + # use the content-length given to us by the server. + "Content-Length": content_stream.headers.get("Content-Length"), + } + response = StreamingHttpResponse(content_stream, headers=headers) + return response diff --git a/src/open_inwoner/berichten/views/bericht_list.py b/src/open_inwoner/berichten/views/bericht_list.py new file mode 100644 index 0000000000..c0960cc0cb --- /dev/null +++ b/src/open_inwoner/berichten/views/bericht_list.py @@ -0,0 +1,41 @@ +import logging + +from django.urls import reverse +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ +from django.views.generic import TemplateView + +from view_breadcrumbs import BaseBreadcrumbMixin + +from open_inwoner.berichten.services import BerichtenService +from open_inwoner.berichten.views.mixins import RequireBsnMixin +from open_inwoner.utils.views import CommonPageMixin + +logger = logging.getLogger(__name__) + + +class BerichtListView( + CommonPageMixin, + BaseBreadcrumbMixin, + RequireBsnMixin, + TemplateView, +): + + template_name = "pages/berichten/list.html" + + @cached_property + def crumbs(self): + return [ + (_("Mijn berichten"), reverse("berichten:list")), + ] + + def page_title(self): + return _("Mijn berichten") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + service = BerichtenService() + bsn = self.request.user.bsn if hasattr(self.request.user, "bsn") else None + context["berichten"] = service.fetch_berichten_for_bsn(bsn) if bsn else [] + + return context diff --git a/src/open_inwoner/berichten/views/mixins.py b/src/open_inwoner/berichten/views/mixins.py new file mode 100644 index 0000000000..83c47ef531 --- /dev/null +++ b/src/open_inwoner/berichten/views/mixins.py @@ -0,0 +1,54 @@ +from django.contrib.auth.mixins import AccessMixin +from django.http import HttpRequest +from django.template.response import TemplateResponse +from django.views import View + +from open_inwoner.berichten.api_models import Bericht +from open_inwoner.berichten.services import BerichtenService + + +class RequireBsnMixin(AccessMixin, View): + + request: HttpRequest + bericht: Bericht + + def dispatch(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return self.handle_no_permission() + + if not request.user.bsn: + return self.handle_no_permission() + + return super().dispatch(request, *args, **kwargs) + + def handle_no_permission(self): + if self.request.user.is_authenticated: + return TemplateResponse(self.request, "pages/cases/403.html") + + return super().handle_no_permission() + + +class BerichtAccessMixin(AccessMixin, View): + + request: HttpRequest + bericht: Bericht + + def dispatch(self, request, *args, **kwargs): + if not (bsn := getattr(request.user, "bsn", None)): + return super().handle_no_permission() + + service = BerichtenService() + self.bericht = service.fetch_bericht(self.kwargs["object_uuid"]) + if ( + self.bericht.identificatie.type != "bsn" + or self.bericht.identificatie.value != bsn + ): + return self.handle_no_permission() + + return super().dispatch(request, *args, **kwargs) + + def handle_no_permission(self): + if self.request.user.is_authenticated: + return TemplateResponse(self.request, "pages/cases/403.html") + + return super().handle_no_permission() diff --git a/src/open_inwoner/cms/plugins/cms_plugins/__init__.py b/src/open_inwoner/cms/plugins/cms_plugins/__init__.py index 56da72f1c1..168d30de32 100644 --- a/src/open_inwoner/cms/plugins/cms_plugins/__init__.py +++ b/src/open_inwoner/cms/plugins/cms_plugins/__init__.py @@ -1,9 +1,11 @@ from .appointments import UserAppointmentsPlugin +from .tasks import TasksPlugin from .userfeed import UserFeedPlugin from .videoplayer import VideoPlayerPlugin __all__ = [ "UserAppointmentsPlugin", "UserFeedPlugin", + "TasksPlugin", "VideoPlayerPlugin", ] diff --git a/src/open_inwoner/cms/plugins/cms_plugins/tasks.py b/src/open_inwoner/cms/plugins/cms_plugins/tasks.py new file mode 100644 index 0000000000..0f7f1dcedd --- /dev/null +++ b/src/open_inwoner/cms/plugins/cms_plugins/tasks.py @@ -0,0 +1,31 @@ +from django.utils.translation import gettext as _ + +from cms.plugin_base import CMSPluginBase +from cms.plugin_pool import plugin_pool + +from ..models import TasksConfig + + +@plugin_pool.register_plugin +class TasksPlugin(CMSPluginBase): + """ + Uses the Objects API to retrieve and show tasks according to the MijnTaken Objecttypes schema + Reuses the UserFeedPlugin template + """ + + model = TasksConfig + name = _("Task list Plugin") + render_template = "cms/plugins/tasks/tasks.html" + cache = False + + def render(self, context, instance, placeholder): + request = context["request"] + context["instance"] = instance + context["tasks"] = [] + + if request.user.is_authenticated and (bsn := request.user.bsn): + tasks = instance.get_tasks_by_bsn(bsn) + for task in tasks: + task["task_url"] = task["url"]["uri"] + context["tasks"] = tasks + return context diff --git a/src/open_inwoner/cms/plugins/migrations/0006_tasksconfig.py b/src/open_inwoner/cms/plugins/migrations/0006_tasksconfig.py new file mode 100644 index 0000000000..5fe141d3b3 --- /dev/null +++ b/src/open_inwoner/cms/plugins/migrations/0006_tasksconfig.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.16 on 2024-10-06 13:07 + +import django.db.models.deletion +from django.db import migrations, models + +import objectsapiclient.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cms", "0022_auto_20180620_1551"), + ("plugins", "0005_userappointments"), + ] + + operations = [ + migrations.CreateModel( + name="TasksConfig", + fields=[ + ( + "cmsplugin_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + related_name="%(app_label)s_%(class)s", + serialize=False, + to="cms.cmsplugin", + ), + ), + ( + "title", + models.CharField( + default="Mijn Taken", + help_text="The title of the tasks block", + max_length=250, + verbose_name="Title", + ), + ), + ( + "object_type", + objectsapiclient.models.ObjectTypeField( + db_index=False, max_length=100 + ), + ), + ], + 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 963a7fdf69..a61326a769 100644 --- a/src/open_inwoner/cms/plugins/models/__init__.py +++ b/src/open_inwoner/cms/plugins/models/__init__.py @@ -1,9 +1,11 @@ from .appointments import UserAppointments +from .tasks import TasksConfig from .userfeed import UserFeed from .videoplayer import VideoPlayer __all__ = [ "UserAppointments", "UserFeed", + "TasksConfig", "VideoPlayer", ] diff --git a/src/open_inwoner/cms/plugins/models/tasks.py b/src/open_inwoner/cms/plugins/models/tasks.py new file mode 100644 index 0000000000..4b883b65e4 --- /dev/null +++ b/src/open_inwoner/cms/plugins/models/tasks.py @@ -0,0 +1,41 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from cms.models import CMSPlugin +from objectsapiclient.models import Configuration, ObjectTypeField + + +class TasksConfig(CMSPlugin): + title = models.CharField( + _("Title"), + max_length=250, + help_text=_("The title of the tasks block"), + default=_("Mijn Taken"), + ) + object_type = ObjectTypeField() # Stores the UUID of the selected object_type + + def __str__(self): + return self.title or super().__str__() + + def get_tasks(self): + # TODO: note that this filters client-side, better would be to filter this + # in the Objects API GET-request using data_attr + return [ + obj.record["data"] + for obj in Configuration.get_solo().client.get_objects( + object_type_uuid=self.object_type + ) + ] + + def get_tasks_by_bsn(self, bsn, status="open"): + tasks = [] + for task in self.get_tasks(): + identificatie = task["identificatie"] + if not identificatie or identificatie["type"] != "bsn": + continue + task_bsn = identificatie["value"] + if task_bsn and task_bsn == bsn: + if status and task["status"] != status: + continue + tasks += [task] + return tasks diff --git a/src/open_inwoner/components/templates/components/Header/Header.html b/src/open_inwoner/components/templates/components/Header/Header.html index b161ffdfef..078225ca1d 100644 --- a/src/open_inwoner/components/templates/components/Header/Header.html +++ b/src/open_inwoner/components/templates/components/Header/Header.html @@ -75,6 +75,11 @@ {{ rendered_cms_menu__home }} +
  • + {% url 'berichten:list' as berichten_href %} + {% link text='Mijn berichten' href=berichten_href icon="move_to_inbox" icon_position="before" icon_outlined=True %} +
  • + {% if has_general_faq_questions %}
  • {% link text=_('FAQ') href='general_faq' icon="help_outline" icon_position="before" icon_outlined=True %} diff --git a/src/open_inwoner/components/templates/components/Header/NavigationAuthenticated.html b/src/open_inwoner/components/templates/components/Header/NavigationAuthenticated.html index 65ee62c734..4fa6567064 100644 --- a/src/open_inwoner/components/templates/components/Header/NavigationAuthenticated.html +++ b/src/open_inwoner/components/templates/components/Header/NavigationAuthenticated.html @@ -17,13 +17,20 @@
      {{ rendered_cms_menu__home }} + + +
    • + {% url 'berichten:list' as berichten_href %} + {% link text='Mijn berichten' href=berichten_href icon="move_to_inbox" icon_position="before" icon_outlined=True %} +
    • + {% if has_general_faq_questions %}
    • {% link text=_('FAQ') href='general_faq' icon="help_outline" icon_position="before" icon_outlined=True %}
    • {% endif %} -
    • +
    • {% trans "Logout" as logout %} {% link text=logout href=request.user.get_logout_url icon="logout" icon_position="before" primary=True %}
    • diff --git a/src/open_inwoner/conf/base.py b/src/open_inwoner/conf/base.py index 50c455841c..4a50390ba1 100644 --- a/src/open_inwoner/conf/base.py +++ b/src/open_inwoner/conf/base.py @@ -204,6 +204,7 @@ "django_setup_configuration", "django_yubin", "notifications", + "objectsapiclient", # Project applications. "open_inwoner.components", "open_inwoner.kvk", @@ -235,6 +236,7 @@ "open_inwoner.cms.footer", "open_inwoner.cms.plugins", "open_inwoner.cms.benefits", + "open_inwoner.berichten", "djchoices", "django_celery_beat", "django_celery_monitor", @@ -579,6 +581,7 @@ "ProductLocationPlugin", "UserFeedPlugin", "UserAppointmentsPlugin", + "TasksPlugin", ], "text_only_plugins": ["LinkPlugin"], "name": _("Content"), diff --git a/src/open_inwoner/conf/fixtures/django-admin-index.json b/src/open_inwoner/conf/fixtures/django-admin-index.json index 3baa74d121..f223924cba 100644 --- a/src/open_inwoner/conf/fixtures/django-admin-index.json +++ b/src/open_inwoner/conf/fixtures/django-admin-index.json @@ -336,6 +336,10 @@ "openzaak", "zaaktypeinformatieobjecttypeconfig" ], + [ + "objectsapiclient", + "configuration" + ], [ "qmatic", "qmaticconfig" diff --git a/src/open_inwoner/js/components/berichten/index.js b/src/open_inwoner/js/components/berichten/index.js new file mode 100644 index 0000000000..87cd6c24fe --- /dev/null +++ b/src/open_inwoner/js/components/berichten/index.js @@ -0,0 +1,23 @@ +const berichtDataJsonElement = document.getElementById('bericht-data-json') + +if (berichtDataJsonElement) { + const berichtData = JSON.parse(berichtDataJsonElement.textContent) + const einddatum = new Date(berichtData.einddatumHandelingstermijn) + const today = new Date() + + // Calculate the remaining days + const remainingTime = einddatum - today // in milliseconds + const remainingDays = Math.ceil(remainingTime / (1000 * 60 * 60 * 24)) // convert to days + + // Display the result + const remainingDaysElement = document.getElementById('remainingDays') + if (remainingDaysElement) { + if (remainingDays > 0) { + remainingDaysElement.textContent = `Er zijn nog ${remainingDays} dagen tot de einddatum.` + } else if (remainingDays === 0) { + remainingDaysElement.textContent = 'Vandaag is de einddatum.' + } else { + remainingDaysElement.textContent = 'De einddatum is verstreken.' + } + } else console.error('Unable to find #remainingDays') +} diff --git a/src/open_inwoner/js/components/index.js b/src/open_inwoner/js/components/index.js index 59f73af38f..4ba09be0bb 100644 --- a/src/open_inwoner/js/components/index.js +++ b/src/open_inwoner/js/components/index.js @@ -8,6 +8,7 @@ import { CreateGumshoe } from './anchor-menu/anchor-menu' import './autocomplete-search' import './autocomplete' import './autosumbit' +import './berichten' import './cases' import { DisableContactFormButton } from './form/DisableContactFormButton' import { DisableSubmitButton } from './cases/document_upload' diff --git a/src/open_inwoner/scss/components/Berichten/BerichtDetail.scss b/src/open_inwoner/scss/components/Berichten/BerichtDetail.scss new file mode 100644 index 0000000000..eaa2a30639 --- /dev/null +++ b/src/open_inwoner/scss/components/Berichten/BerichtDetail.scss @@ -0,0 +1,61 @@ +.berichtdetail { + display: grid; +} + +.bericht { + margin-bottom: var(--spacing-extra-large); + + &__dashboard { + .table__item--notification-danger { + margin-left: var(--spacing-medium); + padding: var(--spacing-small) var(--spacing-medium); + } + + .handelingstermijn { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: flex-end; + align-content: baseline; + justify-items: baseline; + justify-content: space-between; + } + } + + &__heading { + margin: 0 0 var(--spacing-large) 0; + line-height: var(--font-line-height-heading-3); + font-family: var(--font-family-heading); + font-size: var(--font-size-heading-3); + font-weight: var(--font-weight-heading-3); + } + + &__text { + margin: 0 0 var(--row-height) 0; + line-height: var(--font-line-height-body); + font-family: var(--font-family-body); + font-size: var(--font-size-body); + font-weight: normal; + color: var(--font-color-body); + } + + &__actions { + font-weight: bold; + } + + .utrecht-heading-2 { + margin-top: var(--spacing-large); + } + + .button--transparent { + padding: var(--spacing-medium) 0 0 0; + } + + &__bijlagen { + .file__name { + // Reverse ellipsis: only show last part of URL + direction: rtl; + text-align: left; + } + } +} diff --git a/src/open_inwoner/scss/components/Berichten/BerichtenList.scss b/src/open_inwoner/scss/components/Berichten/BerichtenList.scss new file mode 100644 index 0000000000..621e40d63c --- /dev/null +++ b/src/open_inwoner/scss/components/Berichten/BerichtenList.scss @@ -0,0 +1,72 @@ +.berichtenlist { + display: grid; + + .link { + .table__item { + &--notification-danger { + padding: var(--spacing-small) var(--spacing-medium); + } + + &--notification-success { + background-color: var(--color-success-lighter); + border-radius: var(--border-radius-large); + color: var(--color-success); + font-size: var(--font-size-body); + padding: var(--spacing-small) var(--spacing-medium); + text-align: center; + } + } + &.nohover { + width: 100%; + justify-content: space-between; + + &:hover { + text-decoration: none; + } + + .button { + padding-right: 0; + } + } + } + + &__table { + display: grid; + + .table { + border-collapse: separate; + } + + &-row:hover th, + &-row:hover td { + background-color: var(--color-gray-lightest); + } + + &-item { + &--wide { + min-width: 100px; + + @media (min-width: 768px) { + min-width: 250px; + } + } + + &--flex { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + } + } + + .table__heading .table__item { + color: var(--color-black); + font-weight: bold; + } + } + + &--unread { + color: var(--color-body); + font-weight: bold; + } +} diff --git a/src/open_inwoner/scss/components/_index.scss b/src/open_inwoner/scss/components/_index.scss index aaf940dec5..15176c4563 100644 --- a/src/open_inwoner/scss/components/_index.scss +++ b/src/open_inwoner/scss/components/_index.scss @@ -70,6 +70,8 @@ @import './MessageFile/MessageFile.scss'; @import './Messages/Message.scss'; @import './Messages/Messages.scss'; +@import './Berichten/BerichtDetail.scss'; +@import './Berichten/BerichtenList.scss'; @import './Notification/Notification.scss'; @import './Notification/Notifications.scss'; @import './Pagination/Pagination.scss'; diff --git a/src/open_inwoner/templates/cms/plugins/tasks/tasks.html b/src/open_inwoner/templates/cms/plugins/tasks/tasks.html new file mode 100644 index 0000000000..223605d1b3 --- /dev/null +++ b/src/open_inwoner/templates/cms/plugins/tasks/tasks.html @@ -0,0 +1,32 @@ +{% load i18n icon_tags button_tags %} + +{% if instance and tasks %} +
      +
      +
      +

      + {{ instance.title }} +

      +
      +
      + + +
      +{% endif %} diff --git a/src/open_inwoner/templates/pages/berichten/detail.html b/src/open_inwoner/templates/pages/berichten/detail.html new file mode 100644 index 0000000000..7ac9bdc809 --- /dev/null +++ b/src/open_inwoner/templates/pages/berichten/detail.html @@ -0,0 +1,116 @@ +{% extends 'master.html' %} +{% load i18n grid_tags button_tags icon_tags %} + +{% block content %} + {# Serialize the data as JSON so the Javascript snippet to compute days remaining can pick it up #} + + + {% if bericht %} + {% render_grid %} + {% render_column span=12 %} +

      {{ bericht.onderwerp }}

      + + {% endrender_column %} + {% endrender_grid %} + + {% render_grid %} + {% render_column start=4 span=6 %} + +
      +
      +

      {{ bericht.onderwerp }}

      +

      {{ bericht.bericht_tekst|linebreaks }}

      +
      + + + {% if destination %} + + {% endif %} +
      + +
      + + {% if bericht.bijlages %} +
      +
      +

      Bijlagen

      +
      +
      +
        + {% for attachment in bericht.bijlages %} +
      • + +
      • + {% endfor %} +
      +
      +
      + {% else %} +

      {% trans 'Er zijn geen bijlagen bij dit bericht.' %}

      + {% endif %} + + {% endrender_column %} + {% endrender_grid %} + + {% else %} +

      {% trans 'Dit bericht is momenteel niet beschikbaar.' %}

      + {% endif %} +{% endblock content %} diff --git a/src/open_inwoner/templates/pages/berichten/list.html b/src/open_inwoner/templates/pages/berichten/list.html new file mode 100644 index 0000000000..d5075f0f5a --- /dev/null +++ b/src/open_inwoner/templates/pages/berichten/list.html @@ -0,0 +1,71 @@ +{% extends 'master.html' %} +{% load i18n button_tags card_tags utils icon_tags grid_tags %} + +{% block content %} + +
      +

      + Mijn berichten +

      +

      Welkom in uw berichtencentrum. Hier vindt u een overzicht van uw notificaties.

      + + {% render_grid %} + {% render_column start=0 span=10 %} +
      + + + + + + + + + + + {% if not berichten %} + {% trans "U heeft op dit moment nog geen berichten." %} + {% endif %} + + {% for bericht in berichten %} + + + + + + + {% endfor %} + + +
      OnderwerpEinddatumStatusBericht type
      + + {{ bericht.onderwerp }} + + + + {{ bericht.publicatiedatum }} + + {% if bericht.geopend %} +
      + Geopend +
      + {% else %} +
      + Niet geopend +
      + {% endif %} +
      +
      + + {{ bericht.bericht_type }} + + + {% icon "arrow_forward" %} + + +
      +
      + {% endrender_column %} + {% endrender_grid %} + +
      +{% endblock %} diff --git a/src/open_inwoner/urls.py b/src/open_inwoner/urls.py index 0f7c034905..f9d1ff9076 100644 --- a/src/open_inwoner/urls.py +++ b/src/open_inwoner/urls.py @@ -130,6 +130,8 @@ path("kvk/", include("open_inwoner.kvk.urls")), # TODO move search to products cms app? path("", include("open_inwoner.search.urls", namespace="search")), + # Hackathon! Put me someplace beter + path("hackathon/berichten/", include("open_inwoner.berichten.urls")), re_path(r"^", include("cms.urls")), ]