Skip to content

Commit 81710e9

Browse files
committed
mandate constitution upload
1 parent 197f165 commit 81710e9

8 files changed

Lines changed: 157 additions & 9 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.4 on 2025-08-06 06:27
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("clubs", "0124_rankingweights"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="asset",
15+
name="is_constitution",
16+
field=models.BooleanField(default=False),
17+
),
18+
]

backend/clubs/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,13 @@ def create_thumbnail(self, request=None):
435435
def is_wharton(self):
436436
return any(badge.label == "Wharton Council" for badge in self.badges.all())
437437

438+
@cached_property
439+
def has_constitution(self):
440+
"""
441+
Check if the club has a constitution uploaded.
442+
"""
443+
return self.asset_set.filter(is_constitution=True).exists()
444+
438445
def add_ics_events(self):
439446
"""
440447
Fetch the ICS events from the club's calendar URL
@@ -1606,6 +1613,7 @@ class Asset(models.Model):
16061613
file = models.FileField(upload_to=get_asset_file_name)
16071614
club = models.ForeignKey(Club, on_delete=models.CASCADE, null=True)
16081615
name = models.CharField(max_length=255)
1616+
is_constitution = models.BooleanField(default=False)
16091617

16101618
created_at = models.DateTimeField(auto_now_add=True)
16111619
updated_at = models.DateTimeField(auto_now=True)

backend/clubs/serializers.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -974,8 +974,7 @@ def get_constitution(self, obj):
974974
"url": asset.file.url if perm or has_member else None,
975975
}
976976
for asset in obj.prefetch_asset_set
977-
if asset.name.endswith((".docx", ".doc", ".pdf"))
978-
or "constitution" in asset.name.lower()
977+
if asset.is_constitution
979978
]
980979
return None
981980

@@ -1040,6 +1039,11 @@ class ClubListSerializer(serializers.ModelSerializer):
10401039
email = serializers.SerializerMethodField("get_email")
10411040
subtitle = serializers.SerializerMethodField("get_short_description")
10421041

1042+
has_constitution = serializers.BooleanField(read_only=True)
1043+
1044+
def get_has_constitution(self, obj):
1045+
return obj.has_constitution
1046+
10431047
def get_email(self, obj):
10441048
if obj.email_public:
10451049
return obj.email
@@ -1177,6 +1181,7 @@ class Meta:
11771181
"enables_subscription",
11781182
"favorite_count",
11791183
"founded",
1184+
"has_constitution",
11801185
"image_url",
11811186
"is_favorite",
11821187
"is_member",
@@ -2546,7 +2551,16 @@ def validate_file(self, data):
25462551

25472552
class Meta:
25482553
model = Asset
2549-
fields = ("id", "file_url", "file", "creator", "club", "name", "created_at")
2554+
fields = (
2555+
"id",
2556+
"file_url",
2557+
"file",
2558+
"creator",
2559+
"club",
2560+
"name",
2561+
"created_at",
2562+
"is_constitution",
2563+
)
25502564

25512565

25522566
class AuthenticatedClubSerializer(ClubSerializer):

backend/clubs/views.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,11 +243,14 @@ def wrap(self, request, *args, **kwargs):
243243
def file_upload_endpoint_helper(request, code):
244244
obj = get_object_or_404(Club, code=code)
245245
if "file" in request.data and isinstance(request.data["file"], UploadedFile):
246+
is_constitution = request.data.get("is_constitution", "false").lower() == "true"
247+
246248
asset = Asset.objects.create(
247249
creator=request.user,
248250
club=obj,
249251
file=request.data["file"],
250252
name=request.data["file"].name,
253+
is_constitution=is_constitution,
251254
)
252255
else:
253256
return Response(

frontend/components/ClubEditPage.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ const ClubForm = ({
9797
}: ClubFormProps): ReactElement<any> => {
9898
const [club, setClub] = useState<Club | null>(null)
9999
const [isEdit, setIsEdit] = useState<boolean>(typeof clubId !== 'undefined')
100+
const [refreshFiles, setRefreshFiles] = useState<number>(0)
100101

101102
const router = useRouter()
102103

@@ -116,6 +117,8 @@ const ClubForm = ({
116117
club?: Club
117118
isEdit?: boolean
118119
}): Promise<void> => {
120+
// trigger files refresh after successful submission
121+
setRefreshFiles((prev) => prev + 1)
119122
if (typeof club !== 'undefined' && typeof isEditNew !== 'undefined') {
120123
if (!isEdit && isEditNew) {
121124
// if the club is not active, redirect to the renewal page instead of the edit page
@@ -350,7 +353,7 @@ const ClubForm = ({
350353
content: (
351354
<>
352355
<MemberExperiencesCard club={club} />
353-
<FilesCard club={club} />
356+
<FilesCard club={club} refreshTrigger={refreshFiles} />
354357
</>
355358
),
356359
},

frontend/components/ClubEditPage/ClubEditCard.tsx

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,18 @@ import {
6161
} from '../FormComponents'
6262
import { doFormikInitialValueFixes } from '../ModelForm'
6363

64+
function isConstitutionRequired(
65+
club: Club | Partial<Club>,
66+
isEdit: boolean = false,
67+
): boolean {
68+
// if on edit page and club already has constitution, it's not required
69+
if (isEdit && club.has_constitution) {
70+
return false
71+
}
72+
73+
return !club.has_constitution
74+
}
75+
6476
export const CLUB_APPLICATIONS = [
6577
{
6678
value: ClubApplicationRequired.Open,
@@ -289,6 +301,11 @@ export default function ClubEditCard({
289301
delete data.image
290302
}
291303

304+
const constitution = data.constitution
305+
if (constitution !== null) {
306+
delete data.constitution
307+
}
308+
292309
const entries = Object.entries(data)
293310
let body = {}
294311

@@ -433,6 +450,25 @@ export default function ClubEditCard({
433450
msg += ` However, failed to upload ${OBJECT_NAME_SINGULAR} image file!`
434451
}
435452
}
453+
454+
if (constitution && constitution instanceof File) {
455+
const formData = new FormData()
456+
formData.append('file', constitution)
457+
formData.append('is_constitution', 'true')
458+
const resp = await doApiRequest(
459+
`/clubs/${clubCode}/upload_file/?format=json`,
460+
{
461+
method: 'POST',
462+
body: formData,
463+
},
464+
)
465+
if (resp.ok) {
466+
msg += ` ${OBJECT_NAME_TITLE_SINGULAR} constitution also saved.`
467+
} else {
468+
msg += ` However, failed to upload ${OBJECT_NAME_SINGULAR} constitution file!`
469+
}
470+
}
471+
436472
await onSubmit({ isEdit: true, club: info, message: msg })
437473
setStatus({})
438474
setSubmitting(false)
@@ -586,6 +622,14 @@ export default function ClubEditCard({
586622
label: `${OBJECT_NAME_TITLE_SINGULAR} Logo`,
587623
disabled: !REAPPROVAL_QUEUE_ENABLED,
588624
},
625+
{
626+
name: 'constitution',
627+
help: `Upload your ${OBJECT_NAME_SINGULAR} constitution. This is required for all clubs. Please upload a PDF, DOC, or DOCX file.`,
628+
accept: '.pdf,.doc,.docx',
629+
type: 'file',
630+
label: `${OBJECT_NAME_TITLE_SINGULAR} Constitution`,
631+
required: isConstitutionRequired(club, isEdit),
632+
},
589633
{
590634
name: 'size',
591635
type: 'select',
@@ -971,8 +1015,8 @@ export default function ClubEditCard({
9711015
submit({ ...values, emailOverride: false }, actions)
9721016
}
9731017
enableReinitialize
974-
validate={(values) => {
975-
const errors: { email?: string } = {}
1018+
validate={(values: any) => {
1019+
const errors: { email?: string; constitution?: string } = {}
9761020
if (values.email.includes('upenn.edu') && !emailModal) {
9771021
showEmailModal(true)
9781022
errors.email = 'Please confirm your email'
@@ -1058,6 +1102,7 @@ export default function ClubEditCard({
10581102
multiselect: SelectField,
10591103
select: SelectField,
10601104
image: FileField,
1105+
file: FileField,
10611106
address: FormikAddressField,
10621107
checkboxText: CheckboxTextField,
10631108
creatableMultiSelect:

frontend/components/ClubEditPage/FilesCard.tsx

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Field, Form, Formik } from 'formik'
2-
import { ReactElement, useState } from 'react'
2+
import { ReactElement, useEffect, useState } from 'react'
33
import TimeAgo from 'react-timeago'
44

55
import { Club, File } from '../../types'
@@ -15,23 +15,49 @@ import BaseCard from './BaseCard'
1515

1616
type FilesCardProps = {
1717
club: Club
18+
refreshTrigger?: number
1819
}
1920

2021
/**
2122
* A card that allows club officers to view, download, delete, and add files to the club.
2223
*/
23-
export default function FilesCard({ club }: FilesCardProps): ReactElement<any> {
24+
export default function FilesCard({
25+
club,
26+
refreshTrigger,
27+
}: FilesCardProps): ReactElement<any> {
2428
const [files, setFiles] = useState<File[]>(club.files)
29+
const [clubHasConstitution, setClubHasConstitution] = useState<boolean>(
30+
club.has_constitution,
31+
)
2532

2633
const reloadFiles = async (): Promise<void> => {
2734
await doApiRequest(`/clubs/${club.code}/assets/?format=json`)
2835
.then((resp) => resp.json())
2936
.then(setFiles)
37+
38+
// use clublist serializer (minimal data)
39+
await doApiRequest(`/clubs/${club.code}/?format=json`)
40+
.then((resp) => resp.json())
41+
.then((data) => {
42+
setClubHasConstitution(data.has_constitution)
43+
})
3044
}
3145

46+
// trigger reload from ClubEditCard submissions
47+
useEffect(() => {
48+
if (refreshTrigger && refreshTrigger > 0) {
49+
reloadFiles()
50+
}
51+
}, [refreshTrigger])
52+
3253
const submitForm = (data, { setSubmitting, resetForm, setStatus }) => {
3354
const formData = new FormData()
3455
formData.append('file', data.file)
56+
57+
// if filename contains "constitution" then file must be constitution
58+
const isConstitution = data.file.name.toLowerCase().includes('constitution')
59+
formData.append('is_constitution', isConstitution.toString())
60+
3561
doApiRequest(`/clubs/${club.code}/upload_file/?format=json`, {
3662
method: 'POST',
3763
body: formData,
@@ -56,6 +82,28 @@ export default function FilesCard({ club }: FilesCardProps): ReactElement<any> {
5682
{OBJECT_NAME_SINGULAR} members and {SITE_NAME} administrators.{' '}
5783
{OBJECT_TAB_FILES_DESCRIPTION}
5884
</Text>
85+
86+
{/* constitution requirement message */}
87+
{!clubHasConstitution && (
88+
<div className="notification is-warning is-light mb-4">
89+
<div className="content">
90+
<p className="has-text-weight-bold">
91+
<Icon name="alert-triangle" /> Constitution Upload Required
92+
</p>
93+
<p>
94+
Your {OBJECT_NAME_SINGULAR} is required to upload a constitution.
95+
You can upload your constitution using the form below or through
96+
the club edit form.
97+
</p>
98+
<p className="has-text-weight-semibold">
99+
Important: If you choose to upload your constitution from this
100+
tab, please ensure the file name contains the word "constitution"
101+
to automatically mark it as a constitution file.
102+
</p>
103+
</div>
104+
</div>
105+
)}
106+
59107
<table className="table is-fullwidth">
60108
<thead>
61109
<tr>
@@ -68,7 +116,14 @@ export default function FilesCard({ club }: FilesCardProps): ReactElement<any> {
68116
{files && files.length ? (
69117
files.map((a) => (
70118
<tr key={`${a.id}-${a.name}`}>
71-
<td>{a.name}</td>
119+
<td>
120+
{a.name}
121+
{a.is_constitution && (
122+
<span className="tag is-info is-small ml-2">
123+
Constitution
124+
</span>
125+
)}
126+
</td>
72127
<td>
73128
<TimeAgo date={a.created_at} />
74129
</td>

frontend/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ export interface Club {
214214
is_request: boolean
215215
is_subscribe: boolean
216216
is_wharton: boolean
217+
has_constitution: boolean
217218
linkedin: string
218219
listserv: string
219220
members: Membership[]
@@ -260,6 +261,7 @@ export interface File {
260261
name: string
261262
created_at: string
262263
file_url: string
264+
is_constitution: boolean
263265
}
264266

265267
export interface UserInfo {

0 commit comments

Comments
 (0)