diff --git a/sync/__manifest__.py b/sync/__manifest__.py index 6fbd62d9..9a093f7b 100644 --- a/sync/__manifest__.py +++ b/sync/__manifest__.py @@ -7,7 +7,7 @@ "name": "Sync ๐Ÿชฌ Studio", "summary": """Join the Amazing ๐Ÿ˜ Community โคต๏ธ""", "category": "VooDoo โœจ Magic", - "version": "16.0.7.0.0", + "version": "16.0.11.0.0", "application": True, "author": "Ivan Kropotkin", "support": "info@odoomagic.com", @@ -26,8 +26,8 @@ "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", + "views/sync_project_views.xml", "data/queue_job_function_data.xml", ], "assets": { diff --git a/sync/doc/changelog.rst b/sync/doc/changelog.rst index 1515164c..ba973bcc 100644 --- a/sync/doc/changelog.rst +++ b/sync/doc/changelog.rst @@ -1,3 +1,12 @@ +`11.0.0` +------- + +- **New:** Use prime numbers for major releases ;-) +- **New:** Support data files +- **Fix:** Use Project ID for xmlid namespace +- **New:** Support dynamic properties +- **Improvement:** make links dependent on project + `7.0.0` ------- diff --git a/sync/models/__init__.py b/sync/models/__init__.py index d0b38e2a..80ed362d 100644 --- a/sync/models/__init__.py +++ b/sync/models/__init__.py @@ -10,8 +10,10 @@ from . import sync_project_demo from . import sync_task from . import sync_job +from . import sync_data from . import ir_logging from . import ir_actions from . import ir_attachment +from . import ir_fields from . import sync_link from . import base diff --git a/sync/models/base.py b/sync/models/base.py index 16079bb6..a895638f 100644 --- a/sync/models/base.py +++ b/sync/models/base.py @@ -1,7 +1,7 @@ -# Copyright 2020 Ivan Yelizariev +# Copyright 2020,2024 Ivan Yelizariev # License MIT (https://opensource.org/licenses/MIT). -from odoo import models +from odoo import _, exceptions, models class Base(models.AbstractModel): @@ -41,14 +41,21 @@ def _create_or_update_by_xmlid(self, vals, code, namespace="XXX", module="sync") data_obj = self.env["ir.model.data"] res_id = data_obj._xmlid_to_res_id(xmlid_full, raise_if_not_found=False) - + record = None if res_id: - # If record exists, update it record = self.browse(res_id) + + if record and record.exists(): record.write(vals) else: # No record found, create a new one record = self.create(vals) + if res_id: + # exceptional case when data record exists, but record is deleted + data_obj.search( + [("module", "=", module), ("name", "=", xmlid_code)] + ).unlink() + # Also create the corresponding ir.model.data record data_obj.create( { @@ -61,3 +68,111 @@ def _create_or_update_by_xmlid(self, vals, code, namespace="XXX", module="sync") ) return record + + def _set_sync_property(self, property_name, property_type, property_value): + """ + Set or create a property for the current record. If the property field + does not exist, create it dynamically. + + Args: + property_name (str): Name of the property field to set. + property_value (Any): The value to assign to the property. + property_type (str): Type of the property field. + """ + Property = self.env["ir.property"] + sync_project_id = self.env.context.get("sync_project_id") + + if not sync_project_id: + raise exceptions.UserError( + _("The 'sync_project_id' must be provided in the context.") + ) + + field_name = "x_sync_%s_%s_%s" % (sync_project_id, property_name, property_type) + field = self.env["ir.model.fields"].search( + [ + ("name", "=", field_name), + ("model", "=", self._name), + ("ttype", "=", property_type), + ("sync_project_id", "=", sync_project_id), + ], + limit=1, + ) + + if not field: + # Dynamically create the field if it does not exist + field = self.env["ir.model.fields"].create( + { + "name": field_name, + "ttype": property_type, + "model_id": self.env["ir.model"] + .search([("model", "=", self._name)], limit=1) + .id, + "field_description": property_name.capitalize().replace("_", " "), + "sync_project_id": sync_project_id, # Link to the sync project + } + ) + + res_id = f"{self._name},{self.id}" + prop = Property.search( + [ + ("name", "=", property_name), + ("res_id", "=", res_id), + ("fields_id", "=", field.id), + ], + limit=1, + ) + + vals = {"type": property_type, "value": property_value} + if prop: + prop.write(vals) + else: + vals.update( + { + "name": property_name, + "fields_id": field.id, + "res_id": res_id, + } + ) + Property.create(vals) + + def _get_sync_property(self, property_name, property_type): + """ + Get the value of a property for the current record. + + Args: + property_name (str): Name of the property field to get. + """ + Property = self.env["ir.property"] + sync_project_id = self.env.context.get("sync_project_id") + + if not sync_project_id: + raise exceptions.UserError( + _("The 'sync_project_id' must be provided in the context.") + ) + + field_name = "x_sync_%s_%s_%s" % (sync_project_id, property_name, property_type) + field = self.env["ir.model.fields"].search( + [ + ("name", "=", field_name), + ("model", "=", self._name), + ("sync_project_id", "=", sync_project_id), + ], + limit=1, + ) + + if not field: + raise exceptions.UserError( + f"Field '{field_name}' not found for the current model '{self._name}'." + ) + + res_id = f"{self._name},{self.id}" + prop = Property.search( + [ + ("name", "=", property_name), + ("res_id", "=", res_id), + ("fields_id", "=", field.id), + ], + limit=1, + ) + + return prop.get_by_record() if prop else None diff --git a/sync/models/ir_fields.py b/sync/models/ir_fields.py new file mode 100644 index 00000000..c1c92932 --- /dev/null +++ b/sync/models/ir_fields.py @@ -0,0 +1,12 @@ +# Copyright 2024 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). +from odoo import fields, models + + +class IrModelFields(models.Model): + _inherit = "ir.model.fields" + + sync_project_id = fields.Many2one( + "sync.project", + string="Sync Project", + ) diff --git a/sync/models/sync_data.py b/sync/models/sync_data.py new file mode 100644 index 00000000..e26933b5 --- /dev/null +++ b/sync/models/sync_data.py @@ -0,0 +1,45 @@ +# Copyright 2024 Ivan Yelizariev +import base64 +import csv +import json +from io import StringIO + +import yaml + +from odoo import fields, models + + +class SyncData(models.Model): + _name = "sync.data" + _description = "Sync Data File" + + name = fields.Char("Technical name") + project_id = fields.Many2one("sync.project", ondelete="cascade") + file_name = fields.Char("File Name") + file_content = fields.Binary("File Content") + + def csv(self, *args, **kwargs): + """Parse CSV file from binary field.""" + if self.file_content: + file_content = base64.b64decode(self.file_content) + file_content = file_content.decode("utf-8") + file_like_object = StringIO(file_content) + reader = csv.DictReader(file_like_object, *args, **kwargs) + return [row for row in reader] + return [] + + def json(self): + """Parse JSON file from binary field.""" + if self.file_content: + file_content = base64.b64decode(self.file_content) + file_content = file_content.decode("utf-8") + return json.loads(file_content) + return {} + + def yaml(self): + """Parse YAML file from binary field.""" + if self.file_content: + file_content = base64.b64decode(self.file_content) + file_content = file_content.decode("utf-8") + return yaml.safe_load(file_content) + return None diff --git a/sync/models/sync_link.py b/sync/models/sync_link.py index f711dfc6..704ca036 100644 --- a/sync/models/sync_link.py +++ b/sync/models/sync_link.py @@ -1,4 +1,4 @@ -# Copyright 2020 Ivan Yelizariev +# Copyright 2020,2024 Ivan Yelizariev # License MIT (https://opensource.org/licenses/MIT). import logging @@ -23,6 +23,7 @@ class SyncLink(models.Model): _description = "Resource Links" _order = "id desc" + project_id = fields.Char("Project") relation = fields.Char("Relation Name", required=True) system1 = fields.Char("System 1", required=True) # index system2 only to make search "Odoo links" @@ -40,7 +41,7 @@ def _auto_init(self): self._cr, "sync_link_refs_uniq_index", self._table, - ["relation", "system1", "system2", "ref1", "ref2", "model"], + ["project_id", "relation", "system1", "system2", "ref1", "ref2", "model"], ) return res @@ -81,6 +82,7 @@ def _set_link_external( self, relation, external_refs, sync_date=None, allow_many2many=False, model=None ): vals = self.refs2vals(external_refs) + vals["project_id"] = self.env.context.get("sync_project_id") # Check for existing records if allow_many2many: existing = self._search_links_external(relation, external_refs) @@ -142,7 +144,10 @@ def _search_links_external( self, relation, external_refs, model=None, make_logs=False ): vals = self.refs2vals(external_refs) - domain = [("relation", "=", relation)] + domain = [ + ("relation", "=", relation), + ("project_id", "=", self.env.context.get("sync_project_id")), + ] if model: domain.append(("model", "=", model)) for k, v in vals.items(): diff --git a/sync/models/sync_project.py b/sync/models/sync_project.py index 91a70c11..ae26066a 100644 --- a/sync/models/sync_project.py +++ b/sync/models/sync_project.py @@ -5,8 +5,10 @@ import base64 import logging +import os from datetime import datetime +import urllib3 from pytz import timezone from odoo import api, fields, models @@ -105,6 +107,9 @@ class SyncProject(models.Model): 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") + link_ids = fields.One2many("sync.link", "project_id") + link_count = fields.Integer(compute="_compute_link_count") + data_ids = fields.One2many("sync.data", "project_id") def copy(self, default=None): default = dict(default or {}) @@ -134,6 +139,11 @@ def _compute_log_count(self): for r in self: r.log_count = len(r.log_ids) + @api.depends("link_ids") + def _compute_link_count(self): + for r in self: + r.link_count = len(r.link_ids) + def _compute_triggers(self): for r in self: r.trigger_cron_count = len(r.mapped("task_ids.cron_ids")) @@ -249,7 +259,7 @@ def record2image(record, fname="image_1920"): ) ) - context = dict(self.env.context, log_function=log) + 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() MAGIC = AttrDict( @@ -312,6 +322,10 @@ def record2image(record, fname="image_1920"): for w in self.task_ids.mapped("webhook_ids"): WEBHOOKS[w.trigger_name] = w.website_url + DATA = AttrDict() + for d in self.data_ids: + DATA[d.name] = d + core_eval_context = { "MAGIC": MAGIC, "SECRETS": SECRETS, @@ -323,6 +337,7 @@ def record2image(record, fname="image_1920"): "CORE": CORE, "PARAMS": PARAMS, "WEBHOOKS": WEBHOOKS, + "DATA": DATA, } LIB = eval_export(safe_eval, self.common_code, lib_eval_context) @@ -437,7 +452,6 @@ def magic_upgrade(self): raise UserError(_("Please provide url to the gist page")) gist_content = fetch_gist_data(self.source_url) - gist_id = gist_content["id"] gist_files = {} for file_name, file_info in gist_content["files"].items(): gist_files[file_name] = file_info["content"] @@ -481,7 +495,7 @@ def magic_upgrade(self): "project_id": self.id, } self.env[model]._create_or_update_by_xmlid( - param_vals, f"PARAM_{key}", namespace=gist_id + param_vals, f"PARAM_{key}", namespace=self.id ) # [CORE] and [LIB] @@ -492,6 +506,38 @@ def magic_upgrade(self): if gist_files.get(file_name): vals[field_name] = gist_files[file_name] + # [DATA] + http = urllib3.PoolManager() + for file_info in gist_content["files"].values(): + # e.g. "data.emoji.csv" + file_name = file_info["filename"] + if not file_name.startswith("data."): + continue + raw_url = file_info["raw_url"] + response = http.request("GET", raw_url) + if response.status == 200: + file_content = response.data + file_content = base64.b64encode(file_content) + else: + raise Exception( + f"Failed to fetch raw content from {raw_url}. Status code: {response.status}" + ) + + technical_name = file_name + technical_name = technical_name[len("data.") :] + technical_name = os.path.splitext(technical_name)[0] + technical_name = technical_name.replace(".", "_") + + data_vals = { + "name": technical_name, + "project_id": self.id, + "file_name": file_name, + "file_content": file_content, + } + self.env["sync.data"]._create_or_update_by_xmlid( + data_vals, file_name, namespace=self.id + ) + # Tasks ๐Ÿฆ‹ for file_name in gist_files: # e.g. "task.setup.py" @@ -528,7 +574,7 @@ def magic_upgrade(self): "project_id": self.id, } task = self.env["sync.task"]._create_or_update_by_xmlid( - task_vals, task_technical_name, namespace=gist_id + task_vals, task_technical_name, namespace=self.id ) def create_trigger(model, data): @@ -538,7 +584,7 @@ def create_trigger(model, data): trigger_name=data["name"], ) return self.env[model]._create_or_update_by_xmlid( - vals, data["name"], namespace=gist_id + vals, data["name"], namespace=self.id ) # Create/Update triggers diff --git a/sync/models/sync_task.py b/sync/models/sync_task.py index 6be3e04c..09306441 100644 --- a/sync/models/sync_task.py +++ b/sync/models/sync_task.py @@ -125,7 +125,7 @@ def start( queue_job_or_result = run( job, trigger._sync_handler, args, raise_on_error=raise_on_error ) - if with_delay and not self.env.context.get("test_queue_job_no_delay"): + if with_delay and not self.env.context.get("queue_job__no_delay"): job.queue_job_id = queue_job_or_result.db_record() return job else: diff --git a/sync/security/ir.model.access.csv b/sync/security/ir.model.access.csv index 5964da44..2ac9e400 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_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 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 diff --git a/sync/tests/__init__.py b/sync/tests/__init__.py index 24f1e441..3b8371af 100644 --- a/sync/tests/__init__.py +++ b/sync/tests/__init__.py @@ -1,5 +1,6 @@ # License MIT (https://opensource.org/licenses/MIT). +from . import test_property from . import test_links from . import test_trigger_db from . import test_default_value diff --git a/sync/tests/test_links.py b/sync/tests/test_links.py index f11ca733..8088d890 100644 --- a/sync/tests/test_links.py +++ b/sync/tests/test_links.py @@ -1,4 +1,4 @@ -# Copyright 2020 Ivan Yelizariev +# Copyright 2020,2024 Ivan Yelizariev # License MIT (https://opensource.org/licenses/MIT). import uuid @@ -17,6 +17,8 @@ def generate_ref(): class TestLink(TransactionCase): def setUp(self): super(TestLink, self).setUp() + project = self.env["sync.project"].create({"name": "Test Project"}) + self.env = self.env(context=dict(self.env.context, sync_project_id=project.id)) funcs = self.env["sync.link"]._get_eval_context() self.get_link = funcs["get_link"] self.set_link = funcs["set_link"] diff --git a/sync/tests/test_property.py b/sync/tests/test_property.py new file mode 100644 index 00000000..391810e1 --- /dev/null +++ b/sync/tests/test_property.py @@ -0,0 +1,37 @@ +# Copyright 2024 Ivan Yelizariev +# License MIT (https://opensource.org/licenses/MIT). +from odoo.tests.common import TransactionCase + + +class TestProperty(TransactionCase): + def setUp(self): + super(TestProperty, self).setUp() + self.project = self.env["sync.project"].create({"name": "Test Project"}) + self.env = self.env( + context=dict(self.env.context, sync_project_id=self.project.id) + ) + self.company = self.env.ref("base.main_company") + self.partner = self.env["res.partner"].create({"name": "Test Partner"}) + + def test_basic_types(self): + # Basic types tests included for completeness + self.company._set_sync_property("x_test_prop_char", "char", "Hello, World!") + self.company._set_sync_property("x_test_prop_boolean", "boolean", True) + self.company._set_sync_property("x_test_prop_integer", "integer", 42) + self.company._set_sync_property("x_test_prop_float", "float", 3.14159) + + # Invalidate cache before reading + self.env.cache.invalidate() + + # Retrieval and Assertions + prop_char = self.company._get_sync_property("x_test_prop_char", "char") + prop_boolean = self.company._get_sync_property("x_test_prop_boolean", "boolean") + prop_integer = self.company._get_sync_property("x_test_prop_integer", "integer") + prop_float = self.company._get_sync_property("x_test_prop_float", "float") + + self.assertEqual(prop_char, "Hello, World!", "The char property did not match.") + self.assertEqual(prop_boolean, True, "The boolean property did not match.") + self.assertEqual(prop_integer, 42, "The integer property did not match.") + self.assertAlmostEqual( + prop_float, 3.14159, places=5, msg="The float property did not match." + ) diff --git a/sync/tests/test_trigger_db.py b/sync/tests/test_trigger_db.py index fcea7f23..ae45379e 100644 --- a/sync/tests/test_trigger_db.py +++ b/sync/tests/test_trigger_db.py @@ -17,7 +17,7 @@ def setUp(self): self.env = self.env( context=dict( self.env.context, - test_queue_job_no_delay=True, # no jobs thanks + queue_job__no_delay=True, # no jobs thanks ) ) funcs = self.env["sync.link"]._get_eval_context() diff --git a/sync/views/sync_link_views.xml b/sync/views/sync_link_views.xml index 10e499c9..a050d006 100644 --- a/sync/views/sync_link_views.xml +++ b/sync/views/sync_link_views.xml @@ -1,5 +1,5 @@ - @@ -7,6 +7,7 @@ sync.link + @@ -25,6 +26,7 @@ + @@ -79,6 +81,15 @@ tree,form + + Links + sync.link + tree,form + [('project_id', '=', active_id)] + + {'default_project_id': active_id, 'active_test': False} + + - @@ -45,20 +45,14 @@ > -