feat: add Products form to admin
This commit is contained in:
45
home/templates/purchase/admin/admin_purchase.html
Normal file
45
home/templates/purchase/admin/admin_purchase.html
Normal 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">← {% 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 %}
|
||||||
23
home/templates/purchase/admin/admin_purchase_dashboard.html
Normal file
23
home/templates/purchase/admin/admin_purchase_dashboard.html
Normal 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 %}
|
||||||
@@ -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})
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user