From 42e953bf6a3da18588f8045c022b315e0427c6a7 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 13 Oct 2024 10:57:44 -0400 Subject: [PATCH 01/32] Make models, perms, serializers, urls, and views for ownershiprequests and migrate --- .../clubs/migrations/0114_ownershiprequest.py | 41 +++++++ backend/clubs/models.py | 48 ++++++++ backend/clubs/permissions.py | 20 ++++ backend/clubs/serializers.py | 65 +++++++++++ backend/clubs/urls.py | 10 ++ backend/clubs/views.py | 108 ++++++++++++++++++ .../templates/emails/ownershiprequest.html | 17 +++ 7 files changed, 309 insertions(+) create mode 100644 backend/clubs/migrations/0114_ownershiprequest.py create mode 100644 backend/templates/emails/ownershiprequest.html diff --git a/backend/clubs/migrations/0114_ownershiprequest.py b/backend/clubs/migrations/0114_ownershiprequest.py new file mode 100644 index 000000000..a5daf1c50 --- /dev/null +++ b/backend/clubs/migrations/0114_ownershiprequest.py @@ -0,0 +1,41 @@ +# Generated by Django 5.0.4 on 2024-10-06 05:12 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0113_badge_message"), + 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")}, + }, + ), + ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 87848e2c4..1f270c509 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1122,6 +1122,54 @@ class Meta: unique_together = (("person", "club"),) +class OwnershipRequest(models.Model): + """ + Used when users request ownership from the owner + """ + + person = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + club = models.ForeignKey(Club, on_delete=models.CASCADE) + + withdrew = models.BooleanField(default=False) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return "".format( + self.person.username, self.club.code, self.person.email + ) + + def send_request(self, request=None): + domain = get_domain(request) + + 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(), + } + + owner_emails = list( + self.club.membership_set.filter( + role=Membership.ROLE_OWNER, active=True + ).values_list("person__email", flat=True) + ) + + send_mail_helper( + name="ownershiprequest", + subject="Ownership Request from {} for {}".format( + self.person.get_full_name(), self.club.name + ), + emails=owner_emails, + context=context, + ) + + class Meta: + unique_together = (("person", "club"),) + + class Advisor(models.Model): """ Represents one faculty advisor or point of contact for a club. diff --git a/backend/clubs/permissions.py b/backend/clubs/permissions.py index daf103333..8ff6152d3 100644 --- a/backend/clubs/permissions.py +++ b/backend/clubs/permissions.py @@ -437,6 +437,26 @@ 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 + + if request.user.has_perm("clubs.manage_club"): + return True + + 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 015a4c7da..59e221d58 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -42,6 +42,7 @@ MembershipRequest, Note, NoteTag, + OwnershipRequest, Profile, QuestionAnswer, Report, @@ -1998,6 +1999,70 @@ class Meta: fields = ("club", "club_name", "person") +class OwnershipRequestSerializer(serializers.ModelSerializer): + """ + Used by club owners/officers to see who has requested to be owner of the club. + """ + + person = 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) + + school = SchoolSerializer(many=True, source="person.profile.school", read_only=True) + major = MajorSerializer(many=True, source="person.profile.major", read_only=True) + graduation_year = serializers.IntegerField( + source="person.profile.graduation_year", read_only=True + ) + + def get_full_name(self, obj): + return obj.person.get_full_name() + + class Meta: + model = OwnershipRequest + fields = ( + "club", + "created_at", + "email", + "graduation_year", + "major", + "name", + "person", + "school", + "username", + ) + validators = [ + validators.UniqueTogetherValidator( + queryset=OwnershipRequest.objects.all(), fields=["club", "person"] + ) + ] + + +class UserOwnershipRequestSerializer(serializers.ModelSerializer): + """ + Used by the users to return the clubs that the user has sent OwnershipRequest to. + """ + + person = 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 ownership request is created. + """ + obj = super().create(validated_data) + + obj.send_request(self.context["request"]) + + return obj + + class Meta: + model = OwnershipRequest + fields = ("club", "club_name", "person") + + class MinimalUserProfileSerializer(serializers.ModelSerializer): """ A profile serializer used for the list view of all users. diff --git a/backend/clubs/urls.py b/backend/clubs/urls.py index 4395aa838..d9be8bd6c 100644 --- a/backend/clubs/urls.py +++ b/backend/clubs/urls.py @@ -34,6 +34,8 @@ MemberViewSet, NoteViewSet, OptionListView, + OwnershipRequestOwnerViewSet, + OwnershipRequestViewSet, QuestionAnswerViewSet, ReportViewSet, SchoolViewSet, @@ -70,6 +72,9 @@ router.register(r"searches", SearchQueryViewSet, basename="searches") router.register(r"memberships", MembershipViewSet, basename="members") router.register(r"requests", MembershipRequestViewSet, basename="requests") +router.register( + r"ownershiprequests", OwnershipRequestViewSet, basename="ownershiprequests" +) router.register(r"tickets", TicketViewSet, basename="tickets") router.register(r"schools", SchoolViewSet, basename="schools") @@ -106,6 +111,11 @@ MembershipRequestOwnerViewSet, basename="club-membership-requests", ) +clubs_router.register( + r"ownershiprequests", + OwnershipRequestOwnerViewSet, + 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 a5f7fa6fc..858e1a128 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -106,6 +106,7 @@ MembershipInvite, MembershipRequest, Note, + OwnershipRequest, QuestionAnswer, RecurringEvent, Report, @@ -137,6 +138,7 @@ MemberPermission, MembershipRequestPermission, NotePermission, + OwnershipRequestPermission, ProfilePermission, QuestionAnswerPermission, ReadOnly, @@ -178,6 +180,7 @@ MembershipSerializer, MinimalUserProfileSerializer, NoteSerializer, + OwnershipRequestSerializer, QuestionAnswerSerializer, ReportClubSerializer, ReportSerializer, @@ -194,6 +197,7 @@ UserMembershipInviteSerializer, UserMembershipRequestSerializer, UserMembershipSerializer, + UserOwnershipRequestSerializer, UserProfileSerializer, UserSerializer, UserSubscribeSerializer, @@ -3796,6 +3800,110 @@ def accept(self, request, *ages, **kwargs): 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) + obj = OwnershipRequest.objects.filter( + club__code=club, person=request.user + ).first() + if obj is not None: + obj.withdrew = False + obj.created_at = timezone.now() + obj.save(update_fields=["withdrew", "created_at"]) + return Response(UserOwnershipRequestSerializer(obj).data) + + return super().create(request, *args, **kwargs) + + 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.withdrew = True + obj.save(update_fields=["withdrew"]) + + return Response({"success": True}) + + def get_queryset(self): + return OwnershipRequest.objects.filter( + person=self.request.user, + withdrew=False, + club__archived=False, + ) + + +class OwnershipRequestOwnerViewSet(XLSXFormatterMixin, viewsets.ModelViewSet): + """ + list: + Return a list of users who have sent membership request to the club. + + destroy: + Delete a membership request for a specific user. + """ + + serializer_class = OwnershipRequestSerializer + permission_classes = [OwnershipRequestPermission | IsSuperuser] + http_method_names = ["get", "post", "delete"] + lookup_field = "person__username" + + def get_queryset(self): + return OwnershipRequest.objects.filter( + club__code=self.kwargs["club_code"], withdrew=False + ) + + @action(detail=True, methods=["post"]) + def accept(self, request, *ages, **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.person, + 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}) + + class MemberViewSet(XLSXFormatterMixin, viewsets.ModelViewSet): """ list: diff --git a/backend/templates/emails/ownershiprequest.html b/backend/templates/emails/ownershiprequest.html new file mode 100644 index 000000000..d1b7cc6a6 --- /dev/null +++ b/backend/templates/emails/ownershiprequest.html @@ -0,0 +1,17 @@ + +{% extends 'emails/base.html' %} + +{% block content %} +

Ownership Request from {{ full_name }} for {{ club_name }}

+

