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
+8
View File
@@ -9,6 +9,10 @@ from app.models.login_attempt import LoginAttempt
from app.models.ui_settings import UiSettings
from app.models.event_severity_override import EventSeverityOverride
from app.models.user import User
from app.models.mobile_settings import MobileSettings
from app.models.mobile_enrollment_code import MobileEnrollmentCode
from app.models.mobile_device import MobileDevice
from app.models.mobile_refresh_token import MobileRefreshToken
__all__ = [
"ApiKey",
@@ -23,4 +27,8 @@ __all__ = [
"EventSeverityOverride",
"LoginAttempt",
"UiSettings",
"MobileSettings",
"MobileEnrollmentCode",
"MobileDevice",
"MobileRefreshToken",
]
+25
View File
@@ -0,0 +1,25 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class MobileDevice(Base):
__tablename__ = "mobile_devices"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("sac_users.id", ondelete="CASCADE"), index=True)
device_uuid: Mapped[str] = mapped_column(String(64), unique=True, index=True)
display_name: Mapped[str] = mapped_column(String(128), default="")
platform: Mapped[str] = mapped_column(String(32), default="android")
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
fcm_token: Mapped[str | None] = mapped_column(String(512), nullable=True)
fcm_token_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
enrollment_code_id: Mapped[int | None] = mapped_column(
ForeignKey("mobile_enrollment_codes.id", ondelete="SET NULL"),
)
enrolled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,23 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class MobileEnrollmentCode(Base):
__tablename__ = "mobile_enrollment_codes"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
label: Mapped[str] = mapped_column(String(128), default="")
code_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
code_prefix: Mapped[str] = mapped_column(String(16))
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("sac_users.id", ondelete="SET NULL"))
target_user_id: Mapped[int | None] = mapped_column(ForeignKey("sac_users.id", ondelete="SET NULL"))
login_mode: Mapped[str] = mapped_column(String(16), default="password")
max_uses: Mapped[int] = mapped_column(Integer, default=1)
use_count: Mapped[int] = mapped_column(Integer, default=0)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -0,0 +1,17 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class MobileRefreshToken(Base):
__tablename__ = "mobile_refresh_tokens"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
device_id: Mapped[int] = mapped_column(ForeignKey("mobile_devices.id", ondelete="CASCADE"))
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+21
View File
@@ -0,0 +1,21 @@
from sqlalchemy import Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
MOBILE_SETTINGS_ROW_ID = 1
LOGIN_MODE_PASSWORD = "password"
LOGIN_MODE_CODE_ONLY = "code_only"
LOGIN_MODES = frozenset({LOGIN_MODE_PASSWORD, LOGIN_MODE_CODE_ONLY})
class MobileSettings(Base):
"""Singleton: глобальные настройки мобильного клиента Seaca."""
__tablename__ = "mobile_settings"
id: Mapped[int] = mapped_column(primary_key=True)
devices_allowed: Mapped[bool] = mapped_column(Boolean, default=False)
max_devices_per_user: Mapped[int] = mapped_column(Integer, default=3)
min_app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
@@ -16,3 +16,4 @@ class NotificationPolicy(Base):
use_telegram: Mapped[bool] = mapped_column(Boolean, default=True)
use_webhook: Mapped[bool] = mapped_column(Boolean, default=False)
use_email: Mapped[bool] = mapped_column(Boolean, default=False)
use_mobile: Mapped[bool] = mapped_column(Boolean, default=False)