Files
sasa-membership/backend/app/api/v1/esp.py
T

861 lines
30 KiB
Python

import json
import secrets
from datetime import datetime, timedelta
from typing import List, Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from ...api.dependencies import get_admin_user
from ...core.database import get_db
from ...core.datetime import to_utc_naive, to_zulu_iso, unix_ms_utc, utc_now
from ...core.security import (
get_machine_token_hash,
is_machine_token_hash,
verify_machine_token,
verify_password,
)
from ...models.models import (
AttendanceCheckoutSource,
AttendanceSession,
EspReader,
EspReaderProvisioningStatus,
EspTapAction,
RfidCard,
RfidCardWriteJob,
RfidTap,
RfidWriteJobStatus,
User,
UserRole,
)
from ...schemas import (
AttendanceSessionResponse,
EspDashboardLoginResponse,
EspHeartbeatRequest,
EspHeartbeatResponse,
EspReaderCreate,
EspReaderCreateResponse,
EspReaderProvisioningResponse,
EspReaderRegistrationRequest,
EspReaderRegistrationResponse,
EspReaderResponse,
EspReaderUpdate,
EspTimeResponse,
LoginRequest,
MessageResponse,
RfidCardCreate,
RfidCardResponse,
RfidCardUpdate,
RfidTapAdminResponse,
RfidTapRequest,
RfidTapResponse,
RfidWriteJobCompleteRequest,
RfidWriteJobCreate,
RfidWriteJobResponse,
StaleSessionCloseRequest,
StaleSessionCloseResponse,
)
from ...services.attendance_service import close_stale_attendance_sessions, duration_seconds
router = APIRouter()
READER_LAST_SEEN_WRITE_INTERVAL = timedelta(seconds=30)
def _normalize_card_uid(uid: str) -> str:
return uid.strip().upper().replace(" ", "")
def _new_api_key() -> str:
return secrets.token_urlsafe(32)
def _new_registration_token() -> str:
return secrets.token_urlsafe(24)
def _provisioning_status_value(value: object) -> str:
return getattr(value, "value", value)
def _compact_tap_response(tap: RfidTap) -> JSONResponse:
return JSONResponse(
content={
"ok": tap.accepted,
"a": getattr(tap.action, "value", tap.action),
"m": tap.message or "",
}
)
async def get_current_reader(
x_esp_device_id: str = Header(..., alias="X-ESP-Device-ID"),
x_esp_api_key: str = Header(..., alias="X-ESP-API-Key"),
db: Session = Depends(get_db),
) -> EspReader:
reader = db.query(EspReader).filter(EspReader.device_id == x_esp_device_id).first()
provisioning_status = _provisioning_status_value(reader.provisioning_status) if reader else None
credentials_valid = bool(
reader
and reader.is_active
and reader.api_key_hash
and provisioning_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value]
and verify_machine_token(x_esp_api_key, reader.api_key_hash)
)
if (
not credentials_valid
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid ESP reader credentials",
)
now = utc_now()
should_persist_reader = False
if not is_machine_token_hash(reader.api_key_hash):
reader.api_key_hash = get_machine_token_hash(x_esp_api_key)
should_persist_reader = True
if (
reader.last_seen_at is None
or now - reader.last_seen_at >= READER_LAST_SEEN_WRITE_INTERVAL
):
reader.last_seen_at = now
should_persist_reader = True
if reader.pending_api_key:
reader.pending_api_key = None
should_persist_reader = True
if should_persist_reader:
db.commit()
return reader
def _get_reader_by_registration_token(
device_id: str,
registration_token: str,
db: Session,
) -> EspReader:
reader = db.query(EspReader).filter(EspReader.device_id == device_id).first()
credentials_valid = bool(
reader
and reader.registration_token_hash
and verify_machine_token(registration_token, reader.registration_token_hash)
)
if not credentials_valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid reader registration token",
)
if not is_machine_token_hash(reader.registration_token_hash):
reader.registration_token_hash = get_machine_token_hash(registration_token)
db.commit()
return reader
@router.post("/device/register", response_model=EspReaderRegistrationResponse, status_code=status.HTTP_202_ACCEPTED)
async def register_reader(
registration: EspReaderRegistrationRequest,
db: Session = Depends(get_db),
):
existing = db.query(EspReader).filter(EspReader.device_id == registration.device_id).first()
existing_status = _provisioning_status_value(existing.provisioning_status) if existing else None
allow_recovery = bool(
existing
and existing_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value]
and existing.last_seen_at is None
)
if existing and existing_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value] and not allow_recovery:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Reader is already approved. Use its existing API key or rotate it from admin.",
)
registration_token = _new_registration_token()
now = utc_now()
if existing:
existing.name = registration.name
existing.location = registration.location
existing.reader_type = registration.reader_type
existing.can_write_cards = registration.can_write_cards
existing.firmware_version = registration.firmware_version
existing.notes = registration.notes
existing.registration_token_hash = get_machine_token_hash(registration_token)
existing.provisioning_status = (
EspReaderProvisioningStatus.APPROVED
if allow_recovery
else EspReaderProvisioningStatus.PENDING
)
existing.is_active = True
if not allow_recovery:
existing.pending_api_key = None
existing.updated_at = now
reader = existing
else:
reader = EspReader(
device_id=registration.device_id,
name=registration.name,
location=registration.location,
reader_type=registration.reader_type,
can_write_cards=registration.can_write_cards,
firmware_version=registration.firmware_version,
notes=registration.notes,
registration_token_hash=get_machine_token_hash(registration_token),
provisioning_status=EspReaderProvisioningStatus.PENDING,
is_active=True,
)
db.add(reader)
db.commit()
return {
"device_id": reader.device_id,
"provisioning_status": reader.provisioning_status,
"registration_token": registration_token,
"message": (
"Reader recovery accepted. Poll provisioning to receive the API key again."
if allow_recovery
else "Registration received. Approve this reader in the admin panel."
),
"poll_interval_seconds": 5,
}
@router.get("/device/provisioning-status", response_model=EspReaderProvisioningResponse)
async def get_provisioning_status(
x_esp_device_id: str = Header(..., alias="X-ESP-Device-ID"),
x_esp_registration_token: str = Header(..., alias="X-ESP-Registration-Token"),
db: Session = Depends(get_db),
):
reader = _get_reader_by_registration_token(x_esp_device_id, x_esp_registration_token, db)
provisioning_status = _provisioning_status_value(reader.provisioning_status)
if provisioning_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value]:
api_key = reader.pending_api_key or _new_api_key()
now = utc_now()
if not reader.pending_api_key:
reader.api_key_hash = get_machine_token_hash(api_key)
reader.pending_api_key = api_key
reader.provisioning_status = EspReaderProvisioningStatus.PROVISIONED
reader.provisioned_at = now
reader.updated_at = now
db.commit()
payload = {
"device_id": reader.device_id,
"provisioning_status": EspReaderProvisioningStatus.PROVISIONED.value,
"message": "Reader approved. Store this API key; it will not be returned again.",
"api_key": api_key,
"apiKey": api_key,
"poll_interval_seconds": 5,
}
print(
f"[ESP] provisioning-status device={reader.device_id} status=provisioned has_api_key={bool(api_key)}"
)
return JSONResponse(content=payload)
messages = {
EspReaderProvisioningStatus.PENDING.value: "Waiting for admin approval.",
EspReaderProvisioningStatus.APPROVED.value: "Reader approved. API key is already available or will be generated shortly.",
EspReaderProvisioningStatus.PROVISIONED.value: "Reader already provisioned. Use the stored API key.",
EspReaderProvisioningStatus.REJECTED.value: "Reader registration rejected.",
}
payload = {
"device_id": reader.device_id,
"provisioning_status": provisioning_status,
"message": messages.get(provisioning_status, "Waiting for admin approval."),
"api_key": None,
"apiKey": None,
"poll_interval_seconds": 5,
}
print(
f"[ESP] provisioning-status device={reader.device_id} status={provisioning_status} has_api_key=False"
)
return JSONResponse(content=payload)
@router.get("/device/time", response_model=EspTimeResponse)
async def get_device_time(
reader: EspReader = Depends(get_current_reader),
):
now = utc_now()
return {
"server_time_utc": now,
"unix_ms": unix_ms_utc(now),
"poll_interval_seconds": 3,
}
@router.post("/device/heartbeat", response_model=EspHeartbeatResponse)
async def record_heartbeat(
heartbeat: EspHeartbeatRequest,
reader: EspReader = Depends(get_current_reader),
db: Session = Depends(get_db),
):
now = utc_now()
if heartbeat.firmware_version and heartbeat.firmware_version != reader.firmware_version:
reader.firmware_version = heartbeat.firmware_version
reader.last_seen_at = now
reader.updated_at = now
db.commit()
return {
"ok": True,
"server_time_utc": now,
"unix_ms": unix_ms_utc(now),
"heartbeat_interval_seconds": 10,
"time_poll_interval_seconds": 3,
"write_job_poll_interval_seconds": 3,
}
@router.post("/device/taps", response_model=RfidTapResponse)
async def record_tap(
tap_request: RfidTapRequest,
x_esp_compact_response: str | None = Header(None, alias="X-ESP-Compact-Response"),
reader: EspReader = Depends(get_current_reader),
db: Session = Depends(get_db),
):
compact_response = x_esp_compact_response == "1"
now = utc_now()
tapped_at = to_utc_naive(tap_request.tapped_at) or now
card_uid = _normalize_card_uid(tap_request.card_uid)
card = db.query(RfidCard).filter(RfidCard.uid == card_uid).first()
tap = RfidTap(
reader_id=reader.id,
card_id=card.id if card else None,
user_id=card.user_id if card and card.is_active else None,
card_uid=card_uid,
action=EspTapAction.UNKNOWN,
accepted=False,
raw_payload=json.dumps(tap_request.model_dump(mode="json")),
tapped_at=tapped_at,
)
db.add(tap)
db.flush()
if not card:
tap.action = EspTapAction.DENIED
tap.message = "Unknown RFID card"
db.commit()
if compact_response:
return _compact_tap_response(tap)
return {
"accepted": False,
"action": tap.action,
"message": tap.message,
"server_time_utc": now,
"tap_id": tap.id,
}
if not card.is_active or not card.user_id:
tap.action = EspTapAction.DENIED
tap.message = "RFID card is inactive or unassigned"
db.commit()
if compact_response:
return _compact_tap_response(tap)
return {
"accepted": False,
"action": tap.action,
"message": tap.message,
"server_time_utc": now,
"tap_id": tap.id,
}
user = db.query(User).filter(User.id == card.user_id).first()
if not user or not user.is_active:
tap.action = EspTapAction.DENIED
tap.message = "User is inactive"
db.commit()
if compact_response:
return _compact_tap_response(tap)
return {
"accepted": False,
"action": tap.action,
"message": tap.message,
"server_time_utc": now,
"tap_id": tap.id,
"user_id": card.user_id,
}
open_session = (
db.query(AttendanceSession)
.filter(
AttendanceSession.user_id == user.id,
AttendanceSession.is_open == True,
)
.order_by(AttendanceSession.checked_in_at.desc())
.first()
)
user_name = f"{user.first_name} {user.last_name}"
if open_session:
tap.action = EspTapAction.CHECK_OUT
tap.accepted = True
tap.message = "Checked out"
open_session.check_out_tap_id = tap.id
open_session.checked_out_at = tapped_at
open_session.checkout_source = AttendanceCheckoutSource.USER
open_session.duration_seconds = duration_seconds(open_session.checked_in_at, tapped_at)
open_session.is_open = False
open_session.updated_at = now
db.commit()
if compact_response:
return _compact_tap_response(tap)
return {
"accepted": True,
"action": tap.action,
"message": tap.message,
"server_time_utc": now,
"tap_id": tap.id,
"session_id": open_session.id,
"user_id": user.id,
"user_name": user_name,
"checked_in_at": open_session.checked_in_at,
"checked_out_at": open_session.checked_out_at,
"duration_seconds": open_session.duration_seconds,
}
tap.action = EspTapAction.CHECK_IN
tap.accepted = True
tap.message = "Checked in"
session = AttendanceSession(
user_id=user.id,
reader_id=reader.id,
check_in_tap_id=tap.id,
checked_in_at=tapped_at,
is_open=True,
)
db.add(session)
db.commit()
if compact_response:
return _compact_tap_response(tap)
return {
"accepted": True,
"action": tap.action,
"message": tap.message,
"server_time_utc": now,
"tap_id": tap.id,
"session_id": session.id,
"user_id": user.id,
"user_name": user_name,
"checked_in_at": session.checked_in_at,
}
@router.post("/device/dashboard-login", response_model=EspDashboardLoginResponse)
async def validate_dashboard_login(
login_data: LoginRequest,
reader: EspReader = Depends(get_current_reader),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.email == login_data.email).first()
if (
not user
or not user.is_active
or user.role not in [UserRole.ADMIN, UserRole.SUPER_ADMIN]
or not verify_password(login_data.password, user.hashed_password)
):
return {"valid": False}
return {
"valid": True,
"user_id": user.id,
"role": user.role,
"user_name": f"{user.first_name} {user.last_name}",
}
@router.get("/device/write-jobs/next", response_model=RfidWriteJobResponse | None)
async def get_next_write_job(
reader: EspReader = Depends(get_current_reader),
db: Session = Depends(get_db),
):
if not reader.can_write_cards:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Reader is not enabled for card writing")
job = (
db.query(RfidCardWriteJob)
.filter(
RfidCardWriteJob.reader_id == reader.id,
RfidCardWriteJob.status == RfidWriteJobStatus.PENDING,
)
.order_by(RfidCardWriteJob.created_at.asc())
.first()
)
if not job:
return None
user = db.query(User).filter(User.id == job.user_id).first()
job.status = RfidWriteJobStatus.CLAIMED
job.claimed_at = utc_now()
job.write_payload = json.dumps(
{
"job_id": job.id,
"user_id": job.user_id,
"user_name": f"{user.first_name} {user.last_name}" if user else None,
"label": job.label,
"issued_at": to_zulu_iso(job.claimed_at),
}
)
db.commit()
db.refresh(job)
return job
@router.post("/device/write-jobs/{job_id}/complete", response_model=RfidWriteJobResponse)
async def complete_write_job(
job_id: int,
completion: RfidWriteJobCompleteRequest,
reader: EspReader = Depends(get_current_reader),
db: Session = Depends(get_db),
):
job = (
db.query(RfidCardWriteJob)
.filter(RfidCardWriteJob.id == job_id, RfidCardWriteJob.reader_id == reader.id)
.first()
)
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Write job not found")
if job.status not in [RfidWriteJobStatus.PENDING, RfidWriteJobStatus.CLAIMED]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Write job is already closed")
now = utc_now()
if not completion.success:
job.status = RfidWriteJobStatus.FAILED
job.error_message = completion.error_message or "Reader reported write failure"
job.completed_at = now
job.updated_at = now
db.commit()
db.refresh(job)
return job
if not completion.card_uid:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="card_uid is required when success is true")
card_uid = _normalize_card_uid(completion.card_uid)
card = db.query(RfidCard).filter(RfidCard.uid == card_uid).first()
if card:
card.user_id = job.user_id
card.label = job.label
card.is_active = True
card.updated_at = now
else:
card = RfidCard(uid=card_uid, user_id=job.user_id, label=job.label, is_active=True)
db.add(card)
db.flush()
job.card_id = card.id
job.card_uid = card_uid
job.status = RfidWriteJobStatus.COMPLETED
job.completed_at = now
job.updated_at = now
db.commit()
db.refresh(job)
return job
@router.get("/admin/readers", response_model=List[EspReaderResponse])
async def list_readers(
include_inactive: bool = Query(True),
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
query = db.query(EspReader)
if not include_inactive:
query = query.filter(EspReader.is_active == True)
return query.order_by(EspReader.name.asc()).all()
@router.post("/admin/readers", response_model=EspReaderCreateResponse, status_code=status.HTTP_201_CREATED)
async def create_reader(
reader_data: EspReaderCreate,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
existing = db.query(EspReader).filter(EspReader.device_id == reader_data.device_id).first()
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Device ID already exists")
api_key = reader_data.api_key or _new_api_key()
reader = EspReader(
device_id=reader_data.device_id,
name=reader_data.name,
location=reader_data.location,
reader_type=reader_data.reader_type,
provisioning_status=EspReaderProvisioningStatus.PROVISIONED,
api_key_hash=get_machine_token_hash(api_key),
is_active=reader_data.is_active,
can_write_cards=reader_data.can_write_cards,
firmware_version=reader_data.firmware_version,
approved_at=utc_now(),
provisioned_at=utc_now(),
notes=reader_data.notes,
)
db.add(reader)
db.commit()
db.refresh(reader)
return EspReaderCreateResponse(
**EspReaderResponse.model_validate(reader).model_dump(),
api_key=api_key,
)
@router.post("/admin/readers/{reader_id}/approve", response_model=EspReaderResponse)
async def approve_reader(
reader_id: int,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
reader = db.query(EspReader).filter(EspReader.id == reader_id).first()
if not reader:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found")
if reader.provisioning_status == EspReaderProvisioningStatus.REJECTED:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Rejected reader must register again")
now = utc_now()
reader.provisioning_status = EspReaderProvisioningStatus.APPROVED
reader.is_active = True
reader.approved_at = now
reader.updated_at = now
reader.api_key_hash = None
reader.pending_api_key = None
db.commit()
db.refresh(reader)
return reader
@router.post("/admin/readers/{reader_id}/reject", response_model=EspReaderResponse)
async def reject_reader(
reader_id: int,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
reader = db.query(EspReader).filter(EspReader.id == reader_id).first()
if not reader:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found")
reader.provisioning_status = EspReaderProvisioningStatus.REJECTED
reader.is_active = False
reader.pending_api_key = None
reader.updated_at = utc_now()
db.commit()
db.refresh(reader)
return reader
@router.put("/admin/readers/{reader_id}", response_model=EspReaderCreateResponse | EspReaderResponse)
async def update_reader(
reader_id: int,
reader_data: EspReaderUpdate,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
reader = db.query(EspReader).filter(EspReader.id == reader_id).first()
if not reader:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found")
update_data = reader_data.model_dump(exclude_unset=True)
rotate_api_key = update_data.pop("rotate_api_key", False)
for field, value in update_data.items():
setattr(reader, field, value)
new_api_key: Optional[str] = None
if rotate_api_key:
new_api_key = _new_api_key()
reader.api_key_hash = get_machine_token_hash(new_api_key)
reader.pending_api_key = None
reader.provisioning_status = EspReaderProvisioningStatus.PROVISIONED
reader.provisioned_at = utc_now()
reader.updated_at = utc_now()
db.commit()
db.refresh(reader)
if new_api_key:
return EspReaderCreateResponse(
**EspReaderResponse.model_validate(reader).model_dump(),
api_key=new_api_key,
)
return reader
@router.get("/admin/write-jobs", response_model=List[RfidWriteJobResponse])
async def list_write_jobs(
limit: int = Query(100, ge=1, le=500),
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
return db.query(RfidCardWriteJob).order_by(RfidCardWriteJob.created_at.desc()).limit(limit).all()
@router.post("/admin/write-jobs", response_model=RfidWriteJobResponse, status_code=status.HTTP_201_CREATED)
async def queue_write_job(
job_data: RfidWriteJobCreate,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
reader = db.query(EspReader).filter(EspReader.id == job_data.reader_id).first()
if not reader:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reader does not exist")
provisioning_status = _provisioning_status_value(reader.provisioning_status)
if not reader.is_active or provisioning_status not in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reader is not active and provisioned")
if not reader.can_write_cards:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reader is not enabled for card writing")
user = db.query(User).filter(User.id == job_data.user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User does not exist")
job = RfidCardWriteJob(
reader_id=job_data.reader_id,
user_id=job_data.user_id,
label=job_data.label,
status=RfidWriteJobStatus.PENDING,
requested_by_user_id=admin_user.id,
)
db.add(job)
db.commit()
db.refresh(job)
return job
@router.post("/admin/write-jobs/{job_id}/cancel", response_model=RfidWriteJobResponse)
async def cancel_write_job(
job_id: int,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
job = db.query(RfidCardWriteJob).filter(RfidCardWriteJob.id == job_id).first()
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Write job not found")
if job.status not in [RfidWriteJobStatus.PENDING, RfidWriteJobStatus.CLAIMED]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only pending or claimed jobs can be cancelled")
job.status = RfidWriteJobStatus.CANCELLED
job.updated_at = utc_now()
db.commit()
db.refresh(job)
return job
@router.get("/admin/cards", response_model=List[RfidCardResponse])
async def list_cards(
include_inactive: bool = Query(True),
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
query = db.query(RfidCard)
if not include_inactive:
query = query.filter(RfidCard.is_active == True)
return query.order_by(RfidCard.uid.asc()).all()
@router.post("/admin/cards", response_model=RfidCardResponse, status_code=status.HTTP_201_CREATED)
async def create_card(
card_data: RfidCardCreate,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
uid = _normalize_card_uid(card_data.uid)
existing = db.query(RfidCard).filter(RfidCard.uid == uid).first()
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="RFID card already exists")
if card_data.user_id:
user = db.query(User).filter(User.id == card_data.user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User does not exist")
card = RfidCard(
uid=uid,
user_id=card_data.user_id,
label=card_data.label,
is_active=card_data.is_active,
)
db.add(card)
db.commit()
db.refresh(card)
return card
@router.put("/admin/cards/{card_id}", response_model=RfidCardResponse)
async def update_card(
card_id: int,
card_data: RfidCardUpdate,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
card = db.query(RfidCard).filter(RfidCard.id == card_id).first()
if not card:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="RFID card not found")
update_data = card_data.model_dump(exclude_unset=True)
if update_data.get("user_id"):
user = db.query(User).filter(User.id == update_data["user_id"]).first()
if not user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User does not exist")
for field, value in update_data.items():
setattr(card, field, value)
card.updated_at = utc_now()
db.commit()
db.refresh(card)
return card
@router.get("/admin/taps", response_model=List[RfidTapAdminResponse])
async def list_taps(
limit: int = Query(100, ge=1, le=500),
reader_id: Optional[int] = None,
user_id: Optional[int] = None,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
query = db.query(RfidTap)
if reader_id:
query = query.filter(RfidTap.reader_id == reader_id)
if user_id:
query = query.filter(RfidTap.user_id == user_id)
return query.order_by(RfidTap.tapped_at.desc()).limit(limit).all()
@router.get("/admin/attendance", response_model=List[AttendanceSessionResponse])
async def list_attendance_sessions(
open_only: bool = False,
limit: int = Query(100, ge=1, le=500),
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
query = db.query(AttendanceSession)
if open_only:
query = query.filter(AttendanceSession.is_open == True)
return query.order_by(AttendanceSession.checked_in_at.desc()).limit(limit).all()
@router.post("/admin/attendance/close-stale", response_model=StaleSessionCloseResponse)
async def close_stale_sessions(
request: StaleSessionCloseRequest,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
closed_count = close_stale_attendance_sessions(
db,
cutoff_date=request.cutoff_date,
checkout_hour=request.checkout_hour,
)
return {"closed_count": closed_count}
@router.delete("/admin/readers/{reader_id}", response_model=MessageResponse)
async def delete_reader(
reader_id: int,
admin_user: User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
reader = db.query(EspReader).filter(EspReader.id == reader_id).first()
if not reader:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found")
db.query(AttendanceSession).filter(AttendanceSession.reader_id == reader.id).delete(synchronize_session=False)
db.query(RfidTap).filter(RfidTap.reader_id == reader.id).delete(synchronize_session=False)
db.query(RfidCardWriteJob).filter(RfidCardWriteJob.reader_id == reader.id).delete(synchronize_session=False)
db.delete(reader)
db.commit()
return {"message": "Reader deleted"}