DjangoPlay — helpdesk App
helpdesk (verbose name "Helpdesk", AppConfig.name = "helpdesk") owns two public-facing intake channels — bug reports and support tickets — plus a shared generic file-attachment model used by both. ...
On this page ▾
- 1. Summary
- 2. Architecture
- 2.1 Two intake flows, one shared attachment model
- 2.2 Two independent creation paths — with different side effects
- 2.3 Soft delete — same audit-signal gap seen in teamcentral
- 3. Data Models
- 3.1 BugReport
- 3.2 SupportTicket
- 3.3 FileUpload
- 4. Enums (helpdesk/models/enums.py)
- 5. Services
- 5.1 BugService.submit_bug_report(*, request, form)
- 5.2 SupportService.submit_support_request(*, request, subject, full_name, email, message, files)
- 5.3 sync_bug_to_issue / sync_ticket_to_issue
- 6. IssueAdapter (helpdesk/adapters/issue_adapter.py)
- 7. migrate_helpdesk_to_issues Management Command
- 8. Integration Points (cross-app)
- 9. Quick Reference — API Surface
Doc generated by direct inspection of every model, service, adapter, management command, and one representative view/serializer/admin file per model family in
webapp/helpdesk/, plus its cross-app dependency on the third-partygenericissuetracker==0.6.1package and DjangoPlay's own integration layer atwebapp/paystream/integrations/issuetracker/(full documentation of that integration layer is deferred to thepaystreamdoc later in this series — covered here only at the touchpoint levelhelpdeskactually calls).
1. Summary
The architecture here is a live sync, not a replacement: every BugReport and SupportTicket created through the app's own UI-facing services is also mirrored into a genericissuetracker.Issue at creation time, and a one-off management command (migrate_helpdesk_to_issues) exists to backfill any legacy records created before that sync existed. helpdesk keeps writing its own tables (they remain the system of record for the original bug/ticket number, e.g. B4F2A9C1E3D7, and for throttling/email history), while genericissuetracker becomes the system of record for anything user-facing about issue status, comments, and public visibility. The migrated_issue_id field on both models is the join key between the two systems.
2. Architecture
2.1 Two intake flows, one shared attachment model
Public bug-report UI (ReportBugView) Public support UI (support_view)
│ requires login │ anonymous OR authenticated
▼ ▼
BugReportForm (Django form, SupportForm (request=request,
5 files / 10MB each, used for identity checks)
GitHub-issue-URL regex) │
│ ▼
▼ employee_state_by_email(email)
BugService.submit_bug_report() → blocks "inactive" accounts only
│ │
▼ ▼
1. BugReport.save() SupportService.submit_support_request()
2. emit_event("bug_report.created") │
3. FileUpload.objects.create( 1. SupportTicket.save()
content_object=bug, ...) 2. FileUpload.objects.create(...)
4. sync_bug_to_issue() ──────────┐ 3. sync_ticket_to_issue() ──────────┐
5. allow_flow() throttle check │ 4. allow_flow() throttle check │
6. send_bug_report_email_task │ 5. send_support_ticket_email_task │
.delay() (Celery) │ .delay() (Celery) │
▼ ▼
IssueAdapter.build_*_issue_payload()
│
▼
IssueMutationService.create_issue() (paystream.integrations.issuetracker)
│
▼
genericissuetracker.Issue (system of record for status/comments/visibility)
│
bug.migrated_issue_id = issue.id (or ticket.migrated_issue_id)FileUpload uses a Django GenericForeignKey (content_type + object_id), so the same model backs attachments on both BugReport and SupportTicket (and, in principle, anything else — nothing in this app restricts the generic relation to just these two).
2.2 Two independent creation paths — with different side effects
Beyond the UI flows above, helpdesk also exposes plain authenticated CRUD (BugReportViewSet, SupportViewSet, FileUploadViewSet — all standard BaseViewSet ModelViewSets, see the teamcentral doc §7.1 for the shared base class behavior). These CRUD endpoints call neither BugService/SupportService nor the issue-tracker sync — they just serialize straight to/from the model. Practical implication: a BugReport or SupportTicket created via POST /crud/bug-reports/ or POST /crud/support-tickets/ (by anyone with the right Django model permission) will not get a genericissuetracker.Issue, not trigger the confirmation email, not go through allow_flow() throttling, and not emit the bug_report.created audit event — all of that logic lives only in the two services, which only the dedicated UI views call. Anyone building automation against this API should go through the UI-facing form endpoints, not the generic CRUD router, if they need the full side-effect chain.
2.3 Soft delete — same audit-signal gap seen in teamcentral
All three models (BugReport, SupportTicket, FileUpload) inherit core.models.TimeStampedModel's generic soft_delete()/restore(), which fires audit.signals.events.post_soft_delete/post_restore (see the audit doc §2.2 and the teamcentral doc §2.3 for the full mechanism). All three models in this app override soft_delete()/restore() locally — setting fields and calling self.save() directly rather than super().soft_delete() — so, exactly as documented for teamcentral, no <model>.deleted/<model>.restored audit event is ever emitted for BugReport, SupportTicket, or FileUpload, despite all three being registered in AUDIT_TRACKED_MODELS (HelpdeskConfig.ready()). .created/.updated events are unaffected (those come from the separate global pre_save/post_save receivers, untouched by this override pattern).
FileUpload additionally overrides delete() itself (not just soft_delete()) with a hard= kwarg: default behavior is to soft-delete (metadata only, file stays on disk); hard=True physically removes the file from storage via default_storage.delete() and calls the real Model.delete(). Nothing in the views currently passes hard=True — hard deletion is only reachable by calling the model method directly (e.g. from the Django shell or a future admin action), not through any exposed endpoint.
3. Data Models
All three inherit TimeStampedModel + AuditFieldsModel (see core.models, documented in the audit doc) and carry simple_history.HistoricalRecords().
3.1 BugReport
| Field | Notes |
|---|---|
bug_number |
Auto-generated B + 12 uppercase hex chars on first save, unique=True, editable=False. No collision retry (unlike teamcentral.EmploymentProfile.employee_code/MemberProfile.member_code, which retry up to 10 times on collision) — a hash collision here would raise a raw IntegrityError rather than being handled gracefully. Collision probability is astronomically low (12 hex chars = 48 bits) but the asymmetry with the teamcentral pattern is worth noting. |
reporter |
FK → UserIdentity, on_delete=CASCADE, required (not nullable) — meaning direct model-level creation always needs an authenticated reporter; see §2.1, the UI flow enforces login before ever reaching this. |
summary, steps_to_reproduce, expected_result, actual_result |
Free text; only summary/steps_to_reproduce are required at the form layer. |
status |
BugStatus: NEW → TRIAGED → IN_PROGRESS → FIXED → VERIFIED → CLOSED. |
severity |
Severity: LOW/MEDIUM/HIGH/CRITICAL, default MEDIUM. |
external_issue_url |
Free-form URL field on the model; the frontend form (BugReportForm) restricts it via regex to https://github.com/<owner>/<repo>/issues/<n> specifically — the model itself doesn't enforce that shape, so anything created outside that form (admin, API) can set an arbitrary URL. |
resolved_at |
Auto-stamped in save() the first time status becomes FIXED/VERIFIED/CLOSED. |
migrated_issue_id |
UUID FK-by-value into genericissuetracker.Issue.id (see §2.1) — null=True, indexed, used as the sync guard (sync_bug_to_issue no-ops if already set). |
attachments |
GenericRelation(FileUpload). |
email_sent / email_failed are computed properties (not stored) that query mailer.EmailDelivery.objects.filter(metadata__bug_id=self.pk, status=...) — a JSON-field lookup into mailer's delivery log, not a direct FK. See the mailer doc for how EmailDelivery.metadata gets bug_id populated.
3.2 SupportTicket
Structurally similar, but designed for anonymous or authenticated submission: full_name/email are plain text fields (not derived from a required FK), and user is a nullable SET_NULL FK rather than BugReport's required CASCADE reporter. status uses the separate SupportStatus enum (OPEN/IN_PROGRESS/RESOLVED/CLOSED — no NEW/TRIAGED/VERIFIED distinction BugStatus has). ticket_number follows the same S + 12-hex pattern, same no-retry-on-collision caveat as bug_number. Same email_sent/email_failed/migrated_issue_id/attachments shape as BugReport, keyed on metadata__ticket_id instead of metadata__bug_id.
3.3 FileUpload
Generic attachment: content_type + object_id → content_object (GenericForeignKey), file (validated to jpg/jpeg/png/pdf/txt/log/zip/mp4/gif extensions only via FileExtensionValidator — extension-based only, no MIME-sniffing or content validation), original_name/size/mime_type auto-derived from the uploaded file in save() if not explicitly provided. Indexed on (content_type, object_id) and uploaded_at; default ordering -uploaded_at.
⚠️ The CRUD write serializer for this model only exposes the file field (FileUploadWriteSerializerV1.Meta.fields = ("file",)) — content_type/object_id are never settable through POST /crud/file-uploads/. Since object_id has no default and isn't nullable, creating a FileUpload through that endpoint as written will fail at the database level with a not-null constraint violation (or DRF will reject it depending on validation order) — it simply cannot be used to attach a file to anything. In practice, every actual attachment in this app is created the other way: FileUpload.objects.create(content_object=bug_or_ticket, file=f, ...) directly inside BugService/SupportService, bypassing the serializer/viewset entirely. The CRUD endpoint for FileUpload appears functional only for reading existing uploads (read/list/history), not for creating new ones as a generic attachment API.
4. Enums (helpdesk/models/enums.py)
Three TextChoices classes shared across the app: Severity (LOW/MEDIUM/HIGH/CRITICAL — used by both BugReport and SupportTicket), SupportStatus, BugStatus. IssueAdapter (see §6) maps both status enums onto genericissuetracker's own status vocabulary at sync time.
5. Services
5.1 BugService.submit_bug_report(*, request, form)
Single entry point for creating a bug report from the authenticated UI flow. Sequence: create BugReport → emit_event("bug_report.created", ...) (category data_governance) → attach uploaded FileUploads → best-effort sync_bug_to_issue() (failures logged, swallowed — a broken issue-tracker sync never blocks the user's bug submission) → allow_flow() unified throttle check (mailer.throttling.flow_throttle) → if throttled, emits bug_report.throttled (category system) and returns early → otherwise queues send_bug_report_email_task.delay(bug.id) (Celery, best-effort — a failed queue emits a generic email.failed event but still returns status="success" from the ticket-creation standpoint... actually returns status="error" in that branch, see code — the bug itself is already saved either way).
Note: request.user.email.strip().lower() is computed unconditionally near the top of this method, right after a null-safe user_identity = request.user if request.user.is_authenticated else None line — meaning if this service were ever reached with an unauthenticated request (it currently isn't, because ReportBugView.post() redirects unauthenticated users to login before calling this service — see §2.1), that .email access would raise AttributeError on Django's AnonymousUser, which doesn't define an email attribute. This is currently dead code in practice given the one call site, but it's a latent trap for anyone adding a second, anonymous-permitting call path to this service without also removing/guarding that line.
5.2 SupportService.submit_support_request(*, request, subject, full_name, email, message, files)
Handles both anonymous and authenticated submitters correctly (uses the passed-in email parameter throughout, never request.user.email). Checks _is_registered_email() via users.services.identity_query_service.IdentityQueryService.get_by_email() purely for logging — the result doesn't gate ticket creation; the code comment confirms this is intentional ("Allow unregistered users — treat as new visitors"). Same throttle/email-queue/issue-sync shape as BugService.
Dead branch worth knowing about: SupportRequestResult.status is typed to allow "not_registered" (per its docstring/type comment), and the calling view (views/ui/support.py) has a whole message-building branch for result.status == "not_registered" — but submit_support_request() never actually returns that status anywhere in its current implementation (only "error", "limit", "success"). This looks like leftover code from an earlier version of the flow where unregistered emails were rejected outright; the branch in the view is currently unreachable.
5.3 sync_bug_to_issue / sync_ticket_to_issue
Both: no-op if migrated_issue_id already set → build a payload via IssueAdapter → IssueMutationService.create_issue() (from paystream.integrations.issuetracker) → on success, stamp migrated_issue_id and save(update_fields=["migrated_issue_id"]); on failure, raise (caught by the caller, logged, swallowed — sync failure never blocks the primary bug/ticket save). sync_ticket_to_issue's docstring notes the sync exists for a future "show support tickets on the issues subdomain" feature that, as of v1.1.0, isn't wired into the UI yet — the sync itself runs regardless, it's just not surfaced anywhere user-facing yet for tickets specifically.
6. IssueAdapter (helpdesk/adapters/issue_adapter.py)
Pure translation layer, deliberately decoupling helpdesk's services from genericissuetracker's payload shape (docstring: "Keeps Helpdesk services decoupled from IssueTracker implementation"). Two class methods:
build_bug_issue_payload(bug, reporter_user_id=None)— mapsBugStatus→ a 4-value issue-tracker status (NEW/TRIAGED→OPEN,IN_PROGRESS→IN_PROGRESS,FIXED/VERIFIED→RESOLVED,CLOSED→CLOSED), formatssteps_to_reproduce/expected_result/actual_resultinto a single Markdown-ishdescriptionwith###section headers, and setsis_public = not getattr(bug.reporter, "is_staff", False)— i.e. bug reports from staff members become private issues by default; everyone else's become public, including anonymous reporters (getattr(None, "is_staff", False)→False→is_public=True).build_support_issue_payload(ticket, reporter_user_id=None)— same status-mapping pattern forSupportStatus(all four values map 1:1, no collapsing),descriptionis just the rawticket.message, sameis_publicderivation offticket.user.
Both attach metadata documenting provenance (source, helpdesk_id, helpdesk_type, and the legacy bug_number/ticket_number) — this is what lets the migration command and the audit trail trace an Issue back to its originating helpdesk record.
7. migrate_helpdesk_to_issues Management Command
One-time backfill, separate from the live per-record sync in §5.3 — needed because the live sync was presumably added after some BugReport/SupportTicket rows already existed without a corresponding Issue. python manage.py migrate_helpdesk_to_issues [--dry-run]:
- Builds a synthetic request object (
SimpleNamespace) impersonating the first superuser found (User.objects.filter(is_superuser=True).first()) — raisesRuntimeErrorif none exists. - Iterates
BugReport.objects.filter(migrated_issue_id__isnull=True)thenSupportTicket.objects.filter(migrated_issue_id__isnull=True), each row wrapped in its owntransaction.atomic()block with per-row exception handling (self.stderr.write+ continue — one bad row doesn't abort the whole run). - Re-uses the exact same
IssueAdapterpayload builders as the live sync, but goes throughgenericissuetracker.serializers.v1.write.issue.IssueCreateSerializerdirectly (rather thanIssueMutationService.create_issue(), which the live sync uses) for the actualIssuecreation, then separately callsIssueMutationService.add_attachments()to carry over anyFileUploads. --dry-runvalidates the serializer and logs what would migrate without saving anything or advancingmigrated_issue_id.
8. Integration Points (cross-app)
| App / Package | How it touches helpdesk |
|---|---|
genericissuetracker (third-party, ==0.6.1) |
The actual issue-tracking system of record helpdesk mirrors into at creation time (live) and via the backfill command (one-time). helpdesk never imports its models directly — only its write serializer (IssueCreateSerializer, used solely by the migration command) and, indirectly, whatever IssueMutationService/IssueAdapter produce. |
paystream.integrations.issuetracker |
DjangoPlay's own glue layer around genericissuetracker — owns IssueMutationService (used by both live sync paths and the migration command), access-control policies (GENERIC_ISSUETRACKER_DEFAULT_PERMISSION_CLASSES, GENERIC_ISSUETRACKER_TRANSITION_POLICY), and the internal-visibility role list (CEO/DJGO/SSO in GENERIC_ISSUETRACKER_ISSUE_INTERNAL_ALLOWED_ROLES). Full documentation deferred to the paystream doc later in this series. |
mailer |
mailer.flows.bug.send_bug_report_email_task / mailer.flows.support.send_support_ticket_email_task (Celery tasks, queued best-effort) and mailer.throttling.flow_throttle.allow_flow() (shared rate-limit gate, keyed by user/email/IP) are both called directly by the two services. BugReport.email_sent/email_failed and the SupportTicket equivalents read back from mailer.EmailDelivery via a metadata JSON lookup. |
users |
users.services.identity_query_service.IdentityQueryService used for the (log-only) registered-email check in SupportService and, per apps.py, helpdesk registers AUDIT_TRACKED_MODELS entries that get diffed by users' shared UserIdentity-adjacent audit machinery like every other app. BugReport.reporter/SupportTicket.user FK into UserIdentity. |
utilities |
employee_state_by_email() (used by the support UI view to block ticket submission for emails belonging to inactive accounts), BaseViewSet, BaseAdminPage/AdminIconDecorator/changelist_filter, TemplateRegistry — shared platform infrastructure. |
audit |
BugReport, SupportTicket, FileUpload all registered in AUDIT_TRACKED_MODELS (HelpdeskConfig.ready()) — automatic create/update diff tracking, subject to the delete/restore signal gap noted in §2.3/§8. |
core |
TimeStampedModel, AuditFieldsModel base classes; core.events.helpers.emit_event() called directly by BugService for bug_report.created/bug_report.throttled/email.failed. |
9. Quick Reference — API Surface
crud/—BugReportViewSet,SupportViewSet,FileUploadViewSet(standardBaseViewSetCRUD, authenticated, Django-permission-gated — see §2.2 for what this path doesn't do).read/—detail/,list/,history/per model.ui/— autocomplete endpoint(s) (views/api/v1/ui/autocomplete.py).- UI (human-facing, mounted at the app root alongside the API):
ReportBugView(POST-only,csrf_exempt, requires login) — the authenticated bug-report intake flow described in §2.1/§5.1.support_view(GET redirects to the login panel; POST handles bothfetch/HTMX JSON requests and plain form submissions, accepts anonymous submitters) — the support-ticket intake flow described in §2.1/§5.2.
health/— standardHealthCheckView.