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/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..a00dfcf7f 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, ) @@ -2464,6 +2464,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 +2700,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 +3208,60 @@ 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 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( @@ -5203,8 +5254,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 @@ -5241,6 +5297,8 @@ 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 @@ -5335,6 +5393,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__lt=timezone.now()) + | Q(event__ticket_drop_time__isnull=True), + event__club__archived=False, owner__isnull=True, buyable=True, ) diff --git a/backend/tests/clubs/test_ticketing.py b/backend/tests/clubs/test_ticketing.py index f1dbce0d6..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 = { @@ -430,21 +487,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") @@ -605,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. 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/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..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) => ( (