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
2 changes: 2 additions & 0 deletions backend/clubs/management/commands/populate.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,8 @@ def handle(self, *args, **kwargs):
"Inactive",
"Suspended",
"Defunct",
"Temporary",
"Under Review",
]
]

Expand Down
12 changes: 12 additions & 0 deletions backend/clubs/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ def find_membership_helper(user, club):
return membership_instance


def is_officer_or_above(user, club):
"""
Check if user is an officer or above (owner) for the given club.
Returns True if user has clubs.manage_club permission OR is an officer+ of the club.
"""
if user.has_perm("clubs.manage_club"):
return True

membership = find_membership_helper(user, club)
return membership is not None and membership.role <= Membership.ROLE_OFFICER


class ReadOnly(permissions.BasePermission):
"""
Only allow read access. Deny write access to everyone.
Expand Down
198 changes: 150 additions & 48 deletions backend/clubs/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1243,6 +1243,14 @@ class Meta:
"help_text": "The text shown to the user in a preview card. "
"Short description of the club.",
},
"approved": {
"read_only": True,
"help_text": "Whether the club has been approved by administrators.",
},
"active": {
"read_only": True,
"help_text": "Whether the club is active.",
},
}


Expand Down Expand Up @@ -1466,7 +1474,9 @@ class ClubSerializer(ManyToManySaveMixin, ClubListSerializer):
events = serializers.SerializerMethodField("get_events")
is_request = serializers.SerializerMethodField("get_is_request")
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?

)
approved_by = serializers.SerializerMethodField("get_approved_by")
advisor_set = serializers.SerializerMethodField("get_advisor_set")
category = CategorySerializer(required=False)
Expand Down Expand Up @@ -1751,27 +1761,27 @@ def validate_youtube(self, value):
"""
return social_validation_helper(value, ["youtube.com", "youtu.be"])

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

def validate(self, data):
"""
Expand Down Expand Up @@ -1845,30 +1855,35 @@ def save(self):
if request and request.user.has_perm("clubs.approve_club"):
needs_reapproval = False

queue_settings = RegistrationQueueSettings.get()
if needs_reapproval and not queue_settings.reapproval_queue_open:
raise serializers.ValidationError(
"The approval queue is not currently open."
)

has_approved_version = (
self.instance and self.instance.history.filter(approved=True).exists()
)

queue_settings = RegistrationQueueSettings.get()
if needs_reapproval:
# check appropriate queue based on whether club has been approved before
if has_approved_version and not queue_settings.reapproval_queue_open:
raise serializers.ValidationError(
"The reapproval queue is not currently open."
)
elif (
not has_approved_version and not queue_settings.new_approval_queue_open
):
raise serializers.ValidationError(
"The new approval queue is not currently open."
)

if needs_reapproval:
self.validated_data["approved"] = None
self.validated_data["approved_by"] = None
self.validated_data["approved_on"] = None
self.validated_data["ghost"] = has_approved_version

# if approval was revoked, also reset the other fields
if (
"approved" in self.validated_data
and self.validated_data["approved"] is None
):
self.validated_data["approved"] = None
self.validated_data["approved_by"] = None
self.validated_data["approved_on"] = None
under_review_status = Status.objects.filter(
name__iexact="Under Review"
).first()
if under_review_status:
self.validated_data["status"] = under_review_status

# if approved, update who and when club was approved
new_approval_status = self.validated_data.get("approved")
Expand Down Expand Up @@ -2598,6 +2613,7 @@ class Meta:
class AuthenticatedClubSerializer(ClubSerializer):
"""
Provides additional information about the club to members in the club.
Club officers can set status to "Under Review" for renewals.
"""

members = AuthenticatedMembershipSerializer(
Expand All @@ -2611,6 +2627,15 @@ class AuthenticatedClubSerializer(ClubSerializer):
owners = serializers.SerializerMethodField("get_owners")
officers = serializers.SerializerMethodField("get_officers")

# Make status_id writable for club officers (validation enforced below)
status_id = serializers.PrimaryKeyRelatedField(
queryset=Status.objects.all(),
source="status",
write_only=True,
required=False,
allow_null=True,
)

def get_owners(self, obj):
return MinimalUserProfileSerializer(
obj.members.filter(membership__role=Membership.ROLE_OWNER),
Expand All @@ -2627,6 +2652,83 @@ def get_officers(self, obj):
context=self.context,
).data

def validate_status_id(self, value):
"""
Club officers can only set status to "Under Review" for their own club.
Admins should use AdminClubSerializer which skips this validation.
"""
from clubs.permissions import is_officer_or_above

user = self.context["request"].user

# Can only set to "Under Review"
if value and value.name.upper() != "UNDER REVIEW":
raise serializers.ValidationError(
"You can only request a review by setting status to 'Under Review'. "
"Other status changes require administrator permissions."
)

# Must be officer or above of THIS specific club
club_code = self.context["view"].kwargs.get("code")
if not club_code:
raise serializers.ValidationError("Club not found.")

try:
club = Club.objects.get(code=club_code)
except Club.DoesNotExist:
raise serializers.ValidationError("Club not found.")

if not is_officer_or_above(user, club):
raise serializers.ValidationError(
"You must be at least an officer of this club to request a review."
)

return value

def save(self):
"""
Handle status mapping for club officers and admins.
Maps status changes to appropriate approval/active/archived states.
"""
request = self.context.get("request", None)

# Map status changes to approval/active/archived states
# Officers can set to "Under Review", admins can set to any status
if "status" in self.validated_data:
status = self.validated_data["status"]
if status:
status_name = status.name.upper()
if status_name == "DEFUNCT":
# archive and deactivate the club
if self.instance:
self.validated_data["archived"] = True
self.validated_data["archived_by"] = (
request.user if request is not None else None
)
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.
self.validated_data["approved"] = True
self.validated_data["active"] = True
elif status_name in ["SUSPENDED", "INACTIVE"]:
# rejected clubs should be inactive
self.validated_data["approved"] = False
self.validated_data["active"] = False
elif status_name == "UNDER REVIEW":
# under review = pending approval (clear approval fields)
self.validated_data["approved"] = None
self.validated_data["active"] = True
self.validated_data["approved_by"] = None
self.validated_data["approved_on"] = None
elif status_name == "TEMPORARY":
# temporary clubs are active but not approved
self.validated_data["approved"] = False
self.validated_data["active"] = True

# Call parent save to handle reapproval logic and other operations
return super().save()

class Meta(ClubSerializer.Meta):
fields = ClubSerializer.Meta.fields + [
"email_public",
Expand All @@ -2637,40 +2739,40 @@ class Meta(ClubSerializer.Meta):
"owners",
"officers",
"approved_on",
"status_id",
]


class AdminClubSerializer(AuthenticatedClubSerializer):
"""
Serializer for Club objects when used by admins.
Makes admin-only fields (status, type, eligibility) required by default.
Makes admin-only fields writable.
"""

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.
approved_comment = serializers.CharField(required=False, allow_null=True)
type = TypeSerializer(required=False, allow_null=True)
eligibility = EligibilitySerializer(many=True, required=False, allow_null=True)

# def validate(self, attrs):
# """
# require fields on creation
# """
# if self.instance is None:
# missing = {}
# for f in ("status", "type", "eligibility"):
# if not attrs.get(f):
# missing[f] = "This field is required."
# if missing:
# raise serializers.ValidationError(missing)
# return super().validate(attrs)
def validate_status_id(self, value):
"""
Admins can set any status without restrictions.
Override parent validation to skip officer-level checks.
"""
# No validation - admins can do anything
return value

