From e95fc487739060693fc8de12d9d854e35a49dee6 Mon Sep 17 00:00:00 2001 From: Rohan Moniz <60864468+rm03@users.noreply.github.com> Date: Thu, 4 Jan 2024 18:48:31 -0500 Subject: [PATCH] Individual Application Extensions (#602) * add model * add serializer and viewset * update application end time validation logic * update extension serializer and add frontend * update validation logic and address comments * send email upon extension + nit * nit --- backend/clubs/admin.py | 2 + .../migrations/0091_applicationextension.py | 47 +++++++++++ backend/clubs/models.py | 34 ++++++++ backend/clubs/serializers.py | 81 +++++++++++++++++++ backend/clubs/urls.py | 5 ++ backend/clubs/views.py | 37 ++++++--- .../emails/application_extension.html | 22 +++++ .../ClubEditPage/ApplicationsPage.tsx | 52 +++++++++++- frontend/components/ModelForm.tsx | 7 +- .../application/[application]/index.tsx | 18 ----- 10 files changed, 275 insertions(+), 30 deletions(-) create mode 100644 backend/clubs/migrations/0091_applicationextension.py create mode 100644 backend/templates/emails/application_extension.html 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 f0b2c1874..a26531e6c 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1592,6 +1592,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 diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index 2a303bb47..48fc8d84a 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -23,6 +23,7 @@ AdminNote, Advisor, ApplicationCommittee, + ApplicationExtension, ApplicationMultipleChoice, ApplicationQuestion, ApplicationQuestionResponse, @@ -2424,6 +2425,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 6c6fbbc85..28a8bf87e 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, @@ -113,6 +114,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 0390b7b63..19530ec47 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -70,6 +70,7 @@ from clubs.models import ( AdminNote, Advisor, + ApplicationExtension, ApplicationMultipleChoice, ApplicationQuestion, ApplicationQuestionResponse, @@ -125,6 +126,7 @@ from clubs.serializers import ( AdminNoteSerializer, AdvisorSerializer, + ApplicationExtensionSerializer, ApplicationQuestionResponseSerializer, ApplicationQuestionSerializer, ApplicationSubmissionCSVSerializer, @@ -4456,10 +4458,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!"} ) @@ -4811,12 +4816,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): @@ -4956,6 +4963,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. 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 %} +

Application Extension for {{ application_name }}

+

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/frontend/components/ClubEditPage/ApplicationsPage.tsx b/frontend/components/ClubEditPage/ApplicationsPage.tsx index b3c7aec89..af987ab01 100644 --- a/frontend/components/ClubEditPage/ApplicationsPage.tsx +++ b/frontend/components/ClubEditPage/ApplicationsPage.tsx @@ -23,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; @@ -494,6 +500,14 @@ export default function ApplicationsPage({ { label: 'Graduation Year', name: 'graduation_year' }, ] + const extensionTableFields = [ + { label: 'First Name', name: 'first_name' }, + { label: 'Last Name', name: 'last_name' }, + { label: 'Username', name: 'username' }, + { label: 'Graduation Year', name: 'graduation_year' }, + { label: 'Extension End Time', name: 'end_time' }, + ] + const columns = useMemo( () => responseTableFields.map(({ label, name }) => ({ @@ -802,6 +816,42 @@ export default function ApplicationsPage({ )} +

+
+ {currentApplication != null ? ( + <> + + Extensions + + + + + + } + tableFields={extensionTableFields} + confirmDeletion + searchableColumns={['username']} + noun="Extension" + /> + + ) : ( + + )} +
{showModal && ( ReactElement | string noun?: string @@ -112,6 +113,7 @@ type ModelTableProps = { tableFields: TableField[] filterOptions?: FilterOption[] objects: ModelObject[] + searchableColumns?: string[] allowEditing?: boolean allowDeletion?: boolean confirmDeletion?: boolean @@ -131,6 +133,7 @@ export const ModelTable = ({ tableFields, filterOptions, objects, + searchableColumns, allowEditing = false, allowDeletion = false, confirmDeletion = false, @@ -216,7 +219,7 @@ export const ModelTable = ({ { fields, tableFields, filterOptions, + searchableColumns, onUpdate, currentTitle, noun = 'Object', @@ -466,6 +470,7 @@ export const ModelForm = (props: ModelFormProps): ReactElement => { noun={noun} tableFields={tableFields} filterOptions={filterOptions} + searchableColumns={searchableColumns} objects={objects} allowDeletion={allowDeletion} confirmDeletion={confirmDeletion} diff --git a/frontend/pages/club/[club]/application/[application]/index.tsx b/frontend/pages/club/[club]/application/[application]/index.tsx index 7de1f5679..9e37b0ebb 100644 --- a/frontend/pages/club/[club]/application/[application]/index.tsx +++ b/frontend/pages/club/[club]/application/[application]/index.tsx @@ -194,24 +194,6 @@ const ApplicationPage = ({ } } - // submissions open & close error check - const applicationStartTime = moment.tz( - application.application_start_time, - 'America/New_York', - ) - - const applicationEndTime = moment.tz( - application.application_end_time, - 'America/New_York', - ) - const currentTime = moment.tz('America/New_York') - if ( - currentTime.valueOf() < applicationStartTime.valueOf() || - currentTime.valueOf() > applicationEndTime.valueOf() - ) { - submitErrors = 'This application is not currently open!' - } - if (submitErrors === null) { const body: any = { questionIds: [] } for (const [questionId, text] of Object.entries(values).filter(