Skip to content
2 changes: 1 addition & 1 deletion PROJECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ grep -rho "P-[0-9]\{3\}" --include=*.md --include=*.py --include=*.html \

## 🔍 Recent Activity

- **2026-10-28 — Security review fixes**: source review of views, settings and calendar import; dev stack bound to loopback, login-throttle NFKC bypass closed, GDPR erasure made complete, four latent practice-isolation gaps closed. Details in [docs/CHANGELOG.md](docs/CHANGELOG.md) (Unreleased).
- **2026-10-27 — v0.9.0 released**: inquiry lapse/anonymise and charts (#533, #534), same-day session crash fix (#537), systemd timer fixes (#535), Pillow 13 / python-dateutil 3.0. Highlights in [docs/CHANGELOG.md](docs/CHANGELOG.md).
- **2026-10-21 — Inquiry charts, lapse + anonymise**: `/inquiries` gets a monthly chart with marketing periods drawn as lanes (generic `lanes` option on `MPChart.bar`) and an open-inquiry ageing list (PR #533). Follow-up adds a *Lapsed* status and `manage.py tidy_inquiries` (lapse after 60 days without a step, anonymise closed non-client inquiries 180 days after last contact); not on the scheduled-jobs timer yet — the retention period is still to be confirmed with the Verband.

> Ältere Einträge: [docs/CHANGELOG.md](docs/CHANGELOG.md)

Expand Down
31 changes: 19 additions & 12 deletions app/my_practice/management/commands/fetch_calendar_events.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""
Management command to fetch Google Calendar events into the pending queue (P-013).

Run every few hours via systemd timer. Idempotent: google_event_id unique constraint
prevents duplicates. On each run, also marks previously-fetched events as 'cancelled'
Run every few hours via systemd timer. Idempotent: the (practice, google_event_id)
unique constraint prevents duplicates. On each run, also marks previously-fetched events as 'cancelled'
if they no longer appear in Google Calendar — unless the same session simply moved
to a new Google event id, which _supersede_moved_events() re-binds first.
"""
Expand All @@ -13,10 +13,11 @@
from django.core.management.base import BaseCommand
from django.utils import timezone

from ...models import GoogleCalendarToken, PendingCalendarEvent, Session
from ...models import GoogleCalendarToken, PendingCalendarEvent, Practice, Session
from ...utils.google_calendar import (
CalendarEventParser,
GoogleCalendarOAuth,
practice_clients,
)
from ...utils.tag_helpers import sync_no_next_session_tag

Expand Down Expand Up @@ -126,7 +127,7 @@ def _fetch_for_practice(self, practice, calendar_id, dry_run, forced_days, futur
return

live_ids = {e.get("id") for e in raw_events if e.get("id")}
parsed = CalendarEventParser.parse_events(raw_events)
parsed = CalendarEventParser.parse_events(raw_events, practice_clients(practice))

# Clients whose Session rows changed in this run — their
# no-next-session tag is re-synced at the end instead of waiting
Expand Down Expand Up @@ -266,7 +267,7 @@ def _supersede_moved_events(

Returns the number of rows re-bound.
"""
replacements = self._collect_replacement_candidates(parsed_events, live_ids)
replacements = self._collect_replacement_candidates(parsed_events, live_ids, practice)
if not replacements:
return 0

Expand All @@ -287,13 +288,15 @@ def _supersede_moved_events(
return moved

@classmethod
def _collect_replacement_candidates(cls, parsed_events: list, live_ids: set) -> dict:
def _collect_replacement_candidates(
cls, parsed_events: list, live_ids: set, practice: "Practice"
) -> dict:
"""Group live events that have no PendingCalendarEvent row yet by
(client_id, date) — the pool a stale row can be re-bound to."""
known_ids = set(
PendingCalendarEvent.objects.filter(google_event_id__in=live_ids).values_list(
"google_event_id", flat=True
)
PendingCalendarEvent.objects.filter(
practice=practice, google_event_id__in=live_ids
).values_list("google_event_id", flat=True)
)
candidates: dict = {}
for event in parsed_events:
Expand Down Expand Up @@ -401,9 +404,9 @@ def _upsert_event(
return "created"

obj, was_created = PendingCalendarEvent.objects.get_or_create(
practice=practice,
google_event_id=event_id,
defaults={
"practice": practice,
"summary": event.get("summary", ""),
"event_date": event_date,
"event_time": event_time,
Expand Down Expand Up @@ -454,7 +457,7 @@ def _transition_to_cancelled(
obj: "PendingCalendarEvent", event_id: str, affected_clients: set | None
) -> None:
PendingCalendarEvent.objects.filter(
google_event_id=event_id,
pk=obj.pk,
status=PendingCalendarEvent.Status.PENDING,
).update(status=PendingCalendarEvent.Status.CANCELLED)
if obj.session_id:
Expand Down Expand Up @@ -637,5 +640,9 @@ def _auto_create_session(self, event, event_id, event_date, event_time, status):
},
)
if not PendingCalendarEvent.objects.filter(session=session).exists():
PendingCalendarEvent.objects.filter(google_event_id=event_id).update(session=session)
# matched_client comes from the practice's own clients, so its
# practice is the one whose row this is.
PendingCalendarEvent.objects.filter(
practice_id=matched_client.practice_id, google_event_id=event_id
).update(session=session)
return matched_client
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 6.1.2 on 2026-10-28 11:21

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('my_practice', '0008_inquiry_lapsed_status_anonymised_at'),
]

operations = [
migrations.AlterField(
model_name='pendingcalendarevent',
name='google_event_id',
field=models.CharField(max_length=255, verbose_name='Google Event ID'),
),
migrations.AddConstraint(
model_name='pendingcalendarevent',
constraint=models.UniqueConstraint(fields=('practice', 'google_event_id'), name='pce_practice_event_uniq'),
),
]
10 changes: 8 additions & 2 deletions app/my_practice/models/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ class PendingCalendarEvent(models.Model):
command and held here until approved/imported to an invoice, skipped, or
detected as cancelled in Google Calendar.

google_event_id is unique so repeated fetches are idempotent.
(practice, google_event_id) is unique so repeated fetches are idempotent.
"""

class Status(StrEnum):
Expand All @@ -108,9 +108,10 @@ class Status(StrEnum):
related_name="pending_calendar_events",
verbose_name=_("Practice"),
)
# Unique per practice, not globally: a global key let one practice's sync
# pick up — and cancel or move — another practice's row for the same event.
google_event_id = models.CharField(
max_length=255,
unique=True,
verbose_name=_("Google Event ID"),
)
summary = models.CharField(max_length=500, verbose_name=_("Summary"))
Expand Down Expand Up @@ -163,6 +164,11 @@ class Meta:
models.Index(fields=["practice", "status"], name="pce_practice_status_idx"),
models.Index(fields=["matched_client", "event_date"], name="pce_client_date_idx"),
]
constraints = [
models.UniqueConstraint(
fields=["practice", "google_event_id"], name="pce_practice_event_uniq"
),
]

def __str__(self) -> str:
client = self.matched_client.client_code if self.matched_client else "?"
Expand Down
2 changes: 2 additions & 0 deletions app/my_practice/models/inquiry.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ class MarketingPeriod(TimestampedModel):
help_text=_('e.g. "Google Ads €5/day" or "It\'s Complicated Premium"'),
)

objects = PracticeScopedManager()

class Meta:
ordering = ["-start_date"]
verbose_name = _("Marketing period")
Expand Down
26 changes: 26 additions & 0 deletions app/my_practice/tests/test_fetch_calendar_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -902,3 +902,29 @@ def test_dry_run_reports_would_create(self):
call_command("fetch_calendar_events", "--dry-run", stdout=out)
self.assertIn("[dry-run] Would create", out.getvalue())
self.assertFalse(PendingCalendarEvent.objects.exists())


class EventIdPerPracticeTest(UpsertEventTestBase):
"""The same Google event id in two practices' calendars gives two rows."""

def setUp(self):
super().setUp()
self.other = Practice.objects.create(name="Test GmbH", slug="upsert-other", title="T")
self.cmd._upsert_event(self._event(), self.practice, dry_run=False)
self.own_row = PendingCalendarEvent.objects.get(practice=self.practice)
self.session = Session.objects.get(client=self.test_client)

def test_other_practice_gets_its_own_row(self):
result = self.cmd._upsert_event(self._event(matched_client=None), self.other, dry_run=False)
self.assertEqual(result, "created")
self.assertEqual(PendingCalendarEvent.objects.filter(google_event_id="evt-1").count(), 2)

def test_other_practices_cancellation_leaves_this_practice_alone(self):
self.cmd._upsert_event(self._event(matched_client=None), self.other, dry_run=False)
self.cmd._upsert_event(
self._event(matched_client=None, is_cancelled=True), self.other, dry_run=False
)
self.own_row.refresh_from_db()
self.session.refresh_from_db()
self.assertEqual(self.own_row.status, PendingCalendarEvent.Status.PENDING)
self.assertFalse(self.session.cancelled)
Loading
Loading