ticketdesk

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.

Django 5.2 Django REST framework JWT Celery + Redis PostgreSQL

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

RoleTicketsWrites
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

open→ in_progress→ resolved→ closed

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.