forked from CGM_Public/pretix_original
cart add
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
# Generated by Django 4.2.17 on 2025-01-01 20:25
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("pretixbase", "0274_tax_codes"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="CheckoutSession",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True, primary_key=True, serialize=False
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("cart_id", models.CharField(max_length=255, unique=True)),
|
||||||
|
("created", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("testmode", models.BooleanField(default=False)),
|
||||||
|
("session_data", models.JSONField(default=dict)),
|
||||||
|
(
|
||||||
|
"customer",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="checkout_sessions",
|
||||||
|
to="pretixbase.customer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"event",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="pretixbase.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sales_channel",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="pretixbase.saleschannel",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="invoiceaddress",
|
||||||
|
name="checkout_session",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="invoice_address",
|
||||||
|
to="pretixbase.checkoutsession",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -3071,15 +3071,16 @@ class CheckoutSession(models.Model):
|
|||||||
event = models.ForeignKey(
|
event = models.ForeignKey(
|
||||||
Event,
|
Event,
|
||||||
verbose_name=_("Event"),
|
verbose_name=_("Event"),
|
||||||
on_delete=models.CASCADE
|
related_name="checkout_sessions",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
cart_id = models.CharField(
|
cart_id = models.CharField(
|
||||||
max_length=255, unique=True,
|
max_length=255, unique=True,
|
||||||
verbose_name=_("Cart ID (e.g. session key)")
|
verbose_name=_("Cart ID (e.g. session key)"),
|
||||||
)
|
)
|
||||||
created = models.DateTimeField(
|
created = models.DateTimeField(
|
||||||
verbose_name=_("Date"),
|
verbose_name=_("Date"),
|
||||||
auto_now_add=True
|
auto_now_add=True,
|
||||||
)
|
)
|
||||||
customer = models.ForeignKey(
|
customer = models.ForeignKey(
|
||||||
Customer,
|
Customer,
|
||||||
@@ -3094,6 +3095,9 @@ class CheckoutSession(models.Model):
|
|||||||
testmode = models.BooleanField(default=False)
|
testmode = models.BooleanField(default=False)
|
||||||
session_data = models.JSONField(default=dict)
|
session_data = models.JSONField(default=dict)
|
||||||
|
|
||||||
|
def cart_positions(self):
|
||||||
|
return CartPosition.objects.filter(event_id=self.event_id, cart_id=self.cart_id)
|
||||||
|
|
||||||
|
|
||||||
class CartPosition(AbstractPosition):
|
class CartPosition(AbstractPosition):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from datetime import timedelta
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
|
from django.db.models import Exists, OuterRef
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
@@ -32,6 +33,7 @@ from pretix.base.models.customers import CustomerSSOGrant
|
|||||||
|
|
||||||
from ..models import CachedFile, CartPosition, InvoiceAddress
|
from ..models import CachedFile, CartPosition, InvoiceAddress
|
||||||
from ..models.auth import UserKnownLoginSource
|
from ..models.auth import UserKnownLoginSource
|
||||||
|
from ..models.orders import CheckoutSession
|
||||||
from ..signals import periodic_task
|
from ..signals import periodic_task
|
||||||
|
|
||||||
|
|
||||||
@@ -42,6 +44,10 @@ def clean_cart_positions(sender, **kwargs):
|
|||||||
cp.delete()
|
cp.delete()
|
||||||
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True):
|
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True):
|
||||||
cp.delete()
|
cp.delete()
|
||||||
|
for cs in CheckoutSession.objects.filter(created__lt=now() - timedelta(days=14)).exclude(
|
||||||
|
Exists(CartPosition.objects.filter(cart_id=OuterRef("cart_id")))
|
||||||
|
):
|
||||||
|
cs.delete()
|
||||||
for ia in InvoiceAddress.objects.filter(order__isnull=True, customer__isnull=True, last_modified__lt=now() - timedelta(days=14)):
|
for ia in InvoiceAddress.objects.filter(order__isnull=True, customer__isnull=True, last_modified__lt=now() - timedelta(days=14)):
|
||||||
ia.delete()
|
ia.delete()
|
||||||
|
|
||||||
|
|||||||
246
src/pretix/storefrontapi/endpoints/checkout.py
Normal file
246
src/pretix/storefrontapi/endpoints/checkout.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from celery.result import AsyncResult
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils import translation
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
from rest_framework import serializers, status, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.generics import get_object_or_404
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.reverse import reverse
|
||||||
|
|
||||||
|
from pretix.base.models import Item, ItemVariation, SubEvent
|
||||||
|
from pretix.base.models.orders import CartPosition, CheckoutSession
|
||||||
|
from pretix.base.services.cart import add_items_to_cart, error_messages
|
||||||
|
from pretix.base.timemachine import time_machine_now
|
||||||
|
from pretix.presale.views.cart import generate_cart_id
|
||||||
|
from pretix.storefrontapi.permission import StorefrontEventPermission
|
||||||
|
from pretix.storefrontapi.serializers import I18nFlattenedModelSerializer
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CartAddLineSerializer(serializers.Serializer):
|
||||||
|
item = serializers.IntegerField()
|
||||||
|
variation = serializers.IntegerField(allow_null=True, required=False)
|
||||||
|
subevent = serializers.IntegerField(allow_null=True, required=False)
|
||||||
|
count = serializers.IntegerField(default=1)
|
||||||
|
seat = serializers.CharField(allow_null=True, required=False)
|
||||||
|
price = serializers.DecimalField(
|
||||||
|
allow_null=True, required=False, decimal_places=2, max_digits=13
|
||||||
|
)
|
||||||
|
voucher = serializers.CharField(allow_null=True, required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class InlineItemSerializer(I18nFlattenedModelSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Item
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class InlineItemVariationSerializer(I18nFlattenedModelSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ItemVariation
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"value",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class InlineSubEventSerializer(I18nFlattenedModelSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = SubEvent
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"date_from",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CartPositionSerializer(serializers.ModelSerializer):
|
||||||
|
# todo: prefetch related items
|
||||||
|
item = InlineItemSerializer(read_only=True)
|
||||||
|
variation = InlineItemVariationSerializer(read_only=True)
|
||||||
|
subevent = InlineSubEventSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CartPosition
|
||||||
|
fields = [
|
||||||
|
"item",
|
||||||
|
"variation",
|
||||||
|
"subevent",
|
||||||
|
"price",
|
||||||
|
"expires",
|
||||||
|
# todo: attendee_name, attendee_email, voucher, addon_to, used_membership, seat, is_bundled, discount
|
||||||
|
# todo: address, requested_valid_from
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CheckoutSessionSerializer(serializers.ModelSerializer):
|
||||||
|
cart_positions = CartPositionSerializer(many=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CheckoutSession
|
||||||
|
fields = [
|
||||||
|
"cart_id",
|
||||||
|
"sales_channel",
|
||||||
|
"testmode",
|
||||||
|
"cart_positions",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CheckoutViewSet(viewsets.ViewSet):
|
||||||
|
queryset = CheckoutSession.objects.none()
|
||||||
|
lookup_url_kwarg = "cart_id"
|
||||||
|
lookup_field = "cart_id"
|
||||||
|
permission_classes = [
|
||||||
|
StorefrontEventPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def _return_checkout_status(self, cs: CheckoutSession, status=200):
|
||||||
|
serializer = CheckoutSessionSerializer(
|
||||||
|
instance=cs,
|
||||||
|
context={
|
||||||
|
"event": self.request.event,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
serializer.data,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
if (
|
||||||
|
request.event.presale_start
|
||||||
|
and time_machine_now() < request.event.presale_start
|
||||||
|
):
|
||||||
|
raise ValidationError(error_messages["not_started"])
|
||||||
|
if request.event.presale_has_ended:
|
||||||
|
raise ValidationError(error_messages["ended"])
|
||||||
|
|
||||||
|
cs = CheckoutSession.objects.create(
|
||||||
|
event=request.event,
|
||||||
|
cart_id=generate_cart_id(),
|
||||||
|
sales_channel=request.sales_channel,
|
||||||
|
testmode=request.event.testmode,
|
||||||
|
session_data={},
|
||||||
|
)
|
||||||
|
return self._return_checkout_status(cs, status=201)
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
cs = get_object_or_404(
|
||||||
|
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
|
||||||
|
)
|
||||||
|
return self._return_checkout_status(cs, status=200)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["POST"])
|
||||||
|
def add_to_cart(self, request, *args, **kwargs):
|
||||||
|
cs = get_object_or_404(
|
||||||
|
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
|
||||||
|
)
|
||||||
|
serializer = CartAddLineSerializer(
|
||||||
|
data=request.data.get("lines", []),
|
||||||
|
many=True,
|
||||||
|
context={
|
||||||
|
"event": self.request.event,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
return self._do_async(
|
||||||
|
cs,
|
||||||
|
add_items_to_cart,
|
||||||
|
self.request.event.pk,
|
||||||
|
serializer.validated_data,
|
||||||
|
cs.cart_id,
|
||||||
|
translation.get_language(),
|
||||||
|
cs.invoice_address.pk if hasattr(cs, "invoice_address") else None,
|
||||||
|
{},
|
||||||
|
cs.sales_channel.identifier,
|
||||||
|
time_machine_now(default=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(
|
||||||
|
detail=True,
|
||||||
|
methods=["GET"],
|
||||||
|
url_name="task_status",
|
||||||
|
url_path="task/(?P<asyncid>[^/]+)",
|
||||||
|
)
|
||||||
|
def task_status(self, *args, **kwargs):
|
||||||
|
cs = get_object_or_404(
|
||||||
|
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
|
||||||
|
)
|
||||||
|
res = AsyncResult(kwargs["asyncid"])
|
||||||
|
if res.ready():
|
||||||
|
if res.successful() and not isinstance(res.info, Exception):
|
||||||
|
return self._async_success(res, cs)
|
||||||
|
else:
|
||||||
|
return self._async_error(res, cs)
|
||||||
|
return self._async_pending(res, cs)
|
||||||
|
|
||||||
|
def _do_async(self, cs, task, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
res = task.apply_async(args=args, kwargs=kwargs)
|
||||||
|
except ConnectionError:
|
||||||
|
# Task very likely not yet sent, due to redis restarting etc. Let's try once again
|
||||||
|
res = task.apply_async(args=args, kwargs=kwargs)
|
||||||
|
|
||||||
|
if res.ready():
|
||||||
|
if res.successful() and not isinstance(res.info, Exception):
|
||||||
|
return self._async_success(res, cs)
|
||||||
|
else:
|
||||||
|
return self._async_error(res, cs)
|
||||||
|
return self._async_pending(res, cs)
|
||||||
|
|
||||||
|
def _async_success(self, res, cs):
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"checkout_session": self._return_checkout_status(cs).data,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _async_error(self, res, cs):
|
||||||
|
if isinstance(res.info, dict) and res.info["exc_type"] in [
|
||||||
|
"OrderError",
|
||||||
|
"CartError",
|
||||||
|
]:
|
||||||
|
message = res.info["exc_message"]
|
||||||
|
elif res.info.__class__.__name__ in ["OrderError", "CartError"]:
|
||||||
|
message = str(res.info)
|
||||||
|
else:
|
||||||
|
logger.error("Unexpected exception: %r" % res.info)
|
||||||
|
message = _("An unexpected error has occurred, please try again later.")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
status=status.HTTP_409_CONFLICT, # todo: find better status code
|
||||||
|
)
|
||||||
|
|
||||||
|
def _async_pending(self, res, cs):
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"status": "pending",
|
||||||
|
"check_url": reverse(
|
||||||
|
"storefrontapi-v1:checkoutsession-task_status",
|
||||||
|
kwargs={
|
||||||
|
"organizer": self.request.organizer.slug,
|
||||||
|
"event": self.request.event.slug,
|
||||||
|
"cart_id": cs.cart_id,
|
||||||
|
"asyncid": res.id,
|
||||||
|
},
|
||||||
|
request=self.request,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
status=status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
@@ -21,7 +21,7 @@ def opt_str(o):
|
|||||||
return str(o)
|
return str(o)
|
||||||
|
|
||||||
|
|
||||||
class RichtTextField(serializers.Field):
|
class RichTextField(serializers.Field):
|
||||||
def to_representation(self, value):
|
def to_representation(self, value):
|
||||||
return rich_text(value)
|
return rich_text(value)
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ class EventSettingsField(serializers.Field):
|
|||||||
|
|
||||||
|
|
||||||
class CategorySerializer(I18nFlattenedModelSerializer):
|
class CategorySerializer(I18nFlattenedModelSerializer):
|
||||||
description = RichtTextField()
|
description = RichTextField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ItemCategory
|
model = ItemCategory
|
||||||
@@ -222,7 +222,7 @@ class AvailabilityField(serializers.Field):
|
|||||||
|
|
||||||
|
|
||||||
class VariationSerializer(I18nFlattenedModelSerializer):
|
class VariationSerializer(I18nFlattenedModelSerializer):
|
||||||
description = RichtTextField()
|
description = RichTextField()
|
||||||
pricing = PricingField(source="*")
|
pricing = PricingField(source="*")
|
||||||
availability = AvailabilityField(source="*")
|
availability = AvailabilityField(source="*")
|
||||||
|
|
||||||
@@ -238,7 +238,7 @@ class VariationSerializer(I18nFlattenedModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class ItemSerializer(I18nFlattenedModelSerializer):
|
class ItemSerializer(I18nFlattenedModelSerializer):
|
||||||
description = RichtTextField()
|
description = RichTextField()
|
||||||
available_variations = VariationSerializer(many=True, read_only=True)
|
available_variations = VariationSerializer(many=True, read_only=True)
|
||||||
pricing = PricingField(source="*")
|
pricing = PricingField(source="*")
|
||||||
availability = AvailabilityField(source="*")
|
availability = AvailabilityField(source="*")
|
||||||
@@ -253,7 +253,6 @@ class ItemSerializer(I18nFlattenedModelSerializer):
|
|||||||
"description",
|
"description",
|
||||||
"picture",
|
"picture",
|
||||||
"min_per_order",
|
"min_per_order",
|
||||||
"free_price",
|
|
||||||
"available_variations",
|
"available_variations",
|
||||||
"pricing",
|
"pricing",
|
||||||
"availability",
|
"availability",
|
||||||
@@ -269,6 +268,7 @@ class ProductGroupField(serializers.Field):
|
|||||||
subevent=ev if isinstance(ev, SubEvent) else None,
|
subevent=ev if isinstance(ev, SubEvent) else None,
|
||||||
require_seat=False,
|
require_seat=False,
|
||||||
channel=self.context["sales_channel"],
|
channel=self.context["sales_channel"],
|
||||||
|
voucher=None, # TODO
|
||||||
memberships=(
|
memberships=(
|
||||||
self.context["customer"].usable_memberships(
|
self.context["customer"].usable_memberships(
|
||||||
for_event=ev, testmode=event.testmode
|
for_event=ev, testmode=event.testmode
|
||||||
@@ -321,8 +321,7 @@ class SubEventDetailSerializer(BaseEventDetailSerializer):
|
|||||||
|
|
||||||
# todo: vouchers_exist
|
# todo: vouchers_exist
|
||||||
# todo: date range
|
# todo: date range
|
||||||
# todo: waiting list info
|
# todo: seating, seating waiting list
|
||||||
# todo: has seating
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SubEvent
|
model = SubEvent
|
||||||
@@ -346,8 +345,7 @@ class SubEventDetailSerializer(BaseEventDetailSerializer):
|
|||||||
class EventDetailSerializer(BaseEventDetailSerializer):
|
class EventDetailSerializer(BaseEventDetailSerializer):
|
||||||
# todo: vouchers_exist
|
# todo: vouchers_exist
|
||||||
# todo: date range
|
# todo: date range
|
||||||
# todo: waiting list info
|
# todo: seating, seating waiting list
|
||||||
# todo: has seating
|
|
||||||
product_list = ProductGroupField(source="*")
|
product_list = ProductGroupField(source="*")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -379,6 +377,7 @@ class EventViewSet(viewsets.ViewSet):
|
|||||||
|
|
||||||
def retrieve(self, request, *args, **kwargs):
|
def retrieve(self, request, *args, **kwargs):
|
||||||
event = request.event # Lookup is already done
|
event = request.event # Lookup is already done
|
||||||
|
# todo: prefetch related items
|
||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
"sales_channel": request.sales_channel,
|
"sales_channel": request.sales_channel,
|
||||||
|
|||||||
@@ -129,6 +129,14 @@ class ApiMiddleware:
|
|||||||
LocaleMiddleware(NotImplementedError).process_request(request)
|
LocaleMiddleware(NotImplementedError).process_request(request)
|
||||||
r = self.get_response(request)
|
r = self.get_response(request)
|
||||||
r["Access-Control-Allow-Origin"] = "*" # todo: allow whitelist?
|
r["Access-Control-Allow-Origin"] = "*" # todo: allow whitelist?
|
||||||
|
r["Access-Control-Allow-Headers"] = ",".join(
|
||||||
|
[
|
||||||
|
"Content-Type",
|
||||||
|
"X-Storefront-Time-Machine-Date",
|
||||||
|
"Accept",
|
||||||
|
"Accept-Language",
|
||||||
|
]
|
||||||
|
)
|
||||||
return r
|
return r
|
||||||
finally:
|
finally:
|
||||||
timemachine_now_var.set(None)
|
timemachine_now_var.set(None)
|
||||||
|
|||||||
@@ -25,12 +25,13 @@ from django.apps import apps
|
|||||||
from django.urls import include, re_path
|
from django.urls import include, re_path
|
||||||
from rest_framework import routers
|
from rest_framework import routers
|
||||||
|
|
||||||
from .endpoints import event
|
from .endpoints import checkout, event
|
||||||
|
|
||||||
storefront_orga_router = routers.DefaultRouter()
|
storefront_orga_router = routers.DefaultRouter()
|
||||||
storefront_orga_router.register(r"events", event.EventViewSet)
|
storefront_orga_router.register(r"events", event.EventViewSet)
|
||||||
|
|
||||||
storefront_event_router = routers.DefaultRouter()
|
storefront_event_router = routers.DefaultRouter()
|
||||||
|
storefront_event_router.register(r"checkouts", checkout.CheckoutViewSet)
|
||||||
|
|
||||||
# Force import of all plugins to give them a chance to register URLs with the router
|
# Force import of all plugins to give them a chance to register URLs with the router
|
||||||
for app in apps.get_app_configs():
|
for app in apps.get_app_configs():
|
||||||
|
|||||||
Reference in New Issue
Block a user