|
| 1 | +# Copyright 2025 Juan Carlos Oñate - Tecnativa <juancarlos.onate@tecnativa.com> |
| 2 | +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
| 3 | + |
| 4 | +from datetime import datetime |
| 5 | + |
| 6 | +from lxml import etree |
| 7 | + |
| 8 | +from odoo import http, release |
| 9 | +from odoo.http import Response, request |
| 10 | + |
| 11 | + |
| 12 | +class CatalogController(http.Controller): |
| 13 | + @http.route( |
| 14 | + ["/catalog.xml", "/catalog/<string:catalog_id>.xml"], |
| 15 | + type="http", |
| 16 | + auth="public", |
| 17 | + website=True, |
| 18 | + csrf=False, |
| 19 | + ) |
| 20 | + def catalog_feed(self, catalog_id=None, **kwargs): |
| 21 | + # Generate Shoppingfeed product catalog XML for a specific store. |
| 22 | + if not catalog_id: |
| 23 | + return Response( |
| 24 | + "<error>Missing required catalog_id.</error>", |
| 25 | + status=400, |
| 26 | + content_type="application/xml;charset=utf-8", |
| 27 | + ) |
| 28 | + store = ( |
| 29 | + request.env["shoppingfeed.store"] |
| 30 | + .sudo() |
| 31 | + .search([("catalog_id", "=", catalog_id)], limit=1) |
| 32 | + ) |
| 33 | + if not store: |
| 34 | + return Response( |
| 35 | + f"<error>Store with catalog_id {catalog_id} not found.</error>", |
| 36 | + status=404, |
| 37 | + content_type="application/xml;charset=utf-8", |
| 38 | + ) |
| 39 | + products = self._get_products_for_store(store) |
| 40 | + catalog_el = self._build_catalog_xml(store, products) |
| 41 | + xml_bytes = etree.tostring( |
| 42 | + catalog_el, pretty_print=True, xml_declaration=True, encoding="UTF-8" |
| 43 | + ) |
| 44 | + return Response( |
| 45 | + xml_bytes, content_type="application/xml;charset=utf-8", status=200 |
| 46 | + ) |
| 47 | + |
| 48 | + def _get_products_for_store(self, store): |
| 49 | + env = request.env["product.product"].sudo().with_company(store.company_id) |
| 50 | + if store.lang_id: |
| 51 | + env = env.with_context(lang=store.lang_id.code) |
| 52 | + domain = [] |
| 53 | + if store.export_only_selected: |
| 54 | + domain.append(("export_to_shoppingfeed", "=", True)) |
| 55 | + domain.append(("shoppingfeed_store_ids", "in", store.id)) |
| 56 | + # Product type filters |
| 57 | + product_types = [] |
| 58 | + if store.export_type_goods: |
| 59 | + product_types.append("consu") |
| 60 | + if store.export_type_service: |
| 61 | + product_types.append("service") |
| 62 | + if store.export_type_combo: |
| 63 | + product_types.append("combo") |
| 64 | + domain.append(("type", "in", product_types)) |
| 65 | + if not store.export_out_of_stock: |
| 66 | + domain.append(("qty_available", ">", 0)) |
| 67 | + if not store.export_disabled_products: |
| 68 | + domain.append(("active", "=", True)) |
| 69 | + if not store.export_not_salable_products: |
| 70 | + domain.append(("sale_ok", "=", True)) |
| 71 | + if store.allowed_categ_ids: |
| 72 | + domain.append(("categ_id", "in", store.allowed_categ_ids.ids)) |
| 73 | + return env.search(domain) |
| 74 | + |
| 75 | + def _build_catalog_xml(self, store, products): |
| 76 | + catalog_el = etree.Element("catalog") |
| 77 | + products_el = etree.SubElement(catalog_el, "products", version="1.0.0") |
| 78 | + for product in products: |
| 79 | + product_el = etree.SubElement(products_el, "product") |
| 80 | + self._add_product_base_info(store, product_el, product) |
| 81 | + self._add_product_stock_and_price(store, product_el, product) |
| 82 | + self._add_product_media(store, product_el, product) |
| 83 | + self._add_product_attributes(store, product_el, product) |
| 84 | + self._add_metadata(catalog_el, len(products)) |
| 85 | + return catalog_el |
| 86 | + |
| 87 | + def _add_product_base_info(self, store, product_el, product): |
| 88 | + if store.use_product_id_as_sku: |
| 89 | + sku_value = str(product.id) |
| 90 | + elif store.custom_sku_field_id: |
| 91 | + sku_value = getattr(product, store.custom_sku_field_id.name, False) or str( |
| 92 | + product.id |
| 93 | + ) |
| 94 | + else: |
| 95 | + sku_value = product.default_code or str(product.id) |
| 96 | + etree.SubElement( |
| 97 | + product_el, "reference" |
| 98 | + ).text = f"{sku_value}_{store.country_id.code}" |
| 99 | + etree.SubElement(product_el, "gtin").text = product.barcode or "" |
| 100 | + etree.SubElement(product_el, "name").text = etree.CDATA(product.name or "") |
| 101 | + if product.website_url: |
| 102 | + product_url = request.httprequest.host_url.rstrip("/") + product.website_url |
| 103 | + etree.SubElement(product_el, "link").text = etree.CDATA(product_url) |
| 104 | + if product.weight: |
| 105 | + etree.SubElement(product_el, "weight").text = str(product.weight) |
| 106 | + if product.product_brand_id: |
| 107 | + etree.SubElement(product_el, "brand").text = etree.CDATA( |
| 108 | + product.product_brand_id.name or "" |
| 109 | + ) |
| 110 | + if product.categ_id: |
| 111 | + category_el = etree.SubElement(product_el, "category") |
| 112 | + etree.SubElement(category_el, "name").text = etree.CDATA( |
| 113 | + product.categ_id.complete_name |
| 114 | + ) |
| 115 | + etree.SubElement(category_el, "link").text = etree.CDATA( |
| 116 | + f"{request.httprequest.host_url}shop/category/{product.categ_id.id}" |
| 117 | + ) |
| 118 | + description_el = etree.SubElement(product_el, "description") |
| 119 | + full_desc = product.website_description or "" |
| 120 | + etree.SubElement(description_el, "full").text = etree.CDATA(full_desc) |
| 121 | + short_desc = product.description_sale or "" |
| 122 | + etree.SubElement(description_el, "short").text = etree.CDATA(short_desc) |
| 123 | + |
| 124 | + def _add_product_stock_and_price(self, store, product_el, product): |
| 125 | + price = product.lst_price or 0.0 |
| 126 | + if store.pricelist_id: |
| 127 | + price = store.pricelist_id._get_product_price(product, 1.0, None) |
| 128 | + etree.SubElement(product_el, "price").text = str(price) |
| 129 | + if not store.use_actual_stock_state: |
| 130 | + quantity_value = store.default_quantity |
| 131 | + else: |
| 132 | + quantity_value = ( |
| 133 | + product.virtual_available |
| 134 | + if store.quantity_type == "virtual" |
| 135 | + else product.qty_available |
| 136 | + ) or 0 |
| 137 | + if product.type == "service": |
| 138 | + quantity_value = store.default_quantity |
| 139 | + if not product.sale_ok and store.force_zero_quantity_non_salable: |
| 140 | + quantity_value = 0 |
| 141 | + etree.SubElement(product_el, "quantity").text = str(int(quantity_value)) |
| 142 | + |
| 143 | + def _add_product_media(self, store, product_el, product): |
| 144 | + images_el = etree.SubElement(product_el, "images") |
| 145 | + product_images = product.product_template_image_ids |
| 146 | + if store.export_all_images: |
| 147 | + all_images = product_images |
| 148 | + else: |
| 149 | + all_images = product_images[: store.exported_image_count] |
| 150 | + if product.image_1920: |
| 151 | + etree.SubElement(images_el, "image", type="main").text = etree.CDATA( |
| 152 | + f"{request.httprequest.host_url}web/image/product.product/{product.id}/image_1920" |
| 153 | + ) |
| 154 | + for img in all_images: |
| 155 | + etree.SubElement(images_el, "image").text = etree.CDATA( |
| 156 | + f"{request.httprequest.host_url}web/image/{img._name}/{img.id}/image_1920" |
| 157 | + ) |
| 158 | + |
| 159 | + def _add_product_attributes(self, store, product_el, product): |
| 160 | + attributes_el = etree.SubElement(product_el, "attributes") |
| 161 | + # Attributes that generate variants |
| 162 | + for value in product.product_template_attribute_value_ids: |
| 163 | + attr_el = etree.SubElement(attributes_el, "attribute") |
| 164 | + name = ( |
| 165 | + value.attribute_id.shoppingfeed_code_name_attribute |
| 166 | + or value.attribute_id.name |
| 167 | + ) |
| 168 | + etree.SubElement(attr_el, "name").text = name |
| 169 | + etree.SubElement(attr_el, "value").text = value.name |
| 170 | + # Attributes that do NOT generate variants |
| 171 | + for line in product.product_tmpl_id.attribute_line_ids.filtered( |
| 172 | + lambda line_var: line_var.attribute_id.create_variant == "no_variant" |
| 173 | + ): |
| 174 | + if line.value_ids: |
| 175 | + value = line.value_ids[0] # use only the first value |
| 176 | + attr_el = etree.SubElement(attributes_el, "attribute") |
| 177 | + name = ( |
| 178 | + line.attribute_id.shoppingfeed_code_name_attribute |
| 179 | + or line.attribute_id.name |
| 180 | + ) |
| 181 | + etree.SubElement(attr_el, "name").text = name |
| 182 | + etree.SubElement(attr_el, "value").text = value.name |
| 183 | + else: |
| 184 | + attributes_el = etree.SubElement(product_el, "attributes") |
| 185 | + if store.additional_attribute_field_ids: |
| 186 | + for field in store.additional_attribute_field_ids: |
| 187 | + value = getattr(product, field.name, False) |
| 188 | + if not value: |
| 189 | + continue |
| 190 | + attr_el = etree.SubElement(attributes_el, "attribute") |
| 191 | + etree.SubElement(attr_el, "name").text = ( |
| 192 | + field.field_description or field.name |
| 193 | + ) |
| 194 | + if field.ttype == "many2one": |
| 195 | + etree.SubElement(attr_el, "value").text = ( |
| 196 | + value.display_name |
| 197 | + if hasattr(value, "display_name") |
| 198 | + else str(value.id) |
| 199 | + ) |
| 200 | + elif field.ttype == "boolean": |
| 201 | + etree.SubElement(attr_el, "value").text = ( |
| 202 | + "True" if value else "False" |
| 203 | + ) |
| 204 | + else: |
| 205 | + etree.SubElement(attr_el, "value").text = str(value) |
| 206 | + |
| 207 | + def _add_metadata(self, catalog_el, total_products): |
| 208 | + metadata_el = etree.SubElement(catalog_el, "metadata") |
| 209 | + etree.SubElement(metadata_el, "platform").text = f"Odoo:{release.version}" |
| 210 | + etree.SubElement( |
| 211 | + metadata_el, "agent" |
| 212 | + ).text = f"shoppingfeed_integration:{release.version}" |
| 213 | + etree.SubElement(metadata_el, "startedAt").text = datetime.now().isoformat() |
| 214 | + etree.SubElement(metadata_el, "finishedAt").text = datetime.now().isoformat() |
| 215 | + etree.SubElement(metadata_el, "invalid").text = "0" |
| 216 | + etree.SubElement(metadata_el, "ignored").text = "0" |
| 217 | + etree.SubElement(metadata_el, "written").text = str(total_products) |
0 commit comments