From a31bd35800aa79f2915d3ba54f543d4f75c34b4d Mon Sep 17 00:00:00 2001 From: Abby Bigaouette Date: Sun, 16 Aug 2026 08:09:48 -0700 Subject: [PATCH] Requirements PATCH: explicit null clears parent_id too (issue #30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 87cf6ad made five nullable fields clearable but left parent_id on the None-skip path, so `{"parent_id": null}` returned 200 and silently kept the parent — the exact symptom #30 was filed for, in the one nullable field the fix missed. A child requirement could never be promoted back to a level-0 root; the API reported success while ignoring the edit. parent_id joins the clearable set. level is deliberately not adjusted: the two are already independent PATCH fields, and re-levelling a subtree on a parent clear would be a surprise the caller didn't ask for. The existing validation guard still only runs for a non-null parent_id, which is what clearing wants — there is no parent to look up. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014jPbhRuDUZLie159gQxTDZ --- src/opal/api/routes/requirements.py | 12 +++++++++++- tests/unit/test_se_requirements.py | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/opal/api/routes/requirements.py b/src/opal/api/routes/requirements.py index 143ff4e..4fa8222 100644 --- a/src/opal/api/routes/requirements.py +++ b/src/opal/api/routes/requirements.py @@ -326,7 +326,17 @@ def update_requirement( old_values = get_model_dict(req) # Nullable fields clear on explicit null (model_fields_set distinguishes # absent from null — issue #30); non-nullable fields keep the None-skip. - clearable = {"rationale", "category", "verification_method", "tbr_owner_id", "tbr_due"} + # parent_id clears too: a level-0 root has no parent, so promoting a child + # back to a root is a legitimate edit. level is a separate field and is not + # adjusted here — the caller sets it in the same PATCH if it should change. + clearable = { + "rationale", + "category", + "verification_method", + "tbr_owner_id", + "tbr_due", + "parent_id", + } for field in ( "title", "statement", diff --git a/tests/unit/test_se_requirements.py b/tests/unit/test_se_requirements.py index e10eea5..620b22c 100644 --- a/tests/unit/test_se_requirements.py +++ b/tests/unit/test_se_requirements.py @@ -753,3 +753,24 @@ def test_api_patch_explicit_null_clears_nullable_fields(client, test_user): assert body["tbr_due"] is None assert body["category"] == "propulsion" assert body["title"] == req["title"] # non-nullable untouched + + +def test_api_patch_explicit_null_clears_parent(client): + """Issue #30 — parent_id is nullable ('level-0 roots have no parent'), so a + child promotes back to a root on explicit null. level is independent and is + only changed when the caller sends it.""" + parent = _api_create(client, title="Vehicle", level=0) + child = _api_create(client, title="Engine", level=1, parent_id=parent["id"]) + assert child["parent_id"] == parent["id"] + + r = client.patch(f"/api/requirements/{child['id']}", json={"parent_id": None}) + assert r.status_code == 200, r.text + body = r.json() + assert body["parent_id"] is None + assert body["level"] == 1 # untouched — clearing the parent does not relevel + + # It stays cleared on reload, and omitting the field leaves it alone. + r = client.patch(f"/api/requirements/{child['id']}", json={"title": "Engine A"}) + assert r.status_code == 200, r.text + assert r.json()["parent_id"] is None + assert client.get(f"/api/requirements/{child['id']}").json()["parent_id"] is None