Skip to content

Commit

Permalink
✨ Import project from gist
Browse files Browse the repository at this point in the history
  • Loading branch information
yelizariev committed May 8, 2024
1 parent a334c4b commit 1f5d2af
Show file tree
Hide file tree
Showing 13 changed files with 317 additions and 47 deletions.
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
bravado_core
jsonschema<4
markdown
pyyaml
swagger_spec_validator
2 changes: 1 addition & 1 deletion sync/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"website": "https://sync_studio.t.me/",
"license": "Other OSI approved licence", # MIT
"depends": ["base_automation", "mail", "queue_job"],
"external_dependencies": {"python": ["markdown"], "bin": []},
"external_dependencies": {"python": ["markdown", "pyyaml"], "bin": []},
"data": [
"security/sync_groups.xml",
"security/ir.model.access.csv",
Expand Down
1 change: 1 addition & 0 deletions sync/lib/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# License MIT (https://opensource.org/licenses/MIT).

from . import tools
from . import models
from . import controllers
1 change: 1 addition & 0 deletions sync/lib/tools/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import safe_eval
3 changes: 3 additions & 0 deletions sync/lib/tools/safe_eval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# TODO
def test_python_expr__MAGIC(expr, mode="eval"):
return False
8 changes: 4 additions & 4 deletions sync/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# 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
from . import sync_trigger_cron
from . import sync_trigger_automation
from . import sync_trigger_webhook
from . import sync_trigger_button
from . import sync_project
from . import sync_project_context
from . import sync_project_demo
from . import sync_task
from . import sync_job
from . import ir_logging
from . import ir_actions
Expand Down
43 changes: 43 additions & 0 deletions sync/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,46 @@ def search_links(self, relation_name, refs=None):
.with_context(sync_link_odoo_model=self._name)
._search_links_odoo(self, relation_name, refs)
)

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.
Args:
vals (dict): Field values for creating or updating the record.
code (str): A unique part of the XML ID, usually a meaningful name or code.
namespace (str, optional): Additional unique part of the XML ID.
module (str, optional): The module name, defaults to 'sync'.
Returns:
odoo.models.BaseModel: The record that was created or updated.
"""
# Construct the XML ID
xmlid_code = f"MAGIC__{namespace}__{self._table}__{code}"
xmlid_full = f"{module}.{xmlid_code}"

# Try to retrieve the record using the XML ID
data_obj = self.env["ir.model.data"]

res_id = data_obj._xmlid_to_res_id(xmlid_full, raise_if_not_found=False)

if res_id:
# If record exists, update it
record = self.browse(res_id)
record.write(vals)
else:
# No record found, create a new one
record = self.create(vals)
# Also create the corresponding ir.model.data record
data_obj.create(
{
"name": xmlid_code,
"module": module,
"model": self._name,
"res_id": record.id,
"noupdate": False,
}
)

return record
128 changes: 108 additions & 20 deletions sync/models/sync_project.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2020,2022 Ivan Yelizariev <https://twitter.com/yelizariev>
# Copyright 2020,2022,2024 Ivan Yelizariev <https://twitter.com/yelizariev>
# Copyright 2020-2021 Denis Mudarisov <https://github.com/trojikman>
# Copyright 2021 Ilya Ilchenko <https://github.com/mentalko>
# License MIT (https://opensource.org/licenses/MIT).
Expand All @@ -25,7 +25,17 @@

from odoo.addons.queue_job.exception import RetryableJobError

from ..tools import compile_markdown_to_html, fetch_gist_data, url2base64, url2bin
from ..lib.tools.safe_eval import test_python_expr__MAGIC
from ..tools import (
compile_markdown_to_html,
convert_python_front_matter_to_comment,
extract_yaml_from_markdown,
extract_yaml_from_python,
fetch_gist_data,
has_function_defined,
url2base64,
url2bin,
)
from .ir_logging import LOG_CRITICAL, LOG_DEBUG, LOG_ERROR, LOG_INFO, LOG_WARNING

_logger = logging.getLogger(__name__)
Expand All @@ -45,7 +55,8 @@ class SyncProject(models.Model):
_description = "Sync Project"

name = fields.Char(
"Name", help="e.g. Legacy Migration or eCommerce Synchronization", required=True
"Name",
help="e.g. Legacy Migration or eCommerce Integration",
)
active = fields.Boolean(default=False)

Expand Down Expand Up @@ -83,12 +94,6 @@ class SyncProject(models.Model):
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")
Expand Down Expand Up @@ -139,16 +144,21 @@ def _compute_triggers(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("common_code")
def _check_python_code(self):
def _check_python_common_code(self):
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)

@api.constrains("core_code")
def _check_python_core_code(self):
for r in self.sudo().filtered("core_code"):
msg = test_python_expr__MAGIC(expr=(r.core_code or "").strip(), mode="exec")
if msg:
raise ValidationError(msg)

def write(self, vals):
if "core_code" in vals and not self.env.user.has_group(
"sync.sync_group_manager"
Expand Down Expand Up @@ -302,7 +312,7 @@ def record2image(record, fname=None):
"type2str": type2str,
"record2image": record2image,
"DEFAULT_SERVER_DATETIME_FORMAT": DEFAULT_SERVER_DATETIME_FORMAT,
}
},
)
reading_time = time.time() - start_time

Expand Down Expand Up @@ -447,6 +457,7 @@ 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"]
Expand All @@ -468,13 +479,30 @@ def magic_upgrade(self):
)

# [PARAMS] and [SECRETS]
for field_name, file_name in (
("param_description", ".markdown"),
("text_param_description", "settings.templates.markdown"),
("secret_description", "settings.secrets.markdown"),
for model, field_name, file_name in (
("sync.project.param", "param_description", "settings.markdown"),
(
"sync.project.text",
"text_param_description",
"settings.templates.markdown",
),
("sync.project.secret", "secret_description", "settings.secrets.markdown"),
):
if gist_files.get(file_name):
vals[field_name] = compile_markdown_to_html(gist_files[file_name])
file_content = gist_files.get(file_name)
if not file_content:
continue
vals[field_name] = compile_markdown_to_html(file_content)
meta = extract_yaml_from_markdown(file_content)

for key, initial_value in meta.items():
param_vals = {
"key": key,
"initial_value": initial_value,
"project_id": self.id,
}
self.env[model]._create_or_update_by_xmlid(
param_vals, f"PARAM_{key}", namespace=gist_id
)

# [CORE] and [LIB]
for field_name, file_name in (
Expand All @@ -484,7 +512,67 @@ def magic_upgrade(self):
if gist_files.get(file_name):
vals[field_name] = gist_files[file_name]

# TODO: tasks
# Tasks 🦋
for file_name in gist_files:
# e.g. "task.setup.py"
if not (file_name.startswith("task.") and file_name.endswith(".py")):
continue

# e.g. "setup"
task_technical_name = file_name[len("task.") : -len(".py")]

# Process file content
file_content = gist_files[file_name]
meta = extract_yaml_from_python(file_content)
task_name = meta.get("TITLE", f"<No TITLE found at the {file_name}>")

# Update code to bypass security checks
file_content = convert_python_front_matter_to_comment(file_content)

# Check if code is valid
syntax_errors = test_python_expr(file_content, mode="exec")
if syntax_errors:
raise ValueError(
f"Invalid python code at file {file_name}:\n\n{syntax_errors}"
)

# Check if python code has method `handle_button`
has_handle_button = has_function_defined(file_content, "handle_button")

task_vals = {
"name": task_name,
"code": file_content,
"magic_button": meta.get("MAGIC_BUTTON", "Magic ✨ Button")
if has_handle_button
else None,
"project_id": self.id,
}
task = self.env["sync.task"]._create_or_update_by_xmlid(
task_vals, task_technical_name, namespace=gist_id
)

def create_trigger(model, data):
vals = dict(
{key: value for key, value in data.items() if value is not None},
sync_task_id=task.id,
trigger_name=data["name"],
)
return self.env[model]._create_or_update_by_xmlid(
vals, data["name"], namespace=gist_id
)

# Create/Update triggers
for data in meta.get("CRON", []):
create_trigger("sync.trigger.cron", data)

for data in meta.get("WEBHOOK", []):
create_trigger("sync.trigger.webhook", data)

for data in meta.get("DB_TRIGGERS", []):
model_id = self.env["ir.model"]._get(data["model"]).id
create_trigger(
"sync.trigger.automation", dict(data, model_id=model_id, model=None)
)

self.update(vals)

Expand Down
4 changes: 2 additions & 2 deletions sync/models/sync_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class SyncTask(models.Model):
code = fields.Text("Code")
code_check = fields.Text("Syntax check", store=False, readonly=True)
active = fields.Boolean(default=True)
show_magic_button = fields.Boolean(default=False)
magic_button = fields.Char()
cron_ids = fields.One2many("sync.trigger.cron", "sync_task_id", copy=True)
automation_ids = fields.One2many(
"sync.trigger.automation", "sync_task_id", copy=True
Expand Down Expand Up @@ -91,7 +91,7 @@ def _compute_active_triggers(self):
r.active_automation_ids = r.with_context(active_test=True).automation_ids
r.active_webhook_ids = r.with_context(active_test=True).webhook_ids

def magic_button(self):
def action_magic_button(self):
# TODO
pass

Expand Down
1 change: 0 additions & 1 deletion sync/security/ir.model.access.csv
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,3 @@ access_sync_project_secret_dev,sync.project.secret dev,model_sync_project_secret
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
Loading

0 comments on commit 1f5d2af

Please sign in to comment.