feat: Seaca mobile API, enrollment, FCM push and admin UI (0.9.0)

Adds mobile device registration by admin codes, refresh tokens, push channel
in notification policy, and Settings section for managing Seaca clients.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-10 13:19:14 +10:00
parent 86c602cf07
commit 563b836acc
32 changed files with 1916 additions and 45 deletions
@@ -0,0 +1,105 @@
"""mobile settings, enrollment codes, devices, refresh tokens; policy use_mobile
Revision ID: 014
Revises: 013
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "014"
down_revision: Union[str, None] = "013"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
MOBILE_SETTINGS_ROW_ID = 1
def upgrade() -> None:
op.add_column(
"notification_policy",
sa.Column("use_mobile", sa.Boolean(), nullable=False, server_default=sa.text("false")),
)
op.create_table(
"mobile_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("devices_allowed", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("max_devices_per_user", sa.Integer(), nullable=False, server_default="3"),
sa.Column("min_app_version", sa.String(length=32), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.execute(
sa.text(
"INSERT INTO mobile_settings (id, devices_allowed, max_devices_per_user) "
f"VALUES ({MOBILE_SETTINGS_ROW_ID}, false, 3)"
)
)
op.create_table(
"mobile_enrollment_codes",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("label", sa.String(length=128), nullable=False, server_default=""),
sa.Column("code_hash", sa.String(length=64), nullable=False),
sa.Column("code_prefix", sa.String(length=16), nullable=False),
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
sa.Column("target_user_id", sa.Integer(), nullable=True),
sa.Column("login_mode", sa.String(length=16), nullable=False, server_default="password"),
sa.Column("max_uses", sa.Integer(), nullable=False, server_default="1"),
sa.Column("use_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["created_by_user_id"], ["sac_users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["target_user_id"], ["sac_users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_mobile_enrollment_codes_code_hash", "mobile_enrollment_codes", ["code_hash"], unique=True)
op.create_table(
"mobile_devices",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("device_uuid", sa.String(length=64), nullable=False),
sa.Column("display_name", sa.String(length=128), nullable=False, server_default=""),
sa.Column("platform", sa.String(length=32), nullable=False, server_default="android"),
sa.Column("app_version", sa.String(length=32), nullable=True),
sa.Column("fcm_token", sa.String(length=512), nullable=True),
sa.Column("fcm_token_updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("enrollment_code_id", sa.Integer(), nullable=True),
sa.Column("enrolled_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["sac_users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["enrollment_code_id"], ["mobile_enrollment_codes.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_mobile_devices_device_uuid", "mobile_devices", ["device_uuid"], unique=True)
op.create_index("ix_mobile_devices_user_id", "mobile_devices", ["user_id"])
op.create_table(
"mobile_refresh_tokens",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("device_id", sa.Integer(), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["device_id"], ["mobile_devices.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_mobile_refresh_tokens_token_hash", "mobile_refresh_tokens", ["token_hash"], unique=True)
def downgrade() -> None:
op.drop_index("ix_mobile_refresh_tokens_token_hash", table_name="mobile_refresh_tokens")
op.drop_table("mobile_refresh_tokens")
op.drop_index("ix_mobile_devices_user_id", table_name="mobile_devices")
op.drop_index("ix_mobile_devices_device_uuid", table_name="mobile_devices")
op.drop_table("mobile_devices")
op.drop_index("ix_mobile_enrollment_codes_code_hash", table_name="mobile_enrollment_codes")
op.drop_table("mobile_enrollment_codes")
op.drop_table("mobile_settings")
op.drop_column("notification_policy", "use_mobile")