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