From 1cdb67e4639c35dbd04cf1ef51bd08590eba67c4 Mon Sep 17 00:00:00 2001 From: Julian Weng Date: Sat, 23 Nov 2024 00:10:20 -0500 Subject: [PATCH 1/9] Add UI support for ticket drop times, add public visibility of ticketing details for non-dropped events, add ticket_drop_time serializer validation, make events belonging to unapproved clubs visible to club leaders specifically, standardize spelling of "publicly" --- backend/clubs/serializers.py | 9 + backend/clubs/views.py | 62 +++-- backend/tests/clubs/test_ticketing.py | 15 -- .../components/ClubEditPage/EventsCard.tsx | 216 ++++++++++-------- .../components/ClubEditPage/QuestionsCard.tsx | 2 +- .../components/ClubEditPage/TicketsModal.tsx | 23 +- frontend/components/ClubPage/QuestionList.tsx | 4 +- frontend/components/EmbedOption.tsx | 10 +- frontend/components/EventPage/EventCard.tsx | 6 +- frontend/pages/events/[id].tsx | 45 +++- frontend/pages/events/index.tsx | 3 +- frontend/pages/tickets/[[...slug]].tsx | 14 ++ frontend/types.ts | 1 + frontend/utils.tsx | 2 +- 14 files changed, 271 insertions(+), 141 deletions(-) diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index fc9f1a095..c81eb3963 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -457,10 +457,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 +515,7 @@ class Meta: "location", "name", "start_time", + "ticket_drop_time", "ticketed", "type", "url", diff --git a/backend/clubs/views.py b/backend/clubs/views.py index b425af8f1..3c03842cb 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -2682,9 +2682,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") @@ -3193,24 +3190,59 @@ def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) + def partial_update(self, request, *args, **kwargs): + """ + Do not let users modify the ticket drop time if tickets have already been sold. + """ + event = self.get_object() + if ( + "ticket_drop_time" in request.data + 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( diff --git a/backend/tests/clubs/test_ticketing.py b/backend/tests/clubs/test_ticketing.py index f1dbce0d6..128b12890 100644 --- a/backend/tests/clubs/test_ticketing.py +++ b/backend/tests/clubs/test_ticketing.py @@ -430,21 +430,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") diff --git a/frontend/components/ClubEditPage/EventsCard.tsx b/frontend/components/ClubEditPage/EventsCard.tsx index cdd765d91..c0e25062d 100644 --- a/frontend/components/ClubEditPage/EventsCard.tsx +++ b/frontend/components/ClubEditPage/EventsCard.tsx @@ -1,17 +1,19 @@ import { Field } from 'formik' import moment from 'moment' -import React, { ReactElement, useRef, useState } from 'react' +import React, { + 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 +313,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 +358,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,8 +487,9 @@ 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.'} - +
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,25 @@ const TicketsModal = ({ New Ticket Class + {!event.ticket_drop_time && ( + +

+ You can optionally add a time in which after when tickets will be + available{' '} + + . Please note that this cannot be changed once any tickets are + sold. +

+
+ )}
{submitting ? ( <> 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}!

)} diff --git a/frontend/pages/events/index.tsx b/frontend/pages/events/index.tsx index cb2a2a075..b54c10150 100644 --- a/frontend/pages/events/index.tsx +++ b/frontend/pages/events/index.tsx @@ -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) => ( ( From 57ec73d5cd4107347f50fd5bd22a99aed268b3d9 Mon Sep 17 00:00:00 2001 From: Julian Weng Date: Sat, 23 Nov 2024 00:30:47 -0500 Subject: [PATCH 2/9] Add retroactive checks to validate that ticket_drop_time has not changed following a ticket being added to a user's cart --- backend/clubs/views.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 3c03842cb..653618cb3 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -3192,7 +3192,8 @@ def destroy(self, request, *args, **kwargs): def partial_update(self, request, *args, **kwargs): """ - Do not let users modify the ticket drop time if tickets have already been sold. + Do not let club admins modify the ticket drop time + if tickets have already been sold. """ event = self.get_object() if ( @@ -5237,6 +5238,7 @@ def cart(self, request, *args, **kwargs): Q(owner__isnull=False) | Q(holder__isnull=False) | Q(event__end_time__lt=now) + | Q(event__ticket_drop_time__gt=timezone.now()) ).exclude(holder=self.request.user) # In most cases, we won't need to replace, so exit early @@ -5278,6 +5280,7 @@ def cart(self, request, *args, **kwargs): buyable=True, # should not be triggered as buyable is by ticket class owner__isnull=True, holder__isnull=True, + event__ticket_drop_time__lt=timezone.now(), ).exclude(id__in=tickets_in_cart)[: ticket_class["count"]] num_short = ticket_class["count"] - available_tickets.count() @@ -5368,6 +5371,7 @@ def initiate_checkout(self, request, *args, **kwargs): tickets = cart.tickets.select_for_update(skip_locked=True).filter( Q(holder__isnull=True) | Q(holder=self.request.user), owner__isnull=True, + event__ticket_drop_time__lt=timezone.now(), buyable=True, ) From c092ec7372f9f68fc2268fa86bde4161bf07c0c8 Mon Sep 17 00:00:00 2001 From: Julian Weng Date: Sat, 23 Nov 2024 00:37:27 -0500 Subject: [PATCH 3/9] Add action to go to public-facing event page in event editing interface --- frontend/components/ClubEditPage/EventsCard.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/components/ClubEditPage/EventsCard.tsx b/frontend/components/ClubEditPage/EventsCard.tsx index c0e25062d..933cd67d7 100644 --- a/frontend/components/ClubEditPage/EventsCard.tsx +++ b/frontend/components/ClubEditPage/EventsCard.tsx @@ -1,12 +1,7 @@ import { Field } from 'formik' import moment from 'moment' -import React, { - forwardRef, - ReactElement, - RefObject, - 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' @@ -492,6 +487,13 @@ export default function EventsCard({ club }: EventsCardProps): ReactElement { : '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} From 4414beecc4220080f85f801f4696dbec492938e7 Mon Sep 17 00:00:00 2001 From: Julian Weng Date: Sat, 23 Nov 2024 02:24:31 -0500 Subject: [PATCH 4/9] Prevent unregistered clubs from selling tickets, display events from clubs which require reapproval --- backend/clubs/models.py | 1 + backend/clubs/views.py | 18 ++++++++++++++++-- frontend/pages/events/[id].tsx | 21 +++++++++++++-------- frontend/pages/events/index.tsx | 6 +++--- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 6329cb15a..102d438bf 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) diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 653618cb3..187193f6d 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -1762,7 +1762,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, ) @@ -2463,7 +2463,19 @@ def add_to_cart(self, request, *args, **kwargs): type: boolean --- """ - event = self.get_object() + event = self.get_object().select_related("club") + # 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 event.club.approved and not event.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 @@ -5236,6 +5248,7 @@ 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()) @@ -5370,6 +5383,7 @@ 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), + event__club__archived=False, owner__isnull=True, event__ticket_drop_time__lt=timezone.now(), buyable=True, diff --git a/frontend/pages/events/[id].tsx b/frontend/pages/events/[id].tsx index a353f4427..8f69084c0 100644 --- a/frontend/pages/events/[id].tsx +++ b/frontend/pages/events/[id].tsx @@ -269,6 +269,8 @@ const EventPage: React.FC = ({ event.ticket_drop_time !== null && new Date(event.ticket_drop_time) > new Date() + const historicallyApproved = club.approved !== true && !club.is_ghost + return ( <> = ({ disabled={ totalAvailableTickets === 0 || endTime < DateTime.now() || - notDroppedYet + notDroppedYet || + historicallyApproved } onClick={() => setShowTicketModal(true)} > - {endTime < DateTime.now() - ? 'Event Ended' - : notDroppedYet - ? 'Tickets Not Available Yet' - : totalAvailableTickets === 0 - ? 'Sold Out' - : 'Get Tickets'} + {historicallyApproved + ? 'Club Not Approved' + : endTime < DateTime.now() + ? 'Event Ended' + : notDroppedYet + ? 'Tickets Not Available Yet' + : totalAvailableTickets === 0 + ? 'Sold Out' + : 'Get Tickets'} )} diff --git a/frontend/pages/events/index.tsx b/frontend/pages/events/index.tsx index b54c10150..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, ), From ce1648c3e69967b059d029f5d16a05b1df8d0c66 Mon Sep 17 00:00:00 2001 From: Julian Weng Date: Sat, 23 Nov 2024 02:30:59 -0500 Subject: [PATCH 5/9] Add styling for link to drop time field --- frontend/components/ClubEditPage/TicketsModal.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/components/ClubEditPage/TicketsModal.tsx b/frontend/components/ClubEditPage/TicketsModal.tsx index 52fb6cb78..519da4462 100644 --- a/frontend/components/ClubEditPage/TicketsModal.tsx +++ b/frontend/components/ClubEditPage/TicketsModal.tsx @@ -362,15 +362,14 @@ const TicketsModal = ({

