Anna Sirota
ba96ba9937
The following has changed: * Products have their own /tickets/book/../ URLs * Products only show up in product table if featured * Products should link to Stripe prices instead of payment links * Products have `taxes` field, which is a list of tax rules: * this field is used to display VAT lines in invoices * Tickets store Stripe checkout session data (to too many API calls) * Ticket page shows Stripe's product image, if available * Stripe webhook endpoint created, expecting the following events: * `checkout.session.completed` * `checkout.session.async_payment_succeeded` * `payment_intent.requires_action` * `charge.refunded` * Full refund will un-claim everyone who claimed the affected ticket * Invoice PDFs: * with Stripe's bank transfer instructions * refund date and amount * CSV report supports Stripe-paid tickets * CSV has new columns: VAT and refund
187 lines
6.4 KiB
Python
187 lines
6.4 KiB
Python
import logging
|
|
|
|
from django import urls
|
|
from django.contrib import messages
|
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
from django.contrib.sites.shortcuts import get_current_site
|
|
from django.db.models import Sum
|
|
from django.db.models.query_utils import Q
|
|
from django.http import HttpResponseForbidden
|
|
from django.shortcuts import render
|
|
from django.utils.safestring import mark_safe
|
|
from django.views.generic import FormView, ListView
|
|
from django.views.generic.detail import DetailView, SingleObjectMixin
|
|
|
|
from tickets.views.mixins import TicketClaimedOrBoughtRequiredMixin
|
|
import tickets.forms
|
|
import tickets.models
|
|
import tickets.queries
|
|
|
|
from conference_main.models import Edition
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TicketDetailView(LoginRequiredMixin, TicketClaimedOrBoughtRequiredMixin, DetailView):
|
|
model = tickets.models.Ticket
|
|
slug_field = 'token'
|
|
slug_url_kwarg = 'ticket_token'
|
|
context_object_name = 'ticket'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
"""Fetch order via API and add it to the context."""
|
|
context = super().get_context_data(**kwargs)
|
|
context['order'] = self.object.order
|
|
site_settings = get_current_site(self.request).settings
|
|
context['edition'] = site_settings.current_edition
|
|
context['attendee'] = self.attendee
|
|
return context
|
|
|
|
|
|
class TicketsListView(LoginRequiredMixin, ListView):
|
|
paginate_by = 9999
|
|
model = tickets.models.Ticket
|
|
|
|
def get_queryset(self):
|
|
"""Filter tickets by current user."""
|
|
queryset = super().get_queryset()
|
|
return queryset.filter(
|
|
Q(user_id=self.request.user.pk) | Q(attendees__id=self.request.user.pk)
|
|
).distinct()
|
|
|
|
|
|
class TicketClaimView(LoginRequiredMixin, SingleObjectMixin, FormView):
|
|
model = tickets.models.Ticket
|
|
slug_field = 'token'
|
|
slug_url_kwarg = 'ticket_token'
|
|
form_class = tickets.forms.BadgeForm
|
|
template_name = 'tickets/claim.html'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
"""Fetch order via API and add it to the context."""
|
|
self.object = self.get_object()
|
|
context = super().get_context_data(**kwargs)
|
|
context['ticket'] = self.object
|
|
context['order'] = self.object.order
|
|
site_settings = get_current_site(self.request).settings
|
|
context['edition'] = site_settings.current_edition
|
|
return context
|
|
|
|
def get_form_kwargs(self):
|
|
"""Use current user's Profile as the instance for the form.."""
|
|
kwargs = super().get_form_kwargs()
|
|
kwargs.update({"instance": self.request.user.profile})
|
|
return kwargs
|
|
|
|
def form_valid(self, form):
|
|
"""Add current user to attendees."""
|
|
ticket = self.get_object()
|
|
|
|
if not ticket.is_paid and not ticket.is_free:
|
|
form.add_error(
|
|
'__all__',
|
|
['Ticket cannot be claimed: payment pending.'],
|
|
)
|
|
return super().form_invalid(form)
|
|
|
|
if ticket.refund_status == 'full':
|
|
form.add_error('__all__', ['Ticket cannot be claimed: refunded.'])
|
|
return super().form_invalid(form)
|
|
|
|
if ticket.unclaimed < 1:
|
|
form.add_error(
|
|
'__all__',
|
|
['All available tickets had already been claimed.'],
|
|
)
|
|
return super().form_invalid(form)
|
|
|
|
if tickets.queries.is_attending_edition(self.request.user, ticket.edition):
|
|
url = urls.reverse('tickets:list')
|
|
form.add_error(
|
|
'__all__',
|
|
[
|
|
mark_safe(
|
|
f'Your already have a ticket for {ticket.edition}. '
|
|
f'View your tickets in <a href="{url}">My Tickets</a>.'
|
|
)
|
|
],
|
|
)
|
|
return super().form_invalid(form)
|
|
|
|
response = super().form_valid(form)
|
|
# Save changes to the Profile
|
|
form.save()
|
|
ticket.attendees.add(self.request.user)
|
|
messages.add_message(self.request, messages.SUCCESS, 'Ticket claimed!')
|
|
return response
|
|
|
|
@property
|
|
def success_url(self):
|
|
"""Redirect to the ticket page."""
|
|
ticket_token = self.request.resolver_match.kwargs['ticket_token']
|
|
return urls.reverse('tickets:detail', kwargs={'ticket_token': ticket_token})
|
|
|
|
|
|
def tickets_stats(request, edition_path):
|
|
"""Simple view to display tickets allocated."""
|
|
if not request.user.is_staff:
|
|
return HttpResponseForbidden()
|
|
edition: Edition = Edition.objects.get(path=edition_path)
|
|
quantity_total = tickets.models.Ticket.objects.filter(edition=edition, is_paid=True).aggregate(
|
|
Sum('quantity')
|
|
)
|
|
quantity_total = quantity_total['quantity__sum'] or 0
|
|
|
|
quantity_free = tickets.models.Ticket.objects.filter(edition=edition, is_free=True).aggregate(
|
|
Sum('quantity')
|
|
)
|
|
quantity_free = quantity_free['quantity__sum'] or 0
|
|
|
|
quantity_free_claimed = tickets.models.TicketClaim.objects.filter(
|
|
ticket__edition=edition, ticket__is_free=True
|
|
).count()
|
|
quantity_free_unclaimed = quantity_free - quantity_free_claimed
|
|
|
|
quantity_claimed = tickets.models.TicketClaim.objects.filter(
|
|
ticket__edition=edition, ticket__is_paid=True
|
|
).count()
|
|
quantity_unclaimed = quantity_total - quantity_claimed - quantity_free_unclaimed
|
|
|
|
quantity_day_series = (
|
|
tickets.models.Ticket.objects.filter(edition=edition, is_paid=True)
|
|
.values('created_at__date')
|
|
.order_by('created_at__date')
|
|
.annotate(sum=Sum('quantity'))
|
|
)
|
|
|
|
cum_sum = 0
|
|
for q in quantity_day_series:
|
|
cum_sum += q['sum']
|
|
q['cum_sum'] = cum_sum
|
|
|
|
timeseries_sum = [
|
|
{'x': q['created_at__date'].strftime('%Y-%m-%d'), 'y': q['sum']}
|
|
for q in quantity_day_series
|
|
]
|
|
|
|
timeseries_cum_sum = [
|
|
{'x': q['created_at__date'].strftime('%Y-%m-%d'), 'y': q['cum_sum']}
|
|
for q in quantity_day_series
|
|
]
|
|
|
|
context = {
|
|
'quantity_total': quantity_total,
|
|
'quantity_free': quantity_free,
|
|
'quantity_claimed': quantity_claimed,
|
|
'quantity_free_claimed': quantity_free_claimed,
|
|
'quantity_free_unclaimed': quantity_free_unclaimed,
|
|
'quantity_unclaimed': quantity_unclaimed,
|
|
'quantity_day_series': timeseries_sum,
|
|
'quantity_cum_day_series': timeseries_cum_sum,
|
|
}
|
|
return render(
|
|
request,
|
|
'tickets/stats.html',
|
|
context,
|
|
)
|