Repository: carpentries/amy

Issue:
TTT events - open/member seats for learners only
When assigning a seat in an instructor training event as member affiliated or open, this option should be available only to learners.  If you try to assign this for any other role, there should be an error.


Discussion:

Resolving patch:
diff --git a/amy/workshops/models.py b/amy/workshops/models.py
--- a/amy/workshops/models.py
+++ b/amy/workshops/models.py
@@ -158,8 +158,9 @@ class Membership(models.Model):
         blank=True,
         unique=True,
         verbose_name="Registration Code",
-        help_text="Unique registration code used for Eventbrite and trainee application."
-        )
+        help_text="Unique registration code used for Eventbrite and trainee "
+        "application.",
+    )
 
     def __str__(self):
         from workshops.util import human_daterange
@@ -179,10 +180,9 @@ def workshops_without_admin_fee_completed(self):
             administrator__domain="self-organized"
         )
         no_fee = Q(admin_fee=0) | Q(admin_fee=None)
-        date_started = (
-            Q(start__gte=self.agreement_start, start__lt=self.agreement_end)
-            & Q(start__lt=datetime.date.today())
-        )
+        date_started = Q(
+            start__gte=self.agreement_start, start__lt=self.agreement_end
+        ) & Q(start__lt=datetime.date.today())
         cancelled = Q(tags__name="cancelled") | Q(tags__name="stalled")
 
         return (
@@ -201,10 +201,9 @@ def workshops_without_admin_fee_planned(self):
             administrator__domain="self-organized"
         )
         no_fee = Q(admin_fee=0) | Q(admin_fee=None)
-        date_started = (
-            Q(start__gte=self.agreement_start, start__lt=self.agreement_end)
-            & Q(start__gte=datetime.date.today())
-        )
+        date_started = Q(
+            start__gte=self.agreement_start, start__lt=self.agreement_end
+        ) & Q(start__gte=datetime.date.today())
         cancelled = Q(tags__name="cancelled") | Q(tags__name="stalled")
 
         return (
@@ -233,10 +232,9 @@ def self_organized_workshops_completed(self):
         self_organized = Q(administrator=None) | Q(
             administrator__domain="self-organized"
         )
-        date_started = (
-            Q(start__gte=self.agreement_start, start__lt=self.agreement_end)
-            & Q(start__lt=datetime.date.today())
-        )
+        date_started = Q(
+            start__gte=self.agreement_start, start__lt=self.agreement_end
+        ) & Q(start__lt=datetime.date.today())
         cancelled = Q(tags__name="cancelled") | Q(tags__name="stalled")
 
         return (
@@ -254,10 +252,9 @@ def self_organized_workshops_planned(self):
         self_organized = Q(administrator=None) | Q(
             administrator__domain="self-organized"
         )
-        date_started = (
-            Q(start__gte=self.agreement_start, start__lt=self.agreement_end)
-            & Q(start__gte=datetime.date.today())
-        )
+        date_started = Q(
+            start__gte=self.agreement_start, start__lt=self.agreement_end
+        ) & Q(start__gte=datetime.date.today())
         cancelled = Q(tags__name="cancelled") | Q(tags__name="stalled")
 
         return (
@@ -1119,7 +1116,8 @@ class Event(AssignmentMixin, RQJobsMixin, models.Model):
         max_length=STR_LONGEST,
         default="",
         blank=True,
-        verbose_name="Additional people to contact")
+        verbose_name="Additional people to contact",
+    )
     country = CountryField(
         null=True,
         blank=True,
@@ -1484,6 +1482,13 @@ def clean(self):
                 code="invalid",
             )
 
+        if (
+            self.seat_membership or self.seat_open_training
+        ) and self.role.name != "learner":
+            errors["role"] = ValidationError(
+                "Seat (open / membership) can be assigned only to a workshop learner."
+            )
+
         if errors:
             raise ValidationError(errors)
 
@@ -2444,15 +2449,18 @@ class CommonRequest(SecondaryEmailMixin, models.Model):
     )
 
     ONLINE_INPERSON_CHOICES = (
-        ('online', 'Online'),
-        ('inperson', 'In-person'),
-        ('unsure', 'Not sure'),
+        ("online", "Online"),
+        ("inperson", "In-person"),
+        ("unsure", "Not sure"),
     )
     online_inperson = models.CharField(
         max_length=15,
         choices=ONLINE_INPERSON_CHOICES,
-        blank=False, null=False, default="",
-        verbose_name="Will this workshop be held online or in-person?")
+        blank=False,
+        null=False,
+        default="",
+        verbose_name="Will this workshop be held online or in-person?",
+    )
 
     WORKSHOP_LISTED_CHOICES = (
         (True, "Yes"),
@@ -2468,7 +2476,7 @@ class CommonRequest(SecondaryEmailMixin, models.Model):
         'websites: <a href="https://carpentries.org/">The Carpentries</a>,'
         ' <a href="https://datacarpentry.org/">Data Carpentry</a>,'
         ' <a href="https://software-carpentry.org/">Software Carpentry</a>,'
-        ' <a href="https://librarycarpentry.org/">Library Carpentry</a>.'
+        ' <a href="https://librarycarpentry.org/">Library Carpentry</a>.',
     )
     PUBLIC_EVENT_CHOICES = (
         ("public", "This event is open to the public."),
@@ -2654,8 +2662,7 @@ class WorkshopRequest(
         "For online Carpentries workshops, we recommend a maximum of "
         "20 learners per class. If your workshop attendance will "
         "exceed 20 learners please be sure to include a note in the "
-        "comments section below. "
-        ,
+        "comments section below. ",
     )
     # MISSING
     # This field is no longer needed, and should be hidden in the form and


Test patch:
diff --git a/amy/workshops/tests/test_tasks.py b/amy/workshops/tests/test_tasks.py
--- a/amy/workshops/tests/test_tasks.py
+++ b/amy/workshops/tests/test_tasks.py
@@ -61,10 +61,10 @@ def setUp(self):
             start=datetime.now(), slug="test_event_3", host=test_host, admin_fee=0
         )
 
