--- since: 1.2.1 --- # DjangoPlay — `helpdesk` App > 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-party `genericissuetracker==0.6.1` package and DjangoPlay's own integration layer at `webapp/paystream/integrations/issuetracker/` (full documentation of that integration layer is deferred to the `paystream` doc later in this series — covered here only at the touchpoint level `helpdesk` actually calls). ## 1. Summary `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. It is intentionally a thin, legacy-shaped app: its own models are simple and largely superseded at the *display/tracking* layer by a separate third-party issue-tracking package, `genericissuetracker` (pinned `==0.6.1` in `pyproject.toml`, installed as its own Django app, wired into the platform via `webapp/paystream/integrations/issuetracker/`). 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` `ModelViewSet`s, 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 `.deleted`/`.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///issues/` 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 `FileUpload`s → 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)` — maps `BugStatus` → a 4-value issue-tracker status (`NEW`/`TRIAGED` → `OPEN`, `IN_PROGRESS` → `IN_PROGRESS`, `FIXED`/`VERIFIED` → `RESOLVED`, `CLOSED` → `CLOSED`), formats `steps_to_reproduce`/`expected_result`/`actual_result` into a single Markdown-ish `description` with `###` section headers, and sets `is_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 for `SupportStatus` (all four values map 1:1, no collapsing), `description` is just the raw `ticket.message`, same `is_public` derivation off `ticket.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()`) — raises `RuntimeError` if none exists. - Iterates `BugReport.objects.filter(migrated_issue_id__isnull=True)` then `SupportTicket.objects.filter(migrated_issue_id__isnull=True)`, each row wrapped in its own `transaction.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 `IssueAdapter` payload builders as the live sync, but goes through `genericissuetracker.serializers.v1.write.issue.IssueCreateSerializer` directly (rather than `IssueMutationService.create_issue()`, which the live sync uses) for the actual `Issue` creation, then separately calls `IssueMutationService.add_attachments()` to carry over any `FileUpload`s. - `--dry-run` validates the serializer and logs what *would* migrate without saving anything or advancing `migrated_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` (standard `BaseViewSet` CRUD, 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 both `fetch`/HTMX JSON requests and plain form submissions, accepts anonymous submitters) — the support-ticket intake flow described in §2.1/§5.2. - **`health/`** — standard `HealthCheckView`.