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:
@@ -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))
|
||||
Reference in New Issue
Block a user