From 254aeca3655cf578b2a155be2bddb0ee95a652d2 Mon Sep 17 00:00:00 2001 From: Daniel Holbach Date: Wed, 29 Jul 2026 17:48:23 +0200 Subject: [PATCH] feat: materialize open supervision topics as Focus Queue tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SupervisionItem (the existing per-client supervision agenda model, with its own cross-client /supervision/ queue) fits the same materialize-and- reconcile pattern already used for missing session logs and unpaid/unsent invoices. Adds a fourth _sync_object_tasks call: one Task per open (status=offen) SupervisionItem, auto-closed when marked besprochen via the existing supervision queue/client detail page. This makes task_type=SUPERVISION a real, populated type instead of a dead enum value never assigned anywhere (#280) — supervision topics now show up in the single "what needs doing" surface alongside everything else, without duplicating or replacing the dedicated /supervision/ page. Task titles use the client code only, never SupervisionItem.content — that field is Fernet-encrypted specifically because it can hold sensitive clinical material, and titles must stay plaintext-safe like the other materialized types. Co-Authored-By: Claude Sonnet 5 --- .../commands/sync_focus_queue_tasks.py | 36 ++++++++-- app/my_practice/models/todo.py | 4 ++ app/my_practice/tests/test_practice_todo.py | 25 ++++++- .../tests/test_sync_focus_queue_tasks.py | 67 ++++++++++++++++++- 4 files changed, 124 insertions(+), 8 deletions(-) diff --git a/app/my_practice/management/commands/sync_focus_queue_tasks.py b/app/my_practice/management/commands/sync_focus_queue_tasks.py index 04d5511..c8683bb 100644 --- a/app/my_practice/management/commands/sync_focus_queue_tasks.py +++ b/app/my_practice/management/commands/sync_focus_queue_tasks.py @@ -3,9 +3,10 @@ Creates a PracticeTodo (task_type != manual) for each currently-outstanding derived signal (missing session log, unpaid/unsent invoices, pending -operational checklists) and auto-closes ones whose underlying signal has -since resolved. Reuses the same detection logic as the dashboard's "Braucht -Aktion" widget builders rather than re-deriving it. +operational checklists, open supervision topics) and auto-closes ones whose +underlying signal has since resolved. Reuses the same detection logic as the +dashboard's "Braucht Aktion" widget builders (or, for supervision, the +existing SupervisionItem model) rather than re-deriving it. Titles for materialized tasks are intentionally language-neutral (client codes, invoice numbers, raw checklist-type keys) rather than translated @@ -23,6 +24,7 @@ from django.utils import timezone from ...models import Invoice, Practice, PracticeTodo +from ...models.clinical import SupervisionItem from ...models.session import Session from ...utils.dashboard_widgets import ChecklistWidgetBuilder, InvoiceActionsWidgetBuilder from ...utils.tag_helpers import get_sessions_missing_log @@ -31,8 +33,8 @@ class Command(BaseCommand): help = ( "Materialize derived Focus Queue Task rows (missing session log, " - "unpaid/unsent invoices, operational checklists) and auto-close " - "resolved ones." + "unpaid/unsent invoices, operational checklists, open supervision " + "topics) and auto-close resolved ones." ) def handle(self, *args, **options): @@ -43,6 +45,7 @@ def handle(self, *args, **options): self._sync_invoice_unpaid(practice, totals) self._sync_invoice_unsent(practice, totals) self._sync_operational_checklist(practice, totals) + self._sync_supervision(practice, totals) self.stdout.write( self.style.SUCCESS( @@ -128,6 +131,29 @@ def _sync_invoice_unsent(self, practice: Practice, totals: dict) -> None: totals, ) + def _sync_supervision(self, practice: Practice, totals: dict) -> None: + """ + One Task per open SupervisionItem (client__practice=practice, + status=OFFEN), auto-closed once the item is marked besprochen via + the existing supervision queue / client detail page. Title is the + client code only — content is Fernet-encrypted precisely because it + can hold sensitive clinical material, so it must never end up in a + plaintext title field. + """ + items = list( + SupervisionItem.objects.filter( + client__practice=practice, status=SupervisionItem.Status.OFFEN + ).select_related("client") + ) + self._sync_object_tasks( + practice, + PracticeTodo.TaskType.SUPERVISION, + SupervisionItem, + items, + lambda item: item.client.client_code, + totals, + ) + def _sync_operational_checklist(self, practice: Practice, totals: dict) -> None: """ Operational checklists (backups, security review) aren't tied to a diff --git a/app/my_practice/models/todo.py b/app/my_practice/models/todo.py index 7b45abd..17b8bbe 100644 --- a/app/my_practice/models/todo.py +++ b/app/my_practice/models/todo.py @@ -197,6 +197,10 @@ def related_object_url(self) -> str | None: reverse("session_log_create", kwargs={"pk": session.client_id}) + f"?session_date={session.session_date.isoformat()}" ) + if model_name == "supervisionitem": + # SupervisionItem has no page of its own — it lives (and gets + # toggled) on its client's detail page. + return reverse("client_detail", kwargs={"pk": self.related_object.client_id}) url_name = _RELATED_OBJECT_URL_NAMES.get(model_name) if not url_name: return None diff --git a/app/my_practice/tests/test_practice_todo.py b/app/my_practice/tests/test_practice_todo.py index e7fd259..6bcc6ca 100644 --- a/app/my_practice/tests/test_practice_todo.py +++ b/app/my_practice/tests/test_practice_todo.py @@ -3,10 +3,12 @@ from datetime import timedelta from decimal import Decimal -from django.test import TestCase +from django.test import TestCase, override_settings from django.urls import reverse from django.utils import timezone -from my_practice.models import Client, Invoice, Practice, PracticeTodo, Session +from my_practice.models import Client, Invoice, Practice, PracticeTodo, Session, SupervisionItem + +TEST_FERNET_KEY = "7zIJPIlZkdMSPifNsPuNBjIAIqiUkFHmRJN8HGG8ytQ=" # gitleaks:allow class PracticeTodoModelTests(TestCase): @@ -267,6 +269,25 @@ def test_related_object_url_for_session_links_to_log_create(self): ) self.assertEqual(todo.related_object_url, expected) + @override_settings(FERNET_KEY=TEST_FERNET_KEY) + def test_related_object_url_for_supervision_item_links_to_client_detail(self): + client = Client.objects.create( + practice=self.practice, + client_code="XX-6", + full_name="Max Mustermann", + hourly_rate_60=Decimal("100.00"), + ) + item = SupervisionItem.objects.create(client=client, content="Question") + todo = PracticeTodo.objects.create( + practice=self.practice, + title="XX-6", + task_type=PracticeTodo.TaskType.SUPERVISION, + related_object=item, + ) + self.assertEqual( + todo.related_object_url, reverse("client_detail", kwargs={"pk": client.pk}) + ) + def test_reference_date_for_invoice_task_uses_invoice_date(self): client = Client.objects.create( practice=self.practice, diff --git a/app/my_practice/tests/test_sync_focus_queue_tasks.py b/app/my_practice/tests/test_sync_focus_queue_tasks.py index 423b4ff..1b0c4c3 100644 --- a/app/my_practice/tests/test_sync_focus_queue_tasks.py +++ b/app/my_practice/tests/test_sync_focus_queue_tasks.py @@ -4,7 +4,7 @@ from decimal import Decimal from django.core.management import call_command -from django.test import TestCase +from django.test import TestCase, override_settings from django.utils import timezone from ..models import ( @@ -16,8 +16,11 @@ PracticeTodo, ServiceType, Session, + SupervisionItem, ) +TEST_FERNET_KEY = "7zIJPIlZkdMSPifNsPuNBjIAIqiUkFHmRJN8HGG8ytQ=" # gitleaks:allow + def _make_practice(name="Test Practice"): return Practice.objects.create(name=name, title="Testtherapeutin") @@ -241,6 +244,68 @@ def test_auto_closes_when_sent(self): self.assertTrue(task.is_completed) +@override_settings(FERNET_KEY=TEST_FERNET_KEY) +class SyncSupervisionTests(TestCase): + def setUp(self): + self.practice = _make_practice() + self.client_obj = _make_client(self.practice) + + def test_creates_task_for_open_supervision_item(self): + item = SupervisionItem.objects.create(client=self.client_obj, content="Question") + call_command("sync_focus_queue_tasks") + + task = PracticeTodo.objects.get(task_type=PracticeTodo.TaskType.SUPERVISION) + self.assertEqual(task.title, "XX-1") + self.assertEqual(task.related_object, item) + + def test_no_task_for_already_discussed_item(self): + SupervisionItem.objects.create( + client=self.client_obj, content="Question", status=SupervisionItem.Status.BESPROCHEN + ) + call_command("sync_focus_queue_tasks") + self.assertFalse( + PracticeTodo.objects.filter(task_type=PracticeTodo.TaskType.SUPERVISION).exists() + ) + + def test_auto_closes_when_marked_discussed(self): + item = SupervisionItem.objects.create(client=self.client_obj, content="Question") + call_command("sync_focus_queue_tasks") + task = PracticeTodo.objects.get(task_type=PracticeTodo.TaskType.SUPERVISION) + + item.status = SupervisionItem.Status.BESPROCHEN + item.save(update_fields=["status"]) + call_command("sync_focus_queue_tasks") + + task.refresh_from_db() + self.assertTrue(task.is_completed) + + def test_idempotent_no_duplicate_task(self): + SupervisionItem.objects.create(client=self.client_obj, content="Question") + call_command("sync_focus_queue_tasks") + call_command("sync_focus_queue_tasks") + self.assertEqual( + PracticeTodo.objects.filter(task_type=PracticeTodo.TaskType.SUPERVISION).count(), + 1, + ) + + def test_practice_isolation(self): + other_practice = _make_practice("Other Practice") + other_client = _make_client(other_practice, "YY-2") + SupervisionItem.objects.create(client=other_client, content="Question") + call_command("sync_focus_queue_tasks") + + self.assertFalse( + PracticeTodo.objects.filter( + practice=self.practice, task_type=PracticeTodo.TaskType.SUPERVISION + ).exists() + ) + self.assertTrue( + PracticeTodo.objects.filter( + practice=other_practice, task_type=PracticeTodo.TaskType.SUPERVISION + ).exists() + ) + + class SyncOperationalChecklistTests(TestCase): def setUp(self): self.practice = _make_practice()