pyvelm 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. pyvelm-0.1.0/PKG-INFO +18 -0
  2. pyvelm-0.1.0/README.md +80 -0
  3. pyvelm-0.1.0/pyproject.toml +56 -0
  4. pyvelm-0.1.0/pyvelm/__init__.py +75 -0
  5. pyvelm-0.1.0/pyvelm/actions.py +90 -0
  6. pyvelm-0.1.0/pyvelm/automation.py +76 -0
  7. pyvelm-0.1.0/pyvelm/builders.py +517 -0
  8. pyvelm-0.1.0/pyvelm/cli.py +309 -0
  9. pyvelm-0.1.0/pyvelm/cron.py +118 -0
  10. pyvelm-0.1.0/pyvelm/depends.py +22 -0
  11. pyvelm-0.1.0/pyvelm/domain.py +324 -0
  12. pyvelm-0.1.0/pyvelm/env.py +442 -0
  13. pyvelm-0.1.0/pyvelm/fields.py +374 -0
  14. pyvelm-0.1.0/pyvelm/loader.py +580 -0
  15. pyvelm-0.1.0/pyvelm/mail.py +380 -0
  16. pyvelm-0.1.0/pyvelm/model.py +574 -0
  17. pyvelm-0.1.0/pyvelm/modules/__init__.py +22 -0
  18. pyvelm-0.1.0/pyvelm/modules/admin/__init__.py +0 -0
  19. pyvelm-0.1.0/pyvelm/modules/admin/__pyvelm__.py +11 -0
  20. pyvelm-0.1.0/pyvelm/modules/admin/hooks.py +25 -0
  21. pyvelm-0.1.0/pyvelm/modules/admin/migrations/__init__.py +0 -0
  22. pyvelm-0.1.0/pyvelm/modules/admin/models/__init__.py +0 -0
  23. pyvelm-0.1.0/pyvelm/modules/admin/views/__init__.py +0 -0
  24. pyvelm-0.1.0/pyvelm/modules/admin/views/acl.py +94 -0
  25. pyvelm-0.1.0/pyvelm/modules/admin/views/menu.py +61 -0
  26. pyvelm-0.1.0/pyvelm/modules/base/__init__.py +0 -0
  27. pyvelm-0.1.0/pyvelm/modules/base/__pyvelm__.py +14 -0
  28. pyvelm-0.1.0/pyvelm/modules/base/hooks.py +118 -0
  29. pyvelm-0.1.0/pyvelm/modules/base/migrations/0_1_to_0_2.py +19 -0
  30. pyvelm-0.1.0/pyvelm/modules/base/migrations/0_2_to_0_3.py +62 -0
  31. pyvelm-0.1.0/pyvelm/modules/base/migrations/0_3_to_0_4.py +8 -0
  32. pyvelm-0.1.0/pyvelm/modules/base/migrations/0_4_to_0_5.py +62 -0
  33. pyvelm-0.1.0/pyvelm/modules/base/migrations/0_5_to_0_6.py +33 -0
  34. pyvelm-0.1.0/pyvelm/modules/base/migrations/0_6_to_0_7.py +20 -0
  35. pyvelm-0.1.0/pyvelm/modules/base/migrations/0_7_to_0_8.py +37 -0
  36. pyvelm-0.1.0/pyvelm/modules/base/migrations/0_8_to_0_9.py +42 -0
  37. pyvelm-0.1.0/pyvelm/modules/base/migrations/__init__.py +0 -0
  38. pyvelm-0.1.0/pyvelm/modules/base/models/__init__.py +7 -0
  39. pyvelm-0.1.0/pyvelm/modules/base/models/actions.py +5 -0
  40. pyvelm-0.1.0/pyvelm/modules/base/models/company.py +20 -0
  41. pyvelm-0.1.0/pyvelm/modules/base/models/country.py +13 -0
  42. pyvelm-0.1.0/pyvelm/modules/base/models/menu.py +26 -0
  43. pyvelm-0.1.0/pyvelm/modules/base/models/region.py +7 -0
  44. pyvelm-0.1.0/pyvelm/modules/base/models/security.py +116 -0
  45. pyvelm-0.1.0/pyvelm/modules/base/models/view.py +27 -0
  46. pyvelm-0.1.0/pyvelm/modules/base/views/menu.py +32 -0
  47. pyvelm-0.1.0/pyvelm/paths.py +251 -0
  48. pyvelm-0.1.0/pyvelm/registry.py +191 -0
  49. pyvelm-0.1.0/pyvelm/render.py +1797 -0
  50. pyvelm-0.1.0/pyvelm/scaffolder.py +207 -0
  51. pyvelm-0.1.0/pyvelm/scaffolds/__init__.py +7 -0
  52. pyvelm-0.1.0/pyvelm/scaffolds/module/__init__.py +0 -0
  53. pyvelm-0.1.0/pyvelm/scaffolds/module/__pyvelm__.py.template +22 -0
  54. pyvelm-0.1.0/pyvelm/scaffolds/module/hooks.py.template +41 -0
  55. pyvelm-0.1.0/pyvelm/scaffolds/module/migrations/__init__.py +0 -0
  56. pyvelm-0.1.0/pyvelm/scaffolds/module/models/__init__.py.template +1 -0
  57. pyvelm-0.1.0/pyvelm/scaffolds/module/models/{{name}}.py.template +12 -0
  58. pyvelm-0.1.0/pyvelm/scaffolds/module/views/__init__.py +0 -0
  59. pyvelm-0.1.0/pyvelm/scaffolds/module/views/menu.py.template +19 -0
  60. pyvelm-0.1.0/pyvelm/scaffolds/module/views/{{name}}.py.template +35 -0
  61. pyvelm-0.1.0/pyvelm/scaffolds/project/Dockerfile +30 -0
  62. pyvelm-0.1.0/pyvelm/scaffolds/project/README.md.template +50 -0
  63. pyvelm-0.1.0/pyvelm/scaffolds/project/app/__init__.py +0 -0
  64. pyvelm-0.1.0/pyvelm/scaffolds/project/app/modules/dotgitkeep +0 -0
  65. pyvelm-0.1.0/pyvelm/scaffolds/project/app/serve.py.template +68 -0
  66. pyvelm-0.1.0/pyvelm/scaffolds/project/deploy/nginx.conf.template +39 -0
  67. pyvelm-0.1.0/pyvelm/scaffolds/project/deploy/systemd/app.service.template +23 -0
  68. pyvelm-0.1.0/pyvelm/scaffolds/project/deploy/systemd/cron.service.template +22 -0
  69. pyvelm-0.1.0/pyvelm/scaffolds/project/docker-compose.yml.template +59 -0
  70. pyvelm-0.1.0/pyvelm/scaffolds/project/dotenv.example +25 -0
  71. pyvelm-0.1.0/pyvelm/scaffolds/project/dotgitignore +14 -0
  72. pyvelm-0.1.0/pyvelm/scaffolds/project/gunicorn_conf.py +35 -0
  73. pyvelm-0.1.0/pyvelm/scaffolds/project/pyproject.toml.template +15 -0
  74. pyvelm-0.1.0/pyvelm/scaffolds/project/pyvelm.toml.template +9 -0
  75. pyvelm-0.1.0/pyvelm/static/dist/flowbite.min.js +2 -0
  76. pyvelm-0.1.0/pyvelm/static/dist/pyvelm.css +2 -0
  77. pyvelm-0.1.0/pyvelm/static/pyvelm.css +242 -0
  78. pyvelm-0.1.0/pyvelm/static/tailwind.css +115 -0
  79. pyvelm-0.1.0/pyvelm/templates/admin.html +21 -0
  80. pyvelm-0.1.0/pyvelm/templates/apps.html +352 -0
  81. pyvelm-0.1.0/pyvelm/templates/form.html +8 -0
  82. pyvelm-0.1.0/pyvelm/templates/form_body.html +136 -0
  83. pyvelm-0.1.0/pyvelm/templates/kanban.html +57 -0
  84. pyvelm-0.1.0/pyvelm/templates/layouts/main.html +956 -0
  85. pyvelm-0.1.0/pyvelm/templates/list.html +694 -0
  86. pyvelm-0.1.0/pyvelm/templates/list_pagination.html +87 -0
  87. pyvelm-0.1.0/pyvelm/templates/list_row.html +53 -0
  88. pyvelm-0.1.0/pyvelm/templates/list_row_edit.html +36 -0
  89. pyvelm-0.1.0/pyvelm/templates/list_rows.html +8 -0
  90. pyvelm-0.1.0/pyvelm/templates/list_table.html +115 -0
  91. pyvelm-0.1.0/pyvelm/templates/login.html +76 -0
  92. pyvelm-0.1.0/pyvelm/templates/password.html +53 -0
  93. pyvelm-0.1.0/pyvelm/templates/widgets/m2m_input.html +87 -0
  94. pyvelm-0.1.0/pyvelm/templates/widgets/m2o_input.html +134 -0
  95. pyvelm-0.1.0/pyvelm/types.py +337 -0
  96. pyvelm-0.1.0/pyvelm/views.py +312 -0
  97. pyvelm-0.1.0/pyvelm/web.py +1343 -0
  98. pyvelm-0.1.0/pyvelm.egg-info/PKG-INFO +18 -0
  99. pyvelm-0.1.0/pyvelm.egg-info/SOURCES.txt +102 -0
  100. pyvelm-0.1.0/pyvelm.egg-info/dependency_links.txt +1 -0
  101. pyvelm-0.1.0/pyvelm.egg-info/entry_points.txt +3 -0
  102. pyvelm-0.1.0/pyvelm.egg-info/requires.txt +14 -0
  103. pyvelm-0.1.0/pyvelm.egg-info/top_level.txt +1 -0
  104. pyvelm-0.1.0/setup.cfg +4 -0
