diff --git a/sync/README.rst b/sync/README.rst index 39d00a3b..28fb0c60 100644 --- a/sync/README.rst +++ b/sync/README.rst @@ -1,6 +1,6 @@ .. image:: https://itpp.dev/images/infinity-readme.png :alt: Tested and maintained by IT Projects Labs - :target: https://itpp.dev + :target: https://odoomagic.com .. image:: https://img.shields.io/badge/license-MIT-blue.svg :target: https://opensource.org/licenses/MIT diff --git a/sync/__manifest__.py b/sync/__manifest__.py index bf40e8c9..145ba6d7 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -7,13 +7,16 @@ "name": "Sync 🪬 Studio", "summary": """Join the Amazing 😍 Community ⤵️""", "category": "VooDoo ✨ Magic", - "version": "16.0.11.0.1", + "version": "16.0.13.0.0", "application": True, "author": "Ivan Kropotkin", "support": "info@odoomagic.com", "website": "https://sync_studio.t.me/", "license": "Other OSI approved licence", # MIT - "depends": ["base_automation", "mail", "queue_job"], + # The `partner_telegram` dependency is not directly needed, + # but it plays an important role in the **Sync 🪬 Studio** ecosystem + # and is added for the quick onboarding of new **Cyber ✨ Pirates**. + "depends": ["base_automation", "mail", "queue_job", "partner_telegram"], "external_dependencies": {"python": ["markdown", "pyyaml"], "bin": []}, "data": [ "security/sync_groups.xml", @@ -25,6 +28,7 @@ "views/sync_trigger_automation_views.xml", "views/sync_trigger_webhook_views.xml", "views/sync_trigger_button_views.xml", + "views/sync_order_views.xml", "views/sync_task_views.xml", "views/sync_link_views.xml", "views/sync_project_views.xml", @@ -37,12 +41,6 @@ }, "demo": [ "data/sync_project_unittest_demo.xml", - # Obsolete - # "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_context_demo.xml", ], "qweb": [], "post_load": None, diff --git a/sync/doc/MAGIC.rst b/sync/doc/MAGIC.rst index 092fa23b..e6426277 100644 --- a/sync/doc/MAGIC.rst +++ b/sync/doc/MAGIC.rst @@ -69,6 +69,7 @@ Libs * ``MAGIC.timezone`` * ``MAGIC.b64encode`` * ``MAGIC.b64decode`` +* ``MAGIC.sha256`` Tools ===== @@ -80,6 +81,8 @@ Tools * ``MAGIC.type2str``: get type of the given object * ``MAGIC.DEFAULT_SERVER_DATETIME_FORMAT`` * ``MAGIC.AttrDict``: Extended dictionary that allows for attribute-style access +* ``MAGIC.group_by_lang(partners, default_lang="en_US")``: yields `lang, partners` grouped by lang +* ``MAGIC.gen2csv(generator)``: prepares csv as a string Exceptions ========== diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index 38053cc1..891a00cc 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,14 @@ +`13.0.0` +------- + +- **Fix:** Use `__sync.` for xmlid namespace to avoid data loss on module update. +- **Fix:** Use task ID in xmlid namespace for the task triggers. +- **Fix:** Keep job records (and their logs) on task deletion. +- **New:** Add *Sync Order* — advanced manual trigger with blackjack, partners list, text input, etc. +- **New:** Support `data.markdown` for custom documentation in the `DATA.🐫` tab. +- **New:** Add `MAGIC.group_by_lang` to eval context. +- **Improvement:** Add `DATA.*` to the library eval context. + `11.0.1` ------- diff --git a/sync/models/__init__.py b/sync/models/__init__.py index 80ed362d..d6ed5ae3 100644 --- a/sync/models/__init__.py +++ b/sync/models/__init__.py @@ -11,6 +11,7 @@ from . import sync_task from . import sync_job from . import sync_data +from . import sync_order from . import ir_logging from . import ir_actions from . import ir_attachment diff --git a/sync/models/base.py b/sync/models/base.py index 788ebc37..27529f14 100644 --- a/sync/models/base.py +++ b/sync/models/base.py @@ -19,7 +19,7 @@ def search_links(self, relation_name, refs=None): ._search_links_odoo(self, relation_name, refs) ) - def _create_or_update_by_xmlid(self, vals, code, namespace="XXX", module="sync"): + def _create_or_update_by_xmlid(self, vals, code, namespace="XXX", module="__sync"): """ Create or update a record by a dynamically generated XML ID. Warning! The field `noupdate` is ignored, i.e. existing records are always updated. diff --git a/sync/models/sync_job.py b/sync/models/sync_job.py index 5625931d..f6ad2a38 100644 --- a/sync/models/sync_job.py +++ b/sync/models/sync_job.py @@ -31,7 +31,7 @@ class SyncJob(models.Model): 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, ondelete="cascade" + "sync.task", compute="_compute_sync_task_id", store=True, ondelete="set null" ) project_id = fields.Many2one( "sync.project", related="task_id.project_id", readonly=True diff --git a/sync/models/sync_order.py b/sync/models/sync_order.py new file mode 100644 index 00000000..62348add --- /dev/null +++ b/sync/models/sync_order.py @@ -0,0 +1,55 @@ +# Copyright 2024 Ivan Yelizariev +from odoo import api, fields, models + + +class SyncOrder(models.Model): + _name = "sync.order" + _description = "Sync Order" + _inherit = ["mail.thread", "mail.activity.mixin"] + _order = "id desc" + + name = fields.Char("Title") + body = fields.Text("Order") + sync_project_id = fields.Many2one("sync.project", related="sync_task_id.project_id") + sync_task_id = fields.Many2one( + "sync.task", + ondelete="cascade", + required=True, + ) + description = fields.Html(related="sync_task_id.sync_order_description") + record_id = fields.Reference( + string="Record", + selection="_selection_record_id", + help="Optional extra information to perform this task", + ) + + partner_ids = fields.Many2many("res.partner", string="Partners") + state = fields.Selection( + [ + ("draft", "Draft"), + ("open", "In Progress"), + ("done", "Done"), + ("cancel", "Canceled"), + ], + default="draft", + ) + + @api.model + def _selection_record_id(self): + mm = self.sync_task_id.sync_order_model_id + if not mm: + return [] + return [(mm.model, mm.name)] + + def action_done(self): + self.write({"state": "done"}) + + def action_confirm(self): + self.write({"state": "open"}) + + def action_cancel(self): + self.write({"state": "cancel"}) + + def action_refresh(self): + # Magic + pass diff --git a/sync/models/sync_project.py b/sync/models/sync_project.py index 46b9e7e2..2c693e1b 100644 --- a/sync/models/sync_project.py +++ b/sync/models/sync_project.py @@ -4,9 +4,14 @@ # License MIT (https://opensource.org/licenses/MIT). import base64 +import csv +import io import logging import os from datetime import datetime +from hashlib import sha256 +from itertools import groupby +from operator import itemgetter import urllib3 from pytz import timezone @@ -94,6 +99,8 @@ class SyncProject(models.Model): task_ids = fields.One2many("sync.task", "project_id", copy=True) task_count = fields.Integer(compute="_compute_task_count") + task_description = fields.Html(readonly=True) + trigger_cron_count = fields.Integer( compute="_compute_triggers", help="Enabled Crons" ) @@ -103,6 +110,10 @@ class SyncProject(models.Model): trigger_webhook_count = fields.Integer( compute="_compute_triggers", help="Enabled Webhooks" ) + sync_order_ids = fields.One2many( + "sync.order", "sync_project_id", string="Sync Orders", copy=True + ) + sync_order_count = fields.Integer(compute="_compute_sync_order_count") 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") @@ -110,6 +121,7 @@ class SyncProject(models.Model): link_ids = fields.One2many("sync.link", "project_id") link_count = fields.Integer(compute="_compute_link_count") data_ids = fields.One2many("sync.data", "project_id") + data_description = fields.Html(readonly=True) def copy(self, default=None): default = dict(default or {}) @@ -129,6 +141,11 @@ def _compute_task_count(self): for r in self: r.task_count = len(r.with_context(active_test=False).task_ids) + @api.depends("sync_order_ids") + def _compute_sync_order_count(self): + for r in self: + r.sync_order_count = len(r.sync_order_ids) + @api.depends("job_ids") def _compute_job_count(self): for r in self: @@ -259,6 +276,43 @@ def record2image(record, fname="image_1920"): ) ) + def group_by_lang(partners, default_lang="en_US"): + """ + Yield groups of partners grouped by their language. + + :param partners: recordset of res.partner + :return: generator yielding tuples of (lang, partners) + """ + if not partners: + return + + # Sort the partners by 'lang' to ensure groupby works correctly + partners = partners.sorted(key=lambda p: p.lang) + + # Group the partners by 'lang' + for lang, group in groupby(partners, key=itemgetter("lang")): + partner_group = partners.browse([partner.id for partner in group]) + yield lang or default_lang, partner_group + + def gen2csv(generator): + # Prepare a StringIO buffer to hold the CSV data + output = io.StringIO() + + # Create a CSV writer with quoting enabled + writer = csv.writer(output, quoting=csv.QUOTE_ALL) + + # Write rows from the generator + for row in generator: + writer.writerow(row) + + # Get the CSV content + csv_content = output.getvalue() + + # Close the StringIO buffer + output.close() + + return csv_content + context = dict(self.env.context, log_function=log, sync_project_id=self.id) env = self.env(context=context) link_functions = env["sync.link"]._get_eval_context() @@ -294,8 +348,11 @@ def record2image(record, fname="image_1920"): "timezone": timezone, "b64encode": base64.b64encode, "b64decode": base64.b64decode, + "sha256": sha256, "type2str": type2str, "record2image": record2image, + "gen2csv": gen2csv, + "group_by_lang": group_by_lang, "DEFAULT_SERVER_DATETIME_FORMAT": DEFAULT_SERVER_DATETIME_FORMAT, "AttrDict": AttrDict, }, @@ -467,11 +524,16 @@ def magic_upgrade(self): ) # [Documentation] - vals["description"] = ( - compile_markdown_to_html(gist_files.get("README.md")) - if gist_files.get("README.md") - else "

