chore: initial import
This commit is contained in:
7
api/.dockerignore
Normal file
7
api/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.env
|
||||
venv
|
||||
.django
|
||||
21
api/Dockerfile
Normal file
21
api/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
30
api/README.md
Normal file
30
api/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# API
|
||||
|
||||
Django project configured for the Full Moon Bakehouse platform. The scaffold is minimal but wired to the Docker environment variables.
|
||||
|
||||
## Development
|
||||
|
||||
Inside Docker (recommended):
|
||||
|
||||
```bash
|
||||
docker compose up api-gpt
|
||||
```
|
||||
|
||||
Run management commands via:
|
||||
|
||||
```bash
|
||||
docker compose exec api-gpt python manage.py migrate
|
||||
docker compose exec api-gpt python manage.py createsuperuser
|
||||
```
|
||||
|
||||
For local development outside Docker, create a virtualenv and install requirements:
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python manage.py migrate
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
Environment variables default to SQLite if `DATABASE_URL` is not provided.
|
||||
1
api/apps/__init__.py
Normal file
1
api/apps/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Package marker for Django apps.
|
||||
2
api/apps/commerce/__init__.py
Normal file
2
api/apps/commerce/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Default app configuration
|
||||
default_app_config = "apps.commerce.apps.CommerceConfig"
|
||||
87
api/apps/commerce/admin.py
Normal file
87
api/apps/commerce/admin.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
@admin.register(models.ProductCategory)
|
||||
class ProductCategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "slug", "display_order", "is_active")
|
||||
list_filter = ("is_active",)
|
||||
search_fields = ("name", "slug")
|
||||
ordering = ("display_order", "name")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
|
||||
|
||||
@admin.register(models.Product)
|
||||
class ProductAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"category",
|
||||
"price",
|
||||
"inventory_quantity",
|
||||
"max_per_order",
|
||||
"is_active",
|
||||
"is_featured",
|
||||
)
|
||||
list_filter = ("category", "is_active", "is_featured")
|
||||
search_fields = ("name", "slug")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
autocomplete_fields = ("category",)
|
||||
|
||||
|
||||
@admin.register(models.DeliveryZone)
|
||||
class DeliveryZoneAdmin(admin.ModelAdmin):
|
||||
list_display = ("label", "postal_code", "is_active")
|
||||
list_filter = ("is_active",)
|
||||
search_fields = ("label", "postal_code")
|
||||
|
||||
|
||||
@admin.register(models.DeliveryOverrideCode)
|
||||
class DeliveryOverrideCodeAdmin(admin.ModelAdmin):
|
||||
list_display = ("code", "label", "max_uses", "usage_count", "is_active")
|
||||
list_filter = ("is_active",)
|
||||
search_fields = ("code", "label")
|
||||
|
||||
|
||||
class OrderItemInline(admin.TabularInline):
|
||||
model = models.OrderItem
|
||||
extra = 0
|
||||
readonly_fields = (
|
||||
"product",
|
||||
"product_name",
|
||||
"product_slug",
|
||||
"unit_price",
|
||||
"quantity",
|
||||
"line_subtotal",
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.Order)
|
||||
class OrderAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"id",
|
||||
"customer_name",
|
||||
"fulfillment_method",
|
||||
"status",
|
||||
"scheduled_date",
|
||||
"total_amount",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = (
|
||||
"fulfillment_method",
|
||||
"status",
|
||||
"scheduled_date",
|
||||
"delivery_postal_code",
|
||||
)
|
||||
search_fields = ("id", "customer_name", "customer_email")
|
||||
date_hierarchy = "scheduled_date"
|
||||
inlines = [OrderItemInline]
|
||||
readonly_fields = (
|
||||
"subtotal_amount",
|
||||
"tax_amount",
|
||||
"delivery_fee_amount",
|
||||
"discount_amount",
|
||||
"total_amount",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
7
api/apps/commerce/apps.py
Normal file
7
api/apps/commerce/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommerceConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.commerce"
|
||||
verbose_name = "Commerce"
|
||||
267
api/apps/commerce/migrations/0001_initial.py
Normal file
267
api/apps/commerce/migrations/0001_initial.py
Normal file
@@ -0,0 +1,267 @@
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="DeliveryOverrideCode",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("code", models.CharField(max_length=32, unique=True)),
|
||||
("label", models.CharField(max_length=120)),
|
||||
("message_to_customer", models.CharField(blank=True, max_length=240)),
|
||||
(
|
||||
"max_uses",
|
||||
models.PositiveIntegerField(default=0, help_text="0 = unlimited"),
|
||||
),
|
||||
("usage_count", models.PositiveIntegerField(default=0)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
],
|
||||
options={"ordering": ["code"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="DeliveryZone",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("label", models.CharField(max_length=120)),
|
||||
("postal_code", models.CharField(db_index=True, max_length=10)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
],
|
||||
options={"ordering": ["postal_code"], "unique_together": {("postal_code", "label")}},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Order",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("customer_name", models.CharField(max_length=160)),
|
||||
("customer_email", models.EmailField(max_length=254)),
|
||||
("customer_phone", models.CharField(blank=True, max_length=32)),
|
||||
(
|
||||
"fulfillment_method",
|
||||
models.CharField(
|
||||
choices=[("pickup", "Pickup"), ("delivery", "Delivery")],
|
||||
db_index=True,
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("draft", "Draft"),
|
||||
("pending", "Pending"),
|
||||
("confirmed", "Confirmed"),
|
||||
("in_production", "In Production"),
|
||||
("ready", "Ready"),
|
||||
("completed", "Completed"),
|
||||
("cancelled", "Cancelled"),
|
||||
],
|
||||
db_index=True,
|
||||
default="pending",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("scheduled_date", models.DateField(blank=True, null=True)),
|
||||
("scheduled_time_slot", models.CharField(blank=True, max_length=64)),
|
||||
("delivery_address_line1", models.CharField(blank=True, max_length=160)),
|
||||
("delivery_address_line2", models.CharField(blank=True, max_length=160)),
|
||||
("delivery_city", models.CharField(blank=True, max_length=80)),
|
||||
("delivery_state", models.CharField(blank=True, max_length=32)),
|
||||
("delivery_postal_code", models.CharField(blank=True, max_length=16)),
|
||||
(
|
||||
"subtotal_amount",
|
||||
models.DecimalField(
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
max_digits=8,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
),
|
||||
),
|
||||
(
|
||||
"tax_amount",
|
||||
models.DecimalField(
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
max_digits=8,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
),
|
||||
),
|
||||
(
|
||||
"delivery_fee_amount",
|
||||
models.DecimalField(
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
max_digits=8,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
),
|
||||
),
|
||||
(
|
||||
"discount_amount",
|
||||
models.DecimalField(
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
max_digits=8,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
),
|
||||
),
|
||||
(
|
||||
"total_amount",
|
||||
models.DecimalField(
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
max_digits=8,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
),
|
||||
),
|
||||
("notes", models.TextField(blank=True)),
|
||||
(
|
||||
"delivery_override_code",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="orders",
|
||||
to="commerce.deliveryoverridecode",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"ordering": ["-scheduled_date", "-created_at"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ProductCategory",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=120, unique=True)),
|
||||
("slug", models.SlugField(max_length=140, unique=True)),
|
||||
("description", models.TextField(blank=True)),
|
||||
("display_order", models.PositiveIntegerField(default=0)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["display_order", "name"],
|
||||
"verbose_name_plural": "Product categories",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Product",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=160)),
|
||||
("slug", models.SlugField(max_length=180, unique=True)),
|
||||
("short_description", models.CharField(blank=True, max_length=280)),
|
||||
("description", models.TextField(blank=True)),
|
||||
("ingredients", models.TextField(blank=True)),
|
||||
(
|
||||
"allergens",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Comma-separated allergen labels.",
|
||||
max_length=280,
|
||||
),
|
||||
),
|
||||
("price", models.DecimalField(decimal_places=2, max_digits=8)),
|
||||
("inventory_quantity", models.PositiveIntegerField(default=0)),
|
||||
("max_per_order", models.PositiveIntegerField(default=6)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
("is_featured", models.BooleanField(default=False)),
|
||||
("hero_image", models.ImageField(blank=True, upload_to="products/hero/")),
|
||||
(
|
||||
"category",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="products",
|
||||
to="commerce.productcategory",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"ordering": ["name"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="OrderItem",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("product_name", models.CharField(max_length=160)),
|
||||
("product_slug", models.SlugField(max_length=180)),
|
||||
("unit_price", models.DecimalField(decimal_places=2, max_digits=8)),
|
||||
(
|
||||
"quantity",
|
||||
models.PositiveIntegerField(
|
||||
default=1, validators=[django.core.validators.MinValueValidator(1)]
|
||||
),
|
||||
),
|
||||
(
|
||||
"line_subtotal",
|
||||
models.DecimalField(decimal_places=2, default=0, max_digits=8),
|
||||
),
|
||||
(
|
||||
"order",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="items",
|
||||
to="commerce.order",
|
||||
),
|
||||
),
|
||||
(
|
||||
"product",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="order_items",
|
||||
to="commerce.product",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"ordering": ["product_name"]},
|
||||
),
|
||||
]
|
||||
1
api/apps/commerce/migrations/__init__.py
Normal file
1
api/apps/commerce/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Package marker for migrations.
|
||||
198
api/apps/commerce/models.py
Normal file
198
api/apps/commerce/models.py
Normal file
@@ -0,0 +1,198 @@
|
||||
import uuid
|
||||
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
|
||||
|
||||
class TimeStampedModel(models.Model):
|
||||
"""Abstract base with created/updated timestamps."""
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class ProductCategory(TimeStampedModel):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
name = models.CharField(max_length=120, unique=True)
|
||||
slug = models.SlugField(max_length=140, unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
display_order = models.PositiveIntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["display_order", "name"]
|
||||
verbose_name_plural = "Product categories"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class Product(TimeStampedModel):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
category = models.ForeignKey(
|
||||
ProductCategory,
|
||||
related_name="products",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
name = models.CharField(max_length=160)
|
||||
slug = models.SlugField(max_length=180, unique=True)
|
||||
short_description = models.CharField(max_length=280, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
ingredients = models.TextField(blank=True)
|
||||
allergens = models.CharField(
|
||||
max_length=280, blank=True, help_text="Comma-separated allergen labels."
|
||||
)
|
||||
price = models.DecimalField(max_digits=8, decimal_places=2)
|
||||
inventory_quantity = models.PositiveIntegerField(default=0)
|
||||
max_per_order = models.PositiveIntegerField(default=6)
|
||||
is_active = models.BooleanField(default=True)
|
||||
is_featured = models.BooleanField(default=False)
|
||||
hero_image = models.ImageField(upload_to="products/hero/", blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
return self.is_active and self.inventory_quantity > 0
|
||||
|
||||
|
||||
class DeliveryZone(TimeStampedModel):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
label = models.CharField(max_length=120)
|
||||
postal_code = models.CharField(max_length=10, db_index=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["postal_code"]
|
||||
unique_together = ("postal_code", "label")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.label} ({self.postal_code})"
|
||||
|
||||
|
||||
class DeliveryOverrideCode(TimeStampedModel):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
code = models.CharField(max_length=32, unique=True)
|
||||
label = models.CharField(max_length=120)
|
||||
message_to_customer = models.CharField(max_length=240, blank=True)
|
||||
max_uses = models.PositiveIntegerField(default=0, help_text="0 = unlimited")
|
||||
usage_count = models.PositiveIntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["code"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.code
|
||||
|
||||
def can_use(self) -> bool:
|
||||
if not self.is_active:
|
||||
return False
|
||||
if self.max_uses and self.usage_count >= self.max_uses:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class Order(TimeStampedModel):
|
||||
class FulfillmentMethod(models.TextChoices):
|
||||
PICKUP = "pickup", "Pickup"
|
||||
DELIVERY = "delivery", "Delivery"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = "draft", "Draft"
|
||||
PENDING = "pending", "Pending"
|
||||
CONFIRMED = "confirmed", "Confirmed"
|
||||
IN_PRODUCTION = "in_production", "In Production"
|
||||
READY = "ready", "Ready"
|
||||
COMPLETED = "completed", "Completed"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
customer_name = models.CharField(max_length=160)
|
||||
customer_email = models.EmailField()
|
||||
customer_phone = models.CharField(max_length=32, blank=True)
|
||||
fulfillment_method = models.CharField(
|
||||
max_length=16, choices=FulfillmentMethod.choices, db_index=True
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.PENDING, db_index=True
|
||||
)
|
||||
scheduled_date = models.DateField(null=True, blank=True)
|
||||
scheduled_time_slot = models.CharField(max_length=64, blank=True)
|
||||
delivery_address_line1 = models.CharField(max_length=160, blank=True)
|
||||
delivery_address_line2 = models.CharField(max_length=160, blank=True)
|
||||
delivery_city = models.CharField(max_length=80, blank=True)
|
||||
delivery_state = models.CharField(max_length=32, blank=True)
|
||||
delivery_postal_code = models.CharField(max_length=16, blank=True)
|
||||
delivery_override_code = models.ForeignKey(
|
||||
DeliveryOverrideCode,
|
||||
related_name="orders",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
subtotal_amount = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(0)],
|
||||
)
|
||||
tax_amount = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(0)],
|
||||
)
|
||||
delivery_fee_amount = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(0)],
|
||||
)
|
||||
discount_amount = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(0)],
|
||||
)
|
||||
total_amount = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
validators=[MinValueValidator(0)],
|
||||
)
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-scheduled_date", "-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Order {self.id}"
|
||||
|
||||
|
||||
class OrderItem(TimeStampedModel):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
order = models.ForeignKey(Order, related_name="items", on_delete=models.CASCADE)
|
||||
product = models.ForeignKey(
|
||||
Product, related_name="order_items", on_delete=models.SET_NULL, null=True
|
||||
)
|
||||
product_name = models.CharField(max_length=160)
|
||||
product_slug = models.SlugField(max_length=180)
|
||||
unit_price = models.DecimalField(max_digits=8, decimal_places=2)
|
||||
quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)])
|
||||
line_subtotal = models.DecimalField(max_digits=8, decimal_places=2, default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["product_name"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.product_name} x{self.quantity}"
|
||||
253
api/apps/commerce/serializers.py
Normal file
253
api/apps/commerce/serializers.py
Normal file
@@ -0,0 +1,253 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from rest_framework import serializers
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class ProductCategorySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.ProductCategory
|
||||
fields = (
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"description",
|
||||
"display_order",
|
||||
"is_active",
|
||||
)
|
||||
|
||||
|
||||
class ProductSerializer(serializers.ModelSerializer):
|
||||
category = ProductCategorySerializer(read_only=True)
|
||||
category_id = serializers.PrimaryKeyRelatedField(
|
||||
source="category",
|
||||
queryset=models.ProductCategory.objects.all(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Product
|
||||
fields = (
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"short_description",
|
||||
"description",
|
||||
"ingredients",
|
||||
"allergens",
|
||||
"price",
|
||||
"inventory_quantity",
|
||||
"max_per_order",
|
||||
"is_active",
|
||||
"is_featured",
|
||||
"hero_image",
|
||||
"category",
|
||||
"category_id",
|
||||
)
|
||||
|
||||
|
||||
class DeliveryZoneSerializer(serializers.ModelSerializer):
|
||||
is_active = serializers.BooleanField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = models.DeliveryZone
|
||||
fields = ("id", "label", "postal_code", "is_active")
|
||||
|
||||
|
||||
class DeliveryOverrideCodeSerializer(serializers.ModelSerializer):
|
||||
can_use = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.DeliveryOverrideCode
|
||||
fields = (
|
||||
"id",
|
||||
"code",
|
||||
"label",
|
||||
"message_to_customer",
|
||||
"max_uses",
|
||||
"usage_count",
|
||||
"is_active",
|
||||
"can_use",
|
||||
)
|
||||
read_only_fields = ("usage_count", "can_use")
|
||||
|
||||
def get_can_use(self, obj: models.DeliveryOverrideCode) -> bool:
|
||||
return obj.can_use()
|
||||
|
||||
|
||||
class OrderItemSerializer(serializers.ModelSerializer):
|
||||
line_subtotal = serializers.DecimalField(
|
||||
max_digits=8, decimal_places=2, read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.OrderItem
|
||||
fields = (
|
||||
"id",
|
||||
"product",
|
||||
"product_name",
|
||||
"product_slug",
|
||||
"unit_price",
|
||||
"quantity",
|
||||
"line_subtotal",
|
||||
)
|
||||
read_only_fields = ("product_name", "product_slug", "unit_price", "line_subtotal")
|
||||
|
||||
|
||||
class OrderSerializer(serializers.ModelSerializer):
|
||||
items = OrderItemSerializer(many=True)
|
||||
subtotal_amount = serializers.DecimalField(
|
||||
max_digits=8, decimal_places=2, read_only=True
|
||||
)
|
||||
tax_amount = serializers.DecimalField(max_digits=8, decimal_places=2, read_only=True)
|
||||
delivery_fee_amount = serializers.DecimalField(
|
||||
max_digits=8, decimal_places=2, read_only=True
|
||||
)
|
||||
discount_amount = serializers.DecimalField(
|
||||
max_digits=8, decimal_places=2, read_only=True
|
||||
)
|
||||
total_amount = serializers.DecimalField(max_digits=8, decimal_places=2, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Order
|
||||
fields = (
|
||||
"id",
|
||||
"customer_name",
|
||||
"customer_email",
|
||||
"customer_phone",
|
||||
"fulfillment_method",
|
||||
"status",
|
||||
"scheduled_date",
|
||||
"scheduled_time_slot",
|
||||
"delivery_address_line1",
|
||||
"delivery_address_line2",
|
||||
"delivery_city",
|
||||
"delivery_state",
|
||||
"delivery_postal_code",
|
||||
"delivery_override_code",
|
||||
"subtotal_amount",
|
||||
"tax_amount",
|
||||
"delivery_fee_amount",
|
||||
"discount_amount",
|
||||
"total_amount",
|
||||
"notes",
|
||||
"items",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
read_only_fields = ("status", "created_at", "updated_at")
|
||||
|
||||
def create(self, validated_data):
|
||||
items_data = validated_data.pop("items", [])
|
||||
delivery_fee_amount = Decimal(validated_data.pop("delivery_fee_amount", Decimal("0")))
|
||||
discount_amount = Decimal(validated_data.pop("discount_amount", Decimal("0")))
|
||||
tax_amount = Decimal(validated_data.pop("tax_amount", Decimal("0")))
|
||||
|
||||
if not items_data:
|
||||
raise serializers.ValidationError("Order requires at least one item.")
|
||||
|
||||
with transaction.atomic():
|
||||
subtotal = Decimal("0")
|
||||
order_items = []
|
||||
|
||||
for item_data in items_data:
|
||||
product = item_data.get("product")
|
||||
if product is None:
|
||||
raise serializers.ValidationError("Each order item must reference a product.")
|
||||
|
||||
quantity = item_data.get("quantity", 1)
|
||||
if quantity > product.inventory_quantity:
|
||||
raise serializers.ValidationError(
|
||||
f"Insufficient inventory for {product.name}. Requested {quantity}, "
|
||||
f"available {product.inventory_quantity}."
|
||||
)
|
||||
|
||||
line_subtotal = product.price * quantity
|
||||
subtotal += line_subtotal
|
||||
|
||||
order_items.append(
|
||||
{
|
||||
"product": product,
|
||||
"product_name": product.name,
|
||||
"product_slug": product.slug,
|
||||
"unit_price": product.price,
|
||||
"quantity": quantity,
|
||||
"line_subtotal": line_subtotal,
|
||||
}
|
||||
)
|
||||
|
||||
product.inventory_quantity -= quantity
|
||||
product.save(update_fields=["inventory_quantity"])
|
||||
|
||||
total_amount = subtotal + tax_amount + delivery_fee_amount - discount_amount
|
||||
|
||||
order = models.Order.objects.create(
|
||||
subtotal_amount=subtotal,
|
||||
tax_amount=tax_amount,
|
||||
delivery_fee_amount=delivery_fee_amount,
|
||||
discount_amount=discount_amount,
|
||||
total_amount=total_amount,
|
||||
**validated_data,
|
||||
)
|
||||
|
||||
models.OrderItem.objects.bulk_create(
|
||||
[models.OrderItem(order=order, **item) for item in order_items]
|
||||
)
|
||||
|
||||
return order
|
||||
|
||||
|
||||
class OrderAdminSerializer(serializers.ModelSerializer):
|
||||
items = OrderItemSerializer(many=True, read_only=True)
|
||||
subtotal_amount = serializers.DecimalField(
|
||||
max_digits=8, decimal_places=2, read_only=True
|
||||
)
|
||||
tax_amount = serializers.DecimalField(max_digits=8, decimal_places=2, read_only=True)
|
||||
delivery_fee_amount = serializers.DecimalField(
|
||||
max_digits=8, decimal_places=2, read_only=True
|
||||
)
|
||||
discount_amount = serializers.DecimalField(
|
||||
max_digits=8, decimal_places=2, read_only=True
|
||||
)
|
||||
total_amount = serializers.DecimalField(max_digits=8, decimal_places=2, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Order
|
||||
fields = (
|
||||
"id",
|
||||
"customer_name",
|
||||
"customer_email",
|
||||
"customer_phone",
|
||||
"fulfillment_method",
|
||||
"status",
|
||||
"scheduled_date",
|
||||
"scheduled_time_slot",
|
||||
"delivery_address_line1",
|
||||
"delivery_address_line2",
|
||||
"delivery_city",
|
||||
"delivery_state",
|
||||
"delivery_postal_code",
|
||||
"delivery_override_code",
|
||||
"subtotal_amount",
|
||||
"tax_amount",
|
||||
"delivery_fee_amount",
|
||||
"discount_amount",
|
||||
"total_amount",
|
||||
"notes",
|
||||
"items",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
read_only_fields = (
|
||||
"subtotal_amount",
|
||||
"tax_amount",
|
||||
"delivery_fee_amount",
|
||||
"discount_amount",
|
||||
"total_amount",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
34
api/apps/commerce/urls.py
Normal file
34
api/apps/commerce/urls.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework import routers
|
||||
|
||||
from . import views
|
||||
|
||||
public_router = routers.DefaultRouter()
|
||||
public_router.register(
|
||||
"categories", views.ProductCategoryPublicViewSet, basename="category"
|
||||
)
|
||||
public_router.register("products", views.ProductPublicViewSet, basename="product")
|
||||
public_router.register(
|
||||
"delivery-zones", views.DeliveryZonePublicViewSet, basename="delivery-zone"
|
||||
)
|
||||
public_router.register("orders", views.OrderPublicViewSet, basename="order")
|
||||
|
||||
admin_router = routers.DefaultRouter()
|
||||
admin_router.register(
|
||||
"categories", views.ProductCategoryAdminViewSet, basename="admin-category"
|
||||
)
|
||||
admin_router.register("products", views.ProductAdminViewSet, basename="admin-product")
|
||||
admin_router.register(
|
||||
"delivery-zones", views.DeliveryZoneAdminViewSet, basename="admin-delivery-zone"
|
||||
)
|
||||
admin_router.register(
|
||||
"delivery-overrides",
|
||||
views.DeliveryOverrideCodeAdminViewSet,
|
||||
basename="admin-delivery-override",
|
||||
)
|
||||
admin_router.register("orders", views.OrderAdminViewSet, basename="admin-order")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(public_router.urls)),
|
||||
path("admin/", include(admin_router.urls)),
|
||||
]
|
||||
82
api/apps/commerce/views.py
Normal file
82
api/apps/commerce/views.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from rest_framework import mixins, permissions, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from . import models, serializers
|
||||
|
||||
|
||||
class ProductPublicViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""Public storefront products."""
|
||||
|
||||
queryset = models.Product.objects.filter(is_active=True).select_related("category")
|
||||
serializer_class = serializers.ProductSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
lookup_field = "slug"
|
||||
|
||||
|
||||
class ProductAdminViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Product.objects.select_related("category").all()
|
||||
serializer_class = serializers.ProductSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class ProductCategoryPublicViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = models.ProductCategory.objects.filter(is_active=True)
|
||||
serializer_class = serializers.ProductCategorySerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
lookup_field = "slug"
|
||||
|
||||
|
||||
class ProductCategoryAdminViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.ProductCategory.objects.all()
|
||||
serializer_class = serializers.ProductCategorySerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class DeliveryZonePublicViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = models.DeliveryZone.objects.filter(is_active=True)
|
||||
serializer_class = serializers.DeliveryZoneSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
|
||||
class DeliveryZoneAdminViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.DeliveryZone.objects.all()
|
||||
serializer_class = serializers.DeliveryZoneSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class DeliveryOverrideCodeAdminViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.DeliveryOverrideCode.objects.all()
|
||||
serializer_class = serializers.DeliveryOverrideCodeSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
lookup_field = "code"
|
||||
|
||||
@action(methods=["post"], detail=True, permission_classes=[permissions.IsAuthenticated])
|
||||
def activate(self, request, code=None):
|
||||
override_code = self.get_object()
|
||||
override_code.is_active = True
|
||||
override_code.save(update_fields=["is_active"])
|
||||
return Response(self.get_serializer(override_code).data)
|
||||
|
||||
@action(methods=["post"], detail=True, permission_classes=[permissions.IsAuthenticated])
|
||||
def deactivate(self, request, code=None):
|
||||
override_code = self.get_object()
|
||||
override_code.is_active = False
|
||||
override_code.save(update_fields=["is_active"])
|
||||
return Response(self.get_serializer(override_code).data)
|
||||
|
||||
|
||||
class OrderPublicViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet):
|
||||
queryset = models.Order.objects.all()
|
||||
serializer_class = serializers.OrderSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
|
||||
class OrderAdminViewSet(viewsets.ModelViewSet):
|
||||
queryset = (
|
||||
models.Order.objects.select_related("delivery_override_code")
|
||||
.prefetch_related("items")
|
||||
.all()
|
||||
)
|
||||
serializer_class = serializers.OrderAdminSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
BIN
api/celerybeat-schedule
Normal file
BIN
api/celerybeat-schedule
Normal file
Binary file not shown.
3
api/config/__init__.py
Normal file
3
api/config/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .celery_app import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
7
api/config/asgi.py
Normal file
7
api/config/asgi.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
9
api/config/celery_app.py
Normal file
9
api/config/celery_app.py
Normal file
@@ -0,0 +1,9 @@
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
|
||||
app = Celery("fullmoon")
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
app.autodiscover_tasks()
|
||||
123
api/config/settings.py
Normal file
123
api/config/settings.py
Normal file
@@ -0,0 +1,123 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import dj_database_url
|
||||
from dotenv import load_dotenv
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Load environment variables from a sibling .env file if present (useful outside Docker)
|
||||
load_dotenv(BASE_DIR.parent / ".env")
|
||||
|
||||
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "insecure-dev-key")
|
||||
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"
|
||||
ALLOWED_HOSTS = [
|
||||
host.strip()
|
||||
for host in os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
|
||||
if host.strip()
|
||||
]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"rest_framework",
|
||||
"rest_framework.authtoken",
|
||||
"apps.commerce",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [BASE_DIR / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
ASGI_APPLICATION = "config.asgi.application"
|
||||
|
||||
database_url = os.environ.get("DATABASE_URL")
|
||||
|
||||
if database_url:
|
||||
DATABASES = {
|
||||
"default": dj_database_url.parse(database_url, conn_max_age=600),
|
||||
}
|
||||
else:
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": os.environ.get("POSTGRES_DB", "postgres"),
|
||||
"USER": os.environ.get("POSTGRES_USER", "postgres"),
|
||||
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", "postgres"),
|
||||
"HOST": os.environ.get("POSTGRES_HOST", "db-gpt"),
|
||||
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
|
||||
}
|
||||
}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = os.environ.get("DJANGO_TIME_ZONE", "America/Chicago")
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
|
||||
MEDIA_URL = "media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
"rest_framework.authentication.TokenAuthentication",
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
|
||||
],
|
||||
}
|
||||
|
||||
CELERY_BROKER_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
|
||||
CELERY_RESULT_BACKEND = CELERY_BROKER_URL
|
||||
CELERY_ACCEPT_CONTENT = ["json"]
|
||||
CELERY_TASK_SERIALIZER = "json"
|
||||
CELERY_RESULT_SERIALIZER = "json"
|
||||
14
api/config/urls.py
Normal file
14
api/config/urls.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
from rest_framework.authtoken.views import obtain_auth_token
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/commerce/", include("apps.commerce.urls")),
|
||||
path("api/auth/token/", obtain_auth_token, name="api-token"),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
7
api/config/wsgi.py
Normal file
7
api/config/wsgi.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
21
api/manage.py
Normal file
21
api/manage.py
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
8
api/requirements.txt
Normal file
8
api/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
django>=5.0,<6.0
|
||||
djangorestframework>=3.14,<4.0
|
||||
celery>=5.3,<6.0
|
||||
psycopg2-binary>=2.9
|
||||
python-dotenv>=1.0
|
||||
dj-database-url>=2.1
|
||||
redis>=5.0
|
||||
Pillow>=10.0
|
||||
Reference in New Issue
Block a user