Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions API_ENDPOINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions queueless_backend/queue_tracker/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand Down
81 changes: 81 additions & 0 deletions queueless_backend/queue_tracker/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
6 changes: 6 additions & 0 deletions queueless_backend/queue_tracker/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .views import (
InstitutionQueueStatusView,
QueueAutoTickView,
QueueEntryCancelView,
QueueEntryCheckInView,
QueueEntryStatusView,
QueueJoinView,
Expand Down Expand Up @@ -31,6 +32,11 @@
QueueEntryCheckInView.as_view(),
name="queue-entry-check-in",
),
path(
"entries/<uuid:session_id>/cancel/",
QueueEntryCancelView.as_view(),
name="queue-entry-cancel",
),
path(
"auto-tick/",
QueueAutoTickView.as_view(),
Expand Down
21 changes: 21 additions & 0 deletions queueless_backend/queue_tracker/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]

Expand Down
4 changes: 2 additions & 2 deletions queueless_backend/queueless_backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
)


Expand Down Expand Up @@ -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()
]
Expand Down
Loading
Loading