cron better exception management. show supplier commercial name in product card template. linters config
This commit is contained in:
parent
a812b4385d
commit
87dcb4e350
17 changed files with 104 additions and 100 deletions
8
.flake8
8
.flake8
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
[flake8]
|
[flake8]
|
||||||
max-line-length = 88
|
max-line-length = 88
|
||||||
max-complexity = 30
|
max-complexity = 16
|
||||||
# B = bugbear
|
# B = bugbear
|
||||||
# B9 = bugbear opinionated (incl line length)
|
# B9 = bugbear opinionated (incl line length)
|
||||||
select = C,E,F,W,B,B9
|
select = C,E,F,W,B,B9
|
||||||
|
|
@ -13,6 +13,12 @@ select = C,E,F,W,B,B9
|
||||||
# W503: line break before binary operator (black-compatible)
|
# W503: line break before binary operator (black-compatible)
|
||||||
# B950: line too long (soft limit, complements B)
|
# B950: line too long (soft limit, complements B)
|
||||||
ignore = E203,E501,W503,B950
|
ignore = E203,E501,W503,B950
|
||||||
|
# OCA standard per-file ignores:
|
||||||
|
# - __init__.py imports submodules that are intentionally "unused" (F401)
|
||||||
|
# - __manifest__.py is a bare dict literal, not a useless expression (B018)
|
||||||
|
per-file-ignores =
|
||||||
|
__init__.py: F401
|
||||||
|
__manifest__.py: B018
|
||||||
exclude =
|
exclude =
|
||||||
.git,
|
.git,
|
||||||
__pycache__,
|
__pycache__,
|
||||||
|
|
|
||||||
|
|
@ -110,22 +110,11 @@ repos:
|
||||||
- id: flake8
|
- id: flake8
|
||||||
name: flake8
|
name: flake8
|
||||||
additional_dependencies: ["flake8-bugbear==23.12.2"]
|
additional_dependencies: ["flake8-bugbear==23.12.2"]
|
||||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
# NOTE: mypy is intentionally not configured here. It is not part of the
|
||||||
rev: v1.19.1
|
# OCA pre-commit standard and breaks on Odoo's migration folder layout
|
||||||
hooks:
|
# (e.g. ".../migrations/18.0.2.1.0/post-migrate.py" -> not a valid package
|
||||||
- id: mypy
|
# name), because mypy's exclude does not apply to files passed explicitly
|
||||||
# do not run on test files or __init__ files (mypy does not support
|
# by pre-commit.
|
||||||
# namespace packages)
|
|
||||||
exclude: (/tests/|/__init__\.py$|^scripts/)
|
|
||||||
# Exclude migrations explicitly to avoid duplicate-module errors
|
|
||||||
args: ["--exclude", "(?i).*/migrations/.*"]
|
|
||||||
additional_dependencies:
|
|
||||||
- "lxml"
|
|
||||||
- "odoo-stubs"
|
|
||||||
- "types-python-dateutil"
|
|
||||||
- "types-pytz"
|
|
||||||
- "types-requests"
|
|
||||||
- "types-setuptools"
|
|
||||||
- repo: https://github.com/PyCQA/pylint
|
- repo: https://github.com/PyCQA/pylint
|
||||||
rev: v4.0.4
|
rev: v4.0.4
|
||||||
hooks:
|
hooks:
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ enable=anomalous-backslash-in-string,
|
||||||
manifest-deprecated-key,
|
manifest-deprecated-key,
|
||||||
manifest-external-assets,
|
manifest-external-assets,
|
||||||
manifest-maintainers-list,
|
manifest-maintainers-list,
|
||||||
manifest-required-author,
|
|
||||||
manifest-required-key,
|
manifest-required-key,
|
||||||
manifest-required-key-app,
|
manifest-required-key-app,
|
||||||
manifest-superfluous-key,
|
manifest-superfluous-key,
|
||||||
|
|
@ -53,7 +52,6 @@ enable=anomalous-backslash-in-string,
|
||||||
method-search,
|
method-search,
|
||||||
missing-odoo-file,
|
missing-odoo-file,
|
||||||
missing-odoo-file-app,
|
missing-odoo-file-app,
|
||||||
missing-readme,
|
|
||||||
missing-return,
|
missing-return,
|
||||||
no-raise-unlink,
|
no-raise-unlink,
|
||||||
no-search-all,
|
no-search-all,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ enable=anomalous-backslash-in-string,
|
||||||
manifest-data-duplicated,
|
manifest-data-duplicated,
|
||||||
manifest-deprecated-key,
|
manifest-deprecated-key,
|
||||||
manifest-maintainers-list,
|
manifest-maintainers-list,
|
||||||
manifest-required-author,
|
|
||||||
manifest-required-key,
|
manifest-required-key,
|
||||||
manifest-superfluous-key,
|
manifest-superfluous-key,
|
||||||
manifest-version-format,
|
manifest-version-format,
|
||||||
|
|
@ -34,7 +33,6 @@ enable=anomalous-backslash-in-string,
|
||||||
method-inverse,
|
method-inverse,
|
||||||
method-required-super,
|
method-required-super,
|
||||||
method-search,
|
method-search,
|
||||||
missing-readme,
|
|
||||||
missing-return,
|
missing-return,
|
||||||
no-raise-unlink,
|
no-raise-unlink,
|
||||||
no-write-in-compute,
|
no-write-in-compute,
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,4 @@
|
||||||
"excludes": ["account_invoice_fixed_discount"],
|
"excludes": ["account_invoice_fixed_discount"],
|
||||||
"post_init_hook": "post_init_hook",
|
"post_init_hook": "post_init_hook",
|
||||||
"data": ["report/invoice.xml", "views/account_move.xml"],
|
"data": ["report/invoice.xml", "views/account_move.xml"],
|
||||||
"installable": True,
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,4 @@
|
||||||
"data/ir_cron.xml",
|
"data/ir_cron.xml",
|
||||||
"views/res_config_settings_views.xml",
|
"views/res_config_settings_views.xml",
|
||||||
],
|
],
|
||||||
"installable": True,
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
mypy.ini
18
mypy.ini
|
|
@ -1,18 +0,0 @@
|
||||||
[mypy]
|
|
||||||
# Exclude migration scripts (post-migrate.py etc.) from mypy checks to avoid
|
|
||||||
# duplicate module name errors when multiple addons include scripts with the
|
|
||||||
# same filename.
|
|
||||||
# Exclude scripts/ directory from mypy checks (utility scripts, not Odoo code)
|
|
||||||
exclude = .*/migrations/.*|^scripts/.*
|
|
||||||
|
|
||||||
# Ignore missing imports from Odoo modules
|
|
||||||
ignore_missing_imports = True
|
|
||||||
|
|
||||||
[mypy-odoo]
|
|
||||||
ignore_missing_imports = True
|
|
||||||
|
|
||||||
[mypy-odoo.*]
|
|
||||||
ignore_missing_imports = True
|
|
||||||
|
|
||||||
[mypy-odoo.addons.*]
|
|
||||||
ignore_missing_imports = True
|
|
||||||
|
|
@ -11,8 +11,6 @@
|
||||||
"data": [
|
"data": [
|
||||||
"security/security.xml",
|
"security/security.xml",
|
||||||
"security/ir.model.access.csv",
|
"security/ir.model.access.csv",
|
||||||
"security/ir.model.access.csv",
|
|
||||||
"views/portal_templates.xml",
|
"views/portal_templates.xml",
|
||||||
],
|
],
|
||||||
"installable": True,
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,5 @@
|
||||||
"author": "ACSONE SA/NV,Odoo Community Association (OCA)",
|
"author": "ACSONE SA/NV,Odoo Community Association (OCA)",
|
||||||
"website": "https://github.com/OCA/product-attribute",
|
"website": "https://github.com/OCA/product-attribute",
|
||||||
"depends": ["account", "product"],
|
"depends": ["account", "product"],
|
||||||
"data": [],
|
|
||||||
"demo": ["demo/account.xml", "demo/pricelist.xml"],
|
"demo": ["demo/account.xml", "demo/pricelist.xml"],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,5 @@
|
||||||
"views/view_product_product.xml",
|
"views/view_product_product.xml",
|
||||||
"views/view_product_template.xml",
|
"views/view_product_template.xml",
|
||||||
],
|
],
|
||||||
"installable": True,
|
|
||||||
"pre_init_hook": "pre_init_hook",
|
"pre_init_hook": "pre_init_hook",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,5 +18,4 @@
|
||||||
"views/product_template.xml",
|
"views/product_template.xml",
|
||||||
],
|
],
|
||||||
"maintainers": ["sbejaoui"],
|
"maintainers": ["sbejaoui"],
|
||||||
"installable": True,
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,5 +18,4 @@
|
||||||
"views/res_partner_view.xml",
|
"views/res_partner_view.xml",
|
||||||
],
|
],
|
||||||
"post_init_hook": "post_init_hook",
|
"post_init_hook": "post_init_hook",
|
||||||
"installable": True,
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,24 +17,4 @@ extend-exclude = '''
|
||||||
)/
|
)/
|
||||||
'''
|
'''
|
||||||
|
|
||||||
[tool.isort]
|
# isort config lives in .isort.cfg (single source of truth).
|
||||||
profile = "black"
|
|
||||||
force_single_line = true
|
|
||||||
line_length = 88
|
|
||||||
multi_line_output = 3
|
|
||||||
include_trailing_comma = true
|
|
||||||
force_grid_wrap = 0
|
|
||||||
use_parentheses = true
|
|
||||||
ensure_newline_before_comments = true
|
|
||||||
skip_glob = ["*/__init__.py"]
|
|
||||||
known_odoo = ["odoo"]
|
|
||||||
known_odoo_addons = ["odoo.addons"]
|
|
||||||
sections = ["FUTURE", "STDLIB", "THIRDPARTY", "ODOO", "ODOO_ADDONS", "FIRSTPARTY", "LOCALFOLDER"]
|
|
||||||
default_section = "THIRDPARTY"
|
|
||||||
|
|
||||||
[tool.mypy]
|
|
||||||
# Excluir carpetas de migraciones y archivos de post-migrate.py que usan guiones
|
|
||||||
# (evita errores de "Duplicate module" en mypy cuando múltiples addons contienen
|
|
||||||
# archivos con el mismo nombre como `post-migrate.py`). Usamos una expresión
|
|
||||||
# regular que coincide con cualquier ruta que contenga `/migrations/`.
|
|
||||||
exclude = "(?i).*/migrations/.*"
|
|
||||||
|
|
|
||||||
21
setup.cfg
21
setup.cfg
|
|
@ -1,21 +0,0 @@
|
||||||
[flake8]
|
|
||||||
max-line-length = 88
|
|
||||||
max-complexity = 16
|
|
||||||
select = C,E,F,W,B,B9
|
|
||||||
ignore = E203,E501,W503,B950
|
|
||||||
exclude = scripts/, .venv/
|
|
||||||
|
|
||||||
[isort]
|
|
||||||
profile = black
|
|
||||||
force_single_line = True
|
|
||||||
line_length = 88
|
|
||||||
multi_line_output = 3
|
|
||||||
include_trailing_comma = True
|
|
||||||
force_grid_wrap = 0
|
|
||||||
use_parentheses = True
|
|
||||||
ensure_newline_before_comments = True
|
|
||||||
skip_glob = */__init__.py
|
|
||||||
known_odoo=odoo
|
|
||||||
known_odoo_addons=odoo.addons
|
|
||||||
sections=FUTURE,STDLIB,THIRDPARTY,ODOO,ODOO_ADDONS,FIRSTPARTY,LOCALFOLDER
|
|
||||||
default_section=THIRDPARTY
|
|
||||||
|
|
@ -246,7 +246,7 @@ def _get_product_supplier_info(self, products):
|
||||||
supplier_name = ""
|
supplier_name = ""
|
||||||
if product.seller_ids:
|
if product.seller_ids:
|
||||||
partner = product.seller_ids[0].partner_id.sudo()
|
partner = product.seller_ids[0].partner_id.sudo()
|
||||||
supplier_name = partner.name or ""
|
supplier_name = partner.company_name or partner.name or ""
|
||||||
if partner.city:
|
if partner.city:
|
||||||
supplier_name += f" ({partner.city})"
|
supplier_name += f" ({partner.city})"
|
||||||
product_supplier_info[product.id] = supplier_name
|
product_supplier_info[product.id] = supplier_name
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,6 @@ from odoo import models
|
||||||
from odoo.exceptions import ValidationError
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
# Pylint: explicit 'string' attributes are intentional for readable labels in views.
|
|
||||||
# Some linters flag these as redundant; disable that specific check here.
|
|
||||||
# pylint: disable=attribute-string-redundant
|
|
||||||
|
|
||||||
|
|
||||||
class GroupOrder(models.Model):
|
class GroupOrder(models.Model):
|
||||||
|
|
@ -945,6 +942,7 @@ class GroupOrder(models.Model):
|
||||||
# route configuration doesn't block all remaining orders.
|
# route configuration doesn't block all remaining orders.
|
||||||
confirmed_sale_orders = SaleOrder.browse()
|
confirmed_sale_orders = SaleOrder.browse()
|
||||||
failed_sale_orders = SaleOrder.browse()
|
failed_sale_orders = SaleOrder.browse()
|
||||||
|
failure_reasons = {}
|
||||||
|
|
||||||
for sale_order in sale_orders:
|
for sale_order in sale_orders:
|
||||||
try:
|
try:
|
||||||
|
|
@ -953,9 +951,18 @@ class GroupOrder(models.Model):
|
||||||
# route issues. This cron confirms business orders first
|
# route issues. This cron confirms business orders first
|
||||||
# and handles stock exceptions operationally.
|
# and handles stock exceptions operationally.
|
||||||
sale_order.with_context(from_orderpoint=True).action_confirm()
|
sale_order.with_context(from_orderpoint=True).action_confirm()
|
||||||
|
# Flush INSIDE the savepoint. Odoo raises many
|
||||||
|
# ValidationErrors lazily at flush/recompute time, which
|
||||||
|
# would otherwise escape this savepoint (it has already
|
||||||
|
# been released on block exit) and poison the
|
||||||
|
# transaction, aborting every remaining sale order. By
|
||||||
|
# forcing the flush here, a failing order is rolled back
|
||||||
|
# to its savepoint and the loop continues cleanly.
|
||||||
|
self.env.flush_all()
|
||||||
confirmed_sale_orders |= sale_order
|
confirmed_sale_orders |= sale_order
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
failed_sale_orders |= sale_order
|
failed_sale_orders |= sale_order
|
||||||
|
failure_reasons[sale_order.id] = str(exc) or exc.__class__.__name__
|
||||||
_logger.exception(
|
_logger.exception(
|
||||||
"Cron: Error confirming sale order %s (%s) for group order %s (%s). "
|
"Cron: Error confirming sale order %s (%s) for group order %s (%s). "
|
||||||
"Order skipped; remaining orders will continue.",
|
"Order skipped; remaining orders will continue.",
|
||||||
|
|
@ -965,9 +972,12 @@ class GroupOrder(models.Model):
|
||||||
self.name,
|
self.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
batches = self.env["stock.picking.batch"]
|
||||||
if confirmed_sale_orders:
|
if confirmed_sale_orders:
|
||||||
# Create picking batches only for confirmed sale orders
|
# Create picking batches only for confirmed sale orders
|
||||||
self._create_picking_batches_for_sale_orders(confirmed_sale_orders)
|
batches = self._create_picking_batches_for_sale_orders(
|
||||||
|
confirmed_sale_orders
|
||||||
|
)
|
||||||
self._log_missing_procurement_warnings(confirmed_sale_orders)
|
self._log_missing_procurement_warnings(confirmed_sale_orders)
|
||||||
|
|
||||||
if failed_sale_orders:
|
if failed_sale_orders:
|
||||||
|
|
@ -980,6 +990,9 @@ class GroupOrder(models.Model):
|
||||||
self.name,
|
self.name,
|
||||||
failed_sale_orders.ids,
|
failed_sale_orders.ids,
|
||||||
)
|
)
|
||||||
|
self._post_failed_sale_orders_message(
|
||||||
|
batches, failed_sale_orders, failure_reasons
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
_logger.exception(
|
_logger.exception(
|
||||||
"Cron: Error confirming sale orders for group order %s (%s)",
|
"Cron: Error confirming sale orders for group order %s (%s)",
|
||||||
|
|
@ -987,6 +1000,66 @@ class GroupOrder(models.Model):
|
||||||
self.name,
|
self.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _post_failed_sale_orders_message(
|
||||||
|
self, batches, failed_sale_orders, failure_reasons=None
|
||||||
|
):
|
||||||
|
"""Post a note on the picking batch wall about sale orders that failed.
|
||||||
|
|
||||||
|
Operators read the batch chatter to follow up manually on orders that
|
||||||
|
could not be confirmed (and are therefore NOT part of the batch).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
batches: stock.picking.batch recordset to notify (may be empty).
|
||||||
|
failed_sale_orders: sale.order recordset that failed to confirm.
|
||||||
|
failure_reasons: optional {sale_order_id: error message} mapping.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if not failed_sale_orders:
|
||||||
|
return
|
||||||
|
|
||||||
|
failure_reasons = failure_reasons or {}
|
||||||
|
from markupsafe import Markup
|
||||||
|
from markupsafe import escape
|
||||||
|
|
||||||
|
items = Markup()
|
||||||
|
for sale_order in failed_sale_orders:
|
||||||
|
reason = failure_reasons.get(sale_order.id)
|
||||||
|
label = sale_order.name or self.env._("(unnamed)")
|
||||||
|
partner = sale_order.partner_id.display_name or ""
|
||||||
|
item = escape(f"{label} ({partner})") if partner else escape(label)
|
||||||
|
if reason:
|
||||||
|
item += Markup(": ") + escape(reason)
|
||||||
|
items += Markup("<li>%s</li>") % item
|
||||||
|
|
||||||
|
body = (
|
||||||
|
escape(
|
||||||
|
self.env._(
|
||||||
|
"%(count)d sale order(s) linked to group order "
|
||||||
|
"“%(name)s” failed to confirm and are NOT part of this "
|
||||||
|
"batch. Please review and confirm them manually:",
|
||||||
|
count=len(failed_sale_orders),
|
||||||
|
name=self.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ Markup("<ul>%s</ul>") % items
|
||||||
|
)
|
||||||
|
|
||||||
|
if not batches:
|
||||||
|
# No batch was created (all confirmations failed), so there is no
|
||||||
|
# wall to post on. The warning logged by the caller is the record.
|
||||||
|
_logger.warning(
|
||||||
|
"Cron: No picking batch available to report %d failed sale "
|
||||||
|
"orders for group order %s (%s): ids=%s",
|
||||||
|
len(failed_sale_orders),
|
||||||
|
self.id,
|
||||||
|
self.name,
|
||||||
|
failed_sale_orders.ids,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
for batch in batches:
|
||||||
|
batch.message_post(body=body, subtype_xmlid="mail.mt_note")
|
||||||
|
|
||||||
def _log_missing_procurement_warnings(self, sale_orders):
|
def _log_missing_procurement_warnings(self, sale_orders):
|
||||||
"""Log warnings for confirmed orders with stockable lines lacking moves.
|
"""Log warnings for confirmed orders with stockable lines lacking moves.
|
||||||
|
|
||||||
|
|
@ -1028,9 +1101,13 @@ class GroupOrder(models.Model):
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
sale_orders: Recordset of confirmed sale.order
|
sale_orders: Recordset of confirmed sale.order
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
stock.picking.batch recordset with the batches created here.
|
||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
StockPickingBatch = self.env["stock.picking.batch"].sudo()
|
StockPickingBatch = self.env["stock.picking.batch"].sudo()
|
||||||
|
created_batches = StockPickingBatch.browse()
|
||||||
|
|
||||||
# Create batches per group order, not per consumer group.
|
# Create batches per group order, not per consumer group.
|
||||||
# If multiple picking types exist, keep one batch per picking type.
|
# If multiple picking types exist, keep one batch per picking type.
|
||||||
|
|
@ -1067,6 +1144,7 @@ class GroupOrder(models.Model):
|
||||||
)
|
)
|
||||||
|
|
||||||
pickings.write({"batch_id": batch.id})
|
pickings.write({"batch_id": batch.id})
|
||||||
|
created_batches |= batch
|
||||||
|
|
||||||
_logger.info(
|
_logger.info(
|
||||||
"Cron: Created batch %s with %d pickings for group order %s",
|
"Cron: Created batch %s with %d pickings for group order %s",
|
||||||
|
|
@ -1074,3 +1152,5 @@ class GroupOrder(models.Model):
|
||||||
len(pickings),
|
len(pickings),
|
||||||
self.name,
|
self.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return created_batches
|
||||||
|
|
|
||||||
|
|
@ -638,14 +638,14 @@
|
||||||
<i class="fa fa-image fa-3x text-muted" />
|
<i class="fa fa-image fa-3x text-muted" />
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
<t t-set="user_ribbon" t-value="product.sudo().variant_ribbon_id or product.sudo().website_ribbon_id" />
|
|
||||||
<t t-if="user_ribbon">
|
|
||||||
<span t-attf-class="o_ribbon {{ user_ribbon._get_position_class() }} z-1" t-attf-style="color: {{ user_ribbon.text_color }}; background-color: {{ user_ribbon.bg_color }};" t-esc="user_ribbon.name" />
|
|
||||||
</t>
|
|
||||||
<t t-if="product.dynamic_ribbon_id">
|
<t t-if="product.dynamic_ribbon_id">
|
||||||
<t t-set="ribbon" t-value="product.dynamic_ribbon_id" />
|
<t t-set="ribbon" t-value="product.dynamic_ribbon_id" />
|
||||||
<span t-attf-class="o_ribbon {{ ribbon._get_position_class() }} z-1" t-attf-style="color: {{ ribbon.text_color }}; background-color: {{ ribbon.bg_color }};" t-esc="ribbon.name" />
|
<span t-attf-class="o_ribbon {{ ribbon._get_position_class() }} z-1" t-attf-style="color: {{ ribbon.text_color }}; background-color: {{ ribbon.bg_color }};" t-esc="ribbon.name" />
|
||||||
</t>
|
</t>
|
||||||
|
<t t-set="user_ribbon" t-value="product.sudo().variant_ribbon_id or product.sudo().website_ribbon_id" />
|
||||||
|
<t t-if="user_ribbon">
|
||||||
|
<span t-attf-class="o_ribbon {{ user_ribbon._get_position_class() }} z-1" t-attf-style="color: {{ user_ribbon.text_color }}; background-color: {{ user_ribbon.bg_color }};" t-esc="user_ribbon.name" />
|
||||||
|
</t>
|
||||||
<div class="card-body d-flex flex-column">
|
<div class="card-body d-flex flex-column">
|
||||||
<h6 class="card-title" t-esc="product.name" />
|
<h6 class="card-title" t-esc="product.name" />
|
||||||
<t t-if="product.product_tag_ids">
|
<t t-if="product.product_tag_ids">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue