19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:12 +01:00
parent 79f83631d5
commit 73afc09215
6267 changed files with 1534193 additions and 1130106 deletions

View file

@ -1,57 +1,100 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
import json
import logging
from odoo import api, fields, models, _
from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools.float_utils import float_round
_logger = logging.getLogger(__name__)
from odoo.tools import float_round
class ProductTemplate(models.Model):
_inherit = 'product.template'
_check_company_auto = True
service_type = fields.Selection(
[('manual', 'Manually set quantities on order')], string='Track Service',
selection=[('manual', "Manually set quantities on order")],
string="Track Service",
compute='_compute_service_type', store=True, readonly=False, precompute=True,
help="Manually set quantities on order: Invoice based on the manually entered quantity, without creating an analytic account.\n"
"Timesheets on contract: Invoice based on the tracked hours on the related timesheet.\n"
"Create a task and track hours: Create a task on the sales order validation and track the work hours.")
sale_line_warn = fields.Selection(WARNING_MESSAGE, 'Sales Order Line', help=WARNING_HELP, required=True, default="no-message")
sale_line_warn_msg = fields.Text('Message for Sales Order Line')
sale_line_warn_msg = fields.Text(string="Sales Order Line Warning")
expense_policy = fields.Selection(
[('no', 'No'),
('cost', 'At cost'),
('sales_price', 'Sales price')
], string='Re-Invoice Expenses', default='no',
selection=[
('no', "No"),
('cost', "At cost"),
('sales_price', "Sales price"),
],
string="Re-Invoice Costs", default='no',
compute='_compute_expense_policy', store=True, readonly=False,
help="Expenses and vendor bills can be re-invoiced to a customer."
"With this option, a validated expense can be re-invoice to a customer at its cost or sales price.")
visible_expense_policy = fields.Boolean("Re-Invoice Policy visible", compute='_compute_visible_expense_policy')
sales_count = fields.Float(compute='_compute_sales_count', string='Sold', digits='Product Unit of Measure')
visible_qty_configurator = fields.Boolean("Quantity visible in configurator", compute='_compute_visible_qty_configurator')
help="Validated expenses, vendor bills, or stock pickings (set up to track costs) can be invoiced to the customer at either cost or sales price.")
visible_expense_policy = fields.Boolean(
string="Re-Invoice Policy visible", compute='_compute_visible_expense_policy')
sales_count = fields.Float(
string="Sold", compute='_compute_sales_count', digits='Product Unit')
invoice_policy = fields.Selection(
[('order', 'Ordered quantities'),
('delivery', 'Delivered quantities')], string='Invoicing Policy',
compute='_compute_invoice_policy', store=True, readonly=False, precompute=True,
help='Ordered Quantity: Invoice quantities ordered by the customer.\n'
'Delivered Quantity: Invoice quantities delivered to the customer.')
selection=[
('order', "Ordered quantities"),
('delivery', "Delivered quantities"),
],
string="Invoicing Policy",
compute='_compute_invoice_policy',
precompute=True,
store=True,
readonly=False,
tracking=True,
help="Ordered Quantity: Invoice quantities ordered by the customer.\n"
"Delivered Quantity: Invoice quantities delivered to the customer.")
optional_product_ids = fields.Many2many(
comodel_name='product.template',
relation='product_optional_rel',
column1='src_id',
column2='dest_id',
string="Optional Products",
help="Optional Products are suggested "
"whenever the customer hits *Add to Cart* (cross-sell strategy, "
"e.g. for computers: warranty, software, etc.).",
check_company=True)
def _compute_visible_qty_configurator(self):
for product_template in self:
product_template.visible_qty_configurator = True
@api.depends('invoice_policy', 'sale_ok', 'service_tracking')
def _compute_product_tooltip(self):
super()._compute_product_tooltip()
@api.depends('name')
def _prepare_tooltip(self):
tooltip = super()._prepare_tooltip()
if not self.sale_ok:
return tooltip
invoicing_tooltip = self._prepare_invoicing_tooltip()
tooltip = f'{tooltip} {invoicing_tooltip}' if tooltip else invoicing_tooltip
if self.type == 'service':
additional_tooltip = self._prepare_service_tracking_tooltip()
tooltip = f'{tooltip} {additional_tooltip}' if additional_tooltip else tooltip
return tooltip
def _prepare_invoicing_tooltip(self):
if self.invoice_policy == 'delivery' and self.type != 'consu':
return _("Invoice after delivery, based on quantities delivered, not ordered.")
elif self.invoice_policy == 'order' and self.type == 'service':
return _("Invoice ordered quantities as soon as this service is sold.")
return ""
def _prepare_service_tracking_tooltip(self):
return ""
@api.depends('sale_ok')
def _compute_service_tracking(self):
super()._compute_service_tracking()
self.filtered(lambda pt: not pt.sale_ok).service_tracking = 'no'
@api.depends('purchase_ok')
def _compute_visible_expense_policy(self):
visibility = self.user_has_groups('analytic.group_analytic_accounting')
visibility = self.env.user.has_group('analytic.group_analytic_accounting')
for product_template in self:
product_template.visible_expense_policy = visibility
product_template.visible_expense_policy = visibility and product_template.purchase_ok
@api.depends('sale_ok')
def _compute_expense_policy(self):
@ -60,26 +103,25 @@ class ProductTemplate(models.Model):
@api.depends('product_variant_ids.sales_count')
def _compute_sales_count(self):
for product in self:
product.sales_count = float_round(sum([p.sales_count for p in product.with_context(active_test=False).product_variant_ids]), precision_rounding=product.uom_id.rounding)
product.sales_count = product.uom_id.round(sum(p.sales_count for p in product.with_context(active_test=False).product_variant_ids))
@api.constrains('company_id')
def _check_sale_product_company(self):
"""Ensure the product is not being restricted to a single company while
having been sold in another one in the past, as this could cause issues."""
products_by_company = defaultdict(lambda: self.env['product.template'])
products_by_compagny = defaultdict(lambda: self.env['product.template'])
for product in self:
if not product.product_variant_ids or not product.company_id:
# No need to check if the product has just being created (`product_variant_ids` is
# still empty) or if we're writing `False` on its company (should always work.)
continue
products_by_company[product.company_id] |= product
products_by_compagny[product.company_id] |= product
for target_company, products in products_by_company.items():
for target_company, products in products_by_compagny.items():
subquery_products = self.env['product.product'].sudo().with_context(active_test=False)._search([('product_tmpl_id', 'in', products.ids)])
so_lines = self.env['sale.order.line'].sudo().search_read(
[('product_id', 'in', subquery_products), ('company_id', '!=', target_company.id)],
fields=['id', 'product_id']
)
[('product_id', 'in', subquery_products), '!', ('company_id', 'child_of', target_company.id)],
fields=['id', 'product_id'])
if so_lines:
used_products = [sol['product_id'][1] for sol in so_lines]
raise ValidationError(_('The following products cannot be restricted to the company'
@ -89,57 +131,23 @@ class ProductTemplate(models.Model):
'with your company restriction instead, or leave them as '
'shared product.', company=target_company.name, used_products=', '.join(used_products)))
@api.readonly
def action_view_sales(self):
action = self.env["ir.actions.actions"]._for_xml_id("sale.report_all_channels_sales_action")
action = self.env['ir.actions.actions']._for_xml_id('sale.report_all_channels_sales_action')
action['domain'] = [('product_tmpl_id', 'in', self.ids)]
action['context'] = {
'pivot_measures': ['product_uom_qty'],
'active_id': self._context.get('active_id'),
'active_id': self.env.context.get('active_id'),
'active_model': 'sale.report',
'search_default_Sales': 1,
'search_default_filter_order_date': 1,
'search_default_group_by_date': 1,
}
return action
def create_product_variant(self, product_template_attribute_value_ids):
""" Create if necessary and possible and return the id of the product
variant matching the given combination for this template.
Note AWA: Known "exploit" issues with this method:
- This method could be used by an unauthenticated user to generate a
lot of useless variants. Unfortunately, after discussing the
matter with ODO, there's no easy and user-friendly way to block
that behavior.
We would have to use captcha/server actions to clean/... that
are all not user-friendly/overkill mechanisms.
- This method could be used to try to guess what product variant ids
are created in the system and what product template ids are
configured as "dynamic", but that does not seem like a big deal.
The error messages are identical on purpose to avoid giving too much
information to a potential attacker:
- returning 0 when failing
- returning the variant id whether it already existed or not
:param product_template_attribute_value_ids: the combination for which
to get or create variant
:type product_template_attribute_value_ids: list of id
of `product.template.attribute.value`
:return: id of the product variant matching the combination or 0
:rtype: int
"""
combination = self.env['product.template.attribute.value'] \
.browse(product_template_attribute_value_ids)
return self._create_product_variant(combination, log_warning=True).id or 0
@api.onchange('type')
def _onchange_type(self):
res = super(ProductTemplate, self)._onchange_type()
res = super()._onchange_type()
if self._origin and self.sales_count > 0:
res['warning'] = {
'title': _("Warning"),
@ -155,175 +163,149 @@ class ProductTemplate(models.Model):
def _compute_invoice_policy(self):
self.filtered(lambda t: t.type == 'consu' or not t.invoice_policy).invoice_policy = 'order'
def _get_backend_root_menu_ids(self):
return super()._get_backend_root_menu_ids() + [self.env.ref('sale.sale_menu_root').id]
@api.model
def get_import_templates(self):
res = super(ProductTemplate, self).get_import_templates()
if self.env.context.get('sale_multi_pricelist_product_template'):
if self.user_has_groups('product.group_sale_pricelist'):
if self.env.user.has_group('product.group_product_pricelist'):
return [{
'label': _('Import Template for Products'),
'label': _("Import Template for Products"),
'template': '/product/static/xls/product_template.xls'
}]
return res
def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False):
""" Return info about a given combination.
@api.model
def _get_incompatible_types(self):
return []
Note: this method does not take into account whether the combination is
actually possible.
@api.constrains(lambda self: self._get_incompatible_types())
def _check_incompatible_types(self):
incompatible_types = self._get_incompatible_types()
if len(incompatible_types) < 2:
return
fields = self.env['ir.model.fields'].sudo().search_read(
[('model', '=', 'product.template'), ('name', 'in', incompatible_types)],
['name', 'field_description'])
field_descriptions = {v['name']: v['field_description'] for v in fields}
field_list = incompatible_types + ['name']
values = self.read(field_list)
for val in values:
incompatible_fields = [f for f in incompatible_types if val[f]]
if len(incompatible_fields) > 1:
raise ValidationError(_(
"The product (%(product)s) has incompatible values: %(value_list)s",
product=val['name'],
value_list=[field_descriptions[v] for v in incompatible_fields],
))
:param combination: recordset of `product.template.attribute.value`
def get_single_product_variant(self):
""" Method used by the product configurator to check if the product is configurable or not.
:param product_id: id of a `product.product`. If no `combination`
is set, the method will try to load the variant `product_id` if
it exists instead of finding a variant based on the combination.
We need to open the product configurator if the product:
- is configurable (see has_configurable_attributes)
- has optional products """
res = super().get_single_product_variant()
if res.get('product_id', False):
has_optional_products = False
for optional_product in self.product_variant_id.optional_product_ids:
if optional_product.has_dynamic_attributes() or optional_product._get_possible_variants(
self.product_variant_id.product_template_attribute_value_ids
):
has_optional_products = True
break
res.update({
'has_optional_products': has_optional_products,
'is_combo': self.type == 'combo',
})
return res
If there is no combination, that means we definitely want a
variant and not something that will have no_variant set.
@api.model
def _get_saleable_tracking_types(self):
"""Return list of salealbe service_tracking types.
:param add_qty: float with the quantity for which to get the info,
indeed some pricelist rules might depend on it.
:param pricelist: `product.pricelist` the pricelist to use
(can be none, eg. from SO if no partner and no pricelist selected)
:param parent_combination: if no combination and no product_id are
given, it will try to find the first possible combination, taking
into account parent_combination (if set) for the exclusion rules.
:param only_template: boolean, if set to True, get the info for the
template only: ignore combination and don't try to find variant
:return: dict with product/combination info:
- product_id: the variant id matching the combination (if it exists)
- product_template_id: the current template id
- display_name: the name of the combination
- price: the computed price of the combination, take the catalog
price if no pricelist is given
- list_price: the catalog price of the combination, but this is
not the "real" list_price, it has price_extra included (so
it's actually more closely related to `lst_price`), and it
is converted to the pricelist currency (if given)
- has_discounted_price: True if the pricelist discount policy says
the price does not include the discount and there is actually a
discount applied (price < list_price), else False
:rtype: list
"""
self.ensure_one()
# get the name before the change of context to benefit from prefetch
display_name = self.display_name
return ['no']
display_image = True
quantity = self.env.context.get('quantity', add_qty)
product_template = self
####################################
# Product/combo configurator hooks #
####################################
combination = combination or product_template.env['product.template.attribute.value']
@api.model
def _get_configurator_display_price(
self, product_or_template, quantity, date, currency, pricelist, **kwargs
):
""" Return the specified product's display price, to be used by the product and combo
configurators.
if not product_id and not combination and not only_template:
combination = product_template._get_first_possible_combination(parent_combination)
This is a hook meant to customize the display price computation in overriding modules.
if only_template:
product = product_template.env['product.product']
elif product_id and not combination:
product = product_template.env['product.product'].browse(product_id)
else:
product = product_template._get_variant_for_combination(combination)
if product:
# We need to add the price_extra for the attributes that are not
# in the variant, typically those of type no_variant, but it is
# possible that a no_variant attribute is still in a variant if
# the type of the attribute has been changed after creation.
no_variant_attributes_price_extra = [
ptav.price_extra for ptav in combination.filtered(
lambda ptav:
ptav.price_extra and
ptav not in product.product_template_attribute_value_ids
)
]
if no_variant_attributes_price_extra:
product = product.with_context(
no_variant_attributes_price_extra=tuple(no_variant_attributes_price_extra)
)
list_price = product.price_compute('list_price')[product.id]
if pricelist:
price = pricelist._get_product_price(product, quantity)
else:
price = list_price
display_image = bool(product.image_128)
display_name = product.display_name
price_extra = (product.price_extra or 0.0) + (sum(no_variant_attributes_price_extra) or 0.0)
else:
current_attributes_price_extra = [v.price_extra or 0.0 for v in combination]
product_template = product_template.with_context(current_attributes_price_extra=current_attributes_price_extra)
price_extra = sum(current_attributes_price_extra)
list_price = product_template.price_compute('list_price')[product_template.id]
if pricelist:
price = pricelist._get_product_price(product_template, quantity)
else:
price = list_price
display_image = bool(product_template.image_128)
combination_name = combination._get_combination_name()
if combination_name:
display_name = "%s (%s)" % (display_name, combination_name)
if pricelist and pricelist.currency_id != product_template.currency_id:
list_price = product_template.currency_id._convert(
list_price, pricelist.currency_id, product_template._get_current_company(pricelist=pricelist),
fields.Date.today()
)
price_extra = product_template.currency_id._convert(
price_extra, pricelist.currency_id, product_template._get_current_company(pricelist=pricelist),
fields.Date.today()
)
price_without_discount = list_price if pricelist and pricelist.discount_policy == 'without_discount' else price
has_discounted_price = (pricelist or product_template).currency_id.compare_amounts(price_without_discount, price) == 1
return {
'product_id': product.id,
'product_template_id': product_template.id,
'display_name': display_name,
'display_image': display_image,
'price': price,
'list_price': list_price,
'price_extra': price_extra,
'has_discounted_price': has_discounted_price,
}
def _can_be_added_to_cart(self):
:param product.product|product.template product_or_template: The product for which to get
the price.
:param int quantity: The quantity of the product.
:param datetime date: The date to use to compute the price.
:param res.currency currency: The currency to use to compute the price.
:param product.pricelist pricelist: The pricelist to use to compute the price.
:param dict kwargs: Locally unused data passed to `_get_configurator_price`.
:rtype: tuple(float, int or False)
:return: The specified product's display price (and the applied pricelist rule)
"""
Pre-check to `_is_add_to_cart_possible` to know if product can be sold.
return self._get_configurator_price(
product_or_template, quantity, date, currency, pricelist, **kwargs
)
@api.model
def _get_configurator_price(
self, product_or_template, quantity, date, currency, pricelist, **kwargs
):
""" Return the specified product's price, to be used by the product and combo configurators.
This is a hook meant to customize the price computation in overriding modules.
This hook has been extracted from `_get_configurator_display_price` because the price
computation can be overridden in 2 ways:
- Either by transforming super's price (e.g. in `website_sale`, we apply taxes to the
price),
- Or by computing a different price (e.g. in `sale_subscription`, we ignore super when
computing subscription prices).
In some cases, the order of the overrides matters, which is why we need 2 separate methods
(e.g. in `website_sale_subscription`, we must compute the subscription price before applying
taxes).
:param product.product|product.template product_or_template: The product for which to get
the price.
:param int quantity: The quantity of the product.
:param datetime date: The date to use to compute the price.
:param res.currency currency: The currency to use to compute the price.
:param product.pricelist pricelist: The pricelist to use to compute the price.
:param dict kwargs: Locally unused data passed to `_get_product_price`.
:rtype: tuple(float, int or False)
:return: The specified product's price (and the applied pricelist rule)
"""
return self.sale_ok
return pricelist._get_product_price_rule(
product_or_template, quantity=quantity, currency=currency, date=date, **kwargs
)
def _is_add_to_cart_possible(self, parent_combination=None):
@api.model
def _get_additional_configurator_data(
self, product_or_template, date, currency, pricelist, *, uom=None, **kwargs
):
"""Return additional data about the specified product.
This is a hook meant to append module-specific data in overriding modules.
:param product.product|product.template product_or_template: The product for which to get
additional data.
:param datetime date: The date to use to compute prices.
:param res.currency currency: The currency to use to compute prices.
:param product.pricelist pricelist: The pricelist to use to compute prices.
:param uom.uom uom: The uom to use to compute prices.
:param dict kwargs: Locally unused data passed to overrides.
:rtype: dict
:return: A dict containing additional data about the specified product.
"""
It's possible to add to cart (potentially after configuration) if
there is at least one possible combination.
:param parent_combination: the combination from which `self` is an
optional or accessory product.
:type parent_combination: recordset `product.template.attribute.value`
:return: True if it's possible to add to cart, else False
:rtype: bool
"""
self.ensure_one()
if not self.active or not self._can_be_added_to_cart():
# for performance: avoid calling `_get_possible_combinations`
return False
return next(self._get_possible_combinations(parent_combination), False) is not False
def _get_current_company_fallback(self, **kwargs):
"""Override: if a pricelist is given, fallback to the company of the
pricelist if it is set, otherwise use the one from parent method."""
res = super(ProductTemplate, self)._get_current_company_fallback(**kwargs)
pricelist = kwargs.get('pricelist')
return pricelist and pricelist.company_id or res
return {}