1# -*- coding: utf-8 -*-
2# Part of Odoo. See LICENSE file for full copyright and licensing details.
3
4import json
5import logging
6
7from odoo import api, fields, models, _
8from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
9from odoo.exceptions import ValidationError
10from odoo.tools.float_utils import float_round
11
12_logger = logging.getLogger(__name__)
13
14
15class ProductTemplate(models.Model):
16    _inherit = 'product.template'
17
18    def _default_visible_expense_policy(self):
19        return self.user_has_groups('analytic.group_analytic_accounting')
20
21    service_type = fields.Selection([('manual', 'Manually set quantities on order')], string='Track Service',
22        help="Manually set quantities on order: Invoice based on the manually entered quantity, without creating an analytic account.\n"
23             "Timesheets on contract: Invoice based on the tracked hours on the related timesheet.\n"
24             "Create a task and track hours: Create a task on the sales order validation and track the work hours.",
25        default='manual')
26    sale_line_warn = fields.Selection(WARNING_MESSAGE, 'Sales Order Line', help=WARNING_HELP, required=True, default="no-message")
27    sale_line_warn_msg = fields.Text('Message for Sales Order Line')
28    expense_policy = fields.Selection(
29        [('no', 'No'), ('cost', 'At cost'), ('sales_price', 'Sales price')],
30        string='Re-Invoice Expenses',
31        default='no',
32        help="Expenses and vendor bills can be re-invoiced to a customer."
33             "With this option, a validated expense can be re-invoice to a customer at its cost or sales price.")
34    visible_expense_policy = fields.Boolean("Re-Invoice Policy visible", compute='_compute_visible_expense_policy', default=lambda self: self._default_visible_expense_policy())
35    sales_count = fields.Float(compute='_compute_sales_count', string='Sold')
36    visible_qty_configurator = fields.Boolean("Quantity visible in configurator", compute='_compute_visible_qty_configurator')
37    invoice_policy = fields.Selection([
38        ('order', 'Ordered quantities'),
39        ('delivery', 'Delivered quantities')], string='Invoicing Policy',
40        help='Ordered Quantity: Invoice quantities ordered by the customer.\n'
41             'Delivered Quantity: Invoice quantities delivered to the customer.',
42        default='order')
43
44    def _compute_visible_qty_configurator(self):
45        for product_template in self:
46            product_template.visible_qty_configurator = True
47
48    @api.depends('name')
49    def _compute_visible_expense_policy(self):
50        visibility = self.user_has_groups('analytic.group_analytic_accounting')
51        for product_template in self:
52            product_template.visible_expense_policy = visibility
53
54
55    @api.onchange('sale_ok')
56    def _change_sale_ok(self):
57        if not self.sale_ok:
58            self.expense_policy = 'no'
59
60    @api.depends('product_variant_ids.sales_count')
61    def _compute_sales_count(self):
62        for product in self:
63            product.sales_count = float_round(sum([p.sales_count for p in product.with_context(active_test=False).product_variant_ids]), precision_rounding=product.uom_id.rounding)
64
65
66    @api.constrains('company_id')
67    def _check_sale_product_company(self):
68        """Ensure the product is not being restricted to a single company while
69        having been sold in another one in the past, as this could cause issues."""
70        target_company = self.company_id
71        if target_company:  # don't prevent writing `False`, should always work
72            product_data = self.env['product.product'].sudo().with_context(active_test=False).search_read([('product_tmpl_id', 'in', self.ids)], fields=['id'])
73            product_ids = list(map(lambda p: p['id'], product_data))
74            so_lines = self.env['sale.order.line'].sudo().search_read([('product_id', 'in', product_ids), ('company_id', '!=', target_company.id)], fields=['id', 'product_id'])
75            used_products = list(map(lambda sol: sol['product_id'][1], so_lines))
76            if so_lines:
77                raise ValidationError(_('The following products cannot be restricted to the company'
78                                        ' %s because they have already been used in quotations or '
79                                        'sales orders in another company:\n%s\n'
80                                        'You can archive these products and recreate them '
81                                        'with your company restriction instead, or leave them as '
82                                        'shared product.') % (target_company.name, ', '.join(used_products)))
83
84    def action_view_sales(self):
85        action = self.env["ir.actions.actions"]._for_xml_id("sale.report_all_channels_sales_action")
86        action['domain'] = [('product_tmpl_id', 'in', self.ids)]
87        action['context'] = {
88            'pivot_measures': ['product_uom_qty'],
89            'active_id': self._context.get('active_id'),
90            'active_model': 'sale.report',
91            'search_default_Sales': 1,
92            'time_ranges': {'field': 'date', 'range': 'last_365_days'}
93        }
94        return action
95
96    def create_product_variant(self, product_template_attribute_value_ids):
97        """ Create if necessary and possible and return the id of the product
98        variant matching the given combination for this template.
99
100        Note AWA: Known "exploit" issues with this method:
101
102        - This method could be used by an unauthenticated user to generate a
103            lot of useless variants. Unfortunately, after discussing the
104            matter with ODO, there's no easy and user-friendly way to block
105            that behavior.
106
107            We would have to use captcha/server actions to clean/... that
108            are all not user-friendly/overkill mechanisms.
109
110        - This method could be used to try to guess what product variant ids
111            are created in the system and what product template ids are
112            configured as "dynamic", but that does not seem like a big deal.
113
114        The error messages are identical on purpose to avoid giving too much
115        information to a potential attacker:
116            - returning 0 when failing
117            - returning the variant id whether it already existed or not
118
119        :param product_template_attribute_value_ids: the combination for which
120            to get or create variant
121        :type product_template_attribute_value_ids: json encoded list of id
122            of `product.template.attribute.value`
123
124        :return: id of the product variant matching the combination or 0
125        :rtype: int
126        """
127        combination = self.env['product.template.attribute.value'] \
128            .browse(json.loads(product_template_attribute_value_ids))
129
130        return self._create_product_variant(combination, log_warning=True).id or 0
131
132    @api.onchange('type')
133    def _onchange_type(self):
134        """ Force values to stay consistent with integrity constraints """
135        res = super(ProductTemplate, self)._onchange_type()
136        if self.type == 'consu':
137            if not self.invoice_policy:
138                self.invoice_policy = 'order'
139            self.service_type = 'manual'
140        return res
141
142    @api.model
143    def get_import_templates(self):
144        res = super(ProductTemplate, self).get_import_templates()
145        if self.env.context.get('sale_multi_pricelist_product_template'):
146            if self.user_has_groups('product.group_sale_pricelist'):
147                return [{
148                    'label': _('Import Template for Products'),
149                    'template': '/product/static/xls/product_template.xls'
150                }]
151        return res
152
153    def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False):
154        """ Return info about a given combination.
155
156        Note: this method does not take into account whether the combination is
157        actually possible.
158
159        :param combination: recordset of `product.template.attribute.value`
160
161        :param product_id: id of a `product.product`. If no `combination`
162            is set, the method will try to load the variant `product_id` if
163            it exists instead of finding a variant based on the combination.
164
165            If there is no combination, that means we definitely want a
166            variant and not something that will have no_variant set.
167
168        :param add_qty: float with the quantity for which to get the info,
169            indeed some pricelist rules might depend on it.
170
171        :param pricelist: `product.pricelist` the pricelist to use
172            (can be none, eg. from SO if no partner and no pricelist selected)
173
174        :param parent_combination: if no combination and no product_id are
175            given, it will try to find the first possible combination, taking
176            into account parent_combination (if set) for the exclusion rules.
177
178        :param only_template: boolean, if set to True, get the info for the
179            template only: ignore combination and don't try to find variant
180
181        :return: dict with product/combination info:
182
183            - product_id: the variant id matching the combination (if it exists)
184
185            - product_template_id: the current template id
186
187            - display_name: the name of the combination
188
189            - price: the computed price of the combination, take the catalog
190                price if no pricelist is given
191
192            - list_price: the catalog price of the combination, but this is
193                not the "real" list_price, it has price_extra included (so
194                it's actually more closely related to `lst_price`), and it
195                is converted to the pricelist currency (if given)
196
197            - has_discounted_price: True if the pricelist discount policy says
198                the price does not include the discount and there is actually a
199                discount applied (price < list_price), else False
200        """
201        self.ensure_one()
202        # get the name before the change of context to benefit from prefetch
203        display_name = self.display_name
204
205        display_image = True
206        quantity = self.env.context.get('quantity', add_qty)
207        context = dict(self.env.context, quantity=quantity, pricelist=pricelist.id if pricelist else False)
208        product_template = self.with_context(context)
209
210        combination = combination or product_template.env['product.template.attribute.value']
211
212        if not product_id and not combination and not only_template:
213            combination = product_template._get_first_possible_combination(parent_combination)
214
215        if only_template:
216            product = product_template.env['product.product']
217        elif product_id and not combination:
218            product = product_template.env['product.product'].browse(product_id)
219        else:
220            product = product_template._get_variant_for_combination(combination)
221
222        if product:
223            # We need to add the price_extra for the attributes that are not
224            # in the variant, typically those of type no_variant, but it is
225            # possible that a no_variant attribute is still in a variant if
226            # the type of the attribute has been changed after creation.
227            no_variant_attributes_price_extra = [
228                ptav.price_extra for ptav in combination.filtered(
229                    lambda ptav:
230                        ptav.price_extra and
231                        ptav not in product.product_template_attribute_value_ids
232                )
233            ]
234            if no_variant_attributes_price_extra:
235                product = product.with_context(
236                    no_variant_attributes_price_extra=tuple(no_variant_attributes_price_extra)
237                )
238            list_price = product.price_compute('list_price')[product.id]
239            price = product.price if pricelist else list_price
240            display_image = bool(product.image_1920)
241            display_name = product.display_name
242        else:
243            product_template = product_template.with_context(current_attributes_price_extra=[v.price_extra or 0.0 for v in combination])
244            list_price = product_template.price_compute('list_price')[product_template.id]
245            price = product_template.price if pricelist else list_price
246            display_image = bool(product_template.image_1920)
247
248            combination_name = combination._get_combination_name()
249            if combination_name:
250                display_name = "%s (%s)" % (display_name, combination_name)
251
252        if pricelist and pricelist.currency_id != product_template.currency_id:
253            list_price = product_template.currency_id._convert(
254                list_price, pricelist.currency_id, product_template._get_current_company(pricelist=pricelist),
255                fields.Date.today()
256            )
257
258        price_without_discount = list_price if pricelist and pricelist.discount_policy == 'without_discount' else price
259        has_discounted_price = (pricelist or product_template).currency_id.compare_amounts(price_without_discount, price) == 1
260
261        return {
262            'product_id': product.id,
263            'product_template_id': product_template.id,
264            'display_name': display_name,
265            'display_image': display_image,
266            'price': price,
267            'list_price': list_price,
268            'has_discounted_price': has_discounted_price,
269        }
270
271    def _is_add_to_cart_possible(self, parent_combination=None):
272        """
273        It's possible to add to cart (potentially after configuration) if
274        there is at least one possible combination.
275
276        :param parent_combination: the combination from which `self` is an
277            optional or accessory product.
278        :type parent_combination: recordset `product.template.attribute.value`
279
280        :return: True if it's possible to add to cart, else False
281        :rtype: bool
282        """
283        self.ensure_one()
284        if not self.active:
285            # for performance: avoid calling `_get_possible_combinations`
286            return False
287        return next(self._get_possible_combinations(parent_combination), False) is not False
288
289    def _get_current_company_fallback(self, **kwargs):
290        """Override: if a pricelist is given, fallback to the company of the
291        pricelist if it is set, otherwise use the one from parent method."""
292        res = super(ProductTemplate, self)._get_current_company_fallback(**kwargs)
293        pricelist = kwargs.get('pricelist')
294        return pricelist and pricelist.company_id or res
295