diff --git a/backend/clubs/admin.py b/backend/clubs/admin.py index 73046a164..9a9c777b5 100644 --- a/backend/clubs/admin.py +++ b/backend/clubs/admin.py @@ -38,6 +38,7 @@ MembershipRequest, Note, NoteTag, + OwnershipRequest, Profile, QuestionAnswer, RecurringEvent, @@ -282,6 +283,26 @@ def is_member(self, obj): is_member.boolean = True +class OwnershipRequestAdmin(admin.ModelAdmin): + search_fields = ( + "person__username", + "person__email", + "club__name", + "created_at", + ) + list_display = ("requester", "club", "email", "withdrawn", "created_at") + list_filter = ("withdrawn",) + + def person(self, obj): + return obj.requester.username + + def club(self, obj): + return obj.club.name + + def email(self, obj): + return obj.requester.email + + class MembershipAdmin(admin.ModelAdmin): search_fields = ( "person__username", @@ -443,6 +464,7 @@ class ClubApprovalResponseTemplateAdmin(admin.ModelAdmin): admin.site.register(Major, MajorAdmin) admin.site.register(Membership, MembershipAdmin) admin.site.register(MembershipInvite, MembershipInviteAdmin) +admin.site.register(OwnershipRequest, OwnershipRequestAdmin) admin.site.register(Profile, ProfileAdmin) admin.site.register(QuestionAnswer, QuestionAnswerAdmin) admin.site.register(RecurringEvent) diff --git a/backend/clubs/migrations/0118_ownershiprequest.py b/backend/clubs/migrations/0118_ownershiprequest.py new file mode 100644 index 000000000..eab5e325d --- /dev/null +++ b/backend/clubs/migrations/0118_ownershiprequest.py @@ -0,0 +1,38 @@ +# Generated by Django 5.0.4 on 2024-10-18 05:04 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0117_clubapprovalresponsetemplate"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="OwnershipRequest", + fields=[ + ("id", models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID")), + ("withdrew", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("club", models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="clubs.club")), + ("person", models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL)), + ], + options={ + "unique_together": {("person", "club")}, + }, + ), + ] 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 6329cb15a..421d07fd9 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1122,6 +1122,58 @@ class Meta: unique_together = (("person", "club"),) +class OwnershipRequest(models.Model): + """ + 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"" + + def send_request(self, request=None): + domain = get_domain(request) + + edit_url = settings.EDIT_URL.format(domain=domain, club=self.club.code) + + club_name = self.club.name + + full_name = self.requester.get_full_name() + + context = { + "club_name": club_name, + "edit_url": f"{edit_url}/member", + "full_name": full_name, + } + + owner_emails = list( + self.club.membership_set.filter( + role=Membership.ROLE_OWNER, active=True + ).values_list("person__email", flat=True) + ) + + send_mail_helper( + name="ownership_request", + subject=f"Ownership Request from {full_name} for {club_name}", + emails=owner_emails, + context=context, + ) + + class Meta: + unique_together = (("requester", "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 eb70bbb2c..988d75b61 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -43,6 +43,7 @@ MembershipRequest, Note, NoteTag, + OwnershipRequest, Profile, QuestionAnswer, Report, @@ -1999,6 +2000,72 @@ class Meta: fields = ("club", "club_name", "person") +class OwnershipRequestSerializer(serializers.ModelSerializer): + """ + Used by club owners to see who has requested to be owner of the club. + """ + + requester = serializers.HiddenField(default=serializers.CurrentUserDefault()) + club = serializers.SlugRelatedField(queryset=Club.objects.all(), slug_field="code") + name = serializers.SerializerMethodField("get_full_name") + username = serializers.CharField(source="requester.username", read_only=True) + email = serializers.EmailField(source="requester.email", read_only=True) + + school = SchoolSerializer( + many=True, source="requester.profile.school", read_only=True + ) + major = MajorSerializer(many=True, source="requester.profile.major", read_only=True) + graduation_year = serializers.IntegerField( + source="requester.profile.graduation_year", read_only=True + ) + + def get_full_name(self, obj): + return obj.requester.get_full_name() + + class Meta: + model = OwnershipRequest + fields = ( + "club", + "created_at", + "email", + "graduation_year", + "major", + "name", + "requester", + "school", + "username", + ) + validators = [ + validators.UniqueTogetherValidator( + queryset=OwnershipRequest.objects.all(), fields=["club", "requester"] + ) + ] + + +class UserOwnershipRequestSerializer(serializers.ModelSerializer): + """ + Used by the users to return the clubs that the user has sent OwnershipRequest to. + """ + + requester = serializers.HiddenField(default=serializers.CurrentUserDefault()) + club = serializers.SlugRelatedField(queryset=Club.objects.all(), slug_field="code") + club_name = serializers.CharField(source="club.name", read_only=True) + + 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") + + 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 4a1006cad..985cc636d 100644 --- a/backend/clubs/urls.py +++ b/backend/clubs/urls.py @@ -35,6 +35,8 @@ MemberViewSet, NoteViewSet, OptionListView, + OwnershipRequestManagementViewSet, + OwnershipRequestViewSet, QuestionAnswerViewSet, ReportViewSet, SchoolViewSet, @@ -70,7 +72,10 @@ 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"requests/ownership", OwnershipRequestViewSet, basename="ownershiprequests" +) router.register(r"tickets", TicketViewSet, basename="tickets") router.register(r"schools", SchoolViewSet, basename="schools") @@ -108,6 +113,11 @@ MembershipRequestOwnerViewSet, basename="club-membership-requests", ) +clubs_router.register( + r"ownershiprequests", + OwnershipRequestManagementViewSet, + basename="club-ownership-requests", +) clubs_router.register(r"advisors", AdvisorViewSet, basename="club-advisors") clubs_router.register( r"applications", ClubApplicationViewSet, basename="club-applications" diff --git a/backend/clubs/views.py b/backend/clubs/views.py index c71dd1c65..6dc9133e5 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -107,6 +107,7 @@ MembershipInvite, MembershipRequest, Note, + OwnershipRequest, QuestionAnswer, RecurringEvent, Report, @@ -138,6 +139,7 @@ MemberPermission, MembershipRequestPermission, NotePermission, + OwnershipRequestPermission, ProfilePermission, QuestionAnswerPermission, ReadOnly, @@ -180,6 +182,7 @@ MembershipSerializer, MinimalUserProfileSerializer, NoteSerializer, + OwnershipRequestSerializer, QuestionAnswerSerializer, ReportClubSerializer, ReportSerializer, @@ -196,6 +199,7 @@ UserMembershipInviteSerializer, UserMembershipRequestSerializer, UserMembershipSerializer, + UserOwnershipRequestSerializer, UserProfileSerializer, UserSerializer, UserSubscribeSerializer, @@ -3808,6 +3812,179 @@ 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, 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) + + def destroy(self, request, *args, **kwargs): + """ + Don't actually delete the ownership request when it is withdrawn. + + This is to keep track of repeat ownership requests and avoid spamming the club + owners with requests. + """ + obj = self.get_object() + obj.withdrawn = True + obj.save(update_fields=["withdrawn"]) + + return Response({"success": True}) + + def get_queryset(self): + return OwnershipRequest.objects.filter( + requester=self.request.user, + withdrawn=False, + club__archived=False, + ) + + +class OwnershipRequestManagementViewSet(viewsets.ModelViewSet): + """ + list: + Return a list of users who have sent ownership request to the club. + + destroy: + Delete a ownership request for a specific user. + + accept: + Accept an ownership request as a club owner. + + old_requests: + Return a list of ownership requests older than a week. Used by Superusers. + """ + + serializer_class = OwnershipRequestSerializer + + permission_classes = [OwnershipRequestPermission | IsSuperuser] + http_method_names = ["get", "post", "delete"] + lookup_field = "requester__username" + + def get_queryset(self): + if self.action != "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, *args, **kwargs): + """ + Accept an ownership request as a club owner. + --- + requestBody: {} + responses: + "200": + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + description: > + True if this request was properly processed. + --- + """ + request_object = self.get_object() + membership, created = Membership.objects.get_or_create( + person=request_object.requester, + club=request_object.club, + defaults={"role": Membership.ROLE_OWNER}, + ) + + if not created and membership.role != Membership.ROLE_OWNER: + membership.role = Membership.ROLE_OWNER + membership.save(update_fields=["role"]) + + request_object.delete() + return Response({"success": True}) + + @action(detail=False, methods=["get"], permission_classes=[IsSuperuser]) + def 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) + ) + + serializer = self.get_serializer(queryset, many=True) + + return Response(serializer.data) + + class MemberViewSet(XLSXFormatterMixin, viewsets.ModelViewSet): """ list: diff --git a/backend/templates/emails/ownership_request.html b/backend/templates/emails/ownership_request.html new file mode 100644 index 000000000..9c07d005c --- /dev/null +++ b/backend/templates/emails/ownership_request.html @@ -0,0 +1,17 @@ + +{% extends 'emails/base.html' %} + +{% block content %} +

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 +{% endblock %} \ No newline at end of file