First commit
This commit is contained in:
1
src/routers/__init__.py
Normal file
1
src/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# routers package
|
||||
BIN
src/routers/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/routers/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/routers/__pycache__/admins.cpython-313.pyc
Normal file
BIN
src/routers/__pycache__/admins.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/routers/__pycache__/auth.cpython-313.pyc
Normal file
BIN
src/routers/__pycache__/auth.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/routers/__pycache__/credentials.cpython-313.pyc
Normal file
BIN
src/routers/__pycache__/credentials.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/routers/__pycache__/gates.cpython-313.pyc
Normal file
BIN
src/routers/__pycache__/gates.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/routers/__pycache__/keypasses.cpython-313.pyc
Normal file
BIN
src/routers/__pycache__/keypasses.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/routers/__pycache__/stats.cpython-313.pyc
Normal file
BIN
src/routers/__pycache__/stats.cpython-313.pyc
Normal file
Binary file not shown.
55
src/routers/admins.py
Normal file
55
src/routers/admins.py
Normal 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
45
src/routers/auth.py
Normal 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)
|
||||
40
src/routers/credentials.py
Normal file
40
src/routers/credentials.py
Normal 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
189
src/routers/gates.py
Normal 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
89
src/routers/keypasses.py
Normal 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
21
src/routers/stats.py
Normal 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()
|
||||
)
|
||||
Reference in New Issue
Block a user