diff --git a/engine/core/management/commands/demo_data.py b/engine/core/management/commands/demo_data.py new file mode 100644 index 00000000..96e2fb73 --- /dev/null +++ b/engine/core/management/commands/demo_data.py @@ -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) diff --git a/engine/core/templates/admin/dashboard/_income_overview.html b/engine/core/templates/admin/dashboard/_income_overview.html index c42c8c29..ba626dda 100644 --- a/engine/core/templates/admin/dashboard/_income_overview.html +++ b/engine/core/templates/admin/dashboard/_income_overview.html @@ -1,118 +1,97 @@ -{% load i18n unfold arith %} +{% load i18n unfold arith humanize %} -{% with gross=revenue_gross_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/title.html" %} - {% trans "Income overview" %} - {% 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 %} -
-
- -
-
-
- - {% trans "Net" %}: - {% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ net }} -
- {% if tax_amt_pos > 0 %} -
- - {% trans "Taxes" %}: - {% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ tax_amt_pos|floatformat:2 }} -
- {% endif %} -
- - {% trans "Returns" %}: - {% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ returns }} -
-
- - {% trans "Gross (pie total)" %}: - {% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ gross }} -
-
-
- - - {% endwith %} - {% endwith %} - {% endwith %} - {% endwith %} - {% endwith %} +{% with gross=revenue_gross_30|default:0 net=revenue_net_30|default:0 returns=returns_30|default:0 %} + {% component "unfold/components/card.html" with class="xl:col-span-2" %} + {% component "unfold/components/title.html" %} + {% trans "Income overview" %} {% endcomponent %} - {% endwith %} + +
+
+ +
+
+
+ + {% trans "Net revenue" %}: + {% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ net|floatformat:0|intcomma }} +
+
+ + {% trans "Returns" %}: + {% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ returns|floatformat:0|intcomma }} +
+
+ + {% trans "GMV" %}: + {% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ gross|floatformat:0|intcomma }} +
+
+
+ + + {% endcomponent %} {% endwith %} diff --git a/engine/core/templates/admin/dashboard/_kpis.html b/engine/core/templates/admin/dashboard/_kpis.html index d3dcb255..030fc0be 100644 --- a/engine/core/templates/admin/dashboard/_kpis.html +++ b/engine/core/templates/admin/dashboard/_kpis.html @@ -1,4 +1,4 @@ -{% load i18n unfold %} +{% load i18n unfold humanize %}
{% component "unfold/components/card.html" %} @@ -11,7 +11,7 @@ {% endwith %}
{% 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 %} @@ -39,7 +39,7 @@ {% endwith %} {% 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 %} @@ -53,7 +53,7 @@ {% endwith %} {% 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 %} @@ -70,7 +70,7 @@ {{ kpi.refund_rate.value|default:0 }}% {% endcomponent %} {% 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 %} diff --git a/engine/core/templates/admin/dashboard/_low_stock.html b/engine/core/templates/admin/dashboard/_low_stock.html index c8677f4e..4f658d3a 100644 --- a/engine/core/templates/admin/dashboard/_low_stock.html +++ b/engine/core/templates/admin/dashboard/_low_stock.html @@ -6,24 +6,28 @@ {% endcomponent %} {% if low_stock_products %} -
+
+ {% else %} {% component "unfold/components/text.html" with class="text-sm text-gray-500 dark:text-gray-400" %} {% trans "No low stock items." %} diff --git a/engine/core/views.py b/engine/core/views.py index d7e1a808..07d9404b 100644 --- a/engine/core/views.py +++ b/engine/core/views.py @@ -2,6 +2,7 @@ import logging import mimetypes import os import traceback +from contextlib import suppress from datetime import date, timedelta import requests @@ -456,8 +457,9 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context: prev_end = cur_start 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) + 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) 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)) if tax_rate <= 0: - revenue_net_prev = revenue_gross_prev + revenue_net_before_returns_prev = revenue_gross_prev else: if tax_included: 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 ) else: - revenue_net_prev = revenue_gross_prev - revenue_net_prev = round(float(revenue_net_prev or 0.0), 2) + revenue_net_before_returns_prev = revenue_gross_prev + 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: cur_f = float(cur or 0) @@ -684,23 +688,25 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context: except Exception as 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: products = ( Product.objects.annotate(total_qty=Sum("stocks__quantity")) - .values("uuid", "name", "sku", "total_qty") + .prefetch_related("images") .order_by("total_qty")[:5] ) 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( { - "name": str(p.get("name") or ""), - "sku": str(p.get("sku") or ""), + "name": p.name, + "sku": p.sku or "", "qty": qty, - "admin_url": reverse( - "admin:core_product_change", args=[p.get("id")] - ), + "image": img, + "admin_url": reverse("admin:core_product_change", args=[p.pk]), } ) except Exception as exc: