feat(feeds): add marketplace-specific feed generators
add feed generators for Google Merchant, Amazon Seller, Yandex Market, and Yandex Products. Includes a shared base class (`BaseFeedGenerator`) for common functionality, such as product retrieval and validation. Each generator supports format-specific output and handles platform-specific requirements. This change simplifies the creation of product feeds for multiple marketplaces.
This commit is contained in:
parent
c0c1697003
commit
adfa2f20dd
12 changed files with 2405 additions and 578 deletions
|
|
@ -4,155 +4,212 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('blog', '0007_post_is_static_page'),
|
("blog", "0007_post_is_static_page"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content',
|
name="content",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_ar_ar',
|
name="content_ar_ar",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_cs_cz',
|
name="content_cs_cz",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_da_dk',
|
name="content_da_dk",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_de_de',
|
name="content_de_de",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_en_gb',
|
name="content_en_gb",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_en_us',
|
name="content_en_us",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_es_es',
|
name="content_es_es",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_fa_ir',
|
name="content_fa_ir",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_fr_fr',
|
name="content_fr_fr",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_he_il',
|
name="content_he_il",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_hi_in',
|
name="content_hi_in",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_hr_hr',
|
name="content_hr_hr",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_id_id',
|
name="content_id_id",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_it_it',
|
name="content_it_it",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_ja_jp',
|
name="content_ja_jp",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_kk_kz',
|
name="content_kk_kz",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_ko_kr',
|
name="content_ko_kr",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_nl_nl',
|
name="content_nl_nl",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_no_no',
|
name="content_no_no",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_pl_pl',
|
name="content_pl_pl",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_pt_br',
|
name="content_pt_br",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_ro_ro',
|
name="content_ro_ro",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_ru_ru',
|
name="content_ru_ru",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_sv_se',
|
name="content_sv_se",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_th_th',
|
name="content_th_th",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_tr_tr',
|
name="content_tr_tr",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_vi_vn',
|
name="content_vi_vn",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='post',
|
model_name="post",
|
||||||
name='content_zh_hans',
|
name="content_zh_hans",
|
||||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
field=models.TextField(
|
||||||
|
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
21
engine/core/feeds/__init__.py
Normal file
21
engine/core/feeds/__init__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
from engine.core.feeds.amazon_seller import AmazonSellerFeedGenerator
|
||||||
|
from engine.core.feeds.base import BaseFeedGenerator
|
||||||
|
from engine.core.feeds.google_merchant import GoogleMerchantFeedGenerator
|
||||||
|
from engine.core.feeds.yandex_market import YandexMarketFeedGenerator
|
||||||
|
from engine.core.feeds.yandex_products import YandexProductsFeedGenerator
|
||||||
|
|
||||||
|
FEED_GENERATORS: dict[str, type[BaseFeedGenerator]] = {
|
||||||
|
"google_merchant": GoogleMerchantFeedGenerator,
|
||||||
|
"yandex_market": YandexMarketFeedGenerator,
|
||||||
|
"yandex_products": YandexProductsFeedGenerator,
|
||||||
|
"amazon_seller": AmazonSellerFeedGenerator,
|
||||||
|
}
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BaseFeedGenerator",
|
||||||
|
"GoogleMerchantFeedGenerator",
|
||||||
|
"YandexMarketFeedGenerator",
|
||||||
|
"YandexProductsFeedGenerator",
|
||||||
|
"AmazonSellerFeedGenerator",
|
||||||
|
"FEED_GENERATORS",
|
||||||
|
]
|
||||||
241
engine/core/feeds/amazon_seller.py
Normal file
241
engine/core/feeds/amazon_seller.py
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from xml.etree.ElementTree import Element, SubElement
|
||||||
|
|
||||||
|
from constance import config
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
|
from engine.core.feeds.base import BaseFeedGenerator
|
||||||
|
from engine.core.models import Product
|
||||||
|
|
||||||
|
|
||||||
|
class AmazonSellerFeedGenerator(BaseFeedGenerator):
|
||||||
|
"""
|
||||||
|
Amazon Seller Central feed generator.
|
||||||
|
|
||||||
|
Generates product feeds in Amazon's XML format for Seller Central.
|
||||||
|
Reference: https://developer-docs.amazon.com/sp-api/docs/feeds-api-v2021-06-30-reference
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = "amazon_seller"
|
||||||
|
supported_formats: tuple[str, ...] = ("xml", "json", "yaml")
|
||||||
|
default_format: str = "xml"
|
||||||
|
|
||||||
|
AMAZON_NS = "http://www.amazon.com/schema/merchant/product/2024-01"
|
||||||
|
|
||||||
|
def generate_feed_data(self, products: QuerySet[Product]) -> dict[str, Any]:
|
||||||
|
"""Generate feed data as a structured dictionary."""
|
||||||
|
messages = []
|
||||||
|
for idx, product in enumerate(products, start=1):
|
||||||
|
message = self._build_message(product, idx)
|
||||||
|
if message:
|
||||||
|
messages.append(message)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"header": {
|
||||||
|
"documentVersion": "1.0",
|
||||||
|
"merchantIdentifier": config.COMPANY_NAME or settings.PROJECT_NAME,
|
||||||
|
},
|
||||||
|
"messageType": "Product",
|
||||||
|
"purgeAndReplace": False,
|
||||||
|
"messages": messages,
|
||||||
|
"generated_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_message(
|
||||||
|
self, product: Product, message_id: int
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Build a message dictionary for a product."""
|
||||||
|
if not product.price or product.price <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
images = self.get_product_images(product)
|
||||||
|
|
||||||
|
description_data: dict[str, Any] = {
|
||||||
|
"title": product.name[:200],
|
||||||
|
"brand": product.brand.name if product.brand else "",
|
||||||
|
"description": (product.description or "")[:2000],
|
||||||
|
"bulletPoint": self._get_bullet_points(product),
|
||||||
|
"manufacturer": product.brand.name
|
||||||
|
if product.brand
|
||||||
|
else config.COMPANY_NAME or "",
|
||||||
|
"itemType": self._get_item_type(product),
|
||||||
|
}
|
||||||
|
|
||||||
|
if images:
|
||||||
|
description_data["mainImage"] = {
|
||||||
|
"imageType": "Main",
|
||||||
|
"imageLocation": images[0],
|
||||||
|
}
|
||||||
|
if len(images) > 1:
|
||||||
|
description_data["otherImages"] = [
|
||||||
|
{"imageType": f"PT{i}", "imageLocation": img}
|
||||||
|
for i, img in enumerate(images[1:9], start=1)
|
||||||
|
]
|
||||||
|
|
||||||
|
message: dict[str, Any] = {
|
||||||
|
"messageID": message_id,
|
||||||
|
"operationType": "Update",
|
||||||
|
"product": {
|
||||||
|
"sku": product.sku,
|
||||||
|
"standardProductID": self._get_standard_product_id(product),
|
||||||
|
"productTaxCode": "A_GEN_TAX",
|
||||||
|
"descriptionData": description_data,
|
||||||
|
"productData": self._get_product_data(product),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
def _get_standard_product_id(self, product: Product) -> dict[str, str]:
|
||||||
|
"""Get standard product identifier (EAN/UPC/GTIN)."""
|
||||||
|
id_types = [
|
||||||
|
("ean", "EAN"),
|
||||||
|
("upc", "UPC"),
|
||||||
|
("gtin", "GTIN"),
|
||||||
|
("isbn", "ISBN"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for attr_value in product.attributes.all():
|
||||||
|
attr_name_lower = attr_value.attribute.name.lower()
|
||||||
|
for attr_key, amazon_type in id_types:
|
||||||
|
if attr_name_lower == attr_key:
|
||||||
|
return {"type": amazon_type, "value": attr_value.value}
|
||||||
|
|
||||||
|
if product.partnumber:
|
||||||
|
return {"type": "PrivateLabel", "value": product.partnumber}
|
||||||
|
|
||||||
|
return {"type": "PrivateLabel", "value": product.sku}
|
||||||
|
|
||||||
|
def _get_bullet_points(self, product: Product) -> list[str]:
|
||||||
|
"""Generate bullet points from product attributes."""
|
||||||
|
bullet_points = []
|
||||||
|
|
||||||
|
for attr_value in product.attributes.all()[:5]:
|
||||||
|
bullet_points.append(f"{attr_value.attribute.name}: {attr_value.value}")
|
||||||
|
|
||||||
|
return bullet_points
|
||||||
|
|
||||||
|
def _get_item_type(self, product: Product) -> str:
|
||||||
|
"""Get the item type from category."""
|
||||||
|
if product.category:
|
||||||
|
return product.category.name
|
||||||
|
return "General"
|
||||||
|
|
||||||
|
def _get_product_data(self, product: Product) -> dict[str, Any]:
|
||||||
|
"""Get additional product data."""
|
||||||
|
data = {
|
||||||
|
"price": {
|
||||||
|
"standardPrice": {
|
||||||
|
"value": product.price,
|
||||||
|
"currency": self.get_currency(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"inventory": {
|
||||||
|
"quantity": product.quantity,
|
||||||
|
"fulfillmentLatency": 3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if product.discount_price:
|
||||||
|
sale_price = product.price - product.discount_price
|
||||||
|
if sale_price > 0:
|
||||||
|
data["price"]["salePrice"] = {
|
||||||
|
"value": sale_price,
|
||||||
|
"currency": self.get_currency(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def to_xml(self, data: dict[str, Any]) -> str:
|
||||||
|
"""Convert feed data to Amazon XML format."""
|
||||||
|
envelope = Element("AmazonEnvelope")
|
||||||
|
envelope.set("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
|
||||||
|
envelope.set("xsi:noNamespaceSchemaLocation", "amzn-envelope.xsd")
|
||||||
|
|
||||||
|
header = SubElement(envelope, "Header")
|
||||||
|
doc_version = SubElement(header, "DocumentVersion")
|
||||||
|
doc_version.text = data["header"]["documentVersion"]
|
||||||
|
merchant_id = SubElement(header, "MerchantIdentifier")
|
||||||
|
merchant_id.text = data["header"]["merchantIdentifier"]
|
||||||
|
|
||||||
|
message_type = SubElement(envelope, "MessageType")
|
||||||
|
message_type.text = data["messageType"]
|
||||||
|
|
||||||
|
purge = SubElement(envelope, "PurgeAndReplace")
|
||||||
|
purge.text = "true" if data["purgeAndReplace"] else "false"
|
||||||
|
|
||||||
|
for msg_data in data["messages"]:
|
||||||
|
self._add_message_to_xml(envelope, msg_data)
|
||||||
|
|
||||||
|
return '<?xml version="1.0" encoding="UTF-8"?>\n' + self.prettify_xml(envelope)
|
||||||
|
|
||||||
|
def _add_message_to_xml(self, envelope: Element, msg_data: dict[str, Any]) -> None:
|
||||||
|
"""Add a message to the XML envelope."""
|
||||||
|
message = SubElement(envelope, "Message")
|
||||||
|
|
||||||
|
message_id = SubElement(message, "MessageID")
|
||||||
|
message_id.text = str(msg_data["messageID"])
|
||||||
|
|
||||||
|
operation_type = SubElement(message, "OperationType")
|
||||||
|
operation_type.text = msg_data["operationType"]
|
||||||
|
|
||||||
|
product = SubElement(message, "Product")
|
||||||
|
product_data = msg_data["product"]
|
||||||
|
|
||||||
|
sku = SubElement(product, "SKU")
|
||||||
|
sku.text = product_data["sku"]
|
||||||
|
|
||||||
|
std_product_id = SubElement(product, "StandardProductID")
|
||||||
|
type_elem = SubElement(std_product_id, "Type")
|
||||||
|
type_elem.text = product_data["standardProductID"]["type"]
|
||||||
|
value_elem = SubElement(std_product_id, "Value")
|
||||||
|
value_elem.text = product_data["standardProductID"]["value"]
|
||||||
|
|
||||||
|
tax_code = SubElement(product, "ProductTaxCode")
|
||||||
|
tax_code.text = product_data["productTaxCode"]
|
||||||
|
|
||||||
|
desc_data = SubElement(product, "DescriptionData")
|
||||||
|
self._add_description_data_to_xml(desc_data, product_data["descriptionData"])
|
||||||
|
|
||||||
|
def _add_description_data_to_xml(
|
||||||
|
self, parent: Element, desc_data: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Add description data to the XML element."""
|
||||||
|
title = SubElement(parent, "Title")
|
||||||
|
title.text = desc_data["title"]
|
||||||
|
|
||||||
|
if desc_data.get("brand"):
|
||||||
|
brand = SubElement(parent, "Brand")
|
||||||
|
brand.text = desc_data["brand"]
|
||||||
|
|
||||||
|
if desc_data.get("description"):
|
||||||
|
description = SubElement(parent, "Description")
|
||||||
|
description.text = desc_data["description"]
|
||||||
|
|
||||||
|
for bullet in desc_data.get("bulletPoint", []):
|
||||||
|
bullet_point = SubElement(parent, "BulletPoint")
|
||||||
|
bullet_point.text = bullet
|
||||||
|
|
||||||
|
if desc_data.get("manufacturer"):
|
||||||
|
manufacturer = SubElement(parent, "Manufacturer")
|
||||||
|
manufacturer.text = desc_data["manufacturer"]
|
||||||
|
|
||||||
|
if desc_data.get("itemType"):
|
||||||
|
item_type = SubElement(parent, "ItemType")
|
||||||
|
item_type.text = desc_data["itemType"]
|
||||||
|
|
||||||
|
if desc_data.get("mainImage"):
|
||||||
|
main_image = SubElement(parent, "MainImage")
|
||||||
|
img_type = SubElement(main_image, "ImageType")
|
||||||
|
img_type.text = desc_data["mainImage"]["imageType"]
|
||||||
|
img_loc = SubElement(main_image, "ImageLocation")
|
||||||
|
img_loc.text = desc_data["mainImage"]["imageLocation"]
|
||||||
|
|
||||||
|
for other_img in desc_data.get("otherImages", []):
|
||||||
|
other_image = SubElement(parent, "OtherImage")
|
||||||
|
img_type = SubElement(other_image, "ImageType")
|
||||||
|
img_type.text = other_img["imageType"]
|
||||||
|
img_loc = SubElement(other_image, "ImageLocation")
|
||||||
|
img_loc.text = other_img["imageLocation"]
|
||||||
168
engine/core/feeds/base.py
Normal file
168
engine/core/feeds/base.py
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from xml.dom import minidom
|
||||||
|
from xml.etree.ElementTree import Element, tostring
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
|
from engine.core.models import Product
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseFeedGenerator(ABC):
|
||||||
|
"""
|
||||||
|
Base class for marketplace feed generators.
|
||||||
|
|
||||||
|
Each marketplace feed generator should inherit from this class and implement
|
||||||
|
the required methods for generating feed data in the appropriate format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = "base"
|
||||||
|
supported_formats: tuple[str, ...] = ("xml", "json", "yaml")
|
||||||
|
default_format: str = "xml"
|
||||||
|
|
||||||
|
def __init__(self, locale: str = "en-gb"):
|
||||||
|
self.locale = locale
|
||||||
|
self.generated_at = datetime.now()
|
||||||
|
|
||||||
|
def get_products(self) -> QuerySet[Product]:
|
||||||
|
"""Get products that should be exported to marketplaces."""
|
||||||
|
return (
|
||||||
|
Product.objects.filter(
|
||||||
|
is_active=True,
|
||||||
|
export_to_marketplaces=True,
|
||||||
|
)
|
||||||
|
.select_related(
|
||||||
|
"category",
|
||||||
|
"brand",
|
||||||
|
)
|
||||||
|
.prefetch_related(
|
||||||
|
"images",
|
||||||
|
"stocks",
|
||||||
|
"attributes__attribute",
|
||||||
|
"tags",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_product_url(self, product: Product) -> str:
|
||||||
|
"""Generate the frontend URL for a product."""
|
||||||
|
return (
|
||||||
|
f"https://{settings.STOREFRONT_DOMAIN}/{self.locale}/product/{product.slug}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_product_image_url(self, product: Product) -> str:
|
||||||
|
"""Get the primary image URL for a product."""
|
||||||
|
image = product.images.order_by("priority").first()
|
||||||
|
if image:
|
||||||
|
return image.image_url
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_product_images(self, product: Product) -> list[str]:
|
||||||
|
"""Get all image URLs for a product."""
|
||||||
|
return [
|
||||||
|
img.image_url
|
||||||
|
for img in product.images.order_by("priority")
|
||||||
|
if img.image_url
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_availability(self, product: Product) -> str:
|
||||||
|
"""Get availability status for a product."""
|
||||||
|
return "in stock" if product.quantity > 0 else "out of stock"
|
||||||
|
|
||||||
|
def get_currency(self) -> str:
|
||||||
|
"""Get the currency code."""
|
||||||
|
return settings.CURRENCY_CODE
|
||||||
|
|
||||||
|
def get_output_path(self, format_type: str) -> str:
|
||||||
|
"""Get the output file path for the feed."""
|
||||||
|
feeds_dir = os.path.join(settings.MEDIA_ROOT, "feeds")
|
||||||
|
os.makedirs(feeds_dir, exist_ok=True)
|
||||||
|
|
||||||
|
extension = format_type if format_type != "yaml" else "yml"
|
||||||
|
return os.path.join(feeds_dir, f"{self.name}.{extension}")
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def generate_feed_data(self, products: QuerySet[Product]) -> Any:
|
||||||
|
"""
|
||||||
|
Generate the feed data structure.
|
||||||
|
|
||||||
|
This method should be implemented by each marketplace-specific generator
|
||||||
|
to create the appropriate data structure for that marketplace.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def to_xml(self, data: Any) -> str:
|
||||||
|
"""Convert feed data to XML format."""
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"{self.__class__.__name__} does not support XML format"
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_json(self, data: Any) -> str:
|
||||||
|
"""Convert feed data to JSON format."""
|
||||||
|
return json.dumps(data, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
def to_yaml(self, data: Any) -> str:
|
||||||
|
"""Convert feed data to YAML format."""
|
||||||
|
return yaml.dump(data, allow_unicode=True, default_flow_style=False)
|
||||||
|
|
||||||
|
def generate(self, format_type: str | None = None) -> str:
|
||||||
|
"""
|
||||||
|
Generate the feed and save it to a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
format_type: The output format (xml, json, yaml). Defaults to the generator's default.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The path to the generated feed file.
|
||||||
|
"""
|
||||||
|
if format_type is None:
|
||||||
|
format_type = self.default_format
|
||||||
|
|
||||||
|
if format_type not in self.supported_formats:
|
||||||
|
raise ValueError(
|
||||||
|
f"Format '{format_type}' is not supported by {self.__class__.__name__}. "
|
||||||
|
f"Supported formats: {self.supported_formats}"
|
||||||
|
)
|
||||||
|
|
||||||
|
products = self.get_products()
|
||||||
|
product_count = products.count()
|
||||||
|
|
||||||
|
if product_count == 0:
|
||||||
|
logger.warning("No products to export for %s feed", self.name)
|
||||||
|
|
||||||
|
logger.info("Generating %s feed with %d products", self.name, product_count)
|
||||||
|
|
||||||
|
feed_data = self.generate_feed_data(products)
|
||||||
|
|
||||||
|
match format_type:
|
||||||
|
case "xml":
|
||||||
|
content = self.to_xml(feed_data)
|
||||||
|
case "json":
|
||||||
|
content = self.to_json(feed_data)
|
||||||
|
case "yaml" | "yml":
|
||||||
|
content = self.to_yaml(feed_data)
|
||||||
|
case _:
|
||||||
|
raise ValueError(f"Unknown format: {format_type}")
|
||||||
|
|
||||||
|
output_path = self.get_output_path(format_type)
|
||||||
|
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
logger.info("Generated %s feed at %s", self.name, output_path)
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def prettify_xml(elem: Element) -> str:
|
||||||
|
"""Return a pretty-printed XML string for the Element."""
|
||||||
|
rough_string = tostring(elem, encoding="unicode")
|
||||||
|
reparsed = minidom.parseString(rough_string)
|
||||||
|
return reparsed.toprettyxml(indent=" ", encoding=None)
|
||||||
162
engine/core/feeds/google_merchant.py
Normal file
162
engine/core/feeds/google_merchant.py
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from xml.etree.ElementTree import Element, SubElement
|
||||||
|
|
||||||
|
from constance import config
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
|
from engine.core.feeds.base import BaseFeedGenerator
|
||||||
|
from engine.core.models import Product
|
||||||
|
|
||||||
|
|
||||||
|
class GoogleMerchantFeedGenerator(BaseFeedGenerator):
|
||||||
|
"""
|
||||||
|
Google Merchant Center feed generator.
|
||||||
|
|
||||||
|
Generates product feeds in Atom/RSS format compatible with Google Shopping.
|
||||||
|
Reference: https://support.google.com/merchants/answer/7052112
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = "google_merchant"
|
||||||
|
supported_formats: tuple[str, ...] = ("xml", "json")
|
||||||
|
default_format: str = "xml"
|
||||||
|
|
||||||
|
GOOGLE_NS = "http://base.google.com/ns/1.0"
|
||||||
|
|
||||||
|
def generate_feed_data(self, products: QuerySet[Product]) -> list[dict[str, Any]]:
|
||||||
|
"""Generate feed data as a list of product dictionaries."""
|
||||||
|
items = []
|
||||||
|
|
||||||
|
for product in products:
|
||||||
|
item = self._build_product_item(product)
|
||||||
|
if item:
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
def _build_product_item(self, product: Product) -> dict[str, Any] | None:
|
||||||
|
"""Build a product item dictionary for the feed."""
|
||||||
|
if not product.price or product.price <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
images = self.get_product_images(product)
|
||||||
|
primary_image = images[0] if images else ""
|
||||||
|
additional_images = images[1:10] if len(images) > 1 else []
|
||||||
|
|
||||||
|
item = {
|
||||||
|
"id": product.sku,
|
||||||
|
"title": product.name[:150],
|
||||||
|
"description": (product.description or "")[:5000],
|
||||||
|
"link": self.get_product_url(product),
|
||||||
|
"image_link": primary_image,
|
||||||
|
"availability": self.get_availability(product),
|
||||||
|
"price": f"{product.price:.2f} {self.get_currency()}",
|
||||||
|
"brand": product.brand.name if product.brand else "",
|
||||||
|
"condition": "new",
|
||||||
|
"product_type": self._get_product_type(product),
|
||||||
|
}
|
||||||
|
|
||||||
|
if additional_images:
|
||||||
|
item["additional_image_link"] = additional_images
|
||||||
|
|
||||||
|
if product.partnumber:
|
||||||
|
item["mpn"] = product.partnumber
|
||||||
|
|
||||||
|
if product.discount_price:
|
||||||
|
sale_price = product.price - product.discount_price
|
||||||
|
if sale_price > 0:
|
||||||
|
item["sale_price"] = f"{sale_price:.2f} {self.get_currency()}"
|
||||||
|
|
||||||
|
gtin = self._get_gtin(product)
|
||||||
|
if gtin:
|
||||||
|
item["gtin"] = gtin
|
||||||
|
else:
|
||||||
|
item["identifier_exists"] = "no"
|
||||||
|
|
||||||
|
return item
|
||||||
|
|
||||||
|
def _get_product_type(self, product: Product) -> str:
|
||||||
|
"""Build the product type hierarchy from category."""
|
||||||
|
if not product.category:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
ancestors = product.category.get_ancestors(include_self=True)
|
||||||
|
return " > ".join([cat.name for cat in ancestors])
|
||||||
|
|
||||||
|
def _get_gtin(self, product: Product) -> str | None:
|
||||||
|
"""Extract GTIN/EAN/UPC from product attributes."""
|
||||||
|
gtin_names = ["gtin", "ean", "upc", "isbn", "barcode"]
|
||||||
|
|
||||||
|
for attr_value in product.attributes.all():
|
||||||
|
if attr_value.attribute.name.lower() in gtin_names:
|
||||||
|
return attr_value.value
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def to_xml(self, data: list[dict[str, Any]]) -> str:
|
||||||
|
"""Convert feed data to Google Merchant XML format."""
|
||||||
|
rss = Element("rss")
|
||||||
|
rss.set("version", "2.0")
|
||||||
|
rss.set("xmlns:g", self.GOOGLE_NS)
|
||||||
|
|
||||||
|
channel = SubElement(rss, "channel")
|
||||||
|
|
||||||
|
title = SubElement(channel, "title")
|
||||||
|
title.text = config.COMPANY_NAME or settings.PROJECT_NAME
|
||||||
|
|
||||||
|
link = SubElement(channel, "link")
|
||||||
|
link.text = f"https://{settings.STOREFRONT_DOMAIN}"
|
||||||
|
|
||||||
|
description = SubElement(channel, "description")
|
||||||
|
description.text = (
|
||||||
|
f"Product feed for {config.COMPANY_NAME or settings.PROJECT_NAME}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for product_data in data:
|
||||||
|
item = SubElement(channel, "item")
|
||||||
|
self._add_product_to_xml(item, product_data)
|
||||||
|
|
||||||
|
return self.prettify_xml(rss)
|
||||||
|
|
||||||
|
def _add_product_to_xml(self, item: Element, product_data: dict[str, Any]) -> None:
|
||||||
|
"""Add a product's data to an XML item element."""
|
||||||
|
simple_fields = [
|
||||||
|
("id", "g:id"),
|
||||||
|
("title", "g:title"),
|
||||||
|
("description", "g:description"),
|
||||||
|
("link", "g:link"),
|
||||||
|
("image_link", "g:image_link"),
|
||||||
|
("availability", "g:availability"),
|
||||||
|
("price", "g:price"),
|
||||||
|
("brand", "g:brand"),
|
||||||
|
("condition", "g:condition"),
|
||||||
|
("product_type", "g:product_type"),
|
||||||
|
("mpn", "g:mpn"),
|
||||||
|
("gtin", "g:gtin"),
|
||||||
|
("sale_price", "g:sale_price"),
|
||||||
|
("identifier_exists", "g:identifier_exists"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for data_key, xml_tag in simple_fields:
|
||||||
|
if data_key in product_data and product_data[data_key]:
|
||||||
|
elem = SubElement(item, xml_tag)
|
||||||
|
elem.text = str(product_data[data_key])
|
||||||
|
|
||||||
|
additional_images = product_data.get("additional_image_link", [])
|
||||||
|
for img_url in additional_images:
|
||||||
|
elem = SubElement(item, "g:additional_image_link")
|
||||||
|
elem.text = img_url
|
||||||
|
|
||||||
|
def to_json(self, data: list[dict[str, Any]]) -> str:
|
||||||
|
"""Convert feed data to JSON format."""
|
||||||
|
feed = {
|
||||||
|
"channel": {
|
||||||
|
"title": config.COMPANY_NAME or settings.PROJECT_NAME,
|
||||||
|
"link": f"https://{settings.STOREFRONT_DOMAIN}",
|
||||||
|
"description": f"Product feed for {config.COMPANY_NAME or settings.PROJECT_NAME}",
|
||||||
|
"generated_at": datetime.now().isoformat(),
|
||||||
|
},
|
||||||
|
"items": data,
|
||||||
|
}
|
||||||
|
return super().to_json(feed)
|
||||||
219
engine/core/feeds/yandex_market.py
Normal file
219
engine/core/feeds/yandex_market.py
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from xml.etree.ElementTree import Element, SubElement
|
||||||
|
|
||||||
|
from constance import config
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
|
from engine.core.feeds.base import BaseFeedGenerator
|
||||||
|
from engine.core.models import Product
|
||||||
|
|
||||||
|
|
||||||
|
class YandexMarketFeedGenerator(BaseFeedGenerator):
|
||||||
|
"""
|
||||||
|
Yandex Market feed generator (YML format).
|
||||||
|
|
||||||
|
Generates product feeds in Yandex Market Language (YML) format.
|
||||||
|
Reference: https://yandex.ru/support/partnermarket/export/yml.html
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = "yandex_market"
|
||||||
|
supported_formats: tuple[str, ...] = ("xml", "json", "yaml")
|
||||||
|
default_format: str = "xml"
|
||||||
|
|
||||||
|
def generate_feed_data(self, products: QuerySet[Product]) -> dict[str, Any]:
|
||||||
|
"""Generate feed data as a structured dictionary."""
|
||||||
|
categories = self._get_categories_from_products(products)
|
||||||
|
currencies = self._get_currencies()
|
||||||
|
|
||||||
|
offers = []
|
||||||
|
for product in products:
|
||||||
|
offer = self._build_offer(product)
|
||||||
|
if offer:
|
||||||
|
offers.append(offer)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"shop": {
|
||||||
|
"name": config.COMPANY_NAME or settings.PROJECT_NAME,
|
||||||
|
"company": config.COMPANY_NAME or settings.PROJECT_NAME,
|
||||||
|
"url": f"https://{settings.STOREFRONT_DOMAIN}",
|
||||||
|
"currencies": currencies,
|
||||||
|
"categories": categories,
|
||||||
|
"offers": offers,
|
||||||
|
},
|
||||||
|
"date": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_categories_from_products(
|
||||||
|
self, products: QuerySet[Product]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Extract unique categories from products with their hierarchy."""
|
||||||
|
category_ids = set()
|
||||||
|
categories_data = []
|
||||||
|
|
||||||
|
for product in products:
|
||||||
|
if product.category_id:
|
||||||
|
ancestors = product.category.get_ancestors(include_self=True)
|
||||||
|
for cat in ancestors:
|
||||||
|
if cat.id not in category_ids:
|
||||||
|
category_ids.add(cat.id)
|
||||||
|
cat_data = {
|
||||||
|
"id": cat.id,
|
||||||
|
"name": cat.name,
|
||||||
|
}
|
||||||
|
if cat.parent_id:
|
||||||
|
cat_data["parentId"] = cat.parent_id
|
||||||
|
categories_data.append(cat_data)
|
||||||
|
|
||||||
|
return categories_data
|
||||||
|
|
||||||
|
def _get_currencies(self) -> list[dict[str, str]]:
|
||||||
|
"""Get supported currencies."""
|
||||||
|
return [
|
||||||
|
{"id": self.get_currency(), "rate": "1"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def _build_offer(self, product: Product) -> dict[str, Any] | None:
|
||||||
|
"""Build an offer dictionary for a product."""
|
||||||
|
if not product.price or product.price <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
images = self.get_product_images(product)
|
||||||
|
|
||||||
|
offer = {
|
||||||
|
"id": product.sku,
|
||||||
|
"available": product.quantity > 0,
|
||||||
|
"url": self.get_product_url(product),
|
||||||
|
"price": product.price,
|
||||||
|
"currencyId": self.get_currency(),
|
||||||
|
"categoryId": product.category_id,
|
||||||
|
"name": product.name[:120],
|
||||||
|
}
|
||||||
|
|
||||||
|
if images:
|
||||||
|
offer["picture"] = images[:10]
|
||||||
|
|
||||||
|
if product.brand:
|
||||||
|
offer["vendor"] = product.brand.name
|
||||||
|
|
||||||
|
if product.partnumber:
|
||||||
|
offer["vendorCode"] = product.partnumber
|
||||||
|
|
||||||
|
if product.description:
|
||||||
|
offer["description"] = product.description[:3000]
|
||||||
|
|
||||||
|
if product.discount_price:
|
||||||
|
offer["oldprice"] = product.price
|
||||||
|
offer["price"] = product.price - product.discount_price
|
||||||
|
|
||||||
|
barcode = self._get_barcode(product)
|
||||||
|
if barcode:
|
||||||
|
offer["barcode"] = barcode
|
||||||
|
|
||||||
|
params = self._get_params(product)
|
||||||
|
if params:
|
||||||
|
offer["param"] = params
|
||||||
|
|
||||||
|
return offer
|
||||||
|
|
||||||
|
def _get_barcode(self, product: Product) -> str | None:
|
||||||
|
"""Extract barcode/EAN from product attributes."""
|
||||||
|
barcode_names = ["barcode", "ean", "gtin", "upc"]
|
||||||
|
|
||||||
|
for attr_value in product.attributes.all():
|
||||||
|
if attr_value.attribute.name.lower() in barcode_names:
|
||||||
|
return attr_value.value
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_params(self, product: Product) -> list[dict[str, str]]:
|
||||||
|
"""Extract product parameters from attributes."""
|
||||||
|
params = []
|
||||||
|
skip_names = ["barcode", "ean", "gtin", "upc", "isbn"]
|
||||||
|
|
||||||
|
for attr_value in product.attributes.all():
|
||||||
|
attr_name = attr_value.attribute.name
|
||||||
|
if attr_name.lower() not in skip_names:
|
||||||
|
params.append(
|
||||||
|
{
|
||||||
|
"name": attr_name,
|
||||||
|
"value": attr_value.value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
def to_xml(self, data: dict[str, Any]) -> str:
|
||||||
|
"""Convert feed data to YML XML format."""
|
||||||
|
yml_catalog = Element("yml_catalog")
|
||||||
|
yml_catalog.set("date", data["date"])
|
||||||
|
|
||||||
|
shop = SubElement(yml_catalog, "shop")
|
||||||
|
|
||||||
|
name = SubElement(shop, "name")
|
||||||
|
name.text = data["shop"]["name"]
|
||||||
|
|
||||||
|
company = SubElement(shop, "company")
|
||||||
|
company.text = data["shop"]["company"]
|
||||||
|
|
||||||
|
url = SubElement(shop, "url")
|
||||||
|
url.text = data["shop"]["url"]
|
||||||
|
|
||||||
|
currencies = SubElement(shop, "currencies")
|
||||||
|
for curr in data["shop"]["currencies"]:
|
||||||
|
currency = SubElement(currencies, "currency")
|
||||||
|
currency.set("id", curr["id"])
|
||||||
|
currency.set("rate", curr["rate"])
|
||||||
|
|
||||||
|
categories = SubElement(shop, "categories")
|
||||||
|
for cat in data["shop"]["categories"]:
|
||||||
|
category = SubElement(categories, "category")
|
||||||
|
category.set("id", str(cat["id"]))
|
||||||
|
if "parentId" in cat:
|
||||||
|
category.set("parentId", str(cat["parentId"]))
|
||||||
|
category.text = cat["name"]
|
||||||
|
|
||||||
|
offers = SubElement(shop, "offers")
|
||||||
|
for offer_data in data["shop"]["offers"]:
|
||||||
|
self._add_offer_to_xml(offers, offer_data)
|
||||||
|
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE yml_catalog SYSTEM "shops.dtd">\n'
|
||||||
|
+ self.prettify_xml(yml_catalog)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _add_offer_to_xml(self, offers: Element, offer_data: dict[str, Any]) -> None:
|
||||||
|
"""Add an offer to the XML offers element."""
|
||||||
|
offer = SubElement(offers, "offer")
|
||||||
|
offer.set("id", str(offer_data["id"]))
|
||||||
|
offer.set("available", "true" if offer_data["available"] else "false")
|
||||||
|
|
||||||
|
simple_fields = [
|
||||||
|
"url",
|
||||||
|
"price",
|
||||||
|
"oldprice",
|
||||||
|
"currencyId",
|
||||||
|
"categoryId",
|
||||||
|
"name",
|
||||||
|
"vendor",
|
||||||
|
"vendorCode",
|
||||||
|
"description",
|
||||||
|
"barcode",
|
||||||
|
]
|
||||||
|
|
||||||
|
for field in simple_fields:
|
||||||
|
if field in offer_data and offer_data[field] is not None:
|
||||||
|
elem = SubElement(offer, field)
|
||||||
|
elem.text = str(offer_data[field])
|
||||||
|
|
||||||
|
pictures = offer_data.get("picture", [])
|
||||||
|
for pic_url in pictures:
|
||||||
|
picture = SubElement(offer, "picture")
|
||||||
|
picture.text = pic_url
|
||||||
|
|
||||||
|
params = offer_data.get("param", [])
|
||||||
|
for param_data in params:
|
||||||
|
param = SubElement(offer, "param")
|
||||||
|
param.set("name", param_data["name"])
|
||||||
|
param.text = param_data["value"]
|
||||||
141
engine/core/feeds/yandex_products.py
Normal file
141
engine/core/feeds/yandex_products.py
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from xml.etree.ElementTree import Element, SubElement
|
||||||
|
|
||||||
|
from constance import config
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
|
from engine.core.feeds.base import BaseFeedGenerator
|
||||||
|
from engine.core.models import Product
|
||||||
|
|
||||||
|
|
||||||
|
class YandexProductsFeedGenerator(BaseFeedGenerator):
|
||||||
|
"""
|
||||||
|
Yandex Products (Yandex Webmaster) feed generator.
|
||||||
|
|
||||||
|
Generates product feeds for Yandex Webmaster product snippets.
|
||||||
|
Reference: https://yandex.ru/support/webmaster/goods-prices/technical-requirements.html
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = "yandex_products"
|
||||||
|
supported_formats: tuple[str, ...] = ("xml", "json", "yaml")
|
||||||
|
default_format: str = "xml"
|
||||||
|
|
||||||
|
def generate_feed_data(self, products: QuerySet[Product]) -> dict[str, Any]:
|
||||||
|
"""Generate feed data as a structured dictionary."""
|
||||||
|
offers = []
|
||||||
|
for product in products:
|
||||||
|
offer = self._build_offer(product)
|
||||||
|
if offer:
|
||||||
|
offers.append(offer)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"shop": {
|
||||||
|
"name": config.COMPANY_NAME or settings.PROJECT_NAME,
|
||||||
|
"company": config.COMPANY_NAME or settings.PROJECT_NAME,
|
||||||
|
"url": f"https://{settings.STOREFRONT_DOMAIN}",
|
||||||
|
"email": config.EMAIL_HOST_USER or "",
|
||||||
|
"offers": offers,
|
||||||
|
},
|
||||||
|
"date": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_offer(self, product: Product) -> dict[str, Any] | None:
|
||||||
|
"""Build an offer dictionary for a product."""
|
||||||
|
if not product.price or product.price <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
images = self.get_product_images(product)
|
||||||
|
|
||||||
|
offer = {
|
||||||
|
"url": self.get_product_url(product),
|
||||||
|
"price": product.price,
|
||||||
|
"currencyId": self.get_currency(),
|
||||||
|
"name": product.name[:120],
|
||||||
|
"available": product.quantity > 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if images:
|
||||||
|
offer["picture"] = images[0]
|
||||||
|
|
||||||
|
if product.brand:
|
||||||
|
offer["vendor"] = product.brand.name
|
||||||
|
|
||||||
|
if product.category:
|
||||||
|
ancestors = product.category.get_ancestors(include_self=True)
|
||||||
|
offer["category"] = " / ".join([cat.name for cat in ancestors])
|
||||||
|
|
||||||
|
if product.description:
|
||||||
|
offer["description"] = product.description[:500]
|
||||||
|
|
||||||
|
if product.discount_price:
|
||||||
|
offer["oldprice"] = product.price
|
||||||
|
offer["price"] = product.price - product.discount_price
|
||||||
|
|
||||||
|
barcode = self._get_barcode(product)
|
||||||
|
if barcode:
|
||||||
|
offer["barcode"] = barcode
|
||||||
|
|
||||||
|
return offer
|
||||||
|
|
||||||
|
def _get_barcode(self, product: Product) -> str | None:
|
||||||
|
"""Extract barcode/EAN from product attributes."""
|
||||||
|
barcode_names = ["barcode", "ean", "gtin", "upc"]
|
||||||
|
|
||||||
|
for attr_value in product.attributes.all():
|
||||||
|
if attr_value.attribute.name.lower() in barcode_names:
|
||||||
|
return attr_value.value
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def to_xml(self, data: dict[str, Any]) -> str:
|
||||||
|
"""Convert feed data to Yandex Products XML format."""
|
||||||
|
yml_catalog = Element("yml_catalog")
|
||||||
|
yml_catalog.set("date", data["date"])
|
||||||
|
|
||||||
|
shop = SubElement(yml_catalog, "shop")
|
||||||
|
|
||||||
|
name = SubElement(shop, "name")
|
||||||
|
name.text = data["shop"]["name"]
|
||||||
|
|
||||||
|
company = SubElement(shop, "company")
|
||||||
|
company.text = data["shop"]["company"]
|
||||||
|
|
||||||
|
url = SubElement(shop, "url")
|
||||||
|
url.text = data["shop"]["url"]
|
||||||
|
|
||||||
|
if data["shop"].get("email"):
|
||||||
|
email = SubElement(shop, "email")
|
||||||
|
email.text = data["shop"]["email"]
|
||||||
|
|
||||||
|
offers = SubElement(shop, "offers")
|
||||||
|
for offer_data in data["shop"]["offers"]:
|
||||||
|
self._add_offer_to_xml(offers, offer_data)
|
||||||
|
|
||||||
|
return '<?xml version="1.0" encoding="UTF-8"?>\n' + self.prettify_xml(
|
||||||
|
yml_catalog
|
||||||
|
)
|
||||||
|
|
||||||
|
def _add_offer_to_xml(self, offers: Element, offer_data: dict[str, Any]) -> None:
|
||||||
|
"""Add an offer to the XML offers element."""
|
||||||
|
offer = SubElement(offers, "offer")
|
||||||
|
offer.set("available", "true" if offer_data["available"] else "false")
|
||||||
|
|
||||||
|
simple_fields = [
|
||||||
|
"url",
|
||||||
|
"price",
|
||||||
|
"oldprice",
|
||||||
|
"currencyId",
|
||||||
|
"name",
|
||||||
|
"vendor",
|
||||||
|
"category",
|
||||||
|
"picture",
|
||||||
|
"description",
|
||||||
|
"barcode",
|
||||||
|
]
|
||||||
|
|
||||||
|
for field in simple_fields:
|
||||||
|
if field in offer_data and offer_data[field] is not None:
|
||||||
|
elem = SubElement(offer, field)
|
||||||
|
elem.text = str(offer_data[field])
|
||||||
|
|
@ -14,6 +14,7 @@ from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
|
|
||||||
|
from engine.core.feeds import FEED_GENERATORS
|
||||||
from engine.core.models import Product, Promotion
|
from engine.core.models import Product, Promotion
|
||||||
from engine.core.utils.caching import set_default_cache
|
from engine.core.utils.caching import set_default_cache
|
||||||
from engine.core.utils.vendors import get_vendors_integrations
|
from engine.core.utils.vendors import get_vendors_integrations
|
||||||
|
|
@ -238,3 +239,59 @@ def process_promotions() -> tuple[bool, str]:
|
||||||
promotion.products.add(product)
|
promotion.products.add(product)
|
||||||
|
|
||||||
return True, "Promotions updated successfully."
|
return True, "Promotions updated successfully."
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(queue="default")
|
||||||
|
def generate_marketplace_feeds_task() -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Generate product feeds for configured marketplaces.
|
||||||
|
|
||||||
|
This task reads the EXPORT_TO_MARKETPLACES constance setting and generates
|
||||||
|
feeds for each specified marketplace. Supported marketplaces:
|
||||||
|
- google_merchant: Google Merchant Center (XML/JSON)
|
||||||
|
- yandex_market: Yandex Market YML format
|
||||||
|
- yandex_products: Yandex Products/Webmaster format
|
||||||
|
- amazon_seller: Amazon Seller Central format
|
||||||
|
|
||||||
|
The feeds are saved to /media/feeds/<marketplace_name>.<format>
|
||||||
|
"""
|
||||||
|
export_config = config.EXPORT_TO_MARKETPLACES
|
||||||
|
if not export_config or not export_config.strip():
|
||||||
|
logger.info("EXPORT_TO_MARKETPLACES is empty, skipping feed generation")
|
||||||
|
return True, "No marketplaces configured for export"
|
||||||
|
|
||||||
|
marketplaces = [m.strip().lower() for m in export_config.split(",") if m.strip()]
|
||||||
|
|
||||||
|
if not marketplaces:
|
||||||
|
logger.info("No valid marketplaces found in EXPORT_TO_MARKETPLACES")
|
||||||
|
return True, "No valid marketplaces configured"
|
||||||
|
|
||||||
|
generated_feeds: list[str] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for marketplace in marketplaces:
|
||||||
|
if marketplace not in FEED_GENERATORS:
|
||||||
|
error_msg = f"Unknown marketplace: {marketplace}"
|
||||||
|
logger.warning(error_msg)
|
||||||
|
errors.append(error_msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
generator_class = FEED_GENERATORS[marketplace]
|
||||||
|
generator = generator_class(locale=settings.LANGUAGE_CODE)
|
||||||
|
output_path = generator.generate()
|
||||||
|
generated_feeds.append(f"{marketplace}: {output_path}")
|
||||||
|
logger.info("Successfully generated feed for %s", marketplace)
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Failed to generate {marketplace} feed: {e!s}"
|
||||||
|
logger.error(error_msg, exc_info=True)
|
||||||
|
errors.append(error_msg)
|
||||||
|
|
||||||
|
if errors and not generated_feeds:
|
||||||
|
return False, f"All feeds failed: {'; '.join(errors)}"
|
||||||
|
|
||||||
|
result_msg = f"Generated feeds: {', '.join(generated_feeds)}"
|
||||||
|
if errors:
|
||||||
|
result_msg += f"; Errors: {'; '.join(errors)}"
|
||||||
|
|
||||||
|
return True, result_msg
|
||||||
|
|
|
||||||
|
|
@ -1,111 +1,438 @@
|
||||||
# Generated by Django 5.2.9 on 2026-01-26 12:33
|
# Generated by Django 5.2.9 on 2026-01-26 12:33
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import django_extensions.db.fields
|
import django_extensions.db.fields
|
||||||
import engine.vibes_auth.emailing.models
|
|
||||||
import uuid
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import engine.vibes_auth.emailing.models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('vibes_auth', '0006_chatthread_chatmessage_and_more'),
|
("vibes_auth", "0006_chatthread_chatmessage_and_more"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='EmailImage',
|
name="EmailImage",
|
||||||
fields=[
|
fields=[
|
||||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, help_text='unique id is used to surely identify any database object', primary_key=True, serialize=False, verbose_name='unique id')),
|
(
|
||||||
('is_active', models.BooleanField(default=True, help_text="if set to false, this object can't be seen by users without needed permission", verbose_name='is active')),
|
"uuid",
|
||||||
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, help_text='when the object first appeared on the database', verbose_name='created')),
|
models.UUIDField(
|
||||||
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, help_text='when the object was last modified', verbose_name='modified')),
|
default=uuid.uuid4,
|
||||||
('name', models.CharField(help_text='descriptive name for the image', max_length=100, verbose_name='name')),
|
editable=False,
|
||||||
('image', models.ImageField(help_text='image file to use in email templates', upload_to=engine.vibes_auth.emailing.models.get_email_image_path, verbose_name='image')),
|
help_text="unique id is used to surely identify any database object",
|
||||||
('alt_text', models.CharField(blank=True, default='', help_text='alternative text for accessibility', max_length=255, verbose_name='alt text')),
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="unique id",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_active",
|
||||||
|
models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="if set to false, this object can't be seen by users without needed permission",
|
||||||
|
verbose_name="is active",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
help_text="when the object first appeared on the database",
|
||||||
|
verbose_name="created",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
help_text="when the object was last modified",
|
||||||
|
verbose_name="modified",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(
|
||||||
|
help_text="descriptive name for the image",
|
||||||
|
max_length=100,
|
||||||
|
verbose_name="name",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"image",
|
||||||
|
models.ImageField(
|
||||||
|
help_text="image file to use in email templates",
|
||||||
|
upload_to=engine.vibes_auth.emailing.models.get_email_image_path,
|
||||||
|
verbose_name="image",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"alt_text",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="alternative text for accessibility",
|
||||||
|
max_length=255,
|
||||||
|
verbose_name="alt text",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'email image',
|
"verbose_name": "email image",
|
||||||
'verbose_name_plural': 'email images',
|
"verbose_name_plural": "email images",
|
||||||
'ordering': ('-created',),
|
"ordering": ("-created",),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='EmailTemplate',
|
name="EmailTemplate",
|
||||||
fields=[
|
fields=[
|
||||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, help_text='unique id is used to surely identify any database object', primary_key=True, serialize=False, verbose_name='unique id')),
|
(
|
||||||
('is_active', models.BooleanField(default=True, help_text="if set to false, this object can't be seen by users without needed permission", verbose_name='is active')),
|
"uuid",
|
||||||
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, help_text='when the object first appeared on the database', verbose_name='created')),
|
models.UUIDField(
|
||||||
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, help_text='when the object was last modified', verbose_name='modified')),
|
default=uuid.uuid4,
|
||||||
('name', models.CharField(help_text='internal name for the template', max_length=100, verbose_name='name')),
|
editable=False,
|
||||||
('slug', models.SlugField(help_text='unique identifier for the template', unique=True, verbose_name='slug')),
|
help_text="unique id is used to surely identify any database object",
|
||||||
('subject', models.CharField(help_text='email subject line - supports {{ variables }}', max_length=255, verbose_name='subject')),
|
primary_key=True,
|
||||||
('html_content', models.TextField(help_text='email body content - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}', verbose_name='HTML content')),
|
serialize=False,
|
||||||
('plain_content', models.TextField(blank=True, default='', help_text='plain text fallback (auto-generated if empty)', verbose_name='plain text content')),
|
verbose_name="unique id",
|
||||||
('available_variables', models.TextField(blank=True, default='user.first_name, user.last_name, user.email, project_name, unsubscribe_url', help_text='documentation of available template variables', verbose_name='available variables')),
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_active",
|
||||||
|
models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="if set to false, this object can't be seen by users without needed permission",
|
||||||
|
verbose_name="is active",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
help_text="when the object first appeared on the database",
|
||||||
|
verbose_name="created",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
help_text="when the object was last modified",
|
||||||
|
verbose_name="modified",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(
|
||||||
|
help_text="internal name for the template",
|
||||||
|
max_length=100,
|
||||||
|
verbose_name="name",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"slug",
|
||||||
|
models.SlugField(
|
||||||
|
help_text="unique identifier for the template",
|
||||||
|
unique=True,
|
||||||
|
verbose_name="slug",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"subject",
|
||||||
|
models.CharField(
|
||||||
|
help_text="email subject line - supports {{ variables }}",
|
||||||
|
max_length=255,
|
||||||
|
verbose_name="subject",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"html_content",
|
||||||
|
models.TextField(
|
||||||
|
help_text="email body content - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
|
||||||
|
verbose_name="HTML content",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"plain_content",
|
||||||
|
models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="plain text fallback (auto-generated if empty)",
|
||||||
|
verbose_name="plain text content",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"available_variables",
|
||||||
|
models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default="user.first_name, user.last_name, user.email, project_name, unsubscribe_url",
|
||||||
|
help_text="documentation of available template variables",
|
||||||
|
verbose_name="available variables",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'email template',
|
"verbose_name": "email template",
|
||||||
'verbose_name_plural': 'email templates',
|
"verbose_name_plural": "email templates",
|
||||||
'ordering': ('name',),
|
"ordering": ("name",),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='user',
|
model_name="user",
|
||||||
name='unsubscribe_token',
|
name="unsubscribe_token",
|
||||||
field=models.UUIDField(default=uuid.uuid4, help_text='token for secure one-click unsubscribe from campaigns', verbose_name='unsubscribe token'),
|
field=models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
help_text="token for secure one-click unsubscribe from campaigns",
|
||||||
|
verbose_name="unsubscribe token",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='EmailCampaign',
|
name="EmailCampaign",
|
||||||
fields=[
|
fields=[
|
||||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, help_text='unique id is used to surely identify any database object', primary_key=True, serialize=False, verbose_name='unique id')),
|
(
|
||||||
('is_active', models.BooleanField(default=True, help_text="if set to false, this object can't be seen by users without needed permission", verbose_name='is active')),
|
"uuid",
|
||||||
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, help_text='when the object first appeared on the database', verbose_name='created')),
|
models.UUIDField(
|
||||||
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, help_text='when the object was last modified', verbose_name='modified')),
|
default=uuid.uuid4,
|
||||||
('name', models.CharField(help_text='internal name for the campaign', max_length=200, verbose_name='name')),
|
editable=False,
|
||||||
('status', models.CharField(choices=[('draft', 'Draft'), ('scheduled', 'Scheduled'), ('sending', 'Sending'), ('sent', 'Sent'), ('cancelled', 'Cancelled')], default='draft', max_length=16, verbose_name='status')),
|
help_text="unique id is used to surely identify any database object",
|
||||||
('scheduled_at', models.DateTimeField(blank=True, help_text='when to send the campaign (leave empty for manual send)', null=True, verbose_name='scheduled at')),
|
primary_key=True,
|
||||||
('sent_at', models.DateTimeField(blank=True, help_text='when the campaign was actually sent', null=True, verbose_name='sent at')),
|
serialize=False,
|
||||||
('total_recipients', models.PositiveIntegerField(default=0, verbose_name='total recipients')),
|
verbose_name="unique id",
|
||||||
('sent_count', models.PositiveIntegerField(default=0, verbose_name='sent count')),
|
),
|
||||||
('failed_count', models.PositiveIntegerField(default=0, verbose_name='failed count')),
|
),
|
||||||
('opened_count', models.PositiveIntegerField(default=0, verbose_name='opened count')),
|
(
|
||||||
('clicked_count', models.PositiveIntegerField(default=0, verbose_name='clicked count')),
|
"is_active",
|
||||||
('template', models.ForeignKey(help_text='email template to use for this campaign', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='campaigns', to='vibes_auth.emailtemplate', verbose_name='template')),
|
models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="if set to false, this object can't be seen by users without needed permission",
|
||||||
|
verbose_name="is active",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
help_text="when the object first appeared on the database",
|
||||||
|
verbose_name="created",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
help_text="when the object was last modified",
|
||||||
|
verbose_name="modified",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(
|
||||||
|
help_text="internal name for the campaign",
|
||||||
|
max_length=200,
|
||||||
|
verbose_name="name",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("draft", "Draft"),
|
||||||
|
("scheduled", "Scheduled"),
|
||||||
|
("sending", "Sending"),
|
||||||
|
("sent", "Sent"),
|
||||||
|
("cancelled", "Cancelled"),
|
||||||
|
],
|
||||||
|
default="draft",
|
||||||
|
max_length=16,
|
||||||
|
verbose_name="status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"scheduled_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
help_text="when to send the campaign (leave empty for manual send)",
|
||||||
|
null=True,
|
||||||
|
verbose_name="scheduled at",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sent_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
help_text="when the campaign was actually sent",
|
||||||
|
null=True,
|
||||||
|
verbose_name="sent at",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"total_recipients",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
default=0, verbose_name="total recipients"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sent_count",
|
||||||
|
models.PositiveIntegerField(default=0, verbose_name="sent count"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"failed_count",
|
||||||
|
models.PositiveIntegerField(default=0, verbose_name="failed count"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"opened_count",
|
||||||
|
models.PositiveIntegerField(default=0, verbose_name="opened count"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"clicked_count",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
default=0, verbose_name="clicked count"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"template",
|
||||||
|
models.ForeignKey(
|
||||||
|
help_text="email template to use for this campaign",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="campaigns",
|
||||||
|
to="vibes_auth.emailtemplate",
|
||||||
|
verbose_name="template",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'email campaign',
|
"verbose_name": "email campaign",
|
||||||
'verbose_name_plural': 'email campaigns',
|
"verbose_name_plural": "email campaigns",
|
||||||
'ordering': ('-created',),
|
"ordering": ("-created",),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='CampaignRecipient',
|
name="CampaignRecipient",
|
||||||
fields=[
|
fields=[
|
||||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, help_text='unique id is used to surely identify any database object', primary_key=True, serialize=False, verbose_name='unique id')),
|
(
|
||||||
('is_active', models.BooleanField(default=True, help_text="if set to false, this object can't be seen by users without needed permission", verbose_name='is active')),
|
"uuid",
|
||||||
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, help_text='when the object first appeared on the database', verbose_name='created')),
|
models.UUIDField(
|
||||||
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, help_text='when the object was last modified', verbose_name='modified')),
|
default=uuid.uuid4,
|
||||||
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('failed', 'Failed'), ('opened', 'Opened'), ('clicked', 'Clicked')], default='pending', max_length=16, verbose_name='status')),
|
editable=False,
|
||||||
('sent_at', models.DateTimeField(blank=True, null=True, verbose_name='sent at')),
|
help_text="unique id is used to surely identify any database object",
|
||||||
('opened_at', models.DateTimeField(blank=True, null=True, verbose_name='opened at')),
|
primary_key=True,
|
||||||
('clicked_at', models.DateTimeField(blank=True, null=True, verbose_name='clicked at')),
|
serialize=False,
|
||||||
('tracking_id', models.UUIDField(default=uuid.uuid4, help_text='unique ID for tracking opens and clicks', unique=True, verbose_name='tracking ID')),
|
verbose_name="unique id",
|
||||||
('error_message', models.TextField(blank=True, default='', help_text='error details if sending failed', verbose_name='error message')),
|
),
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='campaign_emails', to=settings.AUTH_USER_MODEL, verbose_name='user')),
|
),
|
||||||
('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recipients', to='vibes_auth.emailcampaign', verbose_name='campaign')),
|
(
|
||||||
|
"is_active",
|
||||||
|
models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="if set to false, this object can't be seen by users without needed permission",
|
||||||
|
verbose_name="is active",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
help_text="when the object first appeared on the database",
|
||||||
|
verbose_name="created",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
help_text="when the object was last modified",
|
||||||
|
verbose_name="modified",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("pending", "Pending"),
|
||||||
|
("sent", "Sent"),
|
||||||
|
("failed", "Failed"),
|
||||||
|
("opened", "Opened"),
|
||||||
|
("clicked", "Clicked"),
|
||||||
|
],
|
||||||
|
default="pending",
|
||||||
|
max_length=16,
|
||||||
|
verbose_name="status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sent_at",
|
||||||
|
models.DateTimeField(blank=True, null=True, verbose_name="sent at"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"opened_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True, null=True, verbose_name="opened at"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"clicked_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True, null=True, verbose_name="clicked at"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"tracking_id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
help_text="unique ID for tracking opens and clicks",
|
||||||
|
unique=True,
|
||||||
|
verbose_name="tracking ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"error_message",
|
||||||
|
models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="error details if sending failed",
|
||||||
|
verbose_name="error message",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="campaign_emails",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="user",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"campaign",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="recipients",
|
||||||
|
to="vibes_auth.emailcampaign",
|
||||||
|
verbose_name="campaign",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'campaign recipient',
|
"verbose_name": "campaign recipient",
|
||||||
'verbose_name_plural': 'campaign recipients',
|
"verbose_name_plural": "campaign recipients",
|
||||||
'ordering': ('-created',),
|
"ordering": ("-created",),
|
||||||
'indexes': [models.Index(fields=['campaign', 'status'], name='recipient_camp_status_idx'), models.Index(fields=['tracking_id'], name='recipient_tracking_idx')],
|
"indexes": [
|
||||||
|
models.Index(
|
||||||
|
fields=["campaign", "status"], name="recipient_camp_status_idx"
|
||||||
|
),
|
||||||
|
models.Index(fields=["tracking_id"], name="recipient_tracking_idx"),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='emailcampaign',
|
model_name="emailcampaign",
|
||||||
index=models.Index(fields=['status', 'scheduled_at'], name='campaign_status_sched_idx'),
|
index=models.Index(
|
||||||
|
fields=["status", "scheduled_at"], name="campaign_status_sched_idx"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -111,4 +111,9 @@ CELERY_BEAT_SCHEDULE = {
|
||||||
"schedule": timedelta(minutes=5),
|
"schedule": timedelta(minutes=5),
|
||||||
"options": {"queue": "default"},
|
"options": {"queue": "default"},
|
||||||
},
|
},
|
||||||
|
"generate_marketplace_feeds_task": {
|
||||||
|
"task": "engine.core.tasks.generate_marketplace_feeds_task",
|
||||||
|
"schedule": timedelta(days=1),
|
||||||
|
"options": {"queue": "default"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,15 @@ CONSTANCE_CONFIG = OrderedDict(
|
||||||
),
|
),
|
||||||
("EMAIL_FROM", (getenv("EMAIL_FROM", "Schon"), _("Mail from option"))),
|
("EMAIL_FROM", (getenv("EMAIL_FROM", "Schon"), _("Mail from option"))),
|
||||||
### Features Options ###
|
### Features Options ###
|
||||||
|
(
|
||||||
|
"EXPORT_TO_MARKETPLACES",
|
||||||
|
(
|
||||||
|
"",
|
||||||
|
_(
|
||||||
|
"Export products to specified marketplaces. Comma-separated list from <yandex_products/yandex_market/amazon_seller/google_merchant>"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"DAYS_TO_STORE_ANON_MSGS",
|
"DAYS_TO_STORE_ANON_MSGS",
|
||||||
(1, _("How many days we store messages from anonymous users")),
|
(1, _("How many days we store messages from anonymous users")),
|
||||||
|
|
@ -149,6 +158,7 @@ CONSTANCE_CONFIG_FIELDSETS = OrderedDict(
|
||||||
"EMAIL_FROM",
|
"EMAIL_FROM",
|
||||||
),
|
),
|
||||||
_("Features Options"): (
|
_("Features Options"): (
|
||||||
|
"EXPORT_TO_MARKETPLACES",
|
||||||
"DAYS_TO_STORE_ANON_MSGS",
|
"DAYS_TO_STORE_ANON_MSGS",
|
||||||
"DAYS_TO_STORE_AUTH_MSGS",
|
"DAYS_TO_STORE_AUTH_MSGS",
|
||||||
"DISABLED_COMMERCE",
|
"DISABLED_COMMERCE",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue