lambda-erp 0.8.12__tar.gz → 0.8.13__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.
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/PKG-INFO +1 -1
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/chat.py +28 -9
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/main.py +2 -1
- lambda_erp-0.8.13/api/routers/actions.py +23 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/masters.py +29 -4
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/mcp.py +4 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/services.py +103 -1
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/frontend/src/api/client.ts +6 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/pyproject.toml +1 -1
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/.gitignore +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/LICENSE +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/README.md +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/attachments.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/auth.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/bootstrap.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/demo_limits.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/deps.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/errors.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/oauth.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/pdf.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/providers.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/remarks_md.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/accounting.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/admin.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/analytics.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/availability.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/documents.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/proposals.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/reports.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/routers/setup.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/templates/document.html +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/api/templates/proposal.html +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/docs/agents/README.md +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/frontend/LICENSE +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/frontend/README.md +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/database.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/model.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.8.12 → lambda_erp-0.8.13}/terraform/README.md +0 -0
|
@@ -1260,8 +1260,8 @@ def _chat_doctype_section() -> str:
|
|
|
1260
1260
|
)
|
|
1261
1261
|
|
|
1262
1262
|
|
|
1263
|
-
def build_tools():
|
|
1264
|
-
"""TOOLS with
|
|
1263
|
+
def build_tools(user_info: dict | None = None):
|
|
1264
|
+
"""TOOLS widened with live plugin types and registered business actions.
|
|
1265
1265
|
|
|
1266
1266
|
Plugins register doctypes and masters at startup — after this module is
|
|
1267
1267
|
imported — so the static TOOLS constant can't know about them. The tool
|
|
@@ -1274,7 +1274,14 @@ def build_tools():
|
|
|
1274
1274
|
"""
|
|
1275
1275
|
extra_docs = _extra_document_slugs()
|
|
1276
1276
|
extra_masters = _extra_master_types()
|
|
1277
|
-
|
|
1277
|
+
action_tools = services.registered_action_tools()
|
|
1278
|
+
if user_info is not None:
|
|
1279
|
+
role = user_info.get("role")
|
|
1280
|
+
action_tools = [
|
|
1281
|
+
tool for tool in action_tools
|
|
1282
|
+
if services.registered_action_allowed(tool["function"]["name"], role)
|
|
1283
|
+
]
|
|
1284
|
+
if not extra_docs and not extra_masters and not action_tools:
|
|
1278
1285
|
return TOOLS
|
|
1279
1286
|
tools = copy.deepcopy(TOOLS)
|
|
1280
1287
|
for tool in tools:
|
|
@@ -1284,6 +1291,8 @@ def build_tools():
|
|
|
1284
1291
|
schema["enum"] = DOCUMENT_SLUGS + extra_docs
|
|
1285
1292
|
elif schema.get("enum") == MASTER_TYPES:
|
|
1286
1293
|
schema["enum"] = MASTER_TYPES + extra_masters
|
|
1294
|
+
existing_names = {tool["function"]["name"] for tool in tools}
|
|
1295
|
+
tools.extend(tool for tool in action_tools if tool["function"]["name"] not in existing_names)
|
|
1287
1296
|
return tools
|
|
1288
1297
|
|
|
1289
1298
|
|
|
@@ -2053,11 +2062,20 @@ def build_system_prompt(user_info: dict | None = None, channel: str = "web"):
|
|
|
2053
2062
|
f"\n- **Extensions (deployment-specific):** {', '.join(extra_docs)}" if extra_docs else ""
|
|
2054
2063
|
)
|
|
2055
2064
|
master_types_line = ", ".join(MASTER_TYPES + extra_masters)
|
|
2056
|
-
extension_master_keys = ""
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2065
|
+
extension_master_keys = ""
|
|
2066
|
+
for master in extra_masters:
|
|
2067
|
+
meta = services.MASTER_METADATA.get(master, {})
|
|
2068
|
+
extension_master_keys += (
|
|
2069
|
+
f"\n- **{services.MASTER_TABLES[master][0]}** (master_type `{master}`): "
|
|
2070
|
+
f"key = `name`, display = `{services.MASTER_TABLES[master][1]}`"
|
|
2071
|
+
)
|
|
2072
|
+
if meta.get("description"):
|
|
2073
|
+
extension_master_keys += f". {meta['description']}"
|
|
2074
|
+
if meta.get("fields"):
|
|
2075
|
+
extension_master_keys += (
|
|
2076
|
+
". Key fields: "
|
|
2077
|
+
+ ", ".join(f"`{field}`" for field in meta["fields"])
|
|
2078
|
+
)
|
|
2061
2079
|
chat_doctype_section = _chat_doctype_section()
|
|
2062
2080
|
|
|
2063
2081
|
# Channel-aware link guidance. On the "web" channel the reader is a browser
|
|
@@ -2974,6 +2992,7 @@ async def run_thinking_loop(
|
|
|
2974
2992
|
# delete_master needs the caller's role (admin-only) — scoped here rather
|
|
2975
2993
|
# than in TOOL_HANDLERS, whose handlers are called with (args) only.
|
|
2976
2994
|
tool_handlers["delete_master"] = lambda args: _handle_delete_master(args, user_info)
|
|
2995
|
+
tool_handlers.update(services.registered_action_handlers(user_info))
|
|
2977
2996
|
|
|
2978
2997
|
user_role = user_info.get("role") if user_info else None
|
|
2979
2998
|
demo_mode = is_demo_role(user_role)
|
|
@@ -3075,7 +3094,7 @@ async def run_thinking_loop(
|
|
|
3075
3094
|
# Responses API (ERP_CHAT_API=responses). Returns Chat-shaped
|
|
3076
3095
|
# (message, usage) either way, so everything below is unchanged.
|
|
3077
3096
|
message, usage = await asyncio.to_thread(
|
|
3078
|
-
_orchestrator_turn, openai_client, messages, build_tools(), max_completion,
|
|
3097
|
+
_orchestrator_turn, openai_client, messages, build_tools(user_info), max_completion,
|
|
3079
3098
|
)
|
|
3080
3099
|
except Exception as e:
|
|
3081
3100
|
await on_event({"type": "error", "content": f"Error calling LLM: {e}"})
|
|
@@ -19,7 +19,7 @@ from api.auth import router as auth_router, COOKIE_NAME, decode_token
|
|
|
19
19
|
from api.oauth import router as oauth_router
|
|
20
20
|
from api.attachments import router as attachments_router
|
|
21
21
|
from api.chat import chat_websocket, router as chat_router
|
|
22
|
-
from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, analytics, accounting, proposals, chat_api, mcp, availability
|
|
22
|
+
from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, analytics, accounting, proposals, chat_api, mcp, availability, actions
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def load_plugins() -> None:
|
|
@@ -106,6 +106,7 @@ app.include_router(attachments_router, prefix="/api")
|
|
|
106
106
|
app.include_router(documents.router, prefix="/api")
|
|
107
107
|
app.include_router(proposals.router, prefix="/api")
|
|
108
108
|
app.include_router(masters.router, prefix="/api")
|
|
109
|
+
app.include_router(actions.router, prefix="/api")
|
|
109
110
|
app.include_router(availability.router, prefix="/api")
|
|
110
111
|
app.include_router(reports.router, prefix="/api")
|
|
111
112
|
app.include_router(analytics.router, prefix="/api")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""REST surface for deployment-registered business actions."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
4
|
+
|
|
5
|
+
from api import services
|
|
6
|
+
from api.auth import require_role
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/actions", tags=["actions"])
|
|
10
|
+
_viewer = Depends(require_role("viewer"))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.post("/{action_name}")
|
|
14
|
+
def run_action(action_name: str, data: dict, user: dict = _viewer):
|
|
15
|
+
"""Execute the same registered action exposed to chat and MCP."""
|
|
16
|
+
try:
|
|
17
|
+
return services.run_registered_action(action_name, data, user)
|
|
18
|
+
except KeyError:
|
|
19
|
+
raise HTTPException(status_code=404, detail=f"Unknown action: {action_name}")
|
|
20
|
+
except PermissionError as exc:
|
|
21
|
+
raise HTTPException(status_code=403, detail=str(exc))
|
|
22
|
+
except ValueError as exc:
|
|
23
|
+
raise HTTPException(status_code=422, detail=str(exc))
|
|
@@ -4,12 +4,13 @@ import re
|
|
|
4
4
|
|
|
5
5
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
6
6
|
from lambda_erp.database import get_db
|
|
7
|
-
from lambda_erp.utils import _dict
|
|
7
|
+
from lambda_erp.utils import _dict, now
|
|
8
8
|
# The per-type registries live in api.services so `register_master` can extend
|
|
9
9
|
# them; imported here (not moved) so existing `from api.routers.masters import
|
|
10
10
|
# MASTER_IDENTITY_ALIAS`-style imports keep working.
|
|
11
11
|
from api.services import (
|
|
12
12
|
MASTER_TABLES, MASTER_NAME_PREFIXES, MASTER_IDENTITY_ALIAS,
|
|
13
|
+
MASTER_REFERENCE_CHECKS,
|
|
13
14
|
_search_clause, _where_from_filters, master_search_columns, count_query_cached,
|
|
14
15
|
)
|
|
15
16
|
from api.auth import require_role, require_non_public_manager
|
|
@@ -145,7 +146,11 @@ def _with_active_filter(db, doctype: str, filters: dict | None = None) -> dict |
|
|
|
145
146
|
|
|
146
147
|
def _find_reference(master_type: str, name: str) -> str | None:
|
|
147
148
|
db = get_db()
|
|
148
|
-
|
|
149
|
+
checks = [
|
|
150
|
+
*DELETE_REFERENCE_CHECKS.get(master_type, []),
|
|
151
|
+
*MASTER_REFERENCE_CHECKS.get(master_type, []),
|
|
152
|
+
]
|
|
153
|
+
for query, label in checks:
|
|
149
154
|
params = [name] if query.count("?") == 1 else [name] * query.count("?")
|
|
150
155
|
try:
|
|
151
156
|
if db.sql(query, params):
|
|
@@ -223,6 +228,13 @@ def create_master_record(master_type: str, data: dict) -> dict:
|
|
|
223
228
|
if db.exists(doctype, doc["name"]):
|
|
224
229
|
raise HTTPException(status_code=409, detail=f"{doctype} {doc['name']} already exists")
|
|
225
230
|
|
|
231
|
+
columns = db._get_table_columns(doctype)
|
|
232
|
+
stamp = now()
|
|
233
|
+
if "creation" in columns and not doc.get("creation"):
|
|
234
|
+
doc["creation"] = stamp
|
|
235
|
+
if "modified" in columns and not doc.get("modified"):
|
|
236
|
+
doc["modified"] = stamp
|
|
237
|
+
|
|
226
238
|
# Party masters inherit the company's base currency when none is specified.
|
|
227
239
|
# Otherwise the Customer/Supplier default_currency column default ('USD')
|
|
228
240
|
# wins, so a non-USD company (e.g. CHF) gets USD customers that then force
|
|
@@ -261,6 +273,8 @@ def update_master_record(master_type: str, name: str, data: dict) -> dict:
|
|
|
261
273
|
)
|
|
262
274
|
|
|
263
275
|
update_fields = {k: v for k, v in normalized.items() if k != "name"}
|
|
276
|
+
if "modified" in db._get_table_columns(doctype) and "modified" not in update_fields:
|
|
277
|
+
update_fields["modified"] = now()
|
|
264
278
|
if update_fields:
|
|
265
279
|
db.set_value(doctype, name, update_fields)
|
|
266
280
|
|
|
@@ -273,7 +287,7 @@ def update_master_record(master_type: str, name: str, data: dict) -> dict:
|
|
|
273
287
|
# Query params list_masters consumes itself — anything else is treated as an
|
|
274
288
|
# ad-hoc equality field filter (validated against the master's real columns).
|
|
275
289
|
_MASTER_LIST_RESERVED = {
|
|
276
|
-
"limit", "offset", "include_disabled", "search", "search_fields",
|
|
290
|
+
"limit", "offset", "include_disabled", "search", "search_fields", "fields",
|
|
277
291
|
}
|
|
278
292
|
|
|
279
293
|
|
|
@@ -308,6 +322,7 @@ def list_masters(
|
|
|
308
322
|
include_disabled: bool = False,
|
|
309
323
|
search: str | None = None,
|
|
310
324
|
search_fields: str | None = None,
|
|
325
|
+
fields: str | None = None,
|
|
311
326
|
_user: dict = _viewer,
|
|
312
327
|
):
|
|
313
328
|
doctype, _ = _get_table(master_type)
|
|
@@ -354,7 +369,17 @@ def list_masters(
|
|
|
354
369
|
|
|
355
370
|
# Deterministic order by the `name` PK (already indexed) — required for
|
|
356
371
|
# OFFSET pagination and the /adjacent prev/next contract.
|
|
357
|
-
|
|
372
|
+
requested_fields = [f.strip() for f in (fields or "").split(",") if f.strip()]
|
|
373
|
+
if requested_fields:
|
|
374
|
+
unknown = [f for f in requested_fields if f not in columns]
|
|
375
|
+
if unknown:
|
|
376
|
+
raise HTTPException(status_code=400, detail=f"Unknown list fields: {', '.join(unknown)}")
|
|
377
|
+
if "name" not in requested_fields:
|
|
378
|
+
requested_fields.insert(0, "name")
|
|
379
|
+
projection = ", ".join(f'"{f}"' for f in requested_fields)
|
|
380
|
+
else:
|
|
381
|
+
projection = "*"
|
|
382
|
+
query = f'SELECT {projection} FROM "{doctype}"{where} ORDER BY name LIMIT {int(limit)}'
|
|
358
383
|
if offset:
|
|
359
384
|
query += f" OFFSET {int(offset)}"
|
|
360
385
|
rows = db.sql(query, params)
|
|
@@ -22,6 +22,7 @@ from fastapi.responses import JSONResponse, Response
|
|
|
22
22
|
|
|
23
23
|
from lambda_erp import get_app_version
|
|
24
24
|
from api.auth import get_current_user
|
|
25
|
+
from api import services
|
|
25
26
|
from api import chat as chat_mod
|
|
26
27
|
from api.chat import TOOL_HANDLERS, build_tools
|
|
27
28
|
|
|
@@ -58,6 +59,8 @@ def _can_write(role) -> bool:
|
|
|
58
59
|
def _allowed(name: str, role) -> bool:
|
|
59
60
|
if name in _EXCLUDE:
|
|
60
61
|
return False
|
|
62
|
+
if name in services.REGISTERED_ACTIONS:
|
|
63
|
+
return services.registered_action_allowed(name, role)
|
|
61
64
|
if name in _ADMIN:
|
|
62
65
|
return role == "admin"
|
|
63
66
|
if name in _WRITE:
|
|
@@ -96,6 +99,7 @@ def _call(name: str, args: dict, user: dict):
|
|
|
96
99
|
# delete_master needs the caller's role (admin-only); handled by the chat's
|
|
97
100
|
# role-aware variant.
|
|
98
101
|
handlers["delete_master"] = lambda a: chat_mod._handle_delete_master(a, user)
|
|
102
|
+
handlers.update(services.registered_action_handlers(user))
|
|
99
103
|
handler = handlers.get(name)
|
|
100
104
|
if handler is None:
|
|
101
105
|
raise KeyError(name)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Bridge between FastAPI request data and lambda_erp Document classes."""
|
|
2
2
|
|
|
3
|
+
import re
|
|
3
4
|
import time
|
|
4
5
|
|
|
5
6
|
from lambda_erp.utils import _dict, now
|
|
@@ -98,6 +99,17 @@ MASTER_IDENTITY_ALIAS = {
|
|
|
98
99
|
"item": "item_code",
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
# Optional metadata for plugin masters. Backend consumers use the descriptions
|
|
103
|
+
# to teach chat/MCP about deployment-specific records; the frontend has a
|
|
104
|
+
# parallel build-time registry because Python plugin state is not available to
|
|
105
|
+
# the browser bundle.
|
|
106
|
+
MASTER_METADATA: dict[str, dict] = {}
|
|
107
|
+
|
|
108
|
+
# Reference checks registered alongside plugin masters. Each entry has the same
|
|
109
|
+
# shape as the core checks in api.routers.masters: (query, human label). Keeping
|
|
110
|
+
# this registry here avoids making plugins mutate router-private globals.
|
|
111
|
+
MASTER_REFERENCE_CHECKS: dict[str, list[tuple[str, str]]] = {}
|
|
112
|
+
|
|
101
113
|
# Slug <-> doctype name mapping
|
|
102
114
|
SLUG_TO_DOCTYPE = {}
|
|
103
115
|
DOCTYPE_TO_SLUG = {}
|
|
@@ -201,7 +213,10 @@ def document_columns(doctype_slug: str) -> set:
|
|
|
201
213
|
|
|
202
214
|
def register_master(slug: str, table: str, name_field: str, *,
|
|
203
215
|
name_prefix: str | None = None,
|
|
204
|
-
identity_alias: str | None = None
|
|
216
|
+
identity_alias: str | None = None,
|
|
217
|
+
description: str | None = None,
|
|
218
|
+
fields: list[str] | None = None,
|
|
219
|
+
reference_checks: list[tuple[str, str]] | None = None) -> None:
|
|
205
220
|
"""Register (or override) a master type — the master-side counterpart of
|
|
206
221
|
`register_doctype`.
|
|
207
222
|
|
|
@@ -223,6 +238,93 @@ def register_master(slug: str, table: str, name_field: str, *,
|
|
|
223
238
|
MASTER_NAME_PREFIXES[slug] = name_prefix
|
|
224
239
|
if identity_alias:
|
|
225
240
|
MASTER_IDENTITY_ALIAS[slug] = identity_alias
|
|
241
|
+
if description or fields:
|
|
242
|
+
MASTER_METADATA[slug] = {
|
|
243
|
+
"description": description or "",
|
|
244
|
+
"fields": list(fields or []),
|
|
245
|
+
}
|
|
246
|
+
if reference_checks is not None:
|
|
247
|
+
MASTER_REFERENCE_CHECKS[slug] = list(reference_checks)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# --- Registered actions ----------------------------------------------------
|
|
251
|
+
#
|
|
252
|
+
# Plugins sometimes need one deliberate transition that is richer than CRUD
|
|
253
|
+
# (for example, promoting reference data into a workflow document). Register it
|
|
254
|
+
# once here and it becomes available through REST, AI chat, and MCP. The
|
|
255
|
+
# frontend's master registry can point a button at the same action name.
|
|
256
|
+
|
|
257
|
+
REGISTERED_ACTIONS: dict[str, dict] = {}
|
|
258
|
+
_ACTION_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
259
|
+
_ROLE_LEVEL = {"viewer": 1, "manager": 2, "public_manager": 2, "admin": 3}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def register_action(name: str, handler, *, description: str,
|
|
263
|
+
parameters: dict | None = None,
|
|
264
|
+
minimum_role: str = "manager",
|
|
265
|
+
allow_public_manager: bool = False) -> None:
|
|
266
|
+
"""Register a deployment action across REST, chat, and MCP.
|
|
267
|
+
|
|
268
|
+
``handler`` receives one JSON-compatible argument dict and returns a
|
|
269
|
+
JSON-compatible result. ``parameters`` is a JSON Schema object for that
|
|
270
|
+
dict. Mutating actions should keep the default manager requirement; demo
|
|
271
|
+
``public_manager`` users are excluded unless explicitly opted in.
|
|
272
|
+
"""
|
|
273
|
+
if not _ACTION_NAME_RE.fullmatch(name):
|
|
274
|
+
raise ValueError(f"Invalid action name: {name!r}")
|
|
275
|
+
if minimum_role not in _ROLE_LEVEL:
|
|
276
|
+
raise ValueError(f"Invalid minimum action role: {minimum_role!r}")
|
|
277
|
+
REGISTERED_ACTIONS[name] = {
|
|
278
|
+
"handler": handler,
|
|
279
|
+
"description": description,
|
|
280
|
+
"parameters": parameters or {"type": "object", "properties": {}},
|
|
281
|
+
"minimum_role": minimum_role,
|
|
282
|
+
"allow_public_manager": allow_public_manager,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def registered_action_allowed(name: str, role: str | None) -> bool:
|
|
287
|
+
meta = REGISTERED_ACTIONS.get(name)
|
|
288
|
+
if not meta:
|
|
289
|
+
return False
|
|
290
|
+
if role == "public_manager" and not meta["allow_public_manager"]:
|
|
291
|
+
return False
|
|
292
|
+
return _ROLE_LEVEL.get(role or "", 0) >= _ROLE_LEVEL[meta["minimum_role"]]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def registered_action_tools() -> list[dict]:
|
|
296
|
+
"""OpenAI-style function tool schemas for all live registered actions."""
|
|
297
|
+
return [
|
|
298
|
+
{
|
|
299
|
+
"type": "function",
|
|
300
|
+
"function": {
|
|
301
|
+
"name": name,
|
|
302
|
+
"description": meta["description"],
|
|
303
|
+
"parameters": meta["parameters"],
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
for name, meta in REGISTERED_ACTIONS.items()
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def run_registered_action(name: str, args: dict | None, user_info: dict | None = None):
|
|
311
|
+
meta = REGISTERED_ACTIONS.get(name)
|
|
312
|
+
if not meta:
|
|
313
|
+
raise KeyError(name)
|
|
314
|
+
role = (user_info or {}).get("role")
|
|
315
|
+
if not registered_action_allowed(name, role):
|
|
316
|
+
raise PermissionError(
|
|
317
|
+
f"Action '{name}' requires {meta['minimum_role']} access."
|
|
318
|
+
)
|
|
319
|
+
return meta["handler"](args or {})
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def registered_action_handlers(user_info: dict | None = None) -> dict:
|
|
323
|
+
"""Handlers bound to one caller, used by both chat and MCP dispatch."""
|
|
324
|
+
return {
|
|
325
|
+
name: (lambda args, action=name: run_registered_action(action, args, user_info))
|
|
326
|
+
for name in REGISTERED_ACTIONS
|
|
327
|
+
}
|
|
226
328
|
|
|
227
329
|
|
|
228
330
|
# Chat guidance for registered doctypes: slug -> {"description", "fields"}. The
|
|
@@ -284,6 +284,12 @@ export const api = {
|
|
|
284
284
|
body: JSON.stringify(data),
|
|
285
285
|
}),
|
|
286
286
|
|
|
287
|
+
runAction: (action: string, data: Record<string, any>) =>
|
|
288
|
+
request<any>(`/actions/${encodeURIComponent(action)}`, {
|
|
289
|
+
method: "POST",
|
|
290
|
+
body: JSON.stringify(data),
|
|
291
|
+
}),
|
|
292
|
+
|
|
287
293
|
deleteMaster: (type: string, name: string) =>
|
|
288
294
|
request<any>(`/masters/${type}/${encodeURIComponent(name)}`, { method: "DELETE" }),
|
|
289
295
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|