diff --git a/amy/api/v2/serializers.py b/amy/api/v2/serializers.py index 11305ca21..3b2cae2ec 100644 --- a/amy/api/v2/serializers.py +++ b/amy/api/v2/serializers.py @@ -174,6 +174,9 @@ class MembershipSerializer(serializers.ModelSerializer): inhouse_instructor_training_seats_total = serializers.IntegerField() inhouse_instructor_training_seats_utilized = serializers.IntegerField() inhouse_instructor_training_seats_remaining = serializers.IntegerField() + cldt_seats_total = serializers.IntegerField() + cldt_seats_utilized = serializers.IntegerField() + cldt_seats_remaining = serializers.IntegerField() class Meta: model = Membership @@ -220,6 +223,9 @@ class Meta: "inhouse_instructor_training_seats_total", "inhouse_instructor_training_seats_utilized", "inhouse_instructor_training_seats_remaining", + "cldt_seats_total", + "cldt_seats_utilized", + "cldt_seats_remaining", ) diff --git a/amy/extrequests/utils.py b/amy/extrequests/utils.py index 6d08d0cab..57a116095 100644 --- a/amy/extrequests/utils.py +++ b/amy/extrequests/utils.py @@ -152,6 +152,13 @@ def get_membership_warnings_after_match( "it's been allowed.", ) + cldt_remaining = membership.cldt_seats_remaining + if cldt_remaining <= 0: + warnings.append( + f'Membership "{membership}" is using more CLDT seats than ' + "it's been allowed.", + ) + # check if membership is active if not (membership.agreement_start <= date.today() <= membership.agreement_end): warnings.append( diff --git a/amy/fiscal/forms.py b/amy/fiscal/forms.py index 20f2686e7..fcdb4bcc4 100644 --- a/amy/fiscal/forms.py +++ b/amy/fiscal/forms.py @@ -120,6 +120,8 @@ class Meta: "additional_public_instructor_training_seats", "inhouse_instructor_training_seats", "additional_inhouse_instructor_training_seats", + "cldt_seats", + "additional_cldt_seats", "emergency_contact", "workshops_without_admin_fee_rolled_over", "workshops_without_admin_fee_rolled_from_previous", @@ -127,6 +129,8 @@ class Meta: "public_instructor_training_seats_rolled_from_previous", "inhouse_instructor_training_seats_rolled_over", "inhouse_instructor_training_seats_rolled_from_previous", + "cldt_seats_rolled_over", + "cldt_seats_rolled_from_previous", ] def __init__( @@ -145,10 +149,12 @@ def __init__( del self.fields["workshops_without_admin_fee_rolled_over"] del self.fields["public_instructor_training_seats_rolled_over"] del self.fields["inhouse_instructor_training_seats_rolled_over"] + del self.fields["cldt_seats_rolled_over"] if not show_rolled_from_previous: del self.fields["workshops_without_admin_fee_rolled_from_previous"] del self.fields["public_instructor_training_seats_rolled_from_previous"] del self.fields["inhouse_instructor_training_seats_rolled_from_previous"] + del self.fields["cldt_seats_rolled_from_previous"] # set up a layout object for the helper self.helper.layout = self.helper.build_default_layout(self) @@ -288,6 +294,9 @@ class Meta(MembershipCreateForm.Meta): "inhouse_instructor_training_seats", "additional_inhouse_instructor_training_seats", "inhouse_instructor_training_seats_rolled_from_previous", + "cldt_seats", + "additional_cldt_seats", + "cldt_seats_rolled_from_previous", "emergency_contact", "comment", ] @@ -303,6 +312,7 @@ def __init__(self, *args, **kwargs): "workshops_without_admin_fee_rolled_from_previous", "public_instructor_training_seats_rolled_from_previous", "inhouse_instructor_training_seats_rolled_from_previous", + "cldt_seats_rolled_from_previous", ] for field in fields: self[field].field.min_value = 0 diff --git a/amy/fiscal/views.py b/amy/fiscal/views.py index e4785f548..ff939b790 100644 --- a/amy/fiscal/views.py +++ b/amy/fiscal/views.py @@ -287,6 +287,10 @@ def form_valid(self, form): "inhouse_instructor_training_seats_rolled_over", "inhouse_instructor_training_seats_rolled_from_previous", ), + ( + "cldt_seats_rolled_over", + "cldt_seats_rolled_from_previous", + ), ) save_rolled_to = False try: @@ -584,6 +588,9 @@ def get_initial(self) -> Dict[str, Any]: "inhouse_instructor_training_seats": self.membership.inhouse_instructor_training_seats, # noqa "additional_inhouse_instructor_training_seats": self.membership.additional_inhouse_instructor_training_seats, # noqa "inhouse_instructor_training_seats_rolled_from_previous": 0, + "cldt_seats": self.membership.cldt_seats, # noqa + "additional_cldt_seats": self.membership.additional_cldt_seats, # noqa + "cldt_seats_rolled_from_previous": 0, "emergency_contact": self.membership.emergency_contact, } @@ -599,6 +606,9 @@ def get_form_kwargs(self) -> Dict[str, Any]: "inhouse_instructor_training_seats_rolled_from_previous": max( self.membership.inhouse_instructor_training_seats_remaining, 0 ), + "cldt_seats_rolled_from_previous": max( + self.membership.cldt_seats_remaining, 0 + ), } return { "max_values": max_values, @@ -623,6 +633,9 @@ def form_valid(self, form): self.membership.inhouse_instructor_training_seats_rolled_over = ( form.instance.inhouse_instructor_training_seats_rolled_from_previous ) + self.membership.cldt_seats_rolled_over = ( + form.instance.cldt_seats_rolled_from_previous + ) self.membership.rolled_to_membership = self.object self.membership.save() diff --git a/amy/reports/views.py b/amy/reports/views.py index 38b8fca07..11dfa34ec 100644 --- a/amy/reports/views.py +++ b/amy/reports/views.py @@ -143,12 +143,13 @@ def instructor_issues(request): airport__isnull=True ) - # Everyone who's been in instructor training but doesn't yet have a badge. + # Everyone who's been in instructor training or CLDT but doesn't yet have a badge. learner = Role.objects.get(name="learner") ttt = Tag.objects.get(name="TTT") + cldt = Tag.objects.get(name="CLDT") stalled = Tag.objects.get(name="stalled") trainees = ( - Task.objects.filter(event__tags__in=[ttt], role=learner) + Task.objects.filter(event__tags__in=[ttt, cldt], role=learner) .exclude(person__badges__in=instructor_badges) .order_by("person__family", "person__personal", "event__start") .select_related("person", "event") diff --git a/amy/templates/fiscal/all_memberships.html b/amy/templates/fiscal/all_memberships.html index a99c6e1c2..77ff93760 100644 --- a/amy/templates/fiscal/all_memberships.html +++ b/amy/templates/fiscal/all_memberships.html @@ -17,7 +17,8 @@ Variant Dates Contribution - Remaining instructor training seats + IT seats + CLDT seats {% for membership in all_memberships %} @@ -41,7 +42,10 @@ {{ membership.agreement_start|date:"Y-m-d" }} — {{ membership.agreement_end|date:"Y-m-d" }} {{ membership.get_contribution_type_display }} 0 or membership.instructor_training_seats_remaining < 0 and membership.instructor_training_seats_total == 0 %}class="table-danger"{% endif %}> - {{ membership.instructor_training_seats_remaining }} + {{ membership.instructor_training_seats_remaining }} / {{ membership.instructor_training_seats_total }} + + 0 or membership.cldt_seats_remaining_annotation < 0 and membership.cldt_seats_total_annotation == 0 %}class="table-danger"{% endif %}> + {{ membership.cldt_seats_remaining_annotation }} / {{ membership.cldt_seats_total_annotation }} diff --git a/amy/templates/fiscal/membership.html b/amy/templates/fiscal/membership.html index 207d36a74..b63c76b57 100644 --- a/amy/templates/fiscal/membership.html +++ b/amy/templates/fiscal/membership.html @@ -168,6 +168,30 @@

