diff --git a/src/open_inwoner/cms/cases/views/mixins.py b/src/open_inwoner/cms/cases/views/mixins.py index 8b6dd10132..3a4a37daa3 100644 --- a/src/open_inwoner/cms/cases/views/mixins.py +++ b/src/open_inwoner/cms/cases/views/mixins.py @@ -19,7 +19,7 @@ fetch_single_case_type, fetch_single_status_type, ) -from open_inwoner.openzaak.models import OpenZaakConfig +from open_inwoner.openzaak.models import OpenZaakConfig, StatusTranslation from open_inwoner.openzaak.utils import format_zaak_identificatie, is_zaak_visible from open_inwoner.utils.mixins import PaginationMixin from open_inwoner.utils.views import LogMixin @@ -141,6 +141,7 @@ def get_cases(self) -> List[Zaak]: def process_cases(self, cases: List[Zaak]) -> List[dict]: # Prepare data for frontend config = OpenZaakConfig.get_solo() + status_translate = StatusTranslation.objects.get_lookup() updated_cases = [] for case in cases: @@ -150,7 +151,7 @@ def process_cases(self, cases: List[Zaak]) -> List[dict]: "start_date": case.startdatum, "end_date": getattr(case, "einddatum", None), "description": case.zaaktype.omschrijving, - "current_status": glom( + "current_status": status_translate.from_glom( case, "status.statustype.omschrijving", default="" ), } diff --git a/src/open_inwoner/cms/cases/views/status.py b/src/open_inwoner/cms/cases/views/status.py index 0be35c65cf..1dfcbfadd4 100644 --- a/src/open_inwoner/cms/cases/views/status.py +++ b/src/open_inwoner/cms/cases/views/status.py @@ -23,7 +23,7 @@ create_klant, fetch_klant_for_bsn, ) -from open_inwoner.openzaak.api_models import Zaak +from open_inwoner.openzaak.api_models import Status, Zaak from open_inwoner.openzaak.cases import ( connect_case_with_document, fetch_case_information_objects, @@ -41,6 +41,7 @@ ) from open_inwoner.openzaak.models import ( OpenZaakConfig, + StatusTranslation, ZaakTypeConfig, ZaakTypeInformatieObjectTypeConfig, ) @@ -49,6 +50,7 @@ get_role_name_display, is_info_object_visible, ) +from open_inwoner.utils.translate import TranslationLookup from open_inwoner.utils.views import CommonPageMixin, LogMixin from ..forms import CaseContactForm, CaseUploadForm @@ -110,6 +112,7 @@ def get_context_data(self, **kwargs): if self.case: self.log_case_access(self.case.identificatie) config = OpenZaakConfig.get_solo() + status_translate = StatusTranslation.objects.get_lookup() documents = self.get_case_document_files(self.case) @@ -147,12 +150,12 @@ def get_context_data(self, **kwargs): self.case, "uiterlijke_einddatum_afdoening", None ), "description": self.case.zaaktype.omschrijving, - "current_status": glom( + "current_status": status_translate.from_glom( self.case, "status.statustype.omschrijving", default=_("No data available"), ), - "statuses": statuses, + "statuses": self.get_statuses_data(statuses, status_translate), "documents": documents, "allowed_file_extensions": sorted(config.allowed_file_extensions), } @@ -221,6 +224,19 @@ def get_initiator_display(self, case: Zaak) -> str: return "" return ", ".join([get_role_name_display(r) for r in roles]) + def get_statuses_data( + self, statuses: List[Status], lookup: TranslationLookup + ) -> List[dict]: + return [ + { + "date": s.datum_status_gezet, + "label": lookup.from_glom( + s, "statustype.omschrijving", default=_("No data available") + ), + } + for s in statuses + ] + def get_case_document_files(self, case: Zaak) -> List[SimpleFile]: case_info_objects = fetch_case_information_objects(case.url) diff --git a/src/open_inwoner/components/templates/components/status/status_list.html b/src/open_inwoner/components/templates/components/status/status_list.html deleted file mode 100644 index 726ff1c209..0000000000 --- a/src/open_inwoner/components/templates/components/status/status_list.html +++ /dev/null @@ -1,13 +0,0 @@ -{% load icon_tags i18n %} - - diff --git a/src/open_inwoner/components/templatetags/status_tags.py b/src/open_inwoner/components/templatetags/status_tags.py deleted file mode 100644 index d5cf3d9b11..0000000000 --- a/src/open_inwoner/components/templatetags/status_tags.py +++ /dev/null @@ -1,29 +0,0 @@ -from datetime import datetime -from typing import TypedDict - -from django import template - -register = template.Library() - - -class Status(TypedDict): - icon: str - label: str - date: datetime - - -@register.inclusion_tag("components/status/status_list.html") -def status_list(statuses: list[Status], **kwargs) -> dict: - """ - Shows multiple statuses in an (historic) list. - - Usage: - {% status_list statuses %} - - Variables: - + statuses: list[Status] | List of Status objects. - """ - return { - **kwargs, - "statuses": statuses, - } diff --git a/src/open_inwoner/openzaak/admin.py b/src/open_inwoner/openzaak/admin.py index 62a9886797..65e79eec52 100644 --- a/src/open_inwoner/openzaak/admin.py +++ b/src/open_inwoner/openzaak/admin.py @@ -1,19 +1,23 @@ from django.contrib import admin, messages from django.core.exceptions import ValidationError -from django.db.models import BooleanField, Count, Exists, ExpressionWrapper, Q +from django.db.models import BooleanField, Count, ExpressionWrapper, Q from django.forms.models import BaseInlineFormSet from django.utils.translation import gettext_lazy as _, ngettext +from import_export.admin import ImportExportMixin +from import_export.formats import base_formats from solo.admin import SingletonModelAdmin from .models import ( CatalogusConfig, OpenZaakConfig, + StatusTranslation, UserCaseInfoObjectNotification, UserCaseStatusNotification, ZaakTypeConfig, ZaakTypeInformatieObjectTypeConfig, ) +from .resources.import_resource import StatusTranslationImportResource @admin.register(OpenZaakConfig) @@ -324,3 +328,29 @@ class UserCaseInfoObjectNotificationAdmin(admin.ModelAdmin): def has_change_permission(self, request, obj=None): return False + + +@admin.register(StatusTranslation) +class StatusTranslationAdmin(ImportExportMixin, admin.ModelAdmin): + fields = [ + "status", + "translation", + ] + search_fields = [ + "status", + "translation", + ] + list_display = [ + "id", + "status", + "translation", + ] + list_editable = [ + "status", + "translation", + ] + ordering = ("status",) + + # import-export + resource_class = StatusTranslationImportResource + formats = [base_formats.XLSX, base_formats.CSV] diff --git a/src/open_inwoner/openzaak/managers.py b/src/open_inwoner/openzaak/managers.py index c469e7c5f8..edef3098ca 100644 --- a/src/open_inwoner/openzaak/managers.py +++ b/src/open_inwoner/openzaak/managers.py @@ -6,7 +6,8 @@ from django.utils import timezone from open_inwoner.accounts.models import User -from open_inwoner.openzaak.api_models import Zaak, ZaakType +from open_inwoner.openzaak.api_models import ZaakType +from open_inwoner.utils.translate import TranslationLookup if TYPE_CHECKING: from open_inwoner.openzaak.models import ( @@ -162,3 +163,8 @@ def filter_questions_enabled_for_case_type(self, case_type: ZaakType): return self.none() return self.filter_case_type(case_type).filter(questions_enabled=True) + + +class StatusTranslationQuerySet(models.QuerySet): + def get_lookup(self): + return TranslationLookup(self.values_list("status", "translation")) diff --git a/src/open_inwoner/openzaak/migrations/0021_statustranslation.py b/src/open_inwoner/openzaak/migrations/0021_statustranslation.py new file mode 100644 index 0000000000..892e37aff3 --- /dev/null +++ b/src/open_inwoner/openzaak/migrations/0021_statustranslation.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2.20 on 2023-08-25 08:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "openzaak", + "0020_rename_contact_moments_enabled_zaaktypeconfig_contact_form_enabled", + ), + ] + + operations = [ + migrations.CreateModel( + name="StatusTranslation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + max_length=255, unique=True, verbose_name="Status tekst" + ), + ), + ( + "translation", + models.CharField(max_length=255, verbose_name="Vertaling"), + ), + ], + options={ + "verbose_name": "Status vertaling", + "verbose_name_plural": "Status vertalingen", + }, + ), + ] diff --git a/src/open_inwoner/openzaak/migrations/0023_merge_0021_statustranslation_0022_mark_as_is_sent.py b/src/open_inwoner/openzaak/migrations/0023_merge_0021_statustranslation_0022_mark_as_is_sent.py new file mode 100644 index 0000000000..1f4b6c241e --- /dev/null +++ b/src/open_inwoner/openzaak/migrations/0023_merge_0021_statustranslation_0022_mark_as_is_sent.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.20 on 2023-09-04 10:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("openzaak", "0021_statustranslation"), + ("openzaak", "0022_mark_as_is_sent"), + ] + + operations = [] diff --git a/src/open_inwoner/openzaak/models.py b/src/open_inwoner/openzaak/models.py index 2ac7f64576..76add7400f 100644 --- a/src/open_inwoner/openzaak/models.py +++ b/src/open_inwoner/openzaak/models.py @@ -12,6 +12,7 @@ from zgw_consumers.constants import APITypes from open_inwoner.openzaak.managers import ( + StatusTranslationQuerySet, UserCaseInfoObjectNotificationManager, UserCaseStatusNotificationManager, ZaakTypeConfigQueryset, @@ -361,3 +362,21 @@ def has_received_similar_notes_within(self, period: timedelta) -> bool: ) or UserCaseStatusNotification.objects.has_received_similar_notes_within( self.user, self.case_uuid, period ) + + +class StatusTranslation(models.Model): + status = models.CharField( + verbose_name=_("Status tekst"), + max_length=255, + unique=True, + ) + translation = models.CharField( + verbose_name=_("Vertaling"), + max_length=255, + ) + + objects = StatusTranslationQuerySet.as_manager() + + class Meta: + verbose_name = _("Status vertaling") + verbose_name_plural = _("Status vertalingen") diff --git a/src/open_inwoner/openzaak/resources/__init__.py b/src/open_inwoner/openzaak/resources/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/open_inwoner/openzaak/resources/import_resource.py b/src/open_inwoner/openzaak/resources/import_resource.py new file mode 100644 index 0000000000..8dba9908c8 --- /dev/null +++ b/src/open_inwoner/openzaak/resources/import_resource.py @@ -0,0 +1,33 @@ +from django.utils.translation import ugettext_lazy as _ + +from import_export import fields, resources +from import_export.exceptions import ImportExportError + +from open_inwoner.openzaak.models import StatusTranslation + + +class StatusTranslationImportResource(resources.ModelResource): + def before_import(self, dataset, using_transactions, dry_run, **kwargs): + # Validate that file contains all the headers + missing_headers = set(self.get_diff_headers()) - set(dataset.headers) + if missing_headers: + missing_headers = ",\n".join(missing_headers) + raise ImportExportError(_(f"Missing required headers: {missing_headers}")) + + return super().before_import(dataset, using_transactions, dry_run, **kwargs) + + def get_or_init_instance(self, instance_loader, row): + # Replace newlines from excel + for key, value in row.items(): + if isinstance(value, str): + row[key] = value.replace("_x000D_", "\n") + + return super().get_or_init_instance(instance_loader, row) + + status = fields.Field(column_name="status", attribute="status") + translation = fields.Field(column_name="translation", attribute="translation") + + class Meta: + model = StatusTranslation + import_id_fields = ("status",) + fields = ("status", "translation") diff --git a/src/open_inwoner/openzaak/tests/factories.py b/src/open_inwoner/openzaak/tests/factories.py index 69299e619d..afa8c0579f 100644 --- a/src/open_inwoner/openzaak/tests/factories.py +++ b/src/open_inwoner/openzaak/tests/factories.py @@ -12,6 +12,7 @@ from open_inwoner.openzaak.api_models import Notification, Rol, ZaakType from open_inwoner.openzaak.models import ( CatalogusConfig, + StatusTranslation, UserCaseInfoObjectNotification, UserCaseStatusNotification, ZaakTypeConfig, @@ -141,6 +142,14 @@ class Meta: model = Notification +class StatusTranslationFactory(factory.django.DjangoModelFactory): + status = factory.Faker("pystr", max_chars=50) + translation = factory.Faker("pystr", max_chars=80) + + class Meta: + model = StatusTranslation + + def generate_rol( type_: str, identification: dict, diff --git a/src/open_inwoner/openzaak/tests/test_case_detail.py b/src/open_inwoner/openzaak/tests/test_case_detail.py index 17fc917164..274b6317af 100644 --- a/src/open_inwoner/openzaak/tests/test_case_detail.py +++ b/src/open_inwoner/openzaak/tests/test_case_detail.py @@ -24,6 +24,7 @@ from open_inwoner.accounts.tests.factories import UserFactory from open_inwoner.cms.cases.views.status import SimpleFile from open_inwoner.openzaak.tests.factories import ( + StatusTranslationFactory, ZaakTypeConfigFactory, ZaakTypeInformatieObjectTypeConfigFactory, ) @@ -350,6 +351,8 @@ def _setUpMocks(self, m): ) def test_status_is_retrieved_when_user_logged_in_via_digid(self, m): + self.maxDiff = None + self._setUpMocks(m) status_new_obj, status_finish_obj = factory( Status, [self.status_new, self.status_finish] @@ -370,7 +373,16 @@ def test_status_is_retrieved_when_user_logged_in_via_digid(self, m): "end_date_legal": datetime.date(2022, 1, 5), "description": "Coffee zaaktype", "current_status": "Finish", - "statuses": [status_new_obj, status_finish_obj], + "statuses": [ + { + "date": datetime.datetime(2021, 1, 12), + "label": "Initial request", + }, + { + "date": datetime.datetime(2021, 3, 12), + "label": "Finish", + }, + ], # only one visible information object "documents": [self.informatie_object_file], "initiator": "Foo Bar van der Bazz", @@ -407,6 +419,24 @@ def test_page_reformats_zaak_identificatie(self, m): spy_format.assert_called_once() + def test_page_translates_statuses(self, m): + st1 = StatusTranslationFactory( + status=self.status_type_new["omschrijving"], + translation="Translated First Status Type", + ) + st2 = StatusTranslationFactory( + status=self.status_type_finish["omschrijving"], + translation="Translated Second Status Type", + ) + self._setUpMocks(m) + response = self.app.get( + self.case_detail_url, user=self.user, headers={"HX-Request": "true"} + ) + self.assertNotContains(response, st1.status) + self.assertNotContains(response, st2.status) + self.assertContains(response, st1.translation) + self.assertContains(response, st2.translation) + def test_when_accessing_case_detail_a_timelinelog_is_created(self, m): self._setUpMocks(m) diff --git a/src/open_inwoner/openzaak/tests/test_cases.py b/src/open_inwoner/openzaak/tests/test_cases.py index 212e287d25..fa744883f0 100644 --- a/src/open_inwoner/openzaak/tests/test_cases.py +++ b/src/open_inwoner/openzaak/tests/test_cases.py @@ -21,7 +21,7 @@ from ...utils.tests.helpers import AssertRedirectsMixin from ..models import OpenZaakConfig from ..utils import format_zaak_identificatie -from .factories import ServiceFactory +from .factories import ServiceFactory, StatusTranslationFactory from .shared import CATALOGI_ROOT, ZAKEN_ROOT @@ -381,6 +381,18 @@ def test_list_open_cases_reformats_zaak_identificatie(self, m): spy_format.assert_called() self.assertEqual(spy_format.call_count, 2) + def test_list_open_cases_translates_status(self, m): + st1 = StatusTranslationFactory( + status=self.status_type1["omschrijving"], + translation="Translated Status Type", + ) + self._setUpMocks(m) + response = self.app.get( + self.inner_url_open, user=self.user, headers={"HX-Request": "true"} + ) + self.assertNotContains(response, st1.status) + self.assertContains(response, st1.translation) + def test_list_open_cases_logs_displayed_case_ids(self, m): self._setUpMocks(m) @@ -467,6 +479,18 @@ def test_list_closed_cases_reformats_zaak_identificatie(self, m): spy_format.assert_called() self.assertEqual(spy_format.call_count, 1) + def test_list_closed_cases_translates_status(self, m): + st1 = StatusTranslationFactory( + status=self.status_type2["omschrijving"], + translation="Translated Status Type", + ) + self._setUpMocks(m) + response = self.app.get( + self.inner_url_closed, user=self.user, headers={"HX-Request": "true"} + ) + self.assertNotContains(response, st1.status) + self.assertContains(response, st1.translation) + def test_list_closed_cases_logs_displayed_case_ids(self, m): self._setUpMocks(m) diff --git a/src/open_inwoner/openzaak/tests/test_status_translation.py b/src/open_inwoner/openzaak/tests/test_status_translation.py new file mode 100644 index 0000000000..bfd0ca27a8 --- /dev/null +++ b/src/open_inwoner/openzaak/tests/test_status_translation.py @@ -0,0 +1,26 @@ +from django.test import TestCase + +from open_inwoner.openzaak.models import StatusTranslation +from open_inwoner.openzaak.tests.factories import StatusTranslationFactory + + +class StatusTranslationModelTest(TestCase): + def test_lookup(self): + StatusTranslationFactory(status="foo", translation="FOO") + StatusTranslationFactory(status="bar", translation="BAR") + + lookup = StatusTranslation.objects.get_lookup() + + tests = [ + # input, expected + ("foo", "FOO"), + ("bar", "BAR"), + ("bazz", "bazz"), + ("", ""), + ] + for value, expected in tests: + with self.subTest(value=value, expected=expected): + actual = lookup(value) + self.assertEqual(expected, actual) + + # NOTE the TranslationLookup helper is further tested in its own file diff --git a/src/open_inwoner/templates/pages/cases/status_inner.html b/src/open_inwoner/templates/pages/cases/status_inner.html index 8c1b4adec9..27c2cdab26 100644 --- a/src/open_inwoner/templates/pages/cases/status_inner.html +++ b/src/open_inwoner/templates/pages/cases/status_inner.html @@ -1,4 +1,4 @@ -{% load i18n anchor_menu_tags card_tags dashboard_tags file_tags grid_tags status_tags table_tags solo_tags button_tags icon_tags notification_tags %} +{% load i18n anchor_menu_tags card_tags dashboard_tags file_tags grid_tags table_tags solo_tags button_tags icon_tags notification_tags %} {# Anchor menu-mobile #}
@@ -29,7 +29,19 @@

