Add core data model (12 tables) and initial Alembic migration
All core entities from ARCHITECTURE §5: tenancy (User, Tree, TreeMembership), people (Person, Name, Relationship), facts (Event, Place, PlaceName), provenance (Source, Citation), and the append-only AuditEntry. Cross-cutting mixins give every row a UUID key, timestamps, soft delete, and (where tree-owned) a tree_id for uniform tenant isolation. Modeling choices: parentage as qualified edges (biological/adoptive/step/foster/donor/guardian) so non-traditional families are first-class; events keep both a verbatim date string and a normalized start/end range; closed sets are PG enums while GEDCOM-extensible vocabularies (event/name/source type) stay strings; CHECK constraints enforce single-subject events and single-target citations. Place is tree-scoped in Phase 0 (see ARCHITECTURE note). The migration is verified reversible (upgrade/downgrade drops tables and enum types) and matches the models (alembic check clean); applied on the deploy target. Dockerfile now ships migrations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
+3
-1
@@ -17,8 +17,10 @@ COPY pyproject.toml uv.lock* ./
|
|||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
uv sync --no-dev
|
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 app ./app
|
||||||
|
COPY alembic.ini ./alembic.ini
|
||||||
|
COPY migrations ./migrations
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
|
)
|
||||||
@@ -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"))
|
||||||
@@ -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))
|
||||||
@@ -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())
|
||||||
@@ -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"}
|
||||||
@@ -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}")
|
||||||
@@ -29,6 +29,8 @@ package = false
|
|||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py313"
|
target-version = "py313"
|
||||||
|
# Alembic writes the migration files; don't hold generated code to our style.
|
||||||
|
extend-exclude = ["migrations/versions"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "UP", "B"]
|
select = ["E", "F", "I", "UP", "B"]
|
||||||
|
|||||||
Reference in New Issue
Block a user