From 5cf2480602256572110a961f0cfbd1dfde5a7c08 Mon Sep 17 00:00:00 2001 From: Ross Date: Fri, 19 Dec 2025 21:10:43 +0000 Subject: [PATCH] Refactor Assignment model to enforce single rota assignment per worker and update related view logic to prevent multiple assignments --- .../commands/find_multiple_assignments.py | 16 +++++++ djangorota/rota/models.py | 9 +++- djangorota/rota/views.py | 42 ++++++++++++++++--- 3 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 djangorota/rota/management/commands/find_multiple_assignments.py diff --git a/djangorota/rota/management/commands/find_multiple_assignments.py b/djangorota/rota/management/commands/find_multiple_assignments.py new file mode 100644 index 0000000..85a5048 --- /dev/null +++ b/djangorota/rota/management/commands/find_multiple_assignments.py @@ -0,0 +1,16 @@ +from django.core.management.base import BaseCommand +from django.db.models import Count + +from rota.models import Worker + + +class Command(BaseCommand): + help = "List workers assigned to more than one rota (useful before migrating to OneToOne Assignment)" + + def handle(self, *args, **options): + qs = Worker.objects.annotate(num_rotas=Count('rotas')).filter(num_rotas__gt=1) + if not qs.exists(): + self.stdout.write(self.style.SUCCESS('No workers assigned to more than one rota.')) + return + for w in qs: + self.stdout.write(f"Worker id={w.id} name={w.name!r} assigned_rotas={w.num_rotas}") diff --git a/djangorota/rota/models.py b/djangorota/rota/models.py index 48ffc2c..aa92e6e 100644 --- a/djangorota/rota/models.py +++ b/djangorota/rota/models.py @@ -297,13 +297,18 @@ class Worker(models.Model): class Assignment(models.Model): """Join model assigning a worker to a rota (can hold role / metadata).""" - worker = models.ForeignKey(Worker, on_delete=models.CASCADE) + # Enforce that a Worker may only be assigned to a single RotaSchedule by + # using a OneToOneField here. This ensures at the DB level that each + # Worker has at most one Assignment (and therefore at most one rota). + worker = models.OneToOneField(Worker, on_delete=models.CASCADE) rota = models.ForeignKey(RotaSchedule, on_delete=models.CASCADE) role = models.CharField(max_length=100, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: - unique_together = ("worker", "rota") + # With OneToOneField on worker, uniqueness for (worker, rota) is + # implied by the worker uniqueness. Keep ordering for convenience. + ordering = ("-created_at",) def __str__(self): return f"{self.worker} -> {self.rota}" diff --git a/djangorota/rota/views.py b/djangorota/rota/views.py index 466c63c..b960c9f 100644 --- a/djangorota/rota/views.py +++ b/djangorota/rota/views.py @@ -334,12 +334,19 @@ def worker_create(request): # If we created a worker and a rota_id was provided, create Assignment if rota is not None: from .models import Assignment + # Only assign if the worker is not already assigned to any rota. try: - Assignment.objects.create(worker=worker, rota=rota) + if not worker.rotas.exists(): + Assignment.objects.create(worker=worker, rota=rota) + else: + # skip assigning; worker already assigned elsewhere + pass except Exception: - # best-effort: try using the M2M add as fallback + # best-effort: try using the M2M add as fallback but still + # respect single-rota constraint try: - rota.workers.add(worker) + if not worker.rotas.exists(): + rota.workers.add(worker) except Exception: pass @@ -454,14 +461,37 @@ def worker_import(request, rota_id=None): except Exception: fte = 1.0 - w = Worker.objects.create(name=name.strip(), email=email.strip(), site=site.strip(), grade=grade, fte=fte) + # If an email is provided, try to find an existing worker to + # avoid duplicates; otherwise create a new record. + existing = None + if email: + try: + existing = Worker.objects.filter(email__iexact=email.strip()).first() + except Exception: + existing = None + + if existing is not None: + w = existing + else: + w = Worker.objects.create(name=name.strip(), email=email.strip(), site=site.strip(), grade=grade, fte=fte) created.append(w) + + # Assignment: only assign if the worker is not already assigned + # to another rota. If already assigned, record an error and + # skip assignment. if assign_to_rota and rota is not None: try: - Assignment.objects.create(worker=w, rota=rota) + if not w.rotas.exists(): + Assignment.objects.create(worker=w, rota=rota) + else: + # If already assigned to this rota, nothing to do. + assigned_rota = w.rotas.first() + if assigned_rota and assigned_rota.pk != rota.pk: + errors.append(f"Row {i+1}: worker '{w.name}' already assigned to rota '{assigned_rota.name}'") except Exception: try: - rota.workers.add(w) + if not w.rotas.exists(): + rota.workers.add(w) except Exception: pass except Exception as exc: