lambda-erp 1.2.1__tar.gz → 1.2.3__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.
Files changed (116) hide show
  1. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/PKG-INFO +1 -1
  2. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/chat.py +2 -1
  3. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/mentions.py +98 -22
  4. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/routers/documents.py +4 -2
  5. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/routers/masters.py +4 -2
  6. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/routers/mcp.py +6 -1
  7. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/services.py +5 -3
  8. lambda_erp-1.2.3/api/write_context.py +27 -0
  9. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/pyproject.toml +1 -1
  10. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/.gitignore +0 -0
  11. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/LICENSE +0 -0
  12. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/README.md +0 -0
  13. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/__init__.py +0 -0
  14. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/attachments.py +0 -0
  15. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/auth.py +0 -0
  16. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/bootstrap.py +0 -0
  17. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/chat_links.py +0 -0
  18. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/demo_limits.py +0 -0
  19. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/deps.py +0 -0
  20. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/erp_context.py +0 -0
  21. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/errors.py +0 -0
  22. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/list_values.py +0 -0
  23. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/main.py +0 -0
  24. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/mcp_oauth.py +0 -0
  25. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/oauth.py +0 -0
  26. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/pdf.py +0 -0
  27. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/pdf_contract.py +0 -0
  28. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/pdf_exports.py +0 -0
  29. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/pdf_profiles.py +0 -0
  30. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/providers.py +0 -0
  31. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/remarks_md.py +0 -0
  32. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/routers/__init__.py +0 -0
  33. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/routers/accounting.py +0 -0
  34. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/routers/actions.py +0 -0
  35. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/routers/admin.py +0 -0
  36. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/routers/analytics.py +0 -0
  37. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/routers/availability.py +0 -0
  38. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/routers/bank_reconciliation.py +0 -0
  39. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/routers/bank_statements.py +0 -0
  40. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/routers/chat_api.py +0 -0
  41. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/routers/proposals.py +0 -0
  42. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/routers/reports.py +0 -0
  43. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/routers/setup.py +0 -0
  44. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/templates/_proposal_totals.html +0 -0
  45. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/templates/_record_base.html +0 -0
  46. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/templates/document.html +0 -0
  47. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/templates/proposal.html +0 -0
  48. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/templates/record.html +0 -0
  49. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/time_filters.py +0 -0
  50. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/api/tool_permissions.py +0 -0
  51. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/docs/agents/README.md +0 -0
  52. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/frontend/LICENSE +0 -0
  53. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/frontend/README.md +0 -0
  54. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/frontend/src/api/client.ts +0 -0
  55. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/__init__.py +0 -0
  56. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/__init__.py +0 -0
  57. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/bank_account.py +0 -0
  58. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/bank_reconciliation.py +0 -0
  59. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/bank_statement_import.py +0 -0
  60. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/bank_transaction.py +0 -0
  61. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/budget.py +0 -0
  62. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/camt.py +0 -0
  63. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  64. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/general_ledger.py +0 -0
  65. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/journal_entry.py +0 -0
  66. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/payment_entry.py +0 -0
  67. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/pos_invoice.py +0 -0
  68. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/purchase_invoice.py +0 -0
  69. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/revaluation.py +0 -0
  70. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/sales_invoice.py +0 -0
  71. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/settlement.py +0 -0
  72. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/setup/__init__.py +0 -0
  73. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/setup/engine.py +0 -0
  74. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/setup/pack.py +0 -0
  75. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  76. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  77. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  78. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  79. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  80. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  81. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/setup/profiles.py +0 -0
  82. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/setup/spine.py +0 -0
  83. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/accounting/subscription.py +0 -0
  84. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/assets/__init__.py +0 -0
  85. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/assets/asset.py +0 -0
  86. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/assets/lifecycle.py +0 -0
  87. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/assets/reservation.py +0 -0
  88. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/buying/__init__.py +0 -0
  89. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/buying/purchase_order.py +0 -0
  90. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/controllers/__init__.py +0 -0
  91. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/controllers/currency.py +0 -0
  92. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/controllers/defaults.py +0 -0
  93. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/controllers/item_price.py +0 -0
  94. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/controllers/item_prices.py +0 -0
  95. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/controllers/pricing_rule.py +0 -0
  96. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  97. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/database.py +0 -0
  98. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/exceptions.py +0 -0
  99. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/hooks.py +0 -0
  100. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/master_integrity.py +0 -0
  101. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/model.py +0 -0
  102. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/selling/__init__.py +0 -0
  103. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/selling/proposal.py +0 -0
  104. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/selling/quotation.py +0 -0
  105. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/selling/sales_order.py +0 -0
  106. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/simulation.py +0 -0
  107. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/stock/__init__.py +0 -0
  108. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/stock/delivery_note.py +0 -0
  109. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/stock/purchase_receipt.py +0 -0
  110. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/stock/stock_entry.py +0 -0
  111. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/stock/stock_ledger.py +0 -0
  112. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/timestamps.py +0 -0
  113. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/utils.py +0 -0
  114. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/validation.py +0 -0
  115. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/lambda_erp/workflow.py +0 -0
  116. {lambda_erp-1.2.1 → lambda_erp-1.2.3}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 1.2.1
3
+ Version: 1.2.3
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -3739,7 +3739,8 @@ async def run_thinking_loop(
3739
3739
  success = False
3740
3740
  else:
3741
3741
  try:
3742
- result = await asyncio.to_thread(handler, fn_args)
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 with explicit recipients, atomic delivery and plugin sources.
1
+ """Personal mentions resolved from visible note text, with atomic delivery.
2
2
 
3
- No email/push delivery, inferred recipients, or caller-selectable inbox owner.
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
- recipients = sorted(set(data.mention_user_ids))
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
- 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)
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
- 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)
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 explicit @mentions by name/email. Returns canonical user IDs, acting_user and supported record types. Do not guess a person when ambiguous."),
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 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."),
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
- return create_document(doctype_slug, data)
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
- return update_document(doctype_slug, name, data)
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
- return create_master_record(master_type, data)
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
- return update_master_record(master_type, name, data)
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
- return handler(args or {})
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
- if meta.get("pass_user"):
362
- return meta["handler"](args or {}, user_info)
363
- return meta["handler"](args or {})
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)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "1.2.1"
3
+ version = "1.2.3"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes