chore(home): mirror from kalinamall (9883e6a) with papatramp URLs

This commit is contained in:
2026-07-14 20:43:52 +10:00
commit ed4e78f6c3
312 changed files with 42790 additions and 0 deletions
+378
View File
@@ -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