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):
|
||||
|
||||
dependencies = [
|
||||
('blog', '0007_post_is_static_page'),
|
||||
("blog", "0007_post_is_static_page"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_ar_ar',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_ar_ar",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_cs_cz',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_cs_cz",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_da_dk',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_da_dk",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_de_de',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_de_de",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_en_gb',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_en_gb",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_en_us',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_en_us",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_es_es',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_es_es",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_fa_ir',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_fa_ir",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_fr_fr',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_fr_fr",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_he_il',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_he_il",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_hi_in',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_hi_in",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_hr_hr',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_hr_hr",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_id_id',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_id_id",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_it_it',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_it_it",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_ja_jp',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_ja_jp",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_kk_kz',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_kk_kz",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_ko_kr',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_ko_kr",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_nl_nl',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_nl_nl",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_no_no',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_no_no",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_pl_pl',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_pl_pl",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_pt_br',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_pt_br",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_ro_ro',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_ro_ro",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_ru_ru',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_ru_ru",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_sv_se',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_sv_se",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_th_th',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_th_th",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_tr_tr',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_tr_tr",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_vi_vn',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_vi_vn",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="post content", null=True, verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='content_zh_hans',
|
||||
field=models.TextField(blank=True, help_text='post content', null=True, verbose_name='content'),
|
||||
model_name="post",
|
||||
name="content_zh_hans",
|
||||
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.management import call_command
|
||||
|
||||
from engine.core.feeds import FEED_GENERATORS
|
||||
from engine.core.models import Product, Promotion
|
||||
from engine.core.utils.caching import set_default_cache
|
||||
from engine.core.utils.vendors import get_vendors_integrations
|
||||
|
|
@ -238,3 +239,59 @@ def process_promotions() -> tuple[bool, str]:
|
|||
promotion.products.add(product)
|
||||
|
||||
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
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import engine.vibes_auth.emailing.models
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import engine.vibes_auth.emailing.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vibes_auth', '0006_chatthread_chatmessage_and_more'),
|
||||
("vibes_auth", "0006_chatthread_chatmessage_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EmailImage',
|
||||
name="EmailImage",
|
||||
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')),
|
||||
('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')),
|
||||
(
|
||||
"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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"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={
|
||||
'verbose_name': 'email image',
|
||||
'verbose_name_plural': 'email images',
|
||||
'ordering': ('-created',),
|
||||
"verbose_name": "email image",
|
||||
"verbose_name_plural": "email images",
|
||||
"ordering": ("-created",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EmailTemplate',
|
||||
name="EmailTemplate",
|
||||
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')),
|
||||
('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')),
|
||||
(
|
||||
"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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"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={
|
||||
'verbose_name': 'email template',
|
||||
'verbose_name_plural': 'email templates',
|
||||
'ordering': ('name',),
|
||||
"verbose_name": "email template",
|
||||
"verbose_name_plural": "email templates",
|
||||
"ordering": ("name",),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='unsubscribe_token',
|
||||
field=models.UUIDField(default=uuid.uuid4, help_text='token for secure one-click unsubscribe from campaigns', verbose_name='unsubscribe token'),
|
||||
model_name="user",
|
||||
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(
|
||||
name='EmailCampaign',
|
||||
name="EmailCampaign",
|
||||
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')),
|
||||
('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')),
|
||||
(
|
||||
"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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"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={
|
||||
'verbose_name': 'email campaign',
|
||||
'verbose_name_plural': 'email campaigns',
|
||||
'ordering': ('-created',),
|
||||
"verbose_name": "email campaign",
|
||||
"verbose_name_plural": "email campaigns",
|
||||
"ordering": ("-created",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CampaignRecipient',
|
||||
name="CampaignRecipient",
|
||||
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')),
|
||||
('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')),
|
||||
(
|
||||
"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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"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={
|
||||
'verbose_name': 'campaign recipient',
|
||||
'verbose_name_plural': 'campaign recipients',
|
||||
'ordering': ('-created',),
|
||||
'indexes': [models.Index(fields=['campaign', 'status'], name='recipient_camp_status_idx'), models.Index(fields=['tracking_id'], name='recipient_tracking_idx')],
|
||||
"verbose_name": "campaign recipient",
|
||||
"verbose_name_plural": "campaign recipients",
|
||||
"ordering": ("-created",),
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["campaign", "status"], name="recipient_camp_status_idx"
|
||||
),
|
||||
models.Index(fields=["tracking_id"], name="recipient_tracking_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='emailcampaign',
|
||||
index=models.Index(fields=['status', 'scheduled_at'], name='campaign_status_sched_idx'),
|
||||
model_name="emailcampaign",
|
||||
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),
|
||||
"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"))),
|
||||
### 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",
|
||||
(1, _("How many days we store messages from anonymous users")),
|
||||
|
|
@ -149,6 +158,7 @@ CONSTANCE_CONFIG_FIELDSETS = OrderedDict(
|
|||
"EMAIL_FROM",
|
||||
),
|
||||
_("Features Options"): (
|
||||
"EXPORT_TO_MARKETPLACES",
|
||||
"DAYS_TO_STORE_ANON_MSGS",
|
||||
"DAYS_TO_STORE_AUTH_MSGS",
|
||||
"DISABLED_COMMERCE",
|
||||
|
|
|
|||
Loading…
Reference in a new issue