27 KiB
Appreciations — Workflow & Lifecycle
Source of truth:
apps/appreciation/. This is an outbound recognition/recognition module (recognition sent to staff/physicians), fundamentally different from inbound case-management. This document describes the current implementation only. Every claim is cited asfile:line.
⚠ Read first — two important findings:
VALID_APPRECIATION_TRANSITIONS(models.py:28-34) is defined but never referenced anywhere (dead code). The state machine is enforced only by runtimeValueErrorguards inside the model transition methods.- The REST API
createand complaint→appreciation conversion callappreciation.send()on a freshly-created DRAFT, butsend()requires ACTIVATED/AI_ANALYZED — both would raiseValueError(likely bugs). The working path is the UI activation-gate flow.
1. Purpose
Appreciations = BOTH inbound patient feedback AND outbound staff recognition, but the dominant designed workflow is OUTBOUND recognition FROM PX/hospital staff TO staff/physicians.
Evidence:
apps/appreciation/models.py:2,__init__.py:2,README.md:3,IMPLEMENTATION_SUMMARY.md:4: "Send and track appreciation to users and physicians."- The outbound character:
appreciation_createis gated to PX/hospital staff (ui_views.py:155); the email subject is "Staff Appreciation" (signals.py:72); the body "A staff member in your department has received an appreciation. Please acknowledge their good work." (signals.py:179);tasks.py:18"Send email + SMS to champion, manager, and appreciated staff." - The inbound patient-feedback channel:
public_appreciation_submit(ui_views.py:1196) — patients/public submit a message, created as DRAFT, then PX staff triage and activate/send. Plus complaint→appreciation conversion (complaints/views.py:2076).
So: a recognition engine whose originating actors can be (a) PX/hospital staff sending peer/leadership recognition, (b) public/patient submissions awaiting staff review, or (c) appreciation-type complaints being formally converted. The terminal recipient is always a staff member or physician.
2. How a Case Starts
2.1 Who can create
| Channel | View/Serializer | Permission | File:line |
|---|---|---|---|
| Internal UI form | appreciation_create + AppreciationForm |
@login_required; PX/Hosp-Admin/PX-Mgmt/PX-Employee |
forms.py:8; ui_views.py:153 |
| Public portal | public_appreciation_submit |
No auth (@csrf_exempt); rate-limited (5/5min/IP) |
ui_views.py:1196 |
| External API (HIS integration) | integrations/api_views.py create |
API-key scoped to a hospital | integrations/api_views.py:606 |
| REST API create | AppreciationCreateSerializer |
IsAuthenticated |
views.py:55, serializers.py:132 |
| Complaint → Appreciation conversion | convert_to_appreciation |
Edit permission on the complaint | complaints/views.py:2075 |
2.2 Creation channels in detail
(a) Internal UI form (forms.py:8): ModelForm exposing only ["hospital", "department", "message_en"] (forms.py:13); department restricted to active departments (forms.py:34). On POST: sender = request.user, status = DRAFT (ui_views.py:173). Category/recipient/visibility are NOT set here — decided later during activate/send.
(b) Public portal (ui_views.py:1196): Inputs contact_name, contact_phone, message, hospital, optional staff_name/department/section. Required: name, phone, message, hospital (ui_views.py:1224). Creates with status=DRAFT, visibility=PUBLIC, is_anonymous=False, category=None, metadata {source:"public_form", ...} (ui_views.py:1239). Fires notify_staff_new_item.delay("appreciation", ...) (ui_views.py:1258). Returns reference_number.
(c) External API (integrations/api_views.py:606): Creates DRAFT, visibility PUBLIC, metadata {source:"external_api", ...}. Returns the canonical reference_number generated by save() (APR-YYYYMM-NNNN); the same value is used to retrieve the record via GET /api/v1/external/appreciations/<reference_number>/. Fires notify_staff_new_item.
(d) REST API create (views.py:143, serializer serializers.py:132):
Required: recipient_type (['user','staff']), recipient_id (UUID), message_en, hospital_id. Optional: category_id, message_ar, visibility (default PRIVATE), is_anonymous, department_id. Validation: recipient must belong to hospital; category hospital-compatible. AppreciationViewSet.create resolves a User/Staff via ContentType, creates the record (status DRAFT), then immediately calls appreciation.send() (views.py:198).
(e) Complaint → Appreciation conversion (complaints/views.py:2076): Guards — complaint active (is_active_status, complaints/views.py:2087), complaint_type == "appreciation" (:2096), not already converted (:2103). Defaults: message_en ← complaint.description; message_ar ← complaint.short_description_ar; visibility="private"; is_anonymous=True (:2113). Creates DRAFT (:2171), then appreciation.send() (:2183). Links back via metadata.appreciation_id (:2186); optionally closes complaint (:2196). Default category "Patient Feedback Appreciation" (code patient_feedback, create_patient_feedback_category.py).
2.3 What happens immediately after submission
- Reference number generated in
Appreciation.save()viagenerate_reference("APR", hospital)(models.py:243) →APR-YYYYMM-NNNN(global monthly sequence). Stored onreference_number(models.py:198), comment "internal-only, not publicly trackable". - Default status = DRAFT (
models.py:193). post_savesignalhandle_appreciation_sent(signals.py:27) only acts whenstatus == SENT and not notification_sent— does nothing at creation (DRAFT).- For public/external: PX-admin notification task
notify_staff_new_item("appreciation", id)fired (ui_views.py:1259,api_views.py:625) →emails/new_appreciation_notification.html.
2.4 Why DRAFT + activation gate?
Mirrors the complaints activation gate. A DRAFT appreciation is untriaged: no recipient set (internal form), no AI analysis, no decision to publish at chosen visibility. Activation (ui_views.py:195) is the PX-staff gate that confirms content, triggers AI analysis, stamps activated_at/activated_by, emits audit. Only after activation may it be sent (models.py:335).
The draft count is surfaced to reviewers via core/context_processors.py:74 (draft_appreciation_count) for PX/Hosp-Admin/PX-Mgmt.
3. Complete Lifecycle
3.1 The declared state machine — VALID_APPRECIATION_TRANSITIONS (models.py:28-34) — verified
DRAFT → {ACTIVATED}
ACTIVATED → {AI_ANALYZED, SENT}
AI_ANALYZED → {SENT}
SENT → {ACKNOWLEDGED}
ACKNOWLEDGED → set() (terminal)
3.2 Where enforced — NOWHERE via the constant
VALID_APPRECIATION_TRANSITIONS is never imported or referenced anywhere else (grep confirmed). The model transition methods each re-check the current status and raise ValueError:
activate()(models.py:309) — requires DRAFT.mark_ai_analyzed(analysis_data)(models.py:320) — requires ACTIVATED.send()(models.py:331) — requires ACTIVATED or AI_ANALYZED.acknowledge()(models.py:344) — requires SENT.
3.3 clean() and DB CheckConstraints
- No
clean()method onAppreciation. - No
CheckConstraints in any migration. Onlyunique_togetheron category (models.py:84) andAppreciationStats(models.py:517),unique=Trueonreference_number/badgecode.
Conclusion: status integrity enforced only at runtime in Python methods, not at DB layer.
4. Status Definitions
| Status | Constant | When entered | Who advances | Next | Key code |
|---|---|---|---|---|---|
| DRAFT | AppreciationStatus.DRAFT (models.py:21) |
On creation (model default) | PX/hospital staff via appreciation_activate |
ACTIVATED | created; ref number generated; PX admins notified (public/external only) |
| ACTIVATED | ACTIVATED (models.py:22) |
Staff approves/triages draft; runs AI | AI step (auto) or staff appreciation_send/send_to |
AI_ANALYZED, SENT | activated_at/activated_by stamped (models.py:316, ui_views.py:211) |
| AI_ANALYZED | AI_ANALYZED (models.py:23) |
mark_ai_analyzed() called by activate view after a successful AI call |
Staff via send/send_to |
SENT | ai_analyzed_at + ai_analysis JSON (models.py:326, ui_views.py:254) |
| SENT | SENT (models.py:24) |
send() invoked |
The recipient (User/Staff) via acknowledge |
ACKNOWLEDGED | sent_at stamped (models.py:339); triggers post_save cascade (notifications, stats, badges) |
| ACKNOWLEDGED | ACKNOWLEDGED (models.py:25) |
Recipient acknowledges | (terminal) | none | acknowledged_at (models.py:352) |
4.1 The AI_ANALYZED status — what AI analysis happens
Performed synchronously inside appreciation_activate (ui_views.py:230-258), immediately after DRAFT→ACTIVATED. Uses apps.core.ai_service.AIService.chat_completion (NOT analyze_complaint):
- Prompt asks for JSON:
summary_en,summary_ar,themes(list),tone(warm|formal|casual),suggested_response_en,suggested_response_ar(ui_views.py:234-246). - Parsed and passed to
appreciation.mark_ai_analyzed(analysis_data)(ui_views.py:251). - On failure: logged + swallowed; appreciation stays ACTIVATED (does NOT auto-advance) — still sendable (
ui_views.py:255). AIService.chat_completion(ai_service.py:685) → OpenRouter/chat/completions, modelgoogle/gemini-2.5-flash-litedefault (ai_service.py:41).
So AI_ANALYZED is a "content enrichment" step (summary/themes/tone/draft reply), not a sentiment/severity gate. The appreciation can still be SENT directly from ACTIVATED without ever becoming AI_ANALYZED (models.py:30).
5. Workflow Actions
5.1 UI actions (ui_views.py)
| Action | Function | URL name | Status gate | Permission |
|---|---|---|---|---|
| List | appreciation_list :37 |
appreciation_list |
— | @login_required; hospital-filtered |
| Detail | appreciation_detail :92 |
appreciation_detail |
— | login; PX/Hosp-Admin or same-hospital |
| Create (internal) | appreciation_create :153 |
appreciation_create |
creates DRAFT | PX/Hosp-Admin/PX-Mgmt/PX-Employee |
| Activate (DRAFT→ACTIVATED + AI) | appreciation_activate :193 |
appreciation_activate |
must be DRAFT (:198) |
PX/Hosp-Admin/PX-Mgmt/PX-Employee |
| Send (form) | appreciation_send :264 |
appreciation_send |
ACTIVATED or AI_ANALYZED (:269) |
PX/Hosp-Admin/PX-Mgmt/PX-Employee |
| Send-to (AJAX, person/department) | appreciation_send_to :320 |
appreciation_send_to |
ACTIVATED or AI_ANALYZED (:344) |
PX/Hosp-Admin/Dept-Manager/PX-Mgmt/PX-Employee |
| Acknowledge (SENT→ACKNOWLEDGED) | appreciation_acknowledge :485 |
appreciation_acknowledge |
must be SENT (:500) |
recipient only (User ContentType match, :492) |
| Public submit | public_appreciation_submit :1196 |
public_appreciation_submit |
creates DRAFT | none |
| Restore deleted | appreciation_restore :1036 |
admin_appreciation_restore |
— | PX/Hosp-Admin |
appreciation_pdf :1276 |
appreciation_pdf |
— | @login_required |
|
| Leaderboard | leaderboard_view :515 |
leaderboard_view |
— | login |
| My Badges | my_badges_view :580 |
my_badges_view |
— | login |
| Category CRUD | :666-843 |
category_* |
— | PX/Hosp-Admin only |
| Badge CRUD | :850-1033 |
badge_* |
— | PX/Hosp-Admin only |
| AJAX: users/staff/physicians/departments | :1057-1147 |
ajax/* |
— | login |
Detail view exposes can_activate and can_send flags (ui_views.py:142).
5.2 REST API (views.py)
AppreciationCategoryViewSet (:33), AppreciationViewSet (:55, actions: acknowledge :210, my_appreciations :232, sent_by_me :263, summary :277), AppreciationStatsViewSet (:363), AppreciationBadgeViewSet (:394, PX/Hosp-Admin), UserBadgeViewSet (:416), LeaderboardView (:445).
acknowledge verifies the requester is the recipient via ContentType+id (views.py:216), else HTTP 403.
⚠ Note: there is no
activateorai_analyzeREST action — those are UI-only.
⚠ Gap — REST
createvs status:AppreciationViewSet.create(views.py:143) creates the record and immediately callsappreciation.send()(views.py:198). Becausesend()requires ACTIVATED/AI_ANALYZED (models.py:335), this raisesValueErroron a DRAFT.
6. Decision Points
- Activation gate (DRAFT→ACTIVATED) (
ui_views.py:195). Mirrors the complaints activation gate. - AI analysis path vs direct-send — AI attempted at
appreciation_activate(ui_views.py:230). If it succeeds → AI_ANALYZED; if it throws → stays ACTIVATED and is still sendable. From ACTIVATED the send step is allowed (models.py:30), so AI is optional to progressing. - Visibility/audience decision —
AppreciationVisibility(models.py:37): PRIVATE/DEPARTMENT/HOSPITAL/PUBLIC. Default PRIVATE (models.py:190). Public portal forces PUBLIC (ui_views.py:1248); external API forces PUBLIC (api_views.py:613); complaint conversion defaults"private"(complaints/views.py:2115). Read-access filtered inAppreciationViewSet.get_queryset(views.py:78). - Recipient/target selection — done at send time, not creation, in
appreciation_send_to:recipient_type ∈ {person, department}(ui_views.py:350). If department: auto-targets champion+manager viaget_champion_and_manager. Optionalstaff_idsets recipient to aStaff. - Badge awarding decision —
signals.check_and_award_badges(signals.py:306) runs whenever an appreciation reaches SENT.
7. Assignment Flow — RECIPIENTS
There is no "case assignment to an agent". The analogous concept is the recipient (who the recognition is for) plus the visibility audience.
7.1 Recipient model
- Recipient is a GenericForeignKey (
recipient_content_type+recipient_object_id+recipient) (models.py:132). Eligible concrete types:accounts.Userandorganizations.Staff(incl. physicians asstaff_type='physician',ui_views.py:1110). - Sender:
models.ForeignKey("accounts.User")(models.py:127). AppreciationForm(internal) does not set a recipient — onlyhospital,department,message_en. Recipient bound at send step (ui_views.py:427) for the department path, or viarecipient_type/recipient_idin REST/conversion.- The "owner" (for routing) is computed by
get_owner()(models.py:250) — a cascade over section/department role-holders. ⚠ Gap:get_owner()is defined but not invoked by any code in the appreciation app (verify external callers).
7.2 Visibility
AppreciationVisibility (models.py:37): PRIVATE, DEPARTMENT, HOSPITAL, PUBLIC. Read-side enforcement in AppreciationViewSet.get_queryset (views.py:78): a user sees their sent items, those received by their User/Staff profile, DEPARTMENT-vis in their dept, HOSPITAL-vis in their hospital, and all PUBLIC.
7.3 Organization context
hospital (required, models.py:139), department (nullable, models.py:173), section (nullable, models.py:165), plus three DEPRECATED legacy fields (legacy_location, legacy_main_section, legacy_subsection, models.py:140-163).
8. Investigation Process — N/A
Appreciations have no investigation phase. No investigator field, no investigation status, no evidence model. The closest analog is the AI_ANALYZED step, which is automated content enrichment (summary/themes/tone/suggested reply), not a human investigation. Why N/A: appreciations are positive recognition, not problems requiring root-cause analysis.
9. Communication Flow — notifications
9.1 On reaching SENT — post_save cascade
signals.handle_appreciation_sent (signals.py:27) fires when status == SENT and not notification_sent. It calls:
send_appreciation_notification(signals.py:49): builds HTML email ("Staff Appreciation"); resolves recipient email/phone viaget_recipient_email/get_recipient_phone(models.py:287); sends email + SMS; sender display "Anonymous" ifis_anonymous; calls_send_department_head_notification(signals.py:140) emailingdepartment.manager+Staff(department=dept, is_head=True); calls_send_cc_notifications(signals.py:203); setsnotification_sent=True/notification_sent_at.update_appreciation_stats(signals.py:227) — §12.check_and_award_badges(signals.py:306) — §Gamification.
9.2 The send_appreciation_notifications Celery task
tasks.py:9 (@shared_task) — a separate, richer notification path used by the UI appreciation_send_to department branch (ui_views.py:442). Requires a department; get_champion_and_manager(department); per target sends email + SMS; if staff_id provided, emails/SMS the Staff "You've Been Appreciated!".
⚠ Two notification codepaths —
signals.py(sync, fires on every SENT) andtasks.py(async, fires only from UIsend_todepartment branch). Both can run for the same appreciation if sent viasend_to, potentially double-notifying.
9.3 On creation of a public/external DRAFT
notify_staff_new_item("appreciation", id) emails PX admins/on-call roster (complaints/tasks.py:2489), template emails/new_appreciation_notification.html.
9.4 On badge award
signals.check_and_award_badges emails the recipient user "You earned a badge: !" (signals.py:357).
10. Escalation Flow — N/A
No SLA timers, no escalation levels, no overdue fields in Appreciation (models.py:110-237). No celery beat task references appreciation SLA. Why N/A: appreciation is non-urgent recognition; urgency/SLA semantics belong to complaints.
11. Resolution Process — "acknowledgment"
The analog of resolution is the acknowledge transition (SENT→ACKNOWLEDGED).
- Model method:
acknowledge()(models.py:344) — setsstatus=ACKNOWLEDGED,acknowledged_at=now(). - UI:
appreciation_acknowledge(ui_views.py:485) — POST; only the recipient (matched by User ContentType + id,ui_views.py:492); rejects if not SENT (ui_views.py:500). - REST:
AppreciationViewSet.acknowledge(views.py:210) — same recipient check, HTTP 403 otherwise.
"Acknowledgment" = the recipient has confirmed/thanked — it is the recipient's terminal action. There is no sender-side resolution.
12. Closure Process
12.1 Terminal state & reopen
ACKNOWLEDGED is terminal: VALID_APPRECIATION_TRANSITIONS[ACKNOWLEDGED] = set() (models.py:33), and acknowledge() leaves no forward method. There is no reopen action/view anywhere. Once ACKNOWLEDGED, permanently complete.
12.2 Soft delete / restore
Appreciation extends SoftDeleteModel (models.py:110); soft-deletable + restorable via appreciation_restore (ui_views.py:1036, uses Appreciation.all_objects + restore()). Restore does not change status.
12.3 Statistics aggregation
AppreciationStats (models.py:476) — monthly per recipient. Unique key (recipient_content_type, recipient_object_id, year, month) (models.py:517). Fields: received_count, sent_count, acknowledged_count, hospital_rank, department_rank, category_breakdown (JSON). Aggregated in signals.update_appreciation_stats (signals.py:227) when an appreciation reaches SENT: get_or_create the month row; atomically increment counts via F('...') + 1; update category JSON; recalculate_rankings (signals.py:269) re-numerates ranks by -received_count.
12.4 Leaderboard
LeaderboardView (views.py:445): reads year/month (defaults current month); filters AppreciationStats by hospital; enumerates rank; attaches earned badges. UI equivalent leaderboard_view (ui_views.py:515).
12.5 Summary
AppreciationViewSet.summary (views.py:277) and appreciation_summary_ajax (ui_views.py:1150) compute total/this-month received & sent, badges earned, top category, hospital rank.
13. Exception Flows
13.1 Complaint → Appreciation conversion
complaints/views.py:2076. Guards: is_active_status, complaint_type == "appreciation", metadata.appreciation_id absent. Default category "Patient Feedback Appreciation" (code patient_feedback).
13.2 ⚠ INCONSISTENCY (likely bug): conversion and REST create call send() on DRAFT
- Conversion: creates
status=DRAFT(complaints/views.py:2171) thenappreciation.send()(:2183). - REST create: creates DRAFT then
appreciation.send()(views.py:198). send()(models.py:335) requires ACTIVATED/AI_ANALYZED, elseValueError.
Both paths would raise ValueError at runtime. Neither wraps send() in try/except. This strongly indicates the primary, exercised path is the UI activation-gate flow, and these two callers are out of sync with the gate model.
13.3 Draft abandonment
A DRAFT has no expiry task and no cleanup. Remains queryable as DRAFT (counted in core/context_processors.py:79, ui_views.py:71). No auto-close/activation.
13.4 Visibility changes
visibility is a plain CharField (models.py:190) with no transition guard — editable at any status via admin or direct mutation. Public/external forced PUBLIC at creation, not otherwise change-protected.
13.5 Badge revocation
No revoke/un-award path. UserBadge (models.py:432) has only create + read. badge_delete (ui_views.py:1004) refuses to delete a badge with any UserBadge (ui_views.py:1018) — earned badges are protected. A badge, once awarded, persists.
13.6 Duplicate appreciation
No deduplication on creation. The only dedup is per-badge-award (signals.py:326) and per-stats-month (unique_together models.py:517, guarded by get_or_create).
13.7 Restore from soft-delete
appreciation_restore (ui_views.py:1036) — PX/Hosp-Admin only; redirects to config:deleted_items.
13.8 AI-failure handling
In appreciation_activate, an AI exception is logged but swallowed (ui_views.py:255) — appreciation stays ACTIVATED and is still sendable. At infra level, AIService._notify_ai_failure (ai_service.py:172) emails PX admins (debounced hourly) on HTTP 401/402.
14. Department-Response Sub-Flow — NOT used (confirmed)
Appreciations do not use the inbound send-to-department → department-responds → resolve cycle. There is no responded/department_action status, no department-side response model, no SLA on department reply. The send_to step (ui_views.py:320) is outbound only — it delivers the appreciation to a department's champion+manager+staff and marks the appreciation SENT; the department does not "respond back" through the system (their only return action is the recipient's acknowledge).
Why: this module is outbound recognition. The "send to department" verb here means deliver recognition to that department, not open a case for the department to handle.
15. End-to-End Example
Patient submits appreciation via public form (mentions Nurse A)
↓ (ui_views.py:1196) → status=DRAFT, ref APR-202607-0001,
visibility=PUBLIC, PX admins notified
PX staff reviews + activates
↓ (ui_views.py:193) → status=ACTIVATED, activated_at/by
AI analysis runs synchronously
[DECISION: AI succeeds → AI_ANALYZED, or stays ACTIVATED]
↓ (ui_views.py:230) → ai_analysis JSON stored (summary/themes/tone/suggested reply)
PX sends to Department X (target Nurse A / champion / manager)
↓ (ui_views.py:320) → recipient=Staff(Nurse A), status=SENT, sent_at
[DECISION: visibility PRIVATE/DEPARTMENT/HOSPITAL/PUBLIC]
post_save cascade fires:
- email+SMS to recipient ("Staff Appreciation")
- email department manager + dept heads
- cc notifications
- update_appreciation_stats (received/sent count, rank)
- check_and_award_badges (e.g. "First Appreciation")
→ badge email to recipient
Nurse A (the recipient) acknowledges
↓ (ui_views.py:485) → status=ACKNOWLEDGED, acknowledged_at
[TERMINAL — no reopen, no further actions]
Appendix A — Gamification (Badges)
A.1 AppreciationBadge (models.py:360) — definition
Fields: hospital (null = system-wide), code (unique), bilingual name_en/ar/description_en/ar, icon, color, order. criteria_type (models.py:389): received_count, received_month, streak_weeks, diverse_senders. criteria_value (int threshold). is_active.
A.2 Seed data (seed_appreciation_data.py)
Categories: excellent_care, team_player, innovation, leadership, mentorship, going_extra_mile, reliability, positive_attitude.
Badges (seed file is authoritative — README.md differs):
| code | name_en | criteria_type | value |
|---|---|---|---|
| first_appreciation | First Appreciation | received_count | 1 |
| appreciated_5 | Rising Star | received_count | 5 |
| appreciated_10 | Shining Star | received_count | 10 |
| appreciated_25 | Super Star | received_count | 25 |
| appreciated_50 | Legendary | received_count | 50 |
| monthly_champion | Monthly Champion | received_month | 10 |
| streak_4_weeks | Consistent | streak_weeks | 4 |
| diverse_appreciation | Well-Loved | diverse_senders | 10 |
A.3 UserBadge (models.py:432) — award record
GenericFK recipient, FK badge, earned_at (auto_now_add), appreciation_count (count-at-award), metadata JSON. No unique constraint — awarder dedups manually (signals.py:326).
A.4 Awarding logic — signals.check_and_award_badges (signals.py:306)
Triggered from handle_appreciation_sent when SENT. Iterates active badges for the hospital/system-wide; skip if already earned; check_badge_criteria (signals.py:383): received_count (total SENT to recipient), received_month (this month), streak_weeks (check_appreciation_streak signals.py:498 — Mon-start weeks; gap breaks streak), diverse_senders (distinct senders). If qualifies: UserBadge.objects.create, email recipient.
A.5 Badge progress UI — my_badges_view (ui_views.py:580)
Computes per-badge progress % toward threshold. Template appreciation/my_badges.html.
Appendix B — Flagged gaps / ambiguities
VALID_APPRECIATION_TRANSITIONSis dead code — defined but never enforced via lookup; transitions rely solely on per-methodValueError. Constant and methods agree today but nothing keeps them in sync.- REST
createand complaint conversion callsend()on DRAFT — would raiseValueError(views.py:198,complaints/views.py:2183vs guardmodels.py:335). Likely bug/stale code; UI activation flow is the working path. AppreciationFormdoes not capture recipient/category/visibility — decided later (at send-time for UI path). An internally-created DRAFT has no recipient untilsend_toruns.- Two notification codepaths —
signals.py(sync, every SENT) andtasks.py(async, UIsend_todept branch). Potential double-notification. - No badge revocation, no reopen, no draft expiry — by design.
- README badge list vs seed file badge list differ — seed file is authoritative.
get_owner()(models.py:250) defined but not invoked in the appreciation app — verify external callers.