Files
vmis/backend/app/api/v1/servers.py
VMIS Developer 42d1420f9c feat(backend): Phase 1-4 全新開發完成,37/37 TDD 通過
[Phase 0 Reset]
- 清除舊版 app/、alembic/versions/、雜亂測試腳本
- 新 requirements.txt (移除 caldav/redis/keycloak-lib,加入 apscheduler/croniter/docker/paramiko/ping3/dnspython)

[Phase 1 資料庫]
- 9 張資料表 SQLAlchemy Models:tenants / accounts / schedules / schedule_logs /
  tenant_schedule_results / account_schedule_results / servers / server_status_logs / system_status_logs
- Alembic migration 001_create_all_tables (已套用到 10.1.0.20:5433/virtual_mis)
- seed.py:schedules 初始 3 筆 / servers 初始 4 筆

[Phase 2 CRUD API]
- GET/POST/PUT/DELETE: /api/v1/tenants / accounts / servers / schedules
- /api/v1/system-status
- 帳號編碼自動產生 (prefix + seq_no 4碼左補0)
- 燈號 (lights) 從最新排程結果取得

[Phase 3 Watchdog]
- APScheduler interval 3分鐘,原子 UPDATE status=Going 防重複執行
- 手動觸發 API: POST /api/v1/schedules/{id}/run

[Phase 4 Service Clients]
- KeycloakClient:vmis-admin realm,REST API (不用 python-keycloak)
- MailClient:Docker Mailserver @ 10.1.0.254:8080,含 MX DNS 驗證
- DockerClient:docker-py 本機 + paramiko SSH 遠端 compose
- NextcloudClient:OCS API user/quota
- SystemChecker:功能驗證 (traefik routers>0 / keycloak token / SMTP EHLO / DB SELECT 1 / ping)

[TDD]
- 37 tests / 37 passed (2.11s)
- SQLite in-memory + StaticPool,無需外部 DB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 13:10:15 +08:00

105 lines
3.8 KiB
Python

from typing import List, Optional
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, case
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.server import Server, ServerStatusLog
from app.schemas.server import ServerCreate, ServerUpdate, ServerResponse, ServerAvailability
router = APIRouter(prefix="/servers", tags=["servers"])
def _calc_availability(db: Session, server_id: int, days: int) -> Optional[float]:
since = datetime.utcnow() - timedelta(days=days)
row = (
db.query(
func.count().label("total"),
func.sum(case((ServerStatusLog.result == True, 1), else_=0)).label("ok"),
)
.filter(ServerStatusLog.server_id == server_id, ServerStatusLog.recorded_at >= since)
.first()
)
if not row or not row.total:
return None
return round(row.ok * 100.0 / row.total, 2)
def _get_last_status(db: Session, server_id: int):
return (
db.query(ServerStatusLog)
.filter(ServerStatusLog.server_id == server_id)
.order_by(ServerStatusLog.recorded_at.desc(), ServerStatusLog.id.desc())
.first()
)
@router.get("", response_model=List[ServerResponse])
def list_servers(db: Session = Depends(get_db)):
servers = db.query(Server).order_by(Server.sort_order).all()
result = []
for s in servers:
resp = ServerResponse.model_validate(s)
last = _get_last_status(db, s.id)
if last:
resp.last_result = last.result
resp.last_response_time = last.response_time
resp.availability = ServerAvailability(
availability_30d=_calc_availability(db, s.id, 30),
availability_90d=_calc_availability(db, s.id, 90),
availability_365d=_calc_availability(db, s.id, 365),
)
result.append(resp)
return result
@router.post("", response_model=ServerResponse, status_code=201)
def create_server(payload: ServerCreate, db: Session = Depends(get_db)):
if db.query(Server).filter(Server.ip_address == payload.ip_address).first():
raise HTTPException(status_code=409, detail="IP address already exists")
server = Server(**payload.model_dump())
db.add(server)
db.commit()
db.refresh(server)
return ServerResponse.model_validate(server)
@router.get("/{server_id}", response_model=ServerResponse)
def get_server(server_id: int, db: Session = Depends(get_db)):
server = db.get(Server, server_id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
resp = ServerResponse.model_validate(server)
last = _get_last_status(db, server_id)
if last:
resp.last_result = last.result
resp.last_response_time = last.response_time
resp.availability = ServerAvailability(
availability_30d=_calc_availability(db, server_id, 30),
availability_90d=_calc_availability(db, server_id, 90),
availability_365d=_calc_availability(db, server_id, 365),
)
return resp
@router.put("/{server_id}", response_model=ServerResponse)
def update_server(server_id: int, payload: ServerUpdate, db: Session = Depends(get_db)):
server = db.get(Server, server_id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
for field, value in payload.model_dump(exclude_none=True).items():
setattr(server, field, value)
db.commit()
db.refresh(server)
return ServerResponse.model_validate(server)
@router.delete("/{server_id}", status_code=204)
def delete_server(server_id: int, db: Session = Depends(get_db)):
server = db.get(Server, server_id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
db.delete(server)
db.commit()