diff --git a/backend/clubs/admin.py b/backend/clubs/admin.py index 19235ea06..9a46357aa 100644 --- a/backend/clubs/admin.py +++ b/backend/clubs/admin.py @@ -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 diff --git a/backend/clubs/migrations/0132_alter_ownershiprequest_unique_together_and_more.py b/backend/clubs/migrations/0132_alter_ownershiprequest_unique_together_and_more.py new file mode 100644 index 000000000..ed0194556 --- /dev/null +++ b/backend/clubs/migrations/0132_alter_ownershiprequest_unique_together_and_more.py @@ -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', + ), + ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index f6e603527..6b6e692c3 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -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) @@ -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"" @@ -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"" + @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) diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index c1cea8b27..73005ae80 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -2353,6 +2353,7 @@ class Meta: "name", "requester", "school", + "status", "username", ) diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 478b1e3ff..f4d6d6acb 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -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 @@ -4563,8 +4563,13 @@ 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: @@ -4572,17 +4577,30 @@ def create(self, request, *args, **kwargs): {"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): @@ -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, ) @@ -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 @@ -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): @@ -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"]) + + 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): """ diff --git a/backend/templates/emails/ownership_request_accepted.html b/backend/templates/emails/ownership_request_accepted.html new file mode 100644 index 000000000..e48d55d3f --- /dev/null +++ b/backend/templates/emails/ownership_request_accepted.html @@ -0,0 +1,13 @@ + +{% extends 'emails/base.html' %} + +{% block content %} +

Ownership Request Accepted for {{ club_name }}

+

Congratulations {{ full_name }}! Your request for ownership of {{ club_name }} has been accepted. You are now an owner of this club and can manage its settings and members.

+

You can now access the club management features through the Penn Clubs website.

+{% endblock %} diff --git a/backend/templates/emails/ownership_request_denied.html b/backend/templates/emails/ownership_request_denied.html new file mode 100644 index 000000000..3106174cb --- /dev/null +++ b/backend/templates/emails/ownership_request_denied.html @@ -0,0 +1,13 @@ + +{% extends 'emails/base.html' %} + +{% block content %} +

Ownership Request Denied for {{ club_name }}

+

Hello {{ full_name }}, your request for ownership of {{ club_name }} has been denied.

+

If you have any questions about this decision, please contact the club administrators or the Penn Clubs support team.

