From 7d0a6366c4ca2b344f8fb1949caf9055a26892d1 Mon Sep 17 00:00:00 2001 From: Krishnan Shankar Date: Sat, 10 Feb 2024 21:43:36 -0500 Subject: [PATCH 01/42] feat(announcements): implement models/UI for club announcements --- intranet/apps/announcements/forms.py | 38 +++-- .../migrations/0033_announcement_activity.py | 20 +++ intranet/apps/announcements/models.py | 8 + intranet/apps/announcements/notifications.py | 11 ++ intranet/apps/announcements/urls.py | 2 + intranet/apps/announcements/views.py | 29 +++- intranet/apps/dashboard/views.py | 56 ++++++- .../0066_eighthactivity_officers.py | 20 +++ .../0067_eighthactivity_subscribers.py | 20 +++ .../migrations/0068_auto_20240213_1938.py | 23 +++ intranet/apps/eighth/models.py | 6 + intranet/apps/eighth/serializers.py | 33 +++- intranet/apps/eighth/urls.py | 2 + intranet/apps/eighth/views/signup.py | 18 ++ intranet/apps/users/models.py | 10 ++ intranet/static/css/dashboard.scss | 74 ++++++++ intranet/static/js/common.js | 4 + .../templates/announcements/announcement.html | 34 +++- .../templates/announcements/club-request.html | 158 ++++++++++++++++++ intranet/templates/announcements/request.html | 4 +- intranet/templates/dashboard/dashboard.html | 53 +++++- intranet/templates/eighth/signup.html | 16 ++ 22 files changed, 602 insertions(+), 37 deletions(-) create mode 100644 intranet/apps/announcements/migrations/0033_announcement_activity.py create mode 100644 intranet/apps/eighth/migrations/0066_eighthactivity_officers.py create mode 100644 intranet/apps/eighth/migrations/0067_eighthactivity_subscribers.py create mode 100644 intranet/apps/eighth/migrations/0068_auto_20240213_1938.py create mode 100644 intranet/templates/announcements/club-request.html diff --git a/intranet/apps/announcements/forms.py b/intranet/apps/announcements/forms.py index 2099248f7ef..f0c3e104bd5 100644 --- a/intranet/apps/announcements/forms.py +++ b/intranet/apps/announcements/forms.py @@ -8,30 +8,38 @@ class AnnouncementForm(forms.ModelForm): """A form for generating an announcement.""" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["expiration_date"].help_text = "By default, announcements expire after two weeks. To change this, click in the box above." + expiration_date = forms.DateTimeInput() + notify_email_all = forms.BooleanField(required=False, label="Send Email to All") + update_added_date = forms.BooleanField(required=False, label="Update Added Date") - self.fields["notify_post"].help_text = "If this box is checked, students who have signed up for notifications will receive an email." + class Meta: + model = Announcement + fields = ["title", "author", "content", "groups", "expiration_date", "notify_post", "notify_email_all", "update_added_date", "pinned"] + help_texts = { + "expiration_date": "By default, announcements expire after two weeks. To change this, click in the box above.", + "notify_post": "If this box is checked, students who have signed up for notifications will receive an email.", + "notify_email_all": "This will send an email notification to all of the users who can see this post. This option does NOT take users' email notification preferences into account, so please use with care.", + "update_added_date": "If this announcement has already been added, update the added date to now so that the announcement is pushed to the top. If this option is not selected, the announcement will stay in its current position.", + } - self.fields["notify_email_all"].help_text = ( - "This will send an email notification to all of the users who can see this post. This option " - "does NOT take users' email notification preferences into account, so please use with care." - ) - self.fields["update_added_date"].help_text = ( - "If this announcement has already been added, update the added date to now so that the " - "announcement is pushed to the top. If this option is not selected, the announcement will stay in " - "its current position." - ) +class ClubAnnouncementForm(forms.ModelForm): + """A form for posting a club announcement.""" + + def __init__(self, user, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["activity"].queryset = user.officer_for_set expiration_date = forms.DateTimeInput() - notify_email_all = forms.BooleanField(required=False, label="Send Email to All") update_added_date = forms.BooleanField(required=False, label="Update Added Date") class Meta: model = Announcement - fields = ["title", "author", "content", "groups", "expiration_date", "notify_post", "notify_email_all", "update_added_date", "pinned"] + fields = ["title", "author", "content", "activity", "expiration_date", "update_added_date"] + help_texts = { + "expiration_date": "By default, announcements expire after two weeks. To change this, click in the box above.", + "update_added_date": "If this announcement has already been added, update the added date to now so that the announcement is pushed to the top. If this option is not selected, the announcement will stay in its current position.", + } class AnnouncementEditForm(forms.ModelForm): diff --git a/intranet/apps/announcements/migrations/0033_announcement_activity.py b/intranet/apps/announcements/migrations/0033_announcement_activity.py new file mode 100644 index 00000000000..874a89ff7d5 --- /dev/null +++ b/intranet/apps/announcements/migrations/0033_announcement_activity.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.20 on 2024-02-14 00:06 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('eighth', '0067_eighthactivity_subscribers'), + ('announcements', '0032_alter_warningannouncement_type'), + ] + + operations = [ + migrations.AddField( + model_name='announcement', + name='activity', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='eighth.eighthactivity'), + ), + ] diff --git a/intranet/apps/announcements/models.py b/intranet/apps/announcements/models.py index 96fe49471c9..2683d6c34b9 100644 --- a/intranet/apps/announcements/models.py +++ b/intranet/apps/announcements/models.py @@ -11,6 +11,8 @@ from ...utils.deletion import set_historical_user from ...utils.html import nullify_links +from ..eighth.models import EighthActivity + class AnnouncementManager(Manager): def visible_to_user(self, user): @@ -110,6 +112,8 @@ class Announcement(models.Model): updated = models.DateTimeField(auto_now=True) groups = models.ManyToManyField(DjangoGroup, blank=True) + activity = models.ForeignKey(EighthActivity, null=True, blank=True, on_delete=models.CASCADE) + expiration_date = models.DateTimeField(auto_now=False, default=timezone.make_aware(datetime(3000, 1, 1))) notify_post = models.BooleanField(default=True) @@ -141,6 +145,10 @@ def is_this_year(self): """Return whether the announcement was created after July 1st of this school year.""" return is_current_year(self.added) + @property + def is_club_announcement(self): + return self.activity is not None + def is_visible(self, user): return self in Announcement.objects.visible_to_user(user) diff --git a/intranet/apps/announcements/notifications.py b/intranet/apps/announcements/notifications.py index 51e6d486842..0932d3510b4 100644 --- a/intranet/apps/announcements/notifications.py +++ b/intranet/apps/announcements/notifications.py @@ -7,6 +7,7 @@ from django.contrib import messages from django.contrib.auth import get_user_model from django.core import exceptions +from django.db.models import Q from django.urls import reverse from requests_oauthlib import OAuth1 from sentry_sdk import capture_exception @@ -118,6 +119,16 @@ def announcement_posted_email(request, obj, send_all=False): .objects.filter(user_type="student", graduation_year__gte=get_senior_graduation_year()) .union(get_user_model().objects.filter(user_type__in=["teacher", "counselor"])) ) + elif obj.club: + filter = Q(subscribed_to_set__contains=obj.club) & ( + Q(user_type="student") & Q(graduation_year__gte=get_senior_graduation_year()) | Q(user_type__in=["teacher", "counselor"]) + ) + users = ( + get_user_model() + .objects.filter(user_type="student", graduation_year__gte=get_senior_graduation_year(), subscribed_to_set__contains=obj.club) + .union(get_user_model().objects.filter(user_type__in=["teacher", "counselor"], subscribed_to_set__contains=obj.club)) + ) + else: users = ( get_user_model() diff --git a/intranet/apps/announcements/urls.py b/intranet/apps/announcements/urls.py index f704fab8da0..7c43d863f29 100644 --- a/intranet/apps/announcements/urls.py +++ b/intranet/apps/announcements/urls.py @@ -5,8 +5,10 @@ urlpatterns = [ re_path(r"^$", views.view_announcements, name="view_announcements"), re_path(r"^/archive$", views.view_announcements_archive, name="announcements_archive"), + re_path(r"^/club$", views.view_club_announcements, name="club_announcements"), re_path(r"^/add$", views.add_announcement_view, name="add_announcement"), re_path(r"^/request$", views.request_announcement_view, name="request_announcement"), + re_path(r"^/club/post$", views.post_club_announcement_view, name="post_club_announcement"), re_path(r"^/request/success$", views.request_announcement_success_view, name="request_announcement_success"), re_path(r"^/request/success_self$", views.request_announcement_success_self_view, name="request_announcement_success_self"), re_path(r"^/approve/(?P\d+)$", views.approve_announcement_view, name="approve_announcement"), diff --git a/intranet/apps/announcements/views.py b/intranet/apps/announcements/views.py index b1f92231e0d..62cab9d53f1 100644 --- a/intranet/apps/announcements/views.py +++ b/intranet/apps/announcements/views.py @@ -13,7 +13,7 @@ from ..auth.decorators import announcements_admin_required, deny_restricted from ..dashboard.views import dashboard_view from ..groups.models import Group -from .forms import AnnouncementAdminForm, AnnouncementEditForm, AnnouncementForm, AnnouncementRequestForm +from .forms import AnnouncementAdminForm, AnnouncementEditForm, AnnouncementForm, AnnouncementRequestForm, ClubAnnouncementForm from .models import Announcement, AnnouncementRequest from .notifications import ( admin_request_announcement_email, @@ -40,6 +40,13 @@ def view_announcements_archive(request): return dashboard_view(request, show_widgets=False, show_expired=True, ignore_dashboard_types=["event"]) +@login_required +@deny_restricted +def view_club_announcements(request): + """Show the dashboard with only club posts.""" + return dashboard_view(request, show_widgets=False, show_hidden_club=True, ignore_dashboard_types=["event"]) + + def announcement_posted_hook(request, obj): """Runs whenever a new announcement is created, or a request is approved and posted. @@ -123,6 +130,26 @@ def request_announcement_view(request): return render(request, "announcements/request.html", {"form": form, "action": "add"}) +def post_club_announcement_view(request): + if request.method == "POST": + form = ClubAnnouncementForm(request.user, request.POST) + + if form.is_valid(): + obj = form.save(commit=True) + obj.user = request.user + # SAFE HTML + obj.content = safe_html(obj.content) + + obj.save() + + return redirect("index") + else: + messages.error(request, "Error adding announcement") + else: + form = ClubAnnouncementForm(request.user) + return render(request, "announcements/club-request.html", {"form": form, "action": "add"}) + + @login_required def request_announcement_success_view(request): return render(request, "announcements/success.html", {"type": "request"}) diff --git a/intranet/apps/dashboard/views.py b/intranet/apps/dashboard/views.py index b2400653b8f..0dd286a942f 100644 --- a/intranet/apps/dashboard/views.py +++ b/intranet/apps/dashboard/views.py @@ -264,7 +264,31 @@ def announcements_sorting_key(item): return items -def paginate_announcements_list(request, context, items): +def split_club_announcements(items): + standard, club = [], [] + + for item in items: + if item.dashboard_type == "announcement" and item.is_club_announcement: + club.append(item) + else: + standard.append(item) + + return standard, club + + +def filter_hidden_club_announcements(user, user_hidden_announcements, club_items): + visible, hidden = [], [] + + for item in club_items: + if item.id in user_hidden_announcements or user not in item.activity.subscribers.all(): + hidden.append(item) + else: + visible.append(item) + + return visible, hidden + + +def paginate_announcements_list(request, context, items, visible_club_items, hidden_club_items): """ Paginate ``items`` in groups of 15 @@ -289,6 +313,13 @@ def paginate_announcements_list(request, context, items): context.update( {"items": items, "page_num": page_num, "prev_page": prev_page, "next_page": next_page, "more_items": more_items, "page_obj": paginator} ) + club_items = visible_club_items[:15] + + context.update( + { + "club_items": club_items, + } + ) return context, items @@ -383,7 +414,7 @@ def add_widgets_context(request, context): @login_required -def dashboard_view(request, show_widgets=True, show_expired=False, ignore_dashboard_types=None, show_welcome=False): +def dashboard_view(request, show_widgets=True, show_expired=False, show_hidden_club=False, ignore_dashboard_types=None, show_welcome=False): """Process and show the dashboard, which includes activities, events, and widgets.""" user = request.user @@ -432,6 +463,9 @@ def dashboard_view(request, show_widgets=True, show_expired=False, ignore_dashbo # Show all by default to 8th period office show_all = True + if not show_hidden_club: + show_hidden_club = "show_hidden_club" in request.GET + is_index_page = request.path_info in ["/", ""] context = { @@ -441,18 +475,25 @@ def dashboard_view(request, show_widgets=True, show_expired=False, ignore_dashbo "events_admin": events_admin, "is_index_page": is_index_page, "show_all": show_all, + "show_hidden_club": show_hidden_club, "show_expired": show_expired, "show_tjstar": settings.TJSTAR_BANNER_START_DATE <= now.date() <= settings.TJSTAR_DATE, } + user_hidden_announcements = Announcement.objects.hidden_announcements(user).values_list("id", flat=True) + user_hidden_events = Event.objects.hidden_events(user).values_list("id", flat=True) + # Get list of announcements items = get_announcements_list(request, context) - # Paginate announcements list - context, items = paginate_announcements_list(request, context, items) + items, club_items = split_club_announcements(items) - user_hidden_announcements = Announcement.objects.hidden_announcements(user).values_list("id", flat=True) - user_hidden_events = Event.objects.hidden_events(user).values_list("id", flat=True) + # Paginate announcements list + if not show_hidden_club: + visible_club_items, hidden_club_items = filter_hidden_club_announcements(user, user_hidden_announcements, club_items) + context, items = paginate_announcements_list(request, context, items, visible_club_items, hidden_club_items) + else: + context, items = paginate_announcements_list(request, context, club_items, [], []) if ignore_dashboard_types is None: ignore_dashboard_types = [] @@ -482,6 +523,9 @@ def dashboard_view(request, show_widgets=True, show_expired=False, ignore_dashbo elif show_expired: dashboard_title = dashboard_header = "Announcement Archive" view_announcements_url = "announcements_archive" + elif show_hidden_club: + dashboard_title = dashboard_header = "Club Announcements" + view_announcements_url = "club_announcements" else: dashboard_title = dashboard_header = "Announcements" diff --git a/intranet/apps/eighth/migrations/0066_eighthactivity_officers.py b/intranet/apps/eighth/migrations/0066_eighthactivity_officers.py new file mode 100644 index 00000000000..8f55cd3cded --- /dev/null +++ b/intranet/apps/eighth/migrations/0066_eighthactivity_officers.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.20 on 2024-02-11 02:29 + +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='eighthactivity', + name='officers', + field=models.ManyToManyField(blank=True, related_name='officer_for_set', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/intranet/apps/eighth/migrations/0067_eighthactivity_subscribers.py b/intranet/apps/eighth/migrations/0067_eighthactivity_subscribers.py new file mode 100644 index 00000000000..16e04244faf --- /dev/null +++ b/intranet/apps/eighth/migrations/0067_eighthactivity_subscribers.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.20 on 2024-02-14 00:06 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('eighth', '0066_eighthactivity_officers'), + ] + + operations = [ + migrations.AddField( + model_name='eighthactivity', + name='subscribers', + field=models.ManyToManyField(blank=True, related_name='subscribed_activity_set', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/intranet/apps/eighth/migrations/0068_auto_20240213_1938.py b/intranet/apps/eighth/migrations/0068_auto_20240213_1938.py new file mode 100644 index 00000000000..e325219e3c5 --- /dev/null +++ b/intranet/apps/eighth/migrations/0068_auto_20240213_1938.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.20 on 2024-02-14 00:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('eighth', '0067_eighthactivity_subscribers'), + ] + + operations = [ + migrations.AddField( + model_name='eighthactivity', + name='subscriptions_enabled', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='historicaleighthactivity', + name='subscriptions_enabled', + field=models.BooleanField(default=False), + ), + ] diff --git a/intranet/apps/eighth/models.py b/intranet/apps/eighth/models.py index 6fca981ebe8..f112f3e0ba0 100644 --- a/intranet/apps/eighth/models.py +++ b/intranet/apps/eighth/models.py @@ -185,6 +185,7 @@ class EighthActivity(AbstractBaseEighthModel): sponsors (:obj:`list` of :obj:`EighthSponsor`): The default activity-level sponsors for the activity. On an EighthScheduledActivity basis, you should NOT query this field. Instead, use scheduled_activity.get_true_sponsors() + officers (:obj:`list` of :obj:`User`): The activity's officers as chosen by a club sponsor. rooms (:obj:`list` of :obj:`EighthRoom`): The default activity-level rooms for the activity. On an EighthScheduledActivity basis, you should NOT query this field. Use scheduled_activity.get_true_rooms() @@ -231,6 +232,7 @@ class EighthActivity(AbstractBaseEighthModel): favorites (:obj:`list` of :obj:`User`): A ManyToManyField of User objects who have favorited the activity. similarities (:obj:`list` of :obj:`EighthActivitySimilarity`): A ManyToManyField of EighthActivitySimilarity objects which are similar to this activity. + subscribers (:obj:`list` of :obj:`User`): Individual users subscribed to this activity's announcements. deleted (bool): Whether the activity still technically exists in the system, but was marked to be deleted. """ @@ -240,6 +242,7 @@ class EighthActivity(AbstractBaseEighthModel): name = models.CharField(max_length=100, validators=[validators.MinLengthValidator(4)]) # This should really be unique description = models.CharField(max_length=2000, blank=True) sponsors = models.ManyToManyField(EighthSponsor, blank=True) + officers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name="officer_for_set", blank=True) rooms = models.ManyToManyField(EighthRoom, blank=True) default_capacity = models.SmallIntegerField(null=True, blank=True) @@ -274,6 +277,9 @@ class EighthActivity(AbstractBaseEighthModel): similarities = models.ManyToManyField("EighthActivitySimilarity", related_name="activity_set", blank=True) + subscriptions_enabled = models.BooleanField(default=False) + subscribers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name="subscribed_activity_set", blank=True) + deleted = models.BooleanField(blank=True, default=False) history = HistoricalRecords() diff --git a/intranet/apps/eighth/serializers.py b/intranet/apps/eighth/serializers.py index 1e864654a4e..7ce1fb8828b 100644 --- a/intranet/apps/eighth/serializers.py +++ b/intranet/apps/eighth/serializers.py @@ -92,7 +92,14 @@ class EighthBlockDetailSerializer(serializers.Serializer): comments = serializers.CharField(max_length=100) def process_scheduled_activity( - self, scheduled_activity, request=None, user=None, favorited_activities=None, recommended_activities=None, available_restricted_acts=None + self, + scheduled_activity, + request=None, + user=None, + favorited_activities=None, + subscribed_activities=None, + recommended_activities=None, + available_restricted_acts=None, ): activity = scheduled_activity.activity if user: @@ -127,6 +134,8 @@ def process_scheduled_activity( "description": activity.description, "cancelled": scheduled_activity.cancelled, "favorited": activity.id in favorited_activities, + "subscribed_to": activity.id in subscribed_activities, + "subscriptions_enabled": activity.subscriptions_enabled, "roster": { "count": 0, "capacity": 0, @@ -161,12 +170,26 @@ def process_scheduled_activity( return activity_info def get_activity( - self, user, favorited_activities, recommended_activities, available_restricted_acts, block_id, activity_id, scheduled_activity=None + self, + user, + favorited_activities, + subscribed_activities, + recommended_activities, + available_restricted_acts, + block_id, + activity_id, + scheduled_activity=None, ): if scheduled_activity is None: scheduled_activity = EighthScheduledActivity.objects.get(block_id=block_id, activity_id=activity_id) return self.process_scheduled_activity( - scheduled_activity, self.context["request"], user, favorited_activities, recommended_activities, available_restricted_acts + scheduled_activity, + self.context["request"], + user, + favorited_activities, + subscribed_activities, + recommended_activities, + available_restricted_acts, ) def get_scheduled_activity(self, scheduled_activity_id): @@ -179,9 +202,11 @@ def fetch_activity_list_with_metadata(self, block): if user: favorited_activities = set(user.favorited_activity_set.values_list("id", flat=True)) recommended_activities = user.recommended_activities + subscribed_activities = set(user.subscribed_activity_set.values_list("id", flat=True)) else: favorited_activities = set() recommended_activities = set() + subscribed_activities = set() available_restricted_acts = EighthActivity.restricted_activities_available_to_user(user) @@ -202,7 +227,7 @@ def fetch_activity_list_with_metadata(self, block): for scheduled_activity in scheduled_activities: # Avoid re-fetching scheduled_activity. activity_info = self.get_activity( - user, favorited_activities, recommended_activities, available_restricted_acts, None, None, scheduled_activity + user, favorited_activities, subscribed_activities, recommended_activities, available_restricted_acts, None, None, scheduled_activity ) activity = scheduled_activity.activity scheduled_activity_to_activity_map[scheduled_activity.id] = activity.id diff --git a/intranet/apps/eighth/urls.py b/intranet/apps/eighth/urls.py index 5652682d0de..b1dcd001f01 100644 --- a/intranet/apps/eighth/urls.py +++ b/intranet/apps/eighth/urls.py @@ -14,6 +14,8 @@ re_path(r"^/leave$", signup.leave_waitlist_view, name="leave_waitlist"), re_path(r"^/seen_feature$", signup.seen_new_feature_view, name="seen_new_feature"), re_path(r"^/signup/multi$", signup.eighth_multi_signup_view, name="eighth_multi_signup"), + re_path(r"^/signup/subscribe/(?P\d+)$", signup.subscribe_to_club, name="subscribe_to_club"), + re_path(r"^/signup/unsubscribe/(?P\d+)$", signup.unsubscribe_from_club, name="unsubscribe_from_club"), re_path(r"^/toggle_favorite$", signup.toggle_favorite_view, name="eighth_toggle_favorite"), re_path(r"^/absences$", attendance.eighth_absences_view, name="eighth_absences"), re_path(r"^/absences/(?P\d+)$", attendance.eighth_absences_view, name="eighth_absences"), diff --git a/intranet/apps/eighth/views/signup.py b/intranet/apps/eighth/views/signup.py index 97be5c56d8a..929ead51704 100644 --- a/intranet/apps/eighth/views/signup.py +++ b/intranet/apps/eighth/views/signup.py @@ -386,6 +386,24 @@ def eighth_multi_signup_view(request): return render(request, "eighth/multi_signup.html", context) +@login_required +@deny_restricted +def subscribe_to_club(request, activity_id): + activity = get_object_or_404(EighthActivity, id=activity_id) + + activity.subscribers.add(request.user) + + return redirect(request.META.get("HTTP_REFERER", "/")) + + +def unsubscribe_from_club(request, activity_id): + activity = get_object_or_404(EighthActivity, id=activity_id) + if request.user in activity.subscribers.all(): + activity.subscribers.remove(request.user) + + return redirect(request.META.get("HTTP_REFERER", "/")) + + @login_required @deny_restricted def toggle_favorite_view(request): diff --git a/intranet/apps/users/models.py b/intranet/apps/users/models.py index d592100c261..2374ab22712 100644 --- a/intranet/apps/users/models.py +++ b/intranet/apps/users/models.py @@ -924,6 +924,16 @@ def is_eighth_sponsor(self) -> bool: """ return EighthSponsor.objects.filter(user=self).exists() + @property + def is_eighth_officer(self) -> bool: + """Checks if this user is an officer of an eighth period activity. + + Returns: + Whether this user is an officer of an eighth period activity. + + """ + return self.officer_for_set.exists() + @property def frequent_signups(self): """Return a QuerySet of activity id's and counts for the activities that a given user diff --git a/intranet/static/css/dashboard.scss b/intranet/static/css/dashboard.scss index 0a4f5809ed8..16664858a17 100644 --- a/intranet/static/css/dashboard.scss +++ b/intranet/static/css/dashboard.scss @@ -23,6 +23,62 @@ margin-bottom: 4px; } +.club-announcements { + padding: 10px; + border-radius: 4px; + transition: max-height 0.2s ease-in-out; + text-align: left; + + &:hover { + cursor: pointer; + } + + &.collapsed { + max-height: 90px !important; + overflow: hidden; + } + + &.collapsed::after { + content: ""; + position: absolute; + z-index: 1; + bottom: 0; + left: 0; + pointer-events: none; + background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0), #fce624 90%); + width: 100%; + height: 4em; + } + + &::-webkit-scrollbar { + width: 7px; + } + &::-webkit-scrollbar-track { + background: #d6d6d6; + } + &::-webkit-scrollbar-thumb { + background: #888; + } + &::-webkit-scrollbar-thumb:hover { + background: #555; + } +} + +.club-announcements-header { + text-align: center; +} + +.club-announcements-content { + display: none; +} + +.announcements-icon-wrapper:has(> .club-announcements-button) { + @media (max-width: 800px) { + display: block !important; + width: 100%; + } +} + .announcement { background-color: white; -webkit--radius: 5px; @@ -131,6 +187,24 @@ } } +a.button { + &:hover { + color: white !important; + } + + &.subscribe-button { + color: green; + float: right; + margin-left: 5px; + } + + &.unsubscribe-button { + color: red; + float: right; + margin-left: 5px; + } +} + .announcement { h3 { &:hover .announcement-icon-wrapper .announcement-toggle, diff --git a/intranet/static/js/common.js b/intranet/static/js/common.js index ca83c829413..80e83144954 100644 --- a/intranet/static/js/common.js +++ b/intranet/static/js/common.js @@ -52,6 +52,10 @@ $(function() { $(".warning-toggle-icon").toggleClass("fa-chevron-down fa-chevron-up"); $.cookie("collapseWarning", !collapseWarning, {path: "/", expires: 14}) }); + $(".club-announcements-header").click(function() { + $(".club-announcements-content").slideToggle(); + $(".club-announcements-toggle-icon").toggleClass("fa-chevron-down fa-chevron-up"); + }); if(!collapseWarning) { $(".warning-content").show(); $(".warning-toggle-icon").toggleClass("fa-chevron-down fa-chevron-up"); diff --git a/intranet/templates/announcements/announcement.html b/intranet/templates/announcements/announcement.html index 43c90672608..7ab6b520d09 100644 --- a/intranet/templates/announcements/announcement.html +++ b/intranet/templates/announcements/announcement.html @@ -25,7 +25,21 @@

{{ announcement.title }} {% endif %} -
+
+ {% if announcement.is_club_announcement %} + {% if request.user in announcement.activity.subscribers.all %} + + + Unsubscribe + + {% else %} + + {% endif %} + {% endif %} + {% if hide_announcements %} {% if announcement.id in user_hidden_announcements %} @@ -48,17 +62,21 @@

{% endif %} -

+

@@ -181,6 +199,36 @@

{% endif %} + {% if club_items %} +
+

+   + You have {{ club_items|length }} new club announcement{{ club_items|length|pluralize }} +

+
+ {% for item in club_items %} + {% if not hide_announcements or not item.id in user_hidden_announcements %} + {% with announcement=item show_icon=True %} + {% include "announcements/announcement.html" %} + {% endwith %} + {% endif %} + {% endfor %} + {% if more_club_items and view_announcements_url != "club_announcements" %} + + + Show All Club Announcements + + {% if request.user.is_eighth_officer %} + + + New + + {% endif %} + {% endif %} +
+
+ {% endif %} + {% if show_near_graduation_message %} {% include "dashboard/senior_forwarding.html" %} {% endif %} @@ -207,7 +255,8 @@

{% endfor %} {% if not request.user.is_restricted %} - {% if page_num == 1 and view_announcements_url != 'announcements_archive' %} + + {% if page_num == 1 and view_announcements_url != "announcements_archive" and view_announcements_url != "club_announcements" %} View Archive {% endif %} {% if page_obj.num_pages > 1 %} diff --git a/intranet/templates/eighth/signup.html b/intranet/templates/eighth/signup.html index a0186e72f71..adab7d35895 100644 --- a/intranet/templates/eighth/signup.html +++ b/intranet/templates/eighth/signup.html @@ -66,6 +66,7 @@ } window.isEighthAdmin = {% if request.user.is_eighth_admin %}true{% else %}false{% endif %}; window.waitlistEnabled = {% if waitlist_enabled %}true{% else %}false{% endif %}; + window.subscribedTo = {% if subscribed_to %}true{% else %}false{% endif %}; window.blockIsToday = {% if active_block.is_today %}true{% else %}false{% endif %}; window.signupTime = new Date({{ active_block.date|date:'Y,m-1,j' }},{{ active_block.signup_time|time:'G,i' }}); window.isSelfSignup = {% if request.user == user %}true{% else %}false{% endif %}; @@ -334,6 +335,21 @@

<% } %> <% } %> <%}%> +
+ <% if (subscriptions_enabled || true) { %> + + <% if (subscribed_to) { %> + + + Unsubscribe + + <% } else { %> + + + Subscribe + + <% } %> + <% } %> <%}%>
From ca4ddd4b777e4deef14946916551c63712991a63 Mon Sep 17 00:00:00 2001 From: Krishnan Shankar Date: Mon, 11 Mar 2024 22:12:49 -0400 Subject: [PATCH 02/42] feat(announcements): animate club announcements on dashboard --- intranet/static/js/dashboard/announcements.js | 22 ++++++++++++++----- .../templates/announcements/announcement.html | 2 +- intranet/templates/dashboard/dashboard.html | 16 ++------------ 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/intranet/static/js/dashboard/announcements.js b/intranet/static/js/dashboard/announcements.js index b22f2e26db2..bdd28a59cab 100644 --- a/intranet/static/js/dashboard/announcements.js +++ b/intranet/static/js/dashboard/announcements.js @@ -93,11 +93,23 @@ $(document).ready(function() { .addClass("fa-expand") .attr("title", icon.attr("data-hidden-title")); - setTimeout(function() { - announcement.addClass("hidden"); - }, 450); - announcementContent.css("display", ""); - announcementContent.slideUp(350); + if (announcement.hasClass("remove-on-collapse")) { + announcement.slideUp(350); + setTimeout(function() { + announcement.remove(); + const numAnnouncementsSpan = $(".num-club-announcements"); + console.log(numAnnouncementsSpan); + const numAnnouncements = numAnnouncementsSpan.text().match(/\d+/); + numAnnouncementsSpan.text(numAnnouncements - 1); + $(".club-announcements:has(.club-announcements-content:not(:has(.announcement)))").slideUp(350); + }, 450); + } else { + setTimeout(function() { + announcement.addClass("hidden"); + }, 450); + announcementContent.css("display", ""); + announcementContent.slideUp(350); + } } }; diff --git a/intranet/templates/announcements/announcement.html b/intranet/templates/announcements/announcement.html index 7ab6b520d09..03e6635288c 100644 --- a/intranet/templates/announcements/announcement.html +++ b/intranet/templates/announcements/announcement.html @@ -7,7 +7,7 @@ {% endblock %} -
+

