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:
parent
022505ff18
commit
f0ed7d1e2c
1 changed files with 29 additions and 48 deletions
|
|
@ -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()
|
|
||||||
)
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue