1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
|
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import itertools
import logging
from collections import defaultdict
from odoo import api, fields, models, tools, _, SUPERUSER_ID
from odoo.exceptions import ValidationError, RedirectWarning, UserError
from odoo.osv import expression
_logger = logging.getLogger(__name__)
class ProductTemplate(models.Model):
_name = "product.template"
_inherit = ['mail.thread', 'mail.activity.mixin', 'image.mixin']
_description = "Product Template"
_order = "name"
@tools.ormcache()
def _get_default_category_id(self):
# Deletion forbidden (at least through unlink)
return self.env.ref('product.product_category_all')
@tools.ormcache()
def _get_default_uom_id(self):
# Deletion forbidden (at least through unlink)
return self.env.ref('uom.product_uom_unit')
def _read_group_categ_id(self, categories, domain, order):
category_ids = self.env.context.get('default_categ_id')
if not category_ids and self.env.context.get('group_expand'):
category_ids = categories._search([], order=order, access_rights_uid=SUPERUSER_ID)
return categories.browse(category_ids)
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)
description_purchase = fields.Text(
'Purchase Description', translate=True)
description_sale = fields.Text(
'Sales 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 storable product is a product for which you manage stock. The Inventory app has to be installed.\n'
'A consumable product is a product for which stock is not managed.\n'
'A service is a non-material product you provide.')
categ_id = fields.Many2one(
'product.category', 'Product Category',
change_default=True, default=_get_default_category_id, group_expand='_read_group_categ_id',
required=True, help="Select category for the current product")
currency_id = fields.Many2one(
'res.currency', 'Currency', compute='_compute_currency_id')
cost_currency_id = fields.Many2one(
'res.currency', 'Cost Currency', compute='_compute_cost_currency_id')
# price fields
# price: total template price, context dependent (partner, pricelist, quantity)
price = fields.Float(
'Price', compute='_compute_template_price', inverse='_set_template_price',
digits='Product Price')
# list_price: catalog price, user defined
list_price = fields.Float(
'Sales Price', default=1.0,
digits='Product Price',
help="Price at which the product is sold to customers.")
# lst_price: catalog price for template, but including extra for variants
lst_price = fields.Float(
'Public Price', related='list_price', readonly=False,
digits='Product Price')
standard_price = fields.Float(
'Cost', compute='_compute_standard_price',
inverse='_set_standard_price', search='_search_standard_price',
digits='Product Price', groups="base.group_user",
help="""In Standard Price & AVCO: value of the product (automatically computed in AVCO).
In FIFO: value of the last unit that left the stock (automatically computed).
Used to value the product when the purchase cost is not known (e.g. inventory adjustment).
Used to compute margins on sale orders.""")
volume = fields.Float(
'Volume', compute='_compute_volume', inverse='_set_volume', digits='Volume', store=True)
volume_uom_name = fields.Char(string='Volume unit of measure label', compute='_compute_volume_uom_name')
weight = fields.Float(
'Weight', compute='_compute_weight', digits='Stock Weight',
inverse='_set_weight', store=True)
weight_uom_name = fields.Char(string='Weight unit of measure label', compute='_compute_weight_uom_name')
sale_ok = fields.Boolean('Can be Sold', default=True)
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(
'uom.uom', 'Unit of Measure',
default=_get_default_uom_id, required=True,
help="Default unit of measure used for all stock operations.")
uom_name = fields.Char(string='Unit of Measure Name', related='uom_id.name', readonly=True)
uom_po_id = fields.Many2one(
'uom.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 as the default unit of measure.")
company_id = fields.Many2one(
'res.company', 'Company', 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', depends_context=('company',), help="Define vendor pricelists.")
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.template.attribute.line', 'product_tmpl_id', 'Product Attributes', copy=True)
valid_product_template_attribute_line_ids = fields.Many2many('product.template.attribute.line',
compute="_compute_valid_product_template_attribute_line_ids", string='Valid Product Attribute Lines', help="Technical compute")
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', compute='_compute_barcode', inverse='_set_barcode', search='_search_barcode')
default_code = fields.Char(
'Internal Reference', compute='_compute_default_code',
inverse='_set_default_code', store=True)
pricelist_item_count = fields.Integer("Number of price rules", compute="_compute_item_count")
can_image_1024_be_zoomed = fields.Boolean("Can Image 1024 be zoomed", compute='_compute_can_image_1024_be_zoomed', store=True)
has_configurable_attributes = fields.Boolean("Is a configurable product", compute='_compute_has_configurable_attributes', store=True)
def _compute_item_count(self):
for template in self:
# Pricelist item count counts the rules applicable on current template or on its variants.
template.pricelist_item_count = template.env['product.pricelist.item'].search_count([
'|', ('product_tmpl_id', '=', template.id), ('product_id', 'in', template.product_variant_ids.ids)])
@api.depends('image_1920', 'image_1024')
def _compute_can_image_1024_be_zoomed(self):
for template in self:
template.can_image_1024_be_zoomed = template.image_1920 and tools.is_image_size_above(template.image_1920, template.image_1024)
@api.depends('attribute_line_ids', 'attribute_line_ids.value_ids', 'attribute_line_ids.attribute_id.create_variant')
def _compute_has_configurable_attributes(self):
"""A product is considered configurable if:
- It has dynamic attributes
- It has any attribute line with at least 2 attribute values configured
"""
for product in self:
product.has_configurable_attributes = product.has_dynamic_attributes() or any(len(ptal.value_ids) >= 2 for ptal in product.attribute_line_ids)
@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.depends('company_id')
def _compute_currency_id(self):
main_company = self.env['res.company']._get_main_company()
for template in self:
template.currency_id = template.company_id.sudo().currency_id.id or main_company.currency_id.id
@api.depends_context('company')
def _compute_cost_currency_id(self):
self.cost_currency_id = self.env.company.currency_id.id
def _compute_template_price(self):
prices = self._compute_template_price_no_inverse()
for template in self:
template.price = prices.get(template.id, 0.0)
def _compute_template_price_no_inverse(self):
"""The _compute_template_price writes the 'list_price' field with an inverse method
This method allows computing the price without writing the 'list_price'
"""
prices = {}
pricelist_id_or_name = self._context.get('pricelist')
if pricelist_id_or_name:
pricelist = None
partner = self.env.context.get('partner')
quantity = self.env.context.get('quantity', 1.0)
# Support context pricelists specified as list, display_name or ID for compatibility
if isinstance(pricelist_id_or_name, list):
pricelist_id_or_name = pricelist_id_or_name[0]
if isinstance(pricelist_id_or_name, str):
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, int):
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)
return prices
def _set_template_price(self):
if self._context.get('uom'):
for template in self:
value = self.env['uom.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_context('company')
@api.depends('product_variant_ids', 'product_variant_ids.standard_price')
def _compute_standard_price(self):
# Depends on force_company context because standard_price is company_dependent
# on the product_product
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
def _set_standard_price(self):
for template in self:
if len(template.product_variant_ids) == 1:
template.product_variant_ids.standard_price = template.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
def _set_volume(self):
for template in self:
if len(template.product_variant_ids) == 1:
template.product_variant_ids.volume = template.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):
self.is_product_variant = False
@api.depends('product_variant_ids.barcode')
def _compute_barcode(self):
self.barcode = False
for template in self:
if len(template.product_variant_ids) == 1:
template.barcode = template.product_variant_ids.barcode
def _search_barcode(self, operator, value):
templates = self.with_context(active_test=False).search([('product_variant_ids.barcode', operator, value)])
return [('id', 'in', templates.ids)]
def _set_barcode(self):
if len(self.product_variant_ids) == 1:
self.product_variant_ids.barcode = self.barcode
@api.model
def _get_weight_uom_id_from_ir_config_parameter(self):
""" Get the unit of measure to interpret the `weight` field. By default, we considerer
that weights are expressed in kilograms. Users can configure to express them in pounds
by adding an ir.config_parameter record with "product.product_weight_in_lbs" as key
and "1" as value.
"""
product_weight_in_lbs_param = self.env['ir.config_parameter'].sudo().get_param('product.weight_in_lbs')
if product_weight_in_lbs_param == '1':
return self.env.ref('uom.product_uom_lb')
else:
return self.env.ref('uom.product_uom_kgm')
@api.model
def _get_length_uom_id_from_ir_config_parameter(self):
""" Get the unit of measure to interpret the `length`, 'width', 'height' field.
By default, we considerer that length are expressed in meters. Users can configure
to express them in feet by adding an ir.config_parameter record with "product.volume_in_cubic_feet"
as key and "1" as value.
"""
product_length_in_feet_param = self.env['ir.config_parameter'].sudo().get_param('product.volume_in_cubic_feet')
if product_length_in_feet_param == '1':
return self.env.ref('uom.product_uom_foot')
else:
return self.env.ref('uom.product_uom_meter')
@api.model
def _get_volume_uom_id_from_ir_config_parameter(self):
""" Get the unit of measure to interpret the `volume` field. By default, we consider
that volumes are expressed in cubic meters. Users can configure to express them in cubic feet
by adding an ir.config_parameter record with "product.volume_in_cubic_feet" as key
and "1" as value.
"""
product_length_in_feet_param = self.env['ir.config_parameter'].sudo().get_param('product.volume_in_cubic_feet')
if product_length_in_feet_param == '1':
return self.env.ref('uom.product_uom_cubic_foot')
else:
return self.env.ref('uom.product_uom_cubic_meter')
@api.model
def _get_weight_uom_name_from_ir_config_parameter(self):
return self._get_weight_uom_id_from_ir_config_parameter().display_name
@api.model
def _get_length_uom_name_from_ir_config_parameter(self):
return self._get_length_uom_id_from_ir_config_parameter().display_name
@api.model
def _get_volume_uom_name_from_ir_config_parameter(self):
return self._get_volume_uom_id_from_ir_config_parameter().display_name
def _compute_weight_uom_name(self):
self.weight_uom_name = self._get_weight_uom_name_from_ir_config_parameter()
def _compute_volume_uom_name(self):
self.volume_uom_name = self._get_volume_uom_name_from_ir_config_parameter()
def _set_weight(self):
for template in self:
if len(template.product_variant_ids) == 1:
template.product_variant_ids.weight = template.weight
@api.depends('product_variant_ids.product_tmpl_id')
def _compute_product_variant_count(self):
for template in self:
# do not pollute variants to be prefetched when counting variants
template.product_variant_count = len(template.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 = False
def _set_default_code(self):
for template in self:
if len(template.product_variant_ids) == 1:
template.product_variant_ids.default_code = template.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
else:
p.packaging_ids = False
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(_('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.onchange('uom_po_id')
def _onchange_uom(self):
if self.uom_id and self.uom_po_id and self.uom_id.category_id != self.uom_po_id.category_id:
self.uom_po_id = self.uom_id
@api.onchange('type')
def _onchange_type(self):
# Do nothing but needed for inheritance
return {}
@api.model_create_multi
def create(self, vals_list):
''' Store the initial standard price in order to be able to retrieve the cost of a product template for a given date'''
templates = super(ProductTemplate, self).create(vals_list)
if "create_product_product" not in self._context:
templates._create_variant_ids()
# This is needed to set given values to first variant after creation
for template, vals in zip(templates, vals_list):
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']
# Please do forward port
if vals.get('packaging_ids'):
related_vals['packaging_ids'] = vals['packaging_ids']
if related_vals:
template.write(related_vals)
return templates
def write(self, vals):
if 'uom_id' in vals or 'uom_po_id' in vals:
uom_id = self.env['uom.uom'].browse(vals.get('uom_id')) or self.uom_id
uom_po_id = self.env['uom.uom'].browse(vals.get('uom_po_id')) or self.uom_po_id
if uom_id and uom_po_id and uom_id.category_id != uom_po_id.category_id:
vals['uom_po_id'] = uom_id.id
res = super(ProductTemplate, self).write(vals)
if 'attribute_line_ids' in vals or (vals.get('active') and len(self.product_variant_ids) == 0):
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')})
if 'image_1920' in vals:
self.env['product.product'].invalidate_cache(fnames=[
'image_1920',
'image_1024',
'image_512',
'image_256',
'image_128',
'can_image_1024_be_zoomed',
])
return res
@api.returns('self', lambda value: value.id)
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)
def name_get(self):
# Prefetch the fields used by the `name_get`, so `browse` doesn't fetch other fields
self.browse(self.ids).read(['name', 'default_code'])
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, name_get_uid=None):
# 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, name_get_uid=name_get_uid)
Product = self.env['product.product']
templates = self.browse([])
domain_no_variant = [('product_variant_ids', '=', False)]
while True:
domain = templates and [('product_tmpl_id', 'not in', templates.ids)] or []
args = args if args is not None else []
products_ids = Product._name_search(name, args+domain, operator=operator, name_get_uid=name_get_uid)
products = Product.browse(products_ids)
new_templates = products.mapped('product_tmpl_id')
if new_templates & templates:
"""Product._name_search can bypass the domain we passed (search on supplier info).
If this happens, an infinite loop will occur."""
break
templates |= new_templates
current_round_templates = self.browse([])
if not products:
domain_template = args + domain_no_variant + (templates and [('id', 'not in', templates.ids)] or [])
template_ids = super(ProductTemplate, self)._name_search(name=name, args=domain_template, operator=operator, limit=limit, name_get_uid=name_get_uid)
current_round_templates |= self.browse(template_ids)
templates |= current_round_templates
if (not products and not current_round_templates) or (limit and (len(templates) > limit)):
break
searched_ids = set(templates.ids)
# some product.templates do not have product.products yet (dynamic variants configuration),
# we need to add the base _name_search to the results
# FIXME awa: this is really not performant at all but after discussing with the team
# we don't see another way to do it
if not limit or len(searched_ids) < limit:
searched_ids |= set(super(ProductTemplate, self)._name_search(
name,
args=args,
operator=operator,
limit=limit,
name_get_uid=name_get_uid))
# re-apply product.template order + name_get
return super(ProductTemplate, self)._name_search(
'', args=[('id', 'in', list(searched_ids))],
operator='ilike', limit=limit, name_get_uid=name_get_uid)
def open_pricelist_rules(self):
self.ensure_one()
domain = ['|',
('product_tmpl_id', '=', self.id),
('product_id', 'in', self.product_variant_ids.ids)]
return {
'name': _('Price Rules'),
'view_mode': 'tree,form',
'views': [(self.env.ref('product.product_pricelist_item_tree_view_from_product').id, 'tree'), (False, 'form')],
'res_model': 'product.pricelist.item',
'type': 'ir.actions.act_window',
'target': 'current',
'domain': domain,
'context': {
'default_product_tmpl_id': self.id,
'default_applied_on': '1_product',
'product_without_variants': self.product_variant_count == 1,
},
}
def price_compute(self, price_type, uom=False, currency=False, company=None):
# 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['uom.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_company(company).sudo()
if not company:
company = self.env.company
date = self.env.context.get('date') or fields.Date.today()
prices = dict.fromkeys(self.ids, 0.0)
for template in templates:
prices[template.id] = template[price_type] or 0.0
# yes, there can be attribute values for product template if it's not a variant YET
# (see field product.attribute create_variant)
if price_type == 'list_price' and self._context.get('current_attributes_price_extra'):
# we have a list of price_extra that comes from the attribute values, we need to sum all that
prices[template.id] += sum(self._context.get('current_attributes_price_extra'))
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._convert(prices[template.id], currency, company, date)
return prices
def _create_variant_ids(self):
self.flush()
Product = self.env["product.product"]
variants_to_create = []
variants_to_activate = Product
variants_to_unlink = Product
for tmpl_id in self:
lines_without_no_variants = tmpl_id.valid_product_template_attribute_line_ids._without_no_variant_attributes()
all_variants = tmpl_id.with_context(active_test=False).product_variant_ids.sorted(lambda p: (p.active, -p.id))
current_variants_to_create = []
current_variants_to_activate = Product
# 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
single_value_lines = lines_without_no_variants.filtered(lambda ptal: len(ptal.product_template_value_ids._only_active()) == 1)
if single_value_lines:
for variant in all_variants:
combination = variant.product_template_attribute_value_ids | single_value_lines.product_template_value_ids._only_active()
# Do not add single value if the resulting combination would
# be invalid anyway.
if (
len(combination) == len(lines_without_no_variants) and
combination.attribute_line_id == lines_without_no_variants
):
variant.product_template_attribute_value_ids = combination
# Set containing existing `product.template.attribute.value` combination
existing_variants = {
variant.product_template_attribute_value_ids: variant for variant in all_variants
}
# Determine which product variants need to be created based on the attribute
# configuration. If any attribute is set to generate variants dynamically, skip the
# process.
# Technical note: if there is no attribute, a variant is still created because
# 'not any([])' and 'set([]) not in set([])' are True.
if not tmpl_id.has_dynamic_attributes():
# Iterator containing all possible `product.template.attribute.value` combination
# The iterator is used to avoid MemoryError in case of a huge number of combination.
all_combinations = itertools.product(*[
ptal.product_template_value_ids._only_active() for ptal in lines_without_no_variants
])
# For each possible variant, create if it doesn't exist yet.
for combination_tuple in all_combinations:
combination = self.env['product.template.attribute.value'].concat(*combination_tuple)
if combination in existing_variants:
current_variants_to_activate += existing_variants[combination]
else:
current_variants_to_create.append({
'product_tmpl_id': tmpl_id.id,
'product_template_attribute_value_ids': [(6, 0, combination.ids)],
'active': tmpl_id.active,
})
if len(current_variants_to_create) > 1000:
raise UserError(_(
'The number of variants to generate is too high. '
'You should either not generate variants for each combination or generate them on demand from the sales order. '
'To do so, open the form view of attributes and change the mode of *Create Variants*.'))
variants_to_create += current_variants_to_create
variants_to_activate += current_variants_to_activate
else:
for variant in existing_variants.values():
is_combination_possible = self._is_combination_possible_by_config(
combination=variant.product_template_attribute_value_ids,
ignore_no_variant=True,
)
if is_combination_possible:
current_variants_to_activate += variant
variants_to_activate += current_variants_to_activate
variants_to_unlink += all_variants - current_variants_to_activate
if variants_to_activate:
variants_to_activate.write({'active': True})
if variants_to_create:
Product.create(variants_to_create)
if variants_to_unlink:
variants_to_unlink._unlink_or_archive()
# prefetched o2m have to be reloaded (because of active_test)
# (eg. product.template: product_variant_ids)
# We can't rely on existing invalidate_cache because of the savepoint
# in _unlink_or_archive.
self.flush()
self.invalidate_cache()
return True
def has_dynamic_attributes(self):
"""Return whether this `product.template` has at least one dynamic
attribute.
:return: True if at least one dynamic attribute, False otherwise
:rtype: bool
"""
self.ensure_one()
return any(a.create_variant == 'dynamic' for a in self.valid_product_template_attribute_line_ids.attribute_id)
@api.depends('attribute_line_ids.value_ids')
def _compute_valid_product_template_attribute_line_ids(self):
"""A product template attribute line is considered valid if it has at
least one possible value.
Those with only one value are considered valid, even though they should
not appear on the configurator itself (unless they have an is_custom
value to input), indeed single value attributes can be used to filter
products among others based on that attribute/value.
"""
for record in self:
record.valid_product_template_attribute_line_ids = record.attribute_line_ids.filtered(lambda ptal: ptal.value_ids)
def _get_possible_variants(self, parent_combination=None):
"""Return the existing variants that are possible.
For dynamic attributes, it will only return the variants that have been
created already.
If there are a lot of variants, this method might be slow. Even if there
aren't too many variants, for performance reasons, do not call this
method in a loop over the product templates.
Therefore this method has a very restricted reasonable use case and you
should strongly consider doing things differently if you consider using
this method.
:param parent_combination: combination from which `self` is an
optional or accessory product.
:type parent_combination: recordset `product.template.attribute.value`
:return: the existing variants that are possible.
:rtype: recordset of `product.product`
"""
self.ensure_one()
return self.product_variant_ids.filtered(lambda p: p._is_variant_possible(parent_combination))
def _get_attribute_exclusions(self, parent_combination=None, parent_name=None):
"""Return the list of attribute exclusions of a product.
:param parent_combination: the combination from which
`self` is an optional or accessory product. Indeed exclusions
rules on one product can concern another product.
:type parent_combination: recordset `product.template.attribute.value`
:param parent_name: the name of the parent product combination.
:type parent_name: str
:return: dict of exclusions
- exclusions: from this product itself
- parent_combination: ids of the given parent_combination
- parent_exclusions: from the parent_combination
- parent_product_name: the name of the parent product if any, used in the interface
to explain why some combinations are not available.
(e.g: Not available with Customizable Desk (Legs: Steel))
- mapped_attribute_names: the name of every attribute values based on their id,
used to explain in the interface why that combination is not available
(e.g: Not available with Color: Black)
"""
self.ensure_one()
parent_combination = parent_combination or self.env['product.template.attribute.value']
return {
'exclusions': self._complete_inverse_exclusions(self._get_own_attribute_exclusions()),
'parent_exclusions': self._get_parent_attribute_exclusions(parent_combination),
'parent_combination': parent_combination.ids,
'parent_product_name': parent_name,
'mapped_attribute_names': self._get_mapped_attribute_names(parent_combination),
}
@api.model
def _complete_inverse_exclusions(self, exclusions):
"""Will complete the dictionnary of exclusions with their respective inverse
e.g: Black excludes XL and L
-> XL excludes Black
-> L excludes Black"""
result = dict(exclusions)
for key, value in exclusions.items():
for exclusion in value:
if exclusion in result and key not in result[exclusion]:
result[exclusion].append(key)
else:
result[exclusion] = [key]
return result
def _get_own_attribute_exclusions(self):
"""Get exclusions coming from the current template.
Dictionnary, each product template attribute value is a key, and for each of them
the value is an array with the other ptav that they exclude (empty if no exclusion).
"""
self.ensure_one()
product_template_attribute_values = self.valid_product_template_attribute_line_ids.product_template_value_ids
return {
ptav.id: [
value_id
for filter_line in ptav.exclude_for.filtered(
lambda filter_line: filter_line.product_tmpl_id == self
) for value_id in filter_line.value_ids.ids
]
for ptav in product_template_attribute_values
}
def _get_parent_attribute_exclusions(self, parent_combination):
"""Get exclusions coming from the parent combination.
Dictionnary, each parent's ptav is a key, and for each of them the value is
an array with the other ptav that are excluded because of the parent.
"""
self.ensure_one()
if not parent_combination:
return {}
result = {}
for product_attribute_value in parent_combination:
for filter_line in product_attribute_value.exclude_for.filtered(
lambda filter_line: filter_line.product_tmpl_id == self
):
# Some exclusions don't have attribute value. This means that the template is not
# compatible with the parent combination. If such an exclusion is found, it means that all
# attribute values are excluded.
if filter_line.value_ids:
result[product_attribute_value.id] = filter_line.value_ids.ids
else:
result[product_attribute_value.id] = filter_line.product_tmpl_id.mapped('attribute_line_ids.product_template_value_ids').ids
return result
def _get_mapped_attribute_names(self, parent_combination=None):
""" The name of every attribute values based on their id,
used to explain in the interface why that combination is not available
(e.g: Not available with Color: Black).
It contains both attribute value names from this product and from
the parent combination if provided.
"""
self.ensure_one()
all_product_attribute_values = self.valid_product_template_attribute_line_ids.product_template_value_ids
if parent_combination:
all_product_attribute_values |= parent_combination
return {
attribute_value.id: attribute_value.display_name
for attribute_value in all_product_attribute_values
}
def _is_combination_possible_by_config(self, combination, ignore_no_variant=False):
"""Return whether the given combination is possible according to the config of attributes on the template
:param combination: the combination to check for possibility
:type combination: recordset `product.template.attribute.value`
:param ignore_no_variant: whether no_variant attributes should be ignored
:type ignore_no_variant: bool
:return: wether the given combination is possible according to the config of attributes on the template
:rtype: bool
"""
self.ensure_one()
attribute_lines = self.valid_product_template_attribute_line_ids
if ignore_no_variant:
attribute_lines = attribute_lines._without_no_variant_attributes()
if len(combination) != len(attribute_lines):
# number of attribute values passed is different than the
# configuration of attributes on the template
return False
if attribute_lines != combination.attribute_line_id:
# combination has different attributes than the ones configured on the template
return False
if not (attribute_lines.product_template_value_ids._only_active() >= combination):
# combination has different values than the ones configured on the template
return False
return True
def _is_combination_possible(self, combination, parent_combination=None, ignore_no_variant=False):
"""
The combination is possible if it is not excluded by any rule
coming from the current template, not excluded by any rule from the
parent_combination (if given), and there should not be any archived
variant with the exact same combination.
If the template does not have any dynamic attribute, the combination
is also not possible if the matching variant has been deleted.
Moreover the attributes of the combination must excatly match the
attributes allowed on the template.
:param combination: the combination to check for possibility
:type combination: recordset `product.template.attribute.value`
:param ignore_no_variant: whether no_variant attributes should be ignored
:type ignore_no_variant: bool
:param parent_combination: combination from which `self` is an
optional or accessory product.
:type parent_combination: recordset `product.template.attribute.value`
:return: whether the combination is possible
:rtype: bool
"""
self.ensure_one()
if not self._is_combination_possible_by_config(combination, ignore_no_variant):
return False
variant = self._get_variant_for_combination(combination)
if self.has_dynamic_attributes():
if variant and not variant.active:
# dynamic and the variant has been archived
return False
else:
if not variant or not variant.active:
# not dynamic, the variant has been archived or deleted
return False
exclusions = self._get_own_attribute_exclusions()
if exclusions:
# exclude if the current value is in an exclusion,
# and the value excluding it is also in the combination
for ptav in combination:
for exclusion in exclusions.get(ptav.id):
if exclusion in combination.ids:
return False
parent_exclusions = self._get_parent_attribute_exclusions(parent_combination)
if parent_exclusions:
# parent_exclusion are mapped by ptav but here we don't need to know
# where the exclusion comes from so we loop directly on the dict values
for exclusions_values in parent_exclusions.values():
for exclusion in exclusions_values:
if exclusion in combination.ids:
return False
return True
def _get_variant_for_combination(self, combination):
"""Get the variant matching the combination.
All of the values in combination must be present in the variant, and the
variant should not have more attributes. Ignore the attributes that are
not supposed to create variants.
:param combination: recordset of `product.template.attribute.value`
:return: the variant if found, else empty
:rtype: recordset `product.product`
"""
self.ensure_one()
filtered_combination = combination._without_no_variant_attributes()
return self.env['product.product'].browse(self._get_variant_id_for_combination(filtered_combination))
def _create_product_variant(self, combination, log_warning=False):
""" Create if necessary and possible and return the product variant
matching the given combination for this template.
It is possible to create only if the template has dynamic attributes
and the combination itself is possible.
If we are in this case and the variant already exists but it is
archived, it is activated instead of being created again.
:param combination: the combination for which to get or create variant.
The combination must contain all necessary attributes, including
those of type no_variant. Indeed even though those attributes won't
be included in the variant if newly created, they are needed when
checking if the combination is possible.
:type combination: recordset of `product.template.attribute.value`
:param log_warning: whether a warning should be logged on fail
:type log_warning: bool
:return: the product variant matching the combination or none
:rtype: recordset of `product.product`
"""
self.ensure_one()
Product = self.env['product.product']
product_variant = self._get_variant_for_combination(combination)
if product_variant:
if not product_variant.active and self.has_dynamic_attributes() and self._is_combination_possible(combination):
product_variant.active = True
return product_variant
if not self.has_dynamic_attributes():
if log_warning:
_logger.warning('The user #%s tried to create a variant for the non-dynamic product %s.' % (self.env.user.id, self.id))
return Product
if not self._is_combination_possible(combination):
if log_warning:
_logger.warning('The user #%s tried to create an invalid variant for the product %s.' % (self.env.user.id, self.id))
return Product
return Product.sudo().create({
'product_tmpl_id': self.id,
'product_template_attribute_value_ids': [(6, 0, combination._without_no_variant_attributes().ids)]
})
@tools.ormcache('self.id', 'frozenset(filtered_combination.ids)')
def _get_variant_id_for_combination(self, filtered_combination):
"""See `_get_variant_for_combination`. This method returns an ID
so it can be cached.
Use sudo because the same result should be cached for all users.
"""
self.ensure_one()
domain = [('product_tmpl_id', '=', self.id)]
combination_indices_ids = filtered_combination._ids2str()
if combination_indices_ids:
domain = expression.AND([domain, [('combination_indices', '=', combination_indices_ids)]])
else:
domain = expression.AND([domain, [('combination_indices', 'in', ['', False])]])
return self.env['product.product'].sudo().with_context(active_test=False).search(domain, order='active DESC', limit=1).id
@tools.ormcache('self.id')
def _get_first_possible_variant_id(self):
"""See `_create_first_product_variant`. This method returns an ID
so it can be cached."""
self.ensure_one()
return self._create_first_product_variant().id
def _get_first_possible_combination(self, parent_combination=None, necessary_values=None):
"""See `_get_possible_combinations` (one iteration).
This method return the same result (empty recordset) if no
combination is possible at all which would be considered a negative
result, or if there are no attribute lines on the template in which
case the "empty combination" is actually a possible combination.
Therefore the result of this method when empty should be tested
with `_is_combination_possible` if it's important to know if the
resulting empty combination is actually possible or not.
"""
return next(self._get_possible_combinations(parent_combination, necessary_values), self.env['product.template.attribute.value'])
def _cartesian_product(self, product_template_attribute_values_per_line, parent_combination):
"""
Generate all possible combination for attributes values (aka cartesian product).
It is equivalent to itertools.product except it skips invalid partial combinations before they are complete.
Imagine the cartesian product of 'A', 'CD' and range(1_000_000) and let's say that 'A' and 'C' are incompatible.
If you use itertools.product or any normal cartesian product, you'll need to filter out of the final result
the 1_000_000 combinations that start with 'A' and 'C' . Instead, This implementation will test if 'A' and 'C' are
compatible before even considering range(1_000_000), skip it and and continue with combinations that start
with 'A' and 'D'.
It's necessary for performance reason because filtering out invalid combinations from standard Cartesian product
can be extremely slow
:param product_template_attribute_values_per_line: the values we want all the possibles combinations of.
One list of values by attribute line
:return: a generator of product template attribute value
"""
if not product_template_attribute_values_per_line:
return
all_exclusions = {self.env['product.template.attribute.value'].browse(k):
self.env['product.template.attribute.value'].browse(v) for k, v in
self._get_own_attribute_exclusions().items()}
# The following dict uses product template attribute values as keys
# 0 means the value is acceptable, greater than 0 means it's rejected, it cannot be negative
# Bear in mind that several values can reject the same value and the latter can only be included in the
# considered combination if no value rejects it.
# This dictionary counts how many times each value is rejected.
# Each time a value is included in the considered combination, the values it rejects are incremented
# When a value is discarded from the considered combination, the values it rejects are decremented
current_exclusions = defaultdict(int)
for exclusion in self._get_parent_attribute_exclusions(parent_combination):
current_exclusions[self.env['product.template.attribute.value'].browse(exclusion)] += 1
partial_combination = self.env['product.template.attribute.value']
# The following list reflects product_template_attribute_values_per_line
# For each line, instead of a list of values, it contains the index of the selected value
# -1 means no value has been picked for the line in the current (partial) combination
value_index_per_line = [-1] * len(product_template_attribute_values_per_line)
# determines which line line we're working on
line_index = 0
while True:
current_line_values = product_template_attribute_values_per_line[line_index]
current_ptav_index = value_index_per_line[line_index]
current_ptav = current_line_values[current_ptav_index]
# removing exclusions from current_ptav as we're removing it from partial_combination
if current_ptav_index >= 0:
for ptav_to_include_back in all_exclusions[current_ptav]:
current_exclusions[ptav_to_include_back] -= 1
partial_combination -= current_ptav
if current_ptav_index < len(current_line_values) - 1:
# go to next value of current line
value_index_per_line[line_index] += 1
current_line_values = product_template_attribute_values_per_line[line_index]
current_ptav_index = value_index_per_line[line_index]
current_ptav = current_line_values[current_ptav_index]
elif line_index != 0:
# reset current line, and then go to previous line
value_index_per_line[line_index] = - 1
line_index -= 1
continue
else:
# we're done if we must reset first line
break
# adding exclusions from current_ptav as we're incorporating it in partial_combination
for ptav_to_exclude in all_exclusions[current_ptav]:
current_exclusions[ptav_to_exclude] += 1
partial_combination += current_ptav
# test if included values excludes current value or if current value exclude included values
if current_exclusions[current_ptav] or \
any(intersection in partial_combination for intersection in all_exclusions[current_ptav]):
continue
if line_index == len(product_template_attribute_values_per_line) - 1:
# submit combination if we're on the last line
yield partial_combination
else:
# else we go to the next line
line_index += 1
def _get_possible_combinations(self, parent_combination=None, necessary_values=None):
"""Generator returning combinations that are possible, following the
sequence of attributes and values.
See `_is_combination_possible` for what is a possible combination.
When encountering an impossible combination, try to change the value
of attributes by starting with the further regarding their sequences.
Ignore attributes that have no values.
:param parent_combination: combination from which `self` is an
optional or accessory product.
:type parent_combination: recordset `product.template.attribute.value`
:param necessary_values: values that must be in the returned combination
:type necessary_values: recordset of `product.template.attribute.value`
:return: the possible combinations
:rtype: generator of recordset of `product.template.attribute.value`
"""
self.ensure_one()
if not self.active:
return _("The product template is archived so no combination is possible.")
necessary_values = necessary_values or self.env['product.template.attribute.value']
necessary_attribute_lines = necessary_values.mapped('attribute_line_id')
attribute_lines = self.valid_product_template_attribute_line_ids.filtered(lambda ptal: ptal not in necessary_attribute_lines)
if not attribute_lines and self._is_combination_possible(necessary_values, parent_combination):
yield necessary_values
product_template_attribute_values_per_line = [
ptal.product_template_value_ids._only_active()
for ptal in attribute_lines
]
for partial_combination in self._cartesian_product(product_template_attribute_values_per_line, parent_combination):
combination = partial_combination + necessary_values
if self._is_combination_possible(combination, parent_combination):
yield combination
return _("There are no remaining possible combination.")
def _get_closest_possible_combination(self, combination):
"""See `_get_closest_possible_combinations` (one iteration).
This method return the same result (empty recordset) if no
combination is possible at all which would be considered a negative
result, or if there are no attribute lines on the template in which
case the "empty combination" is actually a possible combination.
Therefore the result of this method when empty should be tested
with `_is_combination_possible` if it's important to know if the
resulting empty combination is actually possible or not.
"""
return next(self._get_closest_possible_combinations(combination), self.env['product.template.attribute.value'])
def _get_closest_possible_combinations(self, combination):
"""Generator returning the possible combinations that are the closest to
the given combination.
If the given combination is incomplete, try to complete it.
If the given combination is invalid, try to remove values from it before
completing it.
:param combination: the values to include if they are possible
:type combination: recordset `product.template.attribute.value`
:return: the possible combinations that are including as much
elements as possible from the given combination.
:rtype: generator of recordset of product.template.attribute.value
"""
while True:
res = self._get_possible_combinations(necessary_values=combination)
try:
# If there is at least one result for the given combination
# we consider that combination set, and we yield all the
# possible combinations for it.
yield(next(res))
for cur in res:
yield(cur)
return _("There are no remaining closest combination.")
except StopIteration:
# There are no results for the given combination, we try to
# progressively remove values from it.
if not combination:
return _("There are no possible combination.")
combination = combination[:-1]
def _get_current_company(self, **kwargs):
"""Get the most appropriate company for this product.
If the company is set on the product, directly return it. Otherwise,
fallback to a contextual company.
:param kwargs: kwargs forwarded to the fallback method.
:return: the most appropriate company for this product
:rtype: recordset of one `res.company`
"""
self.ensure_one()
return self.company_id or self._get_current_company_fallback(**kwargs)
def _get_current_company_fallback(self, **kwargs):
"""Fallback to get the most appropriate company for this product.
This should only be called from `_get_current_company` but is defined
separately to allow override.
The final fallback will be the current user's company.
:return: the fallback company for this product
:rtype: recordset of one `res.company`
"""
self.ensure_one()
return self.env.company
def get_single_product_variant(self):
""" Method used by the product configurator to check if the product is configurable or not.
We need to open the product configurator if the product:
- is configurable (see has_configurable_attributes)
- has optional products (method is extended in sale to return optional products info)
"""
self.ensure_one()
if self.product_variant_count == 1 and not self.has_configurable_attributes:
return {
'product_id': self.product_variant_id.id,
}
return {}
@api.model
def get_empty_list_help(self, help):
self = self.with_context(
empty_list_help_document_name=_("product"),
)
return super(ProductTemplate, self).get_empty_list_help(help)
@api.model
def get_import_templates(self):
return [{
'label': _('Import Template for Products'),
'template': '/product/static/xls/product_template.xls'
}]
|