diff --git a/backend/clubs/admin.py b/backend/clubs/admin.py index 73046a164..9f060f328 100644 --- a/backend/clubs/admin.py +++ b/backend/clubs/admin.py @@ -38,6 +38,7 @@ MembershipRequest, Note, NoteTag, + OwnershipRequest, Profile, QuestionAnswer, RecurringEvent, @@ -263,25 +264,50 @@ def email(self, obj): class MembershipRequestAdmin(admin.ModelAdmin): - search_fields = ("person__username", "person__email", "club__name", "club__pk") - list_display = ("person", "club", "email", "withdrew", "is_member") - list_filter = ("withdrew",) + search_fields = ( + "requester__username", + "requester__email", + "club__name", + "club__pk", + ) + list_display = ("requester", "club", "email", "withdrawn", "is_member") + list_filter = ("withdrawn",) - def person(self, obj): - return obj.person.username + def requester(self, obj): + return obj.requester.username def club(self, obj): return obj.club.name def email(self, obj): - return obj.person.email + return obj.requester.email def is_member(self, obj): - return obj.club.membership_set.filter(person__pk=obj.person.pk).exists() + return obj.club.membership_set.filter(person__pk=obj.requester.pk).exists() is_member.boolean = True +class OwnershipRequestAdmin(admin.ModelAdmin): + search_fields = ( + "requester__username", + "requester__email", + "club__name", + "created_at", + ) + list_display = ("requester", "club", "email", "withdrawn", "created_at") + list_filter = ("withdrawn",) + + def requester(self, obj): + return obj.requester.username + + def club(self, obj): + return obj.club.name + + def email(self, obj): + return obj.requester.email + + class MembershipAdmin(admin.ModelAdmin): search_fields = ( "person__username", @@ -443,6 +469,7 @@ class ClubApprovalResponseTemplateAdmin(admin.ModelAdmin): admin.site.register(Major, MajorAdmin) admin.site.register(Membership, MembershipAdmin) admin.site.register(MembershipInvite, MembershipInviteAdmin) +admin.site.register(OwnershipRequest, OwnershipRequestAdmin) admin.site.register(Profile, ProfileAdmin) admin.site.register(QuestionAnswer, QuestionAnswerAdmin) admin.site.register(RecurringEvent) diff --git a/backend/clubs/migrations/0118_ownershiprequest.py b/backend/clubs/migrations/0118_ownershiprequest.py new file mode 100644 index 000000000..77703c778 --- /dev/null +++ b/backend/clubs/migrations/0118_ownershiprequest.py @@ -0,0 +1,94 @@ +# Generated by Django 5.0.4 on 2024-10-18 05:04 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0117_clubapprovalresponsetemplate"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="OwnershipRequest", + fields=[ + ("id", models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID")), + ("withdrew", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("club", models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="clubs.club")), + ("person", models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL)), + ], + options={ + "unique_together": {("person", "club")}, + }, + ), + migrations.RenameField( + model_name="ownershiprequest", + old_name="withdrew", + new_name="withdrawn", + ), + migrations.RenameField( + model_name="ownershiprequest", + old_name="person", + new_name="requester", + ), + migrations.AlterField( + model_name="ownershiprequest", + name="club", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)ss", + to="clubs.club" + ), + ), + migrations.AlterField( + model_name="ownershiprequest", + name="requester", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)ss", + to=settings.AUTH_USER_MODEL + ), + ), + migrations.RenameField( + model_name="membershiprequest", + old_name="withdrew", + new_name="withdrawn", + ), + migrations.RenameField( + model_name="membershiprequest", + old_name="person", + new_name="requester", + ), + migrations.AlterField( + model_name="membershiprequest", + name="club", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)ss", + to="clubs.club" + ), + ), + migrations.AlterField( + model_name="membershiprequest", + name="requester", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)ss", + to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 6329cb15a..6d02d2170 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1077,49 +1077,89 @@ def __str__(self): return "".format(self.query, self.created_at) -class MembershipRequest(models.Model): +class Request(models.Model): """ - Used when users are not in the club but request membership from the owner + Abstract base class for Membership Request and Ownership Request """ - person = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) - club = models.ForeignKey(Club, on_delete=models.CASCADE) + requester = models.ForeignKey( + get_user_model(), on_delete=models.CASCADE, related_name="%(class)ss" + ) + club = models.ForeignKey(Club, on_delete=models.CASCADE, related_name="%(class)ss") - withdrew = models.BooleanField(default=False) + withdrawn = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + class Meta: + abstract = True + unique_together = (("requester", "club"),) + + +class MembershipRequest(Request): + """ + Used when users are not in the club but request membership from the owner + """ + def __str__(self): - return "".format( - self.person.username, self.club.code, self.person.email - ) + return f"" def send_request(self, request=None): domain = get_domain(request) + edit_url = settings.EDIT_URL.format(domain=domain, club=self.club.code) + club_name = self.club.name + full_name = self.requester.get_full_name() context = { - "club_name": self.club.name, - "edit_url": "{}/member".format( - settings.EDIT_URL.format(domain=domain, club=self.club.code) - ), - "full_name": self.person.get_full_name(), + "club_name": club_name, + "edit_url": f"{edit_url}/member", + "full_name": full_name, } emails = self.club.get_officer_emails() if emails: send_mail_helper( - name="request", - subject="Membership Request from {} for {}".format( - self.person.get_full_name(), self.club.name - ), + name="membership_request", + subject=f"Membership Request from {full_name} for {club_name}", emails=emails, context=context, ) - class Meta: - unique_together = (("person", "club"),) + +class OwnershipRequest(Request): + """ + Represents a user's request to take ownership of a club + """ + + def __str__(self): + return f"" + + def send_request(self, request=None): + domain = get_domain(request) + edit_url = settings.EDIT_URL.format(domain=domain, club=self.club.code) + club_name = self.club.name + full_name = self.requester.get_full_name() + + context = { + "club_name": club_name, + "edit_url": f"{edit_url}/member", + "full_name": full_name, + } + + owner_emails = list( + self.club.membership_set.filter( + role=Membership.ROLE_OWNER, active=True + ).values_list("person__email", flat=True) + ) + + send_mail_helper( + name="ownership_request", + subject=f"Ownership Request from {full_name} for {club_name}", + emails=owner_emails, + context=context, + ) class Advisor(models.Model): diff --git a/backend/clubs/permissions.py b/backend/clubs/permissions.py index 664c0775c..d5b2229c8 100644 --- a/backend/clubs/permissions.py +++ b/backend/clubs/permissions.py @@ -439,6 +439,23 @@ def has_permission(self, request, view): return membership is not None and membership.role <= Membership.ROLE_OFFICER +class OwnershipRequestPermission(permissions.BasePermission): + """ + Only owners can view and modify ownership requests. + """ + + def has_permission(self, request, view): + if not request.user.is_authenticated: + return False + + if "club_code" not in view.kwargs: + return False + + obj = Club.objects.get(code=view.kwargs["club_code"]) + membership = find_membership_helper(request.user, obj) + return membership is not None and membership.role == Membership.ROLE_OWNER + + class InvitePermission(permissions.BasePermission): """ Officers and higher can list/delete invitations. diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index fc9f1a095..36d9f5a48 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -43,6 +43,7 @@ MembershipRequest, Note, NoteTag, + OwnershipRequest, Profile, QuestionAnswer, Report, @@ -636,7 +637,9 @@ def update(self, instance, validated_data): obj.save() # if a membership request exists, delete it - MembershipRequest.objects.filter(person=user, club=self.instance.club).delete() + MembershipRequest.objects.filter( + requester=user, club=self.instance.club + ).delete() return instance @@ -1267,7 +1270,7 @@ def get_is_request(self, obj): user = self.context["request"].user if not user.is_authenticated: return False - return obj.membershiprequest_set.filter(person=user, withdrew=False).exists() + return obj.membershiprequests.filter(requester=user, withdrawn=False).exists() def get_target_years(self, obj): qset = TargetYear.objects.filter(club=obj).select_related("target_years") @@ -1940,20 +1943,22 @@ class MembershipRequestSerializer(serializers.ModelSerializer): Used by club owners/officers to see who has requested to join the club. """ - person = serializers.HiddenField(default=serializers.CurrentUserDefault()) + requester = serializers.HiddenField(default=serializers.CurrentUserDefault()) club = serializers.SlugRelatedField(queryset=Club.objects.all(), slug_field="code") name = serializers.SerializerMethodField("get_full_name") - username = serializers.CharField(source="person.username", read_only=True) - email = serializers.EmailField(source="person.email", read_only=True) + username = serializers.CharField(source="requester.username", read_only=True) + email = serializers.EmailField(source="requester.email", read_only=True) - school = SchoolSerializer(many=True, source="person.profile.school", read_only=True) - major = MajorSerializer(many=True, source="person.profile.major", read_only=True) + school = SchoolSerializer( + many=True, source="requester.profile.school", read_only=True + ) + major = MajorSerializer(many=True, source="requester.profile.major", read_only=True) graduation_year = serializers.IntegerField( - source="person.profile.graduation_year", read_only=True + source="requester.profile.graduation_year", read_only=True ) def get_full_name(self, obj): - return obj.person.get_full_name() + return obj.requester.get_full_name() class Meta: model = MembershipRequest @@ -1964,15 +1969,10 @@ class Meta: "graduation_year", "major", "name", - "person", + "requester", "school", "username", ) - validators = [ - validators.UniqueTogetherValidator( - queryset=MembershipRequest.objects.all(), fields=["club", "person"] - ) - ] class UserMembershipRequestSerializer(serializers.ModelSerializer): @@ -1980,23 +1980,64 @@ class UserMembershipRequestSerializer(serializers.ModelSerializer): Used by the UserSerializer to return the clubs that the user has sent request to. """ - person = serializers.HiddenField(default=serializers.CurrentUserDefault()) + requester = serializers.HiddenField(default=serializers.CurrentUserDefault()) club = serializers.SlugRelatedField(queryset=Club.objects.all(), slug_field="code") club_name = serializers.CharField(source="club.name", read_only=True) - def create(self, validated_data): - """ - Send an email when a membership request is created. - """ - obj = super().create(validated_data) + class Meta: + model = MembershipRequest + fields = ("club", "club_name", "requester") - obj.send_request(self.context["request"]) - return obj +class OwnershipRequestSerializer(serializers.ModelSerializer): + """ + Used by club owners to see who has requested to be owner of the club. + """ + + requester = serializers.HiddenField(default=serializers.CurrentUserDefault()) + club = serializers.SlugRelatedField(queryset=Club.objects.all(), slug_field="code") + name = serializers.SerializerMethodField("get_full_name") + username = serializers.CharField(source="requester.username", read_only=True) + email = serializers.EmailField(source="requester.email", read_only=True) + + school = SchoolSerializer( + many=True, source="requester.profile.school", read_only=True + ) + major = MajorSerializer(many=True, source="requester.profile.major", read_only=True) + graduation_year = serializers.IntegerField( + source="requester.profile.graduation_year", read_only=True + ) + + def get_full_name(self, obj): + return obj.requester.get_full_name() class Meta: - model = MembershipRequest - fields = ("club", "club_name", "person") + model = OwnershipRequest + fields = ( + "club", + "created_at", + "email", + "graduation_year", + "major", + "name", + "requester", + "school", + "username", + ) + + +class UserOwnershipRequestSerializer(serializers.ModelSerializer): + """ + Used by the users to return the clubs that the user has sent an OwnershipRequest to. + """ + + requester = serializers.HiddenField(default=serializers.CurrentUserDefault()) + club = serializers.SlugRelatedField(queryset=Club.objects.all(), slug_field="code") + club_name = serializers.CharField(source="club.name", read_only=True) + + class Meta: + model = OwnershipRequest + fields = ("club", "club_name", "requester") class MinimalUserProfileSerializer(serializers.ModelSerializer): diff --git a/backend/clubs/urls.py b/backend/clubs/urls.py index 48f2f0572..e194bdaa2 100644 --- a/backend/clubs/urls.py +++ b/backend/clubs/urls.py @@ -36,6 +36,8 @@ MemberViewSet, NoteViewSet, OptionListView, + OwnershipRequestManagementViewSet, + OwnershipRequestViewSet, QuestionAnswerViewSet, ReportViewSet, SchoolViewSet, @@ -71,7 +73,12 @@ router.register(r"clubvisits", ClubVisitViewSet, basename="clubvisits") router.register(r"searches", SearchQueryViewSet, basename="searches") router.register(r"memberships", MembershipViewSet, basename="members") -router.register(r"requests", MembershipRequestViewSet, basename="requests") +router.register( + r"requests/membership", MembershipRequestViewSet, basename="membership-requests" +) +router.register( + r"requests/ownership", OwnershipRequestViewSet, basename="ownership-requests" +) router.register(r"tickets", TicketViewSet, basename="tickets") router.register(r"schools", SchoolViewSet, basename="schools") @@ -109,6 +116,11 @@ MembershipRequestOwnerViewSet, basename="club-membership-requests", ) +clubs_router.register( + r"ownershiprequests", + OwnershipRequestManagementViewSet, + basename="club-ownership-requests", +) clubs_router.register(r"advisors", AdvisorViewSet, basename="club-advisors") clubs_router.register( r"applications", ClubApplicationViewSet, basename="club-applications" diff --git a/backend/clubs/views.py b/backend/clubs/views.py index b425af8f1..6fbc91e06 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -107,6 +107,7 @@ MembershipInvite, MembershipRequest, Note, + OwnershipRequest, QuestionAnswer, RecurringEvent, Report, @@ -138,6 +139,7 @@ MemberPermission, MembershipRequestPermission, NotePermission, + OwnershipRequestPermission, ProfilePermission, QuestionAnswerPermission, ReadOnly, @@ -181,6 +183,7 @@ MembershipSerializer, MinimalUserProfileSerializer, NoteSerializer, + OwnershipRequestSerializer, QuestionAnswerSerializer, ReportClubSerializer, ReportSerializer, @@ -197,6 +200,7 @@ UserMembershipInviteSerializer, UserMembershipRequestSerializer, UserMembershipSerializer, + UserOwnershipRequestSerializer, UserProfileSerializer, UserSerializer, UserSubscribeSerializer, @@ -3777,15 +3781,27 @@ def create(self, request, *args, **kwargs): If a membership request object already exists, reuse it. """ club = request.data.get("club", None) - obj = MembershipRequest.objects.filter( - club__code=club, person=request.user - ).first() - if obj is not None: - obj.withdrew = False - obj.save(update_fields=["withdrew"]) - return Response(UserMembershipRequestSerializer(obj).data) + 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 + ) - return super().create(request, *args, **kwargs) + create_defaults = {"club": club_instance, "requester": request.user} + + obj, created = MembershipRequest.objects.update_or_create( + club__code=club, + requester=request.user, + defaults={"withdrawn": False, "created_at": timezone.now()}, + create_defaults=create_defaults, + ) + + if created: + obj.send_request(request) + + serializer = self.get_serializer(obj, many=False) + + return Response(serializer.data, status=status.HTTP_201_CREATED) def destroy(self, request, *args, **kwargs): """ @@ -3795,15 +3811,15 @@ def destroy(self, request, *args, **kwargs): owners with requests. """ obj = self.get_object() - obj.withdrew = True - obj.save(update_fields=["withdrew"]) + obj.withdrawn = True + obj.save(update_fields=["withdrawn"]) - return Response({"success": True}) + return Response(status=status.HTTP_204_NO_CONTENT) def get_queryset(self): return MembershipRequest.objects.filter( - person=self.request.user, - withdrew=False, + requester=self.request.user, + withdrawn=False, club__archived=False, ) @@ -3820,11 +3836,11 @@ class MembershipRequestOwnerViewSet(XLSXFormatterMixin, viewsets.ModelViewSet): serializer_class = MembershipRequestSerializer permission_classes = [MembershipRequestPermission | IsSuperuser] http_method_names = ["get", "post", "delete"] - lookup_field = "person__username" + lookup_field = "requester__username" def get_queryset(self): return MembershipRequest.objects.filter( - club__code=self.kwargs["club_code"], withdrew=False + club__code=self.kwargs["club_code"], withdrawn=False ) @action(detail=True, methods=["post"]) @@ -3848,11 +3864,193 @@ def accept(self, request, *ages, **kwargs): """ request_object = self.get_object() Membership.objects.get_or_create( - person=request_object.person, club=request_object.club + person=request_object.requester, club=request_object.club + ) + request_object.delete() + return Response({"success": True}) + + +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. + + destroy: Deleted a ownership request from a club. + """ + + serializer_class = UserOwnershipRequestSerializer + permission_classes = [IsAuthenticated] + lookup_field = "club__code" + http_method_names = ["get", "post", "delete"] + + def create(self, request, *args, **kwargs): + """ + If a ownership request object already exists, reuse it. + """ + 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, + ) + + if created: + obj.send_request(request) + + serializer = self.get_serializer(obj, many=False) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def destroy(self, request, *args, **kwargs): + """ + Don't actually delete the ownership request when it is withdrawn. + + This is to keep track of repeat ownership requests and avoid spamming the club + owners with requests. + """ + obj = self.get_object() + obj.withdrawn = True + obj.save(update_fields=["withdrawn"]) + + return Response(status=status.HTTP_204_NO_CONTENT) + + def get_queryset(self): + return OwnershipRequest.objects.filter( + requester=self.request.user, + withdrawn=False, + club__archived=False, + ) + + +class OwnershipRequestManagementViewSet(viewsets.ModelViewSet): + """ + list: + Return a list of users who have sent ownership request to the club. + + destroy: + Delete a ownership request for a specific user. + + accept: + Accept an ownership request as a club owner. + + old_requests: + Return a list of ownership requests older than a week. Used by Superusers. + """ + + serializer_class = OwnershipRequestSerializer + + permission_classes = [OwnershipRequestPermission | IsSuperuser] + http_method_names = ["get", "post", "delete"] + lookup_field = "requester__username" + + def get_queryset(self): + if self.action != "all_requests": + return OwnershipRequest.objects.filter( + club__code=self.kwargs["club_code"], withdrawn=False + ) + else: + return OwnershipRequest.objects.filter(withdrawn=False).order_by( + "created_at" + ) + + @action(detail=True, methods=["post"]) + def accept(self, request, *args, **kwargs): + """ + Accept an ownership request as a club owner. + --- + requestBody: {} + responses: + "200": + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + description: > + True if this request was properly processed. + --- + """ + request_object = self.get_object() + membership, created = Membership.objects.get_or_create( + person=request_object.requester, + club=request_object.club, + defaults={"role": Membership.ROLE_OWNER}, ) + + if not created and membership.role != Membership.ROLE_OWNER: + membership.role = Membership.ROLE_OWNER + membership.save(update_fields=["role"]) + request_object.delete() return Response({"success": True}) + @action(detail=False, methods=["get"], permission_classes=[IsSuperuser]) + def all_requests(self, request, *args, **kwargs): + """ + View unaddressed ownership requests, sorted by date. + --- + requestBody: {} + responses: + "200": + content: + application/json: + schema: + type: array + items: + type: object + properties: + club: + type: string + created_at: + type: string + format: date-time + email: + type: string + graduation_year: + type: integer + major: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + name: + type: string + school: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + is_graduate: + type: boolean + username: + type: string + --- + """ + + serializer = self.get_serializer(self.get_queryset(), many=True) + + return Response(serializer.data) + class MemberViewSet(XLSXFormatterMixin, viewsets.ModelViewSet): """ diff --git a/backend/templates/emails/membership_request.html b/backend/templates/emails/membership_request.html new file mode 100644 index 000000000..fd4a9cb80 --- /dev/null +++ b/backend/templates/emails/membership_request.html @@ -0,0 +1,17 @@ + +{% extends 'emails/base.html' %} + +{% block content %} +

Request for membership of {{ club_name }} from {{ full_name }}

+

{{ full_name }} has submitted a request for membership of {{ club_name }} through the Penn + Clubs website. To approve this request, use the button below to navigate to the Penn Clubs website.

+ Approve Request +{% endblock %} \ No newline at end of file diff --git a/backend/templates/emails/request.html b/backend/templates/emails/ownership_request.html similarity index 69% rename from backend/templates/emails/request.html rename to backend/templates/emails/ownership_request.html index 1da3c8e8e..5caadb4e8 100644 --- a/backend/templates/emails/request.html +++ b/backend/templates/emails/ownership_request.html @@ -9,8 +9,8 @@ {% extends 'emails/base.html' %} {% block content %} -

Membership Request from {{ full_name }} for {{ club_name }}

-

{{ full_name }} sent a membership request to join {{ club_name }} through the Penn +

Request for ownership of {{ club_name }} from {{ full_name }}

+

{{ full_name }} has submitted a request for ownership of {{ club_name }} through the Penn Clubs website. To approve this request, use the button below to navigate to the Penn Clubs website.

Approve Request diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index b4931d737..bb571cb64 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -31,6 +31,8 @@ Favorite, Membership, MembershipInvite, + MembershipRequest, + OwnershipRequest, QuestionAnswer, School, Tag, @@ -2937,6 +2939,556 @@ def test_club_approval_response_templates(self): ) self.assertEqual(resp.status_code, 403) + def test_ownership_requests_create_and_view(self): + """ + Test the ownership requests creation and viewing permissions + """ + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + + # Requester can create ownership request and email is sent + 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) + self.assertEqual( + OwnershipRequest.objects.filter( + club=self.club1, requester=self.user2 + ).count(), + 1, + ) + self.assertEqual(len(mail.outbox), 1, mail.outbox) + + # Requester can check own ownership requests + resp = self.client.get(reverse("ownership-requests-list")) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 1, resp.content) + + # Requester can check own ownership request to a club + resp = self.client.get( + reverse("ownership-requests-detail", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()["club"], self.club1.code, resp.content) + + # Requester cannot check club's ownership requests + resp = self.client.get( + reverse("club-ownership-requests-list", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 403, resp.content) + + # Owner can check club's ownership requests + self.client.login(username=self.user1.username, password="test") + resp = self.client.get( + reverse("club-ownership-requests-list", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 1, resp.content) + + # Owner can check club's ownership requests for specific user + resp = self.client.get( + reverse( + "club-ownership-requests-detail", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user2.username, + }, + ) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()["club"], self.club1.code, resp.content) + + def test_ownership_requests_withdraw(self): + """ + Test the ownership requests withdraw feature + """ + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + + 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(len(mail.outbox), 1, mail.outbox) + + OwnershipRequest.objects.filter(club=self.club1, requester=self.user2).update( + created_at=timezone.now() - timezone.timedelta(days=8) + ) + + # Requester can withdraw + self.client.login(username=self.user2.username, password="test") + resp = self.client.delete( + reverse("ownership-requests-detail", args=(self.club1.code,)) + ) + self.assertIn(resp.status_code, [200, 204], resp.content) + self.assertEqual( + OwnershipRequest.objects.filter( + club=self.club1, requester=self.user2, withdrawn=True + ).count(), + 1, + ) + + resp = self.client.get( + reverse("ownership-requests-detail", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 404, resp.content) + + # Owner and superuser cannot see withdrawn requests + + self.client.login(username=self.user1.username, password="test") + resp = self.client.get( + reverse("club-ownership-requests-list", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 0, resp.content) + + self.client.login(username=self.user5.username, password="test") + resp = self.client.get( + reverse("club-ownership-requests-old-requests", args=("anystring",)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 0, resp.content) + + # Recreate 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) + + # Emails are not resent + self.assertEqual(len(mail.outbox), 1, mail.outbox) + + # Owner can see recreated ownership requests + + self.client.login(username=self.user1.username, password="test") + resp = self.client.get( + reverse("club-ownership-requests-list", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 1, resp.content) + + def test_ownership_requests_accept(self): + """ + Test the ownership requests accept feature + """ + + 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", + ) + + # Requester cannot accept requests + 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, 403, resp.content) + + # Owner can accept requests + 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) + + self.assertEqual( + OwnershipRequest.objects.filter( + club=self.club1, requester=self.user2 + ).count(), + 0, + ) + + self.assertEqual( + Membership.objects.filter( + club=self.club1, person=self.user2, role=Membership.ROLE_OWNER + ).count(), + 1, + ) + + def test_ownership_requests_destroy(self): + """ + Test the ownership requests destroy (denial of request) feature + """ + + 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", + ) + + # Requester cannot destroy requests + 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, 403, resp.content) + + # Owner can destroy requests + 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.assertIn(resp.status_code, [200, 204], resp.content) + + self.assertEqual( + OwnershipRequest.objects.filter( + club=self.club1, requester=self.user2 + ).count(), + 0, + ) + + self.assertEqual( + Membership.objects.filter( + club=self.club1, person=self.user2, role=Membership.ROLE_OWNER + ).count(), + 0, + ) + + def test_ownership_requests_list_all_requests(self): + """ + Test the ownership requests list all requests feature + """ + + self.client.login(username=self.user5.username, password="test") + resp = self.client.get( + reverse("club-ownership-requests-all-requests", args=("anystring",)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 0, resp.content) + + OwnershipRequest.objects.create( + club=self.club1, + requester=self.user2, + ) + OwnershipRequest.objects.filter( + club=self.club1, + requester=self.user2, + ).update(created_at=timezone.now() - timezone.timedelta(days=1)) + + OwnershipRequest.objects.create( + club=self.club1, + requester=self.user3, + ) + OwnershipRequest.objects.filter( + club=self.club1, + requester=self.user3, + ).update(created_at=timezone.now() - timezone.timedelta(days=100)) + + OwnershipRequest.objects.create( + club=self.club1, + requester=self.user4, + ) + OwnershipRequest.objects.filter( + club=self.club1, + requester=self.user4, + ).update(created_at=timezone.now() - timezone.timedelta(days=10)) + + self.client.login(username=self.user5.username, password="test") + resp = self.client.get( + reverse("club-ownership-requests-all-requests", args=("anystring",)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 3, resp.content) + + # Check oldest requests first + self.assertEqual(resp.json()[0]["username"], self.user3.username, resp.content) + self.assertEqual(resp.json()[1]["username"], self.user4.username, resp.content) + self.assertEqual(resp.json()[2]["username"], self.user2.username, resp.content) + + def test_membership_requests_create_and_view(self): + """ + Test the membership requests creation and viewing permissions + """ + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse("membership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + self.assertEqual(resp.status_code, 201, resp.content) + self.assertEqual( + MembershipRequest.objects.filter( + club=self.club1, requester=self.user2 + ).count(), + 1, + ) + self.assertEqual(len(mail.outbox), 1, mail.outbox) + + # Requester can check own membership requests + resp = self.client.get(reverse("membership-requests-list")) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 1, resp.content) + + # Requester can check own membership request to a club + resp = self.client.get( + reverse("membership-requests-detail", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()["club"], self.club1.code, resp.content) + + # Requester cannot check club's membership requests + resp = self.client.get( + reverse("club-membership-requests-list", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 403, resp.content) + + # Owner can check club's membership requests + self.client.login(username=self.user1.username, password="test") + resp = self.client.get( + reverse("club-membership-requests-list", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 1, resp.content) + + # Owner can check club's membership requests for specific user + resp = self.client.get( + reverse( + "club-membership-requests-detail", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user2.username, + }, + ) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()["club"], self.club1.code, resp.content) + + def test_membership_requests_withdraw(self): + """ + Test the membership requests withdraw feature + """ + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse("membership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + + # Requester can withdraw + resp = self.client.delete( + reverse("membership-requests-detail", args=(self.club1.code,)) + ) + self.assertIn(resp.status_code, [200, 204], resp.content) + self.assertEqual( + MembershipRequest.objects.filter( + club=self.club1, requester=self.user2, withdrawn=True + ).count(), + 1, + ) + + # Requester cannot see withdrawn request + resp = self.client.get( + reverse("membership-requests-detail", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 404, resp.content) + + # Owner cannot see withdrawn request + self.client.login(username=self.user1.username, password="test") + resp = self.client.get( + reverse("club-membership-requests-list", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 0, resp.content) + + # Recreate membership request + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse("membership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + self.assertEqual(resp.status_code, 201, resp.content) + self.assertEqual( + MembershipRequest.objects.filter( + club=self.club1, requester=self.user2 + ).count(), + 1, + ) + + # Email are not resent + self.assertEqual(len(mail.outbox), 1, mail.outbox) + + # Owner can see recreated membership request + self.client.login(username=self.user1.username, password="test") + resp = self.client.get( + reverse("club-membership-requests-list", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 1, resp.content) + + def test_membership_requests_accept(self): + """ + Test the membership requests accept feature + """ + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse("membership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + + # Requester cannot accept membership requests + resp = self.client.post( + reverse( + "club-membership-requests-accept", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user2.username, + }, + ) + ) + self.assertEqual(resp.status_code, 403, resp.content) + + # Owner can accept membership requests + self.client.login(username=self.user1.username, password="test") + + resp = self.client.post( + reverse( + "club-membership-requests-accept", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user2.username, + }, + ) + ) + self.assertEqual(resp.status_code, 200, resp.content) + + self.assertEqual( + MembershipRequest.objects.filter( + club=self.club1, requester=self.user2 + ).count(), + 0, + ) + + self.assertEqual( + Membership.objects.filter( + club=self.club1, + person=self.user2, + ).count(), + 1, + ) + + def test_membership_requests_destroy(self): + """ + Test the membership requests destroy (denial of request) feature + """ + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse("membership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + + # Requester cannot destroy membership requests + resp = self.client.delete( + reverse( + "club-membership-requests-detail", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user2.username, + }, + ) + ) + self.assertEqual(resp.status_code, 403, resp.content) + + # Owner can accept membership requests + self.client.login(username=self.user1.username, password="test") + + resp = self.client.delete( + reverse( + "club-membership-requests-detail", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user2.username, + }, + ) + ) + self.assertIn(resp.status_code, [200, 204], resp.content) + + self.assertEqual( + MembershipRequest.objects.filter( + club=self.club1, requester=self.user2 + ).count(), + 0, + ) + + self.assertEqual( + Membership.objects.filter( + club=self.club1, + person=self.user2, + ).count(), + 0, + ) + class HealthTestCase(TestCase): def test_health(self):