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:
Egor Pavlovich Gorbunov 2026-01-26 17:44:21 +03:00
parent c0c1697003
commit adfa2f20dd
12 changed files with 2405 additions and 578 deletions

View file

@ -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"
),
),
]

View 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",
]

View 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
View 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)

View 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)

View 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"]

View 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])

View file

@ -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

View file

@ -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"
),
),
]

View file

@ -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"},
},
}

View file

@ -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",