diff --git a/backend/clubs/admin.py b/backend/clubs/admin.py index 73046a164..1a3b49569 100644 --- a/backend/clubs/admin.py +++ b/backend/clubs/admin.py @@ -38,6 +38,7 @@ MembershipRequest, Note, NoteTag, + OwnershipRequest, Profile, QuestionAnswer, RecurringEvent, @@ -263,25 +264,55 @@ def email(self, obj): class MembershipRequestAdmin(admin.ModelAdmin): - search_fields = ("person__username", "person__email", "club__name", "club__pk") - list_display = ("person", "club", "email", "withdrew", "is_member") - list_filter = ("withdrew",) + search_fields = ( + "requester__username", + "requester__email", + "club__name", + "club__pk", + ) + list_display = ("requester", "club", "email", "withdrawn", "is_member") + list_filter = ("withdrawn",) - def person(self, obj): - return obj.person.username + def requester(self, obj): + return obj.requester.username def club(self, obj): return obj.club.name def email(self, obj): - return obj.person.email + return obj.requester.email def is_member(self, obj): - return obj.club.membership_set.filter(person__pk=obj.person.pk).exists() + return obj.club.membership_set.filter(person__pk=obj.requester.pk).exists() is_member.boolean = True +class OwnershipRequestAdmin(admin.ModelAdmin): + search_fields = ( + "requester__username", + "requester__email", + "club__name", + "created_at", + ) + list_display = ("requester", "club", "email", "withdrawn", "is_owner", "created_at") + list_filter = ("withdrawn",) + + def requester(self, obj): + return obj.requester.username + + def club(self, obj): + return obj.club.name + + def email(self, obj): + return obj.requester.email + + def is_owner(self, obj): + return obj.club.membership_set.filter( + person__pk=obj.requester.pk, role=Membership.ROLE_OWNER + ).exists() + + class MembershipAdmin(admin.ModelAdmin): search_fields = ( "person__username", @@ -443,6 +474,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/management/commands/osa_perms_updates.py b/backend/clubs/management/commands/osa_perms_updates.py index d54d3e802..d6b87fe01 100644 --- a/backend/clubs/management/commands/osa_perms_updates.py +++ b/backend/clubs/management/commands/osa_perms_updates.py @@ -9,7 +9,6 @@ class Command(BaseCommand): help = "Give superuser to hard-coded user accounts affiliated with OSA." - web_execute = True def handle(self, *args, **kwargs): User = get_user_model() diff --git a/backend/clubs/migrations/0118_ownershiprequest.py b/backend/clubs/migrations/0118_ownershiprequest.py new file mode 100644 index 000000000..77703c778 --- /dev/null +++ b/backend/clubs/migrations/0118_ownershiprequest.py @@ -0,0 +1,94 @@ +# Generated by Django 5.0.4 on 2024-10-18 05:04 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0117_clubapprovalresponsetemplate"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="OwnershipRequest", + fields=[ + ("id", models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID")), + ("withdrew", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("club", models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="clubs.club")), + ("person", models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL)), + ], + options={ + "unique_together": {("person", "club")}, + }, + ), + migrations.RenameField( + model_name="ownershiprequest", + old_name="withdrew", + new_name="withdrawn", + ), + migrations.RenameField( + model_name="ownershiprequest", + old_name="person", + new_name="requester", + ), + migrations.AlterField( + model_name="ownershiprequest", + name="club", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)ss", + to="clubs.club" + ), + ), + migrations.AlterField( + model_name="ownershiprequest", + name="requester", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)ss", + to=settings.AUTH_USER_MODEL + ), + ), + migrations.RenameField( + model_name="membershiprequest", + old_name="withdrew", + new_name="withdrawn", + ), + migrations.RenameField( + model_name="membershiprequest", + old_name="person", + new_name="requester", + ), + migrations.AlterField( + model_name="membershiprequest", + name="club", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)ss", + to="clubs.club" + ), + ), + migrations.AlterField( + model_name="membershiprequest", + name="requester", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)ss", + to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 22105c7a0..c8c2dcc61 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -355,6 +355,7 @@ class Club(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # signifies the existence of a previous instance within history with approved=True ghost = models.BooleanField(default=False) history = HistoricalRecords(cascade_delete_history=True) @@ -1077,49 +1078,89 @@ def __str__(self): return "".format(self.query, self.created_at) -class MembershipRequest(models.Model): +class JoinRequest(models.Model): """ - Used when users are not in the club but request membership from the owner + Abstract base class for Membership Request and Ownership Request """ - person = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) - club = models.ForeignKey(Club, on_delete=models.CASCADE) + requester = models.ForeignKey( + get_user_model(), on_delete=models.CASCADE, related_name="%(class)ss" + ) + club = models.ForeignKey(Club, on_delete=models.CASCADE, related_name="%(class)ss") - withdrew = models.BooleanField(default=False) + withdrawn = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + class Meta: + abstract = True + unique_together = (("requester", "club"),) + + +class MembershipRequest(JoinRequest): + """ + Used when users are not in the club but request membership from the owner + """ + def __str__(self): - return "".format( - self.person.username, self.club.code, self.person.email - ) + return f"" def send_request(self, request=None): domain = get_domain(request) + edit_url = settings.EDIT_URL.format(domain=domain, club=self.club.code) + club_name = self.club.name + full_name = self.requester.get_full_name() context = { - "club_name": self.club.name, - "edit_url": "{}/member".format( - settings.EDIT_URL.format(domain=domain, club=self.club.code) - ), - "full_name": self.person.get_full_name(), + "club_name": club_name, + "edit_url": f"{edit_url}/member", + "full_name": full_name, } emails = self.club.get_officer_emails() if emails: send_mail_helper( - name="request", - subject="Membership Request from {} for {}".format( - self.person.get_full_name(), self.club.name - ), + name="membership_request", + subject=f"Membership Request from {full_name} for {club_name}", emails=emails, context=context, ) - class Meta: - unique_together = (("person", "club"),) + +class OwnershipRequest(JoinRequest): + """ + Represents a user's request to take ownership of a club + """ + + def __str__(self): + return f"" + + def send_request(self, request=None): + domain = get_domain(request) + edit_url = settings.EDIT_URL.format(domain=domain, club=self.club.code) + club_name = self.club.name + full_name = self.requester.get_full_name() + + context = { + "club_name": club_name, + "edit_url": f"{edit_url}/member", + "full_name": full_name, + } + + owner_emails = list( + self.club.membership_set.filter( + role=Membership.ROLE_OWNER, active=True + ).values_list("person__email", flat=True) + ) + + send_mail_helper( + name="ownership_request", + subject=f"Ownership Request from {full_name} for {club_name}", + emails=owner_emails, + context=context, + ) class Advisor(models.Model): diff --git a/backend/clubs/permissions.py b/backend/clubs/permissions.py index ce634fc9a..717bfe89e 100644 --- a/backend/clubs/permissions.py +++ b/backend/clubs/permissions.py @@ -444,6 +444,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 fc9f1a095..b1513717e 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -43,6 +43,7 @@ MembershipRequest, Note, NoteTag, + OwnershipRequest, Profile, QuestionAnswer, Report, @@ -457,10 +458,18 @@ def validate(self, data): end_time = data.get( "end_time", self.instance.end_time if self.instance is not None else None ) + ticket_drop_time = data.get( + "ticket_drop_time", + self.instance.ticket_drop_time if self.instance is not None else None, + ) if start_time is not None and end_time is not None and start_time > end_time: raise serializers.ValidationError( "Your event start time must be less than the end time!" ) + if ticket_drop_time is not None and ticket_drop_time >= end_time: + raise serializers.ValidationError( + "Your ticket drop time must be before the event ends!" + ) return data def update(self, instance, validated_data): @@ -507,6 +516,7 @@ class Meta: "location", "name", "start_time", + "ticket_drop_time", "ticketed", "type", "url", @@ -636,7 +646,9 @@ def update(self, instance, validated_data): obj.save() # if a membership request exists, delete it - MembershipRequest.objects.filter(person=user, club=self.instance.club).delete() + MembershipRequest.objects.filter( + requester=user, club=self.instance.club + ).delete() return instance @@ -1267,7 +1279,7 @@ def get_is_request(self, obj): user = self.context["request"].user if not user.is_authenticated: return False - return obj.membershiprequest_set.filter(person=user, withdrew=False).exists() + return obj.membershiprequests.filter(requester=user, withdrawn=False).exists() def get_target_years(self, obj): qset = TargetYear.objects.filter(club=obj).select_related("target_years") @@ -1940,20 +1952,22 @@ class MembershipRequestSerializer(serializers.ModelSerializer): Used by club owners/officers to see who has requested to join the club. """ - person = serializers.HiddenField(default=serializers.CurrentUserDefault()) + requester = serializers.HiddenField(default=serializers.CurrentUserDefault()) club = serializers.SlugRelatedField(queryset=Club.objects.all(), slug_field="code") name = serializers.SerializerMethodField("get_full_name") - username = serializers.CharField(source="person.username", read_only=True) - email = serializers.EmailField(source="person.email", read_only=True) + username = serializers.CharField(source="requester.username", read_only=True) + email = serializers.EmailField(source="requester.email", read_only=True) - school = SchoolSerializer(many=True, source="person.profile.school", read_only=True) - major = MajorSerializer(many=True, source="person.profile.major", read_only=True) + school = SchoolSerializer( + many=True, source="requester.profile.school", read_only=True + ) + major = MajorSerializer(many=True, source="requester.profile.major", read_only=True) graduation_year = serializers.IntegerField( - source="person.profile.graduation_year", read_only=True + source="requester.profile.graduation_year", read_only=True ) def get_full_name(self, obj): - return obj.person.get_full_name() + return obj.requester.get_full_name() class Meta: model = MembershipRequest @@ -1964,15 +1978,10 @@ class Meta: "graduation_year", "major", "name", - "person", + "requester", "school", "username", ) - validators = [ - validators.UniqueTogetherValidator( - queryset=MembershipRequest.objects.all(), fields=["club", "person"] - ) - ] class UserMembershipRequestSerializer(serializers.ModelSerializer): @@ -1980,23 +1989,64 @@ class UserMembershipRequestSerializer(serializers.ModelSerializer): Used by the UserSerializer to return the clubs that the user has sent request to. """ - person = serializers.HiddenField(default=serializers.CurrentUserDefault()) + requester = serializers.HiddenField(default=serializers.CurrentUserDefault()) club = serializers.SlugRelatedField(queryset=Club.objects.all(), slug_field="code") club_name = serializers.CharField(source="club.name", read_only=True) - def create(self, validated_data): - """ - Send an email when a membership request is created. - """ - obj = super().create(validated_data) + class Meta: + model = MembershipRequest + fields = ("club", "club_name", "requester") - obj.send_request(self.context["request"]) - return obj +class OwnershipRequestSerializer(serializers.ModelSerializer): + """ + Used by club owners to see who has requested to be owner of the club. + """ + + requester = serializers.HiddenField(default=serializers.CurrentUserDefault()) + club = serializers.SlugRelatedField(queryset=Club.objects.all(), slug_field="code") + name = serializers.SerializerMethodField("get_full_name") + username = serializers.CharField(source="requester.username", read_only=True) + email = serializers.EmailField(source="requester.email", read_only=True) + + school = SchoolSerializer( + many=True, source="requester.profile.school", read_only=True + ) + major = MajorSerializer(many=True, source="requester.profile.major", read_only=True) + graduation_year = serializers.IntegerField( + source="requester.profile.graduation_year", read_only=True + ) + + def get_full_name(self, obj): + return obj.requester.get_full_name() class Meta: - model = MembershipRequest - fields = ("club", "club_name", "person") + model = OwnershipRequest + fields = ( + "club", + "created_at", + "email", + "graduation_year", + "major", + "name", + "requester", + "school", + "username", + ) + + +class UserOwnershipRequestSerializer(serializers.ModelSerializer): + """ + Used by the users to return the clubs that the user has sent an OwnershipRequest to. + """ + + requester = serializers.HiddenField(default=serializers.CurrentUserDefault()) + club = serializers.SlugRelatedField(queryset=Club.objects.all(), slug_field="code") + club_name = serializers.CharField(source="club.name", read_only=True) + + class Meta: + model = OwnershipRequest + fields = ("club", "club_name", "requester") class MinimalUserProfileSerializer(serializers.ModelSerializer): @@ -2444,9 +2494,12 @@ def validate_word_limit(self, value): if data["word_limit"] + current_limit - instance_limit > 500: raise serializers.ValidationError( - f"The total word limit of questions in committee \ - ''{committee['name']} ' should not exceed 500. \ - Current: {current_limit}" + f"The cumulative word limit of questions should not " + f"exceed 500 words as required by the Wharton Council. " + f"Committee '{committee['name']}' currently has a cumulative " + f"word limit of {current_limit} words. Please consult the " + f"Wharton Council's guide to club applications if you have any " + f"questions." ) return value diff --git a/backend/clubs/urls.py b/backend/clubs/urls.py index 48f2f0572..e194bdaa2 100644 --- a/backend/clubs/urls.py +++ b/backend/clubs/urls.py @@ -36,6 +36,8 @@ MemberViewSet, NoteViewSet, OptionListView, + OwnershipRequestManagementViewSet, + OwnershipRequestViewSet, QuestionAnswerViewSet, ReportViewSet, SchoolViewSet, @@ -71,7 +73,12 @@ router.register(r"clubvisits", ClubVisitViewSet, basename="clubvisits") router.register(r"searches", SearchQueryViewSet, basename="searches") router.register(r"memberships", MembershipViewSet, basename="members") -router.register(r"requests", MembershipRequestViewSet, basename="requests") +router.register( + r"requests/membership", MembershipRequestViewSet, basename="membership-requests" +) +router.register( + r"requests/ownership", OwnershipRequestViewSet, basename="ownership-requests" +) router.register(r"tickets", TicketViewSet, basename="tickets") router.register(r"schools", SchoolViewSet, basename="schools") @@ -109,6 +116,11 @@ MembershipRequestOwnerViewSet, basename="club-membership-requests", ) +clubs_router.register( + r"ownershiprequests", + OwnershipRequestManagementViewSet, + basename="club-ownership-requests", +) clubs_router.register(r"advisors", AdvisorViewSet, basename="club-advisors") clubs_router.register( r"applications", ClubApplicationViewSet, basename="club-applications" diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 6d03c9607..2bb4d3d1c 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -107,6 +107,7 @@ MembershipInvite, MembershipRequest, Note, + OwnershipRequest, QuestionAnswer, RecurringEvent, Report, @@ -138,6 +139,7 @@ MemberPermission, MembershipRequestPermission, NotePermission, + OwnershipRequestPermission, ProfilePermission, QuestionAnswerPermission, ReadOnly, @@ -181,6 +183,7 @@ MembershipSerializer, MinimalUserProfileSerializer, NoteSerializer, + OwnershipRequestSerializer, QuestionAnswerSerializer, ReportClubSerializer, ReportSerializer, @@ -197,6 +200,7 @@ UserMembershipInviteSerializer, UserMembershipRequestSerializer, UserMembershipSerializer, + UserOwnershipRequestSerializer, UserProfileSerializer, UserSerializer, UserSubscribeSerializer, @@ -1762,7 +1766,7 @@ def directory(self, request, *args, **kwargs): """ serializer = ClubMinimalSerializer( Club.objects.all() - .exclude(Q(approved=False) | Q(archived=True)) + .exclude((~Q(approved=True) & Q(ghost=False)) | Q(archived=True)) .order_by(Lower("name")), many=True, ) @@ -2464,6 +2468,24 @@ def add_to_cart(self, request, *args, **kwargs): --- """ event = self.get_object() + club = Club.objects.filter(code=event.club.code).first() + # As clubs cannot go from historically approved to unapproved, we can + # check here without checking further on in the checkout process + # (the only exception is archiving a club, which is checked) + if not club: + return Response( + {"detail": "Related club does not exist", "success": False}, + status=status.HTTP_404_NOT_FOUND, + ) + elif not club.approved and not club.ghost: + return Response( + { + "detail": """This club has not been approved + and cannot sell tickets.""", + "success": False, + }, + status=status.HTTP_403_FORBIDDEN, + ) cart, _ = Cart.objects.get_or_create(owner=self.request.user) # Check if the event has already ended @@ -2682,9 +2704,6 @@ def tickets(self, request, *args, **kwargs): event = self.get_object() tickets = Ticket.objects.filter(event=event) - if event.ticket_drop_time and timezone.now() < event.ticket_drop_time: - return Response({"totals": [], "available": []}) - # Take price of first ticket of given type for now totals = ( tickets.values("type") @@ -2773,7 +2792,7 @@ def create_tickets(self, request, *args, **kwargs): event = self.get_object() # Tickets can't be edited after they've dropped - if event.ticket_drop_time and timezone.now() > event.ticket_drop_time: + if event.ticket_drop_time and timezone.now() >= event.ticket_drop_time: return Response( {"detail": "Tickets cannot be edited after they have dropped"}, status=status.HTTP_403_FORBIDDEN, @@ -3280,24 +3299,64 @@ def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) + def partial_update(self, request, *args, **kwargs): + """ + Do not let club admins modify the ticket drop time + if tickets have potentially been sold through the checkout process. + """ + event = self.get_object() + if ( + "ticket_drop_time" in request.data + and ( + event.ticket_drop_time is None # case where sales immediately start + or event.ticket_drop_time <= timezone.now() + ) + and Ticket.objects.filter(event=event, owner__isnull=False).exists() + ): + raise DRFValidationError( + detail="""Ticket drop times cannot be edited + after tickets have been sold.""" + ) + return super().partial_update(request, *args, **kwargs) + def get_queryset(self): qs = Event.objects.all() is_club_specific = self.kwargs.get("club_code") is not None if is_club_specific: qs = qs.filter(club__code=self.kwargs["club_code"]) - qs = qs.filter( - Q(club__approved=True) | Q(type=Event.FAIR) | Q(club__ghost=True), - club__archived=False, - ) + # Check if the user is an officer or admin + if not self.request.user.is_authenticated or ( + not self.request.user.has_perm("clubs.manage_club") + and not Membership.objects.filter( + person=self.request.user, + club__code=self.kwargs["club_code"], + role__lte=Membership.ROLE_OFFICER, + ).exists() + ): + qs = qs.filter( + Q(club__approved=True) | Q(type=Event.FAIR) | Q(club__ghost=True), + club__archived=False, + ) else: - qs = qs.filter( - Q(club__approved=True) - | Q(type=Event.FAIR) - | Q(club__ghost=True) - | Q(club__isnull=True), - Q(club__isnull=True) | Q(club__archived=False), - ) - + if not ( + self.request.user.is_authenticated + and self.request.user.has_perm("clubs.manage_club") + ): + officer_clubs = ( + Membership.objects.filter( + person=self.request.user, role__lte=Membership.ROLE_OFFICER + ).values_list("club", flat=True) + if self.request.user.is_authenticated + else [] + ) + qs = qs.filter( + Q(club__approved=True) + | Q(club__id__in=list(officer_clubs)) + | Q(type=Event.FAIR) + | Q(club__ghost=True) + | Q(club__isnull=True), + Q(club__isnull=True) | Q(club__archived=False), + ) return ( qs.select_related("club", "creator") .prefetch_related( @@ -3864,15 +3923,27 @@ def create(self, request, *args, **kwargs): If a membership request object already exists, reuse it. """ club = request.data.get("club", None) - obj = MembershipRequest.objects.filter( - club__code=club, person=request.user - ).first() - if obj is not None: - obj.withdrew = False - obj.save(update_fields=["withdrew"]) - return Response(UserMembershipRequestSerializer(obj).data) + club_instance = Club.objects.filter(code=club).first() + if club_instance is None: + return Response( + {"detail": "Invalid club code"}, status=status.HTTP_400_BAD_REQUEST + ) - return super().create(request, *args, **kwargs) + create_defaults = {"club": club_instance, "requester": request.user} + + obj, created = MembershipRequest.objects.update_or_create( + club__code=club, + requester=request.user, + defaults={"withdrawn": False, "created_at": timezone.now()}, + create_defaults=create_defaults, + ) + + if created: + obj.send_request(request) + + serializer = self.get_serializer(obj, many=False) + + return Response(serializer.data, status=status.HTTP_201_CREATED) def destroy(self, request, *args, **kwargs): """ @@ -3882,15 +3953,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, ) @@ -3907,11 +3978,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"]) @@ -3935,12 +4006,191 @@ def accept(self, request, *ages, **kwargs): """ request_object = self.get_object() Membership.objects.get_or_create( - person=request_object.person, club=request_object.club + person=request_object.requester, club=request_object.club ) request_object.delete() return Response({"success": True}) +class OwnershipRequestViewSet(viewsets.ModelViewSet): + """ + list: Return a list of clubs that the logged in user has sent ownership request to. + + create: Sent ownership request to a club. + + destroy: Deleted a ownership request from a club. + """ + + serializer_class = UserOwnershipRequestSerializer + permission_classes = [IsAuthenticated] + lookup_field = "club__code" + http_method_names = ["get", "post", "delete"] + + def create(self, request, *args, **kwargs): + """ + If a ownership request object already exists, reuse it. + """ + club = request.data.get("club", None) + club_instance = Club.objects.filter(code=club).first() + if club_instance is None: + return Response( + {"detail": "Invalid club code"}, status=status.HTTP_400_BAD_REQUEST + ) + + create_defaults = {"club": club_instance, "requester": request.user} + + obj, created = OwnershipRequest.objects.update_or_create( + club__code=club, + requester=request.user, + defaults={"withdrawn": False, "created_at": timezone.now()}, + create_defaults=create_defaults, + ) + + if created: + obj.send_request(request) + + serializer = self.get_serializer(obj, many=False) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def destroy(self, request, *args, **kwargs): + """ + Don't actually delete the ownership request when it is withdrawn. + + This is to keep track of repeat ownership requests and avoid spamming the club + owners with requests. + """ + obj = self.get_object() + obj.withdrawn = True + obj.save(update_fields=["withdrawn"]) + + return Response(status=status.HTTP_204_NO_CONTENT) + + def get_queryset(self): + return OwnershipRequest.objects.filter( + requester=self.request.user, + withdrawn=False, + club__archived=False, + ) + + +class OwnershipRequestManagementViewSet(viewsets.ModelViewSet): + """ + list: + Return a list of users who have sent ownership request to the club. + + destroy: + Delete a ownership request for a specific user. + + accept: + Accept an ownership request as a club owner. + + all: + Return a list of ownership requests older than a week. Used by Superusers. + """ + + serializer_class = OwnershipRequestSerializer + + permission_classes = [OwnershipRequestPermission | IsSuperuser] + http_method_names = ["get", "post", "delete"] + lookup_field = "requester__username" + + def get_queryset(self): + if self.action != "all": + return OwnershipRequest.objects.filter( + club__code=self.kwargs["club_code"], withdrawn=False + ) + else: + return OwnershipRequest.objects.filter(withdrawn=False).order_by( + "created_at" + ) + + @action(detail=True, methods=["post"]) + def accept(self, request, *args, **kwargs): + """ + Accept an ownership request as a club owner. + --- + requestBody: {} + responses: + "200": + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + description: > + True if this request was properly processed. + --- + """ + request_object = self.get_object() + + Membership.objects.update_or_create( + person=request_object.requester, + club=request_object.club, + defaults={"role": Membership.ROLE_OWNER}, + ) + + request_object.delete() + return Response({"success": True}) + + @action(detail=False, methods=["get"], permission_classes=[IsSuperuser]) + def all(self, request, *args, **kwargs): + """ + View unaddressed ownership requests, sorted by date. + --- + requestBody: {} + responses: + "200": + content: + application/json: + schema: + type: array + items: + type: object + properties: + club: + type: string + created_at: + type: string + format: date-time + email: + type: string + graduation_year: + type: integer + major: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + name: + type: string + school: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + is_graduate: + type: boolean + username: + type: string + --- + """ + + serializer = self.get_serializer(self.get_queryset(), many=True) + + return Response(serializer.data) + + class MemberViewSet(XLSXFormatterMixin, viewsets.ModelViewSet): """ list: @@ -5290,8 +5540,13 @@ def cart(self, request, *args, **kwargs): tickets_to_replace = cart.tickets.filter( Q(owner__isnull=False) + | Q(event__club__archived=True) | Q(holder__isnull=False) | Q(event__end_time__lt=now) + | ( + Q(event__ticket_drop_time__gt=timezone.now()) + & Q(event__ticket_drop_time__isnull=False) + ) ).exclude(holder=self.request.user) # In most cases, we won't need to replace, so exit early @@ -5328,6 +5583,8 @@ def cart(self, request, *args, **kwargs): continue available_tickets = Ticket.objects.filter( + Q(event__ticket_drop_time__lte=timezone.now()) + | Q(event__ticket_drop_time__isnull=True), event=ticket_class["event"], type=ticket_class["type"], buyable=True, # should not be triggered as buyable is by ticket class @@ -5422,6 +5679,9 @@ def initiate_checkout(self, request, *args, **kwargs): # are locked, we shouldn't block. tickets = cart.tickets.select_for_update(skip_locked=True).filter( Q(holder__isnull=True) | Q(holder=self.request.user), + Q(event__ticket_drop_time__lte=timezone.now()) + | Q(event__ticket_drop_time__isnull=True), + event__club__archived=False, owner__isnull=True, buyable=True, ) @@ -6540,10 +6800,10 @@ def edit_clubs(self, *args, **kwargs): end = cycle.end_date release = cycle.release_date - # Some apps get deleted + # Remove cycle for club applications from non-included clubs ClubApplication.objects.filter(application_cycle=cycle).exclude( club__code__in=club_codes - ).delete() + ).update(application_cycle=None) # Some apps need to be created - use the default Wharton Template prompt_one = ( @@ -6935,7 +7195,6 @@ def list(self, *args, **kwargs): return Response(data) - @method_decorator(cache_page(60 * 60 * 2)) @action(detail=False, methods=["get"]) def export(self, *args, **kwargs): """ @@ -6980,10 +7239,10 @@ def export(self, *args, **kwargs): ) df = pd.DataFrame(ApplicationSubmissionCSVSerializer(data, many=True).data) resp = HttpResponse( - content_type="text/csv", + content_type="text/csv; charset=utf-8-sig", # support special chars headers={"Content-Disposition": "attachment;filename=submissions.csv"}, ) - df.to_csv(index=True, path_or_buf=resp) + df.to_csv(index=True, path_or_buf=resp, encoding="utf-8-sig") return resp @action(detail=False, methods=["get"]) diff --git a/backend/templates/emails/membership_request.html b/backend/templates/emails/membership_request.html new file mode 100644 index 000000000..3d4ad5d39 --- /dev/null +++ b/backend/templates/emails/membership_request.html @@ -0,0 +1,17 @@ + +{% extends 'emails/base.html' %} + +{% block content %} +

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

+

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

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

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

-

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

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

+

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

Approve Request diff --git a/backend/tests/clubs/test_ticketing.py b/backend/tests/clubs/test_ticketing.py index 8f047b9d8..de8acb531 100644 --- a/backend/tests/clubs/test_ticketing.py +++ b/backend/tests/clubs/test_ticketing.py @@ -51,6 +51,14 @@ def commonSetUp(self): email="example@example.com", ) + self.unapproved_club = Club.objects.create( + code="unapproved-club", + name="Unapproved Club", + approved=False, + ghost=False, + email="example2@example.com", + ) + self.event1 = Event.objects.create( code="test-event", club=self.club1, @@ -59,6 +67,14 @@ def commonSetUp(self): end_time=timezone.now() + timezone.timedelta(days=3), ) + self.unapproved_event = Event.objects.create( + code="unapproved-event", + club=self.unapproved_club, + name="Unapproved Event", + start_time=timezone.now() + timezone.timedelta(days=2), + end_time=timezone.now() + timezone.timedelta(days=3), + ) + self.ticket_totals = [ {"type": "normal", "count": 20, "price": 15.0}, {"type": "premium", "count": 10, "price": 30.0}, @@ -73,6 +89,11 @@ def commonSetUp(self): for _ in range(10) ] + self.unapproved_tickets = [ + Ticket.objects.create(type="normal", event=self.unapproved_event, price=15.0) + for _ in range(20) + ] + class TicketEventTestCase(TestCase): """ @@ -87,6 +108,30 @@ def setUp(self): def test_create_ticket_offerings(self): self.client.login(username=self.user1.username, password="test") + + # Test invalid start_time, ticket_drop_time editing + resp = self.client.patch( + reverse("club-events-detail", args=(self.club1.code, self.event1.pk)), + { + "ticket_drop_time": ( + self.event1.end_time + timezone.timedelta(days=20) + ).strftime("%Y-%m-%dT%H:%M:%S%z") + }, + format="json", + ) + self.assertEqual(resp.status_code, 400, resp.content) + + resp = self.client.patch( + reverse("club-events-detail", args=(self.club1.code, self.event1.pk)), + { + "start_time": ( + self.event1.end_time + timezone.timedelta(days=20) + ).strftime("%Y-%m-%dT%H:%M:%S%z") + }, + format="json", + ) + self.assertEqual(resp.status_code, 400, resp.content) + qts = { "quantities": [ {"type": "_normal", "count": 20, "price": 10}, @@ -277,6 +322,18 @@ def test_create_ticket_offerings_already_owned_or_held(self): ) self.assertEqual(resp.status_code, 403, resp.content) + # Changing ticket drop time should fail + resp = self.client.patch( + reverse("club-events-detail", args=(self.club1.code, self.event1.pk)), + { + "ticket_drop_time": ( + timezone.now() + timezone.timedelta(hours=12) + ).strftime("%Y-%m-%dT%H:%M:%S%z") + }, + format="json", + ) + self.assertEqual(resp.status_code, 400, resp.content) + def test_issue_tickets(self): self.client.login(username=self.user1.username, password="test") args = { @@ -468,21 +525,6 @@ def test_get_tickets_information(self): data["available"], ) - def test_get_tickets_before_drop_time(self): - self.event1.ticket_drop_time = timezone.now() + timedelta(days=1) - self.event1.save() - - self.client.login(username=self.user1.username, password="test") - resp = self.client.get( - reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), - ) - self.assertEqual(resp.status_code, 200, resp.content) - data = resp.json() - - # Tickets shouldn't be available before the drop time - self.assertEqual(data["totals"], []) - self.assertEqual(data["available"], []) - def test_get_tickets_buyers(self): self.client.login(username=self.user1.username, password="test") @@ -643,6 +685,50 @@ def test_add_to_cart_before_ticket_drop(self): # Tickets should not be added to cart before drop time self.assertEqual(resp.status_code, 403, resp.content) + def test_add_to_cart_unapproved_club(self): + self.client.login(username=self.user1.username, password="test") + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 2}, + ] + } + resp = self.client.post( + reverse( + "club-events-add-to-cart", + args=(self.unapproved_club.code, self.unapproved_event.pk), + ), + tickets_to_add, + format="json", + ) + self.assertEqual(resp.status_code, 403, resp.content) + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse( + "club-events-add-to-cart", + args=(self.unapproved_club.code, self.unapproved_event.pk), + ), + tickets_to_add, + format="json", + ) + # Cannot see event + self.assertEqual(resp.status_code, 404, resp.content) + + def test_add_to_cart_nonexistent_club(self): + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 2}, + ] + } + resp = self.client.post( + reverse( + "club-events-add-to-cart", + args=("Random club name", self.unapproved_event.pk), + ), + tickets_to_add, + format="json", + ) + self.assertEqual(resp.status_code, 404, resp.content) + def test_remove_from_cart(self): self.client.login(username=self.user1.username, password="test") @@ -1272,6 +1358,53 @@ def test_initiate_checkout_only_free_tickets(self): ).exists() self.assertTrue(record_exists) + def test_initiate_checkout_after_ticket_drop_time_edit(self): + self.client.login(username=self.user1.username, password="test") + + tickets = [Ticket(type="free", event=self.event1, price=0.0) for _ in range(3)] + Ticket.objects.bulk_create(tickets) + + # Add a few free tickets to cart + tickets_to_add = { + "quantities": [ + {"type": "free", "count": 3}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + # Set drop time ahead of current time + resp = self.client.patch( + reverse("club-events-detail", args=(self.club1.code, self.event1.pk)), + { + "ticket_drop_time": ( + timezone.now() + timezone.timedelta(hours=12) + ).strftime("%Y-%m-%dT%H:%M:%S%z") + }, + format="json", + ) + self.assertEqual(resp.status_code, 200, resp.content) + + # Initiate checkout + with patch( + ".".join( + [ + "CyberSource", + "UnifiedCheckoutCaptureContextApi", + "generate_unified_checkout_capture_context_with_http_info", + ] + ) + ) as fake_cap_context: + cap_context_data = "abcde" + fake_cap_context.return_value = cap_context_data, 200, None + resp = self.client.post(reverse("tickets-initiate-checkout")) + # Ticket should not be checked out + self.assertEqual(resp.status_code, 403, resp.content) + def test_initiate_concurrent_checkouts(self): self.client.login(username=self.user1.username, password="test") diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index b4931d737..d73f3ac7d 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -31,6 +31,8 @@ Favorite, Membership, MembershipInvite, + MembershipRequest, + OwnershipRequest, QuestionAnswer, School, Tag, @@ -227,6 +229,14 @@ def setUp(self): visibility=Advisor.ADVISOR_VISIBILITY_ALL, ) + def test_directory(self): + """ + Test retrieving the club directory. + """ + resp = self.client.get(reverse("clubs-directory")) + self.assertIn(resp.status_code, [200, 201], resp.content) + self.assertEqual(len(resp.data), 1) + def test_advisor_visibility(self): """ Tests each tier of advisor visibility. @@ -2937,6 +2947,549 @@ def test_club_approval_response_templates(self): ) self.assertEqual(resp.status_code, 403) + def test_ownership_requests_create_and_view(self): + """ + Test the ownership requests creation and viewing permissions + """ + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + + # Requester can create ownership request and email is sent + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse("ownership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + self.assertEqual(resp.status_code, 201, resp.content) + self.assertEqual( + OwnershipRequest.objects.filter( + club=self.club1, requester=self.user2 + ).count(), + 1, + ) + self.assertEqual(len(mail.outbox), 1, mail.outbox) + + # Requester can check own ownership requests + resp = self.client.get(reverse("ownership-requests-list")) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 1, resp.content) + + # Requester can check own ownership request to a club + resp = self.client.get( + reverse("ownership-requests-detail", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()["club"], self.club1.code, resp.content) + + # Requester cannot check club's ownership requests + resp = self.client.get( + reverse("club-ownership-requests-list", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 403, resp.content) + + # Owner can check club's ownership requests + self.client.login(username=self.user1.username, password="test") + resp = self.client.get( + reverse("club-ownership-requests-list", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 1, resp.content) + + # Owner can check club's ownership requests for specific user + resp = self.client.get( + reverse( + "club-ownership-requests-detail", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user2.username, + }, + ) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()["club"], self.club1.code, resp.content) + + def test_ownership_requests_withdraw(self): + """ + Test the ownership requests withdraw feature + """ + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse("ownership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + self.assertEqual(len(mail.outbox), 1, mail.outbox) + + OwnershipRequest.objects.filter(club=self.club1, requester=self.user2).update( + created_at=timezone.now() - timezone.timedelta(days=8) + ) + + # Requester can withdraw + self.client.login(username=self.user2.username, password="test") + resp = self.client.delete( + reverse("ownership-requests-detail", args=(self.club1.code,)) + ) + self.assertIn(resp.status_code, [200, 204], resp.content) + self.assertEqual( + OwnershipRequest.objects.filter( + club=self.club1, requester=self.user2, withdrawn=True + ).count(), + 1, + ) + + resp = self.client.get( + reverse("ownership-requests-detail", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 404, resp.content) + + # Owner and superuser cannot see withdrawn requests + + self.client.login(username=self.user1.username, password="test") + resp = self.client.get( + reverse("club-ownership-requests-list", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 0, resp.content) + + # Recreate ownership request + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse("ownership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + self.assertEqual(resp.status_code, 201, resp.content) + + # Emails are not resent + self.assertEqual(len(mail.outbox), 1, mail.outbox) + + # Owner can see recreated ownership requests + + self.client.login(username=self.user1.username, password="test") + resp = self.client.get( + reverse("club-ownership-requests-list", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 1, resp.content) + + def test_ownership_requests_accept(self): + """ + Test the ownership requests accept feature + """ + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + + # Create ownership request + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse("ownership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + + # Requester cannot accept requests + resp = self.client.post( + reverse( + "club-ownership-requests-accept", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user2.username, + }, + ) + ) + self.assertEqual(resp.status_code, 403, resp.content) + + # Owner can accept requests + self.client.login(username=self.user1.username, password="test") + + resp = self.client.post( + reverse( + "club-ownership-requests-accept", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user2.username, + }, + ) + ) + self.assertEqual(resp.status_code, 200, resp.content) + + self.assertEqual( + OwnershipRequest.objects.filter( + club=self.club1, requester=self.user2 + ).count(), + 0, + ) + + self.assertEqual( + Membership.objects.filter( + club=self.club1, person=self.user2, role=Membership.ROLE_OWNER + ).count(), + 1, + ) + + def test_ownership_requests_destroy(self): + """ + Test the ownership requests destroy (denial of request) feature + """ + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + + # Create ownership request + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse("ownership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + + # Requester cannot destroy requests + resp = self.client.delete( + reverse( + "club-ownership-requests-detail", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user2.username, + }, + ) + ) + self.assertEqual(resp.status_code, 403, resp.content) + + # Owner can destroy requests + self.client.login(username=self.user1.username, password="test") + + resp = self.client.delete( + reverse( + "club-ownership-requests-detail", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user2.username, + }, + ) + ) + self.assertIn(resp.status_code, [200, 204], resp.content) + + self.assertEqual( + OwnershipRequest.objects.filter( + club=self.club1, requester=self.user2 + ).count(), + 0, + ) + + self.assertEqual( + Membership.objects.filter( + club=self.club1, person=self.user2, role=Membership.ROLE_OWNER + ).count(), + 0, + ) + + def test_ownership_requests_list_all(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", 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", args=("anystring",)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 3, resp.content) + + # Check oldest requests first + self.assertEqual(resp.json()[0]["username"], self.user3.username, resp.content) + self.assertEqual(resp.json()[1]["username"], self.user4.username, resp.content) + self.assertEqual(resp.json()[2]["username"], self.user2.username, resp.content) + + def test_membership_requests_create_and_view(self): + """ + Test the membership requests creation and viewing permissions + """ + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse("membership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + self.assertEqual(resp.status_code, 201, resp.content) + self.assertEqual( + MembershipRequest.objects.filter( + club=self.club1, requester=self.user2 + ).count(), + 1, + ) + self.assertEqual(len(mail.outbox), 1, mail.outbox) + + # Requester can check own membership requests + resp = self.client.get(reverse("membership-requests-list")) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 1, resp.content) + + # Requester can check own membership request to a club + resp = self.client.get( + reverse("membership-requests-detail", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()["club"], self.club1.code, resp.content) + + # Requester cannot check club's membership requests + resp = self.client.get( + reverse("club-membership-requests-list", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 403, resp.content) + + # Owner can check club's membership requests + self.client.login(username=self.user1.username, password="test") + resp = self.client.get( + reverse("club-membership-requests-list", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 1, resp.content) + + # Owner can check club's membership requests for specific user + resp = self.client.get( + reverse( + "club-membership-requests-detail", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user2.username, + }, + ) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json()["club"], self.club1.code, resp.content) + + def test_membership_requests_withdraw(self): + """ + Test the membership requests withdraw feature + """ + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse("membership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + + # Requester can withdraw + resp = self.client.delete( + reverse("membership-requests-detail", args=(self.club1.code,)) + ) + self.assertIn(resp.status_code, [200, 204], resp.content) + self.assertEqual( + MembershipRequest.objects.filter( + club=self.club1, requester=self.user2, withdrawn=True + ).count(), + 1, + ) + + # Requester cannot see withdrawn request + resp = self.client.get( + reverse("membership-requests-detail", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 404, resp.content) + + # Owner cannot see withdrawn request + self.client.login(username=self.user1.username, password="test") + resp = self.client.get( + reverse("club-membership-requests-list", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 0, resp.content) + + # Recreate membership request + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse("membership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + self.assertEqual(resp.status_code, 201, resp.content) + self.assertEqual( + MembershipRequest.objects.filter( + club=self.club1, requester=self.user2 + ).count(), + 1, + ) + + # Email are not resent + self.assertEqual(len(mail.outbox), 1, mail.outbox) + + # Owner can see recreated membership request + self.client.login(username=self.user1.username, password="test") + resp = self.client.get( + reverse("club-membership-requests-list", args=(self.club1.code,)) + ) + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(resp.json()), 1, resp.content) + + def test_membership_requests_accept(self): + """ + Test the membership requests accept feature + """ + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse("membership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + + # Requester cannot accept membership requests + resp = self.client.post( + reverse( + "club-membership-requests-accept", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user2.username, + }, + ) + ) + self.assertEqual(resp.status_code, 403, resp.content) + + # Owner can accept membership requests + self.client.login(username=self.user1.username, password="test") + + resp = self.client.post( + reverse( + "club-membership-requests-accept", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user2.username, + }, + ) + ) + self.assertEqual(resp.status_code, 200, resp.content) + + self.assertEqual( + MembershipRequest.objects.filter( + club=self.club1, requester=self.user2 + ).count(), + 0, + ) + + self.assertEqual( + Membership.objects.filter( + club=self.club1, + person=self.user2, + ).count(), + 1, + ) + + def test_membership_requests_destroy(self): + """ + Test the membership requests destroy (denial of request) feature + """ + + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER + ) + + self.client.login(username=self.user2.username, password="test") + resp = self.client.post( + reverse("membership-requests-list"), + {"club": self.club1.code}, + content_type="application/json", + ) + + # Requester cannot destroy membership requests + resp = self.client.delete( + reverse( + "club-membership-requests-detail", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user2.username, + }, + ) + ) + self.assertEqual(resp.status_code, 403, resp.content) + + # Owner can accept membership requests + self.client.login(username=self.user1.username, password="test") + + resp = self.client.delete( + reverse( + "club-membership-requests-detail", + kwargs={ + "club_code": self.club1.code, + "requester__username": self.user2.username, + }, + ) + ) + self.assertIn(resp.status_code, [200, 204], resp.content) + + self.assertEqual( + MembershipRequest.objects.filter( + club=self.club1, requester=self.user2 + ).count(), + 0, + ) + + self.assertEqual( + Membership.objects.filter( + club=self.club1, + person=self.user2, + ).count(), + 0, + ) + class HealthTestCase(TestCase): def test_health(self): diff --git a/frontend/components/ClubEditPage/EventsCard.tsx b/frontend/components/ClubEditPage/EventsCard.tsx index cdd765d91..933cd67d7 100644 --- a/frontend/components/ClubEditPage/EventsCard.tsx +++ b/frontend/components/ClubEditPage/EventsCard.tsx @@ -1,17 +1,14 @@ import { Field } from 'formik' import moment from 'moment' -import React, { ReactElement, useRef, useState } from 'react' +import Link from 'next/link' +import { forwardRef, ReactElement, RefObject, useRef, useState } from 'react' import TimeAgo from 'react-timeago' import styled from 'styled-components' import { LIGHT_GRAY } from '../../constants' import { Club, ClubEvent, ClubEventType } from '../../types' import { stripTags } from '../../utils' -import { - FAIR_NAME, - OBJECT_EVENT_TYPES, - OBJECT_NAME_SINGULAR, -} from '../../utils/branding' +import { FAIR_NAME, OBJECT_EVENT_TYPES } from '../../utils/branding' import { Device, Icon, Line, Modal, Text } from '../common' import EventModal from '../EventPage/EventModal' import { @@ -311,51 +308,6 @@ const eventTableFields = [ }, ] -const eventFields = ( - <> - - - - value} - isMulti={false} - valueDeserialize={(val) => EVENT_TYPES.find((x) => x.value === val)} - /> - - - - -) - const EventPreviewContainer = styled.div` display: flex; justify-content: space-around; @@ -401,50 +353,122 @@ const CreateContainer = styled.div` align-items: center; ` -const CreateTickets = ({ event, club }: { event: ClubEvent; club: Club }) => { - const [show, setShow] = useState(false) - const showModal = () => setShow(true) - const hideModal = () => setShow(false) - - return ( - -
- - {event.ticketed ? 'Add' : 'Create'} ticket offerings for this event - -
-
- -
- {show && ( - - - - )} -
- ) +interface CreateTicketsProps { + event: ClubEvent + club: Club } +const CreateTickets = forwardRef( + ({ event, club }, ticketDroptimeRef) => { + const [show, setShow] = useState(false) + + const showModal = () => setShow(true) + const hideModal = () => setShow(false) + + return ( + +
+ + {event.ticketed ? 'Add' : 'Create'} ticket offerings for this event + +
+
+ +
+ {show && ( + + { + hideModal() + if (ticketDroptimeRef && 'current' in ticketDroptimeRef) { + const divRef = ticketDroptimeRef as RefObject + ticketDroptimeRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }) + divRef.current?.querySelector('input')?.focus() + } + }} + /> + + )} +
+ ) + }, +) + export default function EventsCard({ club }: EventsCardProps): ReactElement { const [deviceContents, setDeviceContents] = useState({}) const eventDetailsRef = useRef(null) + const ticketDroptimeRef = useRef(null) + + const eventFields = ( + <> + + + + value} + isMulti={false} + valueDeserialize={(val) => EVENT_TYPES.find((x) => x.value === val)} + /> + + +
+ {/* TODO: modify field components to support ref props after forwardRef() is depreciated in React 19 */} + +
+ + + ) const event = { ...deviceContents, @@ -458,10 +482,18 @@ export default function EventsCard({ club }: EventsCardProps): ReactElement { return ( - Manage events for this {OBJECT_NAME_SINGULAR}. Events that have already - passed are hidden by default. + {club.approved || club.is_ghost + ? 'Manage events for this club. Events that have already passed are hidden by default.' + : 'Note: you must be an approved club to create publicly-viewable events.'} ( + + + + )} baseUrl={`/clubs/${club.code}/events/`} listParams={`&end_time__gte=${new Date().toISOString()}`} fields={eventFields} @@ -480,7 +512,7 @@ export default function EventsCard({ club }: EventsCardProps): ReactElement { }} /> - +
diff --git a/frontend/components/ClubEditPage/QuestionsCard.tsx b/frontend/components/ClubEditPage/QuestionsCard.tsx index fe97deb62..d9eeb1d93 100644 --- a/frontend/components/ClubEditPage/QuestionsCard.tsx +++ b/frontend/components/ClubEditPage/QuestionsCard.tsx @@ -20,7 +20,7 @@ export default function QuestionsCard({

You can see a list of questions that prospective {OBJECT_NAME_SINGULAR}{' '} members have asked below. Answering any of these questions will make - them publically available and show your name as the person who answered + them publicly available and show your name as the person who answered the question.

void onSuccessfulSubmit: () => void }): ReactElement => { const { large_image_url, image_url, club_name, name, id } = event @@ -325,7 +327,7 @@ const TicketsModal = ({ {name} - Create new tickets for this event. For our alpha, only free tickets + Create new tickets for this event. For our beta, only free tickets will be supported for now: stay tuned for payments integration! @@ -355,6 +357,24 @@ const TicketsModal = ({ New Ticket Class + {!event.ticket_drop_time && ( + +

+ You can optionally add a time in which after when tickets will be + available{' '} + { + setSubmitting(false) + closeModal() + }} + > + within the event's edit page + + . Please note that this cannot be changed once any tickets are + sold. +

+
+ )}
{submitting ? ( <> diff --git a/frontend/components/ClubPage/Actions.tsx b/frontend/components/ClubPage/Actions.tsx index 8d0073f48..aa5903e1b 100644 --- a/frontend/components/ClubPage/Actions.tsx +++ b/frontend/components/ClubPage/Actions.tsx @@ -229,7 +229,7 @@ export const RequestMembershipButton = ({ // sanity check const resp = await doApiRequest( - `/requests/${club.code}/?format=json`, + `/requests/membership/${club.code}/?format=json`, ) if (resp.ok) { diff --git a/frontend/components/ClubPage/QuestionList.tsx b/frontend/components/ClubPage/QuestionList.tsx index e1a7c7518..d57c102f1 100644 --- a/frontend/components/ClubPage/QuestionList.tsx +++ b/frontend/components/ClubPage/QuestionList.tsx @@ -94,8 +94,8 @@ const QuestionList = ({
Your question has been submitted!

- It will be posted publically once it has been approved and answered - by {OBJECT_NAME_SINGULAR} members. + It will be posted publicly once it has been approved and answered by{' '} + {OBJECT_NAME_SINGULAR} members.

Thank you for contributing to {SITE_NAME}!

- ({ title: label, - strokeWidth: 100, color: colors[label], }))} /> diff --git a/frontend/pages/club/[club]/apply.tsx b/frontend/pages/club/[club]/apply.tsx index 9b2fc8203..d8716b29d 100644 --- a/frontend/pages/club/[club]/apply.tsx +++ b/frontend/pages/club/[club]/apply.tsx @@ -50,11 +50,11 @@ const ApplyPage = ({ club, applications }: Props): ReactElement => { updateRequests={async (code: string) => { logEvent('request', code) if (updatedIsRequest) { - await doApiRequest(`/requests/${club.code}/?format=json`, { + await doApiRequest(`/requests/membership/${club.code}/?format=json`, { method: 'DELETE', }) } else { - await doApiRequest(`/requests/?format=json`, { + await doApiRequest(`/requests/membership/?format=json`, { method: 'POST', body: { club: code }, }) diff --git a/frontend/pages/club/[club]/index.tsx b/frontend/pages/club/[club]/index.tsx index b6bd88f39..ffa341ea9 100644 --- a/frontend/pages/club/[club]/index.tsx +++ b/frontend/pages/club/[club]/index.tsx @@ -115,13 +115,13 @@ const ClubPage = ({ const newClub = { ...club } logEvent(!newClub.is_request ? 'request' : 'unrequest', code) const req = !newClub.is_request - ? doApiRequest('/requests/?format=json', { + ? doApiRequest('/requests/membership/?format=json', { method: 'POST', body: { club: code, }, }) - : doApiRequest(`/requests/${code}/?format=json`, { + : doApiRequest(`/requests/membership/${code}/?format=json`, { method: 'DELETE', }) diff --git a/frontend/pages/events/[id].tsx b/frontend/pages/events/[id].tsx index 49469c1aa..2fe811d61 100644 --- a/frontend/pages/events/[id].tsx +++ b/frontend/pages/events/[id].tsx @@ -1,4 +1,5 @@ import { DateTime, Settings } from 'luxon' +import moment from 'moment-timezone' import { GetServerSideProps, InferGetServerSidePropsType } from 'next' import Link from 'next/link' import React, { useState } from 'react' @@ -7,6 +8,7 @@ import styled from 'styled-components' import { BaseLayout } from '~/components/BaseLayout' import { + Icon, Metadata, Modal, StrongText, @@ -31,6 +33,7 @@ import { } from '~/constants' import { Club, ClubEvent, TicketAvailability } from '~/types' import { doApiRequest, EMPTY_DESCRIPTION } from '~/utils' +import { APPROVAL_AUTHORITY } from '~/utils/branding' import { createBasePropFetcher } from '~/utils/getBaseProps' Settings.defaultZone = 'America/New_York' @@ -50,10 +53,18 @@ export const getServerSideProps = (async (ctx) => { // TODO: Add caching const [baseProps, event] = await Promise.all([ getBaseProps(ctx), - doApiRequest(`/events/${id}`, data).then( - (resp) => resp.json() as Promise, - ), + doApiRequest(`/events/${id}`, data).then((resp) => { + if (!resp.ok) { + return null + } + return resp.json() as Promise + }), ]) + if (!event) { + return { + notFound: true, + } + } const [club, tickets] = await Promise.all([ doApiRequest(`/clubs/${event.club}/`, data).then( (resp) => resp.json() as Promise, @@ -186,6 +197,7 @@ const GetTicketItem: React.FC = ({ borderBottom: '1px solid #e0e0e0', borderTop: '1px solid #e0e0e0', }} + id={ticket.type} >
= ({ const image = event.image_url ?? club.image_url + const notDroppedYet = + event.ticket_drop_time !== null && + new Date(event.ticket_drop_time) > new Date() + + const historicallyApproved = club.approved !== true && !club.is_ghost + return ( <> = ({ + {!club.active && !club.is_ghost && ( +
+ + This event is hosted by a club that has not been approved by the{' '} + {APPROVAL_AUTHORITY} and is therefore not visible to the public + yet. +
+ )}
{event.name} @@ -349,7 +375,19 @@ const EventPage: React.FC = ({ {event.ticketed && ( Tickets - + {notDroppedYet && ( + <> + + Tickets will be available for purchase on{' '} + {moment(event.ticket_drop_time) + .tz('America/New_York') + .format('LLL')} + . + + + + )} + {totalAvailableTickets > 0 ? `${totalAvailableTickets} tickets available` : 'Sold out'} @@ -363,11 +401,22 @@ const EventPage: React.FC = ({ )} diff --git a/frontend/pages/events/index.tsx b/frontend/pages/events/index.tsx index cb2a2a075..0da7da704 100644 --- a/frontend/pages/events/index.tsx +++ b/frontend/pages/events/index.tsx @@ -37,9 +37,9 @@ export const getServerSideProps = (async (ctx) => { // TODO: Add caching const [baseProps, clubs, events] = await Promise.all([ getBaseProps(ctx), - doApiRequest('/clubs/directory/?format=json', data) - .then((resp) => resp.json() as Promise) - .then((resp) => resp.filter(({ approved }) => approved)), + doApiRequest('/clubs/directory/?format=json', data).then( + (resp) => resp.json() as Promise, + ), doApiRequest(`/events/?${params.toString()}`, data).then( (resp) => resp.json() as Promise, ), @@ -47,7 +47,8 @@ export const getServerSideProps = (async (ctx) => { const clubMap = new Map(clubs.map((club) => [club.code, club])) const eventsWithClubs = events.map((event) => ({ ...event, - club: event.club ? clubMap.get(event.club) : null, + club: event.club ? clubMap.get(event.club) ?? null : null, + clubPublic: event.club == null || clubMap.get(event.club) !== undefined, })) return { props: { diff --git a/frontend/pages/tickets/[[...slug]].tsx b/frontend/pages/tickets/[[...slug]].tsx index d49b0c257..853faba91 100644 --- a/frontend/pages/tickets/[[...slug]].tsx +++ b/frontend/pages/tickets/[[...slug]].tsx @@ -1,7 +1,9 @@ import { css } from '@emotion/react' import { Center, Container, Icon, Metadata } from 'components/common' import { Form, Formik } from 'formik' +import moment from 'moment-timezone' import { GetServerSideProps, InferGetServerSidePropsType } from 'next' +import Link from 'next/link' import React, { ReactElement, useState } from 'react' import { toast } from 'react-toastify' import styled from 'styled-components' @@ -160,6 +162,18 @@ const Ticket: React.FC = ({ All Tickets for {event.name} + {event.ticket_drop_time && + new Date(event.ticket_drop_time) > new Date() && ( + + Tickets have not dropped yet. Visit the{' '} + event page{' '} + to change the current drop time of{' '} + {moment(event.ticket_drop_time) + .tz('America/New_York') + .format('MMMM Do YYYY')} + . + + )} {Object.values(tickTypes).map((ticket, i) => ( ( diff --git a/k8s/main.ts b/k8s/main.ts index 8276c0b29..0cc668fa9 100644 --- a/k8s/main.ts +++ b/k8s/main.ts @@ -83,7 +83,7 @@ export class MyChart extends PennLabsChart { }) new CronJob(this, 'osa-perms-updates', { - schedule: cronTime.every(5).minutes(), + schedule: cronTime.every(1).minutes(), image: backendImage, secret: clubsSecret, cmd: ['python', 'manage.py', 'osa_perms_updates'],