diff --git a/backend/Dockerfile b/backend/Dockerfile index c7f3954..0340f0f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -17,8 +17,10 @@ COPY pyproject.toml uv.lock* ./ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --no-dev -# Application source (project is package=false, so no install step needed). +# Application source + migrations (project is package=false, no install step). COPY app ./app +COPY alembic.ini ./alembic.ini +COPY migrations ./migrations EXPOSE 8000 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..e17373c --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,41 @@ +# Alembic config. The database URL is injected from DATABASE_URL in +# migrations/env.py (twelve-factor) — intentionally not set here. + +[alembic] +script_location = migrations +prepend_sys_path = . +path_separator = os + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e9b45c6 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,28 @@ +"""Import every model so ``Base.metadata`` is complete for Alembic autogenerate +and for ``create_all`` in tests.""" + +from app.models.audit import AuditEntry +from app.models.base import Base +from app.models.event import Event +from app.models.person import Name, Person +from app.models.place import Place, PlaceName +from app.models.relationship import Relationship +from app.models.source import Citation, Source +from app.models.tree import Tree, TreeMembership +from app.models.user import User + +__all__ = [ + "Base", + "User", + "Tree", + "TreeMembership", + "Person", + "Name", + "Place", + "PlaceName", + "Relationship", + "Event", + "Source", + "Citation", + "AuditEntry", +] diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py new file mode 100644 index 0000000..792990a --- /dev/null +++ b/backend/app/models/audit.py @@ -0,0 +1,42 @@ +"""AuditEntry — append-only, immutable record of every mutation. + +The actor is a User, or the assistant principal acting *on behalf of* a User +(actor_type = assistant, actor_user_id = the user it serves). No timestamps +mixin and no soft delete: this table is never updated or deleted. +""" + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy import Enum as SAEnum +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base +from app.models.enums import AuditActorType +from app.models.mixins import UUIDPrimaryKey + + +class AuditEntry(Base, UUIDPrimaryKey): + __tablename__ = "audit_entries" + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False, index=True + ) + tree_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("trees.id", ondelete="SET NULL"), index=True + ) + actor_type: Mapped[AuditActorType] = mapped_column( + SAEnum(AuditActorType, name="audit_actor_type"), + default=AuditActorType.user, + server_default=AuditActorType.user.value, + ) + actor_user_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), index=True + ) + action: Mapped[str] = mapped_column(String(64)) # create | update | delete | restore | ... + entity_type: Mapped[str] = mapped_column(String(64)) + entity_id: Mapped[uuid.UUID | None] = mapped_column() + before: Mapped[dict | None] = mapped_column(JSONB) + after: Mapped[dict | None] = mapped_column(JSONB) diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..fd468bf --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,20 @@ +"""Declarative base with a stable constraint-naming convention. + +A fixed naming convention is important so Alembic generates deterministic, +human-readable names for indexes/constraints across migrations. +""" + +from sqlalchemy import MetaData +from sqlalchemy.orm import DeclarativeBase + +NAMING_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +class Base(DeclarativeBase): + metadata = MetaData(naming_convention=NAMING_CONVENTION) diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py new file mode 100644 index 0000000..933789c --- /dev/null +++ b/backend/app/models/enums.py @@ -0,0 +1,57 @@ +"""Closed-set enumerations that drive logic (authorization, privacy, traversal). + +Open-ended, GEDCOM-extensible vocabularies (event type, name type, source type) +are stored as strings instead, so importing real-world files never fails on an +unknown tag. +""" + +import enum + + +class TreeVisibility(enum.StrEnum): + public = "public" + unlisted = "unlisted" + private = "private" + + +class MembershipRole(enum.StrEnum): + owner = "owner" + editor = "editor" + viewer = "viewer" + + +class PersonPrivacy(enum.StrEnum): + """Per-person override of the tree's visibility (PRD US-041).""" + + inherit = "inherit" + private = "private" + public = "public" + + +class RelationshipType(enum.StrEnum): + parent_child = "parent_child" + partnership = "partnership" + sibling = "sibling" + + +class ParentChildQualifier(enum.StrEnum): + """Qualifies a parent_child edge so adoption/donor/blended families are + first-class rather than edge cases (ARCHITECTURE §5).""" + + biological = "biological" + adoptive = "adoptive" + step = "step" + foster = "foster" + donor = "donor" + guardian = "guardian" + + +class CitationConfidence(enum.StrEnum): + high = "high" + medium = "medium" + low = "low" + + +class AuditActorType(enum.StrEnum): + user = "user" + assistant = "assistant" diff --git a/backend/app/models/event.py b/backend/app/models/event.py new file mode 100644 index 0000000..21f8102 --- /dev/null +++ b/backend/app/models/event.py @@ -0,0 +1,51 @@ +"""Event — a typed, dated, placed fact attached to a Person or a partnership. + +Genealogical dates are messy, so we keep both: +- ``date_value`` — the original string, verbatim (e.g. "ABT 1850", "BET 1850 AND + 1855"), for fidelity and GEDCOM round-trip. +- ``date_start`` / ``date_end`` — a normalized range for sorting and filtering + (an exact date sets start == end). +A CHECK enforces that exactly one subject (person XOR relationship) is set. +""" + +import uuid +from datetime import date + +from sqlalchemy import CheckConstraint, Date, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base +from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey + + +class Event(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete): + __tablename__ = "events" + __table_args__ = ( + CheckConstraint( + "(person_id IS NOT NULL) <> (relationship_id IS NOT NULL)", + name="subject_person_xor_relationship", + ), + ) + + # Open vocabulary (birth, death, marriage, residence, immigration, ...). + event_type: Mapped[str] = mapped_column(String(64), index=True) + + person_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("persons.id", ondelete="CASCADE"), index=True + ) + relationship_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("relationships.id", ondelete="CASCADE"), index=True + ) + place_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("places.id", ondelete="SET NULL"), index=True + ) + + date_value: Mapped[str | None] = mapped_column(String(255)) + date_start: Mapped[date | None] = mapped_column(Date) + date_end: Mapped[date | None] = mapped_column(Date) + date_precision: Mapped[str | None] = mapped_column(String(32)) # exact|about|before|after|range + calendar: Mapped[str] = mapped_column( + String(32), default="gregorian", server_default="gregorian" + ) + detail: Mapped[str | None] = mapped_column(String(512)) # e.g. occupation, address + notes: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/models/mixins.py b/backend/app/models/mixins.py new file mode 100644 index 0000000..b0dcbdb --- /dev/null +++ b/backend/app/models/mixins.py @@ -0,0 +1,45 @@ +"""Reusable column mixins. + +- ``UUIDPrimaryKey`` — UUID surrogate key (no PII in URLs; safe for multi-tenant). +- ``Timestamps`` — created/updated audit timestamps (DB-managed). +- ``SoftDelete`` — ``deleted_at``; a row is "deleted" when set. A scheduled + worker purges rows past the 30-day window (PRD US-080/081). +- ``TenantScoped`` — ``tree_id`` FK; every tree-owned row carries it so the + privacy engine can enforce isolation uniformly. +""" + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, func +from sqlalchemy.orm import Mapped, declared_attr, mapped_column + + +class UUIDPrimaryKey: + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + + +class Timestamps: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class SoftDelete: + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, default=None + ) + + +class TenantScoped: + @declared_attr + def tree_id(cls) -> Mapped[uuid.UUID]: # noqa: N805 + return mapped_column( + ForeignKey("trees.id", ondelete="CASCADE"), nullable=False, index=True + ) diff --git a/backend/app/models/person.py b/backend/app/models/person.py new file mode 100644 index 0000000..d266482 --- /dev/null +++ b/backend/app/models/person.py @@ -0,0 +1,52 @@ +"""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, 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" + + 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") diff --git a/backend/app/models/place.py b/backend/app/models/place.py new file mode 100644 index 0000000..5b161fa --- /dev/null +++ b/backend/app/models/place.py @@ -0,0 +1,42 @@ +"""Place — a gazetteer entity — and PlaceName, its historical name variants. + +PlaceName carries date ranges so a record entered as "Königsberg, 1900" sorts +and displays correctly against "Kaliningrad" (ARCHITECTURE §5, §10). + +Phase 0 scopes Place to a Tree (``tree_id``) to keep tenant isolation absolute. +ARCHITECTURE calls the gazetteer "tenant-shared"; a deployment-wide shared +gazetteer is a deliberate later refinement (see ARCHITECTURE §5 note). +""" + +import uuid +from datetime import date + +from sqlalchemy import Date, Float, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base +from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey + + +class Place(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete): + __tablename__ = "places" + + name: Mapped[str] = mapped_column(String(512)) + # Self-referential hierarchy: place within place. + parent_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("places.id", ondelete="SET NULL"), index=True + ) + place_type: Mapped[str | None] = mapped_column(String(64)) + latitude: Mapped[float | None] = mapped_column(Float) + longitude: Mapped[float | None] = mapped_column(Float) + + +class PlaceName(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete): + __tablename__ = "place_names" + + place_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("places.id", ondelete="CASCADE"), index=True + ) + name: Mapped[str] = mapped_column(String(512)) + valid_from: Mapped[date | None] = mapped_column(Date) + valid_to: Mapped[date | None] = mapped_column(Date) diff --git a/backend/app/models/relationship.py b/backend/app/models/relationship.py new file mode 100644 index 0000000..d9c7103 --- /dev/null +++ b/backend/app/models/relationship.py @@ -0,0 +1,40 @@ +"""Relationship — a typed, qualified edge between two Persons. + +Modeling parentage as qualified edges (rather than assuming two biological +parents) is what makes adoption, donor conception, and blended families +first-class. ``qualifier`` applies to parent_child edges; partnership events +(marriage, divorce) attach to the Relationship via Event.relationship_id. +""" + +import uuid + +from sqlalchemy import CheckConstraint, ForeignKey, 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 ParentChildQualifier, RelationshipType +from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey + + +class Relationship(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete): + __tablename__ = "relationships" + __table_args__ = ( + CheckConstraint("person_from_id <> person_to_id", name="different_persons"), + ) + + type: Mapped[RelationshipType] = mapped_column( + SAEnum(RelationshipType, name="relationship_type") + ) + # For parent_child: from = parent, to = child. For partnership/sibling: symmetric. + person_from_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("persons.id", ondelete="CASCADE"), index=True + ) + person_to_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("persons.id", ondelete="CASCADE"), index=True + ) + # Only meaningful for parent_child edges. + qualifier: Mapped[ParentChildQualifier | None] = mapped_column( + SAEnum(ParentChildQualifier, name="parent_child_qualifier") + ) + notes: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/models/source.py b/backend/app/models/source.py new file mode 100644 index 0000000..7416491 --- /dev/null +++ b/backend/app/models/source.py @@ -0,0 +1,66 @@ +"""Source and Citation — the first-class provenance spine. + +A Source is a reusable record of an origin; a Citation links one Source to one +specific fact (a Person, Name, Event, or Relationship — and OwnershipEvent once +property lands). A CHECK enforces exactly one target so a citation always points +at a single fact. +""" + +import uuid + +from sqlalchemy import CheckConstraint, ForeignKey, String, 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 CitationConfidence +from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey + + +class Source(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete): + __tablename__ = "sources" + + title: Mapped[str] = mapped_column(String(512)) + author: Mapped[str | None] = mapped_column(String(255)) + source_type: Mapped[str | None] = mapped_column(String(64)) # book, census, deed, ... + repository: Mapped[str | None] = mapped_column(String(255)) + url: Mapped[str | None] = mapped_column(String(1024)) + citation_text: Mapped[str | None] = mapped_column(Text) + publication_info: Mapped[str | None] = mapped_column(Text) + quality_note: Mapped[str | None] = mapped_column(String(255)) + + +class Citation(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete): + __tablename__ = "citations" + __table_args__ = ( + CheckConstraint( + "(person_id IS NOT NULL)::int + (event_id IS NOT NULL)::int " + "+ (name_id IS NOT NULL)::int + (relationship_id IS NOT NULL)::int = 1", + name="exactly_one_target", + ), + ) + + source_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("sources.id", ondelete="CASCADE"), index=True + ) + + # Exactly one of these is set (see CHECK above). + person_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("persons.id", ondelete="CASCADE"), index=True + ) + event_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("events.id", ondelete="CASCADE"), index=True + ) + name_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("names.id", ondelete="CASCADE"), index=True + ) + relationship_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("relationships.id", ondelete="CASCADE"), index=True + ) + + # Locality within the source. + page: Mapped[str | None] = mapped_column(String(255)) + detail: Mapped[str | None] = mapped_column(Text) # entry, line, free notes + confidence: Mapped[CitationConfidence | None] = mapped_column( + SAEnum(CitationConfidence, name="citation_confidence") + ) diff --git a/backend/app/models/tree.py b/backend/app/models/tree.py new file mode 100644 index 0000000..8581d69 --- /dev/null +++ b/backend/app/models/tree.py @@ -0,0 +1,43 @@ +"""Tree — the top-level tenant boundary for genealogical data — and +TreeMembership, the basis for authorization (ARCHITECTURE §5). +""" + +import uuid + +from sqlalchemy import Enum as SAEnum +from sqlalchemy import ForeignKey, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base +from app.models.enums import MembershipRole, TreeVisibility +from app.models.mixins import SoftDelete, Timestamps, UUIDPrimaryKey + + +class Tree(Base, UUIDPrimaryKey, Timestamps, SoftDelete): + __tablename__ = "trees" + + owner_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="RESTRICT"), index=True + ) + name: Mapped[str] = mapped_column(String(255)) + description: Mapped[str | None] = mapped_column(Text) + visibility: Mapped[TreeVisibility] = mapped_column( + SAEnum(TreeVisibility, name="tree_visibility"), + default=TreeVisibility.private, + server_default=TreeVisibility.private.value, + ) + + +class TreeMembership(Base, UUIDPrimaryKey, Timestamps): + __tablename__ = "tree_memberships" + __table_args__ = ( + UniqueConstraint("tree_id", "user_id", name="uq_tree_memberships_tree_user"), + ) + + tree_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("trees.id", ondelete="CASCADE"), index=True + ) + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + role: Mapped[MembershipRole] = mapped_column(SAEnum(MembershipRole, name="membership_role")) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..16f70b7 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,21 @@ +"""User — a person with login. Identity is internal so one user can link +multiple auth providers later (the provider-link table arrives with the auth +slice). ``hashed_password`` is nullable: external/OIDC users have none. +""" + +from datetime import datetime + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base +from app.models.mixins import SoftDelete, Timestamps, UUIDPrimaryKey + + +class User(Base, UUIDPrimaryKey, Timestamps, SoftDelete): + __tablename__ = "users" + + email: Mapped[str] = mapped_column(String(320), unique=True, index=True) + email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + display_name: Mapped[str | None] = mapped_column(String(255)) + hashed_password: Mapped[str | None] = mapped_column(String(255)) diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..3a43676 --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,59 @@ +"""Alembic environment — async, URL sourced from settings (DATABASE_URL).""" + +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +from app.core.config import get_settings +from app.models import Base # noqa: F401 — importing registers all models + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Inject the runtime database URL (asyncpg driver) from the environment. +config.set_main_option("sqlalchemy.url", get_settings().database_url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=config.get_main_option("sqlalchemy.url"), + target_metadata=target_metadata, + literal_binds=True, + compare_type=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def _do_run_migrations(connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(_do_run_migrations) + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..edb0604 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: str | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/ec43c338e155_core_data_model.py b/backend/migrations/versions/ec43c338e155_core_data_model.py new file mode 100644 index 0000000..d152d19 --- /dev/null +++ b/backend/migrations/versions/ec43c338e155_core_data_model.py @@ -0,0 +1,304 @@ +"""core data model + +Revision ID: ec43c338e155 +Revises: +Create Date: 2026-06-06 10:27:41.671787 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'ec43c338e155' +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('email', sa.String(length=320), nullable=False), + sa.Column('email_verified_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('display_name', sa.String(length=255), nullable=True), + sa.Column('hashed_password', sa.String(length=255), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('pk_users')) + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_table('trees', + sa.Column('owner_id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('visibility', sa.Enum('public', 'unlisted', 'private', name='tree_visibility'), server_default='private', nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['owner_id'], ['users.id'], name=op.f('fk_trees_owner_id_users'), ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_trees')) + ) + op.create_index(op.f('ix_trees_owner_id'), 'trees', ['owner_id'], unique=False) + op.create_table('audit_entries', + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('tree_id', sa.Uuid(), nullable=True), + sa.Column('actor_type', sa.Enum('user', 'assistant', name='audit_actor_type'), server_default='user', nullable=False), + sa.Column('actor_user_id', sa.Uuid(), nullable=True), + sa.Column('action', sa.String(length=64), nullable=False), + sa.Column('entity_type', sa.String(length=64), nullable=False), + sa.Column('entity_id', sa.Uuid(), nullable=True), + sa.Column('before', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('after', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['actor_user_id'], ['users.id'], name=op.f('fk_audit_entries_actor_user_id_users'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_audit_entries_tree_id_trees'), ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_audit_entries')) + ) + op.create_index(op.f('ix_audit_entries_actor_user_id'), 'audit_entries', ['actor_user_id'], unique=False) + op.create_index(op.f('ix_audit_entries_created_at'), 'audit_entries', ['created_at'], unique=False) + op.create_index(op.f('ix_audit_entries_tree_id'), 'audit_entries', ['tree_id'], unique=False) + op.create_table('persons', + sa.Column('gender', sa.String(length=32), nullable=True), + sa.Column('is_living', sa.Boolean(), nullable=True), + sa.Column('privacy', sa.Enum('inherit', 'private', 'public', name='person_privacy'), server_default='inherit', nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tree_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_persons_tree_id_trees'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_persons')) + ) + op.create_index(op.f('ix_persons_tree_id'), 'persons', ['tree_id'], unique=False) + op.create_table('places', + sa.Column('name', sa.String(length=512), nullable=False), + sa.Column('parent_id', sa.Uuid(), nullable=True), + sa.Column('place_type', sa.String(length=64), nullable=True), + sa.Column('latitude', sa.Float(), nullable=True), + sa.Column('longitude', sa.Float(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tree_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['parent_id'], ['places.id'], name=op.f('fk_places_parent_id_places'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_places_tree_id_trees'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_places')) + ) + op.create_index(op.f('ix_places_parent_id'), 'places', ['parent_id'], unique=False) + op.create_index(op.f('ix_places_tree_id'), 'places', ['tree_id'], unique=False) + op.create_table('sources', + sa.Column('title', sa.String(length=512), nullable=False), + sa.Column('author', sa.String(length=255), nullable=True), + sa.Column('source_type', sa.String(length=64), nullable=True), + sa.Column('repository', sa.String(length=255), nullable=True), + sa.Column('url', sa.String(length=1024), nullable=True), + sa.Column('citation_text', sa.Text(), nullable=True), + sa.Column('publication_info', sa.Text(), nullable=True), + sa.Column('quality_note', sa.String(length=255), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tree_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_sources_tree_id_trees'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_sources')) + ) + op.create_index(op.f('ix_sources_tree_id'), 'sources', ['tree_id'], unique=False) + op.create_table('tree_memberships', + sa.Column('tree_id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('role', sa.Enum('owner', 'editor', 'viewer', name='membership_role'), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_tree_memberships_tree_id_trees'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_tree_memberships_user_id_users'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_tree_memberships')), + sa.UniqueConstraint('tree_id', 'user_id', name='uq_tree_memberships_tree_user') + ) + op.create_index(op.f('ix_tree_memberships_tree_id'), 'tree_memberships', ['tree_id'], unique=False) + op.create_index(op.f('ix_tree_memberships_user_id'), 'tree_memberships', ['user_id'], unique=False) + op.create_table('names', + sa.Column('person_id', sa.Uuid(), nullable=False), + sa.Column('name_type', sa.String(length=32), server_default='birth', nullable=False), + sa.Column('given', sa.String(length=255), nullable=True), + sa.Column('surname', sa.String(length=255), nullable=True), + sa.Column('prefix', sa.String(length=64), nullable=True), + sa.Column('suffix', sa.String(length=64), nullable=True), + sa.Column('nickname', sa.String(length=128), nullable=True), + sa.Column('display_name', sa.String(length=512), nullable=True), + sa.Column('is_primary', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tree_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_names_person_id_persons'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_names_tree_id_trees'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_names')) + ) + op.create_index(op.f('ix_names_person_id'), 'names', ['person_id'], unique=False) + op.create_index(op.f('ix_names_tree_id'), 'names', ['tree_id'], unique=False) + op.create_table('place_names', + sa.Column('place_id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=512), nullable=False), + sa.Column('valid_from', sa.Date(), nullable=True), + sa.Column('valid_to', sa.Date(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tree_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['place_id'], ['places.id'], name=op.f('fk_place_names_place_id_places'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_place_names_tree_id_trees'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_place_names')) + ) + op.create_index(op.f('ix_place_names_place_id'), 'place_names', ['place_id'], unique=False) + op.create_index(op.f('ix_place_names_tree_id'), 'place_names', ['tree_id'], unique=False) + op.create_table('relationships', + sa.Column('type', sa.Enum('parent_child', 'partnership', 'sibling', name='relationship_type'), nullable=False), + sa.Column('person_from_id', sa.Uuid(), nullable=False), + sa.Column('person_to_id', sa.Uuid(), nullable=False), + sa.Column('qualifier', sa.Enum('biological', 'adoptive', 'step', 'foster', 'donor', 'guardian', name='parent_child_qualifier'), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tree_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint('person_from_id <> person_to_id', name=op.f('ck_relationships_different_persons')), + sa.ForeignKeyConstraint(['person_from_id'], ['persons.id'], name=op.f('fk_relationships_person_from_id_persons'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['person_to_id'], ['persons.id'], name=op.f('fk_relationships_person_to_id_persons'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_relationships_tree_id_trees'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_relationships')) + ) + op.create_index(op.f('ix_relationships_person_from_id'), 'relationships', ['person_from_id'], unique=False) + op.create_index(op.f('ix_relationships_person_to_id'), 'relationships', ['person_to_id'], unique=False) + op.create_index(op.f('ix_relationships_tree_id'), 'relationships', ['tree_id'], unique=False) + op.create_table('events', + sa.Column('event_type', sa.String(length=64), nullable=False), + sa.Column('person_id', sa.Uuid(), nullable=True), + sa.Column('relationship_id', sa.Uuid(), nullable=True), + sa.Column('place_id', sa.Uuid(), nullable=True), + sa.Column('date_value', sa.String(length=255), nullable=True), + sa.Column('date_start', sa.Date(), nullable=True), + sa.Column('date_end', sa.Date(), nullable=True), + sa.Column('date_precision', sa.String(length=32), nullable=True), + sa.Column('calendar', sa.String(length=32), server_default='gregorian', nullable=False), + sa.Column('detail', sa.String(length=512), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tree_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint('(person_id IS NOT NULL) <> (relationship_id IS NOT NULL)', name=op.f('ck_events_subject_person_xor_relationship')), + sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_events_person_id_persons'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['place_id'], ['places.id'], name=op.f('fk_events_place_id_places'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['relationship_id'], ['relationships.id'], name=op.f('fk_events_relationship_id_relationships'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_events_tree_id_trees'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_events')) + ) + op.create_index(op.f('ix_events_event_type'), 'events', ['event_type'], unique=False) + op.create_index(op.f('ix_events_person_id'), 'events', ['person_id'], unique=False) + op.create_index(op.f('ix_events_place_id'), 'events', ['place_id'], unique=False) + op.create_index(op.f('ix_events_relationship_id'), 'events', ['relationship_id'], unique=False) + op.create_index(op.f('ix_events_tree_id'), 'events', ['tree_id'], unique=False) + op.create_table('citations', + sa.Column('source_id', sa.Uuid(), nullable=False), + sa.Column('person_id', sa.Uuid(), nullable=True), + sa.Column('event_id', sa.Uuid(), nullable=True), + sa.Column('name_id', sa.Uuid(), nullable=True), + sa.Column('relationship_id', sa.Uuid(), nullable=True), + sa.Column('page', sa.String(length=255), nullable=True), + sa.Column('detail', sa.Text(), nullable=True), + sa.Column('confidence', sa.Enum('high', 'medium', 'low', name='citation_confidence'), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tree_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint('(person_id IS NOT NULL)::int + (event_id IS NOT NULL)::int + (name_id IS NOT NULL)::int + (relationship_id IS NOT NULL)::int = 1', name=op.f('ck_citations_exactly_one_target')), + sa.ForeignKeyConstraint(['event_id'], ['events.id'], name=op.f('fk_citations_event_id_events'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['name_id'], ['names.id'], name=op.f('fk_citations_name_id_names'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_citations_person_id_persons'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['relationship_id'], ['relationships.id'], name=op.f('fk_citations_relationship_id_relationships'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['source_id'], ['sources.id'], name=op.f('fk_citations_source_id_sources'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_citations_tree_id_trees'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_citations')) + ) + op.create_index(op.f('ix_citations_event_id'), 'citations', ['event_id'], unique=False) + op.create_index(op.f('ix_citations_name_id'), 'citations', ['name_id'], unique=False) + op.create_index(op.f('ix_citations_person_id'), 'citations', ['person_id'], unique=False) + op.create_index(op.f('ix_citations_relationship_id'), 'citations', ['relationship_id'], unique=False) + op.create_index(op.f('ix_citations_source_id'), 'citations', ['source_id'], unique=False) + op.create_index(op.f('ix_citations_tree_id'), 'citations', ['tree_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_citations_tree_id'), table_name='citations') + op.drop_index(op.f('ix_citations_source_id'), table_name='citations') + op.drop_index(op.f('ix_citations_relationship_id'), table_name='citations') + op.drop_index(op.f('ix_citations_person_id'), table_name='citations') + op.drop_index(op.f('ix_citations_name_id'), table_name='citations') + op.drop_index(op.f('ix_citations_event_id'), table_name='citations') + op.drop_table('citations') + op.drop_index(op.f('ix_events_tree_id'), table_name='events') + op.drop_index(op.f('ix_events_relationship_id'), table_name='events') + op.drop_index(op.f('ix_events_place_id'), table_name='events') + op.drop_index(op.f('ix_events_person_id'), table_name='events') + op.drop_index(op.f('ix_events_event_type'), table_name='events') + op.drop_table('events') + op.drop_index(op.f('ix_relationships_tree_id'), table_name='relationships') + op.drop_index(op.f('ix_relationships_person_to_id'), table_name='relationships') + op.drop_index(op.f('ix_relationships_person_from_id'), table_name='relationships') + op.drop_table('relationships') + op.drop_index(op.f('ix_place_names_tree_id'), table_name='place_names') + op.drop_index(op.f('ix_place_names_place_id'), table_name='place_names') + op.drop_table('place_names') + op.drop_index(op.f('ix_names_tree_id'), table_name='names') + op.drop_index(op.f('ix_names_person_id'), table_name='names') + op.drop_table('names') + op.drop_index(op.f('ix_tree_memberships_user_id'), table_name='tree_memberships') + op.drop_index(op.f('ix_tree_memberships_tree_id'), table_name='tree_memberships') + op.drop_table('tree_memberships') + op.drop_index(op.f('ix_sources_tree_id'), table_name='sources') + op.drop_table('sources') + op.drop_index(op.f('ix_places_tree_id'), table_name='places') + op.drop_index(op.f('ix_places_parent_id'), table_name='places') + op.drop_table('places') + op.drop_index(op.f('ix_persons_tree_id'), table_name='persons') + op.drop_table('persons') + op.drop_index(op.f('ix_audit_entries_tree_id'), table_name='audit_entries') + op.drop_index(op.f('ix_audit_entries_created_at'), table_name='audit_entries') + op.drop_index(op.f('ix_audit_entries_actor_user_id'), table_name='audit_entries') + op.drop_table('audit_entries') + op.drop_index(op.f('ix_trees_owner_id'), table_name='trees') + op.drop_table('trees') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### + + # Enum types are created implicitly by create_table() but not dropped by + # drop_table(); drop them explicitly so downgrade is fully reversible. + for enum_name in ( + "tree_visibility", + "membership_role", + "person_privacy", + "relationship_type", + "parent_child_qualifier", + "citation_confidence", + "audit_actor_type", + ): + op.execute(f"DROP TYPE IF EXISTS {enum_name}") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index fa7b563..fdc41f1 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -29,6 +29,8 @@ package = false [tool.ruff] line-length = 100 target-version = "py313" +# Alembic writes the migration files; don't hold generated code to our style. +extend-exclude = ["migrations/versions"] [tool.ruff.lint] select = ["E", "F", "I", "UP", "B"]