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
68 changes: 67 additions & 1 deletion app/my_practice/tests/test_clinical.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"})
Expand Down
29 changes: 28 additions & 1 deletion app/my_practice/tests/test_views_invoice_billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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": ""})
Expand Down
22 changes: 15 additions & 7 deletions app/my_practice/views/clinical_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
21 changes: 16 additions & 5 deletions app/my_practice/views/crud_mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading