4788ae7723
Fuzzy search: pg_trgm extension + trigram GIN indexes on name parts and a GET /trees/{id}/persons?q= search ranked by trigram similarity (finds Mueller for 'muller'), privacy-filtered. Living-person protection: the privacy engine now derives possibly-living status (explicit flag, else no death fact + birth within ~100y or unknown) and returns 'redacted' for non-members of public/unlisted trees; the service minimises those records ('Living person', no vitals). Members are unaffected. 31 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
69 lines
2.7 KiB
Python
69 lines
2.7 KiB
Python
"""Person and Name.
|
|
|
|
A Person carries living/deceased status and a per-person privacy override; the
|
|
display identity lives in one or more Name rows (variants, married names,
|
|
aliases) so name changes over time are first-class.
|
|
"""
|
|
|
|
import uuid
|
|
|
|
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, Text, text
|
|
from sqlalchemy import Enum as SAEnum
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from app.models.base import Base
|
|
from app.models.enums import PersonPrivacy
|
|
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
|
|
|
|
|
|
class Person(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
|
|
__tablename__ = "persons"
|
|
|
|
# Free-form to stay inclusive; not a closed enum.
|
|
gender: Mapped[str | None] = mapped_column(String(32))
|
|
# NULL = unknown (let the living-person rule derive it); True/False = asserted.
|
|
is_living: Mapped[bool | None] = mapped_column(Boolean)
|
|
privacy: Mapped[PersonPrivacy] = mapped_column(
|
|
SAEnum(PersonPrivacy, name="person_privacy"),
|
|
default=PersonPrivacy.inherit,
|
|
server_default=PersonPrivacy.inherit.value,
|
|
)
|
|
notes: Mapped[str | None] = mapped_column(Text)
|
|
|
|
|
|
class Name(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
|
|
__tablename__ = "names"
|
|
# Trigram indexes for fuzzy name search (Mueller/Müller/Muller). Requires the
|
|
# pg_trgm extension (enabled in the accompanying migration).
|
|
__table_args__ = (
|
|
Index(
|
|
"ix_names_given_trgm",
|
|
"given",
|
|
postgresql_using="gin",
|
|
postgresql_ops={"given": "gin_trgm_ops"},
|
|
),
|
|
Index(
|
|
"ix_names_surname_trgm",
|
|
"surname",
|
|
postgresql_using="gin",
|
|
postgresql_ops={"surname": "gin_trgm_ops"},
|
|
),
|
|
)
|
|
|
|
person_id: Mapped[uuid.UUID] = mapped_column(
|
|
ForeignKey("persons.id", ondelete="CASCADE"), index=True
|
|
)
|
|
# Open vocabulary (birth, married, alias, religious, ...) for GEDCOM fidelity.
|
|
name_type: Mapped[str] = mapped_column(String(32), default="birth", server_default="birth")
|
|
given: Mapped[str | None] = mapped_column(String(255))
|
|
surname: Mapped[str | None] = mapped_column(String(255))
|
|
prefix: Mapped[str | None] = mapped_column(String(64))
|
|
suffix: Mapped[str | None] = mapped_column(String(64))
|
|
nickname: Mapped[str | None] = mapped_column(String(128))
|
|
# Original full form preserved verbatim (round-trip fidelity).
|
|
display_name: Mapped[str | None] = mapped_column(String(512))
|
|
is_primary: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default=text("false")
|
|
)
|
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|