Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions app/my_practice/management/commands/sync_focus_queue_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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):
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/my_practice/models/todo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 23 additions & 2 deletions app/my_practice/tests/test_practice_todo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
67 changes: 66 additions & 1 deletion app/my_practice/tests/test_sync_focus_queue_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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")
Expand Down Expand Up @@ -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()
Expand Down