lambda-erp 1.1.10__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.10 → lambda_erp-1.2.0}/PKG-INFO +1 -1
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/main.py +3 -0
- lambda_erp-1.2.0/api/mentions.py +235 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/routers/mcp.py +1 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/services.py +7 -1
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/pyproject.toml +1 -1
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/.gitignore +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/LICENSE +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/README.md +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/__init__.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/attachments.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/auth.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/bootstrap.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/chat.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/chat_links.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/demo_limits.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/deps.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/erp_context.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/errors.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/list_values.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/mcp_oauth.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/oauth.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/pdf.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/pdf_contract.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/pdf_exports.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/pdf_profiles.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/providers.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/remarks_md.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/routers/__init__.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/routers/accounting.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/routers/actions.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/routers/admin.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/routers/analytics.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/routers/availability.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/routers/bank_statements.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/routers/chat_api.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/routers/documents.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/routers/masters.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/routers/proposals.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/routers/reports.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/routers/setup.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/templates/_record_base.html +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/templates/document.html +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/templates/proposal.html +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/templates/record.html +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/time_filters.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/api/tool_permissions.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/docs/agents/README.md +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/frontend/LICENSE +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/frontend/README.md +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/frontend/src/api/client.ts +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/controllers/item_price.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/controllers/item_prices.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/database.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/model.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/timestamps.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/utils.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/lambda_erp/workflow.py +0 -0
- {lambda_erp-1.1.10 → lambda_erp-1.2.0}/terraform/README.md +0 -0
|
@@ -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)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|