diff options
Diffstat (limited to 'addons/website_sale/models/product.py')
| -rw-r--r-- | addons/website_sale/models/product.py | 476 |
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:] |
