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:
@@ -0,0 +1,365 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.jwt_auth import create_access_token, get_current_user, require_admin, CurrentUser
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.mobile_settings import LOGIN_MODES
|
||||
from app.services.mobile_devices import (
|
||||
get_device_or_raise,
|
||||
list_mobile_devices,
|
||||
revoke_device,
|
||||
touch_device,
|
||||
update_fcm_token,
|
||||
assert_device_active,
|
||||
)
|
||||
from app.services.mobile_enrollment import (
|
||||
create_enrollment_code,
|
||||
enroll_device,
|
||||
list_enrollment_codes,
|
||||
revoke_enrollment_code,
|
||||
)
|
||||
from app.services.mobile_notify import (
|
||||
MobileNotConfiguredError,
|
||||
MobileSendError,
|
||||
get_effective_fcm_config,
|
||||
send_test_push,
|
||||
)
|
||||
from app.services.mobile_settings import upsert_mobile_settings, get_effective_mobile_settings
|
||||
from app.services.mobile_tokens import find_valid_refresh_token, revoke_refresh_tokens_for_device, store_refresh_token
|
||||
|
||||
router = APIRouter(prefix="/mobile", tags=["mobile"])
|
||||
|
||||
|
||||
class MobileSettingsResponse(BaseModel):
|
||||
devices_allowed: bool
|
||||
max_devices_per_user: int
|
||||
min_app_version: str | None = None
|
||||
fcm_enabled: bool
|
||||
fcm_configured: bool
|
||||
source: str
|
||||
|
||||
|
||||
class MobileSettingsUpdate(BaseModel):
|
||||
devices_allowed: bool
|
||||
max_devices_per_user: int = Field(ge=1, le=50)
|
||||
min_app_version: str | None = None
|
||||
|
||||
|
||||
class EnrollmentCodeCreate(BaseModel):
|
||||
label: str = ""
|
||||
target_user_id: int | None = None
|
||||
login_mode: str = "password"
|
||||
max_uses: int = Field(default=1, ge=1, le=100)
|
||||
expires_in_hours: int = Field(default=24, ge=1, le=720)
|
||||
|
||||
|
||||
class EnrollmentCodeResponse(BaseModel):
|
||||
id: int
|
||||
label: str
|
||||
code_prefix: str
|
||||
target_user_id: int | None
|
||||
target_username: str | None
|
||||
login_mode: str
|
||||
max_uses: int
|
||||
use_count: int
|
||||
expires_at: datetime
|
||||
revoked_at: datetime | None
|
||||
created_at: datetime
|
||||
is_active: bool
|
||||
|
||||
|
||||
class EnrollmentCodeCreatedResponse(EnrollmentCodeResponse):
|
||||
enrollment_code: str = Field(description="Показывается один раз при создании")
|
||||
|
||||
|
||||
class MobileDeviceResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
username: str
|
||||
device_uuid: str
|
||||
display_name: str
|
||||
platform: str
|
||||
app_version: str | None
|
||||
has_fcm_token: bool
|
||||
enrolled_at: datetime
|
||||
last_seen_at: datetime | None
|
||||
revoked_at: datetime | None
|
||||
is_active: bool
|
||||
|
||||
|
||||
class EnrollRequest(BaseModel):
|
||||
enrollment_code: str
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
device_uuid: str
|
||||
display_name: str = "Android"
|
||||
platform: str = "android"
|
||||
app_version: str | None = None
|
||||
fcm_token: str | None = None
|
||||
|
||||
|
||||
class EnrollResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
device_id: int
|
||||
username: str
|
||||
role: str
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
device_uuid: str
|
||||
|
||||
|
||||
class FcmTokenUpdate(BaseModel):
|
||||
fcm_token: str
|
||||
|
||||
|
||||
class TestPushResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str
|
||||
|
||||
|
||||
def _settings_response(db: Session) -> MobileSettingsResponse:
|
||||
cfg = get_effective_mobile_settings(db)
|
||||
fcm = get_effective_fcm_config()
|
||||
return MobileSettingsResponse(
|
||||
devices_allowed=cfg.devices_allowed,
|
||||
max_devices_per_user=cfg.max_devices_per_user,
|
||||
min_app_version=cfg.min_app_version,
|
||||
fcm_enabled=fcm.enabled,
|
||||
fcm_configured=fcm.configured,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
def _code_to_response(item) -> EnrollmentCodeResponse:
|
||||
return EnrollmentCodeResponse(
|
||||
id=item.id,
|
||||
label=item.label,
|
||||
code_prefix=item.code_prefix,
|
||||
target_user_id=item.target_user_id,
|
||||
target_username=item.target_username,
|
||||
login_mode=item.login_mode,
|
||||
max_uses=item.max_uses,
|
||||
use_count=item.use_count,
|
||||
expires_at=item.expires_at,
|
||||
revoked_at=item.revoked_at,
|
||||
created_at=item.created_at,
|
||||
is_active=item.is_active,
|
||||
)
|
||||
|
||||
|
||||
def _device_to_response(item) -> MobileDeviceResponse:
|
||||
return MobileDeviceResponse(
|
||||
id=item.id,
|
||||
user_id=item.user_id,
|
||||
username=item.username,
|
||||
device_uuid=item.device_uuid,
|
||||
display_name=item.display_name,
|
||||
platform=item.platform,
|
||||
app_version=item.app_version,
|
||||
has_fcm_token=item.has_fcm_token,
|
||||
enrolled_at=item.enrolled_at,
|
||||
last_seen_at=item.last_seen_at,
|
||||
revoked_at=item.revoked_at,
|
||||
is_active=item.is_active,
|
||||
)
|
||||
|
||||
|
||||
def _admin_user_id(db: Session, username: str) -> int | None:
|
||||
user = db.scalar(select(User).where(func.lower(User.username) == username.lower()))
|
||||
return user.id if user else None
|
||||
|
||||
|
||||
@router.get("/settings", response_model=MobileSettingsResponse)
|
||||
def get_mobile_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> MobileSettingsResponse:
|
||||
return _settings_response(db)
|
||||
|
||||
|
||||
@router.put("/settings", response_model=MobileSettingsResponse)
|
||||
def update_mobile_settings(
|
||||
body: MobileSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> MobileSettingsResponse:
|
||||
try:
|
||||
upsert_mobile_settings(
|
||||
db,
|
||||
devices_allowed=body.devices_allowed,
|
||||
max_devices_per_user=body.max_devices_per_user,
|
||||
min_app_version=body.min_app_version,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return _settings_response(db)
|
||||
|
||||
|
||||
@router.get("/enrollment-codes", response_model=list[EnrollmentCodeResponse])
|
||||
def get_enrollment_codes(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> list[EnrollmentCodeResponse]:
|
||||
return [_code_to_response(item) for item in list_enrollment_codes(db)]
|
||||
|
||||
|
||||
@router.post("/enrollment-codes", response_model=EnrollmentCodeCreatedResponse)
|
||||
def post_enrollment_code(
|
||||
body: EnrollmentCodeCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_admin),
|
||||
) -> EnrollmentCodeCreatedResponse:
|
||||
if body.login_mode not in LOGIN_MODES:
|
||||
raise HTTPException(status_code=422, detail=f"login_mode must be one of: {sorted(LOGIN_MODES)}")
|
||||
try:
|
||||
summary, plaintext = create_enrollment_code(
|
||||
db,
|
||||
created_by_user_id=_admin_user_id(db, user.username),
|
||||
label=body.label,
|
||||
target_user_id=body.target_user_id,
|
||||
login_mode=body.login_mode,
|
||||
max_uses=body.max_uses,
|
||||
expires_in_hours=body.expires_in_hours,
|
||||
)
|
||||
db.commit()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
base = _code_to_response(summary)
|
||||
return EnrollmentCodeCreatedResponse(**base.model_dump(), enrollment_code=plaintext)
|
||||
|
||||
|
||||
@router.delete("/enrollment-codes/{code_id}", status_code=204)
|
||||
def delete_enrollment_code(
|
||||
code_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> None:
|
||||
try:
|
||||
revoke_enrollment_code(db, code_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/devices", response_model=list[MobileDeviceResponse])
|
||||
def get_devices(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> list[MobileDeviceResponse]:
|
||||
return [_device_to_response(item) for item in list_mobile_devices(db)]
|
||||
|
||||
|
||||
@router.delete("/devices/{device_id}", response_model=MobileDeviceResponse)
|
||||
def delete_device(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> MobileDeviceResponse:
|
||||
try:
|
||||
summary = revoke_device(db, device_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return _device_to_response(summary)
|
||||
|
||||
|
||||
@router.post("/devices/{device_id}/test-push", response_model=TestPushResponse)
|
||||
def test_device_push(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> TestPushResponse:
|
||||
try:
|
||||
send_test_push(db=db, device_id=device_id)
|
||||
except MobileNotConfiguredError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except MobileSendError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return TestPushResponse(message="Тестовое push отправлено на устройство")
|
||||
|
||||
|
||||
@router.post("/enroll", response_model=EnrollResponse)
|
||||
def post_enroll(body: EnrollRequest, db: Session = Depends(get_db)) -> EnrollResponse:
|
||||
try:
|
||||
result = enroll_device(
|
||||
db,
|
||||
enrollment_code=body.enrollment_code,
|
||||
username=body.username,
|
||||
password=body.password,
|
||||
device_uuid=body.device_uuid,
|
||||
display_name=body.display_name,
|
||||
platform=body.platform,
|
||||
app_version=body.app_version,
|
||||
fcm_token=body.fcm_token,
|
||||
create_access_token=create_access_token,
|
||||
)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return EnrollResponse(
|
||||
access_token=result.access_token,
|
||||
refresh_token=result.refresh_token,
|
||||
device_id=result.device_id,
|
||||
username=result.username,
|
||||
role=result.role,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/refresh", response_model=EnrollResponse)
|
||||
def post_refresh(body: RefreshRequest, db: Session = Depends(get_db)) -> EnrollResponse:
|
||||
token_row = find_valid_refresh_token(db, body.refresh_token.strip())
|
||||
if token_row is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
device = get_device_or_raise(db, token_row.device_id)
|
||||
try:
|
||||
assert_device_active(device)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
||||
if device.device_uuid != body.device_uuid.strip():
|
||||
raise HTTPException(status_code=401, detail="device_uuid mismatch")
|
||||
|
||||
user = db.get(User, device.user_id)
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(status_code=401, detail="User inactive")
|
||||
|
||||
revoke_refresh_tokens_for_device(db, device.id)
|
||||
refresh_plain = store_refresh_token(db, device_id=device.id)
|
||||
touch_device(db, device)
|
||||
access_token = create_access_token(user.username, user.role, device_id=device.id)
|
||||
db.commit()
|
||||
|
||||
return EnrollResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_plain,
|
||||
device_id=device.id,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/devices/me/fcm", status_code=204)
|
||||
def put_my_fcm_token(
|
||||
body: FcmTokenUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
if user.device_id is None:
|
||||
raise HTTPException(status_code=403, detail="Mobile device token required")
|
||||
device = get_device_or_raise(db, user.device_id)
|
||||
try:
|
||||
assert_device_active(device)
|
||||
update_fcm_token(db, device, body.fcm_token)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
Reference in New Issue
Block a user