Skip to content

Commit 49de27c

Browse files
JasonGrace2282alanzhu0
authored andcommitted
feat: implement individual students stickying
Closes #1719
1 parent 129cfbc commit 49de27c

17 files changed

+282
-20
lines changed

docs/sourcedoc/intranet.apps.eighth.forms.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ intranet.apps.eighth.forms.activities module
2020
:undoc-members:
2121
:show-inheritance:
2222

23+
intranet.apps.eighth.forms.fields module
24+
----------------------------------------
25+
26+
.. automodule:: intranet.apps.eighth.forms.fields
27+
:members:
28+
:undoc-members:
29+
:show-inheritance:
30+
2331
Module contents
2432
---------------
2533

intranet/apps/dashboard/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def gen_schedule(user, num_blocks: int = 6, surrounding_blocks: Iterable[EighthB
7272
if current_sched_act:
7373
current_signup = current_sched_act.title_with_flags
7474
current_signup_cancelled = current_sched_act.cancelled
75-
current_signup_sticky = current_sched_act.activity.sticky
75+
current_signup_sticky = current_sched_act.is_user_stickied(user)
7676
rooms = current_sched_act.get_true_rooms()
7777
else:
7878
current_signup = None

intranet/apps/eighth/forms/admin/scheduling.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from django import forms
2+
from django.contrib.auth import get_user_model
23

34
from ...models import EighthScheduledActivity
5+
from .. import fields
46

57

68
class ScheduledActivityForm(forms.ModelForm):
@@ -20,6 +22,12 @@ def __init__(self, *args, **kwargs):
2022
for fieldname in ["block", "activity"]:
2123
self.fields[fieldname].widget = forms.HiddenInput()
2224

25+
self.fields["sticky_students"] = fields.UserMultipleChoiceField(
26+
queryset=self.initial.get("sticky_students", get_user_model().objects.none()),
27+
required=False,
28+
widget=forms.SelectMultiple(attrs={"class": "remote-source remote-sticky-students"}),
29+
)
30+
2331
def validate_unique(self):
2432
# We'll handle this ourselves by updating if already exists
2533
pass

intranet/apps/eighth/forms/fields.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from django import forms
2+
from django.contrib.auth import get_user_model
3+
from django.core.validators import ValidationError
4+
5+
6+
class UserMultipleChoiceField(forms.ModelMultipleChoiceField):
7+
"""Choose any user from the database."""
8+
9+
def clean(self, value):
10+
if not value and not self.required:
11+
return self.queryset.none()
12+
elif self.required:
13+
raise ValidationError(self.error_messages["required"], code="required")
14+
15+
try:
16+
users = get_user_model().objects.filter(id__in=value)
17+
if len(users) != len(value):
18+
raise ValidationError(self.error_messages["invalid_choice"], code="invalid_choice")
19+
except (ValueError, TypeError) as e:
20+
raise ValidationError(self.error_messages["invalid_choice"], code="invalid_choice") from e
21+
return users
22+
23+
def label_from_instance(self, obj):
24+
if isinstance(obj, get_user_model()):
25+
return f"{obj.get_full_name()} ({obj.username})"
26+
return super().label_from_instance(obj)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 3.2.25 on 2024-10-12 21:12
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
11+
('eighth', '0070_eighthactivity_club_sponsors'),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name='eighthscheduledactivity',
17+
name='sticky_students',
18+
field=models.ManyToManyField(blank=True, related_name='sticky_scheduledactivity_set', to=settings.AUTH_USER_MODEL),
19+
),
20+
]

intranet/apps/eighth/models.py

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
import datetime
33
import logging
44
import string
5+
from collections.abc import Sequence
56
from typing import Collection, Iterable, List, Optional, Union
67

78
from cacheops import invalidate_obj
89
from django.conf import settings
910
from django.contrib.auth import get_user_model
11+
from django.contrib.auth.models import AbstractBaseUser
1012
from django.contrib.auth.models import Group as DjangoGroup
1113
from django.core import validators
1214
from django.core.cache import cache
@@ -807,6 +809,11 @@ class EighthScheduledActivity(AbstractBaseEighthModel):
807809
activity = models.ForeignKey(EighthActivity, on_delete=models.CASCADE)
808810
members = models.ManyToManyField(settings.AUTH_USER_MODEL, through="EighthSignup", related_name="eighthscheduledactivity_set")
809811
waitlist = models.ManyToManyField(settings.AUTH_USER_MODEL, through="EighthWaitlist", related_name="%(class)s_scheduledactivity_set")
812+
sticky_students = models.ManyToManyField(
813+
settings.AUTH_USER_MODEL,
814+
related_name="sticky_scheduledactivity_set",
815+
blank=True,
816+
)
810817