{{ case.description }}

{# Status history. #} {% if case.statuses %}

{% trans 'Status' %}

- {% status_list case.statuses %} + + + {% endif %} {# Documents. #} @@ -49,7 +61,7 @@

{% trans "Document uploaden" %}

{% endblocktranslate %}

{% endif %} - + {# Upload document form. #}
{% include 'pages/cases/document_form.html' %} @@ -65,7 +77,7 @@

{% trans "Document toevoegen" %}

{% button_row %} {% button href=case.external_upload_url text=_("Document uploaden") title=_("Opens new window") primary=True icon="open_in_new" icon_position="after" %} {% endbutton_row %} - {% endif %} + {% endif %} {# Contact moment form #} {% if case.contact_form_enabled %} diff --git a/src/open_inwoner/templates/pages/contactmoment/detail.html b/src/open_inwoner/templates/pages/contactmoment/detail.html index ea0cc5e1df..9055fe43fc 100644 --- a/src/open_inwoner/templates/pages/contactmoment/detail.html +++ b/src/open_inwoner/templates/pages/contactmoment/detail.html @@ -1,5 +1,5 @@ {% extends 'master.html' %} -{% load i18n anchor_menu_tags card_tags dashboard_tags file_tags grid_tags status_tags table_tags solo_tags form_tags button_tags %} +{% load i18n anchor_menu_tags card_tags dashboard_tags file_tags grid_tags table_tags solo_tags form_tags button_tags %} {% block sidebar_content %} {% if contactmoment %} diff --git a/src/open_inwoner/utils/tests/test_translate.py b/src/open_inwoner/utils/tests/test_translate.py new file mode 100644 index 0000000000..ceb5871b3b --- /dev/null +++ b/src/open_inwoner/utils/tests/test_translate.py @@ -0,0 +1,69 @@ +from django.test import TestCase + +from open_inwoner.utils.translate import TranslationLookup + + +class TranslationLookupTest(TestCase): + def test_lookup(self): + values_list = [ + ("foo", "FOO"), + ("bar", "BAR"), + ] + lookup = TranslationLookup(values_list) + + tests = [ + # input, expected + ("foo", "FOO"), + ("bar", "BAR"), + ("bazz", "bazz"), + ("", ""), + ] + for value, expected in tests: + with self.subTest(value=value, expected=expected): + actual = lookup(value) + self.assertEqual(expected, actual) + + # extra + with self.subTest("normal key with default returns key"): + actual = lookup("bazz", default="buzz") + self.assertEqual("bazz", actual) + + with self.subTest("empty key with default return default"): + actual = lookup("", default="buzz") + self.assertEqual("buzz", actual) + + def test_lookup_from_glom(self): + values_list = [ + ("foo", "FOO"), + ("bar", "BAR"), + ] + lookup = TranslationLookup(values_list) + + data = { + "aaa": { + "fff": "foo", + "bbb": "bar", + "zzz": "bazz", + }, + } + + tests = [ + # input, expected + ("aaa.fff", "FOO"), + ("aaa.bbb", "BAR"), + ("aaa.zzz", "bazz"), + ("aaa.xxx", ""), + ("", ""), + ] + for value, expected in tests: + with self.subTest(value=value, expected=expected): + actual = lookup.from_glom(data, value) + self.assertEqual(expected, actual) + + with self.subTest("with default"): + actual = lookup.from_glom(data, "aaa.xxx", default="buzz") + self.assertEqual("buzz", actual) + + with self.subTest("with empty default"): + actual = lookup.from_glom(data, "aaa.xxx", default="") + self.assertEqual("", actual) diff --git a/src/open_inwoner/utils/translate.py b/src/open_inwoner/utils/translate.py new file mode 100644 index 0000000000..5755ac07f5 --- /dev/null +++ b/src/open_inwoner/utils/translate.py @@ -0,0 +1,38 @@ +from typing import Any, Iterable, Optional, Tuple + +from glom import glom + + +class TranslationLookup: + """ + simple key value lookup seeded from queryset.values_list(key, value) + """ + + def __init__(self, key_value_iterable: Iterable[Tuple[str, str]]): + self.mapping = dict(key_value_iterable) + + def __call__(self, key: str, *, default: str = "") -> str: + """ + lookup translation of `key` + """ + # no mapping is found return either default or original string + if not key: + return default + return self.mapping.get(key, key) + + def from_glom(self, obj: Any, path: str, *, default: str = "") -> str: + """ + convenience lookup translation of a value glommed by `path` from object `obj` + + usage: + + str = lookup.from_glom(zaak, "status.statustype.omschrijving", default=_("No data")) + """ + return self( + glom( + obj, + path, + default="", + ), + default=default, + )