From 9bad5208994fcf4e19c3f2633527f9ea89b3fff9 Mon Sep 17 00:00:00 2001 From: JasonGrace2282 Date: Thu, 19 Sep 2024 13:50:49 -0400 Subject: [PATCH] feat: implement individual students stickying Closes #1719 --- docs/sourcedoc/intranet.apps.eighth.forms.rst | 11 +++++++ intranet/apps/dashboard/views.py | 2 +- .../apps/eighth/forms/admin/scheduling.py | 12 ++++++++ intranet/apps/eighth/forms/fields.py | 9 ++++++ ...eighthscheduledactivity_sticky_students.py | 20 +++++++++++++ intranet/apps/eighth/models.py | 30 ++++++++++++------- intranet/apps/eighth/serializers.py | 7 +++-- intranet/apps/eighth/tests/test_signup.py | 25 ++++++++++++++++ .../apps/eighth/views/admin/scheduling.py | 12 ++++++-- .../eighth/admin/schedule_activity.html | 3 +- 10 files changed, 114 insertions(+), 17 deletions(-) create mode 100644 intranet/apps/eighth/forms/fields.py create mode 100644 intranet/apps/eighth/migrations/0066_eighthscheduledactivity_sticky_students.py diff --git a/docs/sourcedoc/intranet.apps.eighth.forms.rst b/docs/sourcedoc/intranet.apps.eighth.forms.rst index af398219a61..cc4db578510 100644 --- a/docs/sourcedoc/intranet.apps.eighth.forms.rst +++ b/docs/sourcedoc/intranet.apps.eighth.forms.rst @@ -9,6 +9,17 @@ Subpackages intranet.apps.eighth.forms.admin +Submodules +---------- + +intranet.apps.eighth.forms.fields module +---------------------------------------- + +.. automodule:: intranet.apps.eighth.forms.fields + :members: + :undoc-members: + :show-inheritance: + Module contents --------------- diff --git a/intranet/apps/dashboard/views.py b/intranet/apps/dashboard/views.py index b2400653b8f..4c25c349aef 100644 --- a/intranet/apps/dashboard/views.py +++ b/intranet/apps/dashboard/views.py @@ -65,7 +65,7 @@ def gen_schedule(user, num_blocks=6, surrounding_blocks=None): if current_sched_act: current_signup = current_sched_act.title_with_flags current_signup_cancelled = current_sched_act.cancelled - current_signup_sticky = current_sched_act.activity.sticky + current_signup_sticky = current_sched_act.is_user_stickied(user) rooms = current_sched_act.get_true_rooms() else: current_signup = None diff --git a/intranet/apps/eighth/forms/admin/scheduling.py b/intranet/apps/eighth/forms/admin/scheduling.py index d0a8d400ac3..e573780ff99 100644 --- a/intranet/apps/eighth/forms/admin/scheduling.py +++ b/intranet/apps/eighth/forms/admin/scheduling.py @@ -1,6 +1,10 @@ from django import forms +from django.contrib.auth import get_user_model + +from intranet.utils.date import get_senior_graduation_year from ...models import EighthScheduledActivity +from .. import fields class ScheduledActivityForm(forms.ModelForm): @@ -20,6 +24,14 @@ def __init__(self, *args, **kwargs): for fieldname in ["block", "activity"]: self.fields[fieldname].widget = forms.HiddenInput() + self.fields["sticky_students"] = fields.UserMultipleChoiceField( + queryset=get_user_model().objects.filter( + user_type="student", + graduation_year__gte=get_senior_graduation_year(), + ), + required=False, + ) + def validate_unique(self): # We'll handle this ourselves by updating if already exists pass diff --git a/intranet/apps/eighth/forms/fields.py b/intranet/apps/eighth/forms/fields.py new file mode 100644 index 00000000000..2b681eed558 --- /dev/null +++ b/intranet/apps/eighth/forms/fields.py @@ -0,0 +1,9 @@ +from django import forms +from django.contrib.auth import get_user_model + + +class UserMultipleChoiceField(forms.ModelMultipleChoiceField): + def label_from_instance(self, obj): + if isinstance(obj, get_user_model()): + return f"{obj.get_full_name()} ({obj.username})" + return super().label_from_instance(obj) diff --git a/intranet/apps/eighth/migrations/0066_eighthscheduledactivity_sticky_students.py b/intranet/apps/eighth/migrations/0066_eighthscheduledactivity_sticky_students.py new file mode 100644 index 00000000000..50f4b632375 --- /dev/null +++ b/intranet/apps/eighth/migrations/0066_eighthscheduledactivity_sticky_students.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.25 on 2024-09-19 20:30 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('eighth', '0065_auto_20220903_0038'), + ] + + operations = [ + migrations.AddField( + model_name='eighthscheduledactivity', + name='sticky_students', + field=models.ManyToManyField(blank=True, related_name='sticky_scheduledactivity_set', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/intranet/apps/eighth/models.py b/intranet/apps/eighth/models.py index 6fca981ebe8..d4e619838dc 100644 --- a/intranet/apps/eighth/models.py +++ b/intranet/apps/eighth/models.py @@ -7,6 +7,7 @@ from cacheops import invalidate_obj from django.conf import settings from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractBaseUser from django.contrib.auth.models import Group as DjangoGroup from django.core import validators from django.core.cache import cache @@ -798,6 +799,11 @@ class EighthScheduledActivity(AbstractBaseEighthModel): activity = models.ForeignKey(EighthActivity, on_delete=models.CASCADE) members = models.ManyToManyField(settings.AUTH_USER_MODEL, through="EighthSignup", related_name="eighthscheduledactivity_set") waitlist = models.ManyToManyField(settings.AUTH_USER_MODEL, through="EighthWaitlist", related_name="%(class)s_scheduledactivity_set") + sticky_students = models.ManyToManyField( + settings.AUTH_USER_MODEL, + related_name="sticky_scheduledactivity_set", + blank=True, + ) admin_comments = models.CharField(max_length=1000, blank=True) title = models.CharField(max_length=1000, blank=True) @@ -853,6 +859,14 @@ def title_with_flags(self) -> str: name_with_flags = "Special: " + name_with_flags return name_with_flags + def is_user_stickied(self, user: AbstractBaseUser) -> bool: + """Check if the given user is stickied to this activity. + + Args: + user: The user to check for stickiness. + """ + return self.sticky or self.activity.sticky or self.sticky_students.filter(pk=user.pk).exists() + def get_true_sponsors(self) -> Union[QuerySet, Collection[EighthSponsor]]: # pylint: disable=unsubscriptable-object """Retrieves the sponsors for the scheduled activity, taking into account activity defaults and overrides. @@ -911,13 +925,6 @@ def get_restricted(self) -> bool: """ return self.restricted or self.activity.restricted - def get_sticky(self) -> bool: - """Gets whether this scheduled activity is sticky. - Returns: - Whether this scheduled activity is sticky. - """ - return self.sticky or self.activity.sticky - def get_finance(self) -> str: """Retrieves the name of this activity's account with the finance office, if any. @@ -1091,7 +1098,7 @@ def notify_waitlist(self, waitlists: Iterable["EighthWaitlist"]): @transaction.atomic # This MUST be run in a transaction. Do NOT remove this decorator. def add_user( self, - user: "get_user_model()", + user: AbstractBaseUser, request: Optional[HttpRequest] = None, force: bool = False, no_after_deadline: bool = False, @@ -1151,8 +1158,9 @@ def add_user( if ( EighthSignup.objects.filter(user=user, scheduled_activity__block__in=all_blocks) .filter(Q(scheduled_activity__activity__sticky=True) | Q(scheduled_activity__sticky=True)) - .filter(Q(scheduled_activity__cancelled=False)) + .filter(scheduled_activity__cancelled=False) .exists() + or user.sticky_scheduledactivity_set.filter(block__in=all_blocks, cancelled=False).exists() ): exception.Sticky = True @@ -1214,7 +1222,7 @@ def add_user( if self.activity.users_blacklisted.filter(username=user).exists(): exception.Blacklisted = True - if self.get_sticky(): + if self.is_user_stickied(user): EighthWaitlist.objects.filter(user_id=user.id, block_id=self.block.id).delete() success_message = "Successfully added to waitlist for activity." if waitlist else "Successfully signed up for activity." @@ -1688,7 +1696,7 @@ def remove_signup(self, user: "get_user_model()" = None, force: bool = False, do exception.ActivityDeleted = True # Check if the user is already stickied into an activity - if self.scheduled_activity.activity and self.scheduled_activity.activity.sticky and not self.scheduled_activity.cancelled: + if self.scheduled_activity.activity and self.scheduled_activity.is_user_stickied(user) and not self.scheduled_activity.cancelled: exception.Sticky = True if exception.messages() and not force: diff --git a/intranet/apps/eighth/serializers.py b/intranet/apps/eighth/serializers.py index 1e864654a4e..5d03baf6574 100644 --- a/intranet/apps/eighth/serializers.py +++ b/intranet/apps/eighth/serializers.py @@ -105,7 +105,10 @@ def process_scheduled_activity( if scheduled_activity.title: prefix += " - " + scheduled_activity.title middle = " (R)" if restricted_for_user else "" - suffix = " (S)" if activity.sticky else "" + if user is not None and scheduled_activity.is_user_stickied(user): + suffix = " (S)" + else: + suffix = "" suffix += " (BB)" if scheduled_activity.is_both_blocks() else "" suffix += " (A)" if activity.administrative else "" suffix += " (Deleted)" if activity.deleted else "" @@ -142,7 +145,7 @@ def process_scheduled_activity( "administrative": scheduled_activity.get_administrative(), "presign": activity.presign, "presign_time": scheduled_activity.is_too_early_to_signup()[1].strftime("%A, %B %-d at %-I:%M %p"), - "sticky": scheduled_activity.get_sticky(), + "sticky": scheduled_activity.is_user_stickied(user), "finance": "", # TODO: refactor JS to remove this "title": scheduled_activity.title, "comments": scheduled_activity.comments, # TODO: refactor JS to remove this diff --git a/intranet/apps/eighth/tests/test_signup.py b/intranet/apps/eighth/tests/test_signup.py index 23502f7ecca..ee5ac302063 100644 --- a/intranet/apps/eighth/tests/test_signup.py +++ b/intranet/apps/eighth/tests/test_signup.py @@ -200,6 +200,31 @@ def test_signup_restricitons(self): self.assertEqual(len(EighthScheduledActivity.objects.get(block=block1.id, activity=act1.id).members.all()), 1) self.assertEqual(len(EighthScheduledActivity.objects.get(block=block1.id, activity=act2.id).members.all()), 0) + def test_user_stickied(self): + """Test that stickying an individual user into an activity works.""" + self.make_admin() + user = get_user_model().objects.create(username="user1", graduation_year=get_senior_graduation_year()) + + block = self.add_block(date="2024-09-09", block_letter="A") + room = self.add_room(name="room1", capacity=1) + act = self.add_activity(name="Test Activity 1", restricted=True, users_allowed=[user]) + act.rooms.add(room) + + schact = EighthScheduledActivity.objects.create(block=block, activity=act, capacity=5) + schact.sticky_students.add(user) + schact.save() + + act2 = self.add_activity(name="Test Activity 2") + act2.rooms.add(room) + schact2 = EighthScheduledActivity.objects.create(block=block, activity=act2, capacity=5) + + # ensure that the user can't sign up to something else + with self.assertRaisesMessage(SignupException, "Sticky"): + self.verify_signup(user, schact2) + + self.client.post(reverse("eighth_signup", args=[block.id]), {"aid": act2.id}) + self.assertFalse(schact2.members.exists()) + def test_eighth_signup_view(self): """Tests :func:`~intranet.apps.eighth.views.signup.eighth_signup_view`.""" diff --git a/intranet/apps/eighth/views/admin/scheduling.py b/intranet/apps/eighth/views/admin/scheduling.py index abc5b4e7372..0cce4257639 100644 --- a/intranet/apps/eighth/views/admin/scheduling.py +++ b/intranet/apps/eighth/views/admin/scheduling.py @@ -147,7 +147,11 @@ def schedule_activity_view(request): messages.error(request, f"Did not unschedule {name} because there is {count} student signed up.") else: messages.error(request, f"Did not unschedule {name} because there are {count} students signed up.") - instance.save() + + if instance: + instance.sticky_students.set(form.cleaned_data["sticky_students"]) + instance.members.add(*form.cleaned_data["sticky_students"]) + instance.save() messages.success(request, "Successfully updated schedule.") @@ -201,7 +205,10 @@ def schedule_activity_view(request): initial_formset_data = [] sched_act_queryset = ( - EighthScheduledActivity.objects.filter(activity=activity).select_related("block").prefetch_related("rooms", "sponsors", "members") + EighthScheduledActivity.objects.filter(activity=activity) + .select_related("block") + .prefetch_related("rooms", "sponsors", "members", "sticky_students") + .all() ) all_sched_acts = {sa.block.id: sa for sa in sched_act_queryset} @@ -227,6 +234,7 @@ def schedule_activity_view(request): "admin_comments": sched_act.admin_comments, "scheduled": not sched_act.cancelled, "cancelled": sched_act.cancelled, + "sticky_students": sched_act.sticky_students.all(), } ) except KeyError: diff --git a/intranet/templates/eighth/admin/schedule_activity.html b/intranet/templates/eighth/admin/schedule_activity.html index ecb1ecad101..920c4e12828 100644 --- a/intranet/templates/eighth/admin/schedule_activity.html +++ b/intranet/templates/eighth/admin/schedule_activity.html @@ -216,6 +216,7 @@

Select an Activity:

Comments Admin Comments + Sticky Students @@ -295,7 +296,7 @@

Select an Activity:

{% endif %} - {% if field.name in "rooms capacity sponsors title special administrative restricted sticky both_blocks comments admin_comments" %} + {% if field.name in "rooms capacity sponsors title special administrative restricted sticky both_blocks comments admin_comments sticky_students " %}