A helpdesk API with a triage worker.
Customers open tickets. Agents move them from open to closed. A Celery worker assigns a category, a priority, and a suggested first reply — from an OpenAI-compatible model, or from keyword rules when no API key is set.
Run it with docker compose up --build, then
docker compose exec api python manage.py seed_demo.
Swagger lives at /api/docs/ on the API process. This page is the
short version; the full schema is the ReDoc reference.
Who can see what
| Role | Tickets | Writes |
|---|---|---|
| customer | Only their own. Other ids are 404. | Open a ticket, comment in public, edit title and description while it is open. |
| agent | The whole queue. | Status, priority, category, assignee, tags, and internal notes. |
| admin | The whole queue. | Everything an agent can do, plus delete tickets and tags. |
Seeded passwords are Ticketdesk-demo-1 for
admin, sam, riley, alice,
bob, and carol. Registration always creates a customer.
Status workflow
resolved can return to in_progress.
closed can return to open.
Any other jump is a 400. Customers cannot change status.
Examples
These bodies were captured from a local seed on 18 Oct 2026. Timestamps will differ on your machine. Tokens are JWTs; the access payload includes role and username.
Sign in
curl -s -X POST http://localhost:8000/api/auth/token/ \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"Ticketdesk-demo-1"}'
{
"refresh": "<jwt>",
"access": "<jwt>"
}
Search Alice's open refunds
curl -s 'http://localhost:8000/api/tickets/?status=open&search=refund' \ -H "Authorization: Bearer $ACCESS"
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 7,
"title": "Refund for unused seats",
"status": "open",
"priority": "high",
"category": "billing",
"requester": {"id": 4, "username": "alice", "role": "customer"},
"assignee": null,
"tags": ["billing"],
"triage_status": "completed",
"created_at": "2026-10-18T10:14:55.243713Z",
"updated_at": "2026-10-18T11:14:55.243713Z"
}
]
}
The same ticket, as the agent
sam sees the suggested reply and the triage metadata.
alice gets null for those fields.
triage_latency_ms: 0 is the measured keyword-classifier time on that run (under 1 ms).
{
"id": 1,
"title": "Invoice charged twice this month",
"status": "resolved",
"priority": "high",
"category": "billing",
"assignee": {"id": 2, "username": "sam", "role": "agent"},
"tags": ["billing"],
"triage_status": "completed",
"triage_source": "fallback",
"triage_model": "",
"triage_latency_ms": 0,
"suggested_reply": "Thanks for contacting us about billing. An agent will review the charge and follow up with the next step.",
"comments": [
{"author": {"username": "alice"}, "body": "The second line item matches the first one exactly.", "is_internal": false},
{"author": {"username": "sam"}, "body": "I see the duplicate. Finance will issue a credit on the next invoice.", "is_internal": false}
]
}
Open a ticket
The create response is pending. The worker fills category and priority after the response.
curl -s -X POST http://localhost:8000/api/tickets/ \
-H "Authorization: Bearer $ACCESS" \
-H 'Content-Type: application/json' \
-d '{"title":"Cannot download the March invoice","description":"The invoice link returns a 404 after I sign in.","tags":["billing"]}'
{
"id": 9,
"title": "Cannot download the March invoice",
"description": "The invoice link returns a 404 after I sign in.",
"tags": ["billing"],
"status": "open",
"priority": "medium",
"category": "general",
"triage_status": "pending",
"created_at": "2026-10-18T14:15:14.063147Z"
}
Illegal transition
curl -s -X PATCH http://localhost:8000/api/tickets/9/ \
-H "Authorization: Bearer $AGENT" \
-H 'Content-Type: application/json' \
-d '{"status":"closed"}'
{"status": ["Cannot transition from in_progress to closed."]}
That 400 was measured after the ticket had already moved from open to
in_progress. Closed is only reachable from resolved.
Triage without a key
Empty LLM_API_KEY skips the network and uses keyword rules:
billing, access, technical, account, then general. Words such as
outage or down force priority urgent.
With a key, the worker calls POST {LLM_BASE_URL}/chat/completions,
retries timeouts and 429/5xx with backoff (1s, 2s, 4s), then stores the keyword
result if the model still fails.