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
4 changes: 2 additions & 2 deletions backend/clubs/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,8 +316,8 @@ class OwnershipRequestAdmin(admin.ModelAdmin):
"club__name",
"created_at",
)
list_display = ("requester", "club", "email", "withdrawn", "is_owner", "created_at")
list_filter = ("withdrawn",)
list_display = ("requester", "club", "email", "status", "is_owner", "created_at")
list_filter = ("status",)

def requester(self, obj):
return obj.requester.username
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 5.2.4 on 2025-09-07 23:44

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('clubs', '0131_alter_club_tags'),
]

operations = [
migrations.AlterUniqueTogether(
name='ownershiprequest',
unique_together=set(),
),
migrations.AddField(
model_name='ownershiprequest',
name='status',
field=models.IntegerField(choices=[(1, 'Pending'), (2, 'Withdrawn'), (3, 'Denied'), (4, 'Accepted')], default=1),
),
migrations.RemoveField(
model_name='ownershiprequest',
name='withdrawn',
),
]
51 changes: 49 additions & 2 deletions backend/clubs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1159,8 +1159,6 @@ class JoinRequest(models.Model):
)
club = models.ForeignKey(Club, on_delete=models.CASCADE, related_name="%(class)ss")

withdrawn = models.BooleanField(default=False)

created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

Expand All @@ -1174,6 +1172,8 @@ class MembershipRequest(JoinRequest):
Used when users are not in the club but request membership from the owner
"""

withdrawn = models.BooleanField(default=False)

def __str__(self):
return f"<MembershipRequest: {self.requester.username} for {self.club.code}>"

Expand Down Expand Up @@ -1205,9 +1205,56 @@ class OwnershipRequest(JoinRequest):
Represents a user's request to take ownership of a club
"""

PENDING = 1
WITHDRAWN = 2
DENIED = 3
ACCEPTED = 4
STATUS_TYPES = (
(PENDING, "Pending"),
(WITHDRAWN, "Withdrawn"),
(DENIED, "Denied"),
(ACCEPTED, "Accepted"),
)
status = models.IntegerField(choices=STATUS_TYPES, default=PENDING)

class Meta:
unique_together = ()

def __str__(self):
return f"<OwnershipRequest: {self.requester.username} for {self.club.code}>"

@classmethod
def get_recent_request(cls, user, club):
"""
Get the most recent ownership request for a user and club within 6 months.
"""
six_months_ago = timezone.now() - datetime.timedelta(days=180)
return (
cls.objects.filter(
club=club, requester=user, created_at__gte=six_months_ago
)
.order_by("-created_at")
.first()
)

@classmethod
def can_user_request_ownership(cls, user, club):
"""
Check if a user can make a new ownership request for a club.
Returns (can_request: bool, reason: str,
recent_request: OwnershipRequest or None)
"""
recent_request = cls.get_recent_request(user, club)

if recent_request is None:
return True, "No recent request found", None
elif recent_request.status == cls.WITHDRAWN:
return True, "Previous request was withdrawn", recent_request
elif recent_request.status == cls.PENDING:
return False, "Request already pending", recent_request
else: # ACCEPTED or DENIED
return False, "Request already handled within 6 months", recent_request

def send_request(self, request=None):
domain = get_domain(request)
edit_url = settings.EDIT_URL.format(domain=domain, club=self.club.code)
Expand Down
1 change: 1 addition & 0 deletions backend/clubs/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2353,6 +2353,7 @@ class Meta:
"name",
"requester",
"school",
"status",
"username",
)

Expand Down
104 changes: 84 additions & 20 deletions backend/clubs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4551,9 +4551,9 @@ class OwnershipRequestViewSet(viewsets.ModelViewSet):
"""
list: Return a list of clubs that the logged in user has sent ownership request to.

create: Sent ownership request to a club.
create: Send ownership request to a club.

destroy: Deleted a ownership request from a club.
destroy: Delete a ownership request from a club.
"""

serializer_class = UserOwnershipRequestSerializer
Expand All @@ -4563,26 +4563,44 @@ class OwnershipRequestViewSet(viewsets.ModelViewSet):

def create(self, request, *args, **kwargs):
"""
If a ownership request object already exists, reuse it.
Implement 6-month cooldown rule for ownership requests:
- If no request was made within the past 6 months, allow new request
- If withdrawn request was made within the past 6 months, allow resubmission
- If pending/accepted/denied request was made within the past 6 months,
deny new request
"""

club = request.data.get("club", None)
club_instance = Club.objects.filter(code=club).first()
if club_instance is None:
return Response(
{"detail": "Invalid club code"}, status=status.HTTP_400_BAD_REQUEST
)

create_defaults = {"club": club_instance, "requester": request.user}

