flectra/addons/product/models/product_template.py

455 lines
22 KiB
Python

# -*- 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