diff --git a/.env.example b/.env.example index 4d8651b..f66b3d5 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/API_ENDPOINTS.md b/API_ENDPOINTS.md index aa07ca5..e9c2af2 100644 --- a/API_ENDPOINTS.md +++ b/API_ENDPOINTS.md @@ -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 @@ -27,10 +27,12 @@ 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 @@ -38,6 +40,7 @@ If disabled, these routes are not exposed. - `waiting` - `notified` +- `serving` - `served` - `expired` - `cancelled` @@ -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 @@ -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 @@ -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 @@ -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 @@ -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", @@ -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"] } ``` @@ -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." } ``` @@ -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. diff --git a/MOCK_QUEUE_OPERATIONS.md b/MOCK_QUEUE_OPERATIONS.md index 74dc2a2..fbdb483 100644 --- a/MOCK_QUEUE_OPERATIONS.md +++ b/MOCK_QUEUE_OPERATIONS.md @@ -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. diff --git a/README.md b/README.md index 52c5c50..6c82be1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/queueless_backend/mock_api/management/commands/seed_mock_data.py b/queueless_backend/mock_api/management/commands/seed_mock_data.py index 414e42d..e5ffc94 100644 --- a/queueless_backend/mock_api/management/commands/seed_mock_data.py +++ b/queueless_backend/mock_api/management/commands/seed_mock_data.py @@ -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", }, diff --git a/queueless_backend/notifications/apps.py b/queueless_backend/notifications/apps.py index 9eb742d..3a08476 100644 --- a/queueless_backend/notifications/apps.py +++ b/queueless_backend/notifications/apps.py @@ -2,4 +2,5 @@ class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" name = "notifications" diff --git a/queueless_backend/notifications/models.py b/queueless_backend/notifications/models.py index 5a7a7dc..09231d5 100644 --- a/queueless_backend/notifications/models.py +++ b/queueless_backend/notifications/models.py @@ -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( diff --git a/queueless_backend/notifications/tests.py b/queueless_backend/notifications/tests.py index b364ab5..2407d92 100644 --- a/queueless_backend/notifications/tests.py +++ b/queueless_backend/notifications/tests.py @@ -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, ) @@ -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) @@ -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" ) ) @@ -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" ) ) @@ -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" ) ) @@ -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" ) ) @@ -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, @@ -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", diff --git a/queueless_backend/notifications/views.py b/queueless_backend/notifications/views.py index c2f9738..03e7bf5 100644 --- a/queueless_backend/notifications/views.py +++ b/queueless_backend/notifications/views.py @@ -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 @@ -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 @@ -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) diff --git a/queueless_backend/queue_tracker/admin.py b/queueless_backend/queue_tracker/admin.py index 65ebc67..0dfab1c 100644 --- a/queueless_backend/queue_tracker/admin.py +++ b/queueless_backend/queue_tracker/admin.py @@ -11,7 +11,9 @@ class QueueEntryAdmin(admin.ModelAdmin): "current_serving_number", "status", "near_turn_notified", + "turn_called_at", + "checked_in_at", "issued_at", ) list_filter = ("institution", "status", "near_turn_notified") - search_fields = ("institution__name", "=queue_number", "phone_number") + search_fields = ("institution__name", "=queue_number", "session_id") diff --git a/queueless_backend/queue_tracker/apps.py b/queueless_backend/queue_tracker/apps.py index 941c1c3..c07bab8 100644 --- a/queueless_backend/queue_tracker/apps.py +++ b/queueless_backend/queue_tracker/apps.py @@ -2,4 +2,5 @@ class QueueTrackerConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" name = "queue_tracker" diff --git a/queueless_backend/queue_tracker/management/commands/queue_worker.py b/queueless_backend/queue_tracker/management/commands/queue_worker.py index 16ccaac..ce930c2 100644 --- a/queueless_backend/queue_tracker/management/commands/queue_worker.py +++ b/queueless_backend/queue_tracker/management/commands/queue_worker.py @@ -2,6 +2,7 @@ import os import time +from django.conf import settings from django.core.management.base import BaseCommand, CommandError from django.db import close_old_connections @@ -78,6 +79,7 @@ def handle(self, *args, **options): result = simulate_queue_tick_for_institution( institution.id, randomize=randomize, + grace_period_seconds=settings.QUEUE_GRACE_PERIOD_SECONDS, ) except Institution.DoesNotExist: continue @@ -96,6 +98,7 @@ def handle(self, *args, **options): f"[{institution.id}] {institution.name}: " f"served={result['served_count']}, " f"notified={result['notified_count']}, " + f"expired={result.get('expired_count', 0)}, " f"current_serving={result['current_serving_number']}" ) ) diff --git a/queueless_backend/queue_tracker/migrations/0002_auto_expiry_serving_status.py b/queueless_backend/queue_tracker/migrations/0002_auto_expiry_serving_status.py new file mode 100644 index 0000000..cb85bc6 --- /dev/null +++ b/queueless_backend/queue_tracker/migrations/0002_auto_expiry_serving_status.py @@ -0,0 +1,52 @@ +# Generated by Django 5.1.7 on 2026-04-19 16:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("mock_api", "0002_institution_address_institution_status"), + ("queue_tracker", "0001_initial"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="queueentry", + name="uniq_active_queue_number_per_institution", + ), + migrations.AddField( + model_name="queueentry", + name="checked_in_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="queueentry", + name="turn_called_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name="queueentry", + name="status", + field=models.CharField( + choices=[ + ("waiting", "Waiting"), + ("notified", "Notified"), + ("serving", "Serving"), + ("served", "Served"), + ("expired", "Expired"), + ("cancelled", "Cancelled"), + ], + default="waiting", + max_length=20, + ), + ), + migrations.AddConstraint( + model_name="queueentry", + constraint=models.UniqueConstraint( + condition=models.Q(("status__in", ("waiting", "notified", "serving"))), + fields=("institution", "queue_number"), + name="uniq_active_queue_number_per_institution", + ), + ), + ] diff --git a/queueless_backend/queue_tracker/models.py b/queueless_backend/queue_tracker/models.py index 36fca17..f71bee7 100644 --- a/queueless_backend/queue_tracker/models.py +++ b/queueless_backend/queue_tracker/models.py @@ -7,12 +7,17 @@ class QueueEntryStatus(models.TextChoices): WAITING = "waiting", "Waiting" NOTIFIED = "notified", "Notified" + SERVING = "serving", "Serving" SERVED = "served", "Served" EXPIRED = "expired", "Expired" CANCELLED = "cancelled", "Cancelled" -ACTIVE_QUEUE_STATUSES = (QueueEntryStatus.WAITING, QueueEntryStatus.NOTIFIED) +ACTIVE_QUEUE_STATUSES = ( + QueueEntryStatus.WAITING, + QueueEntryStatus.NOTIFIED, + QueueEntryStatus.SERVING, +) class QueueEntry(models.Model): @@ -37,6 +42,8 @@ class QueueEntry(models.Model): updated_at = models.DateTimeField(auto_now=True) served_at = models.DateTimeField(null=True, blank=True) expires_at = models.DateTimeField(null=True, blank=True) + turn_called_at = models.DateTimeField(null=True, blank=True) + checked_in_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ["institution_id", "queue_number"] diff --git a/queueless_backend/queue_tracker/serializers.py b/queueless_backend/queue_tracker/serializers.py index 078a093..b4625f0 100644 --- a/queueless_backend/queue_tracker/serializers.py +++ b/queueless_backend/queue_tracker/serializers.py @@ -5,6 +5,7 @@ class QueueJoinSerializer(serializers.Serializer): institution_id = serializers.IntegerField(min_value=1) + queue_number = serializers.IntegerField(min_value=1) phone_number = serializers.CharField( max_length=20, required=False, allow_blank=True ) @@ -32,6 +33,8 @@ class Meta: "near_turn_notified", "issued_at", "updated_at", + "turn_called_at", + "checked_in_at", "people_ahead", ] @@ -53,4 +56,6 @@ class Meta: "updated_at", "served_at", "expires_at", + "turn_called_at", + "checked_in_at", ] diff --git a/queueless_backend/queue_tracker/services.py b/queueless_backend/queue_tracker/services.py index e4c234b..764e5e5 100644 --- a/queueless_backend/queue_tracker/services.py +++ b/queueless_backend/queue_tracker/services.py @@ -1,5 +1,8 @@ import random +from datetime import timedelta +from uuid import uuid4 +from django.core.cache import cache from django.db import transaction from django.db.models import ExpressionWrapper, F, IntegerField, Max, Value from django.utils import timezone @@ -10,13 +13,223 @@ from .models import ACTIVE_QUEUE_STATUSES, QueueEntry, QueueEntryStatus +def expire_stale_serving_entries(institution_id, grace_period_seconds): + """ + Expire SERVING entries whose grace period has elapsed + AND who have NOT checked in. + + Returns the list of expired QueueEntry instances. + """ + with transaction.atomic(): + cutoff = timezone.now() - timedelta(seconds=grace_period_seconds) + stale_qs = QueueEntry.objects.select_for_update().filter( + institution_id=institution_id, + status=QueueEntryStatus.SERVING, + turn_called_at__lte=cutoff, + checked_in_at__isnull=True, + ) + expired_entries = list(stale_qs) + if expired_entries: + now = timezone.now() + expired_ids = [entry.id for entry in expired_entries] + QueueEntry.objects.filter(pk__in=expired_ids).update( + status=QueueEntryStatus.EXPIRED, + updated_at=now, + ) + Notification.objects.bulk_create( + [ + Notification( + queue_entry=entry, + channel=Notification.Channel.SYSTEM, + event_type=Notification.EventType.SESSION_EXPIRED, + message=( + f"Queue #{entry.queue_number} expired " + f"(no check-in within grace period)." + ), + delivered=False, + ) + for entry in expired_entries + ] + ) + return expired_entries + + +def check_in_serving_entry(session_id): + """ + Mark a SERVING entry as checked in. Stops the auto-expiry timer. + + Returns (entry, error). If successful, error is None. + 'error' is a dict with 'code' and 'message'. + """ + with transaction.atomic(): + try: + entry = QueueEntry.objects.select_for_update().get(session_id=session_id) + except QueueEntry.DoesNotExist: + return None, {"code": "NOT_FOUND", "message": "Queue entry not found."} + + if entry.status != QueueEntryStatus.SERVING: + return None, { + "code": "INVALID_STATUS", + "message": ( + f"Cannot check in: status is '{entry.status}', " + "expected 'serving'." + ), + } + + if entry.checked_in_at is not None: + return entry, None # Already checked in, idempotent + + entry.checked_in_at = timezone.now() + entry.save(update_fields=["checked_in_at", "updated_at"]) + return entry, None + + +def maybe_auto_tick_institution( + institution_id: int, + *, + interval_seconds: int = 15, + randomize: bool = True, + grace_period_seconds: int = 180, +): + now = timezone.now().timestamp() + interval = max(1, int(interval_seconds)) + last_tick_key = f"queue:auto_tick:last:{institution_id}" + lock_key = f"queue:auto_tick:lock:{institution_id}" + lock_token = str(uuid4()) + lock_timeout = max(30, interval * 2) + + last_tick_ts = cache.get(last_tick_key) + if last_tick_ts is not None and (now - float(last_tick_ts)) < interval: + return None + + if not cache.add(lock_key, lock_token, timeout=lock_timeout): + return None + + try: + now = timezone.now().timestamp() + last_tick_ts = cache.get(last_tick_key) + if last_tick_ts is not None and (now - float(last_tick_ts)) < interval: + return None + + result = simulate_queue_tick_for_institution( + institution_id=institution_id, + randomize=randomize, + grace_period_seconds=grace_period_seconds, + ) + cache.set(last_tick_key, now, timeout=max(60, interval * 4)) + return result + finally: + # Note: This is a non-atomic TOCTOU check-and-delete. + # In production with Redis, use a Lua script for an atomic release. + try: + if cache.get(lock_key) == lock_token: + cache.delete(lock_key) + except Exception: + pass + + +def auto_tick_active_institutions( + *, + interval_seconds: int = 15, + randomize: bool = True, + force: bool = False, + grace_period_seconds: int = 180, +): + active_institution_ids = QueueEntry.objects.filter( + status__in=ACTIVE_QUEUE_STATUSES + ).values_list("institution_id", flat=True) + + institution_ids = list( + Institution.objects.filter( + is_active=True, + id__in=active_institution_ids, + ) + .values_list("id", flat=True) + .distinct() + ) + + interval = max(1, int(interval_seconds)) + + ticked = 0 + skipped = 0 + for institution_id in institution_ids: + if force: + simulate_queue_tick_for_institution( + institution_id=institution_id, + randomize=randomize, + grace_period_seconds=grace_period_seconds, + ) + cache.set( + f"queue:auto_tick:last:{institution_id}", + timezone.now().timestamp(), + timeout=max(60, interval * 4), + ) + ticked += 1 + continue + + result = maybe_auto_tick_institution( + institution_id=institution_id, + interval_seconds=interval_seconds, + randomize=randomize, + grace_period_seconds=grace_period_seconds, + ) + if result is None: + skipped += 1 + else: + ticked += 1 + + return { + "institutions_considered": len(institution_ids), + "institutions_ticked": ticked, + "institutions_skipped": skipped, + "force": force, + } + + def simulate_queue_tick_for_institution( institution_id: int, randomize: bool = True, + grace_period_seconds: int = 180, ): with transaction.atomic(): institution = Institution.objects.select_for_update().get(pk=institution_id) + # --- Step 1: Expire stale no-shows --- + expired_entries = expire_stale_serving_entries( + institution_id=institution.id, + grace_period_seconds=grace_period_seconds, + ) + + # --- Step 2: Auto-serve checked-in SERVING entries --- + checked_in_serving = list( + QueueEntry.objects.select_for_update().filter( + institution=institution, + status=QueueEntryStatus.SERVING, + checked_in_at__isnull=False, + ) + ) + if checked_in_serving: + now = timezone.now() + checked_in_ids = [entry.id for entry in checked_in_serving] + QueueEntry.objects.filter(pk__in=checked_in_ids).update( + status=QueueEntryStatus.SERVED, + served_at=now, + updated_at=now, + ) + Notification.objects.bulk_create( + [ + Notification( + queue_entry=entry, + channel=Notification.Channel.SYSTEM, + event_type=Notification.EventType.SESSION_COMPLETED, + message=f"Queue #{entry.queue_number} has been completed.", + delivered=False, + ) + for entry in checked_in_serving + ] + ) + + # --- Step 3: Advance current_serving_number --- active_entries = list( QueueEntry.objects.select_for_update() .filter( @@ -38,9 +251,10 @@ def simulate_queue_tick_for_institution( "randomized": randomize, "increment": 0, "current_serving_number": last_known_serving_number, - "served_count": 0, + "served_count": len(checked_in_serving), "notified_count": 0, - "message": "No active queue entries to simulate.", + "expired_count": len(expired_entries), + "message": "No active queue entries to advance.", } current_serving = max(entry.current_serving_number for entry in active_entries) @@ -61,19 +275,22 @@ def simulate_queue_tick_for_institution( current_serving_number=new_current_serving, updated_at=now, ) - served_entries = list( - QueueEntry.objects.filter( + + # --- Step 4: Transition newly-reached entries to SERVING --- + newly_serving_entries = list( + QueueEntry.objects.select_for_update().filter( institution=institution, - status__in=ACTIVE_QUEUE_STATUSES, + status__in=(QueueEntryStatus.WAITING, QueueEntryStatus.NOTIFIED), queue_number__lte=new_current_serving, ) ) - if served_entries: - served_entry_ids = [entry.id for entry in served_entries] - QueueEntry.objects.filter(pk__in=served_entry_ids).update( - status=QueueEntryStatus.SERVED, - served_at=now, + if newly_serving_entries: + newly_serving_ids = [entry.id for entry in newly_serving_entries] + QueueEntry.objects.filter(pk__in=newly_serving_ids).update( + status=QueueEntryStatus.SERVING, + turn_called_at=now, + expires_at=now + timedelta(seconds=grace_period_seconds), updated_at=now, ) Notification.objects.bulk_create( @@ -82,15 +299,20 @@ def simulate_queue_tick_for_institution( queue_entry=entry, channel=Notification.Channel.SYSTEM, event_type=Notification.EventType.TURN_CALLED, - message=f"Queue #{entry.queue_number} is now being served.", + message=( + f"Queue #{entry.queue_number}: it's your turn! " + f"Please check in to confirm your presence." + ), delivered=False, ) - for entry in served_entries + for entry in newly_serving_entries ] ) + # --- Step 5: Near-turn notifications --- near_turn_entries = list( - QueueEntry.objects.filter( + QueueEntry.objects.select_for_update() + .filter( institution=institution, status=QueueEntryStatus.WAITING, near_turn_notified=False, @@ -133,6 +355,8 @@ def simulate_queue_tick_for_institution( "randomized": randomize, "increment": increment, "current_serving_number": new_current_serving, - "served_count": len(served_entries), + "served_count": len(checked_in_serving), + "newly_serving_count": len(newly_serving_entries), "notified_count": len(near_turn_entries), + "expired_count": len(expired_entries), } diff --git a/queueless_backend/queue_tracker/tests.py b/queueless_backend/queue_tracker/tests.py index dc9148b..0c94089 100644 --- a/queueless_backend/queue_tracker/tests.py +++ b/queueless_backend/queue_tracker/tests.py @@ -1,10 +1,20 @@ +from datetime import timedelta + +from django.core.cache import cache from django.test import SimpleTestCase, TestCase +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient from mock_api.models import Institution from notifications.models import Notification from .models import QueueEntry, QueueEntryStatus -from .services import simulate_queue_tick_for_institution +from .services import ( + check_in_serving_entry, + maybe_auto_tick_institution, + simulate_queue_tick_for_institution, +) class QueueTrackerSmokeTest(SimpleTestCase): @@ -49,7 +59,7 @@ def test_tick_with_no_active_entries_uses_last_known_serving_number(self): self.assertEqual(result["served_count"], 0) self.assertEqual(result["notified_count"], 0) - def test_tick_advances_and_notifies(self): + def test_tick_advances_and_transitions_to_serving(self): waiting_entry = QueueEntry.objects.create( institution=self.institution, queue_number=5, @@ -74,9 +84,13 @@ def test_tick_advances_and_notifies(self): self.assertEqual(result["increment"], 1) self.assertEqual(result["current_serving_number"], 5) - self.assertEqual(result["served_count"], 1) + self.assertEqual(result["newly_serving_count"], 1) self.assertEqual(result["notified_count"], 1) - self.assertEqual(waiting_entry.status, QueueEntryStatus.SERVED) + + # Should be SERVING, not SERVED + self.assertEqual(waiting_entry.status, QueueEntryStatus.SERVING) + self.assertIsNotNone(waiting_entry.turn_called_at) + self.assertEqual( waiting_entry_to_notify.status, QueueEntryStatus.NOTIFIED, @@ -87,15 +101,201 @@ def test_tick_advances_and_notifies(self): event_type=Notification.EventType.TURN_CALLED, delivered=False, ) - near_turn_notifications = Notification.objects.filter( - queue_entry=waiting_entry_to_notify, - event_type=Notification.EventType.NEAR_TURN, - delivered=False, + self.assertEqual(turn_called_notifications.count(), 1) + self.assertIn("it's your turn!", turn_called_notifications[0].message) + + def test_checked_in_entry_served_on_next_tick(self): + serving_entry = QueueEntry.objects.create( + institution=self.institution, + queue_number=5, + current_serving_number=5, + status=QueueEntryStatus.SERVING, + turn_called_at=timezone.now(), + checked_in_at=timezone.now(), ) - self.assertEqual(turn_called_notifications.count(), 1) - self.assertEqual(near_turn_notifications.count(), 1) - self.assertIn( - "Queue #5 is now being served.", turn_called_notifications[0].message + result = simulate_queue_tick_for_institution( + self.institution.id, randomize=False + ) + + serving_entry.refresh_from_db() + self.assertEqual(serving_entry.status, QueueEntryStatus.SERVED) + self.assertEqual(result["served_count"], 1) + + def test_expired_entries_skipped_on_next_tick(self): + # Grace period is 180s by default + expired_serving = QueueEntry.objects.create( + institution=self.institution, + queue_number=5, + current_serving_number=5, + status=QueueEntryStatus.SERVING, + turn_called_at=timezone.now() - timedelta(seconds=200), + checked_in_at=None, + ) + + result = simulate_queue_tick_for_institution( + self.institution.id, randomize=False + ) + + expired_serving.refresh_from_db() + self.assertEqual(expired_serving.status, QueueEntryStatus.EXPIRED) + self.assertEqual(result["expired_count"], 1) + + expiry_notifications = Notification.objects.filter( + queue_entry=expired_serving, + event_type=Notification.EventType.SESSION_EXPIRED, + ) + self.assertEqual(expiry_notifications.count(), 1) + + def test_expires_at_populated_when_serving(self): + # Create an entry that will transition to SERVING on next tick + QueueEntry.objects.create( + institution=self.institution, + queue_number=5, + current_serving_number=4, + status=QueueEntryStatus.WAITING, + ) + + simulate_queue_tick_for_institution( + self.institution.id, randomize=False, grace_period_seconds=180 + ) + + entry = QueueEntry.objects.get(institution=self.institution, queue_number=5) + self.assertEqual(entry.status, QueueEntryStatus.SERVING) + self.assertIsNotNone(entry.expires_at) + # expires_at should be roughly turn_called_at + 180s + expected_expiry = entry.turn_called_at + timedelta(seconds=180) + self.assertAlmostEqual( + entry.expires_at.timestamp(), expected_expiry.timestamp(), places=1 + ) + + +class QueueAutoTickServiceTests(TestCase): + def setUp(self): + cache.clear() + self.active_institution = Institution.objects.create( + name="Active Office", + institution_type=Institution.InstitutionType.GOVERNMENT, + status=Institution.Status.OPEN, + is_active=True, + ) + + def test_maybe_auto_tick_skips_within_interval(self): + QueueEntry.objects.create( + institution=self.active_institution, + queue_number=6, + current_serving_number=5, + status=QueueEntryStatus.WAITING, + ) + + first_result = maybe_auto_tick_institution( + institution_id=self.active_institution.id, + interval_seconds=60, + randomize=False, + ) + second_result = maybe_auto_tick_institution( + institution_id=self.active_institution.id, + interval_seconds=60, + randomize=False, + ) + + self.assertIsNotNone(first_result) + self.assertIsNone(second_result) + + +class QueueCheckInTests(TestCase): + def setUp(self): + self.institution = Institution.objects.create( + name="Test Office", + institution_type=Institution.InstitutionType.GOVERNMENT, + status=Institution.Status.OPEN, + is_active=True, + ) + + def test_check_in_success(self): + entry = QueueEntry.objects.create( + institution=self.institution, + queue_number=5, + current_serving_number=5, + status=QueueEntryStatus.SERVING, + turn_called_at=timezone.now(), + ) + + updated_entry, error = check_in_serving_entry(entry.session_id) + + self.assertIsNone(error) + self.assertIsNotNone(updated_entry.checked_in_at) + + def test_check_in_invalid_status(self): + entry = QueueEntry.objects.create( + institution=self.institution, + queue_number=5, + current_serving_number=4, + status=QueueEntryStatus.WAITING, + ) + + updated_entry, error = check_in_serving_entry(entry.session_id) + + self.assertIsNone(updated_entry) + self.assertIn("Cannot check in", error["message"]) + self.assertEqual(error["code"], "INVALID_STATUS") + + +class QueueJoinViewTests(TestCase): + def setUp(self): + self.client = APIClient() + self.institution = Institution.objects.create( + name="Test Office", + institution_type=Institution.InstitutionType.GOVERNMENT, + status=Institution.Status.OPEN, + is_active=True, + ) + + def test_join_with_valid_ticket(self): + response = self.client.post( + "/api/queue/join/", + { + "institution_id": self.institution.id, + "queue_number": 10, + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["queue_number"], 10) + + def test_join_with_already_served_ticket(self): + # Create a served entry to set the current serving number to 10 + QueueEntry.objects.create( + institution=self.institution, + queue_number=10, + current_serving_number=10, + status=QueueEntryStatus.SERVED, + ) + + response = self.client.post( + "/api/queue/join/", + { + "institution_id": self.institution.id, + "queue_number": 5, # 5 < 10 + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("already been served", response.data["detail"]) + + def test_join_with_duplicate_active_ticket(self): + # Someone is already tracking #15 + QueueEntry.objects.create( + institution=self.institution, + queue_number=15, + current_serving_number=10, + status=QueueEntryStatus.WAITING, + ) + + response = self.client.post( + "/api/queue/join/", + { + "institution_id": self.institution.id, + "queue_number": 15, + }, ) - self.assertIn("please prepare", near_turn_notifications[0].message) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("already being tracked", response.data["detail"]) diff --git a/queueless_backend/queue_tracker/urls.py b/queueless_backend/queue_tracker/urls.py index db817ec..eed8acf 100644 --- a/queueless_backend/queue_tracker/urls.py +++ b/queueless_backend/queue_tracker/urls.py @@ -2,6 +2,8 @@ from .views import ( InstitutionQueueStatusView, + QueueAutoTickView, + QueueEntryCheckInView, QueueEntryStatusView, QueueJoinView, QueueSimulateTickView, @@ -24,4 +26,14 @@ QueueSimulateTickView.as_view(), name="queue-simulate-tick", ), + path( + "entries//check-in/", + QueueEntryCheckInView.as_view(), + name="queue-entry-check-in", + ), + path( + "auto-tick/", + QueueAutoTickView.as_view(), + name="queue-auto-tick", + ), ] diff --git a/queueless_backend/queue_tracker/views.py b/queueless_backend/queue_tracker/views.py index 198206f..7dcc2b9 100644 --- a/queueless_backend/queue_tracker/views.py +++ b/queueless_backend/queue_tracker/views.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.db import IntegrityError, transaction from django.db.models import Max from rest_framework import permissions, status @@ -6,14 +7,20 @@ from mock_api.models import Institution -from .models import QueueEntry, QueueEntryStatus +from .models import ACTIVE_QUEUE_STATUSES, QueueEntry, QueueEntryStatus from .query_params import parse_bool_query_param from .serializers import ( InstitutionQueueEntrySerializer, QueueEntryStatusSerializer, QueueJoinSerializer, ) -from .services import simulate_queue_tick_for_institution +from .services import ( + auto_tick_active_institutions, + check_in_serving_entry, + expire_stale_serving_entries, + maybe_auto_tick_institution, + simulate_queue_tick_for_institution, +) class QueueJoinView(APIView): @@ -24,6 +31,7 @@ def post(self, request): serializer.is_valid(raise_exception=True) institution_id = serializer.validated_data["institution_id"] + queue_number = serializer.validated_data["queue_number"] phone_number = serializer.validated_data.get("phone_number", "") browser_push_opt_in = serializer.validated_data.get( "browser_push_opt_in", False @@ -56,15 +64,8 @@ def post(self, request): status=status.HTTP_400_BAD_REQUEST, ) - latest_entry = ( - QueueEntry.objects.select_for_update() - .filter(institution=institution) - .order_by("-queue_number") - .first() - ) - queue_number = ( - latest_entry.queue_number if latest_entry else 0 - ) + 1 + # Get the most recent serving number for this institution + # (Usually tracked on the latest served entries) current_serving_number = ( QueueEntry.objects.filter(institution=institution).aggregate( value=Max("current_serving_number") @@ -72,6 +73,36 @@ def post(self, request): or 0 ) + # Validation: Can't track a number that is already served + if queue_number <= current_serving_number: + return Response( + { + "detail": ( + f"Ticket #{queue_number} has already been served. " + f"Current serving number is " + f"#{current_serving_number}." + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check for duplicate active tracking + # (handled by DB constraint, but we can be explicit) + if QueueEntry.objects.filter( + institution=institution, + queue_number=queue_number, + status__in=ACTIVE_QUEUE_STATUSES, + ).exists(): + return Response( + { + "detail": ( + f"Ticket #{queue_number} is already " + "being tracked by another user." + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + entry = QueueEntry.objects.create( institution=institution, queue_number=queue_number, @@ -83,16 +114,13 @@ def post(self, request): ) break except IntegrityError: + # If on last attempt, return error. Otherwise, retry. if attempt == retries - 1: return Response( - { - "detail": ( - "Could not allocate a queue number due to " - "concurrent requests. Please retry." - ) - }, - status=status.HTTP_409_CONFLICT, + {"detail": f"Ticket #{queue_number} is already being tracked."}, + status=status.HTTP_400_BAD_REQUEST, ) + continue response_serializer = QueueEntryStatusSerializer(entry) return Response(response_serializer.data, status=status.HTTP_201_CREATED) @@ -110,6 +138,43 @@ def get(self, request, session_id): status=status.HTTP_404_NOT_FOUND, ) + with transaction.atomic(): + expire_stale_serving_entries( + institution_id=entry.institution_id, + grace_period_seconds=settings.QUEUE_GRACE_PERIOD_SECONDS, + ) + + tick_result = maybe_auto_tick_institution( + institution_id=entry.institution_id, + interval_seconds=settings.QUEUE_AUTO_TICK_INTERVAL_SECONDS, + randomize=settings.QUEUE_AUTO_TICK_RANDOMIZE, + grace_period_seconds=settings.QUEUE_GRACE_PERIOD_SECONDS, + ) + if tick_result is not None: + entry.refresh_from_db() + else: + entry.refresh_from_db() + + serializer = QueueEntryStatusSerializer(entry) + return Response(serializer.data) + + +class QueueEntryCheckInView(APIView): + permission_classes = [permissions.AllowAny] + + def patch(self, request, session_id): + entry, error = check_in_serving_entry(session_id) + if error: + if error.get("code") == "NOT_FOUND": + return Response( + {"detail": error["message"]}, + status=status.HTTP_404_NOT_FOUND, + ) + return Response( + {"detail": error["message"]}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer = QueueEntryStatusSerializer(entry) return Response(serializer.data) @@ -157,9 +222,7 @@ def get(self, request, institution_id): ) queryset = queryset.filter(status__in=requested_statuses) elif active_only: - queryset = queryset.filter( - status__in=[QueueEntryStatus.WAITING, QueueEntryStatus.NOTIFIED] - ) + queryset = queryset.filter(status__in=ACTIVE_QUEUE_STATUSES) entries = list(queryset) result_count = len(entries) @@ -208,3 +271,25 @@ def post(self, request, institution_id): ) return Response(result) + + +class QueueAutoTickView(APIView): + permission_classes = [permissions.IsAdminUser] + + def post(self, request): + randomize = parse_bool_query_param( + request.query_params.get("randomize"), + default=settings.QUEUE_AUTO_TICK_RANDOMIZE, + ) + force = parse_bool_query_param( + request.query_params.get("force"), + default=False, + ) + + result = auto_tick_active_institutions( + interval_seconds=settings.QUEUE_AUTO_TICK_INTERVAL_SECONDS, + randomize=randomize, + force=force, + grace_period_seconds=settings.QUEUE_GRACE_PERIOD_SECONDS, + ) + return Response(result) diff --git a/queueless_backend/queueless_backend/settings.py b/queueless_backend/queueless_backend/settings.py index 4f912b0..5099d36 100644 --- a/queueless_backend/queueless_backend/settings.py +++ b/queueless_backend/queueless_backend/settings.py @@ -67,7 +67,7 @@ def env_bool(name: str, default: bool = False) -> bool: "corsheaders", "channels", "mock_api.apps.MockApiConfig", - "queue_tracker", + "queue_tracker.apps.QueueTrackerConfig", "notifications.apps.NotificationsConfig", ] @@ -188,9 +188,61 @@ def env_bool(name: str, default: bool = False) -> bool: if origin.strip() ] -# Development-friendly in-memory channel layer. -CHANNEL_LAYERS = { - "default": { - "BACKEND": "channels.layers.InMemoryChannelLayer", - }, -} +cache_url = os.getenv("CACHE_URL") +if cache_url: + CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": cache_url, + } + } +else: + CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "queueless-default-cache", + } + } + +try: + QUEUE_AUTO_TICK_INTERVAL_SECONDS = int( + os.getenv("QUEUE_AUTO_TICK_INTERVAL_SECONDS", "15") + ) +except ValueError as exc: + raise ImproperlyConfigured( + "QUEUE_AUTO_TICK_INTERVAL_SECONDS must be an integer." + ) from exc + +if QUEUE_AUTO_TICK_INTERVAL_SECONDS < 1: + raise ImproperlyConfigured("QUEUE_AUTO_TICK_INTERVAL_SECONDS must be at least 1.") + +QUEUE_AUTO_TICK_RANDOMIZE = env_bool("QUEUE_AUTO_TICK_RANDOMIZE", default=True) + +try: + QUEUE_GRACE_PERIOD_SECONDS = int(os.getenv("QUEUE_GRACE_PERIOD_SECONDS", "180")) +except ValueError as exc: + raise ImproperlyConfigured( + "QUEUE_GRACE_PERIOD_SECONDS must be an integer." + ) from exc + +if QUEUE_GRACE_PERIOD_SECONDS < 1: + raise ImproperlyConfigured("QUEUE_GRACE_PERIOD_SECONDS must be at least 1.") + +# Channel layer configuration. +# Use Redis in production (via CHANNEL_LAYER_URL) or InMemory for dev. +channel_layer_url = os.getenv("CHANNEL_LAYER_URL") +if channel_layer_url: + CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [channel_layer_url], + }, + }, + } +else: + CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels.layers.InMemoryChannelLayer", + }, + } diff --git a/queueless_backend/queueless_backend/urls.py b/queueless_backend/queueless_backend/urls.py index 26e6c3d..4ce5b74 100644 --- a/queueless_backend/queueless_backend/urls.py +++ b/queueless_backend/queueless_backend/urls.py @@ -19,13 +19,16 @@ from django.contrib import admin from django.urls import include, path +from .views import landing_page + urlpatterns = [ path("admin/", admin.site.urls), + path("", landing_page, name="landing"), ] -if settings.ENABLE_MOCK_API: +if getattr(settings, "ENABLE_MOCK_API", False): urlpatterns += [ - path("api/", include("mock_api.urls")), path("api/queue/", include("queue_tracker.urls")), - path("api/queue/", include("notifications.urls")), + path("api/notifications/", include("notifications.urls")), + path("api/", include("mock_api.urls")), ] diff --git a/queueless_backend/queueless_backend/views.py b/queueless_backend/queueless_backend/views.py new file mode 100644 index 0000000..08eb294 --- /dev/null +++ b/queueless_backend/queueless_backend/views.py @@ -0,0 +1,9 @@ +from django.shortcuts import render + + +def landing_page(request): + """ + Renders the beautiful landing page for QueueLess. + Satisfies US-11 requirements. + """ + return render(request, "landing.html")