Back to API Docs

Support Tickets API

Base path: /api

Ticket type & help category pickers

Fetch these to populate the ticket-creation form; both are seeded via ./manage.py seed_master_data --section support and manageable afterwards through Django admin or their own endpoints.

GET /support-ticket-types/

GET /help-categories/

POST /support-tickets/

GET /support-tickets/

GET /support-tickets/{id}/

PATCH /support-tickets/{id}/ (or PUT)

DELETE /support-tickets/{id}/

POST /support-tickets/{id}/add_reply/

Response shape (create/read)

{
  "id": "uuid",
  "ticket_number": "TKT-XXXXXXXX",
  "subject": "string",
  "description": "string",              // decrypted on read
  "ticket_type": "profile",             // resolved slug — always match on this, not display text
  "ticket_type_label": "Profile",       // translated display name, read-only
  "other_ticket_type": "",              // only set when ticket_type fell back to "other"
  "help_categories": [                  // full HelpCategory objects on read (write still takes plain ids, see below)
    {
      "id": "uuid", "slug": "getting-started", "title": "Getting Started",
      "description": "Core onboarding guidance for new users.",
      "icon": "/media/md/s/gs.png", "sort_order": 0, "is_active": true,
      "article_count": 0
    }
  ],
  "status": "open",
  "priority": "medium",
  "resolution": null,                   // null until resolved (nullable as of migration 0012)
  "resolved_at": null,
  "resolved_by": null,
  "attachment_paths": ["support/tickets/attachments/....pdf"],
  "attachment_urls": ["https://.../media/support/tickets/attachments/....pdf"],
  "replies": [],
  "reply_count": 0,
  "user": "uuid",
  "user_email": "string",
  "user_name": "string",
  "created_at": "DD/MM/YYYY HH:MM:SS+TZ",
  "updated_at": "DD/MM/YYYY HH:MM:SS+TZ"
}

Gotchas fixed 2026-08-27

  1. PATCH/PUT 500 crash. resolution was blank=True without null=True, so its DB column was NOT NULL. Any ticket created without a resolution silently decrypted back to None on the next read, and the next PATCH/PUT of any kind (not just ones touching resolution) crashed with a 500 IntegrityError, because DRF's default ModelSerializer.update() re-saves the whole row, not just the changed fields. Fixed in support/migrations/0012_support_ticket_resolution_nullable.py — resolution is nullable now. The same blank=True-without-null=True pattern exists on several encrypted fields in payments/models.py (card/billing fields) and accounts/communication_models.py — flagged separately, not yet fixed.

  2. ticket_type silently reset to "other" on any PATCH that omitted it. SupportTicketSerializer.validate() unconditionally re-resolved ticket_type on every save, including partial updates. A PATCH that only changed status (e.g. {"status": "resolved"}) would wipe a correctly set ticket_type back to "other" as a side effect, since an omitted key resolved the same way an unmatched value would. Fixed in api/serializers.py — a partial update that doesn't include ticket_type in the request body now leaves it (and other_ticket_type) untouched. Verified live: PATCH {"status": "in_progress"} on a profile ticket now keeps ticket_type: "profile"; PATCH {"ticket_type": "billing"} still resolves and updates it correctly.

  3. help_categories never wrote anything. The actual frontend (HelpAndSupport.jsx) sends the field as help_category_ids. The serializer only recognized help_categories (the DRF-generated M2M field name), so every real request silently dropped the category — no error, just an empty help_categories: [] in the response. Fixed in api/serializers.py: help_category_ids (write-only) is now the accepted input key, sourced onto the same help_categories model field; help_categories itself is now read-only and always returns full nested objects. Verified live with the exact frontend payload shape.

Data cleanup, not yet done

Production has both hand-created SupportTicketType rows (Technical, General, Payment, Profile — capitalized slugs, made via admin before this seeder existed) and the seeded lowercase set (technical, general, billing, profile, ...) side by side — 11 rows total, some effectively duplicates (General/general, Profile/profile), and Payment has no lowercase counterpart (closest is billing). Not breaking anything since _resolve_ticket_type matches whichever row hits first, but it'll confuse anyone building the type-picker dropdown from GET /support-ticket-types/ today. Worth a deliberate merge (decide the canonical slug per category, repoint any tickets already using the one being retired, deactivate the other) rather than deleting either side blind.