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