+ + CLDT seats + Allowed: + + {{ membership.cldt_seats_total }} +

+ Includes {{ membership.additional_cldt_seats }} additional seats.
+ Includes {{ membership.cldt_seats_rolled_from_previous|default_if_none:0 }} seats rolled from previous membership. +

+ + + + Utilized: + {{ membership.cldt_seats_utilized }} + + 0 or membership.cldt_seats_remaining < 0 and membership.cldt_seats_total == 0 %}class="table-danger"{% endif %}> + Remaining: + + {{ membership.cldt_seats_remaining }} +

+ {{ membership.cldt_seats_rolled_over|default:"None" }} were rolled over to following membership. +

+ + Instructor training seats: diff --git a/amy/templates/fiscal/organization.html b/amy/templates/fiscal/organization.html index 2103248a2..423014aac 100644 --- a/amy/templates/fiscal/organization.html +++ b/amy/templates/fiscal/organization.html @@ -115,6 +115,7 @@

Memberships

Self-organised workshops Public instructor training seats In-house instructor training seats + CLDT seats @@ -137,6 +138,10 @@

Memberships

Allowed Utilized Remaining + + Allowed + Utilized + Remaining {% for membership in organization.memberships.all %} @@ -203,6 +208,24 @@

Memberships

Inc. {{ membership.inhouse_instructor_training_seats_rolled_over|default:0 }} rolled over

