Skip to content

Commit 5c92337

Browse files
[ADD] shoppingfeed_integration
TT56368
1 parent 3be87de commit 5c92337

36 files changed

Lines changed: 4286 additions & 0 deletions
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
========================
2+
Shoppingfeed Integration
3+
========================
4+
5+
..
6+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
7+
!! This file is generated by oca-gen-addon-readme !!
8+
!! changes will be overwritten. !!
9+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
10+
!! source digest: sha256:900310fb90d1b308542f357095bbe83e185157a9bf74ec78154f8f636d4d8098
11+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
12+
13+
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
14+
:target: https://odoo-community.org/page/development-status
15+
:alt: Beta
16+
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
17+
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
18+
:alt: License: AGPL-3
19+
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fshoppingfeed-lightgray.png?logo=github
20+
:target: https://github.com/OCA/shoppingfeed/tree/18.0/shoppingfeed_integration
21+
:alt: OCA/shoppingfeed
22+
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
23+
:target: https://translation.odoo-community.org/projects/shoppingfeed-18-0/shoppingfeed-18-0-shoppingfeed_integration
24+
:alt: Translate me on Weblate
25+
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
26+
:target: https://runboat.odoo-community.org/builds?repo=OCA/shoppingfeed&target_branch=18.0
27+
:alt: Try me on Runboat
28+
29+
|badge1| |badge2| |badge3| |badge4| |badge5|
30+
31+
Module for Shoppingfeed integration
32+
33+
**Table of contents**
34+
35+
.. contents::
36+
:local:
37+
38+
Usage
39+
=====
40+
41+
The product catalog feed is available at: /catalog.xml
42+
43+
Bug Tracker
44+
===========
45+
46+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/shoppingfeed/issues>`_.
47+
In case of trouble, please check there if your issue has already been reported.
48+
If you spotted it first, help us to smash it by providing a detailed and welcomed
49+
`feedback <https://github.com/OCA/shoppingfeed/issues/new?body=module:%20shoppingfeed_integration%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
50+
51+
Do not contact contributors directly about support or help with technical issues.
52+
53+
Credits
54+
=======
55+
56+
Authors
57+
-------
58+
59+
* Tecnativa
60+
61+
Contributors
62+
------------
63+
64+
- `Tecnativa <https://www.tecnativa.com>`__:
65+
66+
- Juan Carlos Oñate
67+
68+
Maintainers
69+
-----------
70+
71+
This module is maintained by the OCA.
72+
73+
.. image:: https://odoo-community.org/logo.png
74+
:alt: Odoo Community Association
75+
:target: https://odoo-community.org
76+
77+
OCA, or the Odoo Community Association, is a nonprofit organization whose
78+
mission is to support the collaborative development of Odoo features and
79+
promote its widespread use.
80+
81+
.. |maintainer-juancarlosonate-tecnativa| image:: https://github.com/juancarlosonate-tecnativa.png?size=40px
82+
:target: https://github.com/juancarlosonate-tecnativa
83+
:alt: juancarlosonate-tecnativa
84+
85+
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
86+
87+
|maintainer-juancarlosonate-tecnativa|
88+
89+
This module is part of the `OCA/shoppingfeed <https://github.com/OCA/shoppingfeed/tree/18.0/shoppingfeed_integration>`_ project on GitHub.
90+
91+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import controllers
2+
from . import models
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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+
"name": "Shoppingfeed Integration",
5+
"version": "18.0.1.0.0",
6+
"summary": "Integrate Odoo with Shoppingfeed for product export and order sync",
7+
"category": "Sales",
8+
"author": "Tecnativa, Odoo Community Association (OCA)",
9+
"maintainers": ["juancarlosonate-tecnativa"],
10+
"website": "https://github.com/OCA/shoppingfeed",
11+
"license": "AGPL-3",
12+
"depends": [
13+
"stock",
14+
"website_sale",
15+
"product_brand",
16+
"sale_order_type",
17+
"account_payment_sale",
18+
],
19+
"data": [
20+
"security/ir.model.access.csv",
21+
"security/ir_rule.xml",
22+
"views/sale_order_views.xml",
23+
"views/shoppingfeed_ticket_views.xml",
24+
"views/shoppingfeed_store_views.xml",
25+
"views/product_product_views.xml",
26+
"views/shoppingfeed_channel_views.xml",
27+
"views/product_attribute_views.xml",
28+
"views/menus.xml",
29+
"data/cron.xml",
30+
],
31+
"installable": True,
32+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import catalog
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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

Comments
 (0)