1. The comparison compares two different things
factsheet/views.py:167-171:
for s, p, o in oekg.triples((None, RDF.type, OEO.OEO_00020227)):
study_acronym = oekg.value(s, DC.acronym)
if str(clean_name(acronym)) == str(study_acronym):
Duplicate_study_factsheet = True
The left side is normalised, the right side is the raw stored value. clean_name
(factsheet/helper.py) replaces spaces, hyphens, %, Ö/ö, /, :, ( and ) with
underscores or expansions — but bundles are stored with dc:acronym set to the unmodified input.
So for an existing bundle with acronym NEP 2037, a second create with the same acronym compares
"NEP_2037" == "NEP 2037" → False, and the duplicate is created. The check only ever fires for
acronyms containing none of the replaced characters.
Effect: acronym uniqueness is silently unenforced for a large share of real acronyms.
The update path has its own copy of the same comparison (:625-634).
2. When it does fire, it answers 200
response = JsonResponse("Factsheet exists", safe=False, content_type="application/json")
No status code, so Django sends 200 OK. A rejected create is indistinguishable from a successful
one for any client that checks status codes rather than parsing the body string. It should be a
409 Conflict.
3. It is an O(bundles) graph scan per create
The check iterates every OEO_00020227 in the graph and issues an oekg.value() per bundle. With
SPARQLUpdateStore as transport (factsheet/oekg/connection.py) each of those is its own HTTP round
trip to Fuseki. A single ASK would answer the same question in one request.
4. Adjacent: the client-supplied uid is unvalidated
uid = request_body["uid"]
study_URI = URIRef("https://openenergyplatform.org/ontology/oekg/" + uid)
A client-supplied string is concatenated straight into the IRI namespace — no UUID check, no
character check. Note the create checks the acronym for duplicates but never the uid, so a
POST carrying an existing uid with a different acronym writes its triples into the existing
bundle's URI: a silent merge.
Suggested fix
- Compare like with like — normalise both sides, or store the normalised form alongside and compare
that. Decide which is canonical and write it down.
- Answer 409 on a duplicate.
- Replace the scan with a single
ASK.
- Mint the uuid server-side rather than validating a client-supplied one — that closes the
surface instead of policing it.
This is already built and merged for the REST API: oekg/acronyms.py (#2471) holds the whole
acronym promise, and the server mints the uid (#2477). The fix here is to route the browser's create
through the same code rather than to write a second answer.
Why it matters beyond the browser
The API depends on acronym uniqueness being real: a stateless modelling pipeline re-identifies its
bundle with GET /api/v0/scenario-bundles/?acronym=…, so an unenforced acronym makes re-import pick
the wrong bundle or create duplicates. Academy notebook #2464 teaches exactly that pattern.
Found while charting the OEKG REST API (WF-10, "Idempotent re-import for the modelling pipeline").
Verified still present on develop (f21a6a3b7) on 2026-09-21; line numbers are from that commit.
1. The comparison compares two different things
factsheet/views.py:167-171:The left side is normalised, the right side is the raw stored value.
clean_name(
factsheet/helper.py) replaces spaces, hyphens,%,Ö/ö,/,:,(and)withunderscores or expansions — but bundles are stored with
dc:acronymset to the unmodified input.So for an existing bundle with acronym
NEP 2037, a second create with the same acronym compares"NEP_2037" == "NEP 2037"→ False, and the duplicate is created. The check only ever fires foracronyms containing none of the replaced characters.
Effect: acronym uniqueness is silently unenforced for a large share of real acronyms.
The update path has its own copy of the same comparison (
:625-634).2. When it does fire, it answers 200
No status code, so Django sends 200 OK. A rejected create is indistinguishable from a successful
one for any client that checks status codes rather than parsing the body string. It should be a
409 Conflict.
3. It is an O(bundles) graph scan per create
The check iterates every
OEO_00020227in the graph and issues anoekg.value()per bundle. WithSPARQLUpdateStoreas transport (factsheet/oekg/connection.py) each of those is its own HTTP roundtrip to Fuseki. A single
ASKwould answer the same question in one request.4. Adjacent: the client-supplied uid is unvalidated
A client-supplied string is concatenated straight into the IRI namespace — no UUID check, no
character check. Note the create checks the acronym for duplicates but never the uid, so a
POSTcarrying an existing uid with a different acronym writes its triples into the existingbundle's URI: a silent merge.
Suggested fix
that. Decide which is canonical and write it down.
ASK.surface instead of policing it.
This is already built and merged for the REST API:
oekg/acronyms.py(#2471) holds the wholeacronym promise, and the server mints the uid (#2477). The fix here is to route the browser's create
through the same code rather than to write a second answer.
Why it matters beyond the browser
The API depends on acronym uniqueness being real: a stateless modelling pipeline re-identifies its
bundle with
GET /api/v0/scenario-bundles/?acronym=…, so an unenforced acronym makes re-import pickthe wrong bundle or create duplicates. Academy notebook #2464 teaches exactly that pattern.
Found while charting the OEKG REST API (WF-10, "Idempotent re-import for the modelling pipeline").
Verified still present on
develop(f21a6a3b7) on 2026-09-21; line numbers are from that commit.