From 27fdb5537f0046155096d651c18113305f98c15b Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev Date: Fri, 10 Jul 2020 17:10:28 +0500 Subject: [PATCH 01/92] :tada::one::two: Sync Studio --- sync/README.rst | 105 +++ sync/__init__.py | 6 + sync/__manifest__.py | 45 + sync/controllers/__init__.py | 3 + sync/controllers/webhook.py | 23 + sync/data/sync_project_odoo2odoo_demo.xml | 171 ++++ sync/data/sync_project_telegram_demo.xml | 178 ++++ sync/data/sync_project_trello_github_demo.xml | 811 ++++++++++++++++++ sync/data/sync_project_unittest_demo.xml | 34 + sync/doc/changelog.rst | 4 + sync/doc/index.rst | 669 +++++++++++++++ sync/models/__init__.py | 14 + sync/models/base.py | 16 + sync/models/ir_actions.py | 13 + sync/models/ir_logging.py | 40 + sync/models/sync_job.py | 135 +++ sync/models/sync_link.py | 261 ++++++ sync/models/sync_project.py | 314 +++++++ sync/models/sync_task.py | 167 ++++ sync/models/sync_trigger_automation.py | 44 + sync/models/sync_trigger_button.py | 34 + sync/models/sync_trigger_cron.py | 64 ++ sync/models/sync_trigger_mixin.py | 70 ++ sync/models/sync_trigger_webhook.py | 98 +++ sync/security/ir.model.access.csv | 29 + sync/security/sync_groups.xml | 37 + sync/static/description/icon.png | Bin 0 -> 3035 bytes sync/static/description/index.html | 113 +++ sync/static/description/project-tasks.png | Bin 0 -> 113586 bytes sync/static/description/task-code.png | Bin 0 -> 107933 bytes sync/tests/__init__.py | 5 + sync/tests/test_eval.py | 167 ++++ sync/tests/test_links.py | 199 +++++ sync/tests/test_trigger_db.py | 33 + sync/tools/__init__.py | 3 + sync/tools/safe_eval.py | 158 ++++ sync/views/ir_logging_views.xml | 144 ++++ sync/views/sync_job_views.xml | 175 ++++ sync/views/sync_link_views.xml | 90 ++ sync/views/sync_menus.xml | 6 + sync/views/sync_project_views.xml | 300 +++++++ sync/views/sync_task_views.xml | 187 ++++ sync/views/sync_trigger_automation_views.xml | 100 +++ sync/views/sync_trigger_button_views.xml | 34 + sync/views/sync_trigger_cron_views.xml | 92 ++ sync/views/sync_trigger_webhook_views.xml | 42 + sync/wizard/__init__.py | 3 + sync/wizard/sync_make_module.py | 201 +++++ sync/wizard/sync_make_module_views.xml | 106 +++ 49 files changed, 5543 insertions(+) create mode 100644 sync/README.rst create mode 100644 sync/__init__.py create mode 100644 sync/__manifest__.py create mode 100644 sync/controllers/__init__.py create mode 100644 sync/controllers/webhook.py create mode 100644 sync/data/sync_project_odoo2odoo_demo.xml create mode 100644 sync/data/sync_project_telegram_demo.xml create mode 100644 sync/data/sync_project_trello_github_demo.xml create mode 100644 sync/data/sync_project_unittest_demo.xml create mode 100644 sync/doc/changelog.rst create mode 100644 sync/doc/index.rst create mode 100644 sync/models/__init__.py create mode 100644 sync/models/base.py create mode 100644 sync/models/ir_actions.py create mode 100644 sync/models/ir_logging.py create mode 100644 sync/models/sync_job.py create mode 100644 sync/models/sync_link.py create mode 100644 sync/models/sync_project.py create mode 100644 sync/models/sync_task.py create mode 100644 sync/models/sync_trigger_automation.py create mode 100644 sync/models/sync_trigger_button.py create mode 100644 sync/models/sync_trigger_cron.py create mode 100644 sync/models/sync_trigger_mixin.py create mode 100644 sync/models/sync_trigger_webhook.py create mode 100644 sync/security/ir.model.access.csv create mode 100644 sync/security/sync_groups.xml create mode 100644 sync/static/description/icon.png create mode 100644 sync/static/description/index.html create mode 100644 sync/static/description/project-tasks.png create mode 100644 sync/static/description/task-code.png create mode 100644 sync/tests/__init__.py create mode 100644 sync/tests/test_eval.py create mode 100644 sync/tests/test_links.py create mode 100644 sync/tests/test_trigger_db.py create mode 100644 sync/tools/__init__.py create mode 100644 sync/tools/safe_eval.py create mode 100644 sync/views/ir_logging_views.xml create mode 100644 sync/views/sync_job_views.xml create mode 100644 sync/views/sync_link_views.xml create mode 100644 sync/views/sync_menus.xml create mode 100644 sync/views/sync_project_views.xml create mode 100644 sync/views/sync_task_views.xml create mode 100644 sync/views/sync_trigger_automation_views.xml create mode 100644 sync/views/sync_trigger_button_views.xml create mode 100644 sync/views/sync_trigger_cron_views.xml create mode 100644 sync/views/sync_trigger_webhook_views.xml create mode 100644 sync/wizard/__init__.py create mode 100644 sync/wizard/sync_make_module.py create mode 100644 sync/wizard/sync_make_module_views.xml diff --git a/sync/README.rst b/sync/README.rst new file mode 100644 index 00000000..0ffa2219 --- /dev/null +++ b/sync/README.rst @@ -0,0 +1,105 @@ +.. image:: https://img.shields.io/badge/license-MIT-blue.svg + :target: https://opensource.org/licenses/MIT + :alt: License: MIT + +============= + Sync Studio +============= + +Synchronize anything with anything: + +* System X ↔ Odoo +* Odoo 1 ↔ Odoo 2 +* System X ↔ System Y + +Provides a single place to handle synchronization trigered by one of the following events: + +* **Cron** -- provided by ``ir.cron`` +* **DB Event** -- provided by ``base.automation`` +* **Incoming webhook** -- provided by ``ir.actions.server::website_published`` (search for ``/website/action`` in ``website`` module) +* **Manual Triggering** -- provided by ``ir.actions.server``. User needs to click a button to run this action + +Difference with built-in code evaluation: + +* Allows to add extra imports to eval context +* Allows to use json format for incomming webhooks +* Provides helpers for resource linking. See *Links* section in ``__ +* Uses queue_job module as a job broker +* Asynchronous calls to split big task into few small ones +* Allows repeat job on temporary fails (e.g. when external API is not available) + +Roadmap +======= + +* Code widget: show line numbers +* Webhooks: add a possibility to retry failed webhook (e.g. to debug code) + +Developer Hints +=============== + +Public webhook address +---------------------- + +If you run Odoo locally and need to test webhook, you can use ssh tunneling: + +* Connect your server: + + * Edit file ``/etc/ssh/ssd_config``: + + * Find ``GatewayPorts`` attribute and set value to ``yes`` + + * Restart ssh daemon:: + + service ssh restart + +* Connect to your server with ``-R`` attribute:: + + ssh user@yourserver.example -R 0.0.0.0:8069:localhost:8069 + +Now you can set ``http://yourserver.example:8069`` as a value for ``web.base.url`` in Odoo (menu ``[[ Settings ]] >> System Parameters``). Also, you need to set any value to parameter `web.base.url.freeze `__ + +Few more steps requires to use https connection (e.g. telegram api works with https only). In your server do as following: + +* Install nginx in your server +* Add nginx config:: + + server { + listen 80; + server_name yourserver.example; + location / { + proxy_set_header Host $host; + proxy_pass http://localhost:8069; + } + } + +* Install `certbot `__ +* Run + :: + + sudo certbot --nginx + +* Done! + +Now set corresponding ``https://...`` value for ``web.base.url`` parameter. + +Credits +======= + +Contributors +------------ +* `Ivan Yelizariev `__: + + * :one::zero: init version of the module + +Further information +=================== + +HTML Description: https://apps.odoo.com/apps/modules/12.0/sync/ + +Usage instructions: ``__ + +Changelog: ``_ + +Notifications on updates: `via Atom `_, `by Email `_ + +Tested on Odoo 12.0 3fbfa87df85d6463dfcba47416f360fcdef4448e diff --git a/sync/__init__.py b/sync/__init__.py new file mode 100644 index 00000000..f320a03a --- /dev/null +++ b/sync/__init__.py @@ -0,0 +1,6 @@ +# License MIT (https://opensource.org/licenses/MIT). + +from . import models +from . import wizard +from . import tools +from . import controllers diff --git a/sync/__manifest__.py b/sync/__manifest__.py new file mode 100644 index 00000000..94b6e5e9 --- /dev/null +++ b/sync/__manifest__.py @@ -0,0 +1,45 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +{ + "name": """Sync Studio""", + "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY""", + "category": "Extra Tools", + "images": [], + "version": "12.0.1.0.0", + "application": True, + "author": "IT-Projects LLC, Ivan Yelizariev", + "support": "apps@it-projects.info", + "website": "https://github.com/itpp-labs/sync-addons", + "license": "Other OSI approved licence", # MIT + "depends": ["base_automation", "mail", "website", "queue_job"], + "external_dependencies": {"python": [], "bin": []}, + "data": [ + "security/sync_groups.xml", + "security/ir.model.access.csv", + "views/sync_menus.xml", + "views/ir_logging_views.xml", + "views/sync_job_views.xml", + "views/sync_trigger_cron_views.xml", + "views/sync_trigger_automation_views.xml", + "views/sync_trigger_webhook_views.xml", + "views/sync_trigger_button_views.xml", + "views/sync_task_views.xml", + "views/sync_project_views.xml", + "views/sync_link_views.xml", + "wizard/sync_make_module_views.xml", + ], + "demo": [ + "data/sync_project_telegram_demo.xml", + "data/sync_project_odoo2odoo_demo.xml", + "data/sync_project_trello_github_demo.xml", + "data/sync_project_unittest_demo.xml", + ], + "qweb": [], + "post_load": None, + "pre_init_hook": None, + "post_init_hook": None, + "uninstall_hook": None, + "auto_install": False, + "installable": True, +} diff --git a/sync/controllers/__init__.py b/sync/controllers/__init__.py new file mode 100644 index 00000000..9e73463c --- /dev/null +++ b/sync/controllers/__init__.py @@ -0,0 +1,3 @@ +# License MIT (https://opensource.org/licenses/MIT). + +from . import webhook diff --git a/sync/controllers/webhook.py b/sync/controllers/webhook.py new file mode 100644 index 00000000..b2913536 --- /dev/null +++ b/sync/controllers/webhook.py @@ -0,0 +1,23 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +from odoo import http + +from odoo.addons.website.controllers.main import Website + + +class Webhook(http.Controller): + @http.route( + [ + "/website/action-json/", + "/website/action-json//", + ], + type="json", + # type="http", + auth="public", + website=True, + csrf=False, + ) + def actions_server(self, path_or_xml_id_or_id, **post): + res = Website().actions_server(path_or_xml_id_or_id, **post) + return res.data diff --git a/sync/data/sync_project_odoo2odoo_demo.xml b/sync/data/sync_project_odoo2odoo_demo.xml new file mode 100644 index 00000000..aaf54d96 --- /dev/null +++ b/sync/data/sync_project_odoo2odoo_demo.xml @@ -0,0 +1,171 @@ + + + + + + Demo Odoo2odoo Integration + + + + + + + + + + UPLOAD_ALL_PARTNER_PREFIX + Sync Studio: + + + + URL + + URL to external Odoo, e.g. https://odoo.example + + + + + DB + Odoo database name + + + + USERNAME + e.g. admin + + + + PASSWORD + + + + + Sync Local Partners To Remote Odoo + + + + + + + Sync Remote Partners Updates + + +", links.sync_date.strftime(DEFAULT_SERVER_DATETIME_FORMAT)]], + fields=["write_date", IMAGE_FIELD] + ) + # Save fetched data in local Odoo + for ep in external_partners: + link = get_link(PARTNER_REL, ep["id"]) + p = link.odoo + sync_date = parse_date(ep["write_date"]) + if sync_date > link.sync_date: + p.write({IMAGE_FIELD: ep[IMAGE_FIELD]}) + link.update_links(sync_date) +]]> + + + + LOCAL_PARTNER_CREATED + + + on_create + + + CHECK_EXTERNAL_ODOO + + 15 + minutes + + + PUSH_ALL_LOCAL_PARTNERS + + + diff --git a/sync/data/sync_project_telegram_demo.xml b/sync/data/sync_project_telegram_demo.xml new file mode 100644 index 00000000..77a0f148 --- /dev/null +++ b/sync/data/sync_project_telegram_demo.xml @@ -0,0 +1,178 @@ + + + + + Demo Telegram Integration + + +setWebhook", json.dumps([args, kwargs])) + bot.setWebhook(*args, **kwargs) + + def parse_data(data): + return Update.de_json(data, bot) + + return { + "sendMessage": sendMessage, + "setWebhook": setWebhook, + "parse_data": parse_data, + } + +bot = _exports(secrets) + +]]> + + + + + + + TELEGRAM_WELCOME_MESSAGE + Hello! How can I help you? + + Message that is sent to a user on first bot usage + + + + + TELEGRAM_MESSAGE_SENT + The message has sent in telegram: + + When Odoo Bot reports on successfully sent telegram message + + + + + PARTNER_NAME_PREFIX + Telegram: + Prefix for new partner name + + + + TELEGRAM_BOT_TOKEN + + + + + + Setup + + + + + + + + SETUP_TELEGRAM + + + Process Telegram Messages + + + + + + + Telegram updates + + TELEGRAM + + + Send response via Odoo + + + + + + + ON_MESSAGE_POSTED + + + on_create + + [["model","=","res.partner"],["body","ilike","/telegram"]] + + + diff --git a/sync/data/sync_project_trello_github_demo.xml b/sync/data/sync_project_trello_github_demo.xml new file mode 100644 index 00000000..41f0d0e9 --- /dev/null +++ b/sync/data/sync_project_trello_github_demo.xml @@ -0,0 +1,811 @@ + + + + + Demo Trello-Github Integration + + + + + + GITHUB_TOKEN + + Token with access to read issues and create webhooks, i.e. "repo" scope + + + https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token + + + + + GITHUB_REPO + owner/repo_name + + + + + TRELLO_TOKEN + + Trello token to make API calls. Don't confuse it with API Key. Once you get + API Key in https://trello.com/app-key page, you will see a link to generate + token. Click one and you'll get the token + + + https://developer.atlassian.com/cloud/trello/guides/rest-api/api-introduction/ + + + + + TRELLO_KEY + + https://trello.com/app-key + + + + TRELLO_BOARD_ID + + You can get one from the board url: + https://trello.com/b/TRELLO_BOARD_ID/BOARD-NAME + + + https://community.atlassian.com/t5/Trello-questions/How-to-get-Trello-Board-ID/qaq-p/1347525 + + + + + ISSUE_FROM_GITHUB_PREFIX + GITHUB: + + + + + + MESSAGE_PREFIX + A Message posted on GitHub: + + + + + + LABELS_MERGE_STRATEGY + UNION + + Possible values: +USE_TRELLO, USE_GITHUB: use version from one side and override values from another +UNION: add missed labels to each side +INTERSECTION: remove labes that are not attached on another side + + + + + + Setup + + + + + + SETUP_GITHUB + Setup Github Webhook. To delete webhooks, open github repostory, navigate to Settings >> Webhooks + + + + SETUP_TRELLO + Setup Trello Webhook. + + + + DELETE_TRELLO_WEBHOOKS + + + + + DEBUG + + + + + Sync Github Issues to Trello (name, messages, labels) + + + + + + PUSH_ALL_ISSUES + + Initial syncing of github issues. It pushes only issues without linked + trello cards. It doesn't sync further updates + + + + + GITHUB_ISSUE_UPDATES + + Github Issue updates + + + + GITHUB_ISSUE_COMMENT + + Webhook + + + + Sync Trello Cards to Github (labels) + + + + + + TRELLO_CARD_UPDATES + + Trello Card updates + + + + Sync labels Updating/Deleting + + + + + + TRELLO_LABEL_UPDATES + + Trello Label updates + + + + GITHUB_LABEL_UPDATES + + Github Label updates + + + + Labels Conflict Resolving + + + issue + issues_index = {} + for issue in issues: + if str(issue["id"]) in issue_ids: + issues_index[int(issue["id"])] = issue + log("GITHUB issues: %s" % ([issue_ids, issues, issues_index])) + + card_ids = elinks.get(TRELLO) + # https://developer.atlassian.com/cloud/trello/rest/api-group-boards/#api-boards-id-cards-get + # https://developer.atlassian.com/cloud/trello/rest/api-group-cards/#api-cards-id-get + # card is dict {id: int, idLabels: [int], ...} + cards = trello["get_all_cards"]() + # card_id -> card + cards_index = {} + for card in cards: + if str(card["id"]) in card_ids: + cards_index[card["id"]] = card + + for el in elinks: + card_id = el.get(TRELLO)[0] + issue_id = int(el.get(GITHUB)[0]) + card = cards_index.get(card_id) + issue = issues_index.get(issue_id) + if not (card and issue): + log("Linked card or issue is missed: %s" % ([card, issue]), level=LOG_WARNING) + continue + # compare labels + tlabel_ids = card["idLabels"] + glabel_ids = [lbl["id"] for lbl in issue["labels"]] + tlinks = search_links(LABELS_REL, {GITHUB: None, TRELLO: tlabel_ids}) + glinks = search_links(LABELS_REL, {GITHUB: glabel_ids, TRELLO: None}) + if tlinks == glinks: + # all fine + log("card {} and issue {} are already synced".format(card_id, issue_id), LOG_DEBUG) + continue + log("Found labels mismatch: issue=%s, card=%s" % (issue["id"], card["id"]), LOG_DEBUG) + + tlinks_add = None + tlinks_remove = None + glinks_add = None + glinks_remove = None + if params.LABELS_MERGE_STRATEGY == "USE_TRELLO": + glinks_add = tlinks - glinks + glinks_remove = glinks - tlinks + elif params.LABELS_MERGE_STRATEGY == "USE_GITHUB": + tlinks_add = glinks - tlinks + tlinks_remove = tlinks - glinks + elif params.LABELS_MERGE_STRATEGY == "UNION": + tlinks_add = glinks - tlinks + glinks_add = tlinks - glinks + elif params.LABELS_MERGE_STRATEGY == "INTERSECTION": + tlinks_remove = tlinks - glinks + glinks_remove = glinks - tlinks + else: + raise Exception("Unknown LABELS_MERGE_STRATEGY: %s" % LABELS_MERGE_STRATEGY, level=LOG_ERROR) + + if tlinks_add: + trello["card_add_labels"](card_id, tlinks_add.get(TRELLO)) + if glinks_add: + github["issue_add_labels"](issue_id, glinks_add.get(GITHUB)) + if tlinks_remove: + trello["card_remove_labels"](card_id, tlinks_remove.get(TRELLO)) + if glinks_remove: + github["issue_remove_labels"](issue_id, glinks_remove.get(GITHUB)) + + ]]> + + + CONFLICT_RESOLVING + + + 1 + days + + diff --git a/sync/data/sync_project_unittest_demo.xml b/sync/data/sync_project_unittest_demo.xml new file mode 100644 index 00000000..9e9029cf --- /dev/null +++ b/sync/data/sync_project_unittest_demo.xml @@ -0,0 +1,34 @@ + + + + + + Project For Unittests + + + + + PARTNER_RELATION + sync_unittest_partner + + + + Assign ref to new partners + + + + + + + Sync test: Run code On partner created + ON_PARTNER_CREATED + + + on_create + + diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst new file mode 100644 index 00000000..5583eb32 --- /dev/null +++ b/sync/doc/changelog.rst @@ -0,0 +1,4 @@ +`1.0.0` +------- + +- **Init version** diff --git a/sync/doc/index.rst b/sync/doc/index.rst new file mode 100644 index 00000000..0f5584c9 --- /dev/null +++ b/sync/doc/index.rst @@ -0,0 +1,669 @@ +============= + Sync Studio +============= + +.. contents:: + :local: + +Installation +============ + +* Make configuration required for `queue_job `__ module. In particular: + + * add ``queue_job`` to `server wide modules `__, e.g.:: + + ``--load base,web,queue_job`` + +* `Install `__ this module in a usual way +* Install python package that you need to use. For example, to try demo projects install following packages: + + sudo pip3 install python-telegram-bot + sudo pip3 install PyGithub + sudo pip3 install py-trello + +* If your Sync projects use webhooks (most likely), be sure that url opens correct database without asking to select one + + +User Access Levels +================== + +* ``Sync Studio: User``: read-only access +* ``Sync Studio: Developer``: restricted write access +* ``Sync Studio: Manager``: same as Developer, but with access to **Secrets** and **Protected Code** + +Project +======= + +* Open menu ``[[ Sync Studio ]] >> Projects`` +* Create a project + + * **Name**, e.g. *Legacy migration* + + * In the ``Parameters`` tab + + * **Parameters** + + * **Key** + * **Value** + * **Secrets**: Parameters with restricted access: key values are visiable for Managers only + + * In the ``Shared Code`` tab + + * **Protected Code**, **Common Code**: code that is executed before running any + project's task. Can be used for initialization or for helpers. Secret params + and package importing are available in **Protected Code** only. Any variables + and functions that don't start with underscore symbol will be available in + task's code. + + * In the ``Available Tasks`` tab + + * **Name**, e.g. *Sync products* + * **Code**: code with at least one of the following functions + + * ``handle_cron()`` + * ``handle_db(records)`` + + * ``records``: all records on which this task is triggered + + * ``handle_webhook(httprequest)`` + + * ``httprequest``: contains information about request, e.g. + + * `httprequest.data `__: request data + * `httprequest.files `__: uploaded files + * `httprequest.remote_addr `__: ip address of the caller. + * see `Werkzeug doc + `__ + for more information. + * optionally can return data as a response to the webhook request; any data transferred in this way are logged via ``log_transmission`` function: + + * for *json* webhook: + * ``return json_data`` + * for *x-www-form-urlencoded* webhook: + * ``return data_str`` + * ``return data_str, status`` + * ``return data_str, status, headers`` + + * ``status`` is a response code, e.g. ``200``, ``403``, etc. + * ``headers`` is a list of key-value turples, e.g. ``[('Content-Type', 'text/html')]`` + * ``handle_button()`` + + * **Cron Triggers**, **DB Triggers**, **Webhook Triggers**, **Manual + Triggers**: when to execute the Code. See below for further information + +Job Triggers +============ + +Cron +---- + +* **Trigger Name**, e.g. ``NIGHTLY_SYNC`` +* **Execute Every**: every 2 hours, every 1 week, etc. +* **Next Execution Date** +* **Scheduler User** + +DB +-- + +* **Trigger Name**, e.g. ``PRODUCT_PRICE_CHANGE`` +* **Model** +* **Trigger Condition** + + * On Creation + * On Update + * On Creation & Update + * On Deletion + * Based on Timed Condition + + * Allows to trigger task before, after on in time of Date/Time fields, e.g. + 1 day after Sale Order is closed + +* **Apply on**: records filter +* **Before Update Domain**: additional records filter for *On Update* event +* **Watched fields**: fields list for *On Update* event + +Webhook +------- + +* **Trigger Name**, e.g. ``ON_EXTERNAL_UPDATE`` +* **Webhook Type**: *application/x-www-form-urlencoded* or *application/json* + +* **Webhook URL**: readonly. + +Button +------ + +* **Trigger Name**, e.g. ``SYNC_ALL_PRODUCTS`` + +Code +==== + +Available variables and functions: +---------------------------------- + +Base +~~~~ + +* ``env``: Odoo Environment +* ``log(message, level=LOG_INFO)``: logging function to record debug information + + log levels: + + * ``LOG_DEBUG`` + * ``LOG_INFO`` + * ``LOG_WARNING`` + * ``LOG_ERROR`` + * ``LOG_CRITICAL`` + +Links +~~~~~ + +* ``.set_link(relation_name, external, sync_date=None, allow_many2many=False) -> link``: makes link between Odoo and external resource + + * ``allow_many2many``: when False raises an error if there is a link for the + ``record`` and ``relation_name`` or if there is a link for ``relation_name`` + and ``external``; + +* ``.search_links(relation_name, refs=[external_ref1, external_ref2, ...]) -> links`` +* ``get_link(relation_name, external_ref) -> link`` + +Odoo Link usage: + +* ``link.odoo``: normal Odoo record + + * ``link.odoo._name``: model name, e.g. ``res.partner`` + * ``link.odoo.id``: odoo record id + * ``link.odoo.``: some field of the record, e.g. ``link.odoo.email``: partner email + +* ``link.external``: external reference, e.g. external id of a partner +* ``link.sync_date``: last saved date-time information +* ``links.odoo``: normal Odoo RecordSet +* ``links.external``: list of all external references +* ``links.sync_date``: minimal data-time among links +* ``links.update_links(sync_date=None)``: set new sync_date value; if value is not passed, then ``now()`` is used +* ``links.unlink()``: delete links +* ``for link in links:``: iterate over links +* ``if links``: check that link set is not empty +* ``len(links)``: number of links in the set +* sets operaions: + + * ``links1 == links2``: sets are equal + * ``links1 - links2``: links that are in first set, but not in another + * ``links1 | links2``: union + * ``links1 & links2``: intersection + * ``links1 ^ links2``: equal to ``(links1 | links2) - (links1 & links2)`` + + + +You can also link external data with external data on syncing two different system (e.g. github and trello). + +* ``set_link(relation_name, {"github": github_issue_num, "trello": trello_card_num}, sync_date=None, allow_many2many=False) -> elink`` + * ``refs`` is a dictionary with system name and references pairs, e.g. + + .. code-block:: python + + { + "github": github_issue_num, + "trello": trello_card_num, + } + +* ``search_links(relation_name, refs) -> elinks``: + * ``refs`` may contain list of references as values, e.g. + + .. code-block:: python + + { + "github": [github_issue_num], + "trello": [trello_card_num], + } + + * use None values to don't filter by referece value of that system, e.g. + + .. code-block:: python + + { + "github": None, + "trello": [trello_card_num], + } + + * if references for both systems are passed, then elink is added to result + only when its references are presented in both references lists +* ``get_link(relation_name, refs) -> elink`` + + * At least one of the reference should be not Falsy + * ``get_link`` raise error, if there are few odoo records linked to the + references. Set work with multiple relations (*one2many*, *many2one*, + *many2many*) use ``set_link(..., allow_many2many=False)`` and + ``search_links`` + +In place of ``github`` and ``trello`` you can use other labels depending on what you sync. + +External Link is similar to Odoo link with the following differences: + +* ``elink.get()``, e.g. ``elink.get("github")``: reference value for system; it's a replacement for ``link.odoo`` and ``link.external`` in Odoo link + +Network +~~~~~~~ + +* ``log_transmission(recipient_str, data_str)``: report on data transfer to external recipients; example of a function in *Protected Code*: + + .. code-block:: python + + def httpPOST(url, *args, **kwargs): + import requests + log_transmission(url, json.dumps([args, kwargs])) + r = requests.post(url, *args, **kwargs) + return r.text + + +Project Values +~~~~~~~~~~~~~~ + +* ``params.``: project params +* ``secrets.``: available in **Protected Code** only; you need to use closure to use it, for example: + + .. code-block:: python + + def _make_request(secrets): + import requests + def f(data): + return requests.post(params.API_URL, data=data, auth=(secrets.API_USER, secrets.API_PASSWORD)) + return f + make_request = _make_request(secrets) + +* ``webhooks.``: contains webhook url; only in tasks' code + +Event +~~~~~ + +* ``trigger_name``: available in tasks' code only +* ``user``: user related to the event, e.g. who clicked a button + +Asynchronous work +~~~~~~~~~~~~~~~~~ + +* ``add_job(func_name, **options)(*func_args, **func_kwargs)``: call a function asyncroniously; options are similar to ``with_delay`` method of ``queue_job`` module: + + * ``priority``: Priority of the job, 0 being the higher priority. Default is 10. + * ``eta``: Estimated Time of Arrival of the job. It will not be executed before this date/time. + * ``max_retries``: maximum number of retries before giving up and set the job + state to 'failed'. A value of 0 means infinite retries. Default is 5. + * ``description`` human description of the job. If None, description is + computed from the function doc or name + * ``identity_key`` key uniquely identifying the job, if specified and a job + with the same key has not yet been run, the new job will not be added. + +Libs +~~~~ + +* ``json`` +* ``time`` +* ``datetime`` +* ``dateutil`` +* ``timezone`` +* ``b64encode`` +* ``b64decode`` + +Exceptions +~~~~~~~~~~ + +* ``UserError`` +* ``ValidationError`` +* ``RetryableJobError``: raise to restart job from beginning; e.g. in case of temporarly errors like broken connection +* ``OSError`` + +Running Job +=========== + +Depending on Trigger, a job may: + +* be added to a queue or runs immediatly +* be retried in case of failure + + * if ``RetryableJobError`` is raised, then job is retried automatically in following scheme: + + * After first failure wait 5 minute + * If it's not succeeded again, then wait another 15 minutes + * If it's not succeeded again, then wait another 60 minutes + * If it's not succeeded again, then wait another 3 hours + * Try again for the fifth time and stop retrying if it's still failing + +Cron +---- + +* job is added to the queue before run +* failed job can be retried if failed + +DB +-- + +* job is added to the queue before run +* failed job can be retried if failed + +Webhook +------- + +* runs immediately +* failed job cannot be retried via backend UI; the webhook should be called again. + +Button +------ + +* runs immediatly +* to retry click the button again + +Execution Logs +============== + +In Project, Task and Job Trigger forms you can find ``Logs`` button in top-right +hand corner. You can filter and group logs by following fields: + +* Sync Project +* Sync Task +* Job Trigger +* Job Start Time +* Log Level +* Status (Success / Fail) + +Demo Project: Odoo <-> Telegram +=============================== + +In this project we create new partners and attach messages sent to telegram bot. +Odoo Messages prefixed with ``/telegram`` are sent back to telegram. + +To try it, you need to install this module in demo mode. Also, your odoo +instance must be accessable over internet to receive telegram webhooks. Due to +telegram requirements, your web server must use http**s** connection. + +How it works +------------ + +*Webhook Trigger* waits for an update from telegram. Once it happened, the action depends on message text: + +* for ``/start`` message (it's sent on first bot usage), we reply with welcome + message (can be configured in project parameter TELEGRAM_WELCOME_MESSAGE) and + create a partner with **Internal Reference** equal to *@telegram* + +* for any other message we attach a message copy to the partner with corresponding **Internal Reference** + +*DB trigger* waits for a message attached to a telegram partner (telegram partners are filtered by **Internal Reference** field). If the message has ``/telegram`` prefix, task's code is run: + +* a message copy (after removeing the prefix) is sent to corresponding telegram user +* attach report message to the partner record + +Configuration +------------- + +In Telegram: + +* send message ``/new`` to @BotFather and follow further instructions to create bot and get the bot token + +In Odoo: + +* `Activate Developer Mode `__ +* Open menu ``[[ Settings ]] >> Technical >> Parameters >> System Parameters`` +* Check that parameter ``web.base.url`` is properly set and it's accessible over + internet (it should not localhost) +* Open menu ``[[ Sync Studio ]] >> Sync Projects`` +* Select *Demo Telegram Integration* project +* Go to ``Parameters`` tab +* Set **Secrets**: + + * TELEGRAM_BOT_TOKEN + +* Unarchive the project +* Open *Manual Triggers* Tab +* Click button ``[Run Now]`` near to *Setup* task + +Usage +----- + +In Telegram: + +* send some message to the created bot + +In Odoo: + +* Open Contacts/Customers menu +* RESULT: there is new partner with name *Telegram:* (the prefix can be configured in project parameter PARTNER_NAME_PREFIX) +* Open the partner and attach a log/message with prefix ``/telegram``, e.g. ``/telegram Hello! How can I help you?`` +* Wait few seconds to get confirmation +* RESULT: you will see new attached message from Odoo Bot with confirmation that message is sent to telegram + +In telegram: + +* RESULT: the message is delivered via bot + +You can continue chatting in this way + +Demo Project: Odoo2odoo +======================= + +In this project we push partners to external Odoo 12.0 and sync back avatar changes. + +To try it, you need to install this module in demo mode. + +How it works +------------ + +*DB trigger* waits for partner creation. When it happens, task's code is run: + +* creates a copy of partner on external Odoo + + * XMLRPC is used as API + +* gets back id of the partner copy on external Odoo +* attaches the id to the partner of our Odoo via ``set_link`` method + +To sync changes on external Odoo we use *Cron trigger*. It runs every 15 minutes. You can also run it manually. The code works as following: + +* call ``search_links`` function to get ids to sync and the oldest sync date +* request to the external Odoo for the partners, but filtered by sync time to don't load partner without new updates +* for each of the fetched partner compare its update time with sync date saved in the link + + * if a partner is updated since last sync, then update partner and sync date + +Configuration +------------- + +* Open menu ``[[ Sync Studio ]] >> Sync Projects`` +* Select *Demo Odoo2odoo integration* project +* Go to ``Parameters`` tab +* Set **Params**: + * URL, e.g. ``https://3674665-12-0.runbot41.odoo.com`` + * DB, e.g. ``odoo`` +* Set **Secrets**: + + * USERNAME, e.g. ``admin`` + * PASSWORD, e.g. ``admin`` +* Unarchive the project + +Usage +----- + +**Syncing new partner.** + +* Open Contacts/Customers menu +* Create new partner +* Go back to the project +* Click ``Logs`` button and check that there are no errors + +* Open the external Odoo + + * RESULT: the partner copy is on the external Odoo + * Update avatar image on it + +* Go back to the *Demo Odoo2odoo Integration* project in our Odoo +* Click ``Available Tasks`` tab +* Go to ``Sync Remote Partners Updates`` task +* Click on ``Available Triggers`` tab and go inside ``CHECK_EXTERNAL_ODOO`` trigger +* Make trigger Active on the upper right corner + +* Then you can trigger synchronization in some of the following ways: + + 1. Click ``[Run Manually]`` inside the trigger + + 2. Simply wait up to 15 minutes :) + +* Now open the partner in our Odoo +* RESULT: avatar is synced from external Odoo +* You can try to change avatar on external Odoo again and should get the same results + +**Uploading all existing partners.** + +* Open menu ``[[ Sync Studio ]] >> Sync Projects`` +* Select *Demo Odoo2odoo Integration* project +* Choose Sync Task *Sync Local Partners To Remote Odoo* +* Click button ``[Run Now]`` +* Open the external Odoo + + * RESULT: copies of all our partners are in the external Odoo; they have *Sync Studio:* prefix (can be configured in project parameter UPLOAD_ALL_PARTNER_PREFIX) + +Demo project: GitHub <-> Trello +=============================== + +In this project we create copies of github issues/pull requests and their +messages in trello cards. It's one side synchronization: new cards and message in +trello are not published in github. Trello and Github labels are +synchronized in both directions. + +To try it, you need to install this module in demo mode. Also, your odoo +instance must be accessible over internet to receive github and trello webhooks. + +How it works +------------ + + +*Github Webhook Trigger* waits from GitHub for issue creation and new messages: + +* if there is no trello card linked to the issue, then create trello card and link it with the issue +* if new message is posted in github issue, then post message copy in trello card + +*Github Webhook Trigger* waits from GitHub for label attaching/detaching (*Trello Webhook Trigger* works in the same way) + +* if label is attached in GitHub issue , then check for github label and trello + label links and create trello label if there is no such link yet +* if label is attached in github issue, then attach corresponding label in trello card +* if label is detached in github issue, then detach corresponding label in trello card + +*Github Webhook Trigger* waits from GitHub for label updating/deleting (*Trello Webhook Trigger* works in the same way): + +* if label is changed and there is trello label linked to it, then update the label +* if label is changed and there is trello label linked to it, then delete the label + +There is still possibility that labels are mismatch, e.g. due to github api +temporary unavailability or misfunction (e.g. api request to add label responded +with success, but label was not attached) or if odoo was stopped when github +tried to notify about updates. In some cases, we can just retry the handler +(e.g. there was an error on api request to github/trello, then the system tries +few times to repeat label attaching/detaching). As a solution for cases when +retrieng didn't help (e.g. api is still not working) or cannot help (e.g. odoo +didn't get webhhook notification), we run a *Cron Trigger* at night to check for +labels mismatch and synchronize them. In ``LABELS_MERGE_STRATEGY`` you can +choose which strategy to use: + +* ``USE_TRELLO`` -- ignore github labels and override them with trello labels +* ``USE_GITHUB`` -- ignore trello labels and override them with push github labels +* ``UNION`` -- add missed labels from both side +* ``INTERSECTION`` -- remove labels that are not attached on both side + +Configuration +------------- + +* Open menu ``[[ Sync Studio ]] >> Sync Projects`` +* Select *Demo Github-Trello Integration* project +* In ``Parameters`` tab set **Secrets** (check Description and Documentation links near the parameters table about how to get the secret parameters): + + * ``GITHUB_REPO`` + * ``GITHUB_TOKEN`` + * ``TRELLO_BOARD_ID`` + * ``TRELLO_KEY`` + * ``TRELLO_TOKEN`` + +* In *Available Tasks* tab: + + * Click ``[Edit]`` + * Open *Labels Conflict resolving* task + * In *Available Triggers* tab: + + * Open *CONFLICT_RESOLVING* Cron + * Change **Next Execution Date** in webhook to the night time + * Make it active on the upper right corner + * Click ``[Save]`` +* Make integration Active on the upper right corner +* In project's *Manual Triggers* tab: + + * Click ``[Run Now]`` buttons in trigger *SETUP_GITHUB* + * Click ``[Run Now]`` buttons in triggers *SETUP_TRELLO*. Note, that `it doesn't work `_ without one of the following workarounds: + + * open file ``sync/controllers/webhook.py`` and temporarily change ``type="json"`` to ``type="http"``. Revert the changes after successfully setting up trello + * add header "Content-Type: application/json" via your web server. Example for nginx: + + .. code-block:: nginx + + location /website/action-json/ { + proxy_set_header Content-Type "application/json"; + proxy_set_header Host $host; + proxy_pass http://localhost:8069; + } + + * After a successful *SETUP_TRELLO* trigger run, return everything to its original position, otherwise the project will not work correctly + + + +Usage +----- + +**Syncing new Github issue** + +* Open Github +* Create issue +* Open trello +* RESULT: you see a copy of the Github issue +* Go back to the Github issue +* Post a message +* Now go back to the trello card +* RESULT: you see a copy of the message +* You can also add/remove github issue labels or trello card labels. + + * RESULT: once you change them on one side, after short time, you will see the changes on another side + +**Labels syncing** + +* Open Github or Trello +* Rename or delete some label +* RESULT: the same happened in both systems + +**Conflict resolving** + +* Create a github issue and check that it's syncing to trello +* Stop Odoo +* Make *different* changes of labels both in github issue and trello card +* Start Odoo +* Open menu ``[[ Sync Studio ]] >> Projects`` +* Select *Demo Trello-Github integration* project +* Click ``[Edit]`` and open *Labels Conflict Resolving* task in *Available Tasks* tab +* Make ``CONFLICT_RESOLVING`` Cron Trigger run in one of the following ways + + 1. Choose Cron Trigger and click ``[Run Manually]`` + + 2. Change **Next Execution Date** to a past time and wait up to 1 minute + +* RESULT: the github issue and corresponding trello card the same set of labels. The merging is done according to selected stragegy in ``LABELS_MERGE_STRATEGY`` parameter. + + +**Syncing all existing Github issues.** + +* Open menu ``[[ Sync Studio ]] >> Projects`` +* Select *Demo Tello-Github Integration* project +* Click button ``[Run Now]`` near to ``PUSH_ALL_ISSUES`` manual trigger +* It will start asyncronious jobs. You can check progress via button *Jobs* +* After some time open Trello + + * RESULT: copies of all *open* github issues are in trello; they have *GITHUB:* prefix (can be configured in project parameter ISSUE_FROM_GITHUB_PREFIX) + +Custom Integration +================== + +If you made a custom integration via UI and want to package it into a module, +open the Sync Project and click ``[Actions] -> Export to XML`` button. diff --git a/sync/models/__init__.py b/sync/models/__init__.py new file mode 100644 index 00000000..7d64abba --- /dev/null +++ b/sync/models/__init__.py @@ -0,0 +1,14 @@ +# License MIT (https://opensource.org/licenses/MIT). + +from . import sync_project +from . import sync_task +from . import sync_trigger_mixin +from . import sync_trigger_cron +from . import sync_trigger_automation +from . import sync_trigger_webhook +from . import sync_trigger_button +from . import sync_job +from . import ir_logging +from . import ir_actions +from . import sync_link +from . import base diff --git a/sync/models/base.py b/sync/models/base.py new file mode 100644 index 00000000..f4d08dbd --- /dev/null +++ b/sync/models/base.py @@ -0,0 +1,16 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +from odoo import models + + +class Base(models.AbstractModel): + _inherit = "base" + + def set_link(self, relation_name, ref, sync_date=None, allow_many2many=False): + return self.env["sync.link"]._set_link_odoo( + self, relation_name, ref, sync_date, allow_many2many + ) + + def search_links(self, relation_name, refs=None): + return self.env["sync.link"]._search_links_odoo(self, relation_name, refs) diff --git a/sync/models/ir_actions.py b/sync/models/ir_actions.py new file mode 100644 index 00000000..eb0dada4 --- /dev/null +++ b/sync/models/ir_actions.py @@ -0,0 +1,13 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +from odoo import fields, models + + +class IrActionsServer(models.Model): + _inherit = "ir.actions.server" + + sync_task_id = fields.Many2one("sync.task") + sync_project_id = fields.Many2one( + "sync.project", related="sync_task_id.project_id", readonly=True + ) diff --git a/sync/models/ir_logging.py b/sync/models/ir_logging.py new file mode 100644 index 00000000..ff3107f5 --- /dev/null +++ b/sync/models/ir_logging.py @@ -0,0 +1,40 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +from odoo import fields, models + +LOG_DEBUG = "debug" +LOG_INFO = "info" +LOG_WARNING = "warning" +LOG_ERROR = "error" +LOG_CRITICAL = "critical" + +SHORT_MESSAGE_LINES = 5 +SHORT_MESSAGE_CHARS = 100 + + +class IrLogging(models.Model): + _inherit = "ir.logging" + + sync_job_id = fields.Many2one("sync.job") + sync_task_id = fields.Many2one("sync.task", related="sync_job_id.task_id") + sync_project_id = fields.Many2one( + "sync.project", related="sync_job_id.task_id.project_id" + ) + message_short = fields.Text(string="Message...", compute="_compute_message_short") + type = fields.Selection(selection_add=[("data_out", "Data Transmission")]) + + def _compute_message_short(self): + for r in self: + lines = r.message.split("\n") + message_short = "\n".join( + [ + line[:SHORT_MESSAGE_CHARS] + "..." + if len(line) > SHORT_MESSAGE_CHARS + else line + for line in lines[:SHORT_MESSAGE_LINES] + ] + ) + if len(lines) > SHORT_MESSAGE_LINES: + message_short += "\n..." + r.message_short = message_short diff --git a/sync/models/sync_job.py b/sync/models/sync_job.py new file mode 100644 index 00000000..38710e8c --- /dev/null +++ b/sync/models/sync_job.py @@ -0,0 +1,135 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +from odoo import api, fields, models +from odoo.tools.translate import _ + +from odoo.addons.queue_job.job import DONE, ENQUEUED, FAILED, PENDING, STARTED + +from .ir_logging import LOG_CRITICAL, LOG_ERROR, LOG_WARNING + +DONE_WARNING = "done_warning" +TRIGGER_MODEL2FIELD = { + "sync.trigger.cron": "trigger_cron_id", + "sync.trigger.automation": "trigger_automation_id", + "sync.trigger.webhook": "trigger_webhook_id", + "sync.trigger.button": "trigger_button_id", +} +TRIGGER_FIELDS = TRIGGER_MODEL2FIELD.values() + + +class SyncJob(models.Model): + + _name = "sync.job" + _description = "Sync Job" + _rec_name = "trigger_name" + _order = "id desc" + + trigger_name = fields.Char(compute="_compute_trigger_name", store=True) + trigger_cron_id = fields.Many2one("sync.trigger.cron", readonly=True) + trigger_automation_id = fields.Many2one("sync.trigger.automation", readonly=True) + trigger_webhook_id = fields.Many2one("sync.trigger.webhook", readonly=True) + trigger_button_id = fields.Many2one("sync.trigger.button", readonly=True) + task_id = fields.Many2one("sync.task", compute="_compute_sync_task_id", store=True) + project_id = fields.Many2one( + "sync.project", related="task_id.project_id", readonly=True + ) + parent_job_id = fields.Many2one("sync.job", readonly=True) + job_ids = fields.One2many("sync.job", "parent_job_id", "Sub jobs", readonly=True) + log_ids = fields.One2many("ir.logging", "sync_job_id", readonly=True) + log_count = fields.Integer(compute="_compute_log_count") + queue_job_id = fields.Many2one("queue.job", string="Queue Job", readonly=True) + queue_job_state = fields.Selection( + related="queue_job_id.state", readonly=True, string="Queue Job State" + ) + function = fields.Char(string="Task Function") + func_string = fields.Char( + related="queue_job_id.func_string", readonly=True, string="Function" + ) + retry = fields.Integer(related="queue_job_id.retry", readonly=True) + max_retries_str = fields.Char(compute="_compute_max_retries_str") + state = fields.Selection( + [ + (PENDING, "Pending"), + (ENQUEUED, "Enqueued"), + (STARTED, "Started"), + (DONE, "Done"), + (DONE_WARNING, "Done With Warnings"), + (FAILED, "Failed"), + ], + compute="_compute_state", + ) + in_progress = fields.Boolean(compute="_compute_state",) + + @api.depends("queue_job_id.max_retries") + def _compute_max_retries_str(self): + for r in self: + max_retries = r.queue_job_id.max_retries + if not max_retries: + r.max_retries_str = _("infinity") + else: + r.max_retries_str = str(max_retries) + + @api.depends("queue_job_id.state", "job_ids.queue_job_id.state", "log_ids.level") + def _compute_state(self): + for r in self: + jobs = r + r.job_ids + states = [q.state for q in jobs.mapped("queue_job_id")] + levels = {log.level for log in jobs.mapped("log_ids")} + computed_state = DONE + has_errors = any(lev in [LOG_CRITICAL, LOG_ERROR] for lev in levels) + has_warnings = any(lev == LOG_WARNING for lev in levels) + for s in [FAILED, STARTED, ENQUEUED, PENDING]: + if any(s == ss for ss in states): + computed_state = s + break + if computed_state == DONE and has_errors: + computed_state = FAILED + elif computed_state == DONE and has_warnings: + computed_state = DONE_WARNING + + r.state = computed_state + r.in_progress = any(s in [PENDING, ENQUEUED, STARTED] for s in states) + + @api.depends("log_ids") + def _compute_log_count(self): + for r in self: + r.log_count = len(r.log_ids) + + @api.depends("parent_job_id", *TRIGGER_FIELDS) + def _compute_sync_task_id(self): + for r in self: + if r.parent_job_id: + r.task_id = r.parent_job_id.task_id + for f in TRIGGER_FIELDS: + obj = getattr(r, f) + if obj: + r.task_id = obj.sync_task_id + break + + @api.depends(*TRIGGER_FIELDS) + def _compute_trigger_name(self): + for r in self: + if r.parent_job_id: + r.trigger_name = (r.parent_job_id.trigger_name or "") + "." + r.function + continue + for f in TRIGGER_FIELDS: + t = getattr(r, f) + if t: + r.trigger_name = t.trigger_name + break + + def create_trigger_job(self, trigger): + return self.create( + { + TRIGGER_MODEL2FIELD[trigger._name]: trigger.id, + "function": trigger._sync_handler, + } + ) + + def refresh_button(self): + # magic empty method to refresh form content + pass + + def requeue_button(self): + self.queue_job_id.requeue() diff --git a/sync/models/sync_link.py b/sync/models/sync_link.py new file mode 100644 index 00000000..381e1ca7 --- /dev/null +++ b/sync/models/sync_link.py @@ -0,0 +1,261 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +import logging + +from odoo import api, fields, models, tools +from odoo.exceptions import ValidationError +from odoo.tools.translate import _ + +from .ir_logging import LOG_DEBUG + +_logger = logging.getLogger(__name__) + +ODOO = "__odoo__" +ODOO_REF = "ref2" +EXTERNAL = "__external__" +EXTERNAL_REF = "ref1" + + +class SyncLink(models.Model): + + _name = "sync.link" + _description = "Resource Links" + _order = "id desc" + + relation = fields.Char("Relation Name", required=True) + system1 = fields.Char("System 1", required=True) + # index system2 only to make search "Odoo links" + system2 = fields.Char("System 2", required=True, index=True) + ref1 = fields.Char("Ref 1", required=True) + ref2 = fields.Char("Ref 2", required=True) + date = fields.Datetime( + string="Sync Date", default=fields.Datetime.now, required=True + ) + model = fields.Char("Odoo Model", index=True) + + @api.model_cr_context + def _auto_init(self): + res = super(SyncLink, self)._auto_init() + tools.create_unique_index( + self._cr, + "sync_link_refs_uniq_index", + self._table, + ["relation", "system1", "system2", "ref1", "ref2"], + ) + return res + + @api.model + def _log(self, *args, **kwargs): + log = self.env.context.get("log_function") + if not log: + return + kwargs.setdefault("name", "sync.link") + kwargs.setdefault("level", LOG_DEBUG) + return log(*args, **kwargs) + + # External links + @api.model + def refs2vals(self, external_refs): + external_refs = sorted( + external_refs.items(), key=lambda code_value: code_value[0] + ) + system1, ref1 = external_refs[0] + system2, ref2 = external_refs[1] + vals = { + "system1": system1, + "system2": system2, + "ref1": ref1, + "ref2": ref2, + } + for k in ["ref1", "ref2"]: + if vals[k] is None: + continue + if isinstance(vals[k], list): + vals[k] = [str(i) for i in vals[k]] + else: + vals[k] = str(vals[k]) + return vals + + @api.model + def _set_link_external( + self, relation, external_refs, sync_date=None, allow_many2many=False, model=None + ): + vals = self.refs2vals(external_refs) + # Check for existing records + if allow_many2many: + existing = self._search_links_external(relation, external_refs) + else: + # check existing links for a part of external_refs + refs1 = external_refs.copy() + refs2 = external_refs.copy() + for i, k in enumerate(external_refs.keys()): + if i: + refs1[k] = None + else: + refs2[k] = None + + existing = self._search_links_external( + relation, refs1 + ) or self._search_links_external(relation, refs2) + + if existing and not ( + existing.ref1 == vals["ref1"] and existing.ref2 == vals["ref2"] + ): + raise ValidationError( + _("%s link already exists: %s=%s, %s=%s") + % ( + relation, + existing.system1, + existing.ref1, + existing.system1, + existing.ref2, + ) + ) + + if existing: + self._log("{} Use existing link: {}".format(relation, vals)) + existing.update_links(sync_date) + return existing + + if sync_date: + vals["date"] = sync_date + vals["relation"] = relation + if model: + vals["model"] = model + self._log("Create link: %s" % vals) + return self.create(vals) + + @api.model + def _get_link_external(self, relation, external_refs): + links = self._search_links_external(relation, external_refs) + if len(links) > 1: + raise ValidationError( + _( + "get_link found multiple links. Use search_links for many2many relations" + ) + ) + self._log("Get link: {} {} -> {}".format(relation, external_refs, links)) + return links + + @api.model + def _search_links_external( + self, relation, external_refs, model=None, make_logs=False + ): + vals = self.refs2vals(external_refs) + domain = [("relation", "=", relation)] + if model: + domain.append(("model", "=", model)) + for k, v in vals.items(): + if not v: + continue + operator = "in" if isinstance(v, list) else "=" + domain.append((k, operator, v)) + links = self.search(domain) + if make_logs: + self._log("Search links: {} -> {}".format(domain, links)) + return links + + def get(self, system): + res = [] + for r in self: + if r.system1 == system: + res.append(r.ref1) + elif r.system2 == system: + res.append(r.ref2) + else: + raise ValueError( + _("Cannot find value for %s. Found: %s and %s") + % (system, r.system1, r.system2) + ) + return res + + # Odoo links + @property + def odoo(self): + res = None + for r in self: + record = self.env[r.model].browse(int(getattr(r, ODOO_REF))) + if res: + res |= record + else: + res = record + return res + + @property + def external(self): + res = [getattr(r, EXTERNAL_REF) for r in self] + if len(res) == 1: + return res[0] + return res + + def _set_link_odoo( + self, record, relation, ref, sync_date=None, allow_many2many=False + ): + refs = {ODOO: record.id, EXTERNAL: ref} + self._set_link_external( + relation, refs, sync_date, allow_many2many, record._name + ) + + def _get_link_odoo(self, relation, ref): + refs = {ODOO: None, EXTERNAL: ref} + return self._get_link_external(relation, refs) + + def _search_links_odoo(self, records, relation, refs=None): + refs = {ODOO: records.ids, EXTERNAL: refs} + return self._search_links_external( + relation, refs, model=records._name, make_logs=True + ) + + # Common API + def _get_link(self, rel, ref_info): + if isinstance(ref_info, dict): + # External link + external_refs = ref_info + return self._get_link_external(rel, external_refs) + else: + # Odoo link + ref = ref_info + return self._get_link_odoo(rel, ref) + + @property + def sync_date(self): + return min(r.date for r in self) + + def update_links(self, sync_date=None): + if not sync_date: + sync_date = fields.Datetime.now() + self.write({"date": sync_date}) + return self + + def __xor__(self, other): + return (self | other) - (self & other) + + def unlink(self): + self._log("Delete links: %s" % self) + return super(SyncLink, self).unlink() + + @api.model + def _get_eval_context(self): + env = self.env + + def set_link(rel, external_refs, sync_date=None, allow_many2many=False): + # Works for external links only + return env["sync.link"]._set_link_external( + rel, external_refs, sync_date, allow_many2many + ) + + def search_links(rel, external_refs): + # Works for external links only + return env["sync.link"]._search_links_external( + rel, external_refs, make_logs=True + ) + + def get_link(rel, ref_info): + return env["sync.link"]._get_link(rel, ref_info) + + return { + "set_link": set_link, + "search_links": search_links, + "get_link": get_link, + } diff --git a/sync/models/sync_project.py b/sync/models/sync_project.py new file mode 100644 index 00000000..3bb7901c --- /dev/null +++ b/sync/models/sync_project.py @@ -0,0 +1,314 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +import base64 +import datetime +import json +import logging +import time + +from pytz import timezone + +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools.safe_eval import safe_eval, test_python_expr +from odoo.tools.translate import _ + +from odoo.addons.base.models.ir_actions import dateutil +from odoo.addons.queue_job.exception import RetryableJobError + +from ..tools import safe_eval_extra, test_python_expr_extra +from .ir_logging import LOG_CRITICAL, LOG_DEBUG, LOG_ERROR, LOG_INFO, LOG_WARNING + +_logger = logging.getLogger(__name__) +DEFAULT_LOG_NAME = "Log" + + +def cleanup_eval_context(eval_context): + delete = [k for k in eval_context if k.startswith("_")] + for k in delete: + del eval_context[k] + return eval_context + + +class SyncProject(models.Model): + + _name = "sync.project" + _description = "Sync Project" + + name = fields.Char( + "Name", help="e.g. Legacy Migration or eCommerce Synchronization", required=True + ) + active = fields.Boolean(default=True) + secret_code = fields.Text( + "Protected Code", + groups="sync.sync_group_manager", + help="""First code to eval. + + Secret Params and package importing are available here only. + + Any variables and functions that don't start with underscore symbol will be available in Common Code and task's code. + + To log transmitted data, use log_transmission(receiver, data) function. + """, + ) + + secret_code_readonly = fields.Text( + "Protected Code (Readonly)", compute="_compute_secret_code_readonly" + ) + common_code = fields.Text( + "Common Code", + help=""" + A place for helpers and constants. + + You can add here a function or variable, that don't start with underscore and then reuse it in task's code. + """, + ) + param_ids = fields.One2many("sync.project.param", "project_id") + secret_ids = fields.One2many("sync.project.secret", "project_id") + task_ids = fields.One2many("sync.task", "project_id") + task_count = fields.Integer(compute="_compute_task_count") + trigger_cron_count = fields.Integer( + compute="_compute_triggers", help="Enabled Crons" + ) + trigger_automation_count = fields.Integer( + compute="_compute_triggers", help="Enabled DB Triggers" + ) + trigger_webhook_count = fields.Integer( + compute="_compute_triggers", help="Enabled Webhooks" + ) + trigger_button_count = fields.Integer( + compute="_compute_triggers", help="Manual Triggers" + ) + trigger_button_ids = fields.Many2many( + "sync.trigger.button", compute="_compute_triggers", string="Manual Triggers" + ) + job_ids = fields.One2many("sync.job", "project_id") + job_count = fields.Integer(compute="_compute_job_count") + log_ids = fields.One2many("ir.logging", "sync_project_id") + log_count = fields.Integer(compute="_compute_log_count") + + def _compute_secret_code_readonly(self): + for r in self: + r.secret_code_readonly = (r.sudo().secret_code or "").strip() + + def _compute_network_access_readonly(self): + for r in self: + r.network_access_readonly = r.sudo().network_access + + @api.depends("task_ids") + def _compute_task_count(self): + for r in self: + r.task_count = len(r.with_context(active_test=False).task_ids) + + @api.depends("job_ids") + def _compute_job_count(self): + for r in self: + r.job_count = len(r.job_ids) + + @api.depends("log_ids") + def _compute_log_count(self): + for r in self: + r.log_count = len(r.log_ids) + + def _compute_triggers(self): + for r in self: + r.trigger_cron_count = len(r.mapped("task_ids.cron_ids")) + r.trigger_automation_count = len(r.mapped("task_ids.automation_ids")) + r.trigger_webhook_count = len(r.mapped("task_ids.webhook_ids")) + r.trigger_button_count = len(r.mapped("task_ids.button_ids")) + r.trigger_button_ids = r.mapped("task_ids.button_ids") + + @api.constrains("secret_code", "common_code") + def _check_python_code(self): + for r in self.sudo().filtered("secret_code"): + msg = test_python_expr_extra( + expr=(r.secret_code or "").strip(), mode="exec" + ) + if msg: + raise ValidationError(msg) + + for r in self.sudo().filtered("common_code"): + msg = test_python_expr(expr=(r.common_code or "").strip(), mode="exec") + if msg: + raise ValidationError(msg) + + def _get_log_function(self, job, function): + self.ensure_one() + + def _log(cr, message, level, name, log_type): + cr.execute( + """ + INSERT INTO ir_logging(create_date, create_uid, type, dbname, name, level, message, path, line, func, sync_job_id) + VALUES (NOW() at time zone 'UTC', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + self.env.uid, + log_type, + self._cr.dbname, + name, + level, + message, + "sync.job", + job.id, + function, + job.id, + ), + ) + + def log(message, level=LOG_INFO, name=DEFAULT_LOG_NAME, log_type="server"): + if self.env.context.get("new_cursor_logs") is False: + return _log(self.env.cr, message, level, name, log_type) + + with self.env.registry.cursor() as cr: + return _log(cr, message, level, name, log_type) + + return log + + def _get_eval_context(self, job, log): + """Executed Secret and Common codes and return "exported" variables and functions""" + self.ensure_one() + log("Job started", LOG_DEBUG) + start_time = time.time() + + def add_job(function, **options): + if callable(function): + function = function.__name__ + + def f(*args, **kwargs): + sub_job = self.env["sync.job"].create( + {"parent_job_id": job.id, "function": function} + ) + queue_job = job.task_id.with_delay(**options).run( + sub_job, function, args, kwargs + ) + sub_job.queue_job_id = queue_job.db_record() + log( + "add_job: %s(*%s, **%s). See %s" + % (function, args, kwargs, sub_job), + level=LOG_INFO, + ) + + return f + + params = AttrDict() + for p in self.param_ids: + params[p.key] = p.value + + secrets = AttrDict() + for p in self.sudo().secret_ids: + secrets[p.key] = p.value + + webhooks = AttrDict() + for w in self.task_ids.mapped("webhook_ids"): + webhooks[w.trigger_name] = w.website_url + + def log_transmission(recipient_str, data_str): + log(data_str, name=recipient_str, log_type="data_out") + + def safe_getattr(o, k, d=None): + if k.startswith("_"): + raise ValidationError(_("You cannot use %s with getattr") % k) + return getattr(o, k, d) + + def safe_setattr(o, k, v): + if k.startswith("_"): + raise ValidationError(_("You cannot use %s with setattr") % k) + return setattr(o, k, v) + + context = dict(self.env.context, log_function=log) + env = self.env(context=context) + eval_context = dict( + **env["sync.link"]._get_eval_context(), + **{ + "env": env, + "log": log, + "log_transmission": log_transmission, + "LOG_DEBUG": LOG_DEBUG, + "LOG_INFO": LOG_INFO, + "LOG_WARNING": LOG_WARNING, + "LOG_ERROR": LOG_ERROR, + "LOG_CRITICAL": LOG_CRITICAL, + "params": params, + "secrets": secrets, + "webhooks": webhooks, + "user": self.env.user, + "trigger": job.trigger_name, + "add_job": add_job, + "json": json, + "UserError": UserError, + "ValidationError": ValidationError, + "OSError": OSError, + "RetryableJobError": RetryableJobError, + "getattr": safe_getattr, + "setattr": safe_setattr, + "time": time, + "datetime": datetime, + "dateutil": dateutil, + "timezone": timezone, + "b64encode": base64.b64encode, + "b64decode": base64.b64decode, + } + ) + reading_time = time.time() - start_time + + start_time = time.time() + safe_eval_extra( + self.secret_code_readonly, eval_context, mode="exec", nocopy=True + ) + executing_secret_code = time.time() - start_time + del eval_context["secrets"] + cleanup_eval_context(eval_context) + + start_time = time.time() + safe_eval( + (self.common_code or "").strip(), eval_context, mode="exec", nocopy=True + ) + executing_common_code = time.time() - start_time + log( + "Evalution context is prepared. Reading project data: %05.3f sec; Executing secret code: %05.3f sec; Executing Common Code: %05.3f sec" + % (reading_time, executing_secret_code, executing_common_code), + LOG_DEBUG, + ) + cleanup_eval_context(eval_context) + return eval_context + + +class SyncProjectParamMixin(models.AbstractModel): + + _name = "sync.project.param.mixin" + _description = "Template model for Parameters" + _rec_name = "key" + + key = fields.Char("Key", required=True) + value = fields.Char("Value") + description = fields.Char("Description", translate=True) + url = fields.Char("Documentation") + project_id = fields.Many2one("sync.project") + + _sql_constraints = [("key_uniq", "unique (project_id, key)", "Key must be unique.")] + + +class SyncProjectParam(models.Model): + + _name = "sync.project.param" + _description = "Project Parameter" + _inherit = "sync.project.param.mixin" + + value = fields.Char("Value", translate=True) + + +class SyncProjectSecret(models.Model): + + _name = "sync.project.secret" + _description = "Project Secret Parameter" + _inherit = "sync.project.param.mixin" + + value = fields.Char(groups="sync.sync_group_manager") + + +# see https://stackoverflow.com/a/14620633/222675 +class AttrDict(dict): + def __init__(self, *args, **kwargs): + super(AttrDict, self).__init__(*args, **kwargs) + self.__dict__ = self diff --git a/sync/models/sync_task.py b/sync/models/sync_task.py new file mode 100644 index 00000000..5de7801c --- /dev/null +++ b/sync/models/sync_task.py @@ -0,0 +1,167 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +import logging +import time +import traceback +from io import StringIO + +from odoo import api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.safe_eval import safe_eval, test_python_expr + +from odoo.addons.queue_job.job import job + +from .ir_logging import LOG_CRITICAL, LOG_DEBUG + +_logger = logging.getLogger(__name__) + + +class SyncTask(models.Model): + + _name = "sync.task" + _description = "Sync Task" + + project_id = fields.Many2one("sync.project") + name = fields.Char("Name", help="e.g. Sync Products", required=True) + code = fields.Text("Code") + active = fields.Boolean(default=True) + cron_ids = fields.One2many("sync.trigger.cron", "sync_task_id") + automation_ids = fields.One2many("sync.trigger.automation", "sync_task_id") + webhook_ids = fields.One2many("sync.trigger.webhook", "sync_task_id") + button_ids = fields.One2many( + "sync.trigger.button", "sync_task_id", string="Manual Triggers" + ) + active_cron_ids = fields.Many2many( + "sync.trigger.cron", + string="Enabled Crons", + compute="_compute_active_triggers", + context={"active_test": False}, + ) + active_automation_ids = fields.Many2many( + "sync.trigger.automation", + string="Enabled DB Triggers", + compute="_compute_active_triggers", + context={"active_test": False}, + ) + active_webhook_ids = fields.Many2many( + "sync.trigger.webhook", + string="Enabled Webhooks", + compute="_compute_active_triggers", + context={"active_test": False}, + ) + active_button_ids = fields.Many2many( + "sync.trigger.button", + string="Enabled Buttons", + compute="_compute_active_triggers", + context={"active_test": False}, + ) + job_ids = fields.One2many("sync.job", "task_id") + job_count = fields.Integer(compute="_compute_job_count") + log_ids = fields.One2many("ir.logging", "sync_task_id") + log_count = fields.Integer(compute="_compute_log_count") + + @api.depends("job_ids") + def _compute_job_count(self): + for r in self: + r.job_count = len(r.job_ids) + + @api.depends("log_ids") + def _compute_log_count(self): + for r in self: + r.log_count = len(r.log_ids) + + @api.constrains("code") + def _check_python_code(self): + for r in self.sudo().filtered("code"): + msg = test_python_expr(expr=r.code, mode="exec") + if msg: + raise ValidationError(msg) + + @api.depends( + "cron_ids.active", + "automation_ids.active", + "webhook_ids.active", + "button_ids.active", + ) + def _compute_active_triggers(self): + for r in self.with_context(active_test=False): + r.active_cron_ids = r.with_context(active_test=True).cron_ids + r.active_automation_ids = r.with_context(active_test=True).automation_ids + r.active_webhook_ids = r.with_context(active_test=True).webhook_ids + r.active_button_ids = r.with_context(active_test=True).button_ids + + def start( + self, trigger, args=None, with_delay=False, force=False, raise_on_error=True + ): + self.ensure_one() + if not force and not (self.active and self.project_id.active): + _logger.info( + "Triggering archived project or task: %s", trigger.trigger_name + ) + return None + + job = self.env["sync.job"].create_trigger_job(trigger) + run = self.with_delay().run if with_delay else self.run + if not with_delay and self.env.context.get("new_cursor_logs") is not False: + # log records are created via new cursor and they use job.id value for sync_job_id field + self.env.cr.commit() # pylint: disable=invalid-commit + + queue_job_or_result = run( + job, trigger._sync_handler, args, raise_on_error=raise_on_error + ) + if with_delay: + job.queue_job_id = queue_job_or_result.db_record() + return job + else: + return job, queue_job_or_result + + @job(retry_pattern={1: 5 * 60, 2: 15 * 60, 3: 60 * 60, 4: 3 * 60 * 60}) + def run(self, job, function, args=None, kwargs=None, raise_on_error=True): + log = self.project_id._get_log_function(job, function) + try: + eval_context = self.project_id._get_eval_context(job, log) + code = self.code + start_time = time.time() + result = self._eval(code, function, args, kwargs, eval_context) + log( + "Executing {}: {:05.3f} sec".format(function, time.time() - start_time), + LOG_DEBUG, + ) + log("Job finished") + return result, log + except Exception: + buff = StringIO() + traceback.print_exc(file=buff) + log(buff.getvalue(), LOG_CRITICAL) + if raise_on_error: + raise + + @api.model + def _eval(self, code, function, args, kwargs, eval_context): + ARGS = "EXECUTION_ARGS_" + KWARGS = "EXECUTION_KWARGS_" + RESULT = "EXECUTION_RESULT_" + + code += """ +{RESULT} = {function}(*{ARGS}, **{KWARGS}) + """.format( + RESULT=RESULT, function=function, ARGS=ARGS, KWARGS=KWARGS + ) + + eval_context[ARGS] = args or () + eval_context[KWARGS] = kwargs or {} + + safe_eval( + code, eval_context, mode="exec", nocopy=True + ) # nocopy allows to return RESULT + return eval_context[RESULT] + + def name_get(self): + if not self.env.context.get("name_with_project"): + return super(SyncTask, self).name_get() + result = [] + for r in self: + name = r.project_id.name + ": " + r.name + result.append((r.id, name)) + return result diff --git a/sync/models/sync_trigger_automation.py b/sync/models/sync_trigger_automation.py new file mode 100644 index 00000000..fd5e5e6b --- /dev/null +++ b/sync/models/sync_trigger_automation.py @@ -0,0 +1,44 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +from odoo import api, fields, models + + +class SyncTriggerAutomation(models.Model): + + _name = "sync.trigger.automation" + _inherit = ["sync.trigger.mixin", "sync.trigger.mixin.actions"] + _description = "DB Trigger" + _sync_handler = "handle_db" + _default_name = "DB Trigger" + + automation_id = fields.Many2one( + "base.automation", delegate=True, required=True, ondelete="cascade" + ) + + def start(self, records): + if self.active: + self.sync_task_id.start(self, args=(records,)) + + def get_code(self): + return ( + """ +env["sync.trigger.automation"].browse(%s).sudo().start(records) +""" + % self.id + ) + + @api.onchange("model_id") + def onchange_model_id(self): + self.model_name = self.model_id.model + + @api.onchange("trigger") + def onchange_trigger(self): + if self.trigger in ["on_create", "on_create_or_write", "on_unlink"]: + self.filter_pre_domain = ( + self.trg_date_id + ) = self.trg_date_range = self.trg_date_range_type = False + elif self.trigger in ["on_write", "on_create_or_write"]: + self.trg_date_id = self.trg_date_range = self.trg_date_range_type = False + elif self.trigger == "on_time": + self.filter_pre_domain = False diff --git a/sync/models/sync_trigger_button.py b/sync/models/sync_trigger_button.py new file mode 100644 index 00000000..efbf39e6 --- /dev/null +++ b/sync/models/sync_trigger_button.py @@ -0,0 +1,34 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +from odoo import fields, models + + +class SyncTriggerButton(models.Model): + + _name = "sync.trigger.button" + _inherit = "sync.trigger.mixin" + _description = "Manual Trigger" + _sync_handler = "handle_button" + + name = fields.Char("Description") + sync_task_id = fields.Many2one("sync.task", name="Task") + sync_project_id = fields.Many2one( + "sync.project", related="sync_task_id.project_id", readonly=True + ) + active = fields.Boolean(default=True) + + def start_button(self): + job, _result = self.start(raise_on_error=False) + return { + "name": "Job triggered by clicking Button", + "type": "ir.actions.act_window", + "view_type": "form", + "view_mode": "form", + "res_model": "sync.job", + "res_id": job.id, + "target": "self", + } + + def start(self, raise_on_error=True): + return self.sync_task_id.start(self, force=True, raise_on_error=raise_on_error) diff --git a/sync/models/sync_trigger_cron.py b/sync/models/sync_trigger_cron.py new file mode 100644 index 00000000..1613593d --- /dev/null +++ b/sync/models/sync_trigger_cron.py @@ -0,0 +1,64 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +from odoo import api, fields, models +from odoo.tools.translate import _ + + +class SyncTriggerCron(models.Model): + + _name = "sync.trigger.cron" + _inherit = [ + "sync.trigger.mixin", + "sync.trigger.mixin.model_id", + "sync.trigger.mixin.actions", + ] + _description = "Cron Trigger" + _sync_handler = "handle_cron" + + cron_id = fields.Many2one( + "ir.cron", delegate=True, required=True, ondelete="cascade" + ) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + vals.setdefault("name", vals.get("trigger_name", "Sync")) + return super(SyncTriggerCron, self).create(vals_list) + + def start_button(self): + job = self.start(force=True) + return { + "name": "Job triggered by clicking Button", + "type": "ir.actions.act_window", + "view_type": "form", + "view_mode": "form", + "res_model": "sync.job", + "res_id": job.id, + "target": "self", + } + + def start(self, force=False): + if self.active: + return self.sync_task_id.start(self, with_delay=True, force=force) + + def get_code(self): + return ( + """ +env["sync.trigger.cron"].browse(%s).start() +""" + % self.id + ) + + def name_get(self): + result = [] + for r in self: + name = _("%s: every %s %s") % ( + r.trigger_name, + r.interval_number, + r.interval_type, + ) + if r.numbercall > 0: + name += " (%s times)" % r.numbercall + result.append((r.id, name)) + return result diff --git a/sync/models/sync_trigger_mixin.py b/sync/models/sync_trigger_mixin.py new file mode 100644 index 00000000..ac75137b --- /dev/null +++ b/sync/models/sync_trigger_mixin.py @@ -0,0 +1,70 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +from odoo import api, fields, models + + +class SyncTriggerMixin(models.AbstractModel): + + _name = "sync.trigger.mixin" + _description = "Mixing for trigger models" + _rec_name = "trigger_name" + _default_name = None + + trigger_name = fields.Char( + "Trigger Name", help="Technical name to be used in task code", required=True + ) + job_ids = fields.One2many("sync.job", "task_id") + job_count = fields.Integer(compute="_compute_job_count") + + def _compute_job_count(self): + for r in self: + r.job_count = len(r.job_ids) + + @api.model + def default_get(self, fields): + vals = super(SyncTriggerMixin, self).default_get(fields) + if self._default_name: + vals["name"] = self._default_name + return vals + + def name_get(self): + result = [] + for r in self: + name = r.trigger_name + if r.name and r.name != self._default_name: + name += " " + r.name + result.append((r.id, name)) + return result + + +class SyncTriggerMixinModelId(models.AbstractModel): + + _name = "sync.trigger.mixin.model_id" + _description = "Mixing to fill model_id field" + + @api.model_create_multi + def create(self, vals_list): + model_id = self.env.ref("base.model_res_partner").id + for vals in vals_list: + vals.setdefault("model_id", model_id) + return super(SyncTriggerMixinModelId, self).create(vals_list) + + +class SyncTriggerMixinActions(models.AbstractModel): + + _name = "sync.trigger.mixin.actions" + _description = "Mixing for triggers that inherit actions" + + @api.model + def default_get(self, fields): + vals = super().default_get(fields) + vals["state"] = "code" + return vals + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for r in records: + r.code = r.get_code() + return records diff --git a/sync/models/sync_trigger_webhook.py b/sync/models/sync_trigger_webhook.py new file mode 100644 index 00000000..ac569764 --- /dev/null +++ b/sync/models/sync_trigger_webhook.py @@ -0,0 +1,98 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +from odoo import api, fields, models +from odoo.http import Response, request + +from .ir_logging import LOG_DEBUG + + +class SyncTriggerWebhook(models.Model): + + _name = "sync.trigger.webhook" + _inherit = [ + "sync.trigger.mixin", + "sync.trigger.mixin.model_id", + "sync.trigger.mixin.actions", + ] + _description = "Webhook Trigger" + _sync_handler = "handle_webhook" + _default_name = "Webhook" + + action_server_id = fields.Many2one( + "ir.actions.server", delegate=True, required=True, ondelete="cascade" + ) + active = fields.Boolean(default=True) + webhook_type = fields.Selection( + [("http", "application/x-www-form-urlencoded"), ("json", "application/json")], + string="Webhook Type", + default="json", + ) + website_url = fields.Char("Website URL", compute="_compute_website_url") + + @api.depends( + "webhook_type", + "action_server_id.state", + "action_server_id.website_published", + "action_server_id.website_path", + "action_server_id.xml_id", + ) + def _compute_website_url(self): + for r in self: + website_url = r.action_server_id.website_url + if not website_url: + continue + if r.webhook_type == "json": + website_url = website_url.replace( + "/website/action/", "/website/action-json/" + ) + r.website_url = website_url + + @api.model + def default_get(self, fields): + vals = super(SyncTriggerWebhook, self).default_get(fields) + vals["website_published"] = True + return vals + + def start(self): + record = self.sudo() + if record.active: + start_result = record.sync_task_id.start( + record, args=(request.httprequest,) + ) + if not start_result: + return self.make_response("Task or Project is disabled", 404) + + _job, (result, log) = start_result + return self._process_handler_result(result, log) + else: + return self.make_response("This webhook is disabled", 404) + + def get_code(self): + return ( + """ +action = env["sync.trigger.webhook"].browse(%s).start() +""" + % self.id + ) + + @api.model + def _process_handler_result(self, result, log): + if not result: + result = "OK" + data = None + headers = [] + status = 200 + if isinstance(result, tuple): + if len(result) == 3: + data, status, headers = result + elif len(result) == 2: + data, status = result + else: + data = result + log("Webhook response: {} {}\n{}".format(status, headers, data), LOG_DEBUG) + return self.make_response(data, status, headers) + + @api.model + def make_response(self, data, status=200, headers=None): + return Response(data, status=status, headers=headers) diff --git a/sync/security/ir.model.access.csv b/sync/security/ir.model.access.csv new file mode 100644 index 00000000..8cde8dc1 --- /dev/null +++ b/sync/security/ir.model.access.csv @@ -0,0 +1,29 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_sync_project_user,sync.project user,model_sync_project,sync_group_user,1,0,0,0 +access_sync_project_dev,sync.project dev,model_sync_project,sync_group_dev,1,1,0,1 +access_sync_project_manager,sync.project manager,model_sync_project,sync_group_manager,1,1,1,1 +access_sync_task_user,sync.task user,model_sync_task,sync_group_user,1,0,0,0 +access_sync_task_dev,sync.task dev,model_sync_task,sync_group_dev,1,1,1,1 +access_sync_task_manager,sync.task manager,model_sync_task,sync_group_manager,1,1,1,1 +access_sync_trigger_automation_user,sync.trigger.automation user,model_sync_trigger_automation,sync_group_user,1,0,0,0 +access_sync_trigger_automation_dev,sync.trigger.automation dev,model_sync_trigger_automation,sync_group_dev,1,1,1,1 +access_sync_trigger_automation_manager,sync.trigger.automation manager,model_sync_trigger_automation,sync_group_manager,1,1,1,1 +access_sync_trigger_button_user,sync.trigger.button user,model_sync_trigger_button,sync_group_user,1,0,0,0 +access_sync_trigger_button_dev,sync.trigger.button dev,model_sync_trigger_button,sync_group_dev,1,1,1,1 +access_sync_trigger_button_manager,sync.trigger.button manager,model_sync_trigger_button,sync_group_manager,1,1,1,1 +access_sync_trigger_cron_user,sync.trigger.cron user,model_sync_trigger_cron,sync_group_user,1,0,0,0 +access_sync_trigger_cron_dev,sync.trigger.cron dev,model_sync_trigger_cron,sync_group_dev,1,1,1,1 +access_sync_trigger_cron_manager,sync.trigger.cron manager,model_sync_trigger_cron,sync_group_manager,1,1,1,1 +access_sync_trigger_webhook_user,sync.trigger.webhook user,model_sync_trigger_webhook,sync_group_user,1,0,0,0 +access_sync_trigger_webhook_dev,sync.trigger.webhook dev,model_sync_trigger_webhook,sync_group_dev,1,1,1,1 +access_sync_trigger_webhook_manager,sync.trigger.webhook manager,model_sync_trigger_webhook,sync_group_manager,1,1,1,1 +access_sync_job_user,sync.job user,model_sync_job,sync_group_user,1,0,0,0 +access_sync_job_dev,sync.job dev,model_sync_job,sync_group_dev,1,0,0,0 +access_sync_job_manager,sync.job manager,model_sync_job,sync_group_manager,1,1,1,1 +access_sync_link_user,sync.link user,model_sync_link,sync_group_user,1,1,1,1 +access_sync_project_param_user,sync.project.param user,model_sync_project_param,sync_group_user,1,0,0,0 +access_sync_project_param_dev,sync.project.param dev,model_sync_project_param,sync_group_dev,1,1,1,1 +access_sync_project_param_manager,sync.project.param manager,model_sync_project_param,sync_group_manager,1,1,1,1 +access_sync_project_secret_user,sync.project.secret user,model_sync_project_secret,sync_group_user,1,0,0,0 +access_sync_project_secret_dev,sync.project.secret dev,model_sync_project_secret,sync_group_dev,1,1,1,1 +access_sync_project_secret_manager,sync.project.secret manager,model_sync_project_secret,sync_group_manager,1,1,1,1 diff --git a/sync/security/sync_groups.xml b/sync/security/sync_groups.xml new file mode 100644 index 00000000..f003cb4e --- /dev/null +++ b/sync/security/sync_groups.xml @@ -0,0 +1,37 @@ + + + + + + Sync Studio + User: read-only access; + Developer: restricted write access; + Manager: same as Developer, but access to secure staff. + + + + User + + + + + Developer + + + + + Manager + + + + + + + + + + + + + diff --git a/sync/static/description/icon.png b/sync/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b43a0a135f903a4c0c401f03a8690fbbd021a2ab GIT binary patch literal 3035 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 +
+
+

Sync Studio

+

Synchronize anything with anything

+
+
+ + +
+
+
+

+ Customize your integration right in Odoo backend! +

+
+
+ +
+
+
+ +
+
+
+

+ Disable part of integration tasks if needed. +

+
+
+ +
+
+
+ +
+
+
+ +
+ Key integration tools: +
    + +
  • + + Cron: execute custom code by cron schedule +
  • +
  • + + DB Handler: execute custom code on updates in database +
  • +
  • + + Incoming Webhook: process data/event send from external system +
  • +
  • + + Manual Triggering: execute custom code manually +
  • + +
+
+ +
+
+
+ +
+
+
+ +
+ You can check existing solutions based on this module. There is no module that fits you needs or you need an update, send a request to sync@it-projects.info . +
+ +
+
+
+ +
+
+
+

Open Source!

+
+

+ You can find our open github repository via the Website link in the module description above. You are welcome to make a contribution there! It could be either: +

    +
  • a new module
  • +
  • an update to existing module
  • +
  • + an issue with the data xml file attached (generate one via Action -> Export to XML button in project form) +
  • +
  • a bug report
  • +
  • a feature request
  • +
+

+

+ Your contribution will make other Odoo users happy , while IT Projects Labs will assist you on it: +

    +
  • we will test the updates
  • +
  • we will check bug reports
  • +
  • we will document the modules
  • +
  • we will port modules to the new Odoo versions
  • +
+

+

+ Sincerely, Ivan Yelizariev. +

+
+ +
+
+
diff --git a/sync/static/description/project-tasks.png b/sync/static/description/project-tasks.png new file mode 100644 index 0000000000000000000000000000000000000000..bf5e6cac624203f4eab750564b1ec122668e480c GIT binary patch literal 113586 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

DlrhfrqD{$ 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

%gvB7x{K zr4xJ#0|6RMOl)i*<^9>Y9Iuk7YVW?t8WC=zpyvqgIu4gfZXYiJ_mE4K?*XVyRn5{Ht;zIO z!5!os(V!JI_h#yyL?#fFSm;xwTE>eBeUfd-F2SK-mteN5$;B zMTRiRJEy+Hyf)ES*&5KfL@e+;fJ2TR?RZ1y6FsS^)2S5oP0pd)dd`ZkiTHx*t~kpFhmknj<11sdUa}aDD`%3r~d5M20j2f>;2+ z$JfgLrMV7*M-`hX*KxW%Gk>`FsZ^T174l8Do}Sq_gi`1n@6T2hhz7x1%~g}R9W6xws{RtU+Bn_sR(V%gDDkBL%UytVOpTPe#ZOoB zM5XcFLs(pslZh;?$q>+m!hbr@)-kU$?0E3V@($d73mhGXZ?Ot!n8TcR?H|a&oV33p zT3V;m?`;7$$ta1p?KsmzhZZ)K9AS!@?f9L*R9WX68}){fMK+im6Gp8CQ^H-M_l@f5Tngd}l)Xd7SE% z*F(Pfsp0`Cecq)sQnxLD3;NQvf3Wyth{TYgKZ9i?D=pt#&1I=7@(W`?{Q)NkGyG1WWB+81Z zG9#?Zz0WyBdPb~aAt3re68yE?%JV)3Kg4~-Q|b{39lihI_B>?*q%xY=6mmP5w42W68>W*EH0%Y>*nQH9 ztI%^NOe<6lN*)8a!h^S@9yHGe2$mCq#tN*iwYybD;RsYV1$V(O2tLhI-Q!gbSBy>`#AK7QA^jb6d z@*paiJS+fHX;j8HNVMdvRjV02PeLK(m!FvXwk3ms?leRBbzR4$k%)K06O7D8IZ%GY z++FJBd!AR;uuxsfdx`+sHhdxP*tqsS*-j(AcUC`(%UiB_*w`|s!VFbsWk1gEqIyP1 zuR1GJ%x1{ko()r#=+=v>#;I2kP^1^-xG|*>dMV^~{7w;P8jX@T8b}pr-#)&POnhGM z@I@NUbdPhX*Ox`K0IK~AW9|!LR`(o$qPu-f$a`Xd%S`Sr8)Ye%C+1{;b?Heg)S^bz zFlcG9*=IM|`JBw#)Z42>{R_)-s?V4TixmtgKQZIkrE!6~$~7Q`;JY1rdCoRKDAE{H z2mn|nIaAI&)P!+!(ToL@BTFIS78M?z+hX@jsj_LHPIBBP>0X>xtQSX8oai1O1!8^rRN=U32k zO7}%X9!?aJtKwI!C~Kt%iCoD*8#7Etb47Byz5Ps^h9Vi-VZa%U*=&Ln-{e{mE}bgk z+(hnl?6SLOAAe=Pr4A<8UR+HEc?bD=VY3jEdRHwt_Sd`RSOL z$=kD*bcsikXj1m%3%R?A;B>^^$pSox?gnM=HM+=aH7(PK`>JqlanP?DQ#LKus4h&o ztfjB|wPsY&Z|hjEjx;?(pR!9%mrE|;s`YazW;kmQBeZo}-MIbk?J8@j$J1-zpiyaR zGIihg12$Im7(N$6?-=)~PnT6wSwuklFxk3boj!85UFR>P^M&=T@ga*w{uP=C^d>@S zHbrJigBrr@mUMjwaY)!LWekz@>LuS`i)Sx_X?Cal39XTUXP8{DI08O2qOrn5J_1EC>x&+B5|*Rv(5j6< zF&!jj9z6RlkN~at3-4*yZs}b{SuA`PQ7%nB@?xcq+fNO_OLdDwensk3 zgo7^q=Lt_VC+8>R9nSCCbb{(?fYg5bpNcVZih>woooQ>y$wC>O0pHOb9N%t6<^}|G zVn{L_3pzm>i%}(GFwi!j1{_?2x{Q@0O0e}!j(}iDta&Z+SL&U~$D_HL0#Oz*m%h(4 ziDe+K+wDJBqEIp9W_VWL>)%gW|V@ zhtrfaK=2gJB`g4y!+$`bcmOXILqb=v`c225xJkFRdAH z)h8D&{IyC^fvzB+ADPqfA*ZH;ad%0dlY$Yv6?QD>v#SR+Z|!xxM}Hqfo|bsfjIv z%lk4l?4o|CZ0N?P~6aLj}35JV#X0|vW{GlsUhA{%eMB1gLLok*j z1irtnVi2V;%G&l@&f+`o87_mdp7vsiy*CxBhGwft1e!?#Cq!n7sXLAx+5ZqVBk{&bvPM5J(Q>*sfa9$9EVK_}4^AO9M z5k@80)}+TkKLBb%Ehc$~d9CIYhlh#TnN4UF4yU^Dd0Q8s?+r#(K(T_r8qjN})tkUL zVAAY+b-)&hR~2yFaW6CtTNf=Qkx$qst;#U3mS`rMnTu$KDUX8ZSG0peL=-};uu!1a z$o3vMdD@+`=O+sUlB*L(4iY2uudG!!xB$j!)9;iVcCoPvl0YHg`256`0@a1YhVQzs ze2mH9%It8)NS@*-GO9U%6QjSD7|!*LNU zdYi&<CFN1zEPqEV zI>l&lJ0ygG@tNu_St`o`1gOajS_YnPq(}S!vGM$pJNe>gF;cqj2gZ{S{s458DAuAy z*nNwktuJj2OG`$hL5Ym$#xxe#tWvBt=-52V!)d-JYu#$n4Ys$j>l+)0<4Z>;XJ-;M zCdAZ86pu3Ddi99Lm>obXarfi>HJj6h1S|qV*&B-pol5m@SQ0mp=AK&v2|%Y_Io61Y z6CsC1G(S!nx99ne#!;iH6l1hxB#w%G+h%_p&>Y7_oBoV044g*OTgWv9&-(qF{o|&a z^D*;2wm{go|Btt~j;gZx!iEJ7l1dyUq+6v$x?36x z4$>f@beD9uN=pffs5A&1y1To(L=GU`@XdMR_xQ;3{_}nB`qny&bt{K^X7=ov*|Yb) zuKhUE0NjY~253}bNbSnw+Ue=NCv2J5r>2EfLg|gm0I2%57Mz2KT_^jxLE-Z@AAqg> z;?CgykV7-rpZ`m^e)}42G(%Jg?8cJoovxVvXc1W4AHbAa4wT1$6NBTUp7Qk&1l=7} zIjrT6<2!r1lC^~3;(P-@aWni1;mXA9I$FWx1l5mrfgk*KY@@cpomkpo=>o-$sw$OK z5$QAu|G}6imX?->E2*CO0DXM3g(a}%*;|hFA6?N&D$l-80BGB-9sD{LRK9BUuk?;t zk20{4@5=N>c;7Q>|D1LX%&YbcO(_swD7Hb2au(~-2e^Re(IS&bf$wh)PY$Nt@Id5B z|D|HzkCYd^^^;lZc^50E1L5k^N%PhEa&IvB1sIdjC*GPo&RO{70X!E9zKB1rxZU} ze*;pz_pxC;I|GS&u`FZrA?$1?Zz0N)r8P&(WJWo$Ze<#ZH#+HLBf@NK`;FY7yy1a( zRA0?qW22)9JKyR%Gz6f(KG9~#%7HV5k#G_hzpOHAvmT56bH1*Caae}_Gmi8>6QX0L zAw}75=!2tJe$b|XF#6V*Nov~5AvnKQ|H89p0!+R{%LH^XjhIq*-rDLwifW5LevHUjf7yfm*E7)gD$a1ydsq2l-Rhwj; zmLwpayz1k-GJb_O2)ac?MDB*ZV_?XD6fqbYX#SbAKL8Tm6PrVe2d*NK5CzJ4hS4`V z^L%Z`odKq59F{r4Devp!G6}cgoDX4@)+&Mf>0`Mv?Bye;$K(09YW_PW_bdM(43y&Fy%uR?FG@$mVL>L60@Zd_E4$ zwZ_r>4QGB3u^2umHGVWikdqMhfQ4%=~n=9wvo&k;kO(7I3 zD_!JWoPeO5IScj|>p(3&3I+z#VrK%>W#mp!H7)}_*G5Zgkq?iDm9+$`IA)gO0Bgw#RT+w7; z(f)Vx1LQp22Z)+aTLu=F8uP{lupfS3miw7jmhB(n8btZ(RWYB=z%B%G0Sb*j@Rtxfuya zJxpG&{#S45_2RvhW~vt!FpWh$sZL8QQCu#=GGB`YAd!A)aj8-N0Tm6pg3fht^-|}q zV3AOZ)pc5k|1KcVI{?5!yWD4bJ#rL)%2;T8cKLUtVE{;WRnuDe&qMN;SwixV0GGIp z0qZ5HdgFq5=l5mh%T&133D6d{@d94~(WQ=yT}7TBbwo)6442g}0;r!&5-jvzih`Ug zYF{w#-Fi6PtC!V}L>8zYpK|tP4C(7Sr26Hfuh?In_%NjUkuwcn#*nt^LCkH5Zxcsl z4o-YVEMduB@i-Sbw3zb24e+e-{j|92pl*}fvU792>8^|Lm4q0j0g{>$PXM$T-kDOfZIF^)HJGMidoYeWo zCo`{8h}4L3;Z*&gqmfUBWoz{crNag2MbFb+07dZ#RuF#x)q zIH01{-AG!MXEAdVliKkOj8x_G+_0}?<@*ba_=_~WZxD2rZXCUT;4QQoYw4P*TdvKg z9QM0XDuRv49F-;KE_xAZ@2KDJzwQUaH|8P=^BKc6HNV7&@B-{aMWi?~b>>&^^U(aZ zLwMhpsr>|8>72)g`v(Ut$9rq6?_^wwqgUNMh*(9)em`Z!1Ze(!#C|h%UDa0oba15c z?a*BD_f2cn?~kSd%=EvT2D?lcIzZu~-AeptP5gNnirqr)Ce1z%ITBIAm42Uc>TrVm zyLG4cIq;c1H3AKplxcpBaltFf=5p@F{L~06v3BY(xHn!$*F5ig@b10%RH>3>A`DFd zeJ7NdApWdD+1VMMX2#pm;&?nrk;G?1ChR)we_jJklr-2&5yGkqTlJhCz}@%p+qsN*)X;bDRuI{tI$sYELgan2PL5o zili)^ilvJENjBQ{N)Lvps3(hd*;S{F>4wRKnYzBdI(i;(gNP(Ai+KNDRjUl)dp&hn zEP_0jv5VPbDJbZ+l#EQ>*cd0cr=Q28qNW%QcT$j?HJw=7*t{~br2UlP=jT_~)`kM| zB8j?Y;+y;WIAuaT4UY{eeo-_q@7}vAwJ#w%>}$h@2Qw!HKU+wx^GhQcs0c`dlPeC= zb&{X8BIbQ|h}q^dGqR{rCx(ZKy58~B%Wl?W{n(r=!ZRDr39PCTzFVU~5SORZ5V*&0Dd#Ul0Ql>SC0yRptP&--jzM3q=1~Hi%vh-`3 z5p;APqRgY1tkkqB53lg>@R*Lizkl~BTi7Ad@?$2eg`aa~FNN6H5`Laz+c@Nl%|}G8GyjA;Odt%hr^sn=~{8hj1eX zAy3MHy_!_7)EO>#nIK3G@Zq!nc{kv?8Fs=#uJ*-T`%++g`g+0y1-L|hBK%Q5e|mJ1 z@08y#z|_-Msw>&5b)B;E>oV=|&a+~L_W*~+xQW8^s~HBKA6!7PWjG>9xF|CeVx;0C z)(JmS-nbFQR&`>yn=dYpFN%#Rc0u!j!=Uxbjn-5=ZMTD#-8<0Pr`fm9`5jSbl#?T9 z(2noc3As)}Zrp`ksVa95$j`TawKW8}pRG&0$!+m``>rN{&&{~{C0CqFf>35Oe$vST zCBtcB`|)NU8K2X+i0kg>tY-U#04gK`Ypx-2Zq}O_{;COMs(k3-yyH?xL+k1r^vx=D zPVu@|dYf9Tq`zno11G8J3SphiDpg2 zLt>S?5>@EFYup}p`t04?i^-yGi?6RwKqB-JYk#B451_>Q$`ma41jY>~hKm52NIJ1( z$a-T^*nHd>J7?mFWZT;^n%G0E8FQayN=lMLR85{#nF+@seXG=MHOna8TV%g&FCXus z06eoh#&y|&=4&z1d}+2ne?DZ(NAVN@dZQe^gbTH-E;N4&>`2JYP5KPE#@d<;P9xloU(Dg+2ha4eDhJy*;3qx z+qxn($52ATh|N!y4ZUfbw4sSmo^u=QZng*jrAi_!0NL#FH{=PJS9222zwc_~KtS?< z#Xh1eNWZ~h*yRqF2}10HjL{o*&wW_k=}PJfJ}E(lUzXY@rHKf&oUcgyyDtJ4_~r^8 zXP$nM$|dRm$y-{KmWLY^q6={AJOI|q%~k$UHa|V}*@mCteFnLR{k4{esVDBiL*>d- zZ$HBdyj<6}43h;95A!r<=RNWA&+>LiL?n1+ZP1DP&YKg_AC}qbFLu62V6JQ<3%vH* zUfh2JDasEu?}7}Zu|K%2s6Zj`r5=h=r&*3qDo2bZ-8~)Z4Hpluv`63dMhlFXJ!xat zg|69!E^mJEIKd^4(4yowoCrR&x3dGg;bSEo4mNCZ(| z55y@k%V?;440c@yvs1R7&cPZdNbkU5T8S%Yozs7F_|unHHCMtT3VmmkT~z9mqCj6B zkYuxH_gJ_U<16yS+lnkr1_VTPLaP(3rX$s^*N8X*L9G%dIMb?OiZs6vBG zpBoiR56cyG0N&&=4jpL)=KAq9`Icv?dl*MEWhZ>j-@n>2@jls@#H!sk3hb>=L0fD$ zI;!Tlh0pxkb>t%!sv2Fqq+|n!-l!?O+*qb3H#O}+8tTtIPrr-~bzs(I-sR!HfB#7n zMSy1iE->`;e@kZky^-!GvWCLUseq1J<^M>ISgvue9$&iFQyj(I{BtWOkYSvI^nJ=Jn}v~t!iEl5jy zY=pa0)dJh;c)0uHOmx2+GTHp{VxgvCj#~0w#w6{dQn|Z#zudo*F-N$>cuPux^d7@w zc!pv|H2>qaX!*A^HV^L_eSPx$Zo)lj?n#@jls(3qd%1H( z!>&1-$7jROcs1<*4YEM3PG;DfnW!OunNHq~GjB=xR@)G*PQ`Sv328kxYz z`B6_%5}+{Be?!6lYIMESc=H}Dv6-;X(asq2jARqobAZl9x-yS&l14L@e=R zWWsw34BgMVg6BVqCpxR0eirnh#780c^~0Be)flc9unihCAFNnFbmUbRtP47hlA_~0 zTaq6cmBgF2DzjU%y;guYp|WWG`EdVWx++#!lc!>HLvU)^q&`~Z(*!)23O*2-musC- z5%FU<(dwI~RXOKUG-AkX_?Ft*PDhf7(k)@jOyb-44rrOu9ClvBw58f7KVpSX3Y^Gx z$Z~ru#-~>&iwS}{Vsq^-mT#EXzhzjnXx?2?rYB~Noyo1(cx9N%j(VJ3q>Sf+X9qU{ z92R;Cap%j~<2C2yW63;SSi^|TYYy`Jl`b`-a}BauN0#t8RN@|XDdulONKfwBOuEz{g*aZ27v`^@q<+Y}JckKU{|kc7 zgbYhk4P_~ojb(_5Lr-#p#t@eG!R>{TZtY?3lRU1%$#zYMl-Mty*+F{hx}*Td7(HdQ zZ%-&mxAtE#d#It52EJqdJn9)P$TK3|m3$IaA@KE_E++YXb78l8$Mo6Is_w0f+o-hE z|N0mccE0$I4t{vWw$mkj?@D>8jM8G1Y?f-7ZJXxyqt;uJ$7S28#JG#dsnDVAk@WgT zMMp+bN%DEd!;s%zMGHFhNd)|0GMGEv@968u~F@Vymf#C-U#U1O|Ex@zhwb?MclC|Jw)@5e*zZoOY6_?O-S9d3ON zp$84?{nId3BKzduxd6Y4_5WihyyG#Kb0!?0&2eErzjxyeaoq7)@ycc^m8#?Dl zjH*AVnj)EX6(M$7k5%V>mj_ZLsHlXU1Y(bFT>k59Ss;$J;pjc|941vf>fzGA3iyv_T59S#auj;$-zOQ&lq;C2BAvvn=1Yo&N`U`b zFXGDE^k3J|pU=;Qft)n*x}F+d78EAvhU^u&hnK}$ND1VWJ+l0@;Ig0~pc`~vA8%jo zg+?Th)3BM{5bUy`{(qe=F-3H{gN>;i_mlltVK+ydQ#ZXbKw@cP*c@h~OTlm3=x*E* z+vN_Tf0NU$Ypia7pL4wqgX$Zg&y-fp7?oY^`w$qzsyX~}=WqWT$5B8kkp1dty97WpM({w+oe}tsYx&B}4v68D39ZpWlVRslPwEBy zF7J!}#vg-U3;`XhGBJ70*?!_4Vr;+>F{+4ax^xbBtK(Wu>8CpvCMhn>8#NamBRvDRNSI0|Z9^Ft8OP}&g$zK@uLy^|l4E|6 z$7Mk$hddA%~lu7ZZR2`9`5g zh2wf5;B*}$)_tW9h*Jx|kTd}8D`GB?sst4sTT0sxPxQ368U*9q=KP7%J=A{LuEoAV z4DQB*H@pN{7mh~Qrxm(1y6$^VdCLHE@eBEAUkp3}eF5`P4#9;muJjyT#d)qiv$4Vq zsEr1{`Aqec(lExgThE(9ZiZroc$^)NiC!G3RGjTkO!;6(ZFVxoqCh)De8m=3=*?qU zH0B{gvvt0aW_ci>ESQ#%vd*tpd&Ll!jt>pZVwE*4!>Q#6P*_jRca0Vpu74X)wmUNh zgv-i$4&Qs{;5KA(8X#qLwO<19i6Q@c!#vF{P_|F2t=k|`rS_WB@9Rawc2CUD%<=55(GSkE^0tG7*)j3?v zOw9&vsqR89&+}I%M&`Zmy`O%0)FiF(&GbzX-^NtMh=vBfb($XwRGcxo$fU;+>MTW) z&I>XwOI{A$B@?j!oM=wCS-zHQhZ>mgu%^cQ#n3j6NrlqHsQ%IZM#bi{=}H%i?ZvkJ zHl|cK90b;Fpt39rbtr3iPf8nY+>mRoxp0$0o|z$H8_tX}-`X-g`4G=L10x7l>P z8q=4>{S;Fh2anw@>kHg~d)OfS~r_%{%21fKHv_%eQ(M}E*BDH5~Zz+;JRB66K zR3!^oH1>p3Rt5}x`6O>Ngq}-Q1uMk!*`#+w9rxnN9RP7StoDC$_Nm@^Q1b9Cf= ze0z_xRq$?G)Xgs58QczkDnK-0=eAjOpp{zp6)+sz9aOsj(CJi#zdu8>u+3ptmS|0% z2cX$5GQ=mxM}W6E7`?x)b9k~@Gd21=yC2U~wwb{FJjt$Gj?{2&`c4?}G5YGZ@@z7} zMpgSf+IkJh^&BZt*LH}>Ixx$ta?~o*EPiPer|pm1?`@BYSr!f|8v4fJ(zU{E`^wD+zgI^v`HNFX+^E}_7YM3epT)fI* zoG+vnsj<<}avKyYKXUrlX!11+n;B1Y(y^KolH{Q zOWg1Z-ZzjKByDTICoz8Yh?po8hM1?2bIX5?->#kFxJ0p==XusqRpGOsS8S}9Pcj?M zIi4ayx{)lAx?F|Lu3~*srS77_<*0A76r)~#+{YHCuzd}eMeU*S_-*I9uFw`YCUQ3- z;YufBw(r)b;GkTa=B{j@b-zgM^)z+;pLIj60OeEW$kRPfzD6o~tO3C~DfAWN2&Os9z_edvn{X_L6s?PX@%BCqt?6g9c@{p70!cns)X26w zr~z19C@A&=O1x#lv6%N71LBy7s90vJ&QF6>t&Wu?wJs+l?SCL|S*?R+@lEO~( z)SXmL!vHhU<7*ZY!lneH0iqTw6BLFEYxl&h zxa#&k*eT@SQ0j4W!_@tt zp+?cfRzb5~94$&?ZYH&Fs^``>_lTNcLi&^Wd^dAN3tDSYArgY*#M3l`;ag&}RSmF= z$Rf-})T=~x>_5puQ8;Evvy10YHzCcOYBogp`IG%6EJX9gb%J$S(AA$-$HX|^V7nZQ ztgLG4wBCFQ_&Lsx&3KzDYXz^dd8de<1_VM*V&!Jo{S135V*Lf$8w{Aen;soQ_QH10 zsEEFL-1y%u#QO;*{iIH@rERW+^m^*bN~u-_&-m_9&G>1ah_Jr3;@kM2w8!{Tm~x`b zXbDQGB2!p%vuI}d>6}}8li37nutsZ@C44Hsr?Vnrj18C{#7a&5s3p+WURvwD4Rx-4 zyM*4z%=S_7UV#tIy{cJ_FZfhJ_>*s{#9lETq^ue1=?tQv1SfKMw{i6>7|7Rd`{~tJ z-8^`pbuB@4r=`e}bF6uf;EiNLz7|cnFCNtga!i|fXB-Sz!E6sC*(>PzcK8}&Jw_;Z zEF;T9Z;UPK>Ue89dml6+7`W4FG;+l{kNbJSKTu+FZX2YB_R>=8;TypbMOS=Cd|a$+ znXhaqPN%@7yrS6BZ=x)rkH<1XT8q+U%VE$gs~^l(61W8!UcckRM4Mcq zlp}%(sS)ut;^27AW)b*vs>C7@>P2oVD_9rl7;w{aC>o8Ka$WY-an*`3MQeaIbg}|&6A|ik-n&3BC+vL6p%)rH0n-9O|iX;AwY{APzS!b7@ zNVReu8#CYf46Cn=szmD0rtXpQ%*xaPYrD(R_I?1}hOac|x_qh(I&o~#v?{nbfcgJm40$qx6{>mE)p}yyD7lZj2_c8hQoA2p| zEH*x8p@o;x_F^h_CfMs!7)k^fH3|yH36}+3Wjs>swc+2_iX+fI4Nx^o4>ID7=DVJ& z`?IYPQPXsHAsB4bNj7#Zdz4L6ifh_DD^>C`(kguL-XfK4CdI)3jp3 zZ_wG}+XP|mySWBR)R8FMvTRlSrU8co<19G?dqlZCIEy)y(c9hMACKsUb z8G}FT*N4O#%x`K~RDCO`<$Tj9&I{HqF6)HB~mLYN4+T$oG{tr+eiKERZUDPB=?pH zM8cS?ydg9(8OZro1)^ zqcN^J8<-Zit=o*Ab%Gk<9Oy6zA4je1dEYjvyS`lK?IEGh% zi>%{xrq0=9D_|=azAU^9QZtUvM{fM$Ayf7ftG6f>i8hZlmnFo3m6?T%#oFU{Djp_0 zDM*d1;d+QYSwXC&rvkZmggvzP~g*~V0N~=(}4GSk3ux*qU;(&nXcpaU@9`E!FKQqr~O3o zBZ5AIVpN%fzV2n@){E;eny~#oo7g@`#_t-8+@>MK7bY)UTyK-If1SycFX3>-qv366 zZ9R8_9Ft`N*c=<#0q52)HQ4pD`_~|LE8Q4~vyp$GU>PB=OcAwfBlVoNf#ky7^1V2#lBu9gqrZ*O$z(8q%M&{?p zVb*xnJ>U>C+}Z(ev9%s+J~UY1EM7Pr6QAgjDKASvN`;%A`=d3kItz4J2$Kau6c}I>iUeFbXLSPdo&d z&NCLR0zT=PmUjv@Sk>LQX^J&?PO?_kp132aqw-=EZn?8_%|b`pY<0AXsm~FrCRSNj z4EyX77*(dRcDXjsUB*Klu^ncK7h(<|s3}e)lP~OuGEB`TCA z&I-ob`n%=1zL;ZZaBg^~EV^6K9^&C6#)u~*`*U$tyP0oaYx%7Co{>29!g0@uPc@n@ z5#<-V`IalD}BA zRBMOcBfzb$^s5h-`gL!HLd&AI8SnTef9R-a=*qXJx}HXKnIGNpit?Kyt^|te(@5WU z!+En8dmoN2kkYUQFRKKac6;y|~$RQyAW zkuaf+j^Td9e08uJca-yH^a3AyNeB~a!dtIWT}pMk$wA?RIYkZ9o6r8)pQWg~AQ5(L z%e5a=F%CcSV+daga{yOW!fW8XWi-EkQ{$IE{B!Y9?*o^wVGoS|Kita_z`dN1-}mJC zW%u$Z6gZ`Y4VV5MQ9*jeUSnYa2Y9{A<2Szw?@u?hfeLV&?}x?s{^J+;t64EdWLyV} zOiB7bT=c@gVLi&FcKsix+uzOpzaAJ?m!?P}Ic%dVk!LKvkB&?K>3@t8h2ViGY9U7^ zl|lynB}czCHV6-h_}_Z`^s+l2{23=fC#d8n`pHirj?Lks8d2EMpSLJ4cS-(Ykp5sX zaN&wnkn`bBp1n(YS=OBjKx#WB$?|NMI)N_*Hlv-(v?->zr`BTXtF4xL}G} z@N?f@mUU|-PzNrni7$(nh0O`3h+Gmb|Eu=>r9?Y~AXHLmk@@y=sH738d$@CVU;N)2 zn0FLoJQq7{jMjfhGlKBJ_x=n8Tk0^R6Cc#RYLfkrZx!D6_FHC66_(+!n*P?-97dUt zk@k7}@{nm?_vUI>05T^KVX!#-IY&!(X$q)OHQs>{##(wj)w$GCEr^;F9S2;R0WVBH z5DUt-r1_865qADS`;VUBlY%HB-NHxM<=<(9fesLvSwB_3)B);yKtbNvJYu;leHLV7 zF_atrmHAR9KwDVLt$cT4FLhjuh|anneWx_EbGeHb#8!TOJlNpQn&B8zV@d?XGuPJQ z^^7`<3x?a?|CqMzVBH^35gh|AL*vJp%3bdtC7qgsYM54Qphj)g;e2Rpe7y5E&U;16QO3XFPhQ7v72-vskxbF=?+dmA?V&qs14G*tJ5$Blg zML48|kZ{nf7X-AMY07g)ybnoO{=j?N!LT$&#AV==se z3EJv>b;r9qR<+3RP1bBfVZ0r6tlAc ziF!!){z{3)XH^=q`=+n$M0JW^#<=Z+SdioXn8`#C0bMqrS>FeIr4wL&eoWc};I)am zj!Q-rxaw7}9>xr^&v>4#>9WmSy><&;*qJIivqWK0?XlO?6mS1!gdt9AKHCa}MYeL- zmm+024!-v_fXbcb0A*GZa+6u08TjINlxFghJofgOYp7Iec3;dOLjmW93wR?^U@)bE z8}Np+DiA&#&QfPL(ciECqMB!_SH3zR*|PTODZms+RMkghcDrR#S_!;$e7&b?5oyS?0}l&DKg4_S~6+(`rxywgSXY z%t2>jL2T!s6$JPY{R-^Gpz>$xt~A{A*l+%S7EF+s8k6-S$j%ua)5Nu#gxMNgMd~LB ztVbK46&%l8jfb)lhrU|y*nQGSC1~Suc;aIgWulo7bH{N1IpnH#0*uuh4yo&EfXbZ~09o113Kb8u&IRyeXiu@(NC_#IF$@$(v{>#;h)sI+c}vz@_>6YF*e)tt{T` z7An+I>%Qm-qR6nCU8fR$ll9r4d*J7VoYL9PbP-gI%?BaiULMQI?}^pA{AYY8 ze+mde=l(q{kbH$H#kszle}#PHNzKK11=QJ@91>9bip#Jn-Q7ZJK;^qArg8Q@ERmqo zS%+iT^g?x^BTvX9=>tWL$64U!*>TYfJTzAO_PbH$e!Z{7&y2 zu?Jb`Ow_9rs?znj$@WSp-;|{j-Un*?-Z!WVtKhI4hdo0>!$5tSt2MUnc?v2Ln}hT6 zj!&6VB2PDVFmoyKY5$hlx%C!5$@hgwQO9)Oa%Y-%ujmLm$>*C;o&+sh?4XSLfWTexxOB^DE5XYKHCkOVNHF>S+aYy%k!v91R>qaMwMC~B76ZTFrJnsR-j@qQg5tldObU)-|Z$)(>S96BO zakjQDdLHO>gFuNXK5Mt(`~q8qY$J#vIZCX4*Z*}>j8d5Wh`Aj2BOzJ0Kl?FpQ84uU zBHDca?n;}{@WIofB>pZ(1jk7#lkF3>F-2D!wB~lL_hKkc0&J-sP)o)`g!Bf0lrMEA z4CGVJqk`rMI$&PcKf$R@^oihJ_)R30BoGc3a+<&JJg=%r$m;M87pQactragU{jdr z7TQsayT5Kba5?3P98B8ngA<~n?I-t$*24J)@7%H~Z6k%7|5#p_n8o1dH+F4H zb|BJWnP&%ZFl}CRf7H-&KJ00Nq}LzW z4X0ZzQzjAQ{ORp~N^;OUSST=A8yFV)Dv>g#a~olML{3^#x0ymtGN_a}0_PDxkcTH3 zDw?*BIDHb(3HQV3wlNWgCgPBeB4TBKSDToA5TY{$;W)<>^f@1@71j0DIh_=RlDJPy z@nhZiUGSd^K=x= z{*>rVKXSL;vN)1W|1eyQvcz;)_5& z3V{Z5&*Rr##{+jpKYxAux6vpTON;O1!=JPJA12}_?&8OrWooQ^rmcvf^GuzoCv?9o zxIeDSLFmY)UKDfSQ5f);|GGiwzwS5CUlv(e zl8sQK``aMmS`f%$w0LWwR5<2>Wt>;@R!_^f+&ElgAfl~=W-M&#>&+d;QLoa|tjQ0u zcup-la}9x0a_w5ePqJa0Ale=y*coVM51(;r3?fkb`s9vgk#RV;`DpKFoRP;*^K_bytrB23l+3Sd+G80jle@FQ+TZ zxGg978jj2E{c6gCLn+7;)rKj4AcSSGsta$3i&?ij`>8@voZCD1i-w1B>v`4kCD+fo zKHaxqP*WvO)mL&HeB+`a&$jWl6REiQhbO|D2f**e} zNWP6E=!_iZswgVg$Qd_Qx|Im+(AN36!U58T8e(qM3A!KKf5-;Lp_9Yzb>8`NQz9Jv zX2@g@!Qrc`wwOlxjP5CtK{a55RsLf$MWzq|Bg`(W)O>8d@#rms0*lpjCCxyld5|MS z#G=S-gsmf0w1ycklu#>AY7A+xd~@U#X8QLH?ln4QBcLV@^-plbW{A%N2}DI0^f2#J zV?oDvQEOAWzR6Y)+1Hy;osB zUSRk+TOR|zNhzpQG?ojW$9CIpV|tuDtDz^9^?T0k>I3RDkXh3Le@b5mN8^6-SwZ`8&Q!lg!Eqb{5u8ORgOcchkz!rY<5M(r28zk5+Y-^R`h7(G9kxhT2C{gQknK?2K|_ThCRDjm0VZl)An zXPU6p(?$D-OTVR!zHa+tzEp6E?Aoe3vyBvrQiQuaPPZ7&asigjfQH&wM-ow8 zrGdZ3*+v_Wlc~?Ts9vmSs>XW-$B#Y<{wUcqa2>-V?E(Y;EMB+QGa$Xo39>)|MPWIUbS zf}284B)^>>o$=qwcMz& zCR!_+-%M@7VnlP!lzW2z_ACGC$V@7S%qw$b4%L%)Fk;{MR?x{bJ#gKzE#hGN>#N@# zH`cVs@1{O5GT6(8>^v+a>@4`9FaMhlhnf-#tmOzm5xsV}HEMb{>hII_f9;AP{`iJ_ zjA3d6Yw4r`s5%~1K@|Kr@1}v|EHe4-fV(I#Nw}xa4`dnjt*0TatPd_M4GYz~{R$iQ zj@Q*5|2AJitjLqMpoBy=(c{^c-CLe{#v<%x=}@9FfD0Jq%)xw>`r#U1#&MjkM}b{8 ze@k+qdmBUCNB&e$D_({np8reIKB=fj@TX4zUo#p|;2FAv_(Zaib!2-+Ac z%2dd-o~vi0JYeZTo{V<>FuDy*t12UPUH4)e=^Gn;S~yL6&p@_q2?nKeE$>+^wq4#1 zVB*A0b6H7t8jn&DS=}gM)B@#aa>--vKHX*&-SR=_vH+%TWZ7CNrk2Z+M!TTHk42Uu z*0Q7%Pnsf|hO^W3P&Qj9MNo8+Y3ewVknn$Th(8|qV#Vs|R7&JUiXP}Fv>Q8GAEcI? zW;*QG$x;P9zT%C%oVp)Otw>R)TLU|?oAzB&62ibn);3kd-F&LtVZ+iu;x+*Iw_~b_ zl+nctdN*LDjA>o&m(E>eIZ_&JD;EX=J{lX8e0ak0r20eGv>r#nd0aYUX%;dZb-t zS$$+YB7lBKO{oB$kpn3gs@k1p2W`|Qra-t_F-sYHSq0ns{ zC37u8$&%pXEwfOYZ>x`=L$*PJtoiQuzHZu3xMV){a{iO;;FzQNPn}NQV}|8t*X0K5yIsAoa)DlbGXx%=?*v!91t5oDzrCL`he0 zH}YQdawOLR$*}_*krkkL+o*o3$MF|!=Q)3HJCRRq3Xr9AX=Ej#j>du^0PT%AY#vY_ zBC^K;7tvRJ&V6Rj+eo;ZBU4x&I7dwi_OlU<8Q~%zWz>WQ4_uF__1-Wwa`EPM0@qv- zGF29sNHHne4_^GXqe1W=d_R2EvbgAH9xI$U_NGA=5!sb!{5aFzM3~ zeP^M+40w^Jf(KaWAs98gHWLAKt8Qu*DOIbV5)uByI@WB1fHb!dnwP)LEZVc!5!X}J zj&bz|Kd2R4+C#jh_zp5zF;`0~@!54tTgYyw{S?^HAW?D;Q8HmU0K56`^$|qi?Iqk9 zXmOA_fmm@MLp0z`M?Q4?!bFyX&NsYOMQBK%Q)ZhH!>&7BMhV3aL6l0vXRn5Fk3c)M znD>yGie-#N#PMjKJkIviOGikVH{XlzUemvWL#&cC&4s^2n0IJxrQ&rx#vdX z*o0(p#aidm%PVmhUNfP&l2#6is;QrgiBsN=$GA@ZUF!Qsxu|UCt@-Zca!?~FcQ6gY zMf98q+nTDyLXZPgUuX}wPVqyz>eF?IRG#k9QL^wdJfLXi)`;1*tRL~+C#^#@u6sXn z*D(i!d0v4#F)&{fyc5;+vUd>rpSl4K!Pu`#vhTMGPddZv-hu|ZHjF4JtIdp>W-<2| zAtByae41~%(n@q>WCz>ubI4D?LcjT=<3$g+n%f4xpy|!exBXE>%x8{0@|-1>>eWy0 zlWSXndNQ3fdH@*FZf_X)ejOaqcKR~2h3ch1`?{sy6{-dT(piCrq3Dw;z1zZW0R^{u z61ZCaI-{*mN`*(30H4V83Q!KdwC;Ja;+I2@h)^Z4mVvjuyrF?lstX6x9>Q+aVjC_U z?^}U6*WR;<7+cm^8c-JOqS*Dvx0-|(Owffhi2+yxZYYblEA_iJb^xW=sOYTkGL#m~ z7ZXqYm-`EO>!9%V%cOLpcX667LQsvBjD1b!XSvhj;UyQeA)-`W(C%nE%QCoyWn+ZZMOwS67Kwdyv2HL=tr>XW>98A zD$Tyme{-$|1k6j_=1EU={&lPNLLP(87O`rqaNbeG&<9?;48JP3t{=RLB|~k-a#+R< z0*tU)l+LYZ$@k+z(Q1DT=dA5p+3Ah=VL>N@2XdYj+qwmmU6udU$MZ)7P#GO*;C+}f z1XhX;9f-C#9_EkvIz(9Pr%84$cXE^Q_nWV<6=}&C%gJR*j9_YGC{Hd^b0`-fjdfrr zbWj*Hug=0m%U&w{AKuMHHn?xm)qRdJa?_~fZDvko5}!7}A2{`Y`%SW5m`UwOyQ*c} zfZXEvzQ-TXRV46+f?r`fQ+tGFb3z@uNpZ8i)8JhayswL+WRn@*SGMAW%# zl++9i(wD&I6q6A3sjNK2Et6l&4B&|FEX`8BoWF0{TO0F6F!>9a_gWd>dcsNMm&{88 zZ6rh5O04{ z(*acnN9-s`G#12xvPXHJQ8DuGOTz^)oPNE-jMS5~LS9=1mg6e}f(Kmp`I41IX zRYsQZs*L>zSba85jCLkAe@6>)Zi$Eg5iPJf1_xOQ2+QbQ1m#V4)ew|P*&YY=_oJ5<-wHWNsh>TeZz*V&b z+C?7yw9MA1l%0gwXaoKFjZQq_-PhryT#V8jhA)iMuHZP()ASa0iEA6RM;8DN;6rfy z7oYVXKs!NLgiEXDj;G%D-ZWo#u<7O^0KIGYM|jAkaI%d!(lM3I+T8bPv$+5NA?_`s zqFlo+U?n7_I~A0WZje$^r9`A_Xpk1kAp}K0N<@L7Mp{L>o1tR>5$T~zq@@|+yT@~m z=e*zhuJ!%=ez4Z8#VnY4p69;rYwv6CeZ8i(-LK=EL}d_EUOYEE_g4+NpReZi0n!N0 zzYWllbT9lml&2-3nF&6P8NCdV2s&|G5e0PUy)cnH3$X)}G{yZ}I3556ufy!p-3Mmn zqC+PKTB7b8s?BH#78#VZYyv61aqY8DVB*INNOW-x{~_)?A(^-Ziqhe9FqmhBL9=-n z<4UB*%5j68bwdpk5989mmZA9euWxrH9qc-=>TzTMw1 z+#XPdOC$Ur48>S1b;K|`g4$e;A5U|x?%=i&f^8f z65gzbpjtpRdLQJ#kz~#n5#oWoYrjw)1LMFpw9-#9)&TWZGITe)g!hY@*WjyJ;IWMr zZ^wdUrqlNgfQPHVp=vbzS(p7YobA6=A(I;BA+0xjZe^NY?sZSIR^^)egx8JB2pOMk zi1!-<$j8HjtdY$a@sW*%&5`|0e!a>c$8k5jTihRk&zxsS1%xv+VnhU~Dj1rUGwI@F zO@MP+GL304DoGA@y~2x_U#)ycOU3lpav$S+NUp2gP8%i6l1Ua+Y8|Wu%khKp@525$ zPn$p81D|I16TTFU=K}!*>VG%A{diwPO_Dz?@gSIL>9LB~rMa)Zf57pn!fJbAutT2S zQ=Jr%s=fb!&m8lYU;OtktN*naYj+aP8w1e9?f^E*PteKliB8 zbClIfrBTHBNql!glI-AZ|F46&e6ab#rYGL|c=-V~e2JPL{ju?kcyV?CNJV)X0z_QG zru=#gtmvh}W;Qomn!*}`a_HfvGjPh)iGg#brYI56WkP&8FeLy7mB~ z_FVf1=x&k@%yY;Dp8AZMW0^)`=vI&z?Ec%h+5hd{sf_QhmjQWK8Dz&d>vP_|=Q0x= z_BMaAL6>@`e8*Sr+b_4`so#iKloNw^WAwqrRj4e|uY@3wN&f34osUqHHT(ruoiY5k zzbG0eEqF2k6a7Re^?yo;77DO>Q>iEOL3XbfFe*w7hT<=@IUcZbaI!Y+5S2t68CJQZ z0giGYfcAl)2uyW*#}_zsrQK%lyq5e$iOle6I%~&Ad!1QA3%tx3tL*C=q`brXTy{Q1 z`rSAVw^8e|o%v#F9X;*f6gd%OBXL;QdS#RuqA?vXQ9CmP0(I7{q6=NE^D7HWbD9Lo zXOweVH3e>+k#OI4|GB+3niVacb|W=*%8o9GvhvfGI2FVw|U(_sG9& zD`Ti(w0%PiQwg9|YoM8o;!Z#2r~G&O`lHW41yaf)>o>+zRBsv0C{YPq$P?fYG)XBQ zU?_Va%WQz@5*jr!xKoOrV)RikuZFdT9ZScsNz z!nFT~b@gjPIg?&JF)#8*pzPUcGz@+~)&W1+3yznqOf;xJnN=x}tHVU^WBysfcl2&z z8b#B*%_aP#%%s6GHa%1z$gI3fa^$0S>ywc$s~l)>pgw~ez(CBZC8LB(Y7xY&+ARah za=D+S7f?`~Ip%q~06LfTx%|`~OgOd6*^sUtFw%6gBGTt8e%W?Ix;WqkUx0(>YsluF zg`MN0C2q!x4W+u{kXz!mbJ7PxuD!g*F*o#MTA|+KW45g0q3tnKs?1NemD!TAn9GAh zd?zcNH@}u+V0kfD3;h}o81Qtm-=DcoV5{_~%(gF;I&llrZ}|tWvjMWC)pW`ec&c-6 zeUErpVyVpex|=nl9yoaIDh{y1e2#NifdWGI=TpGnP5|=B75fS?e&l-EefXGG{>V6p z8{yO6Oa*JaPVBS(Q2#AGNu%$p6!H_lAQnX?HKo#`F}NWhybA%OQMxk+`=BWoqbhCg z-cH?bx#5eQ8V>3I=zar{%5SP2@dqwgTVTrcGD~9!LV*}8sZpn6kbj2t z0Sq3(*of&u27VVU9(r1hq{R3Lkc=zo{_;Jsgsy@S?(XJUUBRQgNN0zEih~hUuz1ig z7NeePIJ4xl=E99_PRY#%hPJR*{7`vxxED^T z;plL@KKz4#&f9?@rDiVcwhf z1UF=+j=C33(WK)D_VF)w21T6EpWz#61e79C_rLIg!=Vw6AyTlgF`UaJfB>KKzzPV3 z25Nq>jmaR81m7-BMw?Q31ffPX96v$CfG0Gqbj#>Vb)Rg`c&jMF#A7GkdKBwlaja{i&-xFY((R^@I?i3*GTLUq>5z zJ-N;hoNQ#Yx&swmLY&Ay`|+?Uk#Pj4>0rM{#k^GvE<0CkHk4lNjv*N;bb#JvYD`@K z;V<0ze_qnzMvte1**KGRz6}M(JavX7WhIJuwl*B}kGv0%Y%XNYpsd+@k_Vc*{h*h! zNRnlV6{}nZjP%?~3xaGW!6gQPK=HRJ(RMim)-8uc=53pECH%z^f1D8qkQ3g>9PdQa%tYOB@$Ca|?kT zoDT$@FT9kTCInK&Vl{z=?={ry*9@cXR$Cd8(l(l%HoTYJGU0h6zu&OTX;rMG!$PMc z&)dR;Qr>nnFIwm2D1PHM;gDDJqH)Z%-^{cBa`R9YfNGn~<$DxJP7iRX!J}9{%lzSF zG;Od520T~arpBM;NM972do7p9L_nQ>G0=M;{g9>|+mL9BSiINCY z;1GIs_kVoKHABfs)|X+8mrkG0dWkX;M-wfdHTOHr%V}r4K-S|3{lPNz+?MsW`^hD)INxJdT>6vXk+eAL3 zeLo1l6arH`*`95_6-}!kbERHGGCL-If81lsSG3K_P?zK1>O9;Ivng3Q4@Qfh`MAiu z*Z7Zsx;xO#i~ua&h)NE{_jG0f)*#IggBX z7i&N#lOet|o-aw{b7Lw);9?iK1i@8mH?>e)bhwr+gu)c)%)B|bKam;uJZB#j%Mr`= z6gUJ&_xy>N^J>kHQ7Bs5(OZ>q?ZDl8&Ci>b$9V6@U0>KuUZcZ;fL~{#(8dW5A?~Ms zJNJv8WfoYM`wM?g{muhA#NA&vDr;b ze-rchK9ztFrRay&O<~e$(xp^LJ|m^N#1ve-gJ5<+ti!3Q-Xm4%~q9keMBM?v{c@U7$MY84>l$z!LSij^p$_H z{f^dOl{A30T9iQN`&;zs-A5UA=GH0XGJtx4v~C2G=Qis=MOlaKB?(JdORqjmK)cmU zMDT>4egl5@-?|4b#!*%Gtu@dQL1idp_f9n@nmmng?{tb|Q%)Uz>wlaMP3$fMOh#dq zGJ6tq?}r75C=o9d!m#eWWdw zSvg0++KhlE+c;?bvKVP-#gQi3qPBu{v?qpX%&~?r-R8{ku;lfM*-|m%71pX)i%ofP1OR<2)PTkD*OO<`r7 z;D0Qgs~=FqAHy@YS+l|twhEMS-e)XYachNj{m}#+kN?)5Xx*iEvRHm1QYgCcS{O@z z1lP}YwlQE(nKlR2&f=WH)%Z_=+1!mOFpn4YBSMSc?PmF%~Oj3#qyV^IH;VdrQ*@YlldzHft!EICgNlpd7l?QyxX)FiIMu%p1+=5>9^$ARzD%{0Nnu_0vn41 zHF&uKu9l!aNS@&ncJr3hv!an9TX(Y}$C--_@90a{`M5gC`%syJ-&q%RKDnW4=Pu{J zSWM}zNK0Jxe*qWVra+4T+r}qw(X+1T98%6sG@I8gkolYYd@ zi3nY~z^3*<{b{DSv4S|-c7x4vPt%I?J3&Uzg3qgB!T9s4HQGtX3iL#^^1?=6jiQrR zq*g#LT!OpE^(3zNfnzF+$JQ`~pupKAYez5lp2K8AP?a_fPzmp>(964I9KBTIEbt`o zk8$q_p&UR#zl`QS3zg7#pvF{(|cg$NM>GsY(y1_DCqi6W4Tbb1`S zp$}ltGpjf5pa0NXWHWzuc>m-gOs9oV^^W>4yg3EPw*lRI;qc~w@(i3Z^V{KHzuyKW#Zsv{b+_r&`v4v!X{4nD<~c&RW< zyT(5%V=)yQswPfE9DWX#`Xu$$HDQJK%3&B%%kTo5uRL(4&U1}Mm|5OE{E~USPsvh$ z+T@a2%3SxIx&rg2XKZ z3e~?}hN#N7KZ+K->*UcQbvZ6-O@`TB^ubrM6mw-bMCUZ+Y;vf6hD&6)KQktFwU(J_jySFu= zNjv^6UDXtMhO^t*n>&$IG*jZu!kG5f9CBjd%;Q-*mP1*18n~*8!xJ56q-pQGLd{QK z0y*HhF!sPZc}mpI$rL84w^ZHvFVxxxea_TUUEC6Nc>pe+zvnld;N$fR4wX7_f?Q*X zOkLMek%_m3Kqhe_U4ZKPyQldd=H^=9Z&3>%Q785sYQtFkx(s>9DS+hX870)Dh};oh z8TG`qVP6jl0*}((M?Jq89RM@fAj_&wKgppifT(;d9*7~J-3&4ymPH`b%?|Xea;yd% zX7AodOp)C+E;NyXF2n3hriw>|yLZ*(Qz0a}=JE(8zA(2|ykKvTPjj30$8RIc&5(-_ zB7QfhztsB?Z_fP8-%6q_Jnl)ZA4SoWpP&wyQI5wRv~H_V{_)_D z@uJgr+nc!JA2mVl@1nuE)_ZH%>%(* z;z;O!N=Xe&DJe5Q|K^s`O@)yA*ISYT^6}X?^b^heKBbrleOeI#?OYcDEu5z)U&b3RC zPpXeHzU}aELMPqPDv&a|mU&|5j&rY*YnyQOJ@O=83WzcZHGFRdWD|1Y*h6`1401}k zfM#oG^P$j*)T;l{Tv(yx`dnLWAKmtRS@>dN;K$oIEGMDwVLPo@*fzHEoX~tu&D0;f z2&Bmx&97fCEzg{TQ?bI-ejjrvR+1*h_Y2&BPloJv7Iclf_ve;oGYCA zbzoQE(jI0)WAM6Y(<@Z0HU}o_tH^%H98x*MDa&{KHLy3G2j1{_jXM8iVA82`M*7Qn z*Of}!GYYk?^j1xthsfosl}Je34>#e&JOokJ2%QuLv6&Zt6dXdmfHyoFAzs)I8`)*D;BI zft}}U6?#B&_IlJPlqYERy5o+~xqWZw@PNk}sei(YHen+{l}CvN@8Hso;~dXval@Y5 zJeH)!5izC}cN&{jGSCpd%+oY%w_F@g*rP1 zjh-6C4^%M}5OV=Enbf$!S>T;(qKyHq@J_$l$`Hit=^V>Vj>r<^V_^;XVdGF=qo8WTB|lyv(J|Yj$wau$efoCrboK-nR$02QO$(Pjfv z1>>`-i6yhLHk~W7w!L_bz5F+F6;A&n*n{tAysMZWKK(X%{%1 z&CJzr#3!aPtkyqKQt;cmC|_pvA8<1oide3R`YgbY3V+<@_;L2oEGJrOO12<`9?|D; z@tqNYA!v%;Ki{RT{Vnwy-rg4x9Yy$78hR5JHwxk-G*JrQPO|Xi7JQ2%? z_WU@~U#q#{V!HLyI&Y=hBtAJdL8GL^ylJKI-fQK10&=VGHJ^AAr*q}$H?H1H8p^uJ z>uBCOj3PtQ8_u|;THZ{;cD;MgQQCLF<|PF45uK-o(6Th2ATgMa_tMDCVWzRcJrKK! z{_nEM`gaTbxBgSE50Kjoj$_3cJG`S*?nTQO=q;wLCA9q(Ai)-l*MBdI!gNbA-P8=;o-ieGXdBoYxF|v;2=m(%R6x!kf+WlSLFWV>MUGM zb$6aE&}FK75K*TI;%j<_Rl#=sS@8oz#mD0&9i@k6K9Ii%JR~sy3d3({Tu4IsAX4I> z&GVtDnY94FCRv&KMn1G>M%`zwzhUyi>r@ifXXOrRxz)l|b@?xb8%q-~Jp_g*7yFqa z!$p=N-#$ePzD88gWS|L;^ixRD(k-8a{i3PPTj|jE(8@Zd!vv0Bh)Ak;e%>*@@Eb4i=O-PrTnMU%rfc}@~$ z1Ogfl7brK@_+=&0JL6EdA~AY&6yM2?i2e-dOyn6P4dtI^;CPfTgy*+rZd{-|`{@>M z2~XoXDbJoe7j{}v^n07|bwFPJS97B7XDZ;UT*ZQkfz!@uDNM7KyKit z1b%#a674kLI)| zgTki{D=#|u~Y1FBJ56rQ>vm^Hd&a$J9=9k>Gv4|@=H`xPOuZ@V8~doy0t zd4n+n8MK_bj%1ku0-ieQ`#@{#w;MSSP=~7PmF+XDl&@uv(r--Ug$x?)S94# z?zykHt1gikuKd<_2(B(FCW4{8NSGGmm4BrNh2gI?1#J z=>CnewDSdGN9oGYfN}S@n>RV6v{U%5rv#LF#fkall~DeC`EbcyM$sS%TPv95KV3@K z<6VxWeGPdEJx&TvhW>XVRB*X6vkKRdKxg)!tj_Sxuiu4$z29`TfF5ZiR|mOPQQ`r z{xtXfKJpwei19km4e14z{M@yUxtz#o{#_T2fDI%YL!s) zTm)T?dn)Q%UYQnPOMVz+lx<9GTbN|y%u!zH9^GLKW6NSpQ$bx?c9@U>`^v;cOdvJ`M_k!p6;_=^`?80vy#t0VBq={aQ&-nw^)u=%>w4o|N zcX#(DX$S>*q)is+4!dm4ORc2F7xJ0p>+x}pfX0JMBEKHn%UkG8mY`Gj6$K{yjQz*~ zgfOV?D8ul#feprZ6IEl{2<*9T`a0M7fkwp@<4dm-#$A&}2*^>&ahL*at@JCk@(?K# zUxmwdEq#HDZEZ>^z~`F6L{V)-1TZYv+I>NUaH|`gyRth zOaVyj0jgye@^IW+ywSY)mcvRY;RM+&y7Xw!dnz}KVgn|&LB3>6*tW#k!==4^phv}& z(u`L|t3a>x`b>WCYN|RSbpk5z?UyUai{`_B%6SMn{;>J0WHDNzA;;qp&R?uEJPI7% zb9CN>v0!!+n#};}&n|&mmt~%xPUmM#!v~ic?_bPabYX-uNAf2Bz4!f+ROeD?0$WrD z`FeVa8-!5(US|~jLK}PjQMho^*McoF(C3Hr3;P~4)?eKE z7p=GaRGL?6JuaxhR{Zrz_UJD3D7n__FlSinGbF}~3Bf&a4o`-3m+~5R_AKDM^T$A%T|7vcH=%WxX1Qa3tEw!sT(Bzj>qI|~ ziLfPN{V;up5y+xH9vW01>VEV(gp#)1o@p*U;;Sxhh)~{`bdj!Y|Jz-B&FUYC6<`Nw zI8Q)Hovy2Vx5$QD_f8+=2FF#bTL6Cll83_+BLs;N|J> zqfc}=5Ja~FEdOF?$D>>2tMI2~D#I^pV0n7cc=1wWn5R4gtD7C4LyarV{o5+4@KWxh z`_rV;D!c(rJOWZV4!d!fOCB5(d@lo?bC>zPbQzORK=HS*u2Eg*y|sB@4=np*V^v|) zQ4KxST$Y|I<;H(Nyr41w@iqUyf%u@hz>)^Nx*`ww9^f*Mxpj2t#*8K9tK&tP`j7^i2UBA*zACM;~ z1LADXmG*0BeH)$5cXv~aBad8ZRw5=Nfem4Hhi_HmB}|O=_P`ol+}nVq4jY{8dye0l ztQO83r)gc}I%NXPY8X>DJem(`$c@ib_D=<^qCpD8bT2q@)a7q^!hiP}?(mXrOCgwu ztX_LkiX)KkRB19Ynimo;1;X$N30pwjjC?H{%GngP9mzHH-YS1bjV~)Ride8m0}a^2 zsN>Q}V2@(AKbA%>(h6yf)|FZ9#zU{e4*CSzc-LF0Y+t)<>heCy4!^&a^)Rn_k=9Nn zZpu^(9VHV-&b|}1D156=po*8FEz@%GzBG`-3U9iR-pHD?-l$JEw{<>nHvf^vofReQ zaW~lf<*LYGgu8_mIK4RDYOs#|pj8%WYUTvetRC)XhstlDFeu+Kab#s+WbteYm+q0S zx{o*U$hvV@tX-urZc9?()WLr2j>~FoE~(0E`1#&_oCXvHYr<9GBJHmvh9jhN*Q?&# zP`=doah$bC_WTyVY17LS9frBC5>vMu_g{TqErsaRNkes`<^I;G@fGW#d0x5t2bk zbRjR(Tvq#da1Gy9&s83HYQC_P(ZF}-3NEPVP~HRTlqwE2TB1S{T3Z6PuxgsKU+wN4 z2P@b_(%(3A`fK{0=dl<2qEnC9c)aCqj;tJitx@!Gw61`@+Jh?;Sv-tBCwMh;aJp^q z;5?-@$b0xN3Wg&6d6vb}?K#dNnJ7-8+`Hetri`7RY4V;}_=fn>jFMZqZgA4!d#>x? zq;}39z0-NB@>@eN8_fP8$d#pCZ5e3GH!u8tYzjH ztChH~T+}o5P)4}ASotl|lTPvGw;s5D9-cf4aQK`8yX9(|&2)od~bOfUIm#bOCX4uF~5lAZ- z7%XA+8P3tMtog$QWE5_M7_MR7~q=oxNV)tO4WYKWA$t(N#7S`Bkkh%T3f9Y338=Sr8j`Ie0; z$mQbd5N)5D;6(#4lZ=j1;tXKfl9TXr6klbPLs9WrUS?%!M`Y>5u*%0^?5rle^Kvpl zuudszeiJ$nGNzoX4g-@n`E;%)snYKJpr+Z|E|+u%I*cKKp=em93CK{Ow6JBZPdxCQ z-d*gD?<3cgp}>qNS!S|kk!`WcNt#A36oGyH6&OsBG_SZ<08CaTw>xFxlkINohSSEp zZx5;_%?G~o<#3Ml!*O75s|SiT+jWe;Nh3 zhacSDn;xyEu-!`;fze)8l4$Wi|LNr#91QYYLF$$MiXt9IL5@!YL_Hg6hZJBqD`oq%{%IYs*K~lRIlH%1rt2Uz`J9h)GDq z?Vf=EAS#)GM5r?m?ip{JsJ+&HjRLt^nYMk&%Te=y^~nU9EfwCa$*KVp5Gj#<6Qn>f ztU;3W`ef^6@R9;B%252}?QJ&?Qe0Xd{R>dMEhBZry~&q>O)Uf1_h;q41Nmu&c~hVP z7-mp0V}{*1r|0I@HWQCVXQ|e8uw;11P#vA#A5;&<0%Q}y`2uo!SldNXV)sXo z-bOma8s0676VNd;Y4D_Wcsm25O+Q4p<~60<6CEJP2~RKfW%GiMy}0YT-#MOdryba= zHa50o=Rw+D4sRuGVw!sOfnFoJ+17)0b>F%(4gO+;xLrWC%JS)%^qBG{+OWcQ94v64De%e*zyP z7)0xIu36G|!DVe#rp+btw(mz}nf3<|cSp&sGfiHZ$Zsl4zRhb`n)E|vVEznvBQ+LL z<0$2%wFVCy*GB5_rh%8U!}{0{t2VByGbS+}6P*UJ?w;W<$F6wYDI|`~RIXV*yp{o( z9=xD?djiIQRCG4IIO{xY78p-^;F}>)2(j&L@7n*GWiMTY9eBH%J|?@H+<)hWSCIRd z$%3Zf!+ghz%{f|$T0K?{X) zTn`X-rlFK<`CV6Vw9u3h7N%fLA!j5R&Z^^p4~{c4OR`q{P* z4&F>m_4gTh`r3f+T?+-9I3fda{=l3K2Dt1)H*MafM-l+Nr3f)bVP9C1V{O;>mYB7o zFX|21hprSMt!^)g@Jf#fcC8MRK^o+j8>V^I&W{D$y)7yR89N|Dh`yuRX&;<0oQ3gC zEr@qhR&MW2XoBU*2z}BH;$lFuYP=-4P^?gV8oF#+ubutNG(@Tp{T>q2U#Bwqr{@h` z$b{qg0}tGOy0z#R7A&XXCpjL=H;%^~i0m zaC(9qNNyLS)YEM4kL?|2wP3yt4lqOLl&OxzLmGj>Oo^PA<`Slx95q`hMni8^;Yib& z*-T7GC`@iZvo*hE*Pkj>Bod;=I&R<}6)0$mnX4pb`To;4;FaqD2JmS7?5oqPDSc@W zjuar9OMbSuHd>SgvUY4Uf}ssk{F``owc2xJ4YA;JHZvT>8L!M4V`Tc@`;>ZPwF z7Md%ZaYwr?j`2&ry`lK?nPKs#soO(059ykuOIQNWtVG`CK8UPf4T{Cos~cnf+4x(6 zO_U=WCNHU- zr$eW{agINRl)apYXPIygP1y9wt9P^(zy(ZX6M=D6Wvq z6~qm1y06HGnzeplIHN>rFs|Tu+++T4uwVEcncsNrclFrW%Jh-wUrFjeYERZ1m|7f< zzB`8tfBOqY5s(GS0f?^KD6mILm+8Y+?x_v~W5o-Kn-7v2`ZpO4?}{)Cbd{~`=xUD- z1Ep;+pbJuV`)c&zb_o}Z42~qlKjqeFsvTv@QcVoL3kX$K|AHfv;#w)OzW-YlT@xpe zoG3q&L=Df**!4qB%DGF%!}Kfbqc3&t@0&@nMn@WoCF7QY8WDXj(;mf8l<2d@8T)-c zail8*He3g5WJ%k5I{I^d^Ga{etW0t~*ol@0K+veDs-w_V5JS70j zn+d=}Xjiy3Qjj7w!lRKa+7-_kdp~C&MWTrNZ6ydC(rHN1?KMR88Tz7pQ|Y#Xz(Bnc zVB6BMNwrfg`&Cuuj%c=>5qGa#bLdSAXzaJD1r|P(tr+*4)m={O__Kb4jjM;!FA+ ztotTH%1ReiTR?&9v7b)nNfl z$`bDMbZ-(l6yC+>+k9~j^p@(2DXRJ<6`&=gga9s*sKG2%D^MLQ^V-`jj8CA@>UZbs z)5Rsl^%T6Rk()0aA0M!n6`Q(Jb`WqU(So3&&uQ_h{#7r@kOWd@TK-u)k_*t7%t`ZS zXR!nqfI^;|sBm0X!WR4YK?>)?JkSfbE!PVILNEkx-*~qS$ERnj-{{1iyp!^VX{Uo7 zbq+{oms2tMO6*|lo8;5Q@Cc_;0Ty(d$17jbSWP34&U2qL_v6CgW2)=wLd(bf<*f>q zR3R{78{y8oK);4$VsB?NsDBkQU^{d0@T=BqZ5Ew&abO>M2Vx1H%q-{Sh4rUF0)f{f z3c~mJ<5$x-yGb8YTn`|4jJxgm#2?_B+(}Ss!LrKeEW9|Ajxj+a4nA@qRs<6F8Mq~r zsay!3JoTXdDB~4*u5AycK!OND>?~wdHIzMRYW(mGUT;(D*@V0KbJIH(2WnpX8 z*QyDrFxx^y+w~~t0k!dkO3xo7Q*%=)X8$u!B_))~JpskVkbli0?R~g{W@bK_b1m20G%gK$&ZM_vH#LYPE0*`Zl%+cBbdl@RFYaMTNnZe>5s6ZcP zsa!n;#DVSIOb8-mIZ|Y(P3iy?>q_{`fP1H(lu1nfl-j?{-=eNP1+>kAEgI;}N2SLm z51k}&Ye1i(ro@z0>gJ?_d?g!FU1FxpF4ou9&@eSKQ~@@C1NTrFsJYV?8cl2FgQ(u% zpSLSh4DKyA;9uX)+dYPZp?T!}RHdjCb<7a?i~dg}L@(fQR>@csr+LqQ8dHQiT778= z1!CkVOrB+IuRz~qj-&;62TpW2?#>vKXa{sCvk0t2z`6aWVWoWoFOu+Wh371BXlafb zGq*KU#0w1HSaksRxiK7c}l2P3uP*?$I`WU~F?S+t%PV#T8M=@Fo#B}zw~Ex4?u5=MXDWv(c}{ROxuwSjtgRo7he@gVI^*HbAUl_1F_;7aY8CprlS|AiVmjab7M!5sysN}RCGg9Qq>LrnC*eu-75 z`Z6{xUJ~`3(gn;-c`c>=lciaj6%TM5;?~^v~Nvorm zL!OIV9r10b3V|I;Me1wg2=yqr+m{WjO&zaB@F?HX8SO??)Ig`i%^Le3dkRC``Cprh z=Wah03L#g+EC{cFv=*2{8h3ElU`Z6*`b8m4uN{npbovWjMaAzcJvybZlWJuXpxKbiFJJz1e3w`-{zO?dy-1Q*s3+eQK%^1)PJ_ zNbw8VzPhS`lz8qc@;Afalw+W_{U6V@GC)HfCaO^l6C+q5~bhC|i3UxFA1 zMi$e-|k`ECMfPC5QjDkLUhbjP0@m!+v$=s|C}?rROb zTHDud4D1)sws&%LbsRV|r>Y8gk1g0_@PjOuk=hm@Az9Lw;yfN^i4Z z>D-xLv^tldjuJ_=7WGg#Iz{`DxJv4=O)~E5)DT$93)~U9fM+bEOZNrKTxk@a657(R zdgS(&k)E^=egkYh1+wtm&GnNsI#t@cNIKuogYl9wHSIkau}$t}tV^Lcecfk-;SQU} zOIWx=o~xvA)_!bu1l6Oc2fK!g?%`g#ySj77m;|S4ZcgyIc}%)`@D1YLgeM za|Pk$M25?9_*P^%F(iE-ry6f6J|G3+C1J)tE2-0H%8nFI7bnqfh&?)Agf04S)+h%r z*mmH_^HhImKIGR)NaL&B?SJREscXxOZA-E(r(&HJjf)t|+l{hIfh_Mwp>{<=;Sv2S z!SYjLC+qz|T9|{QBT!1-1poCD1lO(ele*t6p5K{!)f50h$u8Vuz{d=?)EV{sVg$-mz|=8?TR>yk;a0c|Yw z0de%8nTi{`1dZEM4ZNnA62YZ3-8OCpmc``=&a8z(ZF>Y8TOTz(qWqI|6+DI+?U5pC!_!Y78JiKY8}mIFj7`tH{$U_gNksSQ z#HS^!#D_99L>U~fH#`OQnfCaw~Ck0b2cJ3Mm{Fn0h>-CM>;8zhYJ&VQv z-m$;mb;lnwNu9Lkr{aI0{@)J6K`_ik^O+7u*#91+GXDZh*YJO+$NIlNeMAiSRVIjR z`ah=8vw}e?3olAf{=t&}e!+XsaOO<~U1zR1p>WEdEWw7O8Sk8N%t>MeJohQYert9eed(#F)^-+H#Q3GI;{<1gsBLhs&V&nwifW zZdGNGc~cCC1o!|X759=w!5e~_R7}pQ#%lx6;=!!I^tFnX~yvHT%y+_qj>7 z1tRx&A*piY>5#u`bep5?OeV^{WY>94^Ds8z9V##c`p|Truu=Y&`(P3>*P$AP7JY zdgIaIzx&}|*_LDz| zb)2;!QoOvxAWBLRBtwGYH(GL6_0|9TW?rlO2U-943t4luG@1FiFXDeKHxs&OaUfv;6w&;AdnDJx56@Rc0E68@+>Q}o zs=pi`x6cC}gQJbAnKw7?Q#>v-p$A_%qz?|j3hakyRy_!56;)5_2>kh-8wVGc{sIn$ zY(%lhG5|%L7!zu|>R79v345aiG*y^@NilXCgNA+#<&o-rF(BWW0QNSd7$~q6fn&VE zKl9oD8X?psds($JhIxwunFk10vWSx5fat>(?aFu9e8yOcVVCN>Q1`nF%}GC+(0?=} zGH76+6P038%e$>!#ae~^YYAJ_MK)Oa56zJyDfK;wfkmGD2 zhG*#@j@}DXz)a8Xkk1VofXlNY5C|1XIEWkJj|2>-2u9XJL-3YU%Y}xJE7ke@O*=?; ziTXfFOxy+#R~%?ifG$`kzWc-p$o(n-w1r@%bD8UI6TktWkik6?xpAZIDD9T+XMDI6 zsY$V<*Lu=Ue6%GdSWl(RV^HCLIhg&U@6_WjbIx4tJSk$r$G>E9D7d@-*n6+U!Qzs5 z=R-khT)|KdmfUrH=zexVw32*__-yMI=P{hZ_zw_)-DM(#^ zg@lPP&TtbR&j8x+i-%L$*3SWOFXIHvT@=0TYv}p(=+cWYtn>5c9JM>Ih`o#3UqNi+xb>23KMQB2$TW7xLvovQ z4VK6?LBMwe+4x>q+!&^|6?JCWZTrZZ^*Ks~ltqT_{QSK7+3MTzUMEL!G!+tvX$6%_ z`~ca1@K9vFuR|7O7_ES3uH_3K7WnY0(q|wRk)vllies0Py#-abuHJ3?3e1T2tJLZr(erMnSH5tN}z zz@QuHuAx!7TN;%G&uqx)hU>H=G{+JJAt+s z?s1Ou{I8vzDE47D+VH}6h2hMWT8&)?1jPCg>3 ziaVf7ezt=uzmwmZ^QaeV{eiC&4rm(+Hr;hu3Wlk0Cs{Me{0 z-AKERONT`}8Zx%JX-ZNm^Cajt$o$-1`sFsN^JVYF%!#+?{O>MD?*(^NCV&zIzTTWmI!zdT zgkKRS?8O($HXBM4q%K65prf<1`i4SuUdu_qnstizG<&RxcX46|^?1(dF%z-Rzvh?0qeSDK5ZAu7`5yc5*D3@1SrQkg2AsXO7?9UfZ%f zk18L3)p7Qc+>F_#7?mwSo=v4YmU{0PYT6&iA=wB0goU$@Hv2G8{W%ltRl@pB_rp+N z}^OT7GG9U1V(5+>QhlTA}47YW;B7y48_EkY3|s-VD$3r~zev7WcrtqjOiUlzIwB zSPoeqmedI8%7oI~iV;rkAEmffK$?<&P>rKvyERHafI%F&C4J|Z#9TIH*mWIek0UnV zxt#FJUjH}OQ}X6TL`{s?V;XkyEoJuvux#1_)H<`D-gs6~U@;u5eY6%l+?Qt-MI;=1 zV9UgBa#Vsxqq5I?RA6D>(#;z@0$RGb0$3gufx)e`QPhJQGq(|r8|ABSa~BRY%j~IX zgBDCiI7URsj2q1Mx)j)#g#pFR)na>hnGv8V}UPd4-O1ADIIY+J@4?# z!S0S74tKx|QW9CwpGOkWZ7A;JW(AM>MZebKhyHSALRDd0me3W3 zjf%)HV`;y6q3)XSl_9S~Cc-4^I@|Ocb3(3Ev29u;9Oqpqgb0p<&X@4olc3;Z)wMdP zCU+7vVw1kM=ru@Bf9s7fma#`|=@?aya~ zx#77_7$yCSRwerf)+lrOtL>3pkKNXTMG=>?E-o$#u_ng*iKq$j$*-uY%!VULyCT}B zV#k74NH|W#)(P1kUNqjdOeG2q7Kx_R_{dk~?aHp#xBi6RZlnBV`89r!n0i%y6tK~S@mz(ct6r?k?Zf3qV@PK<{ z7LSBm;fYzp`SS}^qq3Y*C2(uv50C71TtSNLx-K_g{;tlCk;)NQqt|blbT^+dYuAie z81~EV`rA|!nlivw#mZtxDQWj6(JN=kenT12;I)xZ5uCJN$ROh*F~SxM6v`n}@I+jc z%Gxa-V?zbs{L}v|q*hnlnfIa?-)(1O5HbS5f^DhEbLD@(!0r_;fP>0jyVqTYuJQiw|Nkz-h0%MHiN=aBBNbfFxwckD zL4xD23w1r=vN%KFBCxx2ok`exTTA}n1-e&TRA@5j0|aRj0QAbPrlaM7;H4c8BCT5y zT*-1fSDAjjr6tF%)1he+6$TVo2uo;d(?OOYMdn7V{Gn-I76!;`q)PE;yeK%B@_*^@ zGc6ZC^Fb=3v}7UXguwcIK(#uLyLwsp@!Uz63pm#U$W9}T@4Af&`tSSUx{v-ijI;nLN!EYnnVA6?MWAAUc~VO=@#lGxQkFXWCDW16vI2u2i{TU?wAQa+!|j(GdfksdBU%X}>^>Kc3N$*pG!tey(mE-TgA_ z4}AZ-`8@!h8s^}y!f*8i(GcbJo8yl%HANU=A&u4he_A}A>Y5taZGso4U~GVkB?C=s zW)}N94J`K6YnL1Wvr-SCg>GOWn+Oqr(mV2vj)R#dr6gMCS(PPWE~>NC-clch>|C_n zyryY}$-SfkrlNf?)X4-qxm<3%Hgw$GyqZzRPyy|O9U3HAu~f#<)6?S+5J-Q#{{htw zDO6}tldWt_VSDhBK$=o&5YdFw1&EToyEsxt8Sr>;k$Wj_zwvLtKo=xl(ulzWTS3{8 zM{@$uwA;7+A~g5`1TLfNe)O47{zcylZ5XwU6AKw)y3nlHmc?FG=hbhE?k|-NsX7Lz z%d_SMA*Di1TtNK=u)KRWKj0PJ8f0=uSfl;EHr^+gAEbcr%pk=0z4wEQ@@~bHnV-f$ zP32BbjFq0c=D>=K@i>5_VmmN0HQ)RGrgpi>D5*Z6A|_(29};Cwb- z5;LJr;xSZaw9cfaYYkd9KIZa&4{pW;;T&4h;CJ{JZIrY0F(I{}eh&E~r5vaO_E6iE zXq}dq)*t=j*bxp{6_tY%@yv=$VDA;uhA*K^vVxYnPXk6|kqG($j|g3l>gW*VUg?B~ zDKg~5{m`36y!h1)B=Pm>Xeb)9SJ}@v8nkoBaUHM5A|5sv*nxmp*{MSi zPZ(NwedvIPP~c+Ho&aQDFSnjY|GAKDM9IeJ7bDT6((n+gKY$4LeFkmJjk=goo#EEh z)fw)7b4GV8OPPx(k|>!|hGG5Wb(3;Y=DJ}aoh7iJYeq+D1N^wKBI{nOvP66+59kV{ z6%&>CRmdh2_G-(-##l+WBz7W9BILs)fK-0B%MARuqxcTtQxk!SSB2$tLqSBa`h=ZH zpvT*gRj$Z3#0-ndNqN1uTs*vAteoap+W>nCp?R zj>C^lES*<;b^-taUQh1JgBsE&NV&+oXNXkL_=9Tc+LTz#%xIGnINWS+e~2&ntFkKyR%MJRvH%a_9Gk8b*=5z|<#TJvN!44n*1Pq?u;j#8JlZQ7HOoG;VAk0j>{#T^Mm#~ai|(@l9%tm|Qk<*jIlH_^ zD7te3owD@u3Mq>sChRVJ3SQ&KE{u2IqCUC6g(-8rATV6-y;emz3*9DYRvpu5WUsea z=}3!31j!OL`%Uof$71FRweeONPnzYn7gO;XBZ*Lt=*D9$@YHyh^=3VA7~0!rmNxVp z35xk5v4*3iDvHk5Fy}xC;FV1wblf)k%Cgp|C-nGx#J|wN-TNzA0>8)lmEWoC%r(kg zP2c!BuK!kXr=8p0e5uDJfQFBu>LBp8mq1nYg9abCe`V8dG20YlN7Qu`$-nNS=-wqC zH|%*d-}lmFYbx{CdpYx*`FCS2Ca39~&_cA+;_vNC4gF|Z=9L+zq|?|8M+KImf)fK z?T!8bTl(mMsuH3ykou)T`AOo8VKC5D9k|K9_xTTwbn&bZ&@p;1lG6Tt2>*T~tPMY+ z&BFa+{##x0<9F)50D@=HJnZ~`Uk5gXu5X;;O`!N!{qvKxfUn|Ht@*I=NX%U!07`EajHr^p(>ePFRwbzBvg|hu^Njn?{ZYqp zF6;e|-|;3J^!2Cwo#J8ny^1q9eVqW zGaE3)p|X;PR6VPe5s`^4NbkY{YhhB*4Nww?{?<+Zc%AX)d&zU1}Hi|99TM8-@vdrJvobVhJl22on z&!ky>rds44^$(-d7U^LRbqTsC%hs(!z+{tta~LjUDoKLxxQw3#-1w~vP$=?#XaLJ` zo=no86DK^qv;xmD^EP2Nhz_)s%nv?>HK70AuLUK7Hv%Y!yfQF<22BtV79I(rz;e3T z8v?_}K^e`b@LVO~XvA?v2hE716u*?BzWpbb$xqYy^k?s*;So#vti{@WO zVM|N^+gS_7yHz+jCZ%W^CktqtuJnef25%qu6zFfizBCgFuH1h??aKFI;E}QXf7k%* zH~+8X3l?+T&_Co0&A;S}o(e`WOjvACFcb!G(F$5MN?Im`zK;333f#G8(S)u7ClwGv zhow%tTrgkv0`^ROBuGpm>YAXm!yYhl&xRGO-rzV9#X)AcYx+oq;UQyV=#zz z_=tS@s!DCVPPw~pv76G z?mNwTF~Ycpc*fEO_O)G*>2y(&_fTi*t;gFZ1HwKp6&~SfNGHMGI^*i<3W1tUiw5v$ zX7J8mA+s1Nu{Fz8i3~A0YP+=x`+S)Q1$1`Lw*{7IoG8GrhpyyE8}UFmrT-qFSfCGi%5aegbK zyPP9e=ETEIZT(#GP4B3e9YP0`kW@N=Hd^+=9agWH9|M($GlgEby7OU zWHOn4xg>=yRW*3XF7H0%Sa$$P>npZ2RLq??rvj-AG(Fqi^}mlB!X5B~Kqannl)S+r8T7t4(L9k%uUYzhBTgLYWNGP(;*g}9PW3tfmBszoULXLt{cdbZ| z{?P(p6Cjzt!y6A_Q{(dT@)}DH*W_|6hu6K}&)&lH=Vj?@iN>GRB=0)vO*)ON%+mo= z4=@o?=FOwyo$Xs9e*fN_Kfe*LMoVm>v-o@dPu0uN|1B$$W+^EJ{|>1z&EK z!^76crt;kk>>_|U*Nz(5k)3;YgWJC@C+&AWYkO?fY*GTm85DiPO#K5LVL zUK8rR3^Cj3b3Rw&d?tZUxalMF_`#8ez6|AK6x#?ye3*=SJu5Vwn0NB-T-+>dT^QMU zve8iAZ&0NKGMO~a9i3PxpSzz=pL5!=gZiq)qDpO!oj%OoTuZ0Oh)8dGjuD#6Sx!NQ4h9z3Z3uGZuHM|ned z8VzQ^St+L>=-)mY*f4+BD1-+=%&uAH)x)Bgq>`S}cmdh8#8!+~+|rww*gD05$euS+ zt`aufq!JDjJi){8(%g&zr87Q`LorC{ypSjHg4`1!`o558!h&2Ur6^M@G54hxliWKe!kYK@oh+b@hW|mi1$6i^U3u^D5$1b<$PQ zj0P0^W8(bP7Mt}Cf?4_jM)iw`dYkANm@>y@$v?uXP7dg+&^@e;(QuCVZoV4Ccug#k zFh%gZ!NqSH71Zvc`RW(-2EN;7uYYZSk7=pj#=C#7Dq3Lp2H9sT3OvJhgTx86dVDC^ zf#HnmHQchNJU1K2Zw#mC^Eu7ShPA1X@a`Av1~%oscl={5(omrfgR($`<&}Gnb|?WN zmImUi)7(lf0RNlL#ll#xp@NzUdV`!W;Llho%I{N?JSh-C~L^En)8RE@TO@TPkxdxXdswEWmi}5V3Aln}a2Dfp)7`=?744gOcdcOa`6OpA^ z;)t1B=;F}QZy4vnd+i#Dim9g5e5rS5j>K@uOlOStvP$7!DW<9c7X|PqqGazfVCG)E zJZLyNd?3niW*I2?d%yAj$cda_2=2VyWU~|;((AI4S9JqpzEVOh;AHTVt|j(x8|r=| zNy|9F^wJ9`4MTz&Z`TYuC}WDK&v~4|m=B2Ak!dQo7b2rQ;J93}pBD07`I|wQU)bxV z^f+jvEi;Q2czE~Qa1j1!3Ygo@sjV6*CLX&7V&SWspY8d#+utx_ac|!mT+HR}-QC<$ z>)HHsr(F0OkLJ!n3YeT`5a(MObf1}Cu#3LjD}u@(&EZ$heuSlvEvd%VUryr~L@n9C zCsrAf%M@}Ik<@>x)S>-?@km9@y9bWMIw|rdyhoN!!+GKJTx`N;HgDQg?Zj&`KBo?U zkb`|SHlVbh=K7o0$5lgDKbapKkmr5)PUEuUj6HKyil_03hgZQ+`kUH8i@F#{bYuDR zq69lg4t1LyM|UR`GUT5KtH(d~vX9a>d|=6LY95!Dd#4~ZQ7Z%{WwxsvK0Ukk$NL)j zp~_r3VJA}eAkavwZe$XBe0J8hw;U)oV~^e1x(ZI+{xi|VPDE;5B3{bpM>!R~0SBnP znW=u3W=4F*Y93DJ0lRB@yqTCA8AO( zl(hFmyW>)7E3Iv`hy_`Rp{AGi(P6DlfSL%U=RzT$V{Jc}lvr1o)D6Wc-^4MrrFtm4 zIQ#X8k23so%qUXW|Ibcptm?--$VfDUZ6tW@+@E*uSqv@o&-+y>YkPwKaxB!_tG~QG zq**9)Im~&Y2^kq!#F4S&xDcPYJDGm`8}t98OEX4a7_8N4FCP59?BR!%q(8*UvdmEC zSAq6F2u!v|!uv&?_u?gAo_jB!Q0?5`+4{%F1OI&a0*$Tq1t9nycbxbe1YUV2t=kgR z`ch7Cr1n!LV;w68Q(gXo_{s=h=ZZhc_H?f4f5o3gV(0^8#f;x}lv-VauVB&c9BTZ; zp3~ctg&ydUMDkZx|9lvKAFD9_^Vu4IxWu*ruzU*uWA1;vk1NxV(xH)S_?N_u8wzQv zoDc{@4ue$^KrWztH=^b?V4^(RYF<$$nx6mO7LdT@-@g5M5jdR zH6O(8lj*QOx`g|DX7xss;_ZtUQQ_>~dW&A#R3hhm9u*&U6eWnA>=wwete^lmh{z=z6PKoU|Dw@{;Kfaz2b@}cE%XC@9%?tJ9+=h~wnq>jh%UYn%XUzbS zC=r8n<#-l;=RYI$@~?l6)PGG-KolM_3rGi)dI!b56JL>Ad8bcENghyYe;}Xjq{z0% zj@c08ogl^S>cqoKcG>T86}KEyRd1+A2ygl+o{vm5?>npPa%y9Gw+|?Mr2Ng2?xs$0 zUjO*#RUkR0CHF&QauEekc72h>qzp-aelgw{;FeK1-=QN*yo@AeQ+0!`SEoLJTpFRo z24i?bk})DLr;)-6B&gw%)XKE}LWqZRLGo=E5TUNinrW|AhTVFDXJ}?QXv2q?`4nc0 zJCi;rk!^67`62K2&2B@!06RlHKU+Kgj*7S~i3a_9R>6P52xv8aQ&EL)mvv}9zuv?U8iT(cE-sp|? z=5I3M|1g=&i-B&FqD7r4KyBq$pgrR<>w$%z2ns~)yw4y~%-*^Lfc>}om9x%fpMy#bAIpR%Uw`a*f4&iQ+8X8r8FnOE>=5yvOW6 z^dla}&_&D*73UJ=u$|aKaknE!(O1vzh8;}Jp=FYnWFtvnfD}WjFcl|v!|o#*iGVG$ z15Ig&UvXD<$0Kx;nxJ|x922(aA70IA_y`e1LvSNQ*ahdQQ|es!0u2ZrQHfegDW@m5 z%M#p9y;tUm5cqiK4+aDMY#e{Qq;oo3E`6W>tLGNS6h^a(X}Ev1Ha`k2Ei|R6Mr5dE z$2R5p=O^zA&)iV<<$wPhXXoUPn==qW2%=eF-Rhx{U#+J!L*n(p-l|=jY=9IJydhv3 z1)fh;0s!<%#L;GPxP&h;af?_^*d6jtk$NA{)*|uobV^T30Qc(y$VPSb695@A$7n>^ zI8(il0BQjXp%+H&u{>C{ec0{BG{SZW;JZ9vJQR)epcT`vxQ*^9y-x$>-s9oVJa0L) zOpP@vT&U53r1RkqD%1kl0EwD06E=&S6>l&UvSr6Kb2dLg`?bkK(0chhB#?m1+KA3# ziSv1^(B!I~55+`h$4H}wo8SKVws(Yoyl46X&^Y^E=_jeHIND?NyKt3)p^A!zCP>R= z7b~rlS`8rvqr^Lqg5(L%96ikVD66nMKRh^C1v%*icmi|kiYTSX5cOi8K0N`ri0R5K zdFNtyVvI*bVw8sv`@qKs$MM}+di7Q%wmDMJQ8?e5oa@v+L%oIbFpu8{PMywdrLRTi z*LnZoKqM=T(A;>?ZLf&VL0FtojXBT!Z+pQ!tCsY5wfEXrb+h5iNHqV4o~&m`Mn}n6 zNDSsZ@dU;-9_(UFGjnlg0Pc(j7Xz4Wqm|JY%)r`k7c--`$HR8m7WNL;yiG}u)Jn@C zyx}k-`I1R#d^RpK;n1id%cWCKbpLD%=YQ@R{F6cTfl=Ni6r#4vXeQ-6sdHUKD@1v^ z>4p-G!HD*JD@j}cl~$2CeRJ3%tdmIY$cT%FaDIT?lUCG;446Yf2>CcS%@1<&$BJ3# zI3sjaHz@qaG`1l*6i86UX$PR5(O_e-=<_>UAPQerBQfV0dN#&Cghvo$$5LD&=8*S{ zD!C$%lBVn~g7iZv_QvVyo0|o$x(=gG?+52kY&of1x+C=Y$94Z}jZSmvWDpGF`G7O) zP{2G;TRZzJ6|JJfUQa>AU@5l_KO2J6bsJ#@3IH@Q3nm#IV0VX8CBxZve-SqQFoWB0 z_QjU@?$jue7MTw=fi5srG~{{+#X_WWTN@ZQ85)T}W~X0vu+AdV(yrqA>C%2(F`L^6 zrXF>P!$07FOJ^}|f~Fr}AV~6%Jy}egYg(LH1b_JbqvCaq)5a|QL$0MRhYrG!4w}MD z)|~~e6lQ)d3WyD!6wTbT$Y-6Iu)z{w8ltb%p1UhWmh!3*$Q?{1Hvqt3$OvXb!nNoJ zGJDV786ZsSJ$C; e+K;z`Vh0c^;K*CItX?+NdQabMo&|H|=M=nhS?g2wMG3A)OO zc1wbA-*L7(b@fkyijOO2Z6+U`M6b$0vkI*4H?zR4C89_wWIPLSXCT__2X=Bk`(^-{ z&eoZ3A0W!=jZ{+=Q9hZAcGpcH_3l6 zUpIX8S-CAFG+2m1$%m2t(7wQG^0a)#ZGO;5G*MUf4pU_r-!9h*ciM7U-gSWutpMiK z`|BYOD#4f89k9=$e9OZJlywt_K!ZtP3;7WQc7ti=Yrwy=Z!mdoLdrv ziP$FV=kAc;YhJ!zGK#aYs8-pv;`UNWI9R*dPW$k*b~DM?`guC3=VPu73QRp6lVzi) z|DuS8L;by4?u_#$^&9jwLp=E6%#DKM~tLR~b*g1y|7S?2K<}M~6Im-T_J^31BQx{Y~iV#Cb+R z4oyS2GSt9DjF$YM$rkBgnm{0$Cg8kdY;K-oWPPmyX024-*2_ZzmW&p6YQhLxuI*O^GFpkyU#!^eLQ;sp5cCelqEJxXEU2$)9O|4@}}LV2X|w~2FE}(MI~gzHX-wj{p9L_>GLsN_sBmtA^*d? z#}-UIdlmJTNOY1YCIfLO&PM2pkKUmQ0Vd{&43>T;kuTKF#t+HdS~*EjZDr-)cU7$y zEaC$^4Q18P-;*-zngNU}qZPEE&Z>c#8U-*xvJ^?`4!F-9xqyz>MbF+ug)`6Wpl5GZ zVRnL?^Df3nk%itA+5$iiCefc1xM*OlLUAlR6}6A%TA+{7R!lE87Z)OFfq8m91tUL$ z0(4F2eL}iTLtSEb^4qCdZ5UuP4EgWG#}JXZ)$-6TdzFk%_$!uv&Im@l$$SPXoHL~= zh)k*p@%n)6hnu|BizFzLN-#jE0r6eg1yq1wsEK}+OlQ6KNoiw-LXglQ=dLN0!OSw) zzVsVsmP{Hg^gcjvFFAC*yDg2W^v=>1En{20(^R0*-h1lgNttiF4ckzdQ1AiS;Deq> z>ap&9D}Pk9SJ}vybQm!9A7Vv3@hn>G*qe?$)sAJgf{TjTIKI+9^eOrZ7Q&8sY=!XA zXH5M@b8Jsg_gpN5bDIpRTgb#XA+2gcWGL-bY}+64y7e)q6i4tiT|vGiRNEZ4t75#O zc~?P2g&0}gjOGm7t`R*tDURHMjv%WUBku?NJ-8@cE9wOBgoQ7CmEM&s>ug;@vP_ZR zhGSijEPcjBn|Ha+a7qw8eZGA8otTx!K@nXRv?e%l$aS=|i9NMJNyO;zea?aq5AO>URSi5BgSWt# zUpujzE$A&9IpO*pWPRQO1oSjfg}i~$L@x`8vOlXlioaoD@Hl#D$ml%One}t(rYO98 z;--h^t5biRdJ1U3k1DxA)S>Dh>BN*y2Yc(*=Mo*ndXrwnMOvlvyAKUdWL16zA>!EE z)9n(5dBso$8WM7;XS45SFKmc0xa=|4OD=tFxxqR%6d@ME;7HH*q#U6TeED-*iDQOc z3QzY)#PBQd+9*ufF)rU|6S9M$)pQLL2rH(_{F?XV8o}M|F~s(l+)7GkMNfgdE}&pY zsT>0V-_xKFaFV8hAy|ARNA!-SGsN7X(Um5Z)A^gDwln_$q%HVn7j7j9eaZOpPGcpz zY`Kkn`%BJATM*j`+fY96vjcTSWl|J+a#kmGid1egJVT z@w9FHh zygFX9vJl~WRcL$KD|qMtBn)G%e5078>&(dG-C5dqa&aRhJ4ugIkS-mRa-3tjtzcB4 zaWP#aNN>?|fzf`I`@%f7HpWMNnuYz)5hV<+tvlEr+JJ>76(te| zF_pf*mSQf>_r_;0v017fbv-tzuetc1$hx+&2p${T~Q~AO;g>zy5 z_E*;^>aFn%E!)f$2;nCAlm;@3+9D`$^y^XG95fDd^h(j8D{)ZqN40auKISxPqsm1z zTug8mo>lybG>o~^jm>>c9~5y!+AoGg+G6V7K-?_6Dd!QzRwbWT76#h)F?YG9uXo@dv&y-QUPv;cOW2cTu8NkQ%okCSapq9Tpn}(8 z5VZQd0D?(9w1juPxvpfN{efait~6~deR|ZczHK5m*(L1Qc zU6}00xCkXi(87X1Ya7RI0JHh|i1`3$b?c!|k>O;OHX#cc`^HldJ*ZhI!iAnx)oSb8 z5jxf}5@V~ZpG+@$oTj_~LZ-&QQ=>l!hBiQ^0t4<+!WDgw4+B8Ih zXSmhJg7qB7;%4Xr9E~vj3_1KfX9*JO8^7TY{v|z7`GT|fh<~c>)u8W2jAL(Q$R(Bo zMa+2lHeKXbZND!;fx+pc-}Ig)Du(zy-g$H?V2~dpiohgDm1H$VYtU{#W9QtcX|(TZ z$DrNLcmrqhk)%Fe<6~-$!)-XCMY1~w5C%M$o(r=4`U9-t2 zx$V0qh-BQOVy6Kah0#LngahKth|ysYl{Wh+&L;rsRDt^ZV#-@J!hll5oyh@4*+~71 zrovrpAQ_$6jpZ`%6YvRi9L?2l@_^8GBsluLQ#3{#hG=Q-V#TZ|xVX=*86V_CvM4cb zxQvHe0ocZ|8u0q6W@%p}yQw3A$(FrHg?KlqG!xVazA%qEOm($kP7l0#P>vCL@!He8 zxU*}|19+b&ydmHCM+>lD3yrPGA*uJ_^Kp0et9DcauKhxUtIYmpffaZ0d9Kr+OvLe} z>msW4Q+;*OAsMeriYVY#I3v!{^^YXi9k4%ig=mss%y)UkE52jk!RdOv=sZ#t7*#ZC z%8OO*WgENiy1Lv3S_CsgzWp`AU)U_u4zpCgwSnnq_0@nS|0WX8GO`UDSMmJ+Ds~wsPfT%OX#69z?Ml}P4>Zp zNdd~2c|9W9p-JfS)zRFXy*;)j6t(yWxW90PDL7d?OrXlG4Y-QMj}?6v5jqLQf^67Z zbpktscs?z?ftR0F{ri1N#;jI2GVgt#swjyUaoRzkbLDfh#;(L-aueF?hegDhxnkxr z?PqVsDQJgl+Y}P3m~!q*i3!>)J{H9WFscsB??_C}?2=EM#2~$sCd&HuR0iKqWXAY* zrHw~_P*`-^;B~tr^NHYFDUkpU^}Tymw%*Ljm~PVeihicyvTL+@8BJ8Vg&CKtz7k^N zCcZaEom7R(q0GP~hDH(;+6!pH&BYumZd+i3T?fxdT2fbu`R$E%FWnv?hF1d4hL4IZ zu*gqw>28FiKjxk#PJ2>(2IIrYvqa?XVJwP?FARm z4~QJD&MSU*kH+)|n8fZ5rHCh>d`Y{7)q3wBO7JHmxQ!@4bp^tzDpcJiKUqVq9Y0gY zNPp!mwO(uF5R?`&6ztxt`_0DGBMZZyR5%dlV-jC}s35s>z%Jmpl}0ppu&f=IK42h- z*e}zq!wWqvgVEBX!Y*p=9(1gDEtFe8t$$VmLn=PCk#W$L6tzgK^qS>DKjE^_Y%y-A z5r_2LimRk0&pV5&X{w+5-d%NE?e<5Z*0okix^Wak&Yw3%R?y`&%vdY{)3R zX(+uoS~2Q03Z6TU(xy)0%(CHoFZpwnKQ1ra1-hYDikdyqJSwn6+t4}10C+9{VYlX3 zNgs>!|5&HKCE7MC9Hdsl`3vw1a3;tsuLrO+W=z~hDBZUl*;u(??{KFnLoJsPY!{zg z>^jiQ%Ar+1ZW;)UL4N4Xu+b~m%+{6K*dF?*<#Y%<8Y6X#>;7(kklII;Ow;o2v^MhW z=xB$ZkbLD^Ok?o#6w+PHbPl3W`M~mUm&3J|1U)Sw!kuV2*Flf)ZWXya_-(Tfb;7m^ z^_c4zxzXUPs*2ntvV@rk2ITIfm+R`TpljCJ3qA%(Xd^j^=KI1Fo#SeXKG!HLCg^s4 zRN}Xe96kT+;Z^@Fht44T5W()l5Hnni|I!P8u*1DVI*Dwz}8r{&=XniCLJzG4>8B?D@#E zdkUx{1@rZWk_B@y(e^Mc*8L+K!vS3_akmp%GnoqWH_?$LkwU4-AA>(#zW#?7J5u~j<`40;?$p{eS~3^9fU zF@;Aelq&(5FJ{+8{a-z&)$QkK;T%tW34t9;F!4HLEfLFB+?lpm;fVBq*q6QDuMTI5 zSk3FQyYdquOq>PFjm*SHd6bk@rB*9kGDFvzzPG5m_R{->j}X>JVRrLwn%sJXwY58HWc8o_D-i_rAs2$B`2r z1QZc$>+W6a=4MHJyA(>%5-jph+_iXVn>4KZU5Arq{cWwBV2-7Uj(0->Zz7+_8^;EQ7v*QX$})L zHp06r9C9=xQp+g$v2q12bYFb6pX=Nsxc=Yo&qs*1tXLa?G8Y`by3?7DV-cKeQVDyT4H zl|kjD=QQRaJ{$h7gnH~wYVZ(@$)8MaMlmM-ZODr_I=dV>6_Wz2~?^s?E_ zI4705AL$Oj%8?02vh)Wm7b)fx-&wiSWccyY;RNBekw^U}cQK7NRsx<^cL@y37*+5IH3u6Hu0q@^NGYFNscl&a<7eGC8a;{3jf z?vT%#f`@%&v-{3uwZa)^NAoaeRw3o1hhI|p5A`*T(oBobly-ekPrmnF^}Pq@q+H1n zP$-8Y<})fimnM9IZh!UEB{LF$Zse6e?0Z!yY|&Xk*cs0E@!gq(h8kj#9{nFRTtqb-J&W_*$yG=K zGjuN_`(2lpW(qEfPToaq$aBhV={;WuwaAb4wZdwOG1g~k;pIH3>;Fk!-jc{?@OqJ5 z#?PpXSKo2_z?PnfMJd{T=R%U&flhtn+3i@)rPpHR_7YdLjHpwz9-_-GV4Mh-JPAb~ zyQps5L@so29IZ~#Y9FnFVM<O~@csHX>&-Wu?Wlb+zu(?_zVwyHO0(nqGux}u zQ|VES;j_0q?R*L}+$>=Zk-l zm?K6&rr0_A+?o8Td0B+xgz;r$`i^@4@$)ZqGMYkEEQ(lk*7f^{i6weFE|uGH-4Q24 z#FmE{K9gJ`X(b^OKHKKeqIbkhweU-#0-yO)yx`67xFi`yFuirW(#C)n9t zIA1@0(8n&eA!9PVb!Hi3c0~SyoJZ&=zVcQXuAv_{O;^3ApWPfqW&;a4ILUIx14}@u zsAOxg{(En;9P7_E;Aq_uIe{cH4`O0)VhebxSoEA-@mg*c$3cc6Ah!j(F-@FV=d?*4 z3g4}ER`9g8U!~X%RpMWZr1!g7UcWXYtam@wn@GL47}F^TpKz!UW%caSrx*MjX^TZ| z1-|oyq-TV8=LPcxXYB01Wfs~LU#-53YW{NPqnFM`-Sh4natBdi0 z5%qRW)6n76h>hIR>!?&LoMo-q3H<$5tV>6H$6wdOT-kSuxg_nUgKSY4<-y znPU!NnN+LgzetA6!!{gt>2=+a{2>tDGrL<9mKoIgGuc`mvr4_8zN=V#f%U`AlLpSW za!sBmD&&wJ_p*(x8LzL;eQ@|;`?q}o&f=LVzqr%`E^8sfgskb0qQ?gMzK#=*LS7Z+ zXn&+7JgxT`=}5wnv9(-l$z9kk5m$FCVv?u#hP{(u<1=yjC4I_~y?m$9kMEU=h;v#? zO#DMcrBztsA9X&kjVtPn{V;&)GEYt)xf&Pox%0h(eUNrLEN)J3LHXgpEH3K>0ViAEr~89 zxD>%E?SNd>QfPeZjaxbMJIC|4c1F8KpoL`h6T8nwA9M9&5bDWH^9jRgn7S{cy+DiQCXluS38>lpCh(zRTJ|RaCt=8%5cbgbQv>q3&5N;sYn>=;+GrCXNmk)x;$+3(e>XX3F8dvmL+Qw05#3BzJ98hm z>!SJuiAPI?E-RewAdP!>1xZc~6MVga9e?5B`RUGPYR`BA16~ayR%>5Wv@SQ3HNO9C zNe*2_*X-55*F{~UAY{U>6k z7;(H4at2n~JGU0-!SNm^lGg-p?=1lB;E0F?gY|_@@pqh=?g_bLQ&(2A1ig3Aa>76j z&q`i$i)6wbPwwH zv&y-nEgQl%45Dn+?0o)CGsY9rnS(lQD+* zQd6zZZaI#6l0AGz7whmY%zmpMW7>QvGuQkbf%wW(XNB>X>oeAJM^1-_<^h@lI3au) z^bb_ZHp5N#-_150dseCpwMVNxDBp`XjT)Fa}pq%AjPH7bXZ2R2z(nl8MPl_ZlINJ zwZw9CAeskJ58syQNa;(M6^jS8m!?v6x>3al(NF8WpI_ZenOjYYlAuLsdDrn-(FCoc z!$b>v&@nfR&a83PgG684CTU82?>dg_X-SJWSix`ZQ{PyZcE@Bei3zP;Wi>XZMJq`oB$K+ zWO%h(_>Jg3&M!Kc8%8(WlTACd%#v>WB+3CJ`sz7%7ttPtjf%jQi7_^LjeQj+(ujdJ z=Y9i6loxR4lfZ@fokPL2EFy6hoap&2CNN`NkL)eUzQ6=@mf5K28FH>#VM@}f1IuH* zTW>Bt2=mNuDEBpPpy1d|Ja;z!u)Nv+XxS-WG>{EAp(Gj*YT!?RhOH>#+*Y0H=IBD9JeFgRdfvZVa~ zV(hD5l{&Q5fo5DMG1?PE|D-uK|rM&>F$(NN&)E-Nu{M51dL0gbeD8@z579D zM)3Qt_4{L7&Jyl@;+%bUd}5tkc9$QYt#s53WmEN&eg^!Bf=V)M z)V}w_ksA+kHnhsSmN=y^4y?J$$B}~rn^cvRu2blUVe51^aYy-Hf6(+NKL$?^^pkd+ zS%#4qY=-DAVQ6bX(;Zk^i7gM?P*GgO0I_Az{m~OMNx|Vm=LTpSSFk?|`>2q(RP=)W zBGLIuZrdFa(GHd~X8kcPWVboy$47IlFEDg$M=%ND-o z`(4n`x~8wc#gpD&|r>uw?XnpF;y#O6p=I;?m$!-Z9(0dz+26B^*D4Rui!eL%! zfcSfRvNXd6eu^tfJjXMv8$Ej|0L`!XINp~*Txo}YIb`scf_y9}S?kV}Ct)pp3FObg z6DPAm_h$!53bIWLnupn){vIctd0vT+SfZIFv2UM6^~86T+F6-7Ey9G=7nGb-L2y9n zC^LSj+NUrAQ3{se0M{_Eg|%-d#LaII9I2n8RV?6!u7mscIK5`W+PX1khD5#mlqS5D z=v2LMt#<{ez57GY<(|Yo?!m@UR0WPX*q2+omB9;21P69Z95DA;@0q13^ z@W%yfQC1hF1(`~tCw6>3Kk=oPo}m;U^G?l3KLjNd%2)fM@Jv&ge z73P16tyLz`jFQflP8yT4$$y{#qnU(wc4ff#1d&US4Q+P*FguVp8~=Kex2o*((Wrwh z9kAoLI{l%KWR*SRW_T@+t`oc0P@rR2RRR3X$~k0ig2|lc*GJ%c?llV2bnc5iuTLHT z>}8%rx>el)_q6;Z8J6B@BpzLx?d_h9FMWE218WR$Rs*2%OKlNO6S}mS^iJ0L2I-pA zKGnbG@rN<*M@}G`tFJ#Vu6RD&{*z_untW6V-*k@D=MFnySm`On?5ti zao`#X2$7$j6HJ9(xg4#Vmo8B?Pk%q>JuS7!Lix1eOU~MX8in5!Nt7cy2-KZsijO%~ z=G`)8C!?KLp}5{tbatj+e|jyFWV@Fr$o3;vv{Okze`68fDPpeotNH>S&db)+PR+E^ z*qSs-QTIkX+dXGL41NrYw2$G}EdB2QW&>O#K zH})Y}0aQ$}wInRJkC_~&ynFp(h@@iX`No3HK>5hwNeKa8|LK2p6p%31)BWqIqrp>` z4rzGX-jl`dFmXLw8?UzJT1db}gJa9d-^n{T87n7xCG6^^(N>fBWo+&@!x!C3 zWY4c@o^TCzDC4#MLKMmFbe6Ow@y;$;VTo{PaRb>4pg0e`8qoi##C$oGYlLB>IECHm z3aaEsiM?3R^6O)KTS=qxMP^i&q6R+)-#XqUtE*Gn{uO2pPwJ05acpsw$u6}jH$){l zxra-HhSOqW|*eVp;U`(`NlDIs%zBX)~BB?9+Q>!5W z+amhCk4wK8tJy^x^(@R zIYkXY3f-dcCi=!ZfEzD#HYE66e^N(+ncsOHb_#W6Vc~H#Ru;WS~+deM>^Yg znhmBUDb+@bT{9V(zW32Cp5OzlcrKh8Q)2$$*Ys+GdAaQNV{VN>)1`Heb@>r*Lxxd(Y%v5d^jY+t8&_yNunG7@kxk%D&bZpn>k?HkPgDCL+TfE+ z;HzH7vFD8eRjM~6yG18G{jZijFTv*~^35-f-JEFe?O2~`q{eydBu|5xfX`x4=>KMP ziaKhrz9A#YZ>V`1KTa(87;jCs$Nj>ff3N_-^O=j|sDvfpH~QVf`ZtD$ic>3NzSc?==KSFr;sc z14!V8TEx}qdkmE9HkcqXD^N&t|E)mQI02b;OuJD)C^ws%A=@kaYkNDRy0<8|N2ll3 zpZJE9M|K+e8;Pi`+bJ^GZ-^3o3GTms`f=x4h<{n%fiBC1IHa*lV}5*C4;a!h4eRIF zs*Ns4cli{|w+m^U{OWr1dtZ*|7ekIL`X{pKQ;!&oxYluHXEw0O`)%x3^$|bqYWedje7>=q2*Mu?B6+B_cvJ6eDU05t(ZzfY#6BW%3jS~J zdlP5vH1U4uDQ#Z=GW5mo(XgyQLq8B}LLWPgZ1swo^X0j2gbW{N)Ej16PF^v(&$?x? z(e-FM)9Kn9ePw@YdO4vK5u<*&=@j{eW$MtK!T)YvPJx~IPP}R%8{q@zAz`W*o+;#le@gBzYk6l}z$+u(W zV#W-WbRWKtD3|>vs9U88RlKFU7-(PCD?{V00|wYwTZSMRUoL}C=| zcvQb1M#0iavwmh1mIvjN}V51BZH$eZyuQro9%J>>L*#t&QI2Ko3{sS> zbX-d*I*^F2UxxTH-8x(>8MjuFmAoKoGtK3A3cAMgHc4~7^G`#Us|EBo4U!0y{rN(V zE2iGcB|>q_=%OZ#ver6`j5(!aLCsh)WqrxZmt5GnE-lRS_Q`I$Ugd9h-fZ3^@oZHc zpSNz{7XBcQy$))ngHxKVCJU2iTKlz^h?$!oorMzzc@}F`%Bt(nI%UYX&3dLacMFEN zpp}0u?(?0zG9^Zg^5{(rymx}5ryIi^K9_xstqH6j)HzCl@=gC9p*s8Kk^ILUyLb0r zopYIJiQmvOidap1aYo|z-uS>SZ?bA-pEmu;op~*bjj|PYMJZXWCF}8r>8)>d?dy{c ze?;3O1oI(CEvP2qvy(eVFZmt1jG5HNtBXR*;cUltCi8?LRJs2t5X_IMFNOeAetaB> z1o^8-EJ4mFEYfb=l!u8d-RRNQKbzS*n-8)IjcJh0w{`xA`1GcgDMQ;|)H5$<2PBE@ zK3-oQjr981jR(+xdG_MUgExX^SpZMR0&xg%h_El(1!<~%>aon1HOD70wXcocp-H0N z7|o;8S7P(Qwz<$u8x_ex2)%(y=w!U*VYZr80P~IGpn$TVs|Up@(r*K2l%X6$6b@*{ z)N;+0JQty``81~;xP+mh48)KT0KIWoNjv;WgeVZ%j~|wwI+VB4ZL$n|IjK-;IAhRg zy2!-T;&n_p0csK;DKR0mv-i)k9i5=HCtLs;JZ%|{Anvrl=V}AlU#!k7kS-*ini%8* zt7c#wuKPStY-)hdIgh0Ndu2$+6DrJPp;I`Uo|KLx(3cVFFLUsf01{LUux9!9pcVKM z8lC4re2W%53*6x!20^n=P*U;~IyyRo<+Qx9xMG3v5nxdH+LVcgG06k_3@Olomp}!0 zV=0GG0l#2}YZqi!biW0Hi)W21fQ$F)MxfWIclH9SQJ)VW-nYmw9{pKnoN{?5h`jdn z`45;me7pA<`V-H{4Nl%;*sA8cIRm`KQ*cd11a3sR*Ma_538+J)E3*wn``!bdJ=D?sKzQ zfGK)Whw@;{DX*$CWv3cl@qv*~*KAt~A;L+>DBJuRGbpKeU{z=G{5vV`zvUW@xulm- zs^tcqGWWMa(P3p=w#zzDTHw0l60@RTV`nFsb>+Bx858ti`2j+8+Wa*leg#(2{DOjR zx`D40-kSnh2hs77nn0aA&&tw#Ult`<*CwTso|h_Bn1DsE-wnM>D51`=mR< zJp()5)uuPK*PD<=tts|4yVEW=h4sJ@Xw;nn)(GpWjpifZ1Tj2oqb2NIex!z@^+HtG z0UD?THx@;21M%Pi)zD|>OTki4tSUuse@&hPHp+l8JGW^pAGG?IU$p|WNw9GIX6S|* z7<4ikbg^*nt`8?mC*gq)kXopeq%>_YZX0x0pD%d`bUy;Y0Wdm=pk>}D6bW^Jkc^DY z);3MfpnOuLJIq`VJ+m?ossGiy(3dlnCD4+{s3WR{^?pmY*gk42gzg+vwnit;v!c9i zy>A)*##msoq}A4aM7L!iCvVP9NYKOz#CNF{X~XHP)4l?d(dTDE#XCww!c;{i_8#sS z#V|N~3<2R@_%gFasB5rVxPTQPAgE_Lu6*XnEq?a>>c?l_zY0n(jegS>ykQ_&US7iytLNkhS)n^?NRg_@Q2C3YXOOL#FT>IIr_*z-ApZM{0fVyR^YJb#o zl$oO~SqfuoZB7KJwUXetL`4=>)+ppI!Mkz!K12ChpPd%U34Dtgf;%Hr5|g#AvbSdI z_DkPqmEN*jot|hd1JCUV3BWRhI4Bcnbpg+yh;#~Z!)#|b6ie?i{X36yXun(g2srGf z5?84LfU5DE2V$hgk0J*zb zo(RfBm(J3klFty6S%|iCet_{NUZ%XsfE|ir9k!a-yRJv<;VQ^o4JGW3MU1->m3U}OWD{L)Dl-afzE1zr- zwqr5Rv}!>Qer{uDgO?`j>YoMEWxD-hEJ1{^4S;m>?=;tjCM?#lnDMdIpWF~)Rvzb- zE2}kEd2!un)aiNc&{+LbsYk#}@l8+b2MZv;0eC>;S@%*!kJJ4HL_;~ki`xA$5LVvi z-U5IV)tgRA-n!@S#bbC4Cas@^yk*+&M=>8|h;O{f)yFAfi3~NIQa{=EDh3pHk57qK zYT>XEAGNM3d~ZEeu~v`~x>FD8tTMe&->0+DMgoA^NS-}PoT_D`9D+7Uvv@kh)NLos zf?$<&`K-6z9W87PTsIZ0AAHHno&#O|MH*h$LWGpv6ng}e_>9{9Gq+Fw*%&`k{@9~HeKrYNZiJ4iG3z|yQz6H>;SqCLSu~ZhJOA;|!n87Ord!cW`J)bTFY0ox7 zdkKV8Qh`pB9yATSifYe1@SWeH3MEa`g3a+U{U^iLt7~#M16#zD>7pSBcTn6WD&;@vg_tG2jNMka4nSTG z#v$?Ip5gi`FXo!dtqqE z0TwH#n6IaL_6sku40q`)=l6(`Lad?Pu=$n8En5dVP60$n+C#ISRoqiOHL5;zTBj}f zRcFRD#ToIQvw?mV!ewEc;#nx^sz<*NP{04Yz(D{By|}pX_mB=kcJrSB-Wqk+OE5MJ zaUUs3)a+DZ96RB4Ki|)xaaO#Zt0u6MNTb^1;la7hh%j=T_hT8WwM!Ucr0;(6b)Mo( zE8vN;k2xJO!j(4}_D9x?1}U?C`)%EShP1PTxqAH7iQ;6I=tuBW?B$O4;K}&OZh#ia z5q#MpN9pLG86|5q=C#vHPcA9DZ&}!!3QqF+kfD8O4uJ#={^|do!A+kW?IQ#P{4<=* zm44@Ow=?lVxk<5#PrN&chK~MW@M-TjN-v2d?Y&}gUgIk>J{gp@=D{Roe*=0EBFl_@ zW#}evTM9FqhtcpLU_@T*rUxpq(I@@}Umh z&@ay8FSF;*8OzGiIHL*}JiE(bRqVhx9WzueYuNc#Vivl#x9F(mIh+?TjxGQ}kpXBe zRM`iY$o{Z@NSL2sOy0D(+tp@wdt-oi9Fk4aO3^G77E^q~%`CES>B)cbD!urx?{}1| z#)}_nR@=tTC9jKwg65$=5I@6$bbrTj>0F?4=m;_^H-gmty?zQdLoy}%g_`0jLEi%d zYUB-@QX?Im4!hS-Vw~GiYDRV;4oQ& zK+l5H-kMWKsU4dkkm)KaDs=3E)o~EgemuVmI`xh%r2Djh^vTKrmRE9+{;ap`f%W}s zC7efALV?9_1NZmLBbhWU-aU9KvwWUT8`nZ7bgMm1X4!9;0y*gz?%C^Jro3ZQeV`?d zf-w%ut4Rn(>M(b`8^gnVGnaPRci*4CA3H(h)qzQ@wf~ms9D>V|ZVH#mJ!V>(SG>k!A?L0==V>fMOU#x5M7I$nV zjw@?sfa0TVsbk5u?@Tq{>>jCj5}{@cE$Mra(=W6?oRhwC=)sgD>u+n+fhm>Se1Z)O zsaW<6spRP6e=wifo{gffoX)?EkME<*h2P1nEi`(X{?j)FQ>dy1?$Ji?=DpuMbZ=$9 zYB#}l!&O8xP|2)rXgMM0b2JADafX6`$4Anpcv-WN&t%gknE$&yihgtn>4QzK=zR{m zLXvj1y$<8{+VHlr>QC;SYwAV@!i1%> z!gp&pNeXmMgR-9|EWk^Oljhdfx9;eSUpehK*}`ES!-GCZK!DW{M4G0Y;jbT-L&66c zJYV%qm7m#0#vncR?k;vv!&6L3S?lf=iECRQnGbevMcu+qJmXcQxUxv}sqkA!lWLcW zkKT{oMQOE~r1qX>+7%sD8QRtBZ)SR&ck1xpS_B{a$!SKr?{4LqBlI$|wee!VSk1;Y z)x@;1QN6bseKgl?o_t^1F_Aqu<2dEwhO|>61EL;W%_|ts-ROuW>HQc%U_mLD`f_>? z>w$#B&vIk*Ma@R{{AgQJGuE_EpfA@v>nkkGF#hEYq0t*#Y~1{`n!fmQaA-+v7PV*C zbkf3SKDtyfM}1yuQ=sy6hs|Z89JVyUjIJB=_5LqSrnhzNWfsUaL?bBL4g2_uSdP^C z@exSTM2?XjCkrjNxYgjM`04oG^vufTQ2aS^#(JyszLt}jwIDnC!=$W{i8RNDVXceO2Qr`67)Uu< zEaXhsO)37Cka+h~==U5knd~p0XGxYSweJoZ%Qs}$-r-V{X$A3t3*_m21s?dqYZYy0 z9eh+QXH>_g=KQx$v_4tQAwsQqS_nvP@-OkO^=8}mzGLqCubsP0Nx4mcLL8(zZkdUO zbGWOef9PypNTYsJ_1e{@Wes=Fj5ZATHDE-wh+TVYk8GmlojVM+f6Oh++&$rxf$KpZ z<9(@cF`@?jz1sJiQ%0Kx#GCYyUT=aYMY=PlO)d_M5G*URV2?)AdgIhYix>|$NQv1F z*;G8(8JSOr=l!z)@Zx`nOui#!Dy{^@h<@2MuWbhc4@Ovv7MN?@yrd_2);wi3TJyf8 z{?VKVcZ;_sDOA(BK2mwGZ4lj_FcJ|_Oc?!Crt`QkU8Is*b;e%fn?2vv5ZWoi+xGOTWzh-45SeM_&+_+jmqO$_UG` z+SEwxvsJ&~%M*5!K97n&aM|<<=3Nw8HxzWP^`h<%2)fcx}vja z`Upl>0XDTSk{&t4H2sEM{=(5A7{SV4xeF3mI9|81U9ZG&c`O>fyx&$;HFBrx{HNb( z#wH^1#gWNKm3z(F1l5s=NkhNA5yMkE3qbO>-ghH7Z}d6kaz0EF^}g1=$C^?@^xQsA zD4|G7N?$nVaR-I;uLf(rVusyf{#pqm?UKAyi9*2%N8S?3Z&n6gr=f~pi)21`C&IkS z_bHn%`8AlKCIvg-On7E%+=&{$s($CEI6N)3$)M>N$6jBXr?MAm?+BZxoa0gC(W3m& zl|UO^U|DvLe2!C{w$Cx|QZ#DI&1QSuoq4@KKc4Z!J(?aIe34D(kJMESS(@P-YuwPL zC%JL0JqKCut`3WucMq(0!3r8=!N*Y0RO6J6`wIDLe^u{acn9@~#WV{ip zsbsFJR~qxbR;G@(6JBUS_hzQc`7_o9rv`;FhD;QxVUj{I&QY^DPRik3C%m0=Ih#+X zc~;LyHV`H6C>)i!y31|o)?f9G@~m2`5tr{zyX{JDDZ4lud0AK?#+=h+6pdkZ?|*Vz zkCBuqv62&KC^7EXvN|ymDp~~Jen}H?=MxTh=y}EWY_O-e?Y{-Wwy>e?YQLs6&@Sy% z;SvNB!pAvIq3&C6Bm5`aZRuqe!u{%tqPuP=FO%9cjXtnfD2y%?+01WsQu+K^m!#(> z;k%)GnrLdPdjF$AYofmJsrBiO2K&zUgQs2QjejFU3>PDF)$Z;$L1WU}hKzA+)Q%mv zbkgq%2*tY7eRD$S-?o=9{*=qV)$;DX0oUG0z@b#*cMDZ)aY18rHip4q=OH_R>a3Mt z{qFEc>sB|9w>c*PQy%fd1X10VFEP-k9r+=&@}~hFvY=i5J1PmSxi!TPzSaGo*kAIJ zt-T=LRf=8aW8OUC_=)N6nEy&v&nI!?Uiv!E8obai!3-HT4sq=@0c_3}b{X2<8; z9g`>{clb+Q-$K$h4Y4ge^K`|h7nFHpA1`q=g@zyKMX+rm_i7PK%umfE{m*%X2^PN}X8pdePyTD;U zhiTV5S?^HVvBrYjhMfru?Hi{Z43dR20UXT6qc{PKRFe5eB;S%W<(+KGpr8N7tFovO zYl0DLdL~xu#)WqrKJv4>8wSvG8?)d;!>Hwq2O^y@cX$>1D1G1^k*I32LBE7A`trx=OVo4=MN-R^(&zh z5}QsKZPZnzQmz~uj$Fyw2kyz(pAgEm6pn%o7ya$O&2P6!t#RN4?B~fHtmD?o{OFDuMvd#j8+WcY->YB z<#2^p9RFBsceBQ>-oxyd=muSA4qND3YkO>0xDr)n`y0La{MZ@v*UK$Cgmq|}GLs0W zYB`LDsZWK{**?oJj%u_+OOiNJEh0>eB5IjvMSt#rrrUxRa`VXJ>>fQz(v?F?Za@h+ zs_ZkIt>(s|uubx-*j&oTU$kvIf{GrhIE<Ul8B1xjrYq+<@uC;2gFkM1|j zUZT-a_{jh7Pdx1m$ar-AAnb#$ zc)!*W0c8A!Z;I_fq&4!<0cZeZCq#QIdLV(=ub(dS!PS2;68%o~_a|arfSX1;r73VY zfCygLAb_iPR8OV&8@zuR4Q?9KnU?UtP3_N1_p7ZXuw-!s{hOL_bG3H=T&h&s1IY0G z>-~TP!R}zg?jQWt>cijuXD=xIcI2-J+3wBzVYn8Z?*j(4#IdTI7`hnF&dzS9dMEI~xRpmMJUjXK2a4nXn8 z5F0CC$a^6gr0`#X?aEaU(qaS)YNWP@6l-&Nf&>~AXKy@l1IY6rfXJbBfCcg#XXe`G z+@glE|M0o|_3-xlL1g)cmv^P79MO*XSO^oM^uNbo86QR`#~Nb93NpqToR3vj9td&B zmoC}KEr;O+l{`3u`-7X`Ahaw#hcdy;M9OpZ zxWw`gJS3Sn^&;d-?DK;L@MkcM@q|eyCW2>WHRw6YK-GBVeozINZ9w3sS153X2~+r8 zmYL{t`1&8J$QpIoruxufHj6U ze(T@Hhw+H7p;N!0dp0Uy&gqX!t?NWLKV?-Vyz5a5x|_AlEs=ee>jbG`mYtpJ&j< zcFor11hB>eXH*4tvI`)I#o#KZXF`3nL zhtnKb9N0og9u~*>hHQGCXbKT02Ep5f$p&I&d(~AM5Z9wqvRuwQynuTiAlDUW3p4e- zQn5T38OMX;6Q~qwm5gZ!bb5yn+R7ik)Yl})qQ6{AFV!nX8y6vIc{(?T=`Tw%)?eLR zH`bK)aeEk=A1kt$^58HV8vuN=H4KI-&Gm75f#O1S=@L2!WmJO#<0&#SA7l{%ErNQJ z+iq3=`s3_R3P-<#^0E(jF#$<}NsdP>jH#v#gdISX6+}2N;L}24r2!03)RC$Bd?DCi zag*-vr~h^TE}2jTo8nny=gvL*WC&&}mBs!r3)gX*eVm8b~2y4g!bI8v`I z*#&yL!Vi9Lcv}E)gVtO(GIuOp)t`*K!hVXXs;5NtB==9Ms_T{Zp42|!hw6y}D&!t^ ze4D6OHpxZ-%P;^~1!k!+&~yaDyfCLU%=z&cCqIZU>g)4^S%7h#Q-C?I%zN3BKC<9s zc==p_{P3+dWJPe-30x%OkCvjw;yYjg*uuf74JM^r$Z1x&`TRU&$9FuueG#amXo zCV*nxdfWZfto(eZcYGCq0yXWnv?%^&JI(@th)17=?f5 zQm39n>YF2csCwQ{&ZhUTtsl!ktv$c`!k6QR!_6rdE`)ls(Lm*8_cC~j%cKjNdj##Q zcve|E`|ORM_x}F*;{9z3-R9k{wOb6o&b9jtTj&XEf90Cq`|~BWZAh@%ghhA%YU%F} zZGItoK3UI_HN8D)Oxxmp0#8NK?)Zx{r6)TARd}sW>Gbt_GmOu0@=S6_X1%rI0$ncw z*l(43F)xVeDT*D)Zy{<90cI{)kn_Zxz3N?JG%r%$}cuXYf88l>JeWs zcoO?S?gBSifeqn6a9!WDH7MR+pl=1&=(cHUz*(!6&#j0s-2h@7Cm9-v!K?2!Q88w@&F z0n{grCr$@k`DU3^#`e+68$!=IUZh`<6JoXg^NRK3{o!kF~D!%kN`K+(e}U zO{G?x<{|o6K<1G>M!inLFhlrl)~%H`$bN`pW&wPn0$O3dVvLERk>6*L>OOP?h-~ zZ*zaj$WeGi*6GmBga3VvjDwNU$-Pp?mPppcCx@TXYd&QAjQDe}@{rwnMuY=H8ep!~ z?q0;dRlYuiYpiSz0cSg#*>q7s#(siy#F5=oxiKaC@Hz8~d2){jlxg<*sHK1$AXR9p z*#;iaN@h3GEiZ=-yDQ=!0MUZCpb4^C?nP?57E#K@op1Fn=z66h-ZU7*cyPtKT>d>q zHF%iOxM4b6d(wRB2eJ?=5$@jRRL1j(4x%E2=q4zL2`Ctkdx6v!0dE9DI{`@SM3-&A zR!%-!1Djej)EPLch*VpF#nf3Gu)qct;Lyzjz$AO|?{241prly?2ZAW&&u>sq*yp{+ zIlN9)_PI8e&ZbEYvGRB)Pu@~vgG(wf9mQe#@6ZwdKO8#8JgWzDA{dV@jdZ>w*xaIQ zj>gqhz~#s}{ z(GdAzc85(C4}3}ry-L(h12{hN!|Scku>g+%dvPJ5P=`woj~=}C-<>PcoV;n^FSa)U zYLQCA2CW0sWes+rcMZ@Kij~Rl)u3`JmTCrRwC~T=v#OSg61m_`@)7**0M2+Ds+!WF zH_(pJjz8Q68bDaVk!=v^H;u+qLQlDNHtYl%9lEzm14!Q`$Kx9>RNphqXBQ9I9t|`g zuW*%3AfjVsZaW}Z=-@pdbt2a(_=!9WA_?A)1}EJ-G46N>JFWdi@V1*3`gW7bc>2@h zk@_eMjG}VNx%5TxQ7GF^00MK>)}Zx2sy4Thf$827tZS6xL}&tdAS5dmvzm@KkY}*| zok4cE|7si|*wu5%ojJ~4Sh1E-Kku*^UZ1b#gtpp+X{G>HG%T!4iatdF4>p|Dv zKfliw7gkpi$IkXjyK)^!^ci#wZ(Ro_G`r(AJM2DJXel5Z-p`=4D8mK(*aGRh-w#>T zR`Bhoe_fx=C(%aiN!EYHci2Qrg75cBD<*pR%nI#lD*=X9Z}BM3K~QpDhZ4koGw9Tr zhG?FbNzt~5_B_+u&?Jwgq7U%TTQ@>M<=*hqJa}BFx_vBG*&PX#K=poy2gEqp=AETB z^y3xKK-DXpkE{c48PQUua;CHyd6j&QJ0)^xt5Z8>M`BIH_nc$5=?<9P^IT;3 z;&Cu!^OOAtof>(PP)3*(Rq-58WspdOX!6)e=h9c=OM6Mj<`ra*AH!SaJspi{Cf{-9 z1PSBua&k`h*otKx%7+*mi`-sxIdbZQ*cg|q~sSi_VDo7EXls=w{)vZeVT7aYow!eK3&&|3Y8+A z1b$eHFX=-4$+pqeR^@@iBbE{PV!pkE@=*di5!bB>y42D$eQ{!04d&B;(i;H0k+*E`eFi{pdTNz`V1)W4 zsH#W529GDP<>}7W5|M6&4lHbJM!Np1hl1Ze-T^^g>~>kjNr+Z%%kIo#eYNK@JwdbA z=>4U-3z#}nj$O}l&sz@WSgsg@sVkyZ0GL3tk73OK!3;$2fqsb&fKXNbkKDURnv-deC?Y*_#ZuLlg##R{c7*{@3AWCLcp~c0a<5&9&~5J7 z>jj;Qz)A9mY+#4yWJIb(AI~m)K~q^`HKzpf7TdK?+PwR5sv(VRh zn1Flcss^5yDZP*6*uR|`QDH?=6Rk}a>4IMA-^XVIkv@*)ixSXC+&!)Xy6?@P%N0bg z+;@7ljpKpw1yJ+0sXiy4qx!zCo${j>!;;%PXcdq$PAPqOm~GPpE~&bG*F`DEzx0dF zB&v+XDf##?yEe8fdO9vt+@xr$@v?<_Q~j3rP31Cho>~fb_W(zzaBaXmK?_ugRrfq-D7C5i18|6pMW}7X^QUCf@aDZ;j^| z|3Og_9vv%FXa~VIbi^S=WNUS1p&FlW05I<_{?XQYeoZD>Wwi+e&KXi2qECwvd|lR_ ztr4593d{3bk*#T+1y=tq+JVQYMLs%roJ~-FEu-b|@Y9cC3)@4VG4PbT6=T zsVWfTp_!m*z-tWx$DgI~X@XWO6x>^;c1hF15` zmMB7Jgig!Ex!FjISaL*~<+lRizX9|z1yW}0H&Xc)n(7*vq+i)2ynYZ=(+IjWi4_|6 znT3V{>Pg}BJo_N43POri7-S$ziesn780g2T zE0pNm?!_vVu4CaT>p}Eo>s8Ykt3;g%z2$qm=eZdp)}#g~8eS281%=whaCB`by=)5%%6bv8Y9eu2KE;{1tQc2dD`0XeJ(b zLh?~42O3P~Eg&S&n8~q9T$4N0@3~v_U%w}kv($E_3gjbB5fG?~myJD=f|I!#GM=W7 zy3(2cP$aRm6D*X1rf%5OD@X8Y=H3_y9C-AwWNjUm3;27y4+j4`L%adCC(EEpjXERv zAqJfCpsCl@GmNr(Nhhr9ulchnZtR`j`}VZN!bim);R~O&W`H4wDj?Fa@bFmIF0(yr z`z#m(MqR$ZNeboi4+C=#L}Md8b?DLk4|6v|?+6G0LFt&89Mc0>(+p5h6GDPf()SFg zA7fW(lo~%3`+>`ne!9a@RNcq}R+s|SW~x@B8~_r|3kPVMLt9CE5lkJCqT9Bhn>`0#OR%VTbCI z=U5ojA_OeJpki!y5R!mFs7r+4c&#rV92^|;Q^w(jM^#&!s<=7@B0)lMeBX|H@ak(3 zfWQ+O8p3bAFJ%2z~GOkP*g$_^s8Qe?E|YzAE#Zi|pr zAf%=0%EKDHqFD_>$CMJz5D^j8dqVbko>Iwr(B01D{`;(!F^s z9?7nH7M+B{iXJpWszGGR6^2cMcD;&d%)rq21PqOH&`unzI!YLJBQK8#%T{gpRGWk*Q?2cO3t=4yRu5>Ln+GgMiAD^J{^!xY}cwRE4 zbWisqI>J3Fspil@5}hovQyt;?#iEFx8$r1r6OJSe)5giSyPsEFVVTj`9_A`m*`Q+s z9G$)A3BY`y&W6{xVO@Wc=MVyF9g}sh0L3e(ySGaynf(wd5tzv z`nB|n({wmO%&5?*yYE?=h^j3tVIu9HW`JgFy~{$3{KwPLi*T89sl zw%8X|Wr0vB6~d03KAr|r=k^be9Q7^VBe_TgGC# zgK=w93$wqZT0hY!q7#h_>w+^H`tLtZMWQzx9UIW#&Pf%MWND49Pfic>GZ4@d74>PX z(C_PXfRjImc!z0jC|u=DP>=?3E|W{k^-pKcpV!+nJiEiB+Jh;;9MxG8uYdL=^GnUJ z;7BnQ)+1ou(ah?#wON=^ylkPUe56Cv)3Cp+)M2elRF4%mBCIJ&PKC8KqMqV2bx$}w zA!_T#m;-1lS#{4xEiufUkFaA-gb3IMq1C{d_Yr*H8nE*+Wozq1zbnBs3j3v96n9mm z1qJh0M7i|_3{h^%XyKVr5H{2o*B`vU66F3=C>a}!cE+0Nl4n9(*BH5XzKY4S%}GYo zRHA;G#rgxeu`{?KJi;LW? zC$6Ben(fgoH)nVm@9yq?rh-&rk0=pu$uY09(V~uLEApxT~|7%Z38~jbuB9_iTu2kD-J)bsZe=Gj@eq z8QOS%d@S+>M*rPlp@sBqvF|sNm)27)7TaXVdBRibD1h<4JQedH$xbj?@MFGbfb5^I z-`V*k{JIwLEA&Uww~zL^a!MGw*bLXQoSdeN!#N{>g>tb~DY*H^)nO4O!{u?5Q+=%L z>swCjL{9~ZsU_dw$cRRa1ak zWyv?%)+SS2(+mfX!B(V8A!P9{e0q={2%xWw{NkywAelT)Alkz3)biMeJVCf`@)P<*l&M%4kGOZmW}VExIe+k(h| z1!5P$5tUQfC8xs6K?0dgmtnq!qhX)2pTB>5mUM zJ)%_}3!-FnDU?kUcv5s(OtJWE^Jh8#Zo1Ben0LC}c_(-l~s@`E%GjO(87N(V;r`nOOMhc0dZ=Ox3cTK;p3z-06eWmxu^V(wAA*T>jm} zuxctV4=AYMtr84x|1hFBo8vzZ!dNr3rVUiuXiAwXF61uJjOrdq6(z=l0`+0cA~{<1 z*)E-Kqo5DdAvNApwK&0D^4NvrmQYtQ+nuMp68SDRUxSEl>C4N@DX-MgX4&KyE5D^W%4{-*OG%rwxtKB<#p*s-t%!U=@-doC6bhQpZ|GvSk?r_ zHyP~cHWl=wGn9D5BH0<+E69k6lOVC`IHfNfS+7(M-7r4Y`<4yPQ8}d>HZn3ns790p z^jCwf4f<@DY;L|I7!%>_OMAIQqM9!KKJysfec31BZSOiMR-Mg1Pb$t?xD+Cs`dc8jajznjpABD~CD>lvKemO&hxoKJwUoaMBMQvr0&AGPtHQ!~d(15>St6DD%oUups5wnUF$W}EenIyf~! z+bT(8`~-^fgJ^5fO3LJ}hTHuhtmaq!2|sahK8T9D9e6Gy50FdBm~4Did+*-8$<`$Q zli6BFGCMltx2%+1Hek~~gSZK&S}HitSxmIlc+B<)hl;5feoqYs+ss5H@&i@-s_X}O z7p$=psN?ursUrRw=R@nk^H%%aPrii-%jS%&*)4UGj9XK?d*ox0E*w;M!rAy9oo7wF zHfo*ttex(IXYqE{qcg|<+~yaI^NvR1qAGdSBKtQREj`V6{&eVnqZ=r zDw`I5j#WRj96!3v7E`hi<@c4j*=j-HdBU7By37a}wGa3ZU& z#A$5g&CxUFxRcyTPBiwX58h?Pdn87i3x^t&vq^6;C%nA#uBP=)_FUUn7`37GD-nWG5>Zx43M6+r$>o4-2lS#$sc9tOJ6Drpb31AMJIsA-^Pi9msbfk zPb-M}5}jTYsbEtk`l!=f@uqX@L_$e(iA2-iz*J{X!#<^o1x zo+uRb{E`7Zp#x(YR)&a7ec^!mWHVWLylJY6?*#uIr28K!eg*l+Uc;{WI2QZqFxZms zT z4lPfnU23I8o89z!+_UdUU$DuME$rJup1{SwAD2of>#< zE&I%d;s5!7Dhlc0@vN?SnK(8&-Q+{(Ag198+(J!p!w@71^+pjkRpSbiO;nwpZ(-DU zN>X$S#KLK%u7mM`-w;V}il(NqBYmAYp#m<}UV(<{Vo6qxl!UObhhf3sF&;2`x5PI% z_@pXAex*4TyM{&sh`MAko0+X`QF`Inx#BrfD88409NnGvPV^+GJN$swp`lYg$WQPr zH{IC;;aC~)qb!99&{gHKlHSy>KH1^T^^hU~=}%Pa+#+acu7DG2*D6ewTL8)e5Rw}X zTQm8VFRz;Mnw2cnp0}K$Q|4Ry{-rEkuc6d*^uC=k4DfzHE8|h3C~2-??-P*Eecg$! z2eQI1UcPJv_}fg-GwF!1E`@mElVUNvy?KqK<^Y69{rvf^F9~DS8}+J*2lS;HBB42c z{6Ey6$roFup;#7SjIyMB?7%jtNP-*htSMu1X)1})Ta^F};aJP*jyM2Jb@BuOM#b=s zHYn?#jHYIXR{D=)f=gV4Wc9rDTJOHQje^8Schlpyg4kE6sSzsbB)CMJ?50iza=QSC zM6@5Lw2y_Nj_%T>s@EpyGGex}mn;;`l)+3@3NBj1HGSiB#mAL1p%Qqt z(89=@!_eONR9E+{B!e50Eq6D!4qJfFn(lyY98J*KWZUc)pHV0ocbqfJ&@S!R9r_>9 z;@3hlYBD`m0~a#&^;$+7obv`Mh{Gdn9}CwsQn6i_q@N)tPXqmuWEkq^m6ArWQhnEA zVH-DT79vi8LENecH7ab+0YF92rwBZmL+uRt|Kkuv%P@oCsqr!j6Xo`L8)e{mW>WS4 z8`X4@oK0rUX3NO&2 z=e+CgUJlImsVLX;gl#RFMA zGh$-CHnv-s^@W64@@Re~I`1lWmu+qhWLLJ`=sL@~B4R{@ggR5Y$95sZ()h-1yPS-; z?IflDz8=))e9{>n+Ar-A< zj;1;Qh8e80#jl9g;h7s5JN&VMd(J`(FaCVygbRZCIEU1$>!OkdFx=;V{AaCt^uyVl z7Hdc?X3jnaKS&&Tas`q2J4DNmv@|q8WJcgfIlkN^7CYwAKc6svG&<%O zx3|v0gYAMbFV3mZ#yX|(=tBqhBGCng@q2<8e_Sve=q^iA&K35&tDI{-2TzuOBs3Qe ziA(4H{7U$VNPqa&rL6Ws?L(i#eH062bGrGTqWgCuGGGti8slHu!1L$%0-rNN$FI^5 z=ymKsK@0_WJetdf{j|j_>7Nhe6#oT@Bj{$)f-DE4%PDqBBvIbmnR9vsy2yn0g2=`L};k+ zrnsNJFPv^DvPESum(3D$NvJ}ed)nK0QJd@W=k&(1&T(7jwBDxX#$Z}^s9WR}35h2| zr*=(l8F0rSw07E1%YvW#E}^%1`Q5092*m#w{NHy;6PHuh=@i++1(XHlhElhM^_I$H z$#83i=P18mtGOSa(U#R#aYWxW0B@2nz^$lSAs$}h?m_)s|9y0Bhxo77|6eQ^^Zm)0 zs9~d{(b>r##W?RP(~-*EuW z>bGohklR`HUjR1?dp1_N#c#}o#go%#Wunh>OA(9*DZpaEY2Oy_y9K6gvF3XX99C zD#zVU)A^YaV9N~40cRIn@Z(48i^bzkrwz4eSIuI*D;DBQ(XY~im=&hRCIuI+Ui~;o zpV*W3EEP+C{M#{k-2s^USy`z0&54FdzqQZ~&Y)P^yYKrYSDB5vD+)(s^w!~4`gV_w z%M{V7(di4G$Yp7Z-Y$i9Ap4=~gCR8mv8iVg(GMtF!k< z)=sn7P=2>L+Y-aSS5K`C53fd8?;`P{>(U+3`e5`v3qNwSN> zRBrzkf-d*S7M)=ijIt{f&(g z@5jVH)*jq#WxQNMvb8!wpw=OjN1E^-a1oy>5PXrA>UzU{k;T@M_Yd)`q!`dE*wrZ;}^^>A(O5A^sMa(4ZHcr-K zdB*Gz33L_``pfjvWvl0nu@nsa^25kT4`1d{sRgDwi}`^kLCwzg{EnqfO3#nEa+fiq zXMsM|@FC)hGNBN=OHG&1m1}xymI>ce?n$bvuP=qq#W@ta*RXmIWV7+0g_p{t$lwo(Q{4g;LCUK467Hw5mh-k;|)nBeY+5gW~QZB8_oL`DCpJVoV~}L z3b)7XRXlRU7@WS+9TqNda+-9=q4=aZefo1j#5?}j5S9(pGPfAQDA`Xu89aLYW)4RA z#EC1_nSl-rLX1s}t(X6pba{bQ6w*uQD}0clHJ*@UJf6xf0EZhn%gFN*_bK#oxc^X@ zzCIsO=xtm;^+lZ;8lj_ngDP<_e z^Nb09^l1BER5)GwRp!J1=_&ZsGFz?YGj0~*-C4Iivo91{oI^7IGgh|Tn9O`ORA9o~ zpG=2JU^c=d$B8R(=7Oy%j^z{xe$IBP%F#_=cwNW;qk^PT{K;S8{(98K;`WBptWO4B`8+)|^CHX*BL*FLro* zswfz(hFsFTZB309vz>g}R5{Twe4bA#xlM2pY>3SksMNiV!IhIR3C^;b#YRUWE zGvn*EhdQQ)-kx8&wBsw4NF_R~v^zoV89POSuSK}kFv=2-rxBth)u^H8&c~-F=4GAV zajU*D?#GSg42KfeBGsC4c|7}0c;(rRoSq_wEO=65?-R%ve*aR+%AIHy;d*k#R`f`5EfCn zVe+KATVt9$H}g@yDnR;U)D-#k;pVKYho$%DVU==~`58gQ1;)R`S^bfF!lSPwFTE$- zzvN9-M#wrN-xj~tVimWdp^jR}yiCc13d{`D%tX{Oua?i6nx4sPk_k2}_2_BNFpyrR z-K*AJ(8N2*z;1UmH-Ger66^2mDJeM#maN<52^pP`muG1u_Ro(#Dt&wJ^Y?wFcUd0m zB%?Xi*XQmu6f5+CuW?WHbVD+9r@{gUD;WXb`VYFrzX97CE7>)VPq>ID!+U+a0x&Wm z`NY}EMbUn@4l%v_`8E`F$;+{1x_Wsg_;{S7Cu(_VU^P{HN=3z4y29dx5b^{TAA>hM7>#qyC-P|t@oM=mjXsn+sXJ_ZiRFJ>g zi?wQFZgiw*(RU0jf2G+N8b;vpv!x813X9=s3$KtRu6jMW~`YV!45&HF#*y`u}RT3&{uPON9ldGtEq9ZNsy^$#4@fNc0s zwJfU2y!^~qkqBgupH6~K`4rlyZ{Z98=tA>Ij<`OsPte`9$ca?=_~6B^FutSa&%LR$ zXTXV5b8Fx0r6(y>DQ;xO>oOlYOtWo|m&W#fem7q~WN`rv%1lW;h0oHwUDh8!*+9JZ zuACr9F&YIhgM1smv`Wt+El{NC6C(RR(Z`f}0pIo<%8&CxBdsUvo=MGjUBKI~HvNON z`la>_?ts<&y;66IF}1-N2SYMS4io>R9mJTSzY_TtSN?ilY~sA{xPvDu(>G$ZZ->Z? zPBjdfF#Q~Cia1neSx@e@DQ>&lFAblVZ<3+VeQvE`&YpDPzc142hFF^as))EY{$zTQ zIn#I~pat<9E!D3k@7A7*kE-%3%U&6U%)Kd^8_D|5?u}NY{GQO_F`L#Fpj2kBP<6fG zgh%%&TVEP2=X})TKKRopvN+5n29U>us#Gckb+$KG?CeTc3++yQf|A`VugxRHKQVuz zAJ)~?pQlte;Zyb0Xx^74_u5`9*Tmu5&^4vFyGU*3u4FSo8O>3;L#Z1+Q5DhTt&yNv z=UF#Ai0}7!_iAjW4(f*WZ(7%xO3c%mYVSuE49A-NScp*@A3dU_=-rD9z|J=v=;?z$ zLqfXi%X%3sZ%EIKqNK9Jr^oVW*AxwP^h2_@`@NV^GUS8muVr`Us}Ol`7ZKSdCy+-b z#5ByRgQM0po_d!}Wia_E6@e0+S)=P4hH@U_=iyYixR3Gc@P zK+CI#{$mi`r?lr~WGogNF!RCs4Is9wqC^!uzBZ=!Ej2hRFE96d6d1?uG&MIb6}~U6 zd*2#9VA-$AylGo~N*Dp5u=aYbtvFU80(zd7ng-msQ6ciBzl@bHIp_1i*;0q#tq97T zQZb@^vkrL494qDQv+ShmQk150Xe@quIg#+H8yBY_P0Vg^>lh7VPvSsd)@FY4SfmJ5 zO^^Muc%;R{f*pdjTlP4Ba6zovNUGRc+u*D_`Ig~b;bM@NI^I5cm3B~n1J_ej^w~v~ zYVCkGpw!AidskZ0gNWx<(Dd9ymD!dU!|f!o#RA*+vpuX^tfl^IW@z2FMbB7W%7n@A z1YAPK)1oW;61zfC49gh@%|*#%;%s2wqm+PzY2laIl=5Yb9@VMwPGY(At&Q<|%>e(q zzTW~^x>-3+_%}frr#K;)`2DcZ9iz4Mnz@}j<<+Z#P#7!6bK6I|Ndd2dBX^CuPW?W~ z0TYKh0HTz=ygh8SLmiawY$0Hwa;`$W=w~6GX|`F2=iT@}Ql_scsEeVl6-exk(?NCz zO76ASbpkCUb#4-?*G2O^CnS_4_9jwqwHMGaIeC*w;ZgqYjc(p~pAB0qK7*0@#hU{G zh525sRSOGbU*8l zlShA(vGQL*-D&_>k!~Y2DEsO&}>JLo|uBfPIrZ13o zaugr`viQa>fWMj&h#a756Lj;F0AO#{d9uJM24qs^VSU%7IAs`z`8V^c;5|U9K7q@P zY~f~COaOZzYu6tA_XIcpiGKr#U2aalg(Ep{iuoP~Ft}b(nj|N<1e9$vK=S8kVexU? zVuoY^eSmV(R_30R{u4k#gumfal;svvs|VI8yW*ZLN8SQxDNJr3U>gDju5fPO0=fxB z0g7Ye?noi0X8_%tQw2oHf@n?N^}#;@^e(@X literal 0 HcmV?d00001 diff --git a/sync/tests/__init__.py b/sync/tests/__init__.py new file mode 100644 index 00000000..0bb7d2c7 --- /dev/null +++ b/sync/tests/__init__.py @@ -0,0 +1,5 @@ +# License MIT (https://opensource.org/licenses/MIT). + +from . import test_eval +from . import test_links +from . import test_trigger_db diff --git a/sync/tests/test_eval.py b/sync/tests/test_eval.py new file mode 100644 index 00000000..0d363515 --- /dev/null +++ b/sync/tests/test_eval.py @@ -0,0 +1,167 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +import logging + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + +_logger = logging.getLogger(__name__) + +BUTTON_DATA = {"button_data_key": "button_data_value"} + + +class TestEval(TransactionCase): + def print_last_logs(self, limit=1): + logs = self.env["ir.logging"].search([], limit=limit) + for lg in logs: + _logger.debug("ir.logging: %s", [lg.name, lg.level, lg.message]) + + def create_project_task(self, project_vals, task_vals): + project = self.env["sync.project"].create( + dict( + **{ + "name": "Project Eval Test", + "param_ids": [ + (0, 0, {"key": "KEY1", "value": "VALUE1"}), + (0, 0, {"key": "KEY2", "value": "VALUE2"}), + ], + "secret_ids": [ + (0, 0, {"key": "SECRET1", "value": "SECRETVALUE1"}), + (0, 0, {"key": "SECRET2", "value": "SECRETVALUE2"}), + ], + }, + **project_vals + ) + ) + + task = ( + self.env["sync.task"] + .create( + dict( + **{ + "name": "Task eval test", + "project_id": project.id, + "button_ids": [(0, 0, {"trigger_name": "BUTTON_EVAL_TEST"})], + }, + **task_vals + ) + ) + .with_context(new_cursor_logs=False) + ) + return project, task + + def test_imports(self): + """imports should be available in Protected Code only""" + + # legal way + pvals = { + "secret_code": "from odoo import tools", + "common_code": "log('imported package in common_code: %s' % tools.config)", + } + tvals = { + "code": "\n".join( + [ + "log('imported package in task code: %s' % tools.config)", + "def handle_button():", + " pass", + ] + ) + } + p, t = self.create_project_task(pvals, tvals) + t.button_ids.ensure_one() + t.button_ids.start() + self.print_last_logs(2) + + # import in common_code + pvals = { + "secret_code": "x=2+2", + "common_code": "from odoo import tools \n" + "log('imported package in common_code: %s' % tools.config)", + } + tvals = { + "code": "\n".join( + [ + "def handle_button():", + " log('imported package in task code: %s' % tools.config)", + ] + ) + } + with self.assertRaises(ValidationError): + p, t = self.create_project_task(pvals, tvals) + # t.button_ids.ensure_one() + # t.button_ids.run() + + # import in task's code + pvals = { + "secret_code": "x=2+2", + "common_code": "x=5", + } + tvals = { + "code": "\n".join( + [ + "from odoo import tools", + "def handle_button():", + " log('imported package in task code: %s' % tools.config)", + ] + ) + } + with self.assertRaises(ValidationError): + p, t = self.create_project_task(pvals, tvals) + # t.button_ids.ensure_one() + # t.button_ids.run() + + def test_secrets(self): + """Secrets should be available in Protected Code only""" + + pvals = { + "secret_code": "\n".join( + [ + "import hashlib", + "def hash(data):", + " return hashlib.sha224(data.encode('utf-8')).hexdigest()", + "xxx = hash(secrets.SECRET1)", + ] + ), + } + # legal way + pvals["common_code"] = "log('xxx in common_code: %s' % xxx)" + tvals = { + "code": """ +def handle_button(): + log('2+2=%s' % (2+2)) +""" + } + p, t = self.create_project_task(pvals, tvals) + t.button_ids.ensure_one() + t.button_ids.start() + self.print_last_logs(2) + + # using in common_code + pvals["common_code"] = "xxx = hash(secrets.SECRET1)" + tvals = { + "code": """ +def handle_button(): + log('xxx in task code: %s' % xxx) +""" + } + p, t = self.create_project_task(pvals, tvals) + t.button_ids.ensure_one() + with self.assertRaises(ValueError): + t.button_ids.start() + + # using in task's code + pvals["common_code"] = "log('xxx in common_code: %s' % xxx)" + tvals = { + "code": "\n".join( + [ + "def handle_button():", + " xxx = hash(secrets.SECRET1)", + " log('xxx in task code: %s' % xxx)", + ] + ) + } + p, t = self.create_project_task(pvals, tvals) + t.button_ids.ensure_one() + with self.assertRaises(ValueError): + t.button_ids.start() diff --git a/sync/tests/test_links.py b/sync/tests/test_links.py new file mode 100644 index 00000000..0e60aaf7 --- /dev/null +++ b/sync/tests/test_links.py @@ -0,0 +1,199 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +import uuid +from datetime import datetime + +from dateutil.relativedelta import relativedelta + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +def generate_ref(): + return str(uuid.uuid4()) + + +class TestLink(TransactionCase): + def setUp(self): + super(TestLink, self).setUp() + funcs = self.env["sync.link"]._get_eval_context() + self.get_link = funcs["get_link"] + self.set_link = funcs["set_link"] + self.search_links = funcs["search_links"] + + def create_record(self): + return self.env["res.partner"].create({"name": "Test"}) + + def test_odoo_link(self): + REL = "sync_test_links_partner" + REL2 = "sync_test_links_partner2" + + self.assertFalse(self.env["res.partner"].search([]).search_links(REL)) + + # Set and get links + r = self.create_record() + ref = generate_ref() + r.set_link(REL, ref) + glink = self.get_link(REL, ref) + self.assertEqual(r, glink.odoo) + self.assertEqual(ref, glink.external) + glink = r.search_links(REL) + self.assertEqual(r, glink.odoo) + self.assertEqual(ref, glink.external) + + # check search_links + all_links = self.env["res.partner"].search([]).search_links(REL) + self.assertEqual(1, len(all_links)) + self.assertEqual(r, all_links[0].odoo) + + # update sync_date + now = datetime.now() - relativedelta(days=1) + all_links.update_links(now) + glink = self.get_link(REL, ref) + self.assertEqual(glink.sync_date, now) + + # update sync_date + now = datetime.now() + glink.update_links(now) + glink = self.get_link(REL, ref) + self.assertEqual(glink.sync_date, now) + + # check search_links + all_links = self.env["res.partner"].search([]).search_links(REL) + self.assertTrue(all_links) + self.assertEqual(1, len(all_links)) + self.assertEqual(r, all_links[0].odoo) + + # Multiple refs for the same relation and record + r = self.create_record() + ref1 = generate_ref() + ref2 = generate_ref() + r.set_link(REL, ref1) + with self.assertRaises(ValidationError): + r.set_link(REL, ref2) + r.set_link(REL, ref2, allow_many2many=True) + + # Multiple records for the same relation and ref + r1 = self.create_record() + r2 = self.create_record() + ref = generate_ref() + r1.set_link(REL, ref) + with self.assertRaises(ValidationError): + r2.set_link(REL, ref) + r2.set_link(REL, ref, allow_many2many=True) + + # multiple links for different relation_name + r = self.create_record() + ref1 = generate_ref() + r.set_link(REL, ref1) + ref2 = generate_ref() + r.set_link(REL2, ref2) + self.assertFalse(self.get_link(REL2, ref1)) + + # search links by two sets of references + r1 = self.create_record() + ref1 = generate_ref() + r1.set_link(REL, ref1) + r2 = self.create_record() + ref2 = generate_ref() + r2.set_link(REL, ref2) + r3 = self.create_record() + ref3 = generate_ref() + r3.set_link(REL, ref3) + r123 = r1 | r2 | r3 + links = r123.search_links(REL, [ref1, ref2]) + self.assertEqual(2, len(links)) + links = r123.search_links(REL, [ref1, ref2, ref3]) + self.assertEqual(3, len(links)) + r12 = r1 | r2 + links = r12.search_links(REL, [ref1, ref2, ref3]) + self.assertEqual(2, len(links)) + + # check links + all_links = self.env["res.partner"].search([]).search_links(REL) + self.assertNotEqual(1, len(all_links)) + self.assertNotEqual(1, len(all_links.odoo)) + self.assertIsInstance(all_links.odoo.ids, list) + self.assertIsInstance(all_links.external, list) + self.assertIsInstance(all_links.sync_date, datetime) + for link in all_links: + self.assertIsInstance(link.odoo.id, int) + + # unlink + all_links.unlink() + all_links = self.env["res.partner"].search([]).search_links(REL) + self.assertFalse(all_links) + + def test_external_link(self): + REL = "sync_test_external_links" + all_links = self.search_links(REL, {"github": None, "trello": None}) + self.assertFalse(all_links) + + # set get links + now = datetime.now() - relativedelta(days=1) + slink = self.set_link(REL, {"github": 1, "trello": 101}, sync_date=now) + glink = self.get_link(REL, {"github": 1, "trello": 101}) + self.assertEqual(slink.get("github"), glink.get("github")) + glink = self.get_link(REL, {"github": 1, "trello": None}) + self.assertEqual(slink.get("github"), glink.get("github")) + glink = self.get_link(REL, {"github": None, "trello": 101}) + self.assertEqual(slink.get("github"), glink.get("github")) + + # update sync_date + now = datetime.now() + glink.update_links(now) + glink = self.get_link(REL, {"github": None, "trello": 101}) + self.assertEqual(glink.sync_date, now) + + # search_links + all_links = self.search_links(REL, {"github": None, "trello": None}) + self.assertEqual(1, len(all_links)) + self.assertEqual(now, all_links.sync_date) + for link in all_links: + self.assertEqual(now, link.sync_date) + all_links.update_links(now) + + # sets operations + self.set_link(REL, {"github": 2, "trello": 102}) + self.set_link(REL, {"github": 3, "trello": 103}) + self.set_link(REL, {"github": 4, "trello": 104}) + a = self.search_links(REL, {"github": [1, 2, 3], "trello": None}) + b = self.search_links(REL, {"github": None, "trello": [102, 103, 104]}) + self.assertNotEqual(a, b) + self.assertEqual(set((a - b).get("trello")), {"101"}) + self.assertEqual(set((a - b).get("github")), {"1"}) + self.assertEqual(set((a | b).get("github")), {"1", "2", "3", "4"}) + self.assertEqual(set((a & b).get("github")), {"2", "3"}) + self.assertEqual(set((a ^ b).get("github")), {"1", "4"}) + + # one2many + self.set_link(REL, {"github": 5, "trello": 105}) + with self.assertRaises(Exception): + self.set_link(REL, {"github": 5, "trello": 1005}) + self.set_link(REL, {"github": 5, "trello": 1005}, allow_many2many=True) + with self.assertRaises(Exception): + glink = self.get_link(REL, {"github": 5, "trello": None}) + glinks = self.search_links(REL, {"github": 5, "trello": None}) + self.assertEqual(2, len(glinks)) + glink1 = self.get_link(REL, {"github": 5, "trello": 105}) + glink2 = self.get_link(REL, {"github": 5, "trello": 1005}) + glink3 = self.get_link(REL, {"github": None, "trello": 105}) + glink4 = self.get_link(REL, {"github": None, "trello": 1005}) + self.assertEqual(glink1, glink3) + self.assertEqual(glink2, glink4) + self.assertNotEqual(glink1, glink2) + elinks = self.search_links(REL, {"github": None, "trello": [105, 1005]}) + self.assertEqual(2, len(elinks)) + elinks = self.search_links( + REL, {"github": [2, 5], "trello": [102, 100000002, 105, 1005]} + ) + self.assertEqual(3, len(elinks)) + elinks = self.search_links(REL, {"github": [2, 5], "trello": None}) + self.assertEqual(3, len(elinks)) + + # unlink + all_links = self.search_links(REL, {"github": None, "trello": None}) + all_links.unlink() + all_links = self.search_links(REL, {"github": None, "trello": None}) + self.assertFalse(all_links) diff --git a/sync/tests/test_trigger_db.py b/sync/tests/test_trigger_db.py new file mode 100644 index 00000000..5623f217 --- /dev/null +++ b/sync/tests/test_trigger_db.py @@ -0,0 +1,33 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +import logging + +from odoo.tests.common import TransactionCase, tagged + +_logger = logging.getLogger(__name__) + + +# Use the same tags as in base_automation module tests +@tagged("post_install", "-at_install") +class TestTriggerDB(TransactionCase): + def setUp(self): + super(TestTriggerDB, self).setUp() + funcs = self.env["sync.link"]._get_eval_context() + self.get_link = funcs["get_link"] + + def test_trigger_db(self): + """Test handle_db created in sync_demo.xml""" + + # activate project + self.env.ref("sync.test_project").active = True + # trigger event + partner = ( + self.env["res.partner"] + .with_context(new_cursor_logs=False) + .create({"name": "Test Partner Name"}) + ) + # check that handler is executed + param = self.env.ref("sync.test_project_param") + link = self.get_link(param.value, partner.id) + self.assertTrue(link) diff --git a/sync/tools/__init__.py b/sync/tools/__init__.py new file mode 100644 index 00000000..fdf9a1f4 --- /dev/null +++ b/sync/tools/__init__.py @@ -0,0 +1,3 @@ +# License MIT (https://opensource.org/licenses/MIT). + +from .safe_eval import safe_eval_extra, test_python_expr_extra diff --git a/sync/tools/safe_eval.py b/sync/tools/safe_eval.py new file mode 100644 index 00000000..36262f01 --- /dev/null +++ b/sync/tools/safe_eval.py @@ -0,0 +1,158 @@ +# Copyright 2020 Ivan Yelizariev +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) +# +# Code of this file is based on odoo/tools/safe_eval.py +# License notes from there: +# +# Part of Odoo. See LICENSE file for full copyright and licensing details. +# Module partially ripped from/inspired by several different sources: +# - http://code.activestate.com/recipes/286134/ +# - safe_eval in lp:~xrg/openobject-server/optimize-5.0 +# - safe_eval in tryton http://hg.tryton.org/hgwebdir.cgi/trytond/rev/bbb5f73319ad + +# pylint: disable=eval-referenced + +import logging +import sys +from opcode import opmap +from types import CodeType + +import werkzeug +from psycopg2 import OperationalError + +import odoo +from odoo.tools import pycompat +from odoo.tools.misc import ustr +from odoo.tools.safe_eval import _BUILTINS, _SAFE_OPCODES, test_expr + +_logger = logging.getLogger(__name__) + +_SAFE_OPCODES = _SAFE_OPCODES.union( + { + opmap[x] + for x in [ + "IMPORT_NAME", + "IMPORT_FROM", + "LOAD_DEREF", + "STORE_DEREF", + "MAKE_CLOSURE", # python 3.5 only. See https://python.readthedocs.io/en/stable/whatsnew/3.6.html + "BUILD_TUPLE_UNPACK_WITH_CALL", # python 3.6 + "LOAD_CLOSURE", + ] + if opmap.get(x) + } +) + +unsafe_eval = eval + +_BUILTINS["__import__"] = __import__ + + +# The code below differs from origin by lint changes only +def safe_eval_extra( + expr, + globals_dict=None, + locals_dict=None, + mode="eval", + nocopy=False, + locals_builtins=False, +): + """safe_eval(expression[, globals[, locals[, mode[, nocopy]]]]) -> result + + System-restricted Python expression evaluation + + Evaluates a string that contains an expression that mostly + uses Python constants, arithmetic expressions and the + objects directly provided in context. + + This can be used to e.g. evaluate + an OpenERP domain expression from an untrusted source. + + :throws TypeError: If the expression provided is a code object + :throws SyntaxError: If the expression provided is not valid Python + :throws NameError: If the expression provided accesses forbidden names + :throws ValueError: If the expression provided uses forbidden bytecode + """ + if type(expr) is CodeType: + raise TypeError("safe_eval does not allow direct evaluation of code objects.") + + # prevent altering the globals/locals from within the sandbox + # by taking a copy. + if not nocopy: + # isinstance() does not work below, we want *exactly* the dict class + if (globals_dict is not None and type(globals_dict) is not dict) or ( + locals_dict is not None and type(locals_dict) is not dict + ): + _logger.warning( + "Looks like you are trying to pass a dynamic environment, " + "you should probably pass nocopy=True to safe_eval()." + ) + if globals_dict is not None: + globals_dict = dict(globals_dict) + if locals_dict is not None: + locals_dict = dict(locals_dict) + + if globals_dict is None: + globals_dict = {} + + globals_dict["__builtins__"] = _BUILTINS + if locals_builtins: + if locals_dict is None: + locals_dict = {} + locals_dict.update(_BUILTINS) + c = test_expr(expr, _SAFE_OPCODES, mode=mode) + try: + return unsafe_eval(c, globals_dict, locals_dict) + except odoo.exceptions.except_orm: + raise + except odoo.exceptions.Warning: + raise + except odoo.exceptions.RedirectWarning: + raise + except odoo.exceptions.AccessDenied: + raise + except odoo.exceptions.AccessError: + raise + except werkzeug.exceptions.HTTPException: + raise + except odoo.http.AuthenticationError: + raise + except OperationalError: + # Do not hide PostgreSQL low-level exceptions, to let the auto-replay + # of serialized transactions work its magic + raise + except odoo.exceptions.MissingError: + raise + except Exception as e: + exc_info = sys.exc_info() + pycompat.reraise( + ValueError, + ValueError( + '{}: "{}" while evaluating\n{!r}'.format(ustr(type(e)), ustr(e), expr) + ), + exc_info[2], + ) + + +def test_python_expr_extra(expr, mode="eval"): + try: + test_expr(expr, _SAFE_OPCODES, mode=mode) + except (SyntaxError, TypeError, ValueError) as err: + if len(err.args) >= 2 and len(err.args[1]) >= 4: + error = { + "message": err.args[0], + "filename": err.args[1][0], + "lineno": err.args[1][1], + "offset": err.args[1][2], + "error_line": err.args[1][3], + } + msg = "%s : %s at line %d\n%s" % ( + type(err).__name__, + error["message"], + error["lineno"], + error["error_line"], + ) + else: + msg = ustr(err) + return msg + return False diff --git a/sync/views/ir_logging_views.xml b/sync/views/ir_logging_views.xml new file mode 100644 index 00000000..0d20d575 --- /dev/null +++ b/sync/views/ir_logging_views.xml @@ -0,0 +1,144 @@ + + + + + ir.logging.tree + ir.logging + + + + + + + + + + + + + ir.logging.form + ir.logging + +

+ + + + + + + + + + + + + + + + + + + + + ir.logging.search + ir.logging + + + + + + + + + + + + + + + Logs + ir.logging + tree,form + [('sync_job_id', '!=', False)] + + + + + + Project Logs + ir.logging + tree,form + [('sync_project_id', '=', active_id)] + + + + Task Logs + ir.logging + tree,form + [('sync_task_id', '=', active_id)] + + + + Job Logs + ir.logging + tree,form + + [('sync_job_id', '=', active_id)] + + + diff --git a/sync/views/sync_job_views.xml b/sync/views/sync_job_views.xml new file mode 100644 index 00000000..45ddebd6 --- /dev/null +++ b/sync/views/sync_job_views.xml @@ -0,0 +1,175 @@ + + + + + sync.job.tree + sync.job + + + + + + + + + + + + sync.job.form + sync.job + +
+
+
+ + +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + Project Jobs + sync.job + tree,form + [('project_id', '=', active_id)] + + + Task Jobs + sync.job + tree,form + [('task_id', '=', active_id)] + + + Jobs + sync.job + tree,form + + +
diff --git a/sync/views/sync_link_views.xml b/sync/views/sync_link_views.xml new file mode 100644 index 00000000..10e499c9 --- /dev/null +++ b/sync/views/sync_link_views.xml @@ -0,0 +1,90 @@ + + + + + sync.link.tree + sync.link + + + + + + + + + + + + + + sync.link.form + sync.link + +
+ + + + + + + + + + + + + +
+
+
+ + sync.link.search + sync.link + + + + + + + + + + + + + + Links + sync.link + tree,form + + + +
diff --git a/sync/views/sync_menus.xml b/sync/views/sync_menus.xml new file mode 100644 index 00000000..b32dad0f --- /dev/null +++ b/sync/views/sync_menus.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/sync/views/sync_project_views.xml b/sync/views/sync_project_views.xml new file mode 100644 index 00000000..d39b97d8 --- /dev/null +++ b/sync/views/sync_project_views.xml @@ -0,0 +1,300 @@ + + + + + Sync Projects + sync.project + tree,form + {"active_test": False} + + + sync.project.tree + sync.project + + + + + + + + + sync.project.form + sync.project + +
+
+ +
+ +
+ + + + + + + + + +
+ +
+

+ +

+
+ + +

+
+ Hint: to add manual triggers navigate to corresponding + Task via "Available Tasks" tab +

+ + + + + + + + + +
+

+ +

+
+ + + + + + + +

+ + # The code may use functions and variables from + Project's Shared Code + + +
+ # Hint: if you miss some code editor features, just + copy the code to your lovely editor, update there + and paste here +
+

+ +
+ + +

+ + handle_cron() + +

+ + + + + + + + + + + +

+ + handle_db(records) + +

+ + + + + + + + + +

+ + handle_webhook(httprequest) + +

+ + + + + + + + + + +

+ + handle_button() + +

+ + + + + + + + + +
+ +
+
+ + sync.trigger.cron.form full + sync.trigger.cron + + primary + + + + + + + + + Cron Triggers + sync.trigger.cron + tree,form + [('sync_project_id', '=', active_id)] + + +
diff --git a/sync/views/sync_trigger_webhook_views.xml b/sync/views/sync_trigger_webhook_views.xml new file mode 100644 index 00000000..e3a21308 --- /dev/null +++ b/sync/views/sync_trigger_webhook_views.xml @@ -0,0 +1,42 @@ + + + + + sync.trigger.webhook.tree + sync.trigger.webhook + + + + + + + + + + + sync.trigger.webhook.form + sync.trigger.webhook + +
+ + + + + + + + + + + +
+
+
+ + Webhook Triggers + sync.trigger.webhook + tree + [('sync_project_id', '=', active_id)] + +
diff --git a/sync/wizard/__init__.py b/sync/wizard/__init__.py new file mode 100644 index 00000000..21ddae81 --- /dev/null +++ b/sync/wizard/__init__.py @@ -0,0 +1,3 @@ +# License MIT (https://opensource.org/licenses/MIT). + +from . import sync_make_module diff --git a/sync/wizard/sync_make_module.py b/sync/wizard/sync_make_module.py new file mode 100644 index 00000000..8045b60e --- /dev/null +++ b/sync/wizard/sync_make_module.py @@ -0,0 +1,201 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). +import base64 + +from lxml import etree + +from odoo import api, fields, models + +from odoo.addons.http_routing.models.ir_http import slugify + +PARAM_NAME = "sync.export_project.author_name" +PARAM_URL = "sync.export_project.author_url" +PARAM_LICENSE = "sync.export_project.license" +PARAM_MODULE = "sync.export_project.module" + + +class SyncMakeModule(models.TransientModel): + _name = "sync.make.module" + _description = "Generating XML data file for a module" + + name = fields.Char("File Name", readonly=True, compute="_compute_name") + name2 = fields.Char("File Name Txt", readonly=True, compute="_compute_name") + data = fields.Binary("File", readonly=True, attachment=False) + data2 = fields.Binary("File Txt", related="data") + module = fields.Char("Module Technical Name", required=True) + copyright_years = fields.Char("Copyright Year", default="2020", required=True) + author_name = fields.Char("Author Name", help="e.g. Ivan Yelizariev", required=True) + author_url = fields.Char("Author URL", help="e.g. https://twitter.com/yelizariev") + license_line = fields.Char( + "License", + default="License MIT (https://opensource.org/licenses/MIT)", + required=True, + ) + state = fields.Selection([("choose", "choose"), ("get", "get")], default="choose") + project_id = fields.Many2one("sync.project") + + def _compute_name(self): + for r in self: + name = slugify(r.project_id.name).replace("-", "_") + name = "sync_project_{}_data.xml".format(name) + r.name = name + r.name2 = "{}.txt".format(name) + + @api.model + def default_get(self, fields): + vals = super().default_get(fields) + vals["author_name"] = self.env["ir.config_parameter"].get_param(PARAM_NAME, "") + vals["author_url"] = self.env["ir.config_parameter"].get_param(PARAM_URL, "") + license_line = self.env["ir.config_parameter"].get_param(PARAM_LICENSE) + if license_line: + vals["license_line"] = license_line + module = self.env["ir.config_parameter"].get_param(PARAM_MODULE) + if not module: + m = self.env["ir.module.module"].search( + [("state", "=", "installed")], limit=1, order="write_date desc" + ) + if m.name.startswith("sync_"): + module = m.name + if not module: + module = "sync_x" + vals["module"] = module + return vals + + def act_makefile(self): + self.env["ir.config_parameter"].set_param(PARAM_NAME, self.author_name) + self.env["ir.config_parameter"].set_param(PARAM_LICENSE, self.license_line) + self.env["ir.config_parameter"].set_param(PARAM_MODULE, self.module) + if self.author_url: + self.env["ir.config_parameter"].set_param(PARAM_URL, self.author_url) + + url = " <{}>".format(self.author_url) if self.author_url else "" + copyright_str = "".format( + years=self.copyright_years, + name=self.author_name, + url=url, + license_line=self.license_line, + ) + root = etree.Element("odoo") + project = self.project_id.with_context(active_test=False) + records = [ + (project, ("name", "active", "secret_code", "common_code")), + ] + for secret in project.secret_ids: + records.append((secret, ("key", "description", "url", "project_id"))) + + for param in project.param_ids: + records.append( + (param, ("key", "value", "description", "url", "project_id")) + ) + + for task in project.task_ids: + records.append((task, ("name", "active", "project_id", "code"))) + for trigger in task.button_ids: + records.append((trigger, ("trigger_name", "name", "sync_task_id"))) + for trigger in task.cron_ids: + records.append( + ( + trigger, + ( + "trigger_name", + "active", + "sync_task_id", + "interval_number", + "interval_type", + ), + ) + ) + for trigger in task.automation_ids: + records.append( + ( + trigger, + ( + "trigger_name", + "active", + "sync_task_id", + "model_id", + "trigger", + ), + ) + ) + + for trigger in task.webhook_ids: + records.append( + (trigger, ("trigger_name", "active", "name", "sync_task_id")) + ) + + for r, field_names in records: + root.append(self._record2xml(r, field_names)) + + if hasattr(etree, "indent"): + etree.indent(root, space=" ") + data = etree.tostring( + root, + xml_declaration=True, + encoding="UTF-8", + pretty_print=True, + doctype=copyright_str, + ) + data = base64.encodebytes(data) + self.write({"state": "get", "data": data}) + return self.get_wizard() + + def _record2id(self, record): + existing = self.env["ir.model.data"].search( + [("model", "=", record._name), ("res_id", "=", record.id)] + ) + if existing: + existing = existing[0] + if existing.module == self.module: + return existing.name + else: + return existing.complete_name + + xmlid = "{}_{}".format( + slugify(record.display_name), slugify(record._description) + ) + + self.env["ir.model.data"].create( + { + "model": record._name, + "res_id": record.id, + "module": self.module, + "name": xmlid, + } + ) + return xmlid + + def _field2xml(self, record, fname): + field = record._fields[fname] + value = getattr(record, fname) + xml = etree.Element("field", name=fname) + if field.type in ["char", "selection", "integer"]: + xml.text = str(value) if value else "" + elif field.type == "text": + xml.text = etree.CDATA(value or "") + elif field.type == "many2one": + xml.set("ref", self.module + "." + self._record2id(value)) + elif field.type == "boolean": + xml.set("eval", str(value)) + return xml + + def _record2xml(self, record, fields): + xml = etree.Element("record", id=self._record2id(record), model=record._name) + for fname in fields: + xml.append(self._field2xml(record, fname)) + return xml + + def act_configure(self): + self.write({"state": "choose"}) + return self.get_wizard() + + def get_wizard(self): + return { + "type": "ir.actions.act_window", + "res_model": self._name, + "view_mode": "form", + "view_type": "form", + "res_id": self.id, + "views": [(False, "form")], + "target": "new", + } diff --git a/sync/wizard/sync_make_module_views.xml b/sync/wizard/sync_make_module_views.xml new file mode 100644 index 00000000..798737cf --- /dev/null +++ b/sync/wizard/sync_make_module_views.xml @@ -0,0 +1,106 @@ + + + + + sync.make.module + +
+ + + From 133388fc45257a1c0d06b8901ac4b8f9e4f5a72f Mon Sep 17 00:00:00 2001 From: Denis Mudarisov Date: Fri, 18 Sep 2020 11:08:43 +0500 Subject: [PATCH 02/92] :book: clarification of documentation and correction of typos --- sync/README.rst | 2 +- sync/doc/index.rst | 44 ++++++++++++++++-------------- sync/static/description/index.html | 2 +- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/sync/README.rst b/sync/README.rst index 0ffa2219..5a465a73 100644 --- a/sync/README.rst +++ b/sync/README.rst @@ -44,7 +44,7 @@ If you run Odoo locally and need to test webhook, you can use ssh tunneling: * Connect your server: - * Edit file ``/etc/ssh/ssd_config``: + * Edit file ``/etc/ssh/sshd_config``: * Find ``GatewayPorts`` attribute and set value to ``yes`` diff --git a/sync/doc/index.rst b/sync/doc/index.rst index 0f5584c9..ce376f4e 100644 --- a/sync/doc/index.rst +++ b/sync/doc/index.rst @@ -45,7 +45,7 @@ Project * **Key** * **Value** - * **Secrets**: Parameters with restricted access: key values are visiable for Managers only + * **Secrets**: Parameters with restricted access: key values are visible for Managers only * In the ``Shared Code`` tab @@ -85,7 +85,7 @@ Project * ``return data_str, status, headers`` * ``status`` is a response code, e.g. ``200``, ``403``, etc. - * ``headers`` is a list of key-value turples, e.g. ``[('Content-Type', 'text/html')]`` + * ``headers`` is a list of key-value tuples, e.g. ``[('Content-Type', 'text/html')]`` * ``handle_button()`` * **Cron Triggers**, **DB Triggers**, **Webhook Triggers**, **Manual @@ -185,7 +185,7 @@ Odoo Link usage: * ``for link in links:``: iterate over links * ``if links``: check that link set is not empty * ``len(links)``: number of links in the set -* sets operaions: +* sets operations: * ``links1 == links2``: sets are equal * ``links1 - links2``: links that are in first set, but not in another @@ -217,7 +217,7 @@ You can also link external data with external data on syncing two different syst "trello": [trello_card_num], } - * use None values to don't filter by referece value of that system, e.g. + * use None values to don't filter by reference value of that system, e.g. .. code-block:: python @@ -282,7 +282,7 @@ Event Asynchronous work ~~~~~~~~~~~~~~~~~ -* ``add_job(func_name, **options)(*func_args, **func_kwargs)``: call a function asyncroniously; options are similar to ``with_delay`` method of ``queue_job`` module: +* ``add_job(func_name, **options)(*func_args, **func_kwargs)``: call a function asynchronously; options are similar to ``with_delay`` method of ``queue_job`` module: * ``priority``: Priority of the job, 0 being the higher priority. Default is 10. * ``eta``: Estimated Time of Arrival of the job. It will not be executed before this date/time. @@ -309,7 +309,7 @@ Exceptions * ``UserError`` * ``ValidationError`` -* ``RetryableJobError``: raise to restart job from beginning; e.g. in case of temporarly errors like broken connection +* ``RetryableJobError``: raise to restart job from beginning; e.g. in case of temporary errors like broken connection * ``OSError`` Running Job @@ -349,7 +349,7 @@ Webhook Button ------ -* runs immediatly +* runs immediately * to retry click the button again Execution Logs @@ -372,7 +372,7 @@ In this project we create new partners and attach messages sent to telegram bot. Odoo Messages prefixed with ``/telegram`` are sent back to telegram. To try it, you need to install this module in demo mode. Also, your odoo -instance must be accessable over internet to receive telegram webhooks. Due to +instance must be accessible over internet to receive telegram webhooks. Due to telegram requirements, your web server must use http**s** connection. How it works @@ -388,7 +388,7 @@ How it works *DB trigger* waits for a message attached to a telegram partner (telegram partners are filtered by **Internal Reference** field). If the message has ``/telegram`` prefix, task's code is run: -* a message copy (after removeing the prefix) is sent to corresponding telegram user +* a message copy (after removing the prefix) is sent to corresponding telegram user * attach report message to the partner record Configuration @@ -495,15 +495,18 @@ Usage * Go back to the *Demo Odoo2odoo Integration* project in our Odoo * Click ``Available Tasks`` tab +* Click ``[Edit]`` * Go to ``Sync Remote Partners Updates`` task * Click on ``Available Triggers`` tab and go inside ``CHECK_EXTERNAL_ODOO`` trigger +* Configure cron * Make trigger Active on the upper right corner +* Click ``[Save]`` * Then you can trigger synchronization in some of the following ways: 1. Click ``[Run Manually]`` inside the trigger - 2. Simply wait up to 15 minutes :) + 2. Simply wait up to cron job will start on a schedule :) * Now open the partner in our Odoo * RESULT: avatar is synced from external Odoo @@ -557,8 +560,8 @@ with success, but label was not attached) or if odoo was stopped when github tried to notify about updates. In some cases, we can just retry the handler (e.g. there was an error on api request to github/trello, then the system tries few times to repeat label attaching/detaching). As a solution for cases when -retrieng didn't help (e.g. api is still not working) or cannot help (e.g. odoo -didn't get webhhook notification), we run a *Cron Trigger* at night to check for +retrying didn't help (e.g. api is still not working) or cannot help (e.g. odoo +didn't get webhook notification), we run a *Cron Trigger* at night to check for labels mismatch and synchronize them. In ``LABELS_MERGE_STRATEGY`` you can choose which strategy to use: @@ -588,6 +591,7 @@ Configuration * Open *CONFLICT_RESOLVING* Cron * Change **Next Execution Date** in webhook to the night time + * Set **Number of Calls**, a negative value means no limit (e.g. `-1`) * Make it active on the upper right corner * Click ``[Save]`` * Make integration Active on the upper right corner @@ -601,11 +605,10 @@ Configuration .. code-block:: nginx - location /website/action-json/ { - proxy_set_header Content-Type "application/json"; - proxy_set_header Host $host; - proxy_pass http://localhost:8069; - } + location /website/action-json/ { + return 200 "{}"; + } + * After a successful *SETUP_TRELLO* trigger run, return everything to its original position, otherwise the project will not work correctly @@ -624,7 +627,8 @@ Usage * Post a message * Now go back to the trello card * RESULT: you see a copy of the message -* You can also add/remove github issue labels or trello card labels. +* You can also add/remove github issue labels or trello card labels (note that the name of the label must be added + in Trello so that there are no errors in the GitHub). * RESULT: once you change them on one side, after short time, you will see the changes on another side @@ -649,7 +653,7 @@ Usage 2. Change **Next Execution Date** to a past time and wait up to 1 minute -* RESULT: the github issue and corresponding trello card the same set of labels. The merging is done according to selected stragegy in ``LABELS_MERGE_STRATEGY`` parameter. +* RESULT: the github issue and corresponding trello card the same set of labels. The merging is done according to selected strategy in ``LABELS_MERGE_STRATEGY`` parameter. **Syncing all existing Github issues.** @@ -657,7 +661,7 @@ Usage * Open menu ``[[ Sync Studio ]] >> Projects`` * Select *Demo Tello-Github Integration* project * Click button ``[Run Now]`` near to ``PUSH_ALL_ISSUES`` manual trigger -* It will start asyncronious jobs. You can check progress via button *Jobs* +* It will start asynchronous jobs. You can check progress via button *Jobs* * After some time open Trello * RESULT: copies of all *open* github issues are in trello; they have *GITHUB:* prefix (can be configured in project parameter ISSUE_FROM_GITHUB_PREFIX) diff --git a/sync/static/description/index.html b/sync/static/description/index.html index 23a4f636..d1f252d0 100644 --- a/sync/static/description/index.html +++ b/sync/static/description/index.html @@ -70,7 +70,7 @@

Synchronize anything with anything

- You can check existing solutions based on this module. There is no module that fits you needs or you need an update, send a request to sync@it-projects.info . + You can check existing solutions based on this module. If there is no module that fits you needs or you need an update, send a request to sync@it-projects.info .
From 3de37fccdeba008d757ff2acc93438f27e6e71ab Mon Sep 17 00:00:00 2001 From: Denis Mudarisov Date: Mon, 28 Sep 2020 14:56:55 +0500 Subject: [PATCH 03/92] :arrow_up::one::three: sync --- sync/README.rst | 6 +++--- sync/__manifest__.py | 2 +- sync/data/sync_project_odoo2odoo_demo.xml | 4 +++- sync/doc/index.rst | 4 ++-- sync/models/sync_link.py | 1 - sync/views/sync_project_views.xml | 2 +- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/sync/README.rst b/sync/README.rst index 5a465a73..3a659dac 100644 --- a/sync/README.rst +++ b/sync/README.rst @@ -94,12 +94,12 @@ Contributors Further information =================== -HTML Description: https://apps.odoo.com/apps/modules/12.0/sync/ +HTML Description: https://apps.odoo.com/apps/modules/13.0/sync/ Usage instructions: ``__ Changelog: ``_ -Notifications on updates: `via Atom `_, `by Email `_ +Notifications on updates: `via Atom `_, `by Email `_ -Tested on Odoo 12.0 3fbfa87df85d6463dfcba47416f360fcdef4448e +Tested on Odoo 13.0 9fe7d55e64867d177519e99cc45f9ecfeb3746a3 diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 94b6e5e9..7ff28040 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -6,7 +6,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY""", "category": "Extra Tools", "images": [], - "version": "12.0.1.0.0", + "version": "13.0.1.0.0", "application": True, "author": "IT-Projects LLC, Ivan Yelizariev", "support": "apps@it-projects.info", diff --git a/sync/data/sync_project_odoo2odoo_demo.xml b/sync/data/sync_project_odoo2odoo_demo.xml index aaf54d96..e48aef72 100644 --- a/sync/data/sync_project_odoo2odoo_demo.xml +++ b/sync/data/sync_project_odoo2odoo_demo.xml @@ -34,7 +34,7 @@ from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT `__ module. In particular: +* Make configuration required for `queue_job `__ module. In particular: * add ``queue_job`` to `server wide modules `__, e.g.:: @@ -439,7 +439,7 @@ You can continue chatting in this way Demo Project: Odoo2odoo ======================= -In this project we push partners to external Odoo 12.0 and sync back avatar changes. +In this project we push partners to external Odoo 13.0 and sync back avatar changes. To try it, you need to install this module in demo mode. diff --git a/sync/models/sync_link.py b/sync/models/sync_link.py index 381e1ca7..1acfddba 100644 --- a/sync/models/sync_link.py +++ b/sync/models/sync_link.py @@ -34,7 +34,6 @@ class SyncLink(models.Model): ) model = fields.Char("Odoo Model", index=True) - @api.model_cr_context def _auto_init(self): res = super(SyncLink, self)._auto_init() tools.create_unique_index( diff --git a/sync/views/sync_project_views.xml b/sync/views/sync_project_views.xml index d39b97d8..84b06b58 100644 --- a/sync/views/sync_project_views.xml +++ b/sync/views/sync_project_views.xml @@ -291,7 +291,7 @@ Date: Sun, 4 Oct 2020 20:35:36 +0200 Subject: [PATCH 04/92] :sparkles: sync: prevent odoo container escaping --- sync/README.rst | 4 +- sync/__init__.py | 1 - sync/__manifest__.py | 2 +- sync/data/sync_project_odoo2odoo_demo.xml | 28 +- sync/data/sync_project_telegram_demo.xml | 44 +- sync/data/sync_project_trello_github_demo.xml | 273 ++----------- sync/doc/changelog.rst | 10 + sync/doc/index.rst | 73 ++-- sync/models/__init__.py | 1 + sync/models/sync_project.py | 69 ++-- sync/models/sync_project_demo.py | 378 ++++++++++++++++++ sync/models/sync_task.py | 8 +- sync/models/sync_trigger_button.py | 2 +- sync/tests/__init__.py | 1 - sync/tests/test_eval.py | 167 -------- sync/tools/__init__.py | 3 - sync/tools/safe_eval.py | 158 -------- sync/views/sync_project_views.xml | 30 +- sync/wizard/sync_make_module.py | 2 +- 19 files changed, 514 insertions(+), 740 deletions(-) create mode 100644 sync/models/sync_project_demo.py delete mode 100644 sync/tests/test_eval.py delete mode 100644 sync/tools/__init__.py delete mode 100644 sync/tools/safe_eval.py diff --git a/sync/README.rst b/sync/README.rst index 3a659dac..053fc21f 100644 --- a/sync/README.rst +++ b/sync/README.rst @@ -21,7 +21,6 @@ Provides a single place to handle synchronization trigered by one of the followi Difference with built-in code evaluation: -* Allows to add extra imports to eval context * Allows to use json format for incomming webhooks * Provides helpers for resource linking. See *Links* section in ``__ * Uses queue_job module as a job broker @@ -89,7 +88,8 @@ Contributors ------------ * `Ivan Yelizariev `__: - * :one::zero: init version of the module + * :one::two: init version of the module + * :one::two: redesign module to prevent odoo container escapes Further information =================== diff --git a/sync/__init__.py b/sync/__init__.py index f320a03a..f5b234aa 100644 --- a/sync/__init__.py +++ b/sync/__init__.py @@ -2,5 +2,4 @@ from . import models from . import wizard -from . import tools from . import controllers diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 7ff28040..c674e698 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -6,7 +6,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY""", "category": "Extra Tools", "images": [], - "version": "13.0.1.0.0", + "version": "13.0.2.0.1", "application": True, "author": "IT-Projects LLC, Ivan Yelizariev", "support": "apps@it-projects.info", diff --git a/sync/data/sync_project_odoo2odoo_demo.xml b/sync/data/sync_project_odoo2odoo_demo.xml index e48aef72..c844e570 100644 --- a/sync/data/sync_project_odoo2odoo_demo.xml +++ b/sync/data/sync_project_odoo2odoo_demo.xml @@ -6,31 +6,7 @@ Demo Odoo2odoo Integration - - - + odoo2odoo ", links.sync_date.strftime(DEFAULT_SERVER_DATETIME_FORMAT)]], + [["id", "in", [int(id) for id in links.external]], ["write_date", ">", links.sync_date.strftime(DEFAULT_SERVER_DATETIME_FORMAT)]], fields=["write_date", IMAGE_FIELD] ) # Save fetched data in local Odoo diff --git a/sync/data/sync_project_telegram_demo.xml b/sync/data/sync_project_telegram_demo.xml index 77a0f148..b55384d2 100644 --- a/sync/data/sync_project_telegram_demo.xml +++ b/sync/data/sync_project_telegram_demo.xml @@ -5,41 +5,7 @@ Demo Telegram Integration - -setWebhook", json.dumps([args, kwargs])) - bot.setWebhook(*args, **kwargs) - - def parse_data(data): - return Update.de_json(data, bot) - - return { - "sendMessage": sendMessage, - "setWebhook": setWebhook, - "parse_data": parse_data, - } - -bot = _exports(secrets) - -]]> - + telegram @@ -115,7 +81,7 @@ def handle_webhook(httprequest): data = json.loads(httprequest.data.decode("utf-8")) log("Raw data: %s" % data, LOG_DEBUG) - update = bot["parse_data"](data) + update = telegram.parse_data(data) message = update.message or update.edited_message is_edit = bool(update.edited_message) user_ref = "%s@telegram" % message.from_user.id @@ -127,7 +93,7 @@ def handle_webhook(httprequest): odoo_message_text = "From Telegram:" if message.text == "/start": - bot["sendMessage"](message.chat.id, params.TELEGRAM_WELCOME_MESSAGE) + telegram.sendMessage(message.chat.id, params.TELEGRAM_WELCOME_MESSAGE) elif is_edit: odoo_message_text = "Contact has updated his message:" @@ -159,7 +125,7 @@ def handle_db(records): telegram_user_id = user_ref.split("@telegram")[0] telegram_message_html = html_sanitize_telegram(r.body) telegram_message_html = telegram_message_html.replace("/telegram", "") - bot["sendMessage"](telegram_user_id, telegram_message_html) + telegram.sendMessage(telegram_user_id, telegram_message_html) odoo_message_text = "%s:\n\n%s" % (params.TELEGRAM_MESSAGE_SENT, telegram_message_html) partner.message_post(body=odoo_message_text) diff --git a/sync/data/sync_project_trello_github_demo.xml b/sync/data/sync_project_trello_github_demo.xml index 41f0d0e9..1de027fb 100644 --- a/sync/data/sync_project_trello_github_demo.xml +++ b/sync/data/sync_project_trello_github_demo.xml @@ -5,220 +5,7 @@ Demo Trello-Github Integration - + trello_github @@ -475,7 +262,7 @@ def handle_webhook_issue_comment(data): card_id = issue_id2card_id(issue_id) issue_message = data["comment"]["body"] message = "%s\n%s" % (params.MESSAGE_PREFIX, issue_message) - trello["card_add_message"](card_id, message) + trello.card_add_message(card_id, message) def handle_webhook_issues_update(data): # https://docs.github.com/en/developers/webhooks-and-events/webhook-events-and-payloads#issues @@ -487,14 +274,14 @@ def handle_webhook_issues_update(data): issue_name = issue["title"] card_id = issue_id2card_id(issue_id) if not card_id: - card_id = trello["card_create"](params.ISSUE_FROM_GITHUB_PREFIX + issue_name, issue_id) + card_id = trello.card_create(params.ISSUE_FROM_GITHUB_PREFIX + issue_name, issue_id) set_link(ISSUES_REL, {GITHUB: issue_id, TRELLO: card_id}) if data["action"] == "opened": issue_message = issue["body"] if issue_message: message = "%s\n%s" % (params.MESSAGE_PREFIX, issue_message) - trello["card_add_message"](card_id, message) + trello.card_add_message(card_id, message) # attached labels: labels = [] @@ -506,7 +293,7 @@ def handle_webhook_issues_update(data): tlabel_ids = github2trello_labels(labels) if tlabel_ids: - trello["card_add_labels"](card_id, tlabel_ids) + trello.card_add_labels(card_id, tlabel_ids) # detached labels: if data["action"] == "unlabeled": @@ -517,7 +304,7 @@ def handle_webhook_issues_update(data): tlabel_ids = github2trello_labels(labels) if tlabel_ids: - trello["card_remove_labels"](card_id, tlabel_ids) + trello.card_remove_labels(card_id, tlabel_ids) def issue_id2card_id(issue_id): elink = get_link(ISSUES_REL, {GITHUB: issue_id, TRELLO: None}) @@ -531,7 +318,7 @@ def handle_button(): def fetch_github_issues(page=0): CHUNK_SIZE=10 # fetch page - issues = github["get_all_issues"](page) + issues = github.get_all_issues(page) if not issues: log("No more issues. Page={}".format(page)) return @@ -549,19 +336,19 @@ def process_github_issues(issues): log("Issue is already pushed: %s" % data["id"]) continue issue_name = params.ISSUE_FROM_GITHUB_PREFIX + data["name"] - card_id = trello["card_create"](issue_name, issue_id) + card_id = trello.card_create(issue_name, issue_id) set_link(ISSUES_REL, {GITHUB: issue_id, TRELLO: card_id}) issue_message = data["body"] if issue_message: message = "%s\n%s" % (params.MESSAGE_PREFIX, issue_message) - trello["card_add_message"](card_id, message) + trello.card_add_message(card_id, message) labels = data["labels"] tlabel_ids = github2trello_labels(labels) if tlabel_ids: - trello["card_add_labels"](card_id, tlabel_ids) + trello.card_add_labels(card_id, tlabel_ids) ]]> @@ -689,16 +476,16 @@ def process_github_issues(issues): tlabel_id = elinks.get(TRELLO)[0] if is_updated: if trigger == "TRELLO_LABEL_UPDATES": - github["label_update"](glabel_id, new_name, new_color) + github.label_update(glabel_id, new_name, new_color) elif trigger == "GITHUB_LABEL_UPDATES": - trello["label_update"](tlabel_id, new_name, new_color) + trello.label_update(tlabel_id, new_name, new_color) if is_deleted: elinks.unlink() if trigger == "TRELLO_LABEL_UPDATES": - github["label_delete"](glabel_id) + github.label_delete(glabel_id) elif trigger == "GITHUB_LABEL_UPDATES": - trello["label_delete"](tlabel_id) + trello.label_delete(tlabel_id) ]]> @@ -733,7 +520,7 @@ def handle_cron(): issue_ids = elinks.get(GITHUB) # https://docs.github.com/en/rest/reference/issues#list-repository-issues # issue is {id: int, labels: [{id: int, name: str, ...}]} - issues = github["get_all_issues"]() + issues = github.get_all_issues() # issue_id -> issue issues_index = {} for issue in issues: @@ -745,7 +532,7 @@ def handle_cron(): # https://developer.atlassian.com/cloud/trello/rest/api-group-boards/#api-boards-id-cards-get # https://developer.atlassian.com/cloud/trello/rest/api-group-cards/#api-cards-id-get # card is dict {id: int, idLabels: [int], ...} - cards = trello["get_all_cards"]() + cards = trello.get_all_cards() # card_id -> card cards_index = {} for card in cards: @@ -791,13 +578,13 @@ def handle_cron(): raise Exception("Unknown LABELS_MERGE_STRATEGY: %s" % LABELS_MERGE_STRATEGY, level=LOG_ERROR) if tlinks_add: - trello["card_add_labels"](card_id, tlinks_add.get(TRELLO)) + trello.card_add_labels(card_id, tlinks_add.get(TRELLO)) if glinks_add: - github["issue_add_labels"](issue_id, glinks_add.get(GITHUB)) + github.issue_add_labels(issue_id, glinks_add.get(GITHUB)) if tlinks_remove: - trello["card_remove_labels"](card_id, tlinks_remove.get(TRELLO)) + trello.card_remove_labels(card_id, tlinks_remove.get(TRELLO)) if glinks_remove: - github["issue_remove_labels"](issue_id, glinks_remove.get(GITHUB)) + github.issue_remove_labels(issue_id, glinks_remove.get(GITHUB)) ]]> diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index 5583eb32..3f96cb8e 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,13 @@ +`2.0.1` +------- + +- **Improvement:** add the ability to get type of the given object + +`2.0.0` +------- + +- **Improvement:** for security sake imports are available via module code only + `1.0.0` ------- diff --git a/sync/doc/index.rst b/sync/doc/index.rst index 41ce386d..37b96c41 100644 --- a/sync/doc/index.rst +++ b/sync/doc/index.rst @@ -29,7 +29,7 @@ User Access Levels * ``Sync Studio: User``: read-only access * ``Sync Studio: Developer``: restricted write access -* ``Sync Studio: Manager``: same as Developer, but with access to **Secrets** and **Protected Code** +* ``Sync Studio: Manager``: same as Developer, but with access to **Secrets** Project ======= @@ -47,11 +47,11 @@ Project * **Value** * **Secrets**: Parameters with restricted access: key values are visible for Managers only - * In the ``Shared Code`` tab + * In the ``Evaluation Context`` tab - * **Protected Code**, **Common Code**: code that is executed before running any - project's task. Can be used for initialization or for helpers. Secret params - and package importing are available in **Protected Code** only. Any variables + * **Evaluation context**: predefined additional variables and methods + * **Common_code**: code that is executed before running any + project's task. Can be used for initialization or for helpers. Any variables and functions that don't start with underscore symbol will be available in task's code. @@ -155,6 +155,10 @@ Base * ``LOG_ERROR`` * ``LOG_CRITICAL`` +* ``log_transmission(recipient_str, data_str)``: report on data transfer to external recipients + +* ``DEFAULT_SERVER_DATETIME_FORMAT`` + Links ~~~~~ @@ -242,35 +246,10 @@ External Link is similar to Odoo link with the following differences: * ``elink.get()``, e.g. ``elink.get("github")``: reference value for system; it's a replacement for ``link.odoo`` and ``link.external`` in Odoo link -Network -~~~~~~~ - -* ``log_transmission(recipient_str, data_str)``: report on data transfer to external recipients; example of a function in *Protected Code*: - - .. code-block:: python - - def httpPOST(url, *args, **kwargs): - import requests - log_transmission(url, json.dumps([args, kwargs])) - r = requests.post(url, *args, **kwargs) - return r.text - - Project Values ~~~~~~~~~~~~~~ * ``params.``: project params -* ``secrets.``: available in **Protected Code** only; you need to use closure to use it, for example: - - .. code-block:: python - - def _make_request(secrets): - import requests - def f(data): - return requests.post(params.API_URL, data=data, auth=(secrets.API_USER, secrets.API_PASSWORD)) - return f - make_request = _make_request(secrets) - * ``webhooks.``: contains webhook url; only in tasks' code Event @@ -312,6 +291,40 @@ Exceptions * ``RetryableJobError``: raise to restart job from beginning; e.g. in case of temporary errors like broken connection * ``OSError`` +Evaluation context +------------------ + +Evaluation provides additional variables and methods for a project. For example, for telegram integration is could be method to send message to a telegram user. To make such additional context, you need to make a new module and make extension for ``sync.project`` model. Example: + +.. code-block:: python + + import requests + from odoo import api, fields, models + + class SyncProject(models.Model): + + _inherit = "sync.project" + eval_context = fields.Selection(selection_add=[ + ("my_project", "My Project"), + ]) + + @api.model + def _eval_context_my_project(self, secrets, eval_context): + """Additional function to make http request + + * httpPost(url, **kwargs) + """ + log_transmission = eval_context["log_transmission"] + log = eval_context["log"] + def httpPOST(url, **kwargs): + log_transmission(url, json.dumps(kwargs)) + r = requests.request("POST", url, **kwargs) + log("Response: %s" % r.text) + return r.text + return { + "httpPost": httpPost + } + Running Job =========== diff --git a/sync/models/__init__.py b/sync/models/__init__.py index 7d64abba..881a80aa 100644 --- a/sync/models/__init__.py +++ b/sync/models/__init__.py @@ -1,6 +1,7 @@ # License MIT (https://opensource.org/licenses/MIT). from . import sync_project +from . import sync_project_demo from . import sync_task from . import sync_trigger_mixin from . import sync_trigger_cron diff --git a/sync/models/sync_project.py b/sync/models/sync_project.py index 3bb7901c..7897dd9f 100644 --- a/sync/models/sync_project.py +++ b/sync/models/sync_project.py @@ -11,17 +11,18 @@ from odoo import api, fields, models from odoo.exceptions import UserError, ValidationError +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, frozendict from odoo.tools.safe_eval import safe_eval, test_python_expr from odoo.tools.translate import _ from odoo.addons.base.models.ir_actions import dateutil from odoo.addons.queue_job.exception import RetryableJobError -from ..tools import safe_eval_extra, test_python_expr_extra from .ir_logging import LOG_CRITICAL, LOG_DEBUG, LOG_ERROR, LOG_INFO, LOG_WARNING _logger = logging.getLogger(__name__) DEFAULT_LOG_NAME = "Log" +EVAL_CONTEXT_PREFIX = "_eval_context_" def cleanup_eval_context(eval_context): @@ -40,22 +41,9 @@ class SyncProject(models.Model): "Name", help="e.g. Legacy Migration or eCommerce Synchronization", required=True ) active = fields.Boolean(default=True) - secret_code = fields.Text( - "Protected Code", - groups="sync.sync_group_manager", - help="""First code to eval. + eval_context = fields.Selection([], string="Evaluation context") + eval_context_description = fields.Text(compute="_compute_eval_context_description") - Secret Params and package importing are available here only. - - Any variables and functions that don't start with underscore symbol will be available in Common Code and task's code. - - To log transmitted data, use log_transmission(receiver, data) function. - """, - ) - - secret_code_readonly = fields.Text( - "Protected Code (Readonly)", compute="_compute_secret_code_readonly" - ) common_code = fields.Text( "Common Code", help=""" @@ -88,9 +76,12 @@ class SyncProject(models.Model): log_ids = fields.One2many("ir.logging", "sync_project_id") log_count = fields.Integer(compute="_compute_log_count") - def _compute_secret_code_readonly(self): + def _compute_eval_context_description(self): for r in self: - r.secret_code_readonly = (r.sudo().secret_code or "").strip() + if not r.eval_context: + r.eval_context_description = "" + method = getattr(self, EVAL_CONTEXT_PREFIX + r.eval_context) + r.eval_context_description = method.__doc__ def _compute_network_access_readonly(self): for r in self: @@ -119,15 +110,8 @@ def _compute_triggers(self): r.trigger_button_count = len(r.mapped("task_ids.button_ids")) r.trigger_button_ids = r.mapped("task_ids.button_ids") - @api.constrains("secret_code", "common_code") + @api.constrains("common_code") def _check_python_code(self): - for r in self.sudo().filtered("secret_code"): - msg = test_python_expr_extra( - expr=(r.secret_code or "").strip(), mode="exec" - ) - if msg: - raise ValidationError(msg) - for r in self.sudo().filtered("common_code"): msg = test_python_expr(expr=(r.common_code or "").strip(), mode="exec") if msg: @@ -195,10 +179,6 @@ def f(*args, **kwargs): for p in self.param_ids: params[p.key] = p.value - secrets = AttrDict() - for p in self.sudo().secret_ids: - secrets[p.key] = p.value - webhooks = AttrDict() for w in self.task_ids.mapped("webhook_ids"): webhooks[w.trigger_name] = w.website_url @@ -230,7 +210,6 @@ def safe_setattr(o, k, v): "LOG_ERROR": LOG_ERROR, "LOG_CRITICAL": LOG_CRITICAL, "params": params, - "secrets": secrets, "webhooks": webhooks, "user": self.env.user, "trigger": job.trigger_name, @@ -248,17 +227,25 @@ def safe_setattr(o, k, v): "timezone": timezone, "b64encode": base64.b64encode, "b64decode": base64.b64decode, + "type2str": type2str, + "DEFAULT_SERVER_DATETIME_FORMAT": DEFAULT_SERVER_DATETIME_FORMAT, } ) reading_time = time.time() - start_time - start_time = time.time() - safe_eval_extra( - self.secret_code_readonly, eval_context, mode="exec", nocopy=True - ) - executing_secret_code = time.time() - start_time - del eval_context["secrets"] - cleanup_eval_context(eval_context) + executing_custom_context = 0 + if self.eval_context: + start_time = time.time() + + secrets = AttrDict() + for p in self.sudo().secret_ids: + secrets[p.key] = p.value + eval_context_frozen = frozendict(eval_context) + method = getattr(self, EVAL_CONTEXT_PREFIX + self.eval_context) + eval_context = dict(**eval_context, **method(secrets, eval_context_frozen)) + cleanup_eval_context(eval_context) + + executing_custom_context = time.time() - start_time start_time = time.time() safe_eval( @@ -266,8 +253,8 @@ def safe_setattr(o, k, v): ) executing_common_code = time.time() - start_time log( - "Evalution context is prepared. Reading project data: %05.3f sec; Executing secret code: %05.3f sec; Executing Common Code: %05.3f sec" - % (reading_time, executing_secret_code, executing_common_code), + "Evalution context is prepared. Reading project data: %05.3f sec; preparing custom evalution context: %05.3f sec; Executing Common Code: %05.3f sec" + % (reading_time, executing_custom_context, executing_common_code), LOG_DEBUG, ) cleanup_eval_context(eval_context) @@ -284,7 +271,7 @@ class SyncProjectParamMixin(models.AbstractModel): value = fields.Char("Value") description = fields.Char("Description", translate=True) url = fields.Char("Documentation") - project_id = fields.Many2one("sync.project") + project_id = fields.Many2one("sync.project", ondelete="cascade") _sql_constraints = [("key_uniq", "unique (project_id, key)", "Key must be unique.")] diff --git a/sync/models/sync_project_demo.py b/sync/models/sync_project_demo.py new file mode 100644 index 00000000..6ccff0dd --- /dev/null +++ b/sync/models/sync_project_demo.py @@ -0,0 +1,378 @@ +# Copyright 2020 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +import json +import xmlrpc.client as _client +from math import sqrt + +# https://github.com/python-telegram-bot/python-telegram-bot +from telegram import Bot, Update + +from odoo import api, fields, models +from odoo.exceptions import UserError +from odoo.tools.translate import _ + +from odoo.addons.queue_job.exception import RetryableJobError + +from .ir_logging import LOG_WARNING +from .sync_project import AttrDict + + +class SyncProjectDemo(models.Model): + + _inherit = "sync.project" + eval_context = fields.Selection( + selection_add=[ + ("odoo2odoo", "Odoo2odoo"), + ("telegram", "Telegram"), + ("trello_github", "Trello & Github"), + ] + ) + + @api.model + def _eval_context_odoo2odoo(self, secrets, eval_context): + """ + Additional functions to access external Odoo: + + * odoo_execute_kw(model, method, *args, **kwargs) + + Connection is established according to following parameters: + + params.URL + params.DB + secrets.USERNAME + secrets.PASSWORD + """ + log_transmission = eval_context["log_transmission"] + log = eval_context["log"] + params = eval_context["params"] + if not all([params.URL, params.DB, secrets.USERNAME, secrets.PASSWORD]): + raise UserError(_("External Odoo Credentials are not set")) + + def odoo_execute_kw(model, method, *args, **kwargs): + log_transmission( + "XMLRPC DB={} URL={}".format(params.DB, params.URL), + json.dumps([method, args, kwargs]), + ) + try: + common = _client.ServerProxy("{}/xmlrpc/2/common".format(params.URL)) + uid = common.authenticate( + params.DB, secrets.USERNAME, secrets.PASSWORD, {} + ) + models = _client.ServerProxy("{}/xmlrpc/2/object".format(params.URL)) + except OSError: + raise RetryableJobError("Error on connecting to external Odoo") + res = models.execute_kw( + params.DB, uid, secrets.PASSWORD, model, method, args, kwargs + ) + log("Response: %s" % res, level="debug") + return res + + return { + "odoo_execute_kw": odoo_execute_kw, + } + + @api.model + def _eval_context_telegram(self, secrets, eval_context): + """Adds telegram object: + + * telegram.sendMessage + * telegram.setWebhook + * telegram.parse_data + """ + + log_transmission = eval_context["log_transmission"] + + if secrets.TELEGRAM_BOT_TOKEN: + bot = Bot(token=secrets.TELEGRAM_BOT_TOKEN) + else: + raise Exception("Telegram bot token is not set") + + def sendMessage(chat_id, *args, **kwargs): + log_transmission( + "Message to %s@telegram" % chat_id, json.dumps([args, kwargs]) + ) + bot.sendMessage(chat_id, *args, **kwargs) + + def setWebhook(*args, **kwargs): + log_transmission("Telegram->setWebhook", json.dumps([args, kwargs])) + bot.setWebhook(*args, **kwargs) + + def parse_data(data): + return Update.de_json(data, bot) + + telegram = AttrDict( + { + "sendMessage": sendMessage, + "setWebhook": setWebhook, + "parse_data": parse_data, + } + ) + + return {"telegram": telegram} + + @api.model + def _eval_context_trello_github(self, secrets, eval_context): + """Adds trello and github object with set of available methods (see sync/models/sync_project_demo.py): + * trello + * github + + It also adds two consts: + + * GITHUB="github" + * TRELLO="trello" + + And math function: + + * sqrt + + """ + GITHUB = "github" + TRELLO = "trello" + log_transmission = eval_context["log_transmission"] + log = eval_context["log"] + + # closure is not really needed, but let's keep as it was done in previous + # version when it was mandatory + def _trello(): + for key in ["TRELLO_TOKEN", "TRELLO_KEY", "TRELLO_BOARD_ID"]: + if not getattr(secrets, key): + raise Exception("{} is not set".format(key)) + + # https://github.com/sarumont/py-trello/tree/master/trello + from trello import TrelloClient + from trello.exceptions import ResourceUnavailable + + client = TrelloClient( + api_key=secrets.TRELLO_KEY, api_secret=secrets.TRELLO_TOKEN, + ) + board = client.get_board(secrets.TRELLO_BOARD_ID) + + # Webhook + def set_webhook(url): + id_model = board.id + desc = "Demo Trello-Github Integration" + log_transmission( + TRELLO, "set webhook: {}".format([url, id_model, desc]) + ) + + # original create_hook is not used. See: https://github.com/sarumont/py-trello/pull/323 + # hook = client.create_hook(url, id_model, desc, token=secrets.TRELLO_TOKEN) + token = secrets.TRELLO_TOKEN + res = client.fetch_json( + "tokens/{}/webhooks/".format(token), + http_method="POST", + post_args={ + "callbackURL": url, + "idModel": id_model, + "description": desc, + }, + ) + log("Trello response: %s" % json.dumps(res)) + + def delete_webhooks(): + for hook in client.list_hooks(secrets.TRELLO_TOKEN): + if hook.id_model == board.id: + log_transmission( + TRELLO, "delete webhook: {}".format([hook.callback_url]) + ) + hook.delete() + + # Trello cards + def card_create(name, issue_id): + description = "https://github.com/{}/issues/{}".format( + secrets.GITHUB_REPO, issue_id + ) + log_transmission(TRELLO, "create: {}".format([name, description])) + card_list = board.open_lists()[0] + card = card_list.add_card(name, description) + return card.id + + def card_add_labels(card_id, tlabel_ids): + log_transmission( + TRELLO, "add labels to card#{}: {}".format(card_id, tlabel_ids) + ) + card = client.get_card(card_id) + for label_id in tlabel_ids: + try: + label = client.get_label(label_id, board.id) + except ResourceUnavailable: + log("Label is deleted in trello: %s" % label_id, LOG_WARNING) + continue + if label_id in card.idLabels: + log("Label is already in card: %s" % label) + continue + card.add_label(label) + + def card_remove_labels(card_id, tlabel_ids): + log_transmission( + TRELLO, "remove labels from card#{}: {}".format(card_id, tlabel_ids) + ) + card = client.get_card(card_id) + for label_id in tlabel_ids: + label = client.get_label(label_id, board.id) + if label_id not in card.idLabels: + log("Label is already removed: %s" % label) + continue + card.remove_label(label) + + def card_add_message(card_id, message): + log_transmission( + TRELLO, "add message to card#{}: {}".format(card_id, message) + ) + card = client.get_card(card_id) + card.comment(message) + + # Trello labels + def label_create(name, color): + log_transmission(TRELLO, "create label: %s" % (name)) + label = board.add_label(name, color) + return label.id + + def label_delete(tlabel_id): + log_transmission(TRELLO, "delete label: %s" % (tlabel_id)) + board.delete_label(tlabel_id) + + def label_update(tlabel_id, new_name, new_color): + log_transmission( + TRELLO, + "label#{} update: {}".format(tlabel_id, [new_name, new_color]), + ) + res = client.fetch_json( + "/labels/{}".format(tlabel_id), + http_method="PUT", + post_args={"id": tlabel_id, "name": new_name, "color": new_color}, + ) + log("Trello response: {}".format(res)) + + def get_labels_colors(): + return {lb.id: lb.color for lb in board.get_labels()} + + def get_all_cards(): + return [ + {"id": card.id, "idLabels": card.idLabels} + for card in board.all_cards() + ] + + return AttrDict( + { + "set_webhook": set_webhook, + "delete_webhooks": delete_webhooks, + "card_create": card_create, + "card_add_labels": card_add_labels, + "card_remove_labels": card_remove_labels, + "card_add_message": card_add_message, + "label_create": label_create, + "label_delete": label_delete, + "label_update": label_update, + "get_labels_colors": get_labels_colors, + "get_all_cards": get_all_cards, + } + ) + + # Github + def _github(secrets): + # https://pygithub.readthedocs.io/en/latest/ + from github import Github + from github.GithubException import UnknownObjectException + + if not secrets.GITHUB_TOKEN: + raise Exception("github token is not set") + if not secrets.GITHUB_REPO: + raise Exception("github repo is not set") + + g = Github(secrets.GITHUB_TOKEN) + repo = g.get_repo(secrets.GITHUB_REPO) + + # Github Webhook + def set_webhook(url, events): + # API: https://docs.github.com/en/rest/reference/repos#create-a-repository-webhook + # Events: https://docs.github.com/en/developers/webhooks-and-events/webhook-events-and-payloads + config = {"url": url, "content_type": "json"} + log_transmission(GITHUB, "set webhook: {}".format([config, events])) + repo.create_hook("web", config, events) + + # Github Issues + def issue_add_labels(issue_id, glabel_ids): + issue = repo.get_issue(int(issue_id)) + labels = ids2labels(glabel_ids) + log_transmission(GITHUB, "add labels: {}".format([issue_id, labels])) + for lb in labels: + issue.add_to_labels(lb) + + def issue_remove_labels(issue_id, glabel_ids): + issue = repo.get_issue(int(issue_id)) + labels = ids2labels(glabel_ids) + log_transmission(GITHUB, "remove labels: {}".format([issue_id, labels])) + for lb in labels: + issue.remove_from_labels(lb) + + # Github Labels + def ids2labels(glabel_ids): + # the arg is list of str! + res = [] + for label in repo.get_labels(): + if str(label.raw_data["id"]) in glabel_ids: + res.append(label) + return res + + def label_create(name, color): + # check first if label already exist: + label = None + try: + log_transmission(GITHUB, "get label: %s" % (name)) + label = repo.get_label(name) + except UnknownObjectException: + pass + if not label: + log_transmission(GITHUB, "create label: %s" % (name)) + label = repo.create_label(name, color) + return label.raw_data["id"] + + def label_delete(glabel_id): + labels = ids2labels([int(glabel_id)]) + lb = labels[0] + log_transmission(GITHUB, "delete label: {}".format([lb])) + lb.delete() + + def label_update(glabel_id, new_name, new_color): + labels = ids2labels([int(glabel_id)]) + lb = labels[0] + log_transmission( + GITHUB, "update label: {}".format([lb, new_name, new_color]) + ) + lb.edit(new_name, new_color) + + def get_all_issues(page=None): + issues = repo.get_issues() + if page is not None: + issues = issues.get_page(page) + return [ + { + "id": issue.number, + "name": issue.title, + "labels": issue.raw_data["labels"], + "body": issue.body, + } + for issue in issues + ] + + return AttrDict( + { + "set_webhook": set_webhook, + "issue_add_labels": issue_add_labels, + "issue_remove_labels": issue_remove_labels, + "label_create": label_create, + "label_delete": label_delete, + "label_update": label_update, + "get_all_issues": get_all_issues, + } + ) + + return { + "github": _github(secrets), + "trello": _trello(secrets), + "GITHUB": GITHUB, + "TRELLO": TRELLO, + "sqrt": sqrt, + } diff --git a/sync/models/sync_task.py b/sync/models/sync_task.py index 5de7801c..4df9a979 100644 --- a/sync/models/sync_task.py +++ b/sync/models/sync_task.py @@ -22,7 +22,7 @@ class SyncTask(models.Model): _name = "sync.task" _description = "Sync Task" - project_id = fields.Many2one("sync.project") + project_id = fields.Many2one("sync.project", ondelete="cascade") name = fields.Char("Name", help="e.g. Sync Products", required=True) code = fields.Text("Code") active = fields.Boolean(default=True) @@ -165,3 +165,9 @@ def name_get(self): name = r.project_id.name + ": " + r.name result.append((r.id, name)) return result + + def unlink(self): + self.with_context(active_test=False).mapped("cron_ids").unlink() + self.with_context(active_test=False).mapped("automation_ids").unlink() + self.with_context(active_test=False).mapped("webhook_ids").unlink() + return super(SyncTask, self).unlink() diff --git a/sync/models/sync_trigger_button.py b/sync/models/sync_trigger_button.py index efbf39e6..950036b6 100644 --- a/sync/models/sync_trigger_button.py +++ b/sync/models/sync_trigger_button.py @@ -12,7 +12,7 @@ class SyncTriggerButton(models.Model): _sync_handler = "handle_button" name = fields.Char("Description") - sync_task_id = fields.Many2one("sync.task", name="Task") + sync_task_id = fields.Many2one("sync.task", name="Task", ondelete="cascade") sync_project_id = fields.Many2one( "sync.project", related="sync_task_id.project_id", readonly=True ) diff --git a/sync/tests/__init__.py b/sync/tests/__init__.py index 0bb7d2c7..92bbd8f0 100644 --- a/sync/tests/__init__.py +++ b/sync/tests/__init__.py @@ -1,5 +1,4 @@ # License MIT (https://opensource.org/licenses/MIT). -from . import test_eval from . import test_links from . import test_trigger_db diff --git a/sync/tests/test_eval.py b/sync/tests/test_eval.py deleted file mode 100644 index 0d363515..00000000 --- a/sync/tests/test_eval.py +++ /dev/null @@ -1,167 +0,0 @@ -# Copyright 2020 Ivan Yelizariev -# License MIT (https://opensource.org/licenses/MIT). - -import logging - -from odoo.exceptions import ValidationError -from odoo.tests.common import TransactionCase - -_logger = logging.getLogger(__name__) - -BUTTON_DATA = {"button_data_key": "button_data_value"} - - -class TestEval(TransactionCase): - def print_last_logs(self, limit=1): - logs = self.env["ir.logging"].search([], limit=limit) - for lg in logs: - _logger.debug("ir.logging: %s", [lg.name, lg.level, lg.message]) - - def create_project_task(self, project_vals, task_vals): - project = self.env["sync.project"].create( - dict( - **{ - "name": "Project Eval Test", - "param_ids": [ - (0, 0, {"key": "KEY1", "value": "VALUE1"}), - (0, 0, {"key": "KEY2", "value": "VALUE2"}), - ], - "secret_ids": [ - (0, 0, {"key": "SECRET1", "value": "SECRETVALUE1"}), - (0, 0, {"key": "SECRET2", "value": "SECRETVALUE2"}), - ], - }, - **project_vals - ) - ) - - task = ( - self.env["sync.task"] - .create( - dict( - **{ - "name": "Task eval test", - "project_id": project.id, - "button_ids": [(0, 0, {"trigger_name": "BUTTON_EVAL_TEST"})], - }, - **task_vals - ) - ) - .with_context(new_cursor_logs=False) - ) - return project, task - - def test_imports(self): - """imports should be available in Protected Code only""" - - # legal way - pvals = { - "secret_code": "from odoo import tools", - "common_code": "log('imported package in common_code: %s' % tools.config)", - } - tvals = { - "code": "\n".join( - [ - "log('imported package in task code: %s' % tools.config)", - "def handle_button():", - " pass", - ] - ) - } - p, t = self.create_project_task(pvals, tvals) - t.button_ids.ensure_one() - t.button_ids.start() - self.print_last_logs(2) - - # import in common_code - pvals = { - "secret_code": "x=2+2", - "common_code": "from odoo import tools \n" - "log('imported package in common_code: %s' % tools.config)", - } - tvals = { - "code": "\n".join( - [ - "def handle_button():", - " log('imported package in task code: %s' % tools.config)", - ] - ) - } - with self.assertRaises(ValidationError): - p, t = self.create_project_task(pvals, tvals) - # t.button_ids.ensure_one() - # t.button_ids.run() - - # import in task's code - pvals = { - "secret_code": "x=2+2", - "common_code": "x=5", - } - tvals = { - "code": "\n".join( - [ - "from odoo import tools", - "def handle_button():", - " log('imported package in task code: %s' % tools.config)", - ] - ) - } - with self.assertRaises(ValidationError): - p, t = self.create_project_task(pvals, tvals) - # t.button_ids.ensure_one() - # t.button_ids.run() - - def test_secrets(self): - """Secrets should be available in Protected Code only""" - - pvals = { - "secret_code": "\n".join( - [ - "import hashlib", - "def hash(data):", - " return hashlib.sha224(data.encode('utf-8')).hexdigest()", - "xxx = hash(secrets.SECRET1)", - ] - ), - } - # legal way - pvals["common_code"] = "log('xxx in common_code: %s' % xxx)" - tvals = { - "code": """ -def handle_button(): - log('2+2=%s' % (2+2)) -""" - } - p, t = self.create_project_task(pvals, tvals) - t.button_ids.ensure_one() - t.button_ids.start() - self.print_last_logs(2) - - # using in common_code - pvals["common_code"] = "xxx = hash(secrets.SECRET1)" - tvals = { - "code": """ -def handle_button(): - log('xxx in task code: %s' % xxx) -""" - } - p, t = self.create_project_task(pvals, tvals) - t.button_ids.ensure_one() - with self.assertRaises(ValueError): - t.button_ids.start() - - # using in task's code - pvals["common_code"] = "log('xxx in common_code: %s' % xxx)" - tvals = { - "code": "\n".join( - [ - "def handle_button():", - " xxx = hash(secrets.SECRET1)", - " log('xxx in task code: %s' % xxx)", - ] - ) - } - p, t = self.create_project_task(pvals, tvals) - t.button_ids.ensure_one() - with self.assertRaises(ValueError): - t.button_ids.start() diff --git a/sync/tools/__init__.py b/sync/tools/__init__.py deleted file mode 100644 index fdf9a1f4..00000000 --- a/sync/tools/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# License MIT (https://opensource.org/licenses/MIT). - -from .safe_eval import safe_eval_extra, test_python_expr_extra diff --git a/sync/tools/safe_eval.py b/sync/tools/safe_eval.py deleted file mode 100644 index 36262f01..00000000 --- a/sync/tools/safe_eval.py +++ /dev/null @@ -1,158 +0,0 @@ -# Copyright 2020 Ivan Yelizariev -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) -# -# Code of this file is based on odoo/tools/safe_eval.py -# License notes from there: -# -# Part of Odoo. See LICENSE file for full copyright and licensing details. -# Module partially ripped from/inspired by several different sources: -# - http://code.activestate.com/recipes/286134/ -# - safe_eval in lp:~xrg/openobject-server/optimize-5.0 -# - safe_eval in tryton http://hg.tryton.org/hgwebdir.cgi/trytond/rev/bbb5f73319ad - -# pylint: disable=eval-referenced - -import logging -import sys -from opcode import opmap -from types import CodeType - -import werkzeug -from psycopg2 import OperationalError - -import odoo -from odoo.tools import pycompat -from odoo.tools.misc import ustr -from odoo.tools.safe_eval import _BUILTINS, _SAFE_OPCODES, test_expr - -_logger = logging.getLogger(__name__) - -_SAFE_OPCODES = _SAFE_OPCODES.union( - { - opmap[x] - for x in [ - "IMPORT_NAME", - "IMPORT_FROM", - "LOAD_DEREF", - "STORE_DEREF", - "MAKE_CLOSURE", # python 3.5 only. See https://python.readthedocs.io/en/stable/whatsnew/3.6.html - "BUILD_TUPLE_UNPACK_WITH_CALL", # python 3.6 - "LOAD_CLOSURE", - ] - if opmap.get(x) - } -) - -unsafe_eval = eval - -_BUILTINS["__import__"] = __import__ - - -# The code below differs from origin by lint changes only -def safe_eval_extra( - expr, - globals_dict=None, - locals_dict=None, - mode="eval", - nocopy=False, - locals_builtins=False, -): - """safe_eval(expression[, globals[, locals[, mode[, nocopy]]]]) -> result - - System-restricted Python expression evaluation - - Evaluates a string that contains an expression that mostly - uses Python constants, arithmetic expressions and the - objects directly provided in context. - - This can be used to e.g. evaluate - an OpenERP domain expression from an untrusted source. - - :throws TypeError: If the expression provided is a code object - :throws SyntaxError: If the expression provided is not valid Python - :throws NameError: If the expression provided accesses forbidden names - :throws ValueError: If the expression provided uses forbidden bytecode - """ - if type(expr) is CodeType: - raise TypeError("safe_eval does not allow direct evaluation of code objects.") - - # prevent altering the globals/locals from within the sandbox - # by taking a copy. - if not nocopy: - # isinstance() does not work below, we want *exactly* the dict class - if (globals_dict is not None and type(globals_dict) is not dict) or ( - locals_dict is not None and type(locals_dict) is not dict - ): - _logger.warning( - "Looks like you are trying to pass a dynamic environment, " - "you should probably pass nocopy=True to safe_eval()." - ) - if globals_dict is not None: - globals_dict = dict(globals_dict) - if locals_dict is not None: - locals_dict = dict(locals_dict) - - if globals_dict is None: - globals_dict = {} - - globals_dict["__builtins__"] = _BUILTINS - if locals_builtins: - if locals_dict is None: - locals_dict = {} - locals_dict.update(_BUILTINS) - c = test_expr(expr, _SAFE_OPCODES, mode=mode) - try: - return unsafe_eval(c, globals_dict, locals_dict) - except odoo.exceptions.except_orm: - raise - except odoo.exceptions.Warning: - raise - except odoo.exceptions.RedirectWarning: - raise - except odoo.exceptions.AccessDenied: - raise - except odoo.exceptions.AccessError: - raise - except werkzeug.exceptions.HTTPException: - raise - except odoo.http.AuthenticationError: - raise - except OperationalError: - # Do not hide PostgreSQL low-level exceptions, to let the auto-replay - # of serialized transactions work its magic - raise - except odoo.exceptions.MissingError: - raise - except Exception as e: - exc_info = sys.exc_info() - pycompat.reraise( - ValueError, - ValueError( - '{}: "{}" while evaluating\n{!r}'.format(ustr(type(e)), ustr(e), expr) - ), - exc_info[2], - ) - - -def test_python_expr_extra(expr, mode="eval"): - try: - test_expr(expr, _SAFE_OPCODES, mode=mode) - except (SyntaxError, TypeError, ValueError) as err: - if len(err.args) >= 2 and len(err.args[1]) >= 4: - error = { - "message": err.args[0], - "filename": err.args[1][0], - "lineno": err.args[1][1], - "offset": err.args[1][2], - "error_line": err.args[1][3], - } - msg = "%s : %s at line %d\n%s" % ( - type(err).__name__, - error["message"], - error["lineno"], - error["error_line"], - ) - else: - msg = ustr(err) - return msg - return False diff --git a/sync/views/sync_project_views.xml b/sync/views/sync_project_views.xml index 84b06b58..d39349f7 100644 --- a/sync/views/sync_project_views.xml +++ b/sync/views/sync_project_views.xml @@ -152,31 +152,11 @@ - - -

- - # Protected code can be edited by Sync Managers - only. -
- # Python statement - import - and variable - secrets - can be used in this code only. -
- # Add underscore prefix to variables and functions - (e.g. - _something - ) to make them not available in task code and common - code -
-

- + + + + +

diff --git a/sync/wizard/sync_make_module.py b/sync/wizard/sync_make_module.py index 8045b60e..e7bbe3c6 100644 --- a/sync/wizard/sync_make_module.py +++ b/sync/wizard/sync_make_module.py @@ -78,7 +78,7 @@ def act_makefile(self): root = etree.Element("odoo") project = self.project_id.with_context(active_test=False) records = [ - (project, ("name", "active", "secret_code", "common_code")), + (project, ("name", "active", "common_code")), ] for secret in project.secret_ids: records.append((secret, ("key", "description", "url", "project_id"))) From 1c5a5cf8bac31a11a48c1f6f387846992c7fa2f4 Mon Sep 17 00:00:00 2001 From: Denis Mudarisov Date: Thu, 15 Oct 2020 22:07:26 +0500 Subject: [PATCH 05/92] :ambulance: make the telegram project work --- sync/data/sync_project_telegram_demo.xml | 2 +- sync/models/sync_project_demo.py | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/sync/data/sync_project_telegram_demo.xml b/sync/data/sync_project_telegram_demo.xml index b55384d2..456467f3 100644 --- a/sync/data/sync_project_telegram_demo.xml +++ b/sync/data/sync_project_telegram_demo.xml @@ -20,7 +20,7 @@ def user2name(user): def html_sanitize_telegram(html): allowed_tags = set({"b", "i", "u", "s", "a", "code", "pre"}) - cleaner = clean.Cleaner(safe_attrs_only=True, safe_attrs=set(), allow_tags=allowed_tags, remove_unknown_tags=False) + cleaner = Cleaner(safe_attrs_only=True, safe_attrs=set(), allow_tags=allowed_tags, remove_unknown_tags=False) html = cleaner.clean_html(html) # remove surrounding div return html[5:-6] diff --git a/sync/models/sync_project_demo.py b/sync/models/sync_project_demo.py index 6ccff0dd..9814ed48 100644 --- a/sync/models/sync_project_demo.py +++ b/sync/models/sync_project_demo.py @@ -1,4 +1,5 @@ # Copyright 2020 Ivan Yelizariev +# Copyright 2020 Denis Mudarisov # License MIT (https://opensource.org/licenses/MIT). import json @@ -80,6 +81,8 @@ def _eval_context_telegram(self, secrets, eval_context): * telegram.setWebhook * telegram.parse_data """ + from odoo.tools import html2plaintext + from lxml.html.clean import Cleaner log_transmission = eval_context["log_transmission"] @@ -109,7 +112,11 @@ def parse_data(data): } ) - return {"telegram": telegram} + return { + "telegram": telegram, + "html2plaintext": html2plaintext, + "Cleaner": Cleaner, + } @api.model def _eval_context_trello_github(self, secrets, eval_context): From b895543d2598d7e0bd5a6f78490ed3cb91e86c91 Mon Sep 17 00:00:00 2001 From: Denis Mudarisov Date: Thu, 22 Oct 2020 17:08:37 +0500 Subject: [PATCH 06/92] :ambulance: make trello-github integration work --- sync/models/sync_project_demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sync/models/sync_project_demo.py b/sync/models/sync_project_demo.py index 9814ed48..896034ca 100644 --- a/sync/models/sync_project_demo.py +++ b/sync/models/sync_project_demo.py @@ -141,7 +141,7 @@ def _eval_context_trello_github(self, secrets, eval_context): # closure is not really needed, but let's keep as it was done in previous # version when it was mandatory - def _trello(): + def _trello(secrets): for key in ["TRELLO_TOKEN", "TRELLO_KEY", "TRELLO_BOARD_ID"]: if not getattr(secrets, key): raise Exception("{} is not set".format(key)) From 93ee0213cb387668405e5999e3d068a67635695d Mon Sep 17 00:00:00 2001 From: Denis Mudarisov Date: Wed, 30 Sep 2020 09:32:42 +0500 Subject: [PATCH 07/92] :heart_eyes: add the ability to get type of the given object --- sync/__manifest__.py | 1 + sync/doc/index.rst | 4 +++- sync/models/sync_project.py | 4 ++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index c674e698..e36769d5 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -1,4 +1,5 @@ # Copyright 2020 Ivan Yelizariev +# Copyright 2020 Denis Mudarisov # License MIT (https://opensource.org/licenses/MIT). { diff --git a/sync/doc/index.rst b/sync/doc/index.rst index 37b96c41..67f5728e 100644 --- a/sync/doc/index.rst +++ b/sync/doc/index.rst @@ -153,7 +153,9 @@ Base * ``LOG_INFO`` * ``LOG_WARNING`` * ``LOG_ERROR`` - * ``LOG_CRITICAL`` + * + +* ``type2str``: get type of the given object * ``log_transmission(recipient_str, data_str)``: report on data transfer to external recipients diff --git a/sync/models/sync_project.py b/sync/models/sync_project.py index 7897dd9f..21cdea2b 100644 --- a/sync/models/sync_project.py +++ b/sync/models/sync_project.py @@ -1,4 +1,5 @@ # Copyright 2020 Ivan Yelizariev +# Copyright 2020 Denis Mudarisov # License MIT (https://opensource.org/licenses/MIT). import base64 @@ -196,6 +197,9 @@ def safe_setattr(o, k, v): raise ValidationError(_("You cannot use %s with setattr") % k) return setattr(o, k, v) + def type2str(obj): + return "%s" % type(obj) + context = dict(self.env.context, log_function=log) env = self.env(context=context) eval_context = dict( From 01e20e2c58b585f4e8f54e3e210a5725f8c17ed5 Mon Sep 17 00:00:00 2001 From: Denis Mudarisov Date: Thu, 5 Nov 2020 12:25:16 +0500 Subject: [PATCH 08/92] :cherries: change support addresses --- sync/__manifest__.py | 2 +- sync/static/description/index.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index e36769d5..54300e54 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -10,7 +10,7 @@ "version": "13.0.2.0.1", "application": True, "author": "IT-Projects LLC, Ivan Yelizariev", - "support": "apps@it-projects.info", + "support": "apps@itpp.dev", "website": "https://github.com/itpp-labs/sync-addons", "license": "Other OSI approved licence", # MIT "depends": ["base_automation", "mail", "website", "queue_job"], diff --git a/sync/static/description/index.html b/sync/static/description/index.html index d1f252d0..21cc591e 100644 --- a/sync/static/description/index.html +++ b/sync/static/description/index.html @@ -70,7 +70,7 @@

Synchronize anything with anything

- You can check existing solutions based on this module. If there is no module that fits you needs or you need an update, send a request to sync@it-projects.info . + You can check existing solutions based on this module. If there is no module that fits you needs or you need an update, send a request to sync@itpp.dev .
From 4941497414d98e049fd8848531282123ce3c238a Mon Sep 17 00:00:00 2001 From: Denis Mudarisov Date: Wed, 11 Nov 2020 12:17:15 +0500 Subject: [PATCH 09/92] :cherries: --- sync/__manifest__.py | 2 +- sync/static/description/index.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 54300e54..64d7799d 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -10,7 +10,7 @@ "version": "13.0.2.0.1", "application": True, "author": "IT-Projects LLC, Ivan Yelizariev", - "support": "apps@itpp.dev", + "support": "help@itpp.dev", "website": "https://github.com/itpp-labs/sync-addons", "license": "Other OSI approved licence", # MIT "depends": ["base_automation", "mail", "website", "queue_job"], diff --git a/sync/static/description/index.html b/sync/static/description/index.html index 21cc591e..3963f407 100644 --- a/sync/static/description/index.html +++ b/sync/static/description/index.html @@ -70,7 +70,7 @@

Synchronize anything with anything

- You can check existing solutions based on this module. If there is no module that fits you needs or you need an update, send a request to sync@itpp.dev . + You can check existing solutions based on this module. If there is no module that fits your needs or you need an update, send a request to help@itpp.dev .
From 91a20a4711e08f5654f831be940c0e90e9dcdb10 Mon Sep 17 00:00:00 2001 From: Denis Mudarisov Date: Fri, 20 Nov 2020 18:18:55 +0500 Subject: [PATCH 10/92] :octocat: new support address :point_right: help@itpp.dev --- sync/README.rst | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/sync/README.rst b/sync/README.rst index 053fc21f..24608b03 100644 --- a/sync/README.rst +++ b/sync/README.rst @@ -1,3 +1,7 @@ +.. image:: https://itpp.dev/images/infinity-readme.png + :alt: Tested and maintained by IT Projects Labs + :target: https://itpp.dev + .. image:: https://img.shields.io/badge/license-MIT-blue.svg :target: https://opensource.org/licenses/MIT :alt: License: MIT @@ -81,11 +85,13 @@ Few more steps requires to use https connection (e.g. telegram api works with ht Now set corresponding ``https://...`` value for ``web.base.url`` parameter. -Credits -======= +Questions? +========== + +To get an assistance on this module contact us by email :arrow_right: help@itpp.dev Contributors ------------- +============ * `Ivan Yelizariev `__: * :one::two: init version of the module @@ -94,12 +100,9 @@ Contributors Further information =================== -HTML Description: https://apps.odoo.com/apps/modules/13.0/sync/ - -Usage instructions: ``__ +Odoo Apps Store: https://apps.odoo.com/apps/modules/13.0/sync/ -Changelog: ``_ Notifications on updates: `via Atom `_, `by Email `_ -Tested on Odoo 13.0 9fe7d55e64867d177519e99cc45f9ecfeb3746a3 +Tested on `Odoo 13.0 `_ From fe71f577a8e194f7679b3678c4b74200c6503913 Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev // IEL Date: Wed, 2 Dec 2020 16:17:08 +0100 Subject: [PATCH 11/92] :book: clarify telegram settings: must be https --- sync/doc/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sync/doc/index.rst b/sync/doc/index.rst index 67f5728e..af5ad58d 100644 --- a/sync/doc/index.rst +++ b/sync/doc/index.rst @@ -418,7 +418,7 @@ In Odoo: * `Activate Developer Mode `__ * Open menu ``[[ Settings ]] >> Technical >> Parameters >> System Parameters`` * Check that parameter ``web.base.url`` is properly set and it's accessible over - internet (it should not localhost) + internet (it should not localhost). Also, telegram accepts https addresses only * Open menu ``[[ Sync Studio ]] >> Sync Projects`` * Select *Demo Telegram Integration* project * Go to ``Parameters`` tab From 4a0a047fe93e158277b93ba0b8945168877fa8ae Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev // IEL Date: Wed, 2 Dec 2020 16:18:18 +0100 Subject: [PATCH 12/92] :book: clarify --- sync/doc/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sync/doc/index.rst b/sync/doc/index.rst index af5ad58d..1efd8229 100644 --- a/sync/doc/index.rst +++ b/sync/doc/index.rst @@ -418,7 +418,7 @@ In Odoo: * `Activate Developer Mode `__ * Open menu ``[[ Settings ]] >> Technical >> Parameters >> System Parameters`` * Check that parameter ``web.base.url`` is properly set and it's accessible over - internet (it should not localhost). Also, telegram accepts https addresses only + internet (it should not localhost). Also, telegram accepts https addresses only (i.e. not http) * Open menu ``[[ Sync Studio ]] >> Sync Projects`` * Select *Demo Telegram Integration* project * Go to ``Parameters`` tab From 42b4cc4122180e8640637a7cdb988e20a14a1ce7 Mon Sep 17 00:00:00 2001 From: Eugene Molotov Date: Wed, 9 Dec 2020 16:23:24 +0500 Subject: [PATCH 13/92] :book: Tiny fixup in README --- sync/doc/index.rst | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/sync/doc/index.rst b/sync/doc/index.rst index 1efd8229..db1266e2 100644 --- a/sync/doc/index.rst +++ b/sync/doc/index.rst @@ -12,14 +12,12 @@ Installation * add ``queue_job`` to `server wide modules `__, e.g.:: - ``--load base,web,queue_job`` + --load base,web,queue_job * `Install `__ this module in a usual way * Install python package that you need to use. For example, to try demo projects install following packages: - sudo pip3 install python-telegram-bot - sudo pip3 install PyGithub - sudo pip3 install py-trello + python3 -m pip install python-telegram-bot PyGithub py-trello * If your Sync projects use webhooks (most likely), be sure that url opens correct database without asking to select one From feef765f7ef29a81e3a4d89861cb5536648b17a1 Mon Sep 17 00:00:00 2001 From: Mitchell Admin Date: Sat, 26 Dec 2020 18:48:00 +0000 Subject: [PATCH 14/92] :arrow_up::one::four: OCA/odoo-module-migrator close #212 > Made via .github/workflows/DINAR-PORT.yml --- sync/__manifest__.py | 2 +- sync/security/sync_groups.xml | 5 +++-- sync/views/sync_project_views.xml | 21 ++++++++++----------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 64d7799d..e06a3e84 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -7,7 +7,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY""", "category": "Extra Tools", "images": [], - "version": "13.0.2.0.1", + "version": "14.0.2.0.1", "application": True, "author": "IT-Projects LLC, Ivan Yelizariev", "support": "help@itpp.dev", diff --git a/sync/security/sync_groups.xml b/sync/security/sync_groups.xml index f003cb4e..cf78bedf 100644 --- a/sync/security/sync_groups.xml +++ b/sync/security/sync_groups.xml @@ -2,10 +2,11 @@ - + Sync Studio - User: read-only access; + + User: read-only access; Developer: restricted write access; Manager: same as Developer, but access to secure staff. diff --git a/sync/views/sync_project_views.xml b/sync/views/sync_project_views.xml index d39349f7..e9f33dd7 100644 --- a/sync/views/sync_project_views.xml +++ b/sync/views/sync_project_views.xml @@ -1,4 +1,4 @@ - + @@ -38,7 +38,7 @@ -

diff --git a/sync/views/sync_trigger_cron_views.xml b/sync/views/sync_trigger_cron_views.xml index 96dcf378..c8e068c3 100644 --- a/sync/views/sync_trigger_cron_views.xml +++ b/sync/views/sync_trigger_cron_views.xml @@ -33,20 +33,13 @@ /> -
- -
+ +

From ddb5d9a130ee58bfdf83cf7e76cb3e83f04394a5 Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev // IEL Date: Thu, 3 Jun 2021 09:48:15 +0200 Subject: [PATCH 48/92] :book: sync: add another way to get public URL --- sync/README.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/sync/README.rst b/sync/README.rst index 362e5695..19b345bb 100644 --- a/sync/README.rst +++ b/sync/README.rst @@ -43,7 +43,10 @@ Developer Hints Public webhook address ---------------------- -If you run Odoo locally and need to test webhook, you can use ssh tunneling: +If you run Odoo locally and need to test webhook, your Odoo server should be available via public URL. You can either use specialized services like https://ngrok.com/ or make proxing via ssh tunneling as described in the next section. Once it's done set corresponding ``https://...`` value for ``web.base.url`` parameter (menu ``[[ Settings ]] >> System Parameters``). Also, you should set any value to `web.base.url.freeze `__ to avoid automatic change of ``web.base.url``. + +SSH tunneling +~~~~~~~~~~~~~ * Connect your server: @@ -59,7 +62,7 @@ If you run Odoo locally and need to test webhook, you can use ssh tunneling: ssh user@yourserver.example -R 0.0.0.0:8069:localhost:8069 -Now you can set ``http://yourserver.example:8069`` as a value for ``web.base.url`` in Odoo (menu ``[[ Settings ]] >> System Parameters``). Also, you need to set any value to parameter `web.base.url.freeze `__ +Now you can use ``http://yourserver.example:8069`` as a value for ``web.base.url`` in Odoo. Few more steps requires to use https connection (e.g. telegram api works with https only). In your server do as following: @@ -83,8 +86,6 @@ Few more steps requires to use https connection (e.g. telegram api works with ht * Done! -Now set corresponding ``https://...`` value for ``web.base.url`` parameter. - Questions? ========== From 4b6ef7788bd57bbbc2c49e17f106c76d4dd87047 Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev // IEL Date: Wed, 2 Jun 2021 17:52:27 +0200 Subject: [PATCH 49/92] :book: add another workaround for type mismatch also, clarify workaround with proxy server --- sync/doc/index.rst | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/sync/doc/index.rst b/sync/doc/index.rst index 8dd5d8b1..dae70768 100644 --- a/sync/doc/index.rst +++ b/sync/doc/index.rst @@ -679,8 +679,9 @@ Configuration * Click ``[Run Now]`` buttons in trigger *SETUP_GITHUB* * Click ``[Run Now]`` buttons in triggers *SETUP_TRELLO*. Note, that `it doesn't work `_ without one of the following workarounds: + * delete `line `__ that raise exception in case of type mismatching (search for ``Function declared as capable of handling request of type`` in standard Odoo code). In most cases, this workaround doesn't need to be reverted * open file ``sync/controllers/webhook.py`` and temporarily change ``type="json"`` to ``type="http"``. Revert the changes after successfully setting up trello - * add header "Content-Type: application/json" via your web server. Example for nginx: + * Add a temporal handler in your proxy/web server. Example for nginx: .. code-block:: nginx @@ -688,11 +689,6 @@ Configuration return 200 "{}"; } - - * After a successful *SETUP_TRELLO* trigger run, return everything to its original position, otherwise the project will not work correctly - - - Usage ----- From d830d0568796b4de3eb15fb7d11b8c9b22d770ee Mon Sep 17 00:00:00 2001 From: Denis Mudarisov Date: Sat, 3 Jul 2021 23:00:58 +0500 Subject: [PATCH 50/92] :ambulance: couldn't get the link after setting it --- sync/__manifest__.py | 2 +- sync/doc/changelog.rst | 5 +++++ sync/models/sync_link.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 689271d7..418ee103 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -7,7 +7,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY. ETL/ESB tool similar to OCA/connector, but more flexible""", "category": "Extra Tools", "images": ["images/sync-studio.jpg"], - "version": "14.0.3.1.0", + "version": "14.0.3.1.1", "application": True, "author": "IT Projects Labs, Ivan Yelizariev", "support": "help@itpp.dev", diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index 41437c4c..379de92e 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,8 @@ +`3.1.1` +------- + +- **Fix:** allow getting link after setting it + `3.1.0` ------- diff --git a/sync/models/sync_link.py b/sync/models/sync_link.py index 1acfddba..fcff4168 100644 --- a/sync/models/sync_link.py +++ b/sync/models/sync_link.py @@ -192,7 +192,7 @@ def _set_link_odoo( self, record, relation, ref, sync_date=None, allow_many2many=False ): refs = {ODOO: record.id, EXTERNAL: ref} - self._set_link_external( + return self._set_link_external( relation, refs, sync_date, allow_many2many, record._name ) From 14856825574d6f85c8feede60f1239424f361a88 Mon Sep 17 00:00:00 2001 From: Ilya Ilchenko Date: Fri, 9 Jul 2021 13:19:01 +1000 Subject: [PATCH 51/92] :cherries: sync: rename access group to administrator Fixes #257 --- sync/__manifest__.py | 2 +- sync/doc/changelog.rst | 5 +++++ sync/doc/index.rst | 4 ++-- sync/security/sync_groups.xml | 4 ++-- sync/views/sync_project_views.xml | 3 ++- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 418ee103..80c242e0 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -7,7 +7,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY. ETL/ESB tool similar to OCA/connector, but more flexible""", "category": "Extra Tools", "images": ["images/sync-studio.jpg"], - "version": "14.0.3.1.1", + "version": "14.0.3.1.2", "application": True, "author": "IT Projects Labs, Ivan Yelizariev", "support": "help@itpp.dev", diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index 379de92e..95fa7b6d 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,8 @@ +`3.1.2` +------- + +- **Improvement:** Manager access group renamed to Administrator + `3.1.1` ------- diff --git a/sync/doc/index.rst b/sync/doc/index.rst index dae70768..e2a9a002 100644 --- a/sync/doc/index.rst +++ b/sync/doc/index.rst @@ -27,7 +27,7 @@ User Access Levels * ``Sync Studio: User``: read-only access * ``Sync Studio: Developer``: restricted write access -* ``Sync Studio: Manager``: same as Developer, but with access to **Secrets** +* ``Sync Studio: Administrator``: same as Developer, but with access to **Secrets** Project ======= @@ -44,7 +44,7 @@ Project * **Key** * **Value** * **Texts**: Translatable parameters - * **Secrets**: Parameters with restricted access: key values are visible for Managers only + * **Secrets**: Parameters with restricted access: key values are visible for Administrators only * In the ``Evaluation Context`` tab diff --git a/sync/security/sync_groups.xml b/sync/security/sync_groups.xml index cf78bedf..fcc999af 100644 --- a/sync/security/sync_groups.xml +++ b/sync/security/sync_groups.xml @@ -8,7 +8,7 @@ User: read-only access; Developer: restricted write access; - Manager: same as Developer, but access to secure staff. + Administrator: same as Developer, but access to secure staff.
@@ -22,7 +22,7 @@ - Manager + Administrator diff --git a/sync/views/sync_project_views.xml b/sync/views/sync_project_views.xml index 1d68580a..eede658e 100644 --- a/sync/views/sync_project_views.xml +++ b/sync/views/sync_project_views.xml @@ -212,7 +212,8 @@

Secret parameters values are available for Sync - Managers only (click on a line to see the value) + Administrators only (click on a line to see the + value)

From ce8b2a5955065d2acb6ea6c85b620d28f38c5d81 Mon Sep 17 00:00:00 2001 From: Denis Mudarisov Date: Mon, 12 Jul 2021 05:05:56 +0000 Subject: [PATCH 52/92] :sparkles: get rid of website dependency transferred functionality of website module which used to handle webhooks in sync module so we don't need a whole module as a dependency --- sync/README.rst | 1 + sync/__init__.py | 1 + sync/__manifest__.py | 4 +- sync/controllers/webhook.py | 11 ++-- sync/doc/changelog.rst | 5 ++ sync/lib/__init__.py | 4 ++ sync/lib/controllers/__init__.py | 3 + sync/lib/controllers/main.py | 29 +++++++++ sync/lib/models/__init__.py | 3 + sync/lib/models/ir_actions.py | 91 +++++++++++++++++++++++++++++ sync/models/sync_trigger_webhook.py | 27 --------- 11 files changed, 145 insertions(+), 34 deletions(-) create mode 100644 sync/lib/__init__.py create mode 100644 sync/lib/controllers/__init__.py create mode 100644 sync/lib/controllers/main.py create mode 100644 sync/lib/models/__init__.py create mode 100644 sync/lib/models/ir_actions.py diff --git a/sync/README.rst b/sync/README.rst index 19b345bb..9944b603 100644 --- a/sync/README.rst +++ b/sync/README.rst @@ -36,6 +36,7 @@ Roadmap * Code widget: show line numbers * Webhooks: add a possibility to retry failed webhook (e.g. to debug code) +* Webhooks: during the migration rename `website` appearances in links to `sync`. We decided not to do this in the stable branch to not break existing integrations Developer Hints =============== diff --git a/sync/__init__.py b/sync/__init__.py index f5b234aa..37b8052d 100644 --- a/sync/__init__.py +++ b/sync/__init__.py @@ -3,3 +3,4 @@ from . import models from . import wizard from . import controllers +from . import lib diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 80c242e0..345d34f8 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -7,13 +7,13 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY. ETL/ESB tool similar to OCA/connector, but more flexible""", "category": "Extra Tools", "images": ["images/sync-studio.jpg"], - "version": "14.0.3.1.2", + "version": "14.0.4.0.0", "application": True, "author": "IT Projects Labs, Ivan Yelizariev", "support": "help@itpp.dev", "website": "https://github.com/itpp-labs/sync-addons", "license": "Other OSI approved licence", # MIT - "depends": ["base_automation", "mail", "website", "queue_job"], + "depends": ["base_automation", "mail", "queue_job"], "external_dependencies": {"python": [], "bin": []}, "data": [ "security/sync_groups.xml", diff --git a/sync/controllers/webhook.py b/sync/controllers/webhook.py index cc50e7fd..8aa5636a 100644 --- a/sync/controllers/webhook.py +++ b/sync/controllers/webhook.py @@ -1,12 +1,13 @@ # Copyright 2020 Ivan Yelizariev -# License MIT (https://opensource.org/licenses/MIT). +# Copyright 2021 Denis Mudarisov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). from odoo import http -from odoo.addons.website.controllers.main import Website +from ..lib.controllers.main import Website -class Webhook(http.Controller): +class Webhook(Website): @http.route( [ "/website/action-json/", @@ -18,7 +19,7 @@ class Webhook(http.Controller): csrf=False, ) def actions_server_json(self, path_or_xml_id_or_id, **post): - res = Website().actions_server(path_or_xml_id_or_id, **post) + res = self.actions_server(path_or_xml_id_or_id, **post) return res.data @http.route( @@ -32,4 +33,4 @@ def actions_server_json(self, path_or_xml_id_or_id, **post): csrf=False, ) def actions_server_http(self, path_or_xml_id_or_id, **post): - return Website().actions_server(path_or_xml_id_or_id, **post) + return self.actions_server(path_or_xml_id_or_id, **post) diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index 95fa7b6d..f6cfc7a3 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,8 @@ +`4.0.0` +------- + +**Improvement:** remove Website module from dependencies, handle webhooks directly in Sync Studio + `3.1.2` ------- diff --git a/sync/lib/__init__.py b/sync/lib/__init__.py new file mode 100644 index 00000000..d4e2b5cf --- /dev/null +++ b/sync/lib/__init__.py @@ -0,0 +1,4 @@ +# License MIT (https://opensource.org/licenses/MIT). + +from . import models +from . import controllers diff --git a/sync/lib/controllers/__init__.py b/sync/lib/controllers/__init__.py new file mode 100644 index 00000000..9153c2c3 --- /dev/null +++ b/sync/lib/controllers/__init__.py @@ -0,0 +1,3 @@ +# License MIT (https://opensource.org/licenses/MIT). + +from . import main diff --git a/sync/lib/controllers/main.py b/sync/lib/controllers/main.py new file mode 100644 index 00000000..0a2f5d56 --- /dev/null +++ b/sync/lib/controllers/main.py @@ -0,0 +1,29 @@ +# Copyright 2021 Denis Mudarisov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import werkzeug + +from odoo import http +from odoo.http import request + + +class Website(http.Controller): + def actions_server(self, path_or_xml_id_or_id, **post): + trigger = request.env["sync.trigger.webhook"] + action = None + + action = trigger.sudo().search( + [ + ("website_path", "=", path_or_xml_id_or_id), + ("website_published", "=", True), + ], + limit=1, + ) + # run it, return only if we got a Response object + if action: + if action.state == "code" and action.website_published: + action_res = action.action_server_id.run() + if isinstance(action_res, werkzeug.wrappers.Response): + return action_res + + return request.redirect("/") diff --git a/sync/lib/models/__init__.py b/sync/lib/models/__init__.py new file mode 100644 index 00000000..8c7349cd --- /dev/null +++ b/sync/lib/models/__init__.py @@ -0,0 +1,3 @@ +# License MIT (https://opensource.org/licenses/MIT). + +from . import ir_actions diff --git a/sync/lib/models/ir_actions.py b/sync/lib/models/ir_actions.py new file mode 100644 index 00000000..a7d4309f --- /dev/null +++ b/sync/lib/models/ir_actions.py @@ -0,0 +1,91 @@ +# Copyright 2021 Denis Mudarisov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +import uuid + +from werkzeug import urls + +from odoo import api, fields, models +from odoo.http import request +from odoo.tools.json import scriptsafe as json_scriptsafe + + +class ServerAction(models.Model): + """ Add website option in server actions. """ + + _inherit = "sync.trigger.webhook" + + website_path = fields.Char("Website Path") + website_url = fields.Char( + "Website Url", + compute="_compute_website_url", + help="The full URL to access the server action through the website.", + ) + website_published = fields.Boolean( + "Available on the Website", + copy=False, + help="A code server action can be executed from the website, using a dedicated " + "controller. The address is /website/action/. " + "Set this field as True to allow users to run this action. If it " + "is set to False the action cannot be run through the website.", + ) + + webhook_type = fields.Selection( + [("http", "application/x-www-form-urlencoded"), ("json", "application/json")], + string="Webhook Type", + default="json", + ) + + def _get_website_url(self, website_path, webhook_type): + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + link = ( + website_path + or (self.action_server_id.id and "%d" % self.action_server_id.id) + or "" + ) + if base_url and link: + path = "website/action-{webhook_type}/{link}".format( + webhook_type=webhook_type, link=link + ) + return urls.url_join(base_url, path) + return "" + + @api.depends( + "webhook_type", + "action_server_id.state", + "website_published", + "website_path", + ) + def _compute_website_url(self): + for trigger in self: + action = trigger.action_server_id + if action.state == "code" and trigger.website_published: + trigger.website_url = trigger._get_website_url( + trigger.website_path, trigger.webhook_type + ) + else: + trigger.website_url = False + + if not trigger.website_url: + continue + + @api.model + def _get_eval_context(self, action): + """ Override to add the request object in eval_context. """ + eval_context = self.action_server_id._get_eval_context(action) + if action.state == "code": + eval_context["request"] = request + eval_context["json"] = json_scriptsafe + return eval_context + + @api.model + def _run_action_code_multi(self, eval_context=None): + """Override to allow returning response the same way action is already + returned by the basic server action behavior. Note that response has + priority over action, avoid using both. + """ + res = self.action_server_id._run_action_code_multi(eval_context) + return eval_context.get("response", res) + + def action_website_path(self): + for r in self: + r.website_path = uuid.uuid4() diff --git a/sync/models/sync_trigger_webhook.py b/sync/models/sync_trigger_webhook.py index 4be0ef41..a8c300d7 100644 --- a/sync/models/sync_trigger_webhook.py +++ b/sync/models/sync_trigger_webhook.py @@ -26,29 +26,6 @@ class SyncTriggerWebhook(models.Model): "ir.actions.server", delegate=True, required=True, ondelete="cascade" ) active = fields.Boolean(default=True) - webhook_type = fields.Selection( - [("http", "application/x-www-form-urlencoded"), ("json", "application/json")], - string="Webhook Type", - default="json", - ) - website_url = fields.Char("Webhook URL", compute="_compute_website_url") - - @api.depends( - "webhook_type", - "action_server_id.state", - "action_server_id.website_published", - "action_server_id.website_path", - "action_server_id.xml_id", - ) - def _compute_website_url(self): - for r in self: - website_url = r.action_server_id.website_url - if not website_url: - continue - website_url = website_url.replace( - "/website/action/", "/website/action-%s/" % r.webhook_type - ) - r.website_url = website_url @api.model def default_get(self, fields): @@ -58,10 +35,6 @@ def default_get(self, fields): vals["website_path"] = uuid.uuid4() return vals - def action_website_path(self): - for r in self: - r.website_path = uuid.uuid4() - def start(self): record = self.sudo() if record.active: From d3f2a850667f42cd96f280094ddcaccc9c3de716 Mon Sep 17 00:00:00 2001 From: Denis Mudarisov Date: Tue, 20 Jul 2021 18:58:34 +0500 Subject: [PATCH 53/92] :ambulance: archive project by default to avoid errors --- sync/__manifest__.py | 2 +- sync/data/sync_project_odoo2odoo_demo.xml | 1 - sync/data/sync_project_telegram_demo.xml | 1 - sync/data/sync_project_trello_github_demo.xml | 1 - sync/data/sync_project_unittest_demo.xml | 3 +-- sync/doc/changelog.rst | 5 +++++ sync/models/sync_project.py | 2 +- sync/wizard/sync_make_module.py | 2 +- 8 files changed, 9 insertions(+), 8 deletions(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 345d34f8..12e50100 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -7,7 +7,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY. ETL/ESB tool similar to OCA/connector, but more flexible""", "category": "Extra Tools", "images": ["images/sync-studio.jpg"], - "version": "14.0.4.0.0", + "version": "14.0.4.0.1", "application": True, "author": "IT Projects Labs, Ivan Yelizariev", "support": "help@itpp.dev", diff --git a/sync/data/sync_project_odoo2odoo_demo.xml b/sync/data/sync_project_odoo2odoo_demo.xml index 99c30e20..000017cf 100644 --- a/sync/data/sync_project_odoo2odoo_demo.xml +++ b/sync/data/sync_project_odoo2odoo_demo.xml @@ -6,7 +6,6 @@ Demo Odoo2odoo Integration - odoo2odoo Demo Telegram Integration - telegram_demo Demo Trello-Github Integration - trello_github Project For Unittests - - PARTNER_RELATION diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index f6cfc7a3..8783fb24 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,8 @@ +`4.0.1` +------- + +- **Fix:** set project as archived by default to avoid errors on installation and updating sync modules + `4.0.0` ------- diff --git a/sync/models/sync_project.py b/sync/models/sync_project.py index f70b1c2e..a6b3d104 100644 --- a/sync/models/sync_project.py +++ b/sync/models/sync_project.py @@ -44,7 +44,7 @@ class SyncProject(models.Model): name = fields.Char( "Name", help="e.g. Legacy Migration or eCommerce Synchronization", required=True ) - active = fields.Boolean(default=True) + active = fields.Boolean(default=False) eval_context = fields.Selection([], string="Evaluation context") eval_context_description = fields.Text(compute="_compute_eval_context_description") diff --git a/sync/wizard/sync_make_module.py b/sync/wizard/sync_make_module.py index 4b4a0e31..1f3a2381 100644 --- a/sync/wizard/sync_make_module.py +++ b/sync/wizard/sync_make_module.py @@ -81,7 +81,7 @@ def act_makefile(self): root = etree.Element("odoo") project = self.project_id.with_context(active_test=False) records = [ - (project, ("name", "active", "eval_context", "common_code")), + (project, ("name", "eval_context", "common_code")), ] for secret in project.secret_ids: records.append((secret, ("key", "description", "url", "project_id"))) From e80d0825324b5f40d0423704769783ed611c3f0c Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev Date: Wed, 21 Jul 2021 17:00:47 +0200 Subject: [PATCH 54/92] :rainbow: sync: add more dev tools + make code check visible --- sync/__init__.py | 1 + sync/__manifest__.py | 2 +- sync/doc/changelog.rst | 7 +++++ sync/doc/index.rst | 13 +++++--- sync/models/ir_logging.py | 7 +++-- sync/models/sync_project.py | 7 ++++- sync/tools.py | 56 ++++++++++++++++++++++++++++++++++ sync/views/sync_task_views.xml | 3 +- 8 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 sync/tools.py diff --git a/sync/__init__.py b/sync/__init__.py index 37b8052d..ad71502a 100644 --- a/sync/__init__.py +++ b/sync/__init__.py @@ -4,3 +4,4 @@ from . import wizard from . import controllers from . import lib +from . import tools diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 12e50100..94a8a147 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -7,7 +7,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY. ETL/ESB tool similar to OCA/connector, but more flexible""", "category": "Extra Tools", "images": ["images/sync-studio.jpg"], - "version": "14.0.4.0.1", + "version": "14.0.4.1.0", "application": True, "author": "IT Projects Labs, Ivan Yelizariev", "support": "help@itpp.dev", diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index 8783fb24..13aebd5c 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,10 @@ +`4.1.0` +------- + +- **Improvement:** add more eval context functions (`get_lang`, `url2base64`, `html2plaintext`) +- **Improvement:** add development tools (`LogExternalQuery`), add new type for `ir.logging` +- **Improvement:** move code checker above in task form to make it more visible + `4.0.1` ------- diff --git a/sync/doc/index.rst b/sync/doc/index.rst index e2a9a002..fa501e5d 100644 --- a/sync/doc/index.rst +++ b/sync/doc/index.rst @@ -154,12 +154,8 @@ Base * ``LOG_ERROR`` * -* ``type2str``: get type of the given object - * ``log_transmission(recipient_str, data_str)``: report on data transfer to external recipients -* ``DEFAULT_SERVER_DATETIME_FORMAT`` - Links ~~~~~ @@ -348,6 +344,15 @@ Libs * ``b64encode`` * ``b64decode`` +Tools +~~~~~ + +* ``url2base64`` +* ``get_lang(env, lang_code=False)``: returns `res.lang` record +* ``html2plaintext`` +* ``type2str``: get type of the given object +* ``DEFAULT_SERVER_DATETIME_FORMAT`` + Exceptions ~~~~~~~~~~ diff --git a/sync/models/ir_logging.py b/sync/models/ir_logging.py index 43c48b45..0bfa5d56 100644 --- a/sync/models/ir_logging.py +++ b/sync/models/ir_logging.py @@ -23,8 +23,11 @@ class IrLogging(models.Model): ) message_short = fields.Text(string="Message...", compute="_compute_message_short") type = fields.Selection( - selection_add=[("data_out", "Data Transmission")], - ondelete={"data_out": lambda records: records.write({"type": "server"})}, + selection_add=[("data_out", "Data Transmission"), ("data_in", "Response")], + ondelete={ + "data_out": lambda records: records.write({"type": "server"}), + "data_in": lambda records: records.write({"type": "server"}), + }, ) def _compute_message_short(self): diff --git a/sync/models/sync_project.py b/sync/models/sync_project.py index a6b3d104..f02fedd8 100644 --- a/sync/models/sync_project.py +++ b/sync/models/sync_project.py @@ -9,7 +9,8 @@ from odoo import api, fields, models from odoo.exceptions import UserError, ValidationError -from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, frozendict +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, frozendict, html2plaintext +from odoo.tools.misc import get_lang from odoo.tools.safe_eval import ( datetime, dateutil, @@ -22,6 +23,7 @@ from odoo.addons.queue_job.exception import RetryableJobError +from ..tools import url2base64 from .ir_logging import LOG_CRITICAL, LOG_DEBUG, LOG_ERROR, LOG_INFO, LOG_WARNING _logger = logging.getLogger(__name__) @@ -242,6 +244,9 @@ def type2str(obj): "RetryableJobError": RetryableJobError, "getattr": safe_getattr, "setattr": safe_setattr, + "get_lang": get_lang, + "url2base64": url2base64, + "html2plaintext": html2plaintext, "time": time, "datetime": datetime, "dateutil": dateutil, diff --git a/sync/tools.py b/sync/tools.py new file mode 100644 index 00000000..59eac530 --- /dev/null +++ b/sync/tools.py @@ -0,0 +1,56 @@ +# Copyright 2021 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). +import base64 +import functools + +import requests + +from .models.ir_logging import LOG_ERROR + + +class LogExternalQuery(object): + """Adds logs before and after external query. + Can be used for eval context method. + Example: + + @LogExternalQuery("Viber->send_messages", eval_context) + def send_messages(to, messages): + return viber.send_messages(to, messages) + """ + + def __init__(self, target_name, eval_context): + self.target_name = target_name + self.log = eval_context["log"] + self.log_transmission = eval_context["log_transmission"] + + def __call__(self, func): + @functools.wraps(func) + def wrap(*args, **kwargs): + self.log_transmission( + self.target_name, + "*%s, **%s" + % ( + args, + kwargs, + ), + ) + try: + res = func(*args, **kwargs) + except Exception as err: + self.log( + str(err), name=self.target_name, log_type="data_in", level=LOG_ERROR + ) + raise + self.log("RESULT: %s" % res, name=self.target_name, log_type="data_in") + return res + + return wrap + + +# E.g. to download file and save into in an attachment or Binary field +def url2base64(url): + if not url: + return None + r = requests.get(url, timeout=42) + datas = base64.b64encode(r.content) + return datas diff --git a/sync/views/sync_task_views.xml b/sync/views/sync_task_views.xml index 137dd06a..5d29964c 100644 --- a/sync/views/sync_task_views.xml +++ b/sync/views/sync_task_views.xml @@ -70,13 +70,12 @@ and paste here

+ - -
From 46b01ea9f8dbc0318acef0c9f1628ffdbbbcad6f Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev Date: Wed, 4 Aug 2021 13:48:18 +0200 Subject: [PATCH 55/92] :ambulance: sync: delete website_published field it's not needed and may lead to not working webhook after module upgrade --- sync/README.rst | 2 +- sync/doc/changelog.rst | 1 + sync/lib/controllers/main.py | 3 +-- sync/lib/models/ir_actions.py | 12 +----------- sync/models/sync_trigger_webhook.py | 1 - 5 files changed, 4 insertions(+), 15 deletions(-) diff --git a/sync/README.rst b/sync/README.rst index 9944b603..fbc5df3e 100644 --- a/sync/README.rst +++ b/sync/README.rst @@ -20,7 +20,7 @@ Provides a single place to handle synchronization trigered by one of the followi * **Cron** -- provided by ``ir.cron`` * **DB Event** -- provided by ``base.automation`` -* **Incoming webhook** -- provided by ``ir.actions.server::website_published`` (search for ``/website/action`` in ``website`` module) +* **Incoming webhook** -- modified version of ``/website/action`` controller from ``website`` module * **Manual Triggering** -- provided by ``ir.actions.server``. User needs to click a button to run this action Difference with built-in code evaluation: diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index 13aebd5c..e838f5aa 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -4,6 +4,7 @@ - **Improvement:** add more eval context functions (`get_lang`, `url2base64`, `html2plaintext`) - **Improvement:** add development tools (`LogExternalQuery`), add new type for `ir.logging` - **Improvement:** move code checker above in task form to make it more visible +- **Fix:** delete `website_published` for sake of simplicity and to avoid webhooks problem on upgrading the module to v4.0.0+ `4.0.1` ------- diff --git a/sync/lib/controllers/main.py b/sync/lib/controllers/main.py index 0a2f5d56..2955f26e 100644 --- a/sync/lib/controllers/main.py +++ b/sync/lib/controllers/main.py @@ -15,13 +15,12 @@ def actions_server(self, path_or_xml_id_or_id, **post): action = trigger.sudo().search( [ ("website_path", "=", path_or_xml_id_or_id), - ("website_published", "=", True), ], limit=1, ) # run it, return only if we got a Response object if action: - if action.state == "code" and action.website_published: + if action.state == "code": action_res = action.action_server_id.run() if isinstance(action_res, werkzeug.wrappers.Response): return action_res diff --git a/sync/lib/models/ir_actions.py b/sync/lib/models/ir_actions.py index a7d4309f..b505c7f5 100644 --- a/sync/lib/models/ir_actions.py +++ b/sync/lib/models/ir_actions.py @@ -20,15 +20,6 @@ class ServerAction(models.Model): compute="_compute_website_url", help="The full URL to access the server action through the website.", ) - website_published = fields.Boolean( - "Available on the Website", - copy=False, - help="A code server action can be executed from the website, using a dedicated " - "controller. The address is /website/action/. " - "Set this field as True to allow users to run this action. If it " - "is set to False the action cannot be run through the website.", - ) - webhook_type = fields.Selection( [("http", "application/x-www-form-urlencoded"), ("json", "application/json")], string="Webhook Type", @@ -52,13 +43,12 @@ def _get_website_url(self, website_path, webhook_type): @api.depends( "webhook_type", "action_server_id.state", - "website_published", "website_path", ) def _compute_website_url(self): for trigger in self: action = trigger.action_server_id - if action.state == "code" and trigger.website_published: + if action.state == "code": trigger.website_url = trigger._get_website_url( trigger.website_path, trigger.webhook_type ) diff --git a/sync/models/sync_trigger_webhook.py b/sync/models/sync_trigger_webhook.py index a8c300d7..0a488906 100644 --- a/sync/models/sync_trigger_webhook.py +++ b/sync/models/sync_trigger_webhook.py @@ -30,7 +30,6 @@ class SyncTriggerWebhook(models.Model): @api.model def default_get(self, fields): vals = super(SyncTriggerWebhook, self).default_get(fields) - vals["website_published"] = True vals["groups_id"] = [(4, self.env.ref("base.group_public").id, 0)] vals["website_path"] = uuid.uuid4() return vals From 6c38a0fced540b86f87daa4d4ba6ae496854dfb1 Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev Date: Sun, 8 Aug 2021 12:33:46 +0200 Subject: [PATCH 56/92] :ambulance: fix odoo.links for empty recordset --- sync/__manifest__.py | 2 +- sync/doc/changelog.rst | 5 +++++ sync/models/base.py | 6 +++++- sync/models/sync_link.py | 17 ++++++++++------- sync/tests/test_links.py | 13 ++++++++++++- 5 files changed, 33 insertions(+), 10 deletions(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 94a8a147..2f30c16b 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -7,7 +7,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY. ETL/ESB tool similar to OCA/connector, but more flexible""", "category": "Extra Tools", "images": ["images/sync-studio.jpg"], - "version": "14.0.4.1.0", + "version": "14.0.4.1.1", "application": True, "author": "IT Projects Labs, Ivan Yelizariev", "support": "help@itpp.dev", diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index e838f5aa..fe768477 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,8 @@ +`4.1.1` +------- + +- **Fix:** For empty `links`, the property `links.odoo` must return empty recordset, not `None` + `4.1.0` ------- diff --git a/sync/models/base.py b/sync/models/base.py index f4d08dbd..3cb756d6 100644 --- a/sync/models/base.py +++ b/sync/models/base.py @@ -13,4 +13,8 @@ def set_link(self, relation_name, ref, sync_date=None, allow_many2many=False): ) def search_links(self, relation_name, refs=None): - return self.env["sync.link"]._search_links_odoo(self, relation_name, refs) + return ( + self.env["sync.link"] + .with_context(sync_link_odoo_model=self._name) + ._search_links_odoo(self, relation_name, refs) + ) diff --git a/sync/models/sync_link.py b/sync/models/sync_link.py index fcff4168..6da75891 100644 --- a/sync/models/sync_link.py +++ b/sync/models/sync_link.py @@ -172,14 +172,17 @@ def get(self, system): # Odoo links @property def odoo(self): - res = None + res = set() + model = self.env.context.get("sync_link_odoo_model") for r in self: - record = self.env[r.model].browse(int(getattr(r, ODOO_REF))) - if res: - res |= record - else: - res = record - return res + if model is None: + model = r.model + elif model != r.model: + raise ValidationError( + _("Mixing apples and oranges: %s and %s") % (model, r.model) + ) + res.add(int(r[ODOO_REF])) + return self.env[model].browse(res) if model else None @property def external(self): diff --git a/sync/tests/test_links.py b/sync/tests/test_links.py index 0e60aaf7..aa437fda 100644 --- a/sync/tests/test_links.py +++ b/sync/tests/test_links.py @@ -29,7 +29,10 @@ def test_odoo_link(self): REL = "sync_test_links_partner" REL2 = "sync_test_links_partner2" - self.assertFalse(self.env["res.partner"].search([]).search_links(REL)) + # empty link recordset + no_links = self.env["res.partner"].search([]).search_links(REL) + self.assertFalse(no_links) + self.assertEqual(self.env["res.partner"], no_links.odoo) # Set and get links r = self.create_record() @@ -65,6 +68,14 @@ def test_odoo_link(self): self.assertEqual(1, len(all_links)) self.assertEqual(r, all_links[0].odoo) + # multiple links + r = self.create_record() + ref = generate_ref() + r.set_link(REL, ref) + all_links = self.env["res.partner"].search([]).search_links(REL) + self.assertEqual(2, len(all_links)) + self.assertEqual(2, len(all_links.odoo)) + # Multiple refs for the same relation and record r = self.create_record() ref1 = generate_ref() From 615844e521ec3cb3b0835243d3f0e9660d302fce Mon Sep 17 00:00:00 2001 From: Ilya Ilchenko Date: Tue, 20 Jul 2021 15:29:41 +0300 Subject: [PATCH 57/92] :ambulance: sync: prevent overwriting parameter values closes #271 --- sync/__manifest__.py | 1 + sync/data/sync_project_odoo2odoo_demo.xml | 3 +- sync/data/sync_project_telegram_demo.xml | 7 +++-- sync/data/sync_project_trello_github_demo.xml | 7 +++-- sync/data/sync_project_unittest_demo.xml | 3 +- sync/doc/changelog.rst | 2 ++ sync/models/sync_project.py | 16 ++++++++++ sync/tests/__init__.py | 1 + sync/tests/test_default_value.py | 31 +++++++++++++++++++ sync/wizard/sync_make_module.py | 3 +- 10 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 sync/tests/test_default_value.py diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 2f30c16b..ffbcb0ce 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -1,5 +1,6 @@ # Copyright 2020 Ivan Yelizariev # Copyright 2020-2021 Denis Mudarisov +# Copyright 2021 Ilya Ilchenko # License MIT (https://opensource.org/licenses/MIT). { diff --git a/sync/data/sync_project_odoo2odoo_demo.xml b/sync/data/sync_project_odoo2odoo_demo.xml index 000017cf..73f4b93c 100644 --- a/sync/data/sync_project_odoo2odoo_demo.xml +++ b/sync/data/sync_project_odoo2odoo_demo.xml @@ -1,6 +1,7 @@ @@ -29,7 +30,7 @@ def parse_date(s): UPLOAD_ALL_PARTNER_PREFIX - Sync Studio: + Sync Studio: diff --git a/sync/data/sync_project_telegram_demo.xml b/sync/data/sync_project_telegram_demo.xml index 22eaef88..74cf8c47 100644 --- a/sync/data/sync_project_telegram_demo.xml +++ b/sync/data/sync_project_telegram_demo.xml @@ -1,6 +1,7 @@ @@ -29,7 +30,7 @@ def html_sanitize_telegram(html): TELEGRAM_WELCOME_MESSAGE - Hello! How can I help you? + Hello! How can I help you? Message that is sent to a user on first bot usage @@ -37,7 +38,7 @@ def html_sanitize_telegram(html): TELEGRAM_MESSAGE_SENT - The message has sent in telegram + The message has sent in telegram When Odoo Bot reports on successfully sent telegram message @@ -45,7 +46,7 @@ def html_sanitize_telegram(html): PARTNER_NAME_PREFIX - Telegram: + Telegram: Prefix for new partner name diff --git a/sync/data/sync_project_trello_github_demo.xml b/sync/data/sync_project_trello_github_demo.xml index 8d0a4082..ea41bfff 100644 --- a/sync/data/sync_project_trello_github_demo.xml +++ b/sync/data/sync_project_trello_github_demo.xml @@ -1,6 +1,7 @@ @@ -158,14 +159,14 @@ def color2trello(hex): ISSUE_FROM_GITHUB_PREFIX - GITHUB: + GITHUB: MESSAGE_PREFIX - A Message posted on GitHub: + A Message posted on GitHub: @@ -175,7 +176,7 @@ def color2trello(hex): model="sync.project.param" > LABELS_MERGE_STRATEGY - UNION + UNION Possible values: USE_TRELLO, USE_GITHUB: use version from one side and override values from another diff --git a/sync/data/sync_project_unittest_demo.xml b/sync/data/sync_project_unittest_demo.xml index 7c6bda0d..535eed9a 100644 --- a/sync/data/sync_project_unittest_demo.xml +++ b/sync/data/sync_project_unittest_demo.xml @@ -1,6 +1,7 @@ @@ -9,7 +10,7 @@ PARTNER_RELATION - sync_unittest_partner + sync_unittest_partner diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index fe768477..6a7254d6 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,5 @@ +- **Improvement:** initial values do not overwrite parameter values after a module update. + `4.1.1` ------- diff --git a/sync/models/sync_project.py b/sync/models/sync_project.py index f02fedd8..d0d3e6ba 100644 --- a/sync/models/sync_project.py +++ b/sync/models/sync_project.py @@ -1,5 +1,6 @@ # Copyright 2020 Ivan Yelizariev # Copyright 2020-2021 Denis Mudarisov +# Copyright 2021 Ilya Ilchenko # License MIT (https://opensource.org/licenses/MIT). import base64 @@ -400,12 +401,27 @@ class SyncProjectParamMixin(models.AbstractModel): key = fields.Char("Key", required=True) value = fields.Char("Value") + initial_value = fields.Char( + compute="_compute_initial_value", + inverse="_inverse_initial_value", + help="A virtual field that, during writing, stores the value in the value field, but only if it is empty. \ + It's used during module upgrade to prevent overwriting parameter values. ", + ) description = fields.Char("Description", translate=True) url = fields.Char("Documentation") project_id = fields.Many2one("sync.project", ondelete="cascade") _sql_constraints = [("key_uniq", "unique (project_id, key)", "Key must be unique.")] + def _compute_initial_value(self): + for r in self: + r.initial_value = r.value + + def _inverse_initial_value(self): + for r in self: + if not r.value: + r.value = r.initial_value + class SyncProjectParam(models.Model): diff --git a/sync/tests/__init__.py b/sync/tests/__init__.py index 92bbd8f0..24f1e441 100644 --- a/sync/tests/__init__.py +++ b/sync/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_links from . import test_trigger_db +from . import test_default_value diff --git a/sync/tests/test_default_value.py b/sync/tests/test_default_value.py new file mode 100644 index 00000000..1c304a10 --- /dev/null +++ b/sync/tests/test_default_value.py @@ -0,0 +1,31 @@ +# Copyright 2021 Ilya Ilchenko +# License MIT (https://opensource.org/licenses/MIT). + +import logging + +from odoo.tests.common import TransactionCase + +_logger = logging.getLogger(__name__) + + +class TestDefaultValue(TransactionCase): + def setUp(self): + super(TestDefaultValue, self).setUp() + + def test_create_record(self): + # test variable definition + param_obj = self.env["sync.project.param"] + value = "Test value" + new_value = "New value" + + # Test #1: Creating a record + test_record = param_obj.create({"key": "TEST_KEY", "initial_value": value}) + self.assertEqual(test_record.value, value) + + # Test #2: Checking the overwrite initial_value + test_record.write({"initial_value": new_value}) + self.assertEqual(test_record.value, value) + + # Test #3: Checking the overwrite value + test_record.write({"value": new_value}) + self.assertEqual(test_record.value, new_value) diff --git a/sync/wizard/sync_make_module.py b/sync/wizard/sync_make_module.py index 1f3a2381..90f9327a 100644 --- a/sync/wizard/sync_make_module.py +++ b/sync/wizard/sync_make_module.py @@ -1,5 +1,6 @@ # Copyright 2020-2021 Ivan Yelizariev # Copyright 2021 Denis Mudarisov +# Copyright 2021 Ilya Ilchenko # License MIT (https://opensource.org/licenses/MIT). import base64 @@ -88,7 +89,7 @@ def act_makefile(self): for param in project.param_ids: records.append( - (param, ("key", "value", "description", "url", "project_id")) + (param, ("key", "initial_value", "description", "url", "project_id")) ) for task in project.task_ids: From 4ce4a8a048bca3cc53f8193c6d2e83b518b83f88 Mon Sep 17 00:00:00 2001 From: Ilya Ilchenko Date: Sun, 1 Aug 2021 16:04:59 +0300 Subject: [PATCH 58/92] :ambulance: sync: add exporting of text parameter values --- sync/__manifest__.py | 2 +- sync/doc/changelog.rst | 4 ++++ sync/wizard/sync_make_module.py | 8 ++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index ffbcb0ce..3d1c979b 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -8,7 +8,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY. ETL/ESB tool similar to OCA/connector, but more flexible""", "category": "Extra Tools", "images": ["images/sync-studio.jpg"], - "version": "14.0.4.1.1", + "version": "14.0.4.1.2", "application": True, "author": "IT Projects Labs, Ivan Yelizariev", "support": "help@itpp.dev", diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index 6a7254d6..f63cf859 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,4 +1,8 @@ +`4.1.2` +------- + - **Improvement:** initial values do not overwrite parameter values after a module update. +- **Fix:** add exporting of text parameter values `4.1.1` ------- diff --git a/sync/wizard/sync_make_module.py b/sync/wizard/sync_make_module.py index 90f9327a..493d2d91 100644 --- a/sync/wizard/sync_make_module.py +++ b/sync/wizard/sync_make_module.py @@ -92,6 +92,14 @@ def act_makefile(self): (param, ("key", "initial_value", "description", "url", "project_id")) ) + for text_param in project.text_param_ids: + records.append( + ( + text_param, + ("key", "initial_value", "description", "url", "project_id"), + ) + ) + for task in project.task_ids: records.append((task, ("name", "active", "project_id", "code"))) for trigger in task.button_ids: From eafefc5ca517f91717f148bfbf432150ff88af6e Mon Sep 17 00:00:00 2001 From: Eugene Molotov Date: Tue, 10 Aug 2021 18:17:45 +0500 Subject: [PATCH 59/92] :heart_eyes: sync: add eval context function record2image --- sync/__manifest__.py | 2 +- sync/doc/changelog.rst | 5 +++++ sync/models/sync_project.py | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 3d1c979b..42cbc0d1 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -8,7 +8,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY. ETL/ESB tool similar to OCA/connector, but more flexible""", "category": "Extra Tools", "images": ["images/sync-studio.jpg"], - "version": "14.0.4.1.2", + "version": "14.0.4.2.0", "application": True, "author": "IT Projects Labs, Ivan Yelizariev", "support": "help@itpp.dev", diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index f63cf859..6cfb9d20 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,8 @@ +`4.2.0` +------- + +- **Improvement:** add eval context function `record2image` + `4.1.2` ------- diff --git a/sync/models/sync_project.py b/sync/models/sync_project.py index d0d3e6ba..91ac0382 100644 --- a/sync/models/sync_project.py +++ b/sync/models/sync_project.py @@ -217,6 +217,24 @@ def safe_setattr(o, k, v): def type2str(obj): return "%s" % type(obj) + def record2image(record, fname=None): + # TODO: implement test, that is useful for backporting to 12.0 + if not fname: + fname = "image_1920" + + return ( + record.sudo() + .env["ir.attachment"] + .search( + [ + ("res_model", "=", record._name), + ("res_field", "=", fname), + ("res_id", "=", record.id), + ], + limit=1, + ) + ) + context = dict(self.env.context, log_function=log) env = self.env(context=context) link_functions = env["sync.link"]._get_eval_context() @@ -255,6 +273,7 @@ def type2str(obj): "b64encode": base64.b64encode, "b64decode": base64.b64decode, "type2str": type2str, + "record2image": record2image, "DEFAULT_SERVER_DATETIME_FORMAT": DEFAULT_SERVER_DATETIME_FORMAT, } ) From 997caeefe2d9a5b1eda9744b27a1375c16259993 Mon Sep 17 00:00:00 2001 From: Denis Mudarisov Date: Sat, 14 Aug 2021 16:35:34 +0500 Subject: [PATCH 60/92] :book: add note about configuration in odoo.sh --- sync/doc/index.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/sync/doc/index.rst b/sync/doc/index.rst index fa501e5d..4b3ddefb 100644 --- a/sync/doc/index.rst +++ b/sync/doc/index.rst @@ -21,6 +21,20 @@ Installation * If your Sync projects use webhooks (most likely), be sure that url opens correct database without asking to select one +Odoo.sh +------- + +`queue_job` may not work properly in odoo.sh with workers more than 1 due to `restrictions `__ from Odoo.sh + +For the `queue_job` work correctly in odoo.sh additional configuration is needed. + +Add following lines to `~/.config/odoo.conf` and restart odoo via `odoo-restart` command in Webshell:: + + [queue_job] + scheme=https + port=443 + host=ODOO_SH_ADDRESS.com + User Access Levels ================== From e6e2c9f865c6f02d69aace9408f1150c66cd61bc Mon Sep 17 00:00:00 2001 From: Denis Mudarisov Date: Tue, 24 Aug 2021 13:14:35 +0000 Subject: [PATCH 61/92] :heart_eyes: sync: run triggers with delay solves #323 --- sync/__manifest__.py | 2 +- sync/doc/changelog.rst | 5 +++++ sync/models/sync_trigger_automation.py | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 42cbc0d1..9f38fcf9 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -8,7 +8,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY. ETL/ESB tool similar to OCA/connector, but more flexible""", "category": "Extra Tools", "images": ["images/sync-studio.jpg"], - "version": "14.0.4.2.0", + "version": "14.0.5.0.0", "application": True, "author": "IT Projects Labs, Ivan Yelizariev", "support": "help@itpp.dev", diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index 6cfb9d20..add64a1e 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,8 @@ +`5.0.0` +------- + +- **Improvement:** run triggers with delay + `4.2.0` ------- diff --git a/sync/models/sync_trigger_automation.py b/sync/models/sync_trigger_automation.py index fd5e5e6b..2611c3ea 100644 --- a/sync/models/sync_trigger_automation.py +++ b/sync/models/sync_trigger_automation.py @@ -1,4 +1,5 @@ # Copyright 2020 Ivan Yelizariev +# Copyright 2021 Denis Mudarisov # License MIT (https://opensource.org/licenses/MIT). from odoo import api, fields, models @@ -18,7 +19,7 @@ class SyncTriggerAutomation(models.Model): def start(self, records): if self.active: - self.sync_task_id.start(self, args=(records,)) + self.sync_task_id.start(self, args=(records,), with_delay=True) def get_code(self): return ( From e752ab196ef9db551d9f41d599a9bde2dfa56afd Mon Sep 17 00:00:00 2001 From: Denis Mudarisov Date: Wed, 1 Sep 2021 07:28:05 +0000 Subject: [PATCH 62/92] :shield: fix running asynchronous jobs in tests --- sync/models/sync_task.py | 2 +- sync/tests/test_trigger_db.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/sync/models/sync_task.py b/sync/models/sync_task.py index 1d75dd8d..e190cb74 100644 --- a/sync/models/sync_task.py +++ b/sync/models/sync_task.py @@ -120,7 +120,7 @@ def start( queue_job_or_result = run( job, trigger._sync_handler, args, raise_on_error=raise_on_error ) - if with_delay: + if with_delay and not self.env.context.get("test_queue_job_no_delay"): job.queue_job_id = queue_job_or_result.db_record() return job else: diff --git a/sync/tests/test_trigger_db.py b/sync/tests/test_trigger_db.py index 5623f217..fcea7f23 100644 --- a/sync/tests/test_trigger_db.py +++ b/sync/tests/test_trigger_db.py @@ -1,4 +1,5 @@ # Copyright 2020 Ivan Yelizariev +# Copyright 2021 Denis Mudarisov # License MIT (https://opensource.org/licenses/MIT). import logging @@ -13,6 +14,12 @@ class TestTriggerDB(TransactionCase): def setUp(self): super(TestTriggerDB, self).setUp() + self.env = self.env( + context=dict( + self.env.context, + test_queue_job_no_delay=True, # no jobs thanks + ) + ) funcs = self.env["sync.link"]._get_eval_context() self.get_link = funcs["get_link"] From 2a63198f2f6dc93657a388e2c269276fa8bee357 Mon Sep 17 00:00:00 2001 From: Ilya Ilchenko Date: Thu, 26 Aug 2021 21:26:50 +0300 Subject: [PATCH 63/92] :ambulance: sync: add missing Trigger Fields field closes #325 --- sync/__manifest__.py | 2 +- sync/doc/changelog.rst | 5 +++++ sync/views/sync_trigger_automation_views.xml | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 9f38fcf9..a8258f53 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -8,7 +8,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY. ETL/ESB tool similar to OCA/connector, but more flexible""", "category": "Extra Tools", "images": ["images/sync-studio.jpg"], - "version": "14.0.5.0.0", + "version": "14.0.5.0.1", "application": True, "author": "IT Projects Labs, Ivan Yelizariev", "support": "help@itpp.dev", diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index add64a1e..65964e78 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,8 @@ +`5.0.1` +------- + +- **Fix:** add missing `Trigger Fields` field in the Automation Trigger view + `5.0.0` ------- diff --git a/sync/views/sync_trigger_automation_views.xml b/sync/views/sync_trigger_automation_views.xml index 8fba8166..6a04b110 100644 --- a/sync/views/sync_trigger_automation_views.xml +++ b/sync/views/sync_trigger_automation_views.xml @@ -39,6 +39,20 @@ + + Date: Thu, 9 Sep 2021 06:21:02 +0200 Subject: [PATCH 64/92] :ambulance: sync: clean up on deleting sync.task delete base.automation, ir.cron Note: ir.actions.server will stay in DB, but it cannot harm anybody --- sync/__manifest__.py | 4 ++-- sync/doc/changelog.rst | 5 +++++ sync/models/ir_actions.py | 2 +- sync/models/sync_project.py | 4 ++++ sync/models/sync_trigger_automation.py | 17 ++++++++++++++++- sync/models/sync_trigger_cron.py | 6 ++++++ 6 files changed, 34 insertions(+), 4 deletions(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index a8258f53..c382e364 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -1,4 +1,4 @@ -# Copyright 2020 Ivan Yelizariev +# Copyright 2020-2021 Ivan Yelizariev # Copyright 2020-2021 Denis Mudarisov # Copyright 2021 Ilya Ilchenko # License MIT (https://opensource.org/licenses/MIT). @@ -8,7 +8,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY. ETL/ESB tool similar to OCA/connector, but more flexible""", "category": "Extra Tools", "images": ["images/sync-studio.jpg"], - "version": "14.0.5.0.1", + "version": "14.0.5.0.2", "application": True, "author": "IT Projects Labs, Ivan Yelizariev", "support": "help@itpp.dev", diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index 65964e78..ac57e268 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,8 @@ +`5.0.2` +------- + +- **Fix:** avoid base.automation errors after deleting sync.task + `5.0.1` ------- diff --git a/sync/models/ir_actions.py b/sync/models/ir_actions.py index eb0dada4..cb32f3ce 100644 --- a/sync/models/ir_actions.py +++ b/sync/models/ir_actions.py @@ -1,4 +1,4 @@ -# Copyright 2020 Ivan Yelizariev +# Copyright 2020-2021 Ivan Yelizariev # License MIT (https://opensource.org/licenses/MIT). from odoo import fields, models diff --git a/sync/models/sync_project.py b/sync/models/sync_project.py index 91ac0382..28c23af7 100644 --- a/sync/models/sync_project.py +++ b/sync/models/sync_project.py @@ -89,6 +89,10 @@ def copy(self, default=None): default["active"] = False return super(SyncProject, self).copy(default) + def unlink(self): + self.with_context(active_test=False).mapped("task_ids").unlink() + return super().unlink() + def _compute_eval_context_description(self): for r in self: if not r.eval_context: diff --git a/sync/models/sync_trigger_automation.py b/sync/models/sync_trigger_automation.py index 2611c3ea..677e2980 100644 --- a/sync/models/sync_trigger_automation.py +++ b/sync/models/sync_trigger_automation.py @@ -1,9 +1,12 @@ -# Copyright 2020 Ivan Yelizariev +# Copyright 2020-2021 Ivan Yelizariev # Copyright 2021 Denis Mudarisov # License MIT (https://opensource.org/licenses/MIT). +import logging from odoo import api, fields, models +_logger = logging.getLogger(__name__) + class SyncTriggerAutomation(models.Model): @@ -17,8 +20,20 @@ class SyncTriggerAutomation(models.Model): "base.automation", delegate=True, required=True, ondelete="cascade" ) + def unlink(self): + self.mapped("automation_id").unlink() + return super().unlink() + def start(self, records): if self.active: + if not self.sync_task_id: + # workaround for old deployments + _logger.warning( + "Task was deleted, but there is still base.automation record for it: %s" + % self.automation_id + ) + return + self.sync_task_id.start(self, args=(records,), with_delay=True) def get_code(self): diff --git a/sync/models/sync_trigger_cron.py b/sync/models/sync_trigger_cron.py index 1613593d..226014af 100644 --- a/sync/models/sync_trigger_cron.py +++ b/sync/models/sync_trigger_cron.py @@ -20,6 +20,12 @@ class SyncTriggerCron(models.Model): "ir.cron", delegate=True, required=True, ondelete="cascade" ) + def unlink(self): + crons = self.mapped("cron_id") + if crons: + crons.unlink() + return super().unlink() + @api.model_create_multi def create(self, vals_list): for vals in vals_list: From 4f5711007b183f80fc5e4f99b34cf8a3821bcdd8 Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev Date: Wed, 15 Sep 2021 11:56:25 +0200 Subject: [PATCH 65/92] :heart_eyes: sync: generate better names for triggers --- sync/data/sync_project_trello_github_demo.xml | 3 -- sync/doc/changelog.rst | 1 + sync/models/sync_trigger_automation.py | 1 - sync/models/sync_trigger_mixin.py | 41 +++++++++++++------ sync/models/sync_trigger_webhook.py | 1 - sync/wizard/sync_make_module.py | 1 - 6 files changed, 29 insertions(+), 19 deletions(-) diff --git a/sync/data/sync_project_trello_github_demo.xml b/sync/data/sync_project_trello_github_demo.xml index ea41bfff..d643cfbc 100644 --- a/sync/data/sync_project_trello_github_demo.xml +++ b/sync/data/sync_project_trello_github_demo.xml @@ -227,12 +227,10 @@ def handle_button(): DELETE_TRELLO_WEBHOOKS - DEBUG - @@ -378,7 +376,6 @@ def process_github_issues(issues): GITHUB_ISSUE_COMMENT - Webhook diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index ac57e268..59814521 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -2,6 +2,7 @@ ------- - **Fix:** avoid base.automation errors after deleting sync.task +- **Improvement:** better names for triggers, Server Actions and Base Automation records `5.0.1` ------- diff --git a/sync/models/sync_trigger_automation.py b/sync/models/sync_trigger_automation.py index 677e2980..191852c2 100644 --- a/sync/models/sync_trigger_automation.py +++ b/sync/models/sync_trigger_automation.py @@ -14,7 +14,6 @@ class SyncTriggerAutomation(models.Model): _inherit = ["sync.trigger.mixin", "sync.trigger.mixin.actions"] _description = "DB Trigger" _sync_handler = "handle_db" - _default_name = "DB Trigger" automation_id = fields.Many2one( "base.automation", delegate=True, required=True, ondelete="cascade" diff --git a/sync/models/sync_trigger_mixin.py b/sync/models/sync_trigger_mixin.py index ac75137b..0a675bd9 100644 --- a/sync/models/sync_trigger_mixin.py +++ b/sync/models/sync_trigger_mixin.py @@ -1,4 +1,4 @@ -# Copyright 2020 Ivan Yelizariev +# Copyright 2020-2021 Ivan Yelizariev # License MIT (https://opensource.org/licenses/MIT). from odoo import api, fields, models @@ -9,7 +9,6 @@ class SyncTriggerMixin(models.AbstractModel): _name = "sync.trigger.mixin" _description = "Mixing for trigger models" _rec_name = "trigger_name" - _default_name = None trigger_name = fields.Char( "Trigger Name", help="Technical name to be used in task code", required=True @@ -21,22 +20,38 @@ def _compute_job_count(self): for r in self: r.job_count = len(r.job_ids) + def _update_name(self, vals): + if not ("sync_task_id" in vals or "trigger_name" in vals): + return + if not self._fields["name"].required: + return + for record in self: + if record.name != self._description: + continue + name = "Sync Studio: %s -> %s" % ( + record.sync_project_id.name, + record.trigger_name, + ) + record.write({"name": name}) + + def write(self, vals): + res = super().write(vals) + self._update_name(vals) + return res + @api.model + def create(self, vals): + res = super().create(vals) + res._update_name(vals) + return res + def default_get(self, fields): vals = super(SyncTriggerMixin, self).default_get(fields) - if self._default_name: - vals["name"] = self._default_name + # put model description in case if name is required field + if self._fields["name"].required: + vals["name"] = self._description return vals - def name_get(self): - result = [] - for r in self: - name = r.trigger_name - if r.name and r.name != self._default_name: - name += " " + r.name - result.append((r.id, name)) - return result - class SyncTriggerMixinModelId(models.AbstractModel): diff --git a/sync/models/sync_trigger_webhook.py b/sync/models/sync_trigger_webhook.py index 0a488906..242185d9 100644 --- a/sync/models/sync_trigger_webhook.py +++ b/sync/models/sync_trigger_webhook.py @@ -20,7 +20,6 @@ class SyncTriggerWebhook(models.Model): ] _description = "Webhook Trigger" _sync_handler = "handle_webhook" - _default_name = "Webhook" action_server_id = fields.Many2one( "ir.actions.server", delegate=True, required=True, ondelete="cascade" diff --git a/sync/wizard/sync_make_module.py b/sync/wizard/sync_make_module.py index 493d2d91..a7fc26c8 100644 --- a/sync/wizard/sync_make_module.py +++ b/sync/wizard/sync_make_module.py @@ -140,7 +140,6 @@ def act_makefile(self): ( "trigger_name", "active", - "name", "sync_task_id", "webhook_type", ), From 6d3302da88432f53a6116eabd1930673089ae3d8 Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev Date: Fri, 1 Oct 2021 22:59:46 +0200 Subject: [PATCH 66/92] :book: new website --- sync/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index c382e364..783044f7 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -12,7 +12,7 @@ "application": True, "author": "IT Projects Labs, Ivan Yelizariev", "support": "help@itpp.dev", - "website": "https://github.com/itpp-labs/sync-addons", + "website": "https://t.me/sync_studio", "license": "Other OSI approved licence", # MIT "depends": ["base_automation", "mail", "queue_job"], "external_dependencies": {"python": [], "bin": []}, From 503ed6a76b1dcbda6d5ca491ec49ea2021e063d8 Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev Date: Thu, 26 May 2022 11:45:24 +0200 Subject: [PATCH 67/92] :rainbow: pre-commit 14.0 --- sync/lib/models/ir_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sync/lib/models/ir_actions.py b/sync/lib/models/ir_actions.py index b505c7f5..f6076303 100644 --- a/sync/lib/models/ir_actions.py +++ b/sync/lib/models/ir_actions.py @@ -10,7 +10,7 @@ class ServerAction(models.Model): - """ Add website option in server actions. """ + """Add website option in server actions.""" _inherit = "sync.trigger.webhook" @@ -60,7 +60,7 @@ def _compute_website_url(self): @api.model def _get_eval_context(self, action): - """ Override to add the request object in eval_context. """ + """Override to add the request object in eval_context.""" eval_context = self.action_server_id._get_eval_context(action) if action.state == "code": eval_context["request"] = request From 039f5e76897595b2ff8636825d16db63cdf730a1 Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev Date: Thu, 26 May 2022 13:49:13 +0200 Subject: [PATCH 68/92] :heart_eyes: add filters for sync.job from #353 --- sync/__manifest__.py | 2 +- sync/doc/changelog.rst | 5 +++++ sync/views/sync_job_views.xml | 36 +++++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 783044f7..671a7657 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -8,7 +8,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY. ETL/ESB tool similar to OCA/connector, but more flexible""", "category": "Extra Tools", "images": ["images/sync-studio.jpg"], - "version": "14.0.5.0.2", + "version": "14.0.5.1.0", "application": True, "author": "IT Projects Labs, Ivan Yelizariev", "support": "help@itpp.dev", diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index 59814521..a432d27b 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,8 @@ +`5.1.0` +------- + +- **New:** add search filters for `sync.job` model + `5.0.2` ------- diff --git a/sync/views/sync_job_views.xml b/sync/views/sync_job_views.xml index 45ddebd6..6c946f45 100644 --- a/sync/views/sync_job_views.xml +++ b/sync/views/sync_job_views.xml @@ -148,22 +148,58 @@
+ + sync.job.search + sync.job + + + + + + + + + + Project Jobs sync.job tree,form [('project_id', '=', active_id)] + Task Jobs sync.job tree,form [('task_id', '=', active_id)] + Jobs sync.job tree,form + Date: Mon, 6 Jun 2022 14:25:41 +0200 Subject: [PATCH 69/92] :cherries: fix error message when link alreay exists close #395 --- sync/__manifest__.py | 2 +- sync/doc/changelog.rst | 5 +++++ sync/models/sync_link.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 671a7657..8dd18c87 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -8,7 +8,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY. ETL/ESB tool similar to OCA/connector, but more flexible""", "category": "Extra Tools", "images": ["images/sync-studio.jpg"], - "version": "14.0.5.1.0", + "version": "14.0.5.1.1", "application": True, "author": "IT Projects Labs, Ivan Yelizariev", "support": "help@itpp.dev", diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index a432d27b..611f5cc4 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,8 @@ +`5.1.1` +------- + +- **Fix:** correct error message on link duplicates + `5.1.0` ------- diff --git a/sync/models/sync_link.py b/sync/models/sync_link.py index 6da75891..bad44bdc 100644 --- a/sync/models/sync_link.py +++ b/sync/models/sync_link.py @@ -107,7 +107,7 @@ def _set_link_external( relation, existing.system1, existing.ref1, - existing.system1, + existing.system2, existing.ref2, ) ) From a56803f253b7ac3a7914cea24ee7c63ebadd1fd0 Mon Sep 17 00:00:00 2001 From: Mitchell Admin Date: Mon, 6 Jun 2022 12:48:15 +0000 Subject: [PATCH 70/92] :arrow_up::one::five: OCA/odoo-module-migrator close #405 > Made via .github/workflows/DINAR-PORT.yml --- sync/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 8dd18c87..c8727975 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -8,7 +8,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY. ETL/ESB tool similar to OCA/connector, but more flexible""", "category": "Extra Tools", "images": ["images/sync-studio.jpg"], - "version": "14.0.5.1.1", + "version": "15.0.5.1.1", "application": True, "author": "IT Projects Labs, Ivan Yelizariev", "support": "help@itpp.dev", From 24db39cb5fa5e454915e2333606d6947f68c3cec Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev Date: Wed, 8 Jun 2022 13:32:42 +0200 Subject: [PATCH 71/92] :book: doc updates for v15 --- sync/README.rst | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/sync/README.rst b/sync/README.rst index fbc5df3e..d8077396 100644 --- a/sync/README.rst +++ b/sync/README.rst @@ -92,19 +92,12 @@ Questions? To get an assistance on this module contact us by email :arrow_right: help@itpp.dev -Contributors -============ -* `Ivan Yelizariev `__: - - * :one::two: init version of the module - * :one::two: redesign module to prevent odoo container escapes - Further information =================== Odoo Apps Store: https://apps.odoo.com/apps/modules/14.0/sync/ -Notifications on updates: `via Atom `_, `by Email `_ +Notifications on updates: `via Atom `_, `by Email `_ -Tested on `Odoo 14.0 `_ +Tested on `Odoo 15.0 `_ From 40314e3c74ec5b48ba75fef16edd267a8e6a8184 Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev Date: Thu, 26 May 2022 13:37:55 +0200 Subject: [PATCH 72/92] :rainbow: clean up db on deleting records Credits: @pablo-lp #353 --- sync/models/ir_logging.py | 2 +- sync/models/sync_job.py | 6 ++++-- sync/models/sync_trigger_automation.py | 1 + 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/sync/models/ir_logging.py b/sync/models/ir_logging.py index 0bfa5d56..b2828a6a 100644 --- a/sync/models/ir_logging.py +++ b/sync/models/ir_logging.py @@ -16,7 +16,7 @@ class IrLogging(models.Model): _inherit = "ir.logging" - sync_job_id = fields.Many2one("sync.job") + sync_job_id = fields.Many2one("sync.job", ondelete="cascade") sync_task_id = fields.Many2one("sync.task", related="sync_job_id.task_id") sync_project_id = fields.Many2one( "sync.project", related="sync_job_id.task_id.project_id" diff --git a/sync/models/sync_job.py b/sync/models/sync_job.py index c21711b5..5625931d 100644 --- a/sync/models/sync_job.py +++ b/sync/models/sync_job.py @@ -30,11 +30,13 @@ class SyncJob(models.Model): trigger_automation_id = fields.Many2one("sync.trigger.automation", readonly=True) trigger_webhook_id = fields.Many2one("sync.trigger.webhook", readonly=True) trigger_button_id = fields.Many2one("sync.trigger.button", readonly=True) - task_id = fields.Many2one("sync.task", compute="_compute_sync_task_id", store=True) + task_id = fields.Many2one( + "sync.task", compute="_compute_sync_task_id", store=True, ondelete="cascade" + ) project_id = fields.Many2one( "sync.project", related="task_id.project_id", readonly=True ) - parent_job_id = fields.Many2one("sync.job", readonly=True) + parent_job_id = fields.Many2one("sync.job", readonly=True, ondelete="cascade") job_ids = fields.One2many("sync.job", "parent_job_id", "Sub jobs", readonly=True) log_ids = fields.One2many("ir.logging", "sync_job_id", readonly=True) log_count = fields.Integer(compute="_compute_log_count") diff --git a/sync/models/sync_trigger_automation.py b/sync/models/sync_trigger_automation.py index 191852c2..ff6132b0 100644 --- a/sync/models/sync_trigger_automation.py +++ b/sync/models/sync_trigger_automation.py @@ -21,6 +21,7 @@ class SyncTriggerAutomation(models.Model): def unlink(self): self.mapped("automation_id").unlink() + self.mapped("action_server_id").unlink() return super().unlink() def start(self, records): From 274d2e55d35a5cee870cd2c04440e58cd6b3b462 Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev Date: Wed, 8 Jun 2022 15:32:46 +0200 Subject: [PATCH 73/92] :sparkles: add model to link unique index close #250 --- sync/__manifest__.py | 2 +- sync/doc/changelog.rst | 5 +++++ sync/doc/index.rst | 6 +++--- sync/models/sync_link.py | 26 ++++++++++++++------------ 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index c8727975..1c57efd5 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -8,7 +8,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY. ETL/ESB tool similar to OCA/connector, but more flexible""", "category": "Extra Tools", "images": ["images/sync-studio.jpg"], - "version": "15.0.5.1.1", + "version": "15.0.6.0.0", "application": True, "author": "IT Projects Labs, Ivan Yelizariev", "support": "help@itpp.dev", diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index 611f5cc4..7463689f 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,8 @@ +`6.0.0` +------- + +- **New:** support links with the same relation + refs, but different models + `5.1.1` ------- diff --git a/sync/doc/index.rst b/sync/doc/index.rst index 4b3ddefb..177125fa 100644 --- a/sync/doc/index.rst +++ b/sync/doc/index.rst @@ -180,7 +180,7 @@ Links and ``external``; * ``.search_links(relation_name) -> links`` -* ``get_link(relation_name, external_ref) -> link`` +* ``get_link(relation_name, external_ref, model=None) -> link`` Odoo Link usage: @@ -212,7 +212,7 @@ Odoo Link usage: You can also link external data with external data on syncing two different system (e.g. github and trello). -* ``set_link(relation_name, {"github": github_issue_num, "trello": trello_card_num}, sync_date=None, allow_many2many=False) -> elink`` +* ``set_link(relation_name, {"github": github_issue_num, "trello": trello_card_num}, sync_date=None, allow_many2many=False, model=None) -> elink`` * ``refs`` is a dictionary with system name and references pairs, e.g. .. code-block:: python @@ -243,7 +243,7 @@ You can also link external data with external data on syncing two different syst * if references for both systems are passed, then elink is added to result only when its references are presented in both references lists -* ``get_link(relation_name, refs) -> elink`` +* ``get_link(relation_name, refs, model=None) -> elink`` * At least one of the reference should be not Falsy * ``get_link`` raise error, if there are few odoo records linked to the diff --git a/sync/models/sync_link.py b/sync/models/sync_link.py index bad44bdc..f711dfc6 100644 --- a/sync/models/sync_link.py +++ b/sync/models/sync_link.py @@ -40,7 +40,7 @@ def _auto_init(self): self._cr, "sync_link_refs_uniq_index", self._table, - ["relation", "system1", "system2", "ref1", "ref2"], + ["relation", "system1", "system2", "ref1", "ref2", "model"], ) return res @@ -126,8 +126,8 @@ def _set_link_external( return self.create(vals) @api.model - def _get_link_external(self, relation, external_refs): - links = self._search_links_external(relation, external_refs) + def _get_link_external(self, relation, external_refs, model=None): + links = self._search_links_external(relation, external_refs, model=model) if len(links) > 1: raise ValidationError( _( @@ -199,9 +199,9 @@ def _set_link_odoo( relation, refs, sync_date, allow_many2many, record._name ) - def _get_link_odoo(self, relation, ref): + def _get_link_odoo(self, relation, ref, model=None): refs = {ODOO: None, EXTERNAL: ref} - return self._get_link_external(relation, refs) + return self._get_link_external(relation, refs, model=model) def _search_links_odoo(self, records, relation, refs=None): refs = {ODOO: records.ids, EXTERNAL: refs} @@ -210,15 +210,15 @@ def _search_links_odoo(self, records, relation, refs=None): ) # Common API - def _get_link(self, rel, ref_info): + def _get_link(self, rel, ref_info, model=None): if isinstance(ref_info, dict): # External link external_refs = ref_info - return self._get_link_external(rel, external_refs) + return self._get_link_external(rel, external_refs, model=model) else: # Odoo link ref = ref_info - return self._get_link_odoo(rel, ref) + return self._get_link_odoo(rel, ref, model=model) @property def sync_date(self): @@ -241,10 +241,12 @@ def unlink(self): def _get_eval_context(self): env = self.env - def set_link(rel, external_refs, sync_date=None, allow_many2many=False): + def set_link( + rel, external_refs, sync_date=None, allow_many2many=False, model=None + ): # Works for external links only return env["sync.link"]._set_link_external( - rel, external_refs, sync_date, allow_many2many + rel, external_refs, sync_date, allow_many2many, model ) def search_links(rel, external_refs): @@ -253,8 +255,8 @@ def search_links(rel, external_refs): rel, external_refs, make_logs=True ) - def get_link(rel, ref_info): - return env["sync.link"]._get_link(rel, ref_info) + def get_link(rel, ref_info, model=None): + return env["sync.link"]._get_link(rel, ref_info, model=model) return { "set_link": set_link, From 449f08f01046973beb6cf6cc5fc4234680096080 Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev Date: Sun, 26 Jun 2022 16:21:50 +0200 Subject: [PATCH 74/92] :cherries: Happy new year! --- sync/wizard/sync_make_module.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sync/wizard/sync_make_module.py b/sync/wizard/sync_make_module.py index a7fc26c8..e735b781 100644 --- a/sync/wizard/sync_make_module.py +++ b/sync/wizard/sync_make_module.py @@ -1,4 +1,4 @@ -# Copyright 2020-2021 Ivan Yelizariev +# Copyright 2020-2022 Ivan Yelizariev # Copyright 2021 Denis Mudarisov # Copyright 2021 Ilya Ilchenko # License MIT (https://opensource.org/licenses/MIT). @@ -25,7 +25,7 @@ class SyncMakeModule(models.TransientModel): data = fields.Binary("File", readonly=True, attachment=False) data2 = fields.Binary("File Txt", related="data") module = fields.Char("Module Technical Name", required=True) - copyright_years = fields.Char("Copyright Year", default="2021", required=True) + copyright_years = fields.Char("Copyright Year", default="2022", required=True) author_name = fields.Char("Author Name", help="e.g. Ivan Yelizariev", required=True) author_url = fields.Char("Author URL", help="e.g. https://twitter.com/yelizariev") license_line = fields.Char( From 556886c0b1154801ede8c148d8957b4a3c6c120a Mon Sep 17 00:00:00 2001 From: Mohammad Tomaraei Date: Wed, 6 Apr 2022 19:59:43 +0200 Subject: [PATCH 75/92] :sparkles: sync: multi evaluation context credits: @themreza close #397 close #335 --- sync/__manifest__.py | 2 + sync/data/sync_project_context_demo.xml | 25 +++++++++ sync/data/sync_project_odoo2odoo_demo.xml | 5 +- sync/data/sync_project_telegram_demo.xml | 5 +- sync/data/sync_project_trello_github_demo.xml | 5 +- sync/doc/changelog.rst | 1 + sync/doc/index.rst | 19 +++++-- sync/migrations/15.0.6.0.0/post-migrate.py | 30 +++++++++++ sync/models/__init__.py | 1 + sync/models/sync_project.py | 27 ++++++---- sync/models/sync_project_context.py | 29 ++++++++++ sync/models/sync_project_demo.py | 54 +++++++++---------- sync/security/ir.model.access.csv | 2 + sync/views/sync_project_views.xml | 6 ++- sync/wizard/sync_make_module.py | 9 +++- 15 files changed, 172 insertions(+), 48 deletions(-) create mode 100644 sync/data/sync_project_context_demo.xml create mode 100644 sync/migrations/15.0.6.0.0/post-migrate.py create mode 100644 sync/models/sync_project_context.py diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 1c57efd5..fbaffb0d 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -33,10 +33,12 @@ "data/queue_job_function_data.xml", ], "demo": [ + "data/sync_project_context_demo.xml", "data/sync_project_telegram_demo.xml", "data/sync_project_odoo2odoo_demo.xml", "data/sync_project_trello_github_demo.xml", "data/sync_project_unittest_demo.xml", + "data/sync_project_context_demo.xml", ], "qweb": [], "post_load": None, diff --git a/sync/data/sync_project_context_demo.xml b/sync/data/sync_project_context_demo.xml new file mode 100644 index 00000000..1d96408f --- /dev/null +++ b/sync/data/sync_project_context_demo.xml @@ -0,0 +1,25 @@ + + + + + odoo2odoo + Odoo2odoo + + + telegram_demo + Telegram (Demo) + + + trello + Trello + + + github + Github + + + math + Math functions + + + diff --git a/sync/data/sync_project_odoo2odoo_demo.xml b/sync/data/sync_project_odoo2odoo_demo.xml index 73f4b93c..7919bdf8 100644 --- a/sync/data/sync_project_odoo2odoo_demo.xml +++ b/sync/data/sync_project_odoo2odoo_demo.xml @@ -7,7 +7,10 @@ Demo Odoo2odoo Integration - odoo2odoo + Demo Telegram Integration - telegram_demo + Demo Trello-Github Integration - trello_github + + + + my_project + My Sync Project + + + .. code-block:: python @@ -387,10 +399,7 @@ Evaluation provides additional variables and methods for a project. For example, class SyncProject(models.Model): - _inherit = "sync.project" - eval_context = fields.Selection(selection_add=[ - ("my_project", "My Project"), - ]) + _inherit = "sync.project.context" @api.model def _eval_context_my_project(self, secrets, eval_context): diff --git a/sync/migrations/15.0.6.0.0/post-migrate.py b/sync/migrations/15.0.6.0.0/post-migrate.py new file mode 100644 index 00000000..04b809ff --- /dev/null +++ b/sync/migrations/15.0.6.0.0/post-migrate.py @@ -0,0 +1,30 @@ +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + # Convert eval_context selection values into sync.project.context records + # Copy eval_context values into eval_contexts + with api.Environment.manage(): + env = api.Environment(cr, SUPERUSER_ID, {}) + _logger.info( + "Copying eval_context selection values into sync.project.context records" + ) + env["ir.model.fields.selection"]._update_sync_project_context() + sync_projects = env["sync.project"].with_context(active_test=False).search([]) + for sync_project in sync_projects: + if not sync_project.eval_context: + continue + sync_project_context = env["sync.project.context"].search( + [("name", "=", sync_project.eval_context)], limit=1 + ) + if not sync_project_context: + continue + sync_project.write( + { + "eval_contexts": [(6, 0, sync_project_context.ids)], + } + ) diff --git a/sync/models/__init__.py b/sync/models/__init__.py index 881a80aa..7aa2348d 100644 --- a/sync/models/__init__.py +++ b/sync/models/__init__.py @@ -1,6 +1,7 @@ # License MIT (https://opensource.org/licenses/MIT). from . import sync_project +from . import sync_project_context from . import sync_project_demo from . import sync_task from . import sync_trigger_mixin diff --git a/sync/models/sync_project.py b/sync/models/sync_project.py index 28c23af7..3ed2aca6 100644 --- a/sync/models/sync_project.py +++ b/sync/models/sync_project.py @@ -1,4 +1,4 @@ -# Copyright 2020 Ivan Yelizariev +# Copyright 2020,2022 Ivan Yelizariev # Copyright 2020-2021 Denis Mudarisov # Copyright 2021 Ilya Ilchenko # License MIT (https://opensource.org/licenses/MIT). @@ -29,7 +29,6 @@ _logger = logging.getLogger(__name__) DEFAULT_LOG_NAME = "Log" -EVAL_CONTEXT_PREFIX = "_eval_context_" def cleanup_eval_context(eval_context): @@ -48,7 +47,12 @@ class SyncProject(models.Model): "Name", help="e.g. Legacy Migration or eCommerce Synchronization", required=True ) active = fields.Boolean(default=False) + # Deprecated, please use eval_context_ids + # TODO: delete in v17 release eval_context = fields.Selection([], string="Evaluation context") + eval_context_ids = fields.Many2many( + "sync.project.context", string="Evaluation contexts" + ) eval_context_description = fields.Text(compute="_compute_eval_context_description") common_code = fields.Text( @@ -95,11 +99,11 @@ def unlink(self): def _compute_eval_context_description(self): for r in self: - if not r.eval_context: - r.eval_context_description = "" - continue - method = getattr(self, EVAL_CONTEXT_PREFIX + r.eval_context) - r.eval_context_description = method.__doc__ + r.eval_context_description = "\n".join( + r.eval_context_ids.mapped( + lambda c: "-= " + c.display_name + " =-\n\n" + c.description + ) + ) def _compute_network_access_readonly(self): for r in self: @@ -284,15 +288,18 @@ def record2image(record, fname=None): reading_time = time.time() - start_time executing_custom_context = 0 - if self.eval_context: + if self.eval_context_ids: start_time = time.time() secrets = AttrDict() for p in self.sudo().secret_ids: secrets[p.key] = p.value eval_context_frozen = frozendict(eval_context) - method = getattr(self, EVAL_CONTEXT_PREFIX + self.eval_context) - eval_context = dict(**eval_context, **method(secrets, eval_context_frozen)) + for ec in self.eval_context_ids: + method = ec.get_eval_context_method() + eval_context = dict( + **eval_context, **method(secrets, eval_context_frozen) + ) cleanup_eval_context(eval_context) executing_custom_context = time.time() - start_time diff --git a/sync/models/sync_project_context.py b/sync/models/sync_project_context.py new file mode 100644 index 00000000..bb1821d8 --- /dev/null +++ b/sync/models/sync_project_context.py @@ -0,0 +1,29 @@ +# Copyright 2020,2022 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). + +from odoo import fields, models + +EVAL_CONTEXT_PREFIX = "_eval_context_" + + +class SyncProjectContext(models.Model): + + _name = "sync.project.context" + _description = "Project Context" + _rec_name = "display_name" + + # name is used to match an execution context method (e.g. trello_github -> _eval_context_trello_github) + name = fields.Char("Name", required=True) + display_name = fields.Char("Display name", required=True) + description = fields.Text(compute="_compute_eval_context_description") + + _sql_constraints = [("name_uniq", "unique (name)", "Name must be unique.")] + + def _compute_eval_context_description(self): + for r in self: + method = r.get_eval_context_method() + r.description = method.__doc__ + + def get_eval_context_method(self): + self.ensure_one() + return getattr(self, EVAL_CONTEXT_PREFIX + self.name) diff --git a/sync/models/sync_project_demo.py b/sync/models/sync_project_demo.py index 048014d8..53b2a290 100644 --- a/sync/models/sync_project_demo.py +++ b/sync/models/sync_project_demo.py @@ -7,7 +7,7 @@ import xmlrpc.client as _client from math import sqrt -from odoo import api, fields, models +from odoo import api, models from odoo.exceptions import UserError from odoo.tools.translate import _ @@ -27,14 +27,7 @@ class SyncProjectDemo(models.Model): - _inherit = "sync.project" - eval_context = fields.Selection( - selection_add=[ - ("odoo2odoo", "Odoo2odoo"), - ("telegram_demo", "Telegram (Demo)"), - ("trello_github", "Trello & Github"), - ] - ) + _inherit = "sync.project.context" @api.model def _eval_context_odoo2odoo(self, secrets, eval_context): @@ -126,22 +119,8 @@ def parse_data(data): } @api.model - def _eval_context_trello_github(self, secrets, eval_context): - """Adds trello and github object with set of available methods (see sync/models/sync_project_demo.py): - * trello - * github - - It also adds two consts: - - * GITHUB="github" - * TRELLO="trello" - - And math function: - - * sqrt - - """ - GITHUB = "github" + def _eval_context_trello(self, secrets, eval_context): + """Adds object `trello` with set of available methods (see sync/models/sync_project_demo.py). Adds string const TRELLO.""" TRELLO = "trello" log_transmission = eval_context["log_transmission"] log = eval_context["log"] @@ -285,7 +264,18 @@ def get_all_cards(): } ) - # Github + return { + "trello": _trello(secrets), + "TRELLO": TRELLO, + } + + # TODO: split into 2 or 3 separate contexts + @api.model + def _eval_context_github(self, secrets, eval_context): + """Adds object `github` with a set of available methods (see sync/models/sync_project_demo.py). Adds string const GITHUB.""" + GITHUB = "github" + log_transmission = eval_context["log_transmission"] + def _github(secrets): # https://pygithub.readthedocs.io/en/latest/ from github import Github @@ -386,8 +376,16 @@ def get_all_issues(page=None): return { "github": _github(secrets), - "trello": _trello(secrets), "GITHUB": GITHUB, - "TRELLO": TRELLO, + } + + @api.model + def _eval_context_math(self, secrets, eval_context): + """Adds math function: + + * sqrt + + """ + return { "sqrt": sqrt, } diff --git a/sync/security/ir.model.access.csv b/sync/security/ir.model.access.csv index 029b2cda..96ca48c3 100644 --- a/sync/security/ir.model.access.csv +++ b/sync/security/ir.model.access.csv @@ -30,4 +30,6 @@ access_sync_project_text_manager,sync.project.text manager,model_sync_project_te access_sync_project_secret_user,sync.project.secret user,model_sync_project_secret,sync_group_user,1,0,0,0 access_sync_project_secret_dev,sync.project.secret dev,model_sync_project_secret,sync_group_dev,1,1,1,1 access_sync_project_secret_manager,sync.project.secret manager,model_sync_project_secret,sync_group_manager,1,1,1,1 +access_sync_project_context_user,sync.project.context user,model_sync_project_context,sync_group_user,1,0,0,0 +access_sync_project_context_dev,sync.project.context dev,model_sync_project_context,sync_group_dev,1,1,1,1 access_sync_make_module,access_sync_make_module,model_sync_make_module,base.group_user,1,1,1,1 diff --git a/sync/views/sync_project_views.xml b/sync/views/sync_project_views.xml index eede658e..01aff7fc 100644 --- a/sync/views/sync_project_views.xml +++ b/sync/views/sync_project_views.xml @@ -145,7 +145,11 @@
- + diff --git a/sync/wizard/sync_make_module.py b/sync/wizard/sync_make_module.py index e735b781..ead0511e 100644 --- a/sync/wizard/sync_make_module.py +++ b/sync/wizard/sync_make_module.py @@ -82,8 +82,9 @@ def act_makefile(self): root = etree.Element("odoo") project = self.project_id.with_context(active_test=False) records = [ - (project, ("name", "eval_context", "common_code")), + (project, ("name", "eval_context_ids", "common_code")), ] + for secret in project.secret_ids: records.append((secret, ("key", "description", "url", "project_id"))) @@ -201,6 +202,12 @@ def _field2xml(self, record, fname): xml.text = etree.CDATA(value or "") elif field.type == "many2one": xml.set("ref", self._record2id(value)) + elif field.type == "many2many": + xml.set( + "eval", + "[%s]" + % ", ".join(["(4, ref('%s'))" % self._record2id(v) for v in value]), + ) else: xml.text = str(value) if value else "" return xml From 22a7ff2464feede3e7f315b29211d8b35c0dbd1c Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev Date: Sun, 26 Jun 2022 16:55:42 +0200 Subject: [PATCH 76/92] :ambulance: sync: delete html2plaintext duplicate fixes #415 --- sync/models/sync_project_demo.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/sync/models/sync_project_demo.py b/sync/models/sync_project_demo.py index 53b2a290..b55a8116 100644 --- a/sync/models/sync_project_demo.py +++ b/sync/models/sync_project_demo.py @@ -82,8 +82,6 @@ def _eval_context_telegram_demo(self, secrets, eval_context): """ from lxml.html.clean import Cleaner - from odoo.tools import html2plaintext - log_transmission = eval_context["log_transmission"] if secrets.TELEGRAM_BOT_TOKEN: @@ -114,7 +112,6 @@ def parse_data(data): return { "telegram": telegram, - "html2plaintext": html2plaintext, "Cleaner": Cleaner, } From cea62e479391495fbdc0f68e70bb9038cc004184 Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev Date: Wed, 29 Jun 2022 14:09:29 +0200 Subject: [PATCH 77/92] :rainbow: sync: rename demo data to don't mess it with production module (e.g. sync_telegram) --- sync/data/sync_project_context_demo.xml | 18 +++++++++--------- sync/data/sync_project_odoo2odoo_demo.xml | 2 +- sync/data/sync_project_telegram_demo.xml | 2 +- sync/data/sync_project_trello_github_demo.xml | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/sync/data/sync_project_context_demo.xml b/sync/data/sync_project_context_demo.xml index 1d96408f..ed2a46f9 100644 --- a/sync/data/sync_project_context_demo.xml +++ b/sync/data/sync_project_context_demo.xml @@ -1,25 +1,25 @@ - + odoo2odoo - Odoo2odoo + Odoo2odoo (Demo) - + telegram_demo Telegram (Demo) - + trello - Trello + Trello (Demo) - + github - Github + Github (Demo) - + math - Math functions + Math functions (Demo) diff --git a/sync/data/sync_project_odoo2odoo_demo.xml b/sync/data/sync_project_odoo2odoo_demo.xml index 7919bdf8..e3c1f72f 100644 --- a/sync/data/sync_project_odoo2odoo_demo.xml +++ b/sync/data/sync_project_odoo2odoo_demo.xml @@ -9,7 +9,7 @@ Demo Odoo2odoo Integration Demo Telegram Integration Demo Trello-Github Integration Date: Wed, 29 Jun 2022 14:13:43 +0200 Subject: [PATCH 78/92] :ambulance: sync: fix error on empty eval context --- sync/__manifest__.py | 2 +- sync/doc/changelog.rst | 5 +++++ sync/models/sync_project.py | 10 +++++++--- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index fbaffb0d..0c1a9285 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -8,7 +8,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY. ETL/ESB tool similar to OCA/connector, but more flexible""", "category": "Extra Tools", "images": ["images/sync-studio.jpg"], - "version": "15.0.6.0.0", + "version": "15.0.6.0.1", "application": True, "author": "IT Projects Labs, Ivan Yelizariev", "support": "help@itpp.dev", diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index 127cc507..f2ba61f1 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,8 @@ +`6.0.1` +------- +- **Fix:** error on empty eval context in a project + + `6.0.0` ------- diff --git a/sync/models/sync_project.py b/sync/models/sync_project.py index 3ed2aca6..3bcf6375 100644 --- a/sync/models/sync_project.py +++ b/sync/models/sync_project.py @@ -99,10 +99,14 @@ def unlink(self): def _compute_eval_context_description(self): for r in self: - r.eval_context_description = "\n".join( - r.eval_context_ids.mapped( - lambda c: "-= " + c.display_name + " =-\n\n" + c.description + r.eval_context_description = ( + "\n".join( + r.eval_context_ids.mapped( + lambda c: "-= " + c.display_name + " =-\n\n" + c.description + ) ) + if r.eval_context_ids + else "" ) def _compute_network_access_readonly(self): From d3d2b2ed859f8a80d3cd3cd061dc4f9b257e23aa Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev Date: Wed, 29 Jun 2022 15:33:24 +0200 Subject: [PATCH 79/92] :rainbow: sync{_telegram}: switch to pyTelegramBotAPI close #326 close #295 --- sync/__manifest__.py | 2 +- sync/doc/changelog.rst | 4 ++++ sync/models/sync_project_demo.py | 12 ++++++------ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 0c1a9285..11497b6e 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -8,7 +8,7 @@ "summary": """Synchronize anything with anything: SystemX↔Odoo, Odoo1↔Odoo2, SystemX↔SystemY. ETL/ESB tool similar to OCA/connector, but more flexible""", "category": "Extra Tools", "images": ["images/sync-studio.jpg"], - "version": "15.0.6.0.1", + "version": "15.0.6.1.0", "application": True, "author": "IT Projects Labs, Ivan Yelizariev", "support": "help@itpp.dev", diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index f2ba61f1..2bf7a2ca 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,7 @@ +`6.1.0` +------- +- **Improvement:** use light telegram library in Telegram demo project (pyTelegramBotAPI) + `6.0.1` ------- - **Fix:** error on empty eval context in a project diff --git a/sync/models/sync_project_demo.py b/sync/models/sync_project_demo.py index b55a8116..af9dbd38 100644 --- a/sync/models/sync_project_demo.py +++ b/sync/models/sync_project_demo.py @@ -19,8 +19,8 @@ _logger = logging.getLogger(__name__) try: - # https://github.com/python-telegram-bot/python-telegram-bot - from telegram import Bot, Update # pylint: disable=missing-manifest-dependency + # https://github.com/eternnoir/pyTelegramBotAPI + import telebot # pylint: disable=missing-manifest-dependency except (ImportError, IOError) as err: _logger.debug(err) @@ -85,7 +85,7 @@ def _eval_context_telegram_demo(self, secrets, eval_context): log_transmission = eval_context["log_transmission"] if secrets.TELEGRAM_BOT_TOKEN: - bot = Bot(token=secrets.TELEGRAM_BOT_TOKEN) + bot = telebot.TeleBot(token=secrets.TELEGRAM_BOT_TOKEN) else: raise Exception("Telegram bot token is not set") @@ -93,14 +93,14 @@ def sendMessage(chat_id, *args, **kwargs): log_transmission( "Message to %s@telegram" % chat_id, json.dumps([args, kwargs]) ) - bot.sendMessage(chat_id, *args, **kwargs) + bot.send_message(chat_id, *args, **kwargs) def setWebhook(*args, **kwargs): log_transmission("Telegram->setWebhook", json.dumps([args, kwargs])) - bot.setWebhook(*args, **kwargs) + bot.set_webhook(*args, **kwargs) def parse_data(data): - return Update.de_json(data, bot) + return telebot.types.Update.de_json(data) telegram = AttrDict( { From 8adb07a50e69db5007309d4f147214e6b20a7508 Mon Sep 17 00:00:00 2001 From: Ivan Yelizariev Date: Wed, 29 Jun 2022 16:51:54 +0200 Subject: [PATCH 80/92] :ambulance: sync: add button to show secret parameter value Trick with clicking on the line doesn't work anymore, because it switches to Edit mode in v15 --- sync/doc/changelog.rst | 1 + sync/models/sync_project.py | 11 +++++++++++ sync/views/sync_project_views.xml | 10 ++++++++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index 2bf7a2ca..c36a74d5 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,6 +1,7 @@ `6.1.0` ------- - **Improvement:** use light telegram library in Telegram demo project (pyTelegramBotAPI) +- **Fix:** add button to show secret parameter value `6.0.1` ------- diff --git a/sync/models/sync_project.py b/sync/models/sync_project.py index 3bcf6375..05b69ef5 100644 --- a/sync/models/sync_project.py +++ b/sync/models/sync_project.py @@ -480,6 +480,17 @@ class SyncProjectSecret(models.Model): value = fields.Char(groups="sync.sync_group_manager") + def action_show_value(self): + self.ensure_one() + return { + "name": _("Secret Parameter"), + "type": "ir.actions.act_window", + "view_mode": "form", + "res_model": "sync.project.secret", + "target": "new", + "res_id": self.id, + } + # see https://stackoverflow.com/a/14620633/222675 class AttrDict(dict): diff --git a/sync/views/sync_project_views.xml b/sync/views/sync_project_views.xml index 01aff7fc..46c6dfb8 100644 --- a/sync/views/sync_project_views.xml +++ b/sync/views/sync_project_views.xml @@ -216,8 +216,7 @@

Secret parameters values are available for Sync - Administrators only (click on a line to see the - value) + Administrators only

@@ -228,6 +227,13 @@ password="True" decoration-danger="not value" /> +
+ + + + + + + + + + +
+

+ Here is the xml file to be put into a module: + +
+ Generation time: + +

+
+
+
+
+ +