{{ full_name }} sent an ownership request to join {{ 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 From 94392124fe88c3bb5de0ba9223540da2444e7195 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 15 Oct 2024 14:13:55 -0400 Subject: [PATCH 02/32] add API view for admin to see all ownershiprequests older than a week --- backend/clubs/admin.py | 23 +++++++++++++++++++++++ backend/clubs/urls.py | 6 ++++++ backend/clubs/views.py | 18 ++++++++++++++++-- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/backend/clubs/admin.py b/backend/clubs/admin.py index e48ba1678..aa3bb45b0 100644 --- a/backend/clubs/admin.py +++ b/backend/clubs/admin.py @@ -37,6 +37,7 @@ MembershipRequest, Note, NoteTag, + OwnershipRequest, Profile, QuestionAnswer, RecurringEvent, @@ -281,6 +282,27 @@ def is_member(self, obj): is_member.boolean = True +class OwnershipRequestAdmin(admin.ModelAdmin): + search_fields = ( + "person__username", + "person__email", + "club__name", + "club__pk", + "created_at", + ) + list_display = ("person", "club", "email", "withdrew", "created_at") + list_filter = ("withdrew",) + + def person(self, obj): + return obj.person.username + + def club(self, obj): + return obj.club.name + + def email(self, obj): + return obj.person.email + + class MembershipAdmin(admin.ModelAdmin): search_fields = ( "person__username", @@ -438,6 +460,7 @@ class ApplicationSubmissionAdmin(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/urls.py b/backend/clubs/urls.py index d9be8bd6c..095c57942 100644 --- a/backend/clubs/urls.py +++ b/backend/clubs/urls.py @@ -35,6 +35,7 @@ NoteViewSet, OptionListView, OwnershipRequestOwnerViewSet, + OwnershipRequestSuperuserAPIView, OwnershipRequestViewSet, QuestionAnswerViewSet, ReportViewSet, @@ -173,6 +174,11 @@ path(r"emailpreview/", email_preview, name="email-preview"), path(r"scripts/", ScriptExecutionView.as_view(), name="scripts"), path(r"options/", OptionListView.as_view(), name="options"), + path( + r"ownershiprequestsadmin/", + OwnershipRequestSuperuserAPIView.as_view(), + name="ownershiprequestsadmin", + ), path(r"social/", include("social_django.urls", namespace="social")), path( r"webhook/meeting/", diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 858e1a128..0e3af6ae3 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -3854,10 +3854,10 @@ def get_queryset(self): class OwnershipRequestOwnerViewSet(XLSXFormatterMixin, viewsets.ModelViewSet): """ list: - Return a list of users who have sent membership request to the club. + Return a list of users who have sent ownership request to the club. destroy: - Delete a membership request for a specific user. + Delete a ownership request for a specific user. """ serializer_class = OwnershipRequestSerializer @@ -3904,6 +3904,20 @@ def accept(self, request, *ages, **kwargs): return Response({"success": True}) +class OwnershipRequestSuperuserAPIView(generics.ListAPIView): + """ + Return a list of ownership requests older than a week. + """ + + serializer_class = OwnershipRequestSerializer + permission_classes = [IsSuperuser] + + def get_queryset(self): + return OwnershipRequest.objects.filter( + withdrew=False, created_at__lte=timezone.now() - datetime.timedelta(days=7) + ) + + class MemberViewSet(XLSXFormatterMixin, viewsets.ModelViewSet): """ list: From 32bacedfa5d8a42b1d1e9939e6c632e2a0b29272 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 15 Oct 2024 16:56:13 -0400 Subject: [PATCH 03/32] merge conflicting migrations --- .../clubs/migrations/0117_merge_20241015_1652.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 backend/clubs/migrations/0117_merge_20241015_1652.py diff --git a/backend/clubs/migrations/0117_merge_20241015_1652.py b/backend/clubs/migrations/0117_merge_20241015_1652.py new file mode 100644 index 000000000..79a310e63 --- /dev/null +++ b/backend/clubs/migrations/0117_merge_20241015_1652.py @@ -0,0 +1,14 @@ +# Generated by Django 5.0.4 on 2024-10-15 20:52 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0114_ownershiprequest"), + ("clubs", "0116_alter_club_approved_on_and_more"), + ] + + operations = [ + ] From dc828b2289b8bfef63f187f281fe9432ade60fd1 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 18 Oct 2024 01:08:46 -0400 Subject: [PATCH 04/32] migrate ownershiprequests --- .../clubs/migrations/0117_merge_20241015_1652.py | 14 -------------- ...wnershiprequest.py => 0118_ownershiprequest.py} | 13 +++++-------- 2 files changed, 5 insertions(+), 22 deletions(-) delete mode 100644 backend/clubs/migrations/0117_merge_20241015_1652.py rename backend/clubs/migrations/{0114_ownershiprequest.py => 0118_ownershiprequest.py} (79%) diff --git a/backend/clubs/migrations/0117_merge_20241015_1652.py b/backend/clubs/migrations/0117_merge_20241015_1652.py deleted file mode 100644 index 79a310e63..000000000 --- a/backend/clubs/migrations/0117_merge_20241015_1652.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 5.0.4 on 2024-10-15 20:52 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("clubs", "0114_ownershiprequest"), - ("clubs", "0116_alter_club_approved_on_and_more"), - ] - - operations = [ - ] diff --git a/backend/clubs/migrations/0114_ownershiprequest.py b/backend/clubs/migrations/0118_ownershiprequest.py similarity index 79% rename from backend/clubs/migrations/0114_ownershiprequest.py rename to backend/clubs/migrations/0118_ownershiprequest.py index a5daf1c50..eab5e325d 100644 --- a/backend/clubs/migrations/0114_ownershiprequest.py +++ b/backend/clubs/migrations/0118_ownershiprequest.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.4 on 2024-10-06 05:12 +# Generated by Django 5.0.4 on 2024-10-18 05:04 import django.db.models.deletion from django.conf import settings @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ - ("clubs", "0113_badge_message"), + ("clubs", "0117_clubapprovalresponsetemplate"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -20,19 +20,16 @@ class Migration(migrations.Migration): auto_created=True, primary_key=True, serialize=False, - verbose_name="ID" - )), + 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" - )), + to="clubs.club")), ("person", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL - )), + to=settings.AUTH_USER_MODEL)), ], options={ "unique_together": {("person", "club")}, From 5fba7b88fd85faaf45f444cae26f216f062e1405 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 18 Oct 2024 06:53:50 -0400 Subject: [PATCH 05/32] edit descriptions of OwnershipRequestSerializer and OwnershipRequest --- backend/clubs/models.py | 2 +- backend/clubs/serializers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/clubs/models.py b/backend/clubs/models.py index da3f22812..9c08e3437 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1124,7 +1124,7 @@ class Meta: class OwnershipRequest(models.Model): """ - Used when users request ownership from the owner + Represents a user's request to take ownership of a club """ person = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index 94a28a9cb..d1fa6375e 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -2002,7 +2002,7 @@ class Meta: class OwnershipRequestSerializer(serializers.ModelSerializer): """ - Used by club owners/officers to see who has requested to be owner of the club. + Used by club owners to see who has requested to be owner of the club. """ person = serializers.HiddenField(default=serializers.CurrentUserDefault()) From 6b511f0a0467cea4b17fb09dd20eb766a4fafbdd Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 18 Oct 2024 08:22:30 -0400 Subject: [PATCH 06/32] rename, update ownershiprequest model fields --- backend/clubs/admin.py | 9 ++-- ...rew_ownershiprequest_withdrawn_and_more.py | 44 +++++++++++++++++++ backend/clubs/models.py | 34 +++++++------- backend/clubs/serializers.py | 24 +++++----- backend/clubs/views.py | 24 +++++----- 5 files changed, 92 insertions(+), 43 deletions(-) create mode 100644 backend/clubs/migrations/0119_rename_withdrew_ownershiprequest_withdrawn_and_more.py diff --git a/backend/clubs/admin.py b/backend/clubs/admin.py index cbfa005b8..bf8dead38 100644 --- a/backend/clubs/admin.py +++ b/backend/clubs/admin.py @@ -288,20 +288,19 @@ class OwnershipRequestAdmin(admin.ModelAdmin): "person__username", "person__email", "club__name", - "club__pk", "created_at", ) - list_display = ("person", "club", "email", "withdrew", "created_at") - list_filter = ("withdrew",) + list_display = ("person", "club", "email", "withdrawn", "created_at") + list_filter = ("withdrawn",) def person(self, obj): - return obj.person.username + return obj.requester.username def club(self, obj): return obj.club.name def email(self, obj): - return obj.person.email + return obj.requester.email class MembershipAdmin(admin.ModelAdmin): diff --git a/backend/clubs/migrations/0119_rename_withdrew_ownershiprequest_withdrawn_and_more.py b/backend/clubs/migrations/0119_rename_withdrew_ownershiprequest_withdrawn_and_more.py new file mode 100644 index 000000000..42cc01f7b --- /dev/null +++ b/backend/clubs/migrations/0119_rename_withdrew_ownershiprequest_withdrawn_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 5.0.4 on 2024-10-18 11:42 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0118_ownershiprequest"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + 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="ownership_requests", + to="clubs.club" + ), + ), + migrations.AlterField( + model_name="ownershiprequest", + name="requester", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ownership_requests", + to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 9c08e3437..d564a3189 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1127,28 +1127,34 @@ class OwnershipRequest(models.Model): Represents a user's request to take ownership of a club """ - 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="ownership_requests" + ) + club = models.ForeignKey( + Club, on_delete=models.CASCADE, related_name="ownership_requests" + ) - 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) 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, } owner_emails = list( @@ -1159,15 +1165,13 @@ def send_request(self, request=None): send_mail_helper( name="ownershiprequest", - subject="Ownership Request from {} for {}".format( - self.person.get_full_name(), self.club.name - ), + subject=f"Ownership Request from {full_name} for {club_name}", emails=owner_emails, context=context, ) class Meta: - unique_together = (("person", "club"),) + unique_together = (("requester", "club"),) class Advisor(models.Model): diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index d1fa6375e..988d75b61 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -2005,20 +2005,22 @@ class OwnershipRequestSerializer(serializers.ModelSerializer): Used by club owners to see who has requested to be owner of 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 = OwnershipRequest @@ -2029,13 +2031,13 @@ class Meta: "graduation_year", "major", "name", - "person", + "requester", "school", "username", ) validators = [ validators.UniqueTogetherValidator( - queryset=OwnershipRequest.objects.all(), fields=["club", "person"] + queryset=OwnershipRequest.objects.all(), fields=["club", "requester"] ) ] @@ -2045,7 +2047,7 @@ class UserOwnershipRequestSerializer(serializers.ModelSerializer): Used by the users to return the clubs that the user has sent OwnershipRequest 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) @@ -2061,7 +2063,7 @@ def create(self, validated_data): class Meta: model = OwnershipRequest - fields = ("club", "club_name", "person") + fields = ("club", "club_name", "requester") class MinimalUserProfileSerializer(serializers.ModelSerializer): diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 5f8e5de2b..c415ec2f3 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -3832,12 +3832,12 @@ def create(self, request, *args, **kwargs): """ club = request.data.get("club", None) obj = OwnershipRequest.objects.filter( - club__code=club, person=request.user + club__code=club, requester=request.user ).first() if obj is not None: - obj.withdrew = False + obj.withdrawn = False obj.created_at = timezone.now() - obj.save(update_fields=["withdrew", "created_at"]) + obj.save(update_fields=["withdrawn", "created_at"]) return Response(UserOwnershipRequestSerializer(obj).data) return super().create(request, *args, **kwargs) @@ -3850,20 +3850,20 @@ 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}) def get_queryset(self): return OwnershipRequest.objects.filter( - person=self.request.user, - withdrew=False, + requester=self.request.user, + withdrawn=False, club__archived=False, ) -class OwnershipRequestOwnerViewSet(XLSXFormatterMixin, viewsets.ModelViewSet): +class OwnershipRequestOwnerViewSet(viewsets.ModelViewSet): """ list: Return a list of users who have sent ownership request to the club. @@ -3875,11 +3875,11 @@ class OwnershipRequestOwnerViewSet(XLSXFormatterMixin, viewsets.ModelViewSet): serializer_class = OwnershipRequestSerializer permission_classes = [OwnershipRequestPermission | IsSuperuser] http_method_names = ["get", "post", "delete"] - lookup_field = "person__username" + lookup_field = "requester__username" def get_queryset(self): return OwnershipRequest.objects.filter( - club__code=self.kwargs["club_code"], withdrew=False + club__code=self.kwargs["club_code"], withdrawn=False ) @action(detail=True, methods=["post"]) @@ -3903,7 +3903,7 @@ def accept(self, request, *ages, **kwargs): """ request_object = self.get_object() membership, created = Membership.objects.get_or_create( - person=request_object.person, + requester=request_object.requester, club=request_object.club, defaults={"role": Membership.ROLE_OWNER}, ) @@ -3926,7 +3926,7 @@ class OwnershipRequestSuperuserAPIView(generics.ListAPIView): def get_queryset(self): return OwnershipRequest.objects.filter( - withdrew=False, created_at__lte=timezone.now() - datetime.timedelta(days=7) + withdrawn=False, created_at__lte=timezone.now() - datetime.timedelta(days=7) ) From f36d1f9a1f3bca077db274d50838d1a9af8c391b Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 18 Oct 2024 08:25:46 -0400 Subject: [PATCH 07/32] edit ownership_request.html template --- backend/clubs/models.py | 2 +- .../emails/{ownershiprequest.html => ownership_request.html} | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename backend/templates/emails/{ownershiprequest.html => ownership_request.html} (68%) diff --git a/backend/clubs/models.py b/backend/clubs/models.py index d564a3189..421d07fd9 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1164,7 +1164,7 @@ def send_request(self, request=None): ) send_mail_helper( - name="ownershiprequest", + name="ownership_request", subject=f"Ownership Request from {full_name} for {club_name}", emails=owner_emails, context=context, diff --git a/backend/templates/emails/ownershiprequest.html b/backend/templates/emails/ownership_request.html similarity index 68% rename from backend/templates/emails/ownershiprequest.html rename to backend/templates/emails/ownership_request.html index d1b7cc6a6..9c07d005c 100644 --- a/backend/templates/emails/ownershiprequest.html +++ b/backend/templates/emails/ownership_request.html @@ -9,8 +9,8 @@ {% extends 'emails/base.html' %} {% block content %} -

Ownership Request from {{ full_name }} for {{ club_name }}

-

{{ full_name }} sent an ownership request to join {{ club_name }} through the Penn +

Request for ownership for {{ 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 From c5b6c4c8629c6cd32894fbcd4dd6dc36733b1876 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 18 Oct 2024 17:10:51 -0400 Subject: [PATCH 08/32] Combine club admin and superuser viewsets for ownership requests --- backend/clubs/admin.py | 2 +- backend/clubs/urls.py | 14 ++++---------- backend/clubs/views.py | 40 +++++++++++++++++++++++----------------- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/backend/clubs/admin.py b/backend/clubs/admin.py index bf8dead38..9a9c777b5 100644 --- a/backend/clubs/admin.py +++ b/backend/clubs/admin.py @@ -290,7 +290,7 @@ class OwnershipRequestAdmin(admin.ModelAdmin): "club__name", "created_at", ) - list_display = ("person", "club", "email", "withdrawn", "created_at") + list_display = ("requester", "club", "email", "withdrawn", "created_at") list_filter = ("withdrawn",) def person(self, obj): diff --git a/backend/clubs/urls.py b/backend/clubs/urls.py index c036c486b..985cc636d 100644 --- a/backend/clubs/urls.py +++ b/backend/clubs/urls.py @@ -35,8 +35,7 @@ MemberViewSet, NoteViewSet, OptionListView, - OwnershipRequestOwnerViewSet, - OwnershipRequestSuperuserAPIView, + OwnershipRequestManagementViewSet, OwnershipRequestViewSet, QuestionAnswerViewSet, ReportViewSet, @@ -73,9 +72,9 @@ 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="requests") router.register( - r"ownershiprequests", OwnershipRequestViewSet, basename="ownershiprequests" + r"requests/ownership", OwnershipRequestViewSet, basename="ownershiprequests" ) router.register(r"tickets", TicketViewSet, basename="tickets") @@ -116,7 +115,7 @@ ) clubs_router.register( r"ownershiprequests", - OwnershipRequestOwnerViewSet, + OwnershipRequestManagementViewSet, basename="club-ownership-requests", ) clubs_router.register(r"advisors", AdvisorViewSet, basename="club-advisors") @@ -176,11 +175,6 @@ path(r"emailpreview/", email_preview, name="email-preview"), path(r"scripts/", ScriptExecutionView.as_view(), name="scripts"), path(r"options/", OptionListView.as_view(), name="options"), - path( - r"ownershiprequestsadmin/", - OwnershipRequestSuperuserAPIView.as_view(), - name="ownershiprequestsadmin", - ), path(r"social/", include("social_django.urls", namespace="social")), path( r"webhook/meeting/", diff --git a/backend/clubs/views.py b/backend/clubs/views.py index c415ec2f3..7b641880e 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -3863,27 +3863,37 @@ def get_queryset(self): ) -class OwnershipRequestOwnerViewSet(viewsets.ModelViewSet): +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): - return OwnershipRequest.objects.filter( - club__code=self.kwargs["club_code"], withdrawn=False - ) + if self.action != "old_requests": + return OwnershipRequest.objects.filter( + club__code=self.kwargs["club_code"], withdrawn=False + ) + else: + return OwnershipRequest.objects.filter(withdrawn=False) @action(detail=True, methods=["post"]) - def accept(self, request, *ages, **kwargs): + def accept(self, request, *args, **kwargs): """ Accept an ownership request as a club owner. --- @@ -3903,7 +3913,7 @@ def accept(self, request, *ages, **kwargs): """ request_object = self.get_object() membership, created = Membership.objects.get_or_create( - requester=request_object.requester, + person=request_object.requester, club=request_object.club, defaults={"role": Membership.ROLE_OWNER}, ) @@ -3915,20 +3925,16 @@ def accept(self, request, *ages, **kwargs): request_object.delete() return Response({"success": True}) - -class OwnershipRequestSuperuserAPIView(generics.ListAPIView): - """ - Return a list of ownership requests older than a week. - """ - - serializer_class = OwnershipRequestSerializer - permission_classes = [IsSuperuser] - - def get_queryset(self): - return OwnershipRequest.objects.filter( + @action(detail=False, methods=["get"], permission_classes=[IsSuperuser]) + def old_requests(self, request, *args, **kwargs): + queryset = OwnershipRequest.objects.filter( withdrawn=False, created_at__lte=timezone.now() - datetime.timedelta(days=7) ) + serializer = self.get_serializer(queryset, many=True) + + return Response(serializer.data) + class MemberViewSet(XLSXFormatterMixin, viewsets.ModelViewSet): """ From 917c1fc3cbedf4f9d9ba322c641ce45499c03c3c Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 18 Oct 2024 18:19:37 -0400 Subject: [PATCH 09/32] Specify YAML documentation for old_requests in OwnershipRequestManagementViewSet --- backend/clubs/views.py | 49 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 7b641880e..6dc9133e5 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -3927,6 +3927,55 @@ def accept(self, request, *args, **kwargs): @action(detail=False, methods=["get"], permission_classes=[IsSuperuser]) def old_requests(self, request, *args, **kwargs): + """ + View unaddressed ownership requests that are older than a week. + --- + 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 + --- + """ + queryset = OwnershipRequest.objects.filter( withdrawn=False, created_at__lte=timezone.now() - datetime.timedelta(days=7) ) From ec1597ff4445ff553fe7868263894dd80f6cdebb Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 20 Oct 2024 13:23:39 -0400 Subject: [PATCH 10/32] Fix permissions for OwnershipRequest management to not specify clubs.manage_club --- backend/clubs/permissions.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/clubs/permissions.py b/backend/clubs/permissions.py index 8ff6152d3..8083fd9f8 100644 --- a/backend/clubs/permissions.py +++ b/backend/clubs/permissions.py @@ -449,9 +449,6 @@ def has_permission(self, request, view): if "club_code" not in view.kwargs: return False - if request.user.has_perm("clubs.manage_club"): - return True - 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 From d8ee6604efb1df1b2b7748ed5d7ec173ca7c1174 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Wed, 30 Oct 2024 14:35:50 -0400 Subject: [PATCH 11/32] Change url basename for user ownership requests --- backend/clubs/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/clubs/urls.py b/backend/clubs/urls.py index 985cc636d..ebfc46510 100644 --- a/backend/clubs/urls.py +++ b/backend/clubs/urls.py @@ -74,7 +74,7 @@ router.register(r"memberships", MembershipViewSet, basename="members") router.register(r"requests/membership", MembershipRequestViewSet, basename="requests") router.register( - r"requests/ownership", OwnershipRequestViewSet, basename="ownershiprequests" + r"requests/ownership", OwnershipRequestViewSet, basename="ownership-requests" ) router.register(r"tickets", TicketViewSet, basename="tickets") From f90512226b488019389fb5c9329d43effa5fbda3 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Wed, 30 Oct 2024 16:24:08 -0400 Subject: [PATCH 12/32] Use update_or_create logic for Ownership Requests --- backend/clubs/serializers.py | 10 ---------- backend/clubs/views.py | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index 988d75b61..d28df2e10 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -2051,16 +2051,6 @@ class UserOwnershipRequestSerializer(serializers.ModelSerializer): 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 ownership request is created. - """ - obj = super().create(validated_data) - - obj.send_request(self.context["request"]) - - return obj - class Meta: model = OwnershipRequest fields = ("club", "club_name", "requester") diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 6dc9133e5..fca742bcd 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -3831,6 +3831,7 @@ def create(self, request, *args, **kwargs): If a ownership request object already exists, reuse it. """ club = request.data.get("club", None) + """ obj = OwnershipRequest.objects.filter( club__code=club, requester=request.user ).first() @@ -3841,6 +3842,24 @@ def create(self, request, *args, **kwargs): return Response(UserOwnershipRequestSerializer(obj).data) return super().create(request, *args, **kwargs) + """ + club_instance = Club.objects.get(code=club) + + 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): """ From fa2b7e7ff77b45dcbbe145349c711aa94122c29d Mon Sep 17 00:00:00 2001 From: Gabriel Date: Wed, 30 Oct 2024 16:25:59 -0400 Subject: [PATCH 13/32] Write test for creating and viewing Ownership Requests --- backend/tests/clubs/test_views.py | 180 ++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index f005efd00..f331660ca 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -31,6 +31,7 @@ Favorite, Membership, MembershipInvite, + OwnershipRequest, QuestionAnswer, School, Tag, @@ -2923,3 +2924,182 @@ def test_club_approval_response_templates(self): content_type="application/json", ) self.assertEqual(resp.status_code, 403) + + def test_ownershiprequests_create_and_view(self): # create approve and deny later + """ + Test the ownership requests creation and viewing permissions + """ + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + Membership.objects.create( + person=self.user2, club=self.club1, role=Membership.ROLE_OFFICER + ) + Membership.objects.create( + person=self.user3, club=self.club1, role=Membership.ROLE_MEMBER + ) + + # Officer 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) + + # Officer 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) + + # Officer 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) + + # Officer 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) + + # Member can create ownership request and email is sent + self.client.login(username=self.user3.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.user3 + ).count(), + 1, + ) + self.assertEqual(len(mail.outbox), 2, mail.outbox) + + # Member 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) + + # Member 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) + + # Member 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()), 2, resp.content) + + # Non-member can create ownership request and email is sent + self.client.login(username=self.user4.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.user4 + ).count(), + 1, + ) + self.assertEqual(len(mail.outbox), 3, mail.outbox) + + # Non-member 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) + + # Non-member 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) + + # Non-member 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()), 3, resp.content) + + # Superuser should only see old requests throgh old-requests + 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) + + OwnershipRequest.objects.filter(club=self.club1, requester=self.user2).update( + created_at=timezone.now() - timezone.timedelta(days=8) + ) + OwnershipRequest.objects.filter(club=self.club1, requester=self.user3).update( + created_at=timezone.now() - timezone.timedelta(days=7) + ) + OwnershipRequest.objects.filter(club=self.club1, requester=self.user4).update( + created_at=timezone.now() - timezone.timedelta(days=6) + ) + + 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()), 2, resp.content) From 5462032aebe205948bae138ec8563c5c99d9c327 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 1 Nov 2024 14:51:11 -0400 Subject: [PATCH 14/32] Return 204 for Requester view for destroy ownershiprequest --- backend/clubs/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/clubs/views.py b/backend/clubs/views.py index fca742bcd..86d21926a 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -3872,7 +3872,7 @@ def destroy(self, request, *args, **kwargs): 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 OwnershipRequest.objects.filter( From a16d758aad62f7f0731abb29875567f1702372b1 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 1 Nov 2024 15:18:41 -0400 Subject: [PATCH 15/32] Edit OwnershipRequestAdmin for admin dashboard --- backend/clubs/admin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/clubs/admin.py b/backend/clubs/admin.py index 9a9c777b5..72a9fcbfd 100644 --- a/backend/clubs/admin.py +++ b/backend/clubs/admin.py @@ -285,15 +285,15 @@ def is_member(self, obj): class OwnershipRequestAdmin(admin.ModelAdmin): search_fields = ( - "person__username", - "person__email", + "requester__username", + "requester__email", "club__name", "created_at", ) list_display = ("requester", "club", "email", "withdrawn", "created_at") list_filter = ("withdrawn",) - def person(self, obj): + def requester(self, obj): return obj.requester.username def club(self, obj): From ffed858f6ae8e6d824022a2f1acedcdc47229ad5 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 1 Nov 2024 15:57:19 -0400 Subject: [PATCH 16/32] Create test for withdraw feature of OwnershipRequests --- backend/tests/clubs/test_views.py | 228 +++++++++++++++++++++++++++++- 1 file changed, 227 insertions(+), 1 deletion(-) diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index f331660ca..5f11dd91a 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -2925,7 +2925,7 @@ def test_club_approval_response_templates(self): ) self.assertEqual(resp.status_code, 403) - def test_ownershiprequests_create_and_view(self): # create approve and deny later + def test_ownershiprequests_create_and_view(self): """ Test the ownership requests creation and viewing permissions """ @@ -3103,3 +3103,229 @@ def test_ownershiprequests_create_and_view(self): # create approve and deny lat ) self.assertEqual(resp.status_code, 200, resp.content) self.assertEqual(len(resp.json()), 2, resp.content) + + def test_ownershiprequests_withdraw(self): + """ + Test the ownership requests withdraw feature + """ + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + Membership.objects.create( + person=self.user2, club=self.club1, role=Membership.ROLE_OFFICER + ) + Membership.objects.create( + person=self.user3, club=self.club1, role=Membership.ROLE_MEMBER + ) + + # Create ownership requests for all three + 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.client.login(username=self.user3.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.user3 + ).count(), + 1, + ) + + self.client.login(username=self.user4.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.user4 + ).count(), + 1, + ) + + self.assertEqual(len(mail.outbox), 3, mail.outbox) + + # 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()), 3, resp.content) + + # Superuser should only see old requests throgh old-requests + 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) + + OwnershipRequest.objects.filter(club=self.club1, requester=self.user2).update( + created_at=timezone.now() - timezone.timedelta(days=8) + ) + OwnershipRequest.objects.filter(club=self.club1, requester=self.user3).update( + created_at=timezone.now() - timezone.timedelta(days=7) + ) + OwnershipRequest.objects.filter(club=self.club1, requester=self.user4).update( + created_at=timezone.now() - timezone.timedelta(days=6) + ) + + 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()), 2, resp.content) + + # Officer can withdraw + self.client.login(username=self.user2.username, password="test") + resp = self.client.delete( + reverse("ownership-requests-detail", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 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) + + # Member can withdraw + self.client.login(username=self.user3.username, password="test") + resp = self.client.delete( + reverse("ownership-requests-detail", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 204, resp.content) + self.assertEqual( + OwnershipRequest.objects.filter( + club=self.club1, requester=self.user3, withdrawn=True + ).count(), + 1, + ) + + resp = self.client.get( + reverse("ownership-requests-detail", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 404, resp.content) + + # Non-member can withdraw + self.client.login(username=self.user4.username, password="test") + resp = self.client.delete( + reverse("ownership-requests-detail", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 204, resp.content) + self.assertEqual( + OwnershipRequest.objects.filter( + club=self.club1, requester=self.user4, 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 requests for all three + 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.client.login(username=self.user3.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.user3 + ).count(), + 1, + ) + + self.client.login(username=self.user4.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.user4 + ).count(), + 1, + ) + + # Emails are not resent + self.assertEqual(len(mail.outbox), 3, 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()), 3, resp.content) + + # Recreated ownership requests are not old + + 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) From 60f0962e6f659deabcc896c941f5da161386e356 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 1 Nov 2024 16:40:22 -0400 Subject: [PATCH 17/32] Create test for accept feature of OwnershipRequests --- backend/tests/clubs/test_views.py | 181 +++++++++++++++++++++++++++++- 1 file changed, 180 insertions(+), 1 deletion(-) diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index 5f11dd91a..eb3d2d053 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -3119,7 +3119,7 @@ def test_ownershiprequests_withdraw(self): person=self.user3, club=self.club1, role=Membership.ROLE_MEMBER ) - # Create ownership requests for all three + # Create ownership requests for officer, member, non-member self.client.login(username=self.user2.username, password="test") resp = self.client.post( reverse("ownership-requests-list"), @@ -3329,3 +3329,182 @@ def test_ownershiprequests_withdraw(self): ) self.assertEqual(resp.status_code, 200, resp.content) self.assertEqual(len(resp.json()), 0, resp.content) + + def test_ownershiprequests_accept(self): + """ + Test the ownership requests accept feature + """ + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + Membership.objects.create( + person=self.user2, club=self.club1, role=Membership.ROLE_OFFICER + ) + Membership.objects.create( + person=self.user3, club=self.club1, role=Membership.ROLE_MEMBER + ) + + # Create ownership requests for officer, member, non-member + 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.client.login(username=self.user3.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.user3 + ).count(), + 1, + ) + + self.client.login(username=self.user4.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.user4 + ).count(), + 1, + ) + + # Officer cannot accept requests + self.client.login(username=self.user2.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, 403, resp.content) + + # Member cannot accept requests + self.client.login(username=self.user3.username, password="test") + resp = self.client.post( + reverse( + "club-ownership-requests-accept", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user3.username, + }, + ) + ) + self.assertEqual(resp.status_code, 403, resp.content) + + # Non-member cannot accept requests + self.client.login(username=self.user4.username, password="test") + resp = self.client.post( + reverse( + "club-ownership-requests-accept", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user4.username, + }, + ) + ) + self.assertEqual(resp.status_code, 403, resp.content) + + # Owner can accept requests + + # Accept for officer + 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, + ) + + # Accept for member + resp = self.client.post( + reverse( + "club-ownership-requests-accept", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user3.username, + }, + ) + ) + self.assertEqual(resp.status_code, 200, resp.content) + + self.assertEqual( + OwnershipRequest.objects.filter( + club=self.club1, requester=self.user3 + ).count(), + 0, + ) + + self.assertEqual( + Membership.objects.filter( + club=self.club1, person=self.user3, role=Membership.ROLE_OWNER + ).count(), + 1, + ) + + # Accept for non-member + resp = self.client.post( + reverse( + "club-ownership-requests-accept", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user4.username, + }, + ) + ) + self.assertEqual(resp.status_code, 200, resp.content) + + self.assertEqual( + OwnershipRequest.objects.filter( + club=self.club1, requester=self.user4 + ).count(), + 0, + ) + + self.assertEqual( + Membership.objects.filter( + club=self.club1, person=self.user4, role=Membership.ROLE_OWNER + ).count(), + 1, + ) From d5e4b6c091cac96d604bffae0ddc7844a80e86da Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 1 Nov 2024 16:45:57 -0400 Subject: [PATCH 18/32] Create test for destroy feature of OwnershipRequests --- backend/tests/clubs/test_views.py | 179 ++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index eb3d2d053..e69a65011 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -3508,3 +3508,182 @@ def test_ownershiprequests_accept(self): ).count(), 1, ) + + def test_ownershiprequests_destroy(self): + """ + Test the ownership requests destroy (denial of request) feature + """ + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + Membership.objects.create( + person=self.user2, club=self.club1, role=Membership.ROLE_OFFICER + ) + Membership.objects.create( + person=self.user3, club=self.club1, role=Membership.ROLE_MEMBER + ) + + # Create ownership requests for officer, member, non-member + 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.client.login(username=self.user3.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.user3 + ).count(), + 1, + ) + + self.client.login(username=self.user4.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.user4 + ).count(), + 1, + ) + + # Officer cannot destroy requests + self.client.login(username=self.user2.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, 403, resp.content) + + # Member cannot accept requests + self.client.login(username=self.user3.username, password="test") + resp = self.client.delete( + reverse( + "club-ownership-requests-detail", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user3.username, + }, + ) + ) + self.assertEqual(resp.status_code, 403, resp.content) + + # Non-member cannot accept requests + self.client.login(username=self.user4.username, password="test") + resp = self.client.delete( + reverse( + "club-ownership-requests-detail", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user4.username, + }, + ) + ) + self.assertEqual(resp.status_code, 403, resp.content) + + # Owner can accept requests + + # Accept for officer + 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) + + 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, + ) + + # Accept for member + resp = self.client.delete( + reverse( + "club-ownership-requests-detail", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user3.username, + }, + ) + ) + self.assertEqual(resp.status_code, 204, resp.content) + + self.assertEqual( + OwnershipRequest.objects.filter( + club=self.club1, requester=self.user3 + ).count(), + 0, + ) + + self.assertEqual( + Membership.objects.filter( + club=self.club1, person=self.user3, role=Membership.ROLE_OWNER + ).count(), + 0, + ) + + # Accept for non-member + resp = self.client.delete( + reverse( + "club-ownership-requests-detail", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user4.username, + }, + ) + ) + self.assertEqual(resp.status_code, 204, resp.content) + + self.assertEqual( + OwnershipRequest.objects.filter( + club=self.club1, requester=self.user4 + ).count(), + 0, + ) + + self.assertEqual( + Membership.objects.filter( + club=self.club1, person=self.user4, role=Membership.ROLE_OWNER + ).count(), + 0, + ) From 4e31e86b1daed19179ceab50965f07723d433a7d Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 8 Nov 2024 14:20:03 -0500 Subject: [PATCH 19/32] Change comments and assert status codes for test_ownership_requests --- backend/clubs/views.py | 11 ----------- backend/tests/clubs/test_views.py | 24 ++++++++++++------------ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 86d21926a..a70fcb041 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -3831,18 +3831,7 @@ def create(self, request, *args, **kwargs): If a ownership request object already exists, reuse it. """ club = request.data.get("club", None) - """ - obj = OwnershipRequest.objects.filter( - club__code=club, requester=request.user - ).first() - if obj is not None: - obj.withdrawn = False - obj.created_at = timezone.now() - obj.save(update_fields=["withdrawn", "created_at"]) - return Response(UserOwnershipRequestSerializer(obj).data) - return super().create(request, *args, **kwargs) - """ club_instance = Club.objects.get(code=club) create_defaults = {"club": club_instance, "requester": request.user} diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index e69a65011..28e00fd16 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -3201,7 +3201,7 @@ def test_ownershiprequests_withdraw(self): resp = self.client.delete( reverse("ownership-requests-detail", args=(self.club1.code,)) ) - self.assertEqual(resp.status_code, 204, resp.content) + self.assertIn(resp.status_code, [200, 204], resp.content) self.assertEqual( OwnershipRequest.objects.filter( club=self.club1, requester=self.user2, withdrawn=True @@ -3219,7 +3219,7 @@ def test_ownershiprequests_withdraw(self): resp = self.client.delete( reverse("ownership-requests-detail", args=(self.club1.code,)) ) - self.assertEqual(resp.status_code, 204, resp.content) + self.assertIn(resp.status_code, [200, 204], resp.content) self.assertEqual( OwnershipRequest.objects.filter( club=self.club1, requester=self.user3, withdrawn=True @@ -3237,7 +3237,7 @@ def test_ownershiprequests_withdraw(self): resp = self.client.delete( reverse("ownership-requests-detail", args=(self.club1.code,)) ) - self.assertEqual(resp.status_code, 204, resp.content) + self.assertIn(resp.status_code, [200, 204], resp.content) self.assertEqual( OwnershipRequest.objects.filter( club=self.club1, requester=self.user4, withdrawn=True @@ -3580,7 +3580,7 @@ def test_ownershiprequests_destroy(self): ) self.assertEqual(resp.status_code, 403, resp.content) - # Member cannot accept requests + # Member cannot destroy requests self.client.login(username=self.user3.username, password="test") resp = self.client.delete( reverse( @@ -3593,7 +3593,7 @@ def test_ownershiprequests_destroy(self): ) self.assertEqual(resp.status_code, 403, resp.content) - # Non-member cannot accept requests + # Non-member cannot destroy requests self.client.login(username=self.user4.username, password="test") resp = self.client.delete( reverse( @@ -3606,9 +3606,9 @@ def test_ownershiprequests_destroy(self): ) self.assertEqual(resp.status_code, 403, resp.content) - # Owner can accept requests + # Owner can destroy requests - # Accept for officer + # Destroy for officer self.client.login(username=self.user1.username, password="test") resp = self.client.delete( @@ -3620,7 +3620,7 @@ def test_ownershiprequests_destroy(self): }, ) ) - self.assertEqual(resp.status_code, 204, resp.content) + self.assertIn(resp.status_code, [200, 204], resp.content) self.assertEqual( OwnershipRequest.objects.filter( @@ -3636,7 +3636,7 @@ def test_ownershiprequests_destroy(self): 0, ) - # Accept for member + # Destroy for member resp = self.client.delete( reverse( "club-ownership-requests-detail", @@ -3646,7 +3646,7 @@ def test_ownershiprequests_destroy(self): }, ) ) - self.assertEqual(resp.status_code, 204, resp.content) + self.assertIn(resp.status_code, [200, 204], resp.content) self.assertEqual( OwnershipRequest.objects.filter( @@ -3662,7 +3662,7 @@ def test_ownershiprequests_destroy(self): 0, ) - # Accept for non-member + # Destroy for non-member resp = self.client.delete( reverse( "club-ownership-requests-detail", @@ -3672,7 +3672,7 @@ def test_ownershiprequests_destroy(self): }, ) ) - self.assertEqual(resp.status_code, 204, resp.content) + self.assertIn(resp.status_code, [200, 204], resp.content) self.assertEqual( OwnershipRequest.objects.filter( From ac1f4de1c120e5bfe3da936e48f2497ca7434db5 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 8 Nov 2024 16:44:59 -0500 Subject: [PATCH 20/32] Change basename for membership requests and add test for creating and viewing membership requests --- backend/clubs/urls.py | 4 +- backend/tests/clubs/test_views.py | 64 +++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/backend/clubs/urls.py b/backend/clubs/urls.py index ebfc46510..cc2187c4a 100644 --- a/backend/clubs/urls.py +++ b/backend/clubs/urls.py @@ -72,7 +72,9 @@ 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/membership", MembershipRequestViewSet, basename="requests") +router.register( + r"requests/membership", MembershipRequestViewSet, basename="membership-requests" +) router.register( r"requests/ownership", OwnershipRequestViewSet, basename="ownership-requests" ) diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index 28e00fd16..89f27f1ad 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -31,6 +31,7 @@ Favorite, Membership, MembershipInvite, + MembershipRequest, OwnershipRequest, QuestionAnswer, School, @@ -3687,3 +3688,66 @@ def test_ownershiprequests_destroy(self): ).count(), 0, ) + + def test_membershiprequests_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) From cf898296a12bf962b4c692743f3db0072f04e64d Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 8 Nov 2024 16:59:13 -0500 Subject: [PATCH 21/32] Add test for withdrawing membership requests --- backend/tests/clubs/test_views.py | 76 +++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index 89f27f1ad..3439c033f 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -3751,3 +3751,79 @@ def test_membershiprequests_create_and_view(self): ) self.assertEqual(resp.status_code, 200, resp.content) self.assertEqual(resp.json()["club"], self.club1.code, resp.content) + + def test_membershiprequests_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", + ) + 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 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) From 038a1bb56bbe7afad809010f5d1237d3df62e7a1 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 8 Nov 2024 17:04:19 -0500 Subject: [PATCH 22/32] Add test for accepting membership requests --- backend/tests/clubs/test_views.py | 65 +++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index 3439c033f..1e03d369d 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -3827,3 +3827,68 @@ def test_membershiprequests_withdraw(self): ) self.assertEqual(resp.status_code, 200, resp.content) self.assertEqual(len(resp.json()), 1, resp.content) + + def test_membershiprequests_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", + ) + 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 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, + ) From 826f54c1a96a8f1cff15bac2e7b8e542a2fb7c09 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 8 Nov 2024 17:10:05 -0500 Subject: [PATCH 23/32] Add test for destroying membership requests --- backend/tests/clubs/test_views.py | 65 +++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index 1e03d369d..62e71f0eb 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -3892,3 +3892,68 @@ def test_membershiprequests_accept(self): ).count(), 1, ) + + def test_membershiprequests_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", + ) + 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 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, + ) From ff1719ee1c4a67b1740a8d5c67ca834840f9a1ec Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 8 Nov 2024 18:06:39 -0500 Subject: [PATCH 24/32] Update Membership Requests model and logic to match Ownership Requests --- backend/clubs/admin.py | 19 +++++---- ...rew_ownershiprequest_withdrawn_and_more.py | 28 +++++++++++++ backend/clubs/models.py | 34 ++++++++------- backend/clubs/serializers.py | 40 ++++++++---------- backend/clubs/views.py | 41 +++++++++++-------- 5 files changed, 101 insertions(+), 61 deletions(-) diff --git a/backend/clubs/admin.py b/backend/clubs/admin.py index 72a9fcbfd..9f060f328 100644 --- a/backend/clubs/admin.py +++ b/backend/clubs/admin.py @@ -264,21 +264,26 @@ 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 diff --git a/backend/clubs/migrations/0119_rename_withdrew_ownershiprequest_withdrawn_and_more.py b/backend/clubs/migrations/0119_rename_withdrew_ownershiprequest_withdrawn_and_more.py index 42cc01f7b..7ff440a22 100644 --- a/backend/clubs/migrations/0119_rename_withdrew_ownershiprequest_withdrawn_and_more.py +++ b/backend/clubs/migrations/0119_rename_withdrew_ownershiprequest_withdrawn_and_more.py @@ -41,4 +41,32 @@ class Migration(migrations.Migration): 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="membership_requests", + to="clubs.club" + ), + ), + migrations.AlterField( + model_name="membershiprequest", + name="requester", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="membership_requests", + to=settings.AUTH_USER_MODEL + ), + ), ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 421d07fd9..45e7084d7 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1082,28 +1082,34 @@ class MembershipRequest(models.Model): Used when users are not in the club but request membership from the owner """ - 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="membership_requests" + ) + club = models.ForeignKey( + Club, on_delete=models.CASCADE, related_name="membership_requests" + ) - 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) 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() @@ -1111,15 +1117,13 @@ def send_request(self, request=None): if emails: send_mail_helper( name="request", - subject="Membership Request from {} for {}".format( - self.person.get_full_name(), self.club.name - ), + subject=f"Membership Request from {full_name} for {club_name}", emails=emails, context=context, ) class Meta: - unique_together = (("person", "club"),) + unique_together = (("requester", "club"),) class OwnershipRequest(models.Model): diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index d28df2e10..4edf49355 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -637,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 @@ -1268,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.membership_requests.filter(requester=user, withdrawn=False).exists() def get_target_years(self, obj): qset = TargetYear.objects.filter(club=obj).select_related("target_years") @@ -1941,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 @@ -1965,13 +1969,13 @@ class Meta: "graduation_year", "major", "name", - "person", + "requester", "school", "username", ) validators = [ validators.UniqueTogetherValidator( - queryset=MembershipRequest.objects.all(), fields=["club", "person"] + queryset=MembershipRequest.objects.all(), fields=["club", "requester"] ) ] @@ -1981,23 +1985,13 @@ 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) - - obj.send_request(self.context["request"]) - - return obj - class Meta: model = MembershipRequest - fields = ("club", "club_name", "person") + fields = ("club", "club_name", "requester") class OwnershipRequestSerializer(serializers.ModelSerializer): diff --git a/backend/clubs/views.py b/backend/clubs/views.py index a70fcb041..88bfc29fe 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -3735,15 +3735,24 @@ 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) - return super().create(request, *args, **kwargs) + club_instance = Club.objects.get(code=club) + + 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): """ @@ -3753,15 +3762,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, ) @@ -3778,11 +3787,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"]) @@ -3806,7 +3815,7 @@ 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}) From eb6e86fbfc8f48d8653f944c0648f06b97760a6b Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 15 Nov 2024 17:46:53 -0500 Subject: [PATCH 25/32] Make abstract base class Request for MembershipRequest and OwnershipRequest models and combine migration files --- .../clubs/migrations/0118_ownershiprequest.py | 56 +++++++++++++++ ...rew_ownershiprequest_withdrawn_and_more.py | 72 ------------------- backend/clubs/models.py | 42 +++++------ backend/clubs/serializers.py | 2 +- 4 files changed, 73 insertions(+), 99 deletions(-) delete mode 100644 backend/clubs/migrations/0119_rename_withdrew_ownershiprequest_withdrawn_and_more.py diff --git a/backend/clubs/migrations/0118_ownershiprequest.py b/backend/clubs/migrations/0118_ownershiprequest.py index eab5e325d..77703c778 100644 --- a/backend/clubs/migrations/0118_ownershiprequest.py +++ b/backend/clubs/migrations/0118_ownershiprequest.py @@ -35,4 +35,60 @@ class Migration(migrations.Migration): "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/migrations/0119_rename_withdrew_ownershiprequest_withdrawn_and_more.py b/backend/clubs/migrations/0119_rename_withdrew_ownershiprequest_withdrawn_and_more.py deleted file mode 100644 index 7ff440a22..000000000 --- a/backend/clubs/migrations/0119_rename_withdrew_ownershiprequest_withdrawn_and_more.py +++ /dev/null @@ -1,72 +0,0 @@ -# Generated by Django 5.0.4 on 2024-10-18 11:42 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("clubs", "0118_ownershiprequest"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - 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="ownership_requests", - to="clubs.club" - ), - ), - migrations.AlterField( - model_name="ownershiprequest", - name="requester", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="ownership_requests", - 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="membership_requests", - to="clubs.club" - ), - ), - migrations.AlterField( - model_name="membershiprequest", - name="requester", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="membership_requests", - to=settings.AUTH_USER_MODEL - ), - ), - ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 45e7084d7..673fd5eb7 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1077,23 +1077,31 @@ 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 """ requester = models.ForeignKey( - get_user_model(), on_delete=models.CASCADE, related_name="membership_requests" - ) - club = models.ForeignKey( - Club, on_delete=models.CASCADE, related_name="membership_requests" + get_user_model(), on_delete=models.CASCADE, related_name="%(class)ss" ) + 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) + 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 f"" @@ -1116,33 +1124,18 @@ def send_request(self, request=None): if emails: send_mail_helper( - name="request", + name="membership_request", subject=f"Membership Request from {full_name} for {club_name}", emails=emails, context=context, ) - class Meta: - unique_together = (("requester", "club"),) - -class OwnershipRequest(models.Model): +class OwnershipRequest(Request): """ Represents a user's request to take ownership of a club """ - requester = models.ForeignKey( - get_user_model(), on_delete=models.CASCADE, related_name="ownership_requests" - ) - club = models.ForeignKey( - Club, on_delete=models.CASCADE, related_name="ownership_requests" - ) - - withdrawn = models.BooleanField(default=False) - - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - def __str__(self): return f"" @@ -1174,9 +1167,6 @@ def send_request(self, request=None): context=context, ) - class Meta: - unique_together = (("requester", "club"),) - class Advisor(models.Model): """ diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index 4edf49355..62ad3cf66 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -1270,7 +1270,7 @@ def get_is_request(self, obj): user = self.context["request"].user if not user.is_authenticated: return False - return obj.membership_requests.filter(requester=user, withdrawn=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") From c5d7941c83c7cbbd14a46a314748092cbfd8b93a Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 15 Nov 2024 17:48:15 -0500 Subject: [PATCH 26/32] Modify and rename request.html email template to membership_request.html --- .../emails/{request.html => membership_request.html} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename backend/templates/emails/{request.html => membership_request.html} (68%) diff --git a/backend/templates/emails/request.html b/backend/templates/emails/membership_request.html similarity index 68% rename from backend/templates/emails/request.html rename to backend/templates/emails/membership_request.html index 1da3c8e8e..0437e9366 100644 --- a/backend/templates/emails/request.html +++ b/backend/templates/emails/membership_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 membership from {{ full_name }} for {{ club_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 From f77b2811d7fb8a7eab1090cdf928df7ae63f3b59 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 15 Nov 2024 17:49:37 -0500 Subject: [PATCH 27/32] Shorten and rename tests for membership and ownership requests --- backend/tests/clubs/test_views.py | 549 ++---------------------------- 1 file changed, 23 insertions(+), 526 deletions(-) diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index 62e71f0eb..cf9d5f8c8 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -2926,7 +2926,7 @@ def test_club_approval_response_templates(self): ) self.assertEqual(resp.status_code, 403) - def test_ownershiprequests_create_and_view(self): + def test_ownership_requests_create_and_view(self): """ Test the ownership requests creation and viewing permissions """ @@ -2934,14 +2934,8 @@ def test_ownershiprequests_create_and_view(self): Membership.objects.create( person=self.user1, club=self.club1, role=Membership.ROLE_OWNER ) - Membership.objects.create( - person=self.user2, club=self.club1, role=Membership.ROLE_OFFICER - ) - Membership.objects.create( - person=self.user3, club=self.club1, role=Membership.ROLE_MEMBER - ) - # Officer can create ownership request and email is sent + # 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"), @@ -2957,19 +2951,19 @@ def test_ownershiprequests_create_and_view(self): ) self.assertEqual(len(mail.outbox), 1, mail.outbox) - # Officer can check own ownership requests + # 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) - # Officer can check own ownership request to a club + # 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) - # Officer cannot check club's ownership requests + # Requester cannot check club's ownership requests resp = self.client.get( reverse("club-ownership-requests-list", args=(self.club1.code,)) ) @@ -2996,91 +2990,7 @@ def test_ownershiprequests_create_and_view(self): self.assertEqual(resp.status_code, 200, resp.content) self.assertEqual(resp.json()["club"], self.club1.code, resp.content) - # Member can create ownership request and email is sent - self.client.login(username=self.user3.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.user3 - ).count(), - 1, - ) - self.assertEqual(len(mail.outbox), 2, mail.outbox) - - # Member 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) - - # Member 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) - - # Member 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()), 2, resp.content) - - # Non-member can create ownership request and email is sent - self.client.login(username=self.user4.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.user4 - ).count(), - 1, - ) - self.assertEqual(len(mail.outbox), 3, mail.outbox) - - # Non-member 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) - - # Non-member 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) - - # Non-member 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()), 3, resp.content) - - # Superuser should only see old requests throgh old-requests + # Superuser should only see old requests through old-requests self.client.login(username=self.user5.username, password="test") resp = self.client.get( reverse("club-ownership-requests-old-requests", args=("anystring",)) @@ -3091,21 +3001,15 @@ def test_ownershiprequests_create_and_view(self): OwnershipRequest.objects.filter(club=self.club1, requester=self.user2).update( created_at=timezone.now() - timezone.timedelta(days=8) ) - OwnershipRequest.objects.filter(club=self.club1, requester=self.user3).update( - created_at=timezone.now() - timezone.timedelta(days=7) - ) - OwnershipRequest.objects.filter(club=self.club1, requester=self.user4).update( - created_at=timezone.now() - timezone.timedelta(days=6) - ) 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()), 2, resp.content) + self.assertEqual(len(resp.json()), 1, resp.content) - def test_ownershiprequests_withdraw(self): + def test_ownership_requests_withdraw(self): """ Test the ownership requests withdraw feature """ @@ -3113,91 +3017,20 @@ def test_ownershiprequests_withdraw(self): Membership.objects.create( person=self.user1, club=self.club1, role=Membership.ROLE_OWNER ) - Membership.objects.create( - person=self.user2, club=self.club1, role=Membership.ROLE_OFFICER - ) - Membership.objects.create( - person=self.user3, club=self.club1, role=Membership.ROLE_MEMBER - ) - # Create ownership requests for officer, member, non-member 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.client.login(username=self.user3.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.user3 - ).count(), - 1, - ) - - self.client.login(username=self.user4.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.user4 - ).count(), - 1, - ) - - self.assertEqual(len(mail.outbox), 3, mail.outbox) - - # 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()), 3, resp.content) - - # Superuser should only see old requests throgh old-requests - 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) + 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) ) - OwnershipRequest.objects.filter(club=self.club1, requester=self.user3).update( - created_at=timezone.now() - timezone.timedelta(days=7) - ) - OwnershipRequest.objects.filter(club=self.club1, requester=self.user4).update( - created_at=timezone.now() - timezone.timedelta(days=6) - ) - - 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()), 2, resp.content) - # Officer can withdraw + # Requester can withdraw self.client.login(username=self.user2.username, password="test") resp = self.client.delete( reverse("ownership-requests-detail", args=(self.club1.code,)) @@ -3215,42 +3048,6 @@ def test_ownershiprequests_withdraw(self): ) self.assertEqual(resp.status_code, 404, resp.content) - # Member can withdraw - self.client.login(username=self.user3.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.user3, withdrawn=True - ).count(), - 1, - ) - - resp = self.client.get( - reverse("ownership-requests-detail", args=(self.club1.code,)) - ) - self.assertEqual(resp.status_code, 404, resp.content) - - # Non-member can withdraw - self.client.login(username=self.user4.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.user4, 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") @@ -3267,7 +3064,7 @@ def test_ownershiprequests_withdraw(self): self.assertEqual(resp.status_code, 200, resp.content) self.assertEqual(len(resp.json()), 0, resp.content) - # Recreate ownership requests for all three + # Recreate ownership request self.client.login(username=self.user2.username, password="test") resp = self.client.post( reverse("ownership-requests-list"), @@ -3275,43 +3072,9 @@ def test_ownershiprequests_withdraw(self): 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.client.login(username=self.user3.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.user3 - ).count(), - 1, - ) - - self.client.login(username=self.user4.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.user4 - ).count(), - 1, - ) # Emails are not resent - self.assertEqual(len(mail.outbox), 3, mail.outbox) + self.assertEqual(len(mail.outbox), 1, mail.outbox) # Owner can see recreated ownership requests @@ -3320,7 +3083,7 @@ def test_ownershiprequests_withdraw(self): reverse("club-ownership-requests-list", args=(self.club1.code,)) ) self.assertEqual(resp.status_code, 200, resp.content) - self.assertEqual(len(resp.json()), 3, resp.content) + self.assertEqual(len(resp.json()), 1, resp.content) # Recreated ownership requests are not old @@ -3331,7 +3094,7 @@ def test_ownershiprequests_withdraw(self): self.assertEqual(resp.status_code, 200, resp.content) self.assertEqual(len(resp.json()), 0, resp.content) - def test_ownershiprequests_accept(self): + def test_ownership_requests_accept(self): """ Test the ownership requests accept feature """ @@ -3339,14 +3102,8 @@ def test_ownershiprequests_accept(self): Membership.objects.create( person=self.user1, club=self.club1, role=Membership.ROLE_OWNER ) - Membership.objects.create( - person=self.user2, club=self.club1, role=Membership.ROLE_OFFICER - ) - Membership.objects.create( - person=self.user3, club=self.club1, role=Membership.ROLE_MEMBER - ) - # Create ownership requests for officer, member, non-member + # Create ownership request self.client.login(username=self.user2.username, password="test") resp = self.client.post( reverse("ownership-requests-list"), @@ -3354,43 +3111,8 @@ def test_ownershiprequests_accept(self): 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.client.login(username=self.user3.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.user3 - ).count(), - 1, - ) - - self.client.login(username=self.user4.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.user4 - ).count(), - 1, - ) - - # Officer cannot accept requests - self.client.login(username=self.user2.username, password="test") + # Requester cannot accept requests resp = self.client.post( reverse( "club-ownership-requests-accept", @@ -3402,35 +3124,7 @@ def test_ownershiprequests_accept(self): ) self.assertEqual(resp.status_code, 403, resp.content) - # Member cannot accept requests - self.client.login(username=self.user3.username, password="test") - resp = self.client.post( - reverse( - "club-ownership-requests-accept", - kwargs={ - "club_code": self.club1.code, - "requester__username": self.user3.username, - }, - ) - ) - self.assertEqual(resp.status_code, 403, resp.content) - - # Non-member cannot accept requests - self.client.login(username=self.user4.username, password="test") - resp = self.client.post( - reverse( - "club-ownership-requests-accept", - kwargs={ - "club_code": self.club1.code, - "requester__username": self.user4.username, - }, - ) - ) - self.assertEqual(resp.status_code, 403, resp.content) - # Owner can accept requests - - # Accept for officer self.client.login(username=self.user1.username, password="test") resp = self.client.post( @@ -3458,59 +3152,7 @@ def test_ownershiprequests_accept(self): 1, ) - # Accept for member - resp = self.client.post( - reverse( - "club-ownership-requests-accept", - kwargs={ - "club_code": self.club1.code, - "requester__username": self.user3.username, - }, - ) - ) - self.assertEqual(resp.status_code, 200, resp.content) - - self.assertEqual( - OwnershipRequest.objects.filter( - club=self.club1, requester=self.user3 - ).count(), - 0, - ) - - self.assertEqual( - Membership.objects.filter( - club=self.club1, person=self.user3, role=Membership.ROLE_OWNER - ).count(), - 1, - ) - - # Accept for non-member - resp = self.client.post( - reverse( - "club-ownership-requests-accept", - kwargs={ - "club_code": self.club1.code, - "requester__username": self.user4.username, - }, - ) - ) - self.assertEqual(resp.status_code, 200, resp.content) - - self.assertEqual( - OwnershipRequest.objects.filter( - club=self.club1, requester=self.user4 - ).count(), - 0, - ) - - self.assertEqual( - Membership.objects.filter( - club=self.club1, person=self.user4, role=Membership.ROLE_OWNER - ).count(), - 1, - ) - - def test_ownershiprequests_destroy(self): + def test_ownership_requests_destroy(self): """ Test the ownership requests destroy (denial of request) feature """ @@ -3518,14 +3160,8 @@ def test_ownershiprequests_destroy(self): Membership.objects.create( person=self.user1, club=self.club1, role=Membership.ROLE_OWNER ) - Membership.objects.create( - person=self.user2, club=self.club1, role=Membership.ROLE_OFFICER - ) - Membership.objects.create( - person=self.user3, club=self.club1, role=Membership.ROLE_MEMBER - ) - # Create ownership requests for officer, member, non-member + # Create ownership request self.client.login(username=self.user2.username, password="test") resp = self.client.post( reverse("ownership-requests-list"), @@ -3533,43 +3169,8 @@ def test_ownershiprequests_destroy(self): 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.client.login(username=self.user3.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.user3 - ).count(), - 1, - ) - - self.client.login(username=self.user4.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.user4 - ).count(), - 1, - ) - - # Officer cannot destroy requests - self.client.login(username=self.user2.username, password="test") + # Requester cannot destroy requests resp = self.client.delete( reverse( "club-ownership-requests-detail", @@ -3581,35 +3182,7 @@ def test_ownershiprequests_destroy(self): ) self.assertEqual(resp.status_code, 403, resp.content) - # Member cannot destroy requests - self.client.login(username=self.user3.username, password="test") - resp = self.client.delete( - reverse( - "club-ownership-requests-detail", - kwargs={ - "club_code": self.club1.code, - "requester__username": self.user3.username, - }, - ) - ) - self.assertEqual(resp.status_code, 403, resp.content) - - # Non-member cannot destroy requests - self.client.login(username=self.user4.username, password="test") - resp = self.client.delete( - reverse( - "club-ownership-requests-detail", - kwargs={ - "club_code": self.club1.code, - "requester__username": self.user4.username, - }, - ) - ) - self.assertEqual(resp.status_code, 403, resp.content) - # Owner can destroy requests - - # Destroy for officer self.client.login(username=self.user1.username, password="test") resp = self.client.delete( @@ -3637,59 +3210,7 @@ def test_ownershiprequests_destroy(self): 0, ) - # Destroy for member - resp = self.client.delete( - reverse( - "club-ownership-requests-detail", - kwargs={ - "club_code": self.club1.code, - "requester__username": self.user3.username, - }, - ) - ) - self.assertIn(resp.status_code, [200, 204], resp.content) - - self.assertEqual( - OwnershipRequest.objects.filter( - club=self.club1, requester=self.user3 - ).count(), - 0, - ) - - self.assertEqual( - Membership.objects.filter( - club=self.club1, person=self.user3, role=Membership.ROLE_OWNER - ).count(), - 0, - ) - - # Destroy for non-member - resp = self.client.delete( - reverse( - "club-ownership-requests-detail", - kwargs={ - "club_code": self.club1.code, - "requester__username": self.user4.username, - }, - ) - ) - self.assertIn(resp.status_code, [200, 204], resp.content) - - self.assertEqual( - OwnershipRequest.objects.filter( - club=self.club1, requester=self.user4 - ).count(), - 0, - ) - - self.assertEqual( - Membership.objects.filter( - club=self.club1, person=self.user4, role=Membership.ROLE_OWNER - ).count(), - 0, - ) - - def test_membershiprequests_create_and_view(self): + def test_membership_requests_create_and_view(self): """ Test the membership requests creation and viewing permissions """ @@ -3752,7 +3273,7 @@ def test_membershiprequests_create_and_view(self): self.assertEqual(resp.status_code, 200, resp.content) self.assertEqual(resp.json()["club"], self.club1.code, resp.content) - def test_membershiprequests_withdraw(self): + def test_membership_requests_withdraw(self): """ Test the membership requests withdraw feature """ @@ -3767,14 +3288,6 @@ def test_membershiprequests_withdraw(self): {"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 withdraw resp = self.client.delete( @@ -3828,7 +3341,7 @@ def test_membershiprequests_withdraw(self): self.assertEqual(resp.status_code, 200, resp.content) self.assertEqual(len(resp.json()), 1, resp.content) - def test_membershiprequests_accept(self): + def test_membership_requests_accept(self): """ Test the membership requests accept feature """ @@ -3843,14 +3356,6 @@ def test_membershiprequests_accept(self): {"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 cannot accept membership requests resp = self.client.post( @@ -3893,7 +3398,7 @@ def test_membershiprequests_accept(self): 1, ) - def test_membershiprequests_destroy(self): + def test_membership_requests_destroy(self): """ Test the membership requests destroy (denial of request) feature """ @@ -3908,14 +3413,6 @@ def test_membershiprequests_destroy(self): {"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 cannot destroy membership requests resp = self.client.delete( From eb4fb0a2930d2e25679de188c375964cdcce72a6 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 15 Nov 2024 17:51:43 -0500 Subject: [PATCH 28/32] minor edits to ownership requests accept and destroy tests --- backend/tests/clubs/test_views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index cf9d5f8c8..696bc1304 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -3110,7 +3110,6 @@ def test_ownership_requests_accept(self): {"club": self.club1.code}, content_type="application/json", ) - self.assertEqual(resp.status_code, 201, resp.content) # Requester cannot accept requests resp = self.client.post( @@ -3168,7 +3167,6 @@ def test_ownership_requests_destroy(self): {"club": self.club1.code}, content_type="application/json", ) - self.assertEqual(resp.status_code, 201, resp.content) # Requester cannot destroy requests resp = self.client.delete( From 7b923a1fecc0c11068413337b85cf74c30695737 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 15 Nov 2024 17:56:39 -0500 Subject: [PATCH 29/32] Fixing nits --- backend/clubs/serializers.py | 2 +- backend/templates/emails/membership_request.html | 2 +- backend/templates/emails/ownership_request.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index 62ad3cf66..4580cb4f2 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -2038,7 +2038,7 @@ class Meta: class UserOwnershipRequestSerializer(serializers.ModelSerializer): """ - Used by the users to return the clubs that the user has sent OwnershipRequest to. + Used by the users to return the clubs that the user has sent an OwnershipRequest to. """ requester = serializers.HiddenField(default=serializers.CurrentUserDefault()) diff --git a/backend/templates/emails/membership_request.html b/backend/templates/emails/membership_request.html index 0437e9366..fd4a9cb80 100644 --- a/backend/templates/emails/membership_request.html +++ b/backend/templates/emails/membership_request.html @@ -9,7 +9,7 @@ {% extends 'emails/base.html' %} {% block content %} -

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

+

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.

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

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.

Date: Fri, 15 Nov 2024 18:01:29 -0500 Subject: [PATCH 30/32] Fix redundant UniqueTogether logic in Membership/OwnerhipRequestSerializer already in model --- backend/clubs/serializers.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index 4580cb4f2..e58903c7d 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -1973,11 +1973,6 @@ class Meta: "school", "username", ) - validators = [ - validators.UniqueTogetherValidator( - queryset=MembershipRequest.objects.all(), fields=["club", "requester"] - ) - ] class UserMembershipRequestSerializer(serializers.ModelSerializer): @@ -2029,11 +2024,6 @@ class Meta: "school", "username", ) - validators = [ - validators.UniqueTogetherValidator( - queryset=OwnershipRequest.objects.all(), fields=["club", "requester"] - ) - ] class UserOwnershipRequestSerializer(serializers.ModelSerializer): From 631506575ee97da8f12de16883c7ddc7fe8cf2fa Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 17 Nov 2024 13:58:39 -0500 Subject: [PATCH 31/32] handle invalid club code in request creation --- backend/clubs/models.py | 6 ------ backend/clubs/views.py | 14 ++++++++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 673fd5eb7..6d02d2170 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1107,11 +1107,8 @@ def __str__(self): 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 = { @@ -1141,11 +1138,8 @@ def __str__(self): 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 = { diff --git a/backend/clubs/views.py b/backend/clubs/views.py index a3f1a8bf8..0a1bd6457 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -3781,8 +3781,11 @@ def create(self, request, *args, **kwargs): If a membership request object already exists, reuse it. """ club = request.data.get("club", None) - - club_instance = Club.objects.get(code=club) + 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} @@ -3886,8 +3889,11 @@ 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.get(code=club) + 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} From bf105d4ba73baad508dcdcdd017cc501694b5b33 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 22 Nov 2024 18:04:20 -0500 Subject: [PATCH 32/32] Change old-requests to all-requests --- backend/clubs/views.py | 16 +++---- backend/tests/clubs/test_views.py | 79 ++++++++++++++++++++----------- 2 files changed, 58 insertions(+), 37 deletions(-) diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 0a1bd6457..6fbc91e06 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -3954,12 +3954,14 @@ class OwnershipRequestManagementViewSet(viewsets.ModelViewSet): lookup_field = "requester__username" def get_queryset(self): - if self.action != "old_requests": + if self.action != "all_requests": return OwnershipRequest.objects.filter( club__code=self.kwargs["club_code"], withdrawn=False ) else: - return OwnershipRequest.objects.filter(withdrawn=False) + return OwnershipRequest.objects.filter(withdrawn=False).order_by( + "created_at" + ) @action(detail=True, methods=["post"]) def accept(self, request, *args, **kwargs): @@ -3995,9 +3997,9 @@ def accept(self, request, *args, **kwargs): return Response({"success": True}) @action(detail=False, methods=["get"], permission_classes=[IsSuperuser]) - def old_requests(self, request, *args, **kwargs): + def all_requests(self, request, *args, **kwargs): """ - View unaddressed ownership requests that are older than a week. + View unaddressed ownership requests, sorted by date. --- requestBody: {} responses: @@ -4045,11 +4047,7 @@ def old_requests(self, request, *args, **kwargs): --- """ - queryset = OwnershipRequest.objects.filter( - withdrawn=False, created_at__lte=timezone.now() - datetime.timedelta(days=7) - ) - - serializer = self.get_serializer(queryset, many=True) + serializer = self.get_serializer(self.get_queryset(), many=True) return Response(serializer.data) diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index 979261f92..bb571cb64 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -3003,25 +3003,6 @@ def test_ownership_requests_create_and_view(self): self.assertEqual(resp.status_code, 200, resp.content) self.assertEqual(resp.json()["club"], self.club1.code, resp.content) - # Superuser should only see old requests through old-requests - 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) - - OwnershipRequest.objects.filter(club=self.club1, requester=self.user2).update( - created_at=timezone.now() - timezone.timedelta(days=8) - ) - - 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()), 1, resp.content) - def test_ownership_requests_withdraw(self): """ Test the ownership requests withdraw feature @@ -3098,15 +3079,6 @@ def test_ownership_requests_withdraw(self): self.assertEqual(resp.status_code, 200, resp.content) self.assertEqual(len(resp.json()), 1, resp.content) - # Recreated ownership requests are not old - - 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) - def test_ownership_requests_accept(self): """ Test the ownership requests accept feature @@ -3221,6 +3193,57 @@ def test_ownership_requests_destroy(self): 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