diff --git a/API_ENDPOINTS.md b/API_ENDPOINTS.md index 8930895..d8610c4 100644 --- a/API_ENDPOINTS.md +++ b/API_ENDPOINTS.md @@ -28,6 +28,7 @@ See [MOCK_QUEUE_OPERATIONS.md](MOCK_QUEUE_OPERATIONS.md) for how to seed, join, | 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 | | PATCH | `/api/queue/entries/{session_id}/check-in/` | Public | Confirm presence during SERVING status | Low | +| POST | `/api/queue/entries/{session_id}/cancel/` | Public | Cancel tracking for a queue session | 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 | | POST | `/api/notifications/entries/{session_id}/push-subscription/` | Public | Register browser push subscription | Low | @@ -289,6 +290,44 @@ Status: `200 OK` Low. Single lookup and update by session ID. +## POST /api/queue/entries/{session_id}/cancel/ + +### 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": 24, + "status": "cancelled", + "near_turn_threshold": 3, + "near_turn_notified": true, + "issued_at": "2026-04-19T09:45:23.123456Z", + "updated_at": "2026-04-29T10:15:00.000000Z", + "people_ahead": 17 +} +``` + +### Common errors + +- `400 Bad Request` if the entry is not in an active status (waiting, notified, serving). +- `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 diff --git a/queueless_backend/queue_tracker/services.py b/queueless_backend/queue_tracker/services.py index 119161b..d638ba0 100644 --- a/queueless_backend/queue_tracker/services.py +++ b/queueless_backend/queue_tracker/services.py @@ -118,6 +118,48 @@ def check_in_serving_entry(session_id): return entry, None +def cancel_queue_entry(session_id): + """ + Cancel a queue entry by session_id. + Returns (entry, error). If successful, error is None. + """ + 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 not in ACTIVE_QUEUE_STATUSES: + return None, { + "code": "INVALID_STATUS", + "message": ( + f"Cannot cancel: status is '{entry.status}', " + "only active entries can be cancelled." + ), + } + + entry.status = QueueEntryStatus.CANCELLED + entry.save(update_fields=["status", "updated_at"]) + + # Create system notification + Notification.objects.create( + queue_entry=entry, + channel=Notification.Channel.SYSTEM, + event_type=Notification.EventType.SESSION_COMPLETED, + message=f"Queue #{entry.queue_number} has been cancelled by the user.", + delivered=False, + ) + + msg = f"Your tracking for Queue #{entry.queue_number} has been cancelled." + transaction.on_commit( + lambda e_id=entry.id, m=msg: _trigger_web_push_after_commit( + e_id, Notification.EventType.SESSION_COMPLETED, m + ) + ) + + return entry, None + + def maybe_auto_tick_institution( institution_id: int, *, diff --git a/queueless_backend/queue_tracker/tests.py b/queueless_backend/queue_tracker/tests.py index 0c94089..9043c39 100644 --- a/queueless_backend/queue_tracker/tests.py +++ b/queueless_backend/queue_tracker/tests.py @@ -299,3 +299,84 @@ def test_join_with_duplicate_active_ticket(self): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("already being tracked", response.data["detail"]) + + +class QueueCancellationTests(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_cancel_success(self): + entry = QueueEntry.objects.create( + institution=self.institution, + queue_number=5, + current_serving_number=4, + status=QueueEntryStatus.WAITING, + ) + + response = self.client.post(f"/api/queue/entries/{entry.session_id}/cancel/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + entry.refresh_from_db() + self.assertEqual(entry.status, QueueEntryStatus.CANCELLED) + + # Verify notification created + notifications = Notification.objects.filter( + queue_entry=entry, + message__icontains="cancelled", + ) + self.assertTrue(notifications.exists()) + + def test_cancel_already_cancelled(self): + entry = QueueEntry.objects.create( + institution=self.institution, + queue_number=5, + current_serving_number=4, + status=QueueEntryStatus.CANCELLED, + ) + + response = self.client.post(f"/api/queue/entries/{entry.session_id}/cancel/") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("Cannot cancel", response.data["detail"]) + + def test_rejoin_after_cancel(self): + # 1. Join + entry = QueueEntry.objects.create( + institution=self.institution, + queue_number=20, + current_serving_number=10, + status=QueueEntryStatus.WAITING, + ) + + # 2. Try to join again with same number (should fail) + response_join_fail = self.client.post( + "/api/queue/join/", + { + "institution_id": self.institution.id, + "queue_number": 20, + }, + ) + self.assertEqual(response_join_fail.status_code, status.HTTP_400_BAD_REQUEST) + + # 3. Cancel first entry + response_cancel = self.client.post( + f"/api/queue/entries/{entry.session_id}/cancel/" + ) + self.assertEqual(response_cancel.status_code, status.HTTP_200_OK) + + # 4. Try to join again with same number (should succeed now) + response_join_success = self.client.post( + "/api/queue/join/", + { + "institution_id": self.institution.id, + "queue_number": 20, + }, + ) + self.assertEqual(response_join_success.status_code, status.HTTP_201_CREATED) + self.assertEqual(response_join_success.data["queue_number"], 20) diff --git a/queueless_backend/queue_tracker/urls.py b/queueless_backend/queue_tracker/urls.py index eed8acf..ff9fb74 100644 --- a/queueless_backend/queue_tracker/urls.py +++ b/queueless_backend/queue_tracker/urls.py @@ -3,6 +3,7 @@ from .views import ( InstitutionQueueStatusView, QueueAutoTickView, + QueueEntryCancelView, QueueEntryCheckInView, QueueEntryStatusView, QueueJoinView, @@ -31,6 +32,11 @@ QueueEntryCheckInView.as_view(), name="queue-entry-check-in", ), + path( + "entries//cancel/", + QueueEntryCancelView.as_view(), + name="queue-entry-cancel", + ), path( "auto-tick/", QueueAutoTickView.as_view(), diff --git a/queueless_backend/queue_tracker/views.py b/queueless_backend/queue_tracker/views.py index 7dcc2b9..7bf640b 100644 --- a/queueless_backend/queue_tracker/views.py +++ b/queueless_backend/queue_tracker/views.py @@ -16,6 +16,7 @@ ) from .services import ( auto_tick_active_institutions, + cancel_queue_entry, check_in_serving_entry, expire_stale_serving_entries, maybe_auto_tick_institution, @@ -179,6 +180,26 @@ def patch(self, request, session_id): return Response(serializer.data) +class QueueEntryCancelView(APIView): + permission_classes = [permissions.AllowAny] + + def post(self, request, session_id): + entry, error = cancel_queue_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) + + class InstitutionQueueStatusView(APIView): permission_classes = [permissions.IsAdminUser] diff --git a/queueless_backend/queueless_backend/settings.py b/queueless_backend/queueless_backend/settings.py index 8a2046e..13f41fa 100644 --- a/queueless_backend/queueless_backend/settings.py +++ b/queueless_backend/queueless_backend/settings.py @@ -31,7 +31,7 @@ def env_bool(name: str, default: bool = False) -> bool: IS_TEST_ENV = "PYTEST_CURRENT_TEST" in os.environ or any( - "pytest" in arg for arg in sys.argv + cmd in sys.argv for cmd in ["pytest", "test"] ) @@ -183,7 +183,7 @@ def env_bool(name: str, default: bool = False) -> bool: # CORS configuration from environment variables. CORS_ALLOW_ALL_ORIGINS = env_bool("CORS_ALLOW_ALL_ORIGINS", default=False) CORS_ALLOWED_ORIGINS = [ - origin.strip() + origin.strip().rstrip("/") for origin in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if origin.strip() ] diff --git a/queueless_backend/queueless_backend/views.py b/queueless_backend/queueless_backend/views.py index fe0de7c..32b0761 100644 --- a/queueless_backend/queueless_backend/views.py +++ b/queueless_backend/queueless_backend/views.py @@ -3,14 +3,14 @@ def landing_page(request): """ - Returns a premium landing page for the QueueLess Backend. + Returns a harmonized, premium landing page for the QueueLess Backend. """ font_link = ( "https://fonts.googleapis.com/css2?" - "family=Outfit:wght@300;400;600&display=swap" + "family=Outfit:wght@300;400;500;600;700&display=swap" ) - frontend_url = "https://queue-less-phi.vercel.app" + frontend_url = "https://queueless-ph.vercel.app" html_content = f""" @@ -18,18 +18,21 @@ def landing_page(request): - QueueLess | API Engine + QueueLess | API Core Engine -
-
v2.0 Beta
- -

API Core Engine

+
+
+
+
+ + + +
+
+ API Engine v1.0 Beta +
+

Your API Core, Synchronized.

- The heavy lifting happens here. This backend manages - real-time queue states, synchronizes notifications, - and powers the entire QueueLess ecosystem. + Powers real-time queue states, smart notifications, and seamless + integration for the QueueLess ecosystem.

- Launch Frontend Application -
+
+ Launch Frontend + Admin Console +
+ +
+
+ 99.9% + Uptime SLA +
+
+ <50ms + Avg Response +
+
+ Real-time + Engine Status +
+
+ """