Skip to content

Commit 4ee7147

Browse files
authored
Merge pull request #2447 from OpenEnergyPlatform/feature-oekg-api-introduced-violations
Judge a write by what it adds, not by what it inherited
2 parents 9bf7843 + 9fd7bd8 commit 4ee7147

8 files changed

Lines changed: 346 additions & 84 deletions

File tree

‎oekg/api_support.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,36 @@
2121
from rest_framework import status
2222
from rest_framework.response import Response
2323
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
24+
from rest_framework.views import APIView
2425

2526
from oekg.bundles import BUNDLE_CLASS, bundle_iri
2627
from oekg.graph_store import GraphStore
2728

2829
logger = logging.getLogger("oeplatform")
2930

3031

32+
class Refused(Exception):
33+
"""A refusal, raised where it is decided and returned as it stands.
34+
35+
It carries a whole ``Response`` rather than a detail, and `OekgAPIView`
36+
hands that back untouched. Both halves of that are deliberate:
37+
38+
- **Raised, not returned.** A helper that returned either a value or a
39+
refusal would make every call site test which it got, and one forgotten
40+
test is a refusal silently ignored.
41+
- **A Response, not an ``APIException``.** The obvious alternative is to
42+
subclass ``APIException`` and let the framework render it -- but the
43+
framework rewrites every scalar in an error body through
44+
``ErrorDetail``, so ``None`` comes out as the string ``"None"`` and a
45+
count as a string. This API's refusals carry structured data with
46+
nullable fields, so that would corrupt them. Measured, not assumed.
47+
"""
48+
49+
def __init__(self, response: Response):
50+
super().__init__(getattr(response, "status_code", "refused"))
51+
self.response = response
52+
53+
3154
class ScenarioBundleThrottle(AnonRateThrottle):
3255
"""Reads are public, so the public endpoints need a ceiling of their own."""
3356

@@ -38,6 +61,22 @@ class ScenarioBundleUserThrottle(UserRateThrottle):
3861
scope = "oekg_bundles_user"
3962

4063

64+
class OekgAPIView(APIView):
65+
"""The base every OEKG endpoint shares: one ceiling, one way to refuse.
66+
67+
Handling `Refused` here rather than in each view means a new endpoint
68+
cannot forget to, and a refusal decided three calls deep still reaches the
69+
client as the response it was written as.
70+
"""
71+
72+
throttle_classes = [ScenarioBundleThrottle, ScenarioBundleUserThrottle]
73+
74+
def handle_exception(self, exc):
75+
if isinstance(exc, Refused):
76+
return exc.response
77+
return super().handle_exception(exc)
78+
79+
4180
def is_minted_identifier(uid: str) -> bool:
4281
"""Whether ``uid`` could have come from this API.
4382

‎oekg/api_views.py‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,10 @@
4747
from rest_framework import status
4848
from rest_framework.permissions import AllowAny, IsAuthenticated
4949
from rest_framework.response import Response
50-
from rest_framework.views import APIView
5150

5251
from factsheet.models import ScenarioBundleAccessControl
5352
from oekg.api_support import (
54-
ScenarioBundleThrottle,
55-
ScenarioBundleUserThrottle,
53+
OekgAPIView,
5654
is_minted_identifier,
5755
no_such_bundle,
5856
shape_unavailable,
@@ -95,11 +93,10 @@
9593
logger = logging.getLogger("oeplatform")
9694

9795

98-
class ScenarioBundleCollectionAPIView(APIView):
96+
class ScenarioBundleCollectionAPIView(OekgAPIView):
9997
"""`POST` creates a scenario bundle."""
10098

10199
permission_classes = [IsAuthenticated]
102-
throttle_classes = [ScenarioBundleThrottle, ScenarioBundleUserThrottle]
103100

104101
def post(self, request):
105102
serializer = ScenarioBundleCreateSerializer(data=request.data)
@@ -198,11 +195,9 @@ def post(self, request):
198195
return response
199196

200197

201-
class ScenarioBundleAPIView(APIView):
198+
class ScenarioBundleAPIView(OekgAPIView):
202199
"""`GET` returns one scenario bundle, publicly. `PATCH` changes a field."""
203200

204-
throttle_classes = [ScenarioBundleThrottle, ScenarioBundleUserThrottle]
205-
206201
def get_permissions(self):
207202
# The safe methods are named and everything else is closed, rather than
208203
# the other way round: a verb a later slice adds is then authenticated

‎oekg/history_views.py‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,10 @@
2424
from rest_framework.pagination import PageNumberPagination
2525
from rest_framework.permissions import AllowAny
2626
from rest_framework.response import Response
27-
from rest_framework.views import APIView
2827

2928
from factsheet.models import API_ERA, OEKG_Modifications
3029
from oekg.api_support import (
31-
ScenarioBundleThrottle,
32-
ScenarioBundleUserThrottle,
30+
OekgAPIView,
3331
bundle_exists,
3432
no_such_bundle,
3533
store_unavailable,
@@ -48,11 +46,10 @@ class HistoryPagination(PageNumberPagination):
4846
max_page_size = 100
4947

5048

51-
class ScenarioBundleHistoryAPIView(APIView):
49+
class ScenarioBundleHistoryAPIView(OekgAPIView):
5250
"""`GET` returns one bundle's change history."""
5351