811818
admin_comments = models.CharField(max_length=1000, blank=True)
812819
title = models.CharField(max_length=1000, blank=True)
@@ -862,6 +869,24 @@ def title_with_flags(self) -> str:
862869
name_with_flags = "Special: " + name_with_flags
863870
return name_with_flags
864871

872+
def is_user_stickied(self, user: AbstractBaseUser) -> bool:
873+
"""Check if the given user is stickied to this activity.
874+
875+
Args:
876+
user: The user to check for stickiness.
877+
"""
878+
return self.is_activity_sticky() or self.sticky_students.filter(pk=user.pk).exists()
879+
880+
def is_activity_sticky(self) -> bool:
881+
"""Check if the scheduled activity or activity is sticky
882+
883+
.. warning::
884+
885+
This method does NOT take into account individual user stickies.
886+
In 99.9% of cases, you should use :meth:`is_user_stickied` instead.
887+
"""
888+
return self.sticky or self.activity.sticky
889+
865890
def get_true_sponsors(self) -> Union[QuerySet, Collection[EighthSponsor]]: # pylint: disable=unsubscriptable-object
866891
"""Retrieves the sponsors for the scheduled activity, taking into account activity defaults and
867892
overrides.
@@ -920,13 +945,6 @@ def get_restricted(self) -> bool:
920945
"""
921946
return self.restricted or self.activity.restricted
922947

