conference-website/tickets/models.py

447 lines
16 KiB
Python

from typing import Dict, Any
import logging
import re
import uuid
from django import urls
from django.conf import settings
from django.contrib.auth.models import User
from django.db import models
from tickets.saleor_client import get_order_by_token, get_product_variant
import conference_main.model_mixins as mixins
import conference_main.util as util
import tickets.stripe_utils
import tickets.tasks as tasks
logger = logging.getLogger(__name__)
def _get_upload_to_for_invoice(instance, filename):
return f'invoice/{str(uuid.uuid4())}/{filename}'
class Ticket(mixins.RecordModificationMixin, mixins.CreatedUpdatedMixin, models.Model):
record_modification_fields = {'is_paid', 'is_free', 'quantity', 'invoice_url'}
edition = models.ForeignKey('conference_main.Edition', on_delete=models.CASCADE)
product = models.ForeignKey('tickets.Product', blank=True, null=True, on_delete=models.CASCADE)
is_free = models.BooleanField(default=False, null=False, blank=False)
is_paid = models.BooleanField(
default=False,
null=False,
blank=False,
help_text=(
'Indicates if the order has been paid for already or not. '
' Must be changed manually for order paid via bank transfers.'
),
)
token = models.UUIDField(
unique=True,
blank=False,
default=uuid.uuid4,
editable=False,
null=False,
help_text='Internal token of the ticket. Used for generating ticket claim URL.',
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='managed_tickets',
help_text='Whomever originally bought the ticket(s).',
)
attendees = models.ManyToManyField(
User,
through='TicketClaim',
related_name='tickets',
help_text=(
'Whomever claimed the available tickets. '
'Total count of these must not exceed quantity.'
),
)
sku = models.CharField(
max_length=255,
null=False,
blank=False,
help_text=(
'SKU of the ticket variant in this ticket order '
'(e.g. one day ticket "BC221DAY" for one day entry). '
'For simplicity, a single order can only contain one ticket variant.<br>'
'This value is displayed to the attendee in My Tickets and emails.'
),
)
order_number = models.CharField(
blank=True,
max_length=64,
null=True,
help_text=(
'Customer-facing order number in the payment system.<br>'
'If available, this value is displayed to the attendee in My Tickets and emails.'
),
)
quantity = models.PositiveSmallIntegerField(
default=1,
blank=False,
null=False,
help_text='Total number of tickets bought and available to be claimed.',
)
order_token = models.UUIDField(
unique=True,
blank=True,
editable=False,
null=True,
help_text=(
'Internal token of the order in the payment system. '
'Used for retrieving ticket order info shown in My Tickets.'
),
)
order_id = models.CharField(
unique=True,
blank=True,
max_length=64,
null=True,
help_text=(
'Internal ID of the order in the payment system. '
'Used for generating an admin link to the order in the payment system.'
),
)
invoice_url = models.URLField(
unique=True,
blank=True,
null=True,
help_text='URL of the invoice, if one was automatically generated by the payment system.',
)
invoice = models.FileField(
upload_to=_get_upload_to_for_invoice,
blank=True,
null=True,
help_text='Invoice PDF in case it had to be created manually.',
)
checkout_data = models.JSONField(
blank=True,
null=True,
help_text=(
'Contains order or checkout data as returned by the payment system. '
'Used for displaying ticket order info, can change in case of refund and bank transfer.'
),
)
def __str__(self) -> str:
paid = self.is_free and '🆓' or self.is_paid and '(paid)' or '(pending payment)'
order = f', order #{self.order_number} ' if self.order_number else ''
return f'Ticket {self.sku}{order}{paid}'
class Meta:
indexes = [
models.Index(fields=['token']),
models.Index(fields=['edition', 'is_free']),
]
ordering = ['-created_at']
def get_absolute_url(self):
return urls.reverse('tickets:detail', kwargs={'ticket_token': self.token})
@property
def is_saleor(self) -> bool:
"""If it has a token and doesn't look like Stripe's, this must've been paid via Saleor."""
return self.order_token and not self.is_stripe
@property
def is_stripe(self) -> bool:
"""If order ID looks like Payment Intent ID, this must've been paid via Stripe."""
return self.order_id and self.order_id.startswith('pi_')
def get_admin_order_url(self) -> str:
if not self.order_id:
return ''
if self.is_stripe:
prefix = 'test' in settings.STRIPE_API_KEY and 'test/' or ''
return f'https://dashboard.stripe.com/{prefix}payments/{self.order_id}'
# Saleor shop.blender.org is no longer live
return ''
@property
def unclaimed(self):
return self.quantity - self.attendees.count()
def is_claimed_by(self, user_id: int) -> bool:
return self.attendees.filter(pk=user_id).exists()
@property
def invoice_download_url(self) -> str:
"""Link to invoice PDF."""
if self.invoice:
return self.invoice.url
return self.invoice_url or ''
@property
def claim_url(self) -> str:
"""Link that attendees can use to claim a ticket.
Only usable after ticket order is paid.
"""
if not self.unclaimed:
return ''
return urls.reverse('tickets:claim', kwargs={'ticket_token': self.token})
def clean(self):
super().clean()
# TODO(anna): either is_free or order_* fields must be set.
def save(self, *args: Any, **kwargs: Any) -> None:
"""Record state changes and act on some of them."""
self.full_clean()
was_modified, old_state = self.pre_save_record(*args, **kwargs)
super().save(*args, **kwargs)
if 'is_paid' in old_state and old_state['is_paid'] != self.is_paid:
if self.is_paid:
tasks.send_mail_tickets_paid(ticket_id=self.pk)
for claim in self.claims.all():
if not claim.confirmation_sent_at:
tasks.send_mail_confirm_tickets(ticket_id=self.pk, user_id=claim.user_id)
# TODO don't send general info yet, start when text is confirmed
# Also send out general info
# if not claim.general_info_sent_at:
# tasks.send_mail_general_info(ticket_id=self.pk, user_id=claim.user_id)
for field in self.record_modification_fields:
if field in old_state and old_state[field] != getattr(self, field):
message = f'"{field}" changed from "{old_state[field]}" to "{getattr(self, field)}"'
util.attach_log_entry(self, message)
@property
def order(self) -> Dict[str, Any]:
"""Retrieve order from payment system or construct a fake one based on ticket's SKU."""
if self.is_stripe:
# Use saved checkout session data, if available
if self.checkout_data:
return tickets.stripe_utils._construct_checkout_session(self.checkout_data)
# Otherwise fetch checkout session data from Stripe API
checkout_data = tickets.stripe_utils.retrieve_order(self.order_id, self.sku)
return checkout_data
if self.is_saleor:
if not self.checkout_data:
# TODO,anna: remove this after all Saleor's checkout_data is saved
self.checkout_data = get_order_by_token(self.order_token)
self.save(update_fields=['checkout_data'])
return self.checkout_data
if self.is_free:
return {
'lines': [
{
'variant': {
'name': '-',
'product': {
'name': self.sku,
},
},
}
]
}
# Neither order token nor order ID is set, need to return a stub product
# (e.g. to display bank transfer tickets with are handled manually)
if settings.ACTIVE_PAYMENT_BACKEND == 'saleor':
return {'lines': [{'variant': get_product_variant('default-channel', self.sku)}]}
if settings.ACTIVE_PAYMENT_BACKEND == 'stripe':
return 'TODO STRIPE STUB PRODUCT'
@property
def refund_status(self) -> str:
if self.is_stripe:
order = self.order
charge = order.payment_intent.latest_charge
if not charge:
return ''
amount_refunded = charge.amount_refunded
amount = charge.amount
if amount_refunded > 0:
if amount_refunded == amount:
return 'full'
return 'partial'
return ''
def get_tax_details(self, session) -> dict:
"""Return tax breakdown for given Stripe's checkout session.
Session is passed here because it might come from the API, or from saved `.checkout_data`.
"""
if not self.product or not self.product.taxes:
return {}
# Will contain tax per line item and tax rate, in the same order as line items
# (for use in the invoice template)
per_line_item = []
# Will contain summed tax totals per tax rate
tax_totals = [{**tax, 'amount_total': 0} for tax in self.product.taxes]
for item in session.line_items:
for i in range(len(self.product.taxes)):
tax = self.product.taxes[i]
item_amount_total = tax['amount'] * item.quantity
per_line_item.append({**tax, 'amount_total': item_amount_total})
tax_totals[i]['amount_total'] += item_amount_total
return {'per_line_item': per_line_item, 'tax_totals': tax_totals}
@property
def should_send_mail_bank_transfer_required(self) -> bool:
if not self.is_paid and not self.is_free and self.order_id:
if not self.is_stripe:
return True
order = self.order
# Only send instructions for a Stripe if instructions were provided by Stripe.
# If instructions weren't provided, it might be another kind of delayed payment.
return bool(
order.payment_intent.next_action
and order.payment_intent.next_action.get('display_bank_transfer_instructions', None)
)
return False
def process_new_ticket(self):
"""Send emails, auto-claim newly created ticket, if necessary and so on.
Must be explicitly called after new ticket is created,
otherwise --- no emails, no claiming, no updated profile.
Signals aren't used for this because when Stripe checkout creates a new ticket record,
it doesn't yet have all required fields when `post_save` signal fires
(e.g. `order_number` requires non-null `Ticket.pk` so it's `None` in first `post_save`).
"""
# Looks like bank transfer was selected, instructions should be sent.
if self.should_send_mail_bank_transfer_required:
tickets.tasks.send_mail_bank_transfer_required(ticket_id=self.pk)
# If order is paid, generate the invoice immediately
if self.is_paid and not self.is_free and self.order_id:
tasks.generate_invoice(ticket_id=self.pk)
# Claim the ticket immediately if only one was bought and user doesn't have other tickets.
self.claim_if_paid_for_one()
# Confirm paid tickets, including the info about claim URL, if more than one was bought.
if self.is_paid and self.unclaimed:
tasks.send_mail_tickets_paid(ticket_id=self.pk)
# In case profile is not filled in, use billing address to update it.
tasks.update_profile_from_ticket_order(ticket_id=self.pk)
def claim_if_paid_for_one(self):
"""Claim the ticket if only one was bought and user doesn't have other tickets."""
user = self.user
if (
self.is_paid
and self.quantity == 1
and not self.refund_status
and not user.tickets.filter(edition=self.edition).exists()
):
self.attendees.add(user)
logger.info(
'Ticket pk=%s is now fully paid and claimed by user pk=%s', self.pk, user.pk
)
class TicketClaim(mixins.CreatedUpdatedMixin, models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
ticket = models.ForeignKey('Ticket', on_delete=models.CASCADE, related_name='claims')
confirmation_sent_at = models.DateTimeField(
null=True,
blank=True,
help_text='Date when ticket confirmation email was sent to this attendee.',
)
general_info_sent_at = models.DateTimeField(
null=True,
blank=True,
help_text='Date when general info email was sent to this attendee.',
)
class Meta:
unique_together = ('user', 'ticket')
verbose_name_plural = 'Attendees'
@property
def full_name(self):
return self.user.profile.full_name
@property
def title(self):
return self.user.profile.title
@property
def company(self):
return self.user.profile.company
@property
def country(self):
return self.user.profile.country
@property
def email(self):
return self.user.email
class Product(mixins.CreatedUpdatedMixin, models.Model):
slug = models.CharField(
max_length=32,
unique=True,
help_text=(
'Part of URL initiating ticket checkout and copied to'
' `Ticket.sku` field when a ticket is created from this product.'
' E.g "BC24", "BC241DAY", etc.'
),
)
name = models.CharField(
max_length=255,
help_text=(
'Shown in emails and ticket detail pages for tickets linked to this product.'
' E.g. "BCON24 Full Ticket", "BCON24 One Day Ticket", etc.'
),
)
is_featured = models.BooleanField(
default=False, help_text='Show this ticket in the product table.'
)
price = models.DecimalField(max_digits=5, decimal_places=2)
taxes = models.JSONField(
blank=True,
null=True,
help_text=(
'Breakdown of taxes: only used for display, not for calculating paid totals. '
"Total price is set on the payment backend's product, "
'and assumed to always include all taxes.'
),
)
currency = models.CharField(max_length=3, help_text='Currency such as EUR, USD, etc.')
description = models.TextField(help_text='Description of the product, can be HTML.')
url = models.URLField(help_text='Product link to Stripe, or similar.')
custom_fields = models.JSONField(
blank=True,
null=True,
help_text=(
'Additional fields filled during checkout at Stripe, '
'e.g. a chosen day for a one-day ticket. '
'See https://docs.stripe.com/api/checkout/sessions/create'
'create_checkout_session-custom_fields'
),
)
def get_absolute_url(self) -> str:
return urls.reverse('tickets:stripe-buy', kwargs={'slug': self.slug})
@property
def price_id(self) -> str:
price_id_match = re.match(r'.*/(price_\w+)', self.url)
if price_id_match:
return price_id_match.groups()[0]
return ''
@property
def price_label(self) -> str:
label = f'{self.currency} {self.price}'
if self.currency == 'USD':
label = f'${label}'
return label
def __str__(self) -> str:
return f'{self.name} - {self.price_label}'