feat: add Products form to admin

This commit is contained in:
2026-05-18 17:07:15 +02:00
parent 3b46a18b29
commit 6471b98ec2
9 changed files with 407 additions and 3 deletions

View File

@@ -0,0 +1,45 @@
{% extends "wagtailadmin/base.html" %}
{% load static i18n %}
{% block titletag %}
{% trans "Product" %} {% if product %} — {{ product.name }}{% endif %}
{% endblock titletag %}
{% block content %}
{% include "wagtailadmin/shared/header.html" with title="Products" icon="tag" %}
<div style="padding: 0 3em;">
<a href="{% url 'admin_purchase_dashboard' %}" class="button button-secondary">&larr; {% trans "Back" %}</a>
<h1>{% if product %}{% trans "Edit product" %}{% else %}{% trans "Add product" %}{% endif %}</h1>
{% if error %}
<div class="message error">{{ error }}</div>
{% endif %}
<form action="" method="post">
{% csrf_token %}
<label for="id_name">{% trans "Name" %}</label>
<input id="id_name" name="name" type="text" value="{{ product.name|default_if_none:'' }}" required>
<label for="id_description">{% trans "Description" %}</label>
<textarea id="id_description" name="description">{{ product.description|default_if_none:'' }}</textarea>
<label for="id_price">{% trans "Price (cents)" %}</label>
<input id="id_price" name="price_cents" type="number" value="{{ product.price_cents|default_if_none:'' }}" required>
<label for="id_currency">{% trans "Currency" %}</label>
<input id="id_currency" name="currency" type="text" value="{{ product.currency|default_if_none:'usd' }}">
<p>
{% trans "Stripe product" %}: {{ product.stripe_product_id|default:"—" }}<br>
{% trans "Stripe price" %}: {{ product.stripe_price_id|default:"—" }}
</p>
<div style="margin-top:1em;">
<button type="submit" class="button button-primary">{% trans "Save" %}</button>
{% if product %}
<button type="submit" name="action" value="delete" class="button button-secondary" style="margin-left:0.5em">{% trans "Delete" %}</button>
{% endif %}
</div>
</form>
</div>
{% endblock content %}

View File

@@ -0,0 +1,23 @@
{% extends "wagtailadmin/base.html" %}
{% load static i18n %}
{% block titletag %}
{% trans "Products" %}
{% endblock titletag %}
{% block content %}
{% include "wagtailadmin/shared/header.html" with title="Products" icon="tag" %}
<h1>{% trans "Purchasable Products" %}</h1>
<a href="{% url 'admin_purchase_add' %}" class="button button-primary">{% trans "Add product" %}</a>
<ul>
{% for p in products %}
<li>
<a href="{% url 'admin_purchase' p.id %}">
{{ p.name }} — {{ p.price_cents|floatformat:-2 }} {{ p.currency|upper }}
</a>
</li>
{% empty %}
<li>{% trans "No products found." %}</li>
{% endfor %}
</ul>
{% endblock content %}

View File

@@ -1,10 +1,12 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.shortcuts import redirect, render from django.shortcuts import redirect, render, get_object_or_404
from home.models import ChatMessage from home.models import ChatMessage
from purchase.models import PurchasableProduct
from django.views.decorators.http import require_http_methods
# Chat admin + user views (restored)
@login_required @login_required
def admin_chat_dashboard(request): def admin_chat_dashboard(request):
chats = ChatMessage.get_all_user_senders() chats = ChatMessage.get_all_user_senders()
@@ -41,3 +43,60 @@ def user_chat_send(request, user_id):
if request.user.is_staff: if request.user.is_staff:
return redirect("admin_chat", user_id=user_id) return redirect("admin_chat", user_id=user_id)
return redirect("user_chat") return redirect("user_chat")
# PurchasableProduct admin views
@login_required
def admin_purchase_dashboard(request):
products = PurchasableProduct.objects.all().order_by("-created_at")
return render(
request, "purchase/admin/admin_purchase_dashboard.html", {"products": products}
)
@login_required
@require_http_methods(["GET", "POST"])
def admin_purchase(request, product_id=None):
product = None
if product_id:
product = get_object_or_404(PurchasableProduct, id=product_id)
if request.method == "POST":
# Handle create/update/delete actions
action = request.POST.get("action")
name = request.POST.get("name", "").strip()
description = request.POST.get("description", "").strip()
price = request.POST.get("price_cents")
currency = request.POST.get("currency", "pln").strip()
if action == "delete" and product:
product.delete()
return redirect("admin_purchase_dashboard")
# Create or update
if not name or not price:
# Simple validation: require name and price
error = "Name and price are required"
return render(
request,
"purchase/admin/admin_purchase.html",
{"product": product, "error": error},
)
price_cents = int(price)
if product:
product.name = name
product.description = description
product.price_cents = price_cents
product.currency = currency
product.save()
else:
PurchasableProduct.objects.create(
name=name,
description=description,
price_cents=price_cents,
currency=currency,
)
return redirect("admin_purchase_dashboard")
return render(request, "purchase/admin/admin_purchase.html", {"product": product})

