861 lines
30 KiB
Python
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"}
|