Skip to content
Merged
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ ALLOWED_HOSTS=127.0.0.1,localhost
# CORS configuration
CORS_ALLOW_ALL_ORIGINS=True
# CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000

# Grace period (seconds) for users to check in when their turn is called.
# After this period, unchecked-in users are auto-expired. Default: 180 (3 min).
# QUEUE_GRACE_PERIOD_SECONDS=180
117 changes: 105 additions & 12 deletions API_ENDPOINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ Load notes are qualitative, not benchmark numbers. Actual capacity depends on de
- Institution API base: `/api/institutions/`
- Queue API base: `/api/queue/`

Institution data is simulated and should not be treated as authoritative live data until a real provider is integrated.
Mock routes are controlled by `ENABLE_MOCK_API`.
If disabled, these routes are not exposed.
The sections below provide the full list of available endpoints and load-profile details.

See [MOCK_QUEUE_OPERATIONS.md](MOCK_QUEUE_OPERATIONS.md) for how to seed, join, poll, and advance the mock queue.

## Summary Table

Expand All @@ -27,17 +27,20 @@ If disabled, these routes are not exposed.
| GET | `/api/institutions/{id}/` | Public | Get one institution with queue summary fields | Low |
| POST | `/api/queue/join/` | Public | Join queue for an institution | Low per call, write-heavy in spikes |
| GET | `/api/queue/entries/{session_id}/status/` | Public | Get status for one queue session | Low |
| GET | `/api/queue/entries/{session_id}/notifications/` | Public | List notifications for one queue session | Low |
| PATCH | `/api/queue/entries/{session_id}/notifications/{notification_id}/ack/` | Public | Update notification delivery state | Low |
| PATCH | `/api/queue/entries/{session_id}/check-in/` | Public | Confirm presence during SERVING status | Low |
| GET | `/api/notifications/entries/{session_id}/notifications/` | Public | List notifications for one queue session | Low |
| PATCH | `/api/notifications/entries/{session_id}/notifications/{notification_id}/ack/` | Public | Update notification delivery state | Low |
| GET | `/api/queue/institutions/{institution_id}/entries/` | Admin only | List queue entries for one institution | Medium to high |
| POST | `/api/queue/institutions/{institution_id}/simulate-tick/` | Admin only | Advance queue state and generate notifications | Medium |
| POST | `/api/queue/auto-tick/` | Admin only | Trigger one hybrid tick pass across institutions with active queue entries | Low to medium |

## Data Types and Enums

### Queue status values

