odoo13-addon-shopinvader-wishlist 13.0.3.4.2.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. odoo/addons/shopinvader_wishlist/README.rst +69 -0
  2. odoo/addons/shopinvader_wishlist/__init__.py +4 -0
  3. odoo/addons/shopinvader_wishlist/__manifest__.py +15 -0
  4. odoo/addons/shopinvader_wishlist/components/__init__.py +1 -0
  5. odoo/addons/shopinvader_wishlist/components/access_info.py +20 -0
  6. odoo/addons/shopinvader_wishlist/demo/product_set.xml +17 -0
  7. odoo/addons/shopinvader_wishlist/i18n/shopinvader_wishlist.pot +66 -0
  8. odoo/addons/shopinvader_wishlist/models/__init__.py +1 -0
  9. odoo/addons/shopinvader_wishlist/models/product_set.py +80 -0
  10. odoo/addons/shopinvader_wishlist/readme/CONTRIBUTORS.rst +2 -0
  11. odoo/addons/shopinvader_wishlist/readme/CREDITS.rst +4 -0
  12. odoo/addons/shopinvader_wishlist/readme/DESCRIPTION.rst +1 -0
  13. odoo/addons/shopinvader_wishlist/services/__init__.py +1 -0
  14. odoo/addons/shopinvader_wishlist/services/wishlist.py +550 -0
  15. odoo/addons/shopinvader_wishlist/static/description/icon.png +0 -0
  16. odoo/addons/shopinvader_wishlist/static/description/index.html +426 -0
  17. odoo/addons/shopinvader_wishlist/tests/__init__.py +2 -0
  18. odoo/addons/shopinvader_wishlist/tests/test_product_set.py +119 -0
  19. odoo/addons/shopinvader_wishlist/tests/test_wishlist.py +368 -0
  20. odoo/addons/shopinvader_wishlist/views/product_set.xml +15 -0
  21. odoo/addons/shopinvader_wishlist/wizard/__init__.py +1 -0
  22. odoo/addons/shopinvader_wishlist/wizard/product_set_add.py +24 -0
  23. odoo/addons/shopinvader_wishlist/wizard/product_set_add.xml +13 -0
  24. odoo13_addon_shopinvader_wishlist-13.0.3.4.2.dev1.dist-info/METADATA +86 -0
  25. odoo13_addon_shopinvader_wishlist-13.0.3.4.2.dev1.dist-info/RECORD +27 -0
  26. odoo13_addon_shopinvader_wishlist-13.0.3.4.2.dev1.dist-info/WHEEL +5 -0
  27. odoo13_addon_shopinvader_wishlist-13.0.3.4.2.dev1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,550 @@