{% if show_icon and not announcement.pinned %} diff --git a/intranet/templates/dashboard/dashboard.html b/intranet/templates/dashboard/dashboard.html index b1ec5eb5077..db7af4bcdb8 100644 --- a/intranet/templates/dashboard/dashboard.html +++ b/intranet/templates/dashboard/dashboard.html @@ -117,7 +117,7 @@

{{ dashboard_header }}

Request Post {% else %} - {% if more_club_items and not club_items and view_announcements_url != "club_announcements" %} + {% if view_announcements_url != "club_announcements" %} Club Announcements @@ -203,7 +203,7 @@

{% endif %} From 94777f48ab554d490bdb04dea88c7a1b36e2d34c Mon Sep 17 00:00:00 2001 From: Krishnan Shankar Date: Tue, 12 Mar 2024 20:12:30 -0400 Subject: [PATCH 03/42] feat(announcements): filter club announcements by subscription status also fix subscribe/unsubscribe button styling --- intranet/apps/dashboard/views.py | 19 +++++---- intranet/static/css/dark/dashboard.scss | 10 +++++ intranet/static/css/dashboard.scss | 41 +++++++++++++++++-- intranet/static/js/dashboard/announcements.js | 36 ++++++++++++++++ .../templates/announcements/announcement.html | 2 +- intranet/templates/dashboard/dashboard.html | 11 ++++- 6 files changed, 104 insertions(+), 15 deletions(-) diff --git a/intranet/apps/dashboard/views.py b/intranet/apps/dashboard/views.py index 0dd286a942f..31475dea716 100644 --- a/intranet/apps/dashboard/views.py +++ b/intranet/apps/dashboard/views.py @@ -276,19 +276,21 @@ def split_club_announcements(items): return standard, club -def filter_hidden_club_announcements(user, user_hidden_announcements, club_items): - visible, hidden = [], [] +def filter_club_announcements(user, user_hidden_announcements, club_items): + visible, hidden, unsubscribed = [], [], [] for item in club_items: - if item.id in user_hidden_announcements or user not in item.activity.subscribers.all(): + if user not in item.activity.subscribers.all(): + unsubscribed.append(item) + elif item.id in user_hidden_announcements: hidden.append(item) else: visible.append(item) - return visible, hidden + return visible, hidden, unsubscribed -def paginate_announcements_list(request, context, items, visible_club_items, hidden_club_items): +def paginate_announcements_list(request, context, items, visible_club_items, more_club_items): """ Paginate ``items`` in groups of 15 @@ -488,11 +490,12 @@ def dashboard_view(request, show_widgets=True, show_expired=False, show_hidden_c items, club_items = split_club_announcements(items) - # Paginate announcements list if not show_hidden_club: - visible_club_items, hidden_club_items = filter_hidden_club_announcements(user, user_hidden_announcements, club_items) - context, items = paginate_announcements_list(request, context, items, visible_club_items, hidden_club_items) + # Dashboard + visible_club_items, hidden_club_items, other_club_items = filter_club_announcements(user, user_hidden_announcements, club_items) + context, items = paginate_announcements_list(request, context, items, visible_club_items, hidden_club_items or other_club_items) else: + # Club announcements only context, items = paginate_announcements_list(request, context, club_items, [], []) if ignore_dashboard_types is None: diff --git a/intranet/static/css/dark/dashboard.scss b/intranet/static/css/dark/dashboard.scss index 6f847a424a5..741bd9914db 100644 --- a/intranet/static/css/dark/dashboard.scss +++ b/intranet/static/css/dark/dashboard.scss @@ -33,3 +33,13 @@ color: #6060FF; } +.club-announcement-filters > .club-announcement-filter { + background-color: black; + border-color: $darkborder; +} + +a.button { + &:hover { + color: white !important; + } +} diff --git a/intranet/static/css/dashboard.scss b/intranet/static/css/dashboard.scss index 16664858a17..0e319b8dfd7 100644 --- a/intranet/static/css/dashboard.scss +++ b/intranet/static/css/dashboard.scss @@ -172,7 +172,7 @@ float: right; display: none; - .announcement:hover & { + .announcement:not(.club-announcements):hover & { display: block; } @@ -187,11 +187,44 @@ } } -a.button { - &:hover { - color: white !important; +.club-announcement-filters { + display: flex; + justify-content: space-between; + flex-grow: 1; + + > .club-announcement-filter { + background-color: white; + border: 1px solid rgb(216, 216, 216); + padding: 6px 10px; + margin-bottom: 6px; + position: relative; + + text-align: center; + font-size: 14px; + width: 100%; + + cursor: pointer; + font-weight: bolder; + + &.active { + background-color: rgb(44, 103, 186); + color: white; + } + + &.subscribed-filter { + border-right: none; + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + } + + &.unsubscribed-filter { + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + } } +} +a.button { &.subscribe-button { color: green; float: right; diff --git a/intranet/static/js/dashboard/announcements.js b/intranet/static/js/dashboard/announcements.js index bdd28a59cab..7abe9b79e00 100644 --- a/intranet/static/js/dashboard/announcements.js +++ b/intranet/static/js/dashboard/announcements.js @@ -129,4 +129,40 @@ $(document).ready(function() { var btn = $(".announcement-toggle", $(this).parent()); announcementToggle.call(btn); }); + + const subscribedFilter = $(".subscribed-filter"); + const unsubscribedFilter = $(".unsubscribed-filter"); + + function filterClubAnnouncements() { + if (subscribedFilter.hasClass("active")) { + $(".announcement").each(function() { + if ($(this).hasClass("subscribed")) { + $(this).show(); + } else { + $(this).hide(); + } + }); + } else if (unsubscribedFilter.hasClass("active")) { + $(".announcement").each(function() { + if ($(this).hasClass("subscribed")) { + $(this).hide(); + } else { + $(this).show(); + } + }); + } + } + filterClubAnnouncements(); + + subscribedFilter.click(function() { + $(".unsubscribed-filter").removeClass("active"); + $(this).addClass("active"); + filterClubAnnouncements(); + }); + + unsubscribedFilter.click(function() { + $(".subscribed-filter").removeClass("active"); + $(this).addClass("active"); + filterClubAnnouncements(); + }); }); diff --git a/intranet/templates/announcements/announcement.html b/intranet/templates/announcements/announcement.html index 03e6635288c..f9a12f578eb 100644 --- a/intranet/templates/announcements/announcement.html +++ b/intranet/templates/announcements/announcement.html @@ -7,7 +7,7 @@ {% endblock %} -
+

{% if show_icon and not announcement.pinned %} diff --git a/intranet/templates/dashboard/dashboard.html b/intranet/templates/dashboard/dashboard.html index db7af4bcdb8..ed7efad711f 100644 --- a/intranet/templates/dashboard/dashboard.html +++ b/intranet/templates/dashboard/dashboard.html @@ -117,8 +117,8 @@

{{ dashboard_header }}

Request Post {% else %} - {% if view_announcements_url != "club_announcements" %} - + {% if view_announcements_url != "club_announcements" and more_club_items %} + Club Announcements @@ -220,6 +220,13 @@

{% if show_near_graduation_message %} {% include "dashboard/senior_forwarding.html" %} {% endif %} + + {% if view_announcements_url == "club_announcements" %} +
+
Your Subscriptions
+
Other Club Announcements
+
+ {% endif %} {% for item in items %} {% if item.dashboard_type in ignore_dashboard_types %} From 9353de5501e1b4a8fde8c970f2da909a5aab8e19 Mon Sep 17 00:00:00 2001 From: Alan Zhu <2025azhu@tjhsst.edu> Date: Fri, 29 Mar 2024 00:17:57 -0400 Subject: [PATCH 04/42] refactor: update year on search, announcement request pages --- intranet/apps/announcements/forms.py | 8 +++----- intranet/templates/search/tips.html | 12 ++++++------ 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/intranet/apps/announcements/forms.py b/intranet/apps/announcements/forms.py index f0c3e104bd5..476de634133 100644 --- a/intranet/apps/announcements/forms.py +++ b/intranet/apps/announcements/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.conf import settings from django.contrib.auth import get_user_model from ..users.forms import SortedTeacherMultipleChoiceField @@ -77,10 +78,7 @@ class AnnouncementRequestForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["title"].help_text = ( - "The title of the announcement that will appear on Intranet. Please enter " - "a title more specific than just \"[Club name]'s Intranet Posting'." - ) + self.fields["title"].help_text = "The title of the announcement that will appear on Intranet." self.fields["author"].help_text = ( "If you want this post to have a custom author entry, such as " '"Basket Weaving Club" or "TJ Faculty," enter that name here. ' @@ -91,7 +89,7 @@ def __init__(self, *args, **kwargs): self.fields["notes"].help_text = ( "Any information about this announcement you wish to share with the Intranet " "administrators and teachers selected above. If you want to restrict this posting " - "to a specific group of students, such as the Class of 2016, enter that request here." + f"to a specific group of students, such as the Class of {settings.SENIOR_GRADUATION_YEAR}, enter that request here." ) self.fields["teachers_requested"] = SortedTeacherMultipleChoiceField( queryset=get_user_model().objects.get_approve_announcements_users_sorted(), show_username=True diff --git a/intranet/templates/search/tips.html b/intranet/templates/search/tips.html index 0eae98c6b77..d6c4d69eca6 100644 --- a/intranet/templates/search/tips.html +++ b/intranet/templates/search/tips.html @@ -9,8 +9,8 @@

Advanced Search Tips

  • middle
  • nickname
  • id (e.g. 31863)
  • -
  • username (e.g. 2015elowman)
  • -
  • gradyear (e.g. 2015)
  • +
  • username (e.g. {{ DJANGO_SETTINGS.SENIOR_GRADUATION_YEAR }}jdoe)
  • +
  • gradyear (e.g. {{ DJANGO_SETTINGS.SENIOR_GRADUATION_YEAR }})
  • grade (9-12, staff)
  • sex
  • email
  • @@ -20,9 +20,9 @@

    Advanced Search Tips

    Examples