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 b3bfc532005..1232daaeaf6 100644 --- a/intranet/apps/announcements/admin.py +++ b/intranet/apps/announcements/admin.py @@ -1,17 +1,18 @@ from django.contrib import admin +from simple_history.admin import SimpleHistoryAdmin from .models import Announcement, WarningAnnouncement -class AnnouncementAdmin(admin.ModelAdmin): - list_display = ("title", "user", "author", "added") - list_filter = ("added", "updated") +class AnnouncementAdmin(SimpleHistoryAdmin): + list_display = ("title", "user", "author", "activity", "added") + list_filter = ("added", "updated", "activity") ordering = ("-added",) raw_id_fields = ("user",) search_fields = ("title", "content", "user__first_name", "user__last_name", "user__username") -class WarningAnnouncementAdmin(admin.ModelAdmin): +class WarningAnnouncementAdmin(SimpleHistoryAdmin): list_display = ("title", "content", "active") list_filter = ("active",) search_fields = ("title", "content") diff --git a/intranet/apps/announcements/forms.py b/intranet/apps/announcements/forms.py index 2099248f7ef..43684b8b1fc 100644 --- a/intranet/apps/announcements/forms.py +++ b/intranet/apps/announcements/forms.py @@ -1,37 +1,88 @@ +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): + 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"] + help_texts = { + "expiration_date": "By default, announcements expire after two weeks. Choose the shortest time necessary.", + "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." + ), + } + + +class ClubAnnouncementForm(forms.ModelForm): + """A form for posting a club announcement.""" + + def __init__(self, user, *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." - self.fields["notify_post"].help_text = "If this box is checked, students who have signed up for notifications will receive an email." + 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 + self.fields[ + "notify_post" + ].help_text = "If this box is checked, students who have subscribed to your club's announcements will receive an email." + + 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"].initial = kwargs["instance"].activity - 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." - ) + expiration_date = forms.DateTimeInput() + notify_post = forms.BooleanField(required=False, initial=True) + + class Meta: + model = Announcement + fields = ["activity", "title", "content", "expiration_date", "notify_post"] + help_texts = { + "expiration_date": "By default, announcements expire after two weeks. Choose the shortest time necessary.", + } - 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 ClubAnnouncementEditForm(forms.ModelForm): + """A form for editing a club announcement.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) 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", "content", "expiration_date"] + help_texts = { + "expiration_date": "By default, announcements expire after two weeks. Choose the shortest time necessary.", + } class AnnouncementEditForm(forms.ModelForm): @@ -39,7 +90,7 @@ class AnnouncementEditForm(forms.ModelForm): 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." + self.fields["expiration_date"].help_text = "By default, announcements expire after two weeks. Choose the shortest time necessary." self.fields["notify_post_resend"].help_text = "If this box is checked, students who have signed up for notifications will receive an email." @@ -69,21 +120,18 @@ 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. ' "Otherwise, your name will appear in this field automatically." ) self.fields["content"].help_text = "The contents of the news post which will appear on Intranet." - self.fields["expiration_date"].help_text = "By default, announcements expire after two weeks. To change this, click in the box above." + self.fields["expiration_date"].help_text = "By default, announcements expire after two weeks. Choose the shortest time necessary." 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/migrations/0034_historicalannouncement_historicalwarningannouncement.py b/intranet/apps/announcements/migrations/0034_historicalannouncement_historicalwarningannouncement.py new file mode 100644 index 00000000000..30a9005aae9 --- /dev/null +++ b/intranet/apps/announcements/migrations/0034_historicalannouncement_historicalwarningannouncement.py @@ -0,0 +1,72 @@ +# Generated by Django 3.2.25 on 2024-10-05 05:34 + +import datetime +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +from django.utils.timezone import utc +import simple_history.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('eighth', '0070_eighthactivity_club_sponsors'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('announcements', '0033_announcement_activity'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalWarningAnnouncement', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('title', models.CharField(max_length=127)), + ('content', models.TextField(help_text='Content of the warning. You can use HTML here.')), + ('type', models.CharField(choices=[('dashboard', 'Dashboard Warning (displays on dashboard)'), ('login', 'Login Warning (displays on login page)'), ('dashboard_login', 'Dashboard and Login Warning (displays on dashboard and login pages)'), ('global', 'Global Warning (displays on all pages)')], default='dashboard', max_length=127)), + ('active', models.BooleanField(default=True, help_text='Whether or not to show the warning.')), + ('added', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical warning announcement', + 'verbose_name_plural': 'historical warning announcements', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalAnnouncement', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('title', models.CharField(max_length=127)), + ('content', models.TextField()), + ('author', models.CharField(blank=True, max_length=63)), + ('added', models.DateTimeField(blank=True, editable=False)), + ('updated', models.DateTimeField(blank=True, editable=False)), + ('expiration_date', models.DateTimeField(default=datetime.datetime(3000, 1, 1, 5, 0, tzinfo=utc))), + ('notify_post', models.BooleanField(default=True)), + ('notify_email_all', models.BooleanField(default=False)), + ('pinned', models.BooleanField(default=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('activity', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='eighth.eighthactivity')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical announcement', + 'verbose_name_plural': 'historical announcements', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/intranet/apps/announcements/models.py b/intranet/apps/announcements/models.py index 96fe49471c9..3a4cd45f155 100644 --- a/intranet/apps/announcements/models.py +++ b/intranet/apps/announcements/models.py @@ -6,10 +6,12 @@ from django.db import models from django.db.models import Manager, Q from django.utils import timezone +from simple_history.models import HistoricalRecords 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 +90,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 +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) @@ -117,6 +121,8 @@ class Announcement(models.Model): pinned = models.BooleanField(default=False) + history = HistoricalRecords() + def get_author(self) -> str: """Returns 'author' if it is set. Otherwise, returns the name of the user who created the announcement. @@ -141,9 +147,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 (user in self.activity.officers.all() or user in self.activity.sponsors.all() or user in self.activity.club_sponsors.all()) + ) + # 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 +174,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 @@ -280,6 +297,8 @@ class WarningAnnouncement(models.Model): active = models.BooleanField(default=True, help_text="Whether or not to show the warning.") added = models.DateTimeField(auto_now_add=True) + history = HistoricalRecords() + @property def show_on_dashboard(self): return self.type in ("dashboard", "dashboard_login") # global is not included. It will show on all pages and this logic isn't needed. diff --git a/intranet/apps/announcements/notifications.py b/intranet/apps/announcements/notifications.py index 51e6d486842..05a8da436bf 100644 --- a/intranet/apps/announcements/notifications.py +++ b/intranet/apps/announcements/notifications.py @@ -118,6 +118,19 @@ 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(), + receive_news_emails=True, + subscribed_activity_set=obj.activity, + ) + .union(get_user_model().objects.filter(user_type__in=["teacher", "counselor"], subscribed_activity_set=obj.activity)) + ) + else: users = ( get_user_model() @@ -144,7 +157,10 @@ def announcement_posted_email(request, obj, send_all=False): email_send_task.delay( "announcements/emails/announcement_posted.txt", "announcements/emails/announcement_posted.html", data, subject, emails, bcc=True ) - messages.success(request, f"Sent email to {len(users_send)} users") + if request.user.is_announcements_admin: + messages.success(request, f"Sent email to {len(users_send)} users") + else: + messages.success(request, "Sent notification emails.") else: logger.info("Emailing announcements disabled") diff --git a/intranet/apps/announcements/tests.py b/intranet/apps/announcements/tests.py index f49d5871d05..64e81702c31 100644 --- a/intranet/apps/announcements/tests.py +++ b/intranet/apps/announcements/tests.py @@ -6,6 +6,7 @@ from ...test.ion_test import IonTestCase from ...utils.date import get_senior_graduation_year +from ..eighth.models import EighthActivity from ..users.models import Group from .models import Announcement, AnnouncementRequest @@ -443,6 +444,17 @@ def test_hide_announcement_view(self): response = self.client.get(reverse("hide_announcement")) self.assertEqual(405, response.status_code) + def test_modify_club_announcement(self): + self.make_admin() + act = EighthActivity.objects.get_or_create(name="test")[0] + announce = Announcement.objects.get_or_create(title="test9", content="test9", activity=act)[0] + self.client.post( + reverse("modify_club_announcement", args=[announce.id]), + {"title": "hi", "content": "bye", "expiration_date": "3000-01-01"}, + ) + announce.refresh_from_db() + self.assertEqual(act, announce.activity) + class ApiTest(IonTestCase): def test_api_announcements_list(self): 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 b1f92231e0d..4412ac0299b 100644 --- a/intranet/apps/announcements/views.py +++ b/intranet/apps/announcements/views.py @@ -13,7 +13,14 @@ 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, + ClubAnnouncementEditForm, + ClubAnnouncementForm, +) from .models import Announcement, AnnouncementRequest from .notifications import ( admin_request_announcement_email, @@ -40,6 +47,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. @@ -77,6 +91,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": @@ -124,11 +139,86 @@ 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=False) + obj.user = request.user + # SAFE HTML + obj.content = safe_html(obj.content) + obj.save() + + announcement_posted_hook(request, obj) + + 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 = ClubAnnouncementEditForm(request.POST, instance=announcement) + + if form.is_valid(): + obj = form.save(commit=False) + # 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 = ClubAnnouncementEditForm(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}) @@ -249,11 +339,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} @@ -327,7 +418,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) @@ -336,7 +427,6 @@ def modify_announcement_view(request, announcement_id=None): return render(request, "announcements/add_modify.html", context) -@announcements_admin_required @deny_restricted def delete_announcement_view(request, announcement_id): """Delete an announcement. @@ -344,30 +434,28 @@ def delete_announcement_view(request, announcement_id): announcement_id: announcement id """ + announcement = get_object_or_404(Announcement, id=announcement_id) + + if not (request.user.is_announcements_admin or announcement.is_club_announcement and announcement.can_modify(request.user)): + messages.error(request, "You do not have permission to delete this announcement.") + return redirect("index") + if request.method == "POST": - post_id = None - try: - post_id = request.POST["id"] - except AttributeError: - post_id = None - try: - a = Announcement.objects.get(id=post_id) - if request.POST.get("full_delete", False): - a.delete() - messages.success(request, "Successfully deleted announcement.") - logger.info("Admin %s deleted announcement: %s (%s)", request.user, a, a.id) - else: - a.expiration_date = timezone.localtime() - a.save() - messages.success(request, "Successfully expired announcement.") - logger.info("Admin %s expired announcement: %s (%s)", request.user, a, a.id) - except Announcement.DoesNotExist: - pass + if request.POST.get("full_delete", False) and request.user.is_announcements_admin: + announcement.delete() + messages.success(request, "Successfully deleted announcement.") + logger.info("Admin %s deleted announcement: %s (%s)", request.user, announcement, announcement.id) + else: + announcement.expiration_date = timezone.localtime() + announcement.save() + messages.success(request, "Successfully expired announcement.") + logger.info("%s expired announcement: %s (%s)", request.user, announcement, announcement.id) + if announcement.is_club_announcement: + return redirect("club_announcements") return redirect("index") - else: - announcement = get_object_or_404(Announcement, id=announcement_id) - return render(request, "announcements/delete.html", {"announcement": announcement}) + + return render(request, "announcements/delete.html", {"announcement": announcement}) @login_required diff --git a/intranet/apps/dashboard/views.py b/intranet/apps/dashboard/views.py index b2400653b8f..e8550d99383 100644 --- a/intranet/apps/dashboard/views.py +++ b/intranet/apps/dashboard/views.py @@ -1,19 +1,25 @@ +from __future__ import annotations + import logging from datetime import datetime, time, timedelta from itertools import chain +from typing import Any, Generic, Iterable, Sequence, TypeVar from django.conf import settings from django.contrib.auth.decorators import login_required -from django.core.paginator import Paginator +from django.core.paginator import Page, Paginator +from django.db.models import QuerySet +from django.http import HttpRequest from django.shortcuts import redirect, render from django.urls import reverse from django.utils import timezone from django.utils.timezone import make_aware +from typing_extensions import TypedDict, TypeGuard from ...utils.date import get_senior_graduation_date, get_senior_graduation_year from ...utils.helpers import get_ap_week_warning, get_fcps_emerg, get_warning_html from ..announcements.models import Announcement, AnnouncementRequest, WarningAnnouncement -from ..eighth.models import EighthBlock, EighthScheduledActivity, EighthSignup +from ..eighth.models import EighthBlock, EighthScheduledActivity, EighthSignup, EighthSponsor from ..enrichment.models import EnrichmentActivity from ..events.models import Event, TJStarUUIDMap from ..schedule.models import Day @@ -21,9 +27,10 @@ from ..seniors.models import Senior logger = logging.getLogger(__name__) +T = TypeVar("T") -def gen_schedule(user, num_blocks=6, surrounding_blocks=None): +def gen_schedule(user, num_blocks: int = 6, surrounding_blocks: Iterable[EighthBlock] | None = None): """Generate a list of information about a block and a student's current activity signup. Returns: @@ -109,7 +116,7 @@ def gen_schedule(user, num_blocks=6, surrounding_blocks=None): return schedule, no_signup_today -def gen_sponsor_schedule(user, sponsor=None, num_blocks=6, surrounding_blocks=None, given_date=None): +def gen_sponsor_schedule(user, sponsor=None, num_blocks: int = 6, surrounding_blocks=None, given_date=None): r"""Return a list of :class:`EighthScheduledActivity`\s in which the given user is sponsoring. @@ -193,7 +200,7 @@ def get_prerender_url(request): return request.build_absolute_uri(reverse(view)) -def get_announcements_list(request, context): +def get_announcements_list(request, context) -> list[Announcement | Event]: """ An announcement will be shown if: * It is not expired @@ -238,11 +245,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() @@ -252,6 +259,7 @@ def get_announcements_list(request, context): # Unlike announcements, show events for the rest of the day after they occur. 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") def announcements_sorting_key(item): if context["show_expired"] or context["show_all"]: @@ -264,15 +272,90 @@ def announcements_sorting_key(item): return items -def paginate_announcements_list(request, context, items): +def split_club_announcements(items: Iterable[Announcement | Event]) -> tuple[list[Announcement], list[Announcement]]: + """Split items into standard and club announcements. + + .. warning:: + + This will discard any club announcements with subscriptions disabled + from the resulting list. + + Returns: + a tuple of standard and club announcements. """ - Paginate ``items`` in groups of 15 + def is_announcement(item: Announcement | Event) -> TypeGuard[Announcement]: + return item.dashboard_type == "announcement" + + standard, club = [], [] + + for item in items: + if is_announcement(item) 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: QuerySet[Announcement], club_items: Iterable[Announcement] +) -> tuple[list[Announcement], list[Announcement], list[Announcement]]: + """Filter club announcements into categories + + Returns: + a tuple of visible, hidden, and unsubscribed club announcements for the user. + """ + visible, hidden, unsubscribed = [], [], [] + + for item in club_items: + if item.activity.subscriptions_enabled: + if user.subscribed_activity_set.filter(announcement=item).exists(): + if item.id in user_hidden_announcements: + hidden.append(item) + else: + visible.append(item) + else: + unsubscribed.append(item) + + return visible, hidden, unsubscribed + + +class RawPaginationData(TypedDict, Generic[T]): + club_items: Sequence[Announcement] + items: Page[T] + page_num: int + prev_page: int + next_page: int + more_items: bool + page_obj: Paginator[T] + + +def paginate_announcements_list_raw( + request: HttpRequest, + items: Sequence[T], + visible_club_items: Sequence[Announcement] = (), + *, + query_param: str = "page", +) -> RawPaginationData[T]: + """Return the raw data for paginating announcements. + + Args: + request: The :class:`django.http.HttpRequest` object. + items: The list of items to paginate. + visible_club_items: The list of club announcements to paginate and add to the context. + query_param: The ``request.GET`` parameter to use for the page number. + + Returns: + A dictionary intended to be merged into the context. """ + DEFAULT_PAGE_NUM = 1 - if request.GET.get("page", "INVALID").isdigit(): - page_num = int(request.GET["page"]) + num = request.GET.get(query_param, "") + if num.isdigit(): + page_num = int(num) else: page_num = DEFAULT_PAGE_NUM @@ -286,11 +369,34 @@ def paginate_announcements_list(request, context, items): prev_page = items.previous_page_number() if items.has_previous() else 0 next_page = items.next_page_number() if more_items else 0 - context.update( - {"items": items, "page_num": page_num, "prev_page": prev_page, "next_page": next_page, "more_items": more_items, "page_obj": paginator} + # limit to 15 to prevent extreme slowdowns for large amounts + # of club announcements + club_items = visible_club_items[:15] + + return RawPaginationData( + club_items=club_items, + items=items, + page_num=page_num, + prev_page=prev_page, + next_page=next_page, + more_items=more_items, + page_obj=paginator, ) - return context, items + +def paginate_announcements_list( + request, context: dict[str, Any], items: Sequence[T], visible_club_items: Sequence[Announcement] = () +) -> tuple[dict[str, Any], Page[T]]: + """Paginate ``items`` in groups of 15 + + Returns: + A tuple of the updated context and the page. + """ + new_ctx = paginate_announcements_list_raw(request, items, visible_club_items) + context.update(new_ctx) + context["all_items"] = context["items"] + + return context, new_ctx["items"] def get_tjstar_mapping(user): @@ -383,7 +489,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 @@ -425,13 +531,16 @@ def dashboard_view(request, show_widgets=True, show_expired=False, ignore_dashbo events_admin = user.has_admin_permission("events") if not show_expired: - show_expired = "show_expired" in request.GET + show_expired = request.GET.get("show_expired") == "1" show_all = request.GET.get("show_all", "0") != "0" if "show_all" not in request.GET and request.user.is_eighthoffice: # Show all by default to 8th period office show_all = True + if not show_hidden_club: + show_hidden_club = "show_hidden_club" in request.GET + is_index_page = request.path_info in ["/", ""] context = { @@ -441,18 +550,39 @@ def dashboard_view(request, show_widgets=True, show_expired=False, ignore_dashbo "events_admin": events_admin, "is_index_page": is_index_page, "show_all": show_all, + "show_hidden_club": show_hidden_club, "show_expired": show_expired, "show_tjstar": settings.TJSTAR_BANNER_START_DATE <= now.date() <= settings.TJSTAR_DATE, + "user_sponsor_obj": EighthSponsor.objects.filter(user=request.user).first(), } + 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) + visible_club_items, hidden_club_items, unsubscribed_club_announcements = filter_club_announcements(user, user_hidden_announcements, club_items) + + if not show_hidden_club: + # Dashboard + context, items = paginate_announcements_list(request, context, items, visible_club_items) + else: + # Club announcements only + context, items = paginate_announcements_list(request, context, visible_club_items + hidden_club_items, visible_club_items=[]) + + # add club announcement pagination for non-subscribed + raw_pagination_data = paginate_announcements_list_raw( + request, + unsubscribed_club_announcements, + query_param="unsubscribed_page", + ) + # namespace the pagination data for unsubscribed club announcements so it doesn't + # conflict with other pagination data + context["unsubscribed"] = raw_pagination_data + context["all_items"] = (*context["items"], *context["unsubscribed"]["items"]) if ignore_dashboard_types is None: ignore_dashboard_types = [] @@ -482,6 +612,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" + if 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..9a9be1aeb97 --- /dev/null +++ b/intranet/apps/eighth/forms/activities.py @@ -0,0 +1,33 @@ +from django import forms +from django.contrib.auth import get_user_model + +from ..models import EighthActivity + + +class ActivitySettingsForm(forms.ModelForm): + def __init__(self, *args, sponsors=None, **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" + + sponsors_list = "; ".join([str(sponsor) for sponsor in sponsors]) if sponsors else "no sponsors" + + self.fields["club_sponsors"].help_text = ( + f"Teacher moderators can post and manage this club's announcements. " + f"These are in addition to the activity's eighth period sponsors ({sponsors_list})." + ) + + 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 0f4d8953252..c23ae83fa90 100644 --- a/intranet/apps/eighth/forms/admin/activities.py +++ b/intranet/apps/eighth/forms/admin/activities.py @@ -1,5 +1,6 @@ +from __future__ import annotations + import logging -from typing import List # noqa from django import forms, http from django.contrib.auth import get_user_model @@ -11,7 +12,7 @@ class ActivityDisplayField(forms.ModelChoiceField): - cancelled_acts = None # type: List[EighthActivity] + cancelled_acts: list[EighthActivity] | None = None def __init__(self, *args, **kwargs): if "block" in kwargs: @@ -118,7 +119,7 @@ def __init__(self, *args, label="Activities", block=None, **kwargs): # pylint: class ActivityForm(forms.ModelForm): - def __init__(self, *args, **kwargs): + def __init__(self, *args, sponsors=None, **kwargs): super().__init__(*args, **kwargs) for fieldname in ["sponsors", "rooms", "users_allowed", "groups_allowed", "users_blacklisted"]: @@ -136,9 +137,24 @@ 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." + + sponsors_list = "; ".join([str(sponsor) for sponsor in sponsors]) if sponsors else "no sponsors" + + self.fields["club_sponsors"].help_text = ( + f"Teacher moderators can post and manage this club's announcements. " + f"These are in addition to the activity's eighth period sponsors ({sponsors_list})." + ) + + self.fields["club_sponsors"].label = "Teacher moderators" + 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 = { @@ -152,6 +168,13 @@ def __init__(self, *args, **kwargs): "seniors_allowed", } + self.club_announcements_fields = { + "subscriptions_enabled", + "club_sponsors", + "officers", + "subscribers", + } + class Meta: model = EighthActivity fields = [ @@ -181,6 +204,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 6fca981ebe8..264aba5386e 100644 --- a/intranet/apps/eighth/models.py +++ b/intranet/apps/eighth/models.py @@ -74,7 +74,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) @@ -83,6 +83,9 @@ class EighthSponsor(AbstractBaseEighthModel): history = HistoricalRecords() + def __str__(self): + return self.name + class Meta: unique_together = (("first_name", "last_name", "user", "online_attendance", "full_time", "department"),) ordering = ("last_name", "first_name") @@ -106,9 +109,6 @@ def to_be_assigned(self) -> bool: """ return any(x in self.name.lower() for x in ["to be assigned", "to be determined", "to be announced"]) - def __str__(self): - return self.name - class EighthRoom(AbstractBaseEighthModel): """Represents a room in which an eighth period activity can be held. @@ -185,6 +185,7 @@ class EighthActivity(AbstractBaseEighthModel): sponsors (:obj:`list` of :obj:`EighthSponsor`): The default activity-level sponsors for the activity. On an EighthScheduledActivity basis, you should NOT query this field. Instead, use scheduled_activity.get_true_sponsors() + officers (:obj:`list` of :obj:`User`): The activity's officers as chosen by a club sponsor. rooms (:obj:`list` of :obj:`EighthRoom`): The default activity-level rooms for the activity. On an EighthScheduledActivity basis, you should NOT query this field. Use scheduled_activity.get_true_rooms() @@ -231,6 +232,7 @@ class EighthActivity(AbstractBaseEighthModel): favorites (:obj:`list` of :obj:`User`): A ManyToManyField of User objects who have favorited the activity. similarities (:obj:`list` of :obj:`EighthActivitySimilarity`): A ManyToManyField of EighthActivitySimilarity objects which are similar to this activity. + subscribers (:obj:`list` of :obj:`User`): Individual users subscribed to this activity's announcements. deleted (bool): Whether the activity still technically exists in the system, but was marked to be deleted. """ @@ -268,6 +270,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 1e864654a4e..7ce1fb8828b 100644 --- a/intranet/apps/eighth/serializers.py +++ b/intranet/apps/eighth/serializers.py @@ -92,7 +92,14 @@ class EighthBlockDetailSerializer(serializers.Serializer): comments = serializers.CharField(max_length=100) def process_scheduled_activity( - self, scheduled_activity, request=None, user=None, favorited_activities=None, recommended_activities=None, available_restricted_acts=None + self, + scheduled_activity, + request=None, + user=None, + favorited_activities=None, + subscribed_activities=None, + recommended_activities=None, + available_restricted_acts=None, ): activity = scheduled_activity.activity if user: @@ -127,6 +134,8 @@ def process_scheduled_activity( "description": activity.description, "cancelled": scheduled_activity.cancelled, "favorited": activity.id in favorited_activities, + "subscribed_to": activity.id in subscribed_activities, + "subscriptions_enabled": activity.subscriptions_enabled, "roster": { "count": 0, "capacity": 0, @@ -161,12 +170,26 @@ def process_scheduled_activity( return activity_info def get_activity( - self, user, favorited_activities, recommended_activities, available_restricted_acts, block_id, activity_id, scheduled_activity=None + self, + user, + favorited_activities, + subscribed_activities, + recommended_activities, + available_restricted_acts, + block_id, + activity_id, + scheduled_activity=None, ): if scheduled_activity is None: scheduled_activity = EighthScheduledActivity.objects.get(block_id=block_id, activity_id=activity_id) return self.process_scheduled_activity( - scheduled_activity, self.context["request"], user, favorited_activities, recommended_activities, available_restricted_acts + scheduled_activity, + self.context["request"], + user, + favorited_activities, + subscribed_activities, + recommended_activities, + available_restricted_acts, ) def get_scheduled_activity(self, scheduled_activity_id): @@ -179,9 +202,11 @@ def fetch_activity_list_with_metadata(self, block): if user: favorited_activities = set(user.favorited_activity_set.values_list("id", flat=True)) recommended_activities = user.recommended_activities + subscribed_activities = set(user.subscribed_activity_set.values_list("id", flat=True)) else: favorited_activities = set() recommended_activities = set() + subscribed_activities = set() available_restricted_acts = EighthActivity.restricted_activities_available_to_user(user) @@ -202,7 +227,7 @@ def fetch_activity_list_with_metadata(self, block): for scheduled_activity in scheduled_activities: # Avoid re-fetching scheduled_activity. activity_info = self.get_activity( - user, favorited_activities, recommended_activities, available_restricted_acts, None, None, scheduled_activity + user, favorited_activities, subscribed_activities, recommended_activities, available_restricted_acts, None, None, scheduled_activity ) activity = scheduled_activity.activity scheduled_activity_to_activity_map[scheduled_activity.id] = activity.id diff --git a/intranet/apps/eighth/urls.py b/intranet/apps/eighth/urls.py index 5652682d0de..2d47e43df7f 100644 --- a/intranet/apps/eighth/urls.py +++ b/intranet/apps/eighth/urls.py @@ -14,6 +14,8 @@ re_path(r"^/leave$", signup.leave_waitlist_view, name="leave_waitlist"), re_path(r"^/seen_feature$", signup.seen_new_feature_view, name="seen_new_feature"), re_path(r"^/signup/multi$", signup.eighth_multi_signup_view, name="eighth_multi_signup"), + re_path(r"^/signup/subscribe/(?P\d+)$", signup.subscribe_to_club, name="subscribe_to_club"), + re_path(r"^/signup/unsubscribe/(?P\d+)$", signup.unsubscribe_from_club, name="unsubscribe_from_club"), re_path(r"^/toggle_favorite$", signup.toggle_favorite_view, name="eighth_toggle_favorite"), re_path(r"^/absences$", attendance.eighth_absences_view, name="eighth_absences"), re_path(r"^/absences/(?P\d+)$", attendance.eighth_absences_view, name="eighth_absences"), @@ -37,6 +39,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 563ec3f2fec..32017a3d84c 100644 --- a/intranet/apps/eighth/views/activities.py +++ b/intranet/apps/eighth/views/activities.py @@ -5,10 +5,11 @@ from io import BytesIO 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 reportlab.lib.pagesizes import letter @@ -20,8 +21,9 @@ from ....utils.helpers import is_entirely_digit from ....utils.serialization import safe_json from ...auth.decorators import deny_restricted +from ..forms.activities import ActivitySettingsForm from ..forms.admin.activities import ActivityMultiSelectForm -from ..models import EighthActivity, EighthBlock, EighthScheduledActivity, EighthSignup +from ..models import EighthActivity, EighthBlock, EighthScheduledActivity, EighthSignup, EighthSponsor from ..utils import get_start_date logger = logging.getLogger(__name__) @@ -50,6 +52,27 @@ def activity_view(request, activity_id=None): return render(request, "eighth/activity.html", context) +@login_required +def settings_view(request, activity_id=None): + activity = get_object_or_404(EighthActivity, id=activity_id) + + if not (EighthSponsor.objects.filter(user=request.user).exists() or request.user in activity.club_sponsors.all()): + raise Http404 + + if request.method == "POST": + form = ActivitySettingsForm(request.POST, instance=activity, sponsors=activity.sponsors.all()) + if form.is_valid(): + form.save() + else: + messages.error(request, "There was an error saving the activity settings.") + else: + form = ActivitySettingsForm(instance=activity, sponsors=activity.sponsors.all()) + + 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/admin/activities.py b/intranet/apps/eighth/views/admin/activities.py index 919140edd6d..704bbcf5e39 100644 --- a/intranet/apps/eighth/views/admin/activities.py +++ b/intranet/apps/eighth/views/admin/activities.py @@ -48,7 +48,7 @@ def edit_activity_view(request, activity_id): raise http.Http404 from e if request.method == "POST": - form = ActivityForm(request.POST, instance=activity) + form = ActivityForm(request.POST, instance=activity, sponsors=activity.sponsors.all()) if form.is_valid(): try: # Check if sponsor change @@ -148,7 +148,11 @@ def edit_activity_view(request, activity_id): messages.success(request, "Notifying students of this room change.") room_changed_activity_email.delay(activity, old_rooms, EighthRoom.objects.filter(id__in=new_room_ids)) - form.save() + activity = form.save() + activity.subscribers.add(*[sponsor.user for sponsor in form.cleaned_data["sponsors"]]) + activity.subscribers.add(*form.cleaned_data["club_sponsors"], *form.cleaned_data["officers"]) + activity.save() + except forms.ValidationError as error: error = str(error) messages.error(request, error) @@ -179,7 +183,7 @@ def edit_activity_view(request, activity_id): else: messages.error(request, "Error adding activity.") else: - form = ActivityForm(instance=activity) + form = ActivityForm(instance=activity, sponsors=activity.sponsors.all()) activities = EighthActivity.undeleted_objects.order_by("name") diff --git a/intranet/apps/eighth/views/signup.py b/intranet/apps/eighth/views/signup.py index 97be5c56d8a..00e9cd35846 100644 --- a/intranet/apps/eighth/views/signup.py +++ b/intranet/apps/eighth/views/signup.py @@ -386,6 +386,34 @@ 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 activity.sponsors.filter(user=request.user).exists() or request.user in activity.club_sponsors.all(): + messages.error(request, "You cannot unsubscribe from an activity you sponsor.") + return redirect("club_announcements") + + 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/search/views.py b/intranet/apps/search/views.py index 3c0468d48c8..66b5df3f209 100644 --- a/intranet/apps/search/views.py +++ b/intranet/apps/search/views.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging from django.conf import settings @@ -7,7 +9,7 @@ from django.shortcuts import redirect, render from ...utils.helpers import is_entirely_digit -from ..announcements.models import AnnouncementManager +from ..announcements.models import Announcement, AnnouncementManager from ..auth.decorators import deny_restricted from ..eighth.models import EighthActivity from ..enrichment.models import EnrichmentActivity @@ -214,14 +216,24 @@ def do_courses_search(q): return Course.objects.filter(filter_query).order_by("name") -def do_announcements_search(q, user): +def do_announcements_search(q, user) -> tuple[list[Announcement], list[Announcement]]: + """Search for announcements. + + Returns: + A tuple of the announcements and club announcements + """ filter_query = get_query(q, ["title", "content"]) entries = AnnouncementManager().visible_to_user(user).filter(filter_query).order_by("title") - final_entries = [] + club_announcements = [] + announcements = [] for e in entries: - if e.is_this_year: - final_entries.append(e) - return final_entries + if not e.is_this_year: + continue + if e.activity is None: + announcements.append(e) + else: + club_announcements.append(e) + return (announcements, club_announcements) def do_events_search(q): @@ -266,7 +278,7 @@ def search_view(request): users = sorted(users, key=lambda u: (u.last_name, u.first_name)) activities = do_activities_search(q) - announcements = do_announcements_search(q, request.user) + announcements, club_announcements = do_announcements_search(q, request.user) events = do_events_search(q) enrichments = do_enrichment_search(q) if settings.ENABLE_ENRICHMENT_APP else [] classes = do_courses_search(q) @@ -282,6 +294,7 @@ def search_view(request): "search_query": q, "search_results": users, # User objects "announcements": announcements, # Announcement objects + "club_announcements": club_announcements, # Club Announcement objects "events": events, # Event objects "enrichments": enrichments, # EnrichmentActivity objects "activities": activities, # EighthActivity objects diff --git a/intranet/apps/users/models.py b/intranet/apps/users/models.py index d592100c261..4ff84fe0bde 100644 --- a/intranet/apps/users/models.py +++ b/intranet/apps/users/models.py @@ -924,6 +924,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 3dd7ee6d7c3..2fbdcbbef79 100644 --- a/intranet/settings/__init__.py +++ b/intranet/settings/__init__.py @@ -344,6 +344,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..f63c2dd6486 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,27 @@ 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; +} + +.announcements { + .announcement-banner { + background-color: #000; + } + + .announcement-link { + color: #a3a3a3; + } +} diff --git a/intranet/static/css/dashboard.scss b/intranet/static/css/dashboard.scss index 0a4f5809ed8..0a0c7a98a09 100644 --- a/intranet/static/css/dashboard.scss +++ b/intranet/static/css/dashboard.scss @@ -1,176 +1,336 @@ @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: #e0e0e0; + padding: 10px; + margin-bottom: 15px; + margin-top: 10px; + align-items: center; + border-radius: 5px; + line-height: 1.5; + } + + .announcement-link { + color: #6c6c6c; + font-weight: bold; + text-decoration: underline; + } + + .announcement-link:hover { + color: #464646; + } +} - &.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 +359,63 @@ */ @media print { - div.main div.announcements.primary-content { - position: absolute; - top: 0; - padding: 0; - min-width: initial; - max-width: initial; + 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.announcements-header .announcements-icon-wrapper * { - visibility: hidden; - } - 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..0003dbb1ee5 100644 --- a/intranet/static/js/announcement.form.js +++ b/intranet/static/js/announcement.form.js @@ -1,3 +1,24 @@ +function zero(v) { + return v < 10 ? "0" + v : v; +} + +function dateFormat(date) { + return (date.getFullYear() + "-" + + zero(date.getMonth() + 1) + "-" + + zero(date.getDate()) + " 23:59:59"); +} + +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)); +} + /* global $ */ $(function() { $("select#id_groups").selectize({ @@ -5,12 +26,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,22 +66,21 @@ $(function() { var editor = CKEDITOR.replace("content", { width: "600px" }); - var end_index = 0; editor.on("instanceReady", function () { // TODO: Don't duplicate this function. Bad! var text = editor.getData(); dates = chrono.parse(text) .sort(function (a, b) { - var a_date = a.end ? a.end.date() : a.start.date(); - var b_date = b.end ? b.end.date() : b.start.date(); - return b_date.getTime() - a_date.getTime(); + var aDate = a.end ? a.end.date() : a.start.date(); + var bDate = b.end ? b.end.date() : b.start.date(); + return bDate.getTime() - aDate.getTime(); }) .filter(function (val, ind, ary) { if (ind) { - var a_date = val.end ? val.end.date() : val.start.date(); - var b_date = ary[ind - 1].end ? ary[ind - 1].end.date() : ary[ind - 1].start.date(); - return !ind || a_date.getTime() != b_date.getTime(); + var aDate = val.end ? val.end.date() : val.start.date(); + var bDate = ary[ind - 1].end ? ary[ind - 1].end.date() : ary[ind - 1].start.date(); + return !ind || aDate.getTime() != bDate.getTime(); } else { return true; } @@ -70,8 +91,9 @@ $(function() { else $(".exp-header").css("display", "none"); for (var i = 0; i < dates.length; i++) { - var use_date = dates[i].end ? dates[i].end.date() : dates[i].start.date(); - $(".exp-list").append(`
  • "${dates[i].text}" - ${use_date}
  • `); + var useDate = dates[i].end ? dates[i].end.date() : dates[i].start.date(); + var displayDate = useDate.toDateString(); + $(".exp-list").append(`
  • "${dates[i].text}" - ${displayDate}
  • `); } }); @@ -80,15 +102,15 @@ $(function() { var text = editor.getData(); dates = chrono.parse(text) .sort(function (a, b) { - var a_date = a.end ? a.end.date() : a.start.date(); - var b_date = b.end ? b.end.date() : b.start.date(); - return b_date.getTime() - a_date.getTime(); + var aDate = a.end ? a.end.date() : a.start.date(); + var bDate = b.end ? b.end.date() : b.start.date(); + return bDate.getTime() - aDate.getTime(); }) .filter(function (val, ind, ary) { if (ind) { - var a_date = val.end ? val.end.date() : val.start.date(); - var b_date = ary[ind - 1].end ? ary[ind - 1].end.date() : ary[ind - 1].start.date(); - return !ind || a_date.getTime() != b_date.getTime(); + var aDate = val.end ? val.end.date() : val.start.date(); + var bDate = ary[ind - 1].end ? ary[ind - 1].end.date() : ary[ind - 1].start.date(); + return !ind || aDate.getTime() != bDate.getTime(); } else { return true; } @@ -99,8 +121,33 @@ $(function() { else $(".exp-header").css("display", "none"); for (var i = 0; i < dates.length; i++) { - var use_date = dates[i].end ? dates[i].end.date() : dates[i].start.date(); - $(".exp-list").append(`
  • "${dates[i].text}" - ${use_date}
  • `); + var useDate = dates[i].end ? dates[i].end.date() : dates[i].start.date(); + var displayDate = useDate.toDateString(); + $(".exp-list").append(`
  • "${dates[i].text}" - ${displayDate}
  • `); } }); + + var exp = $("#id_expiration_date"); + + if (exp.val() === "" || exp.val() === "3000-01-01 00:00:00") { + 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); + }); }); diff --git a/intranet/static/js/dashboard/announcements.js b/intranet/static/js/dashboard/announcements.js index b22f2e26db2..25c34111952 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"); + }); - $("div[data-placeholder]").on("keydown keypress input", function() { + $(".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); + }); + + $(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,160 @@ $(document).ready(function() { } }); - function updatePartiallyHidden() { - if(window.disable_partially_hidden_announcements) { - return; - } + $(".subscribed-filter").click(function () { + $(".unsubscribed-filter").removeClass("active"); + $("#non-subscriptions-pagination").hide(); - $(".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"); - } - }); + $("#subscriptions-pagination").show(); + $(this).addClass("active"); + + filterClubAnnouncements(); + }); + + $(".unsubscribed-filter").click(function () { + $(".subscribed-filter").removeClass("active"); + $("#subscriptions-pagination").hide(); + + $("#non-subscriptions-pagination").show(); + $(this).addClass("active"); + + filterClubAnnouncements(); + }); + + const params = new URLSearchParams(window.location.search); + if (params.get("flip_to") == "unsubscribed") { + $(".unsubscribed-filter").click(); } - 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; - } +function updatePartiallyHidden() { + if (window.disable_partially_hidden_announcements) { + return; + } - var hidden = announcement.hasClass("hidden"); - var action = hidden ? "show" : "hide"; + $(".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"); + } + }); +} - $.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; + } - setTimeout(function() { + var hidden = announcement.hasClass("hidden"); + var action = hidden ? "show" : "hide"; + + $.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+/); + // 15 is the cap, and prevent clicking on the button too fast + if(numAnnouncements != 15 && !announcement.hasClass("announcement-read")) { + numAnnouncementsSpan.text(numAnnouncements - 1); + announcement.addClass("announcement-read"); + } + $(".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(); +} 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..b69b7141970 100644 --- a/intranet/templates/announcements/announcement.html +++ b/intranet/templates/announcements/announcement.html @@ -7,9 +7,27 @@ {% endblock %} -
      +