Add UTC datetime helpers to attempt to fix running issue

This commit is contained in:
2026-05-29 18:51:28 +01:00
parent 000555dbd7
commit 2d5bdcbe35
25 changed files with 7373 additions and 5 deletions
+860
View File
@@ -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"}
+30
View File
@@ -0,0 +1,30 @@
from datetime import datetime, timezone
def utc_now() -> datetime:
"""Naive UTC datetime for existing SQLAlchemy DateTime columns."""
return datetime.now(timezone.utc).replace(tzinfo=None)
def to_utc_naive(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value
return value.astimezone(timezone.utc).replace(tzinfo=None)
def to_utc_aware(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def to_zulu_iso(value: datetime) -> str:
return to_utc_aware(value).isoformat().replace("+00:00", "Z")
def unix_ms_utc(value: datetime) -> int:
return int(to_utc_aware(value).timestamp() * 1000)
@@ -0,0 +1,42 @@
from datetime import date, datetime, time, timedelta
from sqlalchemy.orm import Session
from ..core.datetime import utc_now
from ..models.models import AttendanceCheckoutSource, AttendanceSession
def duration_seconds(start: datetime, end: datetime) -> int:
return max(0, int((end - start).total_seconds()))
def close_stale_attendance_sessions(
db: Session,
cutoff_date: date | None = None,
checkout_hour: int = 17,
) -> int:
cutoff = cutoff_date or date.today()
cutoff_at = datetime.combine(cutoff, time.min)
sessions = (
db.query(AttendanceSession)
.filter(
AttendanceSession.is_open == True,
AttendanceSession.checked_in_at < cutoff_at,
)
.all()
)
now = utc_now()
for session in sessions:
checkout_at = datetime.combine(session.checked_in_at.date(), time(hour=checkout_hour))
if checkout_at < session.checked_in_at:
checkout_at = session.checked_in_at + timedelta(minutes=1)
session.checked_out_at = checkout_at
session.checkout_source = AttendanceCheckoutSource.SYSTEM
session.system_flag_reason = "User did not check out before midnight; checkout time was system-set."
session.duration_seconds = duration_seconds(session.checked_in_at, checkout_at)
session.is_open = False
session.updated_at = now
db.commit()
return len(sessions)
+47
View File
@@ -0,0 +1,47 @@
from datetime import datetime
from app.core.datetime import unix_ms_utc, utc_now
from app.schemas import EventCreate, EventResponse, EspTimeResponse
def test_event_input_datetime_is_normalized_to_utc_naive() -> None:
event = EventCreate(
title="Evening briefing",
event_date="2026-06-01T19:30:00+01:00",
event_time=None,
)
assert event.event_date == datetime(2026, 6, 1, 18, 30)
assert event.event_date.tzinfo is None
def test_response_datetimes_serialize_as_zulu() -> None:
event = EventResponse(
id=1,
title="Evening briefing",
description=None,
event_date=datetime(2026, 6, 1, 18, 30),
event_time=None,
location=None,
max_attendees=None,
status="draft",
created_by=1,
created_at=datetime(2026, 5, 1, 10, 0),
updated_at=datetime(2026, 5, 1, 10, 0),
)
payload = event.model_dump_json()
assert '"event_date":"2026-06-01T18:30:00Z"' in payload
assert '"created_at":"2026-05-01T10:00:00Z"' in payload
def test_esp_time_uses_same_utc_instant_for_iso_and_unix_ms() -> None:
now = utc_now()
response = EspTimeResponse(
server_time_utc=now,
unix_ms=unix_ms_utc(now),
)
assert '"server_time_utc":"' in response.model_dump_json()
assert response.unix_ms == unix_ms_utc(now)
+186
View File
@@ -0,0 +1,186 @@
import asyncio
import json
from types import SimpleNamespace
from app.api.v1 import esp
from app.core.security import get_machine_token_hash, get_password_hash, verify_machine_token
from app.models.models import EspReaderProvisioningStatus
from app.schemas import EspReaderRegistrationRequest
class _FakeDb:
def __init__(self, reader=None) -> None:
self.reader = reader
class _Query:
def __init__(self, reader) -> None:
self.reader = reader
def filter(self, *args, **kwargs):
return self
def first(self):
return self.reader
def commit(self) -> None:
return None
def add(self, _obj) -> None:
return None
def refresh(self, _obj) -> None:
return None
def query(self, _model):
return self._Query(self.reader)
def test_provisioning_status_returns_api_key_for_enum_status(monkeypatch) -> None:
reader = SimpleNamespace(
device_id="esp32-123456",
provisioning_status=EspReaderProvisioningStatus.APPROVED,
pending_api_key=None,
api_key_hash=None,
provisioned_at=None,
updated_at=None,
)
monkeypatch.setattr(esp, "_get_reader_by_registration_token", lambda *args, **kwargs: reader)
monkeypatch.setattr(esp, "_new_api_key", lambda: "generated-api-key")
response = asyncio.run(
esp.get_provisioning_status(
x_esp_device_id="esp32-123456",
x_esp_registration_token="token",
db=_FakeDb(),
)
)
payload = json.loads(response.body)
assert payload["provisioning_status"] == EspReaderProvisioningStatus.PROVISIONED.value
assert payload["api_key"] == "generated-api-key"
assert payload["apiKey"] == "generated-api-key"
def test_register_reader_allows_recovery_before_first_authenticated_call(monkeypatch) -> None:
reader = SimpleNamespace(
device_id="esp32-123456",
name="Old Reader",
location="Old Location",
reader_type="checkin_checkout",
can_write_cards=False,
firmware_version="old-fw",
notes="old",
registration_token_hash="old-hash",
provisioning_status=EspReaderProvisioningStatus.PROVISIONED,
is_active=True,
pending_api_key="pending-api-key",
last_seen_at=None,
updated_at=None,
)
db = _FakeDb(reader)
monkeypatch.setattr(esp, "_new_registration_token", lambda: "replacement-token")
monkeypatch.setattr(esp, "get_machine_token_hash", lambda value: f"hashed:{value}")
response = asyncio.run(
esp.register_reader(
EspReaderRegistrationRequest(
device_id="esp32-123456",
name="Recovered Reader",
location="Front Desk",
reader_type="checkin_checkout",
can_write_cards=True,
firmware_version="new-fw",
notes="recovered",
),
db=db,
)
)
assert response["provisioning_status"] == EspReaderProvisioningStatus.APPROVED
assert response["registration_token"] == "replacement-token"
assert response["message"] == "Reader recovery accepted. Poll provisioning to receive the API key again."
assert reader.registration_token_hash == "hashed:replacement-token"
assert reader.pending_api_key == "pending-api-key"
assert reader.provisioning_status == EspReaderProvisioningStatus.APPROVED
def test_machine_token_hash_round_trip() -> None:
token = "esp-device-token"
stored_hash = get_machine_token_hash(token)
assert verify_machine_token(token, stored_hash) is True
assert verify_machine_token("wrong-token", stored_hash) is False
def test_machine_token_verify_supports_legacy_bcrypt_hash() -> None:
token = "legacy-esp-token"
stored_hash = get_password_hash(token)
assert verify_machine_token(token, stored_hash) is True
assert verify_machine_token("wrong-token", stored_hash) is False
def test_get_current_reader_migrates_legacy_bcrypt_api_key() -> None:
api_key = "legacy-api-key"
reader = SimpleNamespace(
device_id="esp32-123456",
provisioning_status=EspReaderProvisioningStatus.PROVISIONED,
is_active=True,
api_key_hash=get_password_hash(api_key),
pending_api_key=None,
last_seen_at=None,
)
db = _FakeDb(reader)
response_reader = asyncio.run(
esp.get_current_reader(
x_esp_device_id="esp32-123456",
x_esp_api_key=api_key,
db=db,
)
)
assert response_reader is reader
assert reader.api_key_hash == get_machine_token_hash(api_key)
def test_compact_tap_response_uses_short_keys() -> None:
tap = SimpleNamespace(
accepted=True,
action=SimpleNamespace(value="check_in"),
message="Checked in",
)
response = esp._compact_tap_response(tap)
payload = json.loads(response.body)
assert payload == {"ok": True, "a": "check_in", "m": "Checked in"}
def test_provisioning_status_returns_api_key_for_string_status(monkeypatch) -> None:
reader = SimpleNamespace(
device_id="esp32-123456",
provisioning_status="approved",
pending_api_key=None,
api_key_hash=None,
provisioned_at=None,
updated_at=None,
)
monkeypatch.setattr(esp, "_get_reader_by_registration_token", lambda *args, **kwargs: reader)
monkeypatch.setattr(esp, "_new_api_key", lambda: "generated-api-key")
response = asyncio.run(
esp.get_provisioning_status(
x_esp_device_id="esp32-123456",
x_esp_registration_token="token",
db=_FakeDb(),
)
)
payload = json.loads(response.body)
assert payload["provisioning_status"] == EspReaderProvisioningStatus.PROVISIONED.value
assert payload["api_key"] == "generated-api-key"
assert payload["apiKey"] == "generated-api-key"