5452
permission_classes = [AllowAny]
55-
throttle_classes = [ScenarioBundleThrottle, ScenarioBundleUserThrottle]
5653

5754
def get(self, request, uid):
5855
expand = request.query_params.get("expand")

‎oekg/scenario_views.py‎

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,9 @@
3030
from rest_framework.pagination import PageNumberPagination
3131
from rest_framework.permissions import AllowAny, IsAuthenticated
3232
from rest_framework.response import Response
33-
from rest_framework.views import APIView
3433

3534
from oekg.api_support import (
36-
ScenarioBundleThrottle,
37-
ScenarioBundleUserThrottle,
35+
OekgAPIView,
3836
shape_unavailable,
3937
store_unavailable,
4038
)
@@ -66,11 +64,9 @@ class ScenarioPagination(PageNumberPagination):
6664
max_page_size = 200
6765

6866

69-
class ScenarioCollectionAPIView(APIView):
67+
class ScenarioCollectionAPIView(OekgAPIView):
7068
"""`GET` lists a bundle's scenarios. `POST` adds one."""
7169

72-
throttle_classes = [ScenarioBundleThrottle, ScenarioBundleUserThrottle]
73-
7470
def get_permissions(self):
7571
if self.request.method in ("GET", "HEAD", "OPTIONS"):
7672
return [AllowAny()]
@@ -114,11 +110,9 @@ def post(self, request, uid):
114110
return response
115111

116112

117-
class ScenarioAPIView(APIView):
113+
class ScenarioAPIView(OekgAPIView):
118114
"""`GET` reads one scenario. `PATCH` changes the keys it names."""
119115

