conference-website/tickets/views/tickets.py
Anna Sirota ba96ba9937 Support delayed Stripe payments; Invoice PDFs
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
2024-05-27 22:32:15 +02:00

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,
)