refactor(models): extract Timestamp/UpdatedAt mixins; fix stale status comment

TimestampMixin (created_at) + UpdatedAtMixin (updated_at) replace the repeated
column blocks across 9 + 3 models. Models that index created_at or default it
Python-side keep their own column on purpose. Verified zero schema drift via an
identical alembic-check diff before/after.
This commit is contained in:
npeter83 2026-06-26 03:15:53 +02:00
parent 022505ff18
commit f0ed7d1e2c

View file

@ -19,7 +19,24 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base from app.db import Base
class User(Base): class TimestampMixin:
"""Adds a server-defaulted `created_at`. Models that index created_at or order it
differently (QuotaEvent, Notification, Message) keep their own column on purpose."""
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class UpdatedAtMixin:
"""Adds an `updated_at` that server-defaults on insert and bumps on every UPDATE."""
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class User(Base, TimestampMixin):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
@ -57,9 +74,6 @@ class User(Base):
) )
# Free-form UI preferences (theme, color scheme, font scale, default filters…). # Free-form UI preferences (theme, color scheme, font scale, default filters…).
preferences: Mapped[dict | None] = mapped_column(JSON) preferences: Mapped[dict | None] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
token: Mapped["OAuthToken | None"] = relationship( token: Mapped["OAuthToken | None"] = relationship(
back_populates="user", uselist=False, cascade="all, delete-orphan" back_populates="user", uselist=False, cascade="all, delete-orphan"
@ -69,7 +83,7 @@ class User(Base):
) )
class OAuthToken(Base): class OAuthToken(Base, UpdatedAtMixin):
__tablename__ = "oauth_tokens" __tablename__ = "oauth_tokens"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
@ -81,14 +95,11 @@ class OAuthToken(Base):
access_token: Mapped[str | None] = mapped_column(String) access_token: Mapped[str | None] = mapped_column(String)
expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
scopes: Mapped[str | None] = mapped_column(String) scopes: Mapped[str | None] = mapped_column(String)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped["User"] = relationship(back_populates="token") user: Mapped["User"] = relationship(back_populates="token")
class AuthToken(Base): class AuthToken(Base, TimestampMixin):
"""Single-use, expiring token for email verification or password reset. Only the SHA-256 """Single-use, expiring token for email verification or password reset. Only the SHA-256
HASH of the token is stored, so a DB leak can't be used to verify/reset; the raw token HASH of the token is stored, so a DB leak can't be used to verify/reset; the raw token
lives only in the emailed link. Consumed (used_at set) on first valid use.""" lives only in the emailed link. Consumed (used_at set) on first valid use."""
@ -103,9 +114,6 @@ class AuthToken(Base):
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class Invite(Base): class Invite(Base):
@ -128,7 +136,7 @@ class Invite(Base):
decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email
class DemoWhitelist(Base): class DemoWhitelist(Base, TimestampMixin):
"""Emails that may enter the shared demo account from the login page (no Google OAuth). """Emails that may enter the shared demo account from the login page (no Google OAuth).
A hidden, admin-curated list of "keys to the same door" every entry logs into the one A hidden, admin-curated list of "keys to the same door" every entry logs into the one
shared demo user. Distinct from Invite (which gates real Google sign-in).""" shared demo user. Distinct from Invite (which gates real Google sign-in)."""
@ -139,12 +147,9 @@ class DemoWhitelist(Base):
email: Mapped[str] = mapped_column(String(320), unique=True, index=True) email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
note: Mapped[str | None] = mapped_column(Text) note: Mapped[str | None] = mapped_column(Text)
added_by: Mapped[str | None] = mapped_column(String(320)) # admin email added_by: Mapped[str | None] = mapped_column(String(320)) # admin email
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class Channel(Base): class Channel(Base, TimestampMixin):
"""A YouTube channel. Shared across all users (one channel's videos are the same """A YouTube channel. Shared across all users (one channel's videos are the same
for everyone), so its expensive metadata is fetched and stored only once.""" for everyone), so its expensive metadata is fetched and stored only once."""
@ -170,15 +175,12 @@ class Channel(Base):
backfill_done: Mapped[bool] = mapped_column( backfill_done: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false" Boolean, default=False, server_default="false"
) )
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
videos: Mapped[list["Video"]] = relationship(back_populates="channel") videos: Mapped[list["Video"]] = relationship(back_populates="channel")
subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel") subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel")
class Subscription(Base): class Subscription(Base, TimestampMixin):
"""Per-user link to a channel, with the user's own priority/visibility overrides.""" """Per-user link to a channel, with the user's own priority/visibility overrides."""
__tablename__ = "subscriptions" __tablename__ = "subscriptions"
@ -202,15 +204,12 @@ class Subscription(Base):
Boolean, default=False, server_default="false" Boolean, default=False, server_default="false"
) )
subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
user: Mapped["User"] = relationship(back_populates="subscriptions") user: Mapped["User"] = relationship(back_populates="subscriptions")
channel: Mapped["Channel"] = relationship(back_populates="subscriptions") channel: Mapped["Channel"] = relationship(back_populates="subscriptions")
class Video(Base): class Video(Base, TimestampMixin):
"""A YouTube video. Shared across users; per-user state lives elsewhere.""" """A YouTube video. Shared across users; per-user state lives elsewhere."""
__tablename__ = "videos" __tablename__ = "videos"
@ -260,14 +259,11 @@ class Video(Base):
DateTime(timezone=True), index=True DateTime(timezone=True), index=True
) )
unavailable_reason: Mapped[str | None] = mapped_column(String(24)) unavailable_reason: Mapped[str | None] = mapped_column(String(24))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
channel: Mapped["Channel"] = relationship(back_populates="videos") channel: Mapped["Channel"] = relationship(back_populates="videos")
class Tag(Base): class Tag(Base, TimestampMixin):
"""A label. System tags (user_id NULL) are computed automatically and shared by all """A label. System tags (user_id NULL) are computed automatically and shared by all
users; user tags (user_id set) are personal. category groups them in the UI.""" users; user tags (user_id set) are personal. category groups them in the UI."""
@ -283,9 +279,6 @@ class Tag(Base):
category: Mapped[str] = mapped_column( category: Mapped[str] = mapped_column(
String(16), default="other", server_default="other" String(16), default="other", server_default="other"
) )
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class ChannelTag(Base): class ChannelTag(Base):
@ -312,7 +305,7 @@ class ChannelTag(Base):
confidence: Mapped[float | None] = mapped_column(Float) confidence: Mapped[float | None] = mapped_column(Float)
class VideoState(Base): class VideoState(Base, UpdatedAtMixin):
"""Per-user watch state for a video. Rows exist only for non-default states; the """Per-user watch state for a video. Rows exist only for non-default states; the
absence of a row means 'new/unseen'.""" absence of a row means 'new/unseen'."""
@ -326,7 +319,7 @@ class VideoState(Base):
video_id: Mapped[str] = mapped_column( video_id: Mapped[str] = mapped_column(
ForeignKey("videos.id", ondelete="CASCADE"), index=True ForeignKey("videos.id", ondelete="CASCADE"), index=True
) )
# new | watched | saved | hidden # new | watched | hidden ("saved" is now Watch-later playlist membership, not a status)
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new") status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Resume position (seconds into the video) for the in-app player. Orthogonal to # Resume position (seconds into the video) for the in-app player. Orthogonal to
@ -337,9 +330,6 @@ class VideoState(Base):
Integer, default=0, server_default="0", nullable=False Integer, default=0, server_default="0", nullable=False
) )
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class ApiQuotaUsage(Base): class ApiQuotaUsage(Base):
@ -422,7 +412,7 @@ class SystemConfig(Base):
) )
class Playlist(Base): class Playlist(Base, TimestampMixin, UpdatedAtMixin):
"""A per-user named, ordered collection of videos from the shared catalog. """A per-user named, ordered collection of videos from the shared catalog.
`kind`: 'user' (normal) or 'watch_later' (built-in, undeletable, one per user). `kind`: 'user' (normal) or 'watch_later' (built-in, undeletable, one per user).
@ -450,12 +440,6 @@ class Playlist(Base):
# baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty). # baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty).
synced_fingerprint: Mapped[str | None] = mapped_column(String) synced_fingerprint: Mapped[str | None] = mapped_column(String)
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0") position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
items: Mapped[list["PlaylistItem"]] = relationship( items: Mapped[list["PlaylistItem"]] = relationship(
back_populates="playlist", back_populates="playlist",
@ -560,7 +544,7 @@ class Message(Base):
) )
class MessageKey(Base): class MessageKey(Base, TimestampMixin):
"""A user's end-to-end-encryption key material for direct messaging (phase 2). """A user's end-to-end-encryption key material for direct messaging (phase 2).
One row per user. `public_key` (base64 SPKI, ECDH P-256) is shared with others so they can One row per user. `public_key` (base64 SPKI, ECDH P-256) is shared with others so they can
@ -581,6 +565,3 @@ class MessageKey(Base):
salt: Mapped[str] = mapped_column(String(64)) salt: Mapped[str] = mapped_column(String(64))
wrap_iv: Mapped[str] = mapped_column(String(32)) wrap_iv: Mapped[str] = mapped_column(String(32))
key_check: Mapped[str] = mapped_column(Text) key_check: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)