Skip to content

Commit

Permalink
Club approval response templates (#739)
Browse files Browse the repository at this point in the history
* initial template frontend + backend

* add test lol

* allow users to select multiple templates

* minor updates courtesy of Avi and Julian
  • Loading branch information
rm03 authored Oct 16, 2024
1 parent ca77e21 commit 715ba15
Show file tree
Hide file tree
Showing 12 changed files with 347 additions and 7 deletions.
6 changes: 6 additions & 0 deletions backend/clubs/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
Cart,
Club,
ClubApplication,
ClubApprovalResponseTemplate,
ClubFair,
ClubFairBooth,
ClubFairRegistration,
Expand Down Expand Up @@ -415,6 +416,10 @@ class ApplicationSubmissionAdmin(admin.ModelAdmin):
list_display = ("user", "id", "created_at", "status")


class ClubApprovalResponseTemplateAdmin(admin.ModelAdmin):
search_fields = ("title", "content")


admin.site.register(Asset)
admin.site.register(ApplicationCommittee)
admin.site.register(ApplicationExtension)
Expand Down Expand Up @@ -460,3 +465,4 @@ class ApplicationSubmissionAdmin(admin.ModelAdmin):
admin.site.register(TicketTransferRecord)
admin.site.register(Cart)
admin.site.register(ApplicationCycle)
admin.site.register(ClubApprovalResponseTemplate, ClubApprovalResponseTemplateAdmin)
42 changes: 42 additions & 0 deletions backend/clubs/migrations/0117_clubapprovalresponsetemplate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Generated by Django 5.0.4 on 2024-10-16 02:18

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("clubs", "0116_alter_club_approved_on_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="ClubApprovalResponseTemplate",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=255, unique=True)),
("content", models.TextField()),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"author",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="templates",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]
18 changes: 18 additions & 0 deletions backend/clubs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1998,6 +1998,24 @@ def send_confirmation_email(self):
)


class ClubApprovalResponseTemplate(models.Model):
"""
Represents a (rejection) template for site administrators to use
during the club approval process.
"""

author = models.ForeignKey(
get_user_model(), on_delete=models.SET_NULL, null=True, related_name="templates"
)
title = models.CharField(max_length=255, unique=True)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)

def __str__(self):
return self.title


@receiver(models.signals.pre_delete, sender=Asset)
def asset_delete_cleanup(sender, instance, **kwargs):
if instance.file:
Expand Down
20 changes: 20 additions & 0 deletions backend/clubs/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
Badge,
Club,
ClubApplication,
ClubApprovalResponseTemplate,
ClubFair,
ClubFairBooth,
ClubVisit,
Expand Down Expand Up @@ -3000,3 +3001,22 @@ class WritableClubFairSerializer(ClubFairSerializer):

class Meta(ClubFairSerializer.Meta):
pass


class ClubApprovalResponseTemplateSerializer(serializers.ModelSerializer):
author = serializers.SerializerMethodField("get_author")

def get_author(self, obj):
return obj.author.get_full_name()

def create(self, validated_data):
validated_data["author"] = self.context["request"].user
return super().create(validated_data)

def update(self, instance, validated_data):
validated_data.pop("author", "")
return super().update(instance, validated_data)

class Meta:
model = ClubApprovalResponseTemplate
fields = ("id", "author", "title", "content", "created_at", "updated_at")
2 changes: 2 additions & 0 deletions backend/clubs/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
BadgeClubViewSet,
BadgeViewSet,
ClubApplicationViewSet,
ClubApprovalResponseTemplateViewSet,
ClubBoothsViewSet,
ClubEventViewSet,
ClubFairViewSet,
Expand Down Expand Up @@ -92,6 +93,7 @@
basename="wharton",
)
router.register(r"submissions", ApplicationSubmissionUserViewSet, basename="submission")
router.register(r"templates", ClubApprovalResponseTemplateViewSet, basename="templates")

clubs_router = routers.NestedSimpleRouter(router, r"clubs", lookup="club")
clubs_router.register(r"members", MemberViewSet, basename="club-members")
Expand Down
11 changes: 11 additions & 0 deletions backend/clubs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
Cart,
Club,
ClubApplication,
ClubApprovalResponseTemplate,
ClubFair,
ClubFairBooth,
ClubFairRegistration,
Expand Down Expand Up @@ -158,6 +159,7 @@
AuthenticatedMembershipSerializer,
BadgeSerializer,
ClubApplicationSerializer,
ClubApprovalResponseTemplateSerializer,
ClubBoothSerializer,
ClubConstitutionSerializer,
ClubFairSerializer,
Expand Down Expand Up @@ -7415,6 +7417,15 @@ def get_queryset(self):
).order_by("-created_at")


class ClubApprovalResponseTemplateViewSet(viewsets.ModelViewSet):
serializer_class = ClubApprovalResponseTemplateSerializer
permission_classes = [IsSuperuser]
lookup_field = "id"

