Skip to content

Commit

Permalink
Individual Application Extensions (#602)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
rm03 authored Jan 4, 2024
1 parent 9ce3206 commit e95fc48
Show file tree
Hide file tree
Showing 10 changed files with 275 additions and 30 deletions.
2 changes: 2 additions & 0 deletions backend/clubs/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
Advisor,
ApplicationCommittee,
ApplicationCycle,
ApplicationExtension,
ApplicationMultipleChoice,
ApplicationQuestion,
ApplicationQuestionResponse,
Expand Down Expand Up @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions backend/clubs/migrations/0091_applicationextension.py
Original file line number Diff line number Diff line change
@@ -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")}},
),
]
34 changes: 34 additions & 0 deletions backend/clubs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions backend/clubs/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
AdminNote,
Advisor,
ApplicationCommittee,
ApplicationExtension,
ApplicationMultipleChoice,
ApplicationQuestion,
ApplicationQuestionResponse,
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions backend/clubs/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from clubs.views import (
AdminNoteViewSet,
AdvisorViewSet,
ApplicationExtensionViewSet,
ApplicationQuestionViewSet,
ApplicationSubmissionUserViewSet,
ApplicationSubmissionViewSet,
Expand Down Expand Up @@ -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 = [
Expand Down
37 changes: 27 additions & 10 deletions backend/clubs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
from clubs.models import (
AdminNote,
Advisor,
ApplicationExtension,
ApplicationMultipleChoice,
ApplicationQuestion,
ApplicationQuestionResponse,
Expand Down Expand Up @@ -125,6 +126,7 @@
from clubs.serializers import (
AdminNoteSerializer,
AdvisorSerializer,
ApplicationExtensionSerializer,
ApplicationQuestionResponseSerializer,
ApplicationQuestionSerializer,
ApplicationSubmissionCSVSerializer,
Expand Down Expand Up @@ -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!"}
)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions backend/templates/emails/application_extension.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!-- TYPES:
name:
type: string
club:
type: string
application_name:
type: string
end_time:
type: string
url:
type: string
-->
{% extends 'emails/base.html' %}

{% block content %}
<h2>Application Extension for {{ application_name }}</h2>
<p style="font-size: 1.2em;">Hello {{ name }},</p>
<p style="font-size: 1.2em;">You have been granted an extension for {{ application_name }} by the officers of {{ club }} on Penn Clubs:</p>
<p style="font-size: 1.2em;">The updated deadline to submit your application is {{ end_time }}. You can apply using the button below.</p>
<a style="text-decoration: none; padding: 5px 20px; font-size: 1.5em; margin-top: 25px; color: white; background-color: green; border-radius: 3px; font-weight: bold"
href="{{ url }}">Apply</a>
{% endblock %}
52 changes: 51 additions & 1 deletion frontend/components/ClubEditPage/ApplicationsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 }) => ({
Expand Down Expand Up @@ -802,6 +816,42 @@ export default function ApplicationsPage({
</a>
</div>
)}
<br></br>
<div>
{currentApplication != null ? (
<>
<StyledHeader style={{ marginBottom: '2px' }}>
Extensions
</StyledHeader>
<ModelForm
key={currentApplication.id}
baseUrl={`/clubs/${club.code}/applications/${currentApplication.id}/extensions/`}
fields={
<>
<Field
name="username"
as={TextField}
required={true}
helpText="The username (PennKey) of the applicant to be granted an extension."
/>
<Field
name="end_time"
as={DateTimeField}
required={true}
helpText="The extended end time for this applicant's application."
/>
</>
}
tableFields={extensionTableFields}
confirmDeletion
searchableColumns={['username']}
noun="Extension"
/>
</>
) : (
<Loading />
)}
</div>
{showModal && (
<Modal
show={showModal}
Expand Down
Loading

0 comments on commit e95fc48

Please sign in to comment.