From 42e953bf6a3da18588f8045c022b315e0427c6a7 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 13 Oct 2024 10:57:44 -0400 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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) )