Repository: carpentries/amy

Issue:
Community Roles: Limit role to individual person's award
An individual Person's assigned role should be limited to what is allowed based on their own awards.

For example, currently if I try to give a role like "Instructor" to Person X, it lets me choose an award from a number of person awards.  This should be limited to only that Person's award.  We would not have a situation where Person X gets a role based on another Person's award.


Discussion:

Resolving patch:
diff --git a/amy/communityroles/forms.py b/amy/communityroles/forms.py
--- a/amy/communityroles/forms.py
+++ b/amy/communityroles/forms.py
@@ -1,11 +1,12 @@
 from collections import defaultdict
-from typing import Any, Optional
+from typing import Any, Optional, Union
 
 from django import forms
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 
 from workshops.fields import HeavySelect2Widget, ModelSelect2Widget
 from workshops.forms import SELECT2_SIDEBAR, BootstrapHelper, WidgetOverrideMixin
+from workshops.models import Award, Person
 
 from .fields import CustomKeysJSONField
 from .models import CommunityRole, CommunityRoleConfig
@@ -65,12 +66,17 @@ def __init__(self, *args, **kwargs):
         }
         self.helper = BootstrapHelper(**bootstrap_kwargs)
 
-    def clean(self) -> dict[str, Any]:
+    def clean(self) -> Union[dict[str, Any], None]:
         """Validate form according to rules set up in related Community Role
         configuration."""
         cleaned_data = super().clean()
+        if not cleaned_data:
+            return cleaned_data
+
         errors: defaultdict[str, list[ValidationError]] = defaultdict(list)
         config: Optional[CommunityRoleConfig] = cleaned_data.get("config")
+        award: Optional[Award] = cleaned_data.get("award")
+        person: Optional[Person] = cleaned_data.get("person")
 
         # Config is required, but field validation for 'config' should raise
         # validation error first.
@@ -78,13 +84,17 @@ def clean(self) -> dict[str, Any]:
             return cleaned_data
 
         # Award required?
-        if config.link_to_award and not cleaned_data.get("award"):
+        if config.link_to_award and not award:
             errors["award"].append(
                 ValidationError(f"Award is required with community role {config}")
             )
 
+        # Award should point at the same person the community role links to
+        if award and award.person != person:
+            errors["award"].append(ValidationError(f"Award should belong to {person}"))
+
         # Specific award badge required?
-        if (badge := config.award_badge_limit) and (award := cleaned_data.get("award")):
+        if (badge := config.award_badge_limit) and award:
             if award.badge != badge:
                 errors["award"].append(
                     ValidationError(
diff --git a/amy/workshops/lookups.py b/amy/workshops/lookups.py
--- a/amy/workshops/lookups.py
+++ b/amy/workshops/lookups.py
@@ -357,6 +357,9 @@ def get_queryset(self):
         if badge := self.request.GET.get("badge"):
             results = results.filter(badge__pk=badge)
 
+        if person := self.request.GET.get("person"):
+            results = results.filter(person__pk=person)
+
         if self.term:
             results = results.filter(
                 Q(person__personal__icontains=self.term)


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
@@ -13,8 +13,8 @@ class TestCommunityRoleForm(TestBase):
     def setUp(self):
         super().setUp()
 
-        test_badge = Badge.objects.create(name="test badge")
-        self.award = Award.objects.create(person=self.hermione, badge=test_badge)
+        self.test_badge = Badge.objects.create(name="test badge")
+        self.award = Award.objects.create(person=self.hermione, badge=self.test_badge)
         self.membership = Membership.objects.create(
             variant="partner",
             registration_code="test-beta-code-test",
@@ -104,6 +104,41 @@ def test_award_required(self):
             form.errors["award"], ["Award is required with community role Test"]
         )
 
+    def test_award_same_person_required(self):
+        # Arrange
+        test_config = CommunityRoleConfig.objects.create(
+            name="test",
+            display_name="Test",
+            link_to_award=True,
+            award_badge_limit=None,
+            link_to_membership=True,
+            additional_url=True,
+            generic_relation_content_type=None,
+        )
+        award = Award.objects.create(person=self.harry, badge=self.test_badge)
+        data = {
+            "config": test_config.pk,
+            "person": self.hermione.pk,
+            "award": award.pk,
+            "start": "2021-11-14",
+            "end": "2022-11-14",
+            "inactivation": None,
+            "membership": self.membership.pk,
+            "url": "https://example.org",
+            "generic_relation_content_type": None,
+            "generic_relation_pk": None,
+        }
+
+        # Act
+        form = CommunityRoleForm(data)
+
+        # Assert
+        self.assertFalse(form.is_valid())  # errors expected
+        self.assertEqual(form.errors.keys(), {"award"})
+        self.assertEqual(
+            form.errors["award"], [f"Award should belong to {self.hermione}"]
+        )
+
     def test_specific_award_badge_required(self):
         # Arrange
         test_config = CommunityRoleConfig.objects.create(
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
@@ -7,7 +7,7 @@
     CommunityRoleConfig,
     CommunityRoleInactivation,
 )
-from workshops.models import Award, Person
+from workshops.models import Award, Badge, Person
 from workshops.tests.base import TestBase
 
 
@@ -58,10 +58,14 @@ def setUp(self):
         super()._setUpBadges()
         super()._setUpInstructors()
         super()._setUpUsersAndLogin()
+        award = Award.objects.create(
+            badge=Badge.objects.first(),
+            person=self.person,
+        )
         self.data = {
             "communityrole-config": self.config.pk,
             "communityrole-person": self.person.pk,
-            "communityrole-award": Award.objects.first().pk,
+            "communityrole-award": award.pk,
             "communityrole-start": date(2021, 11, 26),
             "communityrole-end": date(2022, 11, 26),
             "communityrole-inactivation": self.inactivation.pk,
@@ -88,10 +92,14 @@ def setUp(self):
         super()._setUpBadges()
         super()._setUpInstructors()
         super()._setUpUsersAndLogin()
+        award = Award.objects.create(
+            badge=Badge.objects.first(),
+            person=self.person,
+        )
         self.data = {
             "communityrole-config": self.config.pk,
             "communityrole-person": self.person.pk,
-            "communityrole-award": Award.objects.first().pk,
+            "communityrole-award": award.pk,
             "communityrole-start": date(2021, 11, 26),
             "communityrole-end": date(2022, 11, 26),
             "communityrole-inactivation": self.inactivation.pk,