First commit

This commit is contained in:
Ettore
2026-05-06 01:51:22 +02:00
commit 78fca8ebc2
56 changed files with 2584 additions and 0 deletions

1
src/routers/__init__.py Normal file
View File

@@ -0,0 +1 @@
# routers package

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

55
src/routers/admins.py Normal file
View File

@@ -0,0 +1,55 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from core.auth import hash_password
from core.database import AdminUser, get_db
from core.dependencies import require_admin
from core.schemas import AdminUserCreate, AdminUserResponse
router = APIRouter(prefix="/api/admin/admins", tags=["admin-admins"])
@router.get("", response_model=list[AdminUserResponse])
async def list_admins(
db: Session = Depends(get_db), _: dict = Depends(require_admin)
):
return [AdminUserResponse(id=u.id, username=u.username, role=u.role) for u in db.query(AdminUser).order_by(AdminUser.id).all()]
@router.post("", response_model=AdminUserResponse, status_code=201)
async def create_admin(
req: AdminUserCreate,
db: Session = Depends(get_db),
_: dict = Depends(require_admin),
):
username = req.username.strip()
if not username:
raise HTTPException(422, "Username cannot be empty")
if req.role not in ("admin", "manager"):
raise HTTPException(422, "role must be 'admin' or 'manager'")
if db.query(AdminUser).filter_by(username=username).first():
raise HTTPException(409, "Username already exists")
user = AdminUser(username=username, password_hash=hash_password(req.password), role=req.role)
db.add(user)
db.commit()
db.refresh(user)
return AdminUserResponse(id=user.id, username=user.username, role=user.role)
@router.delete("/{username}", status_code=204)
async def delete_admin(
username: str,
db: Session = Depends(get_db),
caller: dict = Depends(require_admin),
):
if username == caller["sub"]:
raise HTTPException(409, "Cannot delete your own account")
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=username).first()
if not user:
raise HTTPException(404, "Admin not found")
if db.query(AdminUser).count() <= 1:
raise HTTPException(409, "Cannot delete the last admin account")
db.delete(user)
db.commit()

45
src/routers/auth.py Normal file
View File

@@ -0,0 +1,45 @@
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from core.auth import create_token, verify_password
from core.database import AdminUser, Keypass, get_db
from core.schemas import AdminLoginRequest, KeypassLoginRequest, TokenResponse
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/admin", response_model=TokenResponse)
async def admin_login(req: AdminLoginRequest, db: Session = Depends(get_db)):
user: Optional[AdminUser] = db.query(AdminUser).filter_by(username=req.username).first()
if not user or not verify_password(req.password, user.password_hash):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials")
token = create_token({
"sub": user.username,
"role": "admin",
"scope": user.role, # 'admin' | 'manager'
"exp": datetime.utcnow() + timedelta(hours=24),
})
return TokenResponse(token=token)
@router.post("/keypass", response_model=TokenResponse)
async def keypass_login(req: KeypassLoginRequest, db: Session = Depends(get_db)):
kp: Optional[Keypass] = db.query(Keypass).filter_by(
code=req.code.strip().upper()
).first()
if not kp:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid keypass")
if kp.revoked:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has been revoked")
if kp.expires_at is not None and kp.expires_at < datetime.utcnow():
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has expired")
exp = kp.expires_at if kp.expires_at else datetime(2099, 12, 31, 23, 59, 59)
token = create_token({
"sub": str(kp.id),
"role": "keypass",
"exp": exp,
})
return TokenResponse(token=token)

View File

@@ -0,0 +1,40 @@
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from core.auth import encrypt_secret
from core.database import ApiCredential, get_db
from core.dependencies import require_admin
from core.schemas import CredentialRead, CredentialUpsert
router = APIRouter(prefix="/api/admin/credentials", tags=["admin-credentials"])
@router.get("", response_model=list[CredentialRead])
async def list_credentials(
db: Session = Depends(get_db), _: dict = Depends(require_admin)
):
return [CredentialRead(id=c.id, username=c.username) for c in db.query(ApiCredential).all()]
@router.put("", response_model=CredentialRead)
async def upsert_credential(
req: CredentialUpsert,
db: Session = Depends(get_db),
_: dict = Depends(require_admin),
):
cred: Optional[ApiCredential] = db.query(ApiCredential).first()
if cred:
cred.username = req.username
cred.password_enc = encrypt_secret(req.password)
cred.session_id = None # invalidate any cached session
else:
cred = ApiCredential(
username=req.username,
password_enc=encrypt_secret(req.password),
)
db.add(cred)
db.commit()
db.refresh(cred)
return CredentialRead(id=cred.id, username=cred.username)