class Meta(AuthenticatedClubSerializer.Meta):
fields = AuthenticatedClubSerializer.Meta.fields + [
"status",
"status_id",
"type",
"eligibility",
"approved",
"approved_comment",
]
save_related_fields = AuthenticatedClubSerializer.Meta.save_related_fields + [
"status",
"type",
"eligibility",
]
Expand Down
43 changes: 27 additions & 16 deletions backend/clubs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2089,22 +2089,28 @@ def get_filename(self):

def check_approval_permission(self, request):
"""
Only users with specific permissions can modify the approval field.
"""
if (
request.data.get("approved", None) is not None
or request.data.get("approved_comment", None) is not None
):
# users without approve permission cannot approve
if not request.user.has_perm("clubs.approve_club"):
raise PermissionDenied

# an approval request must not modify any other fields
if set(request.data.keys()) - {"approved", "approved_comment"}:
raise DRFValidationError(
"You can only pass the approved and approved_comment fields "
"when performing club approval."
)
Additional validation for approval-related and fair-related requests.
Permission to modify approval fields is enforced by serializer selection,
but we still validate that admins modify approval fields in isolation.
"""
# Check if approval fields are being modified
approval_fields = {"approved_comment", "status_id"}
provided_approval_fields = approval_fields & set(request.data.keys())

if provided_approval_fields:
# Admins must ONLY modify approval fields in a single request
# (They cannot modify approval + other fields simultaneously)
if request.user.has_perm("clubs.approve_club"):
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.
" you cannot modify other club fields in the same request. "
"Please make separate requests."
)
# For non-Admins like Officers, we handle the logic in the serializer.
# We don't turn them away here as they could be setting
# status to "Under Review" for renewal requests.

if request.data.get("fair", None) is not None:
if set(request.data.keys()) - {"fair"}:
Expand Down Expand Up @@ -2493,6 +2499,11 @@ def get_serializer_class(self):
if self.request.user.is_superuser:
return AdminClubSerializer

# Users with approve_club permission should use AdminClubSerializer
# to allow setting any status, not just "Under Review"
if self.request.user.has_perm("clubs.approve_club"):
return AdminClubSerializer

see_pending = self.request.user.has_perm("clubs.see_pending_clubs")
manage_club = self.request.user.has_perm("clubs.manage_club")
is_member = (
Expand Down
Loading
Loading