+{% endblock %} diff --git a/backend/tests/clubs/test_models.py b/backend/tests/clubs/test_models.py index 801619df9..c17928101 100644 --- a/backend/tests/clubs/test_models.py +++ b/backend/tests/clubs/test_models.py @@ -9,6 +9,7 @@ import pytz from django.contrib.auth import get_user_model from django.test import TestCase +from django.utils import timezone from clubs.models import ( Advisor, @@ -19,6 +20,7 @@ Favorite, Membership, Note, + OwnershipRequest, Tag, Year, send_mail_helper, @@ -284,3 +286,210 @@ def test_send_mail_retry_logic(self): ) self.assertEqual(mocked_send.call_count, 3) + + +class OwnershipRequestTestCase(TestCase): + """Test cases for OwnershipRequest model methods""" + + @classmethod + def setUpTestData(cls): + cls.user1 = get_user_model().objects.create_user( + "user1", "user1@example.com", "test" + ) + cls.user2 = get_user_model().objects.create_user( + "user2", "user2@example.com", "test" + ) + cls.club1 = Club.objects.create( + code="club1", name="Club 1", active=True, approved=True + ) + + def test_can_user_request_ownership_no_previous_request(self): + """Test can_user_request_ownership with no previous request""" + can_request, reason, recent_request = ( + OwnershipRequest.can_user_request_ownership(self.user2, self.club1) + ) + self.assertTrue(can_request) + self.assertEqual(reason, "No recent request found") + self.assertIsNone(recent_request) + + def test_can_user_request_ownership_pending_request(self): + """Test can_user_request_ownership with pending request""" + pending_request = OwnershipRequest.objects.create( + club=self.club1, requester=self.user2, status=OwnershipRequest.PENDING + ) + + can_request, reason, recent_request = ( + OwnershipRequest.can_user_request_ownership(self.user2, self.club1) + ) + self.assertFalse(can_request) + self.assertEqual(reason, "Request already pending") + self.assertEqual(recent_request, pending_request) + + def test_can_user_request_ownership_withdrawn_request(self): + """Test can_user_request_ownership with withdrawn request""" + withdrawn_request = OwnershipRequest.objects.create( + club=self.club1, requester=self.user2, status=OwnershipRequest.WITHDRAWN + ) + + can_request, reason, recent_request = ( + OwnershipRequest.can_user_request_ownership(self.user2, self.club1) + ) + self.assertTrue(can_request) + self.assertEqual(reason, "Previous request was withdrawn") + self.assertEqual(recent_request, withdrawn_request) + + def test_can_user_request_ownership_accepted_request(self): + """Test can_user_request_ownership with accepted request""" + accepted_request = OwnershipRequest.objects.create( + club=self.club1, requester=self.user2, status=OwnershipRequest.ACCEPTED + ) + + can_request, reason, recent_request = ( + OwnershipRequest.can_user_request_ownership(self.user2, self.club1) + ) + self.assertFalse(can_request) + self.assertEqual(reason, "Request already handled within 6 months") + self.assertEqual(recent_request, accepted_request) + + def test_can_user_request_ownership_denied_request(self): + """Test can_user_request_ownership with denied request""" + denied_request = OwnershipRequest.objects.create( + club=self.club1, requester=self.user2, status=OwnershipRequest.DENIED + ) + + can_request, reason, recent_request = ( + OwnershipRequest.can_user_request_ownership(self.user2, self.club1) + ) + self.assertFalse(can_request) + self.assertEqual(reason, "Request already handled within 6 months") + self.assertEqual(recent_request, denied_request) + + def test_can_user_request_ownership_old_request(self): + """Test can_user_request_ownership with request older than 6 months""" + old_request = OwnershipRequest.objects.create( + club=self.club1, requester=self.user2, status=OwnershipRequest.ACCEPTED + ) + # Set created_at to 7 months ago + old_request.created_at = timezone.now() - datetime.timedelta(days=210) + old_request.save() + + can_request, reason, recent_request = ( + OwnershipRequest.can_user_request_ownership(self.user2, self.club1) + ) + self.assertTrue(can_request) + self.assertEqual(reason, "No recent request found") + self.assertIsNone(recent_request) + + def test_get_recent_request_within_6_months(self): + """Test get_recent_request finds request within 6 months""" + request = OwnershipRequest.objects.create( + club=self.club1, requester=self.user2, status=OwnershipRequest.PENDING + ) + + recent_request = OwnershipRequest.get_recent_request(self.user2, self.club1) + self.assertEqual(recent_request, request) + + def test_get_recent_request_older_than_6_months(self): + """Test get_recent_request ignores request older than 6 months""" + old_request = OwnershipRequest.objects.create( + club=self.club1, requester=self.user2, status=OwnershipRequest.PENDING + ) + # Set created_at to 7 months ago + old_request.created_at = timezone.now() - datetime.timedelta(days=210) + old_request.save() + + recent_request = OwnershipRequest.get_recent_request(self.user2, self.club1) + self.assertIsNone(recent_request) + + def test_get_recent_request_multiple_requests(self): + """Test get_recent_request returns most recent request""" + # Create older request + old_request = OwnershipRequest.objects.create( + club=self.club1, requester=self.user2, status=OwnershipRequest.PENDING + ) + old_request.created_at = timezone.now() - datetime.timedelta(days=10) + old_request.save() + + # Create newer request + new_request = OwnershipRequest.objects.create( + club=self.club1, requester=self.user2, status=OwnershipRequest.PENDING + ) + new_request.created_at = timezone.now() - datetime.timedelta(days=5) + new_request.save() + + recent_request = OwnershipRequest.get_recent_request(self.user2, self.club1) + self.assertEqual(recent_request, new_request) + + def test_get_recent_request_no_requests(self): + """Test get_recent_request with no requests""" + recent_request = OwnershipRequest.get_recent_request(self.user2, self.club1) + self.assertIsNone(recent_request) + + def test_ownership_request_str_representation(self): + """Test string representation of OwnershipRequest""" + request = OwnershipRequest.objects.create( + club=self.club1, requester=self.user2, status=OwnershipRequest.PENDING + ) + + expected_str = ( + f"" + ) + self.assertEqual(str(request), expected_str) + + def test_ownership_request_default_status(self): + """Test that new requests default to PENDING status""" + request = OwnershipRequest.objects.create(club=self.club1, requester=self.user2) + + self.assertEqual(request.status, OwnershipRequest.PENDING) + + def test_ownership_request_multiple_requests_same_user_club(self): + """Test that multiple requests can exist for same user/club""" + # Create first request + request1 = OwnershipRequest.objects.create( + club=self.club1, requester=self.user2, status=OwnershipRequest.ACCEPTED + ) + + # Create second request (should be allowed due to removed unique constraint) + request2 = OwnershipRequest.objects.create( + club=self.club1, requester=self.user2, status=OwnershipRequest.PENDING + ) + + # Both requests should exist + self.assertEqual( + OwnershipRequest.objects.filter( + club=self.club1, requester=self.user2 + ).count(), + 2, + ) + + # They should be different objects + self.assertNotEqual(request1.id, request2.id) + + def test_ownership_request_6month_boundary(self): + """Test the exact 6-month boundary (180 days)""" + # Create request approximately 180 days ago + request = OwnershipRequest.objects.create( + club=self.club1, requester=self.user2, status=OwnershipRequest.ACCEPTED + ) + request.created_at = timezone.now() - datetime.timedelta( + days=179, hours=23, minutes=59 + ) + request.save() + + # Should still be considered "recent" (within 6 months) + can_request, reason, recent_request = ( + OwnershipRequest.can_user_request_ownership(self.user2, self.club1) + ) + self.assertFalse(can_request) + self.assertEqual(reason, "Request already handled within 6 months") + + # Move it to 181 days ago + request.created_at = timezone.now() - datetime.timedelta(days=181) + request.save() + + # Should now be considered "old" (outside 6 months) + can_request, reason, recent_request = ( + OwnershipRequest.can_user_request_ownership(self.user2, self.club1) + ) + self.assertTrue(can_request) + self.assertEqual(reason, "No recent request found") diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index 79c6e2340..fcd1b4800 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -3389,7 +3389,7 @@ def test_ownership_requests_withdraw(self): self.assertIn(resp.status_code, [200, 204], resp.content) self.assertEqual( OwnershipRequest.objects.filter( - club=self.club1, requester=self.user2, withdrawn=True + club=self.club1, requester=self.user2, status=OwnershipRequest.WITHDRAWN ).count(), 1, ) @@ -3471,11 +3471,18 @@ def test_ownership_requests_accept(self): self.assertEqual( OwnershipRequest.objects.filter( - club=self.club1, requester=self.user2 + club=self.club1, requester=self.user2, status=OwnershipRequest.PENDING ).count(), 0, ) + self.assertEqual( + OwnershipRequest.objects.filter( + club=self.club1, requester=self.user2, status=OwnershipRequest.ACCEPTED + ).count(), + 1, + ) + self.assertEqual( Membership.objects.filter( club=self.club1, person=self.user2, role=Membership.ROLE_OWNER @@ -3528,11 +3535,18 @@ def test_ownership_requests_destroy(self): self.assertEqual( OwnershipRequest.objects.filter( - club=self.club1, requester=self.user2 + club=self.club1, requester=self.user2, status=OwnershipRequest.PENDING ).count(), 0, ) + self.assertEqual( + OwnershipRequest.objects.filter( + club=self.club1, requester=self.user2, status=OwnershipRequest.DENIED + ).count(), + 1, + ) + self.assertEqual( Membership.objects.filter( club=self.club1, person=self.user2, role=Membership.ROLE_OWNER @@ -4239,6 +4253,246 @@ def test_club_description_word_limit(self): self.assertEqual(long_desc_club.description, long_description) self.assertEqual(long_desc_club.subtitle, "A new subtitle") + def test_ownership_requests_6month_cooldown_rule(self): + """ + Test the 6-month cooldown rule for ownership requests + """ + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + + # Test 1: No previous request - should allow new request + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse("ownership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + self.assertEqual(resp.status_code, 201, resp.content) + + # Test 2: Pending request within 6 months - should deny new request + resp = self.client.post( + reverse("ownership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + self.assertEqual(resp.status_code, 400, resp.content) + + # Test 3: Withdrawn request within 6 months - should allow resubmission + ownership_request = OwnershipRequest.objects.get( + club=self.club1, requester=self.user2 + ) + ownership_request.status = OwnershipRequest.WITHDRAWN + ownership_request.save() + + resp = self.client.post( + reverse("ownership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + self.assertEqual(resp.status_code, 201, resp.content) + + # Verify the same request object was reused + ownership_request.refresh_from_db() + self.assertEqual(ownership_request.status, OwnershipRequest.PENDING) + + # Test 4: Accepted request within 6 months - should deny new request + ownership_request.status = OwnershipRequest.ACCEPTED + ownership_request.save() + + resp = self.client.post( + reverse("ownership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + self.assertEqual(resp.status_code, 400, resp.content) + + # Test 5: Denied request within 6 months - should deny new request + ownership_request.status = OwnershipRequest.DENIED + ownership_request.save() + + resp = self.client.post( + reverse("ownership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + self.assertEqual(resp.status_code, 400, resp.content) + + # Test 6: Request older than 6 months - should allow new request + ownership_request.created_at = timezone.now() - timezone.timedelta(days=200) + ownership_request.save() + + resp = self.client.post( + reverse("ownership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + self.assertEqual(resp.status_code, 201, resp.content) + + def test_ownership_requests_withdrawal_validation(self): + """ + Test that withdrawal only works on pending requests + """ + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + + # Create ownership request + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse("ownership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + self.assertEqual(resp.status_code, 201, resp.content) + + ownership_request = OwnershipRequest.objects.get( + club=self.club1, requester=self.user2 + ) + + # Test withdrawing pending request - should work + resp = self.client.delete( + reverse("ownership-requests-detail", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 204, resp.content) + ownership_request.refresh_from_db() + self.assertEqual(ownership_request.status, OwnershipRequest.WITHDRAWN) + + # Test withdrawing already withdrawn request - should fail + resp = self.client.delete( + reverse("ownership-requests-detail", args=(self.club1.code,)) + ) + self.assertEqual( + resp.status_code, 404, resp.content + ) # Not found in user's pending requests + + # Test withdrawing accepted request - should fail + ownership_request.status = OwnershipRequest.ACCEPTED + ownership_request.save() + + # Create a new pending request to test withdrawal validation + OwnershipRequest.objects.create( + club=self.club1, requester=self.user2, status=OwnershipRequest.PENDING + ) + + # Manually set the accepted request back to pending to test validation + ownership_request.status = OwnershipRequest.ACCEPTED + ownership_request.save() + + # Try to withdraw the accepted request (this should fail in the viewset) + # We need to test this through the API, but first create a pending request + OwnershipRequest.objects.filter( + club=self.club1, requester=self.user2, status=OwnershipRequest.PENDING + ).delete() + + OwnershipRequest.objects.create( + club=self.club1, requester=self.user2, status=OwnershipRequest.ACCEPTED + ) + + # This should fail because the request is not pending + resp = self.client.delete( + reverse("ownership-requests-detail", args=(self.club1.code,)) + ) + self.assertEqual( + resp.status_code, 404, resp.content + ) # Not found in user's pending requests + + def test_ownership_requests_email_notifications(self): + """ + Test email notifications for accept/deny actions + """ + from django.core import mail + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + + # Create ownership request + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse("ownership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + self.assertEqual(resp.status_code, 201, resp.content) + + # Test accept email notification + self.client.login(username=self.user1.username, password="test") + resp = self.client.post( + reverse( + "club-ownership-requests-accept", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user2.username, + }, + ) + ) + self.assertEqual(resp.status_code, 200, resp.content) + + # Check that acceptance email was sent + self.assertEqual(len(mail.outbox), 1) + self.assertIn("Ownership Request Accepted", mail.outbox[0].subject) + self.assertIn(self.user2.email, mail.outbox[0].to) + self.assertIn(self.club1.name, mail.outbox[0].body) + + # Create another request for deny test + self.client.login(username=self.user2.username, password="test") + OwnershipRequest.objects.create( + club=self.club1, requester=self.user2, status=OwnershipRequest.PENDING + ) + + # Test deny email notification + self.client.login(username=self.user1.username, password="test") + resp = self.client.delete( + reverse( + "club-ownership-requests-detail", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user2.username, + }, + ) + ) + self.assertEqual(resp.status_code, 204, resp.content) + + # Check that denial email was sent + self.assertEqual(len(mail.outbox), 2) + self.assertIn("Ownership Request Denied", mail.outbox[1].subject) + self.assertIn(self.user2.email, mail.outbox[1].to) + self.assertIn(self.club1.name, mail.outbox[1].body) + + def test_ownership_requests_admin_view_only_pending(self): + """ + Test that admin view only shows pending requests + """ + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + + # Create requests with different statuses + OwnershipRequest.objects.create( + club=self.club1, requester=self.user2, status=OwnershipRequest.PENDING + ) + OwnershipRequest.objects.create( + club=self.club1, requester=self.user3, status=OwnershipRequest.ACCEPTED + ) + OwnershipRequest.objects.create( + club=self.club1, requester=self.user4, status=OwnershipRequest.DENIED + ) + OwnershipRequest.objects.create( + club=self.club1, requester=self.user5, status=OwnershipRequest.WITHDRAWN + ) + + # Test admin view (superuser) + self.client.login(username=self.user5.username, password="test") + resp = self.client.get( + reverse("club-ownership-requests-all", args=("anystring",)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + + # Should only show pending request + self.assertEqual(len(resp.json()), 1) + self.assertEqual(resp.json()[0]["username"], self.user2.username) + class HealthTestCase(TestCase): def test_health(self):