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