Coverage for app/backend/src/couchers/models/ota.py: 100%
33 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 17:03 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 17:03 +0000
1import enum
2import uuid
3from datetime import datetime
4from typing import TYPE_CHECKING
6from sqlalchemy import (
7 BigInteger,
8 CheckConstraint,
9 DateTime,
10 Enum,
11 ForeignKey,
12 Index,
13 String,
14 UniqueConstraint,
15 Uuid,
16 func,
17)
18from sqlalchemy.orm import Mapped, mapped_column, relationship
20from couchers.models.base import Base
22if TYPE_CHECKING:
23 from couchers.models.users import User
26class OTAPlatform(enum.Enum):
27 ios = enum.auto()
28 android = enum.auto()
31class OTAPackage(Base, kw_only=True):
32 # The signed manifest bytes live on the CDN under `version`; this row only records which bundle is
33 # available and how recent it is, so the backend can resolve a request and fetch the bytes verbatim.
34 __tablename__ = "ota_packages"
36 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
37 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
39 creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
41 platform: Mapped[OTAPlatform] = mapped_column(Enum(OTAPlatform))
42 # The manifest's runtimeVersion / build's expo-runtime-version. A build only accepts a manifest whose
43 # runtimeVersion equals its own, so (platform, fingerprint) is the compatibility key.
44 fingerprint: Mapped[str] = mapped_column(String)
45 # The CDN path component the signed manifest is published under, e.g. v1.3.<commit>.<sha>.
46 version: Mapped[str] = mapped_column(String)
47 # The manifest's createdAt: the publish/stamp time used to order rollouts. A rollback rolls forward by
48 # republishing the good bundle re-stamped with a newer createdAt so it sorts newest.
49 manifest_created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
50 # Restamped to a fresh UUID on every publish — expo-updates skips updates whose id matches the
51 # installed one, so reusing an id silently drops the publish.
52 manifest_id: Mapped[str] = mapped_column(String)
54 # Stops handing this bundle to new check-ins; can't reclaim devices already on it (they only move
55 # forward in createdAt), so it's a stop-gap until a re-stamped rollback is published.
56 banned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
57 banned_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
58 banned_reason: Mapped[str | None] = mapped_column(String, default=None)
60 creator_user: Mapped[User] = relationship(init=False, foreign_keys="OTAPackage.creator_user_id")
61 banned_by_user: Mapped[User | None] = relationship(init=False, foreign_keys="OTAPackage.banned_by_user_id")
63 __table_args__ = (
64 UniqueConstraint("platform", "version", name="uq_ota_packages_platform_version"),
65 UniqueConstraint("platform", "manifest_id", name="uq_ota_packages_platform_manifest_id"),
66 Index("ix_ota_packages_resolve", "platform", "fingerprint", "manifest_created_at"),
67 # All three ban columns move together: either the package isn't banned, or every audit field
68 # is filled in. Bans are irreversible (rolled forward by republishing) so the reason is
69 # required.
70 CheckConstraint(
71 "(banned_at IS NULL AND banned_by_user_id IS NULL AND banned_reason IS NULL) "
72 "OR (banned_at IS NOT NULL AND banned_by_user_id IS NOT NULL AND banned_reason IS NOT NULL)",
73 name="ck_ota_packages_ban_columns_consistent",
74 ),
75 )
78class NativeClientUser(Base, kw_only=True):
79 # Append-only log of (eas_client_id, user_id) sightings. Each authenticated CheckNativeStatus
80 # writes a row; the newest row for a given eas_client_id is the current user-of-record. Keeping
81 # history (rather than upserting) lets us reconstruct who was using an install at any past time
82 # for incident debugging, and a shared install shows up as alternating user_ids over time.
83 __tablename__ = "native_client_users"
85 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
86 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
87 eas_client_id: Mapped[uuid.UUID] = mapped_column(Uuid, index=True)
88 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
90 user: Mapped[User] = relationship(init=False)