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"}