Prevent duplicate relationships; harden tree render against bad graphs
Root cause of the blank Jung tree: a child double-linked to the same parent
(and, generally, any cycle) made family-chart recurse forever.
Backend (the real fix):
- create_relationship now rejects an equivalent existing edge → 409.
parent_child is directional (parent→child); partnership/sibling match the
pair in either order. So you can't link the same two people the same way
twice. (GEDCOM import already deduped; manual creates didn't.)
Frontend (defense in depth so data can never blank the view):
- Tree view sanitizes the graph before rendering: dedupes parents/spouses,
drops self-links, and greedily breaks ancestor cycles (a person can't be
their own ancestor); children are derived from the kept edges. The render is
wrapped in try/catch and shows a note instead of a blank canvas, telling you
which conflicting links were skipped.
- Person page surfaces the 409 ("They're already linked that way.").
59 backend tests pass (incl. dup-rejection + reverse-parent-child allowed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ Writes require editor rights; reads go through the privacy engine."""
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import ParentChildQualifier, RelationshipType
|
||||
@@ -49,6 +49,38 @@ async def create_relationship(
|
||||
if not await _person_in_tree(session, pid, tree.id):
|
||||
raise NotFound("person not found in this tree")
|
||||
|
||||
# Reject an equivalent existing edge so the same two people can't be linked
|
||||
# the same way twice. parent_child is directional (parent -> child);
|
||||
# partnership/sibling are symmetric, so match the pair in either order.
|
||||
if type is RelationshipType.parent_child:
|
||||
pair = and_(
|
||||
Relationship.person_from_id == person_from_id,
|
||||
Relationship.person_to_id == person_to_id,
|
||||
)
|
||||
else:
|
||||
pair = or_(
|
||||
and_(
|
||||
Relationship.person_from_id == person_from_id,
|
||||
Relationship.person_to_id == person_to_id,
|
||||
),
|
||||
and_(
|
||||
Relationship.person_from_id == person_to_id,
|
||||
Relationship.person_to_id == person_from_id,
|
||||
),
|
||||
)
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(Relationship.id).where(
|
||||
Relationship.tree_id == tree.id,
|
||||
Relationship.type == type,
|
||||
Relationship.deleted_at.is_(None),
|
||||
pair,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
raise Conflict("these two people are already linked that way")
|
||||
|
||||
relationship = Relationship(
|
||||
tree_id=tree.id,
|
||||
type=type,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Duplicate relationships are rejected (no double-linking)."""
|
||||
|
||||
from tests.conftest import auth, register
|
||||
|
||||
|
||||
async def _setup(client, email):
|
||||
h = auth(await register(client, email))
|
||||
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
|
||||
|
||||
async def person(given):
|
||||
return (
|
||||
await client.post(f"/api/v1/trees/{tid}/persons", json={"given": given}, headers=h)
|
||||
).json()["id"]
|
||||
|
||||
return h, tid, person
|
||||
|
||||
|
||||
async def test_duplicate_parent_child_rejected(client):
|
||||
h, tid, person = await _setup(client, "dup-pc@example.com")
|
||||
karl = await person("Karl")
|
||||
kid = await person("Kid")
|
||||
body = {"type": "parent_child", "person_from_id": karl, "person_to_id": kid}
|
||||
|
||||
first = await client.post(f"/api/v1/trees/{tid}/relationships", json=body, headers=h)
|
||||
assert first.status_code == 201
|
||||
dup = await client.post(f"/api/v1/trees/{tid}/relationships", json=body, headers=h)
|
||||
assert dup.status_code == 409
|
||||
|
||||
|
||||
async def test_duplicate_partnership_either_direction_rejected(client):
|
||||
h, tid, person = await _setup(client, "dup-sp@example.com")
|
||||
a = await person("A")
|
||||
b = await person("B")
|
||||
|
||||
first = await client.post(
|
||||
f"/api/v1/trees/{tid}/relationships",
|
||||
json={"type": "partnership", "person_from_id": a, "person_to_id": b},
|
||||
headers=h,
|
||||
)
|
||||
assert first.status_code == 201
|
||||
# Same couple, reversed order — still a duplicate.
|
||||
dup = await client.post(
|
||||
f"/api/v1/trees/{tid}/relationships",
|
||||
json={"type": "partnership", "person_from_id": b, "person_to_id": a},
|
||||
headers=h,
|
||||
)
|
||||
assert dup.status_code == 409
|
||||
|
||||
|
||||
async def test_reverse_parent_child_is_allowed(client):
|
||||
"""A->B as parent_child shouldn't block B->A (different meaning)."""
|
||||
h, tid, person = await _setup(client, "dup-rev@example.com")
|
||||
a = await person("A")
|
||||
b = await person("B")
|
||||
r1 = await client.post(
|
||||
f"/api/v1/trees/{tid}/relationships",
|
||||
json={"type": "parent_child", "person_from_id": a, "person_to_id": b},
|
||||
headers=h,
|
||||
)
|
||||
r2 = await client.post(
|
||||
f"/api/v1/trees/{tid}/relationships",
|
||||
json={"type": "parent_child", "person_from_id": b, "person_to_id": a},
|
||||
headers=h,
|
||||
)
|
||||
assert r1.status_code == 201 and r2.status_code == 201
|
||||
Reference in New Issue
Block a user