diff --git a/backend/clubs/migrations/0125_asset_is_constitution.py b/backend/clubs/migrations/0125_asset_is_constitution.py new file mode 100644 index 000000000..4a6654bc1 --- /dev/null +++ b/backend/clubs/migrations/0125_asset_is_constitution.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-08-06 06:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0124_rankingweights"), + ] + + operations = [ + migrations.AddField( + model_name="asset", + name="is_constitution", + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 587e67c73..e50ee39c9 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -435,6 +435,13 @@ def create_thumbnail(self, request=None): def is_wharton(self): return any(badge.label == "Wharton Council" for badge in self.badges.all()) + @cached_property + def has_constitution(self): + """ + Check if the club has a constitution uploaded. + """ + return self.asset_set.filter(is_constitution=True).exists() + def add_ics_events(self): """ Fetch the ICS events from the club's calendar URL @@ -1606,6 +1613,7 @@ class Asset(models.Model): file = models.FileField(upload_to=get_asset_file_name) club = models.ForeignKey(Club, on_delete=models.CASCADE, null=True) name = models.CharField(max_length=255) + is_constitution = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index a09285dd8..fc7dbcd18 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -974,8 +974,7 @@ def get_constitution(self, obj): "url": asset.file.url if perm or has_member else None, } for asset in obj.prefetch_asset_set - if asset.name.endswith((".docx", ".doc", ".pdf")) - or "constitution" in asset.name.lower() + if asset.is_constitution ] return None @@ -1040,6 +1039,11 @@ class ClubListSerializer(serializers.ModelSerializer): email = serializers.SerializerMethodField("get_email") subtitle = serializers.SerializerMethodField("get_short_description") + has_constitution = serializers.BooleanField(read_only=True) + + def get_has_constitution(self, obj): + return obj.has_constitution + def get_email(self, obj): if obj.email_public: return obj.email @@ -1177,6 +1181,7 @@ class Meta: "enables_subscription", "favorite_count", "founded", + "has_constitution", "image_url", "is_favorite", "is_member", @@ -2546,7 +2551,16 @@ def validate_file(self, data): class Meta: model = Asset - fields = ("id", "file_url", "file", "creator", "club", "name", "created_at") + fields = ( + "id", + "file_url", + "file", + "creator", + "club", + "name", + "created_at", + "is_constitution", + ) class AuthenticatedClubSerializer(ClubSerializer): diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 9c782ecd2..bb222a2a3 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -243,11 +243,14 @@ def wrap(self, request, *args, **kwargs): def file_upload_endpoint_helper(request, code): obj = get_object_or_404(Club, code=code) if "file" in request.data and isinstance(request.data["file"], UploadedFile): + is_constitution = request.data.get("is_constitution", "false").lower() == "true" + asset = Asset.objects.create( creator=request.user, club=obj, file=request.data["file"], name=request.data["file"].name, + is_constitution=is_constitution, ) else: return Response( diff --git a/frontend/components/ClubEditPage.tsx b/frontend/components/ClubEditPage.tsx index c3cae2334..1c462873f 100644 --- a/frontend/components/ClubEditPage.tsx +++ b/frontend/components/ClubEditPage.tsx @@ -97,6 +97,7 @@ const ClubForm = ({ }: ClubFormProps): ReactElement => { const [club, setClub] = useState(null) const [isEdit, setIsEdit] = useState(typeof clubId !== 'undefined') + const [refreshFiles, setRefreshFiles] = useState(0) const router = useRouter() @@ -116,6 +117,8 @@ const ClubForm = ({ club?: Club isEdit?: boolean }): Promise => { + // trigger files refresh after successful submission + setRefreshFiles((prev) => prev + 1) if (typeof club !== 'undefined' && typeof isEditNew !== 'undefined') { if (!isEdit && isEditNew) { // if the club is not active, redirect to the renewal page instead of the edit page @@ -350,7 +353,7 @@ const ClubForm = ({ content: ( <> - + ), }, diff --git a/frontend/components/ClubEditPage/ClubEditCard.tsx b/frontend/components/ClubEditPage/ClubEditCard.tsx index bafff38d6..c1f19cafc 100644 --- a/frontend/components/ClubEditPage/ClubEditCard.tsx +++ b/frontend/components/ClubEditPage/ClubEditCard.tsx @@ -61,6 +61,18 @@ import { } from '../FormComponents' import { doFormikInitialValueFixes } from '../ModelForm' +function isConstitutionRequired( + club: Club | Partial, + isEdit: boolean = false, +): boolean { + // if on edit page and club already has constitution, it's not required + if (isEdit && club.has_constitution) { + return false + } + + return !club.has_constitution +} + export const CLUB_APPLICATIONS = [ { value: ClubApplicationRequired.Open, @@ -289,6 +301,11 @@ export default function ClubEditCard({ delete data.image } + const constitution = data.constitution + if (constitution !== null) { + delete data.constitution + } + const entries = Object.entries(data) let body = {} @@ -433,6 +450,25 @@ export default function ClubEditCard({ msg += ` However, failed to upload ${OBJECT_NAME_SINGULAR} image file!` } } + + if (constitution && constitution instanceof File) { + const formData = new FormData() + formData.append('file', constitution) + formData.append('is_constitution', 'true') + const resp = await doApiRequest( + `/clubs/${clubCode}/upload_file/?format=json`, + { + method: 'POST', + body: formData, + }, + ) + if (resp.ok) { + msg += ` ${OBJECT_NAME_TITLE_SINGULAR} constitution also saved.` + } else { + msg += ` However, failed to upload ${OBJECT_NAME_SINGULAR} constitution file!` + } + } + await onSubmit({ isEdit: true, club: info, message: msg }) setStatus({}) setSubmitting(false) @@ -586,6 +622,14 @@ export default function ClubEditCard({ label: `${OBJECT_NAME_TITLE_SINGULAR} Logo`, disabled: !REAPPROVAL_QUEUE_ENABLED, }, + { + name: 'constitution', + help: `Upload your ${OBJECT_NAME_SINGULAR} constitution. This is required for all clubs. Please upload a PDF, DOC, or DOCX file.`, + accept: '.pdf,.doc,.docx', + type: 'file', + label: `${OBJECT_NAME_TITLE_SINGULAR} Constitution`, + required: isConstitutionRequired(club, isEdit), + }, { name: 'size', type: 'select', @@ -971,8 +1015,8 @@ export default function ClubEditCard({ submit({ ...values, emailOverride: false }, actions) } enableReinitialize - validate={(values) => { - const errors: { email?: string } = {} + validate={(values: any) => { + const errors: { email?: string; constitution?: string } = {} if (values.email.includes('upenn.edu') && !emailModal) { showEmailModal(true) errors.email = 'Please confirm your email' @@ -1058,6 +1102,7 @@ export default function ClubEditCard({ multiselect: SelectField, select: SelectField, image: FileField, + file: FileField, address: FormikAddressField, checkboxText: CheckboxTextField, creatableMultiSelect: diff --git a/frontend/components/ClubEditPage/FilesCard.tsx b/frontend/components/ClubEditPage/FilesCard.tsx index 117ec5a91..1b7f4e6f9 100644 --- a/frontend/components/ClubEditPage/FilesCard.tsx +++ b/frontend/components/ClubEditPage/FilesCard.tsx @@ -1,5 +1,5 @@ import { Field, Form, Formik } from 'formik' -import { ReactElement, useState } from 'react' +import { ReactElement, useEffect, useState } from 'react' import TimeAgo from 'react-timeago' import { Club, File } from '../../types' @@ -15,23 +15,49 @@ import BaseCard from './BaseCard' type FilesCardProps = { club: Club + refreshTrigger?: number } /** * A card that allows club officers to view, download, delete, and add files to the club. */ -export default function FilesCard({ club }: FilesCardProps): ReactElement { +export default function FilesCard({ + club, + refreshTrigger, +}: FilesCardProps): ReactElement { const [files, setFiles] = useState(club.files) + const [clubHasConstitution, setClubHasConstitution] = useState( + club.has_constitution, + ) const reloadFiles = async (): Promise => { await doApiRequest(`/clubs/${club.code}/assets/?format=json`) .then((resp) => resp.json()) .then(setFiles) + + // use clublist serializer (minimal data) + await doApiRequest(`/clubs/${club.code}/?format=json`) + .then((resp) => resp.json()) + .then((data) => { + setClubHasConstitution(data.has_constitution) + }) } + // trigger reload from ClubEditCard submissions + useEffect(() => { + if (refreshTrigger && refreshTrigger > 0) { + reloadFiles() + } + }, [refreshTrigger]) + const submitForm = (data, { setSubmitting, resetForm, setStatus }) => { const formData = new FormData() formData.append('file', data.file) + + // if filename contains "constitution" then file must be constitution + const isConstitution = data.file.name.toLowerCase().includes('constitution') + formData.append('is_constitution', isConstitution.toString()) + doApiRequest(`/clubs/${club.code}/upload_file/?format=json`, { method: 'POST', body: formData, @@ -56,6 +82,28 @@ export default function FilesCard({ club }: FilesCardProps): ReactElement { {OBJECT_NAME_SINGULAR} members and {SITE_NAME} administrators.{' '} {OBJECT_TAB_FILES_DESCRIPTION} + + {/* constitution requirement message */} + {!clubHasConstitution && ( +
+
+

+ Constitution Upload Required +

+

+ Your {OBJECT_NAME_SINGULAR} is required to upload a constitution. + You can upload your constitution using the form below or through + the club edit form. +

+

+ Important: If you choose to upload your constitution from this + tab, please ensure the file name contains the word "constitution" + to automatically mark it as a constitution file. +

+
+
+ )} + @@ -68,7 +116,14 @@ export default function FilesCard({ club }: FilesCardProps): ReactElement { {files && files.length ? ( files.map((a) => ( - + diff --git a/frontend/types.ts b/frontend/types.ts index d4e9bff69..2c0a4a1b4 100644 --- a/frontend/types.ts +++ b/frontend/types.ts @@ -214,6 +214,7 @@ export interface Club { is_request: boolean is_subscribe: boolean is_wharton: boolean + has_constitution: boolean linkedin: string listserv: string members: Membership[] @@ -260,6 +261,7 @@ export interface File { name: string created_at: string file_url: string + is_constitution: boolean } export interface UserInfo {
{a.name} + {a.name} + {a.is_constitution && ( + + Constitution + + )} +