summaryrefslogtreecommitdiff
path: root/addons/website_sale/models/product.py
diff options
context:
space:
mode:
Diffstat (limited to 'addons/website_sale/models/product.py')
-rw-r--r--addons/website_sale/models/product.py476
1 files changed, 476 insertions, 0 deletions
diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py
new file mode 100644
index 00000000..e7054a9a
--- /dev/null
+++ b/addons/website_sale/models/product.py
@@ -0,0 +1,476 @@
+# -*- 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'].sudo().get_fiscal_position(partner.id)
+ 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
+ combination_info['price'] = self.env['account.tax']._fix_tax_included_price_company(combination_info['price'], product.sudo().taxes_id, taxes, company_id)
+ price = taxes.compute_all(combination_info['price'], pricelist.currency_id, quantity_1, product, partner)[tax_display]
+ if pricelist.discount_policy == 'without_discount':
+ combination_info['list_price'] = self.env['account.tax']._fix_tax_included_price_company(combination_info['list_price'], product.sudo().taxes_id, taxes, company_id)
+ 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:]