1# -*- coding: utf-8 -*- 2# Part of Odoo. See LICENSE file for full copyright and licensing details. 3import logging 4import random 5from datetime import datetime 6from dateutil.relativedelta import relativedelta 7 8from odoo import api, models, fields, _ 9from odoo.http import request 10from odoo.osv import expression 11from odoo.exceptions import UserError, ValidationError 12 13_logger = logging.getLogger(__name__) 14 15 16class SaleOrder(models.Model): 17 _inherit = "sale.order" 18 19 website_order_line = fields.One2many( 20 'sale.order.line', 21 compute='_compute_website_order_line', 22 string='Order Lines displayed on Website', 23 help='Order Lines to be displayed on the website. They should not be used for computation purpose.', 24 ) 25 cart_quantity = fields.Integer(compute='_compute_cart_info', string='Cart Quantity') 26 only_services = fields.Boolean(compute='_compute_cart_info', string='Only Services') 27 is_abandoned_cart = fields.Boolean('Abandoned Cart', compute='_compute_abandoned_cart', search='_search_abandoned_cart') 28 cart_recovery_email_sent = fields.Boolean('Cart recovery email already sent') 29 website_id = fields.Many2one('website', string='Website', readonly=True, 30 help='Website through which this order was placed.') 31 32 @api.depends('order_line') 33 def _compute_website_order_line(self): 34 for order in self: 35 order.website_order_line = order.order_line 36 37 @api.depends('order_line.product_uom_qty', 'order_line.product_id') 38 def _compute_cart_info(self): 39 for order in self: 40 order.cart_quantity = int(sum(order.mapped('website_order_line.product_uom_qty'))) 41 order.only_services = all(l.product_id.type in ('service', 'digital') for l in order.website_order_line) 42 43 @api.depends('website_id', 'date_order', 'order_line', 'state', 'partner_id') 44 def _compute_abandoned_cart(self): 45 for order in self: 46 # a quotation can be considered as an abandonned cart if it is linked to a website, 47 # is in the 'draft' state and has an expiration date 48 if order.website_id and order.state == 'draft' and order.date_order: 49 public_partner_id = order.website_id.user_id.partner_id 50 # by default the expiration date is 1 hour if not specified on the website configuration 51 abandoned_delay = order.website_id.cart_abandoned_delay or 1.0 52 abandoned_datetime = datetime.utcnow() - relativedelta(hours=abandoned_delay) 53 order.is_abandoned_cart = bool(order.date_order <= abandoned_datetime and order.partner_id != public_partner_id and order.order_line) 54 else: 55 order.is_abandoned_cart = False 56 57 def _search_abandoned_cart(self, operator, value): 58 abandoned_delay = self.website_id and self.website_id.cart_abandoned_delay or 1.0 59 abandoned_datetime = fields.Datetime.to_string(datetime.utcnow() - relativedelta(hours=abandoned_delay)) 60 abandoned_domain = expression.normalize_domain([ 61 ('date_order', '<=', abandoned_datetime), 62 ('website_id', '!=', False), 63 ('state', '=', 'draft'), 64 ('partner_id', '!=', self.env.ref('base.public_partner').id), 65 ('order_line', '!=', False) 66 ]) 67 # is_abandoned domain possibilities 68 if (operator not in expression.NEGATIVE_TERM_OPERATORS and value) or (operator in expression.NEGATIVE_TERM_OPERATORS and not value): 69 return abandoned_domain 70 return expression.distribute_not(['!'] + abandoned_domain) # negative domain 71 72 def _cart_find_product_line(self, product_id=None, line_id=None, **kwargs): 73 """Find the cart line matching the given parameters. 74 75 If a product_id is given, the line will match the product only if the 76 line also has the same special attributes: `no_variant` attributes and 77 `is_custom` values. 78 """ 79 self.ensure_one() 80 product = self.env['product.product'].browse(product_id) 81 82 # split lines with the same product if it has untracked attributes 83 if product and (product.product_tmpl_id.has_dynamic_attributes() or product.product_tmpl_id._has_no_variant_attributes()) and not line_id: 84 return self.env['sale.order.line'] 85 86 domain = [('order_id', '=', self.id), ('product_id', '=', product_id)] 87 if line_id: 88 domain += [('id', '=', line_id)] 89 else: 90 domain += [('product_custom_attribute_value_ids', '=', False)] 91 92 return self.env['sale.order.line'].sudo().search(domain) 93 94 def _website_product_id_change(self, order_id, product_id, qty=0): 95 order = self.sudo().browse(order_id) 96 product_context = dict(self.env.context) 97 product_context.setdefault('lang', order.partner_id.lang) 98 product_context.update({ 99 'partner': order.partner_id, 100 'quantity': qty, 101 'date': order.date_order, 102 'pricelist': order.pricelist_id.id, 103 }) 104 product = self.env['product.product'].with_context(product_context).with_company(order.company_id.id).browse(product_id) 105 discount = 0 106 107 if order.pricelist_id.discount_policy == 'without_discount': 108 # This part is pretty much a copy-paste of the method '_onchange_discount' of 109 # 'sale.order.line'. 110 price, rule_id = order.pricelist_id.with_context(product_context).get_product_price_rule(product, qty or 1.0, order.partner_id) 111 pu, currency = request.env['sale.order.line'].with_context(product_context)._get_real_price_currency(product, rule_id, qty, product.uom_id, order.pricelist_id.id) 112 if order.pricelist_id and order.partner_id: 113 order_line = order._cart_find_product_line(product.id) 114 if order_line: 115 price = self.env['account.tax']._fix_tax_included_price_company(price, product.taxes_id, order_line[0].tax_id, self.company_id) 116 pu = self.env['account.tax']._fix_tax_included_price_company(pu, product.taxes_id, order_line[0].tax_id, self.company_id) 117 if pu != 0: 118 if order.pricelist_id.currency_id != currency: 119 # we need new_list_price in the same currency as price, which is in the SO's pricelist's currency 120 date = order.date_order or fields.Date.today() 121 pu = currency._convert(pu, order.pricelist_id.currency_id, order.company_id, date) 122 discount = (pu - price) / pu * 100 123 if discount < 0: 124 # In case the discount is negative, we don't want to show it to the customer, 125 # but we still want to use the price defined on the pricelist 126 discount = 0 127 pu = price 128 else: 129 pu = product.price 130 if order.pricelist_id and order.partner_id: 131 order_line = order._cart_find_product_line(product.id) 132 if order_line: 133 pu = self.env['account.tax']._fix_tax_included_price_company(pu, product.taxes_id, order_line[0].tax_id, self.company_id) 134 135 return { 136 'product_id': product_id, 137 'product_uom_qty': qty, 138 'order_id': order_id, 139 'product_uom': product.uom_id.id, 140 'price_unit': pu, 141 'discount': discount, 142 } 143 144 def _cart_update(self, product_id=None, line_id=None, add_qty=0, set_qty=0, **kwargs): 145 """ Add or set product quantity, add_qty can be negative """ 146 self.ensure_one() 147 product_context = dict(self.env.context) 148 product_context.setdefault('lang', self.sudo().partner_id.lang) 149 SaleOrderLineSudo = self.env['sale.order.line'].sudo().with_context(product_context) 150 # change lang to get correct name of attributes/values 151 product_with_context = self.env['product.product'].with_context(product_context) 152 product = product_with_context.browse(int(product_id)) 153 154 try: 155 if add_qty: 156 add_qty = int(add_qty) 157 except ValueError: 158 add_qty = 1 159 try: 160 if set_qty: 161 set_qty = int(set_qty) 162 except ValueError: 163 set_qty = 0 164 quantity = 0 165 order_line = False 166 if self.state != 'draft': 167 request.session['sale_order_id'] = None 168 raise UserError(_('It is forbidden to modify a sales order which is not in draft status.')) 169 if line_id is not False: 170 order_line = self._cart_find_product_line(product_id, line_id, **kwargs)[:1] 171 172 # Create line if no line with product_id can be located 173 if not order_line: 174 if not product: 175 raise UserError(_("The given product does not exist therefore it cannot be added to cart.")) 176 177 no_variant_attribute_values = kwargs.get('no_variant_attribute_values') or [] 178 received_no_variant_values = product.env['product.template.attribute.value'].browse([int(ptav['value']) for ptav in no_variant_attribute_values]) 179 received_combination = product.product_template_attribute_value_ids | received_no_variant_values 180 product_template = product.product_tmpl_id 181 182 # handle all cases where incorrect or incomplete data are received 183 combination = product_template._get_closest_possible_combination(received_combination) 184 185 # get or create (if dynamic) the correct variant 186 product = product_template._create_product_variant(combination) 187 188 if not product: 189 raise UserError(_("The given combination does not exist therefore it cannot be added to cart.")) 190 191 product_id = product.id 192 193 values = self._website_product_id_change(self.id, product_id, qty=1) 194 195 # add no_variant attributes that were not received 196 for ptav in combination.filtered(lambda ptav: ptav.attribute_id.create_variant == 'no_variant' and ptav not in received_no_variant_values): 197 no_variant_attribute_values.append({ 198 'value': ptav.id, 199 }) 200 201 # save no_variant attributes values 202 if no_variant_attribute_values: 203 values['product_no_variant_attribute_value_ids'] = [ 204 (6, 0, [int(attribute['value']) for attribute in no_variant_attribute_values]) 205 ] 206 207 # add is_custom attribute values that were not received 208 custom_values = kwargs.get('product_custom_attribute_values') or [] 209 received_custom_values = product.env['product.template.attribute.value'].browse([int(ptav['custom_product_template_attribute_value_id']) for ptav in custom_values]) 210 211 for ptav in combination.filtered(lambda ptav: ptav.is_custom and ptav not in received_custom_values): 212 custom_values.append({ 213 'custom_product_template_attribute_value_id': ptav.id, 214 'custom_value': '', 215 }) 216 217 # save is_custom attributes values 218 if custom_values: 219 values['product_custom_attribute_value_ids'] = [(0, 0, { 220 'custom_product_template_attribute_value_id': custom_value['custom_product_template_attribute_value_id'], 221 'custom_value': custom_value['custom_value'] 222 }) for custom_value in custom_values] 223 224 # create the line 225 order_line = SaleOrderLineSudo.create(values) 226 227 try: 228 order_line._compute_tax_id() 229 except ValidationError as e: 230 # The validation may occur in backend (eg: taxcloud) but should fail silently in frontend 231 _logger.debug("ValidationError occurs during tax compute. %s" % (e)) 232 if add_qty: 233 add_qty -= 1 234 235 # compute new quantity 236 if set_qty: 237 quantity = set_qty 238 elif add_qty is not None: 239 quantity = order_line.product_uom_qty + (add_qty or 0) 240 241 # Remove zero of negative lines 242 if quantity <= 0: 243 linked_line = order_line.linked_line_id 244 order_line.unlink() 245 if linked_line: 246 # update description of the parent 247 linked_product = product_with_context.browse(linked_line.product_id.id) 248 linked_line.name = linked_line.get_sale_order_line_multiline_description_sale(linked_product) 249 else: 250 # update line 251 no_variant_attributes_price_extra = [ptav.price_extra for ptav in order_line.product_no_variant_attribute_value_ids] 252 values = self.with_context(no_variant_attributes_price_extra=tuple(no_variant_attributes_price_extra))._website_product_id_change(self.id, product_id, qty=quantity) 253 order = self.sudo().browse(self.id) 254 if self.pricelist_id.discount_policy == 'with_discount' and not self.env.context.get('fixed_price'): 255 product_context.update({ 256 'partner': order.partner_id, 257 'quantity': quantity, 258 'date': order.date_order, 259 'pricelist': order.pricelist_id.id, 260 }) 261 product_with_context = self.env['product.product'].with_context(product_context).with_company(order.company_id.id) 262 product = product_with_context.browse(product_id) 263 264 order_line.write(values) 265 266 # link a product to the sales order 267 if kwargs.get('linked_line_id'): 268 linked_line = SaleOrderLineSudo.browse(kwargs['linked_line_id']) 269 order_line.write({ 270 'linked_line_id': linked_line.id, 271 }) 272 linked_product = product_with_context.browse(linked_line.product_id.id) 273 linked_line.name = linked_line.get_sale_order_line_multiline_description_sale(linked_product) 274 # Generate the description with everything. This is done after 275 # creating because the following related fields have to be set: 276 # - product_no_variant_attribute_value_ids 277 # - product_custom_attribute_value_ids 278 # - linked_line_id 279 order_line.name = order_line.get_sale_order_line_multiline_description_sale(product) 280 281 option_lines = self.order_line.filtered(lambda l: l.linked_line_id.id == order_line.id) 282 283 return {'line_id': order_line.id, 'quantity': quantity, 'option_ids': list(set(option_lines.ids))} 284 285 def _cart_accessories(self): 286 """ Suggest accessories based on 'Accessory Products' of products in cart """ 287 for order in self: 288 products = order.website_order_line.mapped('product_id') 289 accessory_products = self.env['product.product'] 290 for line in order.website_order_line.filtered(lambda l: l.product_id): 291 combination = line.product_id.product_template_attribute_value_ids + line.product_no_variant_attribute_value_ids 292 accessory_products |= line.product_id.accessory_product_ids.filtered(lambda product: 293 product.website_published and 294 product not in products and 295 product._is_variant_possible(parent_combination=combination) and 296 (product.company_id == line.company_id or not product.company_id) 297 ) 298 299 return random.sample(accessory_products, len(accessory_products)) 300 301 def action_recovery_email_send(self): 302 for order in self: 303 order._portal_ensure_token() 304 composer_form_view_id = self.env.ref('mail.email_compose_message_wizard_form').id 305 306 template_id = self._get_cart_recovery_template().id 307 308 return { 309 'type': 'ir.actions.act_window', 310 'view_mode': 'form', 311 'res_model': 'mail.compose.message', 312 'view_id': composer_form_view_id, 313 'target': 'new', 314 'context': { 315 'default_composition_mode': 'mass_mail' if len(self.ids) > 1 else 'comment', 316 'default_res_id': self.ids[0], 317 'default_model': 'sale.order', 318 'default_use_template': bool(template_id), 319 'default_template_id': template_id, 320 'website_sale_send_recovery_email': True, 321 'active_ids': self.ids, 322 }, 323 } 324 325 def _get_cart_recovery_template(self): 326 """ 327 Return the cart recovery template record for a set of orders. 328 If they all belong to the same website, we return the website-specific template; 329 otherwise we return the default template. 330 If the default is not found, the empty ['mail.template'] is returned. 331 """ 332 websites = self.mapped('website_id') 333 template = websites.cart_recovery_mail_template_id if len(websites) == 1 else False 334 template = template or self.env.ref('website_sale.mail_template_sale_cart_recovery', raise_if_not_found=False) 335 return template or self.env['mail.template'] 336 337 def _cart_recovery_email_send(self): 338 """Send the cart recovery email on the current recordset, 339 making sure that the portal token exists to avoid broken links, and marking the email as sent. 340 Similar method to action_recovery_email_send, made to be called in automated actions. 341 Contrary to the former, it will use the website-specific template for each order.""" 342 sent_orders = self.env['sale.order'] 343 for order in self: 344 template = order._get_cart_recovery_template() 345 if template: 346 order._portal_ensure_token() 347 template.send_mail(order.id) 348 sent_orders |= order 349 sent_orders.write({'cart_recovery_email_sent': True}) 350 351 def action_confirm(self): 352 res = super(SaleOrder, self).action_confirm() 353 for order in self: 354 if not order.transaction_ids and not order.amount_total and self._context.get('send_email'): 355 order._send_order_confirmation_mail() 356 return res 357 358 359class SaleOrderLine(models.Model): 360 _inherit = "sale.order.line" 361 362 name_short = fields.Char(compute="_compute_name_short") 363 364 linked_line_id = fields.Many2one('sale.order.line', string='Linked Order Line', domain="[('order_id', '!=', order_id)]", ondelete='cascade') 365 option_line_ids = fields.One2many('sale.order.line', 'linked_line_id', string='Options Linked') 366 367 def get_sale_order_line_multiline_description_sale(self, product): 368 description = super(SaleOrderLine, self).get_sale_order_line_multiline_description_sale(product) 369 if self.linked_line_id: 370 description += "\n" + _("Option for: %s", self.linked_line_id.product_id.display_name) 371 if self.option_line_ids: 372 description += "\n" + '\n'.join([_("Option: %s", option_line.product_id.display_name) for option_line in self.option_line_ids]) 373 return description 374 375 @api.depends('product_id.display_name') 376 def _compute_name_short(self): 377 """ Compute a short name for this sale order line, to be used on the website where we don't have much space. 378 To keep it short, instead of using the first line of the description, we take the product name without the internal reference. 379 """ 380 for record in self: 381 record.name_short = record.product_id.with_context(display_default_code=False).display_name 382 383 def get_description_following_lines(self): 384 return self.name.splitlines()[1:] 385