obj, created = OwnershipRequest.objects.update_or_create(
club__code=club,
requester=request.user,
defaults={"withdrawn": False, "created_at": timezone.now()},
create_defaults=create_defaults,
can_request, reason, recent_request = (
OwnershipRequest.can_user_request_ownership(request.user, club_instance)
)

serializer = self.get_serializer(obj, many=False)
if not can_request:
return Response(
{"detail": reason},
status=status.HTTP_400_BAD_REQUEST,
)

if recent_request is None:
# No request within 6 months, create new one
request_instance = OwnershipRequest.objects.create(
club=club_instance,
requester=request.user,
status=OwnershipRequest.PENDING,
)
else:
# Withdrawn request within 6 months, reuse it
recent_request.status = OwnershipRequest.PENDING
recent_request.save()
request_instance = recent_request

serializer = self.get_serializer(request_instance, many=False)
return Response(serializer.data, status=status.HTTP_201_CREATED)

def destroy(self, request, *args, **kwargs):
Expand All @@ -4593,15 +4611,22 @@ def destroy(self, request, *args, **kwargs):
owners with requests.
"""
obj = self.get_object()
obj.withdrawn = True
obj.save(update_fields=["withdrawn"])

if obj.status != OwnershipRequest.PENDING:
return Response(
{"detail": "Cannot withdraw a request that has already been handled"},
status=status.HTTP_400_BAD_REQUEST,
)

obj.status = OwnershipRequest.WITHDRAWN
obj.save(update_fields=["status"])

return Response(status=status.HTTP_204_NO_CONTENT)

def get_queryset(self):
return OwnershipRequest.objects.filter(
requester=self.request.user,
withdrawn=False,
status=OwnershipRequest.PENDING,
club__archived=False,
)

Expand All @@ -4618,7 +4643,7 @@ class OwnershipRequestManagementViewSet(viewsets.ModelViewSet):
Accept an ownership request as a club owner.

all:
Return a list of ownership requests older than a week. Used by Superusers.
Return a list of ownership requests. Used by Superusers.
"""

serializer_class = OwnershipRequestSerializer
Expand All @@ -4630,12 +4655,12 @@ class OwnershipRequestManagementViewSet(viewsets.ModelViewSet):
def get_queryset(self):
if self.action != "all":
return OwnershipRequest.objects.filter(
club__code=self.kwargs["club_code"], withdrawn=False
status=OwnershipRequest.PENDING, club__code=self.kwargs["club_code"]
)
else:
return OwnershipRequest.objects.filter(withdrawn=False).order_by(
"created_at"
)
return OwnershipRequest.objects.filter(
status=OwnershipRequest.PENDING
).order_by("created_at")

@action(detail=True, methods=["post"])
def accept(self, request, *args, **kwargs):
Expand Down Expand Up @@ -4664,9 +4689,48 @@ def accept(self, request, *args, **kwargs):
defaults={"role": Membership.ROLE_OWNER},
)

request_object.delete()
request_object.status = OwnershipRequest.ACCEPTED
request_object.save(update_fields=["status"])
Comment on lines +4692 to +4693
Copy link
Member

Choose a reason for hiding this comment

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

Just realized: do we update requesters when the status of their request is updated? Feels like we should do that (if we don't already).


club_name = request_object.club.name
full_name = request_object.requester.get_full_name()

context = {
"club_name": club_name,
"full_name": full_name,
}

send_mail_helper(
name="ownership_request_accepted",
subject=f"Ownership Request Accepted for {club_name}",
emails=[request_object.requester.email],
context=context,
)

return Response({"success": True})

def destroy(self, request, *args, **kwargs):
obj = self.get_object()
obj.status = OwnershipRequest.DENIED
obj.save(update_fields=["status"])

club_name = obj.club.name
full_name = obj.requester.get_full_name()

context = {
"club_name": club_name,
"full_name": full_name,
}

send_mail_helper(
name="ownership_request_denied",
subject=f"Ownership Request Denied for {club_name}",
emails=[obj.requester.email],
context=context,
)

return Response(status=status.HTTP_204_NO_CONTENT)

@action(detail=False, methods=["get"], permission_classes=[IsSuperuser])
def all(self, request, *args, **kwargs):
"""
Expand Down
13 changes: 13 additions & 0 deletions backend/templates/emails/ownership_request_accepted.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- TYPES:
club_name:
type: string
full_name:
type: string
-->
{% extends 'emails/base.html' %}

{% block content %}
<h2>Ownership Request Accepted for <b>{{ club_name }}</b></h2>
<p style="font-size: 1.2em">Congratulations <b>{{ full_name }}</b>! Your request for ownership of <b>{{ club_name }}</b> has been accepted. You are now an owner of this club and can manage its settings and members.</p>
<p>You can now access the club management features through the Penn Clubs website.</p>
{% endblock %}
13 changes: 13 additions & 0 deletions backend/templates/emails/ownership_request_denied.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- TYPES:
club_name:
type: string
full_name:
type: string
-->
{% extends 'emails/base.html' %}

{% block content %}
<h2>Ownership Request Denied for <b>{{ club_name }}</b></h2>
<p style="font-size: 1.2em">Hello <b>{{ full_name }}</b>, your request for ownership of <b>{{ club_name }}</b> has been denied.</p>
<p>If you have any questions about this decision, please contact the club administrators or the Penn Clubs support team.</p>
{% endblock %}
Loading
Loading