You can optionally add a time in which after when tickets will be available{' '} - + . Please note that this cannot be changed once any tickets are sold.

From ea6bc1a55335e18f02a6f5bacb01819100b334e5 Mon Sep 17 00:00:00 2001 From: Julian Weng Date: Sat, 23 Nov 2024 02:39:39 -0500 Subject: [PATCH 6/9] Fix types --- frontend/components/EventPage/EventCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/components/EventPage/EventCard.tsx b/frontend/components/EventPage/EventCard.tsx index b3db45da4..379de1b7f 100644 --- a/frontend/components/EventPage/EventCard.tsx +++ b/frontend/components/EventPage/EventCard.tsx @@ -40,7 +40,7 @@ const TicketsPill = styled.div` const clipLink = (s: string) => (s.length > 32 ? `${s.slice(0, 35)}...` : s) const EventCard = (props: { - event: ClubEvent & { clubPublic: boolean } + event: ClubEvent & { clubPublic?: boolean } }): ReactElement => { const { image_url: imageUrl, @@ -80,7 +80,7 @@ const EventCard = (props: { )} {clubName} {name} - {!clubPublic &&

This event is not shown to the public.

} + {clubPublic === false &&

This event is not shown to the public.

} {ticketed && ( Date: Sat, 23 Nov 2024 03:06:32 -0500 Subject: [PATCH 7/9] Fix ticketing tests --- backend/clubs/views.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 187193f6d..a00dfcf7f 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -2463,11 +2463,17 @@ def add_to_cart(self, request, *args, **kwargs): type: boolean --- """ - event = self.get_object().select_related("club") + 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 event.club.approved and not event.club.ghost: + 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 @@ -5251,7 +5257,10 @@ def cart(self, request, *args, **kwargs): | 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__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 @@ -5288,12 +5297,13 @@ def cart(self, request, *args, **kwargs): continue available_tickets = Ticket.objects.filter( + Q(event__ticket_drop_time__lt=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 owner__isnull=True, holder__isnull=True, - event__ticket_drop_time__lt=timezone.now(), ).exclude(id__in=tickets_in_cart)[: ticket_class["count"]] num_short = ticket_class["count"] - available_tickets.count() @@ -5383,9 +5393,10 @@ 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__lt=timezone.now()) + | Q(event__ticket_drop_time__isnull=True), event__club__archived=False, owner__isnull=True, - event__ticket_drop_time__lt=timezone.now(), buyable=True, ) From 491b5df42df82a7a8e79d7bcee628953bd129580 Mon Sep 17 00:00:00 2001 From: Julian Weng Date: Fri, 29 Nov 2024 18:10:16 -0500 Subject: [PATCH 8/9] Add test coverage for changes --- backend/tests/clubs/test_ticketing.py | 101 ++++++++++++++++++++++++++ backend/tests/clubs/test_views.py | 8 ++ 2 files changed, 109 insertions(+) diff --git a/backend/tests/clubs/test_ticketing.py b/backend/tests/clubs/test_ticketing.py index 128b12890..323b14016 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 = { @@ -590,6 +647,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") diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index b4931d737..65c289292 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -227,6 +227,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. From 8d0adca024e1d43878160081785f70ff4604b2f5 Mon Sep 17 00:00:00 2001 From: Julian Weng Date: Fri, 29 Nov 2024 18:37:58 -0500 Subject: [PATCH 9/9] Add unapproved club warning for event page --- frontend/pages/events/[id].tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/pages/events/[id].tsx b/frontend/pages/events/[id].tsx index 8f69084c0..2fe811d61 100644 --- a/frontend/pages/events/[id].tsx +++ b/frontend/pages/events/[id].tsx @@ -8,6 +8,7 @@ import styled from 'styled-components' import { BaseLayout } from '~/components/BaseLayout' import { + Icon, Metadata, Modal, StrongText, @@ -32,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' @@ -195,6 +197,7 @@ const GetTicketItem: React.FC = ({ borderBottom: '1px solid #e0e0e0', borderTop: '1px solid #e0e0e0', }} + id={ticket.type} >
= ({ + {!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}