Repository: carpentries/amy

Issue:
Do not allow concurrent same community roles
People should not have the same concurrent community roles.  For example if a person has an Instructor role from 2018-02-01 through 2020-06-01, any other role must end before 2018-02-01 and start after 2020-06-01.


Discussion:
@maneesha To be sure: if the person is an instructor in 2022 (2022-01-01 to 2022-12-31) they can't have *any* other community role in that time? Or is this specific to the same role (in this case "instructor")?
They can not have the **same** concurrent role.  For example, they should not have two concurrent Instructor roles, but the may have concurrent Instructor and Maintainer roles.
@maneesha thank you for this explanation.

Resolving patch:
diff --git a/amy/communityroles/forms.py b/amy/communityroles/forms.py
--- a/amy/communityroles/forms.py
+++ b/amy/communityroles/forms.py
@@ -4,6 +4,7 @@
 
 from django import forms
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.db.models import Q
 
 from workshops.fields import HeavySelect2Widget, ModelSelect2Widget
 from workshops.forms import SELECT2_SIDEBAR, BootstrapHelper, WidgetOverrideMixin
@@ -81,11 +82,12 @@ def clean(self) -> Union[dict[str, Any], None]:
         inactivation: Optional[CommunityRoleInactivation] = cleaned_data.get(
             "inactivation"
         )
+        start_date: Optional[date] = cleaned_data.get("start")
         end_date: Optional[date] = cleaned_data.get("end")
 
         # Config is required, but field validation for 'config' should raise
         # validation error first.
-        if not config:
+        if not config or not person:
             return cleaned_data
 
         # Award required?
@@ -145,8 +147,13 @@ def clean(self) -> Union[dict[str, Any], None]:
                 ValidationError("Required when Reason for inactivation selected.")
             )
 
+        try:
+            self.check_concurrent_role(config, person, start_date, end_date)
+        except ValidationError as exc:
+            errors["person"].append(exc)
+
         if errors:
-            raise ValidationError(errors)
+            raise ValidationError(errors)  # type: ignore
 
         return cleaned_data
 
@@ -158,6 +165,32 @@ def clean_end(self):
             raise ValidationError("Must not be earlier than start date.")
         return end
 
+    def check_concurrent_role(
+        self,
+        config: Optional[CommunityRoleConfig] = None,
+        person: Optional[Person] = None,
+        start_date: Optional[date] = None,
+        end_date: Optional[date] = None,
+    ) -> Optional[Person]:
+        """Person should not have any concurrent Community Roles of the same type in the
+        same time."""
+        # These are required fields in the form, so they should be present.
+        if config and person and start_date:
+            same_time = Q(end__gt=start_date) | Q(end__isnull=True)
+            if end_date:
+                # if `end_date` is present, introduce additional condition
+                same_time &= Q(start__lt=end_date) | Q(start__isnull=True)
+            if roles := CommunityRole.objects.filter(
+                person=person, config=config
+            ).filter(same_time):
+                raise (
+                    ValidationError(
+                        f"Person {person} has concurrent community roles: "
+                        f"{list(roles)}."
+                    )
+                )
+        return person
+
 
 class CommunityRoleUpdateForm(CommunityRoleForm):
     config = forms.ModelChoiceField(
@@ -173,4 +206,16 @@ class Meta(CommunityRoleForm.Meta):
     def __init__(self, *args, community_role_config: CommunityRoleConfig, **kwargs):
         self.config = community_role_config
         super().__init__(*args, **kwargs)
-        self.fields["custom_keys"].apply_labels(self.config.custom_key_labels)
+        self.fields["custom_keys"].apply_labels(  # type: ignore
+            self.config.custom_key_labels
+        )
+
+    def check_concurrent_role(
+        self,
+        config: Optional[CommunityRoleConfig] = None,
+        person: Optional[Person] = None,
+        start_date: Optional[date] = None,
+        end_date: Optional[date] = None,
+    ) -> Optional[Person]:
+        """When updating a CommunityRole, we shouldn't check for concurrent roles."""
+        return person


Test patch:
diff --git a/amy/communityroles/tests/test_community_role_forms.py b/amy/communityroles/tests/test_community_role_forms.py
--- a/amy/communityroles/tests/test_community_role_forms.py
+++ b/amy/communityroles/tests/test_community_role_forms.py
@@ -368,6 +368,126 @@ def test_end_date_required_when_inactivation_selected(self):
             form.errors["end"], ["Required when Reason for inactivation selected."]
         )
 
