From f0ed7d1e2cd1406f553b0dfbc169593b5dca122b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 26 Jun 2026 03:15:53 +0200 Subject: [PATCH] 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. --- backend/app/models.py | 77 ++++++++++++++++--------------------------- 1 file changed, 29 insertions(+), 48 deletions(-) diff --git a/backend/app/models.py b/backend/app/models.py index 86226b3..4ebf14d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -19,7 +19,24 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship 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" 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…). 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( 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" id: Mapped[int] = mapped_column(primary_key=True) @@ -81,14 +95,11 @@ class OAuthToken(Base): access_token: Mapped[str | None] = mapped_column(String) expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 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") -class AuthToken(Base): +class AuthToken(Base, TimestampMixin): """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 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) expires_at: Mapped[datetime] = 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): @@ -128,7 +136,7 @@ class Invite(Base): 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). 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).""" @@ -139,12 +147,9 @@ class DemoWhitelist(Base): email: Mapped[str] = mapped_column(String(320), unique=True, index=True) note: Mapped[str | None] = mapped_column(Text) 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 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( 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") 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.""" __tablename__ = "subscriptions" @@ -202,15 +204,12 @@ class Subscription(Base): Boolean, default=False, server_default="false" ) 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") 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.""" __tablename__ = "videos" @@ -260,14 +259,11 @@ class Video(Base): DateTime(timezone=True), index=True ) 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") -class Tag(Base): +class Tag(Base, TimestampMixin): """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.""" @@ -283,9 +279,6 @@ class Tag(Base): category: Mapped[str] = mapped_column( String(16), default="other", server_default="other" ) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) class ChannelTag(Base): @@ -312,7 +305,7 @@ class ChannelTag(Base): 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 absence of a row means 'new/unseen'.""" @@ -326,7 +319,7 @@ class VideoState(Base): video_id: Mapped[str] = mapped_column( 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") watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # 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 ) 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): @@ -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. `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). synced_fingerprint: Mapped[str | None] = mapped_column(String) 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( 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). 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)) wrap_iv: Mapped[str] = mapped_column(String(32)) key_check: Mapped[str] = mapped_column(Text) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - )