lambda-erp 1.2.3__tar.gz → 1.3.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-1.2.3 → lambda_erp-1.3.1}/PKG-INFO +1 -1
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/auth.py +21 -2
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/chat.py +114 -12
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/erp_context.py +5 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/main.py +34 -25
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/mentions.py +31 -5
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/oauth.py +2 -2
- lambda_erp-1.3.1/api/record_notes.py +233 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/bank_statements.py +6 -3
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/chat_api.py +22 -2
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/masters.py +13 -22
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/mcp.py +6 -3
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/services.py +6 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/bank_reconciliation.py +11 -47
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/budget.py +10 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/revaluation.py +24 -4
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/database.py +208 -1
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/model.py +11 -1
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/utils.py +7 -45
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/pyproject.toml +1 -1
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/.gitignore +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/LICENSE +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/README.md +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/__init__.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/attachments.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/bootstrap.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/chat_links.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/demo_limits.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/deps.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/errors.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/list_values.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/mcp_oauth.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/pdf.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/pdf_contract.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/pdf_exports.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/pdf_profiles.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/providers.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/remarks_md.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/__init__.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/accounting.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/actions.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/admin.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/analytics.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/availability.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/documents.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/proposals.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/reports.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/setup.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/templates/_record_base.html +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/templates/document.html +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/templates/proposal.html +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/templates/record.html +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/time_filters.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/tool_permissions.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/write_context.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/docs/agents/README.md +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/frontend/LICENSE +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/frontend/README.md +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/frontend/src/api/client.ts +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/controllers/item_price.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/controllers/item_prices.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/timestamps.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/workflow.py +0 -0
- {lambda_erp-1.2.3 → lambda_erp-1.3.1}/terraform/README.md +0 -0
|
@@ -35,12 +35,19 @@ def _resolve_jwt_secret() -> str:
|
|
|
35
35
|
3. Newly-generated random secret (only used if the filesystem is
|
|
36
36
|
read-only or inaccessible — cookies will be invalidated on
|
|
37
37
|
restart in that degenerate case).
|
|
38
|
+
|
|
39
|
+
A Postgres deployment must set JWT_SECRET_KEY: there is no DB file to
|
|
40
|
+
sit next to, so the file would land on the container's own disk and
|
|
41
|
+
every replica and restart would sign with a different key, logging
|
|
42
|
+
users out at random.
|
|
38
43
|
"""
|
|
39
44
|
env_secret = os.environ.get("JWT_SECRET_KEY")
|
|
40
45
|
if env_secret:
|
|
41
46
|
return env_secret
|
|
42
47
|
|
|
43
48
|
db_path = os.environ.get("LAMBDA_ERP_DB", "lambda_erp.db")
|
|
49
|
+
if db_path.startswith(("postgresql://", "postgres://")):
|
|
50
|
+
raise RuntimeError("JWT_SECRET_KEY must be set when LAMBDA_ERP_DB is a Postgres URL")
|
|
44
51
|
data_dir = os.path.dirname(os.path.abspath(db_path)) or "."
|
|
45
52
|
secret_file = os.path.join(data_dir, ".jwt_secret")
|
|
46
53
|
|
|
@@ -338,6 +345,19 @@ def validate_assignable_role(role: str) -> str:
|
|
|
338
345
|
return role
|
|
339
346
|
|
|
340
347
|
|
|
348
|
+
def claim_invite(db, token: str) -> None:
|
|
349
|
+
"""Mark an invite used, exactly once. The conditional UPDATE is the check:
|
|
350
|
+
two sign-ups racing on one invite both pass the earlier read, but only one
|
|
351
|
+
row update succeeds."""
|
|
352
|
+
with db._lock:
|
|
353
|
+
cursor = db.conn.execute(
|
|
354
|
+
'UPDATE "Invite" SET used = 1 WHERE token = ? AND COALESCE(used, 0) = 0', [token])
|
|
355
|
+
claimed = cursor.rowcount == 1
|
|
356
|
+
db.conn.commit()
|
|
357
|
+
if not claimed:
|
|
358
|
+
raise HTTPException(status_code=410, detail="Invite already used")
|
|
359
|
+
|
|
360
|
+
|
|
341
361
|
def require_role(minimum_role: str):
|
|
342
362
|
"""Returns a dependency that checks the user has at least the specified role."""
|
|
343
363
|
min_level = ROLE_HIERARCHY[minimum_role]
|
|
@@ -450,8 +470,7 @@ def register(data: RegisterRequest, request: Request, response: Response):
|
|
|
450
470
|
raise HTTPException(status_code=403, detail="This invite was issued for a different email")
|
|
451
471
|
|
|
452
472
|
role = validate_assignable_role(invite["role"])
|
|
453
|
-
db
|
|
454
|
-
db.conn.commit()
|
|
473
|
+
claim_invite(db, data.invite_token)
|
|
455
474
|
elif _setting_enabled(db, "allow_public_signup"):
|
|
456
475
|
# Open signup is enabled: anyone may self-register, but only as a viewer.
|
|
457
476
|
role = "viewer"
|
|
@@ -146,6 +146,74 @@ def can_access_session(session: dict | None, user: dict | None) -> bool:
|
|
|
146
146
|
return session.get("user_id") == user.get("name")
|
|
147
147
|
|
|
148
148
|
|
|
149
|
+
# A session runs one agent turn at a time. The claim lives on the session row,
|
|
150
|
+
# so it holds across browser tabs, the REST chat API and app processes; a
|
|
151
|
+
# per-connection check let two turns build on the same history and both write.
|
|
152
|
+
# The deadline frees the session if the process running the turn died.
|
|
153
|
+
TURN_LEASE_SECONDS = 30 * 60
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _utc_iso(offset_seconds: int = 0) -> str:
|
|
157
|
+
from datetime import datetime, timedelta, timezone
|
|
158
|
+
return (datetime.now(timezone.utc) + timedelta(seconds=offset_seconds)).isoformat(timespec="seconds")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def claim_session_turn(session_id: str) -> str | None:
|
|
162
|
+
"""Claim the session for one turn; return the claim token, or None while
|
|
163
|
+
another turn is still running on it."""
|
|
164
|
+
db = get_db()
|
|
165
|
+
token = uuid.uuid4().hex
|
|
166
|
+
with db._lock:
|
|
167
|
+
cursor = db.conn.execute(
|
|
168
|
+
'UPDATE "Chat Session" SET active_turn = ?, active_turn_until = ? '
|
|
169
|
+
"WHERE id = ? AND (active_turn IS NULL OR active_turn_until IS NULL "
|
|
170
|
+
"OR active_turn_until < ?)",
|
|
171
|
+
[token, _utc_iso(TURN_LEASE_SECONDS), session_id, _utc_iso()],
|
|
172
|
+
)
|
|
173
|
+
claimed = cursor.rowcount == 1
|
|
174
|
+
db.conn.commit()
|
|
175
|
+
return token if claimed else None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def release_session_turn(session_id: str, token: str | None) -> None:
|
|
179
|
+
if not token:
|
|
180
|
+
return
|
|
181
|
+
db = get_db()
|
|
182
|
+
with db._lock:
|
|
183
|
+
db.conn.execute(
|
|
184
|
+
'UPDATE "Chat Session" SET active_turn = NULL, active_turn_until = NULL '
|
|
185
|
+
"WHERE id = ? AND active_turn = ?",
|
|
186
|
+
[session_id, token],
|
|
187
|
+
)
|
|
188
|
+
db.conn.commit()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def session_turn_running(session_id: str) -> bool:
|
|
192
|
+
rows = get_db().sql(
|
|
193
|
+
'SELECT active_turn_until FROM "Chat Session" WHERE id = ? AND active_turn IS NOT NULL',
|
|
194
|
+
[session_id],
|
|
195
|
+
)
|
|
196
|
+
return bool(rows and rows[0]["active_turn_until"] and rows[0]["active_turn_until"] >= _utc_iso())
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# Turns whose socket closed keep running to completion (see chat_websocket);
|
|
200
|
+
# hold references so they aren't garbage-collected, and so shutdown can wait.
|
|
201
|
+
_DETACHED_TURNS: set[asyncio.Task] = set()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def detach_turn(task: asyncio.Task) -> None:
|
|
205
|
+
_DETACHED_TURNS.add(task)
|
|
206
|
+
task.add_done_callback(_DETACHED_TURNS.discard)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def drain_turns(timeout: float) -> None:
|
|
210
|
+
"""On shutdown, give turns that are still running time to finish and
|
|
211
|
+
persist their results instead of dying between a tool call and its reply."""
|
|
212
|
+
pending = [task for task in _DETACHED_TURNS if not task.done()]
|
|
213
|
+
if pending:
|
|
214
|
+
await asyncio.wait(pending, timeout=timeout)
|
|
215
|
+
|
|
216
|
+
|
|
149
217
|
def delete_session(session_id: str):
|
|
150
218
|
"""Delete a chat session, its messages, and its attachments."""
|
|
151
219
|
try:
|
|
@@ -440,6 +508,8 @@ def api_delete_session(session_id: str, user: dict = _Depends(get_current_user))
|
|
|
440
508
|
session = get_session(session_id)
|
|
441
509
|
if not can_access_session(session, user):
|
|
442
510
|
return {"detail": "Session not found"}
|
|
511
|
+
if session_turn_running(session_id):
|
|
512
|
+
raise HTTPException(status_code=409, detail="Wait for the current response to finish before deleting this chat.")
|
|
443
513
|
delete_session(session_id)
|
|
444
514
|
return {"ok": True}
|
|
445
515
|
|
|
@@ -2650,6 +2720,9 @@ The `remarks` (Notes / Terms) field on quotations, sales orders, and invoices re
|
|
|
2650
2720
|
- Separate blocks with a blank line; a single newline is just a line break.
|
|
2651
2721
|
Plain text (no special characters) still renders fine, so only reach for the markup when it improves a customer-facing note.
|
|
2652
2722
|
|
|
2723
|
+
### Internal notes (never printed)
|
|
2724
|
+
`remarks` is PRINTED and goes to the customer or supplier. Internal team remarks ("customer disputes line 2", "@Anna please check the price") belong in an internal note on the record instead: `add_record_note` (doctype = the record type slug, e.g. `sales-invoice` or `customer`; record_id = its name), read with `list_record_notes`, change with `update_record_note` / `delete_record_note`. Every document and master type supports them; they appear in the record's "Internal notes" card and never change the record. To notify a colleague, first `search_mention_users` and put their exact `mention_text` in the body. Never put internal remarks into `remarks`, and never copy internal notes into customer-facing text.
|
|
2725
|
+
|
|
2653
2726
|
### Purchase Cycle
|
|
2654
2727
|
Purchase Order → Purchase Receipt (receiving) / Purchase Invoice (billing) → Payment Entry
|
|
2655
2728
|
|
|
@@ -4142,7 +4215,9 @@ async def chat_websocket(
|
|
|
4142
4215
|
finally:
|
|
4143
4216
|
session_tasks.pop(target_session_id, None)
|
|
4144
4217
|
|
|
4145
|
-
async def process_session_message(target_session_id: str, user_content: str,
|
|
4218
|
+
async def process_session_message(target_session_id: str, user_content: str,
|
|
4219
|
+
attachment_ids: list | None = None,
|
|
4220
|
+
turn_token: str | None = None):
|
|
4146
4221
|
try:
|
|
4147
4222
|
async def on_event(event: dict):
|
|
4148
4223
|
await send_event(event["type"], session_id=target_session_id, **{
|
|
@@ -4169,7 +4244,9 @@ async def chat_websocket(
|
|
|
4169
4244
|
except Exception as e:
|
|
4170
4245
|
await send_error(str(e), session_id=target_session_id)
|
|
4171
4246
|
finally:
|
|
4172
|
-
|
|
4247
|
+
release_session_turn(target_session_id, turn_token)
|
|
4248
|
+
if session_tasks.get(target_session_id) is asyncio.current_task():
|
|
4249
|
+
session_tasks.pop(target_session_id, None)
|
|
4173
4250
|
|
|
4174
4251
|
try:
|
|
4175
4252
|
await send_event("sessions_list", sessions=list_sessions(user_id=ws_user_id, role=ws_user_role))
|
|
@@ -4240,7 +4317,7 @@ async def chat_websocket(
|
|
|
4240
4317
|
continue
|
|
4241
4318
|
|
|
4242
4319
|
active_task = session_tasks.get(session_id)
|
|
4243
|
-
if active_task and not active_task.done():
|
|
4320
|
+
if (active_task and not active_task.done()) or session_turn_running(session_id):
|
|
4244
4321
|
await send_error(
|
|
4245
4322
|
"Wait for the current response to finish before deleting this chat.",
|
|
4246
4323
|
request_id=request_id,
|
|
@@ -4263,7 +4340,7 @@ async def chat_websocket(
|
|
|
4263
4340
|
continue
|
|
4264
4341
|
|
|
4265
4342
|
active_task = session_tasks.get(session_id)
|
|
4266
|
-
if active_task and not active_task.done():
|
|
4343
|
+
if (active_task and not active_task.done()) or session_turn_running(session_id):
|
|
4267
4344
|
await send_error(
|
|
4268
4345
|
"Wait for the current response to finish before clearing this chat.",
|
|
4269
4346
|
request_id=request_id,
|
|
@@ -4347,7 +4424,8 @@ async def chat_websocket(
|
|
|
4347
4424
|
|
|
4348
4425
|
session = get_session(session_id)
|
|
4349
4426
|
await send_event("demo_started", request_id=request_id, session_id=session_id, title=session["title"] if session else "New Chat")
|
|
4350
|
-
session_tasks[session_id] = asyncio.create_task(
|
|
4427
|
+
session_tasks[session_id] = asyncio.create_task(
|
|
4428
|
+
replay_demo_session(session_id), name=f"demo:{session_id}")
|
|
4351
4429
|
continue
|
|
4352
4430
|
|
|
4353
4431
|
if msg_type == "transcribe":
|
|
@@ -4503,10 +4581,24 @@ async def chat_websocket(
|
|
|
4503
4581
|
if aid in session_attachments
|
|
4504
4582
|
]
|
|
4505
4583
|
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4584
|
+
# Claimed before the user message is stored, so a second tab (or
|
|
4585
|
+
# another app process) can't add a message to a running turn.
|
|
4586
|
+
turn_token = claim_session_turn(session_id)
|
|
4587
|
+
if not turn_token:
|
|
4588
|
+
await send_error(
|
|
4589
|
+
"Wait for the current response to finish before sending another message.",
|
|
4590
|
+
request_id=request_id,
|
|
4591
|
+
session_id=session_id,
|
|
4592
|
+
)
|
|
4593
|
+
continue
|
|
4594
|
+
try:
|
|
4595
|
+
save_chat_message(
|
|
4596
|
+
session_id, "user", user_content or "",
|
|
4597
|
+
metadata={"attachments": attached_meta} if attached_meta else None,
|
|
4598
|
+
)
|
|
4599
|
+
except BaseException:
|
|
4600
|
+
release_session_turn(session_id, turn_token)
|
|
4601
|
+
raise
|
|
4510
4602
|
await send_message_added(
|
|
4511
4603
|
session_id, "user", user_content or "",
|
|
4512
4604
|
attachments=attached_meta if attached_meta else None,
|
|
@@ -4520,11 +4612,21 @@ async def chat_websocket(
|
|
|
4520
4612
|
await send_event("thinking", session_id=session_id, iteration=1)
|
|
4521
4613
|
|
|
4522
4614
|
session_tasks[session_id] = asyncio.create_task(
|
|
4523
|
-
process_session_message(session_id, user_content, attachment_ids)
|
|
4615
|
+
process_session_message(session_id, user_content, attachment_ids, turn_token),
|
|
4616
|
+
name=f"turn:{session_id}",
|
|
4524
4617
|
)
|
|
4525
4618
|
|
|
4526
4619
|
except WebSocketDisconnect:
|
|
4527
|
-
|
|
4528
|
-
task.cancel()
|
|
4620
|
+
pass
|
|
4529
4621
|
except Exception as e:
|
|
4530
4622
|
await send_error(str(e))
|
|
4623
|
+
finally:
|
|
4624
|
+
# A demo replay is only presentation: stop it. An agent turn may be
|
|
4625
|
+
# between a tool's write and its result: cancelling it would commit the
|
|
4626
|
+
# write but lose the result and the reply, so the user retries and the
|
|
4627
|
+
# write happens twice. Let it finish; history shows it on reconnect.
|
|
4628
|
+
for task in list(session_tasks.values()):
|
|
4629
|
+
if task.get_name().startswith("demo:"):
|
|
4630
|
+
task.cancel()
|
|
4631
|
+
elif not task.done():
|
|
4632
|
+
detach_turn(task)
|
|
@@ -24,6 +24,11 @@ draft creation does not imply submission, stock movement or ledger posting. Chec
|
|
|
24
24
|
status and warnings. Correct validation errors using the field metadata and user information.
|
|
25
25
|
Use list/search filters and pagination; an empty or partial search is not proof of zero
|
|
26
26
|
stock. Use query_dataset/get_report for totals and stock, with the requested scope and dates.
|
|
27
|
+
Text for the customer or supplier goes in a document's printed `remarks` (Notes / Terms)
|
|
28
|
+
field. Internal team remarks ("customer disputes line 2", "@Anna please check") go in an
|
|
29
|
+
internal note instead (add_record_note / list_record_notes): never printed, visible to
|
|
30
|
+
signed-in colleagues only, and @mentions there notify colleagues. Never put internal
|
|
31
|
+
remarks into `remarks`, and never copy internal notes into a document the customer sees.
|
|
27
32
|
Generate a fresh PDF after edits. Only a successful artifact descriptor proves generation;
|
|
28
33
|
download that artifact, check its SHA-256 and deliver it through the calling application's
|
|
29
34
|
attachment mechanism. Do not invent download URLs or reuse a stale document attachment.
|
|
@@ -51,41 +51,50 @@ def load_plugins() -> None:
|
|
|
51
51
|
async def lifespan(app: FastAPI):
|
|
52
52
|
# Startup: initialize database
|
|
53
53
|
db_path = os.environ.get("LAMBDA_ERP_DB", "lambda_erp.db")
|
|
54
|
-
setup(db_path)
|
|
54
|
+
db = setup(db_path)
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
# Plugin schema, migrations and seeding run one process at a time, like
|
|
57
|
+
# the core migrations inside setup(): replicas booting together must not
|
|
58
|
+
# race each other through them.
|
|
59
|
+
with db.startup_lock():
|
|
60
|
+
from api.mentions import initialize_mentions
|
|
61
|
+
initialize_mentions()
|
|
62
|
+
from api.record_notes import initialize_record_notes
|
|
63
|
+
initialize_record_notes()
|
|
58
64
|
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
# Load customer extension plugins before anything creates documents.
|
|
66
|
+
load_plugins()
|
|
61
67
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
# Create plugin-registered tables and run their pending migrations. Must be
|
|
69
|
+
# after load_plugins() (registrations happen in register()) and before any
|
|
70
|
+
# document is created.
|
|
71
|
+
from api.services import apply_plugin_schema
|
|
72
|
+
apply_plugin_schema()
|
|
67
73
|
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
from api.pdf_profiles import validate_pdf_registry
|
|
75
|
+
validate_pdf_registry()
|
|
70
76
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
# Ensure the demo spend log table exists before any LLM call happens.
|
|
78
|
+
from api.demo_limits import init_schema as init_demo_spend_schema
|
|
79
|
+
init_demo_spend_schema()
|
|
74
80
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
81
|
+
# Seed a deterministic admin from the environment (if configured) before
|
|
82
|
+
# anyone can register. On deployments whose DB is recreated each rollout
|
|
83
|
+
# this stops the first visitor after a redeploy from grabbing admin.
|
|
84
|
+
from api.auth import ensure_seed_admin
|
|
85
|
+
ensure_seed_admin()
|
|
80
86
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
87
|
+
# When packaged as a demo container, land visitors straight in demo mode.
|
|
88
|
+
if os.environ.get("LAMBDA_ERP_AUTO_DEMO") == "1":
|
|
89
|
+
from api.bootstrap import bootstrap_demo
|
|
84
90
|
|
|
85
|
-
|
|
91
|
+
bootstrap_demo()
|
|
86
92
|
|
|
87
93
|
yield
|
|
88
|
-
# Shutdown:
|
|
94
|
+
# Shutdown: let chat turns whose sockets already closed finish and save
|
|
95
|
+
# their results. Stay inside the container's termination grace period.
|
|
96
|
+
from api.chat import drain_turns
|
|
97
|
+
await drain_turns(timeout=float(os.environ.get("LAMBDA_ERP_SHUTDOWN_DRAIN_SECONDS", "20")))
|
|
89
98
|
|
|
90
99
|
|
|
91
100
|
app = FastAPI(
|
|
@@ -18,6 +18,7 @@ from lambda_erp.database import get_db
|
|
|
18
18
|
from lambda_erp.utils import now
|
|
19
19
|
|
|
20
20
|
SOURCES = {}
|
|
21
|
+
_FALLBACKS = [] # (resolve(doctype) -> source | None, list_types() -> [doctype]) for unregistered types
|
|
21
22
|
REAL_ROLES = {"viewer", "manager", "admin"}
|
|
22
23
|
_WRITING_MENTION_NOTE = ContextVar("writing_mention_note", default=False)
|
|
23
24
|
_MENTION_START = re.compile(r"(?<![\w@./+\-])@(?=\S)")
|
|
@@ -79,6 +80,31 @@ def register_mention_source(doctype, *, label, can_access, create_note, resolve_
|
|
|
79
80
|
create_note=create_note, resolve_note=resolve_note, resolve_notes=resolve_notes)
|
|
80
81
|
|
|
81
82
|
|
|
83
|
+
def register_mention_source_fallback(resolve, list_types):
|
|
84
|
+
"""Resolve sources for record types without an explicit registration
|
|
85
|
+
(api.record_notes uses this for every document and master type)."""
|
|
86
|
+
if all(entry[0] is not resolve for entry in _FALLBACKS):
|
|
87
|
+
_FALLBACKS.append((resolve, list_types))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def find_source(doctype):
|
|
91
|
+
source = SOURCES.get(doctype)
|
|
92
|
+
for resolve, _ in _FALLBACKS:
|
|
93
|
+
if source:
|
|
94
|
+
break
|
|
95
|
+
source = resolve(doctype)
|
|
96
|
+
return source
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def supported_sources():
|
|
100
|
+
sources = {key: value["label"] for key, value in SOURCES.items()}
|
|
101
|
+
for resolve, list_types in _FALLBACKS:
|
|
102
|
+
for doctype in list_types():
|
|
103
|
+
if doctype not in sources and (source := resolve(doctype)):
|
|
104
|
+
sources[doctype] = source["label"]
|
|
105
|
+
return [{"doctype": key, "label": label} for key, label in sources.items()]
|
|
106
|
+
|
|
107
|
+
|
|
82
108
|
class Input(BaseModel):
|
|
83
109
|
model_config = ConfigDict(extra="forbid", strict=True)
|
|
84
110
|
|
|
@@ -121,7 +147,7 @@ def _identity(actor):
|
|
|
121
147
|
|
|
122
148
|
|
|
123
149
|
def _source(doctype):
|
|
124
|
-
source =
|
|
150
|
+
source = find_source(doctype)
|
|
125
151
|
if not source:
|
|
126
152
|
raise ValueError("This record type does not support mention notes.")
|
|
127
153
|
return source
|
|
@@ -141,7 +167,7 @@ def search_mention_users(args, user):
|
|
|
141
167
|
token = full_name if full_name and len(aliases.get(full_name.lower(), {})) == 1 else row["email"]
|
|
142
168
|
row["mention_text"] = "@" + token
|
|
143
169
|
return {"users": rows, "acting_user": _identity(actor),
|
|
144
|
-
"sources":
|
|
170
|
+
"sources": supported_sources()}
|
|
145
171
|
|
|
146
172
|
|
|
147
173
|
def add_record_note(args, user):
|
|
@@ -221,7 +247,7 @@ def sync_note_mentions(doctype, record_id, note_id, body, user):
|
|
|
221
247
|
|
|
222
248
|
|
|
223
249
|
def _visible(row, actor, notes=None, authors=None):
|
|
224
|
-
source =
|
|
250
|
+
source = find_source(row["doctype"])
|
|
225
251
|
note = (notes.get((row["doctype"], row["note_id"])) if notes is not None else
|
|
226
252
|
source["resolve_note"](row["note_id"], actor) if source else None)
|
|
227
253
|
if not note:
|
|
@@ -242,7 +268,7 @@ def list_my_mentions(args, user):
|
|
|
242
268
|
# source and author lookups so polling a populated inbox avoids N+1 queries.
|
|
243
269
|
notes, authors = {}, {}
|
|
244
270
|
for doctype in {row["doctype"] for row in rows}:
|
|
245
|
-
source =
|
|
271
|
+
source = find_source(doctype)
|
|
246
272
|
if not source:
|
|
247
273
|
continue
|
|
248
274
|
ids = list({row["note_id"] for row in rows if row["doctype"] == doctype})
|
|
@@ -301,7 +327,7 @@ def initialize_mentions():
|
|
|
301
327
|
("search_mention_users", search_mention_users, Search, "viewer",
|
|
302
328
|
"Find enabled ERP colleagues for visible @mentions by name/email. Returns mention_text: insert that exact text in the note body to tag the person. Also returns canonical user IDs, acting_user and supported record types. Do not guess a person when ambiguous."),
|
|
303
329
|
("add_record_note", add_record_note, Note, "manager",
|
|
304
|
-
"Save an internal note and notify people visibly @mentioned in its body, including yourself.
|
|
330
|
+
"Save an internal team note on a record and notify people visibly @mentioned in its body, including yourself. doctype is the record type slug: any document or master type (e.g. sales-invoice, purchase-order, customer, supplier, item) or a plugin type listed in search_mention_users sources; record_id is the record's name. Internal notes are never printed and do not change the record; customer-facing text belongs in the document's printed `remarks` (Notes / Terms) field instead. A note needs no mention. To tag someone, first use search_mention_users, then include each recipient's exact mention_text in body, e.g. 'salü vom johnny @Jon Torus'. Recipients are determined only from body; there is no recipient-ID argument. Unknown @handles and ordinary emails remain text. Ambiguous names require @email. CRM lead notes written through the generic document tools use the same mention processing. Use a new request_id (UUID) per intended note; reuse it unchanged on retries. No email is sent. Authorship is the authenticated ERP account."),
|
|
305
331
|
("list_my_mentions", list_my_mentions, Inbox, "viewer",
|
|
306
332
|
"List the authenticated ERP user's mentions, with record/note links. open means not done; unread is separate. Use this for 'my mentions', never guess or pass another user. Returns acting_user, unread_count and pagination."),
|
|
307
333
|
("set_mention_state", set_mention_state, State, "viewer",
|
|
@@ -47,6 +47,7 @@ from api.auth import (
|
|
|
47
47
|
require_interactive_user,
|
|
48
48
|
_is_https_request,
|
|
49
49
|
validate_assignable_role,
|
|
50
|
+
claim_invite,
|
|
50
51
|
_setting_enabled,
|
|
51
52
|
)
|
|
52
53
|
|
|
@@ -362,8 +363,7 @@ def _consume_invite(db, invite_token: str, email: str) -> str:
|
|
|
362
363
|
if invite["email"].lower() != email.lower():
|
|
363
364
|
raise HTTPException(status_code=403, detail="This invite was issued for a different email")
|
|
364
365
|
role = validate_assignable_role(invite["role"])
|
|
365
|
-
db
|
|
366
|
-
db.conn.commit()
|
|
366
|
+
claim_invite(db, invite_token)
|
|
367
367
|
return role
|
|
368
368
|
|
|
369
369
|
|