Skip to content

Commit

Permalink
Merge pull request #1322 from maykinmedia/tasks/2639-zgw-api-group-re…
Browse files Browse the repository at this point in the history
…solver

[#2639] Add logic to resolve a ZGWApiGroupConfig from contextual hints
  • Loading branch information
swrichards authored Jul 25, 2024
2 parents 6874482 + 1091ce6 commit f5137ee
Show file tree
Hide file tree
Showing 2 changed files with 297 additions and 0 deletions.
120 changes: 120 additions & 0 deletions src/open_inwoner/openzaak/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import logging
import warnings
from datetime import timedelta
from typing import Protocol
from urllib.parse import urlparse

from django.db import models, transaction
Expand All @@ -12,6 +14,7 @@
from solo.models import SingletonModel
from zgw_consumers.api_models.constants import VertrouwelijkheidsAanduidingen
from zgw_consumers.constants import APITypes
from zgw_consumers.models import Service

from open_inwoner.openzaak.managers import (
UserCaseInfoObjectNotificationManager,
Expand All @@ -23,6 +26,8 @@

from .constants import StatusIndicators

logger = logging.getLogger(__name__)


def generate_default_file_extensions():
return sorted(
Expand All @@ -48,9 +53,124 @@ def generate_default_file_extensions():
)


class _ZgwClient(Protocol): # Typing helper to avoid circular imports from .clients
configured_from: Service


class ZGWApiGroupConfigQuerySet(models.QuerySet):
def resolve_group_from_hints(
self,
*,
service: Service | None = None,
client: _ZgwClient | None = None,
url: str | None = None,
):
"""Resolve the group based on the provided hints.
This method will raise if the hints resolve to none or more than 1
ZGWApiGroupConfig instances.
"""
qs = self.all()
strategies_with_multiple_results = []

# Priority matters here: the strategies are tried in descending order
# of certainty, beginning with the simplest case. If there is only
# group, there is nothing to resolve.
if qs.count() == 1:
logger.debug("Resolved ZGWApiGroupConfig to only option")
return qs.first()

if service:
service_strategy_qs = qs.filter_by_service(service=service)
if (service_strategy_qs_count := service_strategy_qs.count()) == 1:
logger.debug("Resolved ZGWApiGroupConfig based on service")
return service_strategy_qs.first()

if service_strategy_qs_count > 1:
strategies_with_multiple_results.append("service")

if client:
zgw_client_strategy_qs = qs.filter_by_zgw_client(client)
if (zgw_client_strategy_qs_count := zgw_client_strategy_qs.count()) == 1:
logger.debug("Resolved ZGWApiGroupConfig based on zgw client")
return zgw_client_strategy_qs.first()

if zgw_client_strategy_qs_count > 1:
strategies_with_multiple_results.append("client")

if url:
url_strategy_qs = qs.filter_by_url_root_overlap(url)
if (url_strategy_qs_count := url_strategy_qs.count()) == 1:
logger.debug("Resolved ZGWApiGroupConfig by url")
return url_strategy_qs.first()

if url_strategy_qs_count > 1:
strategies_with_multiple_results.append("url")

if strategies_with_multiple_results:
# This shouldn't happen in the wild, but it's hard to predict without production
# usage, so this is solely to ensure we get a Sentry ping.
# Also: https://www.xkcd.com/2200/
logger.error(
"Strategies for resolving ZGWApiGroupConfig yielded multiple results for "
"strategies: %s",
strategies_with_multiple_results,
)
raise ZGWApiGroupConfig.MultipleObjectsReturned

raise ZGWApiGroupConfig.DoesNotExist

def filter_by_service(self, service: Service):
return self.filter(
models.Q(zrc_service=service)
| models.Q(ztc_service=service)
| models.Q(drc_service=service)
| models.Q(form_service=service)
)

def filter_by_zgw_client(self, client: _ZgwClient):
from .clients import CatalogiClient, DocumentenClient, FormClient, ZakenClient

match client:
case ZakenClient():
return self.filter(zrc_service=client.configured_from)
case CatalogiClient():
return self.filter(ztc_service=client.configured_from)
case DocumentenClient():
return self.filter(drc_service=client.configured_from)
case FormClient():
return self.filter(form_service=client.configured_from)
case _:
raise ValueError(
f"Client is of type {type(client)} but expected to be one of: "
"ZakenClient, DocumentenClient, FormClient, CatalogiClient"
)

def filter_by_url_root_overlap(self, url: str):
filtered_group_ids = set()
for group in self.all():
for field in ("form_service", "zrc_service", "drc_service", "ztc_service"):
service = getattr(group, f"{field}", None)
if not service:
continue

parsed_service_root = urlparse(service.api_root)
parsed_url = urlparse(url)

same_netloc = parsed_service_root.netloc == parsed_url.netloc
same_protocol = parsed_service_root.scheme == parsed_url.scheme

if same_netloc and same_protocol:
filtered_group_ids.add(group.id)

return self.filter(id__in=filtered_group_ids)


class ZGWApiGroupConfig(models.Model):
"""A set of of ZGW service configurations."""

objects = models.Manager.from_queryset(ZGWApiGroupConfigQuerySet)()

open_zaak_config = models.ForeignKey(
"openzaak.OpenZaakConfig", on_delete=models.PROTECT, related_name="api_groups"
)
Expand Down
177 changes: 177 additions & 0 deletions src/open_inwoner/openzaak/tests/test_clients.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from unittest import TestCase as PlainTestCase
from unittest.mock import patch

from django.test import TestCase

Expand All @@ -18,9 +19,16 @@
build_forms_clients,
build_zaken_client,
build_zaken_clients,
build_zgw_client_from_service,
)
from open_inwoner.openzaak.exceptions import MultiZgwClientProxyError
from open_inwoner.openzaak.models import ZGWApiGroupConfig
from open_inwoner.openzaak.tests.factories import ZGWApiGroupConfigFactory
from open_inwoner.openzaak.tests.shared import (
CATALOGI_ROOT,
DOCUMENTEN_ROOT,
ZAKEN_ROOT,
)


class ClientFactoryTestCase(TestCase):
Expand Down Expand Up @@ -66,6 +74,175 @@ def test_originating_service_is_persisted_on_all_clients(self):
self.assertEqual(client.configured_from.api_type, api_type)


class ZGWApiGroupConfigFilterTests(TestCase):
def setUp(self):
self.api_groups = [
ZGWApiGroupConfigFactory(
name="Default API",
ztc_service__api_root=CATALOGI_ROOT,
zrc_service__api_root=ZAKEN_ROOT,
drc_service__api_root=DOCUMENTEN_ROOT,
form_service__api_root="http://some.forms.nl",
),
ZGWApiGroupConfigFactory(name="Second API"),
]

def test_groups_can_be_filtered_by_client(self):
for factory, api_type in (
(build_forms_clients, APITypes.orc),
(build_zaken_clients, APITypes.zrc),
(build_documenten_clients, APITypes.drc),
(build_catalogi_clients, APITypes.ztc),
):
with self.subTest(
f"ZGWApiGroupConfig can be filtered by clients of type {api_type}"
):
clients = factory()
for i, client in enumerate(clients):
value = list(ZGWApiGroupConfig.objects.filter_by_zgw_client(client))
expected = [self.api_groups[i]]
self.assertEqual(value, expected)

def test_filtering_groups_by_client_with_non_client_type_raises(self):
with self.assertRaises(ValueError):
ZGWApiGroupConfig.objects.filter_by_zgw_client("Not a client")

def test_filter_by_service(self):
for api_type, api_group_field in (
(APITypes.orc, "form_service"),
(APITypes.zrc, "zrc_service"),
(APITypes.drc, "drc_service"),
(APITypes.ztc, "ztc_service"),
):
with self.subTest(
f"ZGWApiGroupConfig can be filtered by services of type {api_type}"
):
service = getattr(self.api_groups[0], api_group_field)

self.assertEqual(
list(ZGWApiGroupConfig.objects.filter_by_service(service)),
[self.api_groups[0]],
)

def test_filter_by_root_url_overlap(self):
for root, api_group_field in (
("http://some.forms.nl", "form_service"),
(ZAKEN_ROOT, "zrc_service"),
(DOCUMENTEN_ROOT, "drc_service"),
(ZAKEN_ROOT, "ztc_service"),
):
with self.subTest(
f"ZGWApiGroupConfig can be filtered by URL for {api_group_field}"
):
self.assertEqual(
list(ZGWApiGroupConfig.objects.filter_by_url_root_overlap(root)),
[self.api_groups[0]],
)

def test_resolve_group_from_hints_raises_on_no_args(self):
with self.assertRaises(ZGWApiGroupConfig.DoesNotExist):
ZGWApiGroupConfig.objects.resolve_group_from_hints()

@patch(
"open_inwoner.openzaak.models.ZGWApiGroupConfigQuerySet.filter_by_zgw_client"
)
@patch(
"open_inwoner.openzaak.models.ZGWApiGroupConfigQuerySet.filter_by_url_root_overlap"
)
def test_resolve_group_from_hints_uses_service_as_highest_priority(
self, filter_by_zgw_client_mock, filter_by_url_root_overlap_mock
):
for group in self.api_groups:
for service_field in (
"form_service",
"zrc_service",
"drc_service",
"ztc_service",
):
service = getattr(group, service_field)
client = build_zgw_client_from_service(service)
url = service.api_root
self.assertEqual(
ZGWApiGroupConfig.objects.resolve_group_from_hints(
service=service, client=client, url=url
),
group,
)

filter_by_zgw_client_mock.assert_not_called()
filter_by_url_root_overlap_mock.assert_not_called()

@patch("open_inwoner.openzaak.models.ZGWApiGroupConfigQuerySet.filter_by_service")
@patch(
"open_inwoner.openzaak.models.ZGWApiGroupConfigQuerySet.filter_by_url_root_overlap"
)
def test_resolve_group_from_hints_uses_client_as_middle_priority(
self, filter_by_service_mock, filter_by_url_root_overlap_mock
):
for factory in (
build_forms_clients,
build_zaken_clients,
build_documenten_clients,
build_catalogi_clients,
):
clients = factory()
for i, client in enumerate(clients):
for service_field in (
"form_service",
"zrc_service",
"drc_service",
"ztc_service",
):
service = getattr(self.api_groups[i], service_field)
url = service.api_root
self.assertEqual(
ZGWApiGroupConfig.objects.resolve_group_from_hints(
client=client, url=url
),
self.api_groups[i],
)

filter_by_service_mock.assert_not_called()
filter_by_url_root_overlap_mock.assert_not_called()

@patch("open_inwoner.openzaak.models.ZGWApiGroupConfigQuerySet.filter_by_service")
@patch(
"open_inwoner.openzaak.models.ZGWApiGroupConfigQuerySet.filter_by_zgw_client"
)
def test_resolve_group_from_hints_uses_url_as_lowest_priority(
self, filter_by_service_mock, filter_by_zgw_client_mock
):
for group in self.api_groups:
for service_field in (
"form_service",
"zrc_service",
"drc_service",
"ztc_service",
):
service = getattr(group, service_field)

self.assertEqual(
ZGWApiGroupConfig.objects.resolve_group_from_hints(
url=service.api_root
),
group,
)

filter_by_service_mock.assert_not_called()
filter_by_zgw_client_mock.assert_not_called()

def test_resolving_to_multiple_objects_raises(self):
service_already_used = self.api_groups[0].ztc_service
ZGWApiGroupConfigFactory(
ztc_service=service_already_used,
)

with self.assertRaises(ZGWApiGroupConfig.MultipleObjectsReturned):
ZGWApiGroupConfig.objects.resolve_group_from_hints(
service=service_already_used
)


@requests_mock.Mocker()
class MultiZgwClientProxyTests(PlainTestCase):
class SimpleClient:
Expand Down

0 comments on commit f5137ee

Please sign in to comment.