-        instructor_role = Role.objects.get(name="instructor")
+        self.instructor = Role.objects.get(name="instructor")
         self.learner = Role.objects.get(name="learner")
-        helper_role = Role.objects.get(name="helper")
-        roles = [instructor_role, self.learner, helper_role]
+        self.helper = Role.objects.get(name="helper")
+        roles = [self.instructor, self.learner, self.helper]
         people = [self.test_person_1, self.test_person_2]
 
         for role, person in product(roles, people):
@@ -303,6 +303,55 @@ def test_both_open_app_and_seat(self):
         self.assertNotIn("seat_membership", exception.error_dict)
         self.assertNotIn("seat_open_training", exception.error_dict)
 
+    def test_seats_for_learners_only(self):
+        """Ensure that only learners can be assigned seats."""
+
+        # first wrong task
+        task1 = Task(
+            event=self.ttt_event_open,
+            person=self.test_person_1,
+            role=self.instructor,
+            seat_membership=self.membership,
+            seat_open_training=False,
+        )
+        # second wrong task
+        task2 = Task(
+            event=self.ttt_event_open,
+            person=self.test_person_2,
+            role=self.helper,
+            seat_membership=None,
+            seat_open_training=True,
+        )
+
+        with self.assertRaises(ValidationError) as cm:
+            task1.full_clean()
+        exception = cm.exception
+        self.assertEqual({"role"}, exception.error_dict.keys())
+
+        with self.assertRaises(ValidationError) as cm:
+            task2.full_clean()
+        exception = cm.exception
+        self.assertEqual({"role"}, exception.error_dict.keys())
+
+        # first good task
+        task3 = Task(
+            event=self.ttt_event_open,
+            person=self.test_person_2,
+            role=self.learner,
+            seat_membership=self.membership,
+            seat_open_training=False,
+        )
+        # second good task
+        task4 = Task(
+            event=self.ttt_event_open,
+            person=self.test_person_2,
+            role=self.learner,
+            seat_membership=None,
+            seat_open_training=True,
+        )
+        task3.full_clean()
+        task4.full_clean()
+
 
 class TestTaskCreateNewInstructor(FakeRedisTestCaseMixin, SuperuserMixin, TestCase):
     def setUp(self):