+ + + {{ membership.cldt_seats_total|default_if_none:"—" }} +

+ Inc. + {{ membership.additional_cldt_seats|default:0 }} additional, + {{ membership.cldt_seats_rolled_from_previous|default:0 }} rolled from prev. +

+ + {{ membership.cldt_seats_utilized|default_if_none:"—" }} + 0 or membership.cldt_seats_remaining < 0 and membership.cldt_seats_total == 0 %}class="table-danger"{% endif %} + > + {{ membership.cldt_seats_remaining }} +

+ Inc. {{ membership.cldt_seats_rolled_over|default:0 }} rolled over +

+ diff --git a/amy/templates/reports/membership_trainings_stats.html b/amy/templates/reports/membership_trainings_stats.html index 844ffddd8..38f029b20 100644 --- a/amy/templates/reports/membership_trainings_stats.html +++ b/amy/templates/reports/membership_trainings_stats.html @@ -16,12 +16,16 @@ Agreement Contribution Instructor training seats (combined public and in-house) + CLDT seats Total Utilized Remaining + Total + Utilized + Remaining {% for result in data %} @@ -35,6 +39,9 @@ {{ result.instructor_training_seats_total }} {{ result.instructor_training_seats_utilized }} {{ result.instructor_training_seats_remaining }} + {{ result.cldt_seats_total_annotation }} + {{ result.cldt_seats_utilized_annotation }} + {{ result.cldt_seats_remaining_annotation }} diff --git a/amy/workshops/consts.py b/amy/workshops/consts.py index 62cbc4c8a..833c3cb60 100644 --- a/amy/workshops/consts.py +++ b/amy/workshops/consts.py @@ -5,3 +5,6 @@ STR_LONG = 100 # length of long strings STR_LONGEST = 255 # length of the longest strings STR_REG_KEY = 20 # length of Eventbrite registration key + +CLDT_TAG_NAME = ["CLDT"] +TTT_TAG_NAMES = ["TTT", "ITT"] \ No newline at end of file diff --git a/amy/workshops/management/commands/fake_database.py b/amy/workshops/management/commands/fake_database.py index b7f3c1f9c..5b5738cb4 100644 --- a/amy/workshops/management/commands/fake_database.py +++ b/amy/workshops/management/commands/fake_database.py @@ -182,6 +182,7 @@ def fake_tags(self): ("LSO", 170, "Lesson Specific Onboarding"), ("hackathon", 180, "Event is a hackathon"), ("WiSE", 190, "Women in Science and Engineering"), + ("CLDT", 200, "Collaborative Lesson Development Training"), ] self.stdout.write("Generating {} fake tags...".format(len(tags))) diff --git a/amy/workshops/migrations/0271_event_open_cldt_applications_and_more.py b/amy/workshops/migrations/0271_event_open_cldt_applications_and_more.py new file mode 100644 index 000000000..d99d9d679 --- /dev/null +++ b/amy/workshops/migrations/0271_event_open_cldt_applications_and_more.py @@ -0,0 +1,70 @@ +# Generated by Django 4.2.13 on 2024-08-21 10:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("workshops", "0270_alter_organization_affiliated_organizations"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="open_CLDT_applications", + field=models.BooleanField( + blank=True, + default=False, + help_text="If this event is CLDT, you can mark it as 'open applications' which means that people not associated with this event's member sites can also take part in this event.", + verbose_name="CLDT Open applications", + ), + ), + migrations.AddField( + model_name="membership", + name="additional_cldt_seats", + field=models.PositiveIntegerField( + default=0, + help_text="Use this field if you want to grant more CLDT seats than the agreement provides for.", + verbose_name="Additional CLDT seats", + ), + ), + migrations.AddField( + model_name="membership", + name="cldt_seats", + field=models.PositiveIntegerField( + default=0, + help_text="Number of CLDT seats", + verbose_name="Collaborative Lesson Development Training seats", + ), + ), + migrations.AddField( + model_name="membership", + name="cldt_seats_rolled_from_previous", + field=models.PositiveIntegerField( + blank=True, + help_text="CLDT seats rolled over from previous membership.", + null=True, + ), + ), + migrations.AddField( + model_name="membership", + name="cldt_seats_rolled_over", + field=models.PositiveIntegerField( + blank=True, + help_text="CLDT seats rolled over into next membership.", + null=True, + ), + ), + migrations.AlterField( + model_name="membership", + name="public_status", + field=models.CharField( + choices=[("public", "Public"), ("private", "Private")], + default="private", + help_text="Public memberships may be listed on any of The Carpentries websites.", + max_length=20, + verbose_name="Can this membership be publicized on The Carpentries websites?", + ), + ), + ] diff --git a/amy/workshops/models.py b/amy/workshops/models.py index 664120aed..59909e3ac 100644 --- a/amy/workshops/models.py +++ b/amy/workshops/models.py @@ -44,6 +44,8 @@ STR_MED, STR_REG_KEY, STR_SHORT, + CLDT_TAG_NAME, + TTT_TAG_NAMES ) from workshops.fields import NullableGithubUsernameField, choice_field_with_other from workshops.mixins import ( @@ -140,6 +142,12 @@ class Meta: class MembershipManager(models.Manager): def annotate_with_seat_usage(self): + # cldt_tag = Tag.objects.get(name="CLDT") + # # TTT/ITT included + # ttt_tags = Tag.objects.exclude(name__in=["SWC", "DC", "LC", "WiSE", "CLDT"]) + # cldt_tag_name = "CLDT" + # ttt_tag_names = ["TTT", "ITT"] + return self.get_queryset().annotate( instructor_training_seats_total=( # Public @@ -153,7 +161,12 @@ def annotate_with_seat_usage(self): + Coalesce("inhouse_instructor_training_seats_rolled_from_previous", 0) ), instructor_training_seats_utilized=( - Count("task", filter=Q(task__role__name="learner")) + Count( + "task", + filter=Q( + task__role__name="learner", + task__event__tags__name__in=TTT_TAG_NAMES), + ) ), instructor_training_seats_remaining=( # Public @@ -162,7 +175,12 @@ def annotate_with_seat_usage(self): # Coalesce returns first non-NULL value + Coalesce("public_instructor_training_seats_rolled_from_previous", 0) - Count( - "task", filter=Q(task__role__name="learner", task__seat_public=True) + "task", + filter=Q( + task__role__name="learner", + task__seat_public=True, + task__event__tags__name__in=TTT_TAG_NAMES, + ), ) - Coalesce("public_instructor_training_seats_rolled_over", 0) # Inhouse @@ -171,10 +189,45 @@ def annotate_with_seat_usage(self): + Coalesce("inhouse_instructor_training_seats_rolled_from_previous", 0) - Count( "task", - filter=Q(task__role__name="learner", task__seat_public=False), + filter=Q( + task__role__name="learner", + task__seat_public=False, + task__event__tags__name__in=TTT_TAG_NAMES, + ), ) - Coalesce("inhouse_instructor_training_seats_rolled_over", 0) ), + # CLDT + cldt_seats_total_annotation=( + F("cldt_seats") + + F("additional_cldt_seats") + # Coalesce returns first non-NULL value + + Coalesce("cldt_seats_rolled_from_previous", 0) + ), + cldt_seats_utilized_annotation=( + Count( + "task", + filter=Q( + task__role__name="learner", + task__seat_public=False, + task__event__tags__name__in=CLDT_TAG_NAME), + ) + ), + cldt_seats_remaining_annotation=( + F("cldt_seats") + + F("additional_cldt_seats") + # Coalesce returns first non-NULL value + + Coalesce("cldt_seats_rolled_from_previous", 0) + - Count( + "task", + filter=Q( + task__role__name="learner", + task__seat_public=False, + task__event__tags__name__in=CLDT_TAG_NAME, + ), + ) + - Coalesce("cldt_seats_rolled_over", 0) + ), ) @@ -291,6 +344,34 @@ class Membership(models.Model): blank=True, help_text="In-house instructor training seats rolled over into next membership.", # noqa ) + + # CLDT + cldt_seats = models.PositiveIntegerField( + null=False, + blank=False, + default=0, + verbose_name="Collaborative Lesson Development Training seats", + help_text="Number of CLDT seats", + ) + additional_cldt_seats = models.PositiveIntegerField( + null=False, + blank=False, + default=0, + verbose_name="Additional CLDT seats", + help_text="Use this field if you want to grant more CLDT seats than " + "the agreement provides for.", + ) + cldt_seats_rolled_from_previous = models.PositiveIntegerField( + null=True, + blank=True, + help_text="CLDT seats rolled over from previous membership.", + ) + cldt_seats_rolled_over = models.PositiveIntegerField( + null=True, + blank=True, + help_text="CLDT seats rolled over into next membership.", + ) + organizations = models.ManyToManyField( Organization, blank=False, @@ -323,7 +404,7 @@ class Membership(models.Model): max_length=20, choices=PUBLIC_STATUS_CHOICES, default=PUBLIC_STATUS_CHOICES[1][0], - verbose_name="Can this membership be publicized on The carpentries websites?", + verbose_name="Can this membership be publicized on The Carpentries websites?", help_text="Public memberships may be listed on any of The Carpentries " "websites.", ) @@ -525,7 +606,10 @@ def public_instructor_training_seats_total(self) -> int: @cached_property def public_instructor_training_seats_utilized(self) -> int: """Count number of learner tasks that point to this membership.""" - return self.task_set.filter(role__name="learner", seat_public=True).count() + return self.task_set.filter( + role__name="learner", + event__tags__name__in=TTT_TAG_NAMES, + seat_public=True).count() @property def public_instructor_training_seats_remaining(self) -> int: @@ -550,7 +634,10 @@ def inhouse_instructor_training_seats_total(self) -> int: @cached_property def inhouse_instructor_training_seats_utilized(self) -> int: """Count number of learner tasks that point to this membership.""" - return self.task_set.filter(role__name="learner", seat_public=False).count() + return self.task_set.filter( + role__name="learner", + event__tags__name__in=TTT_TAG_NAMES, + seat_public=False).count() @property def inhouse_instructor_training_seats_remaining(self) -> int: @@ -560,6 +647,34 @@ def inhouse_instructor_training_seats_remaining(self) -> int: c = self.inhouse_instructor_training_seats_rolled_over or 0 return a - b - c + @property + def cldt_seats_total(self) -> int: + """Calculate combined CLDT seats total. + + Unlike workshops w/o admin fee, CLDT seats have two numbers + combined to calculate total of allowed seats in CLDT events. + """ + a = self.cldt_seats + b = self.additional_cldt_seats + c = self.cldt_seats_rolled_from_previous or 0 + return a + b + c + + @cached_property + def cldt_seats_utilized(self) -> int: + """Count number of learner tasks that point to this membership.""" + return self.task_set.filter( + role__name="learner", + event__tags__name__in=CLDT_TAG_NAME, + seat_public=False).count() + + @property + def cldt_seats_remaining(self) -> int: + """Count remaining seats for CLDT.""" + a = self.cldt_seats_total + b = self.cldt_seats_utilized + c = self.cldt_seats_rolled_over or 0 + return a - b - c + # ------------------------------------------------------------ @@ -1075,8 +1190,8 @@ def archive(self) -> None: class TagQuerySet(QuerySet): - CARPENTRIES_TAG_NAMES = ["SWC", "DC", "LC"] - MAIN_TAG_NAMES = ["SWC", "DC", "LC", "TTT", "ITT", "WiSE"] + CARPENTRIES_TAG_NAMES = ["SWC", "DC", "LC", "CLDT"] + MAIN_TAG_NAMES = ["SWC", "DC", "LC", "TTT", "ITT", "WiSE", "CLDT"] def main_tags(self): return self.filter(name__in=self.MAIN_TAG_NAMES) @@ -1484,6 +1599,16 @@ class Event(AssignmentMixin, RQJobsMixin, models.Model): "this event's member sites can also take part in this event.", ) + open_CLDT_applications = models.BooleanField( + null=False, + blank=True, + default=False, + verbose_name="CLDT Open applications", + help_text="If this event is CLDT, you can mark it as 'open " + "applications' which means that people not associated with " + "this event's member sites can also take part in this event.", + ) + # taught curriculum information curricula = models.ManyToManyField( "Curriculum", @@ -1609,9 +1734,9 @@ def clean(self): has_TTT = self.tags.filter(name="TTT") if self.open_TTT_applications and not has_TTT: - errors[ - "open_TTT_applications" - ] = "You cannot open applications on non-TTT event." + errors["open_TTT_applications"] = ( + "You cannot open applications on non-TTT event." + ) if errors: raise ValidationError(errors) diff --git a/amy/workshops/tests/test_tasks.py b/amy/workshops/tests/test_tasks.py index 5f3a40ce3..cd99eb5b0 100644 --- a/amy/workshops/tests/test_tasks.py +++ b/amy/workshops/tests/test_tasks.py @@ -104,6 +104,20 @@ def setUp(self): ) self.ttt_event_non_open.tags.set(Tag.objects.filter(name__in=["DC", "TTT"])) + # CLDT + self.cldt_event = Event.objects.create( + start=datetime.now(), + slug="cldt-event", + host=test_host, + ) + self.cldt_event.tags.set(Tag.objects.filter(name__in=["CLDT"])) + self.cldt_event_open = Event.objects.create( + start=datetime.now(), + slug="cldt-event-open-app", + host=test_host, + open_CLDT_applications=True, + ) + # create a membership self.membership = Membership.objects.create( variant="partner", @@ -112,6 +126,7 @@ def setUp(self): contribution_type="financial", public_instructor_training_seats=1, inhouse_instructor_training_seats=1, + cldt_seats=1, ) Member.objects.create( membership=self.membership, @@ -285,14 +300,23 @@ def test_no_remaining_seats_warnings_when_adding(self): "task-seat_membership": self.membership.pk, "task-seat_public": False, } + data3 = { + "task-event": self.cldt_event_open.pk, + "task-person": self.test_person_1.pk, + "task-role": self.learner.pk, + "task-seat_membership": self.membership.pk, + "task-seat_public": True, + } # Act response1 = self.client.post(reverse("task_add"), data1, follow=True) response2 = self.client.post(reverse("task_add"), data2, follow=True) + response3 = self.client.post(reverse("task_add"), data3, follow=True) # Assert self.assertEqual(response1.status_code, 200) self.assertEqual(response2.status_code, 200) + self.assertEqual(response3.status_code, 200) self.assertContains( response1, f"Membership "{self.membership}" has no public instructor " @@ -303,6 +327,11 @@ def test_no_remaining_seats_warnings_when_adding(self): f"Membership "{self.membership}" has no in-house instructor " "training seats remaining.", ) + self.assertContains( + response3, + f"Membership "{self.membership}" has no CLDT " + "seats remaining.", + ) def test_exceeded_seats_warnings_when_adding(self): """Ensure warnings about memberships with exceeded instructor training @@ -310,6 +339,7 @@ def test_exceeded_seats_warnings_when_adding(self): # Arrange self.membership.public_instructor_training_seats = 0 self.membership.inhouse_instructor_training_seats = 0 + self.membership.cldt_seats = 0 self.membership.save() data1 = { @@ -326,14 +356,23 @@ def test_exceeded_seats_warnings_when_adding(self): "task-seat_membership": self.membership.pk, "task-seat_public": False, } + data3 = { + "task-event": self.cldt_event_open.pk, + "task-person": self.test_person_1.pk, + "task-role": self.learner.pk, + "task-seat_membership": self.membership.pk, + "task-seat_public": True, + } # Act response1 = self.client.post(reverse("task_add"), data1, follow=True) response2 = self.client.post(reverse("task_add"), data2, follow=True) + response3 = self.client.post(reverse("task_add"), data3, follow=True) # Assert self.assertEqual(response1.status_code, 200) self.assertEqual(response2.status_code, 200) + self.assertEqual(response3.status_code, 200) self.assertContains( response1, f"Membership "{self.membership}" is using more public " @@ -344,13 +383,19 @@ def test_exceeded_seats_warnings_when_adding(self): f"Membership "{self.membership}" is using more in-house " "training seats than it's been allowed.", ) + self.assertContains( + response3, + f"Membership "{self.membership}" is using more CLDT " + "seats than it's been allowed.", + ) def test_no_remaining_seats_warnings_when_updating(self): """Ensure warnings about memberships with no remaining instructor training seats appear when existing tasks are edited.""" # Arrange - # `self.membership` is set up with only 1 seat for both public - # and in-house instructor training seats + # `self.membership` is set up with only 1 seat for + # public and in-house instructor training seats, + # and 1 seat for CLDT task1 = Task.objects.create( event=self.ttt_event_open, @@ -366,6 +411,13 @@ def test_no_remaining_seats_warnings_when_updating(self): seat_membership=self.membership, seat_public=False, ) + task3 = Task.objects.create( + event=self.cldt_event_open, + person=self.test_person_1, + role=self.learner, + seat_membership=self.membership, + seat_public=True, + ) data1 = { "event": task1.event.pk, @@ -381,6 +433,13 @@ def test_no_remaining_seats_warnings_when_updating(self): "seat_membership": task2.seat_membership.pk, "seat_public": task2.seat_public, } + data3 = { + "event": task3.event.pk, + "person": task3.person.pk, + "role": task3.role.pk, + "seat_membership": task3.seat_membership.pk, + "seat_public": task3.seat_public, + } # Act response1 = self.client.post( @@ -389,10 +448,14 @@ def test_no_remaining_seats_warnings_when_updating(self): response2 = self.client.post( reverse("task_edit", args=[task2.pk]), data2, follow=True ) + response3 = self.client.post( + reverse("task_edit", args=[task3.pk]), data3, follow=True + ) # Assert self.assertEqual(response1.status_code, 200) self.assertEqual(response2.status_code, 200) + self.assertEqual(response3.status_code, 200) self.assertContains( response1, f"Membership "{self.membership}" has no public instructor " @@ -403,6 +466,11 @@ def test_no_remaining_seats_warnings_when_updating(self): f"Membership "{self.membership}" has no in-house instructor " "training seats remaining.", ) + self.assertContains( + response3, + f"Membership "{self.membership}" has no CLDT " + "seats remaining.", + ) def test_exceeded_seats_warnings_when_updating(self): """Ensure warnings about memberships with exceeded instructor training @@ -410,6 +478,7 @@ def test_exceeded_seats_warnings_when_updating(self): # Arrange self.membership.public_instructor_training_seats = 0 self.membership.inhouse_instructor_training_seats = 0 + self.membership.cldt_seats = 0 self.membership.save() task1 = Task.objects.create( @@ -426,6 +495,13 @@ def test_exceeded_seats_warnings_when_updating(self): seat_membership=self.membership, seat_public=False, ) + task3 = Task.objects.create( + event=self.cldt_event_open, + person=self.test_person_1, + role=self.learner, + seat_membership=self.membership, + seat_public=True, + ) data1 = { "event": task1.event.pk, @@ -441,6 +517,13 @@ def test_exceeded_seats_warnings_when_updating(self): "seat_membership": task2.seat_membership.pk, "seat_public": task2.seat_public, } + data3 = { + "event": task3.event.pk, + "person": task3.person.pk, + "role": task3.role.pk, + "seat_membership": task3.seat_membership.pk, + "seat_public": task3.seat_public, + } # Act response1 = self.client.post( @@ -449,10 +532,14 @@ def test_exceeded_seats_warnings_when_updating(self): response2 = self.client.post( reverse("task_edit", args=[task2.pk]), data2, follow=True ) + response3 = self.client.post( + reverse("task_edit", args=[task3.pk]), data3, follow=True + ) # Assert self.assertEqual(response1.status_code, 200) self.assertEqual(response2.status_code, 200) + self.assertEqual(response3.status_code, 200) self.assertContains( response1, f"Membership "{self.membership}" is using more public " @@ -463,6 +550,11 @@ def test_exceeded_seats_warnings_when_updating(self): f"Membership "{self.membership}" is using more in-house " "training seats than it's been allowed.", ) + self.assertContains( + response3, + f"Membership "{self.membership}" is using more CLDT " + "seats than it's been allowed.", + ) def test_open_applications_TTT(self): """Ensure events with TTT tag but without open application flag raise @@ -529,6 +621,14 @@ def test_seats_for_learners_only(self): seat_membership=None, seat_open_training=True, ) + # third wrong task + task3 = Task( + event=self.cldt_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() @@ -540,8 +640,13 @@ def test_seats_for_learners_only(self): exception = cm.exception self.assertEqual({"role"}, exception.error_dict.keys()) + with self.assertRaises(ValidationError) as cm: + task3.full_clean() + exception = cm.exception + self.assertEqual({"role"}, exception.error_dict.keys()) + # first good task - task3 = Task( + task4 = Task( event=self.ttt_event_open, person=self.test_person_2, role=self.learner, @@ -549,12 +654,28 @@ def test_seats_for_learners_only(self): seat_open_training=False, ) # second good task - task4 = Task( + task5 = Task( event=self.ttt_event_open, person=self.test_person_2, role=self.learner, seat_membership=None, seat_open_training=True, ) - task3.full_clean() + task6 = Task( + event=self.cldt_event_open, + person=self.test_person_2, + role=self.learner, + seat_membership=self.membership, + seat_open_training=False, + ) + task7 = Task( + event=self.cldt_event_open, + person=self.test_person_2, + role=self.learner, + seat_membership=None, + seat_open_training=True, + ) task4.full_clean() + task5.full_clean() + task6.full_clean() + task7.full_clean() diff --git a/amy/workshops/views.py b/amy/workshops/views.py index 4478c3c7e..47585be6f 100644 --- a/amy/workshops/views.py +++ b/amy/workshops/views.py @@ -1622,6 +1622,25 @@ def form_valid(self, form): "it's been allowed.", ) + # CLDT + cldt_remaining = ( + seat_membership.cldt_seats_remaining + ) + # check number of available seats + if cldt_remaining == 1: + messages.warning( + self.request, + # after the form is saved there will be 0 remaining seats + f'Membership "{seat_membership}" has no CLDT ' + "seats remaining.", + ) + if cldt_remaining <= 0: + messages.warning( + self.request, + f'Membership "{seat_membership}" is using more CLDT ' + "seats than it's been allowed.", + ) + today = datetime.date.today() # check if membership is active if not ( @@ -1727,6 +1746,25 @@ def form_valid(self, form): "it's been allowed.", ) + # CLDT + cldt_remaining = ( + seat_membership.cldt_seats_remaining + ) + # check number of available seats + if cldt_remaining == 0: + messages.warning( + self.request, + # after the form is saved there will be 0 remaining seats + f'Membership "{seat_membership}" has no CLDT ' + "seats remaining.", + ) + if cldt_remaining < 0: + messages.warning( + self.request, + f'Membership "{seat_membership}" is using more CLDT ' + "seats than it's been allowed.", + ) + run_instructor_training_approaching_strategy( instructor_training_approaching_strategy(self.object.event), self.request,