Repository: ephios-dev/ephios

Issue:
Refactor SignupStats
Currently, we are tracking min and max participant counts for shifts and aggregate them in the event list view.
That is problematic, as excess people in one shift can fill spots of people missing in other shifts, causing a display of e.g. `8/8` even though the shifts are `0/4` and `8/4`.
We propose changing the mechanism such that shifts report "missing" (min-actual) and "free" (max-actual) spots, clamped to a value of >=0.
Aggreagation of stats then adds all missing spots with None being interpreted as 0 and adds all free spots and a None value causing the sum to be None.



Discussion:

Resolving patch:
diff --git a/ephios/core/signup/methods.py b/ephios/core/signup/methods.py
--- a/ephios/core/signup/methods.py
+++ b/ephios/core/signup/methods.py
@@ -495,15 +495,16 @@ def get_signup_stats(self):
         """
         min_count, max_count = self.get_participant_count_bounds()
         participations = list(self.shift.participations.all())
+        signed_up_count = sum(
+            p.state == AbstractParticipation.States.CONFIRMED for p in participations
+        )
         return SignupStats(
             requested_count=sum(
                 p.state == AbstractParticipation.States.REQUESTED for p in participations
             ),
-            signed_up_count=sum(
-                p.state == AbstractParticipation.States.CONFIRMED for p in participations
-            ),
-            min_count=min_count,
-            max_count=max_count,
+            signed_up_count=signed_up_count,
+            missing=max(min_count - signed_up_count, 0) if min_count else None,
+            free=max(max_count - signed_up_count, 0) if max_count else None,
         )
 
     def render_shift_state(self, request):
@@ -545,21 +546,21 @@ def render_configuration_form(self, *args, form=None, **kwargs):
 class SignupStats:
     requested_count: int
     signed_up_count: int
-    min_count: Optional[int]
-    max_count: Optional[int]
+    missing: Optional[int]
+    free: Optional[int]
 
     def __add__(self, other: "SignupStats"):
-        if self.min_count is not None or other.min_count is not None:
-            min_count = (self.min_count or 0) + (other.min_count or 0)
+        if self.missing is not None or other.missing is not None:
+            missing = (self.missing or 0) + (other.missing or 0)
         else:
-            min_count = None
-        if self.max_count is not None and other.max_count is not None:
-            max_count = (self.max_count or 0) + (other.max_count or 0)
+            missing = None
+        if self.free is not None and other.free is not None:
+            free = self.free + other.free
         else:
-            max_count = None
+            free = None
         return SignupStats(
             requested_count=self.requested_count + other.requested_count,
             signed_up_count=self.signed_up_count + other.signed_up_count,
-            min_count=min_count,
-            max_count=max_count,
+            missing=missing,
+            free=free,
         )
diff --git a/ephios/plugins/basesignup/signup/section_based.py b/ephios/plugins/basesignup/signup/section_based.py
--- a/ephios/plugins/basesignup/signup/section_based.py
+++ b/ephios/plugins/basesignup/signup/section_based.py
@@ -1,4 +1,5 @@
 import uuid
+from collections import Counter
 from functools import cached_property
 from itertools import groupby
 from operator import itemgetter
@@ -233,16 +234,40 @@ def get_configuration_fields(self):
             },
         }
 
-    def get_participant_count_bounds(self):
-        return (
-            sum(section.get("min_count") or 0 for section in self.configuration.sections),
-            sum(
-                section.get("max_count") or section.get("min_count")
-                for section in self.configuration.sections
-            )
-            if all(section.get("max_count") for section in self.configuration.sections)
-            else None,
+    def get_signup_stats(self):
+        from ephios.core.signup.methods import SignupStats
+
+        participations = list(self.shift.participations.all())
+        requested_count = sum(
+            p.state == AbstractParticipation.States.REQUESTED for p in participations
+        )
+        confirmed_count = sum(
+            p.state == AbstractParticipation.States.CONFIRMED for p in participations
         )
+        signup_stats = SignupStats(requested_count, 0, None, 0)
+        section_counter = Counter(p.data.get("dispatched_section_uuid") for p in participations)
+        for section in self.configuration.sections:
+            participation_count = section_counter[section["uuid"]]
+            missing = (
+                max(min_count - participation_count, 0)
+                if (min_count := section.get("min_count"))
+                else None
+            )
+            free = (
+                max(max_count - participation_count, 0)
+                if (max_count := section.get("max_count"))
+                else None
+            )
+            signup_stats += SignupStats(
+                requested_count=0,
+                signed_up_count=participation_count,
+                missing=missing,
+                free=free,
+            )
+        if (undispatched_participations := confirmed_count - signup_stats.signed_up_count) > 0:
+            signup_stats.free -= min(undispatched_participations, signup_stats.free)
+            signup_stats.missing -= min(undispatched_participations, signup_stats.missing)
+        return signup_stats
 
     @staticmethod
     def check_qualification(method, participant):


Test patch:
diff --git a/tests/plugins/basesignup/test_sectionbased_signup.py b/tests/plugins/basesignup/test_sectionbased_signup.py
--- a/tests/plugins/basesignup/test_sectionbased_signup.py
+++ b/tests/plugins/basesignup/test_sectionbased_signup.py
@@ -127,3 +127,31 @@ def test_signup_flow(django_app, qualified_volunteer, planner, event, sectioned_
     assert participation.state == AbstractParticipation.States.CONFIRMED
     assert participation.data.get("preferred_section_uuid") == KTW_UUID
     assert participation.data.get("dispatched_section_uuid") == KTW_UUID
+
+
+def test_signup_methods(django_app, sectioned_shift, volunteer):
+    signup_stats = sectioned_shift.signup_method.get_signup_stats()
+    assert signup_stats.missing == 3
+    assert signup_stats.free is None
+    sectioned_shift.signup_configuration["sections"] = (
+        {
+            "title": "KTW",
+            "qualifications": [],
+            "min_count": 2,
+            "max_count": 3,
+            "uuid": KTW_UUID,
+        },
+    )
+    sectioned_shift.save()
+    signup_stats = sectioned_shift.signup_method.get_signup_stats()
+    assert signup_stats.missing == 2
+    assert signup_stats.free == 3
+    LocalParticipation.objects.create(
+        shift=sectioned_shift,
+        user=volunteer,
+        state=AbstractParticipation.States.CONFIRMED,
+        data=dict(dispatched_section_uuid=KTW_UUID),
+    )
+    signup_stats = sectioned_shift.signup_method.get_signup_stats()
+    assert signup_stats.missing == 1
+    assert signup_stats.free == 2