Skip to content

Commit

Permalink
Add UI support for ticket drop times, add public visibility of ticket…
Browse files Browse the repository at this point in the history
…ing 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"
  • Loading branch information
julianweng committed Nov 23, 2024
1 parent b280dc0 commit 1cdb67e
Show file tree
Hide file tree
Showing 14 changed files with 271 additions and 141 deletions.
9 changes: 9 additions & 0 deletions backend/clubs/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -507,6 +515,7 @@ class Meta:
"location",
"name",
"start_time",
"ticket_drop_time",
"ticketed",
"type",
"url",
Expand Down
62 changes: 47 additions & 15 deletions backend/clubs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 0 additions & 15 deletions backend/tests/clubs/test_ticketing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
216 changes: 123 additions & 93 deletions frontend/components/ClubEditPage/EventsCard.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -311,51 +313,6 @@ const eventTableFields = [
},
]

const eventFields = (
<>
<Field
name="name"
as={TextField}
required
helpText="Provide a descriptive name for the planned event."
/>
<Field
name="url"
as={TextField}
type="url"
helpText="Provide a videoconference link to join the event (Zoom, Google Meet, etc)."
label="Meeting Link"
/>
<Field name="image" as={FileField} isImage />
<Field
name="type"
as={SelectField}
required
choices={EVENT_TYPES}
serialize={({ value }) => value}
isMulti={false}
valueDeserialize={(val) => EVENT_TYPES.find((x) => x.value === val)}
/>
<Field
name="start_time"
required
placeholder="Provide a start time for the event"
as={DateTimeField}
/>
<Field
name="end_time"
required
placeholder="Provide a end time for the event"
as={DateTimeField}
/>
<Field
name="description"
placeholder="Type your event description here!"
as={RichTextField}
/>
</>
)

const EventPreviewContainer = styled.div`
display: flex;
justify-content: space-around;
Expand Down Expand Up @@ -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 (
<CreateContainer>
<div className="is-pulled-left">
<Text style={{ padding: 0, margin: 0 }}>
{event.ticketed ? 'Add' : 'Create'} ticket offerings for this event
</Text>
</div>
<div className="is-pulled-right">
<button
onClick={() => {
showModal()
}}
disabled={!event.name}
className="button is-primary"
>
Create
</button>
</div>
{show && (
<Modal
width="50vw"
show={show}
closeModal={hideModal}
marginBottom={false}
>
<TicketsModal
club={club}
event={event}
onSuccessfulSubmit={hideModal}
/>
</Modal>
)}
</CreateContainer>
)
interface CreateTicketsProps {
event: ClubEvent
club: Club
}

const CreateTickets = forwardRef<HTMLDivElement, CreateTicketsProps>(
({ event, club }, ticketDroptimeRef) => {
const [show, setShow] = useState(false)

const showModal = () => setShow(true)
const hideModal = () => setShow(false)

return (
<CreateContainer>
<div className="is-pulled-left">
<Text style={{ padding: 0, margin: 0 }}>
{event.ticketed ? 'Add' : 'Create'} ticket offerings for this event
</Text>
</div>
<div className="is-pulled-right">
<button
onClick={showModal}
disabled={!event.name}
className="button is-primary"
>
Create
</button>
</div>
{show && (
<Modal
width="50vw"
show={show}
closeModal={hideModal}
marginBottom={false}
>
<TicketsModal
club={club}
event={event}
onSuccessfulSubmit={hideModal}
closeModal={() => {
hideModal()
if (ticketDroptimeRef && 'current' in ticketDroptimeRef) {
const divRef = ticketDroptimeRef as RefObject<HTMLDivElement>
ticketDroptimeRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
divRef.current?.querySelector('input')?.focus()
}
}}
/>
</Modal>
)}
</CreateContainer>
)
},
)

export default function EventsCard({ club }: EventsCardProps): ReactElement {
const [deviceContents, setDeviceContents] = useState<any>({})
const eventDetailsRef = useRef<HTMLDivElement>(null)
const ticketDroptimeRef = useRef<HTMLDivElement>(null)

const eventFields = (
<>
<Field
name="name"
as={TextField}
required
helpText="Provide a descriptive name for the planned event."
/>
<Field
name="url"
as={TextField}
type="url"
helpText="Provide a videoconference link to join the event (Zoom, Google Meet, etc)."
label="Meeting Link"
/>
<Field name="image" as={FileField} isImage />
<Field
name="type"
as={SelectField}
required
choices={EVENT_TYPES}
serialize={({ value }) => value}
isMulti={false}
valueDeserialize={(val) => EVENT_TYPES.find((x) => x.value === val)}
/>
<Field
name="start_time"
required
placeholder="Provide a start time for the event"
as={DateTimeField}
/>
<Field
name="end_time"
required
placeholder="Provide a end time for the event"
as={DateTimeField}
/>
<div ref={ticketDroptimeRef} className="mb-3">
{/* TODO: modify field components to support ref props after forwardRef() is depreciated in React 19 */}
<Field
name="ticket_drop_time"
id="ticket_drop_time"
placeholder="Provide a time when event tickets will first be available (not changeable after first ticket sold)"
as={DateTimeField}
/>
</div>
<Field
name="description"
placeholder="Type your event description here!"
as={RichTextField}
/>
</>
)

const event = {
...deviceContents,
Expand All @@ -458,8 +487,9 @@ export default function EventsCard({ club }: EventsCardProps): ReactElement {
return (
<BaseCard title="Events">
<Text>
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.'}
</Text>
<ModelForm
baseUrl={`/clubs/${club.code}/events/`}
Expand All @@ -480,7 +510,7 @@ export default function EventsCard({ club }: EventsCardProps): ReactElement {
}}
/>
<Line />
<CreateTickets event={event} club={club} />
<CreateTickets event={event} club={club} ref={ticketDroptimeRef} />
<Line />
<div ref={eventDetailsRef}>
<EventPreview event={event} />
Expand Down
Loading

0 comments on commit 1cdb67e

Please sign in to comment.