Skip to content

Commit 8bd8385

Browse files
Merge remote-tracking branch 'origin/19.0' into 19.0-mig-sale_mrp
2 parents 8165477 + 8e4730a commit 8bd8385

77 files changed

Lines changed: 5463 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cpq/README.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
===
2+
cpq
3+
===
4+
5+
This module provides the base functionality:
6+
7+
- Prevents the automatic creation of variants
8+
- Extends of product attributes to allow custom options
9+
- Custom options are propagated to variants, preventing the stock system from mixing them up
10+
- An Owl based UI to configure products (read: create variants)
11+
12+
Additional modules should almost always be installed:
13+
- cpq_sale for sale integration
14+
- cpq_mrp for dynamic BoM generation
15+
- cpq_account for accounts integration
16+

cpq/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import controllers
2+
from . import models

cpq/__manifest__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "cpq",
3+
"summary": "Dynamic Configure-Price-Quote-style generation of products",
4+
"version": "19.0.1.0.0",
5+
"author": "Glo Networks",
6+
"website": "https://github.com/GlodoUK/odoo-addons",
7+
"depends": ["product"],
8+
"data": [
9+
"security/ir.model.access.csv",
10+
"views/product_attribute.xml",
11+
"views/product_product.xml",
12+
"views/product_template.xml",
13+
],
14+
"assets": {
15+
"web.assets_backend": [
16+
"cpq/static/src/components/**/*.js",
17+
"cpq/static/src/components/**/*.xml",
18+
],
19+
},
20+
"license": "LGPL-3",
21+
}

cpq/controllers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import main

cpq/controllers/main.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from odoo.exceptions import UserError
2+
from odoo.http import Controller, request, route
3+
4+
5+
class ProductConfiguratorController(Controller):
6+
def _cpq_extract_from_combination(self, product_tmpl_id, combination):
7+
ptav_ids = request.env["product.template.attribute.value"].sudo()
8+
custom_dict = {}
9+
10+
valid_product_tmpl_ptav_ids = (
11+
product_tmpl_id.valid_product_template_attribute_line_ids.mapped(
12+
"product_template_value_ids"
13+
)
14+
)
15+
16+
for k, v in combination.items():
17+
ptav_id = valid_product_tmpl_ptav_ids.filtered(
18+
lambda v, k=k: v.id == int(k)
19+
)
20+
ptav_id.ensure_one()
21+
22+
if ptav_id.is_custom:
23+
custom_dict.update({ptav_id: v})
24+
25+
ptav_ids |= ptav_id
26+
27+
return (ptav_ids, custom_dict)
28+
29+
@route("/cpq/<int:product_tmpl_id>/data", type="jsonrpc", auth="user")
30+
def cpq_tmpl_data(
31+
self,
32+
product_tmpl_id,
33+
company_id=None,
34+
pricelist_id=None,
35+
ptav_ids=None,
36+
):
37+
product_tmpl_id = request.env["product.template"].browse(product_tmpl_id)
38+
if not product_tmpl_id.cpq_ok:
39+
raise UserError(request.env._("Not a CPQ enabled product!"))
40+
41+
return product_tmpl_id._cpq_get_combination_info()
42+
43+
@route("/cpq/<int:product_tmpl_id>/validate", type="jsonrpc", auth="user")
44+
def cpq_validate(
45+
self,
46+
product_tmpl_id,
47+
combination,
48+
):
49+
product_tmpl_id = request.env["product.template"].sudo().browse(product_tmpl_id)
50+
if not product_tmpl_id.cpq_ok:
51+
return {
52+
"valid": False,
53+
"msg": request.env._("Not CPQ Enabled!"),
54+
}
55+
56+
(ptav_ids, custom_dict) = self._cpq_extract_from_combination(
57+
product_tmpl_id, combination
58+
)
59+
60+
(variant_ok, msg) = product_tmpl_id._cpq_ensure_valid_values(
61+
ptav_ids,
62+
custom_dict,
63+
raise_on_invalidity=False,
64+
validate_only=True,
65+
)
66+
67+
return {
68+
"valid": variant_ok,
69+
"errors": msg,
70+
}
71+
72+
@route("/cpq/<int:product_tmpl_id>/configure", type="jsonrpc", auth="user")
73+
def cpq_configure(
74+
self,
75+
product_tmpl_id,
76+
combination,
77+
):
78+
product_tmpl_id = request.env["product.template"].sudo().browse(product_tmpl_id)
79+
if not product_tmpl_id.cpq_ok:
80+
raise UserError(request.env._("Not a CPQ enabled product!"))
81+
82+
(ptav_ids, custom_dict) = self._cpq_extract_from_combination(
83+
product_tmpl_id, combination
84+
)
85+
86+
variant_id = product_tmpl_id._cpq_get_create_variant(
87+
ptav_ids,
88+
custom_dict,
89+
)
90+
91+
return {
92+
"product_tmpl_id": product_tmpl_id.id,
93+
"product_id": variant_id.id,
94+
}

cpq/models/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from . import product_attribute
2+
from . import product_template
3+
from . import product_product

