lambda-erp 1.1.9__tar.gz → 1.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/PKG-INFO +1 -1
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/chat.py +12 -6
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/main.py +3 -0
- lambda_erp-1.2.0/api/mentions.py +235 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/pdf.py +11 -11
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/pdf_contract.py +3 -2
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/routers/mcp.py +1 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/services.py +7 -1
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/pyproject.toml +1 -1
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/.gitignore +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/LICENSE +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/README.md +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/__init__.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/attachments.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/auth.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/bootstrap.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/chat_links.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/demo_limits.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/deps.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/erp_context.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/errors.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/list_values.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/mcp_oauth.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/oauth.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/pdf_exports.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/pdf_profiles.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/providers.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/remarks_md.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/routers/__init__.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/routers/accounting.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/routers/actions.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/routers/admin.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/routers/analytics.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/routers/availability.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/routers/bank_statements.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/routers/chat_api.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/routers/documents.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/routers/masters.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/routers/proposals.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/routers/reports.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/routers/setup.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/templates/_record_base.html +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/templates/document.html +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/templates/proposal.html +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/templates/record.html +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/time_filters.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/api/tool_permissions.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/docs/agents/README.md +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/frontend/LICENSE +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/frontend/README.md +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/frontend/src/api/client.ts +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/controllers/item_price.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/controllers/item_prices.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/database.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/model.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/timestamps.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/utils.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/lambda_erp/workflow.py +0 -0
- {lambda_erp-1.1.9 → lambda_erp-1.2.0}/terraform/README.md +0 -0
|
@@ -473,6 +473,11 @@ DOCUMENT_SLUGS = [
|
|
|
473
473
|
|
|
474
474
|
MASTER_TYPES = ["customer", "supplier", "item", "warehouse", "account", "company", "cost-center"]
|
|
475
475
|
|
|
476
|
+
# Discovery for direct (MCP) clients, which don't get the chat system prompt.
|
|
477
|
+
# The native chat already carries WORKFLOW_GUIDANCE in its prompt; offering the
|
|
478
|
+
# tool there made the model re-fetch ~4k tokens of the same context most turns.
|
|
479
|
+
DIRECT_CLIENT_ONLY_TOOLS = frozenset({"get_erp_context"})
|
|
480
|
+
|
|
476
481
|
TOOLS = [
|
|
477
482
|
{"type": "function", "function": {
|
|
478
483
|
"name": "get_erp_context",
|
|
@@ -1400,7 +1405,7 @@ def _chat_doctype_section() -> str:
|
|
|
1400
1405
|
)
|
|
1401
1406
|
|
|
1402
1407
|
|
|
1403
|
-
def build_tools(user_info: dict | None = None):
|
|
1408
|
+
def build_tools(user_info: dict | None = None, *, native_chat: bool = False):
|
|
1404
1409
|
"""TOOLS widened with live plugin types and registered business actions.
|
|
1405
1410
|
|
|
1406
1411
|
Plugins register doctypes and masters at startup — after this module is
|
|
@@ -1410,19 +1415,20 @@ def build_tools(user_info: dict | None = None):
|
|
|
1410
1415
|
per request (cheap next to the LLM call), which also makes plugin load
|
|
1411
1416
|
order irrelevant. Only enums that exactly match the core lists are
|
|
1412
1417
|
widened, so any differently-constrained doctype/master param (e.g. a
|
|
1413
|
-
curated subset) is left alone.
|
|
1418
|
+
curated subset) is left alone. `native_chat` drops the direct-client-only
|
|
1419
|
+
discovery tools the chat prompt already covers.
|
|
1414
1420
|
"""
|
|
1415
1421
|
extra_docs = _extra_document_slugs()
|
|
1416
1422
|
extra_masters = _extra_master_types()
|
|
1417
1423
|
action_tools = services.registered_action_tools()
|
|
1418
|
-
hidden_tools: set[str] = set()
|
|
1424
|
+
hidden_tools: set[str] = set(DIRECT_CLIENT_ONLY_TOOLS) if native_chat else set()
|
|
1419
1425
|
if user_info is not None:
|
|
1420
1426
|
role = user_info.get("role")
|
|
1421
1427
|
action_tools = [
|
|
1422
1428
|
tool for tool in action_tools
|
|
1423
1429
|
if tool_allowed(tool["function"]["name"], role)
|
|
1424
1430
|
]
|
|
1425
|
-
hidden_tools
|
|
1431
|
+
hidden_tools |= {
|
|
1426
1432
|
tool["function"]["name"] for tool in TOOLS
|
|
1427
1433
|
if not tool_allowed(tool["function"]["name"], role)
|
|
1428
1434
|
}
|
|
@@ -3645,7 +3651,7 @@ async def run_thinking_loop(
|
|
|
3645
3651
|
# or the legacy API for compatible models. Returns Chat-shaped
|
|
3646
3652
|
# (message, usage) either way, so everything below is unchanged.
|
|
3647
3653
|
message, usage = await asyncio.to_thread(
|
|
3648
|
-
_orchestrator_turn, openai_client, messages, build_tools(user_info), max_completion,
|
|
3654
|
+
_orchestrator_turn, openai_client, messages, build_tools(user_info, native_chat=True), max_completion,
|
|
3649
3655
|
)
|
|
3650
3656
|
except Exception as e:
|
|
3651
3657
|
await on_event({"type": "error", "content": f"Error calling LLM: {e}"})
|
|
@@ -3817,7 +3823,7 @@ async def run_thinking_loop(
|
|
|
3817
3823
|
"stands alone: do NOT refer to 'the results above' or to earlier messages."
|
|
3818
3824
|
),
|
|
3819
3825
|
}],
|
|
3820
|
-
build_tools(user_info),
|
|
3826
|
+
build_tools(user_info, native_chat=True),
|
|
3821
3827
|
2048,
|
|
3822
3828
|
tool_choice="none",
|
|
3823
3829
|
)
|
|
@@ -53,6 +53,9 @@ async def lifespan(app: FastAPI):
|
|
|
53
53
|
db_path = os.environ.get("LAMBDA_ERP_DB", "lambda_erp.db")
|
|
54
54
|
setup(db_path)
|
|
55
55
|
|
|
56
|
+
from api.mentions import initialize_mentions
|
|
57
|
+
initialize_mentions()
|
|
58
|
+
|
|
56
59
|
# Load customer extension plugins before anything creates documents.
|
|
57
60
|
load_plugins()
|
|
58
61
|
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Personal mentions with explicit recipients, atomic delivery and plugin sources.
|
|
2
|
+
|
|
3
|
+
No email/push delivery, inferred recipients, or caller-selectable inbox owner.
|
|
4
|
+
Plugins own record access and note storage; this module owns notifications.
|
|
5
|
+
"""
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
from fastapi import HTTPException
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
from api import services
|
|
15
|
+
from lambda_erp.database import get_db
|
|
16
|
+
from lambda_erp.utils import now
|
|
17
|
+
|
|
18
|
+
SOURCES = {}
|
|
19
|
+
REAL_ROLES = {"viewer", "manager", "admin"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register_mention_source(doctype, *, label, can_access, create_note, resolve_note, resolve_notes=None):
|
|
23
|
+
"""Register a record type's internal notes.
|
|
24
|
+
|
|
25
|
+
can_access(record_id, principal) must check live record visibility.
|
|
26
|
+
create_note(record_id, body, principal) returns a saved note ID; it must
|
|
27
|
+
participate in db.atomic() without committing independently.
|
|
28
|
+
resolve_note(note_id, principal) returns {record_id,title,body,view_url},
|
|
29
|
+
or None for hidden/deleted notes. Check parent AND note visibility here.
|
|
30
|
+
Optional resolve_notes(ids, principal) batches the same checks for inboxes.
|
|
31
|
+
"""
|
|
32
|
+
SOURCES[doctype] = dict(label=label, can_access=can_access,
|
|
33
|
+
create_note=create_note, resolve_note=resolve_note, resolve_notes=resolve_notes)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Input(BaseModel):
|
|
37
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Search(Input):
|
|
41
|
+
query: str = Field(default="", max_length=100)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Note(Input):
|
|
45
|
+
doctype: str = Field(min_length=1, max_length=100)
|
|
46
|
+
record_id: str = Field(min_length=1, max_length=200)
|
|
47
|
+
body: str = Field(min_length=1, max_length=8000)
|
|
48
|
+
mention_user_ids: list[str] = Field(default_factory=list, max_length=20)
|
|
49
|
+
request_id: str = Field(min_length=8, max_length=100)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Inbox(Input):
|
|
53
|
+
view: Literal["open", "unread", "done", "all"] = "open"
|
|
54
|
+
limit: int = Field(default=25, ge=1, le=100)
|
|
55
|
+
offset: int = Field(default=0, ge=0)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class State(Input):
|
|
59
|
+
notification_id: str = Field(min_length=1, max_length=100)
|
|
60
|
+
state: Literal["read", "unread", "done", "reopen"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _actor(user):
|
|
64
|
+
from api.auth import refresh_auth_principal
|
|
65
|
+
actor = refresh_auth_principal(user)
|
|
66
|
+
if not actor or actor.get("role") not in REAL_ROLES:
|
|
67
|
+
raise PermissionError("Sign in with a personal ERP account to use mentions.")
|
|
68
|
+
return {**actor, "user_id": actor.get("user_id") or actor["name"]}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _identity(actor):
|
|
72
|
+
return {"user_id": actor.get("user_id") or actor["name"],
|
|
73
|
+
"full_name": actor.get("full_name"), "email": actor.get("email")}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _source(doctype):
|
|
77
|
+
source = SOURCES.get(doctype)
|
|
78
|
+
if not source:
|
|
79
|
+
raise ValueError("This record type does not support mention notes.")
|
|
80
|
+
return source
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def search_mention_users(args, user):
|
|
84
|
+
data, actor = Search.model_validate(args), _actor(user)
|
|
85
|
+
q = data.query.strip().lower().replace("!", "!!").replace("%", "!%").replace("_", "!_")
|
|
86
|
+
rows = get_db().sql(
|
|
87
|
+
'SELECT name, full_name, email FROM "User" WHERE enabled = 1 '
|
|
88
|
+
"AND role IN ('viewer','manager','admin') "
|
|
89
|
+
"AND (LOWER(COALESCE(full_name,'')) LIKE ? ESCAPE '!' OR LOWER(email) LIKE ? ESCAPE '!') "
|
|
90
|
+
'ORDER BY full_name, name LIMIT 20', [f"%{q}%", f"%{q}%"])
|
|
91
|
+
return {"users": rows, "acting_user": _identity(actor),
|
|
92
|
+
"sources": [{"doctype": key, "label": value["label"]} for key, value in SOURCES.items()]}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def add_record_note(args, user):
|
|
96
|
+
data, actor = Note.model_validate(args), _actor(user)
|
|
97
|
+
if actor["role"] not in {"manager", "admin"}:
|
|
98
|
+
raise PermissionError("Manager access is required to add notes.")
|
|
99
|
+
if not data.body.strip():
|
|
100
|
+
raise ValueError("Note body must not be blank.")
|
|
101
|
+
source, db = _source(data.doctype), get_db()
|
|
102
|
+
actor_id = _identity(actor)["user_id"]
|
|
103
|
+
recipients = sorted(set(data.mention_user_ids))
|
|
104
|
+
payload = {**data.model_dump(exclude={"request_id"}), "mention_user_ids": recipients}
|
|
105
|
+
digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
|
|
106
|
+
request_pk = uuid.uuid4().hex
|
|
107
|
+
with db.atomic():
|
|
108
|
+
if not source["can_access"](data.record_id, actor):
|
|
109
|
+
raise HTTPException(404, "Record is unavailable")
|
|
110
|
+
# The unique constraint also serializes concurrent retries on Postgres.
|
|
111
|
+
db.sql('INSERT INTO "Mention Request" (id, actor_id, request_id, payload_hash, doctype, record_id) '
|
|
112
|
+
'VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (actor_id, request_id) DO NOTHING',
|
|
113
|
+
[request_pk, actor_id, data.request_id, digest, data.doctype, data.record_id])
|
|
114
|
+
saved = db.sql('SELECT * FROM "Mention Request" WHERE actor_id = ? AND request_id = ?',
|
|
115
|
+
[actor_id, data.request_id])[0]
|
|
116
|
+
if saved["payload_hash"] != digest:
|
|
117
|
+
raise ValueError("request_id was already used for a different note; use a new request_id.")
|
|
118
|
+
repeated = saved["id"] != request_pk
|
|
119
|
+
if repeated:
|
|
120
|
+
note_id = saved["note_id"]
|
|
121
|
+
notified_count = db.sql('SELECT COUNT(*) AS n FROM "Mention Notification" WHERE doctype = ? AND note_id = ?',
|
|
122
|
+
[data.doctype, note_id])[0]["n"]
|
|
123
|
+
else:
|
|
124
|
+
targets = []
|
|
125
|
+
for recipient_id in recipients:
|
|
126
|
+
recipient = db.get_value("User", recipient_id, ["name", "full_name", "email", "role", "enabled"])
|
|
127
|
+
if (not recipient or not recipient.get("enabled") or recipient.get("role") not in REAL_ROLES
|
|
128
|
+
or not source["can_access"](data.record_id, dict(recipient))):
|
|
129
|
+
raise ValueError("A mentioned colleague cannot access this record. No note was saved.")
|
|
130
|
+
if recipient_id != actor_id:
|
|
131
|
+
targets.append(recipient_id)
|
|
132
|
+
note_id = source["create_note"](data.record_id, data.body.strip(), actor)
|
|
133
|
+
db.sql('UPDATE "Mention Request" SET note_id = ? WHERE id = ?', [note_id, request_pk])
|
|
134
|
+
for recipient_id in targets:
|
|
135
|
+
db.insert("Mention Notification", {
|
|
136
|
+
"id": uuid.uuid4().hex, "recipient_id": recipient_id, "actor_id": actor_id,
|
|
137
|
+
"doctype": data.doctype, "note_id": note_id, "created_at": now(),
|
|
138
|
+
})
|
|
139
|
+
notified_count = len(targets)
|
|
140
|
+
note = source["resolve_note"](note_id, actor)
|
|
141
|
+
if not note:
|
|
142
|
+
raise HTTPException(404, "Note is no longer available")
|
|
143
|
+
return {"note_id": note_id, "record_id": data.record_id, "view_url": note["view_url"],
|
|
144
|
+
"notified_count": notified_count, "replayed": repeated, "acting_user": _identity(actor)}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _visible(row, actor, notes=None, authors=None):
|
|
148
|
+
source = SOURCES.get(row["doctype"])
|
|
149
|
+
note = (notes.get((row["doctype"], row["note_id"])) if notes is not None else
|
|
150
|
+
source["resolve_note"](row["note_id"], actor) if source else None)
|
|
151
|
+
if not note:
|
|
152
|
+
return None
|
|
153
|
+
author = (authors.get(row["actor_id"]) if authors is not None else
|
|
154
|
+
get_db().get_value("User", row["actor_id"], ["full_name", "email"]))
|
|
155
|
+
return {"id": row["id"], "doctype": row["doctype"], "note_id": row["note_id"],
|
|
156
|
+
"record_id": note["record_id"], "title": note["title"], "excerpt": note["body"][:240],
|
|
157
|
+
"view_url": note["view_url"], "actor_name": (author or {}).get("full_name") or (author or {}).get("email") or row["actor_id"],
|
|
158
|
+
"created_at": row["created_at"], "read_at": row["read_at"], "done_at": row["done_at"]}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def list_my_mentions(args, user):
|
|
162
|
+
data, actor = Inbox.model_validate(args), _actor(user)
|
|
163
|
+
rows = get_db().sql('SELECT * FROM "Mention Notification" WHERE recipient_id = ? ORDER BY created_at DESC, id DESC',
|
|
164
|
+
[_identity(actor)["user_id"]])
|
|
165
|
+
# Re-check live access before exposing either excerpts or counts. Batch
|
|
166
|
+
# source and author lookups so polling a populated inbox avoids N+1 queries.
|
|
167
|
+
notes, authors = {}, {}
|
|
168
|
+
for doctype in {row["doctype"] for row in rows}:
|
|
169
|
+
source = SOURCES.get(doctype)
|
|
170
|
+
if not source:
|
|
171
|
+
continue
|
|
172
|
+
ids = list({row["note_id"] for row in rows if row["doctype"] == doctype})
|
|
173
|
+
for start in range(0, len(ids), 250):
|
|
174
|
+
chunk = ids[start:start + 250]
|
|
175
|
+
resolved = (source["resolve_notes"](chunk, actor) if source.get("resolve_notes") else
|
|
176
|
+
{note_id: source["resolve_note"](note_id, actor) for note_id in chunk})
|
|
177
|
+
notes.update({(doctype, key): value for key, value in resolved.items()})
|
|
178
|
+
author_ids = list({row["actor_id"] for row in rows})
|
|
179
|
+
for start in range(0, len(author_ids), 250):
|
|
180
|
+
chunk = author_ids[start:start + 250]
|
|
181
|
+
authors.update({row["name"]: row for row in get_db().sql(
|
|
182
|
+
'SELECT name, full_name, email FROM "User" WHERE name IN (' + ','.join('?' for _ in chunk) + ')', chunk)})
|
|
183
|
+
visible = [item for row in rows if (item := _visible(row, actor, notes, authors))]
|
|
184
|
+
unread = sum(not r["read_at"] and not r["done_at"] for r in visible)
|
|
185
|
+
filtered = [r for r in visible if data.view == "all" or
|
|
186
|
+
(data.view == "done" and r["done_at"]) or
|
|
187
|
+
(data.view == "open" and not r["done_at"]) or
|
|
188
|
+
(data.view == "unread" and not r["done_at"] and not r["read_at"])]
|
|
189
|
+
return {"rows": filtered[data.offset:data.offset + data.limit], "total": len(filtered),
|
|
190
|
+
"unread_count": unread, "has_more": data.offset + data.limit < len(filtered),
|
|
191
|
+
"acting_user": _identity(actor)}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def set_mention_state(args, user):
|
|
195
|
+
data, actor = State.model_validate(args), _actor(user)
|
|
196
|
+
db, owner = get_db(), _identity(actor)["user_id"]
|
|
197
|
+
with db.atomic():
|
|
198
|
+
rows = db.sql('SELECT * FROM "Mention Notification" WHERE id = ? AND recipient_id = ?',
|
|
199
|
+
[data.notification_id, owner])
|
|
200
|
+
if not rows or not _visible(rows[0], actor):
|
|
201
|
+
raise HTTPException(404, "Mention is unavailable")
|
|
202
|
+
assignments = {"read": 'read_at = COALESCE(read_at, ?)',
|
|
203
|
+
"unread": 'read_at = NULL, done_at = NULL',
|
|
204
|
+
"done": 'done_at = COALESCE(done_at, ?), read_at = COALESCE(read_at, ?)',
|
|
205
|
+
"reopen": 'done_at = NULL'}
|
|
206
|
+
stamps = [now()] * (2 if data.state == "done" else 1 if data.state == "read" else 0)
|
|
207
|
+
db.sql('UPDATE "Mention Notification" SET ' + assignments[data.state] +
|
|
208
|
+
' WHERE id = ? AND recipient_id = ?', [*stamps, data.notification_id, owner])
|
|
209
|
+
return {"success": True, "notification_id": data.notification_id, "state": data.state}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def initialize_mentions():
|
|
213
|
+
db = get_db()
|
|
214
|
+
with db.atomic():
|
|
215
|
+
db.sql('CREATE TABLE IF NOT EXISTS "Mention Request" ('
|
|
216
|
+
'id TEXT PRIMARY KEY, actor_id TEXT NOT NULL, request_id TEXT NOT NULL, '
|
|
217
|
+
'payload_hash TEXT NOT NULL, doctype TEXT NOT NULL, record_id TEXT NOT NULL, note_id TEXT, '
|
|
218
|
+
'UNIQUE(actor_id, request_id))')
|
|
219
|
+
db.sql('CREATE TABLE IF NOT EXISTS "Mention Notification" ('
|
|
220
|
+
'id TEXT PRIMARY KEY, recipient_id TEXT NOT NULL, actor_id TEXT NOT NULL, '
|
|
221
|
+
'doctype TEXT NOT NULL, note_id TEXT NOT NULL, created_at TEXT NOT NULL, read_at TEXT, done_at TEXT, '
|
|
222
|
+
'UNIQUE(recipient_id, doctype, note_id))')
|
|
223
|
+
db.sql('CREATE INDEX IF NOT EXISTS mention_inbox ON "Mention Notification" (recipient_id, created_at)')
|
|
224
|
+
for name, handler, schema, role, description in (
|
|
225
|
+
("search_mention_users", search_mention_users, Search, "viewer",
|
|
226
|
+
"Find enabled ERP colleagues for explicit @mentions by name/email. Returns canonical user IDs, acting_user and supported record types. Do not guess a person when ambiguous."),
|
|
227
|
+
("add_record_note", add_record_note, Note, "manager",
|
|
228
|
+
"Save an internal note on a supported record and notify explicitly selected colleagues. Resolve mention_user_ids using search_mention_users. Use a new request_id (UUID) per intended note; reuse it unchanged on retries. No email is sent. Authorship is the authenticated ERP account. Self-mentions do not notify."),
|
|
229
|
+
("list_my_mentions", list_my_mentions, Inbox, "viewer",
|
|
230
|
+
"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."),
|
|
231
|
+
("set_mention_state", set_mention_state, State, "viewer",
|
|
232
|
+
"Mark one of the authenticated user's mentions read, unread, done, or reopen it. Done only clears this personal notification; it does not complete or modify the business record."),
|
|
233
|
+
):
|
|
234
|
+
services.register_action(name, handler, description=description,
|
|
235
|
+
parameters=schema.model_json_schema(), minimum_role=role, pass_user=True)
|
|
@@ -193,17 +193,17 @@ def _render_commercial_pdf(doctype_slug, name, doc, contract):
|
|
|
193
193
|
if doc.get("status"):
|
|
194
194
|
meta_fields.append({"label": "Status", "value": doc["status"]})
|
|
195
195
|
|
|
196
|
-
# Items —
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
#
|
|
196
|
+
# Items — print the name written on each line. A line may carry its own
|
|
197
|
+
# wording under a generic article (e.g. one project service with a
|
|
198
|
+
# "Phase 1: …" line per step), so the Item master's name is only a
|
|
199
|
+
# fallback for a line that has none.
|
|
200
200
|
items = doc.get("items", [])
|
|
201
201
|
for item in items:
|
|
202
202
|
code = item.get("item_code")
|
|
203
|
-
if code:
|
|
204
|
-
|
|
205
|
-
if
|
|
206
|
-
item["item_name"] =
|
|
203
|
+
if code and not item.get("item_name"):
|
|
204
|
+
master_item_name = db.get_value("Item", code, "item_name")
|
|
205
|
+
if master_item_name:
|
|
206
|
+
item["item_name"] = master_item_name
|
|
207
207
|
|
|
208
208
|
taxes = doc.get("taxes", [])
|
|
209
209
|
|
|
@@ -340,11 +340,11 @@ def _render_proposal_pdf(name, proposal, contract):
|
|
|
340
340
|
contract['tokens'].extend(quote_contract['tokens'])
|
|
341
341
|
contract['warnings'].extend(quote_contract['warnings'])
|
|
342
342
|
items = quote.get("items", []) or []
|
|
343
|
-
#
|
|
344
|
-
# single-document path.
|
|
343
|
+
# The line's own name wins; the master's only fills an empty one,
|
|
344
|
+
# consistent with the single-document path.
|
|
345
345
|
for item in items:
|
|
346
346
|
code = item.get("item_code")
|
|
347
|
-
if code:
|
|
347
|
+
if code and not item.get("item_name"):
|
|
348
348
|
live = db.get_value("Item", code, "item_name")
|
|
349
349
|
if live:
|
|
350
350
|
item["item_name"] = live
|
|
@@ -254,8 +254,9 @@ def _item(row, tokens, index):
|
|
|
254
254
|
if not any(not missing(row.get(k)) for k in ('item_code', 'item_name', 'description')):
|
|
255
255
|
raise PDFError(f'PDF requires item identity: items[{index}]')
|
|
256
256
|
if row.get('item_code'):
|
|
257
|
-
|
|
258
|
-
row
|
|
257
|
+
master_name = resolve('Item', row['item_code']) # the article must still exist
|
|
258
|
+
if missing(row.get('item_name')):
|
|
259
|
+
row['item_name'] = master_name # only the isolated output snapshot is changed
|
|
259
260
|
tokens.append(str(row.get('item_name') or row.get('item_code') or row['description']))
|
|
260
261
|
|
|
261
262
|
|
|
@@ -300,13 +300,16 @@ _ROLE_LEVEL = {"viewer": 1, "manager": 2, "public_manager": 2, "admin": 3}
|
|
|
300
300
|
def register_action(name: str, handler, *, description: str,
|
|
301
301
|
parameters: dict | None = None,
|
|
302
302
|
minimum_role: str = "manager",
|
|
303
|
-
allow_public_manager: bool = False
|
|
303
|
+
allow_public_manager: bool = False,
|
|
304
|
+
pass_user: bool = False) -> None:
|
|
304
305
|
"""Register a deployment action across REST, chat, and MCP.
|
|
305
306
|
|
|
306
307
|
``handler`` receives one JSON-compatible argument dict and returns a
|
|
307
308
|
JSON-compatible result. ``parameters`` is a JSON Schema object for that
|
|
308
309
|
dict. Mutating actions should keep the default manager requirement; demo
|
|
309
310
|
``public_manager`` users are excluded unless explicitly opted in.
|
|
311
|
+
With ``pass_user=True``, the authenticated principal is a second argument;
|
|
312
|
+
never accept caller identity from model-supplied arguments instead.
|
|
310
313
|
"""
|
|
311
314
|
if not _ACTION_NAME_RE.fullmatch(name):
|
|
312
315
|
raise ValueError(f"Invalid action name: {name!r}")
|
|
@@ -318,6 +321,7 @@ def register_action(name: str, handler, *, description: str,
|
|
|
318
321
|
"parameters": parameters or {"type": "object", "properties": {}},
|
|
319
322
|
"minimum_role": minimum_role,
|
|
320
323
|
"allow_public_manager": allow_public_manager,
|
|
324
|
+
"pass_user": pass_user,
|
|
321
325
|
}
|
|
322
326
|
|
|
323
327
|
|
|
@@ -354,6 +358,8 @@ def run_registered_action(name: str, args: dict | None, user_info: dict | None =
|
|
|
354
358
|
raise PermissionError(
|
|
355
359
|
f"Action '{name}' requires {meta['minimum_role']} access."
|
|
356
360
|
)
|
|
361
|
+
if meta.get("pass_user"):
|
|
362
|
+
return meta["handler"](args or {}, user_info)
|
|
357
363
|
return meta["handler"](args or {})
|
|
358
364
|
|
|
359
365
|
|
|
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
|
|
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
|