View File

@@ -47,7 +47,7 @@ def register_code_block_feature(features):
@hooks.register("register_admin_urls") @hooks.register("register_admin_urls")
def register_admin_chat_dashboard_url(): def register_admin_urls():
return [ return [
path("chat/", views.admin_chat_dashboard, name="admin_chat_dashboard"), path("chat/", views.admin_chat_dashboard, name="admin_chat_dashboard"),
path( path(
@@ -55,9 +55,25 @@ def register_admin_chat_dashboard_url():
views.admin_chat, views.admin_chat,
name="admin_chat", name="admin_chat",
), ),
path("purchase/", views.admin_purchase_dashboard, name="admin_purchase_dashboard"),
path("purchase/add/", views.admin_purchase, name="admin_purchase_add"),
path(
"purchase/<int:product_id>/",
views.admin_purchase,
name="admin_purchase",
),
] ]
@hooks.register("register_admin_menu_item") @hooks.register("register_admin_menu_item")
def register_admin_chat_menu_item(): def register_admin_chat_menu_item():
return MenuItem("Chat", reverse("admin_chat_dashboard"), icon_name="mail") return MenuItem("Chat", reverse("admin_chat_dashboard"), icon_name="mail")
@hooks.register("register_admin_menu_item")
def register_admin_purchase_menu_item():
return MenuItem("Products", reverse("admin_purchase_dashboard"), icon_name="tag")

View File

@@ -316,6 +316,8 @@ TAILWIND_APP_NAME = "theme"
SITE_URL = "http://localhost:8000" SITE_URL = "http://localhost:8000"
STRIPE_DEFAULT_CURRENCY = "pln"
# Gitea API # Gitea API
GITEA_ROOT_URL = "http://localhost:3000" GITEA_ROOT_URL = "http://localhost:3000"
GITEA_URL = f"{GITEA_ROOT_URL}/api/v1" GITEA_URL = f"{GITEA_ROOT_URL}/api/v1"

View File

