lambda-erp 1.2.0__tar.gz → 1.2.2__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.0 → lambda_erp-1.2.2}/PKG-INFO +1 -1
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/chat.py +2 -1
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/mentions.py +98 -22
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/routers/documents.py +4 -2
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/routers/masters.py +4 -2
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/routers/mcp.py +6 -1
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/services.py +5 -3
- lambda_erp-1.2.2/api/write_context.py +27 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/pyproject.toml +1 -1
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/.gitignore +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/LICENSE +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/README.md +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/__init__.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/attachments.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/auth.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/bootstrap.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/chat_links.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/demo_limits.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/deps.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/erp_context.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/errors.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/list_values.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/main.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/mcp_oauth.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/oauth.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/pdf.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/pdf_contract.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/pdf_exports.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/pdf_profiles.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/providers.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/remarks_md.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/routers/__init__.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/routers/accounting.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/routers/actions.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/routers/admin.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/routers/analytics.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/routers/availability.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/routers/bank_statements.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/routers/chat_api.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/routers/proposals.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/routers/reports.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/routers/setup.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/templates/_record_base.html +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/templates/document.html +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/templates/proposal.html +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/templates/record.html +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/time_filters.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/api/tool_permissions.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/docs/agents/README.md +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/frontend/LICENSE +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/frontend/README.md +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/frontend/src/api/client.ts +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/controllers/item_price.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/controllers/item_prices.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/database.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/model.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/timestamps.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/utils.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/lambda_erp/workflow.py +0 -0
- {lambda_erp-1.2.0 → lambda_erp-1.2.2}/terraform/README.md +0 -0
|
@@ -3739,7 +3739,8 @@ async def run_thinking_loop(
|
|
|
3739
3739
|
success = False
|
|
3740
3740
|
else:
|
|
3741
3741
|
try:
|
|
3742
|
-
|
|
3742
|
+
from api.write_context import run_as_actor
|
|
3743
|
+
result = await asyncio.to_thread(run_as_actor, principal_ref[0], handler, fn_args)
|
|
3743
3744
|
success = not (isinstance(result, dict) and ("error" in result or result.get("ok") is False or result.get("failed", 0)))
|
|
3744
3745
|
except Exception as e:
|
|
3745
3746
|
result = {"error": str(e)}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
"""Personal mentions
|
|
1
|
+
"""Personal mentions resolved from visible note text, with atomic delivery.
|
|
2
2
|
|
|
3
|
-
No email/push delivery,
|
|
3
|
+
No email/push delivery, hidden recipients, or caller-selectable inbox owner.
|
|
4
4
|
Plugins own record access and note storage; this module owns notifications.
|
|
5
5
|
"""
|
|
6
6
|
import hashlib
|
|
7
7
|
import json
|
|
8
|
+
import re
|
|
8
9
|
import uuid
|
|
10
|
+
from contextvars import ContextVar
|
|
9
11
|
|
|
10
12
|
from fastapi import HTTPException
|
|
11
13
|
from pydantic import BaseModel, ConfigDict, Field
|
|
@@ -17,6 +19,50 @@ from lambda_erp.utils import now
|
|
|
17
19
|
|
|
18
20
|
SOURCES = {}
|
|
19
21
|
REAL_ROLES = {"viewer", "manager", "admin"}
|
|
22
|
+
_WRITING_MENTION_NOTE = ContextVar("writing_mention_note", default=False)
|
|
23
|
+
_MENTION_START = re.compile(r"(?<![\w@./+\-])@(?=\S)")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def writing_mention_note():
|
|
27
|
+
"""Dedicated note action will deliver after its source has saved the note."""
|
|
28
|
+
return _WRITING_MENTION_NOTE.get()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _mention_users():
|
|
32
|
+
return get_db().sql('SELECT name, full_name, email, role, enabled FROM "User" '
|
|
33
|
+
"WHERE enabled = 1 AND role IN ('viewer','manager','admin')")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _aliases(users):
|
|
37
|
+
aliases = {}
|
|
38
|
+
for user in users:
|
|
39
|
+
for value in (user.get("full_name"), user.get("email")):
|
|
40
|
+
if value and value.strip():
|
|
41
|
+
aliases.setdefault(value.strip().lower(), {})[user["name"]] = user
|
|
42
|
+
return aliases
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_note_mentions(body):
|
|
46
|
+
"""Resolve visible @Full Name / @email tokens, never hidden recipient IDs."""
|
|
47
|
+
aliases = _aliases(_mention_users())
|
|
48
|
+
patterns = [(re.compile(re.escape(alias) + r"(?![\w@\-])", re.IGNORECASE), users)
|
|
49
|
+
for alias, users in sorted(aliases.items(), key=lambda item: len(item[0]), reverse=True)]
|
|
50
|
+
recipients, end = {}, 0
|
|
51
|
+
for marker in _MENTION_START.finditer(body):
|
|
52
|
+
if marker.start() < end:
|
|
53
|
+
continue # The second @ inside an explicit @email address.
|
|
54
|
+
for pattern, users in patterns:
|
|
55
|
+
match = pattern.match(body, marker.end())
|
|
56
|
+
if match:
|
|
57
|
+
if len(users) != 1:
|
|
58
|
+
raise ValueError("An @mention matches multiple colleagues. Use the person's @email or select them from the suggestions. No note was saved.")
|
|
59
|
+
recipients.update(users)
|
|
60
|
+
end = match.end()
|
|
61
|
+
break
|
|
62
|
+
# Unknown handles are ordinary text, just like unmarked emails.
|
|
63
|
+
if len(recipients) > 20:
|
|
64
|
+
raise ValueError("A note can mention at most 20 colleagues. No note was saved.")
|
|
65
|
+
return recipients
|
|
20
66
|
|
|
21
67
|
|
|
22
68
|
def register_mention_source(doctype, *, label, can_access, create_note, resolve_note, resolve_notes=None):
|
|
@@ -45,7 +91,6 @@ class Note(Input):
|
|
|
45
91
|
doctype: str = Field(min_length=1, max_length=100)
|
|
46
92
|
record_id: str = Field(min_length=1, max_length=200)
|
|
47
93
|
body: str = Field(min_length=1, max_length=8000)
|
|
48
|
-
mention_user_ids: list[str] = Field(default_factory=list, max_length=20)
|
|
49
94
|
request_id: str = Field(min_length=8, max_length=100)
|
|
50
95
|
|
|
51
96
|
|
|
@@ -61,6 +106,8 @@ class State(Input):
|
|
|
61
106
|
|
|
62
107
|
|
|
63
108
|
def _actor(user):
|
|
109
|
+
if (user or {}).get('_shared_connection'):
|
|
110
|
+
raise PermissionError("Mentions require a personal ERP connection. The shared team key identifies its owner, not the current user.")
|
|
64
111
|
from api.auth import refresh_auth_principal
|
|
65
112
|
actor = refresh_auth_principal(user)
|
|
66
113
|
if not actor or actor.get("role") not in REAL_ROLES:
|
|
@@ -88,6 +135,11 @@ def search_mention_users(args, user):
|
|
|
88
135
|
"AND role IN ('viewer','manager','admin') "
|
|
89
136
|
"AND (LOWER(COALESCE(full_name,'')) LIKE ? ESCAPE '!' OR LOWER(email) LIKE ? ESCAPE '!') "
|
|
90
137
|
'ORDER BY full_name, name LIMIT 20', [f"%{q}%", f"%{q}%"])
|
|
138
|
+
aliases = _aliases(_mention_users())
|
|
139
|
+
for row in rows:
|
|
140
|
+
full_name = (row.get("full_name") or "").strip()
|
|
141
|
+
token = full_name if full_name and len(aliases.get(full_name.lower(), {})) == 1 else row["email"]
|
|
142
|
+
row["mention_text"] = "@" + token
|
|
91
143
|
return {"users": rows, "acting_user": _identity(actor),
|
|
92
144
|
"sources": [{"doctype": key, "label": value["label"]} for key, value in SOURCES.items()]}
|
|
93
145
|
|
|
@@ -100,8 +152,7 @@ def add_record_note(args, user):
|
|
|
100
152
|
raise ValueError("Note body must not be blank.")
|
|
101
153
|
source, db = _source(data.doctype), get_db()
|
|
102
154
|
actor_id = _identity(actor)["user_id"]
|
|
103
|
-
|
|
104
|
-
payload = {**data.model_dump(exclude={"request_id"}), "mention_user_ids": recipients}
|
|
155
|
+
payload = data.model_dump(exclude={"request_id"})
|
|
105
156
|
digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
|
|
106
157
|
request_pk = uuid.uuid4().hex
|
|
107
158
|
with db.atomic():
|
|
@@ -121,22 +172,13 @@ def add_record_note(args, user):
|
|
|
121
172
|
notified_count = db.sql('SELECT COUNT(*) AS n FROM "Mention Notification" WHERE doctype = ? AND note_id = ?',
|
|
122
173
|
[data.doctype, note_id])[0]["n"]
|
|
123
174
|
else:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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)
|
|
175
|
+
token = _WRITING_MENTION_NOTE.set(True)
|
|
176
|
+
try:
|
|
177
|
+
note_id = source["create_note"](data.record_id, data.body.strip(), actor)
|
|
178
|
+
finally:
|
|
179
|
+
_WRITING_MENTION_NOTE.reset(token)
|
|
133
180
|
db.sql('UPDATE "Mention Request" SET note_id = ? WHERE id = ?', [note_id, request_pk])
|
|
134
|
-
|
|
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)
|
|
181
|
+
notified_count = sync_note_mentions(data.doctype, data.record_id, note_id, data.body, actor)
|
|
140
182
|
note = source["resolve_note"](note_id, actor)
|
|
141
183
|
if not note:
|
|
142
184
|
raise HTTPException(404, "Note is no longer available")
|
|
@@ -144,6 +186,40 @@ def add_record_note(args, user):
|
|
|
144
186
|
"notified_count": notified_count, "replayed": repeated, "acting_user": _identity(actor)}
|
|
145
187
|
|
|
146
188
|
|
|
189
|
+
def sync_note_mentions(doctype, record_id, note_id, body, user):
|
|
190
|
+
"""Shared delivery for dedicated actions and generic document save hooks.
|
|
191
|
+
|
|
192
|
+
Call inside the note's save transaction. Existing recipients keep their
|
|
193
|
+
read/done state; new recipients receive one notification; removing a
|
|
194
|
+
visible mention removes its notification. No caller-selected recipient IDs.
|
|
195
|
+
"""
|
|
196
|
+
db, source = get_db(), _source(doctype)
|
|
197
|
+
recipients = resolve_note_mentions(body)
|
|
198
|
+
with db.atomic():
|
|
199
|
+
existing = db.sql('SELECT recipient_id FROM "Mention Notification" WHERE doctype = ? AND note_id = ?',
|
|
200
|
+
[doctype, note_id])
|
|
201
|
+
if not recipients and not existing:
|
|
202
|
+
return 0 # Ordinary text needs no personal notification identity.
|
|
203
|
+
actor = _actor(user)
|
|
204
|
+
if actor['role'] not in {'manager', 'admin'}:
|
|
205
|
+
raise PermissionError('Manager access is required to write mention notes.')
|
|
206
|
+
if not source['can_access'](record_id, actor):
|
|
207
|
+
raise HTTPException(404, 'Record is unavailable')
|
|
208
|
+
for recipient in recipients.values():
|
|
209
|
+
if not source['can_access'](record_id, recipient):
|
|
210
|
+
raise ValueError('A mentioned colleague cannot access this record. No note was saved.')
|
|
211
|
+
previous = {row['recipient_id'] for row in existing}
|
|
212
|
+
for recipient_id in previous - recipients.keys():
|
|
213
|
+
db.sql('DELETE FROM "Mention Notification" WHERE doctype = ? AND note_id = ? AND recipient_id = ?',
|
|
214
|
+
[doctype, note_id, recipient_id])
|
|
215
|
+
for recipient_id in recipients.keys() - previous:
|
|
216
|
+
db.insert('Mention Notification', {
|
|
217
|
+
'id': uuid.uuid4().hex, 'recipient_id': recipient_id, 'actor_id': _identity(actor)['user_id'],
|
|
218
|
+
'doctype': doctype, 'note_id': note_id, 'created_at': now(),
|
|
219
|
+
})
|
|
220
|
+
return len(recipients)
|
|
221
|
+
|
|
222
|
+
|
|
147
223
|
def _visible(row, actor, notes=None, authors=None):
|
|
148
224
|
source = SOURCES.get(row["doctype"])
|
|
149
225
|
note = (notes.get((row["doctype"], row["note_id"])) if notes is not None else
|
|
@@ -223,9 +299,9 @@ def initialize_mentions():
|
|
|
223
299
|
db.sql('CREATE INDEX IF NOT EXISTS mention_inbox ON "Mention Notification" (recipient_id, created_at)')
|
|
224
300
|
for name, handler, schema, role, description in (
|
|
225
301
|
("search_mention_users", search_mention_users, Search, "viewer",
|
|
226
|
-
"Find enabled ERP colleagues for
|
|
302
|
+
"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."),
|
|
227
303
|
("add_record_note", add_record_note, Note, "manager",
|
|
228
|
-
"Save an internal note
|
|
304
|
+
"Save an internal note and notify people visibly @mentioned in its body, including yourself. 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. Generic CRM note writes 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."),
|
|
229
305
|
("list_my_mentions", list_my_mentions, Inbox, "viewer",
|
|
230
306
|
"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
307
|
("set_mention_state", set_mention_state, State, "viewer",
|
|
@@ -264,12 +264,14 @@ def get_doc(doctype_slug: str, name: str, _user: dict = _viewer):
|
|
|
264
264
|
|
|
265
265
|
@router.post("/{doctype_slug}")
|
|
266
266
|
def create_doc(doctype_slug: str, data: dict, _user: dict = _manager):
|
|
267
|
-
|
|
267
|
+
from api.write_context import run_as_actor
|
|
268
|
+
return run_as_actor(_user, create_document, doctype_slug, data)
|
|
268
269
|
|
|
269
270
|
|
|
270
271
|
@router.put("/{doctype_slug}/{name}")
|
|
271
272
|
def update_doc(doctype_slug: str, name: str, data: dict, _user: dict = _manager):
|
|
272
|
-
|
|
273
|
+
from api.write_context import run_as_actor
|
|
274
|
+
return run_as_actor(_user, update_document, doctype_slug, name, data)
|
|
273
275
|
|
|
274
276
|
|
|
275
277
|
@router.post("/{doctype_slug}/{name}/submit")
|
|
@@ -720,12 +720,14 @@ def get_master(master_type: str, name: str, _user: dict = _viewer):
|
|
|
720
720
|
|
|
721
721
|
@router.post("/{master_type}")
|
|
722
722
|
def create_master(master_type: str, data: dict, _user: dict = _manager):
|
|
723
|
-
|
|
723
|
+
from api.write_context import run_as_actor
|
|
724
|
+
return run_as_actor(_user, create_master_record, master_type, data)
|
|
724
725
|
|
|
725
726
|
|
|
726
727
|
@router.put("/{master_type}/{name}")
|
|
727
728
|
def update_master(master_type: str, name: str, data: dict, _user: dict = _manager):
|
|
728
|
-
|
|
729
|
+
from api.write_context import run_as_actor
|
|
730
|
+
return run_as_actor(_user, update_master_record, master_type, name, data)
|
|
729
731
|
|
|
730
732
|
|
|
731
733
|
def delete_master_record(master_type: str, name: str) -> dict:
|
|
@@ -139,7 +139,8 @@ def _call(name: str, args: dict, user: dict):
|
|
|
139
139
|
handler = handlers.get(name)
|
|
140
140
|
if handler is None:
|
|
141
141
|
raise KeyError(name)
|
|
142
|
-
|
|
142
|
+
from api.write_context import run_as_actor
|
|
143
|
+
return run_as_actor(user, handler, args or {})
|
|
143
144
|
|
|
144
145
|
|
|
145
146
|
def _rpc_error(mid, code: int, message: str) -> dict:
|
|
@@ -206,6 +207,10 @@ async def mcp_endpoint(request: Request):
|
|
|
206
207
|
# MCP version. Otherwise a newer connector gets 400 instead of the 401
|
|
207
208
|
# challenge that tells it how to sign in.
|
|
208
209
|
user = _require_caller(request)
|
|
210
|
+
if request.headers.get('x-erp-connection-scope') == 'shared':
|
|
211
|
+
# A gateway can restrict its shared credential; this never grants
|
|
212
|
+
# permissions or changes who the credential belongs to.
|
|
213
|
+
user = {**user, '_shared_connection': True}
|
|
209
214
|
try:
|
|
210
215
|
body = await request.json()
|
|
211
216
|
except Exception:
|
|
@@ -358,9 +358,11 @@ def run_registered_action(name: str, args: dict | None, user_info: dict | None =
|
|
|
358
358
|
raise PermissionError(
|
|
359
359
|
f"Action '{name}' requires {meta['minimum_role']} access."
|
|
360
360
|
)
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
361
|
+
from api.write_context import write_actor
|
|
362
|
+
with write_actor(user_info):
|
|
363
|
+
if meta.get("pass_user"):
|
|
364
|
+
return meta["handler"](args or {}, user_info)
|
|
365
|
+
return meta["handler"](args or {})
|
|
364
366
|
|
|
365
367
|
|
|
366
368
|
def registered_action_handlers(user_info: dict | None = None) -> dict:
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Authenticated write identity propagated to document lifecycle hooks.
|
|
2
|
+
|
|
3
|
+
Set only at server dispatch boundaries, never from document/model arguments.
|
|
4
|
+
ContextVar isolates concurrent requests and is copied by asyncio.to_thread.
|
|
5
|
+
"""
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
|
|
9
|
+
_ACTOR = ContextVar("document_write_actor", default=None)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def current_write_actor():
|
|
13
|
+
return _ACTOR.get()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@contextmanager
|
|
17
|
+
def write_actor(user):
|
|
18
|
+
token = _ACTOR.set(user)
|
|
19
|
+
try:
|
|
20
|
+
yield
|
|
21
|
+
finally:
|
|
22
|
+
_ACTOR.reset(token)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_as_actor(user, handler, *args):
|
|
26
|
+
with write_actor(user):
|
|
27
|
+
return handler(*args)
|
|
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
|
|
File without changes
|