Skip to content

Moving towards a status-based approval system#851

Open
yuviji wants to merge 3 commits intomasterfrom
set-club-status-on-approval
Open

Moving towards a status-based approval system#851
yuviji wants to merge 3 commits intomasterfrom
set-club-status-on-approval

Conversation

@yuviji
Copy link
Contributor

@yuviji yuviji commented Nov 11, 2025

Addresses #835 (and a bit more?)

We set a club's status during approval and auto set status to “Under Review” on queue entry. The Admin Queue can set status in bulk as well. This PR moves us towards using the status field as a single source of truth, conditioning, and validation - currently, we scatter our judgement across active, approved, and archived fields for a Club.

Backend

  • Add Status listing endpoint and status-driven mapping in serializers:
    • Officers can set Status to “Under Review” for their club renewals only; admins can set any Status.
  • Status mapping to state, making many assumptions with guidance from the PennClubs Descriptors Excel file:
    • FULL/PRELIMINARY/PROVISIONAL → approved=True, active=True
    • SUSPENDED/INACTIVE → approved=False, active=False
    • TEMPORARY → approved=False, active=True
    • UNDER REVIEW → approved=None, active=True; clears approved_by/approved_on
    • DEFUNCT → archived_on set, active=False
  • Allow approvers to update clubs:
    • Update object permissions so users with clubs.approve_club can PATCH clubs.
  • Serializer fields: added read-only status everywhere; introduced status_id (write-only) in AuthenticatedClubSerializer and AdminClubSerializer; made approved, approved_comment writable in AdminClubSerializer

Frontend

  • Club Approval Dialog (ClubApprovalDialog.tsx):
    • Change from Accept, Reject, Delete buttons to a single a Status dropdown (excluding “Under Review”).
    • Button labeling/styles/icon dynamically reflect the chosen status (Approve/Reject/Delete).
    • “Revoke Approval” sets Status to “Under Review” and records a reason.
  • Admin Approval Queue (Settings/QueueTab.tsx):
    • New “Change Status” bulk modal with templates + comment, and a Status dropdown (excluding “Under Review”).
    • Applies status and comment to all selected clubs; refreshes table.
  • Status UI helpers (constants/status.ts):
    • Centralize allowed status categories, the “UNDER REVIEW” constant, and UI action mapping (label, button class, icon).

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR implements a status-based approval system for clubs, making the status field the single source of truth for club approval states. It replaces the previous approval workflow that relied on manually setting approved, active, and archived fields with a status-driven approach where these fields are automatically derived from the club's status.

  • Status-driven state mapping automatically sets approved, active, and archived based on status selection
  • Officers can set status to "Under Review" for renewals; admins can set any status
  • Frontend components updated to use status dropdowns instead of discrete approve/reject/delete buttons

Reviewed Changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
frontend/constants/status.ts New file defining status categories and UI mapping logic (labels, button styles, icons)
frontend/pages/club/[club]/renew.tsx Sets status to "Under Review" when completing renewal instead of just setting active=true
frontend/components/ResourceCreationPage.tsx Sets status to "Under Review" when completing club creation instead of setting active=true
frontend/components/ClubPage/ClubApprovalDialog.tsx Replaces approve/reject/delete buttons with status dropdown, adds status-based button styling
frontend/components/Settings/QueueTab.tsx Replaces separate approve/reject buttons with unified "Change Status" modal
backend/clubs/views.py Updates approval permission checks to validate approval fields are modified in isolation
backend/clubs/serializers.py Adds status mapping logic to automatically set approved/active/archived; allows officers to set "Under Review" only
backend/clubs/permissions.py Adds is_officer_or_above helper function
backend/clubs/management/commands/populate.py Adds "Temporary" and "Under Review" statuses to seed data
backend/tests/clubs/test_views.py Comprehensive tests for status-based approval including permission checks and state mapping
Comments suppressed due to low confidence (1)

backend/clubs/serializers.py:1783

  • This comment appears to contain commented-out code.
    # def validate_active(self, value):
    #     """
    #     Only officers, owners, and superusers may change the active status of a club.
    #     """
    #     user = self.context["request"].user

    #     # people with approve permissions can also change the active status of a club
    #     if user.has_perm("clubs.approve_club"):
    #         return value

    #     # check if at least an officer
    #     club_code = self.context["view"].kwargs.get("code")
    #     club = Club.objects.get(code=club_code)
    #     membership = Membership.objects.filter(person=user, club=club).first()
    #     if membership and membership.role <= Membership.ROLE_OFFICER:
    #         return value

    #     # otherwise, can't edit this field
    #     raise serializers.ValidationError(
    #         "You do not have permissions to change the active status of the club."

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

* Get UI action info (button styling, label, icon) based on status
* @param statusName - Status name (case-insensitive, required)
*/
export const getStatusActionInfo = (statusName: string): StatusActionInfo => {
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

The function getStatusActionInfo can throw at runtime if called with an empty string or undefined value since it attempts .toUpperCase() without null checking. The fallback at the end (lines 70-75) would be returned, but the normalized variable would be an empty string. Consider adding validation at the start to handle empty/null/undefined inputs, or document that statusName must be a non-empty string.

Suggested change
export const getStatusActionInfo = (statusName: string): StatusActionInfo => {
export const getStatusActionInfo = (statusName: string): StatusActionInfo => {
if (!statusName || typeof statusName !== 'string' || statusName.trim() === '') {
// Fallback for invalid or empty input
return {
action: 'approve',
label: 'Approve',
buttonClass: 'is-success',
icon: 'check',
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +158 to +170
const actionInfo = getStatusActionInfo(selectedStatus?.name || '')

return (
<button
className={`mb-2 button ${actionInfo.buttonClass}`}
disabled={!selectedStatus}
onClick={() => {
closeModal()
bulkAction(comment, selectedStatus?.id)
}}
>
<Icon name={actionInfo.icon} />
{actionInfo.label} and Send Message
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

If selectedStatus is null, an empty string is passed to getStatusActionInfo, which will return the fallback 'approve' action. This could be misleading when no status is selected. Consider handling the null case explicitly to avoid showing potentially incorrect button styling/text.

Suggested change
const actionInfo = getStatusActionInfo(selectedStatus?.name || '')
return (
<button
className={`mb-2 button ${actionInfo.buttonClass}`}
disabled={!selectedStatus}
onClick={() => {
closeModal()
bulkAction(comment, selectedStatus?.id)
}}
>
<Icon name={actionInfo.icon} />
{actionInfo.label} and Send Message
let actionInfo;
if (selectedStatus) {
actionInfo = getStatusActionInfo(selectedStatus.name);
} else {
actionInfo = {
buttonClass: '',
icon: '',
label: 'Select a status',
};
}
return (
<button
className={`mb-2 button${actionInfo.buttonClass ? ' ' + actionInfo.buttonClass : ''}`}
disabled={!selectedStatus}
onClick={() => {
closeModal()
bulkAction(comment, selectedStatus?.id)
}}
>
{actionInfo.icon && <Icon name={actionInfo.icon} />}
{actionInfo.label}
{selectedStatus && ' and Send Message'}

Copilot uses AI. Check for mistakes.
Comment on lines +641 to +651
// Fetch the "Under Review" status
const statusesResp = await doApiRequest('/statuses/?format=json')
const statusesData = await statusesResp.json()
const underReviewStatus = statusesData.find(
(s: Status) => s.name.toUpperCase() === UNDER_REVIEW_STATUS,
)

if (!underReviewStatus) {
setChangeStatusError('Could not find "Under Review" status')
return
}
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

The logic to fetch and find the 'Under Review' status is duplicated across multiple files (renew.tsx, ResourceCreationPage.tsx, ClubApprovalDialog.tsx). Consider extracting this into a reusable utility function to reduce duplication and make maintenance easier.

Copilot uses AI. Check for mistakes.
student_types = serializers.SerializerMethodField("get_target_student_types")
approved_comment = serializers.CharField(required=False, allow_blank=True)
approved_comment = serializers.CharField(
required=False, allow_blank=True, read_only=True
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

Setting approved_comment to read_only=True in the base ClubSerializer prevents officers from adding comments when requesting review. However, in ClubApprovalDialog.tsx (line 274), there's an attempt to set approved_comment: 'Approval revoked'. While this works for admins using AdminClubSerializer, the field definition inconsistency could cause confusion.

Suggested change
required=False, allow_blank=True, read_only=True
required=False, allow_blank=True

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think Copilot misinterpreted this, Club Officers don't have an option to add comments/message when they request reapproval, right?

Comment on lines +1764 to +1784
# def validate_active(self, value):
# """
# Only officers, owners, and superusers may change the active status of a club.
# """
# user = self.context["request"].user

# people with approve permissions can also change the active status of the club
if user.has_perm("clubs.approve_club"):
return value
# # people with approve permissions can also change the active status of a club
# if user.has_perm("clubs.approve_club"):
# return value

# check if at least an officer
club_code = self.context["view"].kwargs.get("code")
club = Club.objects.get(code=club_code)
membership = Membership.objects.filter(person=user, club=club).first()
if membership and membership.role <= Membership.ROLE_OFFICER:
return value
# # check if at least an officer
# club_code = self.context["view"].kwargs.get("code")
# club = Club.objects.get(code=club_code)
# membership = Membership.objects.filter(person=user, club=club).first()
# if membership and membership.role <= Membership.ROLE_OFFICER:
# return value

# otherwise, can't edit this field
raise serializers.ValidationError(
"You do not have permissions to change the active status of the club."
)
# # otherwise, can't edit this field
# raise serializers.ValidationError(
# "You do not have permissions to change the active status of the club."
# )
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

This commented-out code should be removed entirely rather than left in the codebase. Since active is now read-only and managed through status changes, this validation is obsolete and clutters the code.

Copilot uses AI. Check for mistakes.

status = StatusSerializer(required=False, allow_null=True)
# Admin-only writable fields - override read-only versions from parent
approved = serializers.BooleanField(required=False)
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

The approved field in AdminClubSerializer is writable but should not be directly modified. According to the PR description, status changes should automatically set the approved field. Direct manipulation of approved could bypass the status mapping logic in the save() method, creating inconsistent state.

Suggested change
approved = serializers.BooleanField(required=False)
approved = serializers.BooleanField(read_only=True)

Copilot uses AI. Check for mistakes.
non_approval_fields = set(request.data.keys()) - approval_fields
if non_approval_fields:
raise DRFValidationError(
f"When modifying approval fields ({', '.join(approval_fields)})"
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

[nitpick] The error message references approval_fields set which contains {'approved_comment', 'status_id'}. The order in a set is not guaranteed, so the error message could vary. Consider converting to a sorted list or using a predefined string for consistency.

Suggested change
f"When modifying approval fields ({', '.join(approval_fields)})"
f"When modifying approval fields ({', '.join(sorted(approval_fields))})"

Copilot uses AI. Check for mistakes.
Comment on lines +449 to +469
{(() => {
const handleAction = () => {
if (!selectedStatus) return

setLoading(true)
doApiRequest(`/clubs/${club.code}/?format=json`, {
method: 'DELETE',
method: 'PATCH',
body: {
approved_comment:
comment ||
'(Administrator did not include a comment.)',
status_id: selectedStatus.id,
},
})
.then(() => router.reload())
.finally(() => setLoading(false))
},
message: (
<>
<p>
Are you sure you want to delete <b>{club.name}</b>
?
</p>
<p>
Here are a list of reasons for using this feature:
</p>
<div className="content">
<ul>
<li>
Duplicate {OBJECT_NAME_SINGULAR} entries
</li>
<li>
Mistakenly created {OBJECT_NAME_SINGULAR} with
user acknowledgement and permission
</li>
</ul>
</div>
<p>
If you are deleting this {OBJECT_NAME_SINGULAR}{' '}
for another reason that is not on this list,
please check with <Contact /> first. Thank you!
</p>
</>
),
})
}}
>
<Icon name="trash" /> Delete
</button>
)}
</div>
}

const actionInfo = selectedStatus
? getStatusActionInfo(selectedStatus.name)
: null
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

[nitpick] The immediately invoked function expression (IIFE) (() => { ... })() adds unnecessary complexity. Consider defining handleAction and actionInfo outside the IIFE and conditionally rendering the button based on selectedStatus.

Copilot uses AI. Check for mistakes.
self.validated_data["archived_on"] = timezone.now()
self.validated_data["active"] = False
elif status_name in ["FULL", "PRELIMINARY", "PROVISIONAL"]:
# approved clubs should be active
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

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

[nitpick] The comment says 'approved clubs should be active' but doesn't mention that approval metadata (approved_by, approved_on) should also be set. According to line 1889-1898 in the parent's save() method, these fields are set when approved=True. Consider updating the comment to clarify that approval metadata is handled by the parent save() method.

Suggested change
# approved clubs should be active
# approved clubs should be active; approval metadata (approved_by, approved_on) is set by the parent save() method

Copilot uses AI. Check for mistakes.
yuviji and others added 2 commits November 11, 2025 12:04
Copilot suggestion

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@codecov
Copy link

codecov bot commented Nov 11, 2025

Codecov Report

❌ Patch coverage is 91.54930% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.76%. Comparing base (ac78179) to head (54fdbfd).
⚠️ Report is 31 commits behind head on master.

Files with missing lines Patch % Lines
backend/clubs/serializers.py 91.22% 5 Missing ⚠️
backend/clubs/permissions.py 80.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #851      +/-   ##
==========================================
+ Coverage   74.66%   74.76%   +0.10%     
==========================================
  Files          31       31              
  Lines        7649     7696      +47     
==========================================
+ Hits         5711     5754      +43     
- Misses       1938     1942       +4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@julianweng
Copy link
Member

Thanks Yuvraj, here is what Codex says about your PR


High-Level Findings

  • approved is writable for admins but not treated as an approval field; an admin can PATCH {"approved": ...} without status_id, bypassing status mapping and leaving status/approved/active inconsistent (backend/clubs/serializers.py:2752-2774; backend/clubs/views.py:2096-2109). Make approved read-only for admins and/or include it in approval_fields.
  • Leaving DEFUNCT status doesn’t clear archived/archived_on (and possibly approval), so a club stays archived after setting status to Full/Preliminary/etc. Either disallow exiting Defunct or clear archive/approval when transitioning away (backend/clubs/serializers.py:2700-2727).
  • Officer “Under Review” flows silently no-op if the status is missing: “Request Review” and creation completion send an empty PATCH and reload without error when lookup fails (frontend/components/ClubPage/ClubApprovalDialog.tsx:497-517; frontend/components/ResourceCreationPage.tsx:349-370). Surface an error and skip the PATCH, or guarantee the status exists via migration/seed.

Nits / Other Comments

  • getStatusActionInfo can throw on empty/undefined; add a guard or document non-empty input (frontend/constants/status.ts).
  • Bulk modal button falls back to “Approve” when no status is selected; prefer a disabled/neutral state without implying an action (frontend/components/Settings/QueueTab.tsx).
  • Repeated “Under Review” fetch logic should be centralized (renew.tsx, ResourceCreationPage.tsx, ClubApprovalDialog.tsx).
  • Remove the commented-out validate_active block (backend/clubs/serializers.py ~1750).
  • Approval error message uses a set; sort for deterministic ordering (backend/clubs/views.py).
  • The IIFE around handleAction in ClubApprovalDialog adds noise; define handlers normally (frontend/components/ClubPage/ClubApprovalDialog.tsx).
  • Clarify the approved-status mapping comment to mention approval metadata is set in the parent save (backend/clubs/serializers.py ~2710).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Comments