From b009af348cf0bd554bfb8e28ba8815e3cccb1d8c Mon Sep 17 00:00:00 2001 From: Julian Weng Date: Sat, 6 Jan 2024 11:50:09 -0500 Subject: [PATCH 1/5] WC Admin application cycle edit/create table (#590) Add functionality for Wharton Council admins to manage application cycles. Supports CRUD on cycles, as well as editing of club applications on the cycle and individual deadline exceptions for clubs. --- .gitignore | 3 + ...lication_application_end_time_exception.py | 18 + .../migrations/0092_merge_20240106_1117.py | 13 + backend/clubs/models.py | 1 + backend/clubs/serializers.py | 20 ++ backend/clubs/urls.py | 12 +- backend/clubs/views.py | 276 ++++++++++++++- frontend/components/ModelForm.tsx | 4 + .../Settings/WhartonApplicationCycles.tsx | 318 ++++++++++++++++++ frontend/pages/wharton/[[...slug]].tsx | 6 + frontend/types.ts | 1 + 11 files changed, 663 insertions(+), 9 deletions(-) create mode 100644 backend/clubs/migrations/0091_clubapplication_application_end_time_exception.py create mode 100644 backend/clubs/migrations/0092_merge_20240106_1117.py create mode 100644 frontend/components/Settings/WhartonApplicationCycles.tsx diff --git a/.gitignore b/.gitignore index 3cd5296e7..35a1877f2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ # Python files __pycache__/ *.pyc +.python-version # Distribution /frontend/public/storybook/ @@ -27,6 +28,8 @@ db.sqlite3 # React node_modules/ +.yarn +.yarnrc.yml .next/ # Development Enviroment diff --git a/backend/clubs/migrations/0091_clubapplication_application_end_time_exception.py b/backend/clubs/migrations/0091_clubapplication_application_end_time_exception.py new file mode 100644 index 000000000..92377bdb5 --- /dev/null +++ b/backend/clubs/migrations/0091_clubapplication_application_end_time_exception.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.18 on 2023-11-17 22:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0090_auto_20230106_1443"), + ] + + operations = [ + migrations.AddField( + model_name="clubapplication", + name="application_end_time_exception", + field=models.BooleanField(blank=True, default=False), + ), + ] diff --git a/backend/clubs/migrations/0092_merge_20240106_1117.py b/backend/clubs/migrations/0092_merge_20240106_1117.py new file mode 100644 index 000000000..9656c2c26 --- /dev/null +++ b/backend/clubs/migrations/0092_merge_20240106_1117.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.18 on 2024-01-06 16:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0091_applicationextension"), + ("clubs", "0091_clubapplication_application_end_time_exception"), + ] + + operations = [] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 8955f7fda..5de78ece8 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1551,6 +1551,7 @@ class ClubApplication(CloneModel): description = models.TextField(blank=True) application_start_time = models.DateTimeField() application_end_time = models.DateTimeField() + application_end_time_exception = models.BooleanField(default=False, blank=True) name = models.TextField(blank=True) result_release_time = models.DateTimeField() application_cycle = models.ForeignKey( diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index 48fc8d84a..b326eaf91 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -23,6 +23,7 @@ AdminNote, Advisor, ApplicationCommittee, + ApplicationCycle, ApplicationExtension, ApplicationMultipleChoice, ApplicationQuestion, @@ -97,6 +98,24 @@ def save(self): return super().save() +class ApplicationCycleSerializer(serializers.ModelSerializer): + class Meta: + model = ApplicationCycle + fields = ["id", "name", "start_date", "end_date"] + + def validate(self, data): + """ + Check that start_date is before end_date. + """ + start_date = data.get("start_date") + end_date = data.get("end_date") + + if start_date and end_date and start_date >= end_date: + raise serializers.ValidationError("Start must be before end.") + + return data + + class TagSerializer(serializers.ModelSerializer): clubs = serializers.IntegerField(read_only=True) @@ -2800,6 +2819,7 @@ class Meta: "rejection_email", "application_start_time", "application_end_time", + "application_end_time_exception", "result_release_time", "external_url", "committees", diff --git a/backend/clubs/urls.py b/backend/clubs/urls.py index 28a8bf87e..be60ae628 100644 --- a/backend/clubs/urls.py +++ b/backend/clubs/urls.py @@ -51,6 +51,7 @@ UserZoomAPIView, WhartonApplicationAPIView, WhartonApplicationStatusAPIView, + WhartonCyclesView, YearViewSet, email_preview, ) @@ -78,6 +79,12 @@ router.register( r"external/members/(?P.+)", ExternalMemberListViewSet, basename="external" ) +router.register( + r"cycles", WhartonCyclesView, basename="wharton-applications-create", +) +router.register( + r"whartonapplications", WhartonApplicationAPIView, basename="wharton", +) router.register(r"submissions", ApplicationSubmissionUserViewSet, basename="submission") clubs_router = routers.NestedSimpleRouter(router, r"clubs", lookup="club") @@ -156,11 +163,6 @@ MeetingZoomWebhookAPIView.as_view(), name="webhooks-meeting", ), - path( - r"whartonapplications/", - WhartonApplicationAPIView.as_view(), - name="wharton-applications", - ), path( r"whartonapplications/status/", WhartonApplicationStatusAPIView.as_view(), diff --git a/backend/clubs/views.py b/backend/clubs/views.py index bbb6bcae6..27e9bb4ed 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -69,6 +69,7 @@ from clubs.models import ( AdminNote, Advisor, + ApplicationCycle, ApplicationExtension, ApplicationMultipleChoice, ApplicationQuestion, @@ -125,6 +126,7 @@ from clubs.serializers import ( AdminNoteSerializer, AdvisorSerializer, + ApplicationCycleSerializer, ApplicationExtensionSerializer, ApplicationQuestionResponseSerializer, ApplicationQuestionSerializer, @@ -4853,7 +4855,6 @@ def get_serializer_class(self): return ClubApplicationSerializer def get_queryset(self): - return ( ClubApplication.objects.filter(club__code=self.kwargs["club_code"],) .select_related("application_cycle", "club") @@ -4863,9 +4864,276 @@ def get_queryset(self): ) -class WhartonApplicationAPIView(generics.ListAPIView): +class WhartonCyclesView(viewsets.ModelViewSet): + """ + get: Return information about all Wharton Council application cycles + patch: Update application cycle and WC applications with cycle + clubs: list clubs with cycle + add_clubs: add clubs to cycle + remove_clubs_from_all: remove clubs from all cycles + """ + + permission_classes = [WhartonApplicationPermission | IsSuperuser] + # Designed to support partial updates, but ModelForm sends all fields here + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = ApplicationCycleSerializer + + def get_queryset(self): + return ApplicationCycle.objects.all().order_by("end_date") + + def update(self, *args, **kwargs): + """ + Updates times for all applications with cycle + """ + applications = ClubApplication.objects.filter( + application_cycle=self.get_object() + ) + str_start_date = self.request.data.get("start_date").replace("T", " ") + str_end_date = self.request.data.get("end_date").replace("T", " ") + time_format = "%Y-%m-%d %H:%M:%S%z" + start = ( + datetime.datetime.strptime(str_start_date, time_format) + if str_start_date + else self.get_object().start_date + ) + end = ( + datetime.datetime.strptime(str_end_date, time_format) + if str_end_date + else self.get_object().end_date + ) + for app in applications: + app.application_start_time = start + if app.application_end_time_exception: + continue + app.application_end_time = end + if app.result_release_time < app.application_end_time: + filler_time = app.application_end_time + datetime.timedelta(days=10) + app.result_release_time = filler_time + f = ["application_start_time", "application_end_time", "result_release_time"] + ClubApplication.objects.bulk_update(applications, f) + return super().update(*args, **kwargs) + + @action(detail=True, methods=["get"]) + def clubs(self, *args, **kwargs): + """ + Returns clubs in given cycle + --- + + requestBody: {} + responses: + "200": + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + active: + type: boolean + name: + type: string + cycle: + type: string + acceptance_email: + type: string + rejection_email: + type: string + application_start_time: + type: string + application_end_time: + type: string + result_release_time: + type: string + external_url: + type: string + committees: + type: array + items: + type: object + properties: + name: + type: string + questions: + type: array + items: + type: object + properties: + id: + type: integer + question_type: + type: integer + prompt: + type: string + word_limit: + type: integer + multiple_choice: + type: array + items: + type: object + properties: + value: + type: string + committees: + type: array + committee_question: + type: boolean + precedence: + type: integer + club: + type: string + description: + type: string + updated_at: + type: string + club_image_url: + type: string + --- + """ + cycle = self.get_object() + data = ClubApplication.objects.filter( + is_wharton_council=True, application_cycle=cycle, + ) + return Response(ClubApplicationSerializer(data, many=True).data) + + @action(detail=True, methods=["post"]) + def add_clubs(self, *args, **kwargs): + """ + Adds clubs to given cycle + --- + requestBody: + content: + application/json: + schema: + type: object + properties: + clubs: + type: array + items: + type: string + responses: + "200": + content: {} + --- + """ + cycle = self.get_object() + club_ids = self.request.data.get("clubs") + start = cycle.start_date + end = cycle.end_date + apps = ClubApplication.objects.filter(pk__in=club_ids) + for app in apps: + app.application_cycle = cycle + app.application_start_time = start + app.application_end_time = end + ClubApplication.objects.bulk_update( + apps, + ["application_cycle", "application_start_time", "application_end_time"], + ) + return Response([]) + + @action(detail=False, methods=["post"]) + def remove_clubs_from_all(self, *args, **kwargs): + """ + Remove selected clubs from any/all cycles + --- + requestBody: + content: + application/json: + schema: + type: object + properties: + clubs: + type: array + items: + type: string + responses: + "200": + content: {} + --- + """ + club_ids = self.request.data.get("clubs", []) + apps = ClubApplication.objects.filter(pk__in=club_ids) + for app in apps: + app.application_cycle = None + ClubApplication.objects.bulk_update( + apps, + ["application_cycle", "application_start_time", "application_end_time"], + ) + return Response([]) + + @action(detail=False, methods=["post"]) + def add_clubs_to_exception(self, *args, **kwargs): + """ + Exempt selected clubs from application cycle deadline + --- + requestBody: + content: + application/json: + schema: + type: object + properties: + clubs: + type: array + items: + type: object + properties: + id: + type: integer + application_end_time: + type: string + responses: + "200": + content: {} + --- + """ + clubs = self.request.data.get("clubs") + apps = [] + for club in clubs: + app = ClubApplication.objects.get(pk=club["id"]) + apps.append(app) + app.application_end_time = club["end_date"] + app.application_end_time_exception = True + ClubApplication.objects.bulk_update( + apps, ["application_end_time", "application_end_time_exception"], + ) + return Response([]) + + @action(detail=False, methods=["post"]) + def remove_clubs_from_exception(self, *args, **kwargs): + """ + Remove selected clubs from application cycle deadline exemption + --- + requestBody: + content: + application/json: + schema: + type: object + properties: + clubs: + type: array + items: + type: string + responses: + "200": + content: {} + --- + """ + club_ids = self.request.data.get("clubs", []) + apps = ClubApplication.objects.filter(pk__in=club_ids) + for app in apps: + app.application_end_time_exception = False + app.application_end_time = app.application_cycle.end_date + ClubApplication.objects.bulk_update( + apps, ["application_end_time", "application_end_time_exception"], + ) + return Response([]) + + +class WhartonApplicationAPIView(viewsets.ModelViewSet): """ - get: Return information about all Wharton Council club applications which are + list: Return information about all Wharton Council club applications which are currently on going """ @@ -4873,7 +5141,7 @@ class WhartonApplicationAPIView(generics.ListAPIView): serializer_class = ClubApplicationSerializer def get_operation_id(self, **kwargs): - return "List Wharton applications and details" + return f"{kwargs['operId']} Wharton Application" def get_queryset(self): now = timezone.now() diff --git a/frontend/components/ModelForm.tsx b/frontend/components/ModelForm.tsx index 8b55ae052..95af62886 100644 --- a/frontend/components/ModelForm.tsx +++ b/frontend/components/ModelForm.tsx @@ -1,4 +1,5 @@ import { Form, Formik } from 'formik' +import moment from 'moment' import { ReactElement, useEffect, useMemo, useState } from 'react' import styled from 'styled-components' @@ -355,6 +356,9 @@ export const ModelForm = (props: ModelFormProps): ReactElement => { data[key] !== null ) ) { + if (data[key] instanceof Date) { + data[key] = moment(data[key]).format('YYYY-MM-DD HH:mm:ssZ') + } flt[key] = data[key] } return flt diff --git a/frontend/components/Settings/WhartonApplicationCycles.tsx b/frontend/components/Settings/WhartonApplicationCycles.tsx new file mode 100644 index 000000000..8f0d9db72 --- /dev/null +++ b/frontend/components/Settings/WhartonApplicationCycles.tsx @@ -0,0 +1,318 @@ +import { Field } from 'formik' +import { ReactElement, useEffect, useState } from 'react' +import DatePicker from 'react-datepicker' +import Select from 'react-select' +import styled from 'styled-components' + +import { ClubApplication } from '~/types' +import { doApiRequest } from '~/utils' + +import { Checkbox, Icon, Loading, Modal, Subtitle, Text } from '../common' +import { DateTimeField, TextField } from '../FormComponents' +import ModelForm from '../ModelForm' + +const fields = ( + <> + + + + +) + +type Cycle = { + name: string + id: number | null +} + +type ClubOption = { + label: string + value: number +} + +type ExtensionOption = { + id: number + clubName: string + endDate: Date + exception?: boolean + changed: boolean +} + +const ScrollWrapper = styled.div` + overflow-y: auto; + margin-top: 1rem; + height: 40vh; +` + +const WhartonApplicationCycles = (): ReactElement => { + const [editMembership, setEditMembership] = useState(false) + const [membershipCycle, setMembershipCycle] = useState({ + name: '', + id: null, + }) + + const [editExtensions, setEditExtensions] = useState(false) + const [extensionsCycle, setExtensionsCycle] = useState({ + name: '', + id: null, + }) + + const [clubsSelectedMembership, setClubsSelectedMembership] = useState< + ClubOption[] + >([]) + const [ + clubsInitialOptionsMembership, + setClubsInitialOptionsMembership, + ] = useState([]) + const [clubOptionsMembership, setClubOptionsMembership] = useState< + ClubOption[] + >([]) + + const [clubsExtensions, setClubsExtensions] = useState([]) + + const [permissions, setPermissions] = useState(null) + + const closeMembershipModal = (): void => { + setEditMembership(false) + // calculate difference between initial and selected + const clubsToRemove = clubsInitialOptionsMembership.filter( + (x) => !clubsSelectedMembership.includes(x), + ) + const clubsToAdd = clubsSelectedMembership.filter( + (x) => !clubsInitialOptionsMembership.includes(x), + ) + + // call /cycles/:id/add_clubs and /cycles/remove_clubs_from_all with data.clubs as list of ids + if (clubsToRemove.length > 0) { + doApiRequest(`/cycles/remove_clubs_from_all/`, { + method: 'POST', + body: { clubs: clubsToRemove.map((x) => x.value) }, + }) + } + if (clubsToAdd.length > 0) { + doApiRequest(`/cycles/${membershipCycle.id}/add_clubs/`, { + method: 'POST', + body: { clubs: clubsToAdd.map((x) => x.value) }, + }) + } + } + + const closeExtensionsModal = (): void => { + setEditExtensions(false) + // calculate clubs that have changed + const clubsToUpdate = clubsExtensions.filter((x) => x.changed) + // split into clubs with exceptions and clubs without + const clubsExceptions = clubsToUpdate.filter((x) => x.exception) + const clubsNoExceptions = clubsToUpdate.filter((x) => !x.exception) + + // call /cycles/:id/add_clubs and /cycles/remove_clubs_from_all with data.clubs as list of ids + if (clubsExceptions.length > 0) { + doApiRequest(`/cycles/add_clubs_to_exception/`, { + method: 'POST', + body: { + clubs: clubsExceptions.map((x) => { + // eslint-disable-next-line camelcase + return { id: x.id, end_date: x.endDate } + }), + }, + }) + } + if (clubsNoExceptions.length > 0) { + doApiRequest(`/cycles/remove_clubs_from_exception/`, { + method: 'POST', + body: { clubs: clubsNoExceptions.map((x) => x.id) }, + }) + } + } + + useEffect(() => { + doApiRequest('/whartonapplications/?format=json') + .then((resp) => resp.json()) + .then((data) => { + setClubOptionsMembership( + data.map((club: ClubApplication) => { + return { label: club.name, value: club.id } + }), + ) + }) + }, []) + + useEffect(() => { + doApiRequest('/cycles') + .then((resp) => resp.json()) + .then((data) => { + setPermissions(!data.detail) + }) + }) + + useEffect(() => { + if (membershipCycle && membershipCycle.id != null) { + doApiRequest(`/cycles/${membershipCycle.id}/clubs?format=json`) + .then((resp) => resp.json()) + .then((data) => { + const initialOptions = data.map((club: ClubApplication) => { + return { label: club.name, value: club.id } + }) + setClubsInitialOptionsMembership(initialOptions) + setClubsSelectedMembership(initialOptions) + }) + } + }, [membershipCycle]) + + useEffect(() => { + if (extensionsCycle && extensionsCycle.id != null) { + doApiRequest(`/cycles/${extensionsCycle.id}/clubs?format=json`) + .then((resp) => resp.json()) + .then((data) => { + const initialOptions = data.map((club: ClubApplication) => { + return { + id: club.id, + clubName: club.name, + endDate: new Date(club.application_end_time), + exception: club.application_end_time_exception, + changed: false, + } + }) + setClubsExtensions(initialOptions) + }) + } + }, [extensionsCycle]) + + if (clubOptionsMembership == null || permissions == null) { + return + } + + if (!permissions) { + return You do not have permission to view this page. + } + + return ( + <> + ( + <> + + + + )} + /> + + {membershipCycle && membershipCycle.name && ( + <> + Club Membership for {membershipCycle.name} +
+ setClubsSelectedMembership([...e])} - value={clubsSelectedMembership} - options={clubOptionsMembership} - isMulti - /> -
- + {clubOptionsMembership.length === 0 ? ( +

+ No club applications are currently active. +
Please visit the{' '} + + Admin Scripts + {' '} + page to initialize new applications for the current cycle. +

+ ) : ( + <> +
+ setClubsSelectedMembership([...e])} - value={clubsSelectedMembership} - options={clubOptionsMembership} - isMulti - /> + +