3d36faa49d
При повторной привязке Seaca переиспользуется запись device_uuid вместо INSERT.
Добавлены POST /devices/{id}/revoke и DELETE для полного удаления записи в настройках SAC.
379 lines
12 KiB
Python
379 lines
12 KiB
Python
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,
|
|
delete_device_record,
|
|
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.post("/devices/{device_id}/revoke", response_model=MobileDeviceResponse)
|
|
def post_revoke_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.delete("/devices/{device_id}", status_code=204)
|
|
def delete_device(
|
|
device_id: int,
|
|
db: Session = Depends(get_db),
|
|
_user=Depends(require_admin),
|
|
) -> None:
|
|
try:
|
|
delete_device_record(db, device_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
|
|
|
|
@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
|