Coverage for app/backend/src/couchers/models/ota.py: 100%
25 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-01 03:25 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-01 03:25 +0000
1import enum
2from datetime import datetime
3from typing import TYPE_CHECKING
5from sqlalchemy import (
6 BigInteger,
7 CheckConstraint,
8 DateTime,
9 Enum,
10 ForeignKey,
11 Index,
12 String,
13 UniqueConstraint,
14 func,
15)
16from sqlalchemy.orm import Mapped, mapped_column, relationship
18from couchers.models.base import Base
20if TYPE_CHECKING:
21 from couchers.models.users import User
24class OTAPlatform(enum.Enum):
25 ios = enum.auto()
26 android = enum.auto()
29class OTAPackage(Base, kw_only=True):
30 # The signed manifest bytes live on the CDN under `version`; this row only records which bundle is
31 # available and how recent it is, so the backend can resolve a request and fetch the bytes verbatim.
32 __tablename__ = "ota_packages"
34 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
35 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
37 creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
39 platform: Mapped[OTAPlatform] = mapped_column(Enum(OTAPlatform))
40 # The manifest's runtimeVersion / build's expo-runtime-version. A build only accepts a manifest whose
41 # runtimeVersion equals its own, so (platform, fingerprint) is the compatibility key.
42 fingerprint: Mapped[str] = mapped_column(String)
43 # The CDN path component the signed manifest is published under, e.g. v1.3.<commit>.<sha>.
44 version: Mapped[str] = mapped_column(String)
45 # The manifest's createdAt: the publish/stamp time used to order rollouts. A rollback rolls forward by
46 # republishing the good bundle re-stamped with a newer createdAt so it sorts newest.
47 manifest_created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
48 # NOT unique: a re-stamped rollback reuses the same bundle content and so can repeat an id.
49 manifest_id: Mapped[str] = mapped_column(String)
51 # Stops handing this bundle to new check-ins; can't reclaim devices already on it (they only move
52 # forward in createdAt), so it's a stop-gap until a re-stamped rollback is published.
53 banned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
54 banned_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
55 banned_reason: Mapped[str | None] = mapped_column(String, default=None)
57 creator_user: Mapped[User] = relationship(init=False, foreign_keys="OTAPackage.creator_user_id")
58 banned_by_user: Mapped[User | None] = relationship(init=False, foreign_keys="OTAPackage.banned_by_user_id")
60 __table_args__ = (
61 UniqueConstraint("platform", "version", name="uq_ota_packages_platform_version"),
62 Index("ix_ota_packages_resolve", "platform", "fingerprint", "manifest_created_at"),
63 # All three ban columns move together: either the package isn't banned, or every audit field
64 # is filled in. Bans are irreversible (rolled forward by republishing) so the reason is
65 # required.
66 CheckConstraint(
67 "(banned_at IS NULL AND banned_by_user_id IS NULL AND banned_reason IS NULL) "
68 "OR (banned_at IS NOT NULL AND banned_by_user_id IS NOT NULL AND banned_reason IS NOT NULL)",
69 name="ck_ota_packages_ban_columns_consistent",
70 ),
71 )