120-
throttle_classes = [ScenarioBundleThrottle, ScenarioBundleUserThrottle]
121-
122116
def get_permissions(self):
123117
if self.request.method in ("GET", "HEAD", "OPTIONS"):
124118
return [AllowAny()]
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""A write is judged by what it adds, not by what it inherited.
2+
3+
Every scenario bundle in the live graph fails shape validation, and about a
4+
third of those failures are **missing content** -- which sector a study covers,
5+
who wrote a publication -- that no repair script can infer. Those are exactly
6+
the fields a person would supply *by patching*. So a rule of "the post-state
7+
must conform" makes the defect unfixable through the API: you would need a
8+
patch to add the missing sector, and the patch is refused because the sector is
9+
missing.
10+
11+
The promise that matters survives: **nothing invalid is written by this API.**
12+
What is given up is holding a client responsible for damage that predates it.
13+
14+
A create is the deliberate exception and is tested here too -- it has no
15+
pre-state, so everything it produces is new, and a bundle cannot be *created*
16+
broken.
17+
18+
SPDX-FileCopyrightText: 2026 Jonas Huber <https://github.com/jh-RLI> © Reiner Lemoine Institut
19+
SPDX-License-Identifier: AGPL-3.0-or-later
20+
""" # noqa: 501
21+
22+
from rdflib import Graph
23+
24+
from factsheet.models import ScenarioBundleAccessControl
25+
from oekg.bundles import OEO, build_bundle_graph
26+
from oekg.tests.bundle_fixtures import VALID_PAYLOAD, BundleApiTestCase
27+
28+
# The shape requires at least one technology. A bundle without one is exactly
29+
# the kind of thing the browser has written for years.
30+
INHERITED_DEFECT = {k: v for k, v in VALID_PAYLOAD.items() if k != "technologies"}
31+
32+
33+
class InheritedViolationTest(BundleApiTestCase):
34+
def existing_bundle(self, payload=None, uid=None):
35+
"""A bundle put straight into the store, as another writer would."""
36+
uid = uid or "11111111-2222-3333-4444-555555555555"
37+
self.store.insert(build_bundle_graph(uid, payload or INHERITED_DEFECT))
38+
ScenarioBundleAccessControl.objects.create(owner_user=self.user, bundle_id=uid)
39+
return uid
40+
41+
def test_a_bundle_that_already_violates_the_shape_can_be_patched(self):
42+
# The whole point: a defect the caller did not cause does not stand
43+
# between them and a change to an unrelated field.
44+
uid = self.existing_bundle()
45+
46+
response = self.patch(uid, {"label": "Renamed"}, if_match='"0"')
47+
48+
self.assertEqual(response.status_code, 200, response.data)
49+
self.assertEqual(self.client.get(self.detail_url(uid)).data["label"], "Renamed")
50+
51+
def test_the_inherited_violation_is_still_there_afterwards(self):
52+
# Accepted, not repaired. The API does not quietly fix what it did not
53+
# break, and the bundle stays as non-conforming as it was.
54+
uid = self.existing_bundle()
55+
56+
self.patch(uid, {"label": "Renamed"}, if_match='"0"')
57+
58+
self.assertEqual(self.client.get(self.detail_url(uid)).data["technologies"], [])
59+
60+
def test_a_patch_that_introduces_a_violation_is_still_refused(self):
61+
uid = self.existing_bundle()
62+
63+
response = self.patch(uid, {"sectors": []}, if_match='"0"')
64+
65+
self.assertEqual(response.status_code, 400, response.data)
66+
messages = [v["message"] for v in response.data["violations"]]
67+
self.assertIn("Study target: This should cover at least one sector.", messages)
68+
69+
def test_the_refusal_names_only_what_the_write_added(self):
70+
# The inherited one must not appear in the list, or a client cannot
71+
# tell which of them is its own doing.
72+
uid = self.existing_bundle()
73+
74+
response = self.patch(uid, {"sectors": []}, if_match='"0"')
75+
76+
messages = [v["message"] for v in response.data["violations"]]
77+
self.assertNotIn(
78+
"Study target: This should cover at least one technology.", messages
79+
)
80+
81+
def test_the_refusal_says_how_many_were_inherited(self):
82+
# Named rather than hidden: the bundle is not clean, and a client that
83+
# succeeds should still be able to learn that.
84+
uid = self.existing_bundle()
85+
86+
response = self.patch(uid, {"sectors": []}, if_match='"0"')
87+
88+
self.assertGreaterEqual(response.data["pre_existing_violations"], 1)
89+
90+
def test_the_refusal_keeps_its_types(self):
91+
# The framework rewrites every scalar in an error body it renders
92+
# itself, so `None` would come back as the string "None" and a count as
93+
# a string. Refusals carry their own response for exactly this reason.
94+
uid = self.existing_bundle()
95+
96+
response = self.patch(uid, {"sectors": []}, if_match='"0"')
97+
98+
self.assertIsInstance(response.data["pre_existing_violations"], int)
99+
nullable = [v["path"] for v in response.data["violations"]]
100+
self.assertTrue(
101+
any(p is None for p in nullable)
102+
or all(isinstance(p, str) for p in nullable),
103+
nullable,
104+
)
105+
for violation in response.data["violations"]:
106+
self.assertNotEqual(violation["value"], "None", violation)
107+
108+
def test_a_patch_that_repairs_the_bundle_is_accepted(self):
109+
uid = self.existing_bundle()
110+
111+
response = self.patch(
112+
uid, {"technologies": [str(OEO.OEO_00000407)]}, if_match='"0"'
113+
)
114+
115+
self.assertEqual(response.status_code, 200, response.data)
116+
self.assertEqual(
117+
self.client.get(self.detail_url(uid)).data["technologies"],
118+
[str(OEO.OEO_00000407)],
119+
)
120+
121+
def test_a_second_copy_of_an_inherited_violation_is_refused(self):
122+
# Compared as a multiset, not as a set: a bundle that already misses
123+
# one required field may not come out missing two.
124+
uid = self.existing_bundle()
125+
126+
response = self.patch(
127+
uid, {"sector_divisions": [], "sectors": []}, if_match='"0"'
128+
)
129+
130+
self.assertEqual(response.status_code, 400, response.data)
131+
self.assertEqual(len(response.data["violations"]), 2, response.data)
132+
133+
def test_nothing_is_written_when_a_new_violation_is_refused(self):
134+
uid = self.existing_bundle()
135+
136+
self.patch(uid, {"sectors": [], "label": "Should not land"}, if_match='"0"')
137+
138+
read = self.client.get(self.detail_url(uid)).data
139+
self.assertEqual(read["label"], VALID_PAYLOAD["label"])
140+
self.assertEqual(read["sectors"], VALID_PAYLOAD["sectors"])
141+
142+
143+
class CreateStillHasToConformTest(BundleApiTestCase):
144+
"""The deliberate exception: a bundle cannot be created broken."""
145+
146+
def test_a_create_missing_a_required_field_is_refused(self):
147+
response = self.create(INHERITED_DEFECT)
148+
149+
self.assertEqual(response.status_code, 400, response.data)
150+
self.assertFalse(self.store.ask("ASK { ?s ?p ?o }"))
151+
152+
def test_a_create_reports_every_violation_it_has(self):
153+
# No pre-state to subtract, so nothing is forgiven.
154+
response = self.create(
155+
{k: v for k, v in INHERITED_DEFECT.items() if k != "sectors"}
156+
)
157+
158+
messages = {v["message"] for v in response.data["violations"]}
159+
self.assertIn("Study target: This should cover at least one sector.", messages)
160+
self.assertIn(
161+
"Study target: This should cover at least one technology.", messages
162+
)
163+
164+
165+
class IntroducedViolationsUnitTest(BundleApiTestCase):
166+
"""The comparison itself."""
167+
168+
def test_an_unchanged_graph_introduces_nothing(self):
169+
from oekg.validation import introduced_violations
170+
171+
graph = build_bundle_graph("u1", INHERITED_DEFECT)
172+
173+
new, inherited = introduced_violations(graph, graph)
174+
175+
self.assertEqual(new, [])
176+
self.assertGreaterEqual(inherited, 1)
177+
178+
def test_an_empty_before_forgives_nothing(self):
179+
from oekg.validation import introduced_violations
180+
181+
after = build_bundle_graph("u1", INHERITED_DEFECT)
182+
183+
new, inherited = introduced_violations(Graph(), after)
184+
185+
self.assertEqual(inherited, 0)
186+
self.assertGreaterEqual(len(new), 1)

‎oekg/validation.py‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@
2121
SPDX-License-Identifier: AGPL-3.0-or-later
2222
""" # noqa: 501
2323

24+
from collections import Counter
2425
from dataclasses import dataclass
25-
from typing import List, Optional
26+
from typing import List, Optional, Tuple
2627

2728
from pyshacl import validate as pyshacl_validate
2829
from rdflib import RDF, Graph
@@ -70,6 +71,30 @@ def validate_post_state(post_state: Graph) -> List[ShapeViolation]:
7071
return sorted(violations, key=lambda v: (v.path or "", v.message))
7172

7273

74+
def introduced_violations(before: Graph, after: Graph) -> Tuple[List, int]:
75+
"""What ``after`` violates that ``before`` did not, and how many it inherited.
76+
77+
A write is judged by what it **adds**. The alternative -- the post-state must
78+
conform, full stop -- sounds stricter and is, but it makes an existing defect
79+
unfixable through this API: the fields most often missing are the ones a
80+
person would supply by patching, and the patch would be refused for the very
81+
thing it came to fix. The promise that matters survives either way, because
82+
nothing invalid is written *by this API*.
83+
84+
Compared as a **multiset**, so a bundle already missing one required field
85+
may not come out missing two. And by violation identity -- message, focus
86+
node, path and value together -- so swapping one violation for another
87+
counts as introducing one, which comparing counts alone would miss.
88+
89+
Both graphs must be assembled the same way. Hand this the pruned pre-state,
90+
not the raw read, or the two differ by how they were built rather than by
91+
what the write did, and that shows up as violations nobody introduced.
92+
"""
93+
inherited = Counter(validate_post_state(before))
94+
arrived = Counter(validate_post_state(after))
95+
return list((arrived - inherited).elements()), sum(inherited.values())
96+
97+
7398
def _violation(report: Graph, result) -> ShapeViolation:
7499
source_shape = report.value(result, SH.sourceShape)
75100
# The shape's own wording where it has one, the engine's only as a

0 commit comments

Comments
 (0)