Skip to content
Open
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
18 changes: 18 additions & 0 deletions backend/clubs/migrations/0125_asset_is_constitution.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
8 changes: 8 additions & 0 deletions backend/clubs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we invalidate this cache on file upload or is this implicit in the Django decorator?

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
Expand Down Expand Up @@ -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)
Expand Down
20 changes: 17 additions & 3 deletions backend/clubs/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1177,6 +1181,7 @@ class Meta:
"enables_subscription",
"favorite_count",
"founded",
"has_constitution",
"image_url",
"is_favorite",
"is_member",
Expand Down Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions backend/clubs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion frontend/components/ClubEditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ const ClubForm = ({
}: ClubFormProps): ReactElement<any> => {
const [club, setClub] = useState<Club | null>(null)
const [isEdit, setIsEdit] = useState<boolean>(typeof clubId !== 'undefined')
const [refreshFiles, setRefreshFiles] = useState<number>(0)

const router = useRouter()

Expand All @@ -116,6 +117,8 @@ const ClubForm = ({
club?: Club
isEdit?: boolean
}): Promise<void> => {
// 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
Expand Down Expand Up @@ -350,7 +353,7 @@ const ClubForm = ({
content: (
<>
<MemberExperiencesCard club={club} />
<FilesCard club={club} />
<FilesCard club={club} refreshTrigger={refreshFiles} />
</>
),
},
Expand Down
49 changes: 47 additions & 2 deletions frontend/components/ClubEditPage/ClubEditCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ import {
} from '../FormComponents'
import { doFormikInitialValueFixes } from '../ModelForm'

function isConstitutionRequired(
club: Club | Partial<Club>,
isEdit: boolean = false,
): boolean {
// if on edit page and club already has constitution, it's not required
if (isEdit && club.has_constitution) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this if branch implements separate behavior on isEdit=true

return false
}

return !club.has_constitution
}

export const CLUB_APPLICATIONS = [
{
value: ClubApplicationRequired.Open,
Expand Down Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -971,8 +1015,8 @@ export default function ClubEditCard({
submit({ ...values, emailOverride: false }, actions)
}
enableReinitialize
validate={(values) => {
const errors: { email?: string } = {}
validate={(values: any) => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure there's no expected schema for values?

const errors: { email?: string; constitution?: string } = {}
if (values.email.includes('upenn.edu') && !emailModal) {
showEmailModal(true)
errors.email = 'Please confirm your email'
Expand Down Expand Up @@ -1058,6 +1102,7 @@ export default function ClubEditCard({
multiselect: SelectField,
select: SelectField,
image: FileField,
file: FileField,
address: FormikAddressField,
checkboxText: CheckboxTextField,
creatableMultiSelect:
Expand Down
61 changes: 58 additions & 3 deletions frontend/components/ClubEditPage/FilesCard.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -15,23 +15,49 @@ import BaseCard from './BaseCard'

type FilesCardProps = {
club: Club
refreshTrigger?: number
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this prop ever being incremented on file upload? We may also want to explore more idiomatic ways of forcing refresh for this card vs. a meaningless number.

}

/**
* A card that allows club officers to view, download, delete, and add files to the club.
*/
export default function FilesCard({ club }: FilesCardProps): ReactElement<any> {
export default function FilesCard({
club,
refreshTrigger,
}: FilesCardProps): ReactElement<any> {
const [files, setFiles] = useState<File[]>(club.files)
const [clubHasConstitution, setClubHasConstitution] = useState<boolean>(
club.has_constitution,
)

const reloadFiles = async (): Promise<void> => {
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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just confirming that this is fallback logic for file uploads not done within our new upload constitution UI vs a check for files that are already being uploaded through the new UI meant for uploading constitutions.

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,
Expand All @@ -56,6 +82,28 @@ export default function FilesCard({ club }: FilesCardProps): ReactElement<any> {
{OBJECT_NAME_SINGULAR} members and {SITE_NAME} administrators.{' '}
{OBJECT_TAB_FILES_DESCRIPTION}
</Text>

{/* constitution requirement message */}
{!clubHasConstitution && (
<div className="notification is-warning is-light mb-4">
<div className="content">
<p className="has-text-weight-bold">
<Icon name="alert-triangle" /> Constitution Upload Required
</p>
<p>
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.
</p>
<p className="has-text-weight-semibold">
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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refer to the above comment but I think this process is too confusing / ambiguous and if we can't avoid this, we should just have a single unambiguous place where uploading any valid word or pdf doc would idempotently update a club's constitution

</p>
</div>
</div>
)}

<table className="table is-fullwidth">
<thead>
<tr>
Expand All @@ -68,7 +116,14 @@ export default function FilesCard({ club }: FilesCardProps): ReactElement<any> {
{files && files.length ? (
files.map((a) => (
<tr key={`${a.id}-${a.name}`}>
<td>{a.name}</td>
<td>
{a.name}
{a.is_constitution && (
<span className="tag is-info is-small ml-2">
Constitution
</span>
)}
</td>
<td>
<TimeAgo date={a.created_at} />
</td>
Expand Down
2 changes: 2 additions & 0 deletions frontend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -260,6 +261,7 @@ export interface File {
name: string
created_at: string
file_url: string
is_constitution: boolean
}

export interface UserInfo {
Expand Down
Loading