diff --git a/app/my_practice/tests/test_clinical.py b/app/my_practice/tests/test_clinical.py index c2823e8..edbb288 100644 --- a/app/my_practice/tests/test_clinical.py +++ b/app/my_practice/tests/test_clinical.py @@ -11,7 +11,7 @@ session_duration_edit """ -from datetime import date +from datetime import date, time from decimal import Decimal from django.contrib.auth.models import User @@ -341,6 +341,72 @@ def test_post_finds_existing_session(self): existing.refresh_from_db() self.assertTrue(hasattr(existing, "log")) + def test_post_two_sessions_same_day_attaches_to_unlogged_one(self): + """With two sessions on one date, the log goes to the one without a log yet.""" + logged = Session.objects.create( + client=self.client_obj, session_date=date(2026, 3, 20), session_time=time(9, 0) + ) + SessionLog.objects.create(session=logged, content="Morning") + afternoon = Session.objects.create( + client=self.client_obj, session_date=date(2026, 3, 20), session_time=time(15, 0) + ) + response = self.http.post(self._url(), {"session_date": "2026-03-20", "content": "Later"}) + self.assertEqual(response.status_code, 302) + afternoon.refresh_from_db() + self.assertEqual(afternoon.log.content, "Later") + self.assertEqual(Session.objects.filter(client=self.client_obj).count(), 2) + + def test_post_skips_cancelled_twin_left_by_reschedule(self): + """A cancelled session at the same slot (reschedule leftover) is not chosen.""" + Session.objects.create( + client=self.client_obj, + session_date=date(2026, 3, 20), + session_time=time(10, 0), + cancelled=True, + ) + live = Session.objects.create( + client=self.client_obj, session_date=date(2026, 3, 20), session_time=time(10, 0) + ) + self.http.post(self._url(), {"session_date": "2026-03-20", "content": "Notes"}) + live.refresh_from_db() + self.assertEqual(live.log.content, "Notes") + + def test_post_logged_live_session_with_cancelled_twin_redirects_to_edit(self): + """Once the live session is logged, its unlogged cancelled twin is not used.""" + Session.objects.create( + client=self.client_obj, + session_date=date(2026, 3, 20), + session_time=time(10, 0), + cancelled=True, + ) + live = Session.objects.create( + client=self.client_obj, session_date=date(2026, 3, 20), session_time=time(10, 0) + ) + log = SessionLog.objects.create(session=live, content="Existing") + response = self.http.post(self._url(), {"session_date": "2026-03-20"}) + self.assertRedirects( + response, + reverse("session_log_edit", kwargs={"client_pk": self.client_obj.pk, "log_pk": log.pk}), + ) + + def test_post_two_logged_sessions_same_day_redirects_to_edit(self): + """If every session that day already has a log, redirect to editing the first.""" + first = Session.objects.create( + client=self.client_obj, session_date=date(2026, 3, 20), session_time=time(9, 0) + ) + second = Session.objects.create( + client=self.client_obj, session_date=date(2026, 3, 20), session_time=time(15, 0) + ) + first_log = SessionLog.objects.create(session=first, content="A") + SessionLog.objects.create(session=second, content="B") + response = self.http.post(self._url(), {"session_date": "2026-03-20"}) + self.assertRedirects( + response, + reverse( + "session_log_edit", kwargs={"client_pk": self.client_obj.pk, "log_pk": first_log.pk} + ), + ) + def test_post_invalid_date_redirects(self): """POST with an unparseable session_date redirects with error, no Session created.""" response = self.http.post(self._url(), {"session_date": "not-a-date"}) diff --git a/app/my_practice/tests/test_views_invoice_billing.py b/app/my_practice/tests/test_views_invoice_billing.py index ae22e64..79a82df 100644 --- a/app/my_practice/tests/test_views_invoice_billing.py +++ b/app/my_practice/tests/test_views_invoice_billing.py @@ -3,7 +3,7 @@ delete, add-sessions helpers, and the monthly billing overview. """ -from datetime import date, timedelta +from datetime import date, time, timedelta from decimal import Decimal from unittest import mock @@ -200,6 +200,33 @@ def test_create_success_creates_invoice_and_session(self): self.assertEqual(invoice.items.count(), 1) self.assertTrue(Session.objects.filter(client=self.test_client).exists()) + def test_create_with_two_sessions_same_day_links_unbilled_one(self): + """Two existing sessions on the item's date must not crash; the item links one.""" + for hour in (9, 15): + Session.objects.create( + client=self.test_client, session_date=date.today(), session_time=time(hour, 0) + ) + response = self.client_instance.post(reverse("invoice_create"), self._formset_data()) + self.assertRedirects(response, reverse("invoice_list")) + item = Invoice.objects.get(client=self.test_client).items.get() + self.assertEqual(item.session.session_time, time(9, 0)) + self.assertEqual(Session.objects.filter(client=self.test_client).count(), 2) + + def test_create_skips_cancelled_twin_left_by_reschedule(self): + """A cancelled same-slot session (reschedule leftover) is not billed.""" + Session.objects.create( + client=self.test_client, + session_date=date.today(), + session_time=time(10, 0), + cancelled=True, + ) + live = Session.objects.create( + client=self.test_client, session_date=date.today(), session_time=time(10, 0) + ) + self.client_instance.post(reverse("invoice_create"), self._formset_data()) + item = Invoice.objects.get(client=self.test_client).items.get() + self.assertEqual(item.session, live) + def test_create_invalid_formset_shows_errors_and_rerenders(self): # Missing rate on the one required item makes the formset invalid. data = self._formset_data(**{"items-0-rate": ""}) diff --git a/app/my_practice/views/clinical_views.py b/app/my_practice/views/clinical_views.py index f4a8533..8c7a337 100644 --- a/app/my_practice/views/clinical_views.py +++ b/app/my_practice/views/clinical_views.py @@ -84,14 +84,22 @@ def session_log_create(request, pk): messages.error(request, _("Invalid date.")) return redirect("client_detail", pk=pk) - # Find or create Session for this client + date - session, _created = Session.objects.get_or_create( - client=client, - session_date=session_date, - defaults={ - "duration": int(request.POST.get("duration", 60)), - }, + # A client can have several sessions on one date (calendar import keys on + # date + time, and a reschedule can leave a cancelled twin behind), so attach + # to the first live one still without a log rather than get_or_create, which + # raises MultipleObjectsReturned. + same_day = Session.objects.filter(client=client, session_date=session_date).order_by( + "session_time", "pk" ) + live = same_day.filter(cancelled=False) + pool = live if live.exists() else same_day + session = pool.filter(log__isnull=True).first() or pool.first() + if session is None: + session = Session.objects.create( + client=client, + session_date=session_date, + duration=int(request.POST.get("duration", 60)), + ) # Prevent duplicate logs if hasattr(session, "log"): diff --git a/app/my_practice/views/crud_mixins.py b/app/my_practice/views/crud_mixins.py index a29926e..de6431b 100644 --- a/app/my_practice/views/crud_mixins.py +++ b/app/my_practice/views/crud_mixins.py @@ -128,12 +128,23 @@ def attach_sessions_to_formset(formset, client) -> None: if not session_date: item_form.instance.session = None continue - duration = item_form.cleaned_data.get("duration") or 60 - session, _created = Session.objects.get_or_create( - client=client, - session_date=session_date, - defaults={"duration": duration}, + current = item_form.instance.session + if current and current.client_id == client.pk and current.session_date == session_date: + continue + # Several sessions can share a date (calendar import keys on date + time, + # and a reschedule can leave a cancelled twin behind): prefer a live one not + # yet billed instead of get_or_create, which raises MultipleObjectsReturned. + same_day = Session.objects.filter(client=client, session_date=session_date).order_by( + "session_time", "pk" ) + live = same_day.filter(cancelled=False) + pool = live if live.exists() else same_day + session = pool.filter(invoice_items__isnull=True).first() or pool.first() + if session is None: + duration = item_form.cleaned_data.get("duration") or 60 + session = Session.objects.create( + client=client, session_date=session_date, duration=duration + ) item_form.instance.session = session