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,
|
||||
|
||||
Reference in New Issue
Block a user