def get_queryset(self):
return ClubApprovalResponseTemplate.objects.all().order_by("-created_at")


class ScriptExecutionView(APIView):
"""
View and execute Django management scripts using these endpoints.
Expand Down
67 changes: 67 additions & 0 deletions backend/tests/clubs/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
Badge,
Club,
ClubApplication,
ClubApprovalResponseTemplate,
ClubFair,
ClubFairRegistration,
Event,
Expand Down Expand Up @@ -2856,3 +2857,69 @@ def test_event_add_meeting(self):
self.event1.refresh_from_db()
self.assertIn("url", resp.data, resp.content)
self.assertTrue(self.event1.url, resp.content)

def test_club_approval_response_templates(self):
"""
Test operations and permissions for club approval response templates.
"""

# Log in as superuser
self.client.login(username=self.user5.username, password="test")

# Create a new template
resp = self.client.post(
reverse("templates-list"),
{
"title": "Test template",
"content": "This is a new template",
},
content_type="application/json",
)
self.assertEqual(resp.status_code, 201)

# Create another template
template = ClubApprovalResponseTemplate.objects.create(
author=self.user5,
title="Another template",
content="This is another template",
)

# List templates
resp = self.client.get(reverse("templates-list"))
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.json()), 2)

# Update a template
resp = self.client.patch(
reverse("templates-detail", args=[template.id]),
{"title": "Updated title"},
content_type="application/json",
)
self.assertIn(resp.status_code, [200, 201], resp.content)

# Verify update
template.refresh_from_db()
self.assertEqual(template.title, "Updated title")

# Delete the template
resp = self.client.delete(reverse("templates-detail", args=[template.id]))
self.assertEqual(resp.status_code, 204)

# Verify the template has been deleted
self.assertIsNone(
ClubApprovalResponseTemplate.objects.filter(id=template.id).first()
)

# Test non-superuser access restrictions
self.client.logout()
self.client.login(
username=self.user4.username, password="test"
) # non-superuser

# Non-superuser shouldn't be able to create a template
resp = self.client.post(
reverse("templates-list"),
{"title": "Template", "content": "This should not exist"},
content_type="application/json",
)
self.assertEqual(resp.status_code, 403)
54 changes: 52 additions & 2 deletions frontend/components/ClubPage/ClubApprovalDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useRouter } from 'next/router'
import { ReactElement, useEffect, useState } from 'react'
import Select from 'react-select'

import { CLUB_SETTINGS_ROUTE } from '~/constants/routes'

import { Club, ClubFair, MembershipRank, UserInfo } from '../../types'
import { Club, ClubFair, MembershipRank, Template, UserInfo } from '../../types'
import {
apiCheckPermission,
doApiRequest,
Expand Down Expand Up @@ -36,6 +37,8 @@ const ClubApprovalDialog = ({ club }: Props): ReactElement | null => {
const [loading, setLoading] = useState<boolean>(false)
const [confirmModal, setConfirmModal] = useState<ConfirmParams | null>(null)
const [fairs, setFairs] = useState<ClubFair[]>([])
const [templates, setTemplates] = useState<Template[]>([])
const [selectedTemplates, setSelectedTemplates] = useState<Template[]>([])

const canApprove = apiCheckPermission('clubs.approve_club')
const seeFairStatus = apiCheckPermission('clubs.see_fair_status')
Expand All @@ -54,7 +57,17 @@ const ClubApprovalDialog = ({ club }: Props): ReactElement | null => {
.then((resp) => resp.json())
.then(setFairs)
}
}, [])

if (canApprove) {
doApiRequest('/templates/?format=json')
.then((resp) => resp.json())
.then(setTemplates)
}

setComment(
selectedTemplates.map((template) => template.content).join('\n\n'),
)
}, [selectedTemplates])

return (
<>
Expand Down Expand Up @@ -200,6 +213,43 @@ const ClubApprovalDialog = ({ club }: Props): ReactElement | null => {
className="textarea mb-4"
placeholder="Enter approval or rejection notes here! Your notes will be emailed to the requester when you approve or reject this request."
></textarea>
<div className="field is-grouped mb-3">
<div className="control is-expanded">
<Select
isMulti
isClearable
placeholder="Select templates"
options={templates.map((template) => ({
value: template.id,
label: template.title,
content: template.content,
author: template.author,
}))}
onChange={(selectedOptions) => {
if (selectedOptions) {
const selected = selectedOptions.map((option) => ({
id: option.value,
title: option.label,
content: option.content,
author: option.author,
}))
setSelectedTemplates(selected)
} else {
setSelectedTemplates([])
}
}}
/>
</div>
<div className="control">
<button
className="button is-primary"
onClick={() => router.push('/admin/templates')}
>
<Icon name="edit" />
Edit Templates
</button>
</div>
</div>
</>
)}
<div className="buttons">
Expand Down
Loading

0 comments on commit 715ba15

Please sign in to comment.