From 414935c0aa0c7e70aa6aec30337a11d532b13778 Mon Sep 17 00:00:00 2001 From: Krishnan Shankar Date: Sat, 10 Feb 2024 21:43:36 -0500 Subject: [PATCH 01/17] 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 | 38 ++++- intranet/apps/dashboard/views.py | 68 +++++++- .../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 | 12 +- 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 | 52 +++++- intranet/templates/eighth/signup.html | 16 ++ 22 files changed, 620 insertions(+), 41 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 b8d445aecea..6fc69786bcf 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 e2e61ecabf9..22f11b55bd8 100644 --- a/intranet/apps/announcements/notifications.py +++ b/intranet/apps/announcements/notifications.py @@ -10,6 +10,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 ...utils.date import get_senior_graduation_year @@ -119,6 +120,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 105ceb89c69..a6268bbe956 100644 --- a/intranet/apps/announcements/views.py +++ b/intranet/apps/announcements/views.py @@ -13,10 +13,15 @@ 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, announcement_approved_email, announcement_posted_email, announcement_posted_twitter, - request_announcement_email) +from .notifications import ( + admin_request_announcement_email, + announcement_approved_email, + announcement_posted_email, + announcement_posted_twitter, + request_announcement_email, +) logger = logging.getLogger(__name__) @@ -35,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. @@ -118,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 f3bc81b13d9..8fb9fdf95bb 100644 --- a/intranet/apps/dashboard/views.py +++ b/intranet/apps/dashboard/views.py @@ -261,7 +261,31 @@ def get_announcements_list(request, context): 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): """ ***TODO*** Migrate to django Paginator (see lostitems) @@ -287,7 +311,24 @@ def paginate_announcements_list(request, context, items): else: items = items_sorted - context.update({"items": items, "start_num": start_num, "end_num": end_num, "prev_page": prev_page, "more_items": more_items}) + club_items = visible_club_items[:display_num] + + if hidden_club_items: + more_club_items = True + else: + more_club_items = False + + context.update( + { + "club_items": club_items, + "more_club_items": more_club_items, + "items": items, + "start_num": start_num, + "end_num": end_num, + "prev_page": prev_page, + "more_items": more_items, + } + ) return context, items @@ -382,7 +423,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 @@ -431,6 +472,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 + # Include show_all postfix on next/prev links paginate_link_suffix = "&show_all=1" if show_all else "" is_index_page = request.path_info in ["/", ""] @@ -442,19 +486,26 @@ 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, "paginate_link_suffix": paginate_link_suffix, "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 = [] @@ -484,6 +535,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 aa4d6798526..3d60d134685 100644 --- a/intranet/apps/eighth/models.py +++ b/intranet/apps/eighth/models.py @@ -186,6 +186,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() @@ -232,6 +233,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. """ @@ -241,6 +243,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) @@ -275,6 +278,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 d20a45e3d30..1c354ad87e2 100644 --- a/intranet/apps/eighth/serializers.py +++ b/intranet/apps/eighth/serializers.py @@ -93,7 +93,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: @@ -128,6 +135,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, @@ -162,12 +171,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): @@ -180,9 +203,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) @@ -203,7 +228,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 1035f215a5a..bf226b07b3a 100644 --- a/intranet/apps/eighth/urls.py +++ b/intranet/apps/eighth/urls.py @@ -15,6 +15,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 4671ce6efb4..6f565ab5e96 100644 --- a/intranet/apps/eighth/views/signup.py +++ b/intranet/apps/eighth/views/signup.py @@ -389,6 +389,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 054c07b94ce..b725dd99556 100644 --- a/intranet/apps/users/models.py +++ b/intranet/apps/users/models.py @@ -928,6 +928,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 @@ -1260,7 +1270,7 @@ def attribute_is_public(self, permission: str) -> bool: PERMISSIONS_NAMES = { - prefix: [name[len(prefix) + 1:] for name in dir(UserProperties) if name.startswith(prefix + "_")] for prefix in ["self", "parent"] + prefix: [name[len(prefix) + 1 :] for name in dir(UserProperties) if name.startswith(prefix + "_")] for prefix in ["self", "parent"] } 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 %} -

+

@@ -180,6 +198,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 %} @@ -206,7 +254,7 @@

{% endfor %} {% if not request.user.is_restricted %} - {% if start_num == 0 and view_announcements_url != 'announcements_archive' %} + {% if start_num == 0 and view_announcements_url != "announcements_archive" and view_announcements_url != "club_announcements" %} View Archive {% endif %} diff --git a/intranet/templates/eighth/signup.html b/intranet/templates/eighth/signup.html index 85a39ab42ec..94b8f72392f 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 0c11a9a901e173ae9f5d128795f7992b92205e95 Mon Sep 17 00:00:00 2001 From: Krishnan Shankar Date: Mon, 11 Mar 2024 22:12:49 -0400 Subject: [PATCH 02/17] 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 796f3992d2c..8cb7850b596 100644 --- a/intranet/templates/dashboard/dashboard.html +++ b/intranet/templates/dashboard/dashboard.html @@ -116,7 +116,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 @@ -202,7 +202,7 @@

{% endif %} From fbcd55f9a36506df98fcd7b1089689f71084ca2c Mon Sep 17 00:00:00 2001 From: Krishnan Shankar Date: Tue, 12 Mar 2024 20:12:30 -0400 Subject: [PATCH 03/17] feat(announcements): filter club announcements by subscription status also fix subscribe/unsubscribe button styling --- intranet/apps/dashboard/views.py | 24 +++++------ 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(+), 20 deletions(-) diff --git a/intranet/apps/dashboard/views.py b/intranet/apps/dashboard/views.py index 8fb9fdf95bb..9ac6f16fee8 100644 --- a/intranet/apps/dashboard/views.py +++ b/intranet/apps/dashboard/views.py @@ -273,19 +273,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): """ ***TODO*** Migrate to django Paginator (see lostitems) @@ -313,11 +315,6 @@ def paginate_announcements_list(request, context, items, visible_club_items, hid club_items = visible_club_items[:display_num] - if hidden_club_items: - more_club_items = True - else: - more_club_items = False - context.update( { "club_items": club_items, @@ -500,11 +497,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 8cb7850b596..fddf67f46a5 100644 --- a/intranet/templates/dashboard/dashboard.html +++ b/intranet/templates/dashboard/dashboard.html @@ -116,8 +116,8 @@

{{ dashboard_header }}

Request Post {% else %} - {% if view_announcements_url != "club_announcements" %} - + {% if view_announcements_url != "club_announcements" and more_club_items %} + Club Announcements @@ -219,6 +219,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 1562899d59b7e66287ca264ce625ee40a0083ff4 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/17] 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