chore(home): mirror from kalinamall (9883e6a) with papatramp URLs
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
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
|
||||
Reference in New Issue
Block a user