product.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. # -*- coding: utf-8 -*-
  2. from odoo.tools.misc import formatLang
  3. from odoo.exceptions import ValidationError
  4. from odoo import models, fields, api, tools, _
  5. from lxml import etree
  6. class ProductTemplate(models.Model):
  7. _inherit = 'product.template'
  8. config_ok = fields.Boolean(string='Can be Configured')
  9. config_line_ids = fields.One2many(
  10. comodel_name='product.config.line',
  11. inverse_name='product_tmpl_id',
  12. string="Attribute Dependencies"
  13. )
  14. config_image_ids = fields.One2many(
  15. comodel_name='product.config.image',
  16. inverse_name='product_tmpl_id',
  17. string='Configuration Images'
  18. )
  19. config_step_line_ids = fields.One2many(
  20. comodel_name='product.config.step.line',
  21. inverse_name='product_tmpl_id',
  22. string='Configuration Lines'
  23. )
  24. def flatten_val_ids(self, value_ids):
  25. """ Return a list of value_ids from a list with a mix of ids
  26. and list of ids (multiselection)
  27. :param value_ids: list of value ids or mix of ids and list of ids
  28. (e.g: [1, 2, 3, [4, 5, 6]])
  29. :returns: flattened list of ids ([1, 2, 3, 4, 5, 6]) """
  30. flat_val_ids = set()
  31. for val in value_ids:
  32. if not val:
  33. continue
  34. if isinstance(val, list):
  35. flat_val_ids |= set(val)
  36. elif isinstance(val, int):
  37. flat_val_ids.add(val)
  38. return list(flat_val_ids)
  39. def get_open_step_lines(self, value_ids):
  40. """
  41. Returns a recordset of configuration step lines open for access given
  42. the configuration passed through value_ids
  43. e.g: Field A and B from configuration step 2 depend on Field C
  44. from configuration step 1. Since fields A and B require action from
  45. the previous step, configuration step 2 is deemed closed and redirect
  46. is made for configuration step 1.
  47. :param value_ids: list of value.ids representing the
  48. current configuration
  49. :returns: recordset of accesible configuration steps
  50. """
  51. open_step_lines = self.env['product.config.step.line']
  52. for cfg_line in self.config_step_line_ids:
  53. for attr_line in cfg_line.attribute_line_ids:
  54. available_vals = any(
  55. val for val in attr_line.value_ids if
  56. self.value_available(val.id, value_ids)
  57. )
  58. # TODO: Refactor when adding restriction to custom values
  59. if available_vals or attr_line.custom:
  60. open_step_lines |= cfg_line
  61. break
  62. return open_step_lines.sorted()
  63. def get_adjacent_steps(self, value_ids, active_step_line_id=None):
  64. """Returns the previous and next steps given the configuration passed
  65. via value_ids and the active step line passed via cfg_step_line_id.
  66. If there is no open step return empty dictionary"""
  67. config_step_lines = self.config_step_line_ids
  68. if not config_step_lines:
  69. return {}
  70. active_cfg_step_line = config_step_lines.filtered(
  71. lambda l: l.id == active_step_line_id)
  72. open_step_lines = self.get_open_step_lines(value_ids)
  73. if not active_cfg_step_line:
  74. return {'next_step': open_step_lines[0]}
  75. nr_steps = len(open_step_lines)
  76. adjacent_steps = {}
  77. for i, cfg_step in enumerate(open_step_lines):
  78. if cfg_step == active_cfg_step_line:
  79. adjacent_steps.update({
  80. 'next_step':
  81. None if i + 1 == nr_steps else open_step_lines[i + 1],
  82. 'previous_step': None if i == 0 else open_step_lines[i - 1]
  83. })
  84. return adjacent_steps
  85. def formatPrices(self, prices=None, dp='Product Price'):
  86. if prices is None:
  87. prices = {}
  88. dp = None
  89. prices['taxes'] = formatLang(
  90. self.env, prices['taxes'], monetary=True, dp=dp)
  91. prices['total'] = formatLang(
  92. self.env, prices['total'], monetary=True, dp=dp)
  93. prices['vals'] = [
  94. (v[0], v[1], formatLang(self.env, v[2], monetary=True, dp=dp))
  95. for v in prices['vals']
  96. ]
  97. return prices
  98. @api.multi
  99. def _get_option_values(self, value_ids, pricelist):
  100. """Return only attribute values that have products attached with a
  101. price set to them"""
  102. value_obj = self.env['product.attribute.value'].with_context({
  103. 'pricelist': pricelist.id})
  104. values = value_obj.sudo().browse(value_ids).filtered(
  105. lambda x: x.product_id.price)
  106. return values
  107. @api.multi
  108. def get_components_prices(self, prices, value_ids,
  109. custom_values, pricelist):
  110. """Return prices of the components which make up the final
  111. configured variant"""
  112. vals = self._get_option_values(value_ids, pricelist)
  113. for val in vals:
  114. prices['vals'].append(
  115. (val.attribute_id.name,
  116. val.product_id.name,
  117. val.product_id.price)
  118. )
  119. product = val.product_id.with_context({'pricelist': pricelist.id})
  120. product_prices = product.taxes_id.sudo().compute_all(
  121. price_unit=product.price,
  122. currency=pricelist.currency_id,
  123. quantity=1,
  124. product=self,
  125. partner=self.env.user.partner_id
  126. )
  127. total_included = product_prices['total_included']
  128. taxes = total_included - product_prices['total_excluded']
  129. prices['taxes'] += taxes
  130. prices['total'] += total_included
  131. return prices
  132. @api.multi
  133. def get_cfg_price(self, value_ids, custom_values=None,
  134. pricelist_id=None, formatLang=False):
  135. """ Computes the price of the configured product based on the configuration
  136. passed in via value_ids and custom_values
  137. :param value_ids: list of attribute value_ids
  138. :param custom_values: dictionary of custom attribute values
  139. :param pricelist_id: id of pricelist to use for price computation
  140. :param formatLang: boolean for formatting price dictionary
  141. :returns: dictionary of prices per attribute and total price"""
  142. self.ensure_one()
  143. if custom_values is None:
  144. custom_values = {}
  145. if not pricelist_id:
  146. pricelist = self.env.user.partner_id.property_product_pricelist
  147. pricelist_id = pricelist.id
  148. else:
  149. pricelist = self.env['product.pricelist'].browse(pricelist_id)
  150. currency = pricelist.currency_id
  151. product = self.with_context({'pricelist': pricelist.id})
  152. base_prices = product.taxes_id.sudo().compute_all(
  153. price_unit=product.price,
  154. currency=pricelist.currency_id,
  155. quantity=1,
  156. product=product,
  157. partner=self.env.user.partner_id
  158. )
  159. total_included = base_prices['total_included']
  160. total_excluded = base_prices['total_excluded']
  161. prices = {
  162. 'vals': [
  163. ('Base', self.name, total_excluded)
  164. ],
  165. 'total': total_included,
  166. 'taxes': total_included - total_excluded,
  167. 'currency': currency.name
  168. }
  169. component_prices = self.get_components_prices(
  170. prices, value_ids, custom_values, pricelist)
  171. prices.update(component_prices)
  172. if formatLang:
  173. return self.formatPrices(prices)
  174. return prices
  175. @api.multi
  176. def search_variant(self, value_ids, custom_values=None):
  177. """ Searches product.variants with given value_ids and custom values
  178. given in the custom_values dict
  179. :param value_ids: list of product.attribute.values ids
  180. :param custom_values: dict {product.attribute.id: custom_value}
  181. :returns: product.product recordset of products matching domain
  182. """
  183. if custom_values is None:
  184. custom_values = {}
  185. attr_obj = self.env['product.attribute']
  186. for product_tmpl in self:
  187. domain = [('product_tmpl_id', '=', product_tmpl.id)]
  188. for value_id in value_ids:
  189. domain.append(('attribute_value_ids', '=', value_id))
  190. attr_search = attr_obj.search([
  191. ('search_ok', '=', True),
  192. ('custom_type', 'not in', attr_obj._get_nosearch_fields())
  193. ])
  194. for attr_id, value in custom_values.iteritems():
  195. if attr_id not in attr_search.ids:
  196. domain.append(
  197. ('value_custom_ids.attribute_id', '!=', int(attr_id)))
  198. else:
  199. domain.append(
  200. ('value_custom_ids.attribute_id', '=', int(attr_id)))
  201. domain.append(('value_custom_ids.value', '=', value))
  202. products = self.env['product.product'].search(domain)
  203. return products
  204. def get_config_image_obj(self, value_ids, size=None):
  205. """
  206. Retreive the image object that most closely resembles the configuration
  207. code sent via value_ids list
  208. The default image object is the template (self)
  209. :param value_ids: a list representing the ids of attribute values
  210. (usually stored in the user's session)
  211. :returns: path to the selected image
  212. """
  213. # TODO: Also consider custom values for image change
  214. img_obj = self
  215. max_matches = 0
  216. value_ids = self.flatten_val_ids(value_ids)
  217. for line in self.config_image_ids:
  218. matches = len(set(line.value_ids.ids) & set(value_ids))
  219. if matches > max_matches:
  220. img_obj = line
  221. max_matches = matches
  222. return img_obj
  223. @api.multi
  224. def encode_custom_values(self, custom_values):
  225. """ Hook to alter the values of the custom values before creating or writing
  226. :param custom_values: dict {product.attribute.id: custom_value}
  227. :returns: list of custom values compatible with write and create
  228. """
  229. attr_obj = self.env['product.attribute']
  230. binary_attribute_ids = attr_obj.search([
  231. ('custom_type', '=', 'binary')]).ids
  232. custom_lines = []
  233. for key, val in custom_values.iteritems():
  234. custom_vals = {'attribute_id': key}
  235. # TODO: Is this extra check neccesairy as we already make
  236. # the check in validate_configuration?
  237. attr_obj.browse(key).validate_custom_val(val)
  238. if key in binary_attribute_ids:
  239. custom_vals.update({
  240. 'attachment_ids': [(6, 0, val.ids)]
  241. })
  242. else:
  243. custom_vals.update({'value': val})
  244. custom_lines.append((0, 0, custom_vals))
  245. return custom_lines
  246. @api.multi
  247. def get_variant_vals(self, value_ids, custom_values=None, **kwargs):
  248. """ Hook to alter the values of the product variant before creation
  249. :param value_ids: list of product.attribute.values ids
  250. :param custom_values: dict {product.attribute.id: custom_value}
  251. :returns: dictionary of values to pass to product.create() method
  252. """
  253. self.ensure_one()
  254. image = self.get_config_image_obj(value_ids).image
  255. all_images = tools.image_get_resized_images(
  256. image, avoid_resize_medium=True)
  257. vals = {
  258. 'product_tmpl_id': self.id,
  259. 'attribute_value_ids': [(6, 0, value_ids)],
  260. 'taxes_id': [(6, 0, self.taxes_id.ids)],
  261. 'image': image,
  262. 'image_variant': image,
  263. 'image_medium': all_images['image_medium'],
  264. 'image_small': all_images['image_medium'],
  265. }
  266. if custom_values:
  267. vals.update({
  268. 'value_custom_ids': self.encode_custom_values(custom_values)
  269. })
  270. return vals
  271. @api.multi
  272. def create_variant(self, value_ids, custom_values=None):
  273. """ Creates a product.variant with the attributes passed via value_ids
  274. and custom_values
  275. :param value_ids: list of product.attribute.values ids
  276. :param custom_values: dict {product.attribute.id: custom_value}
  277. :returns: product.product recordset of products matching domain
  278. """
  279. if custom_values is None:
  280. custom_values = {}
  281. valid = self.validate_configuration(value_ids, custom_values)
  282. if not valid:
  283. raise ValidationError(_('Invalid Configuration'))
  284. # TODO: Add all custom values to order line instead of product
  285. vals = self.get_variant_vals(value_ids, custom_values)
  286. variant = self.env['product.product'].create(vals)
  287. return variant
  288. # TODO: Refactor so multiple values can be checked at once
  289. # also a better method for building the domain using the logical
  290. # operators is required
  291. @api.multi
  292. def value_available(self, attr_val_id, value_ids):
  293. """Determines whether the attr_value from the product_template
  294. is available for selection given the configuration ids and the
  295. dependencies set on the product template
  296. :param attr_val_id: int of product.attribute.value object
  297. :param value_ids: list of attribute value ids
  298. :returns: True or False representing availability
  299. """
  300. self.ensure_one()
  301. config_lines = self.config_line_ids.filtered(
  302. lambda l: attr_val_id in l.value_ids.ids
  303. )
  304. domains = config_lines.mapped('domain_id').compute_domain()
  305. for domain in domains:
  306. if domain[1] == 'in':
  307. if not set(domain[2]) & set(value_ids):
  308. return False
  309. else:
  310. if set(domain[2]) & set(value_ids):
  311. return False
  312. return True
  313. @api.multi
  314. def validate_configuration(self, value_ids, custom_vals=None, final=True):
  315. """ Verifies if the configuration values passed via value_ids and custom_vals
  316. are valid
  317. :param value_ids: list of attribute value ids
  318. :param custom_vals: custom values dict {attr_id: custom_val}
  319. :param final: boolean marker to check required attributes.
  320. pass false to check non-final configurations
  321. :returns: Error dict with reason of validation failure
  322. or True
  323. """
  324. # TODO: Raise ConfigurationError with reason
  325. # Check if required values are missing for final configuration
  326. if custom_vals is None:
  327. custom_vals = {}
  328. for line in self.attribute_line_ids:
  329. # Validate custom values
  330. attr = line.attribute_id
  331. if attr.id in custom_vals:
  332. attr.validate_custom_val(custom_vals[attr.id])
  333. if final:
  334. common_vals = set(value_ids) & set(line.value_ids.ids)
  335. custom_val = custom_vals.get(attr.id)
  336. if line.required and not common_vals and not custom_val:
  337. # TODO: Verify custom value type to be correct
  338. return False
  339. # Check if all all the values passed are not restricted
  340. for val in value_ids:
  341. available = self.value_available(
  342. val, [v for v in value_ids if v != val])
  343. if not available:
  344. return False
  345. # Check if custom values are allowed
  346. custom_attr_ids = self.attribute_line_ids.filtered(
  347. 'custom').mapped('attribute_id').ids
  348. if not set(custom_vals.keys()) <= set(custom_attr_ids):
  349. return False
  350. # Check if there are multiple values passed for non-multi attributes
  351. mono_attr_lines = self.attribute_line_ids.filtered(
  352. lambda l: not l.multi)
  353. for line in mono_attr_lines:
  354. if len(set(line.value_ids.ids) & set(value_ids)) > 1:
  355. return False
  356. return True
  357. @api.multi
  358. def toggle_config(self):
  359. for record in self:
  360. record.config_ok = not record.config_ok
  361. # Override name_search delegation to variants introduced by Odony
  362. # TODO: Verify this is still a problem in v9
  363. @api.model
  364. def name_search(self, name='', args=None, operator='ilike', limit=100):
  365. return super(models.Model, self).name_search(name=name,
  366. args=args,
  367. operator=operator,
  368. limit=limit)
  369. @api.multi
  370. def create_variant_ids(self):
  371. """ Prevent configurable products from creating variants as these serve
  372. only as a template for the product configurator"""
  373. for product in self:
  374. if self.config_ok:
  375. return None
  376. return super(ProductTemplate, self).create_variant_ids()
  377. @api.multi
  378. def unlink(self):
  379. """ Prevent the removal of configurable product templates
  380. from variants"""
  381. for template in self:
  382. variant_unlink = self.env.context.get('unlink_from_variant', False)
  383. if template.config_ok and variant_unlink:
  384. self -= template
  385. res = super(ProductTemplate, self).unlink()
  386. return res
  387. class ProductProduct(models.Model):
  388. _inherit = 'product.product'
  389. _rec_name = 'config_name'
  390. def _get_conversions_dict(self):
  391. conversions = {
  392. 'float': float,
  393. 'int': int
  394. }
  395. return conversions
  396. @api.multi
  397. def _compute_product_price_extra(self):
  398. """Compute price of configurable products as sum
  399. of products related to attribute values picked"""
  400. products = self.filtered(lambda x: not x.config_ok)
  401. configurable_products = self - products
  402. if products:
  403. prices = super(ProductProduct, self)._compute_product_price_extra()
  404. conversions = self._get_conversions_dict()
  405. for product in configurable_products:
  406. lst_price = product.product_tmpl_id.lst_price
  407. value_ids = product.attribute_value_ids.ids
  408. # TODO: Merge custom values from products with cfg session
  409. # and use same method to retrieve parsed custom val dict
  410. custom_vals = {}
  411. for val in product.value_custom_ids:
  412. custom_type = val.attribute_id.custom_type
  413. if custom_type in conversions:
  414. try:
  415. custom_vals[val.attribute_id.id] = conversions[
  416. custom_type](val.value)
  417. except:
  418. raise ValidationError(
  419. _("Could not convert custom value '%s' to '%s' on "
  420. "product variant: '%s'" % (val.value,
  421. custom_type,
  422. product.display_name))
  423. )
  424. else:
  425. custom_vals[val.attribute_id.id] = val.value
  426. prices = product.product_tmpl_id.get_cfg_price(
  427. value_ids, custom_vals)
  428. product.price_extra = prices['total'] - prices['taxes'] - lst_price
  429. config_name = fields.Char(
  430. string="Name",
  431. size=256,
  432. compute='_compute_name',
  433. )
  434. value_custom_ids = fields.One2many(
  435. comodel_name='product.attribute.value.custom',
  436. inverse_name='product_id',
  437. string='Custom Values',
  438. readonly=True
  439. )
  440. @api.multi
  441. def _check_attribute_value_ids(self):
  442. """ Removing multi contraint attribute to enable multi selection. """
  443. return True
  444. _constraints = [
  445. (_check_attribute_value_ids, None, ['attribute_value_ids'])
  446. ]
  447. @api.model
  448. def fields_view_get(self, view_id=None, view_type='form',
  449. toolbar=False, submenu=False):
  450. """ For configurable products switch the name field with the config_name
  451. so as to keep the view intact in whatever form it is at the moment
  452. of execution and not duplicate the original just for the sole
  453. purpose of displaying the proper name"""
  454. res = super(ProductProduct, self).fields_view_get(
  455. view_id=view_id, view_type=view_type,
  456. toolbar=toolbar, submenu=submenu
  457. )
  458. if self.env.context.get('default_config_ok'):
  459. xml_view = etree.fromstring(res['arch'])
  460. xml_name = xml_view.xpath("//field[@name='name']")
  461. xml_label = xml_view.xpath("//label[@for='name']")
  462. if xml_name:
  463. xml_name[0].attrib['name'] = 'config_name'
  464. if xml_label:
  465. xml_label[0].attrib['for'] = 'config_name'
  466. view_obj = self.env['ir.ui.view']
  467. xarch, xfields = view_obj.postprocess_and_fields(self._name,
  468. xml_view,
  469. view_id)
  470. res['arch'] = xarch
  471. res['fields'] = xfields
  472. return res
  473. # TODO: Implement naming method for configured products
  474. # TODO: Provide a field with custom name in it that defaults to a name
  475. # pattern
  476. def get_config_name(self):
  477. return self.name
  478. @api.multi
  479. def unlink(self):
  480. """ Signal unlink from product variant through context so
  481. removal can be stopped for configurable templates """
  482. ctx = dict(self.env.context, unlink_from_variant=True)
  483. self.env.context = ctx
  484. return super(ProductProduct, self).unlink()
  485. @api.multi
  486. def _compute_name(self):
  487. """ Compute the name of the configurable products and use template
  488. name for others"""
  489. for product in self:
  490. if product.config_ok:
  491. product.config_name = product.get_config_name()
  492. else:
  493. product.config_name = product.name