Template
Add shop, POS sync, event ticketing, and shipping catalog apps.
Clients can run in-house retail without Shopify while keeping the same Docker/Django/Postgres stack and data-ownership promise. Closes #5.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from events.models import Event, Ticket, TicketOrder
|
||||
|
||||
|
||||
@admin.register(Event)
|
||||
class EventAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "starts_at", "capacity", "price", "is_published")
|
||||
list_filter = ("is_published",)
|
||||
search_fields = ("title", "venue")
|
||||
prepopulated_fields = {"slug": ("title",)}
|
||||
|
||||
|
||||
@admin.register(TicketOrder)
|
||||
class TicketOrderAdmin(admin.ModelAdmin):
|
||||
list_display = ("number", "event", "email", "quantity", "status")
|
||||
list_filter = ("status",)
|
||||
search_fields = ("number", "email")
|
||||
|
||||
|
||||
@admin.register(Ticket)
|
||||
class TicketAdmin(admin.ModelAdmin):
|
||||
list_display = ("code", "event", "order")
|
||||
search_fields = ("code",)
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class EventsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "events"
|
||||
verbose_name = "Event ticketing"
|
||||
|
||||
def ready(self):
|
||||
from events import hooks
|
||||
|
||||
hooks.register()
|
||||
@@ -0,0 +1,33 @@
|
||||
from core.registry import (
|
||||
register_dashboard_collector,
|
||||
register_feature,
|
||||
register_portal_nav,
|
||||
register_public_nav,
|
||||
)
|
||||
|
||||
|
||||
def register() -> None:
|
||||
register_feature("events")
|
||||
register_public_nav(
|
||||
section="events", label="Events", url_name="events:list", order=35
|
||||
)
|
||||
register_portal_nav(
|
||||
section="events",
|
||||
label="Events",
|
||||
url_name="events_portal:event_list",
|
||||
group="Retail",
|
||||
order=30,
|
||||
)
|
||||
register_dashboard_collector(_dashboard)
|
||||
|
||||
|
||||
def _dashboard(request) -> dict:
|
||||
from django.utils import timezone
|
||||
|
||||
from events.models import Event
|
||||
|
||||
return {
|
||||
"upcoming_events": Event.objects.filter(
|
||||
is_published=True, starts_at__gte=timezone.now()
|
||||
).count()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# Generated by Django 6.1 on 2026-09-06 11:17
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Event',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('slug', models.SlugField(max_length=220, unique=True)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('starts_at', models.DateTimeField()),
|
||||
('venue', models.CharField(blank=True, max_length=200)),
|
||||
('capacity', models.PositiveIntegerField(default=0)),
|
||||
('price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10)),
|
||||
('currency', models.CharField(default='usd', max_length=8)),
|
||||
('is_published', models.BooleanField(default=False)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['starts_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TicketOrder',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('number', models.CharField(max_length=32, unique=True)),
|
||||
('email', models.EmailField(max_length=254)),
|
||||
('customer_name', models.CharField(blank=True, max_length=200)),
|
||||
('quantity', models.PositiveIntegerField(default=1)),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('currency', models.CharField(default='usd', max_length=8)),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('open', 'Open'), ('paid', 'Paid'), ('cancelled', 'Cancelled')], default='draft', max_length=16)),
|
||||
('stripe_checkout_session_id', models.CharField(blank=True, max_length=255)),
|
||||
('hosted_checkout_url', models.URLField(blank=True)),
|
||||
('paid_at', models.DateTimeField(blank=True, null=True)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ticket_orders', to='events.event')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Ticket',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('code', models.CharField(max_length=16, unique=True)),
|
||||
('attendee_name', models.CharField(blank=True, max_length=200)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tickets', to='events.event')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tickets', to='events.ticketorder')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,111 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class Event(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
title = models.CharField(max_length=200)
|
||||
slug = models.SlugField(max_length=220, unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
starts_at = models.DateTimeField()
|
||||
venue = models.CharField(max_length=200, blank=True)
|
||||
capacity = models.PositiveIntegerField(default=0)
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0"))
|
||||
currency = models.CharField(max_length=8, default="usd")
|
||||
is_published = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ["starts_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.title
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("events:detail", kwargs={"slug": self.slug})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
base = slugify(self.title)[:200] or "event"
|
||||
slug = base
|
||||
n = 2
|
||||
while Event.objects.filter(slug=slug).exclude(pk=self.pk).exists():
|
||||
slug = f"{base}-{n}"
|
||||
n += 1
|
||||
self.slug = slug
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def tickets_held(self, *, exclude_order_id=None) -> int:
|
||||
qs = self.ticket_orders.filter(
|
||||
status__in=[
|
||||
TicketOrder.Status.DRAFT,
|
||||
TicketOrder.Status.OPEN,
|
||||
TicketOrder.Status.PAID,
|
||||
]
|
||||
)
|
||||
if exclude_order_id:
|
||||
qs = qs.exclude(pk=exclude_order_id)
|
||||
return qs.aggregate(total=models.Sum("quantity"))["total"] or 0
|
||||
|
||||
@property
|
||||
def tickets_sold(self) -> int:
|
||||
return self.tickets_held()
|
||||
|
||||
@property
|
||||
def seats_remaining(self) -> int | None:
|
||||
if not self.capacity:
|
||||
return None
|
||||
return max(self.capacity - self.tickets_sold, 0)
|
||||
|
||||
@property
|
||||
def is_upcoming(self) -> bool:
|
||||
return self.starts_at >= timezone.now()
|
||||
|
||||
|
||||
class TicketOrder(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = "draft", "Draft"
|
||||
OPEN = "open", "Open"
|
||||
PAID = "paid", "Paid"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
|
||||
number = models.CharField(max_length=32, unique=True)
|
||||
event = models.ForeignKey(
|
||||
Event, on_delete=models.PROTECT, related_name="ticket_orders"
|
||||
)
|
||||
email = models.EmailField()
|
||||
customer_name = models.CharField(max_length=200, blank=True)
|
||||
quantity = models.PositiveIntegerField(default=1)
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
currency = models.CharField(max_length=8, default="usd")
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.DRAFT
|
||||
)
|
||||
stripe_checkout_session_id = models.CharField(max_length=255, blank=True)
|
||||
hosted_checkout_url = models.URLField(blank=True)
|
||||
paid_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.number} · {self.event}"
|
||||
|
||||
|
||||
class Ticket(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
order = models.ForeignKey(
|
||||
TicketOrder, on_delete=models.CASCADE, related_name="tickets"
|
||||
)
|
||||
event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name="tickets")
|
||||
code = models.CharField(max_length=16, unique=True)
|
||||
attendee_name = models.CharField(max_length=200, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.code
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.urls import path
|
||||
|
||||
from events import views
|
||||
|
||||
app_name = "events_portal"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.portal_event_list, name="event_list"),
|
||||
path("new/", views.portal_event_edit, name="event_new"),
|
||||
path("<uuid:pk>/", views.portal_event_detail, name="event_detail"),
|
||||
path("<uuid:pk>/edit/", views.portal_event_edit, name="event_edit"),
|
||||
path("webhooks/stripe/", views.stripe_webhook, name="stripe_webhook"),
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.urls import path
|
||||
|
||||
from events import views
|
||||
|
||||
app_name = "events"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.event_list, name="list"),
|
||||
path("order/<uuid:pk>/success/", views.checkout_success, name="checkout_success"),
|
||||
path("order/<uuid:pk>/cancel/", views.checkout_cancel, name="checkout_cancel"),
|
||||
path("<slug:slug>/", views.event_detail, name="detail"),
|
||||
path("<slug:slug>/buy/", views.buy_tickets, name="buy"),
|
||||
]
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Event capacity, Stripe ticket checkout, and confirmation email."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from events.models import Event, Ticket, TicketOrder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventsError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _stripe():
|
||||
secret = (settings.STRIPE_SECRET_KEY or "").strip()
|
||||
if not secret:
|
||||
raise EventsError("STRIPE_SECRET_KEY is not configured")
|
||||
try:
|
||||
import stripe
|
||||
except ImportError as exc:
|
||||
raise EventsError("stripe package is not installed") from exc
|
||||
stripe.api_key = secret
|
||||
return stripe
|
||||
|
||||
|
||||
def next_ticket_order_number() -> str:
|
||||
today = date.today().strftime("%Y%m%d")
|
||||
prefix = f"TIX-{today}-"
|
||||
existing = TicketOrder.objects.filter(number__startswith=prefix).count()
|
||||
return f"{prefix}{existing + 1:03d}"
|
||||
|
||||
|
||||
def new_ticket_code() -> str:
|
||||
while True:
|
||||
code = secrets.token_hex(4).upper()
|
||||
if not Ticket.objects.filter(code=code).exists():
|
||||
return code
|
||||
|
||||
|
||||
def assert_capacity(
|
||||
event: Event, quantity: int, *, exclude_order_id=None
|
||||
) -> None:
|
||||
if quantity < 1:
|
||||
raise EventsError("Quantity must be at least 1.")
|
||||
if not event.capacity:
|
||||
return
|
||||
held = event.tickets_held(exclude_order_id=exclude_order_id)
|
||||
remaining = max(event.capacity - held, 0)
|
||||
if quantity > remaining:
|
||||
raise EventsError(f"Only {remaining} seats left for {event.title}.")
|
||||
|
||||
|
||||
def create_ticket_order(
|
||||
event: Event, *, email: str, customer_name: str = "", quantity: int = 1
|
||||
) -> TicketOrder:
|
||||
email = (email or "").strip()
|
||||
if not email:
|
||||
raise EventsError("Email is required.")
|
||||
if not event.is_published:
|
||||
raise EventsError("This event is not on sale.")
|
||||
assert_capacity(event, quantity)
|
||||
amount = event.price * quantity
|
||||
return TicketOrder.objects.create(
|
||||
number=next_ticket_order_number(),
|
||||
event=event,
|
||||
email=email,
|
||||
customer_name=(customer_name or "").strip(),
|
||||
quantity=quantity,
|
||||
amount=amount,
|
||||
currency=(event.currency or settings.STRIPE_CURRENCY or "usd").lower(),
|
||||
status=TicketOrder.Status.DRAFT,
|
||||
)
|
||||
|
||||
|
||||
def create_checkout_session(
|
||||
order: TicketOrder, *, success_url: str, cancel_url: str
|
||||
) -> str:
|
||||
stripe = _stripe()
|
||||
session = stripe.checkout.Session.create(
|
||||
mode="payment",
|
||||
customer_email=order.email or None,
|
||||
line_items=[
|
||||
{
|
||||
"quantity": order.quantity,
|
||||
"price_data": {
|
||||
"currency": (order.currency or "usd").lower(),
|
||||
"unit_amount": int(
|
||||
(order.event.price * Decimal("100")).quantize(Decimal("1"))
|
||||
),
|
||||
"product_data": {"name": order.event.title},
|
||||
},
|
||||
}
|
||||
],
|
||||
metadata={
|
||||
"ticket_order_id": str(order.pk),
|
||||
"ticket_order_number": order.number,
|
||||
},
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
)
|
||||
order.stripe_checkout_session_id = session.id
|
||||
order.hosted_checkout_url = session.url or ""
|
||||
order.status = TicketOrder.Status.OPEN
|
||||
order.save(
|
||||
update_fields=[
|
||||
"stripe_checkout_session_id",
|
||||
"hosted_checkout_url",
|
||||
"status",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
return session.url or ""
|
||||
|
||||
|
||||
def _issue_tickets(order: TicketOrder) -> list[Ticket]:
|
||||
created = []
|
||||
for _ in range(order.quantity):
|
||||
created.append(
|
||||
Ticket.objects.create(
|
||||
order=order,
|
||||
event=order.event,
|
||||
code=new_ticket_code(),
|
||||
attendee_name=order.customer_name,
|
||||
)
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
def mark_paid(order: TicketOrder, *, stripe_id: str = "") -> None:
|
||||
if order.status == TicketOrder.Status.PAID:
|
||||
return
|
||||
with transaction.atomic():
|
||||
locked = TicketOrder.objects.select_for_update().get(pk=order.pk)
|
||||
if locked.status == TicketOrder.Status.PAID:
|
||||
return
|
||||
assert_capacity(
|
||||
locked.event, locked.quantity, exclude_order_id=locked.pk
|
||||
)
|
||||
_issue_tickets(locked)
|
||||
locked.status = TicketOrder.Status.PAID
|
||||
locked.paid_at = timezone.now()
|
||||
locked.save(update_fields=["status", "paid_at", "updated_at"])
|
||||
order.refresh_from_db()
|
||||
try:
|
||||
send_ticket_email(order)
|
||||
except Exception:
|
||||
logger.exception("ticket email failed for %s", order.number)
|
||||
|
||||
|
||||
def send_ticket_email(order: TicketOrder) -> bool:
|
||||
to_email = (order.email or "").strip()
|
||||
if not to_email:
|
||||
raise EventsError("Order has no email address")
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
|
||||
codes = ", ".join(t.code for t in order.tickets.all())
|
||||
name = order.customer_name or "there"
|
||||
when = timezone.localtime(order.event.starts_at).strftime("%b %d, %Y %-I:%M %p")
|
||||
subject = f"Tickets for {order.event.title} — {settings.SITE_NAME}"
|
||||
text = (
|
||||
f"Hi {name},\n\n"
|
||||
f"Your tickets for {order.event.title} on {when}:\n"
|
||||
f"{codes}\n\nOrder {order.number}\n"
|
||||
)
|
||||
html = (
|
||||
f"<p>Hi {name},</p>"
|
||||
f"<p>Tickets for <strong>{order.event.title}</strong> on {when}:</p>"
|
||||
f"<p><strong>{codes}</strong></p>"
|
||||
f"<p>Order {order.number}</p>"
|
||||
)
|
||||
mail = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[to_email],
|
||||
)
|
||||
mail.attach_alternative(html, "text/html")
|
||||
mail.send(fail_silently=False)
|
||||
return True
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Tickets · {{ event.title }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<div class="container">
|
||||
<h1>Tickets · {{ event.title }}</h1>
|
||||
<p>{{ event.price }} {{ event.currency|upper }} each{% if event.seats_remaining is not None %} · {{ event.seats_remaining }} left{% endif %}</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<p><label>Email <input name="email" type="email" required></label></p>
|
||||
<p><label>Name <input name="customer_name"></label></p>
|
||||
<p><label>Quantity <input name="quantity" type="number" min="1" value="1"></label></p>
|
||||
<button class="button button-primary" type="submit">Pay with Stripe</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Checkout cancelled{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg"><div class="container">
|
||||
<h1>Checkout cancelled</h1>
|
||||
<p>No charge for {{ order.event.title }}. <a href="{% url 'events:buy' order.event.slug %}">Try again</a>.</p>
|
||||
</div></section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ event.title }} · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<div class="container">
|
||||
<p><a href="{% url 'events:list' %}">← Events</a></p>
|
||||
<h1>{{ event.title }}</h1>
|
||||
<p class="muted">{{ event.starts_at|date:"F j, Y g:i A" }}{% if event.venue %} · {{ event.venue }}{% endif %}</p>
|
||||
<div>{{ event.description|linebreaks }}</div>
|
||||
<p>{{ event.price }} {{ event.currency|upper }}</p>
|
||||
{% if event.seats_remaining == 0 %}
|
||||
<p>Sold out.</p>
|
||||
{% else %}
|
||||
<p><a class="button button-primary" href="{% url 'events:buy' event.slug %}">Buy tickets</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Events · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<div class="container">
|
||||
<h1 class="text-uppercase">Events</h1>
|
||||
{% for event in events %}
|
||||
<article style="margin:0 0 32px">
|
||||
<h2><a href="{{ event.get_absolute_url }}">{{ event.title }}</a></h2>
|
||||
<p class="muted">{{ event.starts_at|date:"F j, Y g:i A" }}{% if event.venue %} · {{ event.venue }}{% endif %}</p>
|
||||
<p>{{ event.price }} {{ event.currency|upper }}{% if event.seats_remaining is not None %} · {{ event.seats_remaining }} seats left{% endif %}</p>
|
||||
</article>
|
||||
{% empty %}
|
||||
<p>No upcoming events.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}{{ event.title }} · Portal{% endblock %}
|
||||
{% block topbar_title %}{{ event.title }}{% endblock %}
|
||||
{% block portal_content %}
|
||||
<p><a class="btn btn-ghost" href="{% url 'events_portal:event_edit' event.pk %}">Edit</a></p>
|
||||
<p>{{ event.starts_at }} · {{ event.tickets_sold }} / {{ event.capacity|default:"∞" }} seats · {{ event.price }} {{ event.currency|upper }}</p>
|
||||
<table class="table">
|
||||
<thead><tr><th>Order</th><th>Email</th><th>Qty</th><th>Status</th><th>Codes</th></tr></thead>
|
||||
<tbody>
|
||||
{% for order in orders %}
|
||||
<tr>
|
||||
<td>{{ order.number }}</td>
|
||||
<td>{{ order.email }}</td>
|
||||
<td>{{ order.quantity }}</td>
|
||||
<td>{{ order.get_status_display }}</td>
|
||||
<td>{% for ticket in order.tickets.all %}{{ ticket.code }}{% if not forloop.last %}, {% endif %}{% endfor %}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="empty-state">No ticket orders yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}{% if event %}Edit{% else %}New{% endif %} event · Portal{% endblock %}
|
||||
{% block topbar_title %}{% if event %}Edit event{% else %}New event{% endif %}{% endblock %}
|
||||
{% block portal_content %}
|
||||
<form method="post" class="form-grid">
|
||||
{% csrf_token %}
|
||||
<div class="field"><label>Title</label><input name="title" required value="{{ event.title|default:'' }}"></div>
|
||||
<div class="field"><label>Slug</label><input name="slug" value="{{ event.slug|default:'' }}" placeholder="auto from title"></div>
|
||||
<div class="field"><label>Starts</label><input name="starts_at" type="datetime-local" required value="{% if event %}{{ event.starts_at|date:'Y-m-d\\TH:i' }}{% endif %}"></div>
|
||||
<div class="field"><label>Venue</label><input name="venue" value="{{ event.venue|default:'' }}"></div>
|
||||
<div class="field"><label>Capacity</label><input name="capacity" type="number" min="0" value="{{ event.capacity|default:0 }}"></div>
|
||||
<div class="field"><label>Price</label><input name="price" type="number" step="0.01" min="0" required value="{{ event.price|default:'0' }}"></div>
|
||||
<div class="field"><label>Description</label><textarea name="description" style="min-height:120px">{{ event.description|default:'' }}</textarea></div>
|
||||
<label><input type="checkbox" name="is_published" {% if event.is_published %}checked{% endif %}> Published</label>
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Events · Portal{% endblock %}
|
||||
{% block topbar_title %}Events{% endblock %}
|
||||
{% block portal_content %}
|
||||
<p><a class="btn btn-primary" href="{% url 'events_portal:event_new' %}">New event</a></p>
|
||||
<table class="table">
|
||||
<thead><tr><th>Title</th><th>When</th><th>Capacity</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for event in events %}
|
||||
<tr>
|
||||
<td><a href="{% url 'events_portal:event_detail' event.pk %}">{{ event.title }}</a></td>
|
||||
<td>{{ event.starts_at }}</td>
|
||||
<td>{{ event.tickets_sold }} / {{ event.capacity|default:"∞" }}</td>
|
||||
<td>{% if event.is_published %}Published{% else %}Draft{% endif %}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="empty-state">No events yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Tickets {{ order.number }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg"><div class="container">
|
||||
<h1>You're in</h1>
|
||||
<p>Order {{ order.number }} for {{ order.event.title }} is {{ order.get_status_display|lower }}.</p>
|
||||
<p>Ticket codes will be emailed to {{ order.email }}.</p>
|
||||
</div></section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,95 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import mail
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from events.models import Event, Ticket, TicketOrder
|
||||
from events.services import EventsError, create_ticket_order, mark_paid
|
||||
|
||||
|
||||
def _event(**kwargs):
|
||||
defaults = dict(
|
||||
title="Friday Night Prerelease",
|
||||
starts_at=timezone.now() + timezone.timedelta(days=7),
|
||||
venue="Main shop",
|
||||
capacity=8,
|
||||
price=Decimal("25.00"),
|
||||
is_published=True,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return Event.objects.create(**defaults)
|
||||
|
||||
|
||||
class EventPublicTests(TestCase):
|
||||
def test_list_hides_drafts_and_past(self):
|
||||
_event(title="Live prerelease")
|
||||
_event(title="Draft night", is_published=False)
|
||||
_event(
|
||||
title="Last week",
|
||||
starts_at=timezone.now() - timezone.timedelta(days=2),
|
||||
)
|
||||
response = Client().get(reverse("events:list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Live prerelease")
|
||||
self.assertNotContains(response, "Draft night")
|
||||
self.assertNotContains(response, "Last week")
|
||||
|
||||
def test_buy_form_renders(self):
|
||||
event = _event()
|
||||
response = Client().get(reverse("events:buy", kwargs={"slug": event.slug}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Pay with Stripe")
|
||||
|
||||
|
||||
class EventCapacityTests(TestCase):
|
||||
def test_capacity_blocks_overbook(self):
|
||||
event = _event(capacity=2)
|
||||
create_ticket_order(event, email="a@example.com", quantity=2)
|
||||
with self.assertRaises(EventsError):
|
||||
create_ticket_order(event, email="b@example.com", quantity=1)
|
||||
|
||||
def test_mark_paid_issues_tickets_and_email(self):
|
||||
event = _event(capacity=4)
|
||||
order = create_ticket_order(
|
||||
event, email="fan@example.com", customer_name="Lee", quantity=2
|
||||
)
|
||||
mark_paid(order)
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.status, TicketOrder.Status.PAID)
|
||||
self.assertEqual(Ticket.objects.filter(order=order).count(), 2)
|
||||
self.assertEqual(event.tickets_sold, 2)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertIn(event.title, mail.outbox[0].subject)
|
||||
|
||||
|
||||
class EventPortalTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user("host", password="test-pass-123")
|
||||
self.client = Client()
|
||||
self.client.login(username="host", password="test-pass-123")
|
||||
|
||||
def test_list_requires_login(self):
|
||||
self.assertEqual(Client().get(reverse("events_portal:event_list")).status_code, 302)
|
||||
|
||||
def test_create_published_event(self):
|
||||
starts = (timezone.now() + timezone.timedelta(days=3)).strftime("%Y-%m-%dT%H:%M")
|
||||
response = self.client.post(
|
||||
reverse("events_portal:event_new"),
|
||||
{
|
||||
"title": "Commander night",
|
||||
"starts_at": starts,
|
||||
"venue": "Back room",
|
||||
"capacity": "16",
|
||||
"price": "10.00",
|
||||
"is_published": "on",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
event = Event.objects.get()
|
||||
self.assertEqual(event.slug, "commander-night")
|
||||
self.assertTrue(event.is_published)
|
||||
self.assertEqual(event.capacity, 16)
|
||||
@@ -0,0 +1,194 @@
|
||||
import logging
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from datetime import datetime
|
||||
from django.utils.text import slugify
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from events.models import Event, TicketOrder
|
||||
from events.services import (
|
||||
EventsError,
|
||||
create_checkout_session,
|
||||
create_ticket_order,
|
||||
mark_paid,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _site_base(request) -> str:
|
||||
base = (settings.PUBLIC_SITE_URL or "").rstrip("/")
|
||||
if base:
|
||||
return base
|
||||
return request.build_absolute_uri("/").rstrip("/")
|
||||
|
||||
|
||||
def event_list(request):
|
||||
events = Event.objects.filter(is_published=True, starts_at__gte=timezone.now())
|
||||
return render(request, "events/list.html", {"events": events})
|
||||
|
||||
|
||||
def event_detail(request, slug):
|
||||
event = get_object_or_404(Event, slug=slug, is_published=True)
|
||||
return render(request, "events/detail.html", {"event": event})
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def buy_tickets(request, slug):
|
||||
event = get_object_or_404(Event, slug=slug, is_published=True)
|
||||
if request.method == "POST":
|
||||
email = (request.POST.get("email") or "").strip()
|
||||
name = (request.POST.get("customer_name") or "").strip()
|
||||
try:
|
||||
quantity = int(request.POST.get("quantity") or "1")
|
||||
except ValueError:
|
||||
quantity = 0
|
||||
try:
|
||||
order = create_ticket_order(
|
||||
event, email=email, customer_name=name, quantity=quantity
|
||||
)
|
||||
base = _site_base(request)
|
||||
success = base + reverse(
|
||||
"events:checkout_success", kwargs={"pk": order.pk}
|
||||
)
|
||||
cancel = base + reverse("events:checkout_cancel", kwargs={"pk": order.pk})
|
||||
url = create_checkout_session(
|
||||
order,
|
||||
success_url=success + "?session_id={CHECKOUT_SESSION_ID}",
|
||||
cancel_url=cancel,
|
||||
)
|
||||
except EventsError as exc:
|
||||
messages.error(request, str(exc))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("ticket checkout failed")
|
||||
messages.error(request, f"Could not start checkout: {exc}")
|
||||
else:
|
||||
return redirect(url)
|
||||
return render(request, "events/buy.html", {"event": event})
|
||||
|
||||
|
||||
def checkout_success(request, pk):
|
||||
order = get_object_or_404(TicketOrder.objects.select_related("event"), pk=pk)
|
||||
return render(request, "events/success.html", {"order": order})
|
||||
|
||||
|
||||
def checkout_cancel(request, pk):
|
||||
order = get_object_or_404(TicketOrder.objects.select_related("event"), pk=pk)
|
||||
return render(request, "events/cancel.html", {"order": order})
|
||||
|
||||
|
||||
@login_required
|
||||
def portal_event_list(request):
|
||||
events = Event.objects.all().order_by("-starts_at")
|
||||
return render(request, "events/portal/list.html", {"events": events})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def portal_event_edit(request, pk=None):
|
||||
event = get_object_or_404(Event, pk=pk) if pk else None
|
||||
if request.method == "POST":
|
||||
title = (request.POST.get("title") or "").strip()
|
||||
description = (request.POST.get("description") or "").strip()
|
||||
venue = (request.POST.get("venue") or "").strip()
|
||||
slug = (request.POST.get("slug") or "").strip()
|
||||
starts_raw = (request.POST.get("starts_at") or "").strip()
|
||||
errors = []
|
||||
if not title:
|
||||
errors.append("Title is required.")
|
||||
starts_at = None
|
||||
if starts_raw:
|
||||
try:
|
||||
starts_at = datetime.fromisoformat(starts_raw)
|
||||
except ValueError:
|
||||
starts_at = None
|
||||
if starts_at is None:
|
||||
errors.append("Start date/time is required.")
|
||||
elif timezone.is_naive(starts_at):
|
||||
starts_at = timezone.make_aware(starts_at, timezone.get_current_timezone())
|
||||
try:
|
||||
capacity = int(request.POST.get("capacity") or "0")
|
||||
except ValueError:
|
||||
capacity = 0
|
||||
errors.append("Capacity must be a number.")
|
||||
try:
|
||||
price = Decimal(request.POST.get("price") or "0")
|
||||
if price < 0:
|
||||
raise InvalidOperation
|
||||
except Exception:
|
||||
price = None
|
||||
errors.append("Enter a valid price.")
|
||||
if errors:
|
||||
for err in errors:
|
||||
messages.error(request, err)
|
||||
else:
|
||||
if event is None:
|
||||
event = Event()
|
||||
event.title = title
|
||||
event.description = description
|
||||
event.venue = venue
|
||||
event.slug = slugify(slug)[:220] if slug else ""
|
||||
event.starts_at = starts_at
|
||||
event.capacity = max(capacity, 0)
|
||||
event.price = price
|
||||
event.currency = (settings.STRIPE_CURRENCY or "usd").lower()
|
||||
event.is_published = request.POST.get("is_published") == "on"
|
||||
event.save()
|
||||
messages.success(request, f"Saved {event.title}.")
|
||||
return redirect("events_portal:event_list")
|
||||
return render(request, "events/portal/edit.html", {"event": event})
|
||||
|
||||
|
||||
@login_required
|
||||
def portal_event_detail(request, pk):
|
||||
event = get_object_or_404(Event, pk=pk)
|
||||
orders = event.ticket_orders.select_related().prefetch_related("tickets")
|
||||
return render(
|
||||
request,
|
||||
"events/portal/detail.html",
|
||||
{"event": event, "orders": orders},
|
||||
)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(["POST"])
|
||||
def stripe_webhook(request):
|
||||
secret = (settings.STRIPE_WEBHOOK_SECRET or "").strip()
|
||||
if not secret:
|
||||
logger.error("STRIPE_WEBHOOK_SECRET unset")
|
||||
return HttpResponseBadRequest("webhook not configured")
|
||||
try:
|
||||
import stripe
|
||||
except ImportError:
|
||||
return HttpResponseBadRequest("stripe not installed")
|
||||
sig = request.headers.get("Stripe-Signature", "")
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(request.body, sig, secret)
|
||||
except Exception:
|
||||
logger.exception("events stripe webhook signature failed")
|
||||
return HttpResponseBadRequest("invalid signature")
|
||||
|
||||
obj = event.get("data", {}).get("object", {}) or {}
|
||||
if event.get("type") != "checkout.session.completed":
|
||||
return HttpResponse("ok")
|
||||
order_id = (obj.get("metadata") or {}).get("ticket_order_id") or ""
|
||||
order = None
|
||||
if order_id:
|
||||
order = TicketOrder.objects.filter(pk=order_id).first()
|
||||
if order is None:
|
||||
session_id = obj.get("id") or ""
|
||||
order = TicketOrder.objects.filter(
|
||||
stripe_checkout_session_id=session_id
|
||||
).first()
|
||||
if order and order.status != TicketOrder.Status.PAID:
|
||||
mark_paid(order, stripe_id=obj.get("id") or "")
|
||||
logger.info("ticket order %s marked paid", order.number)
|
||||
return HttpResponse("ok")
|
||||
Reference in New Issue
Block a user