2026-05-20 14:42:55 +08:00
|
|
|
"""Nexus — Settings & Schedules API Routes
|
|
|
|
|
Presentation layer — system settings + push schedules + password presets + audit logs.
|
|
|
|
|
"""
|
|
|
|
|
|
2026-05-21 23:38:01 +08:00
|
|
|
from typing import Optional
|
2026-05-20 14:42:55 +08:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
|
|
|
|
|
|
from server.api.dependencies import get_db
|
2026-05-21 23:38:01 +08:00
|
|
|
from server.api.schemas import ScheduleCreate, ScheduleUpdate, PresetCreate, SettingUpdatePayload
|
2026-05-20 14:42:55 +08:00
|
|
|
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
|
|
|
|
|
from server.infrastructure.database.push_schedule_repo import PushScheduleRepositoryImpl, PushRetryJobRepositoryImpl
|
|
|
|
|
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
|
|
|
|
|
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
|
|
|
|
from server.domain.models import Setting, PushSchedule, PushRetryJob, PasswordPreset, AuditLog
|
|
|
|
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── System Settings ──
|
|
|
|
|
|
|
|
|
|
@router.get("/", response_model=list)
|
|
|
|
|
async def list_settings(db: AsyncSession = Depends(get_db)):
|
|
|
|
|
"""List all system settings"""
|
|
|
|
|
repo = SettingRepositoryImpl(db)
|
|
|
|
|
settings = await repo.get_all()
|
|
|
|
|
return [{"key": s.key, "value": s.value, "updated_at": str(s.updated_at)} for s in settings]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{key}", response_model=dict)
|
|
|
|
|
async def get_setting(key: str, db: AsyncSession = Depends(get_db)):
|
|
|
|
|
"""Get a single setting by key"""
|
|
|
|
|
repo = SettingRepositoryImpl(db)
|
|
|
|
|
value = await repo.get(key)
|
|
|
|
|
if value is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Setting not found")
|
|
|
|
|
return {"key": key, "value": value}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/{key}", response_model=dict)
|
2026-05-21 23:38:01 +08:00
|
|
|
async def set_setting(key: str, payload: SettingUpdatePayload, db: AsyncSession = Depends(get_db)):
|
2026-05-20 14:42:55 +08:00
|
|
|
"""Set a system setting value"""
|
|
|
|
|
repo = SettingRepositoryImpl(db)
|
2026-05-21 23:38:01 +08:00
|
|
|
setting = await repo.set(key, str(payload.value))
|
2026-05-20 14:42:55 +08:00
|
|
|
return {"key": setting.key, "value": setting.value}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Push Schedules ──
|
|
|
|
|
|
|
|
|
|
schedule_router = APIRouter(prefix="/api/schedules", tags=["schedules"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@schedule_router.get("/", response_model=list)
|
|
|
|
|
async def list_schedules(db: AsyncSession = Depends(get_db)):
|
|
|
|
|
"""List all push schedules"""
|
|
|
|
|
repo = PushScheduleRepositoryImpl(db)
|
|
|
|
|
schedules = await repo.get_all()
|
|
|
|
|
return [_schedule_to_dict(s) for s in schedules]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@schedule_router.post("/", response_model=dict, status_code=201)
|
2026-05-21 23:38:01 +08:00
|
|
|
async def create_schedule(payload: ScheduleCreate, db: AsyncSession = Depends(get_db)):
|
|
|
|
|
"""Create a new push schedule"""
|
2026-05-20 14:42:55 +08:00
|
|
|
repo = PushScheduleRepositoryImpl(db)
|
2026-05-21 23:38:01 +08:00
|
|
|
schedule = PushSchedule(**payload.model_dump())
|
2026-05-20 14:42:55 +08:00
|
|
|
created = await repo.create(schedule)
|
|
|
|
|
return _schedule_to_dict(created)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@schedule_router.put("/{id}", response_model=dict)
|
2026-05-21 23:38:01 +08:00
|
|
|
async def update_schedule(id: int, payload: ScheduleUpdate, db: AsyncSession = Depends(get_db)):
|
2026-05-20 14:42:55 +08:00
|
|
|
"""Update a push schedule"""
|
|
|
|
|
repo = PushScheduleRepositoryImpl(db)
|
|
|
|
|
schedule = await repo.get_by_id(id)
|
|
|
|
|
if not schedule:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
2026-05-21 23:38:01 +08:00
|
|
|
for key, value in payload.model_dump(exclude_unset=True).items():
|
2026-05-20 14:42:55 +08:00
|
|
|
if hasattr(schedule, key) and key != "id":
|
|
|
|
|
setattr(schedule, key, value)
|
|
|
|
|
updated = await repo.update(schedule)
|
|
|
|
|
return _schedule_to_dict(updated)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@schedule_router.delete("/{id}", status_code=204)
|
|
|
|
|
async def delete_schedule(id: int, db: AsyncSession = Depends(get_db)):
|
|
|
|
|
"""Delete a push schedule"""
|
|
|
|
|
repo = PushScheduleRepositoryImpl(db)
|
|
|
|
|
result = await repo.delete(id)
|
|
|
|
|
if not result:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Password Presets ──
|
|
|
|
|
|
|
|
|
|
preset_router = APIRouter(prefix="/api/presets", tags=["presets"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@preset_router.get("/", response_model=list)
|
|
|
|
|
async def list_presets(db: AsyncSession = Depends(get_db)):
|
|
|
|
|
"""List all password presets"""
|
|
|
|
|
repo = PasswordPresetRepositoryImpl(db)
|
|
|
|
|
presets = await repo.get_all()
|
|
|
|
|
return [{"id": p.id, "name": p.name, "created_at": str(p.created_at)} for p in presets]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@preset_router.post("/", response_model=dict, status_code=201)
|
2026-05-21 23:38:01 +08:00
|
|
|
async def create_preset(payload: PresetCreate, db: AsyncSession = Depends(get_db)):
|
|
|
|
|
"""Create a password preset (password will be encrypted)"""
|
2026-05-20 14:42:55 +08:00
|
|
|
from server.infrastructure.database.crypto import encrypt_value
|
|
|
|
|
repo = PasswordPresetRepositoryImpl(db)
|
2026-05-21 23:38:01 +08:00
|
|
|
encrypted = encrypt_value(payload.encrypted_pw)
|
|
|
|
|
preset = PasswordPreset(name=payload.name, encrypted_pw=encrypted)
|
2026-05-20 14:42:55 +08:00
|
|
|
created = await repo.create(preset)
|
|
|
|
|
return {"id": created.id, "name": created.name}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@preset_router.delete("/{id}", status_code=204)
|
|
|
|
|
async def delete_preset(id: int, db: AsyncSession = Depends(get_db)):
|
|
|
|
|
"""Delete a password preset"""
|
|
|
|
|
repo = PasswordPresetRepositoryImpl(db)
|
|
|
|
|
result = await repo.delete(id)
|
|
|
|
|
if not result:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Preset not found")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Audit Logs ──
|
|
|
|
|
|
|
|
|
|
audit_router = APIRouter(prefix="/api/audit", tags=["audit"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@audit_router.get("/", response_model=list)
|
|
|
|
|
async def list_audit_logs(
|
|
|
|
|
action: Optional[str] = None,
|
|
|
|
|
limit: int = 200,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
):
|
|
|
|
|
"""List recent audit logs, optionally filtered by action"""
|
|
|
|
|
repo = AuditLogRepositoryImpl(db)
|
|
|
|
|
if action:
|
|
|
|
|
logs = await repo.get_by_action(action, limit)
|
|
|
|
|
else:
|
|
|
|
|
logs = await repo.get_recent(limit)
|
|
|
|
|
return [_audit_to_dict(log) for log in logs]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Retry Jobs ──
|
|
|
|
|
|
|
|
|
|
retry_router = APIRouter(prefix="/api/retries", tags=["retries"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@retry_router.get("/", response_model=list)
|
|
|
|
|
async def list_retry_jobs(limit: int = 100, db: AsyncSession = Depends(get_db)):
|
|
|
|
|
"""List pending retry jobs"""
|
|
|
|
|
repo = PushRetryJobRepositoryImpl(db)
|
|
|
|
|
jobs = await repo.get_pending(limit)
|
|
|
|
|
return [_retry_to_dict(j) for j in jobs]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Helper functions ──
|
|
|
|
|
|
|
|
|
|
def _schedule_to_dict(schedule: PushSchedule) -> dict:
|
|
|
|
|
return {
|
|
|
|
|
"id": schedule.id,
|
|
|
|
|
"name": schedule.name,
|
|
|
|
|
"source_path": schedule.source_path,
|
|
|
|
|
"server_ids": schedule.server_ids,
|
|
|
|
|
"cron_expr": schedule.cron_expr,
|
|
|
|
|
"enabled": schedule.enabled,
|
|
|
|
|
"last_run_at": str(schedule.last_run_at) if schedule.last_run_at else None,
|
|
|
|
|
"created_at": str(schedule.created_at) if schedule.created_at else None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _audit_to_dict(log: AuditLog) -> dict:
|
|
|
|
|
return {
|
|
|
|
|
"id": log.id,
|
|
|
|
|
"admin_username": log.admin_username,
|
|
|
|
|
"action": log.action,
|
|
|
|
|
"target_type": log.target_type,
|
|
|
|
|
"target_id": log.target_id,
|
|
|
|
|
"detail": log.detail,
|
|
|
|
|
"ip_address": log.ip_address,
|
|
|
|
|
"created_at": str(log.created_at) if log.created_at else None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _retry_to_dict(job: PushRetryJob) -> dict:
|
|
|
|
|
return {
|
|
|
|
|
"id": job.id,
|
|
|
|
|
"server_id": job.server_id,
|
|
|
|
|
"server_name": job.server_name,
|
|
|
|
|
"operator": job.operator,
|
|
|
|
|
"source_path": job.source_path,
|
|
|
|
|
"target_path": job.target_path,
|
|
|
|
|
"status": job.status,
|
|
|
|
|
"retry_count": job.retry_count,
|
|
|
|
|
"max_retries": job.max_retries,
|
|
|
|
|
"next_retry_at": str(job.next_retry_at) if job.next_retry_at else None,
|
|
|
|
|
"last_error": job.last_error,
|
|
|
|
|
"created_at": str(job.created_at) if job.created_at else None,
|
|
|
|
|
}
|