diff --git a/backend/clubs/admin.py b/backend/clubs/admin.py index d7785dd18..15fd94b24 100644 --- a/backend/clubs/admin.py +++ b/backend/clubs/admin.py @@ -15,6 +15,7 @@ Advisor, ApplicationCommittee, ApplicationCycle, + ApplicationExtension, ApplicationMultipleChoice, ApplicationQuestion, ApplicationQuestionResponse, @@ -412,6 +413,7 @@ class ApplicationSubmissionAdmin(admin.ModelAdmin): admin.site.register(Asset) admin.site.register(ApplicationCommittee) +admin.site.register(ApplicationExtension) admin.site.register(ApplicationMultipleChoice) admin.site.register(ApplicationQuestion) admin.site.register(ApplicationQuestionResponse) diff --git a/backend/clubs/migrations/0091_applicationextension.py b/backend/clubs/migrations/0091_applicationextension.py new file mode 100644 index 000000000..43cd96efd --- /dev/null +++ b/backend/clubs/migrations/0091_applicationextension.py @@ -0,0 +1,47 @@ +# Generated by Django 3.2.18 on 2023-11-25 03:58 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("clubs", "0090_auto_20230106_1443"), + ] + + operations = [ + migrations.CreateModel( + name="ApplicationExtension", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("end_time", models.DateTimeField()), + ( + "application", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="extensions", + to="clubs.clubapplication", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={"unique_together": {("user", "application")}}, + ), + ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 42c172aee..5de78ece8 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1593,6 +1593,40 @@ def validate_template(cls, template): return all(t in cls.VALID_TEMPLATE_TOKENS for t in tokens) +class ApplicationExtension(models.Model): + """ + Represents an individual club application extension. + """ + + user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + application = models.ForeignKey( + ClubApplication, related_name="extensions", on_delete=models.CASCADE + ) + end_time = models.DateTimeField() + + def send_extension_mail(self): + context = { + "name": self.user.first_name, + "application_name": self.application.name, + "end_time": self.end_time, + "club": self.application.club.name, + "url": ( + f"https://pennclubs.com/club/{self.application.club.code}" + f"/application/{self.application.pk}/" + ), + } + + send_mail_helper( + name="application_extension", + subject=f"Application Extension for {self.application.name}", + emails=[self.user.email], + context=context, + ) + + class Meta: + unique_together = (("user", "application"),) + + class ApplicationCommittee(models.Model): """ Represents a committee for a particular club application. Each application @@ -1696,6 +1730,9 @@ class ApplicationSubmission(models.Model): def __str__(self): return f"{self.user.first_name}: {self.application.name}" + class Meta: + unique_together = (("user", "application", "committee"),) + class ApplicationQuestionResponse(models.Model): """ @@ -1724,6 +1761,9 @@ class ApplicationQuestionResponse(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + class Meta: + unique_together = (("question", "submission"),) + class QuestionResponse(models.Model): """ diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index 7b3c51316..b326eaf91 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -24,6 +24,7 @@ Advisor, ApplicationCommittee, ApplicationCycle, + ApplicationExtension, ApplicationMultipleChoice, ApplicationQuestion, ApplicationQuestionResponse, @@ -2443,6 +2444,86 @@ class Meta: fields = ("text", "multiple_choice", "question_type", "question") +class ApplicationExtensionSerializer(serializers.ModelSerializer): + first_name = serializers.CharField(source="user.first_name", read_only=True) + last_name = serializers.CharField(source="user.last_name", read_only=True) + username = serializers.CharField(source="user.username", read_only=False) + graduation_year = serializers.CharField( + source="user.profile.graduation_year", read_only=True + ) + + class Meta: + model = ApplicationExtension + fields = ( + "id", + "username", + "first_name", + "last_name", + "graduation_year", + "end_time", + ) + + def create(self, validated_data): + username = validated_data.get("user").pop("username") + validated_data["user"] = get_user_model().objects.get(username=username) + + application_pk = self.context["view"].kwargs.get("application_pk") + validated_data["application"] = ClubApplication.objects.filter( + pk=application_pk + ).first() + + return super().create(validated_data) + + def update(self, instance, validated_data): + if user_field := validated_data.pop("user", None): + username = user_field.pop("username") + user = get_user_model().objects.get(username=username) + instance.user = user + return super().update(instance, validated_data) + + def validate(self, data): + username = None + if user_field := data.get("user") or not self.instance: + username = user_field.get("username") + user = get_user_model().objects.filter(username=username).first() + if not user: + raise serializers.ValidationError("Please provide a valid username!") + + application_pk = self.context["view"].kwargs.get("application_pk") + application = ClubApplication.objects.filter(pk=application_pk).first() + + if not application: + raise serializers.ValidationError("Invalid application id!") + + extension_exists = ApplicationExtension.objects.filter( + user=user, application=application + ).exists() + modify_username = not self.instance or ( + username and self.instance.user.username != username + ) + + if modify_username and extension_exists: + raise serializers.ValidationError( + "An extension for this user and application already exists!" + ) + + extension_end_time = data.get("end_time") + if ( + extension_end_time + and extension_end_time <= application.application_end_time + ): + raise serializers.ValidationError( + "Extension end time must be greater than the application end time!" + ) + + return data + + def save(self): + extension_obj = super().save() + extension_obj.send_extension_mail() + return extension_obj + + class ApplicationSubmissionSerializer(serializers.ModelSerializer): committee = ApplicationCommitteeSerializer(required=False, read_only=True) responses = ApplicationQuestionResponseSerializer( diff --git a/backend/clubs/urls.py b/backend/clubs/urls.py index db433ec59..be60ae628 100644 --- a/backend/clubs/urls.py +++ b/backend/clubs/urls.py @@ -4,6 +4,7 @@ from clubs.views import ( AdminNoteViewSet, AdvisorViewSet, + ApplicationExtensionViewSet, ApplicationQuestionViewSet, ApplicationSubmissionUserViewSet, ApplicationSubmissionViewSet, @@ -120,6 +121,10 @@ basename="club-application-submissions", ) +applications_router.register( + r"extensions", ApplicationExtensionViewSet, basename="club-application-extensions" +) + router.register(r"booths", ClubBoothsViewSet, basename="club-booth") urlpatterns = [ diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 80f45b3bc..27e9bb4ed 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -38,7 +38,6 @@ TextField, Value, ) -from django.db.models.expressions import RawSQL from django.db.models.functions import SHA1, Concat, Lower, Trunc from django.db.models.query import prefetch_related_objects from django.http import HttpResponse @@ -71,6 +70,7 @@ AdminNote, Advisor, ApplicationCycle, + ApplicationExtension, ApplicationMultipleChoice, ApplicationQuestion, ApplicationQuestionResponse, @@ -127,6 +127,7 @@ AdminNoteSerializer, AdvisorSerializer, ApplicationCycleSerializer, + ApplicationExtensionSerializer, ApplicationQuestionResponseSerializer, ApplicationQuestionSerializer, ApplicationSubmissionCSVSerializer, @@ -4458,10 +4459,13 @@ def question_response(self, *args, **kwargs): # prevent submissions outside of the open duration now = timezone.now() - if ( - now > application.application_end_time - or now < application.application_start_time - ): + extension = application.extensions.filter(user=self.request.user).first() + end_time = ( + max(extension.end_time, application.application_end_time) + if extension + else application.application_end_time + ) + if now > end_time or now < application.application_start_time: return Response( {"success": False, "detail": "This application is not currently open!"} ) @@ -4481,8 +4485,11 @@ def question_response(self, *args, **kwargs): submissions page""", } ) - submission = ApplicationSubmission.objects.create( - user=self.request.user, application=application, committee=committee, + submission, _ = ApplicationSubmission.objects.get_or_create( + user=self.request.user, + application=application, + committee=committee, + archived=False, ) key = f"applicationsubmissions:{application.id}" @@ -4506,9 +4513,11 @@ def question_response(self, *args, **kwargs): ): text = question_data.get("text", None) if text is not None and text != "": - obj = ApplicationQuestionResponse.objects.create( - text=text, question=question, submission=submission, - ).save() + obj, _ = ApplicationQuestionResponse.objects.update_or_create( + question=question, + submission=submission, + defaults={"text": text}, + ) response = Response(ApplicationQuestionResponseSerializer(obj).data) elif question_type == ApplicationQuestion.MULTIPLE_CHOICE: multiple_choice_value = question_data.get("multipleChoice", None) @@ -4516,13 +4525,12 @@ def question_response(self, *args, **kwargs): multiple_choice_obj = ApplicationMultipleChoice.objects.filter( question=question, value=multiple_choice_value ).first() - obj = ApplicationQuestionResponse.objects.create( - multiple_choice=multiple_choice_obj, + obj, _ = ApplicationQuestionResponse.objects.update_or_create( question=question, submission=submission, - ).save() + defaults={"multiple_choice": multiple_choice_obj}, + ) response = Response(ApplicationQuestionResponseSerializer(obj).data) - submission.save() return response @action(detail=False, methods=["get"]) @@ -4590,10 +4598,10 @@ def questions(self, *args, **kwargs): response = ( ApplicationQuestionResponse.objects.filter( - submission__user=self.request.user, submission__archived=False + question=question, + submission__user=self.request.user, + submission__archived=False, ) - .filter(question__prompt=question.prompt) - .order_by("-updated_at") .select_related("submission", "multiple_choice", "question") .prefetch_related("question__committees", "question__multiple_choice") .first() @@ -4667,6 +4675,8 @@ def send_emails(self, *args, **kwargs): Send out acceptance/rejection emails for a particular application Dry run will validate that all emails have nonempty variables + + Allow resend will renotify submissions that have already been emailed --- requestBody: content: @@ -4674,6 +4684,8 @@ def send_emails(self, *args, **kwargs): schema: type: object properties: + allow_resend: + type: boolean dry_run: type: boolean email_type: @@ -4701,21 +4713,7 @@ def send_emails(self, *args, **kwargs): # Query for recent submissions with user and committee joined submissions = ApplicationSubmission.objects.filter( - application=app, - created_at__in=RawSQL( - """SELECT recent_time - FROM - (SELECT user_id, - committee_id, - application_id, - max(created_at) recent_time - FROM clubs_applicationsubmission - WHERE NOT archived - GROUP BY user_id, - committee_id, application_id) recent_subs""", - (), - ), - archived=False, + application=app, archived=False, ).select_related("user", "committee") dry_run = self.request.data.get("dry_run") @@ -4730,13 +4728,15 @@ def send_emails(self, *args, **kwargs): subject = f"Application Update for {app.name}" n, skip = 0, 0 + allow_resend = self.request.data.get("allow_resend") + acceptance_template = Template(app.acceptance_email) rejection_template = Template(app.rejection_email) mass_emails = [] for submission in submissions: if ( - submission.notified + (not allow_resend and submission.notified) or submission.status == ApplicationSubmission.PENDING or not (submission.reason and submission.user.email) ): @@ -4807,12 +4807,14 @@ def current(self, *args, **kwargs): - $ref: "#/components/schemas/ClubApplication" --- """ - qs = self.get_queryset() - return Response( - ClubApplicationSerializer( - qs.filter(application_end_time__gte=timezone.now()), many=True - ).data - ) + qs = self.get_queryset().prefetch_related("extensions") + now = timezone.now() + user = self.request.user + q = Q(application_end_time__gte=now) + if user.is_authenticated: + q |= Q(extensions__end_time__gte=now, extensions__user=user) + + return Response(ClubApplicationSerializer(qs.filter(q), many=True).data) @action(detail=True, methods=["post"]) def duplicate(self, *args, **kwargs): @@ -5186,21 +5188,7 @@ def get_operation_id(self, **kwargs): def get_queryset(self): return ( ApplicationSubmission.objects.filter( - application__is_wharton_council=True, - created_at__in=RawSQL( - """SELECT recent_time - FROM - (SELECT user_id, - committee_id, - application_id, - max(created_at) recent_time - FROM clubs_applicationsubmission - WHERE NOT archived - GROUP BY user_id, - committee_id, application_id) recent_subs""", - (), - ), - archived=False, + application__is_wharton_council=True, archived=False, ) .annotate( annotated_name=F("application__name"), @@ -5218,6 +5206,16 @@ def get_queryset(self): ) +class ApplicationExtensionViewSet(viewsets.ModelViewSet): + permission_classes = [ClubSensitiveItemPermission | IsSuperuser] + serializer_class = ApplicationExtensionSerializer + + def get_queryset(self): + return ApplicationExtension.objects.filter( + application__pk=self.kwargs["application_pk"] + ) + + class ApplicationSubmissionViewSet(viewsets.ModelViewSet): """ list: List submissions for a given club application. @@ -5231,30 +5229,9 @@ class ApplicationSubmissionViewSet(viewsets.ModelViewSet): http_method_names = ["get", "post"] def get_queryset(self): - # Use a raw SQL query to obtain the most recent (user, committee) pairs - # of application submissions for a specific application. - # Done by grouping by (user_id, commitee_id) and returning the most - # recent instance in each group, then selecting those instances - app_id = self.kwargs["application_pk"] submissions = ( - ApplicationSubmission.objects.filter( - application=app_id, - created_at__in=RawSQL( - """SELECT recent_time - FROM - (SELECT user_id, - committee_id, - application_id, - max(created_at) recent_time - FROM clubs_applicationsubmission - WHERE NOT archived - GROUP BY user_id, - committee_id, application_id) recent_subs""", - (), - ), - archived=False, - ) + ApplicationSubmission.objects.filter(application=app_id, archived=False,) .select_related("user__profile", "committee", "application__club") .prefetch_related( Prefetch( @@ -5319,23 +5296,7 @@ def export(self, *args, **kwargs): """ app_id = int(self.kwargs["application_pk"]) data = ( - ApplicationSubmission.objects.filter( - application=app_id, - created_at__in=RawSQL( - """SELECT recent_time - FROM - (SELECT user_id, - committee_id, - application_id, - max(created_at) recent_time - FROM clubs_applicationsubmission - WHERE NOT archived - GROUP BY user_id, - committee_id, application_id) recent_subs""", - (), - ), - archived=False, - ) + ApplicationSubmission.objects.filter(application=app_id, archived=False,) .select_related("user__profile", "committee", "application__club") .prefetch_related( Prefetch( @@ -5380,19 +5341,6 @@ def exportall(self, *args, **kwargs): ApplicationSubmission.objects.filter( application__is_wharton_council=True, application__application_cycle=cycle, - created_at__in=RawSQL( - """SELECT recent_time - FROM - (SELECT user_id, - committee_id, - application_id, - max(created_at) recent_time - FROM clubs_applicationsubmission - WHERE NOT archived - GROUP BY user_id, - committee_id, application_id) recent_subs""", - (), - ), archived=False, ) .select_related("application", "application__application_cycle") @@ -5547,21 +5495,7 @@ class ApplicationSubmissionUserViewSet(viewsets.ModelViewSet): def get_queryset(self): submissions = ( ApplicationSubmission.objects.filter( - user=self.request.user, - created_at__in=RawSQL( - """SELECT recent_time - FROM - (SELECT user_id, - committee_id, - application_id, - max(created_at) recent_time - FROM clubs_applicationsubmission - WHERE NOT archived - GROUP BY user_id, - committee_id, application_id) recent_subs""", - (), - ), - archived=False, + user=self.request.user, archived=False, ) .select_related("user__profile", "committee", "application__club") .prefetch_related( diff --git a/backend/templates/emails/application_extension.html b/backend/templates/emails/application_extension.html new file mode 100644 index 000000000..f3aeee637 --- /dev/null +++ b/backend/templates/emails/application_extension.html @@ -0,0 +1,22 @@ + +{% extends 'emails/base.html' %} + +{% block content %} +
Hello {{ name }},
+You have been granted an extension for {{ application_name }} by the officers of {{ club }} on Penn Clubs:
+The updated deadline to submit your application is {{ end_time }}. You can apply using the button below.
+ Apply +{% endblock %} diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index a25c71456..7646cc6ec 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -9,6 +9,7 @@ from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.core import mail +from django.core.cache import cache from django.core.management import call_command from django.test import Client, TestCase from django.urls import reverse @@ -1086,6 +1087,7 @@ def test_club_create_description_sanitize_good(self): }, content_type="application/json", ) + cache.clear() self.assertIn(resp.status_code, [200, 201], resp.content) resp = self.client.get(reverse("clubs-detail", args=("penn-labs",))) diff --git a/frontend/components/ClubEditPage/ApplicationsPage.tsx b/frontend/components/ClubEditPage/ApplicationsPage.tsx index a364e964b..af987ab01 100644 --- a/frontend/components/ClubEditPage/ApplicationsPage.tsx +++ b/frontend/components/ClubEditPage/ApplicationsPage.tsx @@ -2,6 +2,7 @@ import { Field, Form, Formik } from 'formik' import moment from 'moment-timezone' import React, { ReactElement, useEffect, useMemo, useState } from 'react' import Select from 'react-select' +import { toast } from 'react-toastify' import styled from 'styled-components' import { ALLBIRDS_GRAY, CLUBS_BLUE, MD, mediaMaxWidth, SNOW } from '~/constants' @@ -22,7 +23,13 @@ import { doApiRequest, getApiUrl, getSemesterFromDate } from '~/utils' import { Checkbox, Loading, Modal, Text } from '../common' import { Icon } from '../common/Icon' import Table from '../common/Table' -import { CheckboxField, SelectField, TextField } from '../FormComponents' +import { + CheckboxField, + DateTimeField, + SelectField, + TextField, +} from '../FormComponents' +import ModelForm from '../ModelForm' const StyledHeader = styled.div.attrs({ className: 'is-clearfix' })` margin-bottom: 20px; @@ -222,7 +229,7 @@ const NotificationModal = (props: { if (data.email_type.id === 'acceptance' && !data.dry_run) { const relevant = submissions.filter( (sub) => - sub.notified === false && + (data.allow_resend || !sub.notified) && sub.status === 'Accepted' && sub.reason, ) @@ -230,7 +237,7 @@ const NotificationModal = (props: { } else if (data.email_type.id === 'rejection' && !data.dry_run) { const relevant = submissions.filter( (sub) => - sub.notified === false && + (data.allow_resend || !sub.notified) && sub.status.startsWith('Rejected') && sub.reason, ) @@ -274,6 +281,23 @@ const NotificationModal = (props: { label="Dry Run" helpText="If selected, will return the number of emails the script would have sent out" /> +