Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Club approval response templates #739

Merged
merged 5 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
)


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

Check warning on line 2016 in backend/clubs/models.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/models.py#L2016

Added line #L2016 was not covered by tests


@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):
rm03 marked this conversation as resolved.
Show resolved Hide resolved
"""
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
Loading