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/
- Auth: authenticated.
- Purpose: active, translated list of configurable ticket types (
slug,name,description,sort_order). - Send the
slug(or the exactname) back asticket_typewhen creating a ticket.
GET /help-categories/
- Auth: authenticated. Read-only.
- Purpose: active help/FAQ categories (
id,slug,name,description,icon,article_count). - Send the
ids back ashelp_category_idswhen creating/updating a ticket.
POST /support-tickets/
- Auth: authenticated (ticket is owned by the caller).
- Content-Type:
application/jsonormultipart/form-data(required if uploadingattachments). - Body keys:
subject(string, required)description(string, required — encrypted at rest)ticket_type(string, optional, defaultgeneral) — matched case-insensitively againstSupportTicketType.slugor its translatedname. No match → stored asother, and the raw text you sent is preserved inother_ticket_typeinstead.other_ticket_type(string, optional) — only kept whenticket_typedidn't resolve to a configured type; otherwise cleared automatically.help_category_ids(array, optional, write-only) — HelpCategoryids fromGET /help-categories/. The response field is named differently:help_categories(no_ids), and returns fullHelpCategoryobjects, not ids — same shape asGET /help-categories/(titleis the translated name) — so the frontend doesn't need a second round trip to show category name/icon after creating/updating a ticket.priority(string, optional:low/medium/high/urgent, defaultmedium)attachments(array, optional, multipart only) - Response
201: full ticket object (see shape below).
GET /support-tickets/
- Auth: authenticated. Regular users see only their own tickets; staff see all.
- Filters:
status,priority,ticket_type,assigned_to. Search:ticket_number,subject,description. Ordering:created_at,priority,status.
GET /support-tickets/{id}/
- Auth: owner or staff.
PATCH /support-tickets/{id}/ (or PUT)
- Auth: owner or staff.
- Same body keys as create.
help_category_idsis a full replace, not a merge — the DRF M2M manager's.set()semantics apply, so a PATCH with"help_category_ids": ["<id>"]drops every category not in that list. Send[]to clear all. Always send the complete desired set.- To resolve a ticket:
{"status": "resolved", "resolution": "..."}.
DELETE /support-tickets/{id}/
- Auth: owner or staff.
POST /support-tickets/{id}/add_reply/
- Auth: owner or staff.
- Body:
message(string, required),is_internal(bool, optional — staff-only notes).
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
-
PATCH/PUT 500 crash.
resolutionwasblank=Truewithoutnull=True, so its DB column wasNOT NULL. Any ticket created without a resolution silently decrypted back toNoneon the next read, and the next PATCH/PUT of any kind (not just ones touchingresolution) crashed with a 500IntegrityError, because DRF's defaultModelSerializer.update()re-saves the whole row, not just the changed fields. Fixed insupport/migrations/0012_support_ticket_resolution_nullable.py—resolutionis nullable now. The sameblank=True-without-null=Truepattern exists on several encrypted fields inpayments/models.py(card/billing fields) andaccounts/communication_models.py— flagged separately, not yet fixed. -
ticket_typesilently reset to"other"on any PATCH that omitted it.SupportTicketSerializer.validate()unconditionally re-resolvedticket_typeon every save, including partial updates. A PATCH that only changedstatus(e.g.{"status": "resolved"}) would wipe a correctly setticket_typeback to"other"as a side effect, since an omitted key resolved the same way an unmatched value would. Fixed inapi/serializers.py— a partial update that doesn't includeticket_typein the request body now leaves it (andother_ticket_type) untouched. Verified live:PATCH {"status": "in_progress"}on aprofileticket now keepsticket_type: "profile";PATCH {"ticket_type": "billing"}still resolves and updates it correctly. -
help_categoriesnever wrote anything. The actual frontend (HelpAndSupport.jsx) sends the field ashelp_category_ids. The serializer only recognizedhelp_categories(the DRF-generated M2M field name), so every real request silently dropped the category — no error, just an emptyhelp_categories: []in the response. Fixed inapi/serializers.py:help_category_ids(write-only) is now the accepted input key, sourced onto the samehelp_categoriesmodel field;help_categoriesitself 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.