+    def test_concurrent_community_roles_disallowed__validation_errors(self) -> None:
+        """"""
+        # Arrange
+        config = CommunityRoleConfig.objects.create(
+            name="test",
+            display_name="Test",
+            link_to_award=False,
+            link_to_membership=False,
+            additional_url=False,
+        )
+        # When the validation failures should occur - failure feed data.
+        failure_data = [
+            # community role start, end, form start, form end
+            (date(2022, 9, 17), date(2023, 9, 16), "2022-10-01", "2022-10-31"),
+            (date(2022, 9, 17), date(2023, 9, 16), "2022-01-01", "2022-10-31"),
+            (date(2022, 9, 17), date(2023, 9, 16), "2023-01-01", "2023-10-31"),
+            (date(2022, 9, 17), None, "2022-10-01", "2022-10-31"),
+            (date(2022, 9, 17), None, "2022-01-01", "2022-10-31"),
+            (date(2022, 9, 17), None, "2022-10-01", None),
+            (date(2022, 9, 17), None, "2022-01-01", None),
+            (None, None, "2022-01-01", None),
+        ]
+        for role_start, role_end, form_start, form_end in failure_data:
+            with self.subTest(
+                role_start=role_start,
+                role_end=role_end,
+                form_start=form_start,
+                form_end=form_end,
+            ):
+                # clear community roles if the test suite doesn't
+                CommunityRole.objects.filter(
+                    config=config, person=self.hermione
+                ).delete()
+
+                community_role = CommunityRole.objects.create(
+                    config=config,
+                    person=self.hermione,
+                    start=role_start,
+                    end=role_end,
+                )
+                form_data = {
+                    "config": config.pk,
+                    "person": self.hermione.pk,
+                    "award": self.award.pk,
+                    "start": form_start,
+                    "end": form_end,
+                    "inactivation": None,
+                    "membership": None,
+                    "url": "",
+                    "generic_relation_content_type": None,
+                    "generic_relation_pk": None,
+                }
+
+                # Act
+                form = CommunityRoleForm(form_data)
+                # Assert
+                self.assertFalse(form.is_valid())
+                self.assertEqual(form.errors.keys(), {"person"})
+                self.assertEqual(
+                    form.errors["person"],
+                    [
+                        f"Person {self.hermione} has concurrent community roles: "
+                        f"{[community_role]}."
+                    ],
+                )
+
+    def test_concurrent_community_roles_disallowed__validation_succeeds(self) -> None:
+        """"""
+        # Arrange
+        config = CommunityRoleConfig.objects.create(
+            name="test",
+            display_name="Test",
+            link_to_award=False,
+            link_to_membership=False,
+            additional_url=False,
+        )
+        # When the validation failures should NOT occur - pass feed data.
+        pass_data = [
+            # community role start, end, form start, form end
+            (date(2022, 9, 17), date(2023, 9, 16), "2023-09-16", "2023-09-30"),
+            (date(2022, 9, 17), date(2023, 9, 16), "2022-01-01", "2022-09-17"),
+            (None, date(2022, 9, 17), "2022-09-17", None),
+        ]
+        for role_start, role_end, form_start, form_end in pass_data:
+            with self.subTest(
+                role_start=role_start,
+                role_end=role_end,
+                form_start=form_start,
+                form_end=form_end,
+            ):
+                # clear community roles if the test suite doesn't
+                CommunityRole.objects.filter(
+                    config=config, person=self.hermione
+                ).delete()
+
+                CommunityRole.objects.create(
+                    config=config,
+                    person=self.hermione,
+                    start=role_start,
+                    end=role_end,
+                )
+                form_data = {
+                    "config": config.pk,
+                    "person": self.hermione.pk,
+                    "award": self.award.pk,
+                    "start": form_start,
+                    "end": form_end,
+                    "inactivation": None,
+                    "membership": None,
+                    "url": "",
+                    "generic_relation_content_type": None,
+                    "generic_relation_pk": None,
+                }
+
+                # Act
+                form = CommunityRoleForm(form_data)
+                # Assert
+                self.assertTrue(form.is_valid())
+                self.assertEqual(form.errors.keys(), set())
+
 
 class TestCommunityRoleUpdateForm(TestBase):
     def test_fields(self) -> None:
@@ -430,7 +550,7 @@ def test_custom_labels_applied(self) -> None:
         # Act
         form = CommunityRoleUpdateForm(community_role_config=config)
         # Assert
-        self.assertEqual(form.fields["custom_keys"].labels, labels)
+        self.assertEqual(form.fields["custom_keys"].labels, labels)  # type: ignore
 
     def test_empty_payload(self) -> None:
         # Arrange
diff --git a/amy/communityroles/tests/test_community_role_views.py b/amy/communityroles/tests/test_community_role_views.py
--- a/amy/communityroles/tests/test_community_role_views.py
+++ b/amy/communityroles/tests/test_community_role_views.py
@@ -31,6 +31,8 @@ def setUp(self):
         self.community_role = CommunityRole.objects.create(
             config=self.config,
             person=self.person,
+            start=date(2022, 1, 1),
+            end=date(2022, 1, 31),
         )
 
 
@@ -66,8 +68,8 @@ def setUp(self):
             "communityrole-config": self.config.pk,
             "communityrole-person": self.person.pk,
             "communityrole-award": award.pk,
-            "communityrole-start": date(2021, 11, 26),
-            "communityrole-end": date(2022, 11, 26),
+            "communityrole-start": date(2022, 11, 26),
+            "communityrole-end": date(2023, 11, 26),
             "communityrole-inactivation": self.inactivation.pk,
             "communityrole-membership": "",
             "communityrole-url": "http://example.org",