pyvelm-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyvelm
3
+ Version: 0.1.0
4
+ Summary: Odoo-style ERP framework — recordsets, declarative models, env-bound cache.
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: psycopg[binary]>=3.2
7
+ Requires-Dist: psycopg-pool>=3.2
8
+ Requires-Dist: python-dotenv>=1.0
9
+ Requires-Dist: fastapi>=0.115
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: jinja2>=3.1
12
+ Requires-Dist: python-multipart>=0.0.18
13
+ Requires-Dist: bcrypt>=4.2
14
+ Requires-Dist: uvicorn>=0.29
15
+ Provides-Extra: docs
16
+ Requires-Dist: mkdocs>=1.6; extra == "docs"
17
+ Requires-Dist: mkdocs-material>=9.5; extra == "docs"
18
+ Requires-Dist: mkdocstrings[python]>=0.27; extra == "docs"
pyvelm-0.1.0/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # pyvelm
2
+
3
+ An Odoo-style ERP framework in Python, built from first principles. The point
4
+ isn't to reinvent Odoo — it's to keep the core ideas visible so the design
5
+ trade-offs stay legible while the framework grows.
6
+
7
+ Status: **Stage 5 Slice A complete.** Access control + HTTP Basic
8
+ auth land on top of Stage 4's full UI: `res.groups`, `res.users`
9
+ (bcrypt), `ir.model.access`, `ir.rule` enforce per-model CRUD perms
10
+ and AND-inject row-level filters into every search. Anonymous reads
11
+ are denied unless a `group_id=None` grant exists; HTTP Basic against
12
+ `res.users.login` populates `env.uid`; superuser at uid=1 bypasses.
13
+ On top of Stage 4 (three view types, mutations, inheritance,
14
+ TypedDicts), Stage 3 (module loader, migrations), and Stage 2 (ORM
15
+ with all four relational field types, computed fields, dotted-path
16
+ traversal) — all on PostgreSQL via psycopg 3. View inheritance is dict-merge with
17
+ Odoo XPath-position parity, addressing into list `fields` *and*
18
+ form `sections[*].fields`. A two-mode widget registry dispatches
19
+ rendering by `(field_type, hint, mode)`. Built on Stage 3 (module
20
+ loader, transactional install/upgrade, hand-written migrations) and
21
+ Stage 2 (ORM with all four relational field types, computed fields,
22
+ dotted-path traversal in both `@depends` and domains) — all on
23
+ PostgreSQL via psycopg 3.
24
+
25
+ ## Quickstart
26
+
27
+ ```bash
28
+ python3 -m venv .venv
29
+ .venv/bin/pip install -e .
30
+ cp .env.example .env # then edit PYVELM_DSN
31
+ .venv/bin/python examples/basic.py
32
+ ```
33
+
34
+ The example smoke test exercises every feature including the HTMX UI.
35
+ The compiled CSS bundle lives at `pyvelm/static/dist/pyvelm.css` and
36
+ is checked in, so a fresh clone runs without Node. If you want to
37
+ hack on styling or component markup:
38
+
39
+ ```bash
40
+ npm install # installs Tailwind v4 + Flowbite into node_modules/
41
+ npm run dev # watch mode: rebuilds dist/pyvelm.css on save
42
+ npm run build # one-shot minified build + Flowbite JS copy
43
+ ```
44
+
45
+ The build scans `pyvelm/templates/**/*.html` and `pyvelm/render.py`
46
+ for utility classes (Tailwind v4 `@source` directives in
47
+ `pyvelm/static/tailwind.css`). Add new utility classes in those files
48
+ and the next build picks them up.
49
+
50
+ The example exercises every feature: CRUD, recordset semantics, all four
51
+ relational field types, M2o traversal, computed fields, dependency-graph
52
+ invalidation, and the singleton guard.
53
+
54
+ ## Documentation
55
+
56
+ - [Architecture overview](docs/architecture.md) — the big picture: why
57
+ recordsets, what `env.cache` is for, how the dependency graph works, the
58
+ multi-pass init sequence.
59
+ - [Module loading & migrations](docs/module-loading.md) — manifest format,
60
+ loader lifecycle, the active-registry contextvar, writing migrations.
61
+ - [Web layer & views as data](docs/web-layer.md) — `ir.ui.view`, the
62
+ `VIEWS` manifest key, the FastAPI app factory, JSON serialization
63
+ shape, three view types, mutations, inline edit.
64
+ - [Access control](docs/acl.md) — the four ACL models, HTTP Basic
65
+ authentication, superuser bypass, record rules, anonymous access
66
+ conventions.
67
+ - [Module reference](docs/modules.md) — what lives in each module, the
68
+ public surface, and the invariants worth knowing.
69
+ - [Extending fields](docs/extending-fields.md) — implementing a custom field
70
+ type without breaking the cache contract.
71
+ - [CONTEXT.md](CONTEXT.md) — current stage state, deferred items, and the
72
+ next concrete task.
73
+
74
+ ## What's deliberately not here yet
75
+
76
+ Things deferred with eyes open, not by accident: LRU/eviction on `env.cache`,
77
+ auto-generated schema diffs (migrations are hand-written),
78
+ Odoo-style M2M command tuples, transaction boundaries beyond psycopg
79
+ autocommit, schema migrations, module loading. See
80
+ [CONTEXT.md](CONTEXT.md) for the rationale.
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "pyvelm"
3
+ version = "0.1.0"
4
+ description = "Odoo-style ERP framework — recordsets, declarative models, env-bound cache."
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "psycopg[binary]>=3.2",
8
+ "psycopg-pool>=3.2",
9
+ "python-dotenv>=1.0",
10
+ "fastapi>=0.115",
11
+ "httpx>=0.27",
12
+ "jinja2>=3.1",
13
+ "python-multipart>=0.0.18",
14
+ "bcrypt>=4.2",
15
+ "uvicorn>=0.29",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ docs = [
20
+ "mkdocs>=1.6",
21
+ "mkdocs-material>=9.5",
22
+ "mkdocstrings[python]>=0.27",
23
+ ]
24
+
25
+ [project.scripts]
26
+ pyvelm = "pyvelm.cli:main"
27
+ # Legacy entry — `pyvelm cron` is the canonical form going forward.
28
+ # Kept so existing docker-compose / systemd configs survive upgrades
29
+ # without edits.
30
+ pyvelm-cron = "pyvelm.cli:cron_main"
31
+
32
+ [build-system]
33
+ requires = ["setuptools>=68"]
34
+ build-backend = "setuptools.build_meta"
35
+
36
+ [tool.setuptools.packages.find]
37
+ include = ["pyvelm*"]
38
+ exclude = ["examples*", "tests*"]
39
+
40
+ [tool.setuptools.package-data]
41
+ pyvelm = [
42
+ "templates/*.html",
43
+ "templates/**/*.html",
44
+ "static/*.css",
45
+ "static/dist/*",
46
+ # Bundled modules. The directory deliberately has no __init__.py
47
+ # so `pyvelm.modules.<name>` is NOT importable — the loader picks
48
+ # up each module via sys.path injection on `pyvelm/modules/`, the
49
+ # same path it uses for example addons.
50
+ "modules/**/*.py",
51
+ "modules/**/__pyvelm__.py",
52
+ # Scaffolder templates materialised by `pyvelm init` / `pyvelm new`.
53
+ # The `dot*` prefix is the rename-on-copy convention — see
54
+ # pyvelm/scaffolder.py for the substitution rules.
55
+ "scaffolds/**/*",
56
+ ]
@@ -0,0 +1,75 @@
1
+ from pathlib import Path as _Path
2
+
3
+ # Public version string. Keep in sync with ``[project].version`` in
4
+ # ``pyproject.toml`` — the release workflow refuses to publish if
5
+ # they diverge, but the check is only enforced in CI; bump both
6
+ # together when cutting a release.
7
+ __version__ = "0.1.0"
8
+
9
+ from .depends import depends
10
+ from .env import Environment
11
+ from .fields import (
12
+ Boolean,
13
+ Char,
14
+ Field,
15
+ Float,
16
+ Integer,
17
+ Many2many,
18
+ Many2one,
19
+ One2many,
20
+ Text,
21
+ )
22
+ from . import builders
23
+ from . import loader
24
+ from . import types
25
+ from .model import BaseModel
26
+ from .registry import Registry
27
+ # NOTE: ServerAction, AutomatedAction, CronJob, Message, MailThread are
28
+ # NOT imported here because they define BaseModel subclasses which must
29
+ # only be evaluated inside a `with registry.activate():` block (i.e. during
30
+ # module loading). Import them directly from their modules when needed:
31
+ # from pyvelm.actions import ServerAction
32
+ # from pyvelm.mail import MailThread, Message
33
+ # The engine helpers (AutomationEngine, CronJob.run_due) are always safe
34
+ # to import because they only touch the registry at call time.
35
+
36
+ # Discovery root for the modules bundled inside the wheel. Today the
37
+ # framework ships two: ``base`` (the primitives every app needs:
38
+ # ir.ui.view / res.users / res.groups / ir.model.access / ir.rule /
39
+ # ir.actions.server / base.automation / ir.cron / mail.message /
40
+ # res.country / res.region / res.company / ir.ui.menu) and ``admin``
41
+ # (the list/form views + sidebar menus that put a usable management
42
+ # UI in front of those models). Apps that boot the framework should
43
+ # include this in their ``loader.load_and_install`` call:
44
+ #
45
+ # from pyvelm import BUILTIN_MODULE_ROOTS
46
+ # loader.load_and_install(
47
+ # BUILTIN_MODULE_ROOTS + [my_app_root], env,
48
+ # )
49
+ #
50
+ # ``pyvelm-cron`` prepends these automatically so the CLI sees the
51
+ # framework modules even if the operator only set PYVELM_MODULE_ROOTS
52
+ # to their app's addons.
53
+ BUILTIN_MODULE_ROOTS: list[_Path] = [_Path(__file__).parent / "modules"]
54
+
55
+
56
+ __all__ = [
57
+ "BUILTIN_MODULE_ROOTS",
58
+ "__version__",
59
+ "BaseModel",
60
+ "Boolean",
61
+ "Char",
62
+ "Environment",
63
+ "Field",
64
+ "Float",
65
+ "Integer",
66
+ "Many2many",
67
+ "Many2one",
68
+ "One2many",
69
+ "Registry",
70
+ "Text",
71
+ "builders",
72
+ "depends",
73
+ "loader",
74
+ "types",
75
+ ]
@@ -0,0 +1,90 @@
1
+ """Server-action execution engine (Stage 6 Slice A).
2
+
3
+ `ir.actions.server` records represent named, storable pieces of logic
4
+ that can be invoked against a recordset at any time — from the API,
5
+ from automated-action triggers, from cron jobs, or from user-facing
6
+ buttons (future).
7
+
8
+ Four action_type values are supported:
9
+
10
+ write — write vals_json onto every record in the target recordset.
11
+ create — create one new record on model with vals_json.
12
+ unlink — delete every record in the target recordset.
13
+ code — execute a Python snippet. Locals: env, records, action.
14
+ The snippet may call any env/ORM method; write side effects
15
+ are the caller's responsibility to wrap in a transaction.
16
+
17
+ Security note: `code` actions execute arbitrary Python. They must only
18
+ be created by administrators (ACL enforced at the ORM level — Admin gets
19
+ create/write/unlink on ir.actions.server via the install hook). Do NOT
20
+ expose `code` contents to untrusted input.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+
26
+ from pyvelm import BaseModel, Boolean, Char, Many2one, Text
27
+
28
+
29
+ class ServerAction(BaseModel):
30
+ _name = "ir.actions.server"
31
+
32
+ name = Char(required=True)
33
+ model = Char(required=True) # target model _name
34
+ # One of: write / create / unlink / code
35
+ action_type = Char(required=True)
36
+ # JSON-encoded dict of field values used by write / create.
37
+ vals_json = Text()
38
+ # Python source executed for action_type == "code".
39
+ code = Text()
40
+
41
+ def run(self, records=None) -> None:
42
+ """Execute this action against *records*.
43
+
44
+ `records` should be a recordset of `self.model`. For
45
+ `create`-type actions the existing recordset is ignored and a
46
+ new record is created. Pass an empty recordset (or None) when
47
+ the target is the model itself with no pre-selected rows.
48
+ """
49
+ self.ensure_one()
50
+ env = self.env
51
+ target_model = self.model
52
+
53
+ if target_model not in env.registry:
54
+ raise ValueError(
55
+ f"ir.actions.server {self.name!r}: model {target_model!r} not in registry"
56
+ )
57
+
58
+ kind = self.action_type
59
+ if kind not in ("write", "create", "unlink", "code"):
60
+ raise ValueError(
61
+ f"ir.actions.server {self.name!r}: unknown action_type {kind!r}"
62
+ )
63
+
64
+ if records is None:
65
+ records = env[target_model]
66
+
67
+ if kind == "write":
68
+ if not records:
69
+ return
70
+ vals = json.loads(self.vals_json or "{}")
71
+ records.write(vals)
72
+
73
+ elif kind == "create":
74
+ vals = json.loads(self.vals_json or "{}")
75
+ env[target_model].create(vals)
76
+
77
+ elif kind == "unlink":
78
+ if not records:
79
+ return
80
+ records.unlink()
81
+
82
+ elif kind == "code":
83
+ code_src = self.code or ""
84
+ _globals: dict = {}
85
+ _locals: dict = {
86
+ "env": env,
87
+ "records": records,
88
+ "action": self,
89
+ }
90
+ exec(compile(code_src, f"<action:{self.name}>", "exec"), _globals, _locals) # noqa: S102
@@ -0,0 +1,76 @@
1
+ """Automated-action trigger engine (Stage 6 Slice B).
2
+
3
+ `base.automation` records attach a server action to a model-level ORM
4
+ event. The three supported trigger values are:
5
+
6
+ on_create — fires after every successful create() on the model.
7
+ on_write — fires after every successful write() on the model.
8
+ on_unlink — fires before every unlink() on the model.
9
+
10
+ The ORM calls `AutomationEngine.fire(env, model_name, event, records)`
11
+ from within `BaseModel.create / write / unlink`. The call is a no-op
12
+ if `base.automation` is not in the registry (e.g. during early install).
13
+
14
+ `base.automation` records are active (active=True) by default.
15
+ Deactivating a rule suppresses it without deleting it.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from pyvelm import BaseModel, Boolean, Char, Many2one
20
+
21
+
22
+ # Valid trigger names.
23
+ TRIGGERS = frozenset({"on_create", "on_write", "on_unlink"})
24
+
25
+
26
+ class AutomatedAction(BaseModel):
27
+ _name = "base.automation"
28
+
29
+ name = Char(required=True)
30
+ model = Char(required=True) # model _name this rule watches
31
+ trigger = Char(required=True) # on_create / on_write / on_unlink
32
+ action_id = Many2one("ir.actions.server", ondelete="CASCADE")
33
+ active = Boolean(default=True)
34
+
35
+
36
+ class AutomationEngine:
37
+ """Stateless helper — all state lives in the DB via base.automation."""
38
+
39
+ @staticmethod
40
+ def fire(env, model_name: str, event: str, records) -> None:
41
+ """Run all active automation rules for (model_name, event).
42
+
43
+ Failures in individual actions are logged to stderr and do NOT
44
+ abort the calling ORM operation; automation side effects are
45
+ best-effort unless the action itself raises inside a transaction
46
+ that the caller manages.
47
+ """
48
+ if "base.automation" not in env.registry:
49
+ return
50
+ if env._acl_bypass:
51
+ # Avoid recursive triggers while installing / migrating.
52
+ return
53
+
54
+ prev = env._acl_bypass
55
+ env._acl_bypass = True
56
+ try:
57
+ rules = env["base.automation"].search([
58
+ ("model", "=", model_name),
59
+ ("trigger", "=", event),
60
+ ("active", "=", True),
61
+ ])
62
+ for rule in rules:
63
+ if not rule.action_id:
64
+ continue
65
+ action = env["ir.actions.server"].browse(rule.action_id.id)
66
+ try:
67
+ action.run(records if event != "on_create" else records)
68
+ except Exception as exc: # noqa: BLE001
69
+ import sys
70
+ print(
71
+ f"[automation] {rule.name!r} failed on {model_name}"
72
+ f" ({event}): {exc}",
73
+ file=sys.stderr,
74
+ )
75
+ finally:
76
+ env._acl_bypass = prev