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