1# -*- coding: utf-8 -*-
2# Part of Odoo. See LICENSE file for full copyright and licensing details.
3
4from odoo import api, fields, models, tools, _
5from odoo.exceptions import ValidationError, UserError
6from odoo.addons.http_routing.models.ir_http import slug
7from odoo.addons.website.models import ir_http
8from odoo.tools.translate import html_translate
9from odoo.osv import expression
10
11
12class ProductRibbon(models.Model):
13    _name = "product.ribbon"
14    _description = 'Product ribbon'
15
16    def name_get(self):
17        return [(ribbon.id, '%s (#%d)' % (tools.html2plaintext(ribbon.html), ribbon.id)) for ribbon in self]
18
19    html = fields.Char(string='Ribbon html', required=True, translate=True)
20    bg_color = fields.Char(string='Ribbon background color', required=False)
21    text_color = fields.Char(string='Ribbon text color', required=False)
22    html_class = fields.Char(string='Ribbon class', required=True, default='')
23
24
25class ProductPricelist(models.Model):
26    _inherit = "product.pricelist"
27
28    def _default_website(self):
29        """ Find the first company's website, if there is one. """
30        company_id = self.env.company.id
31
32        if self._context.get('default_company_id'):
33            company_id = self._context.get('default_company_id')
34
35        domain = [('company_id', '=', company_id)]
36        return self.env['website'].search(domain, limit=1)
37
38    website_id = fields.Many2one('website', string="Website", ondelete='restrict', default=_default_website, domain="[('company_id', '=?', company_id)]")
39    code = fields.Char(string='E-commerce Promotional Code', groups="base.group_user")
40    selectable = fields.Boolean(help="Allow the end user to choose this price list")
41
42    def clear_cache(self):
43        # website._get_pl_partner_order() is cached to avoid to recompute at each request the
44        # list of available pricelists. So, we need to invalidate the cache when
45        # we change the config of website price list to force to recompute.
46        website = self.env['website']
47        website._get_pl_partner_order.clear_cache(website)
48
49    @api.model
50    def create(self, data):
51        if data.get('company_id') and not data.get('website_id'):
52            # l10n modules install will change the company currency, creating a
53            # pricelist for that currency. Do not use user's company in that
54            # case as module install are done with OdooBot (company 1)
55            self = self.with_context(default_company_id=data['company_id'])
56        res = super(ProductPricelist, self).create(data)
57        self.clear_cache()
58        return res
59
60    def write(self, data):
61        res = super(ProductPricelist, self).write(data)
62        if data.keys() & {'code', 'active', 'website_id', 'selectable', 'company_id'}:
63            self._check_website_pricelist()
64        self.clear_cache()
65        return res
66
67    def unlink(self):
68        res = super(ProductPricelist, self).unlink()
69        self._check_website_pricelist()
70        self.clear_cache()
71        return res
72
73    def _get_partner_pricelist_multi_search_domain_hook(self, company_id):
74        domain = super(ProductPricelist, self)._get_partner_pricelist_multi_search_domain_hook(company_id)
75        website = ir_http.get_request_website()
76        if website:
77            domain += self._get_website_pricelists_domain(website.id)
78        return domain
79
80    def _get_partner_pricelist_multi_filter_hook(self):
81        res = super(ProductPricelist, self)._get_partner_pricelist_multi_filter_hook()
82        website = ir_http.get_request_website()
83        if website:
84            res = res.filtered(lambda pl: pl._is_available_on_website(website.id))
85        return res
86
87    def _check_website_pricelist(self):
88        for website in self.env['website'].search([]):
89            if not website.pricelist_ids:
90                raise UserError(_("With this action, '%s' website would not have any pricelist available.") % (website.name))
91
92    def _is_available_on_website(self, website_id):
93        """ To be able to be used on a website, a pricelist should either:
94        - Have its `website_id` set to current website (specific pricelist).
95        - Have no `website_id` set and should be `selectable` (generic pricelist)
96          or should have a `code` (generic promotion).
97        - Have no `company_id` or a `company_id` matching its website one.
98
99        Note: A pricelist without a website_id, not selectable and without a
100              code is a backend pricelist.
101
102        Change in this method should be reflected in `_get_website_pricelists_domain`.
103        """
104        self.ensure_one()
105        if self.company_id and self.company_id != self.env["website"].browse(website_id).company_id:
106            return False
107        return self.website_id.id == website_id or (not self.website_id and (self.selectable or self.sudo().code))
108
109    def _get_website_pricelists_domain(self, website_id):
110        ''' Check above `_is_available_on_website` for explanation.
111        Change in this method should be reflected in `_is_available_on_website`.
112        '''
113        company_id = self.env["website"].browse(website_id).company_id.id
114        return [
115            '&', ('company_id', 'in', [False, company_id]),
116            '|', ('website_id', '=', website_id),
117            '&', ('website_id', '=', False),
118            '|', ('selectable', '=', True), ('code', '!=', False),
119        ]
120
121    def _get_partner_pricelist_multi(self, partner_ids, company_id=None):
122        ''' If `property_product_pricelist` is read from website, we should use
123            the website's company and not the user's one.
124            Passing a `company_id` to super will avoid using the current user's
125            company.
126        '''
127        website = ir_http.get_request_website()
128        if not company_id and website:
129            company_id = website.company_id.id
130        return super(ProductPricelist, self)._get_partner_pricelist_multi(partner_ids, company_id)
131
132    @api.constrains('company_id', 'website_id')
133    def _check_websites_in_company(self):
134        '''Prevent misconfiguration multi-website/multi-companies.
135           If the record has a company, the website should be from that company.
136        '''
137        for record in self.filtered(lambda pl: pl.website_id and pl.company_id):
138            if record.website_id.company_id != record.company_id:
139                raise ValidationError(_("""Only the company's websites are allowed.\nLeave the Company field empty or select a website from that company."""))
140
141
142class ProductPublicCategory(models.Model):
143    _name = "product.public.category"
144    _inherit = ["website.seo.metadata", "website.multi.mixin", 'image.mixin']
145    _description = "Website Product Category"
146    _parent_store = True
147    _order = "sequence, name, id"
148
149    def _default_sequence(self):
150        cat = self.search([], limit=1, order="sequence DESC")
151        if cat:
152            return cat.sequence + 5
153        return 10000
154
155    name = fields.Char(required=True, translate=True)
156    parent_id = fields.Many2one('product.public.category', string='Parent Category', index=True, ondelete="cascade")
157    parent_path = fields.Char(index=True)
158    child_id = fields.One2many('product.public.category', 'parent_id', string='Children Categories')
159    parents_and_self = fields.Many2many('product.public.category', compute='_compute_parents_and_self')
160    sequence = fields.Integer(help="Gives the sequence order when displaying a list of product categories.", index=True, default=_default_sequence)
161    website_description = fields.Html('Category Description', sanitize_attributes=False, translate=html_translate, sanitize_form=False)
162    product_tmpl_ids = fields.Many2many('product.template', relation='product_public_category_product_template_rel')
163
164    @api.constrains('parent_id')
165    def check_parent_id(self):
166        if not self._check_recursion():
167            raise ValueError(_('Error ! You cannot create recursive categories.'))
168
169    def name_get(self):
170        res = []
171        for category in self:
172            res.append((category.id, " / ".join(category.parents_and_self.mapped('name'))))
173        return res
174
175    def _compute_parents_and_self(self):
176        for category in self:
177            if category.parent_path:
178                category.parents_and_self = self.env['product.public.category'].browse([int(p) for p in category.parent_path.split('/')[:-1]])
179            else:
180                category.parents_and_self = category
181
182
183class ProductTemplate(models.Model):
184    _inherit = ["product.template", "website.seo.metadata", 'website.published.multi.mixin', 'rating.mixin']
185    _name = 'product.template'
186    _mail_post_access = 'read'
187    _check_company_auto = True
188
189    website_description = fields.Html('Description for the website', sanitize_attributes=False, translate=html_translate, sanitize_form=False)
190    alternative_product_ids = fields.Many2many(
191        'product.template', 'product_alternative_rel', 'src_id', 'dest_id', check_company=True,
192        string='Alternative Products', help='Suggest alternatives to your customer (upsell strategy). '
193                                            'Those products show up on the product page.')
194    accessory_product_ids = fields.Many2many(
195        'product.product', 'product_accessory_rel', 'src_id', 'dest_id', string='Accessory Products', check_company=True,
196        help='Accessories show up when the customer reviews the cart before payment (cross-sell strategy).')
197    website_size_x = fields.Integer('Size X', default=1)
198    website_size_y = fields.Integer('Size Y', default=1)
199    website_ribbon_id = fields.Many2one('product.ribbon', string='Ribbon')
200    website_sequence = fields.Integer('Website Sequence', help="Determine the display order in the Website E-commerce",
201                                      default=lambda self: self._default_website_sequence(), copy=False)
202    public_categ_ids = fields.Many2many(
203        'product.public.category', relation='product_public_category_product_template_rel',
204        string='Website Product Category',
205        help="The product will be available in each mentioned eCommerce category. Go to Shop > "
206             "Customize and enable 'eCommerce categories' to view all eCommerce categories.")
207
208    product_template_image_ids = fields.One2many('product.image', 'product_tmpl_id', string="Extra Product Media", copy=True)
209
210    def _has_no_variant_attributes(self):
211        """Return whether this `product.template` has at least one no_variant
212        attribute.
213
214        :return: True if at least one no_variant attribute, False otherwise
215        :rtype: bool
216        """
217        self.ensure_one()
218        return any(a.create_variant == 'no_variant' for a in self.valid_product_template_attribute_line_ids.attribute_id)
219
220    def _has_is_custom_values(self):
221        self.ensure_one()
222        """Return whether this `product.template` has at least one is_custom
223        attribute value.
224
225        :return: True if at least one is_custom attribute value, False otherwise
226        :rtype: bool
227        """
228        return any(v.is_custom for v in self.valid_product_template_attribute_line_ids.product_template_value_ids._only_active())
229
230    def _get_possible_variants_sorted(self, parent_combination=None):
231        """Return the sorted recordset of variants that are possible.
232
233        The order is based on the order of the attributes and their values.
234
235        See `_get_possible_variants` for the limitations of this method with
236        dynamic or no_variant attributes, and also for a warning about
237        performances.
238
239        :param parent_combination: combination from which `self` is an
240            optional or accessory product
241        :type parent_combination: recordset `product.template.attribute.value`
242
243        :return: the sorted variants that are possible
244        :rtype: recordset of `product.product`
245        """
246        self.ensure_one()
247
248        def _sort_key_attribute_value(value):
249            # if you change this order, keep it in sync with _order from `product.attribute`
250            return (value.attribute_id.sequence, value.attribute_id.id)
251
252        def _sort_key_variant(variant):
253            """
254                We assume all variants will have the same attributes, with only one value for each.
255                    - first level sort: same as "product.attribute"._order
256                    - second level sort: same as "product.attribute.value"._order
257            """
258            keys = []
259            for attribute in variant.product_template_attribute_value_ids.sorted(_sort_key_attribute_value):
260                # if you change this order, keep it in sync with _order from `product.attribute.value`
261                keys.append(attribute.product_attribute_value_id.sequence)
262                keys.append(attribute.id)
263            return keys
264
265        return self._get_possible_variants(parent_combination).sorted(_sort_key_variant)
266
267    def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False):
268        """Override for website, where we want to:
269            - take the website pricelist if no pricelist is set
270            - apply the b2b/b2c setting to the result
271
272        This will work when adding website_id to the context, which is done
273        automatically when called from routes with website=True.
274        """
275        self.ensure_one()
276
277        current_website = False
278
279        if self.env.context.get('website_id'):
280            current_website = self.env['website'].get_current_website()
281            if not pricelist:
282                pricelist = current_website.get_current_pricelist()
283
284        combination_info = super(ProductTemplate, self)._get_combination_info(
285            combination=combination, product_id=product_id, add_qty=add_qty, pricelist=pricelist,
286            parent_combination=parent_combination, only_template=only_template)
287
288        if self.env.context.get('website_id'):
289            partner = self.env.user.partner_id
290            company_id = current_website.company_id
291            product = self.env['product.product'].browse(combination_info['product_id']) or self
292
293            tax_display = self.user_has_groups('account.group_show_line_subtotals_tax_excluded') and 'total_excluded' or 'total_included'
294            fpos = self.env['account.fiscal.position'].sudo().get_fiscal_position(partner.id)
295            taxes = fpos.map_tax(product.sudo().taxes_id.filtered(lambda x: x.company_id == company_id), product, partner)
296
297            # The list_price is always the price of one.
298            quantity_1 = 1
299            combination_info['price'] = self.env['account.tax']._fix_tax_included_price_company(combination_info['price'], product.sudo().taxes_id, taxes, company_id)
300            price = taxes.compute_all(combination_info['price'], pricelist.currency_id, quantity_1, product, partner)[tax_display]
301            if pricelist.discount_policy == 'without_discount':
302                combination_info['list_price'] = self.env['account.tax']._fix_tax_included_price_company(combination_info['list_price'], product.sudo().taxes_id, taxes, company_id)
303                list_price = taxes.compute_all(combination_info['list_price'], pricelist.currency_id, quantity_1, product, partner)[tax_display]
304            else:
305                list_price = price
306            has_discounted_price = pricelist.currency_id.compare_amounts(list_price, price) == 1
307
308            combination_info.update(
309                price=price,
310                list_price=list_price,
311                has_discounted_price=has_discounted_price,
312            )
313
314        return combination_info
315
316    def _create_first_product_variant(self, log_warning=False):
317        """Create if necessary and possible and return the first product
318        variant for this template.
319
320        :param log_warning: whether a warning should be logged on fail
321        :type log_warning: bool
322
323        :return: the first product variant or none
324        :rtype: recordset of `product.product`
325        """
326        return self._create_product_variant(self._get_first_possible_combination(), log_warning)
327
328    def _get_image_holder(self):
329        """Returns the holder of the image to use as default representation.
330        If the product template has an image it is the product template,
331        otherwise if the product has variants it is the first variant
332
333        :return: this product template or the first product variant
334        :rtype: recordset of 'product.template' or recordset of 'product.product'
335        """
336        self.ensure_one()
337        if self.image_1920:
338            return self
339        variant = self.env['product.product'].browse(self._get_first_possible_variant_id())
340        # if the variant has no image anyway, spare some queries by using template
341        return variant if variant.image_variant_1920 else self
342
343    def _get_current_company_fallback(self, **kwargs):
344        """Override: if a website is set on the product or given, fallback to
345        the company of the website. Otherwise use the one from parent method."""
346        res = super(ProductTemplate, self)._get_current_company_fallback(**kwargs)
347        website = self.website_id or kwargs.get('website')
348        return website and website.company_id or res
349
350    def _default_website_sequence(self):
351        ''' We want new product to be the last (highest seq).
352        Every product should ideally have an unique sequence.
353        Default sequence (10000) should only be used for DB first product.
354        As we don't resequence the whole tree (as `sequence` does), this field
355        might have negative value.
356        '''
357        self._cr.execute("SELECT MAX(website_sequence) FROM %s" % self._table)
358        max_sequence = self._cr.fetchone()[0]
359        if max_sequence is None:
360            return 10000
361        return max_sequence + 5
362
363    def set_sequence_top(self):
364        min_sequence = self.sudo().search([], order='website_sequence ASC', limit=1)
365        self.website_sequence = min_sequence.website_sequence - 5
366
367    def set_sequence_bottom(self):
368        max_sequence = self.sudo().search([], order='website_sequence DESC', limit=1)
369        self.website_sequence = max_sequence.website_sequence + 5
370
371    def set_sequence_up(self):
372        previous_product_tmpl = self.sudo().search([
373            ('website_sequence', '<', self.website_sequence),
374            ('website_published', '=', self.website_published),
375        ], order='website_sequence DESC', limit=1)
376        if previous_product_tmpl:
377            previous_product_tmpl.website_sequence, self.website_sequence = self.website_sequence, previous_product_tmpl.website_sequence
378        else:
379            self.set_sequence_top()
380
381    def set_sequence_down(self):
382        next_prodcut_tmpl = self.search([
383            ('website_sequence', '>', self.website_sequence),
384            ('website_published', '=', self.website_published),
385        ], order='website_sequence ASC', limit=1)
386        if next_prodcut_tmpl:
387            next_prodcut_tmpl.website_sequence, self.website_sequence = self.website_sequence, next_prodcut_tmpl.website_sequence
388        else:
389            return self.set_sequence_bottom()
390
391    def _default_website_meta(self):
392        res = super(ProductTemplate, self)._default_website_meta()
393        res['default_opengraph']['og:description'] = res['default_twitter']['twitter:description'] = self.description_sale
394        res['default_opengraph']['og:title'] = res['default_twitter']['twitter:title'] = self.name
395        res['default_opengraph']['og:image'] = res['default_twitter']['twitter:image'] = self.env['website'].image_url(self, 'image_1024')
396        res['default_meta_description'] = self.description_sale
397        return res
398
399    def _compute_website_url(self):
400        super(ProductTemplate, self)._compute_website_url()
401        for product in self:
402            if product.id:
403                product.website_url = "/shop/%s" % slug(product)
404
405    # ---------------------------------------------------------
406    # Rating Mixin API
407    # ---------------------------------------------------------
408
409    def _rating_domain(self):
410        """ Only take the published rating into account to compute avg and count """
411        domain = super(ProductTemplate, self)._rating_domain()
412        return expression.AND([domain, [('is_internal', '=', False)]])
413
414    def _get_images(self):
415        """Return a list of records implementing `image.mixin` to
416        display on the carousel on the website for this template.
417
418        This returns a list and not a recordset because the records might be
419        from different models (template and image).
420
421        It contains in this order: the main image of the template and the
422        Template Extra Images.
423        """
424        self.ensure_one()
425        return [self] + list(self.product_template_image_ids)
426
427
428class Product(models.Model):
429    _inherit = "product.product"
430
431    website_id = fields.Many2one(related='product_tmpl_id.website_id', readonly=False)
432
433    product_variant_image_ids = fields.One2many('product.image', 'product_variant_id', string="Extra Variant Images")
434
435    website_url = fields.Char('Website URL', compute='_compute_product_website_url', help='The full URL to access the document through the website.')
436
437    @api.depends_context('lang')
438    @api.depends('product_tmpl_id.website_url', 'product_template_attribute_value_ids')
439    def _compute_product_website_url(self):
440        for product in self:
441            attributes = ','.join(str(x) for x in product.product_template_attribute_value_ids.ids)
442            product.website_url = "%s#attr=%s" % (product.product_tmpl_id.website_url, attributes)
443
444    def website_publish_button(self):
445        self.ensure_one()
446        return self.product_tmpl_id.website_publish_button()
447
448    def open_website_url(self):
449        self.ensure_one()
450        res = self.product_tmpl_id.open_website_url()
451        res['url'] = self.website_url
452        return res
453
454    def _get_images(self):
455        """Return a list of records implementing `image.mixin` to
456        display on the carousel on the website for this variant.
457
458        This returns a list and not a recordset because the records might be
459        from different models (template, variant and image).
460
461        It contains in this order: the main image of the variant (if set), the
462        Variant Extra Images, and the Template Extra Images.
463        """
464        self.ensure_one()
465        variant_images = list(self.product_variant_image_ids)
466        if self.image_variant_1920:
467            # if the main variant image is set, display it first
468            variant_images = [self] + variant_images
469        else:
470            # If the main variant image is empty, it will fallback to template
471            # image, in this case insert it after the other variant images, so
472            # that all variant images are first and all template images last.
473            variant_images = variant_images + [self]
474        # [1:] to remove the main image from the template, we only display
475        # the template extra images here
476        return variant_images + self.product_tmpl_id._get_images()[1:]
477