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:
2026-06-06 10:40:00 -04:00
parent 03124027fe
commit 297cb797d6
18 changed files with 942 additions and 1 deletions
+3 -1
View File
@@ -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
+41
View File
@@ -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
+28
View File
@@ -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",
]
+42
View File
@@ -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)
+20
View File
@@ -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)
+57
View File
@@ -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"
+51
View File
@@ -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)
+45
View File
@@ -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
)
+52
View File
@@ -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")
+42
View File
@@ -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)
+40
View File
@@ -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)
+66
View File
@@ -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")
)
+43
View File
@@ -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"))
+21
View File
@@ -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))
+59
View File
@@ -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())
+26
View File
@@ -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}")
+2
View File
@@ -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"]