- `waiting`
- `notified`
- `serving`
- `served`
- `expired`
- `cancelled`
Expand All @@ -49,7 +52,8 @@ For query params that accept boolean values (for example `active_only`, `randomi
- truthy: `1`, `true`, `t`, `yes`, `y`, `on`
- falsy: `0`, `false`, `f`, `no`, `n`, `off`

- any other value falls back silently to the parameter's default value; the backend does not return `400 Bad Request` for unrecognized boolean-like input
- any other value falls back silently to the parameter's default value; the backend does not return `400 Bad Request` for unrecognized boolean-like input.
- **Exception**: Parameters using strict parsing (like `delivered` in notifications) will return `400 Bad Request` on invalid input.
- defaults are endpoint-specific; for example, `active_only` and `randomize` currently fall back to `true`

## Endpoint Contracts
Expand Down Expand Up @@ -244,7 +248,47 @@ Status: `200 OK`

Low. Single lookup by session ID.

## GET /api/queue/entries/{session_id}/notifications/
## PATCH /api/queue/entries/{session_id}/check-in/

### Access

Public

### Request body

None required.

### Success response

Status: `200 OK`

```json
{
"session_id": "0e78ab8f-1f05-4741-8d73-e2d3778e9a35",
"institution_id": 1,
"queue_number": 42,
"current_serving_number": 42,
"status": "serving",
"near_turn_threshold": 3,
"near_turn_notified": true,
"issued_at": "2026-04-19T09:45:23.123456Z",
"updated_at": "2026-04-19T10:05:00.000000Z",
"turn_called_at": "2026-04-19T10:00:00.000000Z",
"checked_in_at": "2026-04-19T10:05:00.000000Z",
"people_ahead": 0
}
```

### Common errors

- `400 Bad Request` if the entry is not in `serving` status.
- `404 Not Found` if the queue session does not exist.

### Load note

Low. Single lookup and update by session ID.

## GET /api/notifications/entries/{session_id}/notifications/

### Access

Expand All @@ -257,7 +301,7 @@ Public
### Query params

- `delivered` (optional boolean, filters notifications by delivery state; this parameter is parsed strictly, and invalid boolean-like values return `400 Bad Request` rather than silently falling back)
- `event_type` (optional string, one of `near_turn`, `turn_called`, `session_expired`, `generic`)
- `event_type` (optional string, one of `near_turn`, `turn_called`, `session_expired`, `session_completed`, `generic`)
- `limit` (optional integer, defaults to `50`, maximum `100`)

### Success response
Expand Down Expand Up @@ -298,7 +342,7 @@ Status: `200 OK`

Low. This is a session-scoped lookup with optional filtering and a capped result size.

## PATCH /api/queue/entries/{session_id}/notifications/{notification_id}/ack/
## PATCH /api/notifications/entries/{session_id}/notifications/{notification_id}/ack/

### Access

Expand Down Expand Up @@ -396,7 +440,7 @@ Status: `200 OK`
"status": "waiting,notified",
"active_only": true
},
"count": 2,
"count": 1,
"results": [
{
"session_id": "d7fd3722-3fb3-413d-8874-294d2f539bc2",
Expand All @@ -423,7 +467,7 @@ Status: `200 OK`
{
"detail": "Invalid status filter values provided.",
"invalid_statuses": ["unknown_status"],
"valid_statuses": ["cancelled", "expired", "notified", "served", "waiting"]
"valid_statuses": ["cancelled", "expired", "notified", "served", "serving", "waiting"]
}
```

Expand Down Expand Up @@ -478,6 +522,11 @@ Status: `200 OK`
```json
{
"institution_id": 1,
"randomized": true,
"increment": 0,
"current_serving_number": 24,
"served_count": 0,
"notified_count": 0,
"message": "No active queue entries to simulate."
}
```
Expand All @@ -502,13 +551,57 @@ Notification creation in the current mock flow happens in the shared tick servic

The response contract for this endpoint should follow the shared tick service output in all cases, including when there are no active entries to process. In particular, the "no active entries" response is not limited to a minimal `{ "institution_id": ..., "message": ... }` body; it includes the additional summary fields returned by the shared tick service as well.

## POST /api/queue/auto-tick/

### Access

Admin only (`IsAdminUser`)

### Query params

- `randomize` (optional boolean, default from `QUEUE_AUTO_TICK_RANDOMIZE`)
- `force` (optional boolean, default `false`)

When `force=false`, the hybrid throttle window is applied per institution using `QUEUE_AUTO_TICK_INTERVAL_SECONDS`.
When `force=true`, one tick is attempted immediately for every institution that currently has active queue entries.

### Success response

Status: `200 OK`

```json
{
"institutions_considered": 3,
"institutions_ticked": 2,
"institutions_skipped": 1,
"force": false
}
```

### Notes

Hybrid mode is designed to avoid a paid always-on worker:

- Request-driven: `/api/queue/entries/{session_id}/status/` and `/api/notifications/entries/{session_id}/notifications/` may trigger throttled auto-ticks.
- Scheduled: a periodic cron ping to `/api/queue/auto-tick/` keeps queues progressing during low traffic.
- Safety: a per-institution lock + interval throttle helps prevent duplicate ticks under concurrent requests.

For multi-instance deployments, configure a shared cache backend via `CACHE_URL` (for example Redis) so lock/throttle coordination works across processes and instances.

## Operational Guidance

- Keep `ENABLE_MOCK_API=False` in production unless these routes are intentionally exposed.
- Use `/api/queue/entries/{session_id}/status/` for frontend polling instead of repeatedly fetching full institution queue lists.
- Add pagination before exposing institution-level queue lists to heavy polling.
- Keep CORS restricted to known frontend origins.

### Frontend Polling

The frontend should poll the following public endpoints to track queue status:

- `GET /api/queue/entries/{session_id}/status/` - Poll to show the user their current position and state.
- `GET /api/notifications/entries/{session_id}/notifications/` - Retrieve near-turn and completion notifications.


## Documentation Scope Note

This contract reflects the current backend implementation and serializer output fields.
Expand Down
3 changes: 2 additions & 1 deletion MOCK_QUEUE_OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,11 @@ This is the closest path to a real-world queue simulation in the current stack b
1. Seed the mock data.
2. Open the frontend and join a queue.
3. Poll the status and notifications endpoints to watch the user move from waiting to notified to served.
- Status: `/api/queue/entries/{session_id}/status/`
- Notifications: `/api/notifications/entries/{session_id}/notifications/`
4. Use the simulation endpoint for manual advancement, or run the worker for automatic backend-driven advancement.

## Notes

- The data is intentionally mock-only until a real institution data provider is available.
- The queue is suitable for frontend demos, testing, and walkthroughs.
- For production-style behavior, replace the simulation flow with a real queue source and real operational triggers.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ For deployed environments, set `ENABLE_MOCK_API=False` unless mock endpoints are
explicitly required.

The institution list used by these mock routes is simulated until a real institution API is integrated.
For the full list of available endpoints and load-profile details, see [API_ENDPOINTS.md](./API_ENDPOINTS.md).
For the full list of available endpoints and load-profile details, see [API_ENDPOINTS.md](API_ENDPOINTS.md).

See [MOCK_QUEUE_OPERATIONS.md](MOCK_QUEUE_OPERATIONS.md) for how to seed, join, poll, and advance the mock queue.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,12 @@
"address": "Makati City",
},
{
"name": "Philippine Postal Savings Bank (UCPB)",
"name": "Philippine Postal Savings Bank (PostBank)",
"institution_type": Institution.InstitutionType.BANK,
"address": "Liwasang Bonifacio, Manila",
},
{
"name": "Philippine National Oil Co. (PNOC)",
"institution_type": Institution.InstitutionType.UTILITY,
"address": "Taguig City",
},
{
"name": "Philippine Power Corp. (PCCP)",
"name": "National Power Corporation (NPC)",
"institution_type": Institution.InstitutionType.UTILITY,
"address": "Ortigas Center, Pasig",
},
Expand Down
1 change: 1 addition & 0 deletions queueless_backend/notifications/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@


class NotificationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "notifications"
1 change: 1 addition & 0 deletions queueless_backend/notifications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class EventType(models.TextChoices):
NEAR_TURN = "near_turn", "Near Turn"
TURN_CALLED = "turn_called", "Turn Called"
SESSION_EXPIRED = "session_expired", "Session Expired"
SESSION_COMPLETED = "session_completed", "Session Completed"
GENERIC = "generic", "Generic"

queue_entry = models.ForeignKey(
Expand Down
28 changes: 14 additions & 14 deletions queueless_backend/notifications/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def setUp(self):
self.queue_entry = QueueEntry.objects.create(
institution=self.institution,
queue_number=5,
current_serving_number=3,
current_serving_number=0,
near_turn_threshold=2,
status=QueueEntryStatus.NOTIFIED,
)
Expand All @@ -42,7 +42,7 @@ def setUp(self):

def test_list_notifications_for_session(self):
response = self.client.get(
f"/api/queue/entries/{self.queue_entry.session_id}/notifications/"
f"/api/notifications/entries/{self.queue_entry.session_id}/notifications/"
)

self.assertEqual(response.status_code, status.HTTP_200_OK)
Expand All @@ -52,8 +52,8 @@ def test_list_notifications_for_session(self):
def test_list_notifications_with_filters(self):
response = self.client.get(
(
f"/api/queue/entries/{self.queue_entry.session_id}/notifications/"
"?delivered=false&event_type=near_turn"
f"/api/notifications/entries/{self.queue_entry.session_id}/"
"notifications/?delivered=false&event_type=near_turn"
)
)

Expand All @@ -66,8 +66,8 @@ def test_list_notifications_with_filters(self):
def test_list_notifications_invalid_event_type(self):
response = self.client.get(
(
f"/api/queue/entries/{self.queue_entry.session_id}/notifications/"
"?event_type=invalid"
f"/api/notifications/entries/{self.queue_entry.session_id}/"
"notifications/?event_type=invalid"
)
)

Expand All @@ -77,8 +77,8 @@ def test_list_notifications_invalid_event_type(self):
def test_list_notifications_invalid_delivered_filter(self):
response = self.client.get(
(
f"/api/queue/entries/{self.queue_entry.session_id}/notifications/"
"?delivered=maybe"
f"/api/notifications/entries/{self.queue_entry.session_id}/"
"notifications/?delivered=maybe"
)
)

Expand All @@ -88,8 +88,8 @@ def test_list_notifications_invalid_delivered_filter(self):
def test_list_notifications_invalid_limit(self):
response = self.client.get(
(
f"/api/queue/entries/{self.queue_entry.session_id}/notifications/"
"?limit=abc"
f"/api/notifications/entries/{self.queue_entry.session_id}/"
"notifications/?limit=abc"
)
)

Expand All @@ -102,8 +102,8 @@ def test_list_notifications_invalid_limit(self):
def test_acknowledge_notification(self):
response = self.client.patch(
(
f"/api/queue/entries/{self.queue_entry.session_id}/notifications/"
f"{self.notification_near_turn.id}/ack/"
f"/api/notifications/entries/{self.queue_entry.session_id}/"
f"notifications/{self.notification_near_turn.id}/ack/"
),
{
"delivered": True,
Expand All @@ -130,8 +130,8 @@ def test_acknowledge_notification_not_found_for_session(self):
)
response = self.client.patch(
(
f"/api/queue/entries/{other_entry.session_id}/notifications/"
f"{self.notification_near_turn.id}/ack/"
f"/api/notifications/entries/{other_entry.session_id}/"
f"notifications/{self.notification_near_turn.id}/ack/"
),
{"delivered": True},
format="json",
Expand Down
17 changes: 14 additions & 3 deletions queueless_backend/notifications/views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from django.conf import settings
from rest_framework import permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
Expand All @@ -8,6 +9,7 @@
VALID_BOOLEAN_QUERY_VALUES,
parse_bool_query_param_strict,
)
from queue_tracker.services import maybe_auto_tick_institution

from .models import Notification
from .serializers import NotificationAcknowledgeSerializer, NotificationSerializer
Expand Down Expand Up @@ -55,16 +57,25 @@ def get(self, request, session_id):
queryset = queryset.filter(delivered=delivered_filter)

if event_type_filter:
valid_event_types = {choice[0] for choice in Notification.EventType.choices}
valid_event_types = {
choice[0].lower(): choice[0]
for choice in Notification.EventType.choices
}
if event_type_filter not in valid_event_types:
return Response(
{
"detail": "Invalid event_type filter value provided.",
"valid_event_types": sorted(valid_event_types),
"valid_event_types": sorted(valid_event_types.values()),
},
status=status.HTTP_400_BAD_REQUEST,
)
queryset = queryset.filter(event_type=event_type_filter)
queryset = queryset.filter(event_type=valid_event_types[event_type_filter])

maybe_auto_tick_institution(
institution_id=queue_entry.institution_id,
interval_seconds=settings.QUEUE_AUTO_TICK_INTERVAL_SECONDS,
randomize=settings.QUEUE_AUTO_TICK_RANDOMIZE,
)

notifications = list(queryset[:limit])
serializer = NotificationSerializer(notifications, many=True)
Expand Down
Loading
Loading