1
+ # Copyright 2019 Camptocamp (http://www.camptocamp.com).
2
+ # @author Simone Orsi <simone.orsi@camptocamp.com>
3
+ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
4
+
5
+ from collections import defaultdict
6
+ from functools import wraps
7
+
8
+ from werkzeug.exceptions import NotFound
9
+
10
+ from odoo import _, exceptions
11
+ from odoo.osv import expression
12
+
13
+ from odoo.addons.base_rest.components.service import to_int
14
+ from odoo.addons.component.core import Component
15
+
16
+
17
+ def data_mode(func):
18
+ """Decorator extend Cerberus schema dict w/ data mode params.
19
+ """
20
+
21
+ @wraps(func)
22
+ def wrapped(*args, **kwargs):
23
+ service = args[0]
24
+ res = func(*args, **kwargs)
25
+ res.update(service._validate_data_mode())
26
+ return res
27
+
28
+ return wrapped
29
+
30
+
31
+ class WishlistService(Component):
32
+ """Shopinvader service to manage current user's wishlists.
33
+ """
34
+
35
+ _name = "shopinvader.wishlist.service"
36
+ _inherit = "base.shopinvader.service"
37
+ _usage = "wishlist"
38
+ _expose_model = "product.set"
39
+ _description = __doc__
40
+
41
+ # The following method are 'public' and can be called from the controller.
42
+ # All params are untrusted so please check it !
43
+
44
+ def get(self, _id, **params):
45
+ record = self._get(_id)
46
+ return self._to_json_one(record, **params)
47
+
48
+ def search(self, **params):
49
+ return self._paginate_search(**params)
50
+
51
+ # pylint: disable=W8106
52
+ def create(self, **params):
53
+ if not self._is_logged_in():
54
+ # TODO: is there any way to control this in the REST API?
55
+ raise exceptions.UserError(
56
+ _("Must be authenticated to create a wishlist")
57
+ )
58
+ vals = self._prepare_params(params.copy())
59
+ record = self.env[self._expose_model].create(vals)
60
+ self._post_create(record)
61
+ return {"data": self._to_json_one(record, **params)}
62
+
63
+ def update(self, _id, **params):
64
+ record = self._get(_id)
65
+ record.write(self._prepare_params(params.copy(), mode="update"))
66
+ self._post_update(record)
67
+ return self.search(**params)
68
+
69
+ def delete(self, _id, **params):
70
+ self._get(_id).unlink()
71
+ return self.search(**params)
72
+
73
+ def add_to_cart(self, _id):
74
+ record = self._get(_id)
75
+ cart_service = self.component(usage="cart")
76
+ cart = cart_service._get()
77
+ self._add_to_cart(record, cart)
78
+ # return new cart
79
+ return cart_service._to_json(cart)
80
+
81
+ def add_items_to_cart(self, _id, **params):
82
+ record = self._get(_id)
83
+ cart_service = self.component(usage="cart")
84
+ cart = cart_service._get()
85
+ prod_ids = [x["product_id"] for x in params["lines"]]
86
+ lines = record.get_lines_by_products(product_ids=prod_ids)
87
+ self._add_items_to_cart(record, cart, lines)
88
+ # return new cart
89
+ return cart_service._to_json(cart)
90
+
91
+ def add_items(self, _id, **params):
92
+ record = self._get(_id)
93
+ self._add_items(record, params)
94
+ return self._to_json_one(record, **params)
95
+
96
+ def update_items(self, _id, **params):
97
+ record = self._get(_id)
98
+ self._update_items(record, params)
99
+ return self._to_json_one(record, **params)
100
+
101
+ def delete_items(self, _id, **params):
102
+ record = self._get(_id)
103
+ self._delete_items(record, params)
104
+ return self._to_json_one(record, **params)
105
+
106
+ def move_items(self, _id, **params):
107
+ record = self._get(_id)
108
+ self._move_items(record, params)
109
+ return self._to_json_one(record, **params)
110
+
111
+ def replace_items(self, _id, **params):
112
+ record = self._get(_id)
113
+ self._replace_items(record, params)
114
+ return self._to_json_one(record, **params)
115
+
116
+ @property
117
+ def access_info(self):
118
+ with self.shopinvader_backend.work_on(
119
+ "res.partner",
120
+ partner=self.partner,
121
+ partner_user=self.partner_user,
122
+ invader_partner=self.invader_partner,
123
+ invader_partner_user=self.invader_partner_user,
124
+ service_work=self.work,
125
+ ) as work:
126
+ return work.component(usage="access.info")
127
+
128
+ def _post_create(self, record):
129
+ pass
130
+
131
+ def _post_update(self, record):
132
+ pass
133
+
134
+ def _validator_get(self):
135
+ return {}
136
+
137
+ def _validate_data_mode(self):
138
+ return {
139
+ "data_mode": {"type": "string", "nullable": True},
140
+ }
141
+
142
+ @data_mode
143
+ def _validator_search(self):
144
+ return {
145
+ "id": {"coerce": to_int, "type": "integer"},
146
+ "per_page": {
147
+ "coerce": to_int,
148
+ "nullable": True,
149
+ "type": "integer",
150
+ },
151
+ "page": {"coerce": to_int, "nullable": True, "type": "integer"},
152
+ "scope": {"type": "dict", "nullable": True},
153
+ }
154
+
155
+ @data_mode
156
+ def _validator_create(self):
157
+ return {
158
+ "name": {"type": "string", "required": True},
159
+ "ref": {"type": "string", "required": False, "nullable": True},
160
+ "partner_id": {
161
+ "type": "integer",
162
+ "coerce": to_int,
163
+ "nullable": True,
164
+ },
165
+ "typology": {"type": "string", "nullable": True},
166
+ "lines": {
167
+ "type": "list",
168
+ "required": False,
169
+ "schema": {
170
+ "type": "dict",
171
+ "schema": self._validator_line_schema(),
172
+ },
173
+ },
174
+ }
175
+
176
+ def _validator_line_schema(self):
177
+ return {
178
+ "product_id": {
179
+ "coerce": to_int,
180
+ "required": True,
181
+ "type": "integer",
182
+ },
183
+ "quantity": {"coerce": float, "type": "float", "default": 1.0},
184
+ "sequence": {"coerce": int, "type": "integer", "required": False},
185
+ }
186
+
187
+ def _validator_update(self):
188
+ res = self._validator_create()
189
+ for key in res:
190
+ if "required" in res[key]:
191
+ del res[key]["required"]
192
+ return res
193
+
194
+ def _validator_add_to_cart(self):
195
+ return {"id": {"coerce": to_int, "type": "integer"}}
196
+
197
+ def _validator_add_items(self):
198
+ return {
199
+ "lines": {
200
+ "type": "list",
201
+ "required": True,
202
+ "schema": {
203
+ "type": "dict",
204
+ "schema": self._validator_add_item(),
205
+ },
206
+ }
207
+ }
208
+
209
+ def _validator_add_items_to_cart(self):
210
+ schema = self._validator_add_to_cart()
211
+ schema.update(
212
+ {
213
+ "lines": {
214
+ "type": "list",
215
+ "required": True,
216
+ "schema": {
217
+ "type": "dict",
218
+ "schema": {
219
+ "product_id": {
220
+ "coerce": to_int,
221
+ "required": True,
222
+ "type": "integer",
223
+ },
224
+ },
225
+ },
226
+ },
227
+ }
228
+ )
229
+ return schema
230
+
231
+ def _validator_add_item(self):
232
+ return self._validator_line_schema()
233
+
234
+ def _validator_update_items(self):
235
+ return self._validator_add_items()
236
+
237
+ @data_mode
238
+ def _validator_move_items(self):
239
+ return {
240
+ "lines": {
241
+ "type": "list",
242
+ "required": True,
243
+ "schema": {
244
+ "type": "dict",
245
+ "schema": self._validator_move_item(),
246
+ },
247
+ }
248
+ }
249
+
250
+ def _validator_move_item(self):
251
+ return {
252
+ "product_id": {
253
+ "coerce": to_int,
254
+ "required": True,
255
+ "type": "integer",
256
+ },
257
+ "move_to_wishlist_id": {
258
+ "coerce": to_int,
259
+ "required": True,
260
+ "type": "integer",
261
+ },
262
+ }
263
+
264
+ @data_mode
265
+ def _validator_delete_items(self):
266
+ return {
267
+ "lines": {
268
+ "type": "list",
269
+ "required": True,
270
+ "schema": {
271
+ "type": "dict",
272
+ "schema": self._validator_delete_item(),
273
+ },
274
+ }
275
+ }
276
+
277
+ def _validator_delete_item(self):
278
+ return {
279
+ "product_id": {
280
+ "coerce": to_int,
281
+ "required": True,
282
+ "type": "integer",
283
+ }
284
+ }
285
+
286
+ @data_mode
287
+ def _validator_replace_items(self):
288
+ return {
289
+ "lines": {
290
+ "type": "list",
291
+ "required": True,
292
+ "schema": {
293
+ "type": "dict",
294
+ "schema": self._validator_replace_item(),
295
+ },
296
+ }
297
+ }
298
+
299
+ def _validator_replace_item(self):
300
+ return {
301
+ # the item to replace
302
+ "product_id": {
303
+ "coerce": to_int,
304
+ "required": True,
305
+ "type": "integer",
306
+ },
307
+ # replace with this
308
+ "replacement_product_id": {
309
+ "coerce": to_int,
310
+ "required": True,
311
+ "type": "integer",
312
+ },
313
+ }
314
+
315
+ def _get_base_search_domain(self):
316
+ if not self._is_logged_in():
317
+ return expression.FALSE_DOMAIN
318
+ return self._default_domain_for_partner_records()
319
+
320
+ def _get_add_to_cart_wizard(self, record, cart):
321
+ return self.env["product.set.add"].create(
322
+ {
323
+ "order_id": cart.id,
324
+ "product_set_id": record.id,
325
+ "skip_existing_products": True,
326
+ }
327
+ )
328
+
329
+ def _add_to_cart(self, record, cart):
330
+ wizard = self._get_add_to_cart_wizard(record, cart)
331
+ return wizard.add_set()
332
+
333
+ def _add_items_to_cart(self, record, cart, lines):
334
+ wizard = self._get_add_to_cart_wizard(record, cart)
335
+ wizard.product_set_line_ids = lines
336
+ return wizard.add_set()
337
+
338
+ def _prepare_params(self, params, mode="create"):
339
+ if mode == "create":
340
+ params["shopinvader_backend_id"] = self.shopinvader_backend.id
341
+ if not params.get("partner_id"):
342
+ params["partner_id"] = self.partner_user.id
343
+ if not params.get("typology"):
344
+ params["typology"] = "wishlist"
345
+ record = self.env[self._expose_model].browse() # no record yet
346
+ params["set_line_ids"] = [
347
+ (0, 0, self._prepare_item(record, line))
348
+ for line in params.pop("lines", [])
349
+ ]
350
+ params.pop("data_mode", None)
351
+ return params
352
+
353
+ def _to_json(self, records, data_mode=None, **kw):
354
+ if data_mode:
355
+ try:
356
+ parser = getattr(self, "_json_parser_" + data_mode)()
357
+ except AttributeError:
358
+ raise exceptions.UserError(
359
+ _("JSON data mode `%s` not found.") % data_mode
360
+ )
361
+ else:
362
+ parser = self._json_parser()
363
+ return records.jsonify(parser)
364
+
365
+ def _to_json_one(self, records, **kw):
366
+ # This works only here... see `_update_item` :/
367
+ records.set_line_ids.invalidate_cache()
368
+ values = self._to_json(records, **kw)
369
+ if len(records) == 1:
370
+ values = values[0]
371
+ return values
372
+
373
+ def _json_parser_light(self):
374
+ return [
375
+ "id",
376
+ "name",
377
+ ("partner_id:partner", ["id", "name"]),
378
+ ("partner_id:access", self._json_parser_wishlist_access),
379
+ ]
380
+
381
+ def _json_parser(self):
382
+ return [
383
+ "id",
384
+ "name",
385
+ "typology",
386
+ "ref",
387
+ ("partner_id:partner", ["id", "name"]),
388
+ ("set_line_ids:lines", self._json_parser_line()),
389
+ ("partner_id:access", self._json_parser_wishlist_access),
390
+ ]
391
+
392
+ def _json_parser_line(self):
393
+ return [
394
+ "id",
395
+ "sequence",
396
+ "quantity",
397
+ ("shopinvader_variant_id:product", self._json_parser_product_data),
398
+ ]
399
+
400
+ def _json_parser_product_data(self, rec, fname):
401
+ if rec.shopinvader_variant_id:
402
+ data = rec.shopinvader_variant_id.get_shop_data()
403
+ data["available"] = rec.shopinvader_variant_id.active
404
+ return data
405
+ return rec.product_id.jsonify(
406
+ self._json_parser_binding_not_available_data(), one=True
407
+ )
408
+
409
+ def _json_parser_binding_not_available_data(self):
410
+ """Special parser for when the binding is not available.
411
+
412
+ A user might delete bindings for a specific product
413
+ or add a product w/out bindings to a wishlist
414
+ and then archive the product which will lead to no binding as well.
415
+ Or, product and wishlists have been imported from CSVs
416
+ and the product was archived in the process or right after
417
+ before creating bindings.
418
+
419
+ In all the cases, you end up w/ broken reference.
420
+
421
+ When this happens, allow the frontend to show a nice message
422
+ to ask users to replace the product.
423
+ """
424
+ return ["id", "name", ("active:available", lambda rec, fname: False)]
425
+
426
+ def _json_parser_wishlist_access(self, rec, fname):
427
+ return self.access_info.for_wishlist(rec)
428
+
429
+ def _get_existing_line(self, record, params, raise_if_not_found=False):
430
+ product_id = params["product_id"]
431
+ line = record.get_lines_by_products(product_ids=[product_id])
432
+ if not line and raise_if_not_found:
433
+ raise NotFound(
434
+ "No product found with id %s" % params["product_id"]
435
+ )
436
+ return line
437
+
438
+ def _update_lines(self, record, lines, raise_if_not_found=False):
439
+ new_items = []
440
+ for item_params in lines:
441
+ existing = self._get_existing_line(
442
+ record, item_params, raise_if_not_found=raise_if_not_found
443
+ )
444
+ if existing:
445
+ item_update_params = self._prepare_item(record, item_params)
446
+ # prevent move or prod change on update
447
+ item_update_params = {
448
+ k: v
449
+ for k, v in item_update_params.items()
450
+ if k not in ("product_set_id", "product_id")
451
+ }
452
+ existing.write(item_update_params)
453
+ else:
454
+ new_items.append(item_params)
455
+ values = [
456
+ (0, 0, self._prepare_item(record, item_params))
457
+ for item_params in new_items
458
+ ]
459
+ if values:
460
+ record.write({"set_line_ids": values})
461
+ # TODO: WTF?? Cache on sequence is not invalidated.
462
+ # And calling this does not work here, must be called in `_to_json_one`.
463
+ # record.set_line_ids.invalidate_cache()
464
+ # flush does not work neither
465
+ # record.set_line_ids.flush()
466
+
467
+ def _add_items(self, record, params):
468
+ self._update_lines(record, params["lines"])
469
+
470
+ def _update_items(self, record, params):
471
+ self._update_lines(record, params["lines"], raise_if_not_found=True)
472
+
473
+ def _move_items(self, record, params):
474
+ # group lines by destination
475
+ by_destination = defaultdict(self.env["product.set.line"].browse)
476
+ to_delete = self.env["product.set.line"].browse()
477
+ for item_params in params["lines"]:
478
+ existing = self._get_existing_line(
479
+ record, item_params, raise_if_not_found=True
480
+ )
481
+ to_delete |= existing
482
+ by_destination[item_params["move_to_wishlist_id"]] += existing
483
+
484
+ # move all lines to each destination at once
485
+ for move_to_id, move_to_items in by_destination.items():
486
+ move_to_wl = self._get(move_to_id)
487
+ lines = move_to_items.read(
488
+ ["product_id", "quantity", "sequence"], load="_classic_write"
489
+ )
490
+ values = [
491
+ (0, 0, self._prepare_item(move_to_wl, line)) for line in lines
492
+ ]
493
+ move_to_wl.write({"set_line_ids": values})
494
+ # delete all old records
495
+ to_delete.unlink()
496
+
497
+ def _replace_items(self, record, params):
498
+ # get all lines
499
+ replace_lines = sorted(params["lines"], key=lambda x: x["product_id"])
500
+ product_ids = [x["product_id"] for x in replace_lines]
501
+ set_lines = record.set_line_ids.filtered(
502
+ lambda x: x.product_id.id in product_ids
503
+ )
504
+ lines_by_pid = {line.product_id.id: line.id for line in set_lines}
505
+ new_values = []
506
+ for line in replace_lines:
507
+ line_id = lines_by_pid.get(line["product_id"])
508
+ if not line_id:
509
+ continue
510
+ new_values.append((line_id, line["replacement_product_id"]))
511
+
512
+ # Update all lines at once to avoid tons of writes and tons of sync events
513
+ # TODO: probably this should be applied to all writes on lines.
514
+ # pylint: disable=sql-injection
515
+ query = """
516
+ UPDATE
517
+ product_set_line AS set_line
518
+ SET
519
+ product_id = c.product_id
520
+ FROM (VALUES {})
521
+ AS c(id, product_id)
522
+ WHERE c.id = set_line.id;
523
+ """.format(
524
+ ",".join(["({}, {})".format(*x) for x in new_values])
525
+ )
526
+ self.env.cr.execute(query)
527
+ set_lines.invalidate_cache(["product_id", "shopinvader_variant_id"])
528
+ set_lines.recompute()
529
+ record.invalidate_cache(["set_line_ids"])
530
+ return set_lines
531
+
532
+ def _prepare_item(self, record, params):
533
+ vals = {
534
+ "product_set_id": record.id,
535
+ "product_id": params["product_id"],
536
+ "quantity": params.get("quantity") or 1,
537
+ }
538
+ if "sequence" in params:
539
+ # Set sequence only if explicitly given
540
+ vals["sequence"] = params.get("sequence")
541
+ return vals
542
+
543
+ def _delete_items(self, record, params):
544
+ to_delete = self.env["product.set.line"].browse()
545
+ for item_params in params["lines"]:
546
+ existing = self._get_existing_line(
547
+ record, item_params, raise_if_not_found=True
548
+ )
549
+ to_delete |= existing
550
+ to_delete.unlink()