cron better exception management. show supplier commercial name in product card template. linters config

This commit is contained in:
GitHub Copilot 2026-06-02 10:43:54 +02:00
parent a812b4385d
commit 87dcb4e350
17 changed files with 104 additions and 100 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,5 +18,4 @@
"views/product_template.xml", "views/product_template.xml",
], ],
"maintainers": ["sbejaoui"], "maintainers": ["sbejaoui"],
"installable": True,
} }

View file

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

View file

@ -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/.*"

View file

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

View file

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

View file

@ -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()
confirmed_sale_orders |= sale_order # Flush INSIDE the savepoint. Odoo raises many
except Exception: # 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
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

View file

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