cpq/models/product_attribute.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
from odoo import api, fields, models
2+
3+
4+
class ProductAttribute(models.Model):
5+
_inherit = "product.attribute"
6+
_order = "sequence"
7+
8+
cpq_propagate_to_variant = fields.Boolean(
9+
"Propagate To Variant",
10+
default=True,
11+
)
12+
13+
def copy_data(self, default=None):
14+
default = dict(default or {})
15+
vals_list = super().copy_data(default=default)
16+
if "name" not in default:
17+
for attribute, vals in zip(self, vals_list, strict=False):
18+
vals["name"] = self.env._("%s (copy)", attribute.name)
19+
return vals_list
20+
21+
22+
class ProductAttributeValue(models.Model):
23+
_inherit = "product.attribute.value"
24+
25+
cpq_custom_type = fields.Selection(
26+
[
27+
("integer", "Integer"),
28+
("float", "Float"),
29+
("char", "Text"),
30+
],
31+
"Configurable Custom Type",
32+
)
33+
34+
def copy_data(self, default=None):
35+
default = dict(default or {})
36+
vals_list = super().copy_data(default=default)
37+
if "name" not in default:
38+
for value, vals in zip(self, vals_list, strict=False):
39+
vals["name"] = self.env._("%s (copy)", value.name)
40+
return vals_list
41+
42+
def _cpq_cast_custom(self, value):
43+
"""
44+
Cast the stored custom_value into the real value.
45+
i.e. custom_value may store a int, which we need to cast into an Odoo
46+
record
47+
"""
48+
self.ensure_one()
49+
50+
if not self.is_custom or not self.cpq_custom_type:
51+
return value
52+
53+
method = f"_cpq_cast_custom_{self.cpq_custom_type}"
54+
return getattr(self, method)(value)
55+
56+
def _cpq_cast_custom_integer(self, value):
57+
return self._cpq_sanitise_custom_integer(value)
58+
59+
def _cpq_cast_custom_float(self, value):
60+
return self._cpq_sanitise_custom_float(value)
61+
62+
def _cpq_cast_custom_char(self, value):
63+
return self._cpq_sanitise_custom_char(value)
64+
65+
def _cpq_sanitise_custom(self, value):
66+
self.ensure_one()
67+
68+
if not self.is_custom or not self.cpq_custom_type:
69+
return value
70+
71+
method = f"_cpq_sanitise_custom_{self.cpq_custom_type}"
72+
return getattr(self, method)(value)
73+
74+
@api.model
75+
def _cpq_sanitise_custom_integer(self, value):
76+
return int(value)
77+
78+
@api.model
79+
def _cpq_sanitise_custom_float(self, value):
80+
return float(value)
81+
82+
@api.model
83+
def _cpq_sanitise_custom_char(self, value):
84+
if not value:
85+
return ""
86+
return value.strip()
87+
88+
def _cpq_validate_custom(self, value):
89+
self.ensure_one()
90+
91+
if not self.is_custom or not self.cpq_custom_type:
92+
return True
93+
94+
method = f"_cpq_validate_custom_{self.cpq_custom_type}"
95+
return getattr(self, method)(value)
96+
97+
@api.model
98+
def _cpq_validate_custom_integer(self, value):
99+
if isinstance(value, bool):
100+
return False
101+
try:
102+
int(value)
103+
return True
104+
except (ValueError, TypeError):
105+
return False
106+
107+
@api.model
108+
def _cpq_validate_custom_float(self, value):
109+
if isinstance(value, bool):
110+
return False
111+
try:
112+
float(value)
113+
return True
114+
except (ValueError, TypeError):
115+
return False
116+
117+
@api.model
118+
def _cpq_validate_custom_char(self, value):
119+
return isinstance(value, str) and value.strip()
120+
121+
122+
class ProductTemplateAttributeLine(models.Model):
123+
_inherit = "product.template.attribute.line"
124+
125+
cpq_propagate_to_variant = fields.Boolean(
126+
related="attribute_id.cpq_propagate_to_variant", store=True
127+
)
128+
129+
def _cpq_get_combination_info(self):
130+
self.ensure_one()
131+
i = self
132+
133+
return {
134+
"id": i.id,
135+
"name": i.display_name,
136+
"display_type": i.attribute_id.display_type,
137+
"ptav_ids": [
138+
ptav_id._cpq_get_combination_info()
139+
for ptav_id in i.product_template_value_ids.filtered(
140+
lambda attr_line: attr_line.ptav_active
141+
)
142+
],
143+
}
144+
145+
146+
class ProductTemplateAttributeValue(models.Model):
147+
_inherit = "product.template.attribute.value"
148+
149+
cpq_propagate_to_variant = fields.Boolean(
150+
related="attribute_id.cpq_propagate_to_variant"
151+
)
152+
cpq_custom_type = fields.Selection(
153+
related="product_attribute_value_id.cpq_custom_type"
154+
)
155+
156+
def _cpq_get_combination_info(self):
157+
self.ensure_one()
158+
ptav_id = self
159+
160+
return {
161+
"id": ptav_id.id,
162+
"name": ptav_id.name,
163+
"html_color": ptav_id.html_color,
164+
"is_custom": ptav_id.is_custom,
165+
"price_extra": 0.0,
166+
"excluded": False,
167+
"cpq_custom_type": ptav_id.cpq_custom_type,
168+
}

0 commit comments

Comments
 (0)