@@ -0,0 +1,26 @@
# Generated by Django 6.0.4 on 2026-05-18 14:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('purchase', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='PurchasableProduct',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('description', models.TextField(blank=True)),
('price_cents', models.PositiveIntegerField(help_text='Price in cents')),
('currency', models.CharField(default='usd', max_length=10)),
('stripe_product_id', models.CharField(blank=True, max_length=255, null=True)),
('stripe_price_id', models.CharField(blank=True, max_length=255, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-05-18 14:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('purchase', '0002_purchasableproduct'),
]
operations = [
migrations.AlterField(
model_name='purchasableproduct',
name='currency',
field=models.CharField(default='pln', max_length=10),
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 6.0.4 on 2026-05-18 15:05
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0023_eventindexpage'),
('purchase', '0003_alter_purchasableproduct_currency'),
]
operations = [
migrations.AddField(
model_name='purchasableproduct',
name='course',
field=models.OneToOneField(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='purchasable_product', to='home.coursepage'),
preserve_default=False,
),
]

View File

@@ -2,9 +2,11 @@ import logging as lg
import os import os
import requests import requests
import stripe
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.db import models from django.db import models
from wagtail.admin.panels import FieldPanel
GITEA_ORG_NAME = "Studio77" GITEA_ORG_NAME = "Studio77"
logger = lg.getLogger(__name__) logger = lg.getLogger(__name__)
@@ -132,3 +134,195 @@ class CoursePurchase(models.Model):
f"Adding user {self.user} to group {group_name} for course {self.course.title}" f"Adding user {self.user} to group {group_name} for course {self.course.title}"
) )
self.add_to_gitea_team() self.add_to_gitea_team()
class PurchasableProduct(models.Model):
"""A product that can be purchased. When created it will create a Stripe Product and Price.
On delete it will try to deactivate the Price and delete the Product in Stripe.
The code is defensive: if STRIPE_API_KEY is not configured the model will still work locally.
"""
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
price_cents = models.PositiveIntegerField(help_text="Price in cents")
currency = models.CharField(
max_length=10, default=getattr(settings, "STRIPE_DEFAULT_CURRENCY", "pln")
)
stripe_product_id = models.CharField(max_length=255, blank=True, null=True)
stripe_price_id = models.CharField(max_length=255, blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
panels = [
FieldPanel("price_cents"),
FieldPanel("currency"),
]
def __str__(self):
return f"{self.name} ({self.price_cents / 100:.2f} {self.currency.upper()})"
def save(self, *args, **kwargs):
"""Save locally first to ensure we have a PK, then create or update Stripe product/price if needed.
Behavior:
- On create: create Stripe Product and Price (if STRIPE_SECRET_KEY is set).
- On update:
- If name or description changed -> update Stripe Product.
- If price_cents or currency changed -> create a new Stripe Price and deactivate the old one, then update stripe_price_id.
- If STRIPE_SECRET_KEY is not configured the model will still work locally.
"""
# Capture whether this is a new object and the previous state (if any)
is_new = self.pk is None
previous = None
if not is_new:
try:
previous = self.__class__.objects.get(pk=self.pk)
except self.__class__.DoesNotExist:
previous = None
# Persist local changes first so we have an id
super().save(*args, **kwargs)
stripe_api_key = getattr(
settings, "STRIPE_SECRET_KEY", os.getenv("STRIPE_SECRET_KEY")
)
if not stripe_api_key:
logger.debug(
"STRIPE_SECRET_KEY not set, skipping Stripe product/price creation/update"
)
return
stripe.api_key = stripe_api_key
try:
changed_fields = []
# Create Stripe Product if missing
if not self.stripe_product_id:
prod = stripe.Product.create(
name=self.name,
description=self.description or None,
metadata={"local_id": str(self.id)},
)
self.stripe_product_id = prod.id
changed_fields.append("stripe_product_id")
logger.info(
f"Created Stripe product {self.stripe_product_id} for PurchasableProduct {self.id}"
)
# If we don't have a price id, create one
if not self.stripe_price_id:
price = stripe.Price.create(
product=self.stripe_product_id,
unit_amount=self.price_cents,
currency=self.currency.lower(),
)
self.stripe_price_id = price.id
changed_fields.append("stripe_price_id")
logger.info(
f"Created Stripe price {self.stripe_price_id} for PurchasableProduct {self.id}"
)
# If this is an update (we had previous state) perform updates
if previous:
# Update product metadata/name/description if they changed
try:
if (previous.name != self.name) or (
previous.description != self.description
):
stripe.Product.modify(
self.stripe_product_id,
name=self.name,
description=self.description or None,
)
logger.info(
f"Updated Stripe product {self.stripe_product_id} for PurchasableProduct {self.id}"
)
except Exception:
logger.exception(
f"Failed to update Stripe product {self.stripe_product_id} for PurchasableProduct {self.id}"
)
# If price or currency changed, create a new Price and deactivate the old one
try:
prev_currency = (previous.currency or "").lower()
curr_currency = (self.currency or "").lower()
if (previous.price_cents != self.price_cents) or (
prev_currency != curr_currency
):
# Create new price for the same product
new_price = stripe.Price.create(
product=self.stripe_product_id,
unit_amount=self.price_cents,
currency=self.currency.lower(),
)
# Attempt to deactivate the old price, but don't fail the whole operation if it fails
try:
if self.stripe_price_id:
stripe.Price.modify(self.stripe_price_id, active=False)
logger.info(
f"Deactivated old Stripe price {self.stripe_price_id} for PurchasableProduct {self.id}"
)
except Exception:
logger.exception(
f"Failed to deactivate old Stripe price {self.stripe_price_id} for PurchasableProduct {self.id}"
)
# Switch to the new price id and persist it
self.stripe_price_id = new_price.id
if "stripe_price_id" not in changed_fields:
changed_fields.append("stripe_price_id")
logger.info(
f"Created new Stripe price {self.stripe_price_id} for PurchasableProduct {self.id}"
)
except Exception:
logger.exception(
f"Failed to create or switch Stripe price for PurchasableProduct {self.id}"
)
# Persist any changed stripe ids without triggering further Stripe operations
if changed_fields:
super().save(update_fields=changed_fields)
except Exception as e:
logger.exception(
f"Failed to create/update Stripe product/price for PurchasableProduct {self.id}: {e}"
)
def delete(self, *args, **kwargs):
"""Try to clean up Stripe resources when the product is deleted locally."""
stripe_api_key = getattr(
settings, "STRIPE_SECRET_KEY", os.getenv("STRIPE_SECRET_KEY")
)
if not stripe_api_key:
logger.debug(
"STRIPE_SECRET_KEY not set, skipping Stripe product/price cleanup"
)
return super().delete(*args, **kwargs)
stripe.api_key = stripe_api_key
# Attempt to deactivate price and delete product. Be tolerant of failures.
if self.stripe_price_id:
try:
stripe.Price.modify(self.stripe_price_id, active=False)
logger.info(
f"Deactivated Stripe price {self.stripe_price_id} for PurchasableProduct {self.id}"
)
except Exception:
logger.exception(
f"Failed to deactivate Stripe price {self.stripe_price_id} for PurchasableProduct {self.id}"
)
if self.stripe_product_id:
try:
stripe.Product.modify(self.stripe_product_id, active=False)
logger.info(
f"Deactivated Stripe product {self.stripe_product_id} for PurchasableProduct {self.id}"
)
except Exception:
logger.exception(
f"Failed to deactivate Stripe product {self.stripe_product_id} for PurchasableProduct {self.id}"
)
return super().delete(*args, **kwargs)