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 %} + +
+ {% csrf_token %} + + + + + + + + + + + + +

+ {% trans "Stripe product" %}: {{ product.stripe_product_id|default:"—" }}
+ {% trans "Stripe price" %}: {{ product.stripe_price_id|default:"—" }} +

+ +
+ + {% if product %} + + {% 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)