From 9b242e2561fe012ea80be98b25687780df9847d5 Mon Sep 17 00:00:00 2001 From: Nicolas Delbovier Date: Mon, 12 May 2025 15:08:04 +0200 Subject: [PATCH 01/44] [REF] sale_quotation: refactor the quotation mechanism. * Extracts the shop_order_mode of products in a new module * Enables to choose a combined shop order mode (quotation or direct sale) * Adds a migration script to translate the products and templates order mode infos from the old to the new fields --- sale_quotation/README.rst | 24 +- sale_quotation/__manifest__.py | 8 +- sale_quotation/i18n/sale_quotation.pot | 111 +---- .../migrations/16.0.0.3.0/pre-migrate.py | 47 ++ sale_quotation/models/__init__.py | 2 - sale_quotation/models/product_product.py | 13 - sale_quotation/models/product_template.py | 51 --- sale_quotation/models/sale_order.py | 8 +- sale_quotation/readme/CONTRIBUTORS.rst | 3 +- sale_quotation/readme/DESCRIPTION.rst | 23 +- sale_quotation/static/description/index.html | 12 +- sale_quotation/static/description/product.png | Bin 156727 -> 0 bytes sale_quotation/tests/__init__.py | 2 +- sale_quotation/tests/test_product.py | 58 --- sale_quotation/views/product_view.xml | 12 - .../addons/shopinvader_product_order_mode | 1 + setup/shopinvader_product_order_mode/setup.py | 6 + .../shopinvader_product_order_mode_on_request | 1 + .../setup.py | 6 + shopinvader_product_order_mode/README.rst | 60 +++ shopinvader_product_order_mode/__init__.py | 1 + .../__manifest__.py | 19 + .../i18n/shopinvader_product_order_mode.pot | 81 ++++ .../models/__init__.py | 2 + .../models/product_product.py | 50 +++ .../models/product_template.py | 39 ++ .../readme/CONTRIBUTORS.rst | 1 + .../readme/DESCRIPTION.rst | 1 + .../static/description/index.html | 416 ++++++++++++++++++ .../tests/__init__.py | 1 + .../tests/test_shop_order_mode.py | 77 ++++ .../views/product_product.xml | 25 ++ .../views/product_template.xml | 21 + .../README.rst | 60 +++ .../__init__.py | 1 + .../__manifest__.py | 17 + ...pinvader_product_order_mode_on_request.pot | 72 +++ .../models/__init__.py | 3 + .../models/product_product.py | 19 + .../models/product_template.py | 19 + .../models/sale_order.py | 21 + .../readme/CONTRIBUTORS.rst | 1 + .../readme/DESCRIPTION.rst | 1 + .../static/description/index.html | 416 ++++++++++++++++++ .../tests/__init__.py | 1 + .../tests/test_quotation.py | 102 +++++ 46 files changed, 1608 insertions(+), 307 deletions(-) create mode 100644 sale_quotation/migrations/16.0.0.3.0/pre-migrate.py delete mode 100644 sale_quotation/models/product_product.py delete mode 100644 sale_quotation/models/product_template.py delete mode 100644 sale_quotation/static/description/product.png delete mode 100644 sale_quotation/tests/test_product.py delete mode 100644 sale_quotation/views/product_view.xml create mode 120000 setup/shopinvader_product_order_mode/odoo/addons/shopinvader_product_order_mode create mode 100644 setup/shopinvader_product_order_mode/setup.py create mode 120000 setup/shopinvader_product_order_mode_on_request/odoo/addons/shopinvader_product_order_mode_on_request create mode 100644 setup/shopinvader_product_order_mode_on_request/setup.py create mode 100644 shopinvader_product_order_mode/README.rst create mode 100644 shopinvader_product_order_mode/__init__.py create mode 100644 shopinvader_product_order_mode/__manifest__.py create mode 100644 shopinvader_product_order_mode/i18n/shopinvader_product_order_mode.pot create mode 100644 shopinvader_product_order_mode/models/__init__.py create mode 100644 shopinvader_product_order_mode/models/product_product.py create mode 100644 shopinvader_product_order_mode/models/product_template.py create mode 100644 shopinvader_product_order_mode/readme/CONTRIBUTORS.rst create mode 100644 shopinvader_product_order_mode/readme/DESCRIPTION.rst create mode 100644 shopinvader_product_order_mode/static/description/index.html create mode 100644 shopinvader_product_order_mode/tests/__init__.py create mode 100644 shopinvader_product_order_mode/tests/test_shop_order_mode.py create mode 100644 shopinvader_product_order_mode/views/product_product.xml create mode 100644 shopinvader_product_order_mode/views/product_template.xml create mode 100644 shopinvader_product_order_mode_on_request/README.rst create mode 100644 shopinvader_product_order_mode_on_request/__init__.py create mode 100644 shopinvader_product_order_mode_on_request/__manifest__.py create mode 100644 shopinvader_product_order_mode_on_request/i18n/shopinvader_product_order_mode_on_request.pot create mode 100644 shopinvader_product_order_mode_on_request/models/__init__.py create mode 100644 shopinvader_product_order_mode_on_request/models/product_product.py create mode 100644 shopinvader_product_order_mode_on_request/models/product_template.py create mode 100644 shopinvader_product_order_mode_on_request/models/sale_order.py create mode 100644 shopinvader_product_order_mode_on_request/readme/CONTRIBUTORS.rst create mode 100644 shopinvader_product_order_mode_on_request/readme/DESCRIPTION.rst create mode 100644 shopinvader_product_order_mode_on_request/static/description/index.html create mode 100644 shopinvader_product_order_mode_on_request/tests/__init__.py create mode 100644 shopinvader_product_order_mode_on_request/tests/test_quotation.py diff --git a/sale_quotation/README.rst b/sale_quotation/README.rst index e9499a5b05..6cef73fb4a 100644 --- a/sale_quotation/README.rst +++ b/sale_quotation/README.rst @@ -28,27 +28,8 @@ Sale Quotation This module is used in the context of e-commerce project (like shopinvader) - -It allows to specify whether your product requires a quotation. -This can be set per "product.template" or per "product.variant" - - -For example, you have a product where the supplier price fluctuates a lot, -you can not give a public price but your customer should be able to request a quotation. - -.. image:: https://raw.githubusercontent.com/shopinvader/odoo-shopinvader/16.0/sale_quotation/static/description/product.png - :width: 400px - :alt: Widget in action - - - -On your shopinvader website when a customer has added a product that requires a quotation, -instead of validating the cart it will "request a quotation". - - - -On Odoo Backoffice the menu quotation has been improved and a new state (quotation_state) has been added. -So you can easily process and follow the quotation request from your external system. +On Odoo Backoffice this module imporves the menu quotation by adding a new state (quotation_state). +Then, you can easily process and follow the quotation request from your external system. .. image:: https://raw.githubusercontent.com/shopinvader/odoo-shopinvader/16.0/sale_quotation/static/description/quotation.png :width: 400px @@ -86,6 +67,7 @@ Contributors * Matthieu Saison * Iván Todorovich * Simone Orsi +* Nicolas DELBOVIER Maintainers ~~~~~~~~~~~ diff --git a/sale_quotation/__manifest__.py b/sale_quotation/__manifest__.py index 4a5b12cb8b..2cb1baa444 100644 --- a/sale_quotation/__manifest__.py +++ b/sale_quotation/__manifest__.py @@ -4,15 +4,17 @@ { "name": "Sale Quotation", "summary": "Sale Quotation", - "version": "16.0.0.2.1", + "version": "16.0.0.3.0", "category": "e-commerce", "development_status": "Production/Stable", "website": "https://github.com/shopinvader/odoo-shopinvader", "author": "Akretion", "license": "AGPL-3", - "depends": ["sale_cart"], + "depends": [ + "sale_cart", + "shopinvader_product_order_mode_on_request", + ], "data": [ - "views/product_view.xml", "views/sale_view.xml", ], "installable": True, diff --git a/sale_quotation/i18n/sale_quotation.pot b/sale_quotation/i18n/sale_quotation.pot index 94c88c9105..aad07b6837 100644 --- a/sale_quotation/i18n/sale_quotation.pot +++ b/sale_quotation/i18n/sale_quotation.pot @@ -1,120 +1,15 @@ # Translation of Odoo Server. # This file contains the translation of the following modules: -# * sale_quotation # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 16.0\n" +"Project-Id-Version: Odoo Server 16.0+e\n" "Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-16 13:39+0000\n" +"PO-Revision-Date: 2025-05-16 13:39+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" - -#. module: sale_quotation -#: model:ir.model.fields.selection,name:sale_quotation.selection__sale_order__quotation_state__accepted -#: model_terms:ir.ui.view,arch_db:sale_quotation.sale_order_view_search_inherit_quotation -msgid "Accepted" -msgstr "" - -#. module: sale_quotation -#: model:ir.model.fields.selection,name:sale_quotation.selection__product_template__shop_only_quotation__all_variant -msgid "All Variant" -msgstr "" - -#. module: sale_quotation -#: model:ir.model.fields.selection,name:sale_quotation.selection__sale_order__quotation_state__cancel -#: model_terms:ir.ui.view,arch_db:sale_quotation.sale_order_view_search_inherit_quotation -msgid "Cancel" -msgstr "" - -#. module: sale_quotation -#: model:ir.model.fields.selection,name:sale_quotation.selection__sale_order__quotation_state__customer_request -msgid "Customer Request" -msgstr "" - -#. module: sale_quotation -#: model_terms:ir.ui.view,arch_db:sale_quotation.sale_order_view_search_inherit_quotation -msgid "Customer request" -msgstr "" - -#. module: sale_quotation -#: model:ir.model.fields.selection,name:sale_quotation.selection__sale_order__quotation_state__draft -#: model_terms:ir.ui.view,arch_db:sale_quotation.sale_order_view_search_inherit_quotation -msgid "Draft" -msgstr "" - -#. module: sale_quotation -#: model:ir.model.fields.selection,name:sale_quotation.selection__product_template__shop_only_quotation__manually_on_variant -msgid "Manually on Variant" -msgstr "" - -#. module: sale_quotation -#: model:ir.model.fields.selection,name:sale_quotation.selection__product_template__shop_only_quotation__never -msgid "Never" -msgstr "" - -#. module: sale_quotation -#: model_terms:ir.ui.view,arch_db:sale_quotation.product_template_form_view -msgid "Only for Quotation" -msgstr "" - -#. module: sale_quotation -#. odoo-python -#: code:addons/sale_quotation/models/sale_order.py:0 -#, python-format -msgid "" -"Only orders of cart typology in draft state can be converted to quotation" -msgstr "" - -#. module: sale_quotation -#. odoo-python -#: code:addons/sale_quotation/models/sale_order.py:0 -#, python-format -msgid "Only quotation with the state 'waiting_acceptation' can be Confirmed." -msgstr "" - -#. module: sale_quotation -#: model:ir.model,name:sale_quotation.model_product_template -msgid "Product" -msgstr "" - -#. module: sale_quotation -#: model:ir.model,name:sale_quotation.model_product_product -msgid "Product Variant" -msgstr "" - -#. module: sale_quotation -#: model:ir.model.fields,field_description:sale_quotation.field_sale_order__quotation_state -msgid "Quotation State" -msgstr "" - -#. module: sale_quotation -#: model:ir.model,name:sale_quotation.model_sale_order -msgid "Sales Order" -msgstr "" - -#. module: sale_quotation -#: model:ir.model.fields,field_description:sale_quotation.field_sale_order__shop_only_quotation -msgid "Shop Only Quotation" -msgstr "" - -#. module: sale_quotation -#: model:ir.model.fields,field_description:sale_quotation.field_product_product__shop_only_quotation -#: model:ir.model.fields,field_description:sale_quotation.field_product_template__shop_only_quotation -msgid "Shopinvader: Only for Quotation" -msgstr "" - -#. module: sale_quotation -#: model_terms:ir.ui.view,arch_db:sale_quotation.sale_order_view_search_inherit_quotation -#: model_terms:ir.ui.view,arch_db:sale_quotation.view_quotation_tree -msgid "State" -msgstr "" - -#. module: sale_quotation -#: model:ir.model.fields.selection,name:sale_quotation.selection__sale_order__quotation_state__waiting_acceptation -#: model_terms:ir.ui.view,arch_db:sale_quotation.sale_order_view_search_inherit_quotation -msgid "Waiting Acceptation" -msgstr "" diff --git a/sale_quotation/migrations/16.0.0.3.0/pre-migrate.py b/sale_quotation/migrations/16.0.0.3.0/pre-migrate.py new file mode 100644 index 0000000000..f5fe5a242b --- /dev/null +++ b/sale_quotation/migrations/16.0.0.3.0/pre-migrate.py @@ -0,0 +1,47 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from openupgradelib import openupgrade + + +def migrate(cr, version): + """ + For each product and for each template where 'shop_only_quotation' is True, + set 'shop_order_mode' to 'quotation_only'. + """ + openupgrade.logged_query( + cr, + """ + UPDATE product_template + SET shop_order_mode = 'quotation_only' + WHERE shop_only_quotation = 'all_variant'; + """, + ) + openupgrade.logged_query( + cr, + """ + UPDATE product_template + SET shop_order_mode = 'direct_sale_only' + WHERE shop_only_quotation = 'never'; + """, + ) + openupgrade.logged_query( + cr, + """ + UPDATE product_template + SET is_shop_order_mode_unabled_on_variant = TRUE + WHERE shop_only_quotation = 'manually_on_variant'; + """, + ) + + openupgrade.logged_query( + cr, + """ + UPDATE product_product + SET shop_order_mode = 'quotation_only' + FROM product_template AS pt + WHERE product_product.product_tmpl_id = pt.id + AND pt.is_shop_order_mode_unabled_on_variant = TRUE + AND product_product.shop_only_quotation = TRUE; + """, + ) diff --git a/sale_quotation/models/__init__.py b/sale_quotation/models/__init__.py index de7a683548..6aacb75313 100644 --- a/sale_quotation/models/__init__.py +++ b/sale_quotation/models/__init__.py @@ -1,3 +1 @@ -from . import product_product -from . import product_template from . import sale_order diff --git a/sale_quotation/models/product_product.py b/sale_quotation/models/product_product.py deleted file mode 100644 index 87bf509dbc..0000000000 --- a/sale_quotation/models/product_product.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2021 Camptocamp (https://www.camptocamp.com). -# @author Iván Todorovich -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). - -from odoo import fields, models - - -class ProductProduct(models.Model): - _inherit = "product.product" - - shop_only_quotation = fields.Boolean( - string="Shopinvader: Only for Quotation", - ) diff --git a/sale_quotation/models/product_template.py b/sale_quotation/models/product_template.py deleted file mode 100644 index 1f58f4e52b..0000000000 --- a/sale_quotation/models/product_template.py +++ /dev/null @@ -1,51 +0,0 @@ -# Copyright 2017-2018 Akretion (http://www.akretion.com). -# Copyright 2021 Camptocamp (https://www.camptocamp.com). -# @author Benoît GUILLOT -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo import api, fields, models - - -class ProductTemplate(models.Model): - _inherit = "product.template" - - shop_only_quotation = fields.Selection( - selection=[ - ("all_variant", "All Variant"), - ("manually_on_variant", "Manually on Variant"), - ("never", "Never"), - ], - string="Shopinvader: Only for Quotation", - compute="_compute_shop_only_quotation", - inverse="_inverse_shop_only_quotation", - store=True, - ) - - @api.depends("product_variant_ids.shop_only_quotation") - def _compute_shop_only_quotation(self): - # True only if true for all its variants - for rec in self: - if not rec.product_variant_ids or not any( - rec.product_variant_ids.mapped("shop_only_quotation") - ): - rec.shop_only_quotation = "never" - elif all(rec.product_variant_ids.mapped("shop_only_quotation")): - rec.shop_only_quotation = "all_variant" - elif any(rec.product_variant_ids.mapped("shop_only_quotation")): - rec.shop_only_quotation = "manually_on_variant" - - def _inverse_shop_only_quotation(self): - # Sets the value on all its variants - for rec in self: - if rec.shop_only_quotation == "all_variant": - rec.product_variant_ids.shop_only_quotation = True - elif rec.shop_only_quotation == "never": - rec.product_variant_ids.shop_only_quotation = False - - def _create_variant_ids(self): - # Make sure new variants have the same value than the template. - res = super()._create_variant_ids() - for rec in self: - if rec.shop_only_quotation == "all_variant": - rec.product_variant_ids.shop_only_quotation = True - return res diff --git a/sale_quotation/models/sale_order.py b/sale_quotation/models/sale_order.py index aae40d577a..02302497f6 100644 --- a/sale_quotation/models/sale_order.py +++ b/sale_quotation/models/sale_order.py @@ -1,5 +1,6 @@ # Copyright 2017-2018 Akretion (http://www.akretion.com). # Copyright 2021 Camptocamp (http://www.camptocamp.com) +# Copyright 2025 ACSONE SA/NV # @author Benoît GUILLOT # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). @@ -24,7 +25,6 @@ class SaleOrder(models.Model): readonly=False, copy=False, ) - shop_only_quotation = fields.Boolean(compute="_compute_shop_only_quotation") @api.depends("state") def _compute_quotation_state(self): @@ -53,12 +53,6 @@ def action_request_quotation(self): self.write({"quotation_state": "customer_request", "typology": "sale"}) return True - def _compute_shop_only_quotation(self): - for record in self: - record.shop_only_quotation = any( - record.order_line.product_id.mapped("shop_only_quotation") - ) - def action_confirm_quotation(self): if any(rec.quotation_state != "waiting_acceptation" for rec in self): raise UserError( diff --git a/sale_quotation/readme/CONTRIBUTORS.rst b/sale_quotation/readme/CONTRIBUTORS.rst index 5116fef324..0b22e8639c 100644 --- a/sale_quotation/readme/CONTRIBUTORS.rst +++ b/sale_quotation/readme/CONTRIBUTORS.rst @@ -2,4 +2,5 @@ * Benoît GUILLOT * Matthieu Saison * Iván Todorovich -* Simone Orsi \ No newline at end of file +* Simone Orsi +* Nicolas DELBOVIER \ No newline at end of file diff --git a/sale_quotation/readme/DESCRIPTION.rst b/sale_quotation/readme/DESCRIPTION.rst index 2c68999acc..6cd8f3500a 100644 --- a/sale_quotation/readme/DESCRIPTION.rst +++ b/sale_quotation/readme/DESCRIPTION.rst @@ -1,26 +1,7 @@ This module is used in the context of e-commerce project (like shopinvader) - -It allows to specify whether your product requires a quotation. -This can be set per "product.template" or per "product.variant" - - -For example, you have a product where the supplier price fluctuates a lot, -you can not give a public price but your customer should be able to request a quotation. - -.. image:: ../static/description/product.png - :width: 400px - :alt: Widget in action - - - -On your shopinvader website when a customer has added a product that requires a quotation, -instead of validating the cart it will "request a quotation". - - - -On Odoo Backoffice the menu quotation has been improved and a new state (quotation_state) has been added. -So you can easily process and follow the quotation request from your external system. +On Odoo Backoffice this module imporves the menu quotation by adding a new state (quotation_state). +Then, you can easily process and follow the quotation request from your external system. .. image:: ../static/description/quotation.png :width: 400px diff --git a/sale_quotation/static/description/index.html b/sale_quotation/static/description/index.html index dd6a510a3a..3d20041cf5 100644 --- a/sale_quotation/static/description/index.html +++ b/sale_quotation/static/description/index.html @@ -376,15 +376,8 @@

Sale Quotation

!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Production/Stable License: AGPL-3 shopinvader/odoo-shopinvader

This module is used in the context of e-commerce project (like shopinvader)

-

It allows to specify whether your product requires a quotation. -This can be set per “product.template” or per “product.variant”

-

For example, you have a product where the supplier price fluctuates a lot, -you can not give a public price but your customer should be able to request a quotation.

-Widget in action -

On your shopinvader website when a customer has added a product that requires a quotation, -instead of validating the cart it will “request a quotation”.

-

On Odoo Backoffice the menu quotation has been improved and a new state (quotation_state) has been added. -So you can easily process and follow the quotation request from your external system.

+

On Odoo Backoffice this module imporves the menu quotation by adding a new state (quotation_state). +Then, you can easily process and follow the quotation request from your external system.

Widget in action

Table of contents

@@ -422,6 +415,7 @@

Contributors

  • Matthieu Saison <matthieu.saison@akretion.com>
  • Iván Todorovich <ivan.todorovich@gmail.com>
  • Simone Orsi <simone.orsi@camptocamp.com>
  • +
  • Nicolas DELBOVIER <nicolas.delbovier@acsone.eu>
  • diff --git a/sale_quotation/static/description/product.png b/sale_quotation/static/description/product.png deleted file mode 100644 index 9f287fdf19ae229e0a502b12f56e6a36f3f49070..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 156727 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⪚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%0AdcNVd{8fXf9 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

    W!Sr#3L?67;Kni87sL%~1b0-Tl;=ff~Mpjj;) zeXr`XFO>A%61@)>rq#P6lgYDR0nKOOR%Yj_7=9CrT%~7$Jhl@-(Egxw2|GeMDKO&h zbQQOmeZ^~7o>6w2R8$m(O)Xymu zAO#JlpaT~845kor-B%3KwPP%b5cTg=C1zqW1Bdgom(3?8r!ILVbc``b0K35w5<%hR z?@6n(%a^}VNHR54Gghp=`~`Y7o&i;QmjbONGo|k)X2>(uKTTo0is^P=ii@7DCyr-H z3g)Q+keQ6O#eQ}01OV1Qe2A*5?7!<4ce93pR@D%Vk**764*@%pYE=uiJ14aB&wnAM zXf|V7MTG=F6_W;4_l0C+=dDJD<88~5KD4PVdaRm%YXVCs&{KmS9GjnlX|$g3xPh?fcn5T8ZV zn=o^SO@K2t5=f{lvOgHA4b&|VE>5aG;u-PrZ8qGq6#e;e(ORW`G#&vg7EiHOO_cB0 z&+ou|%~wkar|@^46#0dPD0~i&jP=~d@*Fess)yX+Z@iYZx6QeGVD=MdDO3)dgL5& z;EsG}ZPraeXUb&e8tooRQSJ{wNxz3C*M3iaHD2rTjb+YHg)ONEul!s*3?k~tC+(B# zGL?NcQMerZ3RN3o?^`1sMza;4c1l80Pb}OB_5R~O4XYoc&=F}tmM7-I_94z4`82y= zFqryri$LnvSe1G~Yf-1e$tPp(v29AKE`;IRz9y^5i@vYl4DwI8oXGJT^64!GXvI{u zzrMa!#Y<@X{Og_n^tfKCuIV!hkPWA!w+kM^k2~-$dehI#ipsmq`WZ3IkaH$j{4nOb zsj%Asrv&^(Jh2=n8!3QCn#z8TnMhi!O4b*@`#}Rcu)AB&<@>9W4yh&jU+tyl5d(m` zX0}&5C=|fNqqnG9>(ba7!$}yZ{<@yk_=S!4eqEx^G7J8I71nULEIA-BppNE!uP4_WW^< zSQsrnHux|N6PW`$vWTHrbqZ4Tzi5WDP{mW45R;hz%awKln1uNUXCo+0Oj}f~zgIH7 z@~1R-Ahf|Fbhc`Y1y(9D1N1}Ocw2FiUcKlWj%mAkvXH!i%YX3zhdOUuOhFf}_b z<#hV?BGNz67eK~i+K-&z_0*$!UOPsJ|H2jqhlhJ-zrf$UfeNG~C;S0H4J-Ci0L_@+E~s5zkMmeX9d|;Nl>Lzq0t=yq zmtJWZsjuvaye@kv>{li7>go^fTa^bw15*}oCbt|Q^4GsimpEs06u>}2fcHG9maf3Z zASH~)KLu&p{q#(I@fFwdkgTu{Xa3SVmX_bGD}<)q&lZh*F)H*Q75m*1Ou2jA42W~< z#)hT;$F&C1=(i0H>-8sp=<^v9_zncH{5uN(8*8dl1tcFaP5-CT=Gxa?Mm5dcR10r4Nr7{Vl zOg;b;(m#@VDd-0&rlyy0jXwFoL3$}h^DQknMR~-rd9IYyZBY23u+`_vP&OGs;V|I{ zZoHVUem93v91;3iko}OUF|;~=hIV9}7z;RG*#BQ(-@SX^gsB{w>5`g9>jhGw;n1#F z^Cu66<77GS5$((Q8$;>1n~c?x3NWK|s`4QZK?x&h)@&;^lXh$u@o8lB{%>Ya(}^QU zD7iy*p&@nyMP&5S81MzwTqdyJs$7JbnDq_N^fBS1DgYgE+Vr;Mlh^YdYGw8n>y;ZpVF6U{WXO>c?ya&-pydA8 z?GztvRi5%c@x0T7nZxx*UT$z?+Q(X!96P2AIcB0q09Mlsss%8(zC9}-AS5urKSNu- zIQk&Kz~Zd#AwXC@3<)WR8fr&wY)8GxN`(6plNG$oNCf^%nSbxEOh($*v54Alu_-AD z2XBGv*RXnVmMW+TF@RnVU;p`O-SH0o65$Io#1#`zUw@Fqb*lzh$KU(Rqx*RzRVYd0 zz-N43wZ^hsj**zbdL%!w6HTxzcMVr6m|7dgJuETIUt!*IAl^9yUJvRF`N%Un@sc30 zW+D98a&d8yGOZyG79=y6?rv9JRekZT84cgvk_ej6NYbUM0 zpZqlu{jFO4@uQe|*2{_wVvUOAc*L|fy`2vSKjtG7@hSzKNs9q%%mjQW4`Lj*?kW_A zQ!&+*Y0Adca4daJ>Cs&&A6s9MloTQOtgb$ zixAD|CAR8EtgPya-RvFx#X%hUl=}@Nq@?B|V`E;pHXQ_gIip*dS5%Z+Czx@{#>Q4B zCp<`JAtU%vQr){bKusm|KifmTVQF26X#7-=@ z=H%#HCO?vq&Xrnl6*8bWTy~VT^)A)B$vkKdjFD&oMJj!eN~STPiim|$j7_^{Qn4-Uz|&$b+}I%K`UDsZb~h zXi(rM>cfBG>%*~0_dKVw{;QNI`4%L6@!p%e#2Yi=_>hzy3VPG<`Z2Fea)MxLi^8&L z3Oaj&oWONVg*rG^sZipHt#+idfsXZzAz-+lB`8lCs*B@oG8tHWRd0tJva zXwIC@-jzQNsqR!>N3nqs1xW#6J1+pa5tWc~2)7;vckIXmM~N8QW`V<@6MEqI1IpYk zoFkC!MzuyCXlrT$ARkJE{*So~Nokiv z!#HT3+f?Y!D029ip-cLO6wd96ze&xKR{;X$yD20UGmjZft7P8{Rsgr{;Pm-GJUqOu z8C+9W-GGdvl#`RyP11zv3}OrppOAiREm@ZSNt{8wdytL?*zxrne2B7~Muyv|=v8N# z&nhn%cFepHf<$V!JT>jM;!2SZfgXDTgZeqYQc{x(M|L5Cy6P`iOqk}}sAVuPfYb7K z^+Bh2Tt;s&$4G3UUjKeZJBsJgPW1*&!m9iBkoPYfa^F!OOuB*sNI;T7&ZRN2t|`P> zptqy|CVqE^HkuZ&_;cTPWCvEZTA@*bYlO^7A~p0cT;l1aHDC9K#G9tQ7mBo%R4^fU zVINLWMOq5%z0xbxctngLSxC8DldUb!Z*re%R&D%oj*A9K1749Ewe|Hd?4S$mIP5ei zVxCz~ecQYyHa9*_OcCHDNr!pQx+N6(tSQn|yUcFR_fK$g!eO!HsMZvm78)Q`weDkg z1M5H`nG@UEXG#9c=nx`jQZYe_bCKR%(jf%140Rza3qW}ww>x`V6fsaga z=6ysmgoJhVJw-tQc6-HEX0rWOhcCG8y5}2btNh#W-~Z1-kDkk9KSKI8Lzvr zW1ly~&feXv$llbW;;*GAS9EaS6tKP|hS)C>$!4Ve^^4p2wbA8*xoaRFbU+Coep5F! z$qlgoX^la++&_})e#T0StFLNz!f=hG+Sy^d0O;co3i=$#l=;1Q3j5lDnOykI6ck#% zk!MggFpT49(3Gm&KO1C|VWm?pTp#!-5AMWUi;X?&?6hKvLqf*peEBoTxD|zWy)7-w z9`m+TNanp4RLqIzpdk{43O0D>fVlx-BQ_xcx*@MYEjgeCxVoAIv0^k3U;$9Gll<96 z{a<*RFMUWzMaJkZuC6ax0)Al*W>|INYPwc3wFN~yT;v0X1k`dH3DEw<7G= zlIMkqGmQ>wedW2x*yg$#5SN(io7;q|BQ=O>oDF4IgAcdgv&slYmXd(9KN5qdNL+X7rbWWr z==d4Lt61^WxYQhUXK02!;Fwqch1WLdAG}Kof3$7dy$64#Q|_$)^d>1Sw#xoY0=(H+ zvEylPZ=bo7KgDXo4M+L-4Fz4Eeh!{UP1v6)ZHUGtK~B_t0D5`uDj<+~*OR%=$sgtc ztnMhP-b!bX4x4qS(<~POow!>+S9rTY1`EYWxZBr;j=iFSA*PkZXsC*|Beg0 zaJ5K+f~o&;)175s=^3gcZko*a-7W2$ZKzIMAZYRDxG*3sTlMS(UA-XM^1^aVy4DiQ_4C^x{U>XKPPG$?d3@X;CbPJnZ|pM_)@;DM9w>lE58n`5 z-?6&;fh$FJ-XcRDk1v~~HIll|qTqVgv+A-KoIWPWnqAG+k9q z8ZH@JU^FwB+s@;MPQ&-gE+5bSHo}-UDL5xuq%tDH&%~maZ{WVb*H`dGhB_P51y&6^n7gVbsadB5s5c4zbb}$2_n)(w4wu}DXQ1dVp=M7$$LLTYO z_G~h9g>ZLhvR0kP7|dZqhmi_?@NSK*SXKJGWWWyN@Ptv6>-`yjgC3^|XOjmtb z%P(GMmJfZ~aPPMTh&st1z3y~cEx_Nln8mrbm;YfqxOdN|;(Nx3!w~IxPaa$HV&1~{ zs`KT;pD2UvB9cee6Aa+a)Vk}iwi@&$pwqbkKSAU5?b|U zTPX+x`e)K`?5e~LI};|HBTx&MY0MTnK#n`U3lp@kwsaPibC?6Q?L7RQwF>H2 z5)n`)fC({uS#n4MG<(#>fX5Un8zS*9gE_93wi7bKe_k372cX2i%i${M7fkh)gKe7e z(6yzfP+p@58@y95K*$ z*Dm`z;BIByRv9!c{k=n#t-7Txx9WmFzzty`7ob&fG5z{^++ak2sZvh&ytOhg=e->E zPrIJOm0#FZ5hCxuT6hWXi9&C_ZoQRzL?xwI7Qk2pGS@d4B*_aBCuRdnn1Wf&yR81;I@vogdFo2|!xnYYBKOq>5s zaz47R7~*V*5ibIV8X+s^taZXv{8sD zDrP)U_9@skhBrynhs!r{ zJvaz#4ZF=f^!WZp{k7EzRD(HCfJ+};s)Oix~FvOG$$fy{LVb}Mk%q!|98HP68BCd1PMlFk}!Oap) zJ-^$Ty^~XwvGJJEjwKFLcc+f~RAJkWX@}NCm-{mIghC%bsv#z3s@q8K*=}REgU-KZ zZTa{GXg;4w0WdXhWL^>^@cZnZ-NtvFmZf4`)oO%k(JRr$RGWI0*AEgCygdK9Ae6fjf*(sxW(16UxBjRBE-=1&j1hY#e)9aO5{4 z-c=#wa{`l()%80zw*7}t!TIqH`NM=!PgJ*jatLYZtJwITo@Dh3@of19TP#oD`?}oR z!ShZD2W)*>4zf+SQfjLiF;C80Gh*T&5br;r(hW4cOUq&;Rg42xbsY*O6&`_~Rl}uz zw`&)Rt1iuE6OLrPYhI#1TFT-a`-9ZB#PMDu2o-m%|Nm;XFwZ;b1`&RpVJXSJ`Sw9;Gj#d#Es_Qh|0Y>GlD zNDreBj*wm=H~YO9VbLt3gCLM`83(oKskh{eC6XZa=>6sX6t9u59CX>gy`g`nuZC z`RG3+3yPR)m+6_8^Z7~}cuw)9?3(lwK9B5|xA-QkzTO#4!(KSsYrhW+lOLitDs!Jn zZ=NmbiWK{f=5_r^Z0tIw;^5b8q|w-@axBj^+ibm%b_j{QIdjU|EJSk%h@Q<(L=WOHrBj4?r4a$jC*e2WATC1d~_WW4m zlS&Wav(ptmUYI#I^1LOSyF-NDhI#);N<$%BDt+5wbH`kQ7fZxUf3M;$_uHbB&5AMA z9TN1?{eA2YcZ7>&ojIpQ^fyXZ^6amhJYyehuZ!Mf35_G0EIHMP)Tc8pmUjduIT(nl zCl1N^w+t;(nv!<*~3L?dF?~K|vgBJL0wVYS|qncX@O2htMi3|4Sv+`$5k?+UxD5ZDn-yXZ{9M&|I#W{bmn3EqJh1S@99$aLuHH2 z+meuR#B=?SjA848@AKzuVamM+xX0S;g6+I82F`5 z`n+(z!_qpgUA;%%nqz&m>>(=Zs9~lrFOd(Cru8}NA%7`Qk{rSS``CMj8EbW`ix{CP zE^|`AM80+1zfQ`0=gjQpY;mj6-i8~HX#7@^#?r>icZ^LBxjFLWZ674!`T418@;WFcku%J;p?}h=w8LuaM@Wz~PUDYu zGQGF4TEa~!t<3_oEvSh(GDnAV0t|xpFOT})i>ADnsKlrbgQhJn31Z*0p~ngJA#7sk zL0c}dvNu=N1PMcYrhgzHA6tU}x>Fg(RKDUH-o_5C_B1RhgS$ck0LbTWL;kqsHP$y;bpUQ9 zw8^BqE)ZEh_6U)kq}ox$;NbUR{Pk%$qPb5+wqgmRXj1e~#f7v-Zg#qAR`eF5o!K4` zm0B&zkyN0UJTIFvH%xv0*0=ssWlH4M$9TLc4F3X_i8g$c^EP*-p`)g?-E9p2CIl#xJqfAj>kaUH?~&J zt`$r>rJhHL6`+)ry~PVW6-tUMx?yGyND6JpMKKLow@wae&j5`4oT->2bhD<($asud zaH7S*iuX2GJNoim3Ae#+8~c3rQnxZs*hXeKAp0r}OK(bNN9SXZcpi7ViS4Z?EA_k4 zPV|GZ2P-}&vuVe=$@9L#C1d?-386P`ybc&Y6|N!`H%$C=sGsp&u6IFOo1h`w?^7MT z8IjPwdUd`EgW1an)0ey4x!xY~ZdXJ}*#td3-YPfNO@a?7dzATlU)WEncr!bQ=IW@L z9&;LGl4?PAi{s?4rgpy{7LqSWoc!wXM}#^4oepKA?qNCyI%*w;Clq zrCQ;dtoFLBr&;Adp{W5!Ob(^5~h{wBH}@A|_(-1%wqFc&%=$xQC#j^To+7Tz2= z)Vp|1;SW^GJ!{QE^&9!^e9Lt8n0Z{3>@&P8$W>XiI4KX_@x45G6#Rrf=i0GC|D+fG z)VIBxDs57tcoUUEt-}1)wr@(RRX#fw?^3|Z>+|uQ2Y@a2^yEUpfAhv`Vd+p=T znRaoxiv5`ki?sGSjVwJjI_azx+@bko>XvjEfVy4h+i^h8n1SZneO6VBu02*EPsJ_? z3so}41th=)*gcFz`AC3tx?2|qc_gPrC2~Qy{jTu3az*SgK+ahByZ%t?%G-k&fmFo~ z7&-Q=pawrMMBVZ3)V5)C#bmF*d3ilr|14xC&UYFlThLS`MJRat0O=vR2kK{(xj7bt z2^UARv|JZw8KQoydroD(ceygBHy5b<(2zE!QtzNEc)WU-p7e~9TEuHEMks-(6#c3gD0U*LdN8GC#1l^5nbqt$-X2daZD z?)(ICisYP0-Vj9>dwct4sPqJ9KzftcmvTm*9AAu69#ARnu%2qraEw%~LwYYX7^)(Z zagqmb@{*O#OZ{$c&A2M91~yZlP7aZ^rjc0w_zgeNZF){%x{fsJ704=mMS( zdcJ;<<+GmWjFVvPjL9go7?Sco!i+YRDjyhs;o|zI=$$7jVqP|MG1e1xxce<|;$%iZ zv2F~Z+uhsK#@4A=+{;0vOZjY}1H{mDl;4hZgCi0+SmX40x;*dT9bm8l1?xv3*8${1 zOC4J=i&iZMK;fZWnTUlfvCG}(LnI~xnja{!%)`aG@AvaX(#y}ifg^+kw|mIoB)iYs zu)aw6aqK7W;fD#D$k1)0O14tA-N0`gE3hE9ZaJFm z!0_@n-oARVH0H&@n$l-5(-P205Q=yg5CXGyI6XxUzM5c{`HcHxxwcG$GLL zHF(kqX5W5vO_g~y(U7_8b+%CzK=(X~F{H69=doYczbzkHE?i<*>Y#97?2Eb~T@|5I zrzjAKoQJZsj$OsX%#rtPx8HaT1#Eb?apj-sh<(Pijq!t}sa;m&HcE57He6662J!HP z*{@DkwCsyZ-$$a^%2~|T*C$P-$^)OnTo>g0n>T$*d^BBmigkHCylT> zwC1DRRO=2|2Csz#;LfAs8$-QA1fi3zXNYNCuugPoRMY-wm`vN%Ei@ z+7aQsJ?-0RI}6rd_Vq9pZO-d9kNwhT;JNZ!z9GNfe0wjQmbSLy^z8Kb<`n)`-9Hw2 z&--^RUGEMFXWv|Yl)ljS( z*D|)3uHMfe77X?;gmP#r$TCCTm1(GnF_{t8mN^}GD$>YJW0_Rg!zfbfIh!V?;s8XrGuJ` ztK_57s#@*NeiGwM8k0P0e<;ySiczK@1NvRp29wwBj$ud{X6|-*nG?TTd#>I2V#XQ{ z;_7|0c{pshxymj@V?eTAi=B7xunvh5{>(?KVICL)-&Z(dqKGc9sm=zQwqu#GCR05&5|qjGiM*oq3`4LfEIG<$^6ozd(-SG)kI370{_M%C-KW&dQS9;lnhCY z^=fUl77&9FF8E8S3(XOTp|*K?aw7Re`188Be^|;A06&t;a_TLnig~v1b^u*bRwju4;gR7*d|2%!gQQeuEoP8)qbeemtEp;+oWyWt<> zHMd1**6A>2Bj?`aXMn)oEVmAT&2BfroE}w0=|b4;t@4FaBgKHryFz7wUoM$CVJR`6%DJMU^K<#@Vq%X>VXj|7_{cTuJ3-P-JN> zSIZoY5*X3g^5kBV6@uKc&Fo@|H#4K{(`CUnG7%??Y_prpjiFKR+I%d8f|u4jeaG#+ zSJG6;zNKp73$m*n zRXQ0H_3m{2YhB&Jth%_|4Td-~Ci&ne=5DZ(o~cNDgNHj8gDyqcc>@VQK% z(Mf3c4zXFo`PM!8@O*Uy)JWB*=XrBy za4d3Cx#w86(Ape9n6t&#CG%A5!}usF(VGU_rXLzC#PYxGE(_htRNvA_8P!{)dallilAj3l3Add()aHyS5H z36R&5L=C7k?6}5xqQg>k$pmW!LfF)<^4o>srRW+wjm=UNIS&fcwl|t$$zDH8o$!}A zp2Igg{JYzE?z;d0T{c-dY7Z50oDtr# zUvppQ+25A(O-8r9306?@`_5XN>f91~kIC&oU`|IXo*1w*-*-*!3c113iao<&!nSUP zB5y{<3Ym#tFs+y?0SOh<)H<${XhPzGKo9>hyR4k%ZSC|wuAZr?pnrqw$30!e?5ED? z*EzZ)Df_hOrICVn)$~JbIgRN-_eDA6ZljouysPQj-|Gj%6 zznmH0+ui$BW=`dokUrrwGUAX=?Q*!TH;jfBzBEKry{r~z= zE=~mQ92uu@v#O75{u;lvka4iHfLL*WzfWxc?AJX}4;=hMUTA~wP2EKa%btc-NDc0D z3JjE~dT4Ewgyz5U5hqRl>8?7sI~kEOGD=CkB|Z6qfK0v&lOl*N`8N^r8T|m6G?Xjg zc_?RsN+2OM3HgVb|K49?^#4s({eQeIKkh7#MK^NZqTl{?=Ye_>>3p%`eJrI4u0pMN zhfwk{*_aaZaZ79PF;VjMCMUmt>3!TrM=(za1=8esyv2#Rh;pf#{nk^lhPvI9dRH+# ziZ~j$GvyOP=2^ie1LrS)t zcvAEb2y2%Avhnxf%(oaxW%%0}@C~{nxyk#v@oT!ZFi!hxxBW(x7m{Yuw!3eLQxm4N z*l+pr#%cHao~`2mm5Tx>ZtV618ybjD;0aXUMx<0BHdYT@@0wo}Qt zlc>qN-`qQ^oH-)walBp8)w-2bvQ-Fs+4fG<4h;`kQCSqc8osXpTa31Y=PbUZrFX&s z?(_H4*xck0*&}OOzFl5dwpjg+!Tpkw{Sx_W;ke&V1(STS?C%!x1}=`%be*2s8Tp=d zKtk9?^ctLQP{9$^jUfE7=|&>E^@{8661D!)(k)F5DiM}awoI3CgADV}FO+lu?WpN` z$$=N<;q~Q`eE<$gd##sZ(pML|$ClEh58SRDV*TM=m1FR_wX~7ypDHp$D9@mpg|3;i zbjtZZD)-_wlHKiwu4{B$&IaSlQiEdZQY>43Iw-j|dZ6EV@vbi#S9}~IdB>gpUP{Aw zZg564FRme~X;48VhRwDFYr9|oVUBA6XY@r@Hy1QF7wAPgwf((-y|0)dcs7$r?8QcC z7$YUW?}%_PW$`S@jyYpbrbyaSRT{?6iZ`&e2>Q86snVJjS?}33x8`Fq9J0)uw|(F1 z-N`H41v6xC#aF1e&I2V-Y?OgQ?DH&u_qyJXhsFth8~#i2aWW1_u*r;!&X>G2DcFsI$1pMYKg%jGsZLF=Hw?>*(0qutxT9nDGn*%=MrxkOP zntM3pTaxe`qkVqMR@Y|V7|{!=b0<5W(PCWC)fok2o%mtPwXn8j%@9UKYh`-0 zvIE!cSfFAVtVLh8e>tyG0|N@P%zOtEtlzQ|yT55&dHeCKWNe-~c=mt2*k1yV1owtu ze~yT?oqi%acx$!)17AWNXbN8UtHeo!UFF)&(h7Nl3MKMvcNV>_`j14t*a44vlICt; z2R?f4$|j!qv1$U}A0b{_DXKJJbeaag9th9ci+!f!u9Th`r~B4-LT8xq<^?bt(Od85 z9<-`BGR#$JG5z>{&}bT&g{tRemZJ`>$_S{KDWoD$=l!bF{hNra?2!by;Ka&zsmU=K z?+w)Gp>@NCX+#u!uMkH!UgFP+n7rh&j1-{I?w4_ieiOP;bW+$J!!-VvEwRzYxi%lD z#tY_7UasKu&6^2d=(%4-wMQ`)SniPPu*fQ{au+GIzgk;g)0l92A;rh`K)K_7@8g3H z^mIUCg07^yY9X|;uP0G%h2!y}dv}q85lV+Ax{n>=IIa!^#>_SLh`CO3h@dx4w(#0^ zMyO22026Tp$XXjeB4K5x=}TK4YuQ4wmF7Ke<_ejW(_OpMAAN+OgkgyWuYf=&)t$gN z8Ypyg??Ci@Z6p-x@>0;o#ycXNx6u8il1h_?x*jVu4_z)sIc|6`**ccnEh+6Yd3uB` z8V=Xz71*~>*R7LX9-gWj`oXkPhY7erhH_)#^e(R*^6+H944a3GH{)SoMXFQ072ehay$tgM7MQ)pqWI8PmJux~fNHL#QIb3prIBv%V8FS8 zwt><}X_0zp9+SmyB6=5(Cp3y7aE%hFexYW+t)q*j9GgJdksm7+Bj3i!kR^{}RAXBY zDhcO!LkUAmN)p1sW8*dUva1zjcQ4^l{9l+7Rs@@mhKq-q0<{<>CaR9-eI+b&bu#+d z*lbC_C9y(Lhh7awPF%C8mG><_Za8$T4086X#VX%~aKWP7uB}qU?i!N^_0w=o7|Q~=8qr+u z-J~AGLjB%>;?|hhMHPA6D}@#xZrD=c!Ox>0Q*%#rpc($$=-Bhy6EgvgL|K;qL)=?N zwbgauqO`n}QVJAzXmO`_@wQm;BEj9A;!av9Zl$=py9EdkN`c}o!Gl`}4#7Ej-}ip! ze&ddDf1EqUId_je@*~M!d+ojEnrp5(pZUy=)7wf6OiK{8Z=5HzKW?eX?|RAKs%yBK zDf+XoD4kgGT*KLqY?5>(%lPsbE~)79;t2C83DZ9d>x0vLeqIi@O`q;>F*-OXG8c|p zT}Z1fQb7ozarvw&wib`m)o&OuEk)AU6YWmFCN|goQ+a1nVmh-%MBz)UkdnZxj@Rmk z)8$h(m@z2ttDoR-XST>*N}2Knd3nVTf3v0Qp*<@E=1Y&++|%h&Lrm=van>e0rwPl? z_JDbWxnC@IR*f0N=6l!+{DNY~!zGY0rMWU&D>dZeSE#ea=e@Bwn{*j0s^ml@9$a_O?gYta#nQd);hvpz;#sm=KpBABEWWUB+_Y*MXV5 zP;^Fm(O8~PlW?-KrtfbgcJ^b~SryUr1PsD*8M*JWTad+M-HS)BCg(yoV^S~}%@L3UIsSPC?s`=hr z>{}HMXm75px%2P8dwkdAE+9tO5YXnKx_lLLYvI%_;#W3vwkA8;H5v~$cj_C=%$TmW z?I$%5(SDtJgDLDP-BT*>cpq@DB%o;*Swju>_P?eX{bEDQ982+;lfY1_S&*da0&o=+ zecVw;8Mx*Fv^y$N3F44ERIn;40YX*u=pbyjuETpMHcpvf$-}~-+4;YMI9z)V5zX%D zp3VrX9sns(VuoFNXszLL?}8R1qg6MAy1Yu{*?vU>&H`A@E1J}C1eyJ}Er?FGcPNvT)<$rh*aQ3KTNo}By_jbap96L&&kx{w{ZW_t&Ahnz! zudBO&&|p^K;M7PH=#|NMW2VVJGMs*SJ`F8A?V${d&D1r@6ZA`O!yU-qce0m&k7wsV zt!!_uFAgi|RilMltYPbeM#j;NiR~E7gn4uUaId!5EnA;v+sR2y86yJ@GP}1I5~J3% zD`!*tFNwY4aHU@M$dFs(<>&afo22c@ z?NHo7%%0%&A)9MuylPu^)=P4_{Z+1PUTb98uBf4}Uj1NU032^;*XzdakF>K#XlYT@ zZh|W4KsbHLwv<{*x+ne0Yj=L-7#6pEAR1TSdHXmr%r1HNuo?pQ5$BA~VQe*J0CBc? zofG1#wQ2n!HcyeE?vz-P+&vVt)`C{ce_p+}sbI2#mNziYcI@1}?J)`o7ioMke!HJg zMG10$PIhD@n!O+YaWEo0e(t-c;|?<&K{^GI%EKNQxf&Pxtm2hg#`~n<6MJqsX1t3p z+u_sgZFpzZ0ftfUgY#J>joRHi>H#{A>)m$q*U$K8ZoP_vF`M(5F844hN*0X!0t-Gx z$0#ul1&(f0)qzI8ZKqsj#dOR=hc{prArno_XIL#ckTYrN%Jia?;JCy49TrqS$S^%l zJlni1&eGzj&sIhxDUR|Lq&Lae-&`N|!Q1d|ug{(PQ~TtGegwW0dg#2wUJL*smM+@o1Z%q0j&P~KyFp$;tC923#m=P{ zpt(lu6n1o}88SMF`^%!2yhjML0et+rZ7%U=O94)VayN%VZcuL0b60Sip=u?GEVNVJ z`ElXV%SD6z-p*sN>93*`ZA5atuaS}Cf<2G)^dEKpfT5G{MPcgnCFOBi+ozVE3JN$o zC>K{o)TThTmk!~PTEgJN1AK6;_fgc=+i zFa12Mtxdc#(C)r!pc&C$oKQh5r`oxUS8+H!ShLl^i(UVHIYYxPQ_6bun9t|9LKRiU z4z(TH2lyGZ=uTG}kq~=T^)PCNg=-p!G=C`zunO-`IVta~X6V%h3#oCIH&lhxFSa#X zA3K@SDqjd%M5Hg0YgXQGoF`QTa0c_kt_Gz0Mci*TsV}ytZtzZg-lh}r!hH=`Iy=1d z)0diht#fWE+@v?>r)4hMuAw06lhpa4+$U|n8>mFbj!ze=e({fS#jDCKY&vsTDh93foS{^_~7jFz93uSu=6$v|p1~8b4&e`J$|% z4M#sYHMLLj!PAUV$dB04$x~3ZXU7QaA0~8;?LjcKbxP4{V7t3+`)NL6z)}_alchMMrwF}0I^)9#W{u1_^ zFzFOVy3t>fb6+{-qucfF%bo8yTwG2BU!6Kx5J~hgpD+ql6Ti6ee1e<#vHHglu|j|w zGy0(7XaW|4N8{U#NR@k!^WClAk-p_>EJ`}anc@x^Eyu<53Mf%Yv1UI8T^|4bm?3;D zQOt<0th1t7Gw@8wEaJVi|Ni_H$1Foj8qMw8%4j;dZt07p`h9E1_a96e9bY!3QRrsf zCe4knX!YzQ5ltf@m5_!g@af<@amG3UHgU}fWVsRojIxcARi>mpu>S1pGW2jHIp0oS ztZs8P+kuil-od#U`(jl+;j;t}>_B1s?6Nh<(!QSRo3XmsBHlN`)zNNtdzXzoVQhjb z&ob-zxgSraV#|xNa4Ra40WU6^n$|rBmC3DX9cl_R`?p@SezZ8U>e1RMvO0Etu%Js^ zY0>*dDJ*#A?|o+_9>-3ozCI{iLQ*Y@EZ1LN7XOu6UtKJIM!r$jr_ycAmAH)hH(V5S ztkFmCZv^Go)l_V6sCRb{UhB>*s?GG{5JNBjsfkysoVVuL%YfhcF~|ew}w=(%5~(NmI%dNqM13(sQ8GeBYn6d9cp*L0Lao z`^N;fj8L?+atuD(mg~3DYHZEC#=>Z8-9+jt2f8bkAa~SSbV@_|+V0MFhwvnZ%d`Q{ z8Je6<*W728^|=s6U(8FgeZItv8#yY`qz7?n8r{L_!d(}U5I9*bClb2YWC5>+$%r$D z5*tMiYo4fAn77S1ehezwQC(`!JX%p;hNIL`Chc`SwD1*-dN8R88p4u`WPVhHC2sef0>6&ptCOE4_VeS@StDSPc`kv z2T4V>dk-#t%2%!)HvT$n{MU-m=zS~8&3OIuFyjTEazO{UQLTXSo6%~;J{2dkc%90; z5_7Y17oaVm__Cjv0^B#7sHK^pqo{v39i%wj6P>HcVyC*osO^;{?9H^H5@?B2K?eYw zTAY3dRkPNV;}g}fKc+XH=7Hh?&ry=~TRS-!t1>xsmLA4`A!ej;sS{JTzp(JqSY7R5 z*gCp|S`-H;Y^kA($zyPm*ir-fM4(| z08QsQC3#b(38kTin+-*7nIa0v{9}pp4sj}Mn}9zv&@Xumh3R! zbo^7VxF;|(MImd4z3(k)f;s7^EYY+19wQdb3yS^E8T8n_F813wEoDawmhNtp=)`P_ z%V&$NnV~ZuW}1$RfJ>TK&kvw|!=Dq%B0Jlyr1mwu`g}9q1I9Xm+K<_mCu=((M+sz~ z*!8pzCsl8aTbNnXQu?`_&shn&*ZP!CUu3A`GB#+tLaa=|v9iM-zCep2Siu4$xqg$z zVU36^mR35gCun_dAeGJi3ZGR2bfj0;{HIm1VS)y%9#40+8F68qGd&AZk9a}vx(jOv zyA2M*XBXI5xf_ty21o^8sq2!fKEopkE4y_eE(5<9P^7Kof#erTn-uL+sEV44jnz&4 zO5tO(BLLsRapnGz8Z>TH8P^nQwbamy7c0n(w2>&J=anKCK2=tNQBImMYL zBrc}kS6yY0!Us$_m0kGqBwXt{0*vQGHbz{bFG=v)N>{tN?+B=s2F%f=@@SQ*3!xOG!~7mAdTghN0n8JUmvT%cGyF>fF3JlFAES zR)QKT1gZN+@WU|XXT6wA1vsd|fztDY$ljGO+MwThs}S%-rCSf=T&y5nSSt8R zt=m;S?=5R{qQHSbsl0}HXt+0*%wW$N#|sK$f%961m@{ zL)N9$$~0@2I!*Ilb8}YVli>gWY|&*vI9cc$^;z86z^++;dS-okLn-_mk`&$fNyr<* zT47Okxu%Tf-_D!-e%=9-%m`|Ltvq9UyfZS8Mvw|0OE1FifN*i4YjpC1D<7jwF>F~k z!w0UyG+MpT$i1edlpBLY*EpC5)9z4J8VWcW;KdE$lo{qEvYDB4Z~w3jPkNXdB^v`C zh;^(rA*cm*b$(xee{XNi^KXQxU94Z;SWvd&IyFL^j}&1bY=4)Q`1!vKLFkLvBr<0%d+{0yS*Qb%q>yw=e#F7cy@dhE$7!1lyVB7U3@pZ{ zd(a2vv61EUc~_HOD1xJtH}2dIqU*JV+_vbKa1lHRP{hE{>O_@VY^E43(* z+qRZxA5*avNv!Wg$tCqX!@INywuZs;MWn~LXVY8De3eHxrK+Nc!u-k;44yq}`Bb`ZcV4U$pVg$9l|=fdccpS$sfs3>X^33F z(cITAVA>eov-~ldpLnY*rG_@La=KxfE9zbA#F&ludqcuDazrjj@HViGJbOs@;sTsA zT3akjLa;TGCFiL%muBk{Ehf~QCsjf&PAR2qxpV?l;p>uJD39iC$gTg-C9EMw`2B_T ztO2K>0M7*ei6uq~{R@n;hStLhm7(u<-J;vn=F9%W!#E6dGJwOXMYR;RndRVgomxGN z3n%J=f-2^1=d5TkCz_=}DR_w9SxlyV6XN6D=!3`ht{IciFBK^c&0gyhRey+iGY!<) zIr_@S$>LW%t(c1hKRlVyS#U0LCmc9_{Lwr)gUVd zm1VH;qt98qk7S|H1_+py)T1(1UF{Ham? z(UBM)K5M#Ws<>otD>{uHtf5i%qb{bDSvYvBV^58e~(sU6hC^omE zJuqPWY-le`U_WcVFPBu|l|9#RnW!{SR%Q>^0sEwuHAwdedr(d=TaI+yGjE6pH29@a z)<9iPp%hw_LR6wxV*_@?=!;*UOv#>~&H%CG=s%$;`#WdgEi0$d!#8Byi1GRsz2)JYx#|B_xVu*7xTk<>jS5;M8 zQ!gfJ7MU=DeQ_NZ-1iVp1%b%1P=tuGdgh7`!^1DIUAD%=_(VOjfwyM4t;ZwjL!ar@ zfzh`9n4GLpiyoad1BEOo76f=IUs_+z`#6bE#AGkWQ0e|PUSz$qLhVjF8lf|#vRtAt zdMNg)?%;Ehy3EM01>WQPZ-}RwHQb9=$ecbZN3cMJ{oV^@;L-bgRB~Z5r~U(_7>`J` zREeeojE1s~N50sk$Buwgick*wB9uXcAZHwlem*PA8F78z zcjT>lOc-Wre8IoydpO*Ry%@{-PT8@7gk@8*LVOXot)|IXEzrJqS}M))*)WYwbScr_O{{iu3X2cD*N(CyO|(c|Slku!=F5_E zsys`)JrH}gA&**vR)$FPyU=o*_aALc8!E|FnT-^e^|6xAifv zuGU>u4`I>POpuP$RL_Lv@$TyOcKF}Tpc<0-2a)jAt7OA$M>MdSd;i}R&PXJ>^Y7pP zIllh+Z=?dh=YJy={uhGM|90%}7AgLVsR3<#?!Q41|BqYcH@Eu_?CIJtcsHAr{0r+w zL-XEgM+lcy6|quat-P5t|F_3zXhtF0;`}&g|2Ln-|4PLC|4Zlo559;0t+!J7&h}Ni z{I&3QM}fTBbrLVJ?d|(Vm_8Y0p8DUTHO}+5n)eoul`d+Ph{_BYpdJ_#L{59SqulLerzKG=e zrA>GN5Kkze*U&Ak8>wY=bS~E z<~l)L-!tR=Hg~w_P8O*KhtAAZ59cUp0_erHi-{C*Dzn5sid^jOnICiEwh|@I5YLEJ z2cU%I5GX`Ka}A5-7e<*E1If;!6t2>PlZn_^m7O;kdzsfNde?r&j-j8j2~UPnBkavo zxpf1RddAS4hMjdvcO6bk_&6C@{qW$VR%O0N zHA(W!g7*!4yn=1&h1S&LprcZwl~AN_awIztH^;B?3f4^O6Qh48#oW>7=9kA2h}`#R z%TK`e;naV;q?hx!nC*aheza7$DhGOopq$PVZ_k{Toedt*2zeH8P!e~Y7k2KzpoNfg zuQQiV^ElIrfB*5rd5&@5c(Vq(u`{hA0g07G;RX!dgM`% zc)bH1rJffUp6c$SFEvC)R8+6%Lp5nGl=(=7M45&9YLD_MuQt2@J;Sf?M4^{3BQP?k zTq6g=Z?5&?mh*%BU?H`e#4fBVAUCI`pdwED>`=3Uy!FOl+(y@&v8=5wbF#M%b_ zq~jGdqb6l`B7>tNV8wD}w3>7fHYr;EqJC}!2E3Yq`8i|b=4FN9?k!e?Bv>8x9=Et2 zpf;9hk68XLAL0#O%Z|+&)d-fYH#!8}RoVehNg}&RaS^k#u*IQ?Wy#w6T3hylvwcHs zO%@2ma&*o}IUvB`UeAO4aVoo~{Pw=3u^{U2zHICvNXSTL-G+bnpO%cQyrLAMT!AV| z``a2UQ8ThS9i>V7NaxfRf8(mouKrf#$Jwq!AD2C|)II7ro>Mkha{kPoSB2xY(s;4h zi6-y?5f*TRycTI*cRWh%aG4=z)(Vt#nXT58S$hzszl~0N=+27qfduhK zHSKzDIxR;f>v~U4DnI~Ewp2MhCw$Z=Io3}lf=e|&0&ubI&O8G3!r;-^J|)2Hv%W5> zf?0G-Bwpgu5@dG>Se<142ZhD_h5gHW()!SPXplXeY+ag;PJ-PP4kE;gP7X;Zi2}Fh z>Xg@vy$C6>!pm-dBU7F44BzaBwFYbh-Nxh?xt7J)rNiEoL+Tus=0ddDbvd!U@DP#b z>^#>vMoBqiqUXKiah`?j(hJGBDtf0u!yk)K!d|3n*k|I*2cXj$Q)~W~(9iJT_|aYP zXgF3Zqww5qJ)B5rG>d&r82*C&VoNIQlOOjjFuh>r9bPshasR?X0bpf)+ zmTu3MN1*Ryu>*Kq&(170YO-}vc}VIPAYiCCuVxZ)1b;8xp1z2~2bLJ4i;d!p%CTeP zVvk1cHe(lske}T*S^o%AW@~c^52+jT<=6nvR0j z_a?VBrjU-ktW*zhdQOw}nmqwant{MYTEGQU@F1d6jjz$|VrFB|O~B*jyFsVV&ObUE z0``B5#clJ6ctRc~GvO)CV}6fTd*z%O?F{F*C|%StsQ5U1dtJH;wIB8~k|mJQ)k0xK z;yn!!{WcglSFVSzNQKbx@zr)np1G|&?kq*v7X)RJKwPzqWvpt0ktrSjcgr^pG>?ygUaQN^IVw#S)25(HCoOIbd(d72@Riw%j< za}5z=_kc%T_&3F7<&*ZsBrO9&5uiroM?UX`VoBN7QZ<=_$apDWN6SeG;6A9NrCvr( zyRu!YOYd9{Wa}g<6`NSb{> z)u63lrR*x>LXzp~w9CVeX4OoEtW}FXR*liDAx&2+&X0!g@)vSOWt_}YYRDbC)B%$> zW%QjQo_1Z68d=-Tv%`y>tIcOcnP+prS^q0n9lp93A@!m`V z>8F^HG&a}FSpRwXF#(VErF=v<;c}bcdX)3wa*HF--9K5W&>k@XhJJlAGHh3(30Rry zuraro%vuVnFQ@n*Gd5VT{A(@vDUOV^w7?#IhPIQan|Og{9-TnrbDFc>a1%~hm-{0% z;~e)4kgM#}f~{J;IV{dNb;70V=Nm36z#oKB;~U-+6{iojML{!fU2yhbBTX#(yAg_5 zX^idQ2se0Fft&yzK0f}*UKq*PB1wiWi$=IW4E0lAT-wQbwcu7DG4XvTcid9+P^C$u zl6Zy}rEqO)jNUS-{r?MWjOr9Mw6t_sH(yZbnz&?RaO+uEd>g60;GUSp`R*Ij*`~8Y zKsQ8~TJ83>l+FV&#|4u$7IPKx+I=-82f>uxM+QQLP9kV5&o&@dWZ6H zx7r2ib9xYZ{>+jY__(J-lZMm-U1xxvIgB8Zq;4NvU6v)~1o7@Xo#ZXTvT}VEEMXOk)cv&1va;yS`|Eme1K-ygTEGPrS7=yKsUaLJY36@W~7c z{HRJ9%N_mBLq>&oM^!c%D>LA1`$opL%(m(9rIh z--;+R*GNE)kn;r#;JtyV0?^%Y;E{w|_tDDvkBxT(oAtjBD(N_L(ClNc^7GgGATO`m z8<@??(=3Cl|M^<4bm0e+7B9J<-HE*0ZxypV?p2 zE!k}}C#<(n+Wu+8`Ia{AmEBwEX^DP^(P&8_hbSir)M`Fw4y_@9N-1l<*iw%lzN2%U*m}|4z(Q;}X==knLaW0# zNa_@9Fp=?3u6)gevYqkNlB&4#L5_C5GkdVV9iH826Kr}6Ou;KXou^#8c2Jhl?A%6j zlQ%|R*3N4SyXh9b$%3TwOz3C5vD>4&*-YT{i)HenunV(9G4-!@h9oUCk?|DthQ~Fi zJYH`xE7ChVUYW`ut@!*C2X0A6jT-5Bo0M2zb=OCuSkmH<*5B8E(`Em)b$uNmG6JlX zG*nj~On^Lw4Rw~Kwtm?eAU4i2xK5C|b%71j%t+T%bJ17WZkyy)zVBm2$ALtjYUW4^4aO(;R3g6Q!7tPg-{gdmgO2`;XMMYE0^R+Q7<`e^dlY zo!UcQ+s#ZCpqP4@JOs@n!Y580w(~q${Wm0yCLu`XoI@)1<<%KKd}6Z|d<*Uhjy@`8P(X+ddPMV}B|VOQ50M3>f%#ozL_zsK9Nw{Odr zn3I%U_PU`D7Ow_SGySAxqy=@J%Z#lik-`v86BbJopWCx~muwmC{m?4%yZi~dZ<~XT zuiMV3xd;|%g>DpX);(Mi=gbW|u}7h)37}gRi%Ml)$s5a*&%@&iD>t>i<=V0jdN2J_ zLsg7((-4x`Md{@L_i*N5v0f@}S}gB{j>5FQUrG30)iSAYf!$xTH>VpkW-V8?a^5Ca zjZ-XjWYx58pS@hRI(MkL*!A?R&NO&ON06qkSNl=@1CMI)LGa@1w_uQHSWl1qp`MR#XBP$9f=kqe{_VyKB9{BaidbWT) zsQTI`qU&yNBnP7UTw0Yv`y^2k<#zg|k-KwV@8ZcR#hKDmL}S>rrZuHDYNv_cc6gMq z)iWO^*t{No_R_Jq(^PJ|+Op!7gM8y=SIJ>?a5UZgl<;>PW1RaJZ-y!&AAX$al@@Mu zWMs@e39iSFSIdS!BWDE0%}W`CnLq|_r_AhV9lUi0gwJUf4vy0B7p*wU$}JFJ52Wv| zms_5mRu^Gi4{7!dY~?S;0-9HwtaJh}6XSvShP^uZbXsM+D#Q4ML{|)Uk`AdXJ}p(_ zxHUf4(@hHvxtJ5n-lC@!RV8P?Fv6s@<+92ZBH?#sM)}o$p1(i=TyPe%-K;8V3_HCL zUTYN>h70H9y!pXWh;-0PamU{JTiW9C^1;u_F(_L^N}7!Ti^|sNT`Z4g=KNTPz$^EU) zzx{P@HA3Z%GBlG0c4{oJdrEirOV7$823)_n!SDAlgR6wqnJlXfT_u^fdN|^9hlf~K z2NDHdy!3n}15=0|cza_K6zAlKeE&u}yoZjiyt!&YTU;n;O&nBj?b67w&6n7}7b5YIJ`wX3Q zcJ>3fZouZ^NLd1f>gs#Dt_gyaH!vl8>+AQl=`%>md&J}upR_c_7XZhA`L)&@#45sp z=J1ZM{04VK)}L)Dt(O00b8GTPZCH@HtdwE`KHK1+Pkx)9noO&biGY9rxfwsdfUZPy z`#NmYIHuA-g`g~yEC!V@c)XOwsoAAiqh2$Sb15liX+9m4?FbT1d+xQa@nXxY?L$ti z=SKLahOO_a`f|hXNG{BC1mT&8mvi;!i;+M-O)Kh~W}U#jxviWV_qja_y0Ss##ARnj z*@}_xtjc4f!ftk+$XZG2s*6%FQRu^&RFt1clY{Mpm)XK0tDzG(y}@U%nD)?cQoFf} zmDGO8Q7O*SdbWi0EgMAKPgHU#OMB_6$+%+qlEjWt-i6$+m87^ZJ4_ zeW>wCb24v8oh`D&`mMU0H6NGQ)?HASR7G+n^^U~d)Du7EO37w#o3jf+ToOG8OHnH#%yjx6yzJ%&me^}dHRv)tFWjSnj(ixC>>GNZx`_O4-slEQOj z3PhH|Uge9N!Z(&^;M=i~DG(OLyOEiL9ow^fl~C(OS_^=jKHDVj-SJeH*zFTo;7YHN zdS8YOnVtv*0l+|?B3qtmIen(762HL7s%%U)1%+)o|BQl?=;)T7sF9NcHb496bNg7? zC95Bp*$xsE*g6&U8I1I^5Q*74FBY~WWan)TbB@%y6QC5In-t0EDUYwI8}wel{(UWR zLI9tHoW+zwe`Tl8C|F21t6}#R9-1`eE7X)kv;qmduqyrtoo$xsGf_lkt_U3RIrlDo zQ+AkEAgJzJ`cyIAc=8-U=Id1y7{6R=`FQK7PAZA5_Tl4Fm8c~FwxPW}LHJz!dK0`P(_;f7n_Iq#hY(w@ZaeB_k$CaNHN4!t=pB?~@|*EQ zpJ`{lZ`~^4$?{j4Tl#Jf-Qcr7Uown_)fq;*?JkWNcJSM6MTRR~O9Ao!`bLIMOUoz7 ze6!o+C-g~un@TcJitFOs%WhQ$ler0(;&kALl^wB*OW6y$(Vl6|!cXC?x+fopT25QB zB6=$wOV&CTo`KuiTCQ6&%4W1LK3*rGudxO}YT_8>dR}$^%>B#~9L>SaEtIQUAx z?Va$-;hF{=a$W5A+*9ST=KRvX8qdi*3Ev`34Hr-oX7(ddw%C#fs=BjX5>$^ znq?*KsV~R%i6$PCr0`k$)bvlf>eK!3{bsCI%*O?SiV3rQ>t*VhePBX)v{Yx+yTlqo zk(LAvL;PaiqwN?fBQvx7F#;5>>N-OHI_W8XnfG75^7c-d;T%Na;opB^myD$-iaixC ze2H&Ox>?f)lW;pdz6<`H8lVYAVot0UQc|G=xsqd?)b z5%M-6JkN${z?x;q-U*a&B$S&1&dSvZ^8cgi7o{X63OXN8Dv_lXrqb9D_8f&~4NEg5 zQr){;od&I(ZO4>$=qB)5BKnLSW+*#uDg@cILH91oy)(1#gR4fP#Ot=c)XyLLNM5|P z4EcGSBbh|M1L4cHLhB29_AKjhz8uCorW%Ki?p%6zbQi@xmx222lMI2x7^<&L%ko3H7Z%;J8G@HG>c8+uCbqqND5|TsV+WD^y#yw z<5JI@KZx%{#OvLlzzsZ_S;T&aR#u)1RlDK4EoO*cYqY?Xnd-|w7lYps#xLUYS~#e(o@2*f zvuEWe+Xopto2_N9(_OA&4t$=4Ji-!f1*mh}0&-D7px`Tq6)SjMsy~}VnLtI2s$O53#ZycuQy0Vtf?X=})UQkG! z-qh%GA4ov+E1I7aQ1Wlxl7lF{sAJ}+0R|(bd#Ur7qS{!6p77Fm7Q2*jcO* z@tbIc34Ta^^sM@eSlm1*YT}lAJDFXY;%3%~y5t-_GpnM6N(3THCAYKr(F%DXC7o$` zv^UiXfO{SpddZI0pTHkyN}afnhj0Fnfl>uCj4#FxrQp>AGfEx4D&#_FZ%~+nU)WvV zld{Tb?M?^b#$($<0v!I}=p3F7ZOYna8~r_5aiXBnk5e3U2#p68>`m>f;V-D)uj^;qPzS5k?o@>Pp4`$)QL4 z{}C4Ozcn2Ge-P~OcjUbp$N1;NUUn)X%~Du_Se3MyF)J&*G+*mKqp(W++gsiH)>A=% z*xaxIZ}HhMa^@2x(01}#ossQ7kO(v*#%Sgc(tMUNc1xV|>9myVVm1`V4G$9?M6>Sx zW1v&a$M-V-qP0*GQ}G=YrO)K`fG56v-0lDhbNl^nxVn=v_C(?wNWg8n76~X`$h0wq zwcL882IxH~D6nupII1cY7GFTWfy2-Kj`#n4tR{U_xse{sz;5$h5XMv$RXf|4pg%Ft z^wm{!a95Hrer&jkj+z)?WE`;Owm#)PY6!XO(9pwZZ2>Qewu-jg_&#ff^WIJQWN>fw zQ=ZyE@|Bo?0LDaG5vm8<rK@5;>0wOGvCTT)pH)YgOcLv2Nyp>0^MvJ7R33E+X~5i3aMI2Y zY_$|N7#?zjy!W>Mtq(3|&+$3#%j+(#>0kt|F#lgYLTmR=9lzUl2@MR8yB0_2M#svf zsL|Bea35zio+2R&FXP3S*Hd^1p*`6~^>^;b>#FJOEkh$aEZq$m+RG$2x|aTG4m*^E{iEpO(4nA_e(34=vzDy0L;t)00MWEOLl4oiP)%&W5bntc)Q)MpLk}gNX~zHdF*Q~e*OxLzw!|~ty*xD_+M*lwAXUO4sc}BND(|r5;BCx}e^TV) z;+2TnqDEkn0_SQva#G^_5^w`bxWp6H0b};GTyOAM&&F!9MC`_j?kqekDk5bow6bnE=DJ6#V9t1Pfty1=Z@HK3I_WtdQ3PEmurWv3PhV7p*~~<{Q12xTJKDx7$63ggnA{ReC4D%R|xE zk$jG5S7)OKT>2)Y$t#|F3L25GSx62d1t=7iqSJdAy!KKQTW?)k6Wh)e%?67|mGgNn zSFqVW)#c@WO#{Uy$^6KD-8ozdUOcd>ZzNTH$ z(6dA5{9!tp8?zZ#$xu+Sq~z@wym3*;LQ>dY>GQtM#UQq2-_)4SA4cJ?!ndD6Sl>@J zWPRRVw%4rO)h!*So}b6%a8Yu61Ko`!uiQTjG&u*jSx*wKbzst*f@zI!-)VC{?|=u- zJPHj4W8_lxHJ~A&Sp@M9t8$OR3({i6?e`n{4gNZ{mK{HQVMt|YKgrgDJ{vcUKJrkA z^x#)HLFhHlg(&RdZCg_5Hn8z##^sPhyY2Sck7haZItO`K+#xw)`KzJHGVuJAgn9>6 z^rjq{94i0h>juijxy&5<*Z%4t@d>?``Bppx=zkS!82qmCqdf&VjtkK;DuFY z7>Bp7=|oPaM9cQ)g&+K0Pto3A$lPk**D@E>f|OSYh(w(RCewHZE6jaBghy507YE@f zl0cKsvwkR7A>~<57?6?^UTO~C8%ya(C%@Rl94qPnnm!a;Y23ufS2Ov36C%|&+5!9! z)cgv~+wi8`*(-;8c19>eI4=PfU1DrV!MgkCjfQ6LahVRtz}*J$yAh22cpjJh@N|8; za^&iyZ&_~49+){H)FY@opfh zjQ44~)7YSp;HI`f+QCP;u^*-lDID1(Z1iZ2Tj%AEP6HFO(}Lt9RPkU81|=i+$Ajl7 zhn|k2k81%@5`Uq+dZ}A?+Em7Mb6>-KU3d~Jw9+D!ih-6pTj0FnhQa|#)6*Cup$uol zm~(c#(8rd;FGjk2%x5(GzL)EaWo%ezXaY$zcQ=b!D1HiAqsrsYs)4($ZS*cgZ2!(p z)C6^U_D9~CMvWmhD&ekiB$w5BweKoaHH+Y8UanUTH@k_6(UloV>ztQwYD*YEb~qXD zPTj}C#AV2@^AzoMj6u8qK?<{1B8>`Ef};A+M`a<^XEho_nm*-GxVM1b{S&JGeEC5Z zLEKqiy`Hz0fcc(#4xDn{Mj6QT<}~fFf#)>!*4X!*md%PA!_@ovH(u3b8>3Ir;KC;l z!okhJ&p*-vCq*28zdvEQZ4H)#zngWQm5S;vr0(4B4>cc*j;Kb_ba|WPI9&&q-wa2a z#a>WI^5}Wn&v0&J@$#Bo^xjQjgxZ>K{xuu|e>lp?sA!iRHk25e`4YGoO8cnW+i|wA zwE8f`850}h-ak0TULO-n`~>Ywk)*r^xwKG1BO* zWasHO7S=CGC9xLCzQv>ZR8>R^=Yq7{p1(G}30gvKe}~C&mh;#~(HYuGna)D!XqIk1 z{VcLLd%QO{jDPEW{=KzfN!l~u?gmu2 zISCIZ1>bHmTSC_&IxP^Y9ES=`0* z@V91{IXk#!Prka~;6F|ETPDB>Tk+e=;vf)rT%rF^DHs6X#(P|*^W8{`e=+H~-2e>m z(G>JwIc;T&sH)3fnI{+J|BvQ=zRd8F{f??qiRsO_Q_~y^8l{Mb?^Vf>!Mhgtxp97# zAD)2vNw{r7JNarlWxCI61?RED-1Oj*6l`?I5#xtwF2xZw-;XNqf@@|YKor4tlce5Oyx+@+`Rpd3We`b%o7F`qZO4OP#su* zaAw(Jo3C|Q_i-oWs-MjI=?{XP>C;u_TTo8UiFak`BcC)gF2Q!Tc)$dSsPyw+fX(b` z>+a&-L9Ce^oqO{qq)q2Z*35oCbNAfQ}sVmxLquA-Kw z(4UGF-q+Q6Cj;Ff0W>B9i>D)4QmgISMtVdnPrq6aBd6IHy{Kxac}5B#jfF{AX>QQ` zh{F~tj{iB&1DOo|I^NlPIrbS1P0(aJ^BpF{Y79h==|$y)R>I?@bZ&u0Mk>UJ-dF|( z`5C`fbX4ZU>yg=WmGKW_F~}s({gX~1-Z7u}JW&Ay-Cx;Kz$@#BxJUftKGcyCT6w>J zFO;L7Oj2a=1dXhn<8 zWw_LS{p$gpnKK%;X?xPVu4%iVRzdWY;)M#auBBAMhNWl4h*i^k)^B=rGAVia`}f!o zf|ND-?hs{b6@z+oG-D68wxAd);o+FCRma1+39rp8g4nGa796zNq`cihOQ!u!!X8Hv>U671{p)$^6 z)bvwrUy+*l|3TJUM@89n|HBupf~0f_(v5T@El4O*Qqs~nbSWSpB_Q414Bai=J>(4C z-OW3C-_P^>zU%#G)|zY0oa>yk&)NCed*{p0-TMBSLvg5}`_IWzM#f5{RRR9F6auTO ztqGB99@8JqIRD#YOfuygbVJXHjW9PcGuW_CvqVyx*FncI8!|AH&Y5*5R+m2eIF#72 zX5vRP8vA&!5g68n8X^B)mvbS?URe$k-&nWzW+!@Za#N`9$<5dLlAQSt6p-Kh^rmj9^R&-H;oBT6FD7& zj;4zg3c+y6MY^`p4e$_S2=ipO3W+sq=(>?Adb;>FU`Zu#7;Zt*QhJ-JgoTS=a~0_j zJ#0!5B2F4|e9GTnqxVU?e>?Kq#7mX}O*A#}Oy;FSz+Ixg8_}72xVH)>n~oEm@RbHU$VY?`g3XY!~fV8MriayRFK2n3OD%PvT*kasq^7C zoRn90$da*-pa&@ZbgHYz#np2Iy{d}*ran51PiK&I92W7zQ6f@@5&aF@`-#L3HRkR; z42)wOZIZ%-?KG=fh2C>|N#32v);trjW|2!j6Fkv6ohjq=c)he)`?QO*SK1D+-kW1( z^PUdGxARE(wrzZB zQ~VZ0;C3pHc+0iBZ#MXdHEc-O^|{W^aHVj6@HDA_Zwuy&Ab)?p+H(Q63D1p2LNPfP zBGJu<0KcXbx%sY?L$IKS2x}0Pze+zAHkZj0r28JTul7+wIMa>?jta899#wkOr?I#I zwtMHG&+CnF6WVREp2s{wpA*e*uVm!^dt01VIh1Y&qG({vFAeW+Hv`@(5MUgz z>12}Ko?Pntm);#F`kAp4d{t-*>O!aV_I#hLpr16yAK~u3U)#)aDq-qM6Usp zlRfCB)X#>xxVs_3e$-CS1}NrNzxn+&s`gK#T8Wd<*-z|;P zI?}n~082AmxEl>C*{|t|M>lThD0F5Y!tH20CB5)aMp|%3P^GZctp=D%V_-tXF#fVO z0oR47p1A>v`?@lf9(w=E8JzzaBi{{@oy7N+=xftFDsFS^)>^rSA4%PtytjLE;@n$b zId#R};d>?bdCiP#wCLRQPx%-$5%}oV)j`Tj?L+i#4BsE(NzV08m_w3q8@t#dlAZO} zxPkQV3$+`>Zqp(Ro#*}av!RzOlqZB(n#fT<3zR*%&x1uDWnM_eHDBLXh^jsTqT2c* z;O(8tMWJ691)S=B8Qq()rLDL#V?3;ene<#7FryAzeHnd`JlI=Q?m;7bx3HoKH~u~g z=dq;LJx=L@Md$hL-&OT%^(G}Uvm%^s(AVLHJnO$0W>QBSSq>85#6krT3T zszK_r*(eX^i%M=~q#CUkTCOLQ%`4>7A;fnZZ1462zNq7J-5$)SQhMEwn8|n31vU%> zO{r)wE4G-qGboU9wLZLGeccOF#IT++b0(Oleb2t`J`W z`WlR!>V&$J)kopS3IsHFBM<~@p{q+2N&;r&sZI34xy9U;JMqjlpA;JEj|})LQBCJ* z>j=!)1If)(RMD}l3A|L@hbQuJQUqQjyI|MHvIFsrX&#}WkJ(30ewYFP?LD39eQZUbLaXKolZ{YZA%|j@8*lqR7xc+G9AAx zPcP~7^F^;WqsczPX!^whO@}?gT=;V9Y(tG>5V%2O81t3X0|M~#ce9QL(EOOKh6I*n7uK}^DdWsLtu*V}5 zzUn29{PB3MB2>C}i+128meo#o8uHd->w5FxNwlH$J=UypYvWxB-cz6ZgE392Sk(Wj zC^pt64HkTa0>Do;axoXm(bLT$eLcKz`gm zsf(xl?pT`Z02FWk}d-R@A zIW_*~XZ$~d(zj8I|1-e7JITN>thV(`%5Rp&A@LXXBWx^{LX+X|IOj{Q|BCxjupFDS zN`a(aD#@dN9B6mB7*Z`9`}EJCHgEV-|9#sTGp>7J=@}~U1^*+MrGRg?OzzLLoR~ZQ zeSd?ZrlkX~SG~o2hH6*LkZE@xGsA0xj%1HmCCcKLeBQ6;oVR-~?#*`YJwYg+{(}WD z73CHQYL2PsTW{uaJjgTA(f2X>O#!V~9}9YvcDEi{$D+*tg^YEp@N(E(5apfJyh}fr@@aNh$$HO^rD`mi)>b&KU zbkVH2{Fw6TT&a>KNg;WrPQA_us&-Y{D;UsPC8>Ql>4Qtv%Zb6o2|$( z__#n@kVsayGD+r3U|Mpd2lEF}m9e5?52oa_q)%`J#*a@AUgRjiT;k;-`T$)R5if&< z=H1NpY3i)XxJX_K_}->(G`%b+y)52inip=V6td5pfde+qStD8^1 z?A9Nq!Xfw@`Pu$QL0e_MdO9{fhzn^HCzb}YMKUl@01tSR z9q|Nsm!}SGzN>WVOP;8pW_#y?xlQyb1nB5gk=snX-I-@L$_YY2d zFadTkN4selmd|m-2z|+Y#o)ZhDQT%q`1RNw4`m;DtKlY8L`slVS1fT z*2^~>i*bn%-(D-L3qP21f-s3=abMaLv02yf2Z1&F#PyT8)@1jW7*(Ay9XmDuG7uX@ zqJKG$kDVvTga=ECjA+GY6+PG?&S7N7t$is_^!_1|g8*R2$rgGhm#mt0v$RfQ0BK*h zgx=A?_OQ>}2#X$Z^IpbQR4WfXZf2y|dI|b0h{?zcV&aB--rOpi^?%vTFdL^>99Ur(0i2KGq6RLhY7X#R&352&Us*au-l5 zs(ftRT{7X$!7VjH6J$i$jJCHyoPW9HV#87Nv%l>2CM)X~^lsAxzr8?MKbauH&`j`K0Ja%{!d5Ofr&MvgB=JU&eyoG&QQCYIG77 z1~SPnsh({^0yy+4ACLE>G%Ozy*)Mqo^F(h~5Pb-MtD8{CP1cv8a*7W5Q(t&K`3Z{7 z1uqW_r&?xonvA|=CwPYj^*)2c$w=_9>_7+tsNE?(&KJs(IagO-|1dmlg8xV%AG^>% zxEBeeuBb(e=5?qDd;ESGx|#BLqr4yT_^@)+4WV4TD%H5lG<&!nJbP)c-!mTDO%RS- zpO^P)yQQX9=Rnex7Ov1`T_&sIkgy8d z=1{#VZ10+7Y_4##o#$apSuc~lA_B0UMOrwOlkONM2zY8grca$}b;!;bVOVaH_C@Xx) zqfn=?jn-eR(VW1QWDSHGiJxSeJ)U+xp{llIe^HCDy0_7RJNL;>sRsd23D4@}F}M2l zgk`pd!7vrBfskv-udm zvguD*P;mEw2d$%yc0|k$vR9Q**@Nla_D2#sY<9zPI9;zPiAnJNC5TXur zzoKH40&1d8@hRR$3*vEsBzCT{0Y*S`(3C=ajeuwUE1!Pi=E%wSObOPAA*@OH2T?bf)b{Z;N?~ zTMLPaZxCgiS8h(Y*z)dLQn3}VWD$!odX?EO*^vVnyY6tzwi}tjw1w^U%Q8zUy!IOB zQ-ih62Aj3xe7qkja|#-N-$|c$#qG=!j}$12@k=-uF`9asig-P4G>Q%0?mPr1O|~Pj zNmwd?0#-9YyA;n%Y+GIisY+P|ZS@9Zf!UfKkFaeS^*t)enKR##y0kt_r=s>9wLY$v zzQk`jnyHBf8{b{7gW=sARW!GvTG?hEj<9B#50$Q|UUn)E$0sorepKGSaf%e0wDvS! z>7N_5t8Sc8_gK!3HTo6#-hwRR?^zAQe9*yG&cV?k)@TWu)S2D|mS^ zt08wOHd!h)eM96(b%b5*tp@}nL`^Gf4ikQvCqv4VQ6$EvLn-4(^_#LfuQx)&q7azR zCK5a^k2w_M4nq0dwAoT|vTVO>bJ2h27)8r_vrf#zKc_i)jIEDOd>DE?6m{DTP)%`Md zDCuHtJEpz-l_64g=da`C-A2-x(Z)3Gd{YF?M>n5V_75|umwidvg{~{fCwFsG>}-n9 zU8>b}dk5G{^Prp=@D10>(X}``y13~zhs6VAO=L)bt&l^5lSf#=@Uvky8^={d*#XYZ(7)9B z`!Pz1jCxy&g(E0W6YPf=E5n$>%A>zNRjb1C;{Tc8!%Ge&cdsB0QR=Hc{XYEqcX^}x=&RX$7fv5s%bgpS=d$p2QEs^yXFY7XxVd-@Z^&vn|BSEcvfGjr z-S%WQZNkduQS9&#a@=a7tm<+0I}WM?>h65ZrB#|QQntRnXa%>N^q;afUw88*Ox-rxzuK|c2+C6GLPxC!I?k5?eSsZx9hp_4!U8pU&@r1TpFsm)z9uHTUu*9 zQoxMNHW@7lVtmV!3v-{#xFjw%T3%!-&N(3MW?kUle;*o8X9chQTAd8%i!-&Hn)Rzc z*L&5|Qx`?rx7|0Te~|wXQ!ydA*x1@stBn4%wRnBEpiaLq@-8X{^3l5><|g|J=INKg z+8~d&iuYnq+!sPd<62*Pnc_H^+PQ2>CWa5LT1milM3iR^o?i z-MhqT#y){C?ei*&>ab1`J=*((>h?N1(W6?|&$etz^7GttpLUWn;r*IzDlG>JU3+@P zFOwSSH0*blGPoW>=wT{-vurteab@paSd)09By0jcwS@n;?IP)8kAD@dt|7bFef1bB zM+{NX#quu5c!rwa!r*0FV{TP^6fyghFImKuTU8KbHPW$CqyXX#{0Z1zBaHMDapgYImcf6`d9Ob`+4SKoY>u!J*D>MU*+ z_cH$l!D`QvAGfrmee9j3@Z(~P^9Js-IHGG#Ei}y1Ap$G|WO{_UQ5znp9`{wUYMLm7 zyYrLXjU&KQU)~k__`*h>J;%G?J3Ou{Jb6~s%ibG!uQsp;*&R18Qkv*+rN0S2mc;z4 z*fxY2B%j@+Q4KbRC+p519Pjz3%dynJJWQs|Y-hmLw|UUUml>DFAc-e{iltY322w(v zyoEMvaRd9smG4B{nkku>u8tmL+|!2csP%w77Hsjk9G6+imc?ZCvs{!cl~~CTg;yj2|CHltr+|A3UgSGv+^M21sj#; zY1d*S7rQ!d4bePH6OF^g@5@ess229gTmPH!-?y(v;6W7aLFcboE?*XYRgD>(gjW=^ zO~FGwD>p8~HAMTEHgA6CWh*|5#+$Q)OZvs0P#^cMCmN`L@5F)E?I{@~A%c}sMj`wF z=2DSeF*}HET>6#KWszqg(0oAm9(t48DNB*G)B^x{kYvGM-VNfa;9A^Z%e7(?uE74M zz9q|sO72SDk)@?m?@+?~m&Q(GX*@VQb?ZxYm-0?pcVa0Sjy-?(k>9>nu!KjOq60um z@t1L02GmeZ=atc~3)G5|r0n+3732BMJh#?^yJ})rl0#R`*v)s&Ov4dJ@}us_fUCsc zhdxdpc~^tca8sTDNBBapN2cFK!?432vDg4q$Nu6fqxf6JoH`f2KwzkTjca`4TMhLd z#|!dDcLOv44iWE)#uKrI6zb3AGxD8utBT#h7y;^Vt+PqpNNT7~jK`6kOiS)`Z?Mj! zJ>Gb(EHm8p)0UowcapE38bXbpWjlIT|ERn=X$46L_d37O6I>6z?Phoi9F@?wy1U4( z9+@aBpPL~7dvP`1M_pR50H1m;nMq|X+Slxd9M%bzd9u7bkw1kljRtsI>mWY_CM1_FPFUi zPgS+N6=!?+3f=@*^wZlLW-~UQ-yxR$hH;_-eA!urSA||_#li>Nw|$M#FS1)Zu~aO@ zlLoj^p;3>E%-4Yb^W>AgljY$-@ke?0OI(*h{Jrbf^Y_D>ha}VQyJ|YBZA+Vp1Rv|K zmqm8&8XBA~OdVv0NECTu=!@_%fFss=G4Dp{Re{qCl&8Z)eE;~(;3|_B&QoGEA?7wl z^Hz_GaXc-OVUx*UYc1+$*``Ao?CRttjwHf{cO1&O%aa+aq>!KmiWCwS#JQh7G>xMK zoX+D<>PJ8MY<$`HN_puj!s8P3Yla)G<&KjOl~!5tIM)9A4r*|Jf^Ty}aR>DW5SX@S zyPtVKs!UuN)t4ls5^Iy^dl-34_KT_*)BTazmYrk|DJv(7Uz1LZr*;S7L#YT@G(ucJ zRrQzoOVV%%e|}(RHm0gW9OfZY8OH|%;HWy$0S{j=WU<5OJ7(>6MR@W|h2GBj=tq5m ztGU-DT1$Bsgowas&516oSkcy=95*d$i7j8i>uM%kq$T4r+>T-4Y$(=K>ukoHF_l`| zF-&jUu}jKyPTkvhD)G!=hZtW-m{mGks744P-bIZl9@Zrwd#a5VMk+L|2KNW(elra>MhwbsK#MI!Mk@4$Na+?l5!9vqs49MzeXi zg=KZAVUa?4k(k;3=Z~)>Ec?3e{R+{ghl(xU#aD^m57=T7Aw|i;3Sw1_ym*cs%@Fot zOk#NL^ZTO_0Bz7l1G{tV5i~Nm;P?Pj$Fi|w)kpvVWkOlk9|xA zEgju}l(1lY3ke8I+H6*O%>qSl$T+x7JM8#!@3vwmxaC;Qe0M+%f_9{vFye_dZP5Bq zsKz%i62elFGFZQw4LrmuY~$1v7+OP2gJ6SfC8&r(9~RH;6)%J!@j^5;dw=fSa@1nC z&GCxd2<`0?4#Nm>DqEI?v#uMfXlhF$B+w|OC-@i*>@$Uav z9h#_(;w=(5`V<+_*SWET*(%W`w^M1|b2C+?s+pf2_7a^^P*eVku2?k-ZJTc6$`{E; z+t%V%)YV?!lAWu$5f^hQs;8@_?jhRj6v9-sA6$QyFQw zcPpJV8kS?jBGSVMgTs4M&dNkOxfZ zlyYJpc3u~}!9*r1*Z%n%AE*89tXRThZ6^3tvYf*5@^AX-7=?T4Kul6GsmYIaYgfT( z&bP>{p;5R*>5+cTC&o8@3Kj8h@nrZbHhxU>gdai~j=hYP(m%J6Qs&ot!gfGtOvpP_ zoJOlL#TT8^s?~JV)bLb=W)GmWP{#}hIW#3M+Ylfss7*vEvlu9HE7y?n4E;1rGT?}n zvQXXKci9&V#qg~bJe>z$nv?Puo#xaP8k2f)RjBAtd4kxRp?J zu}db`1&P_7L-u}nKm^Ln;rel2{(B=lNn~Iws_RNRFAP$YC&ta*)$>*ScDqBj)7{_~X&0NGK+<72$x3s<|x9VlHdR ziU|%vicwF#bWus>2~G7kgimSZ=0>TkPTfcf-y6@r^yq-Jq#H+lzN{?eml`-sXd-=Q-!K z1_Lf4kEZntUth)O1l2x-^7*~0S5}E0E1pRXYe(3eDw3_$gL<-E&(AI>?w!S+%P7?) z4xW1`1V|p-ou=S8AOe1577DHGY|{=nekd0Y3N4#q28^@K&Z-DfBOZ3%p+&%#`2ut? zoK2$YWCWt4yXHf$pkI73ULJ2Wr16qHP*$r=K$F&;qcgpt8ll-Ki(^}nZ=o2Y*oBO9 z3%EkJ!%vw|eZO<6Jl;{-6yxh-4d!1=G=+tMf5z#8uHDOM>PT(ZH~K=I1>UPz+z0KP}%>C}3RvwhiI zQgdO%{`+A+@=im;+W38d1>o5*OSP~MPZE+t-T3B>C#F&|>qJtbOG1uEh(8Asn(GJu z!yCXcxP^T23=B_vbGEDul2(`Me(Gh|Kio7pZO}RNK;m}iG%2eKUtJ@D=KQcQR%5*7 z$=>}Fv9)36=fz!g&aZD#$1W8?Ev}&KyhM)1@t}0ZGcoG=NS{ z?l{v4`bmRDd;3>BaKT2lH^L&G(e+Mo=<7z?lR;$2@b6$hsdujOj5*zofe{}CI|rML zvR!(t78*W;X)O^hjUd`ht>Y79FtveC-+4-F1t{(jZgqXrbrh~d{MG}FT$*N{QPv)) zD(O@x+2K4m{&YUC6>KO5x*QhpRDh0oM(>vO8F@BNd;OFndy@0wiAuDRwyZ9anwO25 z6d8>nLbVqBzV3{erCOoGYnO#gJp(>73PcqcNkqwBRUl2DR5RPO)m~02`%%>>&^s`p zP~qkJyC_Z)26eFjImT^Khs0GDg4Oi3eI{4MZk*&i1LgzFdmq$((j##kU3^9Urn_dkt1Z)WCmR2BS}H#kyI*6i({G83RBC?=8C6?h+w{@?6JNa$V6Yo z#q+l%WYn{6^NHW;jZS~mjcVTPDcLMJkj%VuhoyMJCt&D_u{dMDOLOC3=CH1*N*GZWh-gkkOA|6l?1-;U@Z22Dy+WpopWpa2$4 z@g5nAJiMaWmmg3dvpF!S16LzyH>BeyM=pECwA2n47aA+>dw-?yR*m24+p+zgaVX|X zC+u@#&hZy<+BtrKs4li9?R?XG$Dtuf=tw&73@hC&7`e!DA+sfaMr#B!67b_HK_OJA zmTd71T9CyVeNgvmY~bq*XkE>DJKeWbJWwsKV2>nCE(x^m-t8f^S<_WjP&(Y-;9lqa z{7rFDbs2@84Th!y+pkY+lDNMGd(cjJ$j6@fPysVV15WCZOzA@J`gkV!j*26aGL3dB zrrOzilTgyFX{GU<8@+ZtMrR8I2mHkm+G)lE&2bvm{Me4wt0+aFF+;!O!tX zIC!|wn((HmaiG4i;ya%EZNqC|a%w6eW9VG*MbmRPdbTPzx-bk7;M=>6o;Q(#R^t`p z!s2vCUikqj8`-MighY>^L>+gjFhpU#s~yLnnSd+%W<`ShP{1Z$eiBrG8#YBnGa84I z9%k|hZ#7F@w>o2x2N!I4h+~K}r{i1@d7SdX-VL7kqN>Xbdgko>a{3g-q?xH!T6=oX zmHsN^>FD&26ZLM7I(@U^Z(#O`L`}*VdB9WAJUI1z-go9whAC%Vt>+-66PN8{kJonh zh@*7Y38}~Ed*Q+37Zv03JFj2goTz`O%4i8DL~anoDYG14+8+-ZV5ZS~Lmh*w;34Fz z7Ux|jm0D*U-%{8+X_KKNxsef#3i%ZvnL$P$`YYj((C6($2= zJ9_@%ypml-R$=!b{ObE0YQKBE3jv^^uhvtkms)DvCIi(2&5Ry0ARXHt#5{e^tmtt ziLxo-$paTIcxGU~q4A7#Bt^QYV9CVZLIWf8FC2yn9=~u(!coXZF4R6(f2Lv6*HOz; zJrgv(fAUhqW550mb%l$2CUVrYBsbt#Y420kPu>{w&`?_lomi+a3usyO>6{TM(`T2%>{i@FKpO! zIo}lJ7Ju(wMbhnE*U^D`x-FsWVw%Md@Jvmbe1wn|P>D5`>5{#`eF?N^6>SseWDc#9 zh#ZrY;vxVz9&XHUR5fg?C6(NtK21x46M!o6Dk8Dfa3+5u$$O@LGw-Y|FBe%tF=3c# zkLb~alV~nDO9I+Bu$n}g@^yxQHabZ((cMWKU z_VC=0CXT^oq3pL|gr^F{6+zgPu{GS>m&H0|Tij|NQ;R=v02hfnpY&pvARv6zoZPQ< zvNuAwOJ_1j_yx(O3-O6XrNtFO$hrcE_USiph6MLZ*F~s-!f24@#wR;)G)4xHfRebA zkmnMhczHm_BmvH_Rj<$S=uC{m!#izkYNyvJdMl&`m5)J!)ZYcOGH7i#%PsQa1B~vG zfwtNU{ecCZzhW&s`eO036{{jDQ-srgDP7CDgdWOa)QdG4-`&XtT0nBk$J)^Wjw+tE3` zn(4Gqx(3Z?>D0&B9D_xDmRYAyCAxxA>+MJ1O`B8BNxdGE`pVf_y_a_Y9Uh3oau#j!iBQM=5-}#zVs_pLZOE@89*S7FBAzUzL5aLMXBSjV@qrE@sVRMiwGA zYijrP?cJnme^ePBMgXV)0tJsvWKFpLB-Q9Vs&|cuZ@iqmj5^&im4Qd?7erN~+Givl zZ#SPLl87e5+Wi&Qq-phs&kY;nrzLaG@a&I|ULXK@tVLBJ_YKsDo&mYzY!_HN*A-lu zOrN#V;dOg7sxli2zq%Y9Icz6$X)DAaSjeIR`PDm}8;WlG(6?De$xn`Cn!lQnr5IxX z*l3a1`C}aoZVf`rH=#ODlF`CKPtEwVGMXX!#8i^$i^c~FK|OlM z-1pJpDPe+&wDjlbXM1w4L*-$&>@!UtWUyj!=b&1{y2no(P25=33h#ybQ;v_0ab}CE z2rDl)x!vg$gZAoeg(6+C$cd@w>HNN;&1Ft(5!+U$ApuXaKj83ibJtv^&JC6!F3pFg zDA1}nf)w$3MHah|0SwevCdtWUwhYG`&|tK!cr0sM+r#A3VbTz1a)v7Rt~~!AzR!?Z zGEn_8PF1wSO2dts@0vA%GVNkAj%+TEs%8m|fl3NJdBnEf><@b30_4q zu6dyd-4yA=g$}bog8t`-NMnBvVBb2nRKarGC54y2hfZ1uw?!?_oC^B&ABpg7nCTU=c{1FEZNvbtSpX4z&yz85q6%xDEAtgNkW1noqS||p0yN&B_1_b!Kmft$mSjEv6 z1eDOyN2C_U^bDoou)b>B=8ka=Tsq)+W{pphqDxZXV3yBldpj-hRwX?yE4}1EN2X<# zk!c|gVP$1^zm1R#=823tfU$1o;K0OxbU<{krcug+e%H{Qc>*#?tU1sP71FC{VS?*< z?~&0O+Q#-2=X2gN)f619jXBc&#Qp9pT}Nb+bTAk^PO#Y3#AQMkSEqFudWwe#yl2qW zyv-RTr`M(Hx~`ML0u2x2yUU_W3G)fTOj(bGb|l(-J6|;Uf`k3Sjs$6^EH$^RR~mE zzjD{-XhJ8wmn_tC{VcsQS+Zdup@;!8+vPW@Tdw(4|0kv6#o&Riv)1@H4JCqaj??80 zSCinV22~ZeZolyC9`H_FOwK{41;vZ{);8g%aqlHlx{DJ9c*ccP z)}8hSGs4hj`1wjV3}sBqpX(6E0swz~-IJ@1JtX4PRaw-FAoWGL6$IaNOicSh0msj~ z2p;~GIU72r7hTR%lL3q^EC9nd$zOK-W)r-#K|o~7*<`uuP*YnV6#!sDMJe7L7c}4M z1iCqZ0^prK-Lc_fx6M_$+dXxr)n|XMX67@>#I(xn9=vgHe3Ce|b2{rLCi#2+p!uDo zsfqyuX53Cu-2|)I?A(I4Lr|_W!0I=Of4Nb{oCP3>~~9h=^36NB;3O5j4kC zDEEQ!CMAv8?!DmgwE(^U!zv~5h6Iqj79P6Y(Px-P<)ZXshm5Q5Qh%Uoe-59o&S6Pr zoLNMJhX}yF3PcGWPFZYipW>FYU=nkrR`@M7oEVu+gB0fpE>pMjofw-4;}hSDD25=Gt({+v?xO)1Sca>c%hmemb0F_UHBD zP{reckbLTMD6Rrprdc`u+`5|9j`FUEwc0c2jcz*YVLC z2eEM4M@)MuM&KIVWjcXRCTvoMQ@+6b6__W{RA3T|9icpGgDS}nRAowb~s8MpZ1Zj79W zcqVGjGxios?G(IFOPy(udXD#4-%MwH`WrYw4E17 z@X%bCbo*LQ|4HB#N@NQc;W&lznU{XP3(8SRLxs#UJLiFJN8I`%pWZDGndcEscsN^Z zW@;`1kS|Pg$f9o+nagms?8*ok3nJ;2(_U-=z@%}_-0o?FjvU{q1pQ@O#>8Pf|Ht8@ zt~sVgevUFnN9w>)#*hsz56lhndH2L0DZ+>=XgI+88Gq^3p(JHv+gS#?3oJ?=pQsHx zD$8Ynh&oIzDTni5NKA{@&^Y}r7Z(cuY$J)8yIhAE((RXw zFP3J)`XO$ z**1qUNDgoBM>GAjW>h?st$yu;k4xNwZhwr{iV@xmL=$OzuVn&jGE5|GU}<95C|nW=YCCeORr z)>~KM@frKpoS6qsG=Vv1Z_V2hnRk|zD{L@0jGQDZJ)?eoby7dCyD%&_*3MUtaZ+RK=rjImmL1sMyN*@OOVbL0EhLnP{VDkc@ZVv=fAc zZGp?_h+JRps+=o!ux4L0s`Zq4n*QSf8pV-DQ3KM|fB-x=QE1QA1KAhxwz7RbLjN&E z;Kw(~JT3>(te9qmj7oK*wL6)C`=!h@cz{P=}y7N*a-qJex3mhI9_idxT2H7#w zUavkZ2p1k1PJbR<+MqT8tzKPq_vZjgcXO%Nh8v-$`kS$bx1~BSup_z2mjab(e;h7e z#Upp+-(e96!3)bIaExL)3k04);~w&r(c1hY(!`3k8y8k8y5Am0N@R>Oi zxg&{_qe94gtX}Qcc)R!hL&623FM(>_CyH>=rx~Q7_k$Wf4-HhO5Bj?8cC^EVi>I@3 zvfJ@!2Wixs`ep&G7b>a`X5f8?cehhW6B4$AAJ=@KpKY9~?$-%M*kB$dj8Ic1NB+~!S`(Eq5cqs@bT_m-v|2-`1-y>xVqs} zo=Og8>m7@4O)R0hW`MBcV#2p*5^(m^<+}$;6Fm);E6ezSpBkS(XKm)9z&=xLS5JJl z;h$8uw{s!(54a9S`D{BKLm{TdE~m%Kh?~{SyllfDVqq6-o_8^Myn+g2w2Te!`uR_W zPsStpARMUJ$}(3trWGI5B6P2dTH3sG6`7)@zJf<4V)y{grIUWr2U5MVjLWO%2P1!n zHN5OPMD@sq{dsNMJ!GS7{yDq_dR3BcXfL0Xr~@=*;`IxtX1m7Zha{$FjupFmo@qy0 zlWHOKp%@+flz#z`8IQEdE`KnW|H2`$8>V&l-7YcIIsf-aVv8jn#A!EIZ&rmr*QrCdl##WV&;6A2!+YHQeGcI!Q~t-vKRUJY+MH5U zh5m<;pq$Bq?ZgT@7OlG zY{J*e`7CweQ$qyoc*$TVV-5SW^o2X`c)RIYEIPNgLk{XJAM9Yv0%)Tb6<+g>11!JQ zMStQ8+CAB^@p3b2XWQ=e8ZcbpfK%qr`f3b(o@bzI2wC@tKCUvSZMux^K16dfNv%okpSz_s-)%-<@z?=XyH__F(3y`54}NJ;H59jp3hX}4PU#xZI#2*SKP~E& zx5ilGXqBTV0)m9Bq!x7g(MrImvBS|f&-9j*z4>_TBI=E@bSm%a1;KJ+ z!SS|U9YQHR#Xcm0#bd3x(x&(sd{ee^zrj7Uiv3zXH=o}-&Rm)IeQidP%h%Pgw!6tL z@C08P1WX!^@{PCkJv$^lNWv~1m^97#>PdlMw+oVqpm{@nBbgR8$D{VC$faRCV&UDN z8Ro+fT0tWN5zV^B)Kt;l_v0Zh$|~|iOM77^vPzEI85;X!ZQnzCeQfw6BIpx#E9cwC zGK6%d0&8q$v{VegRG!|yI5r}Hm0SJL8ND@gIqlm|vl?V8G`;{uFSfY(xLiS|<+V+<`D~@m02@el! zRy3y)Ay6-;s_a`aVuLR$RS8~T`>9RuyI~L5T53t{k)L7rVEsLu8t;za>U8SHUBqM~WTnW8-Q72u-F@h0C8F%+OA#t_>6s&J z^tTn^?oI+!2DU-1_X@mx76*y4Y7(k_E-)+4w?@~ymPL5TG#sln^nQ(XEwr;mt+!SY zbRUS@F-DCRZ+2j!g_uNqKGKLq0!NGA33thTZU-v(+2QG8o>%9DV*G4!aIf0;s^yuQ zAzKk!w~Lb7=%!LS(Q2+`;@8Vqx)^q0?kO>m1+T98bpKq<9T6z$i9_Myueh?SnvZ>L z;C$H0BLzj(cB~2tDvz@< z#9hX_T#G(TsNo_?{eSGeRa9J2*DY8;u;A_zG`PD4cMb0D?oddAI|P@)U4y$@aEIXT z?hak~?*04rz5T!Rdp}lnY%)q7RIxq_*hPuQ4uLAJ;_~gav zIPc|L{xe!@TU7du-WMnZ4ziiCl`L?W*JThXF`#=@`WutrqqNa|TH8<*24n-~>6jf{ z>T`;P29*~?cPhKpi|@rQ@z*W}BTU6SsjR$1otXUF*~){K!xa)eR5vS!Annghw*0U( zDDN8%7BFZzs-{(v>#HsAmr+6rH{_eNYmG;#l*chR*`4w|P^qj@UguA5250Fh&GhIi z1mHDw4Q8em=OVXeu}tTV^%=@G_zTe;q~Nv5#U|2*_-y_;c{SExY1-bTur}zbX+k#g z(6>IexP$j%Gd^#mRh6U-Y%~vKsO!tIqux4lM$4`ABB@@JwGeW;*abiWbS1Bb9L_Dr z+h2}&)_QVYUYi{JJ$ao6R7Zaen07-epzyv-Z*0#n4n84b5H-oV_4WkG3dFEJiyDRx*z0G_=mV3x>hDmX05#rely94 z#=%VxblBQ8nmPY`R9>DHoO&k?iwycY-IThf|Kqs+smUDaVL%xV{1n%AqTD2q!<2~F z{&1Njw?DjhHpRn?Kdz+Mq>Mc2`t%ss`(Gxok#x3NZQ*!)N&#`(Onc&5iUe_{g8P3MmJQp(9f<2M7Y)q_~f`rpzILJih34^KiglrEPDrN8@w5XMQF z`NaV9oEecn;{w)foiKh#t*Dh$J|)V1X_n^~E{-Rptthg`Zq z9-3CX7Q|NVSZ<^J*IPr#pht-y*3B84=uBq%FAQ2VX5-6wr`vi2Kr(+e@=ORv{2QSc z)9iI0&HZLPBV!k7_w#eR9;aA&w8-F~)=cEjB9H(DdGSJd+!S0lX(<^#+NO_(400q$ zGZklb?|_5XTs<>ni7}|0{j!}V0=XJ$!zk7ZNqf z-q~Layf*W8p_VZ-UPNBl%&5!_Tgus==~gYY+|2GsrIgXN3KU!jegS5T4o*T~7~&2ybSAi(gIl~@z>JdpiaVIQ-8!_Q{y z68F7oruQMsYuIw4OS8FWSUM^_wDy%qnznzC}{6NyqbY&@zl}=HBR$l}V@EA)- zY0=4lJCNlxIj$VWKls+Bu-rRnF<{_)WyY{vTRuZ~$JNx2d>PHXSW(@;(o@@6?b1W1 zf;VBlh=0rzRyTLNQu6TCYXfxe75^FUgl2Kki}X|V)&JOH2THCO*SH)F?w!EzgO~lC ze#OPP5*N1-#C#6da9;pXpBs(@$0{qErW{F9j5ai0Y($F$>dk=^B!JA`^j>Gs+P5!C zWK{Kaqar;i0B%}E4b6_w;z79Q>dG%ZeMRJ;ktY-e5b76FLm{o2$VbH3&lCWKj-sp^ zJRTzzZ@Gs~DLb=_rd{{a6#jZwR{(dd+I zD*z&e@xv1Y8@94n1OTJDhnnFWhCuVEsaZ$IDd-3aV{0xQegHpUS=+lThSr=aWNIQm zNj}qd`IlB21R|inSXX;8CS8D_Q*Z~b<*~=C^cygzD8HCUPAYLZgBdv&wc8jhB!S%I zBpzM-RkDOo{pXP0ca3fiqLs%rui?jel(Fv-Z~-eDp6$2IeLWC{|7;T$`KT!9gMLsI zE2tj)fav^i&RFKvs7#du!yO*CO`Do9*tV{X4&_R_AT)fct zvUUD+pYOnLdwS~S1iMJ&c?AEqtHpL8HyVY~o=L#!R5>K*YvCUX?8$fWv1c7Ab9kH2 ze{Q(&dwlUhp%`SDr(Ovaxa}P&c;6_TU3fi{r1&TwP%scowJhuOOW2VN>hx{HJJKwd$u8<|10oo%V=hE);RhHIbJ0*2BRl z{dMP;ricrP7&}Bh=X`GY&E~Qn+N8I8*}Q^rweD&K-NI2NFN)IG-vEuH$wLK@vwxlE za^Ia;pX#>*QSxLvk*i(38yrdPI=S^Qw}_i5Qb_v?`jXue5t*q)WML(3PtF1v)F5Fo zu8`C^Ug>5A&9`{Acxf6={`1csV}i}XA%k3jN)If63>DsMZqHR2u&njVz&2V&2{m*U z9o1_RW+gT89>|@K6}@mI%eu0g7)+T-~62L5@8J%OIPD6 zoHN?qO}4vPS)-BwO7x2Da#VtO0|1wWf+E&++B|@qWEaWU2ANsv=xY<*Bp#k5cJGPn zim&hxfGXwk8Zhks8N5@Xm$g$;bn%8EEvn`l<4}`UGXS2)H}}~bp^BOu7O5l%fO^q# zyDz!ZwD1r)mEM)X@uhnm8Nt+O2mvlW&9cWMZh~Fio1j+1vZw|(&biFBkNGBOqL~5; zEPX4|pedvPwU`MwiyjyI(S4N{fd2vXr$^Rq4!n%!&Z=*sfk<#CB!`m#Pn!ML`-IHZ zN|JX!uZU*BgSQR-hV^a;BQ0zIKQXe+qePF!#ludg8ywQzst+QpfC-2 ztsr}XUgt+c<1v0~5DUgZG_v_>Vk2n9C8xe};}`eNR_1Kgp9(7{ z{gjPpXaK3kv&FyG^9>*en34}&MoNb{=8`z~-?D+UocU9*bKtTf5kmG>cN4F z@`w}4R&T8LBK_kl_z^VBWp*y*07;6=;y(avz3IM>o!lKz&;iOOALpSV3GL`0yRr_V$%emgzg%~Hntw+$$J ztY}#va_nG0!Qo2c*8p8_JqBuNH5hb2RZVS0aLEBR>_gOW!%02T92Wy^0APx-qca@F zY}hB%Sjk2;G8nw8Z=M&EhCHikBTv4*ZVo`o{J0eRQJY$!rllGZd1(v<9<2TJs@$}Z z)_TP>H0l4kyW{8o-gg`&g$8k{X{|_7iULYT<8dB&%t6XPS`k`ue_5tv6&4qT-;YlMNj^G7f{X*e3iugTuBjNYaUS11TLUL)z++&(S}-vSj! zM(W=^(4d#6XZFn@1EG9WTNOGwlRg#G416k?&#}qz!$_Rzq()1V0}&E*Z1$u%0hw~e z$gpmPg^8|vQsBWCAV95<_`vUw67T7Q(KLQ;OUtXYDWxg+WrkW3PJX}Yo{jG&N^qF2 z=#QuHN{A*YfB+k9EYr&7`1(^avA|APrhxt$kuQ2YaQhl|hB|At*6$#z+GSpGLfzo- zz@=z`GbSAIs1mhGtGJ;y;wv5rxcY#3tevtyzGYXt;9DkUG1>cS08`RXIW7VWeDw)5 zDOQ*jF0``thZJV;_QgO-rGGH*p3SNDxFY+GMh{R@)gizthoqFdfT>z&3z}Z@cRk{` z%Y1-yyGVB90@2SozMF&wvvup~YMdh{q$|V5b7lhnQDLu&|at zSa2I+$=q2shNUY|1XHsG5-Tp`mWC1ju#A&Ovh^R+(lC8D<4euuxCqlIyGrzF>MPg=6v~m$o z;C1WT5hl!|fGi-hs?si>yp3dKw~B4f`>&MOCr;sidoE(D-N&^qcjDfiE&fr4=~&cx zT)QKhTJ>HKa?xZCkFZ^;FbbmgVOx2^HZ&k^Q}}4$Uz`F46@m$aTS%QG3dpsjmP#Cf zQI9Cr>_{#`93-P7MP^0^fkJh|>OsX>;pK zd!)uB(vv#jbBigtM9+OI$egrwwdey;{W>Cih&&s_CA*Yk8F+M5qD-iOFm_g58G>6i z%9ofqnb~4<7+qaPvd<(Og5PB5_4MATiCF1TxXYIo1V(-=M9a>i$L|FMtmITagqe+?!T?&w%xQ1$_wVLI zXKmdcY&R!h@{`~{BkYSl*jW(ivnEA>CmDEYJ%At$YJ5Q#U5blet;Ub~^r@AZ{!&Ug z*}L0uiLJcHCvxr)FW{+Ay#vjsyv45P{f1DS=x8LYmgS5Lz7mJ;$5CpLGe8;uLCfR# z$NK>n!i)&$`5Hfg+kSbi+V=H!`a2820_zVt`Pwr^%hF3sqK>E}lh-|{VU!#f!0A4o zQN7MWvpiJ9z*FuK^eKW+bCL_43?AjisQb(nX$~bvzD?-0^*iek*Cb;UmfnmOM#SUX z%(C^7FUg{j^#SgccoRm0dLqa}b{oSqr9XXcT}5x!I3Ju;z|A{6q?HWX5HQGg$0QQ8 z$EJ!=xcvgK{cf%e&~V#TvU_-oJvD0yj*enwW5De?`Ujp?;es(+F1^qcYV^;RFH5I{ z8@Ra*E#uU_3L#R}R^&!BwOLhki=~Sm!kT3=QUPJ1(NXC34A0IW3pwK;F3of|ZTsDb z;H$MhvH@B(I70r$mf#SDPQJbmpEs3lg39~QzF?hBj9BI0c31^xSzW@tnN6o zo?eX;NX>th8PF^_HuO?oe81m^vbByY&V9A?OG1E5TR(tG$k{|) z(5eOJ?ywx?h6|unQj>YfQRp~XPjW%*Y>R>B#i7M32{HPaKLyw<%q@d^B_0F-MfXN* z>S!H`gf#U~s94$DPqu@5yR{3s%Z5eJh)~r7DpsEofcdBa^c{CWTLsu&p1XfrdMzQr z;WFQAY5)m%J03AeOC;s-wH^v>-^VOWY{;;-p+6Q!nCTcFTKuq4#gf*4+ATxljzt1L zF_)-|u;AE^&dZt~A0x4u*Hars{I_o&&}IU6-xgG%Iwes#E2d73*oWgD9)}T)7Zny{4tQ)+bDk4%O>JXn{OVh@G-D?xMc|GWgIr8Mysk)m7x;2S zvcWd4h=h^a5Zd)twMuutKI{wPjm_1*Fx;Dtcuz5@r#m2->`({nWO<%=Bmmpla2N>O zuM=WF?zKnfK4rOYG@5YEGn7-gKX5;hJLn}l?FQcY`}VPdodWu5Dt)v?iZ}RZG2EXUP5rB&$+~7WTIgJ-?8B)dpJX)ZFamxOl$^{ysmfQnBcr8}54p zlY&D}nQI5S@Cn5OPpra0Ml7_sE%(5uBQ`UvW$X-6dV8@7-Q|qaD{V#WqtmT~h1K*! zK4F~n_z^P7vxb9-EFL{8R~X7k0s_Nh)4i*jo<^4!lm$4|B1I(wTrFXW>n*f`amV+u zv|lqf4V8@H^o)L<&8fQUtfC!oJ2+_D_7(K=!n%Ww{xEP|H?K7_j7t@K`;^bj0Abtb zp9x6o`6(4?nA0z$<+<2!U~99p_0dgqHC9g|n zkb*xFdQU=1lw_@(-=n0Sv`!~A?mdmqTVM?5Wf{zd2K4^gpV`Jw@$MnIk$Ain(9y&! zz2sN~3Axl3R4mXEvDnr2E^L9#m*8;R6N&y*p%uzy%kWoxgpJPu=Gn^0GiU2Tq*z6sBws|}Zmv&}}nfzzHXKy;UU|+(>Fec>8%y%(0 z-K)y$A-23KNvxN+%QjW;5&@M>w2*dNd=o>=xB9|laFNYn(#*6K97QOgJEh`HZ}ITH znoo%&#^OGo|G*T5e(z3|Y39>4VG{{z;wQh}My7zMD`MUtmCoQ1pbUbIa(F%Tlpbv% zUuy9>;C)rW7JNSuC-*@Rr5Kschis#bL?-5SZ~Yd>{IG6uE``*u_3-Z+EzdyaHBtaT zfyTom&O~9sM$!vq*E^}P2e<$91Z-j8+wvRR88m>QNTGqBkN~39-3dgw;n$ff#(R;g zw)4||&Ys3?Zm;^~)X--;!hj*Hrv&w1!^*`Udw(*NHLUR3Cq#l5K6~*GHn9mCG(@~k z=~Ww*zUq&iirxQzvydHEA}SNQYTuX72bu`zVzEFTLu3Ai#3BF7N~P5ZTvblR4sfVp z?wc+yJqd12mzHBI;^MhNtvCK!Mxwq!&a#GeiUamX@mZMCwzE+D`&FMB|Dnd8DR+%&+Ji-IWs%eNH_kk#<{mrHeh0?ozwo z1|yq*@%kE=Tu??W&0;J8Uh@B}JGfwj`C$ZB%K{zZznY{TSM>v&eENng(pPBIKerw^ z+PAIUK~?A4OG?USEV)Eut@gcL)444Z5!c1zuE_*q2xXahJE>2wJF@t}j^+1qAw$Hyqs@kdYT!C`$;|*`V1v*-zblyztJ3EP} z1jXqN+$ptP?Z%AjaM^8ioG5*(Ne)-|^`cvOHyx9A=l|sKMOrTUp4gaZVn*Jf(`Sms z)>an)HK$$kv=-Yq4_l0cvYqfT-ne<}yzb>=60G=Ka%u1z&VJ3qp|dTQ(e?FD9D&o6 z;9^|U`S_&bRI_p!1Oe2de*KmKiD`zK)%5$=_$nL`WybpR(0NrVixb4+Qp&Fst$86k zqUg{Ky|&JNK1wwz(t!WDZbB6uv-H^Tfd5l5@TRQcb{=t^E`Yri6%2^_0{9^D5U6|jwUZQY(bJY-vU;OB$% zo~;8_$uwY*o^tt-@H-NiR3)wffRf7m!qM@|rH{2AA?fS~CqzGJgD+DMjV&|ofw0kG zzkJpJp#mB2f6s|tCeoPjF^1jJmMap64$uIO3;td6S}WR>gncH%<7;qhgVxc(#Y*D( z(Z4fKD*mk0Q7WFa(~5Vsm%Uptl*@vN&`)AC9W~@XQCoK(?~j^ZFgE^DJlzx>Dt=z% z*eP-Ow}sxEFW(D_SnZ$*)cb`)$*)CAoe#S z?qdaPmI^ps&ZrNGjR9{zil9d0GFs78z*xz^pYIx!jK%vSs~y3~G&HZ}%D1EC$Jv9G zi*qjPYsyAjI;RyoZ8YAkSlVm?q(>C{zFGg~G)JFN)fdDS}O-VRgc zlye%nNq3@(`S@%;ijH^uFAc|c^6W}(ZiBL|)^0EpI;F_%_^|2!Zsl(+J_k%_%IPs; zqxhl!n#nGAMNqa#v*XC2keS#<0*`GjNi)bERuJnyi9KDI?ZPcUISB~~)}d|RIr~pp z67nEW_;*@Qz-Af4-@1?bp1;P&^-lcRQ;$w{_C(dF7_sIG;3B6i&+oT zD;q6TANyOq@4lkquVotyF?$dIi*f;9 zinbiN*}q2l`hG04=lURGk?vW2kjJHzvYB3aQiP~6)tCXZ7Ui?w&h~%L(@m*^7RJ&P zEBxO-0nn(bfzE|9Iab^J=S2Q~U>g$*-n}0iV{j$`>HiG@M+vA}{t5DlxRX##8Q9+s zR%pJ6FCBSTPA!%-a4pDr%v`suZ3cWr$}GD+QUJoxv=8N!x*(X$e?bHQ8nAue7m6CA zB>h2q%N*b3_08cg(w5(^LT<<}fYx4w2moOQa74?Myy~%NS6U_UuGApMQG@y>_;Zg7 zG0*NP%oO%BuPqo@zIUhAG(uTE?e#NLb4L067XgRTvKNE|y;BoqeJ>AK8jESRgo@}T zS--8-%s(LrpScxZBNNh9+MhaK6-E@4j_y;yxL2q1rcV-0^60CWS)Tm%0dV{Mlo>?+ z&BHNNi-M_ktX+}$mD&9^N_^J1EtK7*D8knVfi7yi@(ddm4k}5y&OR**li9G-$EpSk z?`F}IrhRppq1xYFq~Y~xWg8rGS9Lw^;H$!Nnq`5Fyn{5K9HPy^VdbiqnSUFZA`1am zp7pjWkS5shzX;Q??&~*ooMrdN{Dzl5tJLRdn8Gj)4Cf0ot3)jpiZ}hp)P{I4j*I=b8bJk#4G%JIc1G4;#cc_8j z_Kd>Ov;YNavx;tlL6Av0i^n&U%cfi)&BOG;TzNM?axN+*1t*uWDdQ&&1lV}D@o#t8 z0R=RM^{kmcIR7YN^3rK`Ml>xMV@Zt3<-vR|Mn?!@O4sfsrZ37na5rt^{?(PP@9jAWSVwDpMKnp2(s>0W+l+0vy@$+Ve5CM{l;9b^Z`rlmG z9W0gXvak(GogKI!6R*2U-L2^4X?o~6ozp~9*xeHr1}*b?*1qpRmljgJ)ZK;N*?OuR zH{UWliN4>HQ?<|rLEyGuPL)irc(N9@`&j1wV}sBKX+`j6*e&JX!_Y$_72tc!JRHG8 z;Yv1YX%lBD*wwhM{ska!Q3SJ&j@NA!NpsGUx zOzq6;m>HjQy}i0vfX&$3^(>V6{9ozOL=|g$PIN71gl@Mazc%|fcu7;|kuf=c>#wV- zzkNelpJOP{oSHE0wYwVnPYDt-Sxn?|>YNXU~bkE0i@@AQ23@%%y4zg-J zKSVP+ue~1E<%D2LTFNe`mKK_6Fa|$)I~A3+ul|hIfx`5NzHg&2Jux^1DAf6TScQg8 zLgj#U1i|6o#qXm|chhZL!C=D|3;!Gjq#EmgDh^A->s2rE0zl9Obpq>LLA4!qZrbNB zU|zg)@%SGH^J!yR#(-7K2A-4f(b5DBk$}%aq`80A%odI2m9Yi66Pv*36W?QGY5x76 zqRIO#4;Umx5MrgG00Baki)2`v>-*)@;LKr#v!miAATp^41H15V66!nbES=_#n$ zKD->N%Bsj93;M|jbX3EjZ^~~vD@qs87UNgs#RPp+=F#!+Jat;^^Z#*0ohBC#vvaMzy+&IC;Tzuj}X52K3Kz?FQIGK%0_Z&_#VPa=zYavSR zXG~DU7#fM?$)aI`RsS@%U~4z^_X%Uq6B;aYgL9c7h{VZ8Yt|yxpV#{!Z15(hmQjHL z+Y8B8pBe8a)KZyB;RRq8N%@Cl=i-x)16Z zFNVnZSdKqR34&u-ID+?hUnplsN{;$Nn4F`&V325nuqeN5mX@xJ!q2ZgmCG8aPCii< zQx82LL*5;-{})ldF37d$YP;icK)ItK#H>m~2C(I;&Bj{4wN! zmzQMf3aT%a=~Z$`35F7av0QBVbyF4Kly4g7u>$h0jK7iYIIC)5;Rob6MO{^ISkp#j z8PR%-XCWD6Uq$J@Zr09`3MzpoGmxn$WX|N9W6DOT6gcgME5-mYfNj-QCMyN|kK<1% z?Z@wheZFvjBo8bc66e1>IWfm;2KF?8@vi0lvnhW)qzC zIx6xwSU`AZ(XpF&60c-YHk_`vyud*Mx08$nA__o4YK1);#5OlO?f0f*|6v?>lj+BY zMNJ!sm!roEW(YDX4%7C=-SF$F(6gv;;z_J!1FM(KrjFLO%=W7V^GEJmF zS8;rlB!nvshi3j{_S{poIf}XX%qMEG-e#^1mMy6C+ND2S)0V9ihrpti>(7b>4o1u1 zq49L%S^arn%^Jk-n3V;mP^6;uW|c9%9}a1_Ic!NPq$U#go$%D2J-Wu>_~6%a*)skv z!3IkffXx8`<+^Pd5j%6ao+9F1hr?Z!HS^n4@5;k0KcedLJNH-Pw*jAzc$4=GysfyX7hGwd3hey`XZ~N$E4JY@w^m(Iq5%8O?YDLqJXaccK;L zSuCH?>$&bPtPSaB4(+$fdaEWb=aZj12J?sVu?F4Ej?3YV@3kE?>dhu?9)PA=PlE>P zw78BbZ!cFuBvPKnJF0kr>oDPs`*ylbJ;0M@N+Ax2drK~%x7()ohpx8~`Yt+sY`ed2 zFU1RO8epF7)$sbJx;LNa_j>|ECyIRA-iVCF?7rXiQIi)I5)m>Axz6o=M$-Uua>SYe zu)U+XwsaKUNcO$<9{G7Zp_=g4;H_9_9J$SYg08Xd!|%M3{JKrw-`f~9CKcQ+k4aa{ zin03w2VE;d34RnS7#wVimyme^{%K{WyQMph&?yn2g7$orN!JAh5$$Tg5p!G1iX^;d z$l2_I`kxkNp@&xbhnpiP^JQkXdbTnU?_Hm2$}WGjGALH0}C>;2q_XyqUcM7EXud)4$^luwb>6 zlk2xki$hfNEdHi3=~Z0XdY~}N7zif%3cvQ7PNlnMZ@iGpNCBZ<@f1bd(teUC%?^_x z;qchqqIwX!5tQE{GnsAFS%ztnnddC^K`eSZdl;4b@23}ns~AZ|B~Wn8WI{p^ z8s5$Z@j(6bu{NKp%qvZOj)Z1Y{meh>OEZ=w^nWJGar)^AsEX`{?lBvlEUL89R|TLq z@)s9c2l^O0cxWu>xyzg+74*1ye>ZVM#>Y7(s(=yve~O8;ya)Yg#6G~~)9d^6Q{ezi zta5(S4cUA=J(!Xye+a{PHuvJY`=|N(`Uclb>sP{nRBIipT<&p8{~=OoZKJ18YevCm zSPoSsUERbSUk3NCBoET`E#!|qKm1X*rHau9i!1M9$JyKSK%@Bf{zkcfN%JrsePia6 zo*4fukL%sOyO}iEkj`x#iaP_ZnfB^t{v>* zvT+ycll>;qbkm@yF%Gn!yPKtM+^_cC-aCbv;M!Xkdb^s|a_Xd4ve4P9$%|<&Auy{p zPNCQ553T)WLxW-`bUXO=L61H8dIpOUjrpDk%|SHD@KBO$q0hS2z2@z{5>ol1Fo(+r zo*ZCO5wN|@G%Ftp(K}0)7C{>QiveqKuT2x89| zShNNfz#igU95x=iVx!Y*NWlfjR+qX&?x=N4>bi5xaX9~~XNHIBPIwOcwqjKze1-*k zV;v-IGZ^xFb6U*`w7r#|7hJ&9Wo^HM`$6gt!#{X_O8OHvv!$bShKm@ceB)mLPG&VDzB6kfSaSCA+=m;Dy- zU!sqVCdSw+T{E0T<;R{#=tRh^a7Z&2t@7F;M4b36MG*5P2&1dXx=mk4(RYaMEj9P^ z#k9?FCZyJPMfVd{O9Jj;DKo&29_tyCM<>eNC)h_}Gn)(yQ=sp3jdyjB*{~=ON^!-N z_hvwmpqOEtftZT)PqwT2ciiTw(xZqhA+O_dH-&F<9>ib&-;xa(#L9WB%FIHq+3X2N zVR-V}1o2q?8lZ74lPh8QY17v#pYldI6tFWIeck-=5&Nhz~pW#a;kQdyst z<4K6MtY}60$<#4#uFn&W^?XhpGg_I{dO#&|Nz1|%;Cc)Z%umm-Gt*>;zeEd~b= zcP78f)ppcxza6kGpal1$p!f;hsCKxwpEGmyL#vX`3HDpJ=Wq{Q_A&>D+noRF9|T37 zJce;QLPerO(Eyt%ywmnSUk3ub{!CpIVuL#4pB2B~uKL`{l($x2`g)z9GPx%vS^W=5 zjaa75qK%x~qh$+fHO%x!q7sYCc>61v%!&25`%d3zil5*MG4u}Z$k&L6S!lqo6Tt77;t8= zEbblHNS4jVfnN@0lZ+4p*j`F&V(pzwJAs$^^J`jR#e0v>59@25zcp7BD0`Hx$_@NZ zsvggG7Hd+5ko@rgf5YYtPCtD2#fjXj>`(X%PSz+qw?-X4?9~+?#o8f5uKdf7r&>B!`0_-f~MW+V&b%! z#i30(i>CIM^%S3oGJ^5UZZ6HD2zkBxIa%4fe{QdL=O68N(;kwv>4d#gk5>ri{;!={ zW#^dFdJS)2^GK}BsyVLum2j|^L6U#_+bi4Tc@wo@gWFA@GPt*!c_rD3h_zTF~VQTsmMk&eLDpz28f8X1X+t>Kit9aMqWrO?p*_ zETRz}?o(ZqLFkvaY*R#boyLG)kb_`qGMnDM#CI3 z3lD6x{-XAw|r$EoeCB>6!;3|lDg_1IkN8ju}bxP zE3!5O9^P@G(H#dKu9aD0Zq2i?K3FU&;-0F0Am_xm4=Y|Dpj+G$=}iNCmW0S0m^(5z zOE}kI7a75bC|pN>7sjwSv$MnACfMD`_{Qy)4nr%i`ho$locJaTw}3(TL>Atp`R619&sVyL^R|rVKT>1mP?l~tWNpu7`w}j;-0Q6G zGqX7?+)o`)doWefL$UN<2q;i?h^*}GoiZ}66&hfp^|6?U$*4GNUdBaXi#sn(F~U*yxdf*1x=F^94Gba>S&ZFo!I1LPyKr+1&aJFW^WS$1_x!?nG6nG4XV=DdGo4R zkUx}8$wO+3hI!<(-C6rDIFS`>%F>zOcGjGcr}W%b**RY38U0 zR{eOESGrv{>NcKYpWXCK^Y|!o8E!36Ggy;)W5x1ARB|!#<%5?#BTARnaq~vNsEWG@ z&#L_=$P5&%_%XM}}n}LMDa;DaMB8yzy}^GwXBga2N?RcRhnC|xWz z=LQMZAmFd!UbZVFlh^g5u(Na+#ilf2yUoEu!NiO}8+&^}#cfDt-r0*yF++mE7FrY= zF^>uq3*GZjfuWGwr7#qp*9VDynhUvn@BMco26pf0<};s%xXq{+{f~q9*6z~DkE5)b zZvtn2^CRiw)$eCJcOQ>RZ^!vG#*lO7)=g*|mr7d$Z*Aa{jDd>3AS7(@H=ARS!mn>g z5rpNP#5FLfYYFBMQo+Ve;;VNsff?26K{tSAi4<8q_^*b~hOyeDqK^JkfBnK zP#2ROtrI8KnaCXu4xZRJ>{Fiw++PMx@)GhlPU=7j$j+A+c+=G@Y{ zJr%kEjn5T&yC$;^&qrL2ENdIMKKVlOxCMcjO=);k>)EO?-Y}16Cuix&&1bsB<6?m` zB=+nID_*~rb7I;%bjv^Hy_xTy zWMa50IC?GCcW%VsD(W)e}r?kA@iGy%~j_b zvBr9Ne*N6s*O$o;J@`Kwu234$)E_#a;{UV}d+~^XS4UNg4y+y4yxMYW-f_a9-;3Vl zgs%gMlh@tTR_9BdbJpzj--M0-#!VjJmR-Xe0Z!Fn1K_|y>Xfz<(-eba2J#MgPu>+? zez~vt4 z(ul|Zj-jvlu%PE;47l{`qhjMv|9GJv1V;PT~zpR?kDd6V~Uc)8jwE1;Cg&d;YN^PqI&_5^y8 zRt}qyo^lF>x8odcY~lkDpPatcf0u?X*)M?%6T(4=m{HKZwpZx zoU@+XSMYo;S7Faj5u_mk!t$^`tgWu9g?N4+P0j4KY<>E=*R9%gbu>=^0Z83!ne=xx zK;7%sYj}UBJb?gcF4axXT`V ze}8EbQ{9*|Du(Pr8_o84N|sv7dJtMR&|CStVDF~V$TVZ0QA6HO|K@=y5;}?$~7DAtD7XTk!pCC)$YfsbzDZ>?ikEr(V&Yr-j8Qhf5M+t ztajPIW#wZ#RuV>OgbvqfU&(i#L6&?<6@<6n44>FDVwV|vBd=$%PcN_j0 zj}B@7pAzRRt7J4NS_)pE(f=dGKwCA>7tcl(MB~|;ciU}5o*$$W*{OSgmw>y4lxz-4 zCTKA>0(PBqq79pnEk&sMbi(7}P#;$?(tfqxOC=5Yvc}eS#^18tTcJ(LAFm|5GHHJB zI0#ocZYOx;G8jaYfKE}Yc=i~#n3#k}SE#6`ItrgQ-u%Xvu)9I^-S4F0VpZDbA zkA2VZamQy(zYf?u%@6?URZcK@!ORSnp<>>GF4`--Fii@G-xKCw! zowxm+4Bip~J_~T*L5gUe4^OU8@Eqt>=^GQZfJ9}F#HXr|1ZsS~=%nJw@2O(&H|mUG z7-#|iozf65IUwKsp-44Th3IJX)MHwjo}rfEQO84!YaG>r=I;mn1YDiD z5|)-yd<=ll$qS(#Z{OM80n{)vfIZBW&Eap84s>fvrN zkydAslD5cZaV&d5rAwpQfYM|RXR^JY>+y72Q|X`5RJW)aFMGopaodWF%$aLt!e=vH zr!jdNj`o6KHG3MC$Wl|c*z9_?`=0Pwn7$1KiNho-N}wLd ze;+qKI#!C2x|GVfBqORNUF<$I)j3$t&UH2tjXyZEpeqcQOG&k#t}C)#gB#zbpr`M= zoy5nQC#uSHEIY}dtwxcz6jd-=9K0_lZq#68=@FYg2I~e{7jm`8aN7!)&$K;Cu&qg2 z-=+tI@C0Jv_*}l)he8#;NKRUpe89@>qbZ=B951O(*CQBdhbUn1~U|||h z(z!oA{CjAc>^IS>EADPiV|HryZc1@a$9cCiq9gc(`p9#EA8s=8Na8yYlJe7g_{#o`TU`YXrCT@^4yxxcKBZX_LK(Gnoq45_qos$XuSrD4t~eL$n|l_=-pAC2?LzmCG3h(zq2WmeFn*KICMW8id_!jw!@*?6?z-=5LT{l)vE z)giLfEc=eNNWUJfle_Iti)uBzK+p`|0Sb}!%TeHSw%~=WOhO~(bmFPG2(TvoLF%GN z=+f4G=FT~9k#e=tiYfONs!y+xqL|8{`p}-t>anf}hu7P+fJGjz(Au;oPFn=d1pjL5 ztB_sIiG&HC4*%7MFWIc{paJ z%V9jvIcdN}RGXK0ABzKP&n&k1Q=X+762r8MV#ZVJ*eG?vHBsyR(GDrvoHPxeK)Pcv z7n6aH;f&XmlB8Q})$V;-zGN~(#iFj@bW`zwSpt@1#;#cZUz&l((xF2zQa!TPOf2HMHd&pn;aU zI5Fjuru0{lzp!a2yJegUu@0dHo^c8Jr~;sh#06G1wPI(_hxHDq+pKlPKg{(XaU+3} zzf9b9u`Tg58&3qPBD2$Y<>53E?{bp8(1CDrWRi&6$k{EJBrYHHEqG56tCw2> z9TJz_-sl9_gs_N^&QiNL?2S`7ktW|Reeo9s13o`eaof$qS>K%TJp`4;2{Rpq4AzaF1+;3%C>IDi4iSAP?4U=y@*HHZ2KM>D+E_*09DOja13!`zma_#4xywZESv6*oY6yN)gqqyXQHBpvd5Pj!RS zqsJ4-v~7nQ7vorJ8r0en#cW^huBP+hz5vDd-$eAO``2@hwkTx9yq~vUN@4RI%B+sk zSL#Uw`KM+Nr^A%7h9>%W=g-?5S-fL~S9&RJ9TzLun$op~S7FM4@4wH`i>zIe=P7b)~WwdxEO z!kNYnVsUD@kM^O@lB)GDrZ&Zgvf&;)5q+(W!cx;t`NTT;p}{noMEf0s=$iD5MsurbJ>5+a$@ zY7(TQi=Dk~vUbI~pyRkp7~$fb_L*QJFd<&AHWa1 zn-`S;`tq<$5&Fu!PL=lS5`Jo9UtHD*ep$PRQH$H~rVTEb_mZjLQ`tHT|4P;P&! zU2nMdZu7)Juc*u}?n&l}S=HDaOS#9tMHlNk{qmQM>ARGTS7%;(CVArD?%%;Zk-Fm2 zX;IWsxmO8G^;YC2qUibzg4d`$ee{o}aUL-D0u+dYrw63>B>Q zHO~&SzUgl<=(Q7@APV0^C z6SKR*pRE=uHVNyD4}I8^OgR9B`v^pR=OU@>nv*?4k!-0g)4fQZ`HFnbq_K*jo-n1+$~KVU=E@-!})ZiJS5bAyq2 zq5`)ttax5@A|I|HnJ>7{Z3c#Q9Zpny#1Ry)KDHbzwr?rj@9%;dl(2XePCeoE>T9F9 z<}e0LIwuLtTr>Ppgb3vWsvP+oRcKBvky38>CAQ7#zIbt}*TZYQ@rY?ZanIhi#alPu zw8pb9A$4TKD%fdflM|S>q2E;~p{F=g^1aO(sl3w0%6Lq6kY)BN(y9(mX1aRX0%i#1vm3L$O?G zpL6_{iv8A!!~;8NgJ)&;FFr8|%vFe8{`T`grAwI7Vc!k{$79*QqdFwTivEFWHhRib z`>r8h-%jHNcx4K)``PW#Fzb{#@4hSVnol_4Jh`<27N%Df!J|QtOS@b=eL^-O^kbET zx{dYb7JV>F{lnS3S@C-}5}{o6;_U(B=KjoTnhZ9JW0HlD{?Kn&w4vCbDv_{v2Dokg8ff2P7;cdHLPvCre|v+7YPYb z=Aa+lb*m=k+=ejD>xaJIzHbvL7U~8)1z!xABM%U%SAyA8SgnRLlh{Z^)TRF@-v#s_ zsI(;+S3)*TFw%YR+C}`kHLyN8Xik^NcXYeVcyD@}rd_nub@?$Djh=mr)-<6fw$920 zNlKIDCg>WHXZ(D|-}nP6A4}L5FlXks*Od_~IQVzP-Q?{vOAhUbM1Hf8(dk@SRZGK9 zX$?F+Vf$F8kcMF5sK?paESc{d4VB71C-E**=N4s2MDG<17QJ{aC!_Z^jGFFY<3ln} zvk{6O?(nPa2Ce&m-Rm2n$HVy>y$Ae!v-y(M#QYo6TF`4Z@5q_f<8Uf*Gm_AGp#&RY z5Yl7*sU)V6P8s}4M?00eC+36+^wbfT`~J(-#1D&PFq7fmKL z?eCzDnBbgPmVWjXOuDH_!uo`N)O5)oUGa+4-Hd@-D@_^fswJi=BAjyho~QZ>^2T!Q zQEbuv6QpwMhgYVj9s>ijJU*W6EtCS*x!b0A0zNV;$GlEdno_7&;l z%08tu0mM~NEC$)5;oFbl+UrqC+n#cLy}F5VKmgqP(d<+akDB)`XsvwJ|2A%Q3t0O|kC9{T03bd6? zDu#z1#r@91bXy#5B0ku0X4jg?K$gTO-`=aHfn7vP$Fk=8#SO6~{jXEbtKp4Jnd`n6 z@%F4FiK+3~DwTD-KclWm6$+afJDLM3BQUGq`*Q7fXP_3IQEbyv^H}QffeWd_4c-^f zNxqD#xb#9DKqCyBEj#wUu_y%01!~$hFd?c& zn`UG5@7$WmHa6*;D#60`;%rs#gw1<&wMQVnTqYQXh>Mk|LD=zlTQZ%%q z!JZt6u^81CH|1IWsB(T5=HXUur9R9c*K<#Gvq&*QQtN}_V$?k zS5GetIcHrB z04D1R^e-W&v;zUZ;gzJQdV}{<_2YJLP410uk!p=2ZZm1{vZqv1G{&cCk=t7tEhk+8 zN_y!w7rTRLLdqd&qj`&uLdxX@JzaML>=0xTlY38&HN^zZ^J)YY@A2O$ z$sB>KPbeD-N)Ai2DEtfx;l>K>okv1KCY;&^=Rdm5SHBS&Ok$ddINc*S@u%sf{inEY zs~LDKuA^S>zy~IFAYu6R{u5_u*|mAxG$Q3G)6Y^wvx|?;B>w5O8)+!>9}>{ekh7F| zc3^jFMmgHAHcY$WOy<535)JzpNGo!wuXX&17chN#78|g>FjM%V)3S&6za+o%GZ6%* zI%^JXZ{*N;gwH9=Y~%lY@2y(AR2% zfF9@HA}22=pesLSqcpeu0$wt=;jGP4{b3hd=TWtn*#RYl@qZLT;ky)c?^~wYJ`G z&^}Tmn3OZ(R#X>WrX{e|bBueh9-CK#-siiN50`oo7aA!Lxa@=$z)OzGe2lh45iUM* zaw9%i3uES8H1l05-9(oNbIW`&IE&Ai-H+q>g*|HrHYBb4? zN|S4gS8^$tk9sNS*~^jfwvo<6)L~OB*uMY4qhDz8u6~ze`!Tyq!&wUAy;a0s z)IR?*)`?H2$Z~5fPpt<(-F%F2?nf8jynE37#dcjbQ4AMW=o3p<1FZ5l%g-|Dg-x!0 z4(47!{DhJ`E-YNAbE&7Ms?X}K>a_APslvOGHpArGkvAioh(e~l;K)Pii=dHS5x<49 z{mQK?5yxpR`y5;z19fkEp;9k!fof1eJz}Tacgfo=qa}qd*QKPC*+lE0Ilt)ABGRtU z(c1lGIp}^q^K7?1bRt~1Day;g7jZm`37RNM@4Mg;Yl43=tfx9|U7hZm$V*dV&3@Gk zEYSAj%%zZ8#0$E$zio1SQSJDHDHOMpnIP)8TpCX)^TT7yu_veVkGI1#=CCg;1?Wu) zpXmPMarzvj%~ra}WXq%3c5>V5G-r0OO?uZ%j|v|&U9@$jQG{zwM|Ll6UwGA4f52Qdtov#{sUIa@H1_z(rJD84%5br} z{Z(~pWa;t&^5UAIzGd5*>*~IRODAvI^-a(~@g>%$*5>9No72Ju45Nr`2GbIeG&+pu z#{?D;a4x@|h3}ko9C;@8%MCPVuHLOECB;I^P)oMrocH2jvou@Q$T5e@8bVsrjsBq~ zk1$7KEVPbdNg(wMupJBbdT#xUONMzaBG*m$#O#b3KP}HZY>zS+8MKlZKas`iVyL~N z&fOHfn+Lkr{9qGxs@-^^Tk4Wa<&5jel^%vaYN0i=yUsxDx^5riRjb+LX6&SasHLZf z<~%s#e)eoUZyUW=9G~csqK)N4LoHYm22KW^a2jrwM}_SmNSLn1wZe;R>+3(yNGE2SH#b{?5n2CR@j>gBlMo!M2&o;92(_pj{-s?mnQ5wQ&ByeliUGY!@5 zGW`73Brh!->P_4!9A3_f^lQN0XC&lPAlDo2M}{LKPHk@@uH}2Iz$QLCx^flQcU@s- zlqNnt=_OCOgF=g1Qi+rdi$+e!a4nfem@21?VKU{YuqTzHn%T1$0?K)Wxh~k)K{lt~ z(nq%fvs%u{vxB9&%jfQ)i8z9f8j~%a5KKzre48#jS1~;i?xBh z%OxuUKPAOzzIai95w*8@GFHfBOR_ZJxgDGxM%3Sgq5JM{o7IcwkvnaXk$HOuy9Egj z_g9l%o)o%OofyoYvb}}sD*5MjTJa2&ScR(`3}q8BapY~Ov<)sRmb_Lz_>1HK$+B!e zr2)ikU`sKvFQpW`J{GiQk$jbz#eFuFo+UU~bKF27Q|?qL461S7x^543AEZU%TJNVm| zAja~1skuBiaV1w_Jk_ji8!4OJb!&go_49-{65`w<6XRv0g`8CpeNuX5j~i+d-x1Bk zbznt;;NWIa^Ov-?xZH4tFgL^%g;=VKv7QcjX_@)%g5_!W(sASdz5Aks#k~EJ8nRUn zhnDPS+XtI{tor2TLNdc`@8Y&!akcw2xH=ytpvPp={mAUnz~^JoS3@sI+<-S%TLP+f zcjwEYKX~IM4>mcvw!iZJG>1p*>mBaZ^)&3%;Um=HU#fgH`)j&g_@%rr-hLC2K~{8Z z9FsHPU!t5F611aV&fUJ8Y`^zdn>(&&Q6s>BFSzF(u@EL4W=I zcZ=S;p2k5l*cQNP(I7QL`EIX-BdS(-k>^f49P>3e)a0VcT>fg;Yo|_wN$o!;&{W(2qyIlgZ)O48<39&z|NEkU zMe+ZEAkxtk|5Rqr+mqYfot*eYJ@+-?x7C^92VrSF{J_e{XaUn$>wlVJiM6F9tDxlB z;Rt3njoQ_P3#zc$n-+93e!mg;y5=!ZQH$BXt+qsVfK*(c^Zasd;A;S%tA{2s*xtY@ z$}*42DA?}PG0Iv_FM}fDTJx_49YWxu!?8A#j0vTyBbq=ZD?I=8O}v~sHKeFh634!VG^MR0hQ(H z4_`Lox6M1lsqFW5w@nM#MtMIhRpG?I+MQjss)YZUD9s>g0{pSSV<$~w{F1{8Q`^>< z{W7bFq^olZX9p4+g4Z~hC2UE~@Y>Cl6W>|$CfR-Xu-Wlw-kde>T?^}+mppBi{-C790qt^%xGwD^cI?2<(cE_ED66!tsNYcXp{#1 z>G>e&0&#liN*7H)-5#Q?i zd(g)^r&3f9Xqh*93aPviMlT!nm-1?ZBr+v3-&dPOoN^QV8AmFwZF<2EA@_|wd@SYf zb!qaugCoUTfm{{~lWLp0-g{Asj_!0pxSt>;zCw9|uY<}Pojs7s?Uce9hMjV(K26~% zT-M)1UPxnt(yN>YZUc$NkG2T32LIO0^NXEA3ifV{1+)7JIMQ%2wAU1Y)ko{j4_@!B zH+Hqw0Qwk+qTYCKOp>n}i-kbKmTUSemHKuBw#}L833Fb0pOnC%LA;g+O>F=cNB-kg zh`L*;{>9ZOBzZF~()LXh&bC5Wm34zmx~YJ~#`FtUH;@!&=7d*SQ$y-r-RS7^&9@V$RY@KVcO}|o>=ut%3E{G#CaNB$ z4(FMRFtV1%z^$5i??w7xh=%&u+w9xZG16v#o#vaJT%jH(9s_yUgEK8foCKAe3qj$o ziE_zGB2}V$Z}QEpyjkbVO)()tX4|=|eOD8k7%k=dt&;r(Xf-tn6itP+-ex8=)hMGh z^^k$q$8jizeHz4EZ1ULXZ!2S4eCzFi{v+Qm+gb|=%xI9I&>DhvUKMqdS6;g}ZtF1H zESk|2LDA^+^FwtaX3fnUhPD!AWUJ4in^L7rqO?55Jl~#l=&6u-Bv>%f<;>17em`e2 z;|m`}FhqN)-GO!sAl!K43@QdAuQNo0C&r0Pdj|4NB0&v)G}T|RQ{adYFu(oZXdd3s zBu+^XXnAJlns5pVg{F)8-WF*V$K{lsTs`|W1md11x(m=B$$blLi)hUU|Dax%(H$yR zAGfZT@+Y~beQW%5K~f*vLgFQsHFhZO?4g(#Nz=9Qm(a}OD8BS~GSpT5G}oRxigPe> z6%zBzCQR12C-|A&<#Xy+BNLoiH-q!$QFJ+uDS1j|m&VffP*S4$SJ(e2S0Jva%(ye9 zPP4*a^T^|fUEGEg7Rq`jC_o91mR`?QL5XgsCnrvWR3tKiWCKO9T9G96RHaHEw^k1g z{MmQUGpYeg%iC|y_MG1_o9@|z!=XvcOpS%77Hu0tL^NhLur=<|mOGn4dwr3*y=>B+ zeK#M{GHJY7C=H72-S=-#Z46pmlZzyq58qs`?K3DFG|5}*2{s87PJLWf5Ifs8 z=Yve(ug;u)q~BMYMZ+BM%=znmA^dv0mym!6VD4J+;9qMZK{`&N+d^;w@gB8v0k z_~Jz`zmG>;{Z{OACRgkA#M>kfPwB!MrE%wziVy0#hOb@?w~(}(Pkw2rA%=*aj@!*p zfZ|W5m8})qko+nLO}9d}{gpvVe|gq%mlG_1_L<>@r4`F6U?Gs%CWoF?m*H-P9Arn? zR$XL-67ssJW|5_RIYBpj`}y;^?AZ7(slDSwgr0C~zZr+S%#q%5-^8Db(8D3(I}2da zgT7AymMHV7b&UuQmy?r=%P;*N`xnpy0@R*2_+8I$!MC{CBT|>$2O|#xo7sn-hI3O_rhocC>cwxwuXOi~7i&pKGH=gCX9VO#NM<<)qz&Y%o- z)_+Fo^8haJn5(uqgh9b~#@D=&VY)t>ci^Nn@%0jmtU05fv(5mJOBGu3D%Oilp~dOW zX0;!Wp50qk7&fK7wJgxS8DemyBez5L(&7#N<^6IP-(rFK73$-Do@}I*&+IqYBgG}( z)5Jq}FC}*`0#{Bq$rniBrIyT>H?^h^H?ztn^7h5*ruHuZ&_y1RJu!1Pl$(Roj>Gs{ z9@WR`^|rqD@O8JDX)hsRE7m!ulOh93Z;l*#&zpcu(lL`u@O_KAzb)Hgl(hd+nflee z!+5#64=pzp8AR=AGzhW&#nnSfTKWeYK>-SqNuGO1HddLc8vF9hv|KNT$7arbZ)HSf z`(EVEFXgIh*A}D6JF3d|y;O0bR+|1K~Y>(E?-iI3-96Wu;rbjKBu-xyTx+~%Pv z@hDaz`7rPP{ly*Ly_aI$&}L7G;mOsj(n$5h7E0ry(*rH*Fhgz#fZcmMzkIeZ>Iyj!LZcgUjc$(@S z-j%%UjE|3RU$Fd2ebcAJX7`Lj+z4ro^wJr88RC1p`0{PAgY>p?{uF+u^~dj4;y}f2 zx$)&8^iWg!{F)Y1-u0nm)`YJ}K@TUK>gp_801ns0O%QD3%slUHJxDUXQ2sYQ(CBx&>O#sSqH4btq2dIW*5>8oT`FfyjFju*zS=1eG>EDX&*+z3K&fwL)~6HaBI!S zH&=8KQP;U+Pa=0}EA&kNuU^fpxh*$~IhVMm;3Qn?*a45u_!kKdL1SE8srHidBz8PN7hO#iTZZZ;H$e0*2Ep|kS4iDZ5dd-^UZ^jDq7`jDm7FKSb zF+rk5gu8=G1%fSI=pjS~!tuKKcr&HdkGm>adpIKM2DRyW48A`R4tA@+4HIlsGfThmE8j4NB}Bly)Vb8rV*3MPGgsa%6f(pygULqGoUU$Ce{eOn&BAO-h^fG>u8#q zKFlpBWo^vxF@`KDELAu&)SCx87BxQui)>t``TH3h*IH=(=!MwU{e!fl%lsQ&Y<2); zD>Kt{v2AI_p%j>T3wrZac-=1eGv@1T^C;A&k*5e9uM^H8myrgBkZUL|8;eLa81sqo zi>TSVikt-P@p_zH9vg|c>L6+#)80d$1dANoH26L8vz$P-%rP zKT=*nA^hvt@9${274!4*)CdT1aX;AGE04SQ_y|L)o;`a8w32aU!)l>gEzTcZGC)SE z$;&tepy@1>gq;F}taX0f4f2grw>uvt-QeDH<*uhBk(oM;pIB;cQ|afvCki|o`MQFtO2wvNR`6;{)xTGkhcOc@!bY5;QB(;X@o;X?rs{oqQEK= z2Kj7U`Qay8_NQ!_w#zs&hDca1cIsd0qE$YHb)gkqB7-5Q&U3WQtQoSfzGA(dMd7b zcE?jXTslL8Gpxdi34nd)to=ely^))pUV`EhfzqT0|4Ci0tEH0^|+9YYXYzINg zAZ6e%roBPr?c@5v@neQE{Sj&7)@|=MgG=36oqbHxO-~%|;KLbsrsVv(@Tb`N{M%2? z)BY4(w<+cE^qMINNN#J>@U(;4-MF=N&_%N0Szo9)jJSfihO*LOZlFy}KN9H(zds@L zjUbbO5j#1YE)!`-JVnmWE$N4qz4HBTEwJBzYxD}XTS)TX`7Lxs8(;fwx~af3_^bQi zo*3nK!i>g=t?8dCni#eHhn5sg=5#~+Npp>k_BQ9wMU@C7$Ac;kxTplmRVsutNkJt_ zq(MIXje!DPMCtQ2TN=L2qDUI3Ueag)hgoa>(`I4w@msOGt)y0kQpV7%mtia7A`-L& zRNbZN7Mb)4YnfpbAbjm1jYO5$l%+FI|Xj1h)llqPq7-Q)Ck%v?>HWj`~1mp?B}z$;mx|oelDW39cM}>0M|RL1TM& zv!{)%!U{=isng?O8c>%467biSJiD1j7vn3pUYFIyeWu;U*9Ls%+1f%$dC*5}I$YUN z^{R?l9*kDA)Uy%V#N*I(%w&3+zO?-t(4| z^`h!~#r5d63nsH-`&-X2D>TaoTcZ8i(>O;pi`PM!&+MhxN2lUP^i}PQ)~nyLiPeA4 zrt_KuM!wu+$G~`YdcJw$H}yw)+F%_NqA)ArDx7nd+ce19_bx$EyZ9!N5u}{D#=-&~ zijub^B`%I(w*2y0B}GuM$fveuO?ELzrq_!bq+|FKPhx*Zj?pu>W(%HD1LO>c%Ch7L zXS%q!TDGa_-tgb&O34I2moua)-GV~^^sB6(s3>OOh|t9r&d{ zug*xo3hC|Zo1UJAe0-fN^t7ZHrG1s`@8)&et4vC97KJAf3SqWkRTv5U4M(kj`j!^B zy@$mQzgf5K&ASLQMdu=KRuegCoi4pecYpkc3!tp-L+Yg3cDEMgtd(Ze({1i(! ziJL1~h@SF#E8Pz*%>nl|I(*F1eqgPDFF|lsr;!glV|Lmg2TzQ)^gDCa>2htK4JxS+K&g~hOc$JlLJueH3}ICV&{RHcfIt^~AaAAw zZi{^3>0?TIo;oEr3Pm&y;ARCD5zO_V8=tw(ybe#Zh~kl3huXH_FUT4ogmOe*T*0&%uP zKa}k(Y;&>Y!pgqq;2RU)9w(1f26fQuBVPWylPipNkPLQY#q%xDTrG>iq=^lPF9^^) z>^W2Nu%0|oP%t#5H5L~a*URp$C;1ni4B*Xjp)vExwybvU+ui6Af&r{K`peAq{HJ^C zx%0QPIYrJWBl0yr;WJp~Qk$f$uCPe8&oL5gzAe=*b#rT!;UmdwFA6Xhx}Q+w4wzQ;iH&>*t zH!#REhCavq+s)05TXDJ3Ip64C$mluJv+q9*2DI97gxg=OXVUn8*}S|;1|HWnH#u&^m}Nc#XZY4 z-P$q>b_0rEPp=qb|Gw{NJToUh!KFr=b^mJF#HBC&V$Io)jTN z*BAw0Ofn6xB4 zeZyDlmV~a~qH|-FJ<2QBZ$0+zaLUWqtWS>@Sqgalzi4i50(nssM=Ivl$^+58epmBX z$S)Kb?PR!kpxOJ_h{<+m!@gW{p!f;b82dr9erMFW8FAY|T zqw_HNH2u0xqTX&XMlRP@SDHGSUr2~WljUFU!{!@VLFoL6!PoD&S&YKS&Hml_s!OEb zN2hL%u6kDM4LO{==>`lbg^51sBcP&{o;=0WUXE~mHv4g1K!eLvDbcBx@g#Zj=QSD0 zKN&eR6z^Rkss=-?B5{LHHXT{=*hE~onkyuzCU~7AUcPo+nt6jBjGNWqypqq;{5*EN z`V6wqh>gCzDqjyf*QBb#a7@**opHat?PAvLn&EUg%{qQG&lxnaB-CWobBwWWPc&6b z+!!RKn8I0^T058hdG>QPsh@u1*+ImeAP+u~`9-I>Inn0-EdZ--t?NB!(NpuuNv2~Z z@Nb}nh>|%?g4^2KDl6eEC8G`_V2)veP*FEw{gOE&KDd;r&Dn211MVs{nJM{I z06=x(-WkTJ=9%rT<{P3ofK+*~n8L=+MLik4*HUh0{bCr;KT@OX5!oB$6{$$-Wl)78 zgv_5IPZg;0v&>D8a}2eU3}h|GgZjr%cNRN1-_+!~CVq~-ONsXJIE*_R0HMwx@AMw5 zvf%qAaacMB>;JIc-(!whYWHdmr%@gs-ph&4?zMeajGyf3;XS=9FNoYOi3_;{XLyl{ zaM{Hb50SO7!9N=yHtrK{uZ4kmWTo+c1&cYYgrbPIFJQ6xHA;vZc~K`KUSajaij@O_ zV8GWbJq6?gA)+@F@T+s!z>Iy1mn*l^V!Q7hMhZU34M|}A_KKEXRUY+zZeAjHl^`hd zI*SG}r5~r35a_>DRgwLm>jhc5nAKccP=;3bx&EwVOtb&Rr9xweS|4qZ*KxAHrr$wlT8A0TdNk2bLvHeQgby?&y!^4h~o87n@&IChSu@^QTI zy66rP6X>scoVnJ8d~|c$0?iPdt%ua=H4#AA$1m~IBp%O2=OLp@{pF?rHyZRlAW3auu8vx;xb4QVG1LEh zYe4^Tt|-UTy}4S1w?T9JbglPxzmoXW-gOHgvK7}KmVz7{6~J7Sm)8p@7(G2bU0srl z1Y28Mr^3g8>1=OrZ)vFm*v=Xn8{6A`jI%B~FO`2YZ3aIjf^~0y&P4oGXPDy7E>fKX z<_J#DkGRCg9UPcfM|L#si$xLpLZ{_onH3>hVDCtyG4IQ65$)-mgY;Q0w=b=^^m+-} z#CKh*(yJn($t1p>j%$}uUSQ{%sW`KoZOyYe@o{sP$y`j%9ZvJXww&C=$f09z$JW4j zUXYjAx}`vP*N%YXGo^dPT2XYSzutVR^=NG_j?*i1aviaufX@71u1J(f*Iuq;+=wpu`KPHfNBuk8r#(VxeCMKrytfeY~LHb`7k#O{}wC^NTVr*ah@bt0>G3xVHmwv!8H_!Yl{_GcpsuFP9dV8yPSf}&yk zsl(SO-^q*+*m6sHnqr~sQ*z(*u2yVIAh&vruZlM67UTXIgcJ+^rAAuOZ$XAF(FdF|1(o!=O-YZ zKU?{Sy@^6QJG*kXGp__w`=ypfuZumtkD%Air{}IUm{b!c*ijiRneg>3Wf11sZ?yLJ zg)ONTDQt|49<{E3jz34j(>D2MqWpbwC7s81t|nOMHP7QU{z&htpe)8uZ$6ltza2kM z7jh?g!Aua!V?EU(Aasfn0TcYswN`7D8~W}~y(-k0yUQ&pVU_qAA5Tct-QRC9TV+~T zSLf8|^B>Jwr|>^&z^{9DG?&zNa{>JsK7M<9i?}*gCz$-$7}Ih0ho14P$QxdpnGVM^ zK#dq)?DP0PcoP2vedn{181pCoS1)>|C!VR_|(kkWfi5lew}1|7>UTx ze(*n0LH^sD4gDFX_JJ4svJM<6z*L2{LXL;~W^Zq)faH%V7Eu4GmIr?F=6CfiG>m(S z#du}Szg_shZutKvMdUwPbBEri*sURf?@bK#RRNlC2RDPoF3vZErN3lVj`45nw6IER zs#;=4xm{FLZPPY*_7MBW7j4sdUEk z`k{n1P^X81!=ecA7me{-69!z!AF;1;Q0whUSkm+<-rHf?XkDglyFj*eVozVuw6+Dh z{RuC(*Z)mN$RWb*F@*h)=mhSGe5u1I;>+M`yj z1v(PN_}h=Kdl0(m=GYGAhH#|5l^M`7Kw!nA6h?oE& zXtWkK{<2@sg+|wr2sXG~t%)ckB9v3)sZ8agADplw7T;8b(rz0?BXhmX^T*u(?0guxm-|@#F zJ{ev=z4E<*_fE+5w#;v*!VE&iDBX{A?;`2Y$)#PBy+u>&;cN($_Gy<-BuBy$Sg`!C zUoWBVa@`)s6vDIcwnW(B$m(u4sQVhf-3PZsK6DtxZR_YWN$T`Ukj`yK*x1LPGH{wy z!Z{aX+7lxTSEdYkk!y(@5WQG`t27r9J&H9NI&x>ZR7qClz!q-UFFTo6Y+*J`u5 z!Vq;+5;dtK5<$2nYyxZqy@}bysnN-)0rNRdVyd8i=k+On-WuzI)MoeQXMv*nWWL#_ zqh3C;Yr!Z5;u(>#dC2%r=yLl!LoHe?njuQ;H#@kw%@%KMdUSou-{`@Q zaNQGeHC1qvS8p7XVadZ5CZadGUq)biJCFCNc5R`ewU7$UCu20r;d>XWTciykfLh$q z{P16UbA2Y<+1&X(^$8irh;%I+D_pu={3mrNiwIF7D?`vpd_9ap{vD)fs>027d%Z-@ zAGKEZkaB1NuAn3fSU0pau~A=*$6+8=E05A6-A+wnyat(|omvMb{#TBepSM3}ltQtD$+dF>-< zOlYDA>kmbs`Gpk=TlV|3lrx$HHJ5G?^J>(;i5-LK#>O9xch`;RSY^X*T=%*&o?EBd z1^+UHd=eLz4%Ym0&rV#En-fY!z)=c-oK_Y?ZhrH>R>=D{x3_?3%&J;Go7ZvyV>Q)$ zxyp<2TcRav--ffl{4f0b-~lA#Vug~Ql{w5%h+JFnV})pLL$pU_jER0{m!dAo*Nz!% zo%1t~v$yP;ePssE)jRgxZ;a-vKE04A6LuW!$GLB~pI=;jiV z8x0Q*eN)oOfl@lol-j${o<=rId#%2CtLc*rjL;?1e>KYy>Q9;;N#v3o%Jh7C0qqq)5YZidcT4c1?%3TCFmAlk(fN%eKo1KHBs@3V7W&}R&{ z86#ulcg`6w$l6(E_U#3qP-8^wQ+#jvaC(mXGHprhJ^V>PonD07SZeuaHl^21v2K8Xm_QeX{R^o$VVXLaTimD_Hk(JFQx8BO04zY z59K6Y@h8W5N^j*6g5L=iKbfWB7`Y&cg2=P6vBiwAnkEznG7l76FAE9Nokh}F56>!< z8+dn?g@qsMeo4}pIdwYIq(We5UE0M9TTgI2fr1lL-N#2%;m=OSWhE5l1-v&~E^9Wp zo9REc2VmLrGndEYE#|$R;hOS4eIJ!j$@xmK&UU#Bk}6cHIrtGZ^Ma6wD4UjBKKUZ! z*<;iCBr5i&5km$yJ?45&<(yi!KbP1IJ?-c0^w#fjgl|4vot){l`5vZBzV`VF29bHai>x@vETFYddc+V5EYk zJ{*vE_lO~o^>JxK#k=Oo*Y9G~?p<0Bjo<2CEVN8hpnMjY5~s33Ui<5)7KBsA$zZ3U z^}U$EKGy0=dnS8sn^uNncIr%>&lz;kS^HztWvslPuIF(6_BcL8bF;~|9s2$8mwVNO zt}v~PDl9tcvTx;HR@-1)e!!=G08-6TDYZ3`CLfOAJb(rqiqzK zJ?|+u)dh<3zYPge2*Z__IeiB8-28^)p7N#gz92-fY0>9ZsNoBVYV&kRBW}DvA2z9m zvbhNN#UwGtVepKoh_$9=zk-26UtL?3i0kMT7VP~OT&w$Gdw;o#C;7eZ+*d80F!4sS zWjqn&M?|s!yRxoN(|Kq8RQScQLXz7My?zP~u?7gpt|<9ls)E%9L#tfOynXWZ%T{0g zk4Ex>4mm#PMMXuH3`+4B^?#i@y+3Z# zjrFsfoEQocu`B}FAOyXNC#@=V5B)|t{ixTw3ndhHvh`}=)s(HcaS2C@o&^*6o(PLp zEf4s{G6VghG7V|*dy*CXRFb+)0wS9O*#R5{bul;PjXIyEadUTE^YoAvP8 z-pIU)_Y+)S4GvYIJ|ZVXA)V!`oapM6(7hgMR^oh?Z+;8jZ@T%SSq$1CMA_!l3v7B= zwOQHlU*0^Rs>0>-t!&x=QXtodka^wZub+W-8gH7-6gw9N$1V?r(~PDzI?C`tlmuE} z5c8-zjs^E25=eS*G`|}%=O;Hhi?2nMR;2bQ(|V$@bvo@!e!F+Y)n!v#UHC!Lw{}H~ zv3^M<5#)L-x^nH;^RakZiq`iN)2~7J-ZPCF-5g{Rg zbdaqRBu=orNOdq(Vd(BGDBnB2G%sk1z+UuU^I z5N%k=v~bKCXk+2sHi zFNIK**fbYyul#Fo7ysS;LuR8AnxnT_hWi4*$qSk4T>#m|^ zIaiSxeynFjJ6BANUJbILJE!OPBLcU3k z5+<|?Ik_0Umw5l8OZpyw~_{>&kyRD4$Mf@V7N6-C{oJ0Y!GswqI*ROAy zL2P7VbI`*;QSbUZww*-A?|jL?uchNLno4@ILZ?j|zm7hu0^iNqZsCLrh~1qkNF(#k zdGl$aUz^uyUPH_;+te;P;bLDM=Pqf}R9@7BTOOPIbOPympS*9?^U~>D{SIUetR-xgpWa-?YPS7Qw3sP)ORjW|u^5%|$pVOPAYf4PMvuqfCa2cY zi~flBii#Lm0XE|ghOt2HeLGuo#_){6nxa-v^!&P5xy}`}z4l6S5z!9974g0;ktTJ! zZ#(c%N?(Ol2i{A>_~)+%PLtoTYje@__75=5+Gvqrpsggi2>K@L2z__B z>^S18Vui#|%T}?P(Eyxg#SdjCQ&U1_|=YN7bwM+jv0FIlpypSR&Ho=gFQ?KobN za9g|qh#O>SqZZsG5j<8C@0663l$CLwK6MG2792S6_;urF#VuTgP&3HzFz?H!Mn*=z z0#M=su>I*HNjJATs2K}odozi4!k^a+Eb`e|vf(8nBEkoHy+dt9MFsGv)wB9H;I2Jg z^*>D#S#Xz$I8Cdms2J(%3*mi$wvyuG2eq4yb>3SEELOII z@e+|Ptflt(I=&4MivW6T5zX!I-c}!0R5V#CcQmY&X%t<46;2B3I-S2IKh7&SFatd4 zul_uqe@a5Vu`$XGDs;E}NS>!SS(L*{&IcKiDkVBU(?@I5qvZw=p$fnrzn%A!>Bx!m zkhh9UU;|^#FD1^eJ^!94nr>0g!0YnnJ=d^<(R0gB72=y?h5P4K=cd$}uZ|slbE>Yq z;&D$byPIr+Z))MdAkABk&R-eoAs4@Pv{&E+@f@GiSdWF{LBWsn^8rsv0r+?Dk;-BG zCK$q0Cu>-nDs|hRa97O`Ws~ge0O`C*6}`Pl7ec>(^)useg}uDJwIXMeg(ld5h9O@6 zFKTLlDKhFBI7Re%O&AH~9_jTX*bzjqQ zNlBX~U2^AaJ5x-b%5qR6RnF5VuC70iMZz62(Oy0CdlKe%WlLIfPg7!7zXo7sLq04th>h)s~gzZ-I$hv%fHt3zXaW&4tVDLdCqQ^|p zdZx3{v`RBS%@=35n)BC38J2#HkGNrLM^VEnV3OA||N9O4@x`GoJ~NTLW!EGtGAF!Y zZ6a30O07rBaym7;_mF8{bp|@xViX+{#rWx~X;;;`j>DpBB=nEHhD9{DV) z0_Xo7^#8qhEIMkRdt^Ws4|vjGqC@#WIj z=v407EeWT|)v_FsemNt-q>k?5%on39nXexRA;8|Bktv0t!iyH=#I zPkU|1FnHyrR=e6Qu7HMsbgI5Vk&&=%>=q=8XUTo{N0Shbj3%SkQ?1yUqbuA(?nJnI zlcX;pr$k3Rfzy~uT{JOHKJY9!2M8ljV4RawJ_f$tR}NsnoE#H3%6EB-uQJB3bn#+NF$XzXm9^xNX1Ct(W`tjV!y_``ejIiGI zlhi}kU>nj@c($gzPO$H-;;?aAUCyA9PVoXkLvo?>WMf05i#&d2qB>qxB-kE4(_3w< zGO5Y-MDK3-G*%xXnkXx?VT0sH`1!A=Mea1i{C1Gob^%1|rj>Nf-WRMRULJtcQ}I=8 z)s%FcDHdj^QtcOAYuBrhpz&PV}w`erFd{(^+&5xcdlME69}W97(r zh6gie>y~xLWO@(PU;x2#49aQ&zI8p=f4N~GgQsNb3@2-DH(rVrV9Mv7=4VQN-$>WA z)Eg!qBv)TRdM7QOsV1s<)6gPN4QGCr856kmO^Aj2aN|f_FBQ()^DD;RvLg|(<+DzI zI`=UV(@B&z5iuH2r3nt+E4Wm=KM?d?DWOw5k?eSt`&z%7`QTu8dJ|0V!__zfq9%0v zyG4sU!hfP5uQ4RBG9;cpo}SCuV*7BhHJ#v0LO8C);p7}rVrZ=H-8=+DvORum$)^L& zqo_|`p4%g&VU*O57uRY@pYAeQ)J|VxIsKKOXeF|otlIxa_UX1f7GKWfg3k8;x4LLO zDMGRoRigg^5IptzA-e-{O*>ui6CiT2{YuUNO%>#DqQr(1-TA5Vxx3ssc(3lh zigHy9^p6D#)k^-^3_0Uvx}i~~a1X9woTOOCJ`bwV<3 z2{gjcx4zc*GZI_sRj9YgXXUksKJ$tdqx_EBdR>H}36x~YV<{QxoaD!2F1MTfLfbll z0lCgFehT%p&Sty)Q_&t6dDlhGReJ?P*AB(h5onuZ`_gyx_hicd{e<T~STe^^}V zDAs4{aI zsu~p?y<`L+z(DX0nvI#+QS1)Q<@Xu|37i{)Wy3Z%?C>4ki&mD8=KMeE7HuFca0*mK z3dd_b%w@b|rMKe$Hf2w14Qfu(1{jDZV^+%5PG?Vd6^Hoz>fGv8+***HGLVq}I0%w-6?<}a^TZ0Pg?h`?{#M0djzwM*ssooU! zmbHbz6I0{&%X5w1C5~%lv8L657&Z?MjGidm?THm(bA)gF1(l`DJ}Qjv)?Lj(QD+T{ zoLIy)7SSrj56`G_?A)^Vu(Pg_n$T|Hn)O-dPI=N4Lw|KhVDN#@Qav;KELP-d(0;BY z<|M$~1YFraf8DHQ?#(bAA;#-V*pL@Il(;uIqY0Hw(S2Rl%Y9yG08y6r(u}dUZF5pT z^4FS(13)CZOvox_Gh}jxf`an-Z#O`)z|PgEs`*!o^5Ctz+8lGaA;)fn5!v>w+6U+! zY0klMbJKeFnMO}%yX+8g8=wpmXpOWL5mD`2qD{rYnGou;g>-!fiLrH@VH!vyA3o=r zhf(Gc6#8eYo#y1eV~0qTCmGv+y+Dd*m6PsmL>Kaqq!Jsc5;tu~ivD+AZ#! zThQ5NvWI8&+jh!--F7)Hs1r6m8B<%!Y6&dxn-Bk{N8N(&Ug$Rqr>`<(lROj$Jlx-d z?T7l5uAvCVo@Nu%kn@a}gH2{4`>dnWc81*v-MwVcc{6F&#K(E5*0MjMf9-^pDluGZ z&=u|foMv<4dk!Fy7ItHt&^pXyhnv=l3Gf*|WaGP^cfUvZ-}+8|tX}S2J#P;7DbH=s z!R=i&0_UulzC@|bUJRVAw(f(_&OSe7-FndA+*uX3>be zm6)bDF)2(AmVo@8(_lx%PkJbi^!3NWUxc&NWv$yb3@=tnH zv-7&Ue7R}%ll8NTWnX88mGxnB0kly$RQ^HeCY@S{JQQkbBk5n?W80>$C&WD#b;Ix# zDFmI|Ztr!aG%6#bwZv({Sm&YiR`{5eZ42NNQ~!2?>np1uFhE(gSo%EtEnbAQu)FD7 z1c{8`gI7KJ4u<1~t6R*g7k|A0XBhCXSY_i_&7d_-QZV`8u%Y5&@lg#CjugmXyS=`RMjVhCt3U%qOx;qG=?KBrq)53s-O4f7`tm?;BMrNXGGtu z5dpW(q|0efEJL>d^v-DuL?HpJq}`GTb^=e`KOFw< zdJAU^cXM5=_~76d8k!L3fQCV zxdySV;Z~5^OvPcvi`%BxLbuBT55qtc(DASvBhG!lB#`!o!m7OWU?WGT@dD+-dk4dQ zhfxxGAp*xf%<_A!5@jk^Tl~-a;ppS}maPHG31_X|YJN6Fb)hbeKtZI^Nl#G%CJ>J$ z76_^k3THS+)>i$3Re%0C56TTPYF%YMC;M!^%i{5lnpy&|D`!%&@!0^@F(r;OWaED_ z&R5)anm_j2RT~M~RZH>l@d*omx(LDi?*{mrylio8H)r2!bV5f*n@!{1jRGAMqP>OhyU-Mdc{MR?%ruWisLwCOY!|-KvjTzdcI6Q7ml*AA zoZ!f0JH?w8n%}{o*|6l6V1>9c2*@P8$bz8TnoPXx8y)I$m=>zYqk(0oz7;Le6yP^; zR4J+fn);W?LZb?#@h}*?Fop#y9zODREShpT7Okx;a7jJfSnJgshTo#ldhr`KcrZ9w z7(j~|$!DmN=GETN%B)m*h-+OjW2T75EHbXl(@)G>U(;9%c&{QM@UJhW#D8!X624 z+$madKXY7AQP-|H#UB?h)x|lRbtU6AH)2^5L}*lN+ss+~QXY%rMmu+scb;&)DDas5 z14wU5|BZ4f-1;m&gLwb)#j@4g4rc~XBI2Bv_09-I7&}*?(g~7ArGG#O(wO103gE>5 zA6wu8!LW+#OgZrOAT;j$D8wBEus#G$G0YDPD6(w%+Z%GL0j!_P?}}qPhTf&t=-tF zdch8i&2yn^KXvr>OhYZ4bF|Nwy;;K7S=Aut1-MijZktWnbr`n?%yTBKXHuZErn8}s zqkZ`A@y@Xl);ukf5BHjbEM`zE&1p;41OC{~e5(0JUkg*p`rTV}lNv&~syoHxwps(6I_X6#G!R<1-a((mHT1N9u8x65m<( z4rSk|N?>6kADkThEXZ#Pv9GvSQzc5O!wwct`2Cu*R4V0lo!EaNYN3`QPjd19$w-Xv zoOJU$DmSZfL=}Aot8_jAi9YjP~%8m-YGbM*^9cYjGo0V8^c9q03^xx~#f&W5tfruGMfoV$YNM@LHTujiLZGU9j5F%W)pCF9&;J2>g1n96 zyBIzOSYB!sXKSX@TZ2wQ6D4ratNnFr%Y_!x|k<^VW5t40AcX z!t_dKzbGo)q-fnexv@y!o#B@$a1zma9A;P)CRVss1Xb&;?{sf0EHiI=u^Az*T&h4N zTuF2d59fj(e&SNs7hn4zof8Gd-b#XON>ar~i;fJ!SgR@C zn08^tE*dxAIU1jP&(i0iib8$ex}W`e`tel*C4`XL9`;Gu`#8>R?gZ_`Bm4 zdaG$7zK&HcLDxZlcJ`W`K0<@F@OBoV&8b1myJ;YE^Y{|J0KlfirHzIc;P;U*Gj6jE zbwwp>R2i0RSZ35LUT)nKf*EK(G$GbeH8!yn8hFTo5}F`Q!r(_qW=&6MOj?iMIfuu_ zR}UNCFo0oA#;CWZn_D$OE@qJV*30Vq66I6Y)C~n);a|*N7;i=}>xLS)ocq{4OWR&t zaH))r$aQ*HB;>tT;vQmFOI&Vfx-m9|(w}@joggJY7M*acl32E81GScg0U5XsIBEKXHa01q(V_%uXrSbl-xzW`8$m0b3fD zn0gU?Qfly0Kv1!8(!>)T5WS4s5XO}JGK_cj*_?8jrerbPW?e~WI(>6ToL{c^G#M4R z9BGI-sTBhcDKeywAB>u6h@9E)3M(K}wZ9~&R(&fn$S`@x6=qj$BF@3d5vBvP&(3cNt~yFFwnV75QTo(B;${RGICeRh zvwF*8Xsd5V0}x<#?&)Eqbg}RgD~B;H-V#;nhGDa?$SLFo-2KG~H3YS1?v(7VDezMD z6_Yrn+~fTLUcg}a$*&6+rzdc;%UkZPKML-f>pxJSDzz()6Z9(9xos$EsGXW*o=Z*l z@%?QI5AS~JXZ8)_6RtC{_VA`5aJzrz#}yN&t@x?TlZdutvVXt`H%6Y{>2 zO|dKlo1lZhwELBYuD~Us{M!teUxI>8Tjo`5MYZ&^o09QeomOlI(IvD!)8)t z_&XJnp4`j(vje&U3SPK{`GMhurKudJKhSrKRV|gienMbrA=N9{xl}}J2uNp^MinnZ z8-L|Lbc>ce-#wH3tMt#&5|l|oB&E5O5kCb*3RL5N9 zWzX~eThh%G2ZdCVPgw`4Pka{u7r{P7MT478B)d`l9;A5pvbpYTDxBvrgn*jHATfHy z%x2A(oFtu1F`!!t`x}o!tOg8G$s(s#x^LCN;JsbivWc(B1)PA`S70o2fK5+RQuK{L zKiJyJ+{W97LFah}GrrUYVzb<+1NcJQv1Y zcsO7pitT9sQoHoO1VT%S4txXW#$Kb>BL7?@)p#`fFB$J3KMtC|Auu~nsdmpkA9`OCzxpm&=b0uH9Xc_IceN|Dd#iDU{E33F$ zKGzznpnp3c=^@4ZNx`R)N4=N^S?vBook*78d5Qka^%C{}Z~^EQY}OdVBL7vD0L-_n z+QCgmwVR8}QH=NGpa!kjX35Gams`;tE)qOkTtH2eJ}N4m4nkh9X31zG^fMgf0~|<8 zgx}7mm>3{2e);{g0+h-~_FdG^{}qvJ?9RLV-#bKKPabAZR_(<&{gQ9;S#8QS7^HJl z0JM&N(EJnntF@LVk+wzh*@V2Q-6l6S{Ma`ayDqH(cJKCYpF>YZ_2*~4s}VzhSezbn zs4ftSyx9I{H)=^%S>k%*hG0_Ms3 z;fA1>-G4NL$addmX4kT_L)t>7L{xwN(nR0BorpL@(S=TDr0F`;IYd=Q8*2H_-O36g zID>li8?_?-rJc8hDwmt;F}(!{35{rqJRxi`^93GFxD$&x^b_kGx+voyO>&_6+Q0+Y z8bJD+DC-b+4ooPfiPP#SXBxzsiiJ*{gnbhYu&wHr5o>fzk%I(^?T%9mK^A^KM17hm zv#3SxC19QydgF9O1bcKu&N*afVP9lH+T7c>6)Fbs<9+ zJJ4NcfDgwdDw+^>lVZL?OkFgi|NHmvXJ==$j7j^mWFdBbrvcm`hHOc!-Pg)OW@>x^ z7{4k-M+!wQ_A28n=CAUvo||P7s_0FO4ODxknyjMDdD|>ZQUk0o`PuSNU@cuvwH1R> zW|;i(;9!?pIp~47D`BzpuXK8o;j)fioo#rn6EJTsPd24MAw&dYkfse|(p1UnJMXR# z&{+qJfm=wWl8U37NkHHutN;j5>Rik3Y0QWjkXCVcA%hHA!h-7VYKRExhBw#DZ&%I* zjp*+O0nIwqy>jIW72u-}iIsl+`Xi_K)m?(}Cx)#w{RDYxvWLP1kfpLSU+OA2+pVW> z3kmE*iI6kzZC-<%x1TXq7Xn#jj%5?#s}mcoLa&VzVV`QfHp*sv_gDI$XZzFT=yMlm zTz758C)${pqFJYnkH!fF>bex-NXVR{LuaehTJ3~VtF!g)ry;Do4h%TR-n?ZJI;H?j;pvc zgA~IKC!g$xV!SB}JW|BAx|6mvrq1X8aPac}5~k%Hf5_hbz&T3owa-!``bH%4#bD#};lAJ= z4Srny2G_v6Ualr@Nt@nSJ0+4Xb-?Q@$jc>bK}42d`<7O}LzYL&cu2~%^&+V8;X+ld{r>sT08Cg zY4j@o%yvt3bjCnjTtK|Pgt|K~#Kje|0*`lYujkF8cREa~);}h_!~XDVC#6h4Ja)S5 zxu(Ola!GM6Ak@DHb~5nO*49c*4{T2wzv02OkAOx1+iB@9m2)aOF;snY|->bkIzQOY#!ye{)3y7%D*5@g*HCNmPA@;tJ>-4d07&^{9`^G@kK&G9Z#-MZ` zU}OSW?qw98hnX4_r4qa$``#1Y-e&YnkQYq#HHgx4t}WZxJIoe6;_XYHDh20vm5 zhj7Gfo%NOkw946}yR4f?mu4pAH=UmpNqW(F+f8}Q<~;qa#z@g3UipTG3ClzS{f^U% z$Q$u43cuu9#f$d?1Gxf+cno7S`GM#t-`8iv9GXTtKBXZWRcJSq18P_x4mbQ9|ED|tFQkjLiRELIJWBBu$N7L z{wu_{`lEl1U!yH%X4MmYJk-Uh;xw@8OW6l6a_Z#Hl0NBegVuTUq(sx&-AwHd;6vDx z*GM$S>EGzEvkw>JxN(Q0VM0OigBe(bmw|Nbemp8vV`e&Ssail69uX_J20|8ED-%}l zwwy}q_nYI09x`y&x^*pEsCSrC#lXhWP+vX429qyz zZ{vbu^d^+4^k5WFE2i<^7zT%!mHbJeMKhdzF!=e2DIMGOHL!pp*r|BT%uGk;xO_Bl z>}95^M|lq0sck>BZgrx6!r!r(btvFKR(pWy#&B*H3wX^rUbkJMR0PRwam5A2ZNlT& z5j24-VYSgijjB1qjl{c!>?TRMt+jqov%te@y)}K9NwiiTQ)qoh@aQ)GVSN&@ zQbq!I4&V&|6^WWQh$xygm`Sc%zuT@vi@TkPScyCjZkAGJRMpD64H8;fA2zGC8pu%f%0HaEgY`~aEMpcu{Cy3j!Iopgc(tg(3 zFFc9WsClKte7)_C_<^1#Rz9&DC_6L=_6NmpGNX8(YgbDQ8mDc}1}}SC@8Cwutg|D# zFs7O^4Nj2U+WqAI7L{P4Fq@3e##Dj&?F-6R%RiOLdTBR?ESKjytuWi{61L{jg{kR~ zqg|t^i7Alh31GQsD_Dhr`K`|ll?bXQ*D zDanrzx|80+RE2o1M5+2$K|EeB{SI&)rk%fQgWs6`n^e=_{tG&hKakcSRrqWLZlZ0O zlW$iR?6=+1i>nWgQq!{>QLc%>o5L;F(5GN5ffCFnh{p~+Q4pz_ZL?Arm_C`xGZis^ zNzKnc>;S0fo>~0kJgV937_>)lzB&O9h;X?j1>{F(L~H#i^C_evYv+6Q9f&NDdqo0C zv$wF6bEG8vqh;4KH7%k&`IXr23g&fvQ8Y zu`JsNa*`Qh1baXpQ{&}uoe3AoKzJVOtSQ!g_`~%*BS)A`F#}t^Ok$=T9nPqWw^A42 zG25*TWWfMQ7xA)x14#T|moJY{0hd0%R-k6Bvw+14)l0Oy>O&Ju1aAS{StBEVSah#k z>1E;!5Qk7qjtYXA%u&|N9(TAWV5DctjK8(O02;Wr29sz-kh$XVpJ$~q5@tu(V#avB z!vzE6%A7Le`6sfB`?14HqNLYMZ4|B7#Ejbhz|_4!qT9BH6Hq;pLQO?gh-T#cosW}$ zy4Pa8o0S0qX$`aJAG#d=h}Mkk162jg#D^(FdmI*CM@tvKVu;xPrMvGi-mr2CVR-=) zu5@GME3_$=Uq6S^8C5ty+V(*c$<*&NKn|#{kJPCy5=3VfI*w^8JG0Py$BB#!pY0z% zZRnzU`UgixSF}D%Sfk8{V-e6JnnedC^qhTDQBm>q^jyIJ5}G5A0paYc)YN?E6+uMY zuU{|oh8-T<_L0uwByY&C|KC*nd=?Q;e(|}9UUMoAecp33sdmQijbjQu+wQ<+E;ic# zLJ9w!qnaObYfpF&I8L3XnBUCbAD%lzeq~AT%#5c%04pT7X(OOo`gdLY{E*)pz@L(m zN)xc)TG-xxRSo>(hSac&gp8Ebf8i(_0UYHjK}2KY>e^aL=mrj_kpgrAr;3-KAF!rT zcJ|KZCV%gv-$}HuchF}^k>8?n>l+$YmX{Ml-5=aOC6i;7?mO~P{IJx#nD#Ij%4xyl+SK#E~ z0pw6_$9DE))-;YM{>_`AU^NWTC`N4rLm)!8)V#bNx&BG<^!xI%4WLA_wFN_M^mvar znHm3PD|=|I$XeZeF)XHzssVIB01CQdB<(SvB;oIWJ60*33gEE!*?EVAglK4dijG!$ z{ram#xyYajjCl0z_lwJFxROU)$tIJ@{dV2}jT{h1Gd5<%UZU`L1(43m%E|!x`GY&L z(phCyqK<^SO&S02<`X!Js@}g^@%WBJB2E6fxR6#shuYxBq+-ZR{@>yEucvMm`%o?Y zv7WFCs{6dd+H>tM$vuq4#HGWee>QNo^gb|x?l%gY#tuJ+X5W^KPQU+zR1O|{-4p$;VE|2GmhcK zb^6Ahl`rx*+*E6mZzUNy{#Ipp)AH#PcfD)PyPvMq1+Rv)GDho*jcXT2Au{oT9O@Y_ z0?sn3H>zSTA!wIL5^ly5pz{eg6`?1!kf5p@i;P?1S;ff2QeN`WFpBxu4wnBoAY3w< z#y9Egh?azzPr=r}!(3{X7<#{#De4>c89S`=>eZSAyBB8*8?TaIF<0j9TSnOIM=w!IFBMJc1`eQLZmIuh2T>FPsUDbyCjqu$ zayFd&B`Z~Xb&>tfBIKb2LZ$p?sN>O*yq<<>+8FmW?z}~+q`w7+D1Kl*W*$hO_7g%e z!A-wh7^L$GLSN1`ipv7-i=|Yx-`tmx-DIJydNqZ~Xi<&3vT0vTUGy2>S4W5XxMaQQ z?YuO0MTd@EO@LQeNT7N;eA{M!KdzAB`mzc0_37xrER82wU(9WWZ`#wHr)z%^Z@$JP zD(~&owy(z#_Br#N>Fjl^5Oq%aVAdRSnA`ecjAgP)mY!`)pZY@;R{DVxN87UCnL*e!f~GY`Ratd$Y#j+54uU z@=Ipr=4PptO;-*}-kXB$10}IT59UX-^iW|2URI=OKqs0bhNF^gGeAXQ^2z`=_jkKY zkxHs&nBh~S=l<%%eOHH$jgMVA-dCggZ6(wt>LVnaEGD+B1kCGn-7HLkI+4D;Ve|^W z{s2ZANkE}`xqyp1OUe)Jl0<_;if@fl6FlPqXDZ%r|7hc&>7IAmm@wY}Qd{WGUnYaI zud|&xiYSRtAcb1(RMon^U@E}RKf1(yE{hG|qtvjwc{5E-2&_JyF$;O;G)7DGP6h^x zz>_`FxNbxsHX|aLjsj-OK3TDo>Ao~T16gsdi&1l1{DMmRi)-}}%$)af0s_>(TS%{2 z&~q6DjLhtp(h0g+c7~a=WT)|B-QKmfAxaa+@$R2L0Vwkb0aRfekGoAj7~-V%e9zs8 zy9bP0U^}Pq8ktdl7n6OCPd8^Uj=$BW!V-pE@TADqG~kE zZRe1?1~T5YZ=Eo6!5Stv7k%`je3#-ASM2TAde5$ZvACRLa-~{lz@qw@4IRk6Jm`zhF6yfM-TW&(rCJWr|@;1j+OPNrwHJnZL!>|v2s=4`NHQ(LC5iKE5l z#zA|K_*xx-?0&>PYLOWv3?IGED>!P-W31-&XoP{d3N2@vsL9{6Q$$(h*@`(^8mtCH z`@O_INXRvgu0o+caV_8cS>rUn#tHP?clOEV^UTfbtU43s?n1v_s!XStj(*i|r?6KM z(2%NMvnf)3=^v9)U6FjVXkbPAn2@pF7!6GD!=~;Z_m&(l#0n$lw@>4k-U8D?!tA9y zCxB@#H#CQoT`&tYFpd>Kue4&vi1+$K2ER)uhn~9@|ju&!qrZC{uXCQz^MeQ>K#|{=L&1$6C&N8uE?y3dz)CP<}6MeozEbM1-xa?-!2ub$@V?C z)=uK$eWxNeHTSc`kLcc_Z|dtl#w{5aJGFfB^k4t_OAq>I+Y{2+eATnO4-IlUW7(R= z?{!e9F7F5s(z}rq-FyF-zD+B%Y!4%OSI|8hF+wcaQR;ZRSF{AEM@zY?E9%|OY&%|O9lXW#y+-J=KPivU7 zH=4c1pk+gH!6tEv>)UQFbtARIUq(C^dPbEZ5N+tr@r7QD5PD|z$(1?9_U}U*0Or-u z!@=wp$<58W19y|0YlGMJ=C=IC{Wh^F$YmPk_@cvPkTeGeO7c;vvn#OZB*5WZJv_AF zr8!^%VI)jv;`|m}&ew^QNwq$1FacZ-y&;)Pr zz1gU&gg~9kO|5%q7P`A^`zh!Z>7)7y8S+A9@{T|4({l}3FZ|-&x09MDwBJ!_^wL<+ zi?4-2o108&GJeTxW##=VFzYpYJ8u4l2|+z+1_c<3n#1Q#tij6_Z=MNZ!(ZWA?qUk- zf!(d1)x$AM?F}vGuDInmU7#p2%nP7abr@(!lJ z?Wd2)AZlx|$Yx9Ab>3pAK@q>P@_hWgTV7;!RPnUDOJ|2d4@xqxLlNh64mZ zZ=WA4$X!7Y>~1RL4aek_B}{ZG9|y}_p^R0!nY;Ki$+@Vbp<`tWB(lPP31})0$!pkA zca$)q80-$9DvDrU=?xjiNc!BA(K238R{j3U(RXqeaTPb)OlY^U*YHtvHpyqvDrkaRX)E&Lqz!7g|%> z=;Iu+aUyRLdbZ`V^D&Xo_$3pfpinF=jfR9;5{6MZZF!~OiJz~JuNn3zPq|1i(r)rd za&{7wL^T+_r+CATN`*i-U$jX33PS!y6adyMg48mymk_{ovc`;%k2YKqs5RebUD z$4@+s7IBKgDY1$l6zU#H1o`@$yv73y?X_XIBKK-xO#21u+Ws3HwnRY7n1Vv?8VN4< zmDluc(@|tvj!#ff(D}BC$Qa777hGpBLH8-n(Y3Txo@V9Dgd=dARuR9x{*oh;p(2almmf->D*ah);bv?=+@zXxnYb9CjOc*X|jo4}s; zH?Q>JIycz>2OX zEM#aC>0$YGx3l2WmASd83h>Tq@a;`uo7|)M`JY(*s~uLOQzi}MuMcFW@r&r8qmAb5 z^QpUKNsq8(WjcepCFlmeeTrV;B{4SU7P~O!Y~cj8V8X#R9i%y3&Su;cL@Z#BiwKM^ zT1|-Tg^|*xxAlbBfU`Bs?@S-9EyiI+yOgqJcS3yS0min1JAatJ{l>US0b(Rc&36;w z9JiJ8z+@ndm$bAbY)FkdFg~{*5`rbKqcWpGuBrv!!I?G zfFG$LpxTzcN8V4U1)%&lm~KL|0315nsibNT{p2$k4g=jj8o@z`uC5>Mzi(&&OB>=c?$G6EWgO$j%8tXwB8j+hQ%`Bb^}D=vp))%RKRO2G zqi`i9gBUZnNImY9Uc*3va6KxH=N)@#8`um1;3;q`1?`KJJ*}H%tixf<%evCapp|iD zeHyIpyRRmM$s1mqha=NHZSCO9p4I3TbSt`T<367cQwB?KRCl2u1;w8`D$k$k!RgYq z%k*T3d)pmLwIBBHXNN%fL}ty+8)~Ojm>Mbb$VK zvgo6EQ{h*WSCob4%S;hpTTw!pf&u5nWP^sS7oz>M*Mb%{7ZT2c4~a)cRF>AH?&FWw zqZ4mqvyOL=#;}mH2{p1~^@f}6{_Bn<6m3Y!~ S0r$U8QhA~AyiDQsyZ;LhkpT|? diff --git a/sale_quotation/tests/__init__.py b/sale_quotation/tests/__init__.py index 626580ed4e..43ae815229 100644 --- a/sale_quotation/tests/__init__.py +++ b/sale_quotation/tests/__init__.py @@ -1 +1 @@ -from . import test_product +from . import test_quotation diff --git a/sale_quotation/tests/test_product.py b/sale_quotation/tests/test_product.py deleted file mode 100644 index ac5067b976..0000000000 --- a/sale_quotation/tests/test_product.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright 2021 Camptocamp (https://www.camptocamp.com). -# @author Iván Todorovich -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). - -from odoo.tests.common import TransactionCase - - -class TestProduct(TransactionCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) - cls.attribute = cls.env.ref("product.product_attribute_2") - cls.value_1 = cls.env.ref("product.product_attribute_value_3") - cls.value_2 = cls.env.ref("product.product_attribute_value_4") - cls.product_template = cls.env["product.template"].create( - { - "name": "Test Product", - } - ) - - def test_simple_product(self): - # Back and forth flow, setting and reading on template and single variant - self.assertEqual(self.product_template.shop_only_quotation, "never") - self.product_template.shop_only_quotation = "all_variant" - self.assertTrue(self.product_template.product_variant_ids.shop_only_quotation) - self.product_template.product_variant_ids.shop_only_quotation = False - self.assertEqual(self.product_template.shop_only_quotation, "never") - - def test_multi_product(self): - # Configure product with multiple variants - self.product_template.attribute_line_ids = [ - ( - 0, - 0, - { - "attribute_id": self.attribute.id, - "value_ids": [(6, 0, [self.value_1.id, self.value_2.id])], - }, - ) - ] - self.assertEqual(len(self.product_template.product_variant_ids), 2) - template = self.product_template - product_1 = self.product_template.product_variant_ids[0] - product_2 = self.product_template.product_variant_ids[1] - # Set on the template should set all variants - template.shop_only_quotation = "all_variant" - self.assertTrue(product_1.shop_only_quotation) - self.assertTrue(product_2.shop_only_quotation) - # Set a variant to false should set the template to false - # It's only true if it's true for all variants - product_1.shop_only_quotation = False - self.assertEqual(template.shop_only_quotation, "manually_on_variant") - self.assertTrue(product_2.shop_only_quotation, "Other variant shouldn't change") - # Set false on template should set all variants to false - template.shop_only_quotation = "never" - self.assertFalse(product_1.shop_only_quotation) - self.assertFalse(product_2.shop_only_quotation) diff --git a/sale_quotation/views/product_view.xml b/sale_quotation/views/product_view.xml deleted file mode 100644 index 08ce8aaa26..0000000000 --- a/sale_quotation/views/product_view.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - product.template - - - - - - - - diff --git a/setup/shopinvader_product_order_mode/odoo/addons/shopinvader_product_order_mode b/setup/shopinvader_product_order_mode/odoo/addons/shopinvader_product_order_mode new file mode 120000 index 0000000000..304624b9e4 --- /dev/null +++ b/setup/shopinvader_product_order_mode/odoo/addons/shopinvader_product_order_mode @@ -0,0 +1 @@ +../../../../shopinvader_product_order_mode \ No newline at end of file diff --git a/setup/shopinvader_product_order_mode/setup.py b/setup/shopinvader_product_order_mode/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopinvader_product_order_mode/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/shopinvader_product_order_mode_on_request/odoo/addons/shopinvader_product_order_mode_on_request b/setup/shopinvader_product_order_mode_on_request/odoo/addons/shopinvader_product_order_mode_on_request new file mode 120000 index 0000000000..4b8a7d922b --- /dev/null +++ b/setup/shopinvader_product_order_mode_on_request/odoo/addons/shopinvader_product_order_mode_on_request @@ -0,0 +1 @@ +../../../../shopinvader_product_order_mode_on_request \ No newline at end of file diff --git a/setup/shopinvader_product_order_mode_on_request/setup.py b/setup/shopinvader_product_order_mode_on_request/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopinvader_product_order_mode_on_request/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shopinvader_product_order_mode/README.rst b/shopinvader_product_order_mode/README.rst new file mode 100644 index 0000000000..2a68385836 --- /dev/null +++ b/shopinvader_product_order_mode/README.rst @@ -0,0 +1,60 @@ +============================== +Shopinvader Product Order Mode +============================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:276e322df2fe0bcec29a719db9b5ed5ce83469bd68da22df82a44616e3075471 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-shopinvader%2Fodoo--shopinvader-lightgray.png?logo=github + :target: https://github.com/shopinvader/odoo-shopinvader/tree/16.0/shopinvader_product_order_mode + :alt: shopinvader/odoo-shopinvader + +|badge1| |badge2| |badge3| + +This module is a base for generating generic shop order modes on products + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* Nicolas DELBOVIER + +Maintainers +~~~~~~~~~~~ + +This module is part of the `shopinvader/odoo-shopinvader `_ project on GitHub. + +You are welcome to contribute. diff --git a/shopinvader_product_order_mode/__init__.py b/shopinvader_product_order_mode/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/shopinvader_product_order_mode/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/shopinvader_product_order_mode/__manifest__.py b/shopinvader_product_order_mode/__manifest__.py new file mode 100644 index 0000000000..d7280981ad --- /dev/null +++ b/shopinvader_product_order_mode/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Shopinvader Product Order Mode", + "summary": """Add a field for specifying the order mode of a product in a generic way.""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV", + "website": "https://github.com/shopinvader/odoo-shopinvader", + "depends": [ + "product", + ], + "data": [ + "views/product_product.xml", + "views/product_template.xml", + ], + "demo": [], +} diff --git a/shopinvader_product_order_mode/i18n/shopinvader_product_order_mode.pot b/shopinvader_product_order_mode/i18n/shopinvader_product_order_mode.pot new file mode 100644 index 0000000000..451fe7dd8e --- /dev/null +++ b/shopinvader_product_order_mode/i18n/shopinvader_product_order_mode.pot @@ -0,0 +1,81 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopinvader_product_order_mode +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-16 13:41+0000\n" +"PO-Revision-Date: 2025-05-16 13:41+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: shopinvader_product_order_mode +#: model:ir.model.fields,help:shopinvader_product_order_mode.field_product_template__shop_order_mode +msgid "Deault order mode for this template's products in Shopinvader." +msgstr "" + +#. module: shopinvader_product_order_mode +#. odoo-python +#: code:addons/shopinvader_product_order_mode/models/product_product.py:0 +#: code:addons/shopinvader_product_order_mode/models/product_template.py:0 +#: model:ir.model.fields.selection,name:shopinvader_product_order_mode.selection__product_product__shop_order_mode__direct_sale_only +#: model:ir.model.fields.selection,name:shopinvader_product_order_mode.selection__product_template__shop_order_mode__direct_sale_only +#, python-format +msgid "Direct Sale Only" +msgstr "" + +#. module: shopinvader_product_order_mode +#: model:ir.model.fields,help:shopinvader_product_order_mode.field_product_product__is_shop_order_mode_unabled +#: model:ir.model.fields,help:shopinvader_product_order_mode.field_product_product__is_shop_order_mode_unabled_on_variant +#: model:ir.model.fields,help:shopinvader_product_order_mode.field_product_template__is_shop_order_mode_unabled_on_variant +msgid "" +"If True, unables product variants to have a different value for " +"'shop_order_mode' than this product template." +msgstr "" + +#. module: shopinvader_product_order_mode +#: model:ir.model.fields,help:shopinvader_product_order_mode.field_product_product__shop_order_mode +msgid "Order mode for the product in Shopinvader." +msgstr "" + +#. module: shopinvader_product_order_mode +#: model:ir.model,name:shopinvader_product_order_mode.model_product_template +msgid "Product" +msgstr "" + +#. module: shopinvader_product_order_mode +#: model:ir.model,name:shopinvader_product_order_mode.model_product_product +msgid "Product Variant" +msgstr "" + +#. module: shopinvader_product_order_mode +#: model:ir.model.fields,field_description:shopinvader_product_order_mode.field_product_product__shop_order_mode +#: model:ir.model.fields,field_description:shopinvader_product_order_mode.field_product_template__shop_order_mode +msgid "Shopinvader Order Mode" +msgstr "" + +#. module: shopinvader_product_order_mode +#: model:ir.model.fields,field_description:shopinvader_product_order_mode.field_product_product__is_shop_order_mode_unabled_on_variant +#: model:ir.model.fields,field_description:shopinvader_product_order_mode.field_product_template__is_shop_order_mode_unabled_on_variant +msgid "Shopinvader Order Mode Unabled on Variant" +msgstr "" + +#. module: shopinvader_product_order_mode +#: model:ir.model.fields,field_description:shopinvader_product_order_mode.field_product_product__is_shop_order_mode_unabled +msgid "Template Shop Order Mode Disabled" +msgstr "" + +#. module: shopinvader_product_order_mode +#. odoo-python +#: code:addons/shopinvader_product_order_mode/models/product_product.py:0 +#, python-format +msgid "" +"Unable to modify 'shop_order_mode' on the product '%(product_name)s' as it " +"is blocked by the template '%(template_name)s'." +msgstr "" diff --git a/shopinvader_product_order_mode/models/__init__.py b/shopinvader_product_order_mode/models/__init__.py new file mode 100644 index 0000000000..049669dd0f --- /dev/null +++ b/shopinvader_product_order_mode/models/__init__.py @@ -0,0 +1,2 @@ +from . import product_template +from . import product_product diff --git a/shopinvader_product_order_mode/models/product_product.py b/shopinvader_product_order_mode/models/product_product.py new file mode 100644 index 0000000000..6a8d0eb16e --- /dev/null +++ b/shopinvader_product_order_mode/models/product_product.py @@ -0,0 +1,50 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class ProductProduct(models.Model): + _inherit = "product.product" + + shop_order_mode = fields.Selection( + selection=[("direct_sale_only", _("Direct Sale Only"))], + string="Shopinvader Order Mode", + store=True, + readonly=False, + compute="_compute_shop_order_mode", + inverse="_inverse_shop_order_mode", + help="Order mode for the product in Shopinvader.", + ) + is_shop_order_mode_unabled = fields.Boolean( + string="Template Shop Order Mode Disabled", + related="product_tmpl_id.is_shop_order_mode_unabled_on_variant", + store=True, + readonly=True, + ) + + @api.depends("product_tmpl_id.shop_order_mode") + def _compute_shop_order_mode(self): + for product in self: + if product.product_tmpl_id: + product.shop_order_mode = product.product_tmpl_id.shop_order_mode + else: + product.shop_order_mode = False + + def _inverse_shop_order_mode(self): + for product in self: + if not product.is_shop_order_mode_unabled and not self.env.context.get( + "skip_shop_order_mode_validation" + ): + raise ValidationError( + _( + "Unable to modify 'shop_order_mode' on the product " + "'%(product_name)s' as it is blocked by the template " + "'%(template_name)s'." + ) + % { + "product_name": product.name, + "template_name": product.product_tmpl_id.name, + } + ) diff --git a/shopinvader_product_order_mode/models/product_template.py b/shopinvader_product_order_mode/models/product_template.py new file mode 100644 index 0000000000..7e145ceb9b --- /dev/null +++ b/shopinvader_product_order_mode/models/product_template.py @@ -0,0 +1,39 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + shop_order_mode = fields.Selection( + selection=[("direct_sale_only", _("Direct Sale Only"))], + string="Shopinvader Order Mode", + default="direct_sale_only", + help="Deault order mode for this template's products in Shopinvader.", + ) + + is_shop_order_mode_unabled_on_variant = fields.Boolean( + string="Shopinvader Order Mode Unabled on Variant", + help="If True, unables product variants to have a different value for" + " 'shop_order_mode' than this product template.", + default=False, + ) + + # if product order mode goes from unabled to disabled on variants, put back all variants + # to the value of the template + @api.model + def write(self, vals): + res = super().write(vals) + if ( + "is_shop_order_mode_unabled_on_variant" in vals + and not vals["is_shop_order_mode_unabled_on_variant"] + ): + for template in self: + template.with_context( + skip_shop_order_mode_validation=True + ).product_variant_ids.write( + {"shop_order_mode": template.shop_order_mode} + ) + return res diff --git a/shopinvader_product_order_mode/readme/CONTRIBUTORS.rst b/shopinvader_product_order_mode/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..5341bc4906 --- /dev/null +++ b/shopinvader_product_order_mode/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Nicolas DELBOVIER \ No newline at end of file diff --git a/shopinvader_product_order_mode/readme/DESCRIPTION.rst b/shopinvader_product_order_mode/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..21de8412d5 --- /dev/null +++ b/shopinvader_product_order_mode/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +This module is a base for generating generic shop order modes on products \ No newline at end of file diff --git a/shopinvader_product_order_mode/static/description/index.html b/shopinvader_product_order_mode/static/description/index.html new file mode 100644 index 0000000000..49aaeb58e0 --- /dev/null +++ b/shopinvader_product_order_mode/static/description/index.html @@ -0,0 +1,416 @@ + + + + + +Shopinvader Product Order Mode + + + +

    +

    Shopinvader Product Order Mode

    + + +

    Beta License: AGPL-3 shopinvader/odoo-shopinvader

    +

    This module is a base for generating generic shop order modes on products

    +

    Table of contents

    + +
    +

    Bug Tracker

    +

    Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

    +

    Do not contact contributors directly about support or help with technical issues.

    +
    +
    +

    Credits

    +
    +

    Authors

    +
      +
    • ACSONE SA/NV
    • +
    +
    +
    +

    Contributors

    + +
    +
    +

    Maintainers

    +

    This module is part of the shopinvader/odoo-shopinvader project on GitHub.

    +

    You are welcome to contribute.

    +
    +
    +
    + + diff --git a/shopinvader_product_order_mode/tests/__init__.py b/shopinvader_product_order_mode/tests/__init__.py new file mode 100644 index 0000000000..0ce6d0db23 --- /dev/null +++ b/shopinvader_product_order_mode/tests/__init__.py @@ -0,0 +1 @@ +from . import test_shop_order_mode diff --git a/shopinvader_product_order_mode/tests/test_shop_order_mode.py b/shopinvader_product_order_mode/tests/test_shop_order_mode.py new file mode 100644 index 0000000000..f00b8934dc --- /dev/null +++ b/shopinvader_product_order_mode/tests/test_shop_order_mode.py @@ -0,0 +1,77 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo import Command +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestShopOrderMode(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Define different attributes to allow to have mulitple products for the same template + cls.test_attribute = cls.env["product.attribute"].create( + {"name": "Test attribute"} + ) + cls.test_attribute_1 = cls.env["product.attribute.value"].create( + {"name": "Test attribute value 1", "attribute_id": cls.test_attribute.id} + ) + cls.test_attribute_2 = cls.env["product.attribute.value"].create( + {"name": "Test attribute value 2", "attribute_id": cls.test_attribute.id} + ) + + cls.template = cls.env["product.template"].create( + { + "name": "Test Product Template", + "attribute_line_ids": [ + Command.create( + { + "attribute_id": cls.test_attribute.id, + "value_ids": [ + Command.link(cls.test_attribute_1.id), + Command.link(cls.test_attribute_2.id), + ], + } + ) + ], + "shop_order_mode": "direct_sale_only", + "is_shop_order_mode_unabled_on_variant": False, + } + ) + + cls.product = cls.template.product_variant_ids[0] + cls.product_2 = cls.template.product_variant_ids[1] + + def test_default_order_mode_on_products(self): + """ + Test that products order mode is initiated to the order mode of the template. + """ + self.assertEqual(self.product.shop_order_mode, self.template.shop_order_mode) + + def test_product_shop_order_mode_constraint(self): + """ + Test that the shop_order_mode field on product.product is not editable + when is_shop_order_mode_unabled_on_variant is False on product.template. + """ + + with self.assertRaises(ValidationError): + self.product.shop_order_mode = None + + self.template.is_shop_order_mode_unabled_on_variant = True + + self.product.shop_order_mode = None + self.assertNotEqual(self.product.shop_order_mode, "direct_sale_only") + self.assertEqual(self.template.shop_order_mode, "direct_sale_only") + + def test_shop_order_mode_reset(self): + """ + Test that the `shop_order_mode` field on product.product is reset to the + value of the product.template when `is_shop_order_mode_unabled_on_variant` is True. + """ + self.template.is_shop_order_mode_unabled_on_variant = True + self.product.shop_order_mode = None + self.template.is_shop_order_mode_unabled_on_variant = False + self.assertEqual(self.product.shop_order_mode, self.template.shop_order_mode) diff --git a/shopinvader_product_order_mode/views/product_product.xml b/shopinvader_product_order_mode/views/product_product.xml new file mode 100644 index 0000000000..a77c331dd5 --- /dev/null +++ b/shopinvader_product_order_mode/views/product_product.xml @@ -0,0 +1,25 @@ + + + + + + product.product + + + + + + + + + + + + diff --git a/shopinvader_product_order_mode/views/product_template.xml b/shopinvader_product_order_mode/views/product_template.xml new file mode 100644 index 0000000000..e64acf2820 --- /dev/null +++ b/shopinvader_product_order_mode/views/product_template.xml @@ -0,0 +1,21 @@ + + + + + + product.template + + + + + + + + + + + diff --git a/shopinvader_product_order_mode_on_request/README.rst b/shopinvader_product_order_mode_on_request/README.rst new file mode 100644 index 0000000000..88abdc3ec6 --- /dev/null +++ b/shopinvader_product_order_mode_on_request/README.rst @@ -0,0 +1,60 @@ +========================================= +Shopinvader Product Order Mode On Request +========================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:98b6bb5bcac769474c7ce34ae341ba3ede691e6cdeca6896af9e0b058cb95d85 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-shopinvader%2Fodoo--shopinvader-lightgray.png?logo=github + :target: https://github.com/shopinvader/odoo-shopinvader/tree/16.0/shopinvader_product_order_mode_on_request + :alt: shopinvader/odoo-shopinvader + +|badge1| |badge2| |badge3| + +This module adds 2 shop order modes for products: "Quotation Only" and "Direct Sale Or Quotation" + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* Nicolas DELBOVIER + +Maintainers +~~~~~~~~~~~ + +This module is part of the `shopinvader/odoo-shopinvader `_ project on GitHub. + +You are welcome to contribute. diff --git a/shopinvader_product_order_mode_on_request/__init__.py b/shopinvader_product_order_mode_on_request/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/shopinvader_product_order_mode_on_request/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/shopinvader_product_order_mode_on_request/__manifest__.py b/shopinvader_product_order_mode_on_request/__manifest__.py new file mode 100644 index 0000000000..53c452551e --- /dev/null +++ b/shopinvader_product_order_mode_on_request/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Shopinvader Product Order Mode On Request", + "summary": """ Add new order modes on products.""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV", + "website": "https://github.com/shopinvader/odoo-shopinvader", + "depends": [ + "shopinvader_product_order_mode", + "sale_cart", + ], + "data": [], + "demo": [], +} diff --git a/shopinvader_product_order_mode_on_request/i18n/shopinvader_product_order_mode_on_request.pot b/shopinvader_product_order_mode_on_request/i18n/shopinvader_product_order_mode_on_request.pot new file mode 100644 index 0000000000..88ca915f76 --- /dev/null +++ b/shopinvader_product_order_mode_on_request/i18n/shopinvader_product_order_mode_on_request.pot @@ -0,0 +1,72 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopinvader_product_order_mode_on_request +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-16 13:41+0000\n" +"PO-Revision-Date: 2025-05-16 13:41+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: shopinvader_product_order_mode_on_request +#: model:ir.model.fields,help:shopinvader_product_order_mode_on_request.field_product_template__shop_order_mode +msgid "Deault order mode for this template's products in Shopinvader." +msgstr "" + +#. module: shopinvader_product_order_mode_on_request +#. odoo-python +#: code:addons/shopinvader_product_order_mode_on_request/models/product_product.py:0 +#: code:addons/shopinvader_product_order_mode_on_request/models/product_template.py:0 +#: model:ir.model.fields.selection,name:shopinvader_product_order_mode_on_request.selection__product_product__shop_order_mode__direct_sale_or_quotation +#: model:ir.model.fields.selection,name:shopinvader_product_order_mode_on_request.selection__product_template__shop_order_mode__direct_sale_or_quotation +#, python-format +msgid "Direct Sale or Quotation" +msgstr "" + +#. module: shopinvader_product_order_mode_on_request +#: model:ir.model.fields,help:shopinvader_product_order_mode_on_request.field_product_product__shop_order_mode +msgid "Order mode for the product in Shopinvader." +msgstr "" + +#. module: shopinvader_product_order_mode_on_request +#: model:ir.model,name:shopinvader_product_order_mode_on_request.model_product_template +msgid "Product" +msgstr "" + +#. module: shopinvader_product_order_mode_on_request +#: model:ir.model,name:shopinvader_product_order_mode_on_request.model_product_product +msgid "Product Variant" +msgstr "" + +#. module: shopinvader_product_order_mode_on_request +#. odoo-python +#: code:addons/shopinvader_product_order_mode_on_request/models/product_product.py:0 +#: code:addons/shopinvader_product_order_mode_on_request/models/product_template.py:0 +#: model:ir.model.fields.selection,name:shopinvader_product_order_mode_on_request.selection__product_product__shop_order_mode__quotation_only +#: model:ir.model.fields.selection,name:shopinvader_product_order_mode_on_request.selection__product_template__shop_order_mode__quotation_only +#, python-format +msgid "Quotation Only" +msgstr "" + +#. module: shopinvader_product_order_mode_on_request +#: model:ir.model,name:shopinvader_product_order_mode_on_request.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: shopinvader_product_order_mode_on_request +#: model:ir.model.fields,field_description:shopinvader_product_order_mode_on_request.field_sale_order__shop_only_quotation +msgid "Shop Only Quotation" +msgstr "" + +#. module: shopinvader_product_order_mode_on_request +#: model:ir.model.fields,field_description:shopinvader_product_order_mode_on_request.field_product_product__shop_order_mode +#: model:ir.model.fields,field_description:shopinvader_product_order_mode_on_request.field_product_template__shop_order_mode +msgid "Shopinvader Order Mode" +msgstr "" diff --git a/shopinvader_product_order_mode_on_request/models/__init__.py b/shopinvader_product_order_mode_on_request/models/__init__.py new file mode 100644 index 0000000000..de7a683548 --- /dev/null +++ b/shopinvader_product_order_mode_on_request/models/__init__.py @@ -0,0 +1,3 @@ +from . import product_product +from . import product_template +from . import sale_order diff --git a/shopinvader_product_order_mode_on_request/models/product_product.py b/shopinvader_product_order_mode_on_request/models/product_product.py new file mode 100644 index 0000000000..cc890a94ca --- /dev/null +++ b/shopinvader_product_order_mode_on_request/models/product_product.py @@ -0,0 +1,19 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, fields, models + + +class ProductProduct(models.Model): + _inherit = "product.product" + + shop_order_mode = fields.Selection( + selection_add=[ + ("quotation_only", _("Quotation Only")), + ("direct_sale_or_quotation", _("Direct Sale or Quotation")), + ], + ondelete={ + "quotation_only": "set null", + "direct_sale_or_quotation": "set null", + }, + ) diff --git a/shopinvader_product_order_mode_on_request/models/product_template.py b/shopinvader_product_order_mode_on_request/models/product_template.py new file mode 100644 index 0000000000..e96d8ab084 --- /dev/null +++ b/shopinvader_product_order_mode_on_request/models/product_template.py @@ -0,0 +1,19 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + shop_order_mode = fields.Selection( + selection_add=[ + ("quotation_only", _("Quotation Only")), + ("direct_sale_or_quotation", _("Direct Sale or Quotation")), + ], + ondelete={ + "quotation_only": "set null", + "direct_sale_or_quotation": "set null", + }, + ) diff --git a/shopinvader_product_order_mode_on_request/models/sale_order.py b/shopinvader_product_order_mode_on_request/models/sale_order.py new file mode 100644 index 0000000000..0ffc858c42 --- /dev/null +++ b/shopinvader_product_order_mode_on_request/models/sale_order.py @@ -0,0 +1,21 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + shop_only_quotation = fields.Boolean( + compute="_compute_shop_only_quotation", help="" + ) + + @api.depends("order_line.product_id.shop_order_mode") + def _compute_shop_only_quotation(self): + for record in self: + record.shop_only_quotation = any( + record.order_line.product_id.filtered( + lambda p: p.shop_order_mode == "quotation_only" + ) + ) diff --git a/shopinvader_product_order_mode_on_request/readme/CONTRIBUTORS.rst b/shopinvader_product_order_mode_on_request/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..5341bc4906 --- /dev/null +++ b/shopinvader_product_order_mode_on_request/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Nicolas DELBOVIER \ No newline at end of file diff --git a/shopinvader_product_order_mode_on_request/readme/DESCRIPTION.rst b/shopinvader_product_order_mode_on_request/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..0b4373ac01 --- /dev/null +++ b/shopinvader_product_order_mode_on_request/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +This module adds 2 shop order modes for products: "Quotation Only" and "Direct Sale Or Quotation" \ No newline at end of file diff --git a/shopinvader_product_order_mode_on_request/static/description/index.html b/shopinvader_product_order_mode_on_request/static/description/index.html new file mode 100644 index 0000000000..60fbaefd81 --- /dev/null +++ b/shopinvader_product_order_mode_on_request/static/description/index.html @@ -0,0 +1,416 @@ + + + + + +Shopinvader Product Order Mode On Request + + + +
    +

    Shopinvader Product Order Mode On Request

    + + +

    Beta License: AGPL-3 shopinvader/odoo-shopinvader

    +

    This module adds 2 shop order modes for products: “Quotation Only” and “Direct Sale Or Quotation”

    +

    Table of contents

    + +
    +

    Bug Tracker

    +

    Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

    +

    Do not contact contributors directly about support or help with technical issues.

    +
    +
    +

    Credits

    +
    +

    Authors

    +
      +
    • ACSONE SA/NV
    • +
    +
    +
    +

    Contributors

    + +
    +
    +

    Maintainers

    +

    This module is part of the shopinvader/odoo-shopinvader project on GitHub.

    +

    You are welcome to contribute.

    +
    +
    +
    + + diff --git a/shopinvader_product_order_mode_on_request/tests/__init__.py b/shopinvader_product_order_mode_on_request/tests/__init__.py new file mode 100644 index 0000000000..43ae815229 --- /dev/null +++ b/shopinvader_product_order_mode_on_request/tests/__init__.py @@ -0,0 +1 @@ +from . import test_quotation diff --git a/shopinvader_product_order_mode_on_request/tests/test_quotation.py b/shopinvader_product_order_mode_on_request/tests/test_quotation.py new file mode 100644 index 0000000000..a3747d66d9 --- /dev/null +++ b/shopinvader_product_order_mode_on_request/tests/test_quotation.py @@ -0,0 +1,102 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command +from odoo.tests.common import TransactionCase + + +class TestQuotation(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # as the shop_order_mode is readonly by default except if unabled in the template + # we need to create the product template first and then get the product variant + cls.product_quotation_only = ( + cls.env["product.template"] + .create( + {"name": "Quotation only Product", "shop_order_mode": "quotation_only"} + ) + .product_variant_id + ) + + cls.product_direct_sale_only = ( + cls.env["product.template"] + .create( + { + "name": "Direct sale only Product", + "shop_order_mode": "direct_sale_only", + } + ) + .product_variant_id + ) + + cls.product_direct_sale_or_quotation = ( + cls.env["product.template"] + .create( + { + "name": "Direct Sale Or Quotation Product", + "shop_order_mode": "direct_sale_or_quotation", + } + ) + .product_variant_id + ) + + cls.partner = cls.env["res.partner"].create( + { + "name": "Test Partner", + } + ) + + def test_sale_order_shop_only_quotation(self): + so = self.env["sale.order"].create( + { + "name": "Sale order that should be quotation only", + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product_quotation_only.id, + "product_uom_qty": 1, + } + ), + Command.create( + { + "product_id": self.product_direct_sale_only.id, + "product_uom_qty": 1, + } + ), + Command.create( + { + "product_id": self.product_direct_sale_or_quotation.id, + "product_uom_qty": 1, + } + ), + ], + } + ) + + self.assertTrue(so.shop_only_quotation) + + so_2 = self.env["sale.order"].create( + { + "name": "Sale order that should NOT be quotation only", + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product_direct_sale_only.id, + "product_uom_qty": 1, + } + ), + Command.create( + { + "product_id": self.product_direct_sale_or_quotation.id, + "product_uom_qty": 1, + } + ), + ], + } + ) + + self.assertFalse(so_2.shop_only_quotation) From 1a290735bd5772b7e6472dc0570292805d0fca45 Mon Sep 17 00:00:00 2001 From: Nicolas Delbovier Date: Tue, 3 Jun 2025 16:10:21 +0200 Subject: [PATCH 02/44] [IMP] shopinvader_product_order_mode: use field dependency instead of hacking the write. --- .../models/product_product.py | 11 +++++++---- .../models/product_template.py | 19 +------------------ 2 files changed, 8 insertions(+), 22 deletions(-) diff --git a/shopinvader_product_order_mode/models/product_product.py b/shopinvader_product_order_mode/models/product_product.py index 6a8d0eb16e..0fc7806ab6 100644 --- a/shopinvader_product_order_mode/models/product_product.py +++ b/shopinvader_product_order_mode/models/product_product.py @@ -24,13 +24,16 @@ class ProductProduct(models.Model): readonly=True, ) - @api.depends("product_tmpl_id.shop_order_mode") + @api.depends( + "product_tmpl_id.shop_order_mode", + "product_tmpl_id.is_shop_order_mode_unabled_on_variant", + ) def _compute_shop_order_mode(self): for product in self: - if product.product_tmpl_id: - product.shop_order_mode = product.product_tmpl_id.shop_order_mode - else: + if not product.product_tmpl_id: product.shop_order_mode = False + elif not product.product_tmpl_id.is_shop_order_mode_unabled_on_variant: + product.shop_order_mode = product.product_tmpl_id.shop_order_mode def _inverse_shop_order_mode(self): for product in self: diff --git a/shopinvader_product_order_mode/models/product_template.py b/shopinvader_product_order_mode/models/product_template.py index 7e145ceb9b..94853bd147 100644 --- a/shopinvader_product_order_mode/models/product_template.py +++ b/shopinvader_product_order_mode/models/product_template.py @@ -1,7 +1,7 @@ # Copyright 2025 ACSONE SA/NV # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import _, api, fields, models +from odoo import _, fields, models class ProductTemplate(models.Model): @@ -20,20 +20,3 @@ class ProductTemplate(models.Model): " 'shop_order_mode' than this product template.", default=False, ) - - # if product order mode goes from unabled to disabled on variants, put back all variants - # to the value of the template - @api.model - def write(self, vals): - res = super().write(vals) - if ( - "is_shop_order_mode_unabled_on_variant" in vals - and not vals["is_shop_order_mode_unabled_on_variant"] - ): - for template in self: - template.with_context( - skip_shop_order_mode_validation=True - ).product_variant_ids.write( - {"shop_order_mode": template.shop_order_mode} - ) - return res From 3fe5f1ef6b9fdaa38498f7c5b794463e31a68a0d Mon Sep 17 00:00:00 2001 From: Nicolas Delbovier Date: Tue, 10 Jun 2025 22:44:54 +0200 Subject: [PATCH 03/44] [FIX] fix typo 'unabled' -> 'enabled' --- sale_quotation/migrations/16.0.0.3.0/pre-migrate.py | 4 ++-- .../i18n/shopinvader_product_order_mode.pot | 12 ++++++------ .../models/product_product.py | 10 +++++----- .../models/product_template.py | 2 +- .../tests/test_shop_order_mode.py | 12 ++++++------ .../views/product_product.xml | 4 ++-- .../views/product_template.xml | 2 +- 7 files changed, 23 insertions(+), 23 deletions(-) diff --git a/sale_quotation/migrations/16.0.0.3.0/pre-migrate.py b/sale_quotation/migrations/16.0.0.3.0/pre-migrate.py index f5fe5a242b..7772572fc5 100644 --- a/sale_quotation/migrations/16.0.0.3.0/pre-migrate.py +++ b/sale_quotation/migrations/16.0.0.3.0/pre-migrate.py @@ -29,7 +29,7 @@ def migrate(cr, version): cr, """ UPDATE product_template - SET is_shop_order_mode_unabled_on_variant = TRUE + SET is_shop_order_mode_enabled_on_variant = TRUE WHERE shop_only_quotation = 'manually_on_variant'; """, ) @@ -41,7 +41,7 @@ def migrate(cr, version): SET shop_order_mode = 'quotation_only' FROM product_template AS pt WHERE product_product.product_tmpl_id = pt.id - AND pt.is_shop_order_mode_unabled_on_variant = TRUE + AND pt.is_shop_order_mode_enabled_on_variant = TRUE AND product_product.shop_only_quotation = TRUE; """, ) diff --git a/shopinvader_product_order_mode/i18n/shopinvader_product_order_mode.pot b/shopinvader_product_order_mode/i18n/shopinvader_product_order_mode.pot index 451fe7dd8e..688df078d3 100644 --- a/shopinvader_product_order_mode/i18n/shopinvader_product_order_mode.pot +++ b/shopinvader_product_order_mode/i18n/shopinvader_product_order_mode.pot @@ -31,9 +31,9 @@ msgid "Direct Sale Only" msgstr "" #. module: shopinvader_product_order_mode -#: model:ir.model.fields,help:shopinvader_product_order_mode.field_product_product__is_shop_order_mode_unabled -#: model:ir.model.fields,help:shopinvader_product_order_mode.field_product_product__is_shop_order_mode_unabled_on_variant -#: model:ir.model.fields,help:shopinvader_product_order_mode.field_product_template__is_shop_order_mode_unabled_on_variant +#: model:ir.model.fields,help:shopinvader_product_order_mode.field_product_product__is_shop_order_mode_enabled +#: model:ir.model.fields,help:shopinvader_product_order_mode.field_product_product__is_shop_order_mode_enabled_on_variant +#: model:ir.model.fields,help:shopinvader_product_order_mode.field_product_template__is_shop_order_mode_enabled_on_variant msgid "" "If True, unables product variants to have a different value for " "'shop_order_mode' than this product template." @@ -61,13 +61,13 @@ msgid "Shopinvader Order Mode" msgstr "" #. module: shopinvader_product_order_mode -#: model:ir.model.fields,field_description:shopinvader_product_order_mode.field_product_product__is_shop_order_mode_unabled_on_variant -#: model:ir.model.fields,field_description:shopinvader_product_order_mode.field_product_template__is_shop_order_mode_unabled_on_variant +#: model:ir.model.fields,field_description:shopinvader_product_order_mode.field_product_product__is_shop_order_mode_enabled_on_variant +#: model:ir.model.fields,field_description:shopinvader_product_order_mode.field_product_template__is_shop_order_mode_enabled_on_variant msgid "Shopinvader Order Mode Unabled on Variant" msgstr "" #. module: shopinvader_product_order_mode -#: model:ir.model.fields,field_description:shopinvader_product_order_mode.field_product_product__is_shop_order_mode_unabled +#: model:ir.model.fields,field_description:shopinvader_product_order_mode.field_product_product__is_shop_order_mode_enabled msgid "Template Shop Order Mode Disabled" msgstr "" diff --git a/shopinvader_product_order_mode/models/product_product.py b/shopinvader_product_order_mode/models/product_product.py index 0fc7806ab6..4b8bd8e0ee 100644 --- a/shopinvader_product_order_mode/models/product_product.py +++ b/shopinvader_product_order_mode/models/product_product.py @@ -17,27 +17,27 @@ class ProductProduct(models.Model): inverse="_inverse_shop_order_mode", help="Order mode for the product in Shopinvader.", ) - is_shop_order_mode_unabled = fields.Boolean( + is_shop_order_mode_enabled = fields.Boolean( string="Template Shop Order Mode Disabled", - related="product_tmpl_id.is_shop_order_mode_unabled_on_variant", + related="product_tmpl_id.is_shop_order_mode_enabled_on_variant", store=True, readonly=True, ) @api.depends( "product_tmpl_id.shop_order_mode", - "product_tmpl_id.is_shop_order_mode_unabled_on_variant", + "product_tmpl_id.is_shop_order_mode_enabled_on_variant", ) def _compute_shop_order_mode(self): for product in self: if not product.product_tmpl_id: product.shop_order_mode = False - elif not product.product_tmpl_id.is_shop_order_mode_unabled_on_variant: + elif not product.product_tmpl_id.is_shop_order_mode_enabled_on_variant: product.shop_order_mode = product.product_tmpl_id.shop_order_mode def _inverse_shop_order_mode(self): for product in self: - if not product.is_shop_order_mode_unabled and not self.env.context.get( + if not product.is_shop_order_mode_enabled and not self.env.context.get( "skip_shop_order_mode_validation" ): raise ValidationError( diff --git a/shopinvader_product_order_mode/models/product_template.py b/shopinvader_product_order_mode/models/product_template.py index 94853bd147..d85bc46855 100644 --- a/shopinvader_product_order_mode/models/product_template.py +++ b/shopinvader_product_order_mode/models/product_template.py @@ -14,7 +14,7 @@ class ProductTemplate(models.Model): help="Deault order mode for this template's products in Shopinvader.", ) - is_shop_order_mode_unabled_on_variant = fields.Boolean( + is_shop_order_mode_enabled_on_variant = fields.Boolean( string="Shopinvader Order Mode Unabled on Variant", help="If True, unables product variants to have a different value for" " 'shop_order_mode' than this product template.", diff --git a/shopinvader_product_order_mode/tests/test_shop_order_mode.py b/shopinvader_product_order_mode/tests/test_shop_order_mode.py index f00b8934dc..0695dd6813 100644 --- a/shopinvader_product_order_mode/tests/test_shop_order_mode.py +++ b/shopinvader_product_order_mode/tests/test_shop_order_mode.py @@ -38,7 +38,7 @@ def setUpClass(cls): ) ], "shop_order_mode": "direct_sale_only", - "is_shop_order_mode_unabled_on_variant": False, + "is_shop_order_mode_enabled_on_variant": False, } ) @@ -54,13 +54,13 @@ def test_default_order_mode_on_products(self): def test_product_shop_order_mode_constraint(self): """ Test that the shop_order_mode field on product.product is not editable - when is_shop_order_mode_unabled_on_variant is False on product.template. + when is_shop_order_mode_enabled_on_variant is False on product.template. """ with self.assertRaises(ValidationError): self.product.shop_order_mode = None - self.template.is_shop_order_mode_unabled_on_variant = True + self.template.is_shop_order_mode_enabled_on_variant = True self.product.shop_order_mode = None self.assertNotEqual(self.product.shop_order_mode, "direct_sale_only") @@ -69,9 +69,9 @@ def test_product_shop_order_mode_constraint(self): def test_shop_order_mode_reset(self): """ Test that the `shop_order_mode` field on product.product is reset to the - value of the product.template when `is_shop_order_mode_unabled_on_variant` is True. + value of the product.template when `is_shop_order_mode_enabled_on_variant` is True. """ - self.template.is_shop_order_mode_unabled_on_variant = True + self.template.is_shop_order_mode_enabled_on_variant = True self.product.shop_order_mode = None - self.template.is_shop_order_mode_unabled_on_variant = False + self.template.is_shop_order_mode_enabled_on_variant = False self.assertEqual(self.product.shop_order_mode, self.template.shop_order_mode) diff --git a/shopinvader_product_order_mode/views/product_product.xml b/shopinvader_product_order_mode/views/product_product.xml index a77c331dd5..d31619f9b3 100644 --- a/shopinvader_product_order_mode/views/product_product.xml +++ b/shopinvader_product_order_mode/views/product_product.xml @@ -13,9 +13,9 @@ > - + diff --git a/shopinvader_product_order_mode/views/product_template.xml b/shopinvader_product_order_mode/views/product_template.xml index e64acf2820..179ab3cd15 100644 --- a/shopinvader_product_order_mode/views/product_template.xml +++ b/shopinvader_product_order_mode/views/product_template.xml @@ -12,7 +12,7 @@ position="inside" > - + From 89e7e873a916a3395714702812a1b2052c457ca6 Mon Sep 17 00:00:00 2001 From: Nicolas Delbovier Date: Fri, 16 May 2025 16:47:10 +0200 Subject: [PATCH 04/44] [ADD] sale_typology: extract the concept of "typology" in sale.order in a separate module --- sale_cart/README.rst | 3 +- sale_cart/__manifest__.py | 2 +- sale_cart/models/sale_order.py | 2 +- sale_cart/readme/CONTRIBUTORS.rst | 1 + sale_cart/readme/DESCRIPTION.rst | 2 +- sale_cart/static/description/index.html | 3 +- sale_typology/README.rst | 60 +++ sale_typology/__init__.py | 1 + sale_typology/__manifest__.py | 14 + sale_typology/models/__init__.py | 1 + sale_typology/models/sale_order.py | 11 + sale_typology/readme/CONTRIBUTORS.rst | 1 + sale_typology/readme/DESCRIPTION.rst | 1 + sale_typology/static/description/index.html | 416 ++++++++++++++++++ sale_typology/tests/__init__.py | 1 + sale_typology/tests/test_sale_typology.py | 30 ++ setup/sale_typology/odoo/addons/sale_typology | 1 + setup/sale_typology/setup.py | 6 + 18 files changed, 551 insertions(+), 5 deletions(-) create mode 100644 sale_typology/README.rst create mode 100644 sale_typology/__init__.py create mode 100644 sale_typology/__manifest__.py create mode 100644 sale_typology/models/__init__.py create mode 100644 sale_typology/models/sale_order.py create mode 100644 sale_typology/readme/CONTRIBUTORS.rst create mode 100644 sale_typology/readme/DESCRIPTION.rst create mode 100644 sale_typology/static/description/index.html create mode 100644 sale_typology/tests/__init__.py create mode 100644 sale_typology/tests/test_sale_typology.py create mode 120000 setup/sale_typology/odoo/addons/sale_typology create mode 100644 setup/sale_typology/setup.py diff --git a/sale_cart/README.rst b/sale_cart/README.rst index 62c67b624b..ccd7fe7f2a 100644 --- a/sale_cart/README.rst +++ b/sale_cart/README.rst @@ -22,7 +22,7 @@ Sale Cart |badge1| |badge2| |badge3| -This addon add a typology on sale.order to distinguish the sale orders that are +This addon adds a "cart" typology on sale.order to distinguish the sale orders that are in the process of being written (cart) and those that are quotations ready to be confirmed. @@ -56,6 +56,7 @@ Contributors ~~~~~~~~~~~~ * Laurent Mignon +* Nicolas DELBOVIER Maintainers ~~~~~~~~~~~ diff --git a/sale_cart/__manifest__.py b/sale_cart/__manifest__.py index 6fe431db48..7ffca856ae 100644 --- a/sale_cart/__manifest__.py +++ b/sale_cart/__manifest__.py @@ -10,7 +10,7 @@ "license": "AGPL-3", "author": "ACSONE SA/NV", "website": "https://github.com/shopinvader/odoo-shopinvader", - "depends": ["sale", "sales_team"], + "depends": ["sale", "sales_team", "sale_typology"], "data": ["views/sale_order.xml"], "demo": [], "external_dependencies": {"python": ["openupgradelib"]}, diff --git a/sale_cart/models/sale_order.py b/sale_cart/models/sale_order.py index 390faeb5e7..96fde1d0bd 100644 --- a/sale_cart/models/sale_order.py +++ b/sale_cart/models/sale_order.py @@ -7,7 +7,7 @@ class SaleOrder(models.Model): _inherit = "sale.order" - typology = fields.Selection([("sale", "Sale"), ("cart", "Cart")], default="sale") + typology = fields.Selection(selection_add=[("cart", "Cart")]) def _confirm_cart(self): self.ensure_one() diff --git a/sale_cart/readme/CONTRIBUTORS.rst b/sale_cart/readme/CONTRIBUTORS.rst index 172b2d223c..a455b581a9 100644 --- a/sale_cart/readme/CONTRIBUTORS.rst +++ b/sale_cart/readme/CONTRIBUTORS.rst @@ -1 +1,2 @@ * Laurent Mignon +* Nicolas DELBOVIER \ No newline at end of file diff --git a/sale_cart/readme/DESCRIPTION.rst b/sale_cart/readme/DESCRIPTION.rst index 7f6c5b3950..c0964e8344 100644 --- a/sale_cart/readme/DESCRIPTION.rst +++ b/sale_cart/readme/DESCRIPTION.rst @@ -1,4 +1,4 @@ -This addon add a typology on sale.order to distinguish the sale orders that are +This addon adds a "cart" typology on sale.order to distinguish the sale orders that are in the process of being written (cart) and those that are quotations ready to be confirmed. diff --git a/sale_cart/static/description/index.html b/sale_cart/static/description/index.html index 7e461a4cdb..0ad025eed1 100644 --- a/sale_cart/static/description/index.html +++ b/sale_cart/static/description/index.html @@ -370,7 +370,7 @@

    Sale Cart

    !! source digest: sha256:f8b90f8e15973c46f8a0158f465e5ffe13ef8e244cef29288fe7b60472da3262 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

    Beta License: AGPL-3 shopinvader/odoo-shopinvader

    -

    This addon add a typology on sale.order to distinguish the sale orders that are +

    This addon adds a “cart” typology on sale.order to distinguish the sale orders that are in the process of being written (cart) and those that are quotations ready to be confirmed.

    It also automatically converts the cart into a sale when the payment is @@ -407,6 +407,7 @@

    Authors

    Contributors

    diff --git a/sale_typology/README.rst b/sale_typology/README.rst new file mode 100644 index 0000000000..01c044a2c7 --- /dev/null +++ b/sale_typology/README.rst @@ -0,0 +1,60 @@ +============= +Sale Typology +============= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b322433186805aca5486f254a72af4c1f83b4fc5d91a8ca0c1a985c1c0e3f071 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-shopinvader%2Fodoo--shopinvader-lightgray.png?logo=github + :target: https://github.com/shopinvader/odoo-shopinvader/tree/16.0/sale_typology + :alt: shopinvader/odoo-shopinvader + +|badge1| |badge2| |badge3| + +This addon adds a typology on sale.order + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* Nicolas DELBOVIER + +Maintainers +~~~~~~~~~~~ + +This module is part of the `shopinvader/odoo-shopinvader `_ project on GitHub. + +You are welcome to contribute. diff --git a/sale_typology/__init__.py b/sale_typology/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/sale_typology/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sale_typology/__manifest__.py b/sale_typology/__manifest__.py new file mode 100644 index 0000000000..d7f40583ec --- /dev/null +++ b/sale_typology/__manifest__.py @@ -0,0 +1,14 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Sale Typology", + "summary": """Adds a 'typology' field to sale orders to distinguish sales from carts.""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV", + "website": "https://github.com/shopinvader/odoo-shopinvader", + "depends": ["sale"], + "data": [], + "demo": [], +} diff --git a/sale_typology/models/__init__.py b/sale_typology/models/__init__.py new file mode 100644 index 0000000000..6aacb75313 --- /dev/null +++ b/sale_typology/models/__init__.py @@ -0,0 +1 @@ +from . import sale_order diff --git a/sale_typology/models/sale_order.py b/sale_typology/models/sale_order.py new file mode 100644 index 0000000000..f4ee724916 --- /dev/null +++ b/sale_typology/models/sale_order.py @@ -0,0 +1,11 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class SaleOrder(models.Model): + + _inherit = "sale.order" + + typology = fields.Selection([("sale", "Sale")], default="sale") diff --git a/sale_typology/readme/CONTRIBUTORS.rst b/sale_typology/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..5341bc4906 --- /dev/null +++ b/sale_typology/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Nicolas DELBOVIER \ No newline at end of file diff --git a/sale_typology/readme/DESCRIPTION.rst b/sale_typology/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..a3dbee0ade --- /dev/null +++ b/sale_typology/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +This addon adds a typology on sale.order \ No newline at end of file diff --git a/sale_typology/static/description/index.html b/sale_typology/static/description/index.html new file mode 100644 index 0000000000..fec9901a28 --- /dev/null +++ b/sale_typology/static/description/index.html @@ -0,0 +1,416 @@ + + + + + +Sale Typology + + + +
    +

    Sale Typology

    + + +

    Beta License: AGPL-3 shopinvader/odoo-shopinvader

    +

    This addon adds a typology on sale.order

    +

    Table of contents

    + +
    +

    Bug Tracker

    +

    Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

    +

    Do not contact contributors directly about support or help with technical issues.

    +
    +
    +

    Credits

    +
    +

    Authors

    +
      +
    • ACSONE SA/NV
    • +
    +
    +
    +

    Contributors

    + +
    +
    +

    Maintainers

    +

    This module is part of the shopinvader/odoo-shopinvader project on GitHub.

    +

    You are welcome to contribute.

    +
    +
    +
    + + diff --git a/sale_typology/tests/__init__.py b/sale_typology/tests/__init__.py new file mode 100644 index 0000000000..f357e9a2cf --- /dev/null +++ b/sale_typology/tests/__init__.py @@ -0,0 +1 @@ +from . import test_sale_typology diff --git a/sale_typology/tests/test_sale_typology.py b/sale_typology/tests/test_sale_typology.py new file mode 100644 index 0000000000..5fb8d25675 --- /dev/null +++ b/sale_typology/tests/test_sale_typology.py @@ -0,0 +1,30 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import Command +from odoo.tests.common import TransactionCase + + +class TestSaleCart(TransactionCase): + @classmethod + def setUpClass(cls): + super(TestSaleCart, cls).setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.product = cls.env["product.product"].create( + { + "name": "product", + "uom_id": cls.env.ref("uom.product_uom_unit").id, + } + ) + cls.partner = cls.env["res.partner"].create({"name": "partner"}) + cls.sale_order = cls.env["sale.order"].create( + { + "partner_id": cls.partner.id, + "order_line": [ + Command.create({"product_id": cls.product.id, "product_uom_qty": 1}) + ], + } + ) + + def test_typology_default(self): + self.assertEqual(self.sale_order.typology, "sale") diff --git a/setup/sale_typology/odoo/addons/sale_typology b/setup/sale_typology/odoo/addons/sale_typology new file mode 120000 index 0000000000..dfce1a0912 --- /dev/null +++ b/setup/sale_typology/odoo/addons/sale_typology @@ -0,0 +1 @@ +../../../../sale_typology \ No newline at end of file diff --git a/setup/sale_typology/setup.py b/setup/sale_typology/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/sale_typology/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) From 9abeea53e62e4b726ba0a081ea7c5c7266a8838f Mon Sep 17 00:00:00 2001 From: Nicolas Delbovier Date: Mon, 19 May 2025 10:50:58 +0200 Subject: [PATCH 05/44] [IMP] sale_quotation: use the "typology" field to delcare the sale order as a quotation --- sale_quotation/models/sale_order.py | 12 ++++++++++++ sale_quotation/tests/test_quotation.py | 8 ++++++++ sale_quotation/views/sale_view.xml | 16 ++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/sale_quotation/models/sale_order.py b/sale_quotation/models/sale_order.py index 02302497f6..72d6524a53 100644 --- a/sale_quotation/models/sale_order.py +++ b/sale_quotation/models/sale_order.py @@ -26,6 +26,8 @@ class SaleOrder(models.Model): copy=False, ) + typology = fields.Selection(selection_add=[("quote", "Quote")], default="quote") + @api.depends("state") def _compute_quotation_state(self): for record in self: @@ -62,3 +64,13 @@ def action_confirm_quotation(self): ) ) self.quotation_state = "accepted" + self.typology = "sale" + + def action_confirm(self): + if self.typology == "quote": + self.action_confirm_quotation() + return super().action_confirm() + + def action_draft(self): + self.typology = "quote" + return super().action_draft() diff --git a/sale_quotation/tests/test_quotation.py b/sale_quotation/tests/test_quotation.py index ebcbe5c5fa..8031b97bb8 100644 --- a/sale_quotation/tests/test_quotation.py +++ b/sale_quotation/tests/test_quotation.py @@ -22,6 +22,7 @@ def setUpClass(cls): def test_create_quotation(self): self.assertEqual(self.so.quotation_state, "draft") + self.assertEqual(self.so.typology, "quote") def test_request_quotation(self): self.so.typology = "cart" @@ -42,8 +43,15 @@ def test_confirm_quotation(self): self.so.action_quotation_sent() self.so.action_confirm_quotation() self.assertEqual(self.so.quotation_state, "accepted") + self.assertEqual(self.so.typology, "sale") def test_not_allowed_confirm_quotation(self): with self.assertRaises(UserError) as cm: self.so.action_confirm_quotation() self.assertIn("Only quotation with the state", cm.exception.args[0]) + + def test_convert_to_draft_resets_typology(self): + self.so.action_quotation_sent() + self.so.action_confirm() + self.so.action_draft() + self.assertEqual(self.so.typology, "quote") diff --git a/sale_quotation/views/sale_view.xml b/sale_quotation/views/sale_view.xml index f8b771ea4b..41d140383b 100644 --- a/sale_quotation/views/sale_view.xml +++ b/sale_quotation/views/sale_view.xml @@ -70,6 +70,14 @@ domain="[('quotation_state', '=', 'cancel')]" /> + + + 1 @@ -90,4 +98,12 @@ + + [('typology', '=', 'quote')] + + + + [('typology', '=', 'quote')] + + From 75a5176f055b5f5de7f1b2c64f3f8e06a5638e8b Mon Sep 17 00:00:00 2001 From: Nicolas Delbovier Date: Mon, 19 May 2025 11:40:38 +0200 Subject: [PATCH 06/44] [ADD] sale_cart_quotation: introduce module to decouple sale_quotation and sale_cart --- sale_cart_quotation/README.rst | 60 +++ sale_cart_quotation/__init__.py | 1 + sale_cart_quotation/__manifest__.py | 16 + sale_cart_quotation/models/__init__.py | 1 + sale_cart_quotation/models/sale_order.py | 20 + sale_cart_quotation/readme/CONTRIBUTORS.rst | 1 + sale_cart_quotation/readme/DESCRIPTION.rst | 1 + .../static/description/index.html | 416 ++++++++++++++++++ sale_cart_quotation/tests/__init__.py | 1 + sale_cart_quotation/tests/test_quotation.py | 30 ++ sale_cart_quotation/views/sale_order.xml | 12 + sale_quotation/__manifest__.py | 1 - sale_quotation/models/sale_order.py | 11 - .../odoo/addons/sale_cart_quotation | 1 + setup/sale_cart_quotation/setup.py | 6 + 15 files changed, 566 insertions(+), 12 deletions(-) create mode 100644 sale_cart_quotation/README.rst create mode 100644 sale_cart_quotation/__init__.py create mode 100644 sale_cart_quotation/__manifest__.py create mode 100644 sale_cart_quotation/models/__init__.py create mode 100644 sale_cart_quotation/models/sale_order.py create mode 100644 sale_cart_quotation/readme/CONTRIBUTORS.rst create mode 100644 sale_cart_quotation/readme/DESCRIPTION.rst create mode 100644 sale_cart_quotation/static/description/index.html create mode 100644 sale_cart_quotation/tests/__init__.py create mode 100644 sale_cart_quotation/tests/test_quotation.py create mode 100644 sale_cart_quotation/views/sale_order.xml create mode 120000 setup/sale_cart_quotation/odoo/addons/sale_cart_quotation create mode 100644 setup/sale_cart_quotation/setup.py diff --git a/sale_cart_quotation/README.rst b/sale_cart_quotation/README.rst new file mode 100644 index 0000000000..fd6f19a665 --- /dev/null +++ b/sale_cart_quotation/README.rst @@ -0,0 +1,60 @@ +=================== +Sale Cart Quotation +=================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:092764e34a3532b13b87a0b07712f405be8de3df4ce5265a1c4f0640ea1f44f8 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-shopinvader%2Fodoo--shopinvader-lightgray.png?logo=github + :target: https://github.com/shopinvader/odoo-shopinvader/tree/16.0/sale_cart_quotation + :alt: shopinvader/odoo-shopinvader + +|badge1| |badge2| |badge3| + +Glue module between sale_quotation and sale_cart + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* Nicolas DELBOVIER + +Maintainers +~~~~~~~~~~~ + +This module is part of the `shopinvader/odoo-shopinvader `_ project on GitHub. + +You are welcome to contribute. diff --git a/sale_cart_quotation/__init__.py b/sale_cart_quotation/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/sale_cart_quotation/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sale_cart_quotation/__manifest__.py b/sale_cart_quotation/__manifest__.py new file mode 100644 index 0000000000..b2a8636aaf --- /dev/null +++ b/sale_cart_quotation/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Sale Cart Quotation", + "summary": """Glue module between sale_quotation and sale_cart""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV", + "website": "https://github.com/shopinvader/odoo-shopinvader", + "depends": ["sale_quotation", "sale_cart"], + "data": [ + "views/sale_order.xml", + ], + "demo": [], +} diff --git a/sale_cart_quotation/models/__init__.py b/sale_cart_quotation/models/__init__.py new file mode 100644 index 0000000000..6aacb75313 --- /dev/null +++ b/sale_cart_quotation/models/__init__.py @@ -0,0 +1 @@ +from . import sale_order diff --git a/sale_cart_quotation/models/sale_order.py b/sale_cart_quotation/models/sale_order.py new file mode 100644 index 0000000000..6a37793c28 --- /dev/null +++ b/sale_cart_quotation/models/sale_order.py @@ -0,0 +1,20 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, models +from odoo.exceptions import UserError + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + def action_request_quotation(self): + if any(rec.state != "draft" or rec.typology != "cart" for rec in self): + raise UserError( + _( + "Only orders of cart typology in draft state " + "can be converted to quotation" + ) + ) + self.write({"quotation_state": "customer_request", "typology": "sale"}) + return True diff --git a/sale_cart_quotation/readme/CONTRIBUTORS.rst b/sale_cart_quotation/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..5341bc4906 --- /dev/null +++ b/sale_cart_quotation/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Nicolas DELBOVIER \ No newline at end of file diff --git a/sale_cart_quotation/readme/DESCRIPTION.rst b/sale_cart_quotation/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..b8745a7bb0 --- /dev/null +++ b/sale_cart_quotation/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Glue module between sale_quotation and sale_cart \ No newline at end of file diff --git a/sale_cart_quotation/static/description/index.html b/sale_cart_quotation/static/description/index.html new file mode 100644 index 0000000000..779434cb6c --- /dev/null +++ b/sale_cart_quotation/static/description/index.html @@ -0,0 +1,416 @@ + + + + + +Sale Cart Quotation + + + +
    +

    Sale Cart Quotation

    + + +

    Beta License: AGPL-3 shopinvader/odoo-shopinvader

    +

    Glue module between sale_quotation and sale_cart

    +

    Table of contents

    + +
    +

    Bug Tracker

    +

    Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

    +

    Do not contact contributors directly about support or help with technical issues.

    +
    +
    +

    Credits

    +
    +

    Authors

    +
      +
    • ACSONE SA/NV
    • +
    +
    +
    +

    Contributors

    + +
    +
    +

    Maintainers

    +

    This module is part of the shopinvader/odoo-shopinvader project on GitHub.

    +

    You are welcome to contribute.

    +
    +
    +
    + + diff --git a/sale_cart_quotation/tests/__init__.py b/sale_cart_quotation/tests/__init__.py new file mode 100644 index 0000000000..43ae815229 --- /dev/null +++ b/sale_cart_quotation/tests/__init__.py @@ -0,0 +1 @@ +from . import test_quotation diff --git a/sale_cart_quotation/tests/test_quotation.py b/sale_cart_quotation/tests/test_quotation.py new file mode 100644 index 0000000000..132b88e6f9 --- /dev/null +++ b/sale_cart_quotation/tests/test_quotation.py @@ -0,0 +1,30 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import SavepointCase + + +class TestQuotation(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + product = cls.env["product.product"].create({"name": "Test Product"}) + partner = cls.env["res.partner"].create({"name": "John"}) + cls.so = cls.env["sale.order"].create( + { + "partner_id": partner.id, + "order_line": [(0, 0, {"product_id": product.id})], + } + ) + + def test_request_quotation(self): + self.so.typology = "cart" + self.so.action_request_quotation() + self.assertEqual(self.so.quotation_state, "customer_request") + + def test_send_requested_quotation(self): + self.so.typology = "cart" + self.so.action_request_quotation() + self.so.action_quotation_sent() + self.assertEqual(self.so.quotation_state, "waiting_acceptation") diff --git a/sale_cart_quotation/views/sale_order.xml b/sale_cart_quotation/views/sale_order.xml new file mode 100644 index 0000000000..945ebf92cc --- /dev/null +++ b/sale_cart_quotation/views/sale_order.xml @@ -0,0 +1,12 @@ + + + + + [('typology', '=', 'quote')] + + + + [('typology', '=', 'quote')] + + diff --git a/sale_quotation/__manifest__.py b/sale_quotation/__manifest__.py index 2cb1baa444..c77168ab16 100644 --- a/sale_quotation/__manifest__.py +++ b/sale_quotation/__manifest__.py @@ -11,7 +11,6 @@ "author": "Akretion", "license": "AGPL-3", "depends": [ - "sale_cart", "shopinvader_product_order_mode_on_request", ], "data": [ diff --git a/sale_quotation/models/sale_order.py b/sale_quotation/models/sale_order.py index 72d6524a53..8b75f6cdb2 100644 --- a/sale_quotation/models/sale_order.py +++ b/sale_quotation/models/sale_order.py @@ -44,17 +44,6 @@ def _compute_quotation_state(self): elif record.state == "sale": record.quotation_state = "accepted" - def action_request_quotation(self): - if any(rec.state != "draft" or rec.typology != "cart" for rec in self): - raise UserError( - _( - "Only orders of cart typology in draft state " - "can be converted to quotation" - ) - ) - self.write({"quotation_state": "customer_request", "typology": "sale"}) - return True - def action_confirm_quotation(self): if any(rec.quotation_state != "waiting_acceptation" for rec in self): raise UserError( diff --git a/setup/sale_cart_quotation/odoo/addons/sale_cart_quotation b/setup/sale_cart_quotation/odoo/addons/sale_cart_quotation new file mode 120000 index 0000000000..9258838b8e --- /dev/null +++ b/setup/sale_cart_quotation/odoo/addons/sale_cart_quotation @@ -0,0 +1 @@ +../../../../sale_cart_quotation \ No newline at end of file diff --git a/setup/sale_cart_quotation/setup.py b/setup/sale_cart_quotation/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/sale_cart_quotation/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) From c23d91ac774b9342fe57b00ffbedc7724d67d896 Mon Sep 17 00:00:00 2001 From: Nicolas Delbovier Date: Tue, 20 May 2025 14:57:33 +0200 Subject: [PATCH 07/44] [IMP] shopinvader_api_quotation: remove sale_cart dependency and enhance API - put shopinvader_sale_state as a dependency in order to remove sale_cart because we need the field sale.order.shopinvader_state This commit introduces significant enhancements to the REST API for quotation management, including: - **Create Method:** Added a new endpoint to create quotations. - **Line Management Methods:** Implemented dedicated endpoints for adding, deleting, or updating single or multiple lines within a quotation. - **Update Extension:** Extended the existing update method to support the addition of new lines --- shopinvader_api_quotation/__init__.py | 3 + shopinvader_api_quotation/__manifest__.py | 2 +- shopinvader_api_quotation/dependencies.py | 140 +++++++++ .../routers/quotation.py | 190 +++++++----- shopinvader_api_quotation/schemas/sale.py | 65 +++- .../tests/test_shopinvader_api_quotation.py | 283 ++++++++++++++++-- 6 files changed, 582 insertions(+), 101 deletions(-) create mode 100644 shopinvader_api_quotation/dependencies.py diff --git a/shopinvader_api_quotation/__init__.py b/shopinvader_api_quotation/__init__.py index 62a5d54f85..cecef1199b 100644 --- a/shopinvader_api_quotation/__init__.py +++ b/shopinvader_api_quotation/__init__.py @@ -1 +1,4 @@ from . import routers +from . import dependencies +from . import schemas +from . import models diff --git a/shopinvader_api_quotation/__manifest__.py b/shopinvader_api_quotation/__manifest__.py index 5334705968..04fb37c1e2 100644 --- a/shopinvader_api_quotation/__manifest__.py +++ b/shopinvader_api_quotation/__manifest__.py @@ -13,7 +13,7 @@ "shopinvader_api_security_sale", "shopinvader_api_cart", "shopinvader_api_sale", - "sale_cart", + "shopinvader_sale_state", "sale_quotation", ], "data": [], diff --git a/shopinvader_api_quotation/dependencies.py b/shopinvader_api_quotation/dependencies.py new file mode 100644 index 0000000000..b27ce45f51 --- /dev/null +++ b/shopinvader_api_quotation/dependencies.py @@ -0,0 +1,140 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from typing import Annotated + +from fastapi import Depends + +from odoo import Command, models +from odoo.api import Environment +from odoo.exceptions import MissingError + +from odoo.addons.base.models.res_partner import Partner as ResPartner +from odoo.addons.fastapi.dependencies import ( + authenticated_partner, + authenticated_partner_env, +) +from odoo.addons.sale.models.sale_order import SaleOrder + +from .schemas.sale import ( + QuotationAddLineRequest, + QuotationCreateRequest, + QuotationDeleteLineRequest, + QuotationLines, + QuotationUpdateLineRequest, +) + + +class ShopinvaderApiQuotationRouterHelper(models.AbstractModel): + _name = "shopinvader_api_quotation.router.helper" + _description = "Shopinvader api quotations router helper" + _inherit = "shopinvader_api_sale.sales_router.helper" + + def _get_domain_adapter(self): + return [ + ("partner_id", "=", self.partner.id), + ("typology", "=", "quote"), + ] + + def _process_confirm_quotation(self, quotation, data): + """Process the quotation confirmation + Can be inherited if you expect specific params + for confirming a quotation""" + return quotation.action_confirm_quotation() + + def _confirm(self, quotation_id, data): + quotation = self._get(quotation_id) + self._process_confirm_quotation(quotation, data) + return quotation + + def _update(self, quotation_id, data): + quotation = self._get(quotation_id) + quotation.write(data.to_sale_order_vals()) + return quotation + + def _create(self, rqst: QuotationCreateRequest) -> SaleOrder: + vals = rqst.model_dump() + vals["partner_id"] = self.partner.id + + if "lines" in vals: + vals["order_line"] = [ + Command.create( + { + "product_id": line["product_id"], + "product_uom_qty": line["quantity"], + } + ) + # use "pop" so that "lines" does not stay in "vals" + # preventing an error in the "create" below + for line in vals.pop("lines") + ] + + quotation = self.env["sale.order"].create(vals) + + return quotation + + def _add_lines( + self, quotation_id: int, rqst: QuotationLines[QuotationAddLineRequest] + ) -> SaleOrder: + quotation = self._get(quotation_id) + for line in rqst.lines: + quotation.write( + { + "order_line": [ + Command.create( + { + "product_id": line.product_id, + "product_uom_qty": line.quantity, + } + ) + ] + } + ) + return quotation + + def _update_lines( + self, quotation_id: int, rqst: QuotationLines[QuotationUpdateLineRequest] + ) -> SaleOrder: + quotation = self._get(quotation_id) + existing_lines_ids = {line.id for line in quotation.order_line} + vals = {"order_line": []} + for update_line_rqst in rqst.lines: + if update_line_rqst.line_id not in existing_lines_ids: + raise MissingError( + f"Line with ID {update_line_rqst.line_id} " + f"not found in quotation {quotation_id}" + ) + vals["order_line"].append( + Command.update( + update_line_rqst.line_id, + { + "product_id": update_line_rqst.product_id, + "product_uom_qty": update_line_rqst.quantity, + "sequence": update_line_rqst.sequence, + }, + ) + ) + quotation.write(vals) + return quotation + + def _delete_lines( + self, quotation_id: int, rqst: QuotationLines[QuotationDeleteLineRequest] + ) -> SaleOrder: + quotation = self._get(quotation_id) + existing_lines_ids = {line.id for line in quotation.order_line} + vals = {"order_line": []} + for delete_line_rqst in rqst.lines: + if delete_line_rqst.line_id not in existing_lines_ids: + raise MissingError( + f"Line with ID {delete_line_rqst.line_id} " + f"not found in quotation {quotation_id}" + ) + vals["order_line"].append(Command.delete(delete_line_rqst.line_id)) + quotation.write(vals) + return quotation + + +def quotation_router_helper( + env: Annotated[Environment, Depends(authenticated_partner_env)], + partner: Annotated[ResPartner, Depends(authenticated_partner)], +) -> ShopinvaderApiQuotationRouterHelper: + return env["shopinvader_api_quotation.router.helper"].new({"partner": partner}) diff --git a/shopinvader_api_quotation/routers/quotation.py b/shopinvader_api_quotation/routers/quotation.py index 4cf3316655..bdfa538cbe 100644 --- a/shopinvader_api_quotation/routers/quotation.py +++ b/shopinvader_api_quotation/routers/quotation.py @@ -3,51 +3,58 @@ from fastapi import APIRouter, Depends from fastapi.responses import FileResponse, StreamingResponse -from odoo import models -from odoo.api import Environment from odoo.http import content_disposition -from odoo.addons.base.models.res_partner import Partner as ResPartner from odoo.addons.extendable_fastapi.schemas import PagedCollection -from odoo.addons.fastapi.dependencies import ( - authenticated_partner, - authenticated_partner_env, - paging, -) +from odoo.addons.fastapi.dependencies import paging from odoo.addons.fastapi.schemas import Paging from odoo.addons.shopinvader_schema_sale.schemas.sale import Sale, SaleSearch -from ..schemas.sale import QuotationConfirmInput, QuotationUpdateInput +from ..dependencies import ShopinvaderApiQuotationRouterHelper, quotation_router_helper +from ..schemas.sale import ( + QuotationAddLineRequest, + QuotationConfirmInput, + QuotationCreateRequest, + QuotationDeleteLineRequest, + QuotationLines, + QuotationUpdateInput, + QuotationUpdateLineRequest, +) -# create a router quotation_router = APIRouter(tags=["quotations"]) +@quotation_router.post("/quotations/create", status_code=201) +def create_quotation( + quotation_router_helper: Annotated[ + ShopinvaderApiQuotationRouterHelper, Depends(quotation_router_helper) + ], + rqst: QuotationCreateRequest, +) -> Sale: + quotation = quotation_router_helper._create(rqst) + return Sale.from_sale_order(quotation) + + @quotation_router.get("/quotations/{quotation_id}") def get( - env: Annotated[Environment, Depends(authenticated_partner_env)], - partner: Annotated[ResPartner, Depends(authenticated_partner)], + quotation_router_helper: Annotated[ + ShopinvaderApiQuotationRouterHelper, Depends(quotation_router_helper) + ], quotation_id: int, ) -> Sale | None: - return Sale.from_sale_order( - env["shopinvader_api_quotation.quotations_router.helper"] - .new({"partner": partner}) - ._get(quotation_id) - ) + quotation = quotation_router_helper._get(quotation_id) + return Sale.from_sale_order(quotation) @quotation_router.post("/quotations/{quotation_id}/confirm", status_code=200) def confirm_quotation( - env: Annotated[Environment, Depends(authenticated_partner_env)], - partner: Annotated[ResPartner, Depends(authenticated_partner)], + quotation_router_helper: Annotated[ + ShopinvaderApiQuotationRouterHelper, Depends(quotation_router_helper) + ], quotation_id: int, data: QuotationConfirmInput | None = None, ) -> None: - order = ( - env["shopinvader_api_quotation.quotations_router.helper"] - .new({"partner": partner}) - ._confirm(quotation_id, data) - ) + order = quotation_router_helper._confirm(quotation_id, data) return Sale.from_sale_order(order) @@ -55,14 +62,11 @@ def confirm_quotation( def search_quotation( params: Annotated[SaleSearch, Depends()], paging: Annotated[Paging, Depends(paging)], - env: Annotated[Environment, Depends(authenticated_partner_env)], - partner: Annotated[ResPartner, Depends(authenticated_partner)], + quotation_router_helper: Annotated[ + ShopinvaderApiQuotationRouterHelper, Depends(quotation_router_helper) + ], ) -> PagedCollection[Sale]: - count, orders = ( - env["shopinvader_api_quotation.quotations_router.helper"] - .new({"partner": partner}) - ._search(paging, params) - ) + count, orders = quotation_router_helper._search(paging, params) return PagedCollection[Sale]( count=count, items=[Sale.from_sale_order(order) for order in orders], @@ -72,30 +76,24 @@ def search_quotation( @quotation_router.post("/quotations/{quotation_id}") def update_quotation( data: QuotationUpdateInput, - env: Annotated[Environment, Depends(authenticated_partner_env)], - partner: Annotated["ResPartner", Depends(authenticated_partner)], + quotation_router_helper: Annotated[ + ShopinvaderApiQuotationRouterHelper, Depends(quotation_router_helper) + ], quotation_id: int, ) -> Sale: - order = ( - env["shopinvader_api_quotation.quotations_router.helper"] - .new({"partner": partner}) - ._update(quotation_id, data) - ) + order = quotation_router_helper._update(quotation_id, data) return Sale.from_sale_order(order) @quotation_router.get("/quotations/{quotation_id}/download") def download( quotation_id: int, - env: Annotated[Environment, Depends(authenticated_partner_env)], - partner: Annotated[ResPartner, Depends(authenticated_partner)], + quotation_router_helper: Annotated[ + ShopinvaderApiQuotationRouterHelper, Depends(quotation_router_helper) + ], ) -> FileResponse: """Download document.""" - filename, pdf = ( - env["shopinvader_api_quotation.quotations_router.helper"] - .new({"partner": partner}) - ._get_pdf(quotation_id) - ) + filename, pdf = quotation_router_helper._get_pdf(quotation_id) if not filename.lower().endswith(".pdf"): filename += ".pdf" header = { @@ -110,29 +108,79 @@ def pseudo_stream(): ) -class ShopinvaderApiSaleSalesRouterHelper(models.AbstractModel): - _name = "shopinvader_api_quotation.quotations_router.helper" - _inherit = "shopinvader_api_sale.sales_router.helper" - _description = "Shopinvader api sale router helper" - - def _get_domain_adapter(self): - return [ - ("partner_id", "=", self.partner.id), - ("quotation_state", "in", ("customer_request", "waiting_acceptation")), - ] - - def _process_confirm_quotation(self, quotation, data): - """Process the quotation confirmation - Can be inherited if you expect specific params - for confirming a quotation""" - return quotation.action_confirm_quotation() - - def _confirm(self, quotation_id, data): - quotation = self._get(quotation_id) - self._process_confirm_quotation(quotation, data) - return quotation - - def _update(self, quotation_id, data): - quotation = self._get(quotation_id) - quotation.write(data.to_sale_order_vals()) - return quotation +@quotation_router.post("/quotations/{quotation_id}/add_line", status_code=201) +def add_line( + quotation_router_helper: Annotated[ + ShopinvaderApiQuotationRouterHelper, Depends(quotation_router_helper) + ], + quotation_id: int, + rqst: QuotationAddLineRequest, +) -> Sale: + quotation = quotation_router_helper._add_lines( + quotation_id, QuotationLines[QuotationAddLineRequest](lines=[rqst]) + ) + return Sale.from_sale_order(quotation) + + +@quotation_router.post("/quotations/{quotation_id}/add_lines", status_code=201) +def add_lines( + quotation_router_helper: Annotated[ + ShopinvaderApiQuotationRouterHelper, Depends(quotation_router_helper) + ], + quotation_id: int, + rqst: QuotationLines[QuotationAddLineRequest], +) -> Sale: + quotation = quotation_router_helper._add_lines(quotation_id, rqst) + return Sale.from_sale_order(quotation) + + +@quotation_router.put("/quotations/{quotation_id}/update_line", status_code=200) +def update_line( + quotation_router_helper: Annotated[ + ShopinvaderApiQuotationRouterHelper, Depends(quotation_router_helper) + ], + quotation_id: int, + rqst: QuotationUpdateLineRequest, +) -> Sale: + quotation = quotation_router_helper._update_lines( + quotation_id, QuotationLines[QuotationUpdateLineRequest](lines=[rqst]) + ) + return Sale.from_sale_order(quotation) + + +@quotation_router.put("/quotations/{quotation_id}/update_lines", status_code=200) +def update_lines( + quotation_router_helper: Annotated[ + ShopinvaderApiQuotationRouterHelper, Depends(quotation_router_helper) + ], + quotation_id: int, + rqst: QuotationLines[QuotationUpdateLineRequest], +) -> Sale: + quotation = quotation_router_helper._update_lines(quotation_id, rqst) + return Sale.from_sale_order(quotation) + + +@quotation_router.post("/quotations/{quotation_id}/delete_line", status_code=200) +def delete_line( + quotation_router_helper: Annotated[ + ShopinvaderApiQuotationRouterHelper, Depends(quotation_router_helper) + ], + quotation_id: int, + rqst: QuotationDeleteLineRequest, +) -> Sale: + quotation = quotation_router_helper._delete_lines( + quotation_id, QuotationLines[QuotationDeleteLineRequest](lines=[rqst]) + ) + return Sale.from_sale_order(quotation) + + +@quotation_router.post("/quotations/{quotation_id}/delete_lines", status_code=200) +def delete_lines( + quotation_router_helper: Annotated[ + ShopinvaderApiQuotationRouterHelper, Depends(quotation_router_helper) + ], + quotation_id: int, + rqst: QuotationLines[QuotationDeleteLineRequest], +) -> Sale: + quotation = quotation_router_helper._delete_lines(quotation_id, rqst) + return Sale.from_sale_order(quotation) diff --git a/shopinvader_api_quotation/schemas/sale.py b/shopinvader_api_quotation/schemas/sale.py index 8a63debb89..ce237d74cd 100644 --- a/shopinvader_api_quotation/schemas/sale.py +++ b/shopinvader_api_quotation/schemas/sale.py @@ -1,5 +1,9 @@ +from typing import Generic, TypeVar + from extendable_pydantic import StrictExtendableBaseModel +from odoo import Command + from odoo.addons.shopinvader_schema_sale.schemas.sale import Sale @@ -13,12 +17,63 @@ def from_sale_order(cls, odoo_rec): return res -class QuotationUpdateInput(StrictExtendableBaseModel, extra="ignore"): +class QuotationConfirmInput(StrictExtendableBaseModel): + """Extend it if you need params for the confirmation""" + + +class QuotationLineCreateRequest(StrictExtendableBaseModel, extra="ignore"): + sequence: int | None = None + product_id: int + quantity: float + + +class QuotationCreateRequest(StrictExtendableBaseModel, extra="ignore"): + name: str + lines: list[QuotationLineCreateRequest] | None = None client_order_ref: str | None = None + typology: str - def to_sale_order_vals(self) -> dict: - return {"client_order_ref": self.client_order_ref} +class QuotationAddLineRequest(StrictExtendableBaseModel, extra="ignore"): + sequence: int | None = None + product_id: int + quantity: float -class QuotationConfirmInput(StrictExtendableBaseModel): - """Extend it if you need params for the confirmation""" + +class QuotationUpdateLineRequest(StrictExtendableBaseModel, extra="ignore"): + line_id: int + sequence: int | None = None + product_id: int + quantity: float + + +class QuotationDeleteLineRequest(StrictExtendableBaseModel, extra="ignore"): + line_id: int + + +T = TypeVar("T") + + +class QuotationLines(StrictExtendableBaseModel, Generic[T]): + lines: list[T] = [] + + +class QuotationUpdateInput(StrictExtendableBaseModel, extra="ignore"): + client_order_ref: str | None = None + lines: list[QuotationUpdateLineRequest] | None = None + + def to_sale_order_vals(self) -> dict: + return { + "client_order_ref": self.client_order_ref, + "order_line": [ + Command.update( + line.line_id, + { + "product_id": line.product_id, + "product_uom_qty": line.quantity, + "sequence": line.sequence, + }, + ) + for line in self.lines + ], + } diff --git a/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py b/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py index 608f78219c..e9dd08cb2e 100644 --- a/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py +++ b/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py @@ -4,6 +4,7 @@ from fastapi import status from requests import Response +from odoo import Command from odoo.tests.common import tagged from odoo.addons.extendable_fastapi.tests.common import FastAPITransactionCase @@ -23,7 +24,7 @@ def setUpClass(cls) -> None: { "name": "Test User Without Rights", "login": "user_no_rights", - "groups_id": [(6, 0, [])], + "groups_id": [Command.set([])], } ) user_with_rights = cls.env["res.users"].create( @@ -32,13 +33,13 @@ def setUpClass(cls) -> None: "login": "user_with_rights", "groups_id": [ ( - 6, - 0, - [ - cls.env.ref( - "shopinvader_api_security_sale.shopinvader_sale_user_group" - ).id, - ], + Command.set( + [ + cls.env.ref( + "shopinvader_api_security_sale.shopinvader_sale_user_group" + ).id, + ] + ) ) ], } @@ -64,11 +65,37 @@ def setUpClass(cls) -> None: } ) + cls.quotation = cls.env["sale.order"].create( + { + "partner_id": cls.default_fastapi_authenticated_partner.id, + "quotation_state": "waiting_acceptation", + "order_line": [ + Command.create( + { + "product_id": cls.product_1.id, + "product_uom_qty": 1.0, + } + ), + Command.create( + { + "product_id": cls.product_2.id, + "product_uom_qty": 2.0, + } + ), + ], + } + ) + + def test_default_typology(self): + self.assertEqual(self.quotation.typology, "quote") + def test_search_quotations(self): + # This one should not be returned as this is a "sale" and not a "quote" self.env["sale.order"].create( { "partner_id": self.default_fastapi_authenticated_partner.id, "quotation_state": "waiting_acceptation", + "typology": "sale", } ) with self._create_test_client() as test_client: @@ -77,16 +104,10 @@ def test_search_quotations(self): self.assertEqual(response.json()["count"], 1) def test_get_quotation(self): - sale = self.env["sale.order"].create( - { - "partner_id": self.default_fastapi_authenticated_partner.id, - "quotation_state": "waiting_acceptation", - } - ) with self._create_test_client() as test_client: - response: Response = test_client.get(f"/quotations/{sale.id}") + response: Response = test_client.get(f"/quotations/{self.quotation.id}") self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json()["name"], sale.name) + self.assertEqual(response.json()["name"], self.quotation.name) def test_confirm_quotation(self): quotation = self.env["sale.order"].create( @@ -112,13 +133,18 @@ def test_request_quotation(self): self.assertEqual(response_json["typology"], "sale") def test_update_quotation(self): - data = {"client_order_ref": "PO_123123"} - quotation = self.env["sale.order"].create( - { - "partner_id": self.default_fastapi_authenticated_partner.id, - "quotation_state": "waiting_acceptation", - } - ) + quotation = self.quotation + data = { + "client_order_ref": "PO_123123", + "lines": [ + { + "line_id": quotation.order_line[0].id, + "sequence": 42, + "product_id": self.product_2.id, + "quantity": 123, + } + ], + } with self._create_test_client() as test_client: response: Response = test_client.post( f"/quotations/{quotation.id}", json=data @@ -130,4 +156,213 @@ def test_update_quotation(self): ) response_json = response.json() self.assertEqual(response_json["id"], quotation.id) - self.assertEqual(response_json["client_order_ref"], "PO_123123") + self.assertEqual(quotation.client_order_ref, "PO_123123") + self.assertEqual(quotation.order_line[0].product_id, self.product_2) + self.assertEqual(quotation.order_line[0].product_uom_qty, 123) + self.assertEqual(quotation.order_line[0].sequence, 42) + + def test_create_quotation(self): + data = { + "name": "Test Quotation", + "typology": "quote", + "client_order_ref": "PO_12345", + "lines": [ + { + "product_id": self.product_1.id, + "quantity": 1.0, + }, + { + "product_id": self.product_2.id, + "quantity": 2.0, + }, + ], + } + + with self._create_test_client() as test_client: + response: Response = test_client.post( + "/quotations/create", content=json.dumps(data) + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response_json = response.json() + self.assertIn("id", response_json) + self.assertEqual(response_json["name"], "Test Quotation") + self.assertEqual(response_json["typology"], "quote") + self.assertEqual(response_json["client_order_ref"], "PO_12345") + + created_quotation = self.env["sale.order"].browse(response_json["id"]) + self.assertTrue(created_quotation.exists()) + self.assertEqual(created_quotation.name, "Test Quotation") + self.assertEqual( + created_quotation.partner_id.id, + self.default_fastapi_authenticated_partner.id, + ) + self.assertEqual(len(created_quotation.order_line), 2) + self.assertEqual( + created_quotation.order_line[0].product_id.id, self.product_1.id + ) + self.assertEqual(created_quotation.order_line[0].product_uom_qty, 1.0) + self.assertEqual( + created_quotation.order_line[1].product_id.id, self.product_2.id + ) + self.assertEqual(created_quotation.order_line[1].product_uom_qty, 2.0) + + def test_add_quotation_line(self): + quotation = self.quotation + + data = { + "product_id": self.product_1.id, + "quantity": 3.0, + } + + with self._create_test_client() as test_client: + response: Response = test_client.post( + f"/quotations/{quotation.id}/add_line", content=json.dumps(data) + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + response_json = response.json() + self.assertEqual(response_json["id"], quotation.id) + + self.assertEqual(len(quotation.order_line), 3) + self.assertEqual(quotation.order_line[-1].product_id.id, self.product_1.id) + self.assertEqual(quotation.order_line[-1].product_uom_qty, 3.0) + + def test_add_quotation_lines(self): + quotation = self.quotation + + data = { + "lines": [ + { + "product_id": self.product_1.id, + "quantity": 3.0, + }, + { + "product_id": self.product_2.id, + "quantity": 4.0, + }, + ] + } + + with self._create_test_client() as test_client: + response: Response = test_client.post( + f"/quotations/{quotation.id}/add_lines", content=json.dumps(data) + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + response_json = response.json() + self.assertEqual(response_json["id"], quotation.id) + + self.assertEqual(len(quotation.order_line), 4) + self.assertEqual(quotation.order_line[-2].product_id.id, self.product_1.id) + self.assertEqual(quotation.order_line[-2].product_uom_qty, 3.0) + self.assertEqual(quotation.order_line[-1].product_id.id, self.product_2.id) + self.assertEqual(quotation.order_line[-1].product_uom_qty, 4.0) + + def test_update_quotation_line(self): + quotation = self.quotation + line_id = quotation.order_line[0].id + data = { + "line_id": line_id, + "quantity": 42.0, + "product_id": self.product_2.id, + "sequence": 42, + } + + with self._create_test_client() as test_client: + response: Response = test_client.put( + f"/quotations/{quotation.id}/update_line", content=json.dumps(data) + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_json = response.json() + self.assertEqual(response_json["id"], quotation.id) + + self.assertEqual(len(quotation.order_line), 2) + self.assertEqual(quotation.order_line[0].id, line_id) + self.assertEqual(quotation.order_line[0].product_uom_qty, 42) + self.assertEqual(quotation.order_line[0].product_id.id, self.product_2.id) + self.assertEqual(quotation.order_line[0].sequence, 42) + + def test_update_quotation_lines(self): + quotation = self.quotation + data = { + "lines": [ + { + "line_id": quotation.order_line[0].id, + "quantity": 10, + "product_id": self.product_2.id, + "sequence": 10, + }, + { + "line_id": quotation.order_line[1].id, + "quantity": 20, + "product_id": self.product_1.id, + "sequence": 20, + }, + ] + } + + with self._create_test_client() as test_client: + response: Response = test_client.put( + f"/quotations/{quotation.id}/update_lines", content=json.dumps(data) + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_json = response.json() + self.assertEqual(response_json["id"], quotation.id) + + self.assertEqual(len(quotation.order_line), 2) + + self.assertEqual(quotation.order_line[0].id, data["lines"][0]["line_id"]) + self.assertEqual(quotation.order_line[0].product_uom_qty, 10) + self.assertEqual(quotation.order_line[0].product_id.id, self.product_2.id) + self.assertEqual(quotation.order_line[0].sequence, 10) + + self.assertEqual(quotation.order_line[1].id, data["lines"][1]["line_id"]) + self.assertEqual(quotation.order_line[1].product_uom_qty, 20) + self.assertEqual(quotation.order_line[1].product_id.id, self.product_1.id) + self.assertEqual(quotation.order_line[1].sequence, 20) + + def test_delete_quotation_line(self): + quotation = self.quotation + line_id = quotation.order_line[0].id + data = { + "line_id": line_id, + } + + with self._create_test_client() as test_client: + response: Response = test_client.post( + f"/quotations/{quotation.id}/delete_line", content=json.dumps(data) + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_json = response.json() + self.assertEqual(response_json["id"], quotation.id) + + self.assertEqual(len(quotation.order_line), 1) + + def test_delete_quotation_lines(self): + quotation = self.quotation + data = { + "lines": [ + { + "line_id": quotation.order_line[0].id, + }, + { + "line_id": quotation.order_line[1].id, + }, + ] + } + + with self._create_test_client() as test_client: + response: Response = test_client.post( + f"/quotations/{quotation.id}/delete_lines", content=json.dumps(data) + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_json = response.json() + self.assertEqual(response_json["id"], quotation.id) + + self.assertEqual(len(quotation.order_line), 0) From 2584403f202777bed09e774b6527887bde8befcd Mon Sep 17 00:00:00 2001 From: Nicolas Delbovier Date: Wed, 21 May 2025 10:24:54 +0200 Subject: [PATCH 08/44] [ADD] shopinvader_api_cart_quotation: add a module to isolate the logic of transforming a cart to a quotation. --- .../addons/shopinvader_api_cart_quotation | 1 + setup/shopinvader_api_cart_quotation/setup.py | 6 + shopinvader_api_cart_quotation/README.rst | 60 +++ shopinvader_api_cart_quotation/__init__.py | 1 + .../__manifest__.py | 14 + .../i18n/shopinvader_api_cart_quotation.pot | 15 + .../readme/CONTRIBUTORS.rst | 1 + .../readme/DESCRIPTION.rst | 1 + .../routers/__init__.py | 1 + .../routers/cart.py | 8 +- .../static/description/index.html | 416 ++++++++++++++++++ .../tests/__init__.py | 1 + .../test_shopinvader_api_cart_quotation.py | 59 +++ shopinvader_api_quotation/routers/__init__.py | 1 - .../tests/test_shopinvader_api_quotation.py | 13 +- 15 files changed, 581 insertions(+), 17 deletions(-) create mode 120000 setup/shopinvader_api_cart_quotation/odoo/addons/shopinvader_api_cart_quotation create mode 100644 setup/shopinvader_api_cart_quotation/setup.py create mode 100644 shopinvader_api_cart_quotation/README.rst create mode 100644 shopinvader_api_cart_quotation/__init__.py create mode 100644 shopinvader_api_cart_quotation/__manifest__.py create mode 100644 shopinvader_api_cart_quotation/i18n/shopinvader_api_cart_quotation.pot create mode 100644 shopinvader_api_cart_quotation/readme/CONTRIBUTORS.rst create mode 100644 shopinvader_api_cart_quotation/readme/DESCRIPTION.rst create mode 100644 shopinvader_api_cart_quotation/routers/__init__.py rename {shopinvader_api_quotation => shopinvader_api_cart_quotation}/routers/cart.py (83%) create mode 100644 shopinvader_api_cart_quotation/static/description/index.html create mode 100644 shopinvader_api_cart_quotation/tests/__init__.py create mode 100644 shopinvader_api_cart_quotation/tests/test_shopinvader_api_cart_quotation.py diff --git a/setup/shopinvader_api_cart_quotation/odoo/addons/shopinvader_api_cart_quotation b/setup/shopinvader_api_cart_quotation/odoo/addons/shopinvader_api_cart_quotation new file mode 120000 index 0000000000..b04403464c --- /dev/null +++ b/setup/shopinvader_api_cart_quotation/odoo/addons/shopinvader_api_cart_quotation @@ -0,0 +1 @@ +../../../../shopinvader_api_cart_quotation \ No newline at end of file diff --git a/setup/shopinvader_api_cart_quotation/setup.py b/setup/shopinvader_api_cart_quotation/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopinvader_api_cart_quotation/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shopinvader_api_cart_quotation/README.rst b/shopinvader_api_cart_quotation/README.rst new file mode 100644 index 0000000000..4c009f5c1e --- /dev/null +++ b/shopinvader_api_cart_quotation/README.rst @@ -0,0 +1,60 @@ +============================== +Shopinvader Api Cart Quotation +============================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:34a803016d1c4168229e05a2f73933ab0457957f9985ea347e670a1cc816825a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-shopinvader%2Fodoo--shopinvader-lightgray.png?logo=github + :target: https://github.com/shopinvader/odoo-shopinvader/tree/16.0/shopinvader_api_cart_quotation + :alt: shopinvader/odoo-shopinvader + +|badge1| |badge2| |badge3| + +This module adds a REST API for shopinvader to manage the transformation of carts into quotations. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* Nicolas DELBOVIER + +Maintainers +~~~~~~~~~~~ + +This module is part of the `shopinvader/odoo-shopinvader `_ project on GitHub. + +You are welcome to contribute. diff --git a/shopinvader_api_cart_quotation/__init__.py b/shopinvader_api_cart_quotation/__init__.py new file mode 100644 index 0000000000..62a5d54f85 --- /dev/null +++ b/shopinvader_api_cart_quotation/__init__.py @@ -0,0 +1 @@ +from . import routers diff --git a/shopinvader_api_cart_quotation/__manifest__.py b/shopinvader_api_cart_quotation/__manifest__.py new file mode 100644 index 0000000000..d7211cc3d2 --- /dev/null +++ b/shopinvader_api_cart_quotation/__manifest__.py @@ -0,0 +1,14 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Shopinvader Api Cart Quotation", + "summary": """cart-to-quote management module""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV", + "website": "https://github.com/shopinvader/odoo-shopinvader", + "depends": ["shopinvader_api_quotation", "sale_cart_quotation"], + "data": [], + "demo": [], +} diff --git a/shopinvader_api_cart_quotation/i18n/shopinvader_api_cart_quotation.pot b/shopinvader_api_cart_quotation/i18n/shopinvader_api_cart_quotation.pot new file mode 100644 index 0000000000..80562d8f3d --- /dev/null +++ b/shopinvader_api_cart_quotation/i18n/shopinvader_api_cart_quotation.pot @@ -0,0 +1,15 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-21 06:38+0000\n" +"PO-Revision-Date: 2025-05-21 06:38+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" diff --git a/shopinvader_api_cart_quotation/readme/CONTRIBUTORS.rst b/shopinvader_api_cart_quotation/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..eaed2d826a --- /dev/null +++ b/shopinvader_api_cart_quotation/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Nicolas DELBOVIER diff --git a/shopinvader_api_cart_quotation/readme/DESCRIPTION.rst b/shopinvader_api_cart_quotation/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..2d0e6e6cb4 --- /dev/null +++ b/shopinvader_api_cart_quotation/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +This module adds a REST API for shopinvader to manage the transformation of carts into quotations. \ No newline at end of file diff --git a/shopinvader_api_cart_quotation/routers/__init__.py b/shopinvader_api_cart_quotation/routers/__init__.py new file mode 100644 index 0000000000..7a2ac32694 --- /dev/null +++ b/shopinvader_api_cart_quotation/routers/__init__.py @@ -0,0 +1 @@ +from .cart import cart_quotation_router diff --git a/shopinvader_api_quotation/routers/cart.py b/shopinvader_api_cart_quotation/routers/cart.py similarity index 83% rename from shopinvader_api_quotation/routers/cart.py rename to shopinvader_api_cart_quotation/routers/cart.py index 64f27e2514..39db676035 100644 --- a/shopinvader_api_quotation/routers/cart.py +++ b/shopinvader_api_cart_quotation/routers/cart.py @@ -12,12 +12,12 @@ ) from odoo.addons.shopinvader_schema_sale.schemas.sale import Sale -quotation_cart_router = APIRouter(tags=["carts"]) +cart_quotation_router = APIRouter(tags=["carts"]) -@quotation_cart_router.post("/{uuid}/request_quotation") -@quotation_cart_router.post("/current/request_quotation") -@quotation_cart_router.post("/request_quotation") +@cart_quotation_router.post("/{uuid}/request_quotation") +@cart_quotation_router.post("/current/request_quotation") +@cart_quotation_router.post("/request_quotation") def request_quotation( env: Annotated[api.Environment, Depends(authenticated_partner_env)], partner: Annotated[ResPartner, Depends(authenticated_partner)], diff --git a/shopinvader_api_cart_quotation/static/description/index.html b/shopinvader_api_cart_quotation/static/description/index.html new file mode 100644 index 0000000000..5449f93f0d --- /dev/null +++ b/shopinvader_api_cart_quotation/static/description/index.html @@ -0,0 +1,416 @@ + + + + + +Shopinvader Api Cart Quotation + + + +
    +

    Shopinvader Api Cart Quotation

    + + +

    Beta License: AGPL-3 shopinvader/odoo-shopinvader

    +

    This module adds a REST API for shopinvader to manage the transformation of carts into quotations.

    +

    Table of contents

    + +
    +

    Bug Tracker

    +

    Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

    +

    Do not contact contributors directly about support or help with technical issues.

    +
    +
    +

    Credits

    +
    +

    Authors

    +
      +
    • ACSONE SA/NV
    • +
    +
    +
    +

    Contributors

    + +
    +
    +

    Maintainers

    +

    This module is part of the shopinvader/odoo-shopinvader project on GitHub.

    +

    You are welcome to contribute.

    +
    +
    +
    + + diff --git a/shopinvader_api_cart_quotation/tests/__init__.py b/shopinvader_api_cart_quotation/tests/__init__.py new file mode 100644 index 0000000000..2e8fee4862 --- /dev/null +++ b/shopinvader_api_cart_quotation/tests/__init__.py @@ -0,0 +1 @@ +from . import test_shopinvader_api_cart_quotation diff --git a/shopinvader_api_cart_quotation/tests/test_shopinvader_api_cart_quotation.py b/shopinvader_api_cart_quotation/tests/test_shopinvader_api_cart_quotation.py new file mode 100644 index 0000000000..789e4aa0a6 --- /dev/null +++ b/shopinvader_api_cart_quotation/tests/test_shopinvader_api_cart_quotation.py @@ -0,0 +1,59 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from fastapi import status +from requests import Response + +from odoo import Command +from odoo.tests.common import tagged + +from odoo.addons.extendable_fastapi.tests.common import FastAPITransactionCase + +from ..routers import cart_quotation_router + + +@tagged("post_install", "-at_install") +class TestCartQuotation(FastAPITransactionCase): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + + partner = cls.env["res.partner"].create({"name": "FastAPI Cart Demo"}) + + cls.user_no_rights = cls.env["res.users"].create( + { + "name": "Test User Without Rights", + "login": "user_no_rights", + "groups_id": [Command.set([])], + } + ) + user_with_rights = cls.env["res.users"].create( + { + "name": "Test User With Rights", + "login": "user_with_rights", + "groups_id": [ + ( + Command.set( + [ + cls.env.ref( + "shopinvader_api_security_sale.shopinvader_sale_user_group" + ).id, + ] + ) + ) + ], + } + ) + cls.default_fastapi_running_user = user_with_rights + cls.default_fastapi_authenticated_partner = partner.with_user(user_with_rights) + cls.default_fastapi_router = cart_quotation_router + + def test_request_quotation(self): + cart = self.env["sale.order"]._create_empty_cart( + self.default_fastapi_authenticated_partner.id + ) + with self._create_test_client() as test_client: + response: Response = test_client.post(f"/{cart.uuid}/request_quotation") + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_json = response.json() + self.assertEqual(response_json["uuid"], cart.uuid) + self.assertEqual(response_json["typology"], "sale") diff --git a/shopinvader_api_quotation/routers/__init__.py b/shopinvader_api_quotation/routers/__init__.py index 662b059b89..87ad64f4c0 100644 --- a/shopinvader_api_quotation/routers/__init__.py +++ b/shopinvader_api_quotation/routers/__init__.py @@ -1,2 +1 @@ from .quotation import quotation_router -from .cart import quotation_cart_router diff --git a/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py b/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py index e9dd08cb2e..c2ee811b96 100644 --- a/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py +++ b/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py @@ -9,7 +9,7 @@ from odoo.addons.extendable_fastapi.tests.common import FastAPITransactionCase -from ..routers import quotation_cart_router, quotation_router +from ..routers import quotation_router @tagged("post_install", "-at_install") @@ -121,17 +121,6 @@ def test_confirm_quotation(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()["id"], quotation.id) - def test_request_quotation(self): - cart = self.env["sale.order"]._create_empty_cart( - self.default_fastapi_authenticated_partner.id - ) - with self._create_test_client(router=quotation_cart_router) as test_client: - response: Response = test_client.post(f"/{cart.uuid}/request_quotation") - self.assertEqual(response.status_code, status.HTTP_200_OK) - response_json = response.json() - self.assertEqual(response_json["uuid"], cart.uuid) - self.assertEqual(response_json["typology"], "sale") - def test_update_quotation(self): quotation = self.quotation data = { From d26f28acbc611ea6701da408ae3ee77a75835660 Mon Sep 17 00:00:00 2001 From: Nicolas Delbovier Date: Tue, 3 Jun 2025 15:10:08 +0200 Subject: [PATCH 09/44] [IMP] sale_cart: add ondelete strategy on typology field --- sale_cart/models/sale_order.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sale_cart/models/sale_order.py b/sale_cart/models/sale_order.py index 96fde1d0bd..f1a00cd835 100644 --- a/sale_cart/models/sale_order.py +++ b/sale_cart/models/sale_order.py @@ -7,7 +7,12 @@ class SaleOrder(models.Model): _inherit = "sale.order" - typology = fields.Selection(selection_add=[("cart", "Cart")]) + typology = fields.Selection( + selection_add=[("cart", "Cart")], + ondelete={ + "cart": "cascade", + }, + ) def _confirm_cart(self): self.ensure_one() From 3fe01bd4ecad854caf0817c26d5b8c34443f51f1 Mon Sep 17 00:00:00 2001 From: Nicolas Delbovier Date: Tue, 3 Jun 2025 15:12:58 +0200 Subject: [PATCH 10/44] [IMP] sale_cart_quotation: add a button to trigger the "request quotation" action in the form view of sale.order + refine module description --- sale_cart_quotation/README.rst | 2 +- sale_cart_quotation/__manifest__.py | 3 ++- sale_cart_quotation/readme/DESCRIPTION.rst | 2 +- .../static/description/index.html | 2 +- sale_cart_quotation/views/sale_order.xml | 18 ++++++++++++++++++ 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/sale_cart_quotation/README.rst b/sale_cart_quotation/README.rst index fd6f19a665..4b21a22939 100644 --- a/sale_cart_quotation/README.rst +++ b/sale_cart_quotation/README.rst @@ -22,7 +22,7 @@ Sale Cart Quotation |badge1| |badge2| |badge3| -Glue module between sale_quotation and sale_cart +Glue module between sale_quotation and sale_cart, allowing users to request a quotation directly from a cart. **Table of contents** diff --git a/sale_cart_quotation/__manifest__.py b/sale_cart_quotation/__manifest__.py index b2a8636aaf..8e29bed16f 100644 --- a/sale_cart_quotation/__manifest__.py +++ b/sale_cart_quotation/__manifest__.py @@ -3,7 +3,8 @@ { "name": "Sale Cart Quotation", - "summary": """Glue module between sale_quotation and sale_cart""", + "summary": """Glue module between sale_quotation and sale_cart, + allowing users to request a quotation directly from a cart.""", "version": "16.0.1.0.0", "license": "AGPL-3", "author": "ACSONE SA/NV", diff --git a/sale_cart_quotation/readme/DESCRIPTION.rst b/sale_cart_quotation/readme/DESCRIPTION.rst index b8745a7bb0..9021b4ab73 100644 --- a/sale_cart_quotation/readme/DESCRIPTION.rst +++ b/sale_cart_quotation/readme/DESCRIPTION.rst @@ -1 +1 @@ -Glue module between sale_quotation and sale_cart \ No newline at end of file +Glue module between sale_quotation and sale_cart, allowing users to request a quotation directly from a cart. \ No newline at end of file diff --git a/sale_cart_quotation/static/description/index.html b/sale_cart_quotation/static/description/index.html index 779434cb6c..32c0a763ac 100644 --- a/sale_cart_quotation/static/description/index.html +++ b/sale_cart_quotation/static/description/index.html @@ -370,7 +370,7 @@

    Sale Cart Quotation

    !! source digest: sha256:092764e34a3532b13b87a0b07712f405be8de3df4ce5265a1c4f0640ea1f44f8 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

    Beta License: AGPL-3 shopinvader/odoo-shopinvader

    -

    Glue module between sale_quotation and sale_cart

    +

    Glue module between sale_quotation and sale_cart, allowing users to request a quotation directly from a cart.

    Table of contents

      diff --git a/sale_cart_quotation/views/sale_order.xml b/sale_cart_quotation/views/sale_order.xml index 945ebf92cc..8f5694e129 100644 --- a/sale_cart_quotation/views/sale_order.xml +++ b/sale_cart_quotation/views/sale_order.xml @@ -9,4 +9,22 @@ [('typology', '=', 'quote')] + + + sale.cart.quotation.sale.order.form + sale.order + + + +
    {"invisible": [("state", "in", ("draft", "sent", "cancel"))]} + + + { + "use_quotation_confirm_wizard": True, + } + + + + + { + "use_quotation_confirm_wizard": True, + } + + + diff --git a/sale_quotation/wizards/__init__.py b/sale_quotation/wizards/__init__.py new file mode 100644 index 0000000000..20badc62c6 --- /dev/null +++ b/sale_quotation/wizards/__init__.py @@ -0,0 +1 @@ +from . import sale_order_confirm_warning_wizard diff --git a/sale_quotation/wizards/sale_order_confirm_warning_wizard.py b/sale_quotation/wizards/sale_order_confirm_warning_wizard.py new file mode 100644 index 0000000000..3e4cecacaa --- /dev/null +++ b/sale_quotation/wizards/sale_order_confirm_warning_wizard.py @@ -0,0 +1,18 @@ +# wizards/sale_order_confirm_warning_wizard.py +from odoo import fields, models + + +class SaleOrderConfirmWarningWizard(models.TransientModel): + _name = "sale.order.confirm.warning.wizard" + _description = "Sale Order Confirmation Warning Wizard" + + sale_order_ids = fields.Many2many("sale.order", string="Sale Orders", required=True) + message = fields.Char(readonly=True) + + def confirm_and_proceed(self): + for order in self.sale_order_ids: + order.action_confirm_quotation() + self.sale_order_ids.with_context( + use_quotation_confirm_wizard=False + ).action_confirm() + return {"type": "ir.actions.act_window_close"} diff --git a/sale_quotation/wizards/sale_order_wizard_views.xml b/sale_quotation/wizards/sale_order_wizard_views.xml new file mode 100644 index 0000000000..3aeffc69dc --- /dev/null +++ b/sale_quotation/wizards/sale_order_wizard_views.xml @@ -0,0 +1,25 @@ + + + + sale.order.confirm.warning.wizard.form + sale.order.confirm.warning.wizard + +
    +
    +

    + +

    +
    +
    +
    +
    +
    +
    +
    diff --git a/sale_typology/tests/__init__.py b/sale_typology/tests/__init__.py deleted file mode 100644 index f357e9a2cf..0000000000 --- a/sale_typology/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import test_sale_typology diff --git a/sale_typology/tests/test_sale_typology.py b/sale_typology/tests/test_sale_typology.py deleted file mode 100644 index 5fb8d25675..0000000000 --- a/sale_typology/tests/test_sale_typology.py +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright 2025 ACSONE SA/NV -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo import Command -from odoo.tests.common import TransactionCase - - -class TestSaleCart(TransactionCase): - @classmethod - def setUpClass(cls): - super(TestSaleCart, cls).setUpClass() - cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) - cls.product = cls.env["product.product"].create( - { - "name": "product", - "uom_id": cls.env.ref("uom.product_uom_unit").id, - } - ) - cls.partner = cls.env["res.partner"].create({"name": "partner"}) - cls.sale_order = cls.env["sale.order"].create( - { - "partner_id": cls.partner.id, - "order_line": [ - Command.create({"product_id": cls.product.id, "product_uom_qty": 1}) - ], - } - ) - - def test_typology_default(self): - self.assertEqual(self.sale_order.typology, "sale") From 11f5de7100641754d37332b17acacc641da10f71 Mon Sep 17 00:00:00 2001 From: Nicolas Delbovier Date: Tue, 8 Jul 2025 15:11:59 +0200 Subject: [PATCH 16/44] [IMP] sale_quotation: improve UI of the sale_order form. * add a button to accept the quotation * add some margin to the workflow text * Show quotation_state in the title instead of messing with the status bar --- sale_quotation/views/sale_view.xml | 54 ++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/sale_quotation/views/sale_view.xml b/sale_quotation/views/sale_view.xml index f83cf78457..4ca9222aa3 100644 --- a/sale_quotation/views/sale_view.xml +++ b/sale_quotation/views/sale_view.xml @@ -9,15 +9,46 @@ sale.order - - + + + + + + + + + 1 + + + + + + () + + + + + + +
    -
    - - {"invisible": [("state", "in", ("draft", "sent", "cancel"))]} - + Date: Wed, 9 Jul 2025 12:03:52 +0200 Subject: [PATCH 17/44] [IMP] sale_quotation: rename method for clarity. Changes `action_confirm_quotation` to `action_accept_quotation`. --- sale_quotation/models/sale_order.py | 4 ++-- sale_quotation/tests/test_quotation.py | 2 +- sale_quotation/views/sale_view.xml | 4 ++-- .../wizards/sale_order_confirm_warning_wizard.py | 2 +- shopinvader_api_quotation/dependencies.py | 8 ++++---- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/sale_quotation/models/sale_order.py b/sale_quotation/models/sale_order.py index 612825b2b7..1890782bbf 100644 --- a/sale_quotation/models/sale_order.py +++ b/sale_quotation/models/sale_order.py @@ -52,7 +52,7 @@ def _compute_quotation_state(self): elif record.state == "sale": record.quotation_state = "accepted" - def action_confirm_quotation(self): + def action_accept_quotation(self): self.quotation_state = "accepted" self.typology = "sale" @@ -78,7 +78,7 @@ def action_confirm(self): }, } else: - self.action_confirm_quotation() + self.action_accept_quotation() return super(SaleOrder, self).action_confirm() def action_draft(self): diff --git a/sale_quotation/tests/test_quotation.py b/sale_quotation/tests/test_quotation.py index 2b6ca6ad33..9ed7f3e425 100644 --- a/sale_quotation/tests/test_quotation.py +++ b/sale_quotation/tests/test_quotation.py @@ -32,7 +32,7 @@ def test_send_draft_quotation(self): def test_confirm_quotation(self): self.so.action_quotation_sent() - self.so.action_confirm_quotation() + self.so.action_accept_quotation() self.assertEqual(self.so.quotation_state, "accepted") self.assertEqual(self.so.typology, "sale") diff --git a/sale_quotation/views/sale_view.xml b/sale_quotation/views/sale_view.xml index 4ca9222aa3..9683b1be25 100644 --- a/sale_quotation/views/sale_view.xml +++ b/sale_quotation/views/sale_view.xml @@ -36,8 +36,8 @@ - -
    -
    -
    + + + SaleOrder: vals["partner_id"] = self.partner.id # Enforce quote typology, correcting changes from other addons. - vals["typology"] = "quote" + vals["use_customer_quotation_workflow"] = True if "lines" in vals: vals["order_line"] = [ diff --git a/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py b/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py index 938772143c..17034ec028 100644 --- a/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py +++ b/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py @@ -69,7 +69,8 @@ def setUpClass(cls) -> None: cls.quotation = cls.env["sale.order"].create( { "partner_id": cls.default_fastapi_authenticated_partner.id, - "quotation_state": "waiting_acceptation", + "use_customer_quotation_workflow": True, + "quotation_state": "draft", "order_line": [ Command.create( { @@ -95,8 +96,6 @@ def test_search_quotations(self): self.env["sale.order"].create( { "partner_id": self.default_fastapi_authenticated_partner.id, - "quotation_state": "waiting_acceptation", - "typology": "sale", } ) with self._create_test_client() as test_client: From 16a420ca73fc3de689cc75b37dc2f8f863da2205 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 14 Jul 2025 09:36:51 +0200 Subject: [PATCH 21/44] [IMP] sale_quotation: Defines user's specific actions Add methods triggered by the cutomer to manage the requested quotation. Ensures that methods called by customer are allowed according the the quotation state --- sale_quotation/exceptions.py | 21 ++ sale_quotation/models/sale_order.py | 207 ++++++++++++++---- sale_quotation/tests/test_quotation.py | 117 +++++++++- sale_quotation/views/sale_view.xml | 43 +++- .../sale_order_confirm_warning_wizard.py | 2 +- 5 files changed, 339 insertions(+), 51 deletions(-) create mode 100644 sale_quotation/exceptions.py diff --git a/sale_quotation/exceptions.py b/sale_quotation/exceptions.py new file mode 100644 index 0000000000..633ecc1da6 --- /dev/null +++ b/sale_quotation/exceptions.py @@ -0,0 +1,21 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import _ +from odoo.exceptions import UserError + + +class InvalidQuotationStateError(UserError): + """Exception raised when an operation is attempted on a quotation + that is not in the expected state. + """ + + def __init__(self, env, action, expected_states, current_state): + self.env = env + message = _( + "Cannot perform '%(action)s' on quotation in state '%(current_state)s'. " + "Expected states: '%(expected_states)s'.", + action=action, + expected_states=", ".join(expected_states), + current_state=current_state, + ) + super().__init__(message) diff --git a/sale_quotation/models/sale_order.py b/sale_quotation/models/sale_order.py index 5cdb068299..73a7a5463b 100644 --- a/sale_quotation/models/sale_order.py +++ b/sale_quotation/models/sale_order.py @@ -10,6 +10,8 @@ from odoo.addons.sale.models.sale_order import READONLY_FIELD_STATES +from ..exceptions import InvalidQuotationStateError + class SaleOrder(models.Model): _inherit = "sale.order" @@ -42,6 +44,20 @@ class SaleOrder(models.Model): states=READONLY_FIELD_STATES, ) + is_action_customer_request_quotation_allowed = fields.Boolean( + compute="_compute_is_action_customer_request_quotation_allowed" + ) + + is_action_customer_accept_quotation_allowed = fields.Boolean( + compute="_compute_is_action_customer_accept_quotation_allowed" + ) + is_action_customer_reset_quotation_to_draft_allowed = fields.Boolean( + compute="_compute_is_action_customer_reset_quotation_to_draft_allowed" + ) + is_action_customer_cancel_quotation_allowed = fields.Boolean( + compute="_compute_is_action_customer_cancel_quotation_allowed" + ) + def _inverse_use_customer_quotation_workflow(self): """Set the typology to 'quote' when enabling the customer quotation workflow.""" for order in self: @@ -68,47 +84,164 @@ def _compute_quotation_state(self): elif record.state == "sale": record.quotation_state = "accepted" - def action_accept_quotation(self): + @api.depends("use_customer_quotation_workflow", "quotation_state") + def _compute_is_action_customer_request_quotation_allowed(self): + for order in self: + order.is_action_customer_request_quotation_allowed = ( + order._check_customer_action_allowed( + "request_quotation", raise_exception=False + ) + ) + + @api.depends("use_customer_quotation_workflow", "quotation_state") + def _compute_is_action_customer_accept_quotation_allowed(self): + for order in self: + order.is_action_customer_accept_quotation_allowed = ( + order._check_customer_action_allowed( + "accept_quotation", raise_exception=False + ) + ) + + @api.depends("use_customer_quotation_workflow", "quotation_state") + def _compute_is_action_customer_reset_quotation_to_draft_allowed(self): + for order in self: + order.is_action_customer_reset_quotation_to_draft_allowed = ( + order._check_customer_action_allowed( + "reset_to_draft", raise_exception=False + ) + ) + + @api.depends("use_customer_quotation_workflow", "quotation_state") + def _compute_is_action_customer_cancel_quotation_allowed(self): + for order in self: + order.is_action_customer_cancel_quotation_allowed = ( + order._check_customer_action_allowed( + "cancel_quotation", raise_exception=False + ) + ) + + @property + def _customer_actions_by_quotation_state(self): + """Customer actions available for each state of the quotation workflow.""" + return { + "draft": ["request_quotation"], + "customer_request": [ + "reset_to_draft", + "cancel_quotation", + "quotation_sent", + ], + "waiting_acceptation": [ + "reset_to_draft", + "cancel_quotation", + "accept_quotation", + ], + "cancel": ["reset_to_draft"], + "accepted": [], + } + + def _check_customer_action_allowed(self, action, raise_exception=True): + """Check if the action is allowed for the current quotation state.""" + for rec in self: + exception = None + if not rec.use_customer_quotation_workflow: + exception = UserError( + _("Customer quotation workflow is not enabled for this order.") + ) + elif action not in self._customer_actions_by_quotation_state.get( + rec.quotation_state, [] + ): + expected_states = [] + for ( + quotation_sate, + actions, + ) in self._customer_actions_by_quotation_state.items(): + if action in actions: + expected_states.append(quotation_sate) + exception = InvalidQuotationStateError( + self.env, + action=action, + expected_states=expected_states, + current_state=self.quotation_state, + ) + if exception: + if raise_exception: + raise exception + return False + return True + + def action_customer_request_quotation(self): + self._check_customer_action_allowed("request_quotation") + self.quotation_state = "customer_request" + return True + + def action_customer_accept_quotation(self): + self._check_customer_action_allowed("accept_quotation") self.quotation_state = "accepted" self.typology = "sale" + return self.with_context( + bypass_customer_quotation=True, + ).action_confirm() + + def action_customer_reset_quotation_to_draft(self): + self._check_customer_action_allowed("reset_to_draft") + self.quotation_state = "draft" + self.state = "draft" + self.filtered(lambda so: so.typology != "quote").typology = "quote" + return self.with_context( + bypass_customer_quotation=True, + ).action_draft() + + def action_customer_cancel_quotation(self): + self._check_customer_action_allowed("cancel_quotation") + self.quotation_state = "cancel" + return self.with_context( + disable_cancel_warning=True, + ).action_cancel() def action_confirm(self): - if ( - self.use_customer_quotation_workflow - and self.env.context.get("use_quotation_confirm_wizard") - and self.typology == "quote" - and any(rec.quotation_state != "waiting_acceptation" for rec in self) - ): - return { - "name": _("Confirm Sale Order"), - "type": "ir.actions.act_window", - "res_model": "sale.order.confirm.warning.wizard", - "views": [[False, "form"]], - "target": "new", - "context": { - "default_sale_order_ids": self.ids, - "default_message": _( - "The selected quotation(s) are not in 'Waiting Acceptation' " - "state. Are you sure you want to confirm them?" - ), - }, - } - else: - self.action_accept_quotation() - return super(SaleOrder, self).action_confirm() + other_quotations = self + if not self.env.context.get("bypass_customer_quotation", False): + customer_quotations = self.filtered("use_customer_quotation_workflow") + if ( + customer_quotations + and self.env.context.get("use_quotation_confirm_wizard") + and any(rec.quotation_state != "waiting_acceptation" for rec in self) + ): + return { + "name": _("Confirm Sale Order"), + "type": "ir.actions.act_window", + "res_model": "sale.order.confirm.warning.wizard", + "views": [[False, "form"]], + "target": "new", + "context": { + "default_sale_order_ids": self.ids, + "default_message": _( + "The selected quotation(s) are not in 'Waiting Acceptation' " + "state. Are you sure you want to confirm them?" + ), + }, + } + else: + customer_quotations.action_customer_accept_quotation() + return super(SaleOrder, other_quotations).action_confirm() def action_draft(self): - self.typology = "quote" - return super().action_draft() + other_quotations = self + if not self.env.context.get("bypass_customer_quotation", False): + customer_quotations = self.filtered("use_customer_quotation_workflow") + if customer_quotations: + self.action_customer_reset_quotation_to_draft() + return super(SaleOrder, other_quotations).action_draft() - def action_toggle_customer_quotation_workflow(self): - for order in self: - if order.state != "draft": - raise UserError( - _( - "Only sale orders in 'draft' state can toggle the quotation workflow." - ) - ) - order.use_customer_quotation_workflow = ( - not order.use_customer_quotation_workflow - ) + def action_cancel(self): + self.filtered( + lambda so: so.state in ("sale", "sent") + and so.use_customer_quotation_workflow + ).quotation_state = "cancel" + return super().action_cancel() + + def action_quotation_sent(self): + customer_quotations = self.filtered("use_customer_quotation_workflow") + if customer_quotations: + self._check_customer_action_allowed("quotation_sent") + return super().action_quotation_sent() diff --git a/sale_quotation/tests/test_quotation.py b/sale_quotation/tests/test_quotation.py index 9ed7f3e425..c1ec9c62ec 100644 --- a/sale_quotation/tests/test_quotation.py +++ b/sale_quotation/tests/test_quotation.py @@ -3,15 +3,17 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from odoo import Command -from odoo.exceptions import UserError +from odoo.addons.base.tests.common import DISABLED_MAIL_CONTEXT from odoo.tests.common import TransactionCase +from ..exceptions import InvalidQuotationStateError + class TestQuotation(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.env = cls.env(context=dict(cls.env.context, **DISABLED_MAIL_CONTEXT)) cls.product = cls.env["product.product"].create({"name": "Test Product"}) cls.partner = cls.env["res.partner"].create({"name": "John"}) cls.so = cls.env["sale.order"].create( @@ -27,12 +29,14 @@ def test_create_quotation(self): self.assertEqual(self.so.typology, "quote") def test_send_draft_quotation(self): + self.so.action_customer_request_quotation() self.so.action_quotation_sent() self.assertEqual(self.so.quotation_state, "waiting_acceptation") def test_confirm_quotation(self): + self.so.action_customer_request_quotation() self.so.action_quotation_sent() - self.so.action_accept_quotation() + self.so.action_customer_accept_quotation() self.assertEqual(self.so.quotation_state, "accepted") self.assertEqual(self.so.typology, "sale") @@ -49,8 +53,10 @@ def test_not_allowed_confirm_quotation(self): ) def test_convert_to_draft_resets_typology(self): + self.so.action_customer_request_quotation() self.so.action_quotation_sent() self.so.action_confirm() + self.so.action_cancel() self.so.action_draft() self.assertEqual(self.so.typology, "quote") @@ -66,11 +72,102 @@ def test_normal_so_workflow_not_broken(self): so.action_confirm() self.assertEqual(so.state, "sale") - def test_unable_to_change_workflow_when_confirmed(self): - so = self.so - self.assertEqual(so.state, "draft") + def test_action_customer_workflow(self): + """Test that the customer workflow actions are working as expected.""" + ######################## + # quotation state: draft + ######################## - so.action_confirm() - self.assertEqual(so.state, "sale") - with self.assertRaises(UserError): - so.action_toggle_customer_quotation_workflow() + self.assertEqual(self.so.quotation_state, "draft") + with self.assertRaises(InvalidQuotationStateError): + self.so.action_customer_accept_quotation() + + with self.assertRaises(InvalidQuotationStateError): + self.so.action_customer_reset_quotation_to_draft() + + with self.assertRaises(InvalidQuotationStateError): + self.so.action_customer_cancel_quotation() + + # in draft state we can: + self.so.action_customer_request_quotation() + self.assertEqual(self.so.quotation_state, "customer_request") + + ################################### + # quotation state: customer_request + ################################### + + with self.assertRaises(InvalidQuotationStateError): + self.so.action_customer_accept_quotation() + + # in customer_request state we can reset to draft: + self.so.action_customer_reset_quotation_to_draft() + self.assertEqual(self.so.quotation_state, "draft") + self.assertEqual(self.so.typology, "quote") + + # set in customer_request state again: + self.so.action_customer_request_quotation() + self.assertEqual(self.so.quotation_state, "customer_request") + + # in customer_request state we can cancel: + self.so.action_customer_cancel_quotation() + self.assertEqual(self.so.quotation_state, "cancel") + self.assertEqual(self.so.typology, "quote") + self.assertEqual(self.so.state, "cancel") + + # set in customer_request state again: + self.so.action_customer_reset_quotation_to_draft() + self.so.action_customer_request_quotation() + self.assertEqual(self.so.quotation_state, "customer_request") + + # customer_request state we can send to client: + self.so.action_quotation_sent() + self.assertEqual(self.so.quotation_state, "waiting_acceptation") + self.assertEqual(self.so.state, "sent") + + ###################################### + # quotation state: waiting_acceptation + ###################################### + with self.assertRaises(InvalidQuotationStateError): + self.so.action_quotation_sent() + + # in waiting_acceptation state customer can accept the quotation: + self.so.action_customer_accept_quotation() + self.assertEqual(self.so.quotation_state, "accepted") + self.assertEqual(self.so.typology, "sale") + + # reset to waiting_acceptation state: + self.so.action_cancel() + self.so.action_customer_reset_quotation_to_draft() + self.so.action_customer_request_quotation() + self.so.action_quotation_sent() + self.assertEqual(self.so.quotation_state, "waiting_acceptation") + self.assertEqual(self.so.typology, "quote") + + # in waiting_acceptation state customer can reset to draft: + self.so.action_customer_reset_quotation_to_draft() + self.assertEqual(self.so.quotation_state, "draft") + + # reset to waiting_acceptation state again: + self.so.action_customer_request_quotation() + self.so.action_quotation_sent() + self.assertEqual(self.so.quotation_state, "waiting_acceptation") + + # in waiting_acceptation state customer can cancel the quotation: + self.so.action_customer_cancel_quotation() + self.assertEqual(self.so.quotation_state, "cancel") + self.assertEqual(self.so.typology, "quote") + + ######################### + # quotation state: cancel + ######################### + with self.assertRaises(InvalidQuotationStateError): + self.so.action_customer_accept_quotation() + with self.assertRaises(InvalidQuotationStateError): + self.so.action_customer_request_quotation() + with self.assertRaises(InvalidQuotationStateError): + self.so.action_customer_cancel_quotation() + + # in cancel state we can reset to draft: + self.so.action_customer_reset_quotation_to_draft() + self.assertEqual(self.so.quotation_state, "draft") + self.assertEqual(self.so.typology, "quote") diff --git a/sale_quotation/views/sale_view.xml b/sale_quotation/views/sale_view.xml index 9b78979b29..4e89b931b7 100644 --- a/sale_quotation/views/sale_view.xml +++ b/sale_quotation/views/sale_view.xml @@ -35,12 +35,49 @@ diff --git a/sale_quotation/wizards/sale_order_confirm_warning_wizard.py b/sale_quotation/wizards/sale_order_confirm_warning_wizard.py index 3d5d542f46..86041eb44e 100644 --- a/sale_quotation/wizards/sale_order_confirm_warning_wizard.py +++ b/sale_quotation/wizards/sale_order_confirm_warning_wizard.py @@ -11,7 +11,7 @@ class SaleOrderConfirmWarningWizard(models.TransientModel): def confirm_and_proceed(self): for order in self.sale_order_ids: - order.action_accept_quotation() + order.action_customer_accept_quotation() self.sale_order_ids.with_context( use_quotation_confirm_wizard=False ).action_confirm() From 0131797a2aa26a8bfb180a47290e1a73f1793500 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 14 Jul 2025 09:38:06 +0200 Subject: [PATCH 22/44] [IMP] sale_cart_quotation: Uses new actions declared for customer use only --- sale_cart_quotation/models/sale_order.py | 3 ++- sale_cart_quotation/tests/test_quotation.py | 23 ++++++++++++++++++--- sale_cart_quotation/views/sale_order.xml | 2 +- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/sale_cart_quotation/models/sale_order.py b/sale_cart_quotation/models/sale_order.py index db6a150764..6ddcefe945 100644 --- a/sale_cart_quotation/models/sale_order.py +++ b/sale_cart_quotation/models/sale_order.py @@ -8,7 +8,7 @@ class SaleOrder(models.Model): _inherit = "sale.order" - def action_request_quotation(self): + def action_cart_request_quotation(self): if any(rec.state != "draft" or rec.typology != "cart" for rec in self): raise UserError( _( @@ -17,4 +17,5 @@ def action_request_quotation(self): ) ) self.write({"use_customer_quotation_workflow": True}) + self.action_customer_request_quotation() return True diff --git a/sale_cart_quotation/tests/test_quotation.py b/sale_cart_quotation/tests/test_quotation.py index 5739fcca50..7c4357ff78 100644 --- a/sale_cart_quotation/tests/test_quotation.py +++ b/sale_cart_quotation/tests/test_quotation.py @@ -22,11 +22,28 @@ def setUpClass(cls): def test_request_quotation(self): self.so.typology = "cart" - self.so.action_request_quotation() - self.assertEqual(self.so.quotation_state, "customer_request") + self.so.action_cart_request_quotation() + self.assertRecordValues( + self.so, + [ + { + "quotation_state": "customer_request", + "typology": "quote", + } + ], + ) def test_send_requested_quotation(self): self.so.typology = "cart" - self.so.action_request_quotation() + self.so.action_cart_request_quotation() self.so.action_quotation_sent() + self.assertRecordValues( + self.so, + [ + { + "quotation_state": "waiting_acceptation", + "typology": "quote", + } + ], + ) self.assertEqual(self.so.quotation_state, "waiting_acceptation") diff --git a/sale_cart_quotation/views/sale_order.xml b/sale_cart_quotation/views/sale_order.xml index 6f0db96f79..0fedb28780 100644 --- a/sale_cart_quotation/views/sale_order.xml +++ b/sale_cart_quotation/views/sale_order.xml @@ -10,7 +10,7 @@ Date: Wed, 16 Jul 2025 16:26:35 +0200 Subject: [PATCH 27/44] [IMP] sale_quotation: Improves UI --- sale_quotation/views/sale_view.xml | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/sale_quotation/views/sale_view.xml b/sale_quotation/views/sale_view.xml index e96bd17174..88fa6a1b11 100644 --- a/sale_quotation/views/sale_view.xml +++ b/sale_quotation/views/sale_view.xml @@ -63,19 +63,6 @@ - - - - - - - - - 1 - Date: Wed, 16 Jul 2025 16:38:23 +0200 Subject: [PATCH 28/44] [IMP] sale_quotation: Translations --- sale_quotation/i18n/fr.po | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/sale_quotation/i18n/fr.po b/sale_quotation/i18n/fr.po index fd6e559492..a8738918ba 100644 --- a/sale_quotation/i18n/fr.po +++ b/sale_quotation/i18n/fr.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 16.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-07-16 09:10+0000\n" -"PO-Revision-Date: 2025-07-16 09:10+0000\n" +"POT-Creation-Date: 2025-07-16 14:28+0000\n" +"PO-Revision-Date: 2025-07-16 14:28+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -97,7 +97,7 @@ msgstr "Client remet le devis à l'état brouillon" #: code:addons/sale_quotation/models/sale_order.py:0 #, python-format msgid "Customer quotation workflow is not enabled for this order." -msgstr "Le flux de travail de devis client n'est pas activé pour cette commande." +msgstr "Le mode devis par le client n'est pas activé pour cette commande." #. module: sale_quotation #: model_terms:ir.ui.view,arch_db:sale_quotation.sale_order_view_search_inherit_quotation @@ -118,7 +118,7 @@ msgstr "Brouillon" #. module: sale_quotation #: model:ir.model.fields,field_description:sale_quotation.field_sale_order_confirm_warning_wizard__id msgid "ID" -msgstr "ID" +msgstr "" #. module: sale_quotation #: model:ir.model.fields,field_description:sale_quotation.field_sale_order__is_action_customer_accept_quotation_allowed @@ -158,7 +158,7 @@ msgstr "Dernière mise à jour le" #. module: sale_quotation #: model:ir.model.fields,field_description:sale_quotation.field_sale_order_confirm_warning_wizard__message msgid "Message" -msgstr "Message" +msgstr "" #. module: sale_quotation #: model:ir.model.fields,field_description:sale_quotation.field_sale_order__quotation_state @@ -199,7 +199,7 @@ msgid "" "The selected quotation(s) are not in 'Waiting Acceptation' state. Are you " "sure you want to confirm them?" msgstr "" -"Le(s) devis sélectionné(s) ne sont pas dans l'état 'En attente d'acceptation'. " +"Le(s) devis sélectionné(s) n'est/ne sont pas dans l'état 'En attente d'acceptation'." "Êtes-vous sûr de vouloir les confirmer ?" #. module: sale_quotation From 8833a15ac4e505871c22cfdca3b35b58dc7422f4 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Wed, 23 Jul 2025 11:31:08 +0200 Subject: [PATCH 29/44] [IMP] shopinvader_api_quotation: No download while the quotation is not sent to the customer --- shopinvader_api_quotation/dependencies.py | 13 +++++++++ .../tests/test_shopinvader_api_quotation.py | 29 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/shopinvader_api_quotation/dependencies.py b/shopinvader_api_quotation/dependencies.py index 197f566957..eca7659ec7 100644 --- a/shopinvader_api_quotation/dependencies.py +++ b/shopinvader_api_quotation/dependencies.py @@ -248,6 +248,19 @@ def _delete_lines( quotation.write(vals) return quotation + def _get_pdf(self, record_id) -> tuple[str, bytes]: + quotation = self._get(record_id) + if quotation.quotation_state not in ( + "cancel", + "waiting_acceptation", + "accepted", + ): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Quotation cannot be downloaded before it is confirmed.", + ) + return super()._get_pdf(record_id) + def quotation_router_helper( env: Annotated[Environment, Depends(authenticated_partner_env)], diff --git a/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py b/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py index 8cd4298aa2..3f2b8a5d20 100644 --- a/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py +++ b/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py @@ -194,6 +194,35 @@ def test_create_quotation(self): ) self.assertEqual(created_quotation.order_line[1].product_uom_qty, 2.0) + def test_download_quotation_pdf(self): + quotation = self.quotation + + # in draft state, cannot download + with self._create_test_client() as test_client: + response: Response = test_client.get(f"/quotations/{quotation.id}/download") + + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + + quotation.action_customer_request_quotation() + + # in customer_request state, cannot download + with self._create_test_client() as test_client: + response: Response = test_client.get(f"/quotations/{quotation.id}/download") + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + + quotation.action_quotation_sent() + + # in waiting_acceptation state, can download + with self._create_test_client() as test_client: + response: Response = test_client.get(f"/quotations/{quotation.id}/download") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.headers["Content-Type"], "application/pdf") + self.assertTrue( + response.headers["Content-Disposition"].startswith("attachment;") + ) + self.assertTrue(response.headers["Content-Disposition"].endswith(".pdf")) + self.assertGreater(len(response.content), 0) + def test_add_quotation_line(self): quotation = self.quotation From ecb6cd9c12474a2953e961656345c03040458fd6 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Wed, 23 Jul 2025 11:32:08 +0200 Subject: [PATCH 30/44] [IMP] shopinvader_api_quotation: Quotation name is not editable. The customer can only fill the client ref as rederence. The name is reserved for Odoo --- shopinvader_api_quotation/schemas/sale.py | 1 - .../tests/test_shopinvader_api_quotation.py | 3 --- 2 files changed, 4 deletions(-) diff --git a/shopinvader_api_quotation/schemas/sale.py b/shopinvader_api_quotation/schemas/sale.py index 5543ce239e..e25970453c 100644 --- a/shopinvader_api_quotation/schemas/sale.py +++ b/shopinvader_api_quotation/schemas/sale.py @@ -26,7 +26,6 @@ class QuotationLineCreateRequest(StrictExtendableBaseModel, extra="ignore"): class QuotationCreateRequest(StrictExtendableBaseModel, extra="ignore"): - name: str lines: list[QuotationLineCreateRequest] | None = None client_order_ref: str | None = None diff --git a/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py b/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py index 3f2b8a5d20..ab2dc7100b 100644 --- a/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py +++ b/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py @@ -150,7 +150,6 @@ def test_update_quotation(self): def test_create_quotation(self): data = { - "name": "Test Quotation", "client_order_ref": "PO_12345", "lines": [ { @@ -173,13 +172,11 @@ def test_create_quotation(self): response_json = response.json() self.assertIn("id", response_json) - self.assertEqual(response_json["name"], "Test Quotation") self.assertEqual(response_json["typology"], "quote") self.assertEqual(response_json["client_order_ref"], "PO_12345") created_quotation = self.env["sale.order"].browse(response_json["id"]) self.assertTrue(created_quotation.exists()) - self.assertEqual(created_quotation.name, "Test Quotation") self.assertEqual( created_quotation.partner_id.id, self.default_fastapi_authenticated_partner.id, From 5ef4fe73c3d5723a5f5b18f4a0a6dc1e3639cbee Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Wed, 23 Jul 2025 12:24:50 +0200 Subject: [PATCH 31/44] [IMP]shopinvader_api_quotation: Refactor code to split sale and quotation schemas --- shopinvader_api_quotation/dependencies.py | 2 +- .../routers/quotation.py | 7 +-- shopinvader_api_quotation/schemas/__init__.py | 12 ++++- .../schemas/quotation.py | 49 ++++++++++++++++++ shopinvader_api_quotation/schemas/sale.py | 50 +------------------ 5 files changed, 67 insertions(+), 53 deletions(-) create mode 100644 shopinvader_api_quotation/schemas/quotation.py diff --git a/shopinvader_api_quotation/dependencies.py b/shopinvader_api_quotation/dependencies.py index eca7659ec7..925a7de2e5 100644 --- a/shopinvader_api_quotation/dependencies.py +++ b/shopinvader_api_quotation/dependencies.py @@ -16,7 +16,7 @@ from odoo.addons.sale.models.sale_order import SaleOrder from odoo.addons.sale_quotation.exceptions import InvalidQuotationStateError -from .schemas.sale import ( +from .schemas import ( QuotationAddLineRequest, QuotationCreateRequest, QuotationDeleteLineRequest, diff --git a/shopinvader_api_quotation/routers/quotation.py b/shopinvader_api_quotation/routers/quotation.py index 6e7b0afdb6..8107cea3be 100644 --- a/shopinvader_api_quotation/routers/quotation.py +++ b/shopinvader_api_quotation/routers/quotation.py @@ -8,15 +8,16 @@ from odoo.addons.extendable_fastapi.schemas import PagedCollection from odoo.addons.fastapi.dependencies import paging from odoo.addons.fastapi.schemas import Paging -from odoo.addons.shopinvader_schema_sale.schemas.sale import Sale, SaleSearch +from odoo.addons.shopinvader_schema_sale.schemas.sale import Sale from ..dependencies import ShopinvaderApiQuotationRouterHelper, quotation_router_helper -from ..schemas.sale import ( +from ..schemas import ( QuotationAddLineRequest, QuotationConfirmInput, QuotationCreateRequest, QuotationDeleteLineRequest, QuotationLines, + QuotationSearch, QuotationUpdateInput, QuotationUpdateLineRequest, ) @@ -48,7 +49,7 @@ def get( @quotation_router.get("/quotations", status_code=200) def search_quotation( - params: Annotated[SaleSearch, Depends()], + params: Annotated[QuotationSearch, Depends()], paging: Annotated[Paging, Depends(paging)], quotation_router_helper: Annotated[ ShopinvaderApiQuotationRouterHelper, Depends(quotation_router_helper) diff --git a/shopinvader_api_quotation/schemas/__init__.py b/shopinvader_api_quotation/schemas/__init__.py index 8a0dc04e1f..3f2daaa3cb 100644 --- a/shopinvader_api_quotation/schemas/__init__.py +++ b/shopinvader_api_quotation/schemas/__init__.py @@ -1 +1,11 @@ -from . import sale +from .sale import Sale +from .quotation import ( + QuotationState, + QuotationLines, + QuotationCreateRequest, + QuotationUpdateInput, + QuotationConfirmInput, + QuotationAddLineRequest, + QuotationUpdateLineRequest, + QuotationDeleteLineRequest, +) diff --git a/shopinvader_api_quotation/schemas/quotation.py b/shopinvader_api_quotation/schemas/quotation.py new file mode 100644 index 0000000000..eda064c866 --- /dev/null +++ b/shopinvader_api_quotation/schemas/quotation.py @@ -0,0 +1,49 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from typing import Generic, TypeVar + +from extendable_pydantic import StrictExtendableBaseModel + + +class QuotationConfirmInput(StrictExtendableBaseModel): + """Extend it if you need params for the confirmation""" + + +class QuotationLineCreateRequest(StrictExtendableBaseModel, extra="ignore"): + sequence: int | None = None + product_id: int + quantity: float + + +class QuotationCreateRequest(StrictExtendableBaseModel, extra="ignore"): + lines: list[QuotationLineCreateRequest] | None = None + client_order_ref: str | None = None + + +class QuotationAddLineRequest(StrictExtendableBaseModel, extra="ignore"): + sequence: int | None = None + product_id: int + quantity: float + + +class QuotationUpdateLineRequest(StrictExtendableBaseModel, extra="ignore"): + line_id: int + sequence: int | None = None + product_id: int + quantity: float + + +class QuotationDeleteLineRequest(StrictExtendableBaseModel, extra="ignore"): + line_id: int + + +T = TypeVar("T") + + +class QuotationLines(StrictExtendableBaseModel, Generic[T]): + lines: list[T] = [] + + +class QuotationUpdateInput(StrictExtendableBaseModel, extra="ignore"): + client_order_ref: str | None = None + lines: list[QuotationUpdateLineRequest | QuotationAddLineRequest] | None = None diff --git a/shopinvader_api_quotation/schemas/sale.py b/shopinvader_api_quotation/schemas/sale.py index e25970453c..ec1fe5ad96 100644 --- a/shopinvader_api_quotation/schemas/sale.py +++ b/shopinvader_api_quotation/schemas/sale.py @@ -1,7 +1,5 @@ -from typing import Generic, TypeVar - -from extendable_pydantic import StrictExtendableBaseModel - +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from odoo.addons.shopinvader_schema_sale.schemas.sale import Sale @@ -13,47 +11,3 @@ def from_sale_order(cls, odoo_rec): res = super().from_sale_order(odoo_rec) res.shop_only_quotation = odoo_rec.shop_only_quotation return res - - -class QuotationConfirmInput(StrictExtendableBaseModel): - """Extend it if you need params for the confirmation""" - - -class QuotationLineCreateRequest(StrictExtendableBaseModel, extra="ignore"): - sequence: int | None = None - product_id: int - quantity: float - - -class QuotationCreateRequest(StrictExtendableBaseModel, extra="ignore"): - lines: list[QuotationLineCreateRequest] | None = None - client_order_ref: str | None = None - - -class QuotationAddLineRequest(StrictExtendableBaseModel, extra="ignore"): - sequence: int | None = None - product_id: int - quantity: float - - -class QuotationUpdateLineRequest(StrictExtendableBaseModel, extra="ignore"): - line_id: int - sequence: int | None = None - product_id: int - quantity: float - - -class QuotationDeleteLineRequest(StrictExtendableBaseModel, extra="ignore"): - line_id: int - - -T = TypeVar("T") - - -class QuotationLines(StrictExtendableBaseModel, Generic[T]): - lines: list[T] = [] - - -class QuotationUpdateInput(StrictExtendableBaseModel, extra="ignore"): - client_order_ref: str | None = None - lines: list[QuotationUpdateLineRequest | QuotationAddLineRequest] | None = None From c7e5ca3833fbdfdccf13cc96da49052f2136ed19 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Wed, 23 Jul 2025 12:29:14 +0200 Subject: [PATCH 32/44] [IMP] shopinvader_api_quotation: Add search params Allows to search on quotation_state and client_order_ref --- shopinvader_api_quotation/schemas/__init__.py | 1 + .../schemas/quotation.py | 35 ++++++++++++++++++- .../tests/test_shopinvader_api_quotation.py | 17 +++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/shopinvader_api_quotation/schemas/__init__.py b/shopinvader_api_quotation/schemas/__init__.py index 3f2daaa3cb..4996c195e2 100644 --- a/shopinvader_api_quotation/schemas/__init__.py +++ b/shopinvader_api_quotation/schemas/__init__.py @@ -1,6 +1,7 @@ from .sale import Sale from .quotation import ( QuotationState, + QuotationSearch, QuotationLines, QuotationCreateRequest, QuotationUpdateInput, diff --git a/shopinvader_api_quotation/schemas/quotation.py b/shopinvader_api_quotation/schemas/quotation.py index eda064c866..5aca5185e7 100644 --- a/shopinvader_api_quotation/schemas/quotation.py +++ b/shopinvader_api_quotation/schemas/quotation.py @@ -1,8 +1,41 @@ # Copyright 2025 ACSONE SA/NV # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from typing import Generic, TypeVar +from enum import Enum +from typing import Annotated, Generic, TypeVar from extendable_pydantic import StrictExtendableBaseModel +from pydantic import Field + +from odoo.addons.shopinvader_schema_sale.schemas.sale import SaleSearch + + +class QuotationState(Enum): + cancel = "cancel" + draft = "draft" + customer_request = "customer_request" + waiting_acceptation = "waiting_acceptation" + accepted = "accepted" + + +class QuotationSearch(SaleSearch): + """Extend it if you need specific params for searching quotations""" + + client_order_ref: Annotated[ + str | None, + Field( + description="When used, the search look for any quotation oder " + "where client_order_ref contains the given value case insensitively." + ), + ] = None + quotation_state: QuotationState | None = None + + def to_odoo_domain(self, env): + domain = super().to_odoo_domain(env) + if self.client_order_ref: + domain.append(("client_order_ref", "ilike", self.client_order_ref)) + if self.quotation_state: + domain.append(("quotation_state", "=", self.quotation_state.value)) + return domain class QuotationConfirmInput(StrictExtendableBaseModel): diff --git a/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py b/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py index ab2dc7100b..5512b7ef07 100644 --- a/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py +++ b/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py @@ -11,6 +11,7 @@ from odoo.addons.extendable_fastapi.tests.common import FastAPITransactionCase from ..routers import quotation_router +from ..schemas import QuotationState @tagged("post_install", "-at_install") @@ -103,6 +104,22 @@ def test_search_quotations(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()["count"], 1) + with self._create_test_client() as test_client: + response: Response = test_client.get( + "/quotations", params={"quotation_state": QuotationState.draft.value} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_json = response.json() + self.assertEqual(response_json["count"], 1) + + with self._create_test_client() as test_client: + response: Response = test_client.get( + "/quotations", params={"quotation_state": QuotationState.accepted.value} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_json = response.json() + self.assertEqual(response_json["count"], 0) + def test_get_quotation(self): with self._create_test_client() as test_client: response: Response = test_client.get(f"/quotations/{self.quotation.id}") From 32d591caa686096a5746336f201ed4df0f1bb81f Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Wed, 23 Jul 2025 12:29:43 +0200 Subject: [PATCH 33/44] [IMP] shopinvader_api_quotation: Add the quotation_state into the sale info --- shopinvader_api_quotation/schemas/sale.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/shopinvader_api_quotation/schemas/sale.py b/shopinvader_api_quotation/schemas/sale.py index ec1fe5ad96..56ccb59045 100644 --- a/shopinvader_api_quotation/schemas/sale.py +++ b/shopinvader_api_quotation/schemas/sale.py @@ -2,12 +2,20 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from odoo.addons.shopinvader_schema_sale.schemas.sale import Sale +from .quotation import QuotationState + class Sale(Sale, extends=True): shop_only_quotation: bool | None = None + quotation_state: QuotationState | None = None @classmethod def from_sale_order(cls, odoo_rec): res = super().from_sale_order(odoo_rec) res.shop_only_quotation = odoo_rec.shop_only_quotation + res.quotation_state = ( + QuotationState(odoo_rec.quotation_state) + if odoo_rec.quotation_state + else None + ) return res From 3500a94c95faf5a34ed2b13d96ca8699b11f3f58 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Wed, 23 Jul 2025 13:48:23 +0200 Subject: [PATCH 34/44] [IMP] shopinvader_api_quotation: Implement partial update and add note --- shopinvader_api_quotation/dependencies.py | 71 +++++++++++-------- .../schemas/quotation.py | 4 +- .../tests/test_shopinvader_api_quotation.py | 40 +++++++++++ 3 files changed, 86 insertions(+), 29 deletions(-) diff --git a/shopinvader_api_quotation/dependencies.py b/shopinvader_api_quotation/dependencies.py index 925a7de2e5..eaa4ab0632 100644 --- a/shopinvader_api_quotation/dependencies.py +++ b/shopinvader_api_quotation/dependencies.py @@ -128,45 +128,60 @@ def _ensure_can_update(self, quotation: SaleOrder): def _update(self, quotation_id, data: QuotationUpdateInput) -> SaleOrder: quotation = self._get(quotation_id) self._ensure_can_update(quotation) - vals = {"client_order_ref": data.client_order_ref} - vals = self._modify_lines_vals(quotation, data.lines, vals) - - # remove any line that is in the quotation but does not appear in the request data - request_line_ids = { - line.line_id for line in data.lines if getattr(line, "line_id", None) - } - lines_to_remove_ids = [ - line.id for line in quotation.order_line if line.id not in request_line_ids - ] - for line_id in lines_to_remove_ids: - vals["order_line"].append(Command.unlink(line_id)) - - quotation.write(vals) + vals = self._quotation_update_to_vals(quotation, data) + if vals: + quotation.write(vals) return quotation def _create(self, rqst: QuotationCreateRequest) -> SaleOrder: - vals = rqst.model_dump() - vals["partner_id"] = self.partner.id - - # Enforce quote typology, correcting changes from other addons. - vals["use_customer_quotation_workflow"] = True + vals = self._quotation_create_to_vals(rqst) + quotation = self.env["sale.order"].create(vals) + return quotation - if "lines" in vals: + def _quotation_create_to_vals(self, data: QuotationCreateRequest) -> dict: + vals = {"use_customer_quotation_workflow": True, "partner_id": self.partner.id} + values = data.model_dump(exclude_unset=True) + if "client_order_ref" in values: + vals["client_order_ref"] = data.client_order_ref + if "note" in values: + vals["note"] = data.note + if "lines" in values: vals["order_line"] = [ Command.create( { - "product_id": line["product_id"], - "product_uom_qty": line["quantity"], + "product_id": line.product_id, + "product_uom_qty": line.quantity, } ) - # use "pop" so that "lines" does not stay in "vals" - # preventing an error in the "create" below - for line in vals.pop("lines") + for line in data.lines ] + return vals - quotation = self.env["sale.order"].create(vals) - - return quotation + def _quotation_update_to_vals( + self, quotation: SaleOrder, data: QuotationUpdateInput + ) -> dict: + vals = {} + values = data.model_dump(exclude_unset=True) + if "client_order_ref" in values: + vals["client_order_ref"] = data.client_order_ref + if "note" in values: + vals["note"] = data.note + + if "lines" in values: + vals = self._modify_lines_vals(quotation, data.lines, vals) + + # remove any line that is in the quotation but does not appear in the request data + request_line_ids = { + line.line_id for line in data.lines if getattr(line, "line_id", None) + } + lines_to_remove_ids = [ + line.id + for line in quotation.order_line + if line.id not in request_line_ids + ] + for line_id in lines_to_remove_ids: + vals["order_line"].append(Command.unlink(line_id)) + return vals def _modify_lines_vals( self, diff --git a/shopinvader_api_quotation/schemas/quotation.py b/shopinvader_api_quotation/schemas/quotation.py index 5aca5185e7..208458b599 100644 --- a/shopinvader_api_quotation/schemas/quotation.py +++ b/shopinvader_api_quotation/schemas/quotation.py @@ -49,8 +49,9 @@ class QuotationLineCreateRequest(StrictExtendableBaseModel, extra="ignore"): class QuotationCreateRequest(StrictExtendableBaseModel, extra="ignore"): - lines: list[QuotationLineCreateRequest] | None = None client_order_ref: str | None = None + note: str | None = None + lines: list[QuotationLineCreateRequest] | None = None class QuotationAddLineRequest(StrictExtendableBaseModel, extra="ignore"): @@ -79,4 +80,5 @@ class QuotationLines(StrictExtendableBaseModel, Generic[T]): class QuotationUpdateInput(StrictExtendableBaseModel, extra="ignore"): client_order_ref: str | None = None + note: str | None = None lines: list[QuotationUpdateLineRequest | QuotationAddLineRequest] | None = None diff --git a/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py b/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py index 5512b7ef07..d93a10b915 100644 --- a/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py +++ b/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py @@ -130,6 +130,7 @@ def test_update_quotation(self): quotation = self.quotation data = { "client_order_ref": "PO_123123", + "note": "This is a test note", "lines": [ { "line_id": quotation.order_line[0].id, @@ -160,14 +161,52 @@ def test_update_quotation(self): response_json = response.json() self.assertEqual(response_json["id"], quotation.id) self.assertEqual(quotation.client_order_ref, "PO_123123") + self.assertEqual(quotation.note, "

    This is a test note

    ") self.assertEqual(quotation.order_line[0].product_id, self.product_2) self.assertEqual(quotation.order_line[0].product_uom_qty, 123) self.assertEqual(quotation.order_line[0].sequence, 42) self.assertEqual(len(quotation.order_line), 3) + def test_partial_update_quotation(self): + quotation = self.quotation + self.assertEqual(len(quotation.order_line), 2) + data = { + "client_order_ref": "PO_123124", + "note": "This is a test note bis", + } + with self._create_test_client() as test_client: + response: Response = test_client.post( + f"/quotations/{quotation.id}", content=json.dumps(data) + ) + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + msg=f"error message: {response.text}", + ) + response_json = response.json() + self.assertEqual(response_json["id"], quotation.id) + self.assertEqual(quotation.client_order_ref, "PO_123124") + self.assertEqual(quotation.note, "

    This is a test note bis

    ") + self.assertEqual(len(quotation.order_line), 2) + + data = { + "lines": [], + } + with self._create_test_client() as test_client: + response: Response = test_client.post( + f"/quotations/{quotation.id}", content=json.dumps(data) + ) + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + msg=f"error message: {response.text}", + ) + self.assertEqual(len(quotation.order_line), 0) + def test_create_quotation(self): data = { "client_order_ref": "PO_12345", + "note": "This is a test note", "lines": [ { "product_id": self.product_1.id, @@ -191,6 +230,7 @@ def test_create_quotation(self): self.assertIn("id", response_json) self.assertEqual(response_json["typology"], "quote") self.assertEqual(response_json["client_order_ref"], "PO_12345") + self.assertEqual(response_json["note"], "

    This is a test note

    ") created_quotation = self.env["sale.order"].browse(response_json["id"]) self.assertTrue(created_quotation.exists()) From 6604c1c57fd84d945767bcfa387556f37b3330a8 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Tue, 29 Jul 2025 17:00:21 +0200 Subject: [PATCH 35/44] [FIX] sale_quotation, shopinvader_api_quotation: Add pot file complete pot files are required to get the translations working. Indeed, when a transation file is loaded by the polib, the entry is marked as obsolete if it's not present into the pot file. obsolete entries are not loaded by odoo --- sale_quotation/i18n/sale_quotation.pot | 204 +++++++++++++++++- .../i18n/shopinvader_api_quotation.pot | 29 ++- 2 files changed, 225 insertions(+), 8 deletions(-) diff --git a/sale_quotation/i18n/sale_quotation.pot b/sale_quotation/i18n/sale_quotation.pot index aad07b6837..a2f5267c06 100644 --- a/sale_quotation/i18n/sale_quotation.pot +++ b/sale_quotation/i18n/sale_quotation.pot @@ -1,15 +1,215 @@ # Translation of Odoo Server. # This file contains the translation of the following modules: +# * sale_quotation # msgid "" msgstr "" "Project-Id-Version: Odoo Server 16.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-05-16 13:39+0000\n" -"PO-Revision-Date: 2025-05-16 13:39+0000\n" +"POT-Creation-Date: 2025-07-29 14:48+0000\n" +"PO-Revision-Date: 2025-07-29 14:48+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" + +#. module: sale_quotation +#: model:ir.model.fields.selection,name:sale_quotation.selection__sale_order__quotation_state__accepted +#: model_terms:ir.ui.view,arch_db:sale_quotation.sale_order_view_search_inherit_quotation +msgid "Accepted" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields.selection,name:sale_quotation.selection__sale_order__quotation_state__cancel +#: model_terms:ir.ui.view,arch_db:sale_quotation.sale_order_confirm_warning_wizard_form_view +#: model_terms:ir.ui.view,arch_db:sale_quotation.sale_order_view_search_inherit_quotation +msgid "Cancel" +msgstr "" + +#. module: sale_quotation +#. odoo-python +#: code:addons/sale_quotation/exceptions.py:0 +#, python-format +msgid "" +"Cannot perform '%(action)s' on quotation in state '%(current_state)s'. " +"Expected states: '%(expected_states)s'." +msgstr "" + +#. module: sale_quotation +#: model_terms:ir.ui.view,arch_db:sale_quotation.sale_order_confirm_warning_wizard_form_view +msgid "Confirm Anyway" +msgstr "" + +#. module: sale_quotation +#. odoo-python +#: code:addons/sale_quotation/models/sale_order.py:0 +#: model_terms:ir.ui.view,arch_db:sale_quotation.sale_order_confirm_warning_wizard_form_view +#, python-format +msgid "Confirm Sale Order" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields,field_description:sale_quotation.field_sale_order_confirm_warning_wizard__create_uid +msgid "Created by" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields,field_description:sale_quotation.field_sale_order_confirm_warning_wizard__create_date +msgid "Created on" +msgstr "" + +#. module: sale_quotation +#: model_terms:ir.ui.view,arch_db:sale_quotation.view_order_form +msgid "Customer Accept Quotation" +msgstr "" + +#. module: sale_quotation +#: model_terms:ir.ui.view,arch_db:sale_quotation.view_order_form +msgid "Customer Cancel Quotation" +msgstr "" + +#. module: sale_quotation +#: model_terms:ir.ui.view,arch_db:sale_quotation.sale_order_view_search_inherit_quotation +msgid "Customer Quotation" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields.selection,name:sale_quotation.selection__sale_order__quotation_state__customer_request +msgid "Customer Request" +msgstr "" + +#. module: sale_quotation +#: model_terms:ir.ui.view,arch_db:sale_quotation.view_order_form +msgid "Customer Request Quotation" +msgstr "" + +#. module: sale_quotation +#: model_terms:ir.ui.view,arch_db:sale_quotation.view_order_form +msgid "Customer Reset Quotation to Draft" +msgstr "" + +#. module: sale_quotation +#. odoo-python +#: code:addons/sale_quotation/models/sale_order.py:0 +#, python-format +msgid "Customer quotation workflow is not enabled for this order." +msgstr "" + +#. module: sale_quotation +#: model_terms:ir.ui.view,arch_db:sale_quotation.sale_order_view_search_inherit_quotation +msgid "Customer request" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields,field_description:sale_quotation.field_sale_order_confirm_warning_wizard__display_name +msgid "Display Name" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields.selection,name:sale_quotation.selection__sale_order__quotation_state__draft +#: model_terms:ir.ui.view,arch_db:sale_quotation.sale_order_view_search_inherit_quotation +msgid "Draft" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields,field_description:sale_quotation.field_sale_order_confirm_warning_wizard__id +msgid "ID" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields,field_description:sale_quotation.field_sale_order__is_action_customer_accept_quotation_allowed +msgid "Is Action Customer Accept Quotation Allowed" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields,field_description:sale_quotation.field_sale_order__is_action_customer_cancel_quotation_allowed +msgid "Is Action Customer Cancel Quotation Allowed" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields,field_description:sale_quotation.field_sale_order__is_action_customer_request_quotation_allowed +msgid "Is Action Customer Request Quotation Allowed" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields,field_description:sale_quotation.field_sale_order__is_action_customer_reset_quotation_to_draft_allowed +msgid "Is Action Customer Reset Quotation To Draft Allowed" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields,field_description:sale_quotation.field_sale_order_confirm_warning_wizard____last_update +msgid "Last Modified on" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields,field_description:sale_quotation.field_sale_order_confirm_warning_wizard__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields,field_description:sale_quotation.field_sale_order_confirm_warning_wizard__write_date +msgid "Last Updated on" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields,field_description:sale_quotation.field_sale_order_confirm_warning_wizard__message +msgid "Message" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields,field_description:sale_quotation.field_sale_order__quotation_state +msgid "Quotation State" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields.selection,name:sale_quotation.selection__sale_order__typology__quote +msgid "Quote" +msgstr "" + +#. module: sale_quotation +#: model:ir.model,name:sale_quotation.model_sale_order_confirm_warning_wizard +msgid "Sale Order Confirmation Warning Wizard" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields,field_description:sale_quotation.field_sale_order_confirm_warning_wizard__sale_order_ids +msgid "Sale Orders" +msgstr "" + +#. module: sale_quotation +#: model:ir.model,name:sale_quotation.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: sale_quotation +#: model_terms:ir.ui.view,arch_db:sale_quotation.sale_order_view_search_inherit_quotation +#: model_terms:ir.ui.view,arch_db:sale_quotation.view_quotation_tree +msgid "State" +msgstr "" + +#. module: sale_quotation +#. odoo-python +#: code:addons/sale_quotation/models/sale_order.py:0 +#, python-format +msgid "" +"The selected quotation(s) are not in 'Waiting Acceptation' state. Are you " +"sure you want to confirm them?" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields,field_description:sale_quotation.field_sale_order__typology +msgid "Typology" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields,field_description:sale_quotation.field_sale_order__use_customer_quotation_workflow +msgid "Use Customer Quotation Workflow" +msgstr "" + +#. module: sale_quotation +#: model:ir.model.fields.selection,name:sale_quotation.selection__sale_order__quotation_state__waiting_acceptation +#: model_terms:ir.ui.view,arch_db:sale_quotation.sale_order_view_search_inherit_quotation +msgid "Waiting Acceptation" +msgstr "" \ No newline at end of file diff --git a/shopinvader_api_quotation/i18n/shopinvader_api_quotation.pot b/shopinvader_api_quotation/i18n/shopinvader_api_quotation.pot index 8ea8c8a7df..0b6d69a557 100644 --- a/shopinvader_api_quotation/i18n/shopinvader_api_quotation.pot +++ b/shopinvader_api_quotation/i18n/shopinvader_api_quotation.pot @@ -4,8 +4,10 @@ # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 16.0\n" +"Project-Id-Version: Odoo Server 16.0+e\n" "Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-07-29 14:57+0000\n" +"PO-Revision-Date: 2025-07-29 14:57+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -14,16 +16,31 @@ msgstr "" "Plural-Forms: \n" #. module: shopinvader_api_quotation -#: model:ir.model.fields,field_description:shopinvader_api_quotation.field_shopinvader_api_quotation_quotations_router_helper__partner +#: model:ir.model.fields.selection,name:shopinvader_api_quotation.selection__sale_order__shopinvader_state__estimated +msgid "Estimated Quotation" +msgstr "" + +#. module: shopinvader_api_quotation +#: model:ir.model.fields.selection,name:shopinvader_api_quotation.selection__sale_order__shopinvader_state__estimating +msgid "Estimating Quotation" +msgstr "" + +#. module: shopinvader_api_quotation +#: model:ir.model.fields,field_description:shopinvader_api_quotation.field_shopinvader_api_quotation_router_helper__partner msgid "Partner" msgstr "" #. module: shopinvader_api_quotation -#: model:ir.model,name:shopinvader_api_quotation.model_shopinvader_api_cart_cart_router_helper -msgid "ShopInvader API Cart Router Helper" +#: model:ir.model,name:shopinvader_api_quotation.model_sale_order +msgid "Sales Order" msgstr "" #. module: shopinvader_api_quotation -#: model:ir.model,name:shopinvader_api_quotation.model_shopinvader_api_quotation_quotations_router_helper -msgid "Shopinvader api sale router helper" +#: model:ir.model.fields,field_description:shopinvader_api_quotation.field_sale_order__shopinvader_state +msgid "Shopinvader State" msgstr "" + +#. module: shopinvader_api_quotation +#: model:ir.model,name:shopinvader_api_quotation.model_shopinvader_api_quotation_router_helper +msgid "Shopinvader api quotations router helper" +msgstr "" \ No newline at end of file From 4c5095470ed46e9ebe32c147543bdd02375a9b18 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 1 Sep 2025 10:41:35 +0200 Subject: [PATCH 36/44] [FIX] sale_quotation: Avoid double call to actions --- sale_quotation/models/sale_order.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sale_quotation/models/sale_order.py b/sale_quotation/models/sale_order.py index 73a7a5463b..fe9c15b90c 100644 --- a/sale_quotation/models/sale_order.py +++ b/sale_quotation/models/sale_order.py @@ -202,6 +202,7 @@ def action_confirm(self): other_quotations = self if not self.env.context.get("bypass_customer_quotation", False): customer_quotations = self.filtered("use_customer_quotation_workflow") + other_quotations = self - customer_quotations if ( customer_quotations and self.env.context.get("use_quotation_confirm_wizard") @@ -229,8 +230,9 @@ def action_draft(self): other_quotations = self if not self.env.context.get("bypass_customer_quotation", False): customer_quotations = self.filtered("use_customer_quotation_workflow") + other_quotations = self - customer_quotations if customer_quotations: - self.action_customer_reset_quotation_to_draft() + customer_quotations.action_customer_reset_quotation_to_draft() return super(SaleOrder, other_quotations).action_draft() def action_cancel(self): @@ -243,5 +245,5 @@ def action_cancel(self): def action_quotation_sent(self): customer_quotations = self.filtered("use_customer_quotation_workflow") if customer_quotations: - self._check_customer_action_allowed("quotation_sent") + customer_quotations._check_customer_action_allowed("quotation_sent") return super().action_quotation_sent() From 767fa97086cf259aae161b2b9f122248ca66bfdd Mon Sep 17 00:00:00 2001 From: Nicolas Delbovier Date: Wed, 15 Oct 2025 11:16:26 +0200 Subject: [PATCH 37/44] [FIX] shopinvader_api_quotation: Ensure salesperson is auto-subscribed upon creation When a quotation is created via the API client, the assigned salesperson (user_id) was not automatically added as a follower. This occurred because the Odoo core mechanism for auto-subscribing the user_id is only triggered when the 'user_id' value is explicitly present in the create values dictionary. Since the API relies on Odoo's default value computation to assign the salesperson, the auto-subscription logic was incorrectly bypassed. This commit explicitly calls message_subscribe for the assigned user_id.partner_id during the API creation flow, ensuring the salesperson receives relevant Chatter notifications as expected. --- shopinvader_api_quotation/dependencies.py | 7 +++++++ .../tests/test_shopinvader_api_quotation.py | 20 ++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/shopinvader_api_quotation/dependencies.py b/shopinvader_api_quotation/dependencies.py index eaa4ab0632..e7be8d2fd6 100644 --- a/shopinvader_api_quotation/dependencies.py +++ b/shopinvader_api_quotation/dependencies.py @@ -136,6 +136,13 @@ def _update(self, quotation_id, data: QuotationUpdateInput) -> SaleOrder: def _create(self, rqst: QuotationCreateRequest) -> SaleOrder: vals = self._quotation_create_to_vals(rqst) quotation = self.env["sale.order"].create(vals) + + # We need to add the `user_id` as follower manually because of + # the way `MailThread._message_auto_subscribe_followers` works. + # This function only assigns the `user_id` as a follower if given + # explicitly inside the create dict (most likely a bug) + quotation.message_subscribe(partner_ids=quotation.user_id.sudo().partner_id.ids) + return quotation def _quotation_create_to_vals(self, data: QuotationCreateRequest) -> dict: diff --git a/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py b/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py index d93a10b915..0597c21003 100644 --- a/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py +++ b/shopinvader_api_quotation/tests/test_shopinvader_api_quotation.py @@ -20,7 +20,19 @@ class TestQuotation(FastAPITransactionCase): def setUpClass(cls) -> None: super().setUpClass() - partner = cls.env["res.partner"].create({"name": "FastAPI Cart Demo"}) + cls.salesman_user = cls.env["res.users"].create( + { + "name": "Test Salesman", + "login": "salesman", + } + ) + + partner = cls.env["res.partner"].create( + { + "name": "FastAPI Cart Demo", + "user_id": cls.salesman_user.id, + } + ) cls.user_no_rights = cls.env["res.users"].create( { @@ -248,6 +260,12 @@ def test_create_quotation(self): ) self.assertEqual(created_quotation.order_line[1].product_uom_qty, 2.0) + self.assertEqual(created_quotation.user_id, self.salesman_user) + self.assertIn( + self.salesman_user.partner_id.id, + created_quotation.message_follower_ids.partner_id.ids, + ) + def test_download_quotation_pdf(self): quotation = self.quotation From ad37158d1a7495fac45126db0ca7a22f45529e4e Mon Sep 17 00:00:00 2001 From: Nicolas Delbovier Date: Thu, 28 May 2026 11:47:57 +0200 Subject: [PATCH 38/44] pre-commit update --- sale_cart/README.rst | 6 ++++- sale_cart/static/description/index.html | 24 ++++++++++++------- sale_cart_quotation/README.rst | 6 ++++- .../static/description/index.html | 24 ++++++++++++------- sale_typology/README.rst | 6 ++++- sale_typology/static/description/index.html | 24 ++++++++++++------- 6 files changed, 60 insertions(+), 30 deletions(-) diff --git a/sale_cart/README.rst b/sale_cart/README.rst index ccd7fe7f2a..ad9286f503 100644 --- a/sale_cart/README.rst +++ b/sale_cart/README.rst @@ -1,3 +1,7 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + ========= Sale Cart ========= @@ -13,7 +17,7 @@ Sale Cart .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-shopinvader%2Fodoo--shopinvader-lightgray.png?logo=github diff --git a/sale_cart/static/description/index.html b/sale_cart/static/description/index.html index 0ad025eed1..ac74cbd74e 100644 --- a/sale_cart/static/description/index.html +++ b/sale_cart/static/description/index.html @@ -3,7 +3,7 @@ -Sale Cart +README.rst -
    -

    Sale Cart

    +
    + + +Odoo Community Association + +
    +

    Sale Cart

    -

    Beta License: AGPL-3 shopinvader/odoo-shopinvader

    +

    Beta License: AGPL-3 shopinvader/odoo-shopinvader

    This addon adds a “cart” typology on sale.order to distinguish the sale orders that are in the process of being written (cart) and those that are quotations ready to be confirmed.

    @@ -388,7 +393,7 @@

    Sale Cart

    -

    Bug Tracker

    +

    Bug Tracker

    Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -396,26 +401,27 @@

    Bug Tracker

    Do not contact contributors directly about support or help with technical issues.

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    • ACSONE SA/NV
    -

    Maintainers

    +

    Maintainers

    This module is part of the shopinvader/odoo-shopinvader project on GitHub.

    You are welcome to contribute.

    +
    diff --git a/sale_cart_quotation/README.rst b/sale_cart_quotation/README.rst index 4b21a22939..6f081426ff 100644 --- a/sale_cart_quotation/README.rst +++ b/sale_cart_quotation/README.rst @@ -1,3 +1,7 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + =================== Sale Cart Quotation =================== @@ -13,7 +17,7 @@ Sale Cart Quotation .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-shopinvader%2Fodoo--shopinvader-lightgray.png?logo=github diff --git a/sale_cart_quotation/static/description/index.html b/sale_cart_quotation/static/description/index.html index 32c0a763ac..8068db88da 100644 --- a/sale_cart_quotation/static/description/index.html +++ b/sale_cart_quotation/static/description/index.html @@ -3,7 +3,7 @@ -Sale Cart Quotation +README.rst -
    -

    Sale Cart Quotation

    +
    + + +Odoo Community Association + +
    +

    Sale Cart Quotation

    -

    Beta License: AGPL-3 shopinvader/odoo-shopinvader

    +

    Beta License: AGPL-3 shopinvader/odoo-shopinvader

    Glue module between sale_quotation and sale_cart, allowing users to request a quotation directly from a cart.

    Table of contents

    @@ -384,7 +389,7 @@

    Sale Cart Quotation

    -

    Bug Tracker

    +

    Bug Tracker

    Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -392,25 +397,26 @@

    Bug Tracker

    Do not contact contributors directly about support or help with technical issues.

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    • ACSONE SA/NV
    -

    Maintainers

    +

    Maintainers

    This module is part of the shopinvader/odoo-shopinvader project on GitHub.

    You are welcome to contribute.

    +
    diff --git a/sale_typology/README.rst b/sale_typology/README.rst index 01c044a2c7..4b40148202 100644 --- a/sale_typology/README.rst +++ b/sale_typology/README.rst @@ -1,3 +1,7 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + ============= Sale Typology ============= @@ -13,7 +17,7 @@ Sale Typology .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-shopinvader%2Fodoo--shopinvader-lightgray.png?logo=github diff --git a/sale_typology/static/description/index.html b/sale_typology/static/description/index.html index fec9901a28..9f48fcadb8 100644 --- a/sale_typology/static/description/index.html +++ b/sale_typology/static/description/index.html @@ -3,7 +3,7 @@ -Sale Typology +README.rst -
    -

    Sale Typology

    +
    + + +Odoo Community Association + +
    +

    Sale Typology

    -

    Beta License: AGPL-3 shopinvader/odoo-shopinvader

    +

    Beta License: AGPL-3 shopinvader/odoo-shopinvader

    This addon adds a typology on sale.order

    Table of contents

    @@ -384,7 +389,7 @@

    Sale Typology

    -

    Bug Tracker

    +

    Bug Tracker

    Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -392,25 +397,26 @@

    Bug Tracker

    Do not contact contributors directly about support or help with technical issues.

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    • ACSONE SA/NV
    -

    Maintainers

    +

    Maintainers

    This module is part of the shopinvader/odoo-shopinvader project on GitHub.

    You are welcome to contribute.

    +
    From 527a4f3a28e89a97c65e6755ffe2873d8ba657af Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 27 May 2026 12:22:29 +0200 Subject: [PATCH 39/44] sale_quotation: Avoid unnecessary ping pong calls --- sale_quotation/models/sale_order.py | 89 ++++++++----------- sale_quotation/tests/test_quotation.py | 9 +- .../sale_order_confirm_warning_wizard.py | 2 - 3 files changed, 43 insertions(+), 57 deletions(-) diff --git a/sale_quotation/models/sale_order.py b/sale_quotation/models/sale_order.py index fe9c15b90c..5989dfa5e2 100644 --- a/sale_quotation/models/sale_order.py +++ b/sale_quotation/models/sale_order.py @@ -176,71 +176,52 @@ def action_customer_request_quotation(self): def action_customer_accept_quotation(self): self._check_customer_action_allowed("accept_quotation") - self.quotation_state = "accepted" - self.typology = "sale" - return self.with_context( - bypass_customer_quotation=True, - ).action_confirm() + return self.action_confirm() def action_customer_reset_quotation_to_draft(self): self._check_customer_action_allowed("reset_to_draft") - self.quotation_state = "draft" - self.state = "draft" - self.filtered(lambda so: so.typology != "quote").typology = "quote" - return self.with_context( - bypass_customer_quotation=True, - ).action_draft() + return self.action_draft() def action_customer_cancel_quotation(self): self._check_customer_action_allowed("cancel_quotation") self.quotation_state = "cancel" - return self.with_context( - disable_cancel_warning=True, - ).action_cancel() + return self.with_context(disable_cancel_warning=True).action_cancel() def action_confirm(self): - other_quotations = self - if not self.env.context.get("bypass_customer_quotation", False): - customer_quotations = self.filtered("use_customer_quotation_workflow") - other_quotations = self - customer_quotations - if ( - customer_quotations - and self.env.context.get("use_quotation_confirm_wizard") - and any(rec.quotation_state != "waiting_acceptation" for rec in self) - ): - return { - "name": _("Confirm Sale Order"), - "type": "ir.actions.act_window", - "res_model": "sale.order.confirm.warning.wizard", - "views": [[False, "form"]], - "target": "new", - "context": { - "default_sale_order_ids": self.ids, - "default_message": _( - "The selected quotation(s) are not in 'Waiting Acceptation' " - "state. Are you sure you want to confirm them?" - ), - }, - } - else: - customer_quotations.action_customer_accept_quotation() - return super(SaleOrder, other_quotations).action_confirm() + customer_quotations = self.filtered("use_customer_quotation_workflow") + if ( + customer_quotations + and self.env.context.get("use_quotation_confirm_wizard") + and any( + rec.quotation_state not in ("waiting_acceptation", "accepted") + for rec in customer_quotations + ) + ): + return { + "name": _("Confirm Sale Order"), + "type": "ir.actions.act_window", + "res_model": "sale.order.confirm.warning.wizard", + "views": [[False, "form"]], + "target": "new", + "context": { + "default_sale_order_ids": self.ids, + "default_message": _( + "The selected quotation(s) are not in 'Waiting Acceptation' " + "state. Are you sure you want to confirm them?" + ), + }, + } + customer_quotations.quotation_state = "accepted" + customer_quotations.typology = "sale" + return super().action_confirm() def action_draft(self): - other_quotations = self - if not self.env.context.get("bypass_customer_quotation", False): - customer_quotations = self.filtered("use_customer_quotation_workflow") - other_quotations = self - customer_quotations - if customer_quotations: - customer_quotations.action_customer_reset_quotation_to_draft() - return super(SaleOrder, other_quotations).action_draft() - - def action_cancel(self): - self.filtered( - lambda so: so.state in ("sale", "sent") - and so.use_customer_quotation_workflow - ).quotation_state = "cancel" - return super().action_cancel() + customer_quotations = self.filtered("use_customer_quotation_workflow") + customer_quotations.quotation_state = "draft" + customer_quotations.filtered( + lambda so: so.typology != "quote" + ).typology = "quote" + return super().action_draft() def action_quotation_sent(self): customer_quotations = self.filtered("use_customer_quotation_workflow") diff --git a/sale_quotation/tests/test_quotation.py b/sale_quotation/tests/test_quotation.py index c1ec9c62ec..0b299d8950 100644 --- a/sale_quotation/tests/test_quotation.py +++ b/sale_quotation/tests/test_quotation.py @@ -51,6 +51,13 @@ def test_not_allowed_confirm_quotation(self): self.assertIn( "not in 'Waiting Acceptation'", action["context"]["default_message"] ) + self.env["sale.order.confirm.warning.wizard"].create( + { + "sale_order_ids": [Command.link(self.so.id)], + } + ).confirm_and_proceed() + self.assertEqual(self.so.quotation_state, "accepted") + self.assertEqual(self.so.typology, "sale") def test_convert_to_draft_resets_typology(self): self.so.action_customer_request_quotation() @@ -136,7 +143,7 @@ def test_action_customer_workflow(self): self.assertEqual(self.so.typology, "sale") # reset to waiting_acceptation state: - self.so.action_cancel() + self.so.with_context(disable_cancel_warning=True).action_cancel() self.so.action_customer_reset_quotation_to_draft() self.so.action_customer_request_quotation() self.so.action_quotation_sent() diff --git a/sale_quotation/wizards/sale_order_confirm_warning_wizard.py b/sale_quotation/wizards/sale_order_confirm_warning_wizard.py index 86041eb44e..263c35e753 100644 --- a/sale_quotation/wizards/sale_order_confirm_warning_wizard.py +++ b/sale_quotation/wizards/sale_order_confirm_warning_wizard.py @@ -10,8 +10,6 @@ class SaleOrderConfirmWarningWizard(models.TransientModel): message = fields.Char(readonly=True) def confirm_and_proceed(self): - for order in self.sale_order_ids: - order.action_customer_accept_quotation() self.sale_order_ids.with_context( use_quotation_confirm_wizard=False ).action_confirm() From a480dfa8914c012963182aab86f74ad8ba60e1e2 Mon Sep 17 00:00:00 2001 From: Nicolas Delbovier Date: Thu, 28 May 2026 11:48:04 +0200 Subject: [PATCH 40/44] [REF] sale_quotation: refactor confirmation workflow to use native UI prompts --- sale_quotation/__init__.py | 1 - sale_quotation/__manifest__.py | 2 - sale_quotation/models/sale_order.py | 37 ++++++----------- sale_quotation/tests/test_quotation.py | 22 +--------- sale_quotation/views/sale_view.xml | 40 ++++++++++++++----- sale_quotation/wizards/__init__.py | 1 - .../sale_order_confirm_warning_wizard.py | 16 -------- .../wizards/sale_order_wizard_views.xml | 25 ------------ 8 files changed, 45 insertions(+), 99 deletions(-) delete mode 100644 sale_quotation/wizards/__init__.py delete mode 100644 sale_quotation/wizards/sale_order_confirm_warning_wizard.py delete mode 100644 sale_quotation/wizards/sale_order_wizard_views.xml diff --git a/sale_quotation/__init__.py b/sale_quotation/__init__.py index aee8895e7a..0650744f6b 100644 --- a/sale_quotation/__init__.py +++ b/sale_quotation/__init__.py @@ -1,2 +1 @@ from . import models -from . import wizards diff --git a/sale_quotation/__manifest__.py b/sale_quotation/__manifest__.py index ed57a8ffeb..19fee0b267 100644 --- a/sale_quotation/__manifest__.py +++ b/sale_quotation/__manifest__.py @@ -16,8 +16,6 @@ ], "data": [ "views/sale_view.xml", - "wizards/sale_order_wizard_views.xml", - "security/ir.model.access.csv", ], "installable": True, } diff --git a/sale_quotation/models/sale_order.py b/sale_quotation/models/sale_order.py index 5989dfa5e2..70c290738e 100644 --- a/sale_quotation/models/sale_order.py +++ b/sale_quotation/models/sale_order.py @@ -58,6 +58,18 @@ class SaleOrder(models.Model): compute="_compute_is_action_customer_cancel_quotation_allowed" ) + show_quotation_confirm_warning = fields.Boolean( + compute="_compute_show_quotation_confirm_warning" + ) + + def _compute_show_quotation_confirm_warning(self): + for rec in self: + rec.show_quotation_confirm_warning = ( + rec.state in ("draft", "sent") + and rec.use_customer_quotation_workflow + and rec.quotation_state not in ("waiting_acceptation", "accepted") + ) + def _inverse_use_customer_quotation_workflow(self): """Set the typology to 'quote' when enabling the customer quotation workflow.""" for order in self: @@ -189,30 +201,7 @@ def action_customer_cancel_quotation(self): def action_confirm(self): customer_quotations = self.filtered("use_customer_quotation_workflow") - if ( - customer_quotations - and self.env.context.get("use_quotation_confirm_wizard") - and any( - rec.quotation_state not in ("waiting_acceptation", "accepted") - for rec in customer_quotations - ) - ): - return { - "name": _("Confirm Sale Order"), - "type": "ir.actions.act_window", - "res_model": "sale.order.confirm.warning.wizard", - "views": [[False, "form"]], - "target": "new", - "context": { - "default_sale_order_ids": self.ids, - "default_message": _( - "The selected quotation(s) are not in 'Waiting Acceptation' " - "state. Are you sure you want to confirm them?" - ), - }, - } - customer_quotations.quotation_state = "accepted" - customer_quotations.typology = "sale" + customer_quotations.write({"quotation_state": "accepted", "typology": "sale"}) return super().action_confirm() def action_draft(self): diff --git a/sale_quotation/tests/test_quotation.py b/sale_quotation/tests/test_quotation.py index 0b299d8950..7ea44d6594 100644 --- a/sale_quotation/tests/test_quotation.py +++ b/sale_quotation/tests/test_quotation.py @@ -3,9 +3,10 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from odoo import Command -from odoo.addons.base.tests.common import DISABLED_MAIL_CONTEXT from odoo.tests.common import TransactionCase +from odoo.addons.base.tests.common import DISABLED_MAIL_CONTEXT + from ..exceptions import InvalidQuotationStateError @@ -40,25 +41,6 @@ def test_confirm_quotation(self): self.assertEqual(self.so.quotation_state, "accepted") self.assertEqual(self.so.typology, "sale") - def test_not_allowed_confirm_quotation(self): - action = self.so.with_context( - use_quotation_confirm_wizard=True - ).action_confirm() - self.assertIsInstance(action, dict) - self.assertEqual(action.get("res_model"), "sale.order.confirm.warning.wizard") - self.assertEqual(action.get("type"), "ir.actions.act_window") - self.assertIn("default_message", action.get("context", {})) - self.assertIn( - "not in 'Waiting Acceptation'", action["context"]["default_message"] - ) - self.env["sale.order.confirm.warning.wizard"].create( - { - "sale_order_ids": [Command.link(self.so.id)], - } - ).confirm_and_proceed() - self.assertEqual(self.so.quotation_state, "accepted") - self.assertEqual(self.so.typology, "sale") - def test_convert_to_draft_resets_typology(self): self.so.action_customer_request_quotation() self.so.action_quotation_sent() diff --git a/sale_quotation/views/sale_view.xml b/sale_quotation/views/sale_view.xml index 88fa6a1b11..ec7cf9cc95 100644 --- a/sale_quotation/views/sale_view.xml +++ b/sale_quotation/views/sale_view.xml @@ -72,25 +72,45 @@ class="oe_inline" /> + + + + - - { - "use_quotation_confirm_wizard": True, - } - + + - - { - "use_quotation_confirm_wizard": True, - } - + + + + +