Add UTC datetime helpers to attempt to fix running issue
This commit is contained in:
@@ -0,0 +1,860 @@
|
||||
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"}
|
||||
Reference in New Issue
Block a user