feat(admin): add demo data management command and enhance low stock UI
Introduce `demo_data` management command for populating or removing demo fixtures in the system, facilitating testing and demonstration. Enhancements include low stock product list UI with product images and integration of value formatting for financial KPIs.
This commit is contained in:
parent
f3260686cf
commit
45a1813465
5 changed files with 720 additions and 144 deletions
587
engine/core/management/commands/demo_data.py
Normal file
587
engine/core/management/commands/demo_data.py
Normal file
|
|
@ -0,0 +1,587 @@
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import shutil
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from engine.core.models import (
|
||||||
|
Address,
|
||||||
|
Attribute,
|
||||||
|
AttributeGroup,
|
||||||
|
Brand,
|
||||||
|
Category,
|
||||||
|
CategoryTag,
|
||||||
|
Order,
|
||||||
|
OrderProduct,
|
||||||
|
Product,
|
||||||
|
ProductImage,
|
||||||
|
ProductTag,
|
||||||
|
Stock,
|
||||||
|
Vendor,
|
||||||
|
Wishlist,
|
||||||
|
)
|
||||||
|
from engine.payments.models import Balance
|
||||||
|
from engine.vibes_auth.models import Group, User
|
||||||
|
|
||||||
|
DEMO_EMAIL_DOMAIN = "demo.schon.store"
|
||||||
|
DEMO_VENDOR_NAME = "GemSource Global"
|
||||||
|
DEMO_IMAGES_DIR = Path(settings.BASE_DIR) / "engine/core/fixtures/demo_products_images"
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Install or remove demo fixtures for Schon"
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.demo_data: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
subparsers = parser.add_subparsers(dest="action", help="Action to perform")
|
||||||
|
|
||||||
|
install_parser = subparsers.add_parser("install", help="Install demo fixtures")
|
||||||
|
install_parser.add_argument(
|
||||||
|
"--users",
|
||||||
|
type=int,
|
||||||
|
default=50,
|
||||||
|
help="Number of demo users to create (default: 50)",
|
||||||
|
)
|
||||||
|
install_parser.add_argument(
|
||||||
|
"--orders",
|
||||||
|
type=int,
|
||||||
|
default=100,
|
||||||
|
help="Number of demo orders to create (default: 100)",
|
||||||
|
)
|
||||||
|
install_parser.add_argument(
|
||||||
|
"--days",
|
||||||
|
type=int,
|
||||||
|
default=30,
|
||||||
|
help="Number of days to spread orders over (default: 30)",
|
||||||
|
)
|
||||||
|
install_parser.add_argument(
|
||||||
|
"--skip-products",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip creating gem products (use if already created)",
|
||||||
|
)
|
||||||
|
|
||||||
|
subparsers.add_parser("remove", help="Remove all demo fixtures")
|
||||||
|
|
||||||
|
def handle(self, *args: list[Any], **options: dict[str, Any]) -> None:
|
||||||
|
action = options.get("action")
|
||||||
|
|
||||||
|
if not action:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR("Please specify an action: install or remove")
|
||||||
|
)
|
||||||
|
self.stdout.write("Usage: python manage.py demo_data install|remove")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._load_demo_data()
|
||||||
|
|
||||||
|
if action == "install":
|
||||||
|
self._install(options)
|
||||||
|
elif action == "remove":
|
||||||
|
self._remove()
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.ERROR(f"Unknown action: {action}"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def staff_user(self):
|
||||||
|
user, _ = User.objects.get_or_create(
|
||||||
|
email=f"staff@{DEMO_EMAIL_DOMAIN}",
|
||||||
|
password="Staff!Demo888",
|
||||||
|
first_name="Alice",
|
||||||
|
last_name="Schon",
|
||||||
|
is_staff=True,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
if not user.groups.filter(name="E-Commerce Admin").exists():
|
||||||
|
user.groups.add(Group.objects.get(name="E-Commerce Admin"))
|
||||||
|
return user
|
||||||
|
|
||||||
|
@property
|
||||||
|
def super_user(self):
|
||||||
|
user, _ = User.objects.get_or_create(
|
||||||
|
email=f"super@{DEMO_EMAIL_DOMAIN}",
|
||||||
|
password="Super!Demo888",
|
||||||
|
first_name="Bob",
|
||||||
|
last_name="Schon",
|
||||||
|
is_superuser=True,
|
||||||
|
is_staff=True,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def _load_demo_data(self) -> None:
|
||||||
|
fixture_path = Path(settings.BASE_DIR) / "engine/core/fixtures/demo.json"
|
||||||
|
with open(fixture_path, encoding="utf-8") as f:
|
||||||
|
self.demo_data = json.load(f)
|
||||||
|
|
||||||
|
def _install(self, options: dict[str, Any]) -> None:
|
||||||
|
num_users = options["users"]
|
||||||
|
num_orders = options["orders"]
|
||||||
|
num_days = options["days"]
|
||||||
|
skip_products = options.get("skip_products", False)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.NOTICE("Starting demo fixture installation..."))
|
||||||
|
|
||||||
|
if not skip_products:
|
||||||
|
self.stdout.write("Creating gem products with translations...")
|
||||||
|
self._create_gem_products()
|
||||||
|
self.stdout.write(self.style.SUCCESS("Gem products created!"))
|
||||||
|
|
||||||
|
self.stdout.write(f"Creating {num_users} demo users...")
|
||||||
|
users = self._create_demo_users(num_users)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Created {len(users)} users"))
|
||||||
|
|
||||||
|
products = list(Product.objects.filter(is_active=True))
|
||||||
|
if not products:
|
||||||
|
self.stdout.write(self.style.ERROR("No products found!"))
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stdout.write(f"Creating {num_orders} orders over {num_days} days...")
|
||||||
|
orders, refunded_count = self._create_demo_orders(
|
||||||
|
users, products, num_orders, num_days
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Created {len(orders)} orders ({refunded_count} refunded)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write("Creating wishlists for demo users...")
|
||||||
|
wishlist_count = self._create_demo_wishlists(users, products)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Created {wishlist_count} wishlists"))
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"Created staff {self.staff_user.email} user")
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"Created super {self.super_user.email} user")
|
||||||
|
)
|
||||||
|
|
||||||
|
self._print_summary(users, orders, refunded_count, num_days)
|
||||||
|
|
||||||
|
def _remove(self) -> None:
|
||||||
|
self.stdout.write(self.style.WARNING("Removing demo fixtures..."))
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
demo_users = User.objects.filter(email__endswith=f"@{DEMO_EMAIL_DOMAIN}")
|
||||||
|
user_count = demo_users.count()
|
||||||
|
|
||||||
|
orders = Order.objects.filter(user__in=demo_users)
|
||||||
|
order_count = orders.count()
|
||||||
|
|
||||||
|
OrderProduct.objects.filter(order__in=orders).delete()
|
||||||
|
|
||||||
|
orders.delete()
|
||||||
|
|
||||||
|
Address.objects.filter(user__in=demo_users).delete()
|
||||||
|
|
||||||
|
Balance.objects.filter(user__in=demo_users).delete()
|
||||||
|
|
||||||
|
Wishlist.objects.filter(user__in=demo_users).delete()
|
||||||
|
|
||||||
|
demo_users.delete()
|
||||||
|
|
||||||
|
try:
|
||||||
|
vendor = Vendor.objects.get(name=DEMO_VENDOR_NAME)
|
||||||
|
Stock.objects.filter(vendor=vendor).delete()
|
||||||
|
vendor.delete()
|
||||||
|
self.stdout.write(f" Removed vendor: {DEMO_VENDOR_NAME}")
|
||||||
|
except Vendor.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
partnumbers = [p["partnumber"] for p in self.demo_data["products"]]
|
||||||
|
products = Product.objects.filter(partnumber__in=partnumbers)
|
||||||
|
product_count = products.count()
|
||||||
|
|
||||||
|
for product in products:
|
||||||
|
for image in product.images.all():
|
||||||
|
if image.image:
|
||||||
|
image.image.delete(save=False)
|
||||||
|
image.delete()
|
||||||
|
product_dir: Path = settings.MEDIA_ROOT / "products" / str(product.uuid)
|
||||||
|
if product_dir.exists() and not any(product_dir.iterdir()):
|
||||||
|
shutil.rmtree(product_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
products.delete()
|
||||||
|
|
||||||
|
brand_names = [b["name"] for b in self.demo_data["brands"]]
|
||||||
|
Brand.objects.filter(name__in=brand_names).delete()
|
||||||
|
|
||||||
|
for cat_data in reversed(self.demo_data["categories"]):
|
||||||
|
Category.objects.filter(name=cat_data["name"]).delete()
|
||||||
|
|
||||||
|
tag_names = [t["tag_name"] for t in self.demo_data["category_tags"]]
|
||||||
|
CategoryTag.objects.filter(tag_name__in=tag_names).delete()
|
||||||
|
|
||||||
|
tag_names = [t["tag_name"] for t in self.demo_data["product_tags"]]
|
||||||
|
ProductTag.objects.filter(tag_name__in=tag_names).delete()
|
||||||
|
|
||||||
|
group_names = [g["name"] for g in self.demo_data["attribute_groups"]]
|
||||||
|
Attribute.objects.filter(group__name__in=group_names).delete()
|
||||||
|
AttributeGroup.objects.filter(name__in=group_names).delete()
|
||||||
|
|
||||||
|
self.staff_user.delete()
|
||||||
|
self.super_user.delete()
|
||||||
|
|
||||||
|
self.stdout.write("")
|
||||||
|
self.stdout.write(self.style.SUCCESS("=" * 50))
|
||||||
|
self.stdout.write(self.style.SUCCESS("Demo fixtures removed successfully!"))
|
||||||
|
self.stdout.write(self.style.SUCCESS("=" * 50))
|
||||||
|
self.stdout.write(f" Users removed: {user_count}")
|
||||||
|
self.stdout.write(f" Orders removed: {order_count}")
|
||||||
|
self.stdout.write(f" Products removed: {product_count}")
|
||||||
|
|
||||||
|
def _print_summary(
|
||||||
|
self, users: list, orders: list, refunded_count: int, num_days: int
|
||||||
|
) -> None:
|
||||||
|
password = self.demo_data["demo_users"]["password"]
|
||||||
|
|
||||||
|
self.stdout.write("")
|
||||||
|
self.stdout.write(self.style.SUCCESS("=" * 50))
|
||||||
|
self.stdout.write(self.style.SUCCESS("Demo fixtures installed successfully!"))
|
||||||
|
self.stdout.write(self.style.SUCCESS("=" * 50))
|
||||||
|
self.stdout.write(f" Products: {Product.objects.count()}")
|
||||||
|
self.stdout.write(f" Categories: {Category.objects.count()}")
|
||||||
|
self.stdout.write(f" Brands: {Brand.objects.count()}")
|
||||||
|
self.stdout.write(f" Users created: {len(users)}")
|
||||||
|
self.stdout.write(f" Orders created: {len(orders)}")
|
||||||
|
self.stdout.write(f" Refunded orders: {refunded_count}")
|
||||||
|
self.stdout.write(f" Date range: {num_days} days")
|
||||||
|
self.stdout.write(f" Demo password: {password}")
|
||||||
|
self.stdout.write("")
|
||||||
|
self.stdout.write("Sample demo accounts:")
|
||||||
|
for user in users[:5]:
|
||||||
|
self.stdout.write(f" - {user.email}")
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def _create_gem_products(self) -> None:
|
||||||
|
data = self.demo_data
|
||||||
|
|
||||||
|
vendor, _ = Vendor.objects.get_or_create(
|
||||||
|
name=data["vendor"]["name"],
|
||||||
|
defaults={"markup_percent": data["vendor"]["markup_percent"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
for tag_data in data["category_tags"]:
|
||||||
|
tag, created = CategoryTag.objects.get_or_create(
|
||||||
|
tag_name=tag_data["tag_name"],
|
||||||
|
defaults={"name": tag_data["name"]},
|
||||||
|
)
|
||||||
|
if created and "name_ru" in tag_data:
|
||||||
|
tag.name_ru_ru = tag_data["name_ru"]
|
||||||
|
tag.save()
|
||||||
|
|
||||||
|
for tag_data in data["product_tags"]:
|
||||||
|
tag, created = ProductTag.objects.get_or_create(
|
||||||
|
tag_name=tag_data["tag_name"],
|
||||||
|
defaults={"name": tag_data["name"]},
|
||||||
|
)
|
||||||
|
if created and "name_ru" in tag_data:
|
||||||
|
tag.name_ru_ru = tag_data["name_ru"]
|
||||||
|
tag.save()
|
||||||
|
|
||||||
|
attr_groups = {}
|
||||||
|
for group_data in data["attribute_groups"]:
|
||||||
|
group, created = AttributeGroup.objects.get_or_create(
|
||||||
|
name=group_data["name"]
|
||||||
|
)
|
||||||
|
if created and "name_ru" in group_data:
|
||||||
|
group.name_ru_ru = group_data["name_ru"]
|
||||||
|
group.save()
|
||||||
|
attr_groups[group_data["name"]] = group
|
||||||
|
|
||||||
|
for attr_data in data["attributes"]:
|
||||||
|
group = attr_groups.get(attr_data["group"])
|
||||||
|
if group:
|
||||||
|
attr, created = Attribute.objects.get_or_create(
|
||||||
|
group=group,
|
||||||
|
name=attr_data["name"],
|
||||||
|
defaults={
|
||||||
|
"value_type": attr_data["value_type"],
|
||||||
|
"is_filterable": attr_data["is_filterable"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if created and "name_ru" in attr_data:
|
||||||
|
attr.name_ru_ru = attr_data["name_ru"]
|
||||||
|
attr.save()
|
||||||
|
|
||||||
|
brands = {}
|
||||||
|
for brand_data in data["brands"]:
|
||||||
|
brand, created = Brand.objects.get_or_create(
|
||||||
|
name=brand_data["name"],
|
||||||
|
defaults={"description": brand_data["description"]},
|
||||||
|
)
|
||||||
|
if created and "description_ru" in brand_data:
|
||||||
|
brand.description_ru_ru = brand_data["description_ru"]
|
||||||
|
brand.save()
|
||||||
|
brands[brand_data["name"]] = brand
|
||||||
|
|
||||||
|
categories = {}
|
||||||
|
for cat_data in data["categories"]:
|
||||||
|
parent = categories.get(cat_data["parent"]) if cat_data["parent"] else None
|
||||||
|
category, created = Category.objects.get_or_create(
|
||||||
|
name=cat_data["name"],
|
||||||
|
defaults={
|
||||||
|
"description": cat_data["description"],
|
||||||
|
"parent": parent,
|
||||||
|
"markup_percent": cat_data["markup_percent"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
if "name_ru" in cat_data:
|
||||||
|
category.name_ru_ru = cat_data["name_ru"]
|
||||||
|
if "description_ru" in cat_data:
|
||||||
|
category.description_ru_ru = cat_data["description_ru"]
|
||||||
|
category.save()
|
||||||
|
categories[cat_data["name"]] = category
|
||||||
|
|
||||||
|
for prod_data in data["products"]:
|
||||||
|
category = categories.get(prod_data["category"])
|
||||||
|
brand = brands.get(prod_data["brand"])
|
||||||
|
|
||||||
|
if not category:
|
||||||
|
continue
|
||||||
|
|
||||||
|
product, created = Product.objects.get_or_create(
|
||||||
|
partnumber=prod_data["partnumber"],
|
||||||
|
defaults={
|
||||||
|
"name": prod_data["name"],
|
||||||
|
"description": prod_data["description"],
|
||||||
|
"category": category,
|
||||||
|
"brand": brand,
|
||||||
|
"is_digital": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
if "name_ru" in prod_data:
|
||||||
|
product.name_ru_ru = prod_data["name_ru"]
|
||||||
|
if "description_ru" in prod_data:
|
||||||
|
product.description_ru_ru = prod_data["description_ru"]
|
||||||
|
product.save()
|
||||||
|
|
||||||
|
Stock.objects.create(
|
||||||
|
vendor=vendor,
|
||||||
|
product=product,
|
||||||
|
sku=f"GS-{prod_data['partnumber']}",
|
||||||
|
price=prod_data["price"],
|
||||||
|
purchase_price=prod_data["purchase_price"],
|
||||||
|
quantity=prod_data["quantity"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add product image
|
||||||
|
self._add_product_image(product, prod_data["partnumber"])
|
||||||
|
|
||||||
|
def _add_product_image(self, product: Product, partnumber: str) -> None:
|
||||||
|
image_path = DEMO_IMAGES_DIR / f"{partnumber}.jpg"
|
||||||
|
if not image_path.exists():
|
||||||
|
image_path = DEMO_IMAGES_DIR / "placeholder.png"
|
||||||
|
|
||||||
|
if not image_path.exists():
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f" No image found for {partnumber}, skipping...")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(image_path, "rb") as f:
|
||||||
|
image_content = f.read()
|
||||||
|
|
||||||
|
filename = image_path.name
|
||||||
|
product_image = ProductImage(
|
||||||
|
product=product,
|
||||||
|
alt=product.name,
|
||||||
|
priority=1,
|
||||||
|
)
|
||||||
|
product_image.image.save(filename, ContentFile(image_content), save=True)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def _create_demo_users(self, count: int) -> list:
|
||||||
|
users = []
|
||||||
|
user_data = self.demo_data["demo_users"]
|
||||||
|
existing_emails = set(User.objects.values_list("email", flat=True))
|
||||||
|
|
||||||
|
first_names = user_data["first_names"]
|
||||||
|
last_names = user_data["last_names"]
|
||||||
|
cities = user_data["cities"]
|
||||||
|
streets = user_data["streets"]
|
||||||
|
password = user_data["password"]
|
||||||
|
email_domain = user_data["email_domain"]
|
||||||
|
|
||||||
|
for _ in range(count):
|
||||||
|
first_name = random.choice(first_names)
|
||||||
|
last_name = random.choice(last_names)
|
||||||
|
|
||||||
|
base_email = f"{first_name.lower()}.{last_name.lower()}@{email_domain}"
|
||||||
|
email = base_email
|
||||||
|
counter = 1
|
||||||
|
while email in existing_emails:
|
||||||
|
email = (
|
||||||
|
f"{first_name.lower()}.{last_name.lower()}{counter}@{email_domain}"
|
||||||
|
)
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
existing_emails.add(email)
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
user = User(
|
||||||
|
email=email,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
user.set_password(password)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
Balance.objects.get_or_create(
|
||||||
|
user=user,
|
||||||
|
defaults={"amount": round(random.uniform(100, 5000), 2)},
|
||||||
|
)
|
||||||
|
|
||||||
|
city_data = random.choice(cities)
|
||||||
|
street_num = random.randint(1, 999)
|
||||||
|
street = random.choice(streets)
|
||||||
|
address_line = (
|
||||||
|
f"{street_num} {street}, {city_data['city']}, "
|
||||||
|
f"{city_data['region']} {city_data['postal_code']}, {city_data['country']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
address = Address(
|
||||||
|
user=user,
|
||||||
|
street=f"{street_num} {street}",
|
||||||
|
city=city_data["city"],
|
||||||
|
region=city_data["region"],
|
||||||
|
postal_code=city_data["postal_code"],
|
||||||
|
country=city_data["country"],
|
||||||
|
address_line=address_line,
|
||||||
|
raw_data=address_line,
|
||||||
|
)
|
||||||
|
address.save()
|
||||||
|
|
||||||
|
users.append(user)
|
||||||
|
|
||||||
|
return users
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def _create_demo_orders(
|
||||||
|
self,
|
||||||
|
users: list,
|
||||||
|
products: list,
|
||||||
|
count: int,
|
||||||
|
days: int,
|
||||||
|
) -> tuple[list, int]:
|
||||||
|
orders = []
|
||||||
|
refunded_count = 0
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
refund_target = int(count * 0.08)
|
||||||
|
refund_indices = set(random.sample(range(count), refund_target))
|
||||||
|
|
||||||
|
day_weights = [1 + (i / days) for i in range(days)]
|
||||||
|
total_weight = sum(day_weights)
|
||||||
|
day_probabilities = [w / total_weight for w in day_weights]
|
||||||
|
|
||||||
|
for i in range(count):
|
||||||
|
user = random.choice(users)
|
||||||
|
|
||||||
|
is_refunded = i in refund_indices
|
||||||
|
|
||||||
|
day_offset = random.choices(range(days), weights=day_probabilities)[0]
|
||||||
|
order_date = now - timedelta(
|
||||||
|
days=days - 1 - day_offset,
|
||||||
|
hours=random.randint(0, 23),
|
||||||
|
minutes=random.randint(0, 59),
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_refunded:
|
||||||
|
status = "FAILED"
|
||||||
|
refunded_count += 1
|
||||||
|
elif day_offset > days * 0.7: # Recent orders
|
||||||
|
status = random.choice(["CREATED", "DELIVERING", "FINISHED"])
|
||||||
|
else:
|
||||||
|
status = "FINISHED"
|
||||||
|
|
||||||
|
address = Address.objects.filter(user=user).first()
|
||||||
|
|
||||||
|
order = Order.objects.create(
|
||||||
|
user=user,
|
||||||
|
status=status,
|
||||||
|
buy_time=order_date,
|
||||||
|
billing_address=address,
|
||||||
|
shipping_address=address,
|
||||||
|
)
|
||||||
|
|
||||||
|
Order.objects.filter(pk=order.pk).update(created=order_date)
|
||||||
|
|
||||||
|
num_products = random.randint(1, 4)
|
||||||
|
order_products = random.sample(products, min(num_products, len(products)))
|
||||||
|
|
||||||
|
for product in order_products:
|
||||||
|
quantity = random.randint(1, 3)
|
||||||
|
price = product.price if product.price else random.uniform(100, 5000)
|
||||||
|
|
||||||
|
if is_refunded:
|
||||||
|
op_status = "RETURNED"
|
||||||
|
elif status == "FINISHED":
|
||||||
|
op_status = "FINISHED"
|
||||||
|
elif status == "DELIVERING":
|
||||||
|
op_status = random.choice(["DELIVERING", "DELIVERED"])
|
||||||
|
else:
|
||||||
|
op_status = random.choice(["ACCEPTED", "PENDING"])
|
||||||
|
|
||||||
|
OrderProduct.objects.create(
|
||||||
|
order=order,
|
||||||
|
product=product,
|
||||||
|
quantity=quantity,
|
||||||
|
buy_price=round(price, 2),
|
||||||
|
status=op_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
orders.append(order)
|
||||||
|
|
||||||
|
return orders, refunded_count
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def _create_demo_wishlists(self, users: list, products: list) -> int:
|
||||||
|
"""
|
||||||
|
Ensure exactly 5 products are wishlisted, each by 2-4 random demo users.
|
||||||
|
"""
|
||||||
|
if len(products) < 5:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f"Not enough products ({len(products)}) to create 5 wishlisted items"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
wishlisted_products = products
|
||||||
|
else:
|
||||||
|
wishlisted_products = random.sample(products, 5)
|
||||||
|
|
||||||
|
if len(users) < 2:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING("Not enough users to create wishlists")
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
users_with_wishlists = set()
|
||||||
|
|
||||||
|
for product in wishlisted_products:
|
||||||
|
num_users = random.randint(2, min(4, len(users)))
|
||||||
|
selected_users = random.sample(users, num_users)
|
||||||
|
|
||||||
|
for user in selected_users:
|
||||||
|
wishlist, _ = Wishlist.objects.get_or_create(user=user)
|
||||||
|
wishlist.products.add(product)
|
||||||
|
users_with_wishlists.add(user.id)
|
||||||
|
|
||||||
|
return len(users_with_wishlists)
|
||||||
|
|
@ -1,52 +1,30 @@
|
||||||
{% load i18n unfold arith %}
|
{% load i18n unfold arith humanize %}
|
||||||
|
|
||||||
{% with gross=revenue_gross_30|default:0 returns=returns_30|default:0 %}
|
{% with gross=revenue_gross_30|default:0 net=revenue_net_30|default:0 returns=returns_30|default:0 %}
|
||||||
{% with total=gross|add:returns %}
|
|
||||||
{% component "unfold/components/card.html" with class="xl:col-span-2" %}
|
{% component "unfold/components/card.html" with class="xl:col-span-2" %}
|
||||||
{% component "unfold/components/title.html" %}
|
{% component "unfold/components/title.html" %}
|
||||||
{% trans "Income overview" %}
|
{% trans "Income overview" %}
|
||||||
{% endcomponent %}
|
{% endcomponent %}
|
||||||
|
|
||||||
{% with net=revenue_net_30|default:0 %}
|
|
||||||
{% with tax_amt=gross|sub:net %}
|
|
||||||
{% with returns_capped=returns %}
|
|
||||||
{% if returns > gross %}
|
|
||||||
{% with returns_capped=gross %}{% endwith %}
|
|
||||||
{% endif %}
|
|
||||||
{% with tax_amt_pos=tax_amt %}
|
|
||||||
{% if tax_amt_pos < 0 %}
|
|
||||||
{% with tax_amt_pos=0 %}{% endwith %}
|
|
||||||
{% endif %}
|
|
||||||
{% with net_for_pie=gross|sub:tax_amt_pos|sub:returns_capped %}
|
|
||||||
{% if net_for_pie < 0 %}
|
|
||||||
{% with net_for_pie=0 %}{% endwith %}
|
|
||||||
{% endif %}
|
|
||||||
<div class="flex flex-col sm:flex-row items-center gap-6">
|
<div class="flex flex-col sm:flex-row items-center gap-6">
|
||||||
<div class="relative w-48 h-48">
|
<div class="relative w-48 h-48">
|
||||||
<canvas id="incomePieChart" width="192" height="192"></canvas>
|
<canvas id="incomePieChart" width="192" height="192"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="space-y-2">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="inline-block w-3 h-3 rounded-sm" style="background:rgb(34,197,94)"></span>
|
<span class="inline-block w-3 h-3 rounded-sm" style="background:rgb(34,197,94)"></span>
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Net" %}:</span>
|
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Net revenue" %}:</span>
|
||||||
<span class="font-semibold">{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ net }}</span>
|
<span class="font-semibold">{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ net|floatformat:0|intcomma }}</span>
|
||||||
</div>
|
|
||||||
{% if tax_amt_pos > 0 %}
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<span class="inline-block w-3 h-3 rounded-sm" style="background:rgb(249,115,22)"></span>
|
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Taxes" %}:</span>
|
|
||||||
<span class="font-semibold">{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ tax_amt_pos|floatformat:2 }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<span class="inline-block w-3 h-3 rounded-sm" style="background:rgb(239,68,68)"></span>
|
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Returns" %}:</span>
|
|
||||||
<span class="font-semibold">{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ returns }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="inline-block w-3 h-3 rounded-sm" style="background:linear-gradient(90deg, rgba(0,0,0,0.15), rgba(0,0,0,0.15))"></span>
|
<span class="inline-block w-3 h-3 rounded-sm" style="background:rgb(239,68,68)"></span>
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Gross (pie total)" %}:</span>
|
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Returns" %}:</span>
|
||||||
<span class="font-semibold">{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ gross }}</span>
|
<span class="font-semibold">{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ returns|floatformat:0|intcomma }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<span class="inline-block w-3 h-3 rounded-sm" style="background:rgb(59,130,246)"></span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "GMV" %}:</span>
|
||||||
|
<span class="font-semibold">{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ gross|floatformat:0|intcomma }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -56,15 +34,15 @@
|
||||||
try {
|
try {
|
||||||
const ctx = document.getElementById('incomePieChart').getContext('2d');
|
const ctx = document.getElementById('incomePieChart').getContext('2d');
|
||||||
const gross = Number({{ gross|default:0 }});
|
const gross = Number({{ gross|default:0 }});
|
||||||
const netBackend = Number({{ net|default:0 }});
|
const net = Number({{ net|default:0 }});
|
||||||
const returnsRaw = Number({{ returns_capped|default:0 }});
|
const returns = Number({{ returns|default:0 }});
|
||||||
const taxes = Math.max(gross - netBackend, 0);
|
|
||||||
const returnsVal = Math.min(returnsRaw, gross);
|
|
||||||
const netVal = Math.max(gross - taxes - returnsVal, 0);
|
|
||||||
|
|
||||||
let dataValues = [netVal, taxes, returnsVal];
|
const netVal = Math.max(net, 0);
|
||||||
let labels = ['{{ _("Net") }}', '{{ _("Taxes") }}', '{{ _("Returns") }}'];
|
const returnsVal = Math.max(Math.min(returns, gross), 0);
|
||||||
let colors = ['rgb(34,197,94)', 'rgb(249,115,22)', 'rgb(239,68,68)'];
|
|
||||||
|
let dataValues = [netVal, returnsVal];
|
||||||
|
let labels = ['{{ _("Net revenue") }}', '{{ _("Returns") }}'];
|
||||||
|
let colors = ['rgb(34,197,94)', 'rgb(239,68,68)'];
|
||||||
|
|
||||||
const sum = dataValues.reduce((a, b) => a + Number(b || 0), 0);
|
const sum = dataValues.reduce((a, b) => a + Number(b || 0), 0);
|
||||||
if (sum <= 0) {
|
if (sum <= 0) {
|
||||||
|
|
@ -74,6 +52,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const currency = "{% if currency_symbol %}{{ currency_symbol }}{% endif %}";
|
const currency = "{% if currency_symbol %}{{ currency_symbol }}{% endif %}";
|
||||||
|
|
||||||
|
function formatNumber(num) {
|
||||||
|
return num.toLocaleString('en-US', {maximumFractionDigits: 0});
|
||||||
|
}
|
||||||
|
|
||||||
new Chart(ctx, {
|
new Chart(ctx, {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -81,26 +64,28 @@
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: dataValues,
|
data: dataValues,
|
||||||
backgroundColor: colors,
|
backgroundColor: colors,
|
||||||
borderWidth: 0,
|
borderWidth: 2,
|
||||||
|
borderColor: document.documentElement.classList.contains('dark') ? '#1f2937' : '#ffffff',
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {position: 'bottom'},
|
legend: {display: false},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: function (context) {
|
label: function (context) {
|
||||||
const label = context.label || '';
|
const label = context.label || '';
|
||||||
const val = context.parsed || 0;
|
const val = context.parsed || 0;
|
||||||
|
const pct = sum > 0 ? ((val / sum) * 100).toFixed(1) : 0;
|
||||||
if (labels.length === 1) return label;
|
if (labels.length === 1) return label;
|
||||||
return `${label}: ${currency}${val}`;
|
return `${label}: ${currency}${formatNumber(val)} (${pct}%)`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
cutout: '55%'
|
cutout: '60%'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -108,11 +93,5 @@
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endwith %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endcomponent %}
|
{% endcomponent %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endwith %}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{% load i18n unfold %}
|
{% load i18n unfold humanize %}
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4 mb-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4 mb-4">
|
||||||
{% component "unfold/components/card.html" %}
|
{% component "unfold/components/card.html" %}
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
{% component "unfold/components/title.html" %}
|
{% component "unfold/components/title.html" %}
|
||||||
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ kpi.gmv.value|default:0 }}
|
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ kpi.gmv.value|default:0|floatformat:0|intcomma }}
|
||||||
{% endcomponent %}
|
{% endcomponent %}
|
||||||
{% endcomponent %}
|
{% endcomponent %}
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
{% component "unfold/components/title.html" %}
|
{% component "unfold/components/title.html" %}
|
||||||
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ kpi.aov.value|default:0 }}
|
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ kpi.aov.value|default:0|floatformat:2|intcomma }}
|
||||||
{% endcomponent %}
|
{% endcomponent %}
|
||||||
{% endcomponent %}
|
{% endcomponent %}
|
||||||
|
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
{% component "unfold/components/title.html" %}
|
{% component "unfold/components/title.html" %}
|
||||||
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ kpi.net.value|default:0 }}
|
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ kpi.net.value|default:0|floatformat:0|intcomma }}
|
||||||
{% endcomponent %}
|
{% endcomponent %}
|
||||||
{% endcomponent %}
|
{% endcomponent %}
|
||||||
|
|
||||||
|
|
@ -70,7 +70,7 @@
|
||||||
{{ kpi.refund_rate.value|default:0 }}%
|
{{ kpi.refund_rate.value|default:0 }}%
|
||||||
{% endcomponent %}
|
{% endcomponent %}
|
||||||
{% component "unfold/components/text.html" with class="text-xs text-gray-500 dark:text-gray-400" %}
|
{% component "unfold/components/text.html" with class="text-xs text-gray-500 dark:text-gray-400" %}
|
||||||
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ returns_amount|default:0 }} {% trans "returned" %}
|
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ returns_amount|default:0|floatformat:0|intcomma }} {% trans "returned" %}
|
||||||
{% endcomponent %}
|
{% endcomponent %}
|
||||||
{% endcomponent %}
|
{% endcomponent %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,24 +6,28 @@
|
||||||
{% endcomponent %}
|
{% endcomponent %}
|
||||||
|
|
||||||
{% if low_stock_products %}
|
{% if low_stock_products %}
|
||||||
<div class="divide-y divide-gray-100 dark:divide-gray-800">
|
<ul class="flex flex-col divide-y divide-gray-200 dark:divide-base-700/50">
|
||||||
{% for p in low_stock_products %}
|
{% for p in low_stock_products %}
|
||||||
<a href="{{ p.admin_url }}" class="flex items-center justify-between py-2 hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-md px-2 -mx-2">
|
<li class="py-2 first:pt-0 last:pb-0">
|
||||||
<div class="min-w-0">
|
<a href="{{ p.admin_url }}" class="flex items-center gap-4">
|
||||||
<div class="truncate font-medium">{{ p.name }}</div>
|
{% if p.image %}
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">SKU: {{ p.sku }}</div>
|
<img src="{{ p.image }}" alt="{{ p.name }}"
|
||||||
|
class="w-12 h-12 object-cover rounded"/>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<span class="font-medium truncate block">{{ p.name }}</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ p.sku }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm">
|
<span class="text-xs px-2 py-0.5 rounded-full
|
||||||
<span class="px-2 py-0.5 rounded-full text-xs
|
|
||||||
{% if p.qty|default:0 <= 0 %} bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300
|
{% if p.qty|default:0 <= 0 %} bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300
|
||||||
{% elif p.qty <= 5 %} bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300
|
{% elif p.qty <= 5 %} bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300
|
||||||
{% else %} bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200 {% endif %}">
|
{% else %} bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200 {% endif %}">
|
||||||
{{ p.qty }}
|
{{ p.qty }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
</a>
|
</a>
|
||||||
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% component "unfold/components/text.html" with class="text-sm text-gray-500 dark:text-gray-400" %}
|
{% component "unfold/components/text.html" with class="text-sm text-gray-500 dark:text-gray-400" %}
|
||||||
{% trans "No low stock items." %}
|
{% trans "No low stock items." %}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import traceback
|
import traceback
|
||||||
|
from contextlib import suppress
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
@ -456,8 +457,9 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
|
||||||
prev_end = cur_start
|
prev_end = cur_start
|
||||||
|
|
||||||
revenue_gross_cur: float = get_revenue(clear=False, period=period)
|
revenue_gross_cur: float = get_revenue(clear=False, period=period)
|
||||||
revenue_net_cur: float = get_revenue(clear=True, period=period)
|
|
||||||
returns_cur: float = get_returns(period=period)
|
returns_cur: float = get_returns(period=period)
|
||||||
|
revenue_net_before_returns: float = get_revenue(clear=True, period=period)
|
||||||
|
revenue_net_cur: float = max(revenue_net_before_returns - returns_cur, 0.0)
|
||||||
processed_orders_cur: int = get_total_processed_orders(period=period)
|
processed_orders_cur: int = get_total_processed_orders(period=period)
|
||||||
|
|
||||||
orders_finished_cur: int = Order.objects.filter(
|
orders_finished_cur: int = Order.objects.filter(
|
||||||
|
|
@ -500,16 +502,18 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
|
||||||
tax_included = bool(getattr(config, "TAX_INCLUDED", False))
|
tax_included = bool(getattr(config, "TAX_INCLUDED", False))
|
||||||
|
|
||||||
if tax_rate <= 0:
|
if tax_rate <= 0:
|
||||||
revenue_net_prev = revenue_gross_prev
|
revenue_net_before_returns_prev = revenue_gross_prev
|
||||||
else:
|
else:
|
||||||
if tax_included:
|
if tax_included:
|
||||||
divisor = 1.0 + (tax_rate / 100.0)
|
divisor = 1.0 + (tax_rate / 100.0)
|
||||||
revenue_net_prev = (
|
revenue_net_before_returns_prev = (
|
||||||
revenue_gross_prev / divisor if divisor > 0 else revenue_gross_prev
|
revenue_gross_prev / divisor if divisor > 0 else revenue_gross_prev
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
revenue_net_prev = revenue_gross_prev
|
revenue_net_before_returns_prev = revenue_gross_prev
|
||||||
revenue_net_prev = round(float(revenue_net_prev or 0.0), 2)
|
revenue_net_prev = max(
|
||||||
|
round(float(revenue_net_before_returns_prev or 0.0), 2) - returns_prev, 0.0
|
||||||
|
)
|
||||||
|
|
||||||
def pct_delta(cur: float | int, prev: float | int) -> float:
|
def pct_delta(cur: float | int, prev: float | int) -> float:
|
||||||
cur_f = float(cur or 0)
|
cur_f = float(cur or 0)
|
||||||
|
|
@ -684,23 +688,25 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to build daily stats: %s", exc)
|
logger.error("Failed to build daily stats: %s", exc)
|
||||||
|
|
||||||
low_stock_list: list[dict[str, str | int]] = []
|
low_stock_list: list[dict[str, str | int | None]] = []
|
||||||
try:
|
try:
|
||||||
products = (
|
products = (
|
||||||
Product.objects.annotate(total_qty=Sum("stocks__quantity"))
|
Product.objects.annotate(total_qty=Sum("stocks__quantity"))
|
||||||
.values("uuid", "name", "sku", "total_qty")
|
.prefetch_related("images")
|
||||||
.order_by("total_qty")[:5]
|
.order_by("total_qty")[:5]
|
||||||
)
|
)
|
||||||
for p in products:
|
for p in products:
|
||||||
qty = int(p.get("total_qty") or 0)
|
qty = int(p.total_qty or 0)
|
||||||
|
img = ""
|
||||||
|
with suppress(Exception):
|
||||||
|
img = p.images.first().image_url if p.images.exists() else ""
|
||||||
low_stock_list.append(
|
low_stock_list.append(
|
||||||
{
|
{
|
||||||
"name": str(p.get("name") or ""),
|
"name": p.name,
|
||||||
"sku": str(p.get("sku") or ""),
|
"sku": p.sku or "",
|
||||||
"qty": qty,
|
"qty": qty,
|
||||||
"admin_url": reverse(
|
"image": img,
|
||||||
"admin:core_product_change", args=[p.get("id")]
|
"admin_url": reverse("admin:core_product_change", args=[p.pk]),
|
||||||
),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue