From f15862a0f894f55e3b1891292adc045d07d8bd9c Mon Sep 17 00:00:00 2001 From: 6reenhorn Date: Sun, 19 Apr 2026 22:03:55 +0800 Subject: [PATCH 01/11] feat: hybrid simulation tick --- API_ENDPOINTS.md | 36 +++++++++ queueless_backend/notifications/views.py | 8 ++ queueless_backend/queue_tracker/services.py | 76 +++++++++++++++++++ queueless_backend/queue_tracker/urls.py | 6 ++ queueless_backend/queue_tracker/views.py | 35 ++++++++- .../queueless_backend/settings.py | 5 ++ 6 files changed, 165 insertions(+), 1 deletion(-) diff --git a/API_ENDPOINTS.md b/API_ENDPOINTS.md index aa07ca5..9c258ff 100644 --- a/API_ENDPOINTS.md +++ b/API_ENDPOINTS.md @@ -31,6 +31,7 @@ If disabled, these routes are not exposed. | PATCH | `/api/queue/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 active institutions | Low to medium | ## Data Types and Enums @@ -502,6 +503,41 @@ 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/queue/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. + ## Operational Guidance - Keep `ENABLE_MOCK_API=False` in production unless these routes are intentionally exposed. diff --git a/queueless_backend/notifications/views.py b/queueless_backend/notifications/views.py index c2f9738..2a840d8 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 @@ -25,6 +27,12 @@ def get(self, request, session_id): status=status.HTTP_404_NOT_FOUND, ) + maybe_auto_tick_institution( + institution_id=queue_entry.institution_id, + interval_seconds=settings.QUEUE_AUTO_TICK_INTERVAL_SECONDS, + randomize=settings.QUEUE_AUTO_TICK_RANDOMIZE, + ) + delivered_filter_raw = request.query_params.get("delivered") delivered_filter = parse_bool_query_param_strict(delivered_filter_raw) if delivered_filter is INVALID_BOOLEAN_QUERY_VALUE: diff --git a/queueless_backend/queue_tracker/services.py b/queueless_backend/queue_tracker/services.py index e4c234b..9630299 100644 --- a/queueless_backend/queue_tracker/services.py +++ b/queueless_backend/queue_tracker/services.py @@ -1,5 +1,6 @@ import random +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,6 +11,81 @@ from .models import ACTIVE_QUEUE_STATUSES, QueueEntry, QueueEntryStatus +def maybe_auto_tick_institution( + institution_id: int, + *, + interval_seconds: int = 15, + randomize: bool = True, +): + 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}" + + 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, "1", timeout=interval): + 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, + ) + cache.set(last_tick_key, now, timeout=max(60, interval * 4)) + return result + finally: + cache.delete(lock_key) + + +def auto_tick_active_institutions( + *, + interval_seconds: int = 15, + randomize: bool = True, + force: bool = False, +): + institution_ids = list( + QueueEntry.objects.filter(status__in=ACTIVE_QUEUE_STATUSES) + .values_list("institution_id", flat=True) + .distinct() + ) + + ticked = 0 + skipped = 0 + for institution_id in institution_ids: + if force: + simulate_queue_tick_for_institution( + institution_id=institution_id, + randomize=randomize, + ) + ticked += 1 + continue + + result = maybe_auto_tick_institution( + institution_id=institution_id, + interval_seconds=interval_seconds, + randomize=randomize, + ) + 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, diff --git a/queueless_backend/queue_tracker/urls.py b/queueless_backend/queue_tracker/urls.py index db817ec..f2a4a62 100644 --- a/queueless_backend/queue_tracker/urls.py +++ b/queueless_backend/queue_tracker/urls.py @@ -2,6 +2,7 @@ from .views import ( InstitutionQueueStatusView, + QueueAutoTickView, QueueEntryStatusView, QueueJoinView, QueueSimulateTickView, @@ -24,4 +25,9 @@ QueueSimulateTickView.as_view(), name="queue-simulate-tick", ), + 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..68c6ba3 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 @@ -13,7 +14,11 @@ QueueEntryStatusSerializer, QueueJoinSerializer, ) -from .services import simulate_queue_tick_for_institution +from .services import ( + auto_tick_active_institutions, + maybe_auto_tick_institution, + simulate_queue_tick_for_institution, +) class QueueJoinView(APIView): @@ -110,6 +115,13 @@ def get(self, request, session_id): status=status.HTTP_404_NOT_FOUND, ) + maybe_auto_tick_institution( + institution_id=entry.institution_id, + interval_seconds=settings.QUEUE_AUTO_TICK_INTERVAL_SECONDS, + randomize=settings.QUEUE_AUTO_TICK_RANDOMIZE, + ) + entry.refresh_from_db() + serializer = QueueEntryStatusSerializer(entry) return Response(serializer.data) @@ -208,3 +220,24 @@ 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, + ) + return Response(result) diff --git a/queueless_backend/queueless_backend/settings.py b/queueless_backend/queueless_backend/settings.py index 4f912b0..69c5e0b 100644 --- a/queueless_backend/queueless_backend/settings.py +++ b/queueless_backend/queueless_backend/settings.py @@ -188,6 +188,11 @@ def env_bool(name: str, default: bool = False) -> bool: if origin.strip() ] +QUEUE_AUTO_TICK_INTERVAL_SECONDS = int( + os.getenv("QUEUE_AUTO_TICK_INTERVAL_SECONDS", "15") +) +QUEUE_AUTO_TICK_RANDOMIZE = env_bool("QUEUE_AUTO_TICK_RANDOMIZE", default=True) + # Development-friendly in-memory channel layer. CHANNEL_LAYERS = { "default": { From 66cc692c66126f3e561afef51296831bbc5b85ab Mon Sep 17 00:00:00 2001 From: 6reenhorn Date: Mon, 20 Apr 2026 01:11:24 +0800 Subject: [PATCH 02/11] feat: implement auto-expiry and check-in flow for queue entries --- .env.example | 4 + API_ENDPOINTS.md | 4 +- queueless_backend/notifications/views.py | 12 +- queueless_backend/queue_tracker/admin.py | 2 + .../management/commands/queue_worker.py | 3 + .../0002_auto_expiry_serving_status.py | 37 ++++ queueless_backend/queue_tracker/models.py | 9 +- .../queue_tracker/serializers.py | 4 + queueless_backend/queue_tracker/services.py | 166 ++++++++++++++++-- queueless_backend/queue_tracker/tests.py | 141 +++++++++++++-- queueless_backend/queue_tracker/urls.py | 6 + queueless_backend/queue_tracker/views.py | 36 +++- .../queueless_backend/settings.py | 41 ++++- 13 files changed, 423 insertions(+), 42 deletions(-) create mode 100644 queueless_backend/queue_tracker/migrations/0002_auto_expiry_serving_status.py 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 9c258ff..78d374d 100644 --- a/API_ENDPOINTS.md +++ b/API_ENDPOINTS.md @@ -31,7 +31,7 @@ If disabled, these routes are not exposed. | PATCH | `/api/queue/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 active institutions | Low to 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 @@ -538,6 +538,8 @@ Hybrid mode is designed to avoid a paid always-on worker: - 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. diff --git a/queueless_backend/notifications/views.py b/queueless_backend/notifications/views.py index 2a840d8..4999bc0 100644 --- a/queueless_backend/notifications/views.py +++ b/queueless_backend/notifications/views.py @@ -27,12 +27,6 @@ def get(self, request, session_id): status=status.HTTP_404_NOT_FOUND, ) - maybe_auto_tick_institution( - institution_id=queue_entry.institution_id, - interval_seconds=settings.QUEUE_AUTO_TICK_INTERVAL_SECONDS, - randomize=settings.QUEUE_AUTO_TICK_RANDOMIZE, - ) - delivered_filter_raw = request.query_params.get("delivered") delivered_filter = parse_bool_query_param_strict(delivered_filter_raw) if delivered_filter is INVALID_BOOLEAN_QUERY_VALUE: @@ -74,6 +68,12 @@ def get(self, request, session_id): ) queryset = queryset.filter(event_type=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..24ca0e2 100644 --- a/queueless_backend/queue_tracker/admin.py +++ b/queueless_backend/queue_tracker/admin.py @@ -11,6 +11,8 @@ 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") 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..ebd2c65 --- /dev/null +++ b/queueless_backend/queue_tracker/migrations/0002_auto_expiry_serving_status.py @@ -0,0 +1,37 @@ +# 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..2b7a6ca 100644 --- a/queueless_backend/queue_tracker/serializers.py +++ b/queueless_backend/queue_tracker/serializers.py @@ -32,6 +32,8 @@ class Meta: "near_turn_notified", "issued_at", "updated_at", + "turn_called_at", + "checked_in_at", "people_ahead", ] @@ -53,4 +55,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 9630299..3f24391 100644 --- a/queueless_backend/queue_tracker/services.py +++ b/queueless_backend/queue_tracker/services.py @@ -1,4 +1,6 @@ import random +from datetime import timedelta +from uuid import uuid4 from django.core.cache import cache from django.db import transaction @@ -11,22 +13,89 @@ 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. + """ + cutoff = timezone.now() - timedelta(seconds=grace_period_seconds) + stale_qs = QueueEntry.objects.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_message). If successful, error_message is None. + """ + try: + entry = QueueEntry.objects.get(session_id=session_id) + except QueueEntry.DoesNotExist: + return None, "Queue entry not found." + + if entry.status != QueueEntryStatus.SERVING: + return None, ( + f"Cannot check in: status is '{entry.status}', " f"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, "1", timeout=interval): + if not cache.add(lock_key, lock_token, timeout=lock_timeout): return None try: @@ -38,11 +107,13 @@ def maybe_auto_tick_institution( 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: - cache.delete(lock_key) + if cache.get(lock_key) == lock_token: + cache.delete(lock_key) def auto_tick_active_institutions( @@ -50,13 +121,23 @@ 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( - QueueEntry.objects.filter(status__in=ACTIVE_QUEUE_STATUSES) - .values_list("institution_id", flat=True) + 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: @@ -64,6 +145,12 @@ def auto_tick_active_institutions( 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 @@ -72,6 +159,7 @@ def auto_tick_active_institutions( institution_id=institution_id, interval_seconds=interval_seconds, randomize=randomize, + grace_period_seconds=grace_period_seconds, ) if result is None: skipped += 1 @@ -89,10 +177,47 @@ def auto_tick_active_institutions( 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.TURN_CALLED, + message=f"Queue #{entry.queue_number} is now being served.", + delivered=False, + ) + for entry in checked_in_serving + ] + ) + + # --- Step 3: Advance current_serving_number --- active_entries = list( QueueEntry.objects.select_for_update() .filter( @@ -114,9 +239,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) @@ -137,19 +263,21 @@ def simulate_queue_tick_for_institution( current_serving_number=new_current_serving, updated_at=now, ) - served_entries = list( + + # --- Step 4: Transition newly-reached entries to SERVING --- + newly_serving_entries = list( QueueEntry.objects.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, updated_at=now, ) Notification.objects.bulk_create( @@ -158,13 +286,17 @@ 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( institution=institution, @@ -209,6 +341,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..5fdb4b4 100644 --- a/queueless_backend/queue_tracker/tests.py +++ b/queueless_backend/queue_tracker/tests.py @@ -1,10 +1,18 @@ +from datetime import timedelta + +from django.core.cache import cache from django.test import SimpleTestCase, TestCase +from django.utils import timezone 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 +57,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 +82,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 +99,118 @@ 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 ) - self.assertIn("please prepare", near_turn_notifications[0].message) + + 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) + + +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) diff --git a/queueless_backend/queue_tracker/urls.py b/queueless_backend/queue_tracker/urls.py index f2a4a62..eed8acf 100644 --- a/queueless_backend/queue_tracker/urls.py +++ b/queueless_backend/queue_tracker/urls.py @@ -3,6 +3,7 @@ from .views import ( InstitutionQueueStatusView, QueueAutoTickView, + QueueEntryCheckInView, QueueEntryStatusView, QueueJoinView, QueueSimulateTickView, @@ -25,6 +26,11 @@ 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(), diff --git a/queueless_backend/queue_tracker/views.py b/queueless_backend/queue_tracker/views.py index 68c6ba3..8ab923c 100644 --- a/queueless_backend/queue_tracker/views.py +++ b/queueless_backend/queue_tracker/views.py @@ -16,6 +16,8 @@ ) from .services import ( auto_tick_active_institutions, + check_in_serving_entry, + expire_stale_serving_entries, maybe_auto_tick_institution, simulate_queue_tick_for_institution, ) @@ -115,12 +117,41 @@ def get(self, request, session_id): status=status.HTTP_404_NOT_FOUND, ) - maybe_auto_tick_institution( + 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, ) - entry.refresh_from_db() + 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 entry is None and error == "Queue entry not found.": + return Response( + {"detail": error}, + status=status.HTTP_404_NOT_FOUND, + ) + return Response( + {"detail": error}, + status=status.HTTP_400_BAD_REQUEST, + ) serializer = QueueEntryStatusSerializer(entry) return Response(serializer.data) @@ -239,5 +270,6 @@ def post(self, request): 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 69c5e0b..fb1970d 100644 --- a/queueless_backend/queueless_backend/settings.py +++ b/queueless_backend/queueless_backend/settings.py @@ -188,11 +188,46 @@ def env_bool(name: str, default: bool = False) -> bool: if origin.strip() ] -QUEUE_AUTO_TICK_INTERVAL_SECONDS = int( - os.getenv("QUEUE_AUTO_TICK_INTERVAL_SECONDS", "15") -) +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.") + # Development-friendly in-memory channel layer. CHANNEL_LAYERS = { "default": { From bc61ade0b859ecfa27d593961a59f5c1e8110e37 Mon Sep 17 00:00:00 2001 From: 6reenhorn Date: Mon, 20 Apr 2026 01:16:45 +0800 Subject: [PATCH 03/11] fix: adjust notification test setup to prevent auto-tick interference --- queueless_backend/notifications/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/queueless_backend/notifications/tests.py b/queueless_backend/notifications/tests.py index b364ab5..4499f84 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, ) From 67faf8e2a3454b0ee94e7b800076da88927a976c Mon Sep 17 00:00:00 2001 From: 6reenhorn Date: Mon, 20 Apr 2026 01:19:12 +0800 Subject: [PATCH 04/11] chore: rerun pre_commit --- .../0002_auto_expiry_serving_status.py | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) 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 index ebd2c65..cb85bc6 100644 --- a/queueless_backend/queue_tracker/migrations/0002_auto_expiry_serving_status.py +++ b/queueless_backend/queue_tracker/migrations/0002_auto_expiry_serving_status.py @@ -6,32 +6,47 @@ class Migration(migrations.Migration): dependencies = [ - ('mock_api', '0002_institution_address_institution_status'), - ('queue_tracker', '0001_initial'), + ("mock_api", "0002_institution_address_institution_status"), + ("queue_tracker", "0001_initial"), ] operations = [ migrations.RemoveConstraint( - model_name='queueentry', - name='uniq_active_queue_number_per_institution', + model_name="queueentry", + name="uniq_active_queue_number_per_institution", ), migrations.AddField( - model_name='queueentry', - name='checked_in_at', + model_name="queueentry", + name="checked_in_at", field=models.DateTimeField(blank=True, null=True), ), migrations.AddField( - model_name='queueentry', - name='turn_called_at', + 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), + 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'), + 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", + ), ), ] From 84e4d28e21d0ccd59f660b63abcfcd3ae17341ad Mon Sep 17 00:00:00 2001 From: 6reenhorn Date: Mon, 20 Apr 2026 21:55:50 +0800 Subject: [PATCH 05/11] feat: refactor queue joining to input-based tracking --- .../queue_tracker/serializers.py | 1 + queueless_backend/queue_tracker/tests.py | 62 +++++++++++++++++++ queueless_backend/queue_tracker/views.py | 60 ++++++++++++------ 3 files changed, 103 insertions(+), 20 deletions(-) diff --git a/queueless_backend/queue_tracker/serializers.py b/queueless_backend/queue_tracker/serializers.py index 2b7a6ca..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 ) diff --git a/queueless_backend/queue_tracker/tests.py b/queueless_backend/queue_tracker/tests.py index 5fdb4b4..cd181b3 100644 --- a/queueless_backend/queue_tracker/tests.py +++ b/queueless_backend/queue_tracker/tests.py @@ -3,6 +3,8 @@ 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 @@ -214,3 +216,63 @@ def test_check_in_invalid_status(self): self.assertIsNone(updated_entry) self.assertIn("Cannot check in", error) + + +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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("already being tracked", response.data["detail"]) diff --git a/queueless_backend/queue_tracker/views.py b/queueless_backend/queue_tracker/views.py index 8ab923c..b54050c 100644 --- a/queueless_backend/queue_tracker/views.py +++ b/queueless_backend/queue_tracker/views.py @@ -7,7 +7,7 @@ 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, @@ -31,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 @@ -63,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") @@ -79,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, @@ -90,16 +114,12 @@ def post(self, request): ) break except IntegrityError: - if attempt == retries - 1: - return Response( - { - "detail": ( - "Could not allocate a queue number due to " - "concurrent requests. Please retry." - ) - }, - status=status.HTTP_409_CONFLICT, - ) + # This catches race conditions where two users hit the + # exact same ticket # at the exact same time + return Response( + {"detail": f"Ticket #{queue_number} is already being tracked."}, + status=status.HTTP_400_BAD_REQUEST, + ) response_serializer = QueueEntryStatusSerializer(entry) return Response(response_serializer.data, status=status.HTTP_201_CREATED) From f3e02dcaf0127b183b33893f77401596023990dc Mon Sep 17 00:00:00 2001 From: 6reenhorn Date: Mon, 20 Apr 2026 23:27:57 +0800 Subject: [PATCH 06/11] chore: addressed potential issues from reviews --- MOCK_QUEUE_OPERATIONS.md | 2 ++ .../mock_api/management/commands/seed_mock_data.py | 9 ++------- queueless_backend/notifications/apps.py | 1 + queueless_backend/queue_tracker/apps.py | 1 + queueless_backend/queue_tracker/services.py | 9 +++++++-- queueless_backend/queue_tracker/views.py | 13 +++++++------ queueless_backend/queueless_backend/urls.py | 2 +- 7 files changed, 21 insertions(+), 16 deletions(-) diff --git a/MOCK_QUEUE_OPERATIONS.md b/MOCK_QUEUE_OPERATIONS.md index 74dc2a2..40ab08d 100644 --- a/MOCK_QUEUE_OPERATIONS.md +++ b/MOCK_QUEUE_OPERATIONS.md @@ -89,6 +89,8 @@ 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/queue/entries/{session_id}/notifications/` 4. Use the simulation endpoint for manual advancement, or run the worker for automatic backend-driven advancement. ## Notes 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/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/services.py b/queueless_backend/queue_tracker/services.py index 3f24391..8dfd8da 100644 --- a/queueless_backend/queue_tracker/services.py +++ b/queueless_backend/queue_tracker/services.py @@ -112,8 +112,13 @@ def maybe_auto_tick_institution( cache.set(last_tick_key, now, timeout=max(60, interval * 4)) return result finally: - if cache.get(lock_key) == lock_token: - cache.delete(lock_key) + # 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( diff --git a/queueless_backend/queue_tracker/views.py b/queueless_backend/queue_tracker/views.py index b54050c..5bf4a45 100644 --- a/queueless_backend/queue_tracker/views.py +++ b/queueless_backend/queue_tracker/views.py @@ -114,12 +114,13 @@ def post(self, request): ) break except IntegrityError: - # This catches race conditions where two users hit the - # exact same ticket # at the exact same time - return Response( - {"detail": f"Ticket #{queue_number} is already being tracked."}, - status=status.HTTP_400_BAD_REQUEST, - ) + # If on last attempt, return error. Otherwise, retry. + if attempt == retries - 1: + return Response( + {"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) diff --git a/queueless_backend/queueless_backend/urls.py b/queueless_backend/queueless_backend/urls.py index 26e6c3d..9be77d5 100644 --- a/queueless_backend/queueless_backend/urls.py +++ b/queueless_backend/queueless_backend/urls.py @@ -23,7 +23,7 @@ path("admin/", admin.site.urls), ] -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")), From ea8f65750529b642ef89d7b8c5925a673695a132 Mon Sep 17 00:00:00 2001 From: 6reenhorn Date: Tue, 21 Apr 2026 00:04:28 +0800 Subject: [PATCH 07/11] chore: addressed potential issues from reviews --- API_ENDPOINTS.md | 84 ++++++++++++++++--- README.md | 2 +- queueless_backend/notifications/views.py | 9 +- queueless_backend/queue_tracker/admin.py | 2 +- queueless_backend/queue_tracker/services.py | 5 +- queueless_backend/queue_tracker/views.py | 4 +- .../queueless_backend/settings.py | 2 +- queueless_backend/queueless_backend/urls.py | 2 +- 8 files changed, 87 insertions(+), 23 deletions(-) diff --git a/API_ENDPOINTS.md b/API_ENDPOINTS.md index 78d374d..5a80d44 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. +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. ## Summary Table @@ -27,8 +27,9 @@ 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 | @@ -50,7 +51,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 @@ -245,7 +247,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 @@ -299,7 +341,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 @@ -397,7 +439,7 @@ Status: `200 OK` "status": "waiting,notified", "active_only": true }, - "count": 2, + "count": 1, "results": [ { "session_id": "d7fd3722-3fb3-413d-8874-294d2f539bc2", @@ -479,6 +521,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." } ``` @@ -534,7 +581,7 @@ Status: `200 OK` Hybrid mode is designed to avoid a paid always-on worker: -- Request-driven: `/api/queue/entries/{session_id}/status/` and `/api/queue/entries/{session_id}/notifications/` may trigger throttled auto-ticks. +- 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. @@ -543,7 +590,22 @@ For multi-instance deployments, configure a shared cache backend via `CACHE_URL` ## 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. +- Use the public status endpoint: + +- `GET /api/queue/entries/{session_id}/status/` + +This is the endpoint the frontend can poll to show the user their current position and state. + +### 6. Check Notifications + +Use the public notifications endpoint: + +- `GET /api/notifications/entries/{session_id}/notifications/` + +This endpoint returns near-turn notifications sent to the user when they are close to being served. + +### 7. Advance The Queue Manually + - Add pagination before exposing institution-level queue lists to heavy polling. - Keep CORS restricted to known frontend origins. 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/notifications/views.py b/queueless_backend/notifications/views.py index 4999bc0..03e7bf5 100644 --- a/queueless_backend/notifications/views.py +++ b/queueless_backend/notifications/views.py @@ -57,16 +57,19 @@ 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, diff --git a/queueless_backend/queue_tracker/admin.py b/queueless_backend/queue_tracker/admin.py index 24ca0e2..0dfab1c 100644 --- a/queueless_backend/queue_tracker/admin.py +++ b/queueless_backend/queue_tracker/admin.py @@ -16,4 +16,4 @@ class QueueEntryAdmin(admin.ModelAdmin): "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/services.py b/queueless_backend/queue_tracker/services.py index 8dfd8da..a94d9a3 100644 --- a/queueless_backend/queue_tracker/services.py +++ b/queueless_backend/queue_tracker/services.py @@ -271,7 +271,7 @@ def simulate_queue_tick_for_institution( # --- Step 4: Transition newly-reached entries to SERVING --- newly_serving_entries = list( - QueueEntry.objects.filter( + QueueEntry.objects.select_for_update().filter( institution=institution, status__in=(QueueEntryStatus.WAITING, QueueEntryStatus.NOTIFIED), queue_number__lte=new_current_serving, @@ -303,7 +303,8 @@ def simulate_queue_tick_for_institution( # --- 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, diff --git a/queueless_backend/queue_tracker/views.py b/queueless_backend/queue_tracker/views.py index 5bf4a45..8a91eb0 100644 --- a/queueless_backend/queue_tracker/views.py +++ b/queueless_backend/queue_tracker/views.py @@ -221,9 +221,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) diff --git a/queueless_backend/queueless_backend/settings.py b/queueless_backend/queueless_backend/settings.py index fb1970d..003ee18 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", ] diff --git a/queueless_backend/queueless_backend/urls.py b/queueless_backend/queueless_backend/urls.py index 9be77d5..37eb96a 100644 --- a/queueless_backend/queueless_backend/urls.py +++ b/queueless_backend/queueless_backend/urls.py @@ -27,5 +27,5 @@ 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")), ] From 4384bb0f47ca4c057fa89a12b775b3a76d746a06 Mon Sep 17 00:00:00 2001 From: 6reenhorn Date: Tue, 21 Apr 2026 00:40:05 +0800 Subject: [PATCH 08/11] chore: addressed potential issues from reviews --- API_ENDPOINTS.md | 25 +++++++------------ queueless_backend/notifications/models.py | 1 + queueless_backend/queue_tracker/services.py | 6 ++--- .../queueless_backend/settings.py | 24 +++++++++++++----- 4 files changed, 31 insertions(+), 25 deletions(-) diff --git a/API_ENDPOINTS.md b/API_ENDPOINTS.md index 5a80d44..92e16ac 100644 --- a/API_ENDPOINTS.md +++ b/API_ENDPOINTS.md @@ -15,7 +15,7 @@ Load notes are qualitative, not benchmark numbers. Actual capacity depends on de - Institution API base: `/api/institutions/` - Queue API base: `/api/queue/` -For the full list of available endpoints and load-profile details, see [API_ENDPOINTS.md](API_ENDPOINTS.md). +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. @@ -40,6 +40,7 @@ See [MOCK_QUEUE_OPERATIONS.md](MOCK_QUEUE_OPERATIONS.md) for how to seed, join, - `waiting` - `notified` +- `serving` - `served` - `expired` - `cancelled` @@ -300,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 @@ -590,24 +591,16 @@ For multi-instance deployments, configure a shared cache backend via `CACHE_URL` ## Operational Guidance - Keep `ENABLE_MOCK_API=False` in production unless these routes are intentionally exposed. -- Use the public status endpoint: - -- `GET /api/queue/entries/{session_id}/status/` - -This is the endpoint the frontend can poll to show the user their current position and state. - -### 6. Check Notifications - -Use the public notifications endpoint: +- Add pagination before exposing institution-level queue lists to heavy polling. +- Keep CORS restricted to known frontend origins. -- `GET /api/notifications/entries/{session_id}/notifications/` +### Frontend Polling -This endpoint returns near-turn notifications sent to the user when they are close to being served. +The frontend should poll the following public endpoints to track queue status: -### 7. Advance The Queue Manually +- `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. -- Add pagination before exposing institution-level queue lists to heavy polling. -- Keep CORS restricted to known frontend origins. ## Documentation Scope Note 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/queue_tracker/services.py b/queueless_backend/queue_tracker/services.py index a94d9a3..767e2bd 100644 --- a/queueless_backend/queue_tracker/services.py +++ b/queueless_backend/queue_tracker/services.py @@ -21,7 +21,7 @@ def expire_stale_serving_entries(institution_id, grace_period_seconds): Returns the list of expired QueueEntry instances. """ cutoff = timezone.now() - timedelta(seconds=grace_period_seconds) - stale_qs = QueueEntry.objects.filter( + stale_qs = QueueEntry.objects.select_for_update().filter( institution_id=institution_id, status=QueueEntryStatus.SERVING, turn_called_at__lte=cutoff, @@ -214,8 +214,8 @@ def simulate_queue_tick_for_institution( Notification( queue_entry=entry, channel=Notification.Channel.SYSTEM, - event_type=Notification.EventType.TURN_CALLED, - message=f"Queue #{entry.queue_number} is now being served.", + event_type=Notification.EventType.SESSION_COMPLETED, + message=f"Queue #{entry.queue_number} has been completed.", delivered=False, ) for entry in checked_in_serving diff --git a/queueless_backend/queueless_backend/settings.py b/queueless_backend/queueless_backend/settings.py index 003ee18..5099d36 100644 --- a/queueless_backend/queueless_backend/settings.py +++ b/queueless_backend/queueless_backend/settings.py @@ -228,9 +228,21 @@ def env_bool(name: str, default: bool = False) -> bool: if QUEUE_GRACE_PERIOD_SECONDS < 1: raise ImproperlyConfigured("QUEUE_GRACE_PERIOD_SECONDS must be at least 1.") -# Development-friendly in-memory channel layer. -CHANNEL_LAYERS = { - "default": { - "BACKEND": "channels.layers.InMemoryChannelLayer", - }, -} +# 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", + }, + } From 88c8d860af01b2b246dbf3dad25eab0e887f3038 Mon Sep 17 00:00:00 2001 From: 6reenhorn Date: Tue, 21 Apr 2026 01:10:30 +0800 Subject: [PATCH 09/11] chore: addressed potential issues from reviews --- MOCK_QUEUE_OPERATIONS.md | 6 ++--- queueless_backend/notifications/tests.py | 26 ++++++++++----------- queueless_backend/queueless_backend/urls.py | 2 +- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/MOCK_QUEUE_OPERATIONS.md b/MOCK_QUEUE_OPERATIONS.md index 40ab08d..f4b0112 100644 --- a/MOCK_QUEUE_OPERATIONS.md +++ b/MOCK_QUEUE_OPERATIONS.md @@ -90,10 +90,8 @@ This is the closest path to a real-world queue simulation in the current stack b 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/queue/entries/{session_id}/notifications/` -4. Use the simulation endpoint for manual advancement, or run the worker for automatic backend-driven advancement. - -## Notes + - 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. diff --git a/queueless_backend/notifications/tests.py b/queueless_backend/notifications/tests.py index 4499f84..2407d92 100644 --- a/queueless_backend/notifications/tests.py +++ b/queueless_backend/notifications/tests.py @@ -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/queueless_backend/urls.py b/queueless_backend/queueless_backend/urls.py index 37eb96a..ba1d5c8 100644 --- a/queueless_backend/queueless_backend/urls.py +++ b/queueless_backend/queueless_backend/urls.py @@ -25,7 +25,7 @@ if getattr(settings, "ENABLE_MOCK_API", False): urlpatterns += [ - path("api/", include("mock_api.urls")), path("api/queue/", include("queue_tracker.urls")), path("api/notifications/", include("notifications.urls")), + path("api/", include("mock_api.urls")), ] From a0dff180cbba34e21e60566bc407f3f118cfcc68 Mon Sep 17 00:00:00 2001 From: 6reenhorn Date: Tue, 21 Apr 2026 01:22:14 +0800 Subject: [PATCH 10/11] chore: addressed potential issues from reviews --- API_ENDPOINTS.md | 2 +- MOCK_QUEUE_OPERATIONS.md | 3 +- queueless_backend/queue_tracker/services.py | 36 ++++++++++++--------- queueless_backend/queue_tracker/tests.py | 3 +- queueless_backend/queue_tracker/views.py | 6 ++-- 5 files changed, 29 insertions(+), 21 deletions(-) diff --git a/API_ENDPOINTS.md b/API_ENDPOINTS.md index 92e16ac..e9c2af2 100644 --- a/API_ENDPOINTS.md +++ b/API_ENDPOINTS.md @@ -467,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"] } ``` diff --git a/MOCK_QUEUE_OPERATIONS.md b/MOCK_QUEUE_OPERATIONS.md index f4b0112..fbdb483 100644 --- a/MOCK_QUEUE_OPERATIONS.md +++ b/MOCK_QUEUE_OPERATIONS.md @@ -91,8 +91,9 @@ This is the closest path to a real-world queue simulation in the current stack b 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 +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/queueless_backend/queue_tracker/services.py b/queueless_backend/queue_tracker/services.py index 767e2bd..4b84085 100644 --- a/queueless_backend/queue_tracker/services.py +++ b/queueless_backend/queue_tracker/services.py @@ -57,24 +57,30 @@ def check_in_serving_entry(session_id): """ Mark a SERVING entry as checked in. Stops the auto-expiry timer. - Returns (entry, error_message). If successful, error_message is None. + Returns (entry, error). If successful, error is None. + 'error' is a dict with 'code' and 'message'. """ - try: - entry = QueueEntry.objects.get(session_id=session_id) - except QueueEntry.DoesNotExist: - return None, "Queue entry not found." - - if entry.status != QueueEntryStatus.SERVING: - return None, ( - f"Cannot check in: status is '{entry.status}', " f"expected 'serving'." - ) + 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 + 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 + entry.checked_in_at = timezone.now() + entry.save(update_fields=["checked_in_at", "updated_at"]) + return entry, None def maybe_auto_tick_institution( diff --git a/queueless_backend/queue_tracker/tests.py b/queueless_backend/queue_tracker/tests.py index cd181b3..0cbfc96 100644 --- a/queueless_backend/queue_tracker/tests.py +++ b/queueless_backend/queue_tracker/tests.py @@ -215,7 +215,8 @@ def test_check_in_invalid_status(self): updated_entry, error = check_in_serving_entry(entry.session_id) self.assertIsNone(updated_entry) - self.assertIn("Cannot check in", error) + self.assertIn("Cannot check in", error["message"]) + self.assertEqual(error["code"], "INVALID_STATUS") class QueueJoinViewTests(TestCase): diff --git a/queueless_backend/queue_tracker/views.py b/queueless_backend/queue_tracker/views.py index 8a91eb0..2768a3a 100644 --- a/queueless_backend/queue_tracker/views.py +++ b/queueless_backend/queue_tracker/views.py @@ -164,13 +164,13 @@ class QueueEntryCheckInView(APIView): def patch(self, request, session_id): entry, error = check_in_serving_entry(session_id) if error: - if entry is None and error == "Queue entry not found.": + if error.get("code") == "NOT_FOUND": return Response( - {"detail": error}, + {"detail": error["message"]}, status=status.HTTP_404_NOT_FOUND, ) return Response( - {"detail": error}, + {"detail": error["message"]}, status=status.HTTP_400_BAD_REQUEST, ) From 87eaa66b27f103f4ac8cd717edd8a5eb1a6c3f47 Mon Sep 17 00:00:00 2001 From: 6reenhorn Date: Fri, 24 Apr 2026 21:24:08 +0800 Subject: [PATCH 11/11] feat: added root api view --- queueless_backend/queue_tracker/services.py | 62 ++++++++++---------- queueless_backend/queue_tracker/tests.py | 22 +++++++ queueless_backend/queue_tracker/views.py | 29 ++++----- queueless_backend/queueless_backend/urls.py | 3 + queueless_backend/queueless_backend/views.py | 9 +++ 5 files changed, 81 insertions(+), 44 deletions(-) create mode 100644 queueless_backend/queueless_backend/views.py diff --git a/queueless_backend/queue_tracker/services.py b/queueless_backend/queue_tracker/services.py index 4b84085..764e5e5 100644 --- a/queueless_backend/queue_tracker/services.py +++ b/queueless_backend/queue_tracker/services.py @@ -20,37 +20,38 @@ def expire_stale_serving_entries(institution_id, grace_period_seconds): Returns the list of expired QueueEntry instances. """ - 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 - ] + 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, ) - return expired_entries + 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): @@ -289,6 +290,7 @@ def simulate_queue_tick_for_institution( 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( diff --git a/queueless_backend/queue_tracker/tests.py b/queueless_backend/queue_tracker/tests.py index 0cbfc96..0c94089 100644 --- a/queueless_backend/queue_tracker/tests.py +++ b/queueless_backend/queue_tracker/tests.py @@ -147,6 +147,28 @@ def test_expired_entries_skipped_on_next_tick(self): ) 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): diff --git a/queueless_backend/queue_tracker/views.py b/queueless_backend/queue_tracker/views.py index 2768a3a..7dcc2b9 100644 --- a/queueless_backend/queue_tracker/views.py +++ b/queueless_backend/queue_tracker/views.py @@ -138,21 +138,22 @@ def get(self, request, session_id): status=status.HTTP_404_NOT_FOUND, ) - expire_stale_serving_entries( - institution_id=entry.institution_id, - grace_period_seconds=settings.QUEUE_GRACE_PERIOD_SECONDS, - ) + 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() + 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) diff --git a/queueless_backend/queueless_backend/urls.py b/queueless_backend/queueless_backend/urls.py index ba1d5c8..4ce5b74 100644 --- a/queueless_backend/queueless_backend/urls.py +++ b/queueless_backend/queueless_backend/urls.py @@ -19,8 +19,11 @@ 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 getattr(settings, "ENABLE_MOCK_API", False): 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")