Override name_get to remove html from name this commit clsoes #60204 closes odoo/odoo#62694 X-original-commit: f70f017a04c4d0e2aaf378cce9541b01b42ed0a7 Signed-off-by: Jérémy Kersten (jke) <jke@openerp.com>
475 lines
22 KiB
Python
475 lines
22 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from odoo import api, fields, models, tools, _
|
|
from odoo.exceptions import ValidationError, UserError
|
|
from odoo.addons.http_routing.models.ir_http import slug
|
|
from odoo.addons.website.models import ir_http
|
|
from odoo.tools.translate import html_translate
|
|
from odoo.osv import expression
|
|
|
|
|
|
class ProductRibbon(models.Model):
|
|
_name = "product.ribbon"
|
|
_description = 'Product ribbon'
|
|
|
|
def name_get(self):
|
|
return [(ribbon.id, '%s (#%d)' % (tools.html2plaintext(ribbon.html), ribbon.id)) for ribbon in self]
|
|
|
|
html = fields.Char(string='Ribbon html', required=True, translate=True)
|
|
bg_color = fields.Char(string='Ribbon background color', required=False)
|
|
text_color = fields.Char(string='Ribbon text color', required=False)
|
|
html_class = fields.Char(string='Ribbon class', required=True, default='')
|
|
|
|
|
|
class ProductPricelist(models.Model):
|
|
_inherit = "product.pricelist"
|
|
|
|
def _default_website(self):
|
|
""" Find the first company's website, if there is one. """
|
|
company_id = self.env.company.id
|
|
|
|
if self._context.get('default_company_id'):
|
|
company_id = self._context.get('default_company_id')
|
|
|
|
domain = [('company_id', '=', company_id)]
|
|
return self.env['website'].search(domain, limit=1)
|
|
|
|
website_id = fields.Many2one('website', string="Website", ondelete='restrict', default=_default_website, domain="[('company_id', '=?', company_id)]")
|
|
code = fields.Char(string='E-commerce Promotional Code', groups="base.group_user")
|
|
selectable = fields.Boolean(help="Allow the end user to choose this price list")
|
|
|
|
def clear_cache(self):
|
|
# website._get_pl_partner_order() is cached to avoid to recompute at each request the
|
|
# list of available pricelists. So, we need to invalidate the cache when
|
|
# we change the config of website price list to force to recompute.
|
|
website = self.env['website']
|
|
website._get_pl_partner_order.clear_cache(website)
|
|
|
|
@api.model
|
|
def create(self, data):
|
|
if data.get('company_id') and not data.get('website_id'):
|
|
# l10n modules install will change the company currency, creating a
|
|
# pricelist for that currency. Do not use user's company in that
|
|
# case as module install are done with OdooBot (company 1)
|
|
self = self.with_context(default_company_id=data['company_id'])
|
|
res = super(ProductPricelist, self).create(data)
|
|
self.clear_cache()
|
|
return res
|
|
|
|
def write(self, data):
|
|
res = super(ProductPricelist, self).write(data)
|
|
if data.keys() & {'code', 'active', 'website_id', 'selectable', 'company_id'}:
|
|
self._check_website_pricelist()
|
|
self.clear_cache()
|
|
return res
|
|
|
|
def unlink(self):
|
|
res = super(ProductPricelist, self).unlink()
|
|
self._check_website_pricelist()
|
|
self.clear_cache()
|
|
return res
|
|
|
|
def _get_partner_pricelist_multi_search_domain_hook(self, company_id):
|
|
domain = super(ProductPricelist, self)._get_partner_pricelist_multi_search_domain_hook(company_id)
|
|
website = ir_http.get_request_website()
|
|
if website:
|
|
domain += self._get_website_pricelists_domain(website.id)
|
|
return domain
|
|
|
|
def _get_partner_pricelist_multi_filter_hook(self):
|
|
res = super(ProductPricelist, self)._get_partner_pricelist_multi_filter_hook()
|
|
website = ir_http.get_request_website()
|
|
if website:
|
|
res = res.filtered(lambda pl: pl._is_available_on_website(website.id))
|
|
return res
|
|
|
|
def _check_website_pricelist(self):
|
|
for website in self.env['website'].search([]):
|
|
if not website.pricelist_ids:
|
|
raise UserError(_("With this action, '%s' website would not have any pricelist available.") % (website.name))
|
|
|
|
def _is_available_on_website(self, website_id):
|
|
""" To be able to be used on a website, a pricelist should either:
|
|
- Have its `website_id` set to current website (specific pricelist).
|
|
- Have no `website_id` set and should be `selectable` (generic pricelist)
|
|
or should have a `code` (generic promotion).
|
|
- Have no `company_id` or a `company_id` matching its website one.
|
|
|
|
Note: A pricelist without a website_id, not selectable and without a
|
|
code is a backend pricelist.
|
|
|
|
Change in this method should be reflected in `_get_website_pricelists_domain`.
|
|
"""
|
|
self.ensure_one()
|
|
if self.company_id and self.company_id != self.env["website"].browse(website_id).company_id:
|
|
return False
|
|
return self.website_id.id == website_id or (not self.website_id and (self.selectable or self.sudo().code))
|
|
|
|
def _get_website_pricelists_domain(self, website_id):
|
|
''' Check above `_is_available_on_website` for explanation.
|
|
Change in this method should be reflected in `_is_available_on_website`.
|
|
'''
|
|
company_id = self.env["website"].browse(website_id).company_id.id
|
|
return [
|
|
'&', ('company_id', 'in', [False, company_id]),
|
|
'|', ('website_id', '=', website_id),
|
|
'&', ('website_id', '=', False),
|
|
'|', ('selectable', '=', True), ('code', '!=', False),
|
|
]
|
|
|
|
def _get_partner_pricelist_multi(self, partner_ids, company_id=None):
|
|
''' If `property_product_pricelist` is read from website, we should use
|
|
the website's company and not the user's one.
|
|
Passing a `company_id` to super will avoid using the current user's
|
|
company.
|
|
'''
|
|
website = ir_http.get_request_website()
|
|
if not company_id and website:
|
|
company_id = website.company_id.id
|
|
return super(ProductPricelist, self)._get_partner_pricelist_multi(partner_ids, company_id)
|
|
|
|
@api.constrains('company_id', 'website_id')
|
|
def _check_websites_in_company(self):
|
|
'''Prevent misconfiguration multi-website/multi-companies.
|
|
If the record has a company, the website should be from that company.
|
|
'''
|
|
for record in self.filtered(lambda pl: pl.website_id and pl.company_id):
|
|
if record.website_id.company_id != record.company_id:
|
|
raise ValidationError(_("""Only the company's websites are allowed.\nLeave the Company field empty or select a website from that company."""))
|
|
|
|
|
|
class ProductPublicCategory(models.Model):
|
|
_name = "product.public.category"
|
|
_inherit = ["website.seo.metadata", "website.multi.mixin", 'image.mixin']
|
|
_description = "Website Product Category"
|
|
_parent_store = True
|
|
_order = "sequence, name, id"
|
|
|
|
def _default_sequence(self):
|
|
cat = self.search([], limit=1, order="sequence DESC")
|
|
if cat:
|
|
return cat.sequence + 5
|
|
return 10000
|
|
|
|
name = fields.Char(required=True, translate=True)
|
|
parent_id = fields.Many2one('product.public.category', string='Parent Category', index=True, ondelete="cascade")
|
|
parent_path = fields.Char(index=True)
|
|
child_id = fields.One2many('product.public.category', 'parent_id', string='Children Categories')
|
|
parents_and_self = fields.Many2many('product.public.category', compute='_compute_parents_and_self')
|
|
sequence = fields.Integer(help="Gives the sequence order when displaying a list of product categories.", index=True, default=_default_sequence)
|
|
website_description = fields.Html('Category Description', sanitize_attributes=False, translate=html_translate, sanitize_form=False)
|
|
product_tmpl_ids = fields.Many2many('product.template', relation='product_public_category_product_template_rel')
|
|
|
|
@api.constrains('parent_id')
|
|
def check_parent_id(self):
|
|
if not self._check_recursion():
|
|
raise ValueError(_('Error ! You cannot create recursive categories.'))
|
|
|
|
def name_get(self):
|
|
res = []
|
|
for category in self:
|
|
res.append((category.id, " / ".join(category.parents_and_self.mapped('name'))))
|
|
return res
|
|
|
|
def _compute_parents_and_self(self):
|
|
for category in self:
|
|
if category.parent_path:
|
|
category.parents_and_self = self.env['product.public.category'].browse([int(p) for p in category.parent_path.split('/')[:-1]])
|
|
else:
|
|
category.parents_and_self = category
|
|
|
|
|
|
class ProductTemplate(models.Model):
|
|
_inherit = ["product.template", "website.seo.metadata", 'website.published.multi.mixin', 'rating.mixin']
|
|
_name = 'product.template'
|
|
_mail_post_access = 'read'
|
|
_check_company_auto = True
|
|
|
|
website_description = fields.Html('Description for the website', sanitize_attributes=False, translate=html_translate, sanitize_form=False)
|
|
alternative_product_ids = fields.Many2many(
|
|
'product.template', 'product_alternative_rel', 'src_id', 'dest_id', check_company=True,
|
|
string='Alternative Products', help='Suggest alternatives to your customer (upsell strategy). '
|
|
'Those products show up on the product page.')
|
|
accessory_product_ids = fields.Many2many(
|
|
'product.product', 'product_accessory_rel', 'src_id', 'dest_id', string='Accessory Products', check_company=True,
|
|
help='Accessories show up when the customer reviews the cart before payment (cross-sell strategy).')
|
|
website_size_x = fields.Integer('Size X', default=1)
|
|
website_size_y = fields.Integer('Size Y', default=1)
|
|
website_ribbon_id = fields.Many2one('product.ribbon', string='Ribbon')
|
|
website_sequence = fields.Integer('Website Sequence', help="Determine the display order in the Website E-commerce",
|
|
default=lambda self: self._default_website_sequence(), copy=False)
|
|
public_categ_ids = fields.Many2many(
|
|
'product.public.category', relation='product_public_category_product_template_rel',
|
|
string='Website Product Category',
|
|
help="The product will be available in each mentioned eCommerce category. Go to Shop > "
|
|
"Customize and enable 'eCommerce categories' to view all eCommerce categories.")
|
|
|
|
product_template_image_ids = fields.One2many('product.image', 'product_tmpl_id', string="Extra Product Media", copy=True)
|
|
|
|
def _has_no_variant_attributes(self):
|
|
"""Return whether this `product.template` has at least one no_variant
|
|
attribute.
|
|
|
|
:return: True if at least one no_variant attribute, False otherwise
|
|
:rtype: bool
|
|
"""
|
|
self.ensure_one()
|
|
return any(a.create_variant == 'no_variant' for a in self.valid_product_template_attribute_line_ids.attribute_id)
|
|
|
|
def _has_is_custom_values(self):
|
|
self.ensure_one()
|
|
"""Return whether this `product.template` has at least one is_custom
|
|
attribute value.
|
|
|
|
:return: True if at least one is_custom attribute value, False otherwise
|
|
:rtype: bool
|
|
"""
|
|
return any(v.is_custom for v in self.valid_product_template_attribute_line_ids.product_template_value_ids._only_active())
|
|
|
|
def _get_possible_variants_sorted(self, parent_combination=None):
|
|
"""Return the sorted recordset of variants that are possible.
|
|
|
|
The order is based on the order of the attributes and their values.
|
|
|
|
See `_get_possible_variants` for the limitations of this method with
|
|
dynamic or no_variant attributes, and also for a warning about
|
|
performances.
|
|
|
|
:param parent_combination: combination from which `self` is an
|
|
optional or accessory product
|
|
:type parent_combination: recordset `product.template.attribute.value`
|
|
|
|
:return: the sorted variants that are possible
|
|
:rtype: recordset of `product.product`
|
|
"""
|
|
self.ensure_one()
|
|
|
|
def _sort_key_attribute_value(value):
|
|
# if you change this order, keep it in sync with _order from `product.attribute`
|
|
return (value.attribute_id.sequence, value.attribute_id.id)
|
|
|
|
def _sort_key_variant(variant):
|
|
"""
|
|
We assume all variants will have the same attributes, with only one value for each.
|
|
- first level sort: same as "product.attribute"._order
|
|
- second level sort: same as "product.attribute.value"._order
|
|
"""
|
|
keys = []
|
|
for attribute in variant.product_template_attribute_value_ids.sorted(_sort_key_attribute_value):
|
|
# if you change this order, keep it in sync with _order from `product.attribute.value`
|
|
keys.append(attribute.product_attribute_value_id.sequence)
|
|
keys.append(attribute.id)
|
|
return keys
|
|
|
|
return self._get_possible_variants(parent_combination).sorted(_sort_key_variant)
|
|
|
|
def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False):
|
|
"""Override for website, where we want to:
|
|
- take the website pricelist if no pricelist is set
|
|
- apply the b2b/b2c setting to the result
|
|
|
|
This will work when adding website_id to the context, which is done
|
|
automatically when called from routes with website=True.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
current_website = False
|
|
|
|
if self.env.context.get('website_id'):
|
|
current_website = self.env['website'].get_current_website()
|
|
if not pricelist:
|
|
pricelist = current_website.get_current_pricelist()
|
|
|
|
combination_info = super(ProductTemplate, self)._get_combination_info(
|
|
combination=combination, product_id=product_id, add_qty=add_qty, pricelist=pricelist,
|
|
parent_combination=parent_combination, only_template=only_template)
|
|
|
|
if self.env.context.get('website_id'):
|
|
partner = self.env.user.partner_id
|
|
company_id = current_website.company_id
|
|
product = self.env['product.product'].browse(combination_info['product_id']) or self
|
|
|
|
tax_display = self.user_has_groups('account.group_show_line_subtotals_tax_excluded') and 'total_excluded' or 'total_included'
|
|
fpos = self.env['account.fiscal.position'].get_fiscal_position(partner.id).sudo()
|
|
taxes = fpos.map_tax(product.sudo().taxes_id.filtered(lambda x: x.company_id == company_id), product, partner)
|
|
|
|
# The list_price is always the price of one.
|
|
quantity_1 = 1
|
|
price = taxes.compute_all(combination_info['price'], pricelist.currency_id, quantity_1, product, partner)[tax_display]
|
|
if pricelist.discount_policy == 'without_discount':
|
|
list_price = taxes.compute_all(combination_info['list_price'], pricelist.currency_id, quantity_1, product, partner)[tax_display]
|
|
else:
|
|
list_price = price
|
|
has_discounted_price = pricelist.currency_id.compare_amounts(list_price, price) == 1
|
|
|
|
combination_info.update(
|
|
price=price,
|
|
list_price=list_price,
|
|
has_discounted_price=has_discounted_price,
|
|
)
|
|
|
|
return combination_info
|
|
|
|
def _create_first_product_variant(self, log_warning=False):
|
|
"""Create if necessary and possible and return the first product
|
|
variant for this template.
|
|
|
|
:param log_warning: whether a warning should be logged on fail
|
|
:type log_warning: bool
|
|
|
|
:return: the first product variant or none
|
|
:rtype: recordset of `product.product`
|
|
"""
|
|
return self._create_product_variant(self._get_first_possible_combination(), log_warning)
|
|
|
|
def _get_image_holder(self):
|
|
"""Returns the holder of the image to use as default representation.
|
|
If the product template has an image it is the product template,
|
|
otherwise if the product has variants it is the first variant
|
|
|
|
:return: this product template or the first product variant
|
|
:rtype: recordset of 'product.template' or recordset of 'product.product'
|
|
"""
|
|
self.ensure_one()
|
|
if self.image_1920:
|
|
return self
|
|
variant = self.env['product.product'].browse(self._get_first_possible_variant_id())
|
|
# if the variant has no image anyway, spare some queries by using template
|
|
return variant if variant.image_variant_1920 else self
|
|
|
|
def _get_current_company_fallback(self, **kwargs):
|
|
"""Override: if a website is set on the product or given, fallback to
|
|
the company of the website. Otherwise use the one from parent method."""
|
|
res = super(ProductTemplate, self)._get_current_company_fallback(**kwargs)
|
|
website = self.website_id or kwargs.get('website')
|
|
return website and website.company_id or res
|
|
|
|
def _default_website_sequence(self):
|
|
''' We want new product to be the last (highest seq).
|
|
Every product should ideally have an unique sequence.
|
|
Default sequence (10000) should only be used for DB first product.
|
|
As we don't resequence the whole tree (as `sequence` does), this field
|
|
might have negative value.
|
|
'''
|
|
self._cr.execute("SELECT MAX(website_sequence) FROM %s" % self._table)
|
|
max_sequence = self._cr.fetchone()[0]
|
|
if max_sequence is None:
|
|
return 10000
|
|
return max_sequence + 5
|
|
|
|
def set_sequence_top(self):
|
|
min_sequence = self.sudo().search([], order='website_sequence ASC', limit=1)
|
|
self.website_sequence = min_sequence.website_sequence - 5
|
|
|
|
def set_sequence_bottom(self):
|
|
max_sequence = self.sudo().search([], order='website_sequence DESC', limit=1)
|
|
self.website_sequence = max_sequence.website_sequence + 5
|
|
|
|
def set_sequence_up(self):
|
|
previous_product_tmpl = self.sudo().search([
|
|
('website_sequence', '<', self.website_sequence),
|
|
('website_published', '=', self.website_published),
|
|
], order='website_sequence DESC', limit=1)
|
|
if previous_product_tmpl:
|
|
previous_product_tmpl.website_sequence, self.website_sequence = self.website_sequence, previous_product_tmpl.website_sequence
|
|
else:
|
|
self.set_sequence_top()
|
|
|
|
def set_sequence_down(self):
|
|
next_prodcut_tmpl = self.search([
|
|
('website_sequence', '>', self.website_sequence),
|
|
('website_published', '=', self.website_published),
|
|
], order='website_sequence ASC', limit=1)
|
|
if next_prodcut_tmpl:
|
|
next_prodcut_tmpl.website_sequence, self.website_sequence = self.website_sequence, next_prodcut_tmpl.website_sequence
|
|
else:
|
|
return self.set_sequence_bottom()
|
|
|
|
def _default_website_meta(self):
|
|
res = super(ProductTemplate, self)._default_website_meta()
|
|
res['default_opengraph']['og:description'] = res['default_twitter']['twitter:description'] = self.description_sale
|
|
res['default_opengraph']['og:title'] = res['default_twitter']['twitter:title'] = self.name
|
|
res['default_opengraph']['og:image'] = res['default_twitter']['twitter:image'] = self.env['website'].image_url(self, 'image_1024')
|
|
res['default_meta_description'] = self.description_sale
|
|
return res
|
|
|
|
def _compute_website_url(self):
|
|
super(ProductTemplate, self)._compute_website_url()
|
|
for product in self:
|
|
if product.id:
|
|
product.website_url = "/shop/%s" % slug(product)
|
|
|
|
# ---------------------------------------------------------
|
|
# Rating Mixin API
|
|
# ---------------------------------------------------------
|
|
|
|
def _rating_domain(self):
|
|
""" Only take the published rating into account to compute avg and count """
|
|
domain = super(ProductTemplate, self)._rating_domain()
|
|
return expression.AND([domain, [('is_internal', '=', False)]])
|
|
|
|
def _get_images(self):
|
|
"""Return a list of records implementing `image.mixin` to
|
|
display on the carousel on the website for this template.
|
|
|
|
This returns a list and not a recordset because the records might be
|
|
from different models (template and image).
|
|
|
|
It contains in this order: the main image of the template and the
|
|
Template Extra Images.
|
|
"""
|
|
self.ensure_one()
|
|
return [self] + list(self.product_template_image_ids)
|
|
|
|
|
|
class Product(models.Model):
|
|
_inherit = "product.product"
|
|
|
|
website_id = fields.Many2one(related='product_tmpl_id.website_id', readonly=False)
|
|
|
|
product_variant_image_ids = fields.One2many('product.image', 'product_variant_id', string="Extra Variant Images")
|
|
|
|
website_url = fields.Char('Website URL', compute='_compute_product_website_url', help='The full URL to access the document through the website.')
|
|
|
|
@api.depends_context('lang')
|
|
@api.depends('product_tmpl_id.website_url', 'product_template_attribute_value_ids')
|
|
def _compute_product_website_url(self):
|
|
for product in self:
|
|
attributes = ','.join(str(x) for x in product.product_template_attribute_value_ids.ids)
|
|
product.website_url = "%s#attr=%s" % (product.product_tmpl_id.website_url, attributes)
|
|
|
|
def website_publish_button(self):
|
|
self.ensure_one()
|
|
return self.product_tmpl_id.website_publish_button()
|
|
|
|
def open_website_url(self):
|
|
self.ensure_one()
|
|
res = self.product_tmpl_id.open_website_url()
|
|
res['url'] = self.website_url
|
|
return res
|
|
|
|
def _get_images(self):
|
|
"""Return a list of records implementing `image.mixin` to
|
|
display on the carousel on the website for this variant.
|
|
|
|
This returns a list and not a recordset because the records might be
|
|
from different models (template, variant and image).
|
|
|
|
It contains in this order: the main image of the variant (if set), the
|
|
Variant Extra Images, and the Template Extra Images.
|
|
"""
|
|
self.ensure_one()
|
|
variant_images = list(self.product_variant_image_ids)
|
|
if self.image_variant_1920:
|
|
# if the main variant image is set, display it first
|
|
variant_images = [self] + variant_images
|
|
else:
|
|
# If the main variant image is empty, it will fallback to template
|
|
# image, in this case insert it after the other variant images, so
|
|
# that all variant images are first and all template images last.
|
|
variant_images = variant_images + [self]
|
|
# [1:] to remove the main image from the template, we only display
|
|
# the template extra images here
|
|
return variant_images + self.product_tmpl_id._get_images()[1:]
|