-
Notifications
You must be signed in to change notification settings - Fork 7
Moving towards a status-based approval system #851
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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.", | ||||||
| }, | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
|
|
@@ -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 | ||||||
| ) | ||||||
| approved_by = serializers.SerializerMethodField("get_approved_by") | ||||||
| advisor_set = serializers.SerializerMethodField("get_advisor_set") | ||||||
| category = CategorySerializer(required=False) | ||||||
|
|
@@ -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
|
||||||
|
|
||||||
| def validate(self, data): | ||||||
| """ | ||||||
|
|
@@ -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") | ||||||
|
|
@@ -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( | ||||||
|
|
@@ -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), | ||||||
|
|
@@ -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 | ||||||
|
||||||
| # approved clubs should be active | |
| # approved clubs should be active; approval metadata (approved_by, approved_on) is set by the parent save() method |
Copilot
AI
Nov 11, 2025
There was a problem hiding this comment.
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.
| approved = serializers.BooleanField(required=False) | |
| approved = serializers.BooleanField(read_only=True) |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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)})" | ||||||
|
||||||
| f"When modifying approval fields ({', '.join(approval_fields)})" | |
| f"When modifying approval fields ({', '.join(sorted(approval_fields))})" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Setting
approved_commenttoread_only=Truein the baseClubSerializerprevents officers from adding comments when requesting review. However, inClubApprovalDialog.tsx(line 274), there's an attempt to setapproved_comment: 'Approval revoked'. While this works for admins usingAdminClubSerializer, the field definition inconsistency could cause confusion.There was a problem hiding this comment.
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?