diff --git a/home/templates/purchase/admin/admin_purchase.html b/home/templates/purchase/admin/admin_purchase.html
new file mode 100644
index 0000000..a1097c4
--- /dev/null
+++ b/home/templates/purchase/admin/admin_purchase.html
@@ -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" %}
+
+
← {% trans "Back" %}
+
{% if product %}{% trans "Edit product" %}{% else %}{% trans "Add product" %}{% endif %}
+
+ {% if error %}
+
{{ error }}
+ {% endif %}
+
+
+
+{% endblock content %}
diff --git a/home/templates/purchase/admin/admin_purchase_dashboard.html b/home/templates/purchase/admin/admin_purchase_dashboard.html
new file mode 100644
index 0000000..cc0e76a
--- /dev/null
+++ b/home/templates/purchase/admin/admin_purchase_dashboard.html
@@ -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" %}
+ {% trans "Purchasable Products" %}
+ {% trans "Add product" %}
+
+{% endblock content %}
diff --git a/home/views.py b/home/views.py
index 490f87c..0ba3ffd 100644
--- a/home/views.py
+++ b/home/views.py
@@ -1,10 +1,12 @@
from django.contrib.auth.decorators import login_required
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 purchase.models import PurchasableProduct
+from django.views.decorators.http import require_http_methods
+# Chat admin + user views (restored)
@login_required
def admin_chat_dashboard(request):
chats = ChatMessage.get_all_user_senders()
@@ -41,3 +43,60 @@ def user_chat_send(request, user_id):
if request.user.is_staff:
return redirect("admin_chat", user_id=user_id)
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})
diff --git a/home/wagtail_hooks.py b/home/wagtail_hooks.py
index e4b11e9..718eb81 100644
--- a/home/wagtail_hooks.py
+++ b/home/wagtail_hooks.py
@@ -47,7 +47,7 @@ def register_code_block_feature(features):
@hooks.register("register_admin_urls")
-def register_admin_chat_dashboard_url():
+def register_admin_urls():
return [
path("chat/", views.admin_chat_dashboard, name="admin_chat_dashboard"),
path(
@@ -55,9 +55,25 @@ def register_admin_chat_dashboard_url():
views.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//",
+ views.admin_purchase,
+ name="admin_purchase",
+ ),
]
+
+
+
@hooks.register("register_admin_menu_item")
def register_admin_chat_menu_item():
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")
+
diff --git a/kursy/settings/base.py b/kursy/settings/base.py
index 09395c0..b469ae5 100644
--- a/kursy/settings/base.py
+++ b/kursy/settings/base.py
@@ -316,6 +316,8 @@ TAILWIND_APP_NAME = "theme"
SITE_URL = "http://localhost:8000"
+STRIPE_DEFAULT_CURRENCY = "pln"
+
# Gitea API
GITEA_ROOT_URL = "http://localhost:3000"
GITEA_URL = f"{GITEA_ROOT_URL}/api/v1"
diff --git a/purchase/migrations/0002_purchasableproduct.py b/purchase/migrations/0002_purchasableproduct.py
new file mode 100644
index 0000000..5bfe80f
--- /dev/null
+++ b/purchase/migrations/0002_purchasableproduct.py
@@ -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)),
+ ],
+ ),
+ ]
diff --git a/purchase/migrations/0003_alter_purchasableproduct_currency.py b/purchase/migrations/0003_alter_purchasableproduct_currency.py
new file mode 100644
index 0000000..863c2e6
--- /dev/null
+++ b/purchase/migrations/0003_alter_purchasableproduct_currency.py
@@ -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),
+ ),
+ ]
diff --git a/purchase/migrations/0004_purchasableproduct_course.py b/purchase/migrations/0004_purchasableproduct_course.py
new file mode 100644
index 0000000..dd29b2e
--- /dev/null
+++ b/purchase/migrations/0004_purchasableproduct_course.py
@@ -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,
+ ),
+ ]
diff --git a/purchase/models.py b/purchase/models.py
index 03f1c47..97004c8 100644
--- a/purchase/models.py
+++ b/purchase/models.py
@@ -2,9 +2,11 @@ import logging as lg
import os
import requests
+import stripe
from django.conf import settings
from django.contrib.auth.models import Group
from django.db import models
+from wagtail.admin.panels import FieldPanel
GITEA_ORG_NAME = "Studio77"
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}"
)
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)