lambda-erp 0.5.0__tar.gz → 0.6.1__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.5.0 → lambda_erp-0.6.1}/PKG-INFO +17 -2
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/README.md +16 -1
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/auth.py +59 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/bootstrap.py +16 -1
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/chat.py +39 -2
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/main.py +12 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/documents.py +30 -2
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/services.py +111 -6
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/database.py +8 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/pyproject.toml +1 -1
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/.gitignore +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/LICENSE +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/attachments.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/demo_limits.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/deps.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/errors.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/oauth.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/pdf.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/providers.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/remarks_md.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/accounting.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/admin.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/analytics.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/masters.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/proposals.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/reports.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/routers/setup.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/templates/document.html +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/api/templates/proposal.html +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/docs/agents/README.md +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/frontend/LICENSE +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/frontend/README.md +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/frontend/src/api/client.ts +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/model.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.5.0 → lambda_erp-0.6.1}/terraform/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lambda-erp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Core ERP logic - accounting, sales, purchasing, inventory
|
|
5
5
|
Author: TORUS INVESTMENTS AG
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -112,6 +112,7 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
|
|
|
112
112
|
**Key design choices:**
|
|
113
113
|
|
|
114
114
|
- **Chat-first, not chat-bolted-on.** The chat isn't a copilot sidebar - it's the primary way to interact with the system. Every document type, every report, every master record is reachable from tool-use.
|
|
115
|
+
- **Extensions are first-class to the AI.** A customer deployment registers its own doctypes and master types through plugin seams (`register_doctype`, `register_master`) and the chat discovers them automatically - tool schemas and the system prompt are built per request from the live registries, and fields are introspected from the live table, so there is nothing to teach per type. A CRM lead added by a plugin is searchable in chat from day one, with zero prompt or tool edits.
|
|
115
116
|
- **One shape for every document.** Invoices, sales orders, stock entries, payments - all share a single `Document` base class and the same three-state lifecycle (Draft → Submitted → Cancelled) with `on_submit`/`on_cancel` hooks. The LLM learns the pattern once and drives every doctype the same way. Leading open-source and commercial ERPs have per-model action verbs spread across 150+ core models; each one is a separate tool the model has to get right.
|
|
116
117
|
- **Metadata-driven UI, shared with the LLM.** A single React form component renders every doctype from `frontend/src/lib/doctypes.ts`. The schema the model reasons over and the schema the user sees are literally the same file. Adding a field is two lines - one in the Python class, one in the config - not a new module with hand-written views and inheritance overlays.
|
|
117
118
|
- **Two-model orchestration.** A planner model handles reasoning and tool-use. When it needs to generate code for a custom report, it delegates to a code-specialist sub-agent. This keeps each model doing what it's best at and keeps latency down on simple turns.
|
|
@@ -259,10 +260,15 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
|
|
|
259
260
|
OPENAI_API_KEY=sk-...
|
|
260
261
|
ANTHROPIC_API_KEY=sk-ant-... # optional, used for the code-specialist sub-agent
|
|
261
262
|
ANTHROPIC_CODE_MODEL=claude-opus-4-7 # optional, default shown
|
|
263
|
+
LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
|
|
264
|
+
LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
|
|
265
|
+
LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
|
|
262
266
|
```
|
|
263
267
|
|
|
264
268
|
Chat needs `OPENAI_API_KEY`. Custom-report code generation uses `ANTHROPIC_API_KEY` when set; otherwise it falls back and the chat will tell you it can't generate reports.
|
|
265
269
|
|
|
270
|
+
**Seeded admin.** By default the first person to register becomes the admin. Set `LAMBDA_ERP_ADMIN_EMAIL` + `LAMBDA_ERP_ADMIN_PASSWORD` to instead provision that admin automatically at startup — useful when the database is recreated on every deploy, so a redeployed instance can't be claimed by whoever visits first. It's create-if-missing and idempotent (an existing account with that email is promoted to an enabled admin; its password is left untouched), and the password is only ever read from the environment.
|
|
271
|
+
|
|
266
272
|
### Run the validation suite
|
|
267
273
|
|
|
268
274
|
```bash
|
|
@@ -353,12 +359,13 @@ for side-effects/integrations).
|
|
|
353
359
|
|
|
354
360
|
```python
|
|
355
361
|
# acme/plugin.py
|
|
356
|
-
from api.services import register_doctype, register_converter
|
|
362
|
+
from api.services import register_doctype, register_master, register_converter
|
|
357
363
|
from lambda_erp.hooks import register_hook
|
|
358
364
|
from .sales_invoice import AcmeSalesInvoice
|
|
359
365
|
|
|
360
366
|
def register():
|
|
361
367
|
register_doctype("Sales Invoice", AcmeSalesInvoice)
|
|
368
|
+
register_master("gadget", "Gadget", "gadget_name", name_prefix="GAD")
|
|
362
369
|
register_hook("Sales Invoice:after_submit", push_to_external_system)
|
|
363
370
|
# register_converter(source, target, fn) # only to replace conversion *logic*
|
|
364
371
|
```
|
|
@@ -367,6 +374,14 @@ Point the deployment at it with `LAMBDA_ERP_PLUGINS=acme` (comma-separated for
|
|
|
367
374
|
several). On startup the core imports each module and calls `register()`. Unset
|
|
368
375
|
= the core runs unchanged.
|
|
369
376
|
|
|
377
|
+
A `register_master` type (core 0.5.0+) is served at `/api/masters/{slug}` and
|
|
378
|
+
**discovered by the AI chat automatically**: `search_masters` (with fuzzy
|
|
379
|
+
fallback), `get_master_fields`, and `create/update/delete_master` accept it
|
|
380
|
+
from day one. The chat tool schemas and system prompt are built per request
|
|
381
|
+
from the live registries, and fields are introspected from the live table — no
|
|
382
|
+
per-type declarations. Details and caveats:
|
|
383
|
+
[`docs/core-extension-architecture.md`](docs/core-extension-architecture.md).
|
|
384
|
+
|
|
370
385
|
**Frontend overrides — the `@lambda-development/erp-core` library**
|
|
371
386
|
|
|
372
387
|
The frontend ships as a library. The customer app depends on it, registers its
|
|
@@ -85,6 +85,7 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
|
|
|
85
85
|
**Key design choices:**
|
|
86
86
|
|
|
87
87
|
- **Chat-first, not chat-bolted-on.** The chat isn't a copilot sidebar - it's the primary way to interact with the system. Every document type, every report, every master record is reachable from tool-use.
|
|
88
|
+
- **Extensions are first-class to the AI.** A customer deployment registers its own doctypes and master types through plugin seams (`register_doctype`, `register_master`) and the chat discovers them automatically - tool schemas and the system prompt are built per request from the live registries, and fields are introspected from the live table, so there is nothing to teach per type. A CRM lead added by a plugin is searchable in chat from day one, with zero prompt or tool edits.
|
|
88
89
|
- **One shape for every document.** Invoices, sales orders, stock entries, payments - all share a single `Document` base class and the same three-state lifecycle (Draft → Submitted → Cancelled) with `on_submit`/`on_cancel` hooks. The LLM learns the pattern once and drives every doctype the same way. Leading open-source and commercial ERPs have per-model action verbs spread across 150+ core models; each one is a separate tool the model has to get right.
|
|
89
90
|
- **Metadata-driven UI, shared with the LLM.** A single React form component renders every doctype from `frontend/src/lib/doctypes.ts`. The schema the model reasons over and the schema the user sees are literally the same file. Adding a field is two lines - one in the Python class, one in the config - not a new module with hand-written views and inheritance overlays.
|
|
90
91
|
- **Two-model orchestration.** A planner model handles reasoning and tool-use. When it needs to generate code for a custom report, it delegates to a code-specialist sub-agent. This keeps each model doing what it's best at and keeps latency down on simple turns.
|
|
@@ -232,10 +233,15 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
|
|
|
232
233
|
OPENAI_API_KEY=sk-...
|
|
233
234
|
ANTHROPIC_API_KEY=sk-ant-... # optional, used for the code-specialist sub-agent
|
|
234
235
|
ANTHROPIC_CODE_MODEL=claude-opus-4-7 # optional, default shown
|
|
236
|
+
LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
|
|
237
|
+
LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
|
|
238
|
+
LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
|
|
235
239
|
```
|
|
236
240
|
|
|
237
241
|
Chat needs `OPENAI_API_KEY`. Custom-report code generation uses `ANTHROPIC_API_KEY` when set; otherwise it falls back and the chat will tell you it can't generate reports.
|
|
238
242
|
|
|
243
|
+
**Seeded admin.** By default the first person to register becomes the admin. Set `LAMBDA_ERP_ADMIN_EMAIL` + `LAMBDA_ERP_ADMIN_PASSWORD` to instead provision that admin automatically at startup — useful when the database is recreated on every deploy, so a redeployed instance can't be claimed by whoever visits first. It's create-if-missing and idempotent (an existing account with that email is promoted to an enabled admin; its password is left untouched), and the password is only ever read from the environment.
|
|
244
|
+
|
|
239
245
|
### Run the validation suite
|
|
240
246
|
|
|
241
247
|
```bash
|
|
@@ -326,12 +332,13 @@ for side-effects/integrations).
|
|
|
326
332
|
|
|
327
333
|
```python
|
|
328
334
|
# acme/plugin.py
|
|
329
|
-
from api.services import register_doctype, register_converter
|
|
335
|
+
from api.services import register_doctype, register_master, register_converter
|
|
330
336
|
from lambda_erp.hooks import register_hook
|
|
331
337
|
from .sales_invoice import AcmeSalesInvoice
|
|
332
338
|
|
|
333
339
|
def register():
|
|
334
340
|
register_doctype("Sales Invoice", AcmeSalesInvoice)
|
|
341
|
+
register_master("gadget", "Gadget", "gadget_name", name_prefix="GAD")
|
|
335
342
|
register_hook("Sales Invoice:after_submit", push_to_external_system)
|
|
336
343
|
# register_converter(source, target, fn) # only to replace conversion *logic*
|
|
337
344
|
```
|
|
@@ -340,6 +347,14 @@ Point the deployment at it with `LAMBDA_ERP_PLUGINS=acme` (comma-separated for
|
|
|
340
347
|
several). On startup the core imports each module and calls `register()`. Unset
|
|
341
348
|
= the core runs unchanged.
|
|
342
349
|
|
|
350
|
+
A `register_master` type (core 0.5.0+) is served at `/api/masters/{slug}` and
|
|
351
|
+
**discovered by the AI chat automatically**: `search_masters` (with fuzzy
|
|
352
|
+
fallback), `get_master_fields`, and `create/update/delete_master` accept it
|
|
353
|
+
from day one. The chat tool schemas and system prompt are built per request
|
|
354
|
+
from the live registries, and fields are introspected from the live table — no
|
|
355
|
+
per-type declarations. Details and caveats:
|
|
356
|
+
[`docs/core-extension-architecture.md`](docs/core-extension-architecture.md).
|
|
357
|
+
|
|
343
358
|
**Frontend overrides — the `@lambda-development/erp-core` library**
|
|
344
359
|
|
|
345
360
|
The frontend ships as a library. The customer app depends on it, registers its
|
|
@@ -630,6 +630,65 @@ def _setting_enabled(db, key: str) -> bool:
|
|
|
630
630
|
return str(value) == "1"
|
|
631
631
|
|
|
632
632
|
|
|
633
|
+
def ensure_seed_admin() -> dict | None:
|
|
634
|
+
"""Idempotently provision an admin account from the environment.
|
|
635
|
+
|
|
636
|
+
When both LAMBDA_ERP_ADMIN_EMAIL and LAMBDA_ERP_ADMIN_PASSWORD are set,
|
|
637
|
+
guarantee an enabled admin with that email exists *before* the first
|
|
638
|
+
visitor can register. This closes the "first user to sign up becomes
|
|
639
|
+
admin" bootstrap window (see `register`) on any deployment whose
|
|
640
|
+
database is recreated on every rollout — e.g. the ephemeral-SQLite demo
|
|
641
|
+
container, where otherwise whichever stranger loads the login page first
|
|
642
|
+
after a redeploy would silently own the instance.
|
|
643
|
+
|
|
644
|
+
Create-if-missing: an existing account with that email is promoted to an
|
|
645
|
+
enabled admin if needed, but its password is left untouched so a redeploy
|
|
646
|
+
never clobbers a credential changed inside a live instance. The password
|
|
647
|
+
is read only from the environment and is never logged.
|
|
648
|
+
|
|
649
|
+
No-op (returns None) when either variable is unset — this is a general
|
|
650
|
+
ops seam, not demo-only.
|
|
651
|
+
"""
|
|
652
|
+
email = os.environ.get("LAMBDA_ERP_ADMIN_EMAIL")
|
|
653
|
+
password = os.environ.get("LAMBDA_ERP_ADMIN_PASSWORD")
|
|
654
|
+
if not email or not password:
|
|
655
|
+
return None
|
|
656
|
+
|
|
657
|
+
email = email.strip().lower()
|
|
658
|
+
db = get_db()
|
|
659
|
+
|
|
660
|
+
existing = db.sql('SELECT name, role, enabled FROM "User" WHERE email = ?', [email])
|
|
661
|
+
if existing:
|
|
662
|
+
row = existing[0]
|
|
663
|
+
updates = {}
|
|
664
|
+
if row["role"] != "admin":
|
|
665
|
+
updates["role"] = "admin"
|
|
666
|
+
if not row["enabled"]:
|
|
667
|
+
updates["enabled"] = 1
|
|
668
|
+
if updates:
|
|
669
|
+
updates["modified"] = now()
|
|
670
|
+
db.set_value("User", row["name"], updates)
|
|
671
|
+
print(f"[seed-admin] promoted existing user to enabled admin: {email}", flush=True)
|
|
672
|
+
else:
|
|
673
|
+
print(f"[seed-admin] admin already present: {email}", flush=True)
|
|
674
|
+
return {"name": row["name"], "status": "exists"}
|
|
675
|
+
|
|
676
|
+
user_name = f"USR-{uuid.uuid4().hex[:8]}"
|
|
677
|
+
full_name = os.environ.get("LAMBDA_ERP_ADMIN_NAME", "Administrator")
|
|
678
|
+
db.insert("User", {
|
|
679
|
+
"name": user_name,
|
|
680
|
+
"email": email,
|
|
681
|
+
"full_name": full_name,
|
|
682
|
+
"hashed_password": hash_password(password),
|
|
683
|
+
"role": "admin",
|
|
684
|
+
"enabled": 1,
|
|
685
|
+
"creation": now(),
|
|
686
|
+
"modified": now(),
|
|
687
|
+
})
|
|
688
|
+
print(f"[seed-admin] created admin account: {email}", flush=True)
|
|
689
|
+
return {"name": user_name, "status": "created"}
|
|
690
|
+
|
|
691
|
+
|
|
633
692
|
@router.get("/settings")
|
|
634
693
|
def get_settings(user: dict = Depends(get_current_user)):
|
|
635
694
|
db = get_db()
|
|
@@ -58,6 +58,16 @@ SETTING_DEMO_REDSTONE_SINV_DATE = "demo_chat_redstone_sinv_date"
|
|
|
58
58
|
SETTING_DEMO_REDSTONE_DUE_DATE = "demo_chat_redstone_due_date"
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
def _seed_setting_on(db, key: str) -> None:
|
|
62
|
+
"""Turn a boolean Settings flag on, but only if it has never been set —
|
|
63
|
+
so an admin who later toggles it off in a persistent instance isn't
|
|
64
|
+
overridden on the next boot. On the ephemeral demo DB the key is always
|
|
65
|
+
absent at boot, so this reliably enables it there."""
|
|
66
|
+
if not db.sql('SELECT 1 FROM "Settings" WHERE key = ?', [key]):
|
|
67
|
+
db.sql('INSERT INTO "Settings" (key, value) VALUES (?, ?)', [key, "1"])
|
|
68
|
+
db.conn.commit()
|
|
69
|
+
|
|
70
|
+
|
|
61
71
|
def bootstrap_demo() -> None:
|
|
62
72
|
"""Idempotent demo bootstrap. Logs each phase to stdout so a
|
|
63
73
|
`docker compose up` user sees steady progress during the ~3-minute
|
|
@@ -131,7 +141,12 @@ def bootstrap_demo() -> None:
|
|
|
131
141
|
create_public_manager(user=None)
|
|
132
142
|
ensure_demo_chat_records(company)
|
|
133
143
|
else:
|
|
134
|
-
|
|
144
|
+
# No public-manager replay: let visitors self-register as viewers so
|
|
145
|
+
# the demo is still hands-on. Admin is provisioned separately from the
|
|
146
|
+
# environment (see api.auth.ensure_seed_admin), so open signup here only
|
|
147
|
+
# ever mints viewers — nobody can grab admin off the login page.
|
|
148
|
+
_seed_setting_on(db, "allow_public_signup")
|
|
149
|
+
print("[bootstrap] public demo disabled — open viewer signup enabled; admin comes from LAMBDA_ERP_ADMIN_* env", flush=True)
|
|
135
150
|
|
|
136
151
|
elapsed = time.monotonic() - t0
|
|
137
152
|
# Recommend 127.0.0.1 — on WSL2 + Docker Desktop, browsers can stall
|
|
@@ -463,7 +463,9 @@ TOOLS = [
|
|
|
463
463
|
"type": "object",
|
|
464
464
|
"properties": {
|
|
465
465
|
"doctype": {"type": "string", "enum": DOCUMENT_SLUGS, "description": "Document type slug"},
|
|
466
|
-
"filters": {"type": "object", "description": "Optional filters
|
|
466
|
+
"filters": {"type": "object", "description": "Optional equality filters on any column, e.g. {\"status\": \"Draft\", \"customer\": \"CUST-001\", \"lead_id\": \"LEAD-3316\"}", "default": {}},
|
|
467
|
+
"order_by": {"type": "string", "description": "Optional column to sort by (e.g. \"occurred_at\" for a timeline). Defaults to creation."},
|
|
468
|
+
"order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort direction (default desc)", "default": "desc"},
|
|
467
469
|
"limit": {"type": "integer", "description": "Max results (default 20, max 500)", "default": 20},
|
|
468
470
|
},
|
|
469
471
|
"required": ["doctype"],
|
|
@@ -1175,6 +1177,38 @@ def _extra_master_types():
|
|
|
1175
1177
|
return sorted(m for m in services.MASTER_TABLES if m not in MASTER_TYPES)
|
|
1176
1178
|
|
|
1177
1179
|
|
|
1180
|
+
def _chat_doctype_section() -> str:
|
|
1181
|
+
"""A '## Custom record types' block for doctypes registered via
|
|
1182
|
+
register_chat_doctype: each type's description, key fields, and its
|
|
1183
|
+
LINK_FIELDS relationships (read live from the Document class). Tells the
|
|
1184
|
+
model these are driven with the DOCUMENT tools and how they attach to a
|
|
1185
|
+
parent (e.g. a contact links to a lead via `lead_id`). Empty when none."""
|
|
1186
|
+
slugs = [s for s in services.CHAT_DOCTYPES if s in services.SLUG_TO_DOCTYPE]
|
|
1187
|
+
if not slugs:
|
|
1188
|
+
return ""
|
|
1189
|
+
lines = []
|
|
1190
|
+
for slug in slugs:
|
|
1191
|
+
meta = services.CHAT_DOCTYPES[slug]
|
|
1192
|
+
doctype = services.SLUG_TO_DOCTYPE[slug]
|
|
1193
|
+
cls = services.DOCUMENT_CLASSES.get(doctype)
|
|
1194
|
+
links = getattr(cls, "LINK_FIELDS", None) or {}
|
|
1195
|
+
line = f"- **{doctype}** (slug `{slug}`): {meta['description']}"
|
|
1196
|
+
if meta.get("fields"):
|
|
1197
|
+
line += f" Key fields: {', '.join('`' + f + '`' for f in meta['fields'])}."
|
|
1198
|
+
if links:
|
|
1199
|
+
link_str = ", ".join(f"`{f}` → the {t}'s name" for f, t in links.items())
|
|
1200
|
+
line += f" Links: {link_str}."
|
|
1201
|
+
lines.append(line)
|
|
1202
|
+
return (
|
|
1203
|
+
"\n\n## Custom record types (deployment-specific)\n"
|
|
1204
|
+
"Manage these with the DOCUMENT tools (`create_document`, `update_document`, "
|
|
1205
|
+
"`list_documents`, `get_document`) — NOT the master tools — so their `validate()` "
|
|
1206
|
+
"runs. To attach one to a parent, set its link field to the parent record's `name` "
|
|
1207
|
+
"(find the parent first, e.g. with `search_masters` or `list_documents`). Do not put "
|
|
1208
|
+
"child data on the parent's own fields.\n" + "\n".join(lines)
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
|
|
1178
1212
|
def build_tools():
|
|
1179
1213
|
"""TOOLS with the doctype/master enums widened from the live registries.
|
|
1180
1214
|
|
|
@@ -1217,6 +1251,8 @@ def _handle_list_documents(args):
|
|
|
1217
1251
|
args["doctype"],
|
|
1218
1252
|
filters=args.get("filters"),
|
|
1219
1253
|
limit=args.get("limit", 20),
|
|
1254
|
+
order_by=args.get("order_by"),
|
|
1255
|
+
order=args.get("order", "desc"),
|
|
1220
1256
|
)
|
|
1221
1257
|
for row in rows:
|
|
1222
1258
|
for key in child_keys:
|
|
@@ -1898,6 +1934,7 @@ def build_system_prompt(user_info: dict | None = None, channel: str = "web"):
|
|
|
1898
1934
|
f"display = `{services.MASTER_TABLES[m][1]}`"
|
|
1899
1935
|
for m in extra_masters
|
|
1900
1936
|
)
|
|
1937
|
+
chat_doctype_section = _chat_doctype_section()
|
|
1901
1938
|
|
|
1902
1939
|
# Channel-aware link guidance. On the "web" channel the reader is a browser
|
|
1903
1940
|
# inside the ERP, so web-relative links are clickable. On the "api" channel the
|
|
@@ -2006,7 +2043,7 @@ When a user asks you to do something they don't have permission for, explain wha
|
|
|
2006
2043
|
- **Buying:** purchase-order, purchase-invoice
|
|
2007
2044
|
- **Accounting:** payment-entry, journal-entry, budget, subscription, bank-transaction
|
|
2008
2045
|
- **Stock:** stock-entry, delivery-note, purchase-receipt
|
|
2009
|
-
- **Settings:** pricing-rule{extension_doctypes_line}
|
|
2046
|
+
- **Settings:** pricing-rule{extension_doctypes_line}{chat_doctype_section}
|
|
2010
2047
|
|
|
2011
2048
|
## Document Workflow & What Each Document Does
|
|
2012
2049
|
|
|
@@ -55,10 +55,22 @@ async def lifespan(app: FastAPI):
|
|
|
55
55
|
# Load customer extension plugins before anything creates documents.
|
|
56
56
|
load_plugins()
|
|
57
57
|
|
|
58
|
+
# Create plugin-registered tables and run their pending migrations. Must be
|
|
59
|
+
# after load_plugins() (registrations happen in register()) and before any
|
|
60
|
+
# document is created.
|
|
61
|
+
from api.services import apply_plugin_schema
|
|
62
|
+
apply_plugin_schema()
|
|
63
|
+
|
|
58
64
|
# Ensure the demo spend log table exists before any LLM call happens.
|
|
59
65
|
from api.demo_limits import init_schema as init_demo_spend_schema
|
|
60
66
|
init_demo_spend_schema()
|
|
61
67
|
|
|
68
|
+
# Seed a deterministic admin from the environment (if configured) before
|
|
69
|
+
# anyone can register. On deployments whose DB is recreated each rollout
|
|
70
|
+
# this stops the first visitor after a redeploy from grabbing admin.
|
|
71
|
+
from api.auth import ensure_seed_admin
|
|
72
|
+
ensure_seed_admin()
|
|
73
|
+
|
|
62
74
|
# When packaged as a demo container, land visitors straight in demo mode.
|
|
63
75
|
if os.environ.get("LAMBDA_ERP_AUTO_DEMO") == "1":
|
|
64
76
|
from api.bootstrap import bootstrap_demo
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Generic CRUD routes for all document types."""
|
|
2
2
|
|
|
3
|
-
from fastapi import APIRouter, Depends, Query
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
4
4
|
from fastapi.responses import Response
|
|
5
5
|
from api.services import (
|
|
6
6
|
create_document,
|
|
@@ -12,6 +12,7 @@ from api.services import (
|
|
|
12
12
|
convert_document,
|
|
13
13
|
list_documents,
|
|
14
14
|
count_documents,
|
|
15
|
+
document_columns,
|
|
15
16
|
)
|
|
16
17
|
from api.pdf import generate_pdf
|
|
17
18
|
from api.auth import require_role
|
|
@@ -21,16 +22,26 @@ router = APIRouter(prefix="/documents", tags=["documents"])
|
|
|
21
22
|
_viewer = Depends(require_role("viewer"))
|
|
22
23
|
_manager = Depends(require_role("manager"))
|
|
23
24
|
|
|
25
|
+
# Query params the list endpoint interprets itself — everything else is treated
|
|
26
|
+
# as an ad-hoc column=value filter (validated against the doctype's columns).
|
|
27
|
+
_LIST_RESERVED = {
|
|
28
|
+
"status", "party", "from_date", "to_date", "docstatus",
|
|
29
|
+
"include_discarded", "limit", "offset", "order_by", "order",
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
|
|
25
33
|
@router.get("/{doctype_slug}")
|
|
26
34
|
def list_docs(
|
|
27
35
|
doctype_slug: str,
|
|
36
|
+
request: Request,
|
|
28
37
|
status: str | None = None,
|
|
29
38
|
party: str | None = None,
|
|
30
39
|
from_date: str | None = None,
|
|
31
40
|
to_date: str | None = None,
|
|
32
41
|
docstatus: int | None = None,
|
|
33
42
|
include_discarded: bool = False,
|
|
43
|
+
order_by: str | None = None,
|
|
44
|
+
order: str = "desc",
|
|
34
45
|
limit: int = Query(default=50, le=500),
|
|
35
46
|
offset: int = Query(default=0, ge=0),
|
|
36
47
|
_user: dict = _viewer,
|
|
@@ -46,8 +57,25 @@ def list_docs(
|
|
|
46
57
|
filters["from_date"] = from_date
|
|
47
58
|
if to_date:
|
|
48
59
|
filters["to_date"] = to_date
|
|
60
|
+
|
|
61
|
+
# Ad-hoc equality filters: any remaining query param that names a real column
|
|
62
|
+
# of this doctype (e.g. /documents/activity?lead_id=LEAD-3316). Validate
|
|
63
|
+
# against the live columns so an unknown field is a 400, never interpolated.
|
|
64
|
+
columns = document_columns(doctype_slug)
|
|
65
|
+
for key, value in request.query_params.items():
|
|
66
|
+
if key in _LIST_RESERVED:
|
|
67
|
+
continue
|
|
68
|
+
if key not in columns:
|
|
69
|
+
raise HTTPException(status_code=400, detail=f"Unknown filter field: {key}")
|
|
70
|
+
filters[key] = value
|
|
71
|
+
|
|
72
|
+
if order_by is not None and order_by not in columns:
|
|
73
|
+
raise HTTPException(status_code=400, detail=f"Unknown order_by field: {order_by}")
|
|
74
|
+
if order.lower() not in ("asc", "desc"):
|
|
75
|
+
raise HTTPException(status_code=400, detail="order must be 'asc' or 'desc'")
|
|
76
|
+
|
|
49
77
|
rows = list_documents(doctype_slug, filters=filters, limit=limit, offset=offset,
|
|
50
|
-
include_discarded=include_discarded)
|
|
78
|
+
include_discarded=include_discarded, order_by=order_by, order=order)
|
|
51
79
|
total = count_documents(doctype_slug, filters=filters, include_discarded=include_discarded)
|
|
52
80
|
return {"rows": rows, "total": total, "limit": limit, "offset": offset}
|
|
53
81
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Bridge between FastAPI request data and lambda_erp Document classes."""
|
|
2
2
|
|
|
3
|
-
from lambda_erp.utils import _dict
|
|
3
|
+
from lambda_erp.utils import _dict, now
|
|
4
4
|
from lambda_erp.database import get_db
|
|
5
5
|
|
|
6
6
|
from lambda_erp.selling.quotation import (
|
|
@@ -114,6 +114,82 @@ def register_doctype(doctype: str, cls, slug: str | None = None) -> None:
|
|
|
114
114
|
DOCTYPE_TO_SLUG[doctype] = slug
|
|
115
115
|
|
|
116
116
|
|
|
117
|
+
# --- Plugin schema seam ---
|
|
118
|
+
#
|
|
119
|
+
# Core tables are created by database.setup(); plugin tables have no home there
|
|
120
|
+
# (setup() runs before plugins load). These two registries let a plugin declare
|
|
121
|
+
# its own tables and one-time migrations from register(); the core applies them
|
|
122
|
+
# via apply_plugin_schema() in the app lifespan, right after load_plugins() and
|
|
123
|
+
# before any document is created. See docs/core-extension-architecture.md.
|
|
124
|
+
|
|
125
|
+
_PLUGIN_TABLES: list[str] = []
|
|
126
|
+
_PLUGIN_MIGRATIONS: list = [] # list[tuple[str, Callable[[DB], None]]]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def register_table(ddl: str) -> None:
|
|
130
|
+
"""Register a CREATE TABLE (use IF NOT EXISTS) for a plugin table. The DDL is
|
|
131
|
+
the SQLite-flavoured subset the core's own DDL uses; apply_plugin_schema()
|
|
132
|
+
translates it per backend via db._ddl(). Idempotent — safe on every boot."""
|
|
133
|
+
_PLUGIN_TABLES.append(ddl)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def register_migration(migration_id: str, fn) -> None:
|
|
137
|
+
"""Register a one-shot migration run exactly once per database, recorded in
|
|
138
|
+
_PluginMigrations by `migration_id` (namespace it, e.g. "internal:0001_x").
|
|
139
|
+
`fn(db)` receives the DB; use db.ensure_column(...) for idempotent ALTERs
|
|
140
|
+
and plain db.sql(...) for backfills. Runs after registered tables exist, in
|
|
141
|
+
registration order. Must be idempotent and self-contained (single logical
|
|
142
|
+
unit — it commits on success)."""
|
|
143
|
+
_PLUGIN_MIGRATIONS.append((migration_id, fn))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def apply_plugin_schema() -> None:
|
|
147
|
+
"""Create registered plugin tables, then run pending plugin migrations once
|
|
148
|
+
each. Call from the app lifespan after load_plugins(). Table creation always
|
|
149
|
+
runs (IF NOT EXISTS); each migration runs only if its id isn't already in
|
|
150
|
+
_PluginMigrations. A failing migration is rolled back and left unrecorded so
|
|
151
|
+
it retries next boot; it does not abort startup (mirrors the core migrator)."""
|
|
152
|
+
db = get_db()
|
|
153
|
+
for ddl in _PLUGIN_TABLES:
|
|
154
|
+
db.conn.execute(db._ddl(ddl))
|
|
155
|
+
db.conn.commit()
|
|
156
|
+
|
|
157
|
+
if not _PLUGIN_MIGRATIONS:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
db.conn.execute(
|
|
161
|
+
'CREATE TABLE IF NOT EXISTS "_PluginMigrations" '
|
|
162
|
+
"(migration_id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"
|
|
163
|
+
)
|
|
164
|
+
db.conn.commit()
|
|
165
|
+
applied = {r["migration_id"] for r in db.sql('SELECT migration_id FROM "_PluginMigrations"')}
|
|
166
|
+
|
|
167
|
+
for migration_id, fn in _PLUGIN_MIGRATIONS:
|
|
168
|
+
if migration_id in applied:
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
fn(db)
|
|
172
|
+
db.sql(
|
|
173
|
+
'INSERT INTO "_PluginMigrations" (migration_id, applied_at) VALUES (?, ?)',
|
|
174
|
+
[migration_id, now()],
|
|
175
|
+
)
|
|
176
|
+
db.conn.commit()
|
|
177
|
+
print(f"[plugin-migration] applied {migration_id}", flush=True)
|
|
178
|
+
except Exception as e:
|
|
179
|
+
db.conn.rollback()
|
|
180
|
+
print(f"[plugin-migration] FAILED {migration_id}: {e!r} — will retry next boot", flush=True)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def document_columns(doctype_slug: str) -> set:
|
|
184
|
+
"""The real column names of a registered doctype's table, or empty set if the
|
|
185
|
+
slug is unknown. Used to validate ad-hoc list filters against actual columns
|
|
186
|
+
(see the documents router) so an unknown field is a 400, never SQL."""
|
|
187
|
+
doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
|
|
188
|
+
if not doctype:
|
|
189
|
+
return set()
|
|
190
|
+
return get_db()._get_table_columns(doctype)
|
|
191
|
+
|
|
192
|
+
|
|
117
193
|
def register_master(slug: str, table: str, name_field: str, *,
|
|
118
194
|
name_prefix: str | None = None,
|
|
119
195
|
identity_alias: str | None = None) -> None:
|
|
@@ -140,6 +216,23 @@ def register_master(slug: str, table: str, name_field: str, *,
|
|
|
140
216
|
MASTER_IDENTITY_ALIAS[slug] = identity_alias
|
|
141
217
|
|
|
142
218
|
|
|
219
|
+
# Chat guidance for registered doctypes: slug -> {"description", "fields"}. The
|
|
220
|
+
# document tools (create/update/list/get_document) already accept any registered
|
|
221
|
+
# doctype and run its validate(); this registry only teaches the AI chat WHAT a
|
|
222
|
+
# custom doctype is and HOW it links, so it uses the validated document path
|
|
223
|
+
# instead of, say, scribbling on a parent's fields. Relationships are read
|
|
224
|
+
# automatically from the Document class's LINK_FIELDS — not declared here.
|
|
225
|
+
CHAT_DOCTYPES: dict = {}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def register_chat_doctype(slug: str, *, description: str, fields: list | None = None) -> None:
|
|
229
|
+
"""Give the AI chat a description (and optional key-field hints) for a doctype
|
|
230
|
+
already registered via `register_doctype`, so build_system_prompt can tell the
|
|
231
|
+
model what it is and how it links (LINK_FIELDS are surfaced automatically).
|
|
232
|
+
No new tools — the document tools already cover every registered doctype."""
|
|
233
|
+
CHAT_DOCTYPES[slug] = {"description": description, "fields": fields or []}
|
|
234
|
+
|
|
235
|
+
|
|
143
236
|
def get_document_class(doctype_slug: str):
|
|
144
237
|
"""Get document class from URL slug."""
|
|
145
238
|
doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
|
|
@@ -272,8 +365,18 @@ def _exclude_discarded(db, doctype: str, db_filters: dict, include_discarded: bo
|
|
|
272
365
|
db_filters["discarded"] = 0
|
|
273
366
|
|
|
274
367
|
|
|
368
|
+
def _order_clause(order_by: str = None, order: str = "desc") -> str:
|
|
369
|
+
"""ORDER BY clause for list queries. Defaults to newest-created first.
|
|
370
|
+
`order_by`, when given, must already be a validated column name (the
|
|
371
|
+
documents router checks it against document_columns) — it is quoted here."""
|
|
372
|
+
if not order_by:
|
|
373
|
+
return "creation DESC"
|
|
374
|
+
direction = "ASC" if str(order).lower() == "asc" else "DESC"
|
|
375
|
+
return f'"{order_by}" {direction}'
|
|
376
|
+
|
|
377
|
+
|
|
275
378
|
def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, offset: int = 0,
|
|
276
|
-
include_discarded: bool = False) -> list:
|
|
379
|
+
include_discarded: bool = False, order_by: str = None, order: str = "desc") -> list:
|
|
277
380
|
doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
|
|
278
381
|
if not doctype:
|
|
279
382
|
raise ValueError(f"Unknown document type: {doctype_slug}")
|
|
@@ -315,15 +418,17 @@ def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, off
|
|
|
315
418
|
db, doctype, doctype_slug, db_filters, date_field, from_date, to_date, limit, offset
|
|
316
419
|
)
|
|
317
420
|
|
|
421
|
+
order_clause = _order_clause(order_by, order)
|
|
422
|
+
|
|
318
423
|
# get_all doesn't support offset, so use raw SQL when needed
|
|
319
424
|
if offset:
|
|
320
|
-
return _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset)
|
|
425
|
+
return _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset, order_clause)
|
|
321
426
|
|
|
322
427
|
rows = db.get_all(
|
|
323
428
|
doctype,
|
|
324
429
|
filters=db_filters if db_filters else None,
|
|
325
430
|
fields=["*"],
|
|
326
|
-
order_by=
|
|
431
|
+
order_by=order_clause,
|
|
327
432
|
limit=limit,
|
|
328
433
|
)
|
|
329
434
|
|
|
@@ -378,7 +483,7 @@ def count_documents(doctype_slug: str, filters: dict = None, include_discarded:
|
|
|
378
483
|
return int(rows[0]["c"]) if rows else 0
|
|
379
484
|
|
|
380
485
|
|
|
381
|
-
def _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset):
|
|
486
|
+
def _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset, order_clause="creation DESC"):
|
|
382
487
|
where_parts = []
|
|
383
488
|
params = []
|
|
384
489
|
for k, v in db_filters.items():
|
|
@@ -392,7 +497,7 @@ def _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset):
|
|
|
392
497
|
query = f'SELECT * FROM "{doctype}"'
|
|
393
498
|
if where_parts:
|
|
394
499
|
query += " WHERE " + " AND ".join(where_parts)
|
|
395
|
-
query += " ORDER BY
|
|
500
|
+
query += f" ORDER BY {order_clause}"
|
|
396
501
|
if limit:
|
|
397
502
|
query += f" LIMIT {int(limit)}"
|
|
398
503
|
if offset:
|
|
@@ -1391,6 +1391,14 @@ class Database:
|
|
|
1391
1391
|
self._col_cache.pop(table, None)
|
|
1392
1392
|
self._text_col_cache.pop(table, None)
|
|
1393
1393
|
|
|
1394
|
+
def ensure_column(self, table: str, column: str, definition: str) -> None:
|
|
1395
|
+
"""Idempotently add a column if it's missing. Public helper for plugin
|
|
1396
|
+
migrations registered via api.services.register_migration — the same
|
|
1397
|
+
backend-portable, PRAGMA/information_schema-guarded add the core's own
|
|
1398
|
+
migrations use. `definition` is the SQLite-flavoured column type (e.g.
|
|
1399
|
+
"TEXT", "INTEGER DEFAULT 0"); it is translated per dialect via _ddl."""
|
|
1400
|
+
self._add_column_if_missing(table, column, definition)
|
|
1401
|
+
|
|
1394
1402
|
def _migrate(self):
|
|
1395
1403
|
"""Run each pending migration in order, tracking applied versions."""
|
|
1396
1404
|
self.conn.execute(
|
|
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
|