923-
def get_sticky(self) -> bool:
924-
"""Gets whether this scheduled activity is sticky.
925-
Returns:
926-
Whether this scheduled activity is sticky.
927-
"""
928-
return self.sticky or self.activity.sticky
929-
930948
def get_finance(self) -> str:
931949
"""Retrieves the name of this activity's account with the
932950
finance office, if any.
@@ -1097,10 +1115,50 @@ def notify_waitlist(self, waitlists: Iterable["EighthWaitlist"]):
10971115
[waitlist.user.primary_email_address],
10981116
)
10991117

1118+
def set_sticky_students(self, users: "Sequence[AbstractBaseUser]") -> None:
1119+
"""Sets the given users to the sticky students list for this activity.
1120+
1121+
This also sends emails to students.
1122+
1123+
Args:
1124+
users: The users to add to the sticky students list.
1125+
1126+
Returns:
1127+
A tuple of the new stickied students and the unstickied students.
1128+
"""
1129+
for user in users:
1130+
signup = EighthSignup.objects.filter(user=user, scheduled_activity__block=self.block).first()
1131+
if signup is not None:
1132+
signup.remove_signup(user, force=True)
1133+
self.add_user(user, force=True)
1134+
1135+
old_sticky_students = self.sticky_students.all()
1136+
self.sticky_students.set(users)
1137+
1138+
# note: this will send separate emails to each student for each activity they are stickied in
1139+
new_stickied_students = [user.notification_email for user in users if user not in old_sticky_students]
1140+
unstickied_students = [user.notification_email for user in old_sticky_students if user not in users]
1141+
email_send_task.delay(
1142+
"eighth/emails/students_stickied.txt",
1143+
"eighth/emails/students_stickied.html",
1144+
data={"activity": self},
1145+
subject="You have been stickied into an activity",
1146+
emails=new_stickied_students,
1147+
bcc=True,
1148+
)
1149+
email_send_task.delay(
1150+
"eighth/emails/students_unstickied.txt",
1151+
"eighth/emails/students_unstickied.html",
1152+
data={"activity": self},
1153+
subject="You have been unstickied from an activity",
1154+
emails=unstickied_students,
1155+
bcc=True,
1156+
)
1157+
11001158
@transaction.atomic # This MUST be run in a transaction. Do NOT remove this decorator.
11011159
def add_user(
11021160
self,
1103-
user: "get_user_model()",
1161+
user: AbstractBaseUser,
11041162
request: Optional[HttpRequest] = None,
11051163
force: bool = False,
11061164
no_after_deadline: bool = False,
@@ -1160,8 +1218,9 @@ def add_user(
11601218
if (
11611219
EighthSignup.objects.filter(user=user, scheduled_activity__block__in=all_blocks)
11621220
.filter(Q(scheduled_activity__activity__sticky=True) | Q(scheduled_activity__sticky=True))
1163-
.filter(Q(scheduled_activity__cancelled=False))
1221+
.filter(scheduled_activity__cancelled=False)
11641222
.exists()
1223+
or user.sticky_scheduledactivity_set.filter(block__in=all_blocks, cancelled=False).exists()
11651224
):
11661225
exception.Sticky = True
11671226

@@ -1223,7 +1282,7 @@ def add_user(
12231282
if self.activity.users_blacklisted.filter(username=user).exists():
12241283
exception.Blacklisted = True
12251284

1226-
if self.get_sticky():
1285+
if self.is_user_stickied(user):
12271286
EighthWaitlist.objects.filter(user_id=user.id, block_id=self.block.id).delete()
12281287

12291288
success_message = "Successfully added to waitlist for activity." if waitlist else "Successfully signed up for activity."
@@ -1697,7 +1756,7 @@ def remove_signup(self, user: "get_user_model()" = None, force: bool = False, do
16971756
exception.ActivityDeleted = True
16981757

16991758
# Check if the user is already stickied into an activity
1700-
if self.scheduled_activity.activity and self.scheduled_activity.activity.sticky and not self.scheduled_activity.cancelled:
1759+
if self.scheduled_activity.activity and self.scheduled_activity.is_user_stickied(user) and not self.scheduled_activity.cancelled:
17011760
exception.Sticky = True
17021761

17031762
if exception.messages() and not force:

intranet/apps/eighth/serializers.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,10 @@ def process_scheduled_activity(
112112
if scheduled_activity.title:
113113
prefix += " - " + scheduled_activity.title
114114
middle = " (R)" if restricted_for_user else ""
115-
suffix = " (S)" if activity.sticky else ""
115+
if user is not None and scheduled_activity.is_user_stickied(user):
116+
suffix = " (S)"
117+
else:
118+
suffix = ""
116119
suffix += " (BB)" if scheduled_activity.is_both_blocks() else ""
117120
suffix += " (A)" if activity.administrative else ""
118121
suffix += " (Deleted)" if activity.deleted else ""
@@ -151,7 +154,8 @@ def process_scheduled_activity(
151154
"administrative": scheduled_activity.get_administrative(),
152155
"presign": activity.presign,
153156
"presign_time": scheduled_activity.is_too_early_to_signup()[1].strftime("%A, %B %-d at %-I:%M %p"),
154-
"sticky": scheduled_activity.get_sticky(),
157+
"sticky": scheduled_activity.is_activity_sticky(),
158+
"user_sticky": scheduled_activity.is_user_stickied(user),
155159
"finance": "", # TODO: refactor JS to remove this
156160
"title": scheduled_activity.title,
157161
"comments": scheduled_activity.comments, # TODO: refactor JS to remove this

intranet/apps/eighth/tests/test_signup.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,58 @@ def test_signup_restricitons(self):
200200
self.assertEqual(len(EighthScheduledActivity.objects.get(block=block1.id, activity=act1.id).members.all()), 1)
201201
self.assertEqual(len(EighthScheduledActivity.objects.get(block=block1.id, activity=act2.id).members.all()), 0)
202202

203+
def test_user_stickied(self):
204+
"""Test that stickying an individual user into an activity works."""
205+
self.make_admin()
206+
user = get_user_model().objects.create(username="user1", graduation_year=get_senior_graduation_year())
207+
208+
block = self.add_block(date="2024-09-09", block_letter="A")
209+
room = self.add_room(name="room1", capacity=1)
210+
act = self.add_activity(name="Test Activity 1", restricted=True, users_allowed=[user])
211+
act.rooms.add(room)
212+
213+
schact = EighthScheduledActivity.objects.create(block=block, activity=act, capacity=5)
214+
schact.set_sticky_students([user])
215+
216+
act2 = self.add_activity(name="Test Activity 2")
217+
act2.rooms.add(room)
218+
schact2 = EighthScheduledActivity.objects.create(block=block, activity=act2, capacity=5)
219+
220+
# ensure that the user can't sign up to something else
221+
with self.assertRaisesMessage(SignupException, "Sticky"):
222+
self.verify_signup(user, schact2)
223+
224+
self.client.post(reverse("eighth_signup"), data={"uid": user.id, "bid": block.id, "aid": act2.id})
225+
self.assertFalse(schact2.members.exists())
226+
227+
def test_set_sticky_students(self):
228+
"""Test :meth:`~.EighthScheduledActivity.set_sticky_students`."""
229+
self.make_admin()
230+
user = get_user_model().objects.create(username="user1", graduation_year=get_senior_graduation_year())
231+
232+
block = self.add_block(date="2024-09-09", block_letter="A")
233+
room = self.add_room(name="room1", capacity=1)
234+
235+
old_act = self.add_activity(name="Test Activity 2")
236+
old_act.rooms.add(room)
237+
old_schact = EighthScheduledActivity.objects.create(block=block, activity=old_act, capacity=5)
238+
239+
old_schact.add_user(user)
240+
241+
act = self.add_activity(name="Test Activity 1", restricted=True, users_allowed=[user])
242+
act.rooms.add(room)
243+
244+
schact = EighthScheduledActivity.objects.create(block=block, activity=act, capacity=5)
245+
schact.set_sticky_students([user])
246+
247+
self.assertEqual(1, EighthSignup.objects.filter(user=user, scheduled_activity=schact).count())
248+
self.assertEqual(0, old_schact.members.count())
249+
250+
# and they shouldn't be able to change back to their old activity
251+
self.client.post(reverse("eighth_signup"), data={"uid": user.id, "bid": block.id, "aid": old_act.id})
252+
self.assertEqual(0, EighthSignup.objects.filter(user=user, scheduled_activity=old_schact).count())
253+
self.assertEqual(0, old_schact.members.count())
254+
203255
def test_eighth_signup_view(self):
204256
"""Tests :func:`~intranet.apps.eighth.views.signup.eighth_signup_view`."""
205257

intranet/apps/eighth/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
re_path(r"^blocks/delete/(?P<block_id>\d+)$", blocks.delete_block_view, name="eighth_admin_delete_block"),
6868
# Users
6969
re_path(r"^users$", users.list_user_view, name="eighth_admin_manage_users"),
70+
re_path(r"^users/non-graduated$", users.list_non_graduated_view, name="eighth_admin_manage_non_graduated"),
7071
re_path(r"^users/delete/(\d+)$", users.delete_user_view, name="eighth_admin_manage_users"),
7172
# Scheduling
7273
re_path(r"^scheduling/schedule$", scheduling.schedule_activity_view, name="eighth_admin_schedule_activity"),

intranet/apps/eighth/views/admin/scheduling.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,10 @@ def schedule_activity_view(request):
147147
messages.error(request, f"Did not unschedule {name} because there is {count} student signed up.")
148148
else:
149149
messages.error(request, f"Did not unschedule {name} because there are {count} students signed up.")
150-
instance.save()
150+
151+
if instance:
152+
instance.save()
153+
instance.set_sticky_students(form.cleaned_data["sticky_students"])
151154

152155
messages.success(request, "Successfully updated schedule.")
153156

@@ -201,7 +204,10 @@ def schedule_activity_view(request):
201204
initial_formset_data = []
202205

203206
sched_act_queryset = (
204-
EighthScheduledActivity.objects.filter(activity=activity).select_related("block").prefetch_related("rooms", "sponsors", "members")
207+
EighthScheduledActivity.objects.filter(activity=activity)
208+
.select_related("block")
209+
.prefetch_related("rooms", "sponsors", "members", "sticky_students")
210+
.all()
205211
)
206212
all_sched_acts = {sa.block.id: sa for sa in sched_act_queryset}
207213

@@ -227,6 +233,7 @@ def schedule_activity_view(request):
227233
"admin_comments": sched_act.admin_comments,
228234
"scheduled": not sched_act.cancelled,
229235
"cancelled": sched_act.cancelled,
236+
"sticky_students": sched_act.sticky_students.all(),
230237
}
231238
)
232239
except KeyError:

0 commit comments

Comments
 (0)