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}
+
+
-
@@ -281,6 +253,26 @@
+
+
+
+
+
+
+
+
+
+
+ Hint:
+
+ Documentation
+
+
+
+
diff --git a/sync/views/sync_task_views.xml b/sync/views/sync_task_views.xml
index 9d661c97..823afafd 100644
--- a/sync/views/sync_task_views.xml
+++ b/sync/views/sync_task_views.xml
@@ -1,5 +1,5 @@
-
@@ -68,6 +68,13 @@
Hint: Updating this code won't change the triggers.
Instead, update the gist file.
+
+
+ Documentation
+