189
src/routers/gates.py Normal file
View File

@@ -0,0 +1,189 @@
import json
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from core.auth import decrypt_secret
from core.database import ApiCredential, GateAccessLog, GateDB, Keypass, get_db
from core.dependencies import require_admin, require_manager, require_keypass
from models import Credential, Gate as GateModel, Status
from core.schemas import GateCreate, GateResponse
from services.gates import call_open_gate
router = APIRouter(tags=["gates"])
# ── Admin: gate CRUD ──────────────────────────────────────────────────────────
@router.get("/api/admin/gates", response_model=list[GateResponse])
async def list_gates_admin(
db: Session = Depends(get_db), _: dict = Depends(require_manager)
):
return db.query(GateDB).order_by(GateDB.id).all()
@router.post("/api/admin/gates", response_model=GateResponse, status_code=201)
async def create_gate(
req: GateCreate,
db: Session = Depends(get_db),
_: dict = Depends(require_admin),
):
if req.gate_type not in ("car", "pedestrian"):
raise HTTPException(400, "gate_type must be 'car' or 'pedestrian'")
gate = GateDB(**req.model_dump())
db.add(gate)
db.commit()
db.refresh(gate)
return gate
@router.put("/api/admin/gates/{gate_id}", response_model=GateResponse)
async def update_gate(
gate_id: int,
req: GateCreate,
db: Session = Depends(get_db),
_: dict = Depends(require_admin),
):
gate: Optional[GateDB] = db.query(GateDB).filter(GateDB.id == gate_id).first()
if not gate:
raise HTTPException(404, "Gate not found")
for k, v in req.model_dump().items():
setattr(gate, k, v)
db.commit()
db.refresh(gate)
return gate
@router.delete("/api/admin/gates/{gate_id}", status_code=204)
async def delete_gate(
gate_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_admin),
):
gate: Optional[GateDB] = db.query(GateDB).filter(GateDB.id == gate_id).first()
if not gate:
raise HTTPException(404, "Gate not found")
db.delete(gate)
db.commit()
@router.post("/api/admin/gates/{gate_id}/open")
async def admin_open_gate(
gate_id: int,
request: Request,
db: Session = Depends(get_db),
caller: dict = Depends(require_manager),
):
gate_db: Optional[GateDB] = db.query(GateDB).filter(GateDB.id == gate_id).first()
if not gate_db:
raise HTTPException(404, "Gate not found")
if gate_db.status != "enabled":
raise HTTPException(409, "Gate is disabled")
cred_db: Optional[ApiCredential] = db.query(ApiCredential).first()
if not cred_db:
raise HTTPException(503, "AVConnect credentials not configured")
credential = Credential(
username=cred_db.username,
password=decrypt_secret(cred_db.password_enc),
)
credential.sessionid = cred_db.session_id
gate = GateModel(id=gate_db.avconnect_macro_id, name=gate_db.name, status=Status.ENABLED)
ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else None)
ua = request.headers.get("User-Agent")
success, error_msg, new_sid = call_open_gate(gate, credential)
db.add(GateAccessLog(
timestamp=datetime.utcnow(),
keypass_id=0,
keypass_code=f"[{caller['sub']}]",
gate_id=gate_db.id,
gate_name=gate_db.name,
ip_address=ip,
user_agent=ua,
success=success,
error=error_msg,
))
if new_sid and new_sid != cred_db.session_id:
cred_db.session_id = new_sid
db.commit()
if not success:
raise HTTPException(502, error_msg or "Gate operation failed")
return {"success": True, "gate": gate_db.name}
# ── User-facing gate routes ───────────────────────────────────────────────────
@router.get("/api/gates", response_model=list[GateResponse])
async def list_gates(
db: Session = Depends(get_db), _kp: Keypass = Depends(require_keypass)
):
allowed = json.loads(_kp.allowed_gates) if _kp.allowed_gates else None
q = db.query(GateDB).filter(GateDB.status == "enabled")
if allowed is not None:
q = q.filter(GateDB.id.in_(allowed))
return q.order_by(GateDB.id).all()
@router.post("/api/gates/{gate_id}/open")
async def open_gate(
gate_id: int,
request: Request,
db: Session = Depends(get_db),
_kp: Keypass = Depends(require_keypass),
):
gate_db: Optional[GateDB] = db.query(GateDB).filter(
GateDB.id == gate_id, GateDB.status == "enabled"
).first()
if not gate_db:
raise HTTPException(404, "Gate not found or disabled")
cred_db: Optional[ApiCredential] = db.query(ApiCredential).first()
if not cred_db:
raise HTTPException(503, "AVConnect credentials not configured")
credential = Credential(
username=cred_db.username,
password=decrypt_secret(cred_db.password_enc),
)
credential.sessionid = cred_db.session_id
gate_status = Status.ENABLED if gate_db.status == "enabled" else Status.DISABLED
gate = GateModel(id=gate_db.avconnect_macro_id, name=gate_db.name, status=gate_status)
ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else None)
ua = request.headers.get("User-Agent")
allowed = json.loads(_kp.allowed_gates) if _kp.allowed_gates else None
if allowed is not None and gate_id not in allowed:
raise HTTPException(403, "This keypass does not have access to this gate")
success, error_msg, new_sid = call_open_gate(gate, credential)
db.add(GateAccessLog(
timestamp=datetime.utcnow(),
keypass_id=_kp.id,
keypass_code=_kp.code,
gate_id=gate_db.id,
gate_name=gate_db.name,
ip_address=ip,
user_agent=ua,
success=success,
error=error_msg,
))
if new_sid and new_sid != cred_db.session_id:
cred_db.session_id = new_sid
db.commit()
if not success:
raise HTTPException(502, error_msg or "Gate operation failed")
return {"success": True, "gate": gate_db.name}