Please add README.md file to place some documentation here

" - ) + for field_name, file_name in ( + ("description", "README.md"), + ("task_description", "tasks.markdown"), + ("data_description", "datas.markdown"), + ): + vals[field_name] = ( + compile_markdown_to_html(gist_files.get(file_name)) + if gist_files.get(file_name) + else f"

Please add {file_name} file to place some documentation here

" + ) # [PARAMS] and [SECRETS] for model, field_name, file_name in ( @@ -512,7 +574,7 @@ def magic_upgrade(self): for file_info in gist_content["files"].values(): # e.g. "data.emoji.csv" file_name = file_info["filename"] - if not file_name.startswith("data."): + if not (file_name.startswith("data.") and file_name != "data.markdown"): continue raw_url = file_info["raw_url"] response = http.request("GET", raw_url) @@ -574,6 +636,20 @@ def magic_upgrade(self): else None, "project_id": self.id, } + # Sync Order Model + if meta.get("SYNC_ORDER_MODEL"): + model = self._get_model(meta.get("SYNC_ORDER_MODEL")) + task_vals["sync_order_model_id"] = model.id + + # Parse docs + sync_order_description = gist_files.get( + file_name[: -len(".py")] + ".markdown" + ) + if sync_order_description: + task_vals["sync_order_description"] = compile_markdown_to_html( + sync_order_description + ) + task = self.env["sync.task"]._create_or_update_by_xmlid( task_vals, task_technical_name, namespace=self.id ) @@ -585,7 +661,7 @@ def create_trigger(model, data): trigger_name=data["name"], ) return self.env[model]._create_or_update_by_xmlid( - vals, data["name"], namespace=self.id + vals, data["name"], namespace=f"p{self.id}t{task.id}" ) # Create/Update triggers @@ -596,20 +672,37 @@ def create_trigger(model, data): create_trigger("sync.trigger.webhook", data) for data in meta.get("DB_TRIGGERS", []): - model_id = self.env["ir.model"]._get(data["model"]).id - if not model_id: - raise ValidationError( - _( - "Model %s is not available. Check if you need to install an extra module first." + model = self._get_model(data["model"]) + if data.get("trigger_fields"): + trigger_field_ids = [] + for f in data.pop("trigger_fields").split(","): + ff = self.env["ir.model.fields"]._get(model.model, f) + trigger_field_ids.append(ff.id) + data["trigger_field_ids"] = [(6, 0, trigger_field_ids)] + + for field_name in ("filter_pre_domain", "filter_domain"): + if data.get(field_name): + data[field_name] = data[field_name].replace( + "{TASK_ID}", str(task.id) ) - % data["model"] - ) + create_trigger( - "sync.trigger.automation", dict(data, model_id=model_id, model=None) + "sync.trigger.automation", dict(data, model_id=model.id, model=None) ) self.update(vals) + def _get_model(self, model_name): + model = self.env["ir.model"]._get(model_name) + if not model: + raise ValidationError( + _( + "Model %s is not available. Check if you need to install an extra module first." + ) + % model_name + ) + return model + class SyncProjectParamMixin(models.AbstractModel): diff --git a/sync/models/sync_task.py b/sync/models/sync_task.py index 09306441..4f4be285 100644 --- a/sync/models/sync_task.py +++ b/sync/models/sync_task.py @@ -26,6 +26,8 @@ class SyncTask(models.Model): code = fields.Text("Code") code_check = fields.Text("Syntax check", store=False, readonly=True) active = fields.Boolean(default=True) + sync_order_model_id = fields.Many2one("ir.model") + sync_order_description = fields.Html(readonly=True) magic_button = fields.Char() button_ids = fields.One2many( "sync.trigger.button", "sync_task_id", string="Manual Triggers", copy=True @@ -35,6 +37,9 @@ class SyncTask(models.Model): "sync.trigger.automation", "sync_task_id", copy=True ) webhook_ids = fields.One2many("sync.trigger.webhook", "sync_task_id", copy=True) + # sync_trigger_order_ids = fields.One2many( + # "sync.trigger.order", "sync_task_id", string="Sync Order Triggers", copy=True + # ) active_cron_ids = fields.Many2many( "sync.trigger.cron", string="Enabled Crons", @@ -95,7 +100,7 @@ def _compute_active_triggers(self): r.active_webhook_ids = r.with_context(active_test=True).webhook_ids def action_magic_button(self): - # TODO: This should be refactored to delete button_ids + # TODO: This should be refactored, because we use single button per task if not self.button_ids: self.button_ids.create( { @@ -106,6 +111,36 @@ def action_magic_button(self): ) return self.button_ids.start_button() + def _get_current_date_formatted(self): + user_lang = self.env.user.lang or "en_US" + lang = self.env["res.lang"].search([("code", "=", user_lang)], limit=1) + today = fields.Date.context_today(self) + if lang: + date_format = lang.date_format + formatted_date = today.strftime(date_format) + else: + formatted_date = today.strftime("%Y-%m-%d") + + return formatted_date + + def action_super_magic_button(self): + self.ensure_one() + sync_order = self.env["sync.order"].create( + { + "name": self._get_current_date_formatted(), + "sync_task_id": self.id, + } + ) + return { + "name": "Super 🔥 Magic", + "type": "ir.actions.act_window", + "view_type": "form", + "view_mode": "form", + "res_model": "sync.order", + "res_id": sync_order.id, + "target": "self", + } + def start( self, trigger, args=None, with_delay=False, force=False, raise_on_error=True ): diff --git a/sync/models/sync_trigger_mixin.py b/sync/models/sync_trigger_mixin.py index 0a675bd9..0422bd12 100644 --- a/sync/models/sync_trigger_mixin.py +++ b/sync/models/sync_trigger_mixin.py @@ -39,10 +39,11 @@ def write(self, vals): self._update_name(vals) return res - @api.model - def create(self, vals): - res = super().create(vals) - res._update_name(vals) + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + res = super().create(vals) + res._update_name(vals) return res def default_get(self, fields): diff --git a/sync/security/ir.model.access.csv b/sync/security/ir.model.access.csv index 2ac9e400..fa317b6b 100644 --- a/sync/security/ir.model.access.csv +++ b/sync/security/ir.model.access.csv @@ -5,6 +5,9 @@ access_sync_project_manager,sync.project manager,model_sync_project,sync_group_m 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_order_user,sync.order user,model_sync_order,sync_group_user,1,0,0,0 +access_sync_order_dev,sync.order dev,model_sync_order,sync_group_dev,1,1,1,1 +access_sync_order_manager,sync.order manager,model_sync_order,sync_group_manager,1,1,1,1 access_sync_data_user,sync.data user,model_sync_data,sync_group_user,1,0,0,0 access_sync_data_dev,sync.data dev,model_sync_data,sync_group_dev,1,1,1,1 access_sync_data_manager,sync.data manager,model_sync_data,sync_group_manager,1,1,1,1 diff --git a/sync/views/sync_order_views.xml b/sync/views/sync_order_views.xml new file mode 100644 index 00000000..1781ffe1 --- /dev/null +++ b/sync/views/sync_order_views.xml @@ -0,0 +1,91 @@ + + + + + sync.order.tree + sync.order + + + + + + + + + + + sync.order.form + sync.order + +
+ +
+
+ +
+
+ + + + + + + + + + + + + +
+ +
+
+
+ + + +
+ +
+
+ + Sync Orders + sync.order + tree,form + [('sync_project_id', '=', active_id)] + +
diff --git a/sync/views/sync_project_views.xml b/sync/views/sync_project_views.xml index 6a04c9f7..48e7b984 100644 --- a/sync/views/sync_project_views.xml +++ b/sync/views/sync_project_views.xml @@ -53,6 +53,18 @@ > +