schon/engine/core/feeds/base.py
Egor fureunoir Gorbunov adfa2f20dd 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.
2026-01-26 17:44:21 +03:00

168 lines
5.4 KiB
Python

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)