# -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. import itertools import psycopg2 from flectra.addons import decimal_precision as dp from flectra import api, fields, models, tools, _ from flectra.exceptions import ValidationError, RedirectWarning, except_orm from flectra.tools import pycompat class ProductTemplate(models.Model): _name = "product.template" _inherit = ['mail.thread', 'mail.activity.mixin'] _description = "Product Template" _order = "name" def _get_default_category_id(self): if self._context.get('categ_id') or self._context.get('default_categ_id'): return self._context.get('categ_id') or self._context.get('default_categ_id') category = self.env.ref('product.product_category_all', raise_if_not_found=False) if not category: category = self.env['product.category'].search([], limit=1) if category: return category.id else: err_msg = _('You must define at least one product category in order to be able to create products.') redir_msg = _('Go to Internal Categories') raise RedirectWarning(err_msg, self.env.ref('product.product_category_action_form').id, redir_msg) def _get_default_uom_id(self): return self.env["product.uom"].search([], limit=1, order='id').id name = fields.Char('Name', index=True, required=True, translate=True) sequence = fields.Integer('Sequence', default=1, help='Gives the sequence order when displaying a product list') description = fields.Text( 'Description', translate=True, help="A precise description of the Product, used only for internal information purposes.") description_purchase = fields.Text( 'Purchase Description', translate=True, help="A description of the Product that you want to communicate to your vendors. " "This description will be copied to every Purchase Order, Receipt and Vendor Bill/Credit Note.") description_sale = fields.Text( 'Sale Description', translate=True, help="A description of the Product that you want to communicate to your customers. " "This description will be copied to every Sales Order, Delivery Order and Customer Invoice/Credit Note") type = fields.Selection([ ('consu', _('Consumable')), ('service', _('Service'))], string='Product Type', default='consu', required=True, help='A stockable product is a product for which you manage stock. The "Inventory" app has to be installed.\n' 'A consumable product, on the other hand, is a product for which stock is not managed.\n' 'A service is a non-material product you provide.\n' 'A digital content is a non-material product you sell online. The files attached to the products are the one that are sold on ' 'the e-commerce such as e-books, music, pictures,... The "Digital Product" module has to be installed.') rental = fields.Boolean('Can be Rent') categ_id = fields.Many2one( 'product.category', 'Internal Category', change_default=True, default=_get_default_category_id, required=True, help="Select category for the current product") currency_id = fields.Many2one( 'res.currency', 'Currency', compute='_compute_currency_id') # price fields price = fields.Float( 'Price', compute='_compute_template_price', inverse='_set_template_price', digits=dp.get_precision('Product Price')) list_price = fields.Float( 'Sales Price', default=1.0, digits=dp.get_precision('Product Price'), help="Base price to compute the customer price. Sometimes called the catalog price.") lst_price = fields.Float( 'Public Price', related='list_price', digits=dp.get_precision('Product Price')) standard_price = fields.Float( 'Cost', compute='_compute_standard_price', inverse='_set_standard_price', search='_search_standard_price', digits=dp.get_precision('Product Price'), groups="base.group_user", help = "Cost used for stock valuation in standard price and as a first price to set in average/fifo. " "Also used as a base price for pricelists. " "Expressed in the default unit of measure of the product. ") volume = fields.Float( 'Volume', compute='_compute_volume', inverse='_set_volume', help="The volume in m3.", store=True) weight = fields.Float( 'Weight', compute='_compute_weight', digits=dp.get_precision('Stock Weight'), inverse='_set_weight', store=True, help="The weight of the contents in Kg, not including any packaging, etc.") sale_ok = fields.Boolean( 'Can be Sold', default=True, help="Specify if the product can be selected in a sales order line.") purchase_ok = fields.Boolean('Can be Purchased', default=True) pricelist_id = fields.Many2one( 'product.pricelist', 'Pricelist', store=False, help='Technical field. Used for searching on pricelists, not stored in database.') uom_id = fields.Many2one( 'product.uom', 'Unit of Measure', default=_get_default_uom_id, required=True, help="Default Unit of Measure used for all stock operation.") uom_po_id = fields.Many2one( 'product.uom', 'Purchase Unit of Measure', default=_get_default_uom_id, required=True, help="Default Unit of Measure used for purchase orders. It must be in the same category than the default unit of measure.") company_id = fields.Many2one( 'res.company', 'Company', default=lambda self: self.env['res.company']._company_default_get('product.template'), index=1) packaging_ids = fields.One2many( 'product.packaging', string="Product Packages", compute="_compute_packaging_ids", inverse="_set_packaging_ids", help="Gives the different ways to package the same product.") seller_ids = fields.One2many('product.supplierinfo', 'product_tmpl_id', 'Vendors') variant_seller_ids = fields.One2many('product.supplierinfo', 'product_tmpl_id') active = fields.Boolean('Active', default=True, help="If unchecked, it will allow you to hide the product without removing it.") color = fields.Integer('Color Index') is_product_variant = fields.Boolean(string='Is a product variant', compute='_compute_is_product_variant') attribute_line_ids = fields.One2many('product.attribute.line', 'product_tmpl_id', 'Product Attributes') product_variant_ids = fields.One2many('product.product', 'product_tmpl_id', 'Products', required=True) # performance: product_variant_id provides prefetching on the first product variant only product_variant_id = fields.Many2one('product.product', 'Product', compute='_compute_product_variant_id') product_variant_count = fields.Integer( '# Product Variants', compute='_compute_product_variant_count') # related to display product product information if is_product_variant barcode = fields.Char('Barcode', oldname='ean13', related='product_variant_ids.barcode') default_code = fields.Char( 'Internal Reference', compute='_compute_default_code', inverse='_set_default_code', store=True) item_ids = fields.One2many('product.pricelist.item', 'product_tmpl_id', 'Pricelist Items') # image: all image fields are base64 encoded and PIL-supported image = fields.Binary( "Image", attachment=True, help="This field holds the image used as image for the product, limited to 1024x1024px.") image_medium = fields.Binary( "Medium-sized image", attachment=True, help="Medium-sized image of the product. It is automatically " "resized as a 128x128px image, with aspect ratio preserved, " "only when the image exceeds one of those sizes. Use this field in form views or some kanban views.") image_small = fields.Binary( "Small-sized image", attachment=True, help="Small-sized image of the product. It is automatically " "resized as a 64x64px image, with aspect ratio preserved. " "Use this field anywhere a small image is required.") @api.depends('product_variant_ids') def _compute_product_variant_id(self): for p in self: p.product_variant_id = p.product_variant_ids[:1].id @api.multi def _compute_currency_id(self): try: main_company = self.sudo().env.ref('base.main_company') except ValueError: main_company = self.env['res.company'].sudo().search([], limit=1, order="id") for template in self: template.currency_id = template.company_id.sudo().currency_id.id or main_company.currency_id.id @api.multi def _compute_template_price(self): prices = {} pricelist_id_or_name = self._context.get('pricelist') if pricelist_id_or_name: pricelist = None partner = self._context.get('partner') quantity = self._context.get('quantity', 1.0) # Support context pricelists specified as display_name or ID for compatibility if isinstance(pricelist_id_or_name, pycompat.string_types): pricelist_data = self.env['product.pricelist'].name_search(pricelist_id_or_name, operator='=', limit=1) if pricelist_data: pricelist = self.env['product.pricelist'].browse(pricelist_data[0][0]) elif isinstance(pricelist_id_or_name, pycompat.integer_types): pricelist = self.env['product.pricelist'].browse(pricelist_id_or_name) if pricelist: quantities = [quantity] * len(self) partners = [partner] * len(self) prices = pricelist.get_products_price(self, quantities, partners) for template in self: template.price = prices.get(template.id, 0.0) @api.multi def _set_template_price(self): if self._context.get('uom'): for template in self: value = self.env['product.uom'].browse(self._context['uom'])._compute_price(template.price, template.uom_id) template.write({'list_price': value}) else: self.write({'list_price': self.price}) @api.depends('product_variant_ids', 'product_variant_ids.standard_price') def _compute_standard_price(self): unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1) for template in unique_variants: template.standard_price = template.product_variant_ids.standard_price for template in (self - unique_variants): template.standard_price = 0.0 @api.one def _set_standard_price(self): if len(self.product_variant_ids) == 1: self.product_variant_ids.standard_price = self.standard_price def _search_standard_price(self, operator, value): products = self.env['product.product'].search([('standard_price', operator, value)], limit=None) return [('id', 'in', products.mapped('product_tmpl_id').ids)] @api.depends('product_variant_ids', 'product_variant_ids.volume') def _compute_volume(self): unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1) for template in unique_variants: template.volume = template.product_variant_ids.volume for template in (self - unique_variants): template.volume = 0.0 @api.one def _set_volume(self): if len(self.product_variant_ids) == 1: self.product_variant_ids.volume = self.volume @api.depends('product_variant_ids', 'product_variant_ids.weight') def _compute_weight(self): unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1) for template in unique_variants: template.weight = template.product_variant_ids.weight for template in (self - unique_variants): template.weight = 0.0 def _compute_is_product_variant(self): for template in self: if template._name == 'product.template': template.is_product_variant = False else: template.is_product_variant = True @api.one def _set_weight(self): if len(self.product_variant_ids) == 1: self.product_variant_ids.weight = self.weight @api.one @api.depends('product_variant_ids.product_tmpl_id') def _compute_product_variant_count(self): # do not pollute variants to be prefetched when counting variants self.product_variant_count = len(self.with_prefetch().product_variant_ids) @api.depends('product_variant_ids', 'product_variant_ids.default_code') def _compute_default_code(self): unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1) for template in unique_variants: template.default_code = template.product_variant_ids.default_code for template in (self - unique_variants): template.default_code = '' @api.one def _set_default_code(self): if len(self.product_variant_ids) == 1: self.product_variant_ids.default_code = self.default_code @api.depends('product_variant_ids', 'product_variant_ids.packaging_ids') def _compute_packaging_ids(self): for p in self: if len(p.product_variant_ids) == 1: p.packaging_ids = p.product_variant_ids.packaging_ids def _set_packaging_ids(self): for p in self: if len(p.product_variant_ids) == 1: p.product_variant_ids.packaging_ids = p.packaging_ids @api.constrains('uom_id', 'uom_po_id') def _check_uom(self): if any(template.uom_id and template.uom_po_id and template.uom_id.category_id != template.uom_po_id.category_id for template in self): raise ValidationError(_('Error: The default Unit of Measure and the purchase Unit of Measure must be in the same category.')) return True @api.onchange('uom_id') def _onchange_uom_id(self): if self.uom_id: self.uom_po_id = self.uom_id.id @api.model def create(self, vals): ''' Store the initial standard price in order to be able to retrieve the cost of a product template for a given date''' # TDE FIXME: context brol tools.image_resize_images(vals) template = super(ProductTemplate, self).create(vals) if "create_product_product" not in self._context: template.with_context(create_from_tmpl=True).create_variant_ids() # This is needed to set given values to first variant after creation related_vals = {} if vals.get('barcode'): related_vals['barcode'] = vals['barcode'] if vals.get('default_code'): related_vals['default_code'] = vals['default_code'] if vals.get('standard_price'): related_vals['standard_price'] = vals['standard_price'] if vals.get('volume'): related_vals['volume'] = vals['volume'] if vals.get('weight'): related_vals['weight'] = vals['weight'] if related_vals: template.write(related_vals) return template @api.multi def write(self, vals): tools.image_resize_images(vals) res = super(ProductTemplate, self).write(vals) if 'attribute_line_ids' in vals or vals.get('active'): self.create_variant_ids() if 'active' in vals and not vals.get('active'): self.with_context(active_test=False).mapped('product_variant_ids').write({'active': vals.get('active')}) return res @api.multi def copy(self, default=None): # TDE FIXME: should probably be copy_data self.ensure_one() if default is None: default = {} if 'name' not in default: default['name'] = _("%s (copy)") % self.name return super(ProductTemplate, self).copy(default=default) @api.multi def name_get(self): return [(template.id, '%s%s' % (template.default_code and '[%s] ' % template.default_code or '', template.name)) for template in self] @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): # Only use the product.product heuristics if there is a search term and the domain # does not specify a match on `product.template` IDs. if not name or any(term[0] == 'id' for term in (args or [])): return super(ProductTemplate, self).name_search(name=name, args=args, operator=operator, limit=limit) Product = self.env['product.product'] templates = self.browse([]) while True: domain = templates and [('product_tmpl_id', 'not in', templates.ids)] or [] args = args if args is not None else [] products_ns = Product.name_search(name, args+domain, operator=operator) products = Product.browse([x[0] for x in products_ns]) templates |= products.mapped('product_tmpl_id') if (not products) or (limit and (len(templates) > limit)): break # re-apply product.template order + name_get return super(ProductTemplate, self).name_search( '', args=[('id', 'in', list(set(templates.ids)))], operator='ilike', limit=limit) @api.multi def price_compute(self, price_type, uom=False, currency=False, company=False): # TDE FIXME: delegate to template or not ? fields are reencoded here ... # compatibility about context keys used a bit everywhere in the code if not uom and self._context.get('uom'): uom = self.env['product.uom'].browse(self._context['uom']) if not currency and self._context.get('currency'): currency = self.env['res.currency'].browse(self._context['currency']) templates = self if price_type == 'standard_price': # standard_price field can only be seen by users in base.group_user # Thus, in order to compute the sale price from the cost for users not in this group # We fetch the standard price as the superuser templates = self.with_context(force_company=company and company.id or self._context.get('force_company', self.env.user.company_id.id)).sudo() prices = dict.fromkeys(self.ids, 0.0) for template in templates: prices[template.id] = template[price_type] or 0.0 if uom: prices[template.id] = template.uom_id._compute_price(prices[template.id], uom) # Convert from current user company currency to asked one # This is right cause a field cannot be in more than one currency if currency: prices[template.id] = template.currency_id.compute(prices[template.id], currency) return prices # compatibility to remove after v10 - DEPRECATED @api.model def _price_get(self, products, ptype='list_price'): return products.price_compute(ptype) @api.multi def create_variant_ids(self): Product = self.env["product.product"] AttributeValues = self.env['product.attribute.value'] for tmpl_id in self.with_context(active_test=False): # adding an attribute with only one value should not recreate product # write this attribute on every product to make sure we don't lose them variant_alone = tmpl_id.attribute_line_ids.filtered(lambda line: line.attribute_id.create_variant and len(line.value_ids) == 1).mapped('value_ids') for value_id in variant_alone: updated_products = tmpl_id.product_variant_ids.filtered(lambda product: value_id.attribute_id not in product.mapped('attribute_value_ids.attribute_id')) updated_products.write({'attribute_value_ids': [(4, value_id.id)]}) # iterator of n-uple of product.attribute.value *ids* variant_matrix = [ AttributeValues.browse(value_ids) for value_ids in itertools.product(*(line.value_ids.ids for line in tmpl_id.attribute_line_ids if line.value_ids[:1].attribute_id.create_variant)) ] # get the value (id) sets of existing variants existing_variants = {frozenset(variant.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant).ids) for variant in tmpl_id.product_variant_ids} # -> for each value set, create a recordset of values to create a # variant for if the value set isn't already a variant to_create_variants = [ value_ids for value_ids in variant_matrix if set(value_ids.ids) not in existing_variants ] # check product variants_to_activate = self.env['product.product'] variants_to_unlink = self.env['product.product'] for product_id in tmpl_id.product_variant_ids: if not product_id.active and product_id.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant) in variant_matrix: variants_to_activate |= product_id elif product_id.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant) not in variant_matrix: variants_to_unlink |= product_id if variants_to_activate: variants_to_activate.write({'active': True}) # create new product for variant_ids in to_create_variants: new_variant = Product.create({ 'product_tmpl_id': tmpl_id.id, 'attribute_value_ids': [(6, 0, variant_ids.ids)] }) # unlink or inactive product for variant in variants_to_unlink: try: with self._cr.savepoint(), tools.mute_logger('flectra.sql_db'): variant.unlink() # We catch all kind of exception to be sure that the operation doesn't fail. except (psycopg2.Error, except_orm): variant.write({'active': False}) pass return True