forked from jamesp/sasa-membership
main #5
@@ -0,0 +1,23 @@
|
||||
# An .aiignore file follows the same syntax as a .gitignore file.
|
||||
# .gitignore documentation: https://git-scm.com/docs/gitignore
|
||||
|
||||
# you can ignore files
|
||||
.DS_Store
|
||||
*.log
|
||||
*.tmp
|
||||
|
||||
# or folders
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
.idea/
|
||||
|
||||
/.aiignore
|
||||
.aiignore
|
||||
|
||||
/.env
|
||||
/.env.*
|
||||
.env
|
||||
.env.*
|
||||
**/.env
|
||||
**/.env.*
|
||||
@@ -24,6 +24,13 @@ wheels/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
+463
@@ -0,0 +1,463 @@
|
||||
# ESP RFID Integration Contract
|
||||
|
||||
The ESP32 reader is expected to host its own local setup/dashboard UI. The reader registers itself with the SASA server, waits for admin approval, receives an API key once, then uses that key for heartbeat, time sync, taps, dashboard login checks, and queued card-write jobs.
|
||||
|
||||
All timestamps are UTC ISO-8601 unless otherwise noted.
|
||||
|
||||
## 1. Reader Setup and Approval
|
||||
|
||||
### Register from the ESP dashboard
|
||||
|
||||
Unauthenticated endpoint. Use this when the reader first boots or when it is reset/reconfigured.
|
||||
|
||||
```http
|
||||
POST /api/v1/esp/device/register
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "front-desk-01",
|
||||
"name": "Front Desk Reader",
|
||||
"location": "Reception",
|
||||
"reader_type": "checkin_checkout",
|
||||
"can_write_cards": true,
|
||||
"firmware_version": "esp32-rfid-0.1.0",
|
||||
"notes": "PN532 over I2C"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "front-desk-01",
|
||||
"provisioning_status": "pending",
|
||||
"registration_token": "one-time-registration-token",
|
||||
"message": "Registration received. Approve this reader in the admin panel.",
|
||||
"poll_interval_seconds": 5
|
||||
}
|
||||
```
|
||||
|
||||
Firmware must store:
|
||||
|
||||
- `device_id`
|
||||
- `registration_token`
|
||||
|
||||
### Admin approval
|
||||
|
||||
In the web portal:
|
||||
|
||||
- Dashboard -> Admin -> ESP RFID -> Setup
|
||||
- Approve or reject pending readers.
|
||||
|
||||
When approved, the server does not show the key in the admin panel. The reader gets it through the provisioning poll below.
|
||||
|
||||
### Poll provisioning status
|
||||
|
||||
Use this while waiting for approval.
|
||||
|
||||
```http
|
||||
GET /api/v1/esp/device/provisioning-status
|
||||
X-ESP-Device-ID: front-desk-01
|
||||
X-ESP-Registration-Token: one-time-registration-token
|
||||
```
|
||||
|
||||
Pending response:
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "front-desk-01",
|
||||
"provisioning_status": "pending",
|
||||
"message": "Waiting for admin approval.",
|
||||
"api_key": null,
|
||||
"poll_interval_seconds": 5
|
||||
}
|
||||
```
|
||||
|
||||
Approved response:
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "front-desk-01",
|
||||
"provisioning_status": "provisioned",
|
||||
"message": "Reader approved. Store this API key; it will not be returned again.",
|
||||
"api_key": "generated-api-key",
|
||||
"poll_interval_seconds": 5
|
||||
}
|
||||
```
|
||||
|
||||
Firmware must store `api_key` permanently. The server only returns it once. If it is lost, either rotate the key in admin and manually enter it on the ESP, or re-register the reader.
|
||||
|
||||
Rejected response:
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "front-desk-01",
|
||||
"provisioning_status": "rejected",
|
||||
"message": "Reader registration rejected.",
|
||||
"api_key": null,
|
||||
"poll_interval_seconds": 5
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Authenticated Reader Requests
|
||||
|
||||
After provisioning, every reader endpoint uses:
|
||||
|
||||
```http
|
||||
X-ESP-Device-ID: front-desk-01
|
||||
X-ESP-API-Key: generated-api-key
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Invalid, inactive, unapproved, or rejected readers receive `401`.
|
||||
|
||||
## 3. Time Sync
|
||||
|
||||
The backend returns the server clock in UTC/Zulu. Firmware should treat this as the source of truth and display it locally as needed.
|
||||
|
||||
## 3. Local ESP Dashboard
|
||||
|
||||
The firmware in `ESP/main.cpp` serves the local dashboard on port `80`.
|
||||
|
||||
Access options:
|
||||
|
||||
- If WiFi connects, open `http://<reader-lan-ip>/`.
|
||||
- If WiFi does not connect, the reader starts a setup AP named `RFID-Setup-XXXXXX`; connect to it and open `http://192.168.4.1/`.
|
||||
|
||||
Default local dashboard credentials are configured in `ESP/secrets.h`:
|
||||
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
|
||||
The dashboard shows:
|
||||
|
||||
- WiFi status and RSSI
|
||||
- Provisioning status
|
||||
- Current operating mode
|
||||
- Server-synced clock
|
||||
- Last heartbeat result
|
||||
- Current write-job state
|
||||
- Last card UID/action/result
|
||||
|
||||
The local UI also exposes controls for:
|
||||
|
||||
- Saving server/WiFi/reader configuration
|
||||
- Registering or re-registering the reader
|
||||
- Manually syncing time
|
||||
- Manually polling for queued write jobs
|
||||
- Clearing provisioning
|
||||
- Rebooting
|
||||
|
||||
Local dashboard JSON endpoints:
|
||||
|
||||
- `GET /api/status`
|
||||
- `POST /api/config`
|
||||
- `POST /api/register`
|
||||
- `POST /api/sync-time`
|
||||
- `POST /api/poll-job`
|
||||
- `POST /api/clear-provisioning`
|
||||
- `POST /api/cancel-local-job`
|
||||
- `POST /api/reboot`
|
||||
|
||||
These are local ESP endpoints, not SASA backend endpoints.
|
||||
|
||||
## 4. Heartbeat
|
||||
|
||||
The reader sends a heartbeat after provisioning so the server knows it is alive and so the ESP can show heartbeat status on its dashboard.
|
||||
|
||||
```http
|
||||
POST /api/v1/esp/device/heartbeat
|
||||
X-ESP-Device-ID: front-desk-01
|
||||
X-ESP-API-Key: generated-api-key
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "idle",
|
||||
"message": "Heartbeat OK",
|
||||
"wifi_rssi": -54,
|
||||
"free_heap": 184320,
|
||||
"firmware_version": "esp32-rfid-0.2.0",
|
||||
"active_write_job_id": null
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"server_time_utc": "2026-05-05T10:15:30.123456",
|
||||
"unix_ms": 1777976130123,
|
||||
"heartbeat_interval_seconds": 10,
|
||||
"time_poll_interval_seconds": 3,
|
||||
"write_job_poll_interval_seconds": 3
|
||||
}
|
||||
```
|
||||
|
||||
Firmware modes currently used:
|
||||
|
||||
- `setup`
|
||||
- `waiting approval`
|
||||
- `idle`
|
||||
- `syncing time`
|
||||
- `heartbeat`
|
||||
- `checking jobs`
|
||||
- `ready to write`
|
||||
- `writing card`
|
||||
- `reporting tap`
|
||||
- `error`
|
||||
|
||||
The backend updates `last_seen_at` when heartbeat succeeds.
|
||||
|
||||
## 5. Time Sync
|
||||
|
||||
The reader should poll every 3 seconds.
|
||||
|
||||
```http
|
||||
GET /api/v1/esp/device/time
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_time_utc": "2026-05-05T10:15:30.123456",
|
||||
"unix_ms": 1777976130123,
|
||||
"poll_interval_seconds": 3
|
||||
}
|
||||
```
|
||||
|
||||
Use `unix_ms` to correct the ESP local clock. Do not convert the value to a local timezone before applying it on the device.
|
||||
|
||||
## 6. Check-In and Check-Out Taps
|
||||
|
||||
```http
|
||||
POST /api/v1/esp/device/taps
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"card_uid": "04A1B2C3D4",
|
||||
"tapped_at": "2026-05-05T10:15:30.123456",
|
||||
"reader_type": "checkin_checkout"
|
||||
}
|
||||
```
|
||||
|
||||
`tapped_at` is optional. If omitted, server time is used. If provided, it is normalized to UTC before persistence.
|
||||
|
||||
Accepted check-in:
|
||||
|
||||
```json
|
||||
{
|
||||
"accepted": true,
|
||||
"action": "check_in",
|
||||
"message": "Checked in",
|
||||
"server_time_utc": "2026-05-05T10:15:30.123456",
|
||||
"tap_id": 123,
|
||||
"session_id": 45,
|
||||
"user_id": 7,
|
||||
"user_name": "Jane Smith",
|
||||
"checked_in_at": "2026-05-05T10:15:30.123456",
|
||||
"checked_out_at": null,
|
||||
"duration_seconds": null
|
||||
}
|
||||
```
|
||||
|
||||
Accepted check-out:
|
||||
|
||||
```json
|
||||
{
|
||||
"accepted": true,
|
||||
"action": "check_out",
|
||||
"message": "Checked out",
|
||||
"server_time_utc": "2026-05-05T12:20:30.123456",
|
||||
"tap_id": 124,
|
||||
"session_id": 45,
|
||||
"user_id": 7,
|
||||
"user_name": "Jane Smith",
|
||||
"checked_in_at": "2026-05-05T10:15:30.123456",
|
||||
"checked_out_at": "2026-05-05T12:20:30.123456",
|
||||
"duration_seconds": 7500
|
||||
}
|
||||
```
|
||||
|
||||
Denied:
|
||||
|
||||
```json
|
||||
{
|
||||
"accepted": false,
|
||||
"action": "denied",
|
||||
"message": "Unknown RFID card",
|
||||
"server_time_utc": "2026-05-05T10:15:30.123456",
|
||||
"tap_id": 125,
|
||||
"session_id": null,
|
||||
"user_id": null,
|
||||
"user_name": null
|
||||
}
|
||||
```
|
||||
|
||||
The server logs denied taps so unknown cards can still be audited.
|
||||
|
||||
The stored tap, check-in, check-out, approval, heartbeat, and write-job timestamps are all UTC. The frontend renders those values in Europe/London, but the backend and ESP contract stay on UTC/Zulu.
|
||||
|
||||
## 7. Queued RFID Card Writes
|
||||
|
||||
Admins queue card writes in:
|
||||
|
||||
- Dashboard -> Admin -> ESP RFID -> Setup -> Queue Card Write
|
||||
|
||||
They select:
|
||||
|
||||
- User
|
||||
- Writing reader
|
||||
- Card label
|
||||
|
||||
The reader does not need an inbound network port. It polls for jobs.
|
||||
|
||||
### Poll next write job
|
||||
|
||||
Only readers with `can_write_cards=true` may use this endpoint.
|
||||
|
||||
```http
|
||||
GET /api/v1/esp/device/write-jobs/next
|
||||
```
|
||||
|
||||
No job response:
|
||||
|
||||
```json
|
||||
null
|
||||
```
|
||||
|
||||
Job response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 20,
|
||||
"reader_id": 3,
|
||||
"user_id": 7,
|
||||
"card_id": null,
|
||||
"label": "Jane main card",
|
||||
"status": "claimed",
|
||||
"requested_by_user_id": 1,
|
||||
"card_uid": null,
|
||||
"write_payload": "{\"job_id\":20,\"user_id\":7,\"user_name\":\"Jane Smith\",\"label\":\"Jane main card\",\"issued_at\":\"2026-05-05T10:15:30.123456\"}",
|
||||
"claimed_at": "2026-05-05T10:15:30.123456",
|
||||
"completed_at": null,
|
||||
"error_message": null,
|
||||
"created_at": "2026-05-05T10:14:00.000000",
|
||||
"updated_at": "2026-05-05T10:15:30.123456"
|
||||
}
|
||||
```
|
||||
|
||||
Firmware behavior:
|
||||
|
||||
1. Poll for a job.
|
||||
2. If a job is returned, enter `ready to write` mode and show the target user/label on the ESP dashboard.
|
||||
3. On the next card tap, write `write_payload` to the card if supported by the chosen RFID card type.
|
||||
4. Report success/failure.
|
||||
|
||||
The included ESP firmware writes MIFARE Classic 1K/4K cards using the default key `FF FF FF FF FF FF`, starting at block 4 and skipping trailer blocks. Unsupported cards are reported as failed write jobs.
|
||||
|
||||
### Complete write job
|
||||
|
||||
```http
|
||||
POST /api/v1/esp/device/write-jobs/20/complete
|
||||
```
|
||||
|
||||
Success request:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"card_uid": "04A1B2C3D4"
|
||||
}
|
||||
```
|
||||
|
||||
Failure request:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error_message": "Card write failed verification"
|
||||
}
|
||||
```
|
||||
|
||||
On success, the server creates or updates the RFID card record and assigns it to the selected user with the queued label.
|
||||
|
||||
## 8. ESP Local Dashboard Login
|
||||
|
||||
Use this when someone logs into the ESP-hosted dashboard.
|
||||
|
||||
```http
|
||||
POST /api/v1/esp/device/dashboard-login
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "admin@example.com",
|
||||
"password": "password"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"user_id": 1,
|
||||
"role": "admin",
|
||||
"user_name": "Admin User"
|
||||
}
|
||||
```
|
||||
|
||||
Only `admin` and `super_admin` users return `valid: true`.
|
||||
|
||||
## 9. Admin API Summary
|
||||
|
||||
These endpoints use the normal portal JWT.
|
||||
|
||||
- `GET /api/v1/esp/admin/readers`
|
||||
- `POST /api/v1/esp/admin/readers` manual reader/key creation fallback
|
||||
- `PUT /api/v1/esp/admin/readers/{reader_id}`
|
||||
- `POST /api/v1/esp/admin/readers/{reader_id}/approve`
|
||||
- `POST /api/v1/esp/admin/readers/{reader_id}/reject`
|
||||
- `DELETE /api/v1/esp/admin/readers/{reader_id}`
|
||||
- `POST /api/v1/esp/device/heartbeat`
|
||||
- `GET /api/v1/esp/admin/cards`
|
||||
- `POST /api/v1/esp/admin/cards`
|
||||
- `PUT /api/v1/esp/admin/cards/{card_id}`
|
||||
- `GET /api/v1/esp/admin/write-jobs?limit=100`
|
||||
- `POST /api/v1/esp/admin/write-jobs`
|
||||
- `POST /api/v1/esp/admin/write-jobs/{job_id}/cancel`
|
||||
- `GET /api/v1/esp/admin/taps?limit=100`
|
||||
- `GET /api/v1/esp/admin/attendance?open_only=false&limit=100`
|
||||
- `POST /api/v1/esp/admin/attendance/close-stale`
|
||||
|
||||
Stale close request:
|
||||
|
||||
```json
|
||||
{
|
||||
"checkout_hour": 17
|
||||
}
|
||||
```
|
||||
|
||||
The backend also runs stale closing automatically on startup and hourly. It closes sessions checked in before today's midnight, sets checkout to 17:00 on the check-in date, and marks `checkout_source` as `system`.
|
||||
|
||||
## 10. Time Contract Summary
|
||||
|
||||
- API responses use UTC/Zulu datetimes.
|
||||
- ESP readers sync against `/api/v1/esp/device/time`.
|
||||
- Reader-supplied timestamps are normalized to UTC before storage.
|
||||
- The browser frontend displays these values in Europe/London for operators and members.
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Add ESP RFID attendance tables
|
||||
|
||||
Revision ID: 8d2b0c4a1f7e
|
||||
Revises: 2e8a0f9d4b31
|
||||
Create Date: 2026-05-05 11:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "8d2b0c4a1f7e"
|
||||
down_revision: Union[str, None] = "2e8a0f9d4b31"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"esp_readers",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("device_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("location", sa.String(length=255), nullable=True),
|
||||
sa.Column("reader_type", sa.Enum("checkin_checkout", name="espreadertype"), nullable=False),
|
||||
sa.Column("api_key_hash", sa.String(length=255), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_esp_readers_device_id"), "esp_readers", ["device_id"], unique=True)
|
||||
op.create_index(op.f("ix_esp_readers_id"), "esp_readers", ["id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"rfid_cards",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("uid", sa.String(length=100), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("label", sa.String(length=255), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_rfid_cards_id"), "rfid_cards", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_rfid_cards_uid"), "rfid_cards", ["uid"], unique=True)
|
||||
op.create_index(op.f("ix_rfid_cards_user_id"), "rfid_cards", ["user_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"rfid_taps",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("reader_id", sa.Integer(), nullable=False),
|
||||
sa.Column("card_id", sa.Integer(), nullable=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("card_uid", sa.String(length=100), nullable=False),
|
||||
sa.Column("action", sa.Enum("check_in", "check_out", "denied", "unknown", name="esptapaction"), nullable=False),
|
||||
sa.Column("accepted", sa.Boolean(), nullable=False),
|
||||
sa.Column("message", sa.String(length=255), nullable=True),
|
||||
sa.Column("raw_payload", sa.Text(), nullable=True),
|
||||
sa.Column("tapped_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["card_id"], ["rfid_cards.id"]),
|
||||
sa.ForeignKeyConstraint(["reader_id"], ["esp_readers.id"]),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_rfid_taps_card_id"), "rfid_taps", ["card_id"], unique=False)
|
||||
op.create_index(op.f("ix_rfid_taps_card_uid"), "rfid_taps", ["card_uid"], unique=False)
|
||||
op.create_index(op.f("ix_rfid_taps_id"), "rfid_taps", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_rfid_taps_reader_id"), "rfid_taps", ["reader_id"], unique=False)
|
||||
op.create_index(op.f("ix_rfid_taps_tapped_at"), "rfid_taps", ["tapped_at"], unique=False)
|
||||
op.create_index(op.f("ix_rfid_taps_user_id"), "rfid_taps", ["user_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"attendance_sessions",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("reader_id", sa.Integer(), nullable=False),
|
||||
sa.Column("check_in_tap_id", sa.Integer(), nullable=False),
|
||||
sa.Column("check_out_tap_id", sa.Integer(), nullable=True),
|
||||
sa.Column("checked_in_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("checked_out_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("checkout_source", sa.Enum("user", "system", name="attendancecheckoutsource"), nullable=True),
|
||||
sa.Column("system_flag_reason", sa.String(length=255), nullable=True),
|
||||
sa.Column("duration_seconds", sa.Integer(), nullable=True),
|
||||
sa.Column("is_open", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["check_in_tap_id"], ["rfid_taps.id"]),
|
||||
sa.ForeignKeyConstraint(["check_out_tap_id"], ["rfid_taps.id"]),
|
||||
sa.ForeignKeyConstraint(["reader_id"], ["esp_readers.id"]),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_attendance_sessions_checked_in_at"), "attendance_sessions", ["checked_in_at"], unique=False)
|
||||
op.create_index(op.f("ix_attendance_sessions_checked_out_at"), "attendance_sessions", ["checked_out_at"], unique=False)
|
||||
op.create_index(op.f("ix_attendance_sessions_id"), "attendance_sessions", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_attendance_sessions_is_open"), "attendance_sessions", ["is_open"], unique=False)
|
||||
op.create_index(op.f("ix_attendance_sessions_reader_id"), "attendance_sessions", ["reader_id"], unique=False)
|
||||
op.create_index(op.f("ix_attendance_sessions_user_id"), "attendance_sessions", ["user_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_attendance_sessions_user_id"), table_name="attendance_sessions")
|
||||
op.drop_index(op.f("ix_attendance_sessions_reader_id"), table_name="attendance_sessions")
|
||||
op.drop_index(op.f("ix_attendance_sessions_is_open"), table_name="attendance_sessions")
|
||||
op.drop_index(op.f("ix_attendance_sessions_id"), table_name="attendance_sessions")
|
||||
op.drop_index(op.f("ix_attendance_sessions_checked_out_at"), table_name="attendance_sessions")
|
||||
op.drop_index(op.f("ix_attendance_sessions_checked_in_at"), table_name="attendance_sessions")
|
||||
op.drop_table("attendance_sessions")
|
||||
op.drop_index(op.f("ix_rfid_taps_user_id"), table_name="rfid_taps")
|
||||
op.drop_index(op.f("ix_rfid_taps_tapped_at"), table_name="rfid_taps")
|
||||
op.drop_index(op.f("ix_rfid_taps_reader_id"), table_name="rfid_taps")
|
||||
op.drop_index(op.f("ix_rfid_taps_id"), table_name="rfid_taps")
|
||||
op.drop_index(op.f("ix_rfid_taps_card_uid"), table_name="rfid_taps")
|
||||
op.drop_index(op.f("ix_rfid_taps_card_id"), table_name="rfid_taps")
|
||||
op.drop_table("rfid_taps")
|
||||
op.drop_index(op.f("ix_rfid_cards_user_id"), table_name="rfid_cards")
|
||||
op.drop_index(op.f("ix_rfid_cards_uid"), table_name="rfid_cards")
|
||||
op.drop_index(op.f("ix_rfid_cards_id"), table_name="rfid_cards")
|
||||
op.drop_table("rfid_cards")
|
||||
op.drop_index(op.f("ix_esp_readers_id"), table_name="esp_readers")
|
||||
op.drop_index(op.f("ix_esp_readers_device_id"), table_name="esp_readers")
|
||||
op.drop_table("esp_readers")
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Add ESP provisioning and RFID write jobs
|
||||
|
||||
Revision ID: c4f1d2a9b8e6
|
||||
Revises: 8d2b0c4a1f7e
|
||||
Create Date: 2026-05-05 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "c4f1d2a9b8e6"
|
||||
down_revision: Union[str, None] = "8d2b0c4a1f7e"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"esp_readers",
|
||||
sa.Column(
|
||||
"provisioning_status",
|
||||
sa.Enum("pending", "approved", "provisioned", "rejected", name="espreaderprovisioningstatus"),
|
||||
nullable=False,
|
||||
server_default="provisioned",
|
||||
),
|
||||
)
|
||||
op.add_column("esp_readers", sa.Column("registration_token_hash", sa.String(length=255), nullable=True))
|
||||
op.add_column("esp_readers", sa.Column("can_write_cards", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
op.add_column("esp_readers", sa.Column("firmware_version", sa.String(length=100), nullable=True))
|
||||
op.add_column("esp_readers", sa.Column("approved_at", sa.DateTime(), nullable=True))
|
||||
op.add_column("esp_readers", sa.Column("provisioned_at", sa.DateTime(), nullable=True))
|
||||
op.alter_column("esp_readers", "api_key_hash", existing_type=sa.String(length=255), nullable=True)
|
||||
|
||||
op.create_table(
|
||||
"rfid_card_write_jobs",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("reader_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("card_id", sa.Integer(), nullable=True),
|
||||
sa.Column("label", sa.String(length=255), nullable=False),
|
||||
sa.Column("status", sa.Enum("pending", "claimed", "completed", "failed", "cancelled", name="rfidwritejobstatus"), nullable=False),
|
||||
sa.Column("requested_by_user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("card_uid", sa.String(length=100), nullable=True),
|
||||
sa.Column("write_payload", sa.Text(), nullable=True),
|
||||
sa.Column("claimed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("error_message", sa.String(length=500), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["card_id"], ["rfid_cards.id"]),
|
||||
sa.ForeignKeyConstraint(["reader_id"], ["esp_readers.id"]),
|
||||
sa.ForeignKeyConstraint(["requested_by_user_id"], ["users.id"]),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_rfid_card_write_jobs_card_id"), "rfid_card_write_jobs", ["card_id"], unique=False)
|
||||
op.create_index(op.f("ix_rfid_card_write_jobs_card_uid"), "rfid_card_write_jobs", ["card_uid"], unique=False)
|
||||
op.create_index(op.f("ix_rfid_card_write_jobs_id"), "rfid_card_write_jobs", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_rfid_card_write_jobs_reader_id"), "rfid_card_write_jobs", ["reader_id"], unique=False)
|
||||
op.create_index(op.f("ix_rfid_card_write_jobs_status"), "rfid_card_write_jobs", ["status"], unique=False)
|
||||
op.create_index(op.f("ix_rfid_card_write_jobs_user_id"), "rfid_card_write_jobs", ["user_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_rfid_card_write_jobs_user_id"), table_name="rfid_card_write_jobs")
|
||||
op.drop_index(op.f("ix_rfid_card_write_jobs_status"), table_name="rfid_card_write_jobs")
|
||||
op.drop_index(op.f("ix_rfid_card_write_jobs_reader_id"), table_name="rfid_card_write_jobs")
|
||||
op.drop_index(op.f("ix_rfid_card_write_jobs_id"), table_name="rfid_card_write_jobs")
|
||||
op.drop_index(op.f("ix_rfid_card_write_jobs_card_uid"), table_name="rfid_card_write_jobs")
|
||||
op.drop_index(op.f("ix_rfid_card_write_jobs_card_id"), table_name="rfid_card_write_jobs")
|
||||
op.drop_table("rfid_card_write_jobs")
|
||||
op.alter_column("esp_readers", "api_key_hash", existing_type=sa.String(length=255), nullable=False)
|
||||
op.drop_column("esp_readers", "provisioned_at")
|
||||
op.drop_column("esp_readers", "approved_at")
|
||||
op.drop_column("esp_readers", "firmware_version")
|
||||
op.drop_column("esp_readers", "can_write_cards")
|
||||
op.drop_column("esp_readers", "registration_token_hash")
|
||||
op.drop_column("esp_readers", "provisioning_status")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Add pending ESP API key delivery
|
||||
|
||||
Revision ID: e7a9c2b1d4f0
|
||||
Revises: c4f1d2a9b8e6
|
||||
Create Date: 2026-05-05 13:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "e7a9c2b1d4f0"
|
||||
down_revision: Union[str, None] = "c4f1d2a9b8e6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("esp_readers", sa.Column("pending_api_key", sa.String(length=255), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("esp_readers", "pending_api_key")
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
Generated
+3411
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { User } from '../../services/membershipService';
|
||||
import ProfileMenu from '../ProfileMenu';
|
||||
import PortalBrand from '../layout/PortalBrand';
|
||||
|
||||
interface DashboardTopbarProps {
|
||||
activeTab: 'overview' | 'questions' | 'settings' | 'admin';
|
||||
isAdmin: boolean;
|
||||
isAdminWorkspace: boolean;
|
||||
navigateToTab: (tab: 'overview' | 'questions' | 'settings' | 'admin') => void;
|
||||
enterAdminArea: () => void;
|
||||
exitAdminArea: () => void;
|
||||
onEditProfile: () => void;
|
||||
subtitle?: string;
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
const userTabs: Array<{ key: 'overview' | 'questions' | 'settings'; label: string }> = [
|
||||
{ key: 'overview', label: 'Overview' },
|
||||
{ key: 'questions', label: 'Profile Questions' },
|
||||
{ key: 'settings', label: 'Profile Settings' }
|
||||
];
|
||||
|
||||
const DashboardTopbar: React.FC<DashboardTopbarProps> = ({
|
||||
activeTab,
|
||||
isAdmin,
|
||||
isAdminWorkspace,
|
||||
navigateToTab,
|
||||
enterAdminArea,
|
||||
exitAdminArea,
|
||||
onEditProfile,
|
||||
subtitle,
|
||||
user
|
||||
}) => (
|
||||
<nav className={isAdminWorkspace ? 'portal-topbar portal-topbar-admin' : 'portal-topbar member-topbar'}>
|
||||
<PortalBrand
|
||||
title={isAdminWorkspace ? 'SASA Admin' : 'SASA Member Portal'}
|
||||
subtitle={subtitle || (isAdminWorkspace ? 'Operations network' : `Welcome, ${user?.first_name || 'Member'}`)}
|
||||
admin={isAdminWorkspace}
|
||||
/>
|
||||
|
||||
{!isAdminWorkspace && (
|
||||
<div className="portal-nav">
|
||||
{userTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={activeTab === tab.key ? 'portal-tab active' : 'portal-tab'}
|
||||
onClick={() => navigateToTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
{isAdmin && (
|
||||
<button className="portal-switch-button" onClick={enterAdminArea}>
|
||||
Enter Admin Area
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="portal-meta">
|
||||
{isAdminWorkspace && (
|
||||
<button className="portal-exit-button" onClick={exitAdminArea}>
|
||||
Back to User Space
|
||||
</button>
|
||||
)}
|
||||
<ProfileMenu
|
||||
userName={`${user?.first_name || ''} ${user?.last_name || ''}`.trim()}
|
||||
userRole={user?.role || ''}
|
||||
user={user}
|
||||
onEditProfile={onEditProfile}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
export default DashboardTopbar;
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const AppFooter: React.FC = () => (
|
||||
<footer className="site-footer">
|
||||
<div>
|
||||
<Link to="/privacy-policy">Privacy Policy</Link>
|
||||
<Link to="/terms-of-service">Terms of Service</Link>
|
||||
</div>
|
||||
<div className="site-footer-caption">SASA Portal</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
export default AppFooter;
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CookieBannerProps {
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
const CookieBanner: React.FC<CookieBannerProps> = ({ onDismiss }) => (
|
||||
<div className="cookie-banner">
|
||||
<div>
|
||||
We use cookies for session authentication, security, and basic site functionality.
|
||||
</div>
|
||||
<button className="btn btn-primary cookie-banner-button" onClick={onDismiss}>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default CookieBanner;
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PortalBrandProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
admin?: boolean;
|
||||
}
|
||||
|
||||
const PortalBrand: React.FC<PortalBrandProps> = ({ title, subtitle, admin = false }) => (
|
||||
<div className="portal-brand">
|
||||
<div className="portal-mark">S</div>
|
||||
<div className={`portal-brand-text${admin ? ' admin-brand-text' : ''}`}>
|
||||
<h1>{title}</h1>
|
||||
<div className="portal-subtitle">{subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PortalBrand;
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
interface ConfirmOptions {
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
tone?: 'default' | 'danger';
|
||||
}
|
||||
|
||||
interface ConfirmState extends ConfirmOptions {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
interface ConfirmContextValue {
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
|
||||
|
||||
export const ConfirmProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const location = useLocation();
|
||||
const resolverRef = useRef<((value: boolean) => void) | null>(null);
|
||||
const lastLocationKeyRef = useRef(location.key);
|
||||
const [dialog, setDialog] = useState<ConfirmState>({
|
||||
open: false,
|
||||
title: '',
|
||||
message: '',
|
||||
confirmLabel: 'Confirm',
|
||||
cancelLabel: 'Cancel',
|
||||
tone: 'default'
|
||||
});
|
||||
|
||||
const closeDialog = useCallback((result: boolean) => {
|
||||
resolverRef.current?.(result);
|
||||
resolverRef.current = null;
|
||||
setDialog((prev) => ({ ...prev, open: false }));
|
||||
}, []);
|
||||
|
||||
const confirm = useCallback((options: ConfirmOptions) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolverRef.current = resolve;
|
||||
setDialog({
|
||||
open: true,
|
||||
title: options.title || 'Confirm action',
|
||||
message: options.message,
|
||||
confirmLabel: options.confirmLabel || 'Confirm',
|
||||
cancelLabel: options.cancelLabel || 'Cancel',
|
||||
tone: options.tone || 'default'
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ConfirmContextValue>(() => ({ confirm }), [confirm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastLocationKeyRef.current === location.key) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastLocationKeyRef.current = location.key;
|
||||
|
||||
if (!dialog.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
resolverRef.current?.(false);
|
||||
resolverRef.current = null;
|
||||
setDialog((prev) => ({ ...prev, open: false }));
|
||||
}, [dialog.open, location.key]);
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={value}>
|
||||
{children}
|
||||
{dialog.open && (
|
||||
<div className="modal-overlay" onClick={() => closeDialog(false)}>
|
||||
<div className="modal-content confirm-dialog" onClick={(event) => event.stopPropagation()}>
|
||||
<h3 className={dialog.tone === 'danger' ? 'confirm-dialog-title danger' : 'confirm-dialog-title'}>
|
||||
{dialog.title}
|
||||
</h3>
|
||||
<p className="confirm-dialog-message">{dialog.message}</p>
|
||||
<div className="modal-button-row">
|
||||
<button className="btn btn-secondary" type="button" onClick={() => closeDialog(false)}>
|
||||
{dialog.cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
className={dialog.tone === 'danger' ? 'btn btn-danger' : 'btn btn-primary'}
|
||||
type="button"
|
||||
onClick={() => closeDialog(true)}
|
||||
>
|
||||
{dialog.confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useConfirm = (): ConfirmContextValue => {
|
||||
const context = useContext(ConfirmContext);
|
||||
if (!context) {
|
||||
throw new Error('useConfirm must be used within a ConfirmProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||
|
||||
type ToastTone = 'success' | 'error' | 'info';
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
message: string;
|
||||
tone: ToastTone;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showToast: (message: string, tone?: ToastTone) => void;
|
||||
success: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
|
||||
const dismissToast = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback((message: string, tone: ToastTone = 'info') => {
|
||||
const id = Date.now() + Math.floor(Math.random() * 1000);
|
||||
setToasts((prev) => [...prev, { id, message, tone }]);
|
||||
window.setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, 5000);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ToastContextValue>(() => ({
|
||||
showToast,
|
||||
success: (message) => showToast(message, 'success'),
|
||||
error: (message) => showToast(message, 'error'),
|
||||
info: (message) => showToast(message, 'info')
|
||||
}), [showToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className="toast-viewport" aria-live="polite" aria-atomic="true">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className={`toast toast-${toast.tone}`}>
|
||||
<div className="toast-message">{toast.message}</div>
|
||||
<button className="toast-close" type="button" onClick={() => dismissToast(toast.id)} aria-label="Dismiss notification">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useToast = (): ToastContextValue => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
|
||||
type AdminAreaKey = 'operations' | 'rfid' | 'comms' | 'flags' | 'tiers';
|
||||
type AdminSectionKey =
|
||||
| 'overview'
|
||||
| 'users'
|
||||
| 'events'
|
||||
| 'profileQuestions'
|
||||
| 'espActions'
|
||||
| 'espReaders'
|
||||
| 'espCards'
|
||||
| 'espActivity'
|
||||
| 'featureFlags'
|
||||
| 'tiers'
|
||||
| 'email'
|
||||
| 'bounces';
|
||||
|
||||
interface AdminWorkspacePageProps {
|
||||
activeAdminArea: AdminAreaKey;
|
||||
activePageItems: Array<{ key: AdminSectionKey; label: string }>;
|
||||
adminPrimaryItems: Array<{ key: AdminAreaKey; label: string; defaultSection: AdminSectionKey }>;
|
||||
adminSection: AdminSectionKey;
|
||||
children: React.ReactNode;
|
||||
renderAdminRailTools: () => React.ReactNode;
|
||||
renderPrimaryIcon: (area: AdminAreaKey) => React.ReactNode;
|
||||
showAdminPageRail: boolean;
|
||||
navigateToAdminSection: (section: AdminSectionKey) => void;
|
||||
}
|
||||
|
||||
const AdminWorkspacePage: React.FC<AdminWorkspacePageProps> = ({
|
||||
activeAdminArea,
|
||||
activePageItems,
|
||||
adminPrimaryItems,
|
||||
adminSection,
|
||||
children,
|
||||
renderAdminRailTools,
|
||||
renderPrimaryIcon,
|
||||
showAdminPageRail,
|
||||
navigateToAdminSection
|
||||
}) => (
|
||||
<div className={`admin-workspace ${showAdminPageRail ? 'has-page-rail' : 'single-page-area'}`}>
|
||||
<aside className="admin-primary-rail">
|
||||
<nav className="admin-primary-nav" aria-label="Admin areas">
|
||||
{adminPrimaryItems.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={activeAdminArea === item.key ? 'admin-primary-link active' : 'admin-primary-link'}
|
||||
onClick={() => navigateToAdminSection(item.defaultSection)}
|
||||
title={item.label}
|
||||
>
|
||||
<span className="admin-primary-icon">{renderPrimaryIcon(item.key)}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{showAdminPageRail && (
|
||||
<aside className="admin-page-rail">
|
||||
<div className="admin-page-rail-title">
|
||||
{adminPrimaryItems.find((item) => item.key === activeAdminArea)?.label}
|
||||
</div>
|
||||
<nav className="admin-page-nav" aria-label="Admin pages">
|
||||
{activePageItems.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={adminSection === item.key ? 'admin-page-link active' : 'admin-page-link'}
|
||||
onClick={() => navigateToAdminSection(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{renderAdminRailTools()}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<section className="admin-content">{children}</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AdminWorkspacePage;
|
||||
@@ -0,0 +1,189 @@
|
||||
import React from 'react';
|
||||
import { Event, Membership, Payment } from '../../services/membershipService';
|
||||
import { utcToLondonTimeInput } from '../../utils/timezone';
|
||||
|
||||
interface MemberOverviewPageProps {
|
||||
activeMembership?: Membership;
|
||||
formatDate: (dateString: string) => string;
|
||||
getStatusClass: (status: string) => string;
|
||||
handleMembershipSetup: () => void;
|
||||
handleRSVP: (eventId: number, status: 'attending' | 'maybe' | 'not_attending') => void;
|
||||
payments: Payment[];
|
||||
rsvpLoading: { [eventId: number]: boolean };
|
||||
upcomingEvents: Event[];
|
||||
}
|
||||
|
||||
const MemberOverviewPage: React.FC<MemberOverviewPageProps> = ({
|
||||
activeMembership,
|
||||
formatDate,
|
||||
getStatusClass,
|
||||
handleMembershipSetup,
|
||||
handleRSVP,
|
||||
payments,
|
||||
rsvpLoading,
|
||||
upcomingEvents
|
||||
}) => (
|
||||
<>
|
||||
<section className="member-hero">
|
||||
<div>
|
||||
<p className="member-hero-kicker">Member Dashboard</p>
|
||||
<h2 className="member-hero-title">Everything you need for your SASA membership</h2>
|
||||
<p className="member-hero-copy">
|
||||
Track your status, respond to upcoming events, and keep your details current from one place.
|
||||
</p>
|
||||
</div>
|
||||
<div className="member-stat-strip">
|
||||
<div className="member-stat-chip">
|
||||
<span className="member-stat-label">Membership</span>
|
||||
<strong className="member-stat-value">{activeMembership ? activeMembership.status : 'Not set up'}</strong>
|
||||
</div>
|
||||
<div className="member-stat-chip">
|
||||
<span className="member-stat-label">Events</span>
|
||||
<strong className="member-stat-value">{upcomingEvents.length}</strong>
|
||||
</div>
|
||||
<div className="member-stat-chip">
|
||||
<span className="member-stat-label">Payments</span>
|
||||
<strong className="member-stat-value">{payments.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="dashboard-grid member-overview-grid">
|
||||
{activeMembership ? (
|
||||
<div className="card member-card">
|
||||
<div className="member-card-header">
|
||||
<div>
|
||||
<p className="member-card-kicker">Membership</p>
|
||||
<h3>Your Membership</h3>
|
||||
</div>
|
||||
<span className={`status-badge ${getStatusClass(activeMembership.status)}`}>{activeMembership.status.toUpperCase()}</span>
|
||||
</div>
|
||||
<h4 className="member-tier-title">{activeMembership.tier.name}</h4>
|
||||
<div className="member-data-list">
|
||||
<div className="member-data-row"><strong>Membership Number</strong><span>{activeMembership.id}</span></div>
|
||||
<div className="member-data-row"><strong>Annual Fee</strong><span>£{activeMembership.tier.annual_fee.toFixed(2)}</span></div>
|
||||
<div className="member-data-row"><strong>Valid From</strong><span>{formatDate(activeMembership.start_date)}</span></div>
|
||||
<div className="member-data-row"><strong>Valid Until</strong><span>{formatDate(activeMembership.end_date)}</span></div>
|
||||
<div className="member-data-row"><strong>Auto Renew</strong><span>{activeMembership.auto_renew ? 'Yes' : 'No'}</span></div>
|
||||
</div>
|
||||
<div className="member-info-panel">
|
||||
<strong>Benefits:</strong>
|
||||
<p>{activeMembership.tier.benefits}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card member-card">
|
||||
<div className="member-card-header">
|
||||
<div>
|
||||
<p className="member-card-kicker">Membership</p>
|
||||
<h3>Set Up Your Membership</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p>Choose from our membership tiers to get started with SASA benefits.</p>
|
||||
<p className="member-muted-copy">Available tiers include Personal, Aircraft Owners, and Corporate memberships.</p>
|
||||
<button className="btn btn-primary member-inline-action" onClick={handleMembershipSetup}>
|
||||
Set Up Membership
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card member-card">
|
||||
<div className="member-card-header">
|
||||
<div>
|
||||
<p className="member-card-kicker">Calendar</p>
|
||||
<h3>Upcoming Events</h3>
|
||||
</div>
|
||||
</div>
|
||||
{upcomingEvents.length > 0 ? (
|
||||
<div className="events-container">
|
||||
{upcomingEvents.map((event) => (
|
||||
<div key={event.id} className="event-card">
|
||||
<div className="event-header">
|
||||
<div className="event-info">
|
||||
<h4 className="event-title">{event.title}</h4>
|
||||
<p className="event-datetime">
|
||||
{formatDate(event.event_date)} at {utcToLondonTimeInput(event.event_date)}
|
||||
</p>
|
||||
{event.location && <p className="event-location">{event.location}</p>}
|
||||
</div>
|
||||
<div className="event-rsvp-buttons">
|
||||
<button
|
||||
className={`rsvp-btn rsvp-btn-attending ${event.rsvp_status === 'attending' ? 'active' : ''}`}
|
||||
onClick={() => handleRSVP(event.id, 'attending')}
|
||||
disabled={rsvpLoading[event.id]}
|
||||
>
|
||||
{rsvpLoading[event.id] ? '...' : 'Attending'}
|
||||
</button>
|
||||
<button
|
||||
className={`rsvp-btn rsvp-btn-maybe ${event.rsvp_status === 'maybe' ? 'active' : ''}`}
|
||||
onClick={() => handleRSVP(event.id, 'maybe')}
|
||||
disabled={rsvpLoading[event.id]}
|
||||
>
|
||||
{rsvpLoading[event.id] ? '...' : 'Maybe'}
|
||||
</button>
|
||||
<button
|
||||
className={`rsvp-btn rsvp-btn-not-attending ${event.rsvp_status === 'not_attending' ? 'active' : ''}`}
|
||||
onClick={() => handleRSVP(event.id, 'not_attending')}
|
||||
disabled={rsvpLoading[event.id]}
|
||||
>
|
||||
{rsvpLoading[event.id] ? '...' : 'Not Attending'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{event.description && <p className="event-description">{event.description}</p>}
|
||||
{event.rsvp_status && (
|
||||
<div className={`event-rsvp-status ${event.rsvp_status}`}>
|
||||
<strong>Your RSVP:</strong> <span className="member-rsvp-state">{event.rsvp_status.replace('_', ' ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="member-muted-copy">No upcoming events at this time.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card member-card">
|
||||
<div className="member-card-header">
|
||||
<div>
|
||||
<p className="member-card-kicker">Billing</p>
|
||||
<h3>Payment History</h3>
|
||||
</div>
|
||||
</div>
|
||||
{payments.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table className="member-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Amount</th>
|
||||
<th>Method</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payments.map((payment) => (
|
||||
<tr key={payment.id}>
|
||||
<td>{payment.payment_date ? formatDate(payment.payment_date) : 'Pending'}</td>
|
||||
<td>£{payment.amount.toFixed(2)}</td>
|
||||
<td className="member-table-caps">{payment.payment_method}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${getStatusClass(payment.status)}`}>
|
||||
{payment.status.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="member-muted-copy">No payment history available.</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default MemberOverviewPage;
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import ProfileQuestionsForm from '../../components/ProfileQuestionsForm';
|
||||
import { ProfileAnswerInput, ProfileQuestionForUser } from '../../services/membershipService';
|
||||
|
||||
interface MemberQuestionsPageProps {
|
||||
onSave: (answers: ProfileAnswerInput[]) => Promise<void>;
|
||||
questions: ProfileQuestionForUser[];
|
||||
}
|
||||
|
||||
const MemberQuestionsPage: React.FC<MemberQuestionsPageProps> = ({ onSave, questions }) => (
|
||||
<ProfileQuestionsForm
|
||||
title="Your Profile Questions"
|
||||
description="Optional details that help us support your membership and volunteering. Some fields are admin-managed."
|
||||
questions={questions}
|
||||
onSave={onSave}
|
||||
saveLabel="Save Profile Answers"
|
||||
surface="member"
|
||||
/>
|
||||
);
|
||||
|
||||
export default MemberQuestionsPage;
|
||||
@@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
|
||||
interface MemberSettingsPageProps {
|
||||
passwordError: string;
|
||||
passwordForm: {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
confirm_password: string;
|
||||
};
|
||||
passwordSaving: boolean;
|
||||
passwordSuccess: string;
|
||||
profileError: string;
|
||||
profileFormData: {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
};
|
||||
profileSaving: boolean;
|
||||
profileSuccess: string;
|
||||
setPasswordForm: React.Dispatch<React.SetStateAction<{
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
confirm_password: string;
|
||||
}>>;
|
||||
setProfileFormData: React.Dispatch<React.SetStateAction<{
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
}>>;
|
||||
onChangePassword: () => void;
|
||||
onSaveProfile: () => void;
|
||||
}
|
||||
|
||||
const MemberSettingsPage: React.FC<MemberSettingsPageProps> = ({
|
||||
passwordError,
|
||||
passwordForm,
|
||||
passwordSaving,
|
||||
passwordSuccess,
|
||||
profileError,
|
||||
profileFormData,
|
||||
profileSaving,
|
||||
profileSuccess,
|
||||
setPasswordForm,
|
||||
setProfileFormData,
|
||||
onChangePassword,
|
||||
onSaveProfile
|
||||
}) => (
|
||||
<div className="card member-card member-settings-card">
|
||||
<div className="member-card-header">
|
||||
<div>
|
||||
<p className="member-card-kicker">Settings</p>
|
||||
<h3>Profile Settings</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profileError && <div className="alert alert-error">{profileError}</div>}
|
||||
{profileSuccess && <div className="alert alert-success">{profileSuccess}</div>}
|
||||
|
||||
<div className="member-settings-grid">
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-first-name">First Name</label>
|
||||
<input
|
||||
id="settings-first-name"
|
||||
type="text"
|
||||
placeholder="First Name"
|
||||
value={profileFormData.first_name}
|
||||
onChange={(e) => setProfileFormData((prev) => ({ ...prev, first_name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-last-name">Last Name</label>
|
||||
<input
|
||||
id="settings-last-name"
|
||||
type="text"
|
||||
placeholder="Last Name"
|
||||
value={profileFormData.last_name}
|
||||
onChange={(e) => setProfileFormData((prev) => ({ ...prev, last_name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-email">Email</label>
|
||||
<input
|
||||
id="settings-email"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={profileFormData.email}
|
||||
onChange={(e) => setProfileFormData((prev) => ({ ...prev, email: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-phone">Phone</label>
|
||||
<input
|
||||
id="settings-phone"
|
||||
type="text"
|
||||
placeholder="Phone"
|
||||
value={profileFormData.phone}
|
||||
onChange={(e) => setProfileFormData((prev) => ({ ...prev, phone: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-address">Address</label>
|
||||
<textarea
|
||||
id="settings-address"
|
||||
placeholder="Address"
|
||||
value={profileFormData.address}
|
||||
onChange={(e) => setProfileFormData((prev) => ({ ...prev, address: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="member-settings-actions">
|
||||
<button className="btn btn-primary" disabled={profileSaving} onClick={onSaveProfile}>
|
||||
{profileSaving ? 'Saving...' : 'Save Profile'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="member-settings-divider" />
|
||||
<h4 className="member-section-heading">Change Password</h4>
|
||||
{passwordError && <div className="alert alert-error">{passwordError}</div>}
|
||||
{passwordSuccess && <div className="alert alert-success">{passwordSuccess}</div>}
|
||||
<div className="member-settings-grid">
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-current-password">Current Password</label>
|
||||
<input
|
||||
id="settings-current-password"
|
||||
type="password"
|
||||
placeholder="Current Password"
|
||||
value={passwordForm.current_password}
|
||||
onChange={(e) => setPasswordForm((prev) => ({ ...prev, current_password: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-new-password">New Password</label>
|
||||
<input
|
||||
id="settings-new-password"
|
||||
type="password"
|
||||
placeholder="New Password"
|
||||
value={passwordForm.new_password}
|
||||
onChange={(e) => setPasswordForm((prev) => ({ ...prev, new_password: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-confirm-password">Confirm New Password</label>
|
||||
<input
|
||||
id="settings-confirm-password"
|
||||
type="password"
|
||||
placeholder="Confirm New Password"
|
||||
value={passwordForm.confirm_password}
|
||||
onChange={(e) => setPasswordForm((prev) => ({ ...prev, confirm_password: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="member-settings-actions">
|
||||
<button className="btn btn-secondary" disabled={passwordSaving} onClick={onChangePassword}>
|
||||
{passwordSaving ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MemberSettingsPage;
|
||||
@@ -28,15 +28,11 @@ export const canEditProfileQuestion = (
|
||||
question: EditableProfileQuestion,
|
||||
allowAdminManagedEdit = false
|
||||
): boolean => {
|
||||
if (allowAdminManagedEdit) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!question.can_edit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (question.admin_only_edit) {
|
||||
if (question.admin_only_edit && !allowAdminManagedEdit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
const LONDON_TIME_ZONE = 'Europe/London';
|
||||
|
||||
const parseUtcDate = (value: string): Date => {
|
||||
const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/.test(value) ? value : `${value}Z`;
|
||||
return new Date(normalized);
|
||||
};
|
||||
|
||||
const londonParts = (date: Date) => {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: LONDON_TIME_ZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23'
|
||||
}).formatToParts(date);
|
||||
|
||||
const get = (type: string) => parts.find((part) => part.type === type)?.value || '00';
|
||||
return {
|
||||
year: get('year'),
|
||||
month: get('month'),
|
||||
day: get('day'),
|
||||
hour: get('hour'),
|
||||
minute: get('minute')
|
||||
};
|
||||
};
|
||||
|
||||
const timeZoneOffsetMs = (instant: Date): number => {
|
||||
const parts = londonParts(instant);
|
||||
const wallAsUtc = Date.UTC(
|
||||
Number(parts.year),
|
||||
Number(parts.month) - 1,
|
||||
Number(parts.day),
|
||||
Number(parts.hour),
|
||||
Number(parts.minute)
|
||||
);
|
||||
return wallAsUtc - instant.getTime();
|
||||
};
|
||||
|
||||
export const ensureUtcIso = (value: string): string => parseUtcDate(value).toISOString();
|
||||
|
||||
export const utcMillis = (value: string | null | undefined): number => {
|
||||
if (!value) return 0;
|
||||
return parseUtcDate(value).getTime();
|
||||
};
|
||||
|
||||
export const formatLondonDate = (value: string): string => {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: LONDON_TIME_ZONE,
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(parseUtcDate(value));
|
||||
};
|
||||
|
||||
export const formatLondonDateTime = (value: string | null): string => {
|
||||
if (!value) return 'Never';
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: LONDON_TIME_ZONE,
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23'
|
||||
}).format(parseUtcDate(value));
|
||||
};
|
||||
|
||||
export const utcToLondonDateInput = (value: string): string => {
|
||||
const parts = londonParts(parseUtcDate(value));
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
};
|
||||
|
||||
export const utcToLondonTimeInput = (value: string): string => {
|
||||
const parts = londonParts(parseUtcDate(value));
|
||||
return `${parts.hour}:${parts.minute}`;
|
||||
};
|
||||
|
||||
export const londonTodayDateInput = (): string => {
|
||||
const parts = londonParts(new Date());
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
};
|
||||
|
||||
export const londonInputToUtcIso = (dateValue: string, timeValue: string = '00:00'): string => {
|
||||
const [year, month, day] = dateValue.split('-').map(Number);
|
||||
const [hour, minute] = timeValue.split(':').map(Number);
|
||||
const wallAsUtc = Date.UTC(year, month - 1, day, hour || 0, minute || 0);
|
||||
const firstPass = new Date(wallAsUtc - timeZoneOffsetMs(new Date(wallAsUtc)));
|
||||
const secondPass = new Date(wallAsUtc - timeZoneOffsetMs(firstPass));
|
||||
return secondPass.toISOString();
|
||||
};
|
||||
Reference in New Issue
Block a user