Skip to content

Commit

Permalink
Merge pull request #1483 from maykinmedia/task/2852-openklant2-esuite…
Browse files Browse the repository at this point in the history
…-list

[#2852] Integrate OpenKlant2 service with list view
  • Loading branch information
alextreme authored Nov 8, 2024
2 parents 39f1cf8 + 4faf2f2 commit 35ae098
Show file tree
Hide file tree
Showing 8 changed files with 390 additions and 110 deletions.
34 changes: 23 additions & 11 deletions src/open_inwoner/accounts/views/contactmoments.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
from view_breadcrumbs import BaseBreadcrumbMixin

from open_inwoner.accounts.models import User
from open_inwoner.openklant.constants import KlantenServiceType
from open_inwoner.openklant.models import KlantContactMomentAnswer
from open_inwoner.openklant.services import (
KlantenService,
OpenKlant2Service,
Question,
QuestionValidator,
ZaakWithApiGroup,
Expand Down Expand Up @@ -63,7 +65,7 @@ class VragenService(Protocol):
def list_questions(
self,
fetch_params: FetchParameters,
user: User,
user: User | None = None,
) -> Iterable[Question]: # noqa: E704
...

Expand All @@ -87,9 +89,11 @@ def get_fetch_parameters(
class KlantContactMomentBaseView(
CommonPageMixin, BaseBreadcrumbMixin, KlantContactMomentAccessMixin, TemplateView
):
def get_service(self) -> VragenService:
# TODO: Refactor to support both OpenKlant2 and eSuite services at once
return eSuiteVragenService()
def get_service(self, service_type: str) -> VragenService:
if service_type == KlantenServiceType.ESUITE:
return eSuiteVragenService()
elif service_type == KlantenServiceType.OPENKLANT2:
return OpenKlant2Service()

def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
Expand Down Expand Up @@ -131,13 +135,21 @@ def get_anchors(self) -> list:

def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
service = self.get_service()
questions = service.list_questions(
self.get_fetch_params(service), user=self.request.user
esuite_service = self.get_service(service_type=KlantenServiceType.ESUITE)
ok2_service = self.get_service(service_type=KlantenServiceType.OPENKLANT2)

questions_esuite = esuite_service.list_questions(
fetch_params=self.get_fetch_params(esuite_service),
user=self.request.user,
)
ctx["contactmomenten"] = [
QuestionValidator.validate_python(q) for q in questions
]
questions_ok2 = ok2_service.list_questions(
self.get_fetch_params(ok2_service),
user=self.request.user,
)
all_questions = questions_esuite + questions_ok2
all_questions.sort(key=lambda q: q["registered_date"], reverse=True)
ctx["contactmomenten"] = all_questions

paginator_dict = self.paginate_with_context(ctx["contactmomenten"])
ctx.update(paginator_dict)

Expand All @@ -162,7 +174,7 @@ def get_anchors(self) -> list:

def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
service = self.get_service()
service = self.get_service(service_type=KlantenServiceType.ESUITE)

kcm, zaak = service.retrieve_question(
self.get_fetch_params(service), kwargs["kcm_uuid"], user=self.request.user
Expand Down
7 changes: 7 additions & 0 deletions src/open_inwoner/openklant/constants.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import enum

from django.db import models
from django.utils.translation import gettext_lazy as _

Expand All @@ -17,3 +19,8 @@ def safe_label(cls, value, default=""):
if default:
return default
return str(value).replace("_", " ").title()


class KlantenServiceType(enum.Enum):
ESUITE = "esuite"
OPENKLANT2 = "openklant2"
7 changes: 5 additions & 2 deletions src/open_inwoner/openklant/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import uuid
from dataclasses import dataclass
from typing import Self
from urllib.parse import urljoin
from urllib.parse import urljoin, urlparse, urlunparse

from django.core.exceptions import ImproperlyConfigured
from django.db import models
Expand Down Expand Up @@ -207,7 +207,10 @@ class OpenKlant2Config:

@property
def api_url(self):
return urljoin(self.api_root, self.api_path)
joined = urljoin(self.api_root, self.api_path)
scheme, netloc, path, params, query, fragment = urlparse(joined)
path = path.replace("//", "/")
return urlunparse((scheme, netloc, path, params, query, fragment))

@classmethod
def from_django_settings(cls) -> Self:
Expand Down
112 changes: 85 additions & 27 deletions src/open_inwoner/openklant/services.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import datetime
import logging
import uuid
from datetime import timedelta
from typing import Iterable, Literal, NotRequired, Protocol, Self

from django.conf import settings
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _

import glom
from ape_pie.client import APIClient
from attr import dataclass
from pydantic import TypeAdapter
from pydantic import BaseModel, ConfigDict, TypeAdapter
from requests.exceptions import RequestException
from typing_extensions import TypedDict
from zgw_consumers.api_models.base import factory
Expand All @@ -31,7 +33,7 @@
KlantCreateData,
ObjectContactMoment,
)
from open_inwoner.openklant.constants import Status
from open_inwoner.openklant.constants import KlantenServiceType, Status
from open_inwoner.openklant.models import (
ContactFormSubject,
KlantContactMomentAnswer,
Expand All @@ -49,6 +51,7 @@
from open_inwoner.openzaak.models import ZGWApiGroupConfig
from open_inwoner.utils.api import ClientError, get_json_response
from open_inwoner.utils.logentry import system_action
from open_inwoner.utils.time import instance_is_new
from openklant2.client import OpenKlant2Client
from openklant2.types.resources.digitaal_adres import DigitaalAdres
from openklant2.types.resources.klant_contact import (
Expand Down Expand Up @@ -80,15 +83,17 @@ class ZaakWithApiGroup:

class Question(TypedDict):
identification: str
source_url: str # points to contactmoment or klantcontact
subject: str
registered_date: datetime.datetime
question_text: str
answer_text: str | None
status: str

new_answer_available: bool
case_detail_url: str
channel: str
case_detail_url: str | None = None

api_service: KlantenServiceType
new_answer_available: bool = False


QuestionValidator = TypeAdapter(Question)
Expand Down Expand Up @@ -593,7 +598,7 @@ def _get_question_data(
if isinstance(kcm.contactmoment, str):
raise ValueError("Received unresolved contactmoment")

return {
question_data = {
"answer_text": kcm.contactmoment.antwoord,
"identification": kcm.contactmoment.identificatie,
"question_text": kcm.contactmoment.tekst,
Expand All @@ -607,7 +612,10 @@ def _get_question_data(
"cases:contactmoment_detail", kwargs={"kcm_uuid": kcm.uuid}
),
"channel": kcm.contactmoment.kanaal.title(),
"source_url": kcm.contactmoment.url,
"api_service": KlantenServiceType.ESUITE,
}
return QuestionValidator.validate_python(question_data)

def fetch_klantcontactmomenten(
self,
Expand Down Expand Up @@ -700,6 +708,9 @@ class OpenKlant2Answer:
nummer: str
plaatsgevonden_op: datetime.datetime

# metadata
viewed: bool = False

@classmethod
def from_klantcontact(cls, klantcontact: KlantContact) -> Self:
if klantcontact["inhoud"] is None:
Expand All @@ -716,14 +727,20 @@ def from_klantcontact(cls, klantcontact: KlantContact) -> Self:


@dataclass(frozen=True)
class OpenKlant2Question:
class OpenKlant2Question(BaseModel):
url: str
question: str
question_kcm_uuid: str
onderwerp: str
kanaal: str
taal: str
nummer: str
plaatsgevonden_op: datetime.datetime

answer: OpenKlant2Answer | None = None

model_config = ConfigDict(arbitrary_types_allowed=True)

@classmethod
def from_klantcontact_and_answer(
cls, klantcontact: KlantContact, answer: OpenKlant2Answer | None = None
Expand All @@ -734,11 +751,15 @@ def from_klantcontact_and_answer(
return cls(
question=klantcontact["inhoud"],
question_kcm_uuid=klantcontact["uuid"],
onderwerp=klantcontact["onderwerp"],
kanaal=klantcontact["kanaal"],
taal=klantcontact["taal"],
nummer=klantcontact["nummer"],
plaatsgevonden_op=datetime.datetime.fromisoformat(
klantcontact["plaatsgevondenOp"]
),
answer=answer,
url=klantcontact["url"],
)


Expand Down Expand Up @@ -1144,35 +1165,20 @@ def create_answer(
def klantcontacten_for_partij(
self, partij_uuid: str, *, kanaal: str | None = None
) -> Iterable[KlantContact]:
# There is currently no good way to filter the klantcontacten by a
# Partij (see https://github.com/maykinmedia/open-klant/issues/256). So
# unfortunately, we have to fetch all rows and do the filtering client
# side.
params: ListKlantContactParams = {
"expand": [
"leiddeTotInterneTaken",
"gingOverOnderwerpobjecten",
"hadBetrokkenen",
"hadBetrokkenen.wasPartij",
],
"kanaal": kanaal or self.config.mijn_vragen_kanaal,
}
if kanaal:
params["kanaal"] = kanaal

klantcontacten = self.client.klant_contact.list_iter(params=params)

# There is currently no good way to filter the klantcontacten by a
# Partij (see https://github.com/maykinmedia/open-klant/issues/256). So
# unfortunately, we have to fetch all rows and do the filtering client
# side.
klantcontacten = self.client.klant_contact.list_iter(
params={
"expand": [
"leiddeTotInterneTaken",
"gingOverOnderwerpobjecten",
"hadBetrokkenen",
"hadBetrokkenen.wasPartij",
],
"kanaal": self.config.mijn_vragen_kanaal,
}
)

klantcontacten_for_partij = filter(
lambda row: partij_uuid
in glom.glom(
Expand Down Expand Up @@ -1237,3 +1243,55 @@ def questions_for_partij(self, partij_uuid: str) -> list[OpenKlant2Question]:

question_objs.sort(key=lambda o: o.plaatsgevonden_op)
return question_objs

def list_questions(
self, fetch_params: FetchParameters, user: User
) -> list[Question]:
if bsn := fetch_params.get("user_bsn"):
partij = self.find_persoon_for_bsn(bsn)
elif kvk_or_rsin := fetch_params.get("user_kvk_or_rsin"):
partij = self.find_organisatie_for_kvk(kvk_or_rsin)

questions = self.questions_for_partij(partij_uuid=partij["uuid"])
return self._reformat_questions(questions, user)

# TODO: handle `status` + `new_answer_available`
# `case_detail_url`: will be handled in integration of detail view
# `status`: eSuite has three: "nieuw", "in behandeling", "afgehandeld"
def _reformat_questions(
self,
questions_ok2: list[OpenKlant2Question],
user: User,
) -> list[Question]:
questions = []
for q in questions_ok2:
answer_metadata = KlantContactMomentAnswer.objects.get_or_create(
user=user, contactmoment_url=q.url
)
question = {
"identification": q.nummer,
"source_url": q.url,
"subject": q.onderwerp,
"registered_date": q.plaatsgevonden_op,
"question_text": q.question,
"answer_text": q.answer.answer,
"status": "",
"channel": q.kanaal,
"case_detail_url": "",
"api_service": KlantenServiceType.OPENKLANT2,
"new_answer_available": self._has_new_answer_available(
q, answer=answer_metadata
),
}
questions.append(question)
return [QuestionValidator.validate_python(q) for q in questions]

def _has_new_answer_available(
self, question: Question, answer: KlantContactMomentAnswer
) -> bool:
answer_is_recent = instance_is_new(
question.answer,
"plaatsgevonden_op",
timedelta(days=settings.CONTACTMOMENT_NEW_DAYS),
)
return answer_is_recent and not answer.is_seen
18 changes: 11 additions & 7 deletions src/open_inwoner/openklant/tests/test_esuite_vragen_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import requests_mock

from open_inwoner.accounts.tests.factories import UserFactory
from open_inwoner.openklant.constants import Status
from open_inwoner.openklant.constants import KlantenServiceType, Status
from open_inwoner.openklant.models import ContactFormSubject, OpenKlantConfig
from open_inwoner.openklant.services import eSuiteVragenService
from open_inwoner.openklant.tests.data import MockAPIReadData
Expand Down Expand Up @@ -89,16 +89,18 @@ def test_list_questions_returns_expected_rows(self, m):
self.assertEqual(
questions[0],
{
"identification": expected_contactmoment["identificatie"],
"source_url": expected_contactmoment["url"],
"subject": self.contactformsubject.subject,
"registered_date": datetime.fromisoformat(
expected_contactmoment["registratiedatum"]
),
"channel": expected_contactmoment["kanaal"].title(),
"question_text": expected_contactmoment["tekst"],
"subject": self.contactformsubject.subject,
"answer_text": expected_contactmoment["antwoord"],
"identification": expected_contactmoment["identificatie"],
"status": str(Status.afgehandeld.label),
"channel": expected_contactmoment["kanaal"].title(),
"case_detail_url": detail_url,
"api_service": KlantenServiceType.ESUITE,
"new_answer_available": False,
},
)
Expand Down Expand Up @@ -162,16 +164,18 @@ def test_retrieve_question_returns_expected_result(self, m):
self.assertEqual(
question,
{
"identification": expected_contactmoment["identificatie"],
"source_url": expected_contactmoment["url"],
"subject": self.contactformsubject.subject,
"registered_date": datetime.fromisoformat(
expected_contactmoment["registratiedatum"]
),
"channel": expected_contactmoment["kanaal"].title(),
"question_text": expected_contactmoment["tekst"],
"subject": self.contactformsubject.subject,
"answer_text": expected_contactmoment["antwoord"],
"identification": expected_contactmoment["identificatie"],
"status": str(Status.afgehandeld.label),
"channel": expected_contactmoment["kanaal"].title(),
"case_detail_url": detail_url,
"api_service": KlantenServiceType.ESUITE,
"new_answer_available": False,
},
)
Expand Down
4 changes: 4 additions & 0 deletions src/open_inwoner/openklant/tests/test_openklant2_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,10 +416,14 @@ def test_create_question(self):
self.assertEqual(
question,
OpenKlant2Question(
url=klantcontact["url"],
answer=None,
nummer=klantcontact["nummer"],
question_kcm_uuid=klantcontact["uuid"],
question="A question asked by Alice",
onderwerp="Important questions",
kanaal=self.openklant2_config.mijn_vragen_kanaal,
taal="nld",
plaatsgevonden_op=QUESTION_DATE,
),
)
Expand Down
Loading

0 comments on commit 35ae098

Please sign in to comment.