Skip to content

Commit ee2189c

Browse files
committed
✨ [#2149] Display new answer header for mijn vragen list
task: https://taiga.maykinmedia.nl/project/open-inwoner/task/2149
1 parent 7104017 commit ee2189c

File tree

9 files changed

+209
-51
lines changed

9 files changed

+209
-51
lines changed

src/open_inwoner/accounts/views/contactmoments.py

+62-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import logging
2-
from datetime import datetime
3-
from typing import TypedDict
2+
from datetime import datetime, timedelta
3+
from typing import Optional, TypedDict
4+
from uuid import UUID
45

6+
from django.conf import settings
57
from django.contrib.auth.mixins import AccessMixin
68
from django.http import Http404
79
from django.shortcuts import redirect
@@ -12,22 +14,48 @@
1214

1315
from view_breadcrumbs import BaseBreadcrumbMixin
1416

17+
from open_inwoner.accounts.models import User
1518
from open_inwoner.openklant.api_models import KlantContactMoment
1619
from open_inwoner.openklant.clients import build_client
1720
from open_inwoner.openklant.constants import Status
18-
from open_inwoner.openklant.models import ContactFormSubject
21+
from open_inwoner.openklant.models import ContactFormSubject, KlantContactMomentLocal
1922
from open_inwoner.openklant.wrap import (
2023
fetch_klantcontactmoment,
2124
fetch_klantcontactmomenten,
2225
get_fetch_parameters,
2326
)
2427
from open_inwoner.openzaak.clients import build_client as build_client_openzaak
2528
from open_inwoner.utils.mixins import PaginationMixin
29+
from open_inwoner.utils.time import is_new
2630
from open_inwoner.utils.views import CommonPageMixin
2731

2832
logger = logging.getLogger(__name__)
2933

3034

35+
# TODO rename to local kcm data?
36+
def get_kcm_answer_mapping(
37+
kcms: list[KlantContactMoment], user: User
38+
) -> dict[str, KlantContactMomentLocal]:
39+
existing_kcms = KlantContactMomentLocal.objects.filter(user=user).values_list(
40+
"kcm_url", flat=True
41+
)
42+
to_create = []
43+
for kcm in kcms:
44+
if kcm.url in existing_kcms:
45+
continue
46+
47+
to_create.append(KlantContactMomentLocal(user=user, kcm_url=kcm.url))
48+
49+
KlantContactMomentLocal.objects.bulk_create(to_create)
50+
51+
kcm_answer_mapping = {
52+
kcm_answer.kcm_url: kcm_answer
53+
for kcm_answer in KlantContactMomentLocal.objects.filter(user=user)
54+
}
55+
56+
return kcm_answer_mapping
57+
58+
3159
class KlantContactMomentAccessMixin(AccessMixin):
3260
"""
3361
Shared authorisation check
@@ -69,12 +97,26 @@ class KCMDict(TypedDict):
6997
onderwerp: str
7098
status: str
7199
antwoord: str
100+
new_answer_available: bool
72101

73102

74103
class KlantContactMomentBaseView(
75104
CommonPageMixin, BaseBreadcrumbMixin, KlantContactMomentAccessMixin, TemplateView
76105
):
77-
def get_kcm_data(self, kcm: KlantContactMoment) -> KCMDict:
106+
def get_kcm_data(
107+
self,
108+
kcm: KlantContactMoment,
109+
kcm_answer_mapping: Optional[dict[UUID, KlantContactMomentLocal]] = None,
110+
) -> KCMDict:
111+
_is_new = is_new(
112+
kcm.contactmoment,
113+
"registratiedatum",
114+
timedelta(days=settings.CONTACTMOMENT_NEW_DAYS),
115+
)
116+
if kcm_answer_mapping:
117+
is_seen = getattr(kcm_answer_mapping.get(kcm.url), "is_seen", False)
118+
else:
119+
is_seen = True
78120
data = {
79121
"registered_date": kcm.contactmoment.registratiedatum,
80122
"channel": kcm.contactmoment.kanaal.title(),
@@ -85,6 +127,9 @@ def get_kcm_data(self, kcm: KlantContactMoment) -> KCMDict:
85127
"type": kcm.contactmoment.type,
86128
"status": Status.safe_label(kcm.contactmoment.status, _("Onbekend")),
87129
"antwoord": kcm.contactmoment.antwoord,
130+
"new_answer_available": bool(kcm.contactmoment.antwoord)
131+
and _is_new
132+
and not is_seen,
88133
}
89134

90135
# replace e_suite_subject_code with OIP configured subject, if applicable
@@ -139,7 +184,12 @@ def get_context_data(self, **kwargs):
139184
kcms = fetch_klantcontactmomenten(
140185
**get_fetch_parameters(self.request, use_vestigingsnummer=True)
141186
)
142-
ctx["contactmomenten"] = [self.get_kcm_data(kcm) for kcm in kcms]
187+
ctx["contactmomenten"] = [
188+
self.get_kcm_data(
189+
kcm, kcm_answer_mapping=get_kcm_answer_mapping(kcms, self.request.user)
190+
)
191+
for kcm in kcms
192+
]
143193
paginator_dict = self.paginate_with_context(ctx["contactmomenten"])
144194
ctx.update(paginator_dict)
145195
return ctx
@@ -172,6 +222,13 @@ def get_context_data(self, **kwargs):
172222
if not kcm:
173223
raise Http404()
174224

225+
local_kcm, is_created = KlantContactMomentLocal.objects.get_or_create( # noqa
226+
user=self.request.user, kcm_url=kcm.url
227+
)
228+
if not local_kcm.is_seen:
229+
local_kcm.is_seen = True
230+
local_kcm.save()
231+
175232
if client := build_client("contactmomenten"):
176233
zaken_client = build_client_openzaak("zaak")
177234
ocm = client.retrieve_objectcontactmoment(

src/open_inwoner/conf/base.py

+3
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,9 @@
800800
# recent documents: created/added no longer than n days in the past
801801
DOCUMENT_RECENT_DAYS = config("DOCUMENT_RECENT_DAYS", default=1)
802802

803+
# recent answers to contactmomenten: no longer than n days in the past
804+
CONTACTMOMENT_NEW_DAYS = config("CONTACTMOMENT_NEW_DAYS", default=7)
805+
803806
#
804807
# Maykin 2FA
805808
#

src/open_inwoner/conf/fixtures/django-admin-index.json

+4
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,10 @@
175175
[
176176
"filer",
177177
"folderpermission"
178+
],
179+
[
180+
"openklant",
181+
"klantcontactmomentlocal"
178182
]
179183
]
180184
}

src/open_inwoner/openklant/admin.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from ordered_model.admin import OrderedInlineModelAdminMixin, OrderedTabularInline
66
from solo.admin import SingletonModelAdmin
77

8-
from .models import ContactFormSubject, OpenKlantConfig
8+
from .models import ContactFormSubject, KlantContactMomentLocal, OpenKlantConfig
99

1010

1111
class ContactFormSubjectInlineAdmin(OrderedTabularInline):
@@ -70,3 +70,10 @@ class OpenKlantConfigAdmin(OrderedInlineModelAdminMixin, SingletonModelAdmin):
7070
},
7171
),
7272
]
73+
74+
75+
@admin.register(KlantContactMomentLocal)
76+
class KlantContactMomentLocalAdmin(admin.ModelAdmin):
77+
search_fields = ["user", "kcm_uuid"]
78+
list_filter = ["is_seen"]
79+
list_display = ["user", "kcm_url", "is_seen"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Generated by Django 4.2.10 on 2024-03-04 16:13
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
("openklant", "0008_openklantconfig_use_rsin_for_innnnpid_query_parameter"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="KlantContactMomentLocal",
18+
fields=[
19+
(
20+
"id",
21+
models.AutoField(
22+
auto_created=True,
23+
primary_key=True,
24+
serialize=False,
25+
verbose_name="ID",
26+
),
27+
),
28+
(
29+
"kcm_url",
30+
models.URLField(
31+
max_length=1000,
32+
unique=True,
33+
verbose_name="KlantContactMoment URL",
34+
),
35+
),
36+
(
37+
"is_seen",
38+
models.BooleanField(
39+
default=False,
40+
help_text="Whether or not the user has seen the answer",
41+
verbose_name="Is seen",
42+
),
43+
),
44+
(
45+
"user",
46+
models.ForeignKey(
47+
help_text="This is the user that asked the question to which this is an answer.",
48+
on_delete=django.db.models.deletion.CASCADE,
49+
related_name="contactmoment_answers",
50+
to=settings.AUTH_USER_MODEL,
51+
verbose_name="User",
52+
),
53+
),
54+
],
55+
options={
56+
"verbose_name": "KlantContactMoment",
57+
"verbose_name_plural": "KlantContactMomenten",
58+
},
59+
),
60+
]

src/open_inwoner/openklant/models.py

+25
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,28 @@ class Meta(OrderedModel.Meta):
127127

128128
def __str__(self):
129129
return self.subject
130+
131+
132+
class KlantContactMomentLocal(models.Model):
133+
user = models.ForeignKey(
134+
"accounts.User",
135+
verbose_name=_("User"),
136+
on_delete=models.CASCADE,
137+
related_name="contactmoment_answers",
138+
help_text=_(
139+
"This is the user that asked the question to which this is an answer."
140+
),
141+
)
142+
# TODO should we store the URL?
143+
kcm_url = models.URLField(
144+
verbose_name=_("KlantContactMoment URL"), unique=True, max_length=1000
145+
)
146+
is_seen = models.BooleanField(
147+
verbose_name=_("Is seen"),
148+
help_text=_("Whether or not the user has seen the answer"),
149+
default=False,
150+
)
151+
152+
class Meta:
153+
verbose_name = _("KlantContactMoment")
154+
verbose_name_plural = _("KlantContactMomenten")

src/open_inwoner/scss/components/Card/Card.scss

+43
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,49 @@
1818
position: relative;
1919
text-decoration: none;
2020

21+
.card__header {
22+
display: flex;
23+
background-color: var(--color-info-lighter);
24+
border-top-left-radius: var(--border-radius);
25+
border-top-right-radius: var(--border-radius);
26+
color: var(--color-info-darker);
27+
font-size: var(--font-size-body);
28+
29+
.card__status_indicator_text {
30+
// Fix for optical illusion
31+
padding: 8px var(--spacing-large) 10px 0;
32+
}
33+
34+
[class*='icons'] {
35+
// Fix for optical illusion
36+
margin: 6px 10px 6px 10px;
37+
}
38+
39+
&.success {
40+
display: flex;
41+
background-color: var(--color-success-lighter);
42+
color: var(--color-success);
43+
}
44+
45+
&.info {
46+
display: flex;
47+
background-color: var(--color-info-lighter);
48+
color: var(--color-info-darker);
49+
}
50+
51+
&.warning {
52+
display: flex;
53+
background-color: var(--color-danger-lightest);
54+
color: var(--color-danger-darker);
55+
}
56+
57+
&.failure {
58+
display: flex;
59+
background-color: var(--color-error-lighter);
60+
color: var(--color-error-darker);
61+
}
62+
}
63+
2164
&__product-card {
2265
min-height: 150px;
2366
}

src/open_inwoner/scss/components/Cases/Cases.scss

-45
Original file line numberDiff line numberDiff line change
@@ -41,49 +41,4 @@
4141
&__detail {
4242
box-sizing: border-box;
4343
}
44-
45-
/// cards with statuses
46-
47-
.card__header {
48-
display: flex;
49-
background-color: var(--color-info-lighter);
50-
border-top-left-radius: var(--border-radius);
51-
border-top-right-radius: var(--border-radius);
52-
color: var(--color-info-darker);
53-
font-size: var(--font-size-body);
54-
55-
.card__status_indicator_text {
56-
// Fix for optical illusion
57-
padding: 8px var(--spacing-large) 10px 0;
58-
}
59-
60-
[class*='icons'] {
61-
// Fix for optical illusion
62-
margin: 6px 10px 6px 10px;
63-
}
64-
65-
&.success {
66-
display: flex;
67-
background-color: var(--color-success-lighter);
68-
color: var(--color-success);
69-
}
70-
71-
&.info {
72-
display: flex;
73-
background-color: var(--color-info-lighter);
74-
color: var(--color-info-darker);
75-
}
76-
77-
&.warning {
78-
display: flex;
79-
background-color: var(--color-danger-lightest);
80-
color: var(--color-danger-darker);
81-
}
82-
83-
&.failure {
84-
display: flex;
85-
background-color: var(--color-error-lighter);
86-
color: var(--color-error-darker);
87-
}
88-
}
8944
}

src/open_inwoner/templates/pages/contactmoment/list.html

+4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ <h1 class="utrecht-heading-1" id="contactmomenten">{{ page_title }} ({{ contactm
99
{% for contactmoment in page_obj.object_list %}
1010
{% render_column start=forloop.counter_0|multiply:4 span=4 %}
1111
<div class="card card--compact card--stretch">
12+
{% if contactmoment.new_answer_available %}
13+
{% translate "Nieuw antwoord beschikbaar" as new_answer_text %}
14+
{% include "components/StatusIndicator/StatusIndicator.html" with status_indicator="info" status_indicator_text=new_answer_text %}
15+
{% endif %}
1216
<div class="card__body">
1317
<a href="{{ contactmoment.url }}" class="contactmomenten__link">
1418
{% render_list %}

0 commit comments

Comments
 (0)