89
src/routers/keypasses.py Normal file
View File

@@ -0,0 +1,89 @@
import json
import secrets
import string
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from core.database import Keypass, get_db
from core.dependencies import require_manager
from core.schemas import KeypassCreate, KeypassPatch, KeypassResponse, keypass_to_response
router = APIRouter(prefix="/api/admin/keypasses", tags=["admin-keypasses"])
def _generate_code(length: int = 12) -> str:
alphabet = string.ascii_uppercase + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
@router.get("", response_model=list[KeypassResponse])
async def list_keypasses(
db: Session = Depends(get_db), _: dict = Depends(require_manager)
):
return [keypass_to_response(kp) for kp in db.query(Keypass).order_by(Keypass.created_at.desc()).all()]
@router.post("", response_model=KeypassResponse, status_code=201)
async def create_keypass(
req: KeypassCreate,
db: Session = Depends(get_db),
_: dict = Depends(require_manager),
):
code = req.code.strip().upper() if req.code and req.code.strip() else _generate_code()
if db.query(Keypass).filter(Keypass.code == code).first():
raise HTTPException(409, "A keypass with this code already exists")
kp = Keypass(
code=code,
description=req.description,
created_at=datetime.utcnow(),
expires_at=req.expires_at,
revoked=False,
allowed_gates=json.dumps(req.gate_ids) if req.gate_ids else None,
)
db.add(kp)
db.commit()
db.refresh(kp)
return keypass_to_response(kp)
@router.patch("/{kp_id}", response_model=KeypassResponse)
async def update_keypass(
kp_id: int,
req: KeypassPatch,
db: Session = Depends(get_db),
_: dict = Depends(require_manager),
):
kp: Optional[Keypass] = db.query(Keypass).filter(Keypass.id == kp_id).first()
if not kp:
raise HTTPException(404, "Keypass not found")
if kp.revoked:
raise HTTPException(409, "Revoked keypasses cannot be edited")
if req.description is not None:
kp.description = req.description.strip()
kp.expires_at = req.expires_at
if req.gate_ids is not None:
kp.allowed_gates = json.dumps(req.gate_ids) if req.gate_ids else None
db.commit()
db.refresh(kp)
return keypass_to_response(kp)
@router.delete("/{kp_id}", status_code=204)
async def revoke_keypass(
kp_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_manager),
):
kp: Optional[Keypass] = db.query(Keypass).filter(Keypass.id == kp_id).first()
if not kp:
raise HTTPException(404, "Keypass not found")
if kp.expires_at is not None and kp.expires_at < datetime.utcnow():
raise HTTPException(409, "Expired keypasses cannot be revoked")
if kp.revoked:
raise HTTPException(409, "Keypass is already revoked")
kp.revoked = True
kp.revoked_at = datetime.utcnow()
db.commit()

21
src/routers/stats.py Normal file
View File

@@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from core.database import GateAccessLog, get_db
from core.dependencies import require_manager
from core.schemas import AccessLogResponse
router = APIRouter(prefix="/api/admin/stats", tags=["admin-stats"])
@router.get("", response_model=list[AccessLogResponse])
async def get_stats(
db: Session = Depends(get_db),
_: dict = Depends(require_manager),
):
return (
db.query(GateAccessLog)
.order_by(GateAccessLog.timestamp.desc())
.limit(500)
.all()
)