diff --git a/Ion.egg-info/SOURCES.txt b/Ion.egg-info/SOURCES.txt index c06892ce7d4..ec5a7aad23f 100644 --- a/Ion.egg-info/SOURCES.txt +++ b/Ion.egg-info/SOURCES.txt @@ -199,6 +199,7 @@ intranet/apps/announcements/migrations/0029_alter_warningannouncement_type.py intranet/apps/announcements/migrations/0030_alter_warningannouncement_type.py intranet/apps/announcements/migrations/0031_alter_warningannouncement_type.py intranet/apps/announcements/migrations/0032_alter_warningannouncement_type.py +intranet/apps/announcements/migrations/0033_announcement_activity.py intranet/apps/announcements/migrations/__init__.py intranet/apps/api/__init__.py intranet/apps/api/authentication.py @@ -297,6 +298,7 @@ intranet/apps/eighth/tasks.py intranet/apps/eighth/urls.py intranet/apps/eighth/utils.py intranet/apps/eighth/forms/__init__.py +intranet/apps/eighth/forms/activities.py intranet/apps/eighth/forms/admin/__init__.py intranet/apps/eighth/forms/admin/activities.py intranet/apps/eighth/forms/admin/blocks.py @@ -385,6 +387,11 @@ intranet/apps/eighth/migrations/0062_auto_20200116_1926.py intranet/apps/eighth/migrations/0063_auto_20201224_1745.py intranet/apps/eighth/migrations/0064_auto_20210205_1153.py intranet/apps/eighth/migrations/0065_auto_20220903_0038.py +intranet/apps/eighth/migrations/0066_eighthactivity_officers.py +intranet/apps/eighth/migrations/0067_eighthactivity_subscribers.py +intranet/apps/eighth/migrations/0068_auto_20240213_1938.py +intranet/apps/eighth/migrations/0069_alter_eighthsponsor_user.py +intranet/apps/eighth/migrations/0070_eighthactivity_club_sponsors.py intranet/apps/eighth/migrations/__init__.py intranet/apps/eighth/tests/__init__.py intranet/apps/eighth/tests/eighth_test.py @@ -887,6 +894,7 @@ intranet/static/css/_reset.scss intranet/static/css/about.scss intranet/static/css/admin.scss intranet/static/css/announcements.form.scss +intranet/static/css/announcements.request.scss intranet/static/css/api.scss intranet/static/css/base.scss intranet/static/css/board.scss @@ -3271,6 +3279,7 @@ intranet/templates/page_with_nav.html intranet/templates/announcements/add_modify.html intranet/templates/announcements/announcement.html intranet/templates/announcements/approve.html +intranet/templates/announcements/club-request.html intranet/templates/announcements/delete.html intranet/templates/announcements/request.html intranet/templates/announcements/request_status.html @@ -3305,6 +3314,7 @@ intranet/templates/docs/privacy.html intranet/templates/docs/terminology.html intranet/templates/eighth/absences.html intranet/templates/eighth/activity.html +intranet/templates/eighth/activity_settings.html intranet/templates/eighth/edit_profile.html intranet/templates/eighth/email_students.html intranet/templates/eighth/empty_state.html diff --git a/docs/sourcedoc/intranet.apps.eighth.forms.rst b/docs/sourcedoc/intranet.apps.eighth.forms.rst index af398219a61..a512b0cc269 100644 --- a/docs/sourcedoc/intranet.apps.eighth.forms.rst +++ b/docs/sourcedoc/intranet.apps.eighth.forms.rst @@ -9,6 +9,17 @@ Subpackages intranet.apps.eighth.forms.admin +Submodules +---------- + +intranet.apps.eighth.forms.activities module +-------------------------------------------- + +.. automodule:: intranet.apps.eighth.forms.activities + :members: + :undoc-members: + :show-inheritance: + Module contents --------------- diff --git a/intranet/apps/announcements/admin.py b/intranet/apps/announcements/admin.py index e09ceeb8179..771ae454563 100644 --- a/intranet/apps/announcements/admin.py +++ b/intranet/apps/announcements/admin.py @@ -4,8 +4,8 @@ class AnnouncementAdmin(admin.ModelAdmin): - list_display = ("title", "user", "author", "added") - list_filter = ("added", "updated") + list_display = ("title", "user", "author", "activity", "added") + list_filter = ("added", "updated", "activity") ordering = ("-added",) raw_id_fields = ("user",) diff --git a/intranet/apps/announcements/forms.py b/intranet/apps/announcements/forms.py index 2099248f7ef..37fa37ac515 100644 --- a/intranet/apps/announcements/forms.py +++ b/intranet/apps/announcements/forms.py @@ -1,37 +1,62 @@ +import logging + from django import forms +from django.conf import settings from django.contrib.auth import get_user_model +from ..eighth.models import EighthActivity from ..users.forms import SortedTeacherMultipleChoiceField from .models import Announcement, AnnouncementRequest +logger = logging.getLogger(__name__) + 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) + + if user.is_announcements_admin: + self.fields["activity"].queryset = EighthActivity.objects.filter(subscriptions_enabled=True) + elif user.is_club_officer: + self.fields["activity"].queryset = EighthActivity.objects.filter(subscriptions_enabled=True, officers=user) + elif user.is_club_sponsor: + self.fields["activity"].queryset = user.club_sponsor_for_set.filter(subscriptions_enabled=True) + else: + self.fields["activity"].queryset = [] + self.fields["activity"].required = True + + if "instance" in kwargs: # Don't allow changing the activity once the announcement has been created + self.fields["activity"].widget.attrs["disabled"] = True + self.fields["activity"].required = False 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 = ["activity", "title", "content", "expiration_date"] + help_texts = { + "expiration_date": "By default, announcements expire after two weeks. To change this, click in the box above.", + } class AnnouncementEditForm(forms.ModelForm): @@ -69,10 +94,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. ' @@ -83,7 +105,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/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..4212964c81b 100644 --- a/intranet/apps/announcements/models.py +++ b/intranet/apps/announcements/models.py @@ -10,6 +10,7 @@ from ...utils.date import get_date_range_this_year, is_current_year from ...utils.deletion import set_historical_user from ...utils.html import nullify_links +from ..eighth.models import EighthActivity class AnnouncementManager(Manager): @@ -88,7 +89,7 @@ class Announcement(models.Model): The title of the announcement content The HTML content of the news post - authors + author The name of the author added The date the announcement was added @@ -110,6 +111,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,9 +144,20 @@ 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) + def can_modify(self, user): + return ( + user.is_announcements_admin + or self.is_club_announcement + and (self.is_visible_submitter(user) or user.club_sponsor_for_set.filter(id=self.activity.id).exists()) + ) + # False, not None. This can be None if no AnnouncementRequest exists for this Announcement, # and we should not reevaluate in that case. _announcementrequest = False # type: AnnouncementRequest @@ -157,13 +171,13 @@ def announcementrequest(self): def is_visible_requester(self, user): try: - return self.announcementrequest_set.filter(teachers_requested__id=user.id).exists() + return self.announcementrequest_set.filter(teachers_requested=user).exists() except get_user_model().DoesNotExist: return False def is_visible_submitter(self, user): try: - return (self.announcementrequest and user.id == self.announcementrequest.user_id) or self.user_id == user.id + return self.user == user or self.announcementrequest and user == self.announcementrequest.user except get_user_model().DoesNotExist: return False diff --git a/intranet/apps/announcements/notifications.py b/intranet/apps/announcements/notifications.py index e2e61ecabf9..8d7ff9bf005 100644 --- a/intranet/apps/announcements/notifications.py +++ b/intranet/apps/announcements/notifications.py @@ -119,6 +119,14 @@ 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.activity: + subject = f"Club Announcement for {obj.activity.name}: {obj.title}" + users = ( + get_user_model() + .objects.filter(user_type="student", graduation_year__gte=get_senior_graduation_year(), subscribed_to_set__contains=obj.activity) + .union(get_user_model().objects.filter(user_type__in=["teacher", "counselor"], subscribed_to_set__contains=obj.activity)) + ) + else: users = ( get_user_model() diff --git a/intranet/apps/announcements/urls.py b/intranet/apps/announcements/urls.py index f704fab8da0..91c482d50ec 100644 --- a/intranet/apps/announcements/urls.py +++ b/intranet/apps/announcements/urls.py @@ -5,8 +5,11 @@ 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/add$", views.add_club_announcement_view, name="add_club_announcement"), + re_path(r"^/club/modify/(?P\d+)$", views.modify_club_announcement_view, name="modify_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..6a05bd86a87 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, announcement_approved_email, announcement_posted_email, announcement_posted_twitter, request_announcement_email) @@ -35,6 +35,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. @@ -72,6 +79,7 @@ def announcement_approved_hook(request, obj, req): @login_required +@deny_restricted def request_announcement_view(request): """The request announcement page.""" if request.method == "POST": @@ -119,11 +127,88 @@ def request_announcement_view(request): @login_required +@deny_restricted +def add_club_announcement_view(request): + is_announcements_admin = request.user.is_announcements_admin + is_club_sponsor = request.user.is_club_sponsor + is_club_officer = request.user.is_club_officer + + if not (is_announcements_admin or is_club_sponsor or is_club_officer): + messages.error(request, "You do not have permission to post club announcements.") + return redirect("club_announcements") + + 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() + + messages.success(request, "Successfully posted club announcement.") + return redirect("club_announcements") + else: + messages.error(request, "Error adding club announcement") + else: + form = ClubAnnouncementForm(request.user) + + if not form.fields["activity"].queryset.exists(): + if is_announcements_admin: + messages.error(request, "No clubs have enabled this feature yet.") + elif is_club_sponsor: + messages.error(request, "Please enable club announcements for your club.") + else: + messages.error(request, "Please ask your club sponsor to enable posting announcements for your club.") + return redirect("club_announcements") + + return render(request, "announcements/club-request.html", {"form": form, "action": "post"}) + + +@login_required +@deny_restricted +def modify_club_announcement_view(request, announcement_id): + announcement = get_object_or_404(Announcement, id=announcement_id) + + if not announcement.is_club_announcement: + messages.error(request, "This announcement is not a club announcement.") + return redirect("club_announcements") + + if not announcement.can_modify(request.user): + messages.error(request, "You do not have permission to modify this club announcement.") + return redirect("club_announcements") + + if request.method == "POST": + form = ClubAnnouncementForm(request.user, request.POST, instance=announcement) + + if form.is_valid(): + obj = form.save(commit=True) + obj.user = request.user + obj.activity = announcement.activity + # SAFE HTML + obj.content = safe_html(obj.content) + + obj.save() + + messages.success(request, "Successfully modified club announcement.") + return redirect("club_announcements") + else: + messages.error(request, "Error modifying club announcement") + else: + form = ClubAnnouncementForm(request.user, instance=announcement) + return render(request, "announcements/club-request.html", {"form": form, "action": "modify"}) + + +@login_required +@deny_restricted def request_announcement_success_view(request): return render(request, "announcements/success.html", {"type": "request"}) @login_required +@deny_restricted def request_announcement_success_self_view(request): return render(request, "announcements/success.html", {"type": "request", "self": True}) @@ -244,11 +329,12 @@ def admin_approve_announcement_view(request, req_id): @announcements_admin_required @deny_restricted def admin_request_status_view(request): - all_waiting = AnnouncementRequest.objects.filter(posted=None, rejected=False).this_year() + prefetch_fields = ["user", "teachers_requested", "teachers_approved", "posted", "posted_by", "rejected_by"] + all_waiting = AnnouncementRequest.objects.filter(posted=None, rejected=False).this_year().prefetch_related(*prefetch_fields) awaiting_teacher = all_waiting.filter(teachers_approved__isnull=True) awaiting_approval = all_waiting.filter(teachers_approved__isnull=False) - approved = AnnouncementRequest.objects.exclude(posted=None).this_year() - rejected = AnnouncementRequest.objects.filter(rejected=True).this_year() + approved = AnnouncementRequest.objects.exclude(posted=None).this_year().prefetch_related(*prefetch_fields) + rejected = AnnouncementRequest.objects.filter(rejected=True).this_year().prefetch_related(*prefetch_fields) context = {"awaiting_teacher": awaiting_teacher, "awaiting_approval": awaiting_approval, "approved": approved, "rejected": rejected} @@ -318,7 +404,7 @@ def modify_announcement_view(request, announcement_id=None): logger.info("Admin %s modified announcement: %s (%s)", request.user, announcement, announcement.id) return redirect("index") else: - messages.error(request, "Error adding announcement") + messages.error(request, "Error modifying announcement") else: announcement = get_object_or_404(Announcement, id=announcement_id) form = AnnouncementEditForm(instance=announcement) diff --git a/intranet/apps/auth/decorators.py b/intranet/apps/auth/decorators.py index c99db081cac..c1005d5b3d4 100644 --- a/intranet/apps/auth/decorators.py +++ b/intranet/apps/auth/decorators.py @@ -26,6 +26,9 @@ def in_admin_group(user): #: Restrict the wrapped view to eighth admins eighth_admin_required = admin_required("eighth") +# Restrict the wrapped view to eighth sponsors +eighth_sponsor_required = user_passes_test(lambda u: not u.is_anonymous and u.is_eighth_sponsor) + #: Restrict the wrapped view to announcements admins announcements_admin_required = admin_required("announcements") diff --git a/intranet/apps/dashboard/views.py b/intranet/apps/dashboard/views.py index f3bc81b13d9..2d3f91964f6 100644 --- a/intranet/apps/dashboard/views.py +++ b/intranet/apps/dashboard/views.py @@ -239,11 +239,11 @@ def get_announcements_list(request, context): # Load information on the user who posted the announcement # Unless the announcement has a custom author (some do, but not all), we will need the user information to construct the byline, - announcements = announcements.select_related("user") + announcements = announcements.select_related("user", "activity") # We may query the announcement request multiple times while checking if the user submitted or approved the announcement. # prefetch_related() will still make a separate query for each request, but the results are cached if we check them multiple times - announcements = announcements.prefetch_related("announcementrequest_set") + announcements = announcements.prefetch_related("announcementrequest_set", "groups") if context["events_admin"] and context["show_all"]: events = Event.objects.all() @@ -255,13 +255,43 @@ def get_announcements_list(request, context): midnight = timezone.localtime().replace(hour=0, minute=0, second=0, microsecond=0) events = Event.objects.visible_to_user(user).filter(time__gte=midnight, show_on_dashboard=True) + events = events.select_related("user").prefetch_related("groups") + items = sorted(chain(announcements, events), key=lambda item: (item.pinned, item.added)) items.reverse() 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: + if item.activity.subscriptions_enabled: + club.append(item) + else: + standard.append(item) + + return standard, club + + +def filter_club_announcements(user, user_hidden_announcements, club_items): + visible, hidden, unsubscribed = [], [], [] + + for item in club_items: + if item.activity.subscriptions_enabled: + 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, unsubscribed + + +def paginate_announcements_list(request, context, items, visible_club_items, more_club_items): """ ***TODO*** Migrate to django Paginator (see lostitems) @@ -287,7 +317,19 @@ 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] + + 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 +424,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 +473,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 +487,27 @@ 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) + if not show_hidden_club: + # 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: ignore_dashboard_types = [] @@ -484,6 +537,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/forms/activities.py b/intranet/apps/eighth/forms/activities.py new file mode 100644 index 00000000000..631f37dc6e6 --- /dev/null +++ b/intranet/apps/eighth/forms/activities.py @@ -0,0 +1,30 @@ +from typing import List # noqa + +from django import forms +from django.contrib.auth import get_user_model + +from ..models import EighthActivity + + +class ActivitySettingsForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["officers"].queryset = get_user_model().objects.get_students() + self.fields["club_sponsors"].queryset = get_user_model().objects.filter(user_type__in=["teacher", "counselor"]) + + self.fields["subscriptions_enabled"].label = "Enable club announcements" + self.fields["subscriptions_enabled"].help_text = "Allow students to subscribe to receive announcements for this activity through Ion." + self.fields["club_sponsors"].label = "Teacher moderators" + self.fields["club_sponsors"].help_text = ( + "Teacher moderators can post and manage this club's announcements. You should include club sponsors here." + ) + self.fields["officers"].label = "Student officers" + self.fields["officers"].help_text = "Student officers can send club announcements to subscribers." + + class Meta: + model = EighthActivity + fields = [ + "subscriptions_enabled", + "club_sponsors", + "officers", + ] diff --git a/intranet/apps/eighth/forms/admin/activities.py b/intranet/apps/eighth/forms/admin/activities.py index d81a7744b19..ad2466a3b7a 100644 --- a/intranet/apps/eighth/forms/admin/activities.py +++ b/intranet/apps/eighth/forms/admin/activities.py @@ -135,9 +135,18 @@ def __init__(self, *args, **kwargs): student_objects = get_user_model().objects.get_students() self.fields["users_allowed"].queryset = student_objects self.fields["users_blacklisted"].queryset = student_objects + self.fields["officers"].queryset = student_objects + self.fields["club_sponsors"].queryset = get_user_model().objects.filter(user_type__in=["teacher", "counselor"]) self.fields["presign"].label = "2 day pre-signup" self.fields["default_capacity"].help_text = "Overrides the sum of each room's capacity above, if set." + self.fields["subscriptions_enabled"].label = "Enable club announcements" + self.fields["subscriptions_enabled"].help_text = "Allow students to subscribe to receive announcements for this activity through Ion." + self.fields["officers"].help_text = "Student officers can send club announcements to subscribers." + self.fields["club_sponsors"].help_text = ( + "Club sponsors can manage this club's announcements. May be different from the activity's scheduled sponsors." + ) + self.fields["subscribers"].help_text = "Students who subscribe to this activity will receive club announcements." # These fields are rendered on the right of the page on the edit activity page. self.right_fields = set( @@ -153,6 +162,15 @@ def __init__(self, *args, **kwargs): ] ) + self.club_announcements_fields = set( + [ + "subscriptions_enabled", + "club_sponsors", + "officers", + "subscribers", + ] + ) + class Meta: model = EighthActivity fields = [ @@ -182,6 +200,10 @@ class Meta: "fri_a", "fri_b", "admin_comments", + "subscriptions_enabled", + "club_sponsors", + "officers", + "subscribers", ] widgets = { "description": forms.Textarea(attrs={"rows": 9, "cols": 46}), 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/migrations/0069_alter_eighthsponsor_user.py b/intranet/apps/eighth/migrations/0069_alter_eighthsponsor_user.py new file mode 100644 index 00000000000..3e8b62f3d04 --- /dev/null +++ b/intranet/apps/eighth/migrations/0069_alter_eighthsponsor_user.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.25 on 2024-03-30 04:11 + +from django.conf import settings +from django.db import migrations, models +import intranet.utils.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('eighth', '0068_auto_20240213_1938'), + ] + + operations = [ + migrations.AlterField( + model_name='eighthsponsor', + name='user', + field=models.OneToOneField(blank=True, null=True, on_delete=intranet.utils.deletion.set_historical_user, related_name='sponsor_obj', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/intranet/apps/eighth/migrations/0070_eighthactivity_club_sponsors.py b/intranet/apps/eighth/migrations/0070_eighthactivity_club_sponsors.py new file mode 100644 index 00000000000..32122833a90 --- /dev/null +++ b/intranet/apps/eighth/migrations/0070_eighthactivity_club_sponsors.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.25 on 2024-04-01 02:04 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('eighth', '0069_alter_eighthsponsor_user'), + ] + + operations = [ + migrations.AddField( + model_name='eighthactivity', + name='club_sponsors', + field=models.ManyToManyField(blank=True, related_name='club_sponsor_for_set', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/intranet/apps/eighth/models.py b/intranet/apps/eighth/models.py index aa4d6798526..027b63af0cc 100644 --- a/intranet/apps/eighth/models.py +++ b/intranet/apps/eighth/models.py @@ -75,7 +75,7 @@ class EighthSponsor(AbstractBaseEighthModel): first_name = models.CharField(max_length=50) last_name = models.CharField(max_length=50) - user = models.OneToOneField(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=set_historical_user) + user = models.OneToOneField(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=set_historical_user, related_name="sponsor_obj") department = models.CharField(max_length=20, choices=DEPARTMENTS, default="general") full_time = models.BooleanField(default=True) online_attendance = models.BooleanField(default=True) @@ -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. """ @@ -269,6 +271,13 @@ class EighthActivity(AbstractBaseEighthModel): fri_a = models.BooleanField("Meets Friday A", default=False) fri_b = models.BooleanField("Meets Friday B", default=False) + # For club announcements + subscriptions_enabled = models.BooleanField(default=False) + subscribers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name="subscribed_activity_set", blank=True) + officers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name="officer_for_set", blank=True) + # Can be different from the sponsor(s) listed for scheduling purposes + club_sponsors = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name="club_sponsor_for_set", blank=True) + admin_comments = models.CharField(max_length=1000, blank=True) favorites = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name="favorited_activity_set", blank=True) 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..cc8adb918cf 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"), @@ -38,6 +40,7 @@ re_path(r"^/roster/raw/waitlist/(?P\d+)$", attendance.raw_waitlist_view, name="eighth_raw_waitlist"), # Activity Info (for students/teachers) re_path(r"^/activity/(?P\d+)$", activities.activity_view, name="eighth_activity"), + re_path(r"^/activity/(?P\d+)/settings$", activities.settings_view, name="eighth_activity_settings"), re_path(r"^/activity/statistics/global$", activities.stats_global_view, name="eighth_statistics_global"), re_path(r"^/activity/statistics/multiple$", activities.stats_multiple_view, name="eighth_statistics_multiple"), re_path(r"^/activity/statistics/(?P\d+)$", activities.stats_view, name="eighth_statistics"), diff --git a/intranet/apps/eighth/views/activities.py b/intranet/apps/eighth/views/activities.py index 0d6397184bb..ba010bfeb55 100644 --- a/intranet/apps/eighth/views/activities.py +++ b/intranet/apps/eighth/views/activities.py @@ -10,17 +10,19 @@ from reportlab.platypus import PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table from django.conf import settings +from django.contrib import messages from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.db.models import Count -from django.http import HttpResponse +from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, render from django.utils import timezone from ....utils.date import get_date_range_this_year, get_senior_graduation_year from ....utils.helpers import is_entirely_digit from ....utils.serialization import safe_json -from ...auth.decorators import deny_restricted +from ...auth.decorators import deny_restricted, eighth_sponsor_required +from ..forms.activities import ActivitySettingsForm from ..forms.admin.activities import ActivityMultiSelectForm from ..models import EighthActivity, EighthBlock, EighthScheduledActivity, EighthSignup from ..utils import get_start_date @@ -51,6 +53,30 @@ def activity_view(request, activity_id=None): return render(request, "eighth/activity.html", context) +@eighth_sponsor_required +def settings_view(request, activity_id=None): + activity = get_object_or_404(EighthActivity, id=activity_id) + + if not request.user.sponsor_obj: + raise Http404 + + if request.user.sponsor_obj not in activity.sponsors.all(): + raise Http404 + + if request.method == "POST": + form = ActivitySettingsForm(request.POST, instance=activity) + if form.is_valid(): + form.save() + else: + messages.error(request, "There was an error saving the activity settings.") + else: + form = ActivitySettingsForm(instance=activity) + + context = {"activity": activity, "form": form} + + return render(request, "eighth/activity_settings.html", context) + + def chunks(items, n): for i in range(0, len(items), n): yield items[i: i + n] diff --git a/intranet/apps/eighth/views/signup.py b/intranet/apps/eighth/views/signup.py index 4671ce6efb4..1d6319f5f6d 100644 --- a/intranet/apps/eighth/views/signup.py +++ b/intranet/apps/eighth/views/signup.py @@ -389,6 +389,30 @@ 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) + + if activity.subscriptions_enabled: + activity.subscribers.add(request.user) + else: + messages.error(request, "Subscriptions are not enabled for this activity.") + + return redirect(request.META.get("HTTP_REFERER", "/")) + + +@login_required +@deny_restricted +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..3d0e863562e 100644 --- a/intranet/apps/users/models.py +++ b/intranet/apps/users/models.py @@ -928,6 +928,22 @@ def is_eighth_sponsor(self) -> bool: """ return EighthSponsor.objects.filter(user=self).exists() + @property + def is_club_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 is_club_sponsor(self) -> bool: + """Used only for club announcements permissions. Not used for eighth period scheduling. + Use User.is_eighth_sponsor for that instead.""" + return self.club_sponsor_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/settings/__init__.py b/intranet/settings/__init__.py index e8b04c40b41..bca103cb76b 100644 --- a/intranet/settings/__init__.py +++ b/intranet/settings/__init__.py @@ -338,6 +338,7 @@ "groups", "board", "announcements.form", + "announcements.request", "polls.form", "preferences", "signage.base", diff --git a/intranet/static/css/announcements.form.scss b/intranet/static/css/announcements.form.scss index d8dafd4b46a..7c2a5579ac4 100644 --- a/intranet/static/css/announcements.form.scss +++ b/intranet/static/css/announcements.form.scss @@ -84,3 +84,4 @@ div.cke_chrome { padding-left: 14px; } } + diff --git a/intranet/static/css/announcements.request.scss b/intranet/static/css/announcements.request.scss new file mode 100644 index 00000000000..c88a23ec49a --- /dev/null +++ b/intranet/static/css/announcements.request.scss @@ -0,0 +1,45 @@ +.announcements { + table { + width: 600px; + + th { + min-width: 120px; + } + + td { + padding: 10px 0; + } + } + + #cke_id_content { + width: 600px; + margin-bottom: -15px; + } + + table, #cke_id_content { + @media (max-width: 810px) { + width: 342px !important; + } + + @media (max-width: 550px) { + width: 400px !important; + } + } + + input, textarea { + width: 100%; + } + + .selectize-control { + display: inline-block; + } +} + +ol li { + margin-left: 40px; + list-style-type: circle; +} + +div.cke_chrome { + margin: 10px 0; +} \ No newline at end of file diff --git a/intranet/static/css/dark/dashboard.scss b/intranet/static/css/dark/dashboard.scss index 6f847a424a5..eaed2c8adff 100644 --- a/intranet/static/css/dark/dashboard.scss +++ b/intranet/static/css/dark/dashboard.scss @@ -4,7 +4,7 @@ opacity: 0.75; } -.announcement { +.announcement, .club-announcements, .announcement-meta { background: black; border-color: $darkborder; @@ -23,6 +23,10 @@ black 80% ); } + + &.club-announcements { + background-color: rgb(24, 24, 24); + } } .announcement-icon-wrapper > a, .announcement h3 .announcement-icon-wrapper:hover .announcement-toggle { @@ -33,3 +37,17 @@ color: #6060FF; } +.club-announcement-filters > .club-announcement-filter { + background-color: black; + border-color: $darkborder; +} + +a.button { + &:hover { + color: white !important; + } +} + +a.club-announcement-meta-link:hover { + color: rgb(196, 196, 196) !important; +} diff --git a/intranet/static/css/dashboard.scss b/intranet/static/css/dashboard.scss index 0a4f5809ed8..cebe6c078ed 100644 --- a/intranet/static/css/dashboard.scss +++ b/intranet/static/css/dashboard.scss @@ -1,176 +1,335 @@ @import "colors"; .announcements { - padding-right: 432px; - min-width: 290px; - /* for 320x480 screens */ - max-width: 1000px; - margin-bottom: 100px; + padding-right: 432px; + min-width: 290px; + /* for 320x480 screens */ + max-width: 1000px; + margin-bottom: 100px; + + &.no-widgets { + padding-right: 0; + } + + h2 { + padding-left: 10px; + line-height: 38px; + float: left; + } + + .announcement-banner { + background-color: #fff3cd; + color: #533f05; + border: 1px solid #fbe298; + padding: 10px; + margin-bottom: 15px; + margin-top: 10px; + align-items: center; + border-radius: 5px; + line-height: 1.5; + } + + .announcement-link { + color: #856404; + font-weight: bold; + text-decoration: underline; + } + + .announcement-link:hover { + color: #251900; + } +} - &.no-widgets { - padding-right: 0; - } +.announcements-header { + height: 38px; + margin-bottom: 4px; +} - h2 { - padding-left: 10px; - line-height: 38px; - float: left; - } +.club-announcements { + padding: 10px; + border-radius: 4px; + transition: max-height 0.2s ease-in-out; + text-align: left; + + &.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; + } } -.announcements-header { - height: 38px; - margin-bottom: 4px; +.club-announcements-header { + text-align: center; + margin-bottom: 0; } -.announcement { - background-color: white; - -webkit--radius: 5px; - -moz--radius: 5px; - -radius: 5px; - border: 1px solid rgb(216, 216, 216); - padding: 6px 10px; +.club-announcements-content { + display: none; + margin-top: 5px; +} + +.club-announcements-container { + .announcement, + .announcement-meta { + display: none; + } +} + +a.club-announcement-meta-link, +a.club-announcement-meta-link:visited { + color: rgb(144, 144, 144); + text-decoration: underline; + + &:hover { + color: rgb(66, 66, 66); + } +} + +.club-announcements-toggle-icon { + float: right; + margin-top: 4px; +} + +.announcements-icon-wrapper:has(> .club-announcements-button) { + @media (max-width: 800px) { + display: block !important; + width: 100%; + } + + @media (max-width: 550px) { margin-bottom: 6px; - overflow-x: auto; - position: relative; - behavior: url("/static/js/vendor/PIE/PIE.htc"); + } +} - h3 { - cursor: pointer; +.announcement, +.club-announcements, +.announcement-meta { + background-color: white; + -webkit--radius: 5px; + -moz--radius: 5px; + -radius: 5px; + border: 1px solid rgb(216, 216, 216); + padding: 6px 10px; + margin-bottom: 6px; + overflow-x: auto; + position: relative; + behavior: url("/static/js/vendor/PIE/PIE.htc"); + + h3 { + cursor: pointer; - > a.announcement-link { - cursor: pointer; - color: $grey !important; - } + > a.announcement-link { + cursor: pointer; + color: $grey !important; } - &.announcement-meta h3 { - cursor: initial; + &:hover .announcement-icon-wrapper .announcement-toggle, + .announcement-icon-wrapper .announcement-toggle:hover, + .announcement-icon-wrapper:hover .announcement-toggle:hover { + color: rgb(32, 66, 224); } - &.pinned h3 { - color: rgb(181, 0, 0); + .announcement-icon-wrapper:hover .announcement-toggle { + color: $grey; } + } - .announcement-content { - b, - strong { - font-weight: bold; - } + &.announcement-meta h3 { + cursor: initial; + } - i, - em { - font-style: italic; - } + &.pinned h3 { + color: rgb(181, 0, 0); + } - u { - text-decoration: underline; - } + .announcement-content { + b, + strong { + font-weight: bold; + } - ol { - list-style-type: decimal; - list-style-position: inside; - } + i, + em { + font-style: italic; + } - p { - margin-bottom: 5px; - } + u { + text-decoration: underline; } - &.partially-hidden { - .announcement-toggle-content { - max-height: 200px; - overflow-y: hidden; + ol { + list-style-type: decimal; + list-style-position: inside; + } - &::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), - white 80% - ); - width: 100%; - height: 5em; - } - } + p { + margin-bottom: 5px; } + } + + &.partially-hidden { + .announcement-toggle-content { + max-height: 200px; + overflow-y: hidden; + + &::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), + white 80% + ); + width: 100%; + height: 5em; + } + } + } + + &.club-announcements { + background-color: rgb(231, 231, 231); + } + + &-icon { + cursor: pointer; + } + + &.hidden .announcement-toggle-content { + display: none; + } } .announcements-icon-wrapper { - float: right; + float: right; } .announcement-metadata { - color: rgb(144, 144, 144); - font-size: 12px; - line-height: 12px; - margin-bottom: 5px; + color: rgb(144, 144, 144); + font-size: 12px; + line-height: 12px; + margin-bottom: 5px; } .announcement-icon-wrapper { - float: right; - display: none; + float: right; + display: none; - .announcement:hover & { - display: block; - } + .announcement:not(.club-announcements):hover & { + display: block; + } - > a { - color: $grey; - text-decoration: none !important; - padding-left: 2px; + > a { + color: $grey; + text-decoration: none !important; + padding-left: 2px; - &:hover { - color: rgb(32, 66, 224); - } + &:hover { + color: rgb(32, 66, 224); } + } } -.announcement { - h3 { - &:hover .announcement-icon-wrapper .announcement-toggle, - .announcement-icon-wrapper .announcement-toggle:hover, - .announcement-icon-wrapper:hover .announcement-toggle:hover { - color: rgb(32, 66, 224); - } +.club-announcement-filters { + display: flex; + justify-content: space-between; + flex-grow: 1; - .announcement-icon-wrapper:hover .announcement-toggle { - color: $grey; - } + > .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; } - &-icon { - cursor: pointer; + &.subscribed-filter { + border-right: none; + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; } - &.hidden .announcement-toggle-content { - display: none; + &.unsubscribed-filter { + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; } + } +} + +a.button { + &.subscribe-button { + color: green; + float: right; + margin-left: 5px; + } + + &.unsubscribe-button { + color: red; + float: right; + margin-left: 5px; + } } .event.hidden .event-toggle-content { - display: none; + display: none; } .content-center { - width: 100%; - text-align: center; + width: 100%; + text-align: center; } @media (max-width: 800px) { - /* + /* * widgets that fall underneath nav shouldn't float * all the way to the left in 800-500px tablet view. * mainly affects student admins */ - ul.nav { - margin-bottom: 100%; - } + ul.nav { + margin-bottom: 100%; + } } /* @@ -199,56 +358,63 @@ */ @media print { - div.main div.announcements.primary-content { - position: absolute; - top: 0; - padding: 0; - min-width: initial; - max-width: initial; - } - div.announcements-header .announcements-icon-wrapper * { - visibility: hidden; + div.main div.announcements.primary-content { + position: absolute; + top: 0; + padding: 0; + min-width: initial; + max-width: initial; + } + div.announcements-header .announcements-icon-wrapper * { + visibility: hidden; + } + div.announcement { + &-icon-wrapper { + visibility: hidden !important; } - div.announcement { - &-icon-wrapper { - visibility: hidden !important; - } - &.announcement-meta { - display: none; - } + &.announcement-meta { + display: none; } + } } div[data-placeholder]:not(:focus):not([data-div-placeholder-content]):before { - content: attr(data-placeholder); - float: left; - margin-left: 5px; - color: $grey; + content: attr(data-placeholder); + float: left; + margin-left: 5px; + color: $grey; } .dashboard-item-icon { - float: left; - font-size: 32px; - opacity: 0.6; - margin: 0; - padding: 2px 8px 0 0; - width: 27px; - text-align: center; - cursor: pointer; - - &:hover, - .announcement h3:hover &, - .event h3:hover & { - opacity: 1; - } + float: left; + font-size: 32px; + opacity: 0.6; + margin: 0; + padding: 2px 8px 0 0; + width: 27px; + text-align: center; + cursor: pointer; + + &:hover, + .announcement h3:hover &, + .event h3:hover & { + opacity: 1; + } + + &.fa-users { + width: 36px; + font-size: 28px; + position: relative; + top: 2px; + } } .main div.primary-content { - @media (min-width: 801px) { - padding-right: 316px; - } - @media (min-width: 961px) { - padding-right: 432px; - } + @media (min-width: 801px) { + padding-right: 316px; + } + @media (min-width: 961px) { + padding-right: 432px; + } } diff --git a/intranet/static/css/eighth.admin.scss b/intranet/static/css/eighth.admin.scss index be3dde677bd..77a542959d6 100644 --- a/intranet/static/css/eighth.admin.scss +++ b/intranet/static/css/eighth.admin.scss @@ -224,3 +224,13 @@ input[type="submit"], input[type="reset"] { border: 1px solid rgba(0, 72, 171, 0.3); } + +form[name=edit_form] input[type=checkbox] { + position: relative; + top: 5px; +} + +tr.club-announcements-field > td, tr.club-announcements-field > th { + padding-bottom: 15px; + vertical-align: top; +} diff --git a/intranet/static/js/announcement.form.js b/intranet/static/js/announcement.form.js index c5c2184adc0..69e668caf14 100644 --- a/intranet/static/js/announcement.form.js +++ b/intranet/static/js/announcement.form.js @@ -5,12 +5,13 @@ $(function() { placeholder: "Everyone" }); - var reset = $("#id_expiration_date").val() !== "3000-01-01 00:00:00"; $("#id_expiration_date").datetimepicker({ lazyInit: true, format: "Y-m-d H:i:s" }); + $("select#id_activity").selectize(); + // for approval page $("select#id_teachers_requested").selectize({ plugins: ["remove_button"], @@ -44,7 +45,6 @@ $(function() { var editor = CKEDITOR.replace("content", { width: "600px" }); - var end_index = 0; editor.on("instanceReady", function () { // TODO: Don't duplicate this function. Bad! @@ -103,4 +103,47 @@ $(function() { $(".exp-list").append(`
  • "${dates[i].text}" - ${use_date}
  • `); } }); + + var exp = $("#id_expiration_date"); + + dateReset(exp); + + $(".helptext", exp.parent()).before("
      "); + $(".helptext", exp.parent()).before("" + + "Reset to Default" + + "Don't Expire" + + ""); + + $(".exp-list").on("click", "a", function () { + exp.val(dateFormat(new Date($(this).data("date")))); + }) + + $("#date-reset-btn").click(function () { + dateReset(exp); + }); + + $("#no-expire-btn").click(function () { + date3000(exp); + }); }); + +function dateReset(exp) { + var date = new Date(); + date.setDate(date.getDate() + 14); + exp.val(dateFormat(date)); +} + +function date3000(exp) { + var date = new Date("3000-01-01 00:00:00"); + exp.val(dateFormat(date)); +} + +function dateFormat(date) { + return (date.getFullYear() + "-" + + zero(date.getMonth() + 1) + "-" + + zero(date.getDate()) + " 23:59:59"); +} + +function zero(v) { + return v < 10 ? "0" + v : v; +} \ No newline at end of file diff --git a/intranet/static/js/dashboard/announcements.js b/intranet/static/js/dashboard/announcements.js index b22f2e26db2..9b5e8b7a438 100644 --- a/intranet/static/js/dashboard/announcements.js +++ b/intranet/static/js/dashboard/announcements.js @@ -1,7 +1,40 @@ /* global $ */ $(document).ready(function() { + updatePartiallyHidden(); + + filterClubAnnouncements(); + + $(".club-announcements-header").click(function () { + let content = $(".club-announcements-content"); + if (!content.is(":visible")) { // Avoid FOUC + content.show(); + updatePartiallyHidden(); + content.hide(); + } + content.slideToggle(); + $(".club-announcements-toggle-icon").toggleClass("fa-chevron-down fa-chevron-up"); + }); + + $(".announcement[data-id] h3").click(function (e) { + if (e.target !== this) return; + var btn = $(".announcement-toggle", $(this)); + announcementToggle.call(btn); + }); + + $(".announcement[data-id] h3 .announcement-toggle").click(function (e) { + e.preventDefault(); + announcementToggle.call($(this)); + }); + + $(".announcement[data-id] h3 .dashboard-item-icon").click(function (e) { + e.preventDefault(); + var btn = $(".announcement-toggle", $(this).parent()); + announcementToggle.call(btn); + }); - $("div[data-placeholder]").on("keydown keypress input", function() { + $(window).resize(function () { setTimeout(updatePartiallyHidden, 0); }); + + $("div[data-placeholder]").on("keydown keypress input", function () { if (this.textContent) { this.dataset.divPlaceholderContent = 'true'; } else { @@ -9,112 +42,144 @@ $(document).ready(function() { } }); - function updatePartiallyHidden() { - if(window.disable_partially_hidden_announcements) { - return; - } + $(".subscribed-filter").click(function () { + $(".unsubscribed-filter").removeClass("active"); + $(this).addClass("active"); + filterClubAnnouncements(); + }); - $(".announcement:not(.toggled):not(.hidden).partially-hidden").each(function() { - var content = $(this).find(".announcement-content"); - if(content.height() <= 200) { - $(this).removeClass("partially-hidden"); - content.off("click"); - } - }); - $(".announcement:not(.toggled):not(.hidden):not(.partially-hidden)").each(function() { - var content = $(this).find(".announcement-content"); - if(content.height() > 200) { - $(this).addClass("partially-hidden"); - content.click(function() { - announcementToggle.call($(this).closest(".announcement")); - }); - } - else { - content.off("click"); - } - }); + $(".unsubscribed-filter").click(function () { + $(".subscribed-filter").removeClass("active"); + $(this).addClass("active"); + filterClubAnnouncements(); + }); + +}); + +function updatePartiallyHidden() { + if (window.disable_partially_hidden_announcements) { + return; } - updatePartiallyHidden(); - $(window).resize(function() {setTimeout(updatePartiallyHidden, 0);}); - - function announcementToggle() { - var announcement = $(this).closest(".announcement"); - var announcementContent = $(".announcement-toggle-content", announcement); - var icon = $(this).children(0); - var id = announcement.attr("data-id"); - - if(announcement.hasClass("partially-hidden")) { - announcement.addClass("toggled"); - - announcement.find(".announcement-content").off("click"); - - announcementContent.animate( - {"max-height": announcement.find(".announcement-content").height()}, - { - "duration": 350, - complete: function() { - announcement.removeClass("partially-hidden"); - announcementContent.css("max-height", ""); - } - } - ); - return; - } - if (!id) { - console.error("Couldn't toggle invalid announcement ID"); - return; + $(".announcement:not(.toggled):not(.hidden).partially-hidden").each(function () { + var content = $(this).find(".announcement-content"); + if (content.height() <= 200) { + $(this).removeClass("partially-hidden"); + content.off("click"); } + }); + $(".announcement:not(.toggled):not(.hidden):not(.partially-hidden)").each(function () { + var content = $(this).find(".announcement-content"); + if (content.height() > 200) { + $(this).addClass("partially-hidden"); + content.click(function () { + announcementToggle.call($(this).closest(".announcement")); + }); + } + else { + content.off("click"); + } + }); +} - var hidden = announcement.hasClass("hidden"); - var action = hidden ? "show" : "hide"; - - $.post("/announcements/" + action + "?" + id, { - announcement_id: id - }, function() { - console.info("Announcement", id, action); - }); +function announcementToggle() { + var announcement = $(this).closest(".announcement"); + var announcementContent = $(".announcement-toggle-content", announcement); + var icon = $(this).children(0); + var id = announcement.attr("data-id"); + if (announcement.hasClass("partially-hidden")) { announcement.addClass("toggled"); - if (action === "show") { - icon.removeClass("fa-expand") - .addClass("fa-compress") - .attr("title", icon.attr("data-visible-title")); + announcement.find(".announcement-content").off("click"); - setTimeout(function() { - announcement.removeClass("hidden"); - }, 450); + announcementContent.animate( + { "max-height": announcement.find(".announcement-content").height() }, + { + "duration": 350, + complete: function () { + announcement.removeClass("partially-hidden"); + announcementContent.css("max-height", ""); + } + } + ); + return; + } - announcementContent.css("display", ""); - announcementContent.slideDown(350); - } else { - icon.removeClass("fa-compress") - .addClass("fa-expand") - .attr("title", icon.attr("data-hidden-title")); + if (!id) { + console.error("Couldn't toggle invalid announcement ID"); + return; + } + + var hidden = announcement.hasClass("hidden"); + var action = hidden ? "show" : "hide"; - setTimeout(function() { + $.post("/announcements/" + action + "?" + id, { + announcement_id: id + }); + + announcement.addClass("toggled"); + + if (action === "show") { + icon.removeClass("fa-expand") + .addClass("fa-compress") + .attr("title", icon.attr("data-visible-title")); + + setTimeout(function () { + announcement.removeClass("hidden"); + }, 450); + + announcementContent.css("display", ""); + announcementContent.slideDown(350); + } else { + icon.removeClass("fa-compress") + .addClass("fa-expand") + .attr("title", icon.attr("data-hidden-title")); + + if (announcement.hasClass("remove-on-collapse")) { + announcement.slideUp(350); + setTimeout(function () { + announcement.remove(); + const numAnnouncementsSpan = $(".num-club-announcements"); + 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); } - }; - - $(".announcement[data-id] h3").click(function(e) { - if (e.target !== this) return; - var btn = $(".announcement-toggle", $(this)); - announcementToggle.call(btn); - }); - - $(".announcement[data-id] h3 .announcement-toggle").click(function(e) { - e.preventDefault(); - announcementToggle.call($(this)); - }); - - $(".announcement[data-id] h3 .dashboard-item-icon").click(function(e) { - e.preventDefault(); - var btn = $(".announcement-toggle", $(this).parent()); - announcementToggle.call(btn); - }); -}); + } +}; + +function filterClubAnnouncements() { + if ($(".subscribed-filter").hasClass("active")) { + $(".announcement").each(function () { + if ($(this).hasClass("exclude-subscribed-filer")) { + $(this).fadeIn(); + return; + } + if ($(this).hasClass("subscribed")) { + $(this).fadeIn(); + } else { + $(this).hide(); + } + }); + } else if ($(".unsubscribed-filter").hasClass("active")) { + $(".announcement").each(function () { + if ($(this).hasClass("exclude-subscribed-filer")) { + $(this).fadeIn(); + return; + } + if ($(this).hasClass("subscribed")) { + $(this).hide(); + } else { + $(this).fadeIn(); + } + }); + } + updatePartiallyHidden(); +} \ No newline at end of file diff --git a/intranet/static/js/eighth/admin.js b/intranet/static/js/eighth/admin.js index f31683c2bfa..1e3b2a30bbd 100644 --- a/intranet/static/js/eighth/admin.js +++ b/intranet/static/js/eighth/admin.js @@ -128,15 +128,15 @@ $(function() { }); // Disable *_allowed form elements if Restricted isn't checked - var updateRestrictedFormFields = function() { - var restricted = $("#id_restricted").prop("checked"); - $("#id_restricted").parents("tr").nextAll().each(function(index, tr) { - $(tr).find("input").attr("readonly", !restricted); + function updateDisabledFormFields(el) { + var checked = $(el).prop("checked"); + $(el).parents("tr").nextAll().each(function(index, tr) { + $(tr).find("input").attr("readonly", !checked); if ($(tr).find("input").attr("type") === "checkbox") { - $(tr).find("input").attr("disabled", !restricted); + $(tr).find("input").attr("disabled", !checked); } $(tr).find("select").each(function(index, select) { - if (restricted) { + if (checked) { select.selectize.enable(); } else { select.selectize.disable(); @@ -144,15 +144,25 @@ $(function() { }).attr('disabled', false); }); - // Blacklist should be always enabled + // Blacklist should always be enabled $("#id_users_blacklisted").parent("td").find("input").attr("readonly", false); - var select = $("#id_users_blacklisted").parent("td").find("select")[0].selectize.enable(); + $("#id_users_blacklisted").parent("td").find("select")[0].selectize.enable(); } - $("#id_restricted").click(updateRestrictedFormFields); + $("#id_restricted").click(function() { + updateDisabledFormFields($(this)); + }); + + $("#id_subscriptions_enabled").click(function() { + updateDisabledFormFields($(this)); + }); if ($("#id_restricted").length > 0) { - updateRestrictedFormFields(); + updateDisabledFormFields($("#id_restricted")); + } + + if ($("#id_subscriptions_enabled").length > 0) { + updateDisabledFormFields($("#id_subscriptions_enabled")); } $("#only-show-overbooked").click(function() { diff --git a/intranet/static/js/eighth/signup.js b/intranet/static/js/eighth/signup.js index 6576e06e252..a0f494b7046 100644 --- a/intranet/static/js/eighth/signup.js +++ b/intranet/static/js/eighth/signup.js @@ -44,7 +44,9 @@ $(function() { "click button#leave-waitlist": "leaveWaitlistClickHandler", "click a#roster-button": "rosterClickHandler", "click a#roster-waitlist-button": "rosterWaitlistClickHandler", - "click button#close-activity-detail": "closeActivityDetail" + "click button#close-activity-detail": "closeActivityDetail", + "click a#subscribe-button": "subscribeClickHandler", + "click a#unsubscribe-button": "unsubscribeClickHandler" }, render: function() { @@ -142,6 +144,62 @@ $(function() { $(".primary-content.eighth-signup").removeClass("activity-detail-selected"); $("#activity-detail").removeClass("selected"); $("li.selected[data-activity-id]").removeClass("selected"); + }, + + subscribeClickHandler: function(e) { + e.preventDefault(); + var target = e.target; + var spinnerEl = document.getElementById("signup-spinner"); + var spinner = new Spinner(spinnerOptions).spin(spinnerEl); + var url = $(target).attr("href"); + var name = $(target).parent().parent().parent().find(".activity-detail-link").text().trim(); + $.post(url, function(data) { + spinner.spin(false); + $(target).html(" Unsubscribe"); + $(target).attr("id", "unsubscribe-button"); + $(target).attr("href", url.replace("subscribe", "unsubscribe")); + Messenger().success({ + message: 'Subscribed to club announcements for ' + name + '.', + hideAfter: 2, + showCloseButton: true + }); + }).fail(function (xhr, status, error) { + spinner.spin(false); + console.error(xhr.responseText); + Messenger().error({ + message: 'An error occurred subscribing to club announcements for ' + name + '. Try refreshing the page.', + hideAfter: 5, + showCloseButton: false + }); + }); + }, + + unsubscribeClickHandler: function (e) { + e.preventDefault(); + var target = e.target; + var spinnerEl = document.getElementById("signup-spinner"); + var spinner = new Spinner(spinnerOptions).spin(spinnerEl); + var url = $(target).attr("href"); + var name = $(target).parent().parent().parent().find(".activity-detail-link").text().trim(); + $.post(url, function (data) { + spinner.spin(false); + $(target).html(" Subscribe"); + $(target).attr("id", "subscribe-button"); + $(target).attr("href", url.replace("unsubscribe", "subscribe")); + Messenger().error({ + message: 'Unsubscribed from club announcements for ' + name + '.', + hideAfter: 2, + showCloseButton: true + }); + }).fail(function (xhr, status, error) { + spinner.spin(false); + console.error(xhr.responseText); + Messenger().error({ + message: 'An error occurred unsubscribing from club announcements for ' + name + '. Try refreshing the page.', + hideAfter: 5, + showCloseButton: false + }); + }); } }); diff --git a/intranet/templates/announcements/add_modify.html b/intranet/templates/announcements/add_modify.html index 318a3f8ec8f..dbbdb8c0a6b 100644 --- a/intranet/templates/announcements/add_modify.html +++ b/intranet/templates/announcements/add_modify.html @@ -16,40 +16,12 @@ {% endblock %} diff --git a/intranet/templates/announcements/announcement.html b/intranet/templates/announcements/announcement.html index 43c90672608..1af23e6d9c7 100644 --- a/intranet/templates/announcements/announcement.html +++ b/intranet/templates/announcements/announcement.html @@ -7,9 +7,27 @@ {% endblock %} -
      +