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.
- pyvelm-0.1.0/PKG-INFO +18 -0
- pyvelm-0.1.0/README.md +80 -0
- pyvelm-0.1.0/pyproject.toml +56 -0
- pyvelm-0.1.0/pyvelm/__init__.py +75 -0
- pyvelm-0.1.0/pyvelm/actions.py +90 -0
- pyvelm-0.1.0/pyvelm/automation.py +76 -0
- pyvelm-0.1.0/pyvelm/builders.py +517 -0
- pyvelm-0.1.0/pyvelm/cli.py +309 -0
- pyvelm-0.1.0/pyvelm/cron.py +118 -0
- pyvelm-0.1.0/pyvelm/depends.py +22 -0
- pyvelm-0.1.0/pyvelm/domain.py +324 -0
- pyvelm-0.1.0/pyvelm/env.py +442 -0
- pyvelm-0.1.0/pyvelm/fields.py +374 -0
- pyvelm-0.1.0/pyvelm/loader.py +580 -0
- pyvelm-0.1.0/pyvelm/mail.py +380 -0
- pyvelm-0.1.0/pyvelm/model.py +574 -0
- pyvelm-0.1.0/pyvelm/modules/__init__.py +22 -0
- pyvelm-0.1.0/pyvelm/modules/admin/__init__.py +0 -0
- pyvelm-0.1.0/pyvelm/modules/admin/__pyvelm__.py +11 -0
- pyvelm-0.1.0/pyvelm/modules/admin/hooks.py +25 -0
- pyvelm-0.1.0/pyvelm/modules/admin/migrations/__init__.py +0 -0
- pyvelm-0.1.0/pyvelm/modules/admin/models/__init__.py +0 -0
- pyvelm-0.1.0/pyvelm/modules/admin/views/__init__.py +0 -0
- pyvelm-0.1.0/pyvelm/modules/admin/views/acl.py +94 -0
- pyvelm-0.1.0/pyvelm/modules/admin/views/menu.py +61 -0
- pyvelm-0.1.0/pyvelm/modules/base/__init__.py +0 -0
- pyvelm-0.1.0/pyvelm/modules/base/__pyvelm__.py +14 -0
- pyvelm-0.1.0/pyvelm/modules/base/hooks.py +118 -0
- pyvelm-0.1.0/pyvelm/modules/base/migrations/0_1_to_0_2.py +19 -0
- pyvelm-0.1.0/pyvelm/modules/base/migrations/0_2_to_0_3.py +62 -0
- pyvelm-0.1.0/pyvelm/modules/base/migrations/0_3_to_0_4.py +8 -0
- pyvelm-0.1.0/pyvelm/modules/base/migrations/0_4_to_0_5.py +62 -0
- pyvelm-0.1.0/pyvelm/modules/base/migrations/0_5_to_0_6.py +33 -0
- pyvelm-0.1.0/pyvelm/modules/base/migrations/0_6_to_0_7.py +20 -0
- pyvelm-0.1.0/pyvelm/modules/base/migrations/0_7_to_0_8.py +37 -0
- pyvelm-0.1.0/pyvelm/modules/base/migrations/0_8_to_0_9.py +42 -0
- pyvelm-0.1.0/pyvelm/modules/base/migrations/__init__.py +0 -0
- pyvelm-0.1.0/pyvelm/modules/base/models/__init__.py +7 -0
- pyvelm-0.1.0/pyvelm/modules/base/models/actions.py +5 -0
- pyvelm-0.1.0/pyvelm/modules/base/models/company.py +20 -0
- pyvelm-0.1.0/pyvelm/modules/base/models/country.py +13 -0
- pyvelm-0.1.0/pyvelm/modules/base/models/menu.py +26 -0
- pyvelm-0.1.0/pyvelm/modules/base/models/region.py +7 -0
- pyvelm-0.1.0/pyvelm/modules/base/models/security.py +116 -0
- pyvelm-0.1.0/pyvelm/modules/base/models/view.py +27 -0
- pyvelm-0.1.0/pyvelm/modules/base/views/menu.py +32 -0
- pyvelm-0.1.0/pyvelm/paths.py +251 -0
- pyvelm-0.1.0/pyvelm/registry.py +191 -0
- pyvelm-0.1.0/pyvelm/render.py +1797 -0
- pyvelm-0.1.0/pyvelm/scaffolder.py +207 -0
- pyvelm-0.1.0/pyvelm/scaffolds/__init__.py +7 -0
- pyvelm-0.1.0/pyvelm/scaffolds/module/__init__.py +0 -0
- pyvelm-0.1.0/pyvelm/scaffolds/module/__pyvelm__.py.template +22 -0
- pyvelm-0.1.0/pyvelm/scaffolds/module/hooks.py.template +41 -0
- pyvelm-0.1.0/pyvelm/scaffolds/module/migrations/__init__.py +0 -0
- pyvelm-0.1.0/pyvelm/scaffolds/module/models/__init__.py.template +1 -0
- pyvelm-0.1.0/pyvelm/scaffolds/module/models/{{name}}.py.template +12 -0
- pyvelm-0.1.0/pyvelm/scaffolds/module/views/__init__.py +0 -0
- pyvelm-0.1.0/pyvelm/scaffolds/module/views/menu.py.template +19 -0
- pyvelm-0.1.0/pyvelm/scaffolds/module/views/{{name}}.py.template +35 -0
- pyvelm-0.1.0/pyvelm/scaffolds/project/Dockerfile +30 -0
- pyvelm-0.1.0/pyvelm/scaffolds/project/README.md.template +50 -0
- pyvelm-0.1.0/pyvelm/scaffolds/project/app/__init__.py +0 -0
- pyvelm-0.1.0/pyvelm/scaffolds/project/app/modules/dotgitkeep +0 -0
- pyvelm-0.1.0/pyvelm/scaffolds/project/app/serve.py.template +68 -0
- pyvelm-0.1.0/pyvelm/scaffolds/project/deploy/nginx.conf.template +39 -0
- pyvelm-0.1.0/pyvelm/scaffolds/project/deploy/systemd/app.service.template +23 -0
- pyvelm-0.1.0/pyvelm/scaffolds/project/deploy/systemd/cron.service.template +22 -0
- pyvelm-0.1.0/pyvelm/scaffolds/project/docker-compose.yml.template +59 -0
- pyvelm-0.1.0/pyvelm/scaffolds/project/dotenv.example +25 -0
- pyvelm-0.1.0/pyvelm/scaffolds/project/dotgitignore +14 -0
- pyvelm-0.1.0/pyvelm/scaffolds/project/gunicorn_conf.py +35 -0
- pyvelm-0.1.0/pyvelm/scaffolds/project/pyproject.toml.template +15 -0
- pyvelm-0.1.0/pyvelm/scaffolds/project/pyvelm.toml.template +9 -0
- pyvelm-0.1.0/pyvelm/static/dist/flowbite.min.js +2 -0
- pyvelm-0.1.0/pyvelm/static/dist/pyvelm.css +2 -0
- pyvelm-0.1.0/pyvelm/static/pyvelm.css +242 -0
- pyvelm-0.1.0/pyvelm/static/tailwind.css +115 -0
- pyvelm-0.1.0/pyvelm/templates/admin.html +21 -0
- pyvelm-0.1.0/pyvelm/templates/apps.html +352 -0
- pyvelm-0.1.0/pyvelm/templates/form.html +8 -0
- pyvelm-0.1.0/pyvelm/templates/form_body.html +136 -0
- pyvelm-0.1.0/pyvelm/templates/kanban.html +57 -0
- pyvelm-0.1.0/pyvelm/templates/layouts/main.html +956 -0
- pyvelm-0.1.0/pyvelm/templates/list.html +694 -0
- pyvelm-0.1.0/pyvelm/templates/list_pagination.html +87 -0
- pyvelm-0.1.0/pyvelm/templates/list_row.html +53 -0
- pyvelm-0.1.0/pyvelm/templates/list_row_edit.html +36 -0
- pyvelm-0.1.0/pyvelm/templates/list_rows.html +8 -0
- pyvelm-0.1.0/pyvelm/templates/list_table.html +115 -0
- pyvelm-0.1.0/pyvelm/templates/login.html +76 -0
- pyvelm-0.1.0/pyvelm/templates/password.html +53 -0
- pyvelm-0.1.0/pyvelm/templates/widgets/m2m_input.html +87 -0
- pyvelm-0.1.0/pyvelm/templates/widgets/m2o_input.html +134 -0
- pyvelm-0.1.0/pyvelm/types.py +337 -0
- pyvelm-0.1.0/pyvelm/views.py +312 -0
- pyvelm-0.1.0/pyvelm/web.py +1343 -0
- pyvelm-0.1.0/pyvelm.egg-info/PKG-INFO +18 -0
- pyvelm-0.1.0/pyvelm.egg-info/SOURCES.txt +102 -0
- pyvelm-0.1.0/pyvelm.egg-info/dependency_links.txt +1 -0
- pyvelm-0.1.0/pyvelm.egg-info/entry_points.txt +3 -0
- pyvelm-0.1.0/pyvelm.egg-info/requires.txt +14 -0
- pyvelm-0.1.0/pyvelm.egg-info/top_level.txt +1 -0
- 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
|