feat: add Products form to admin
This commit is contained in:
26
purchase/migrations/0002_purchasableproduct.py
Normal file
26
purchase/migrations/0002_purchasableproduct.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
21
purchase/migrations/0004_purchasableproduct_course.py
Normal file
21
purchase/migrations/0004_purchasableproduct_course.py
Normal 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,
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user