lambda-erp 1.3.0__tar.gz → 1.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/PKG-INFO +1 -1
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/chat.py +3 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/erp_context.py +5 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/main.py +2 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/mentions.py +31 -5
- lambda_erp-1.3.1/api/record_notes.py +233 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/routers/masters.py +4 -1
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/routers/mcp.py +1 -1
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/database.py +20 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/pyproject.toml +1 -1
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/.gitignore +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/LICENSE +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/README.md +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/attachments.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/auth.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/bootstrap.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/chat_links.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/demo_limits.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/deps.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/errors.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/list_values.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/mcp_oauth.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/oauth.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/pdf.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/pdf_contract.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/pdf_exports.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/pdf_profiles.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/providers.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/remarks_md.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/routers/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/routers/accounting.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/routers/actions.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/routers/admin.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/routers/analytics.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/routers/availability.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/routers/bank_statements.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/routers/chat_api.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/routers/documents.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/routers/proposals.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/routers/reports.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/routers/setup.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/services.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/templates/_record_base.html +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/templates/document.html +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/templates/proposal.html +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/templates/record.html +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/time_filters.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/tool_permissions.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/api/write_context.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/docs/agents/README.md +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/frontend/LICENSE +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/frontend/README.md +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/frontend/src/api/client.ts +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/controllers/item_price.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/controllers/item_prices.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/model.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/timestamps.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/utils.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/lambda_erp/workflow.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.1}/terraform/README.md +0 -0
|
@@ -2720,6 +2720,9 @@ The `remarks` (Notes / Terms) field on quotations, sales orders, and invoices re
|
|
|
2720
2720
|
- Separate blocks with a blank line; a single newline is just a line break.
|
|
2721
2721
|
Plain text (no special characters) still renders fine, so only reach for the markup when it improves a customer-facing note.
|
|
2722
2722
|
|
|
2723
|
+
### Internal notes (never printed)
|
|
2724
|
+
`remarks` is PRINTED and goes to the customer or supplier. Internal team remarks ("customer disputes line 2", "@Anna please check the price") belong in an internal note on the record instead: `add_record_note` (doctype = the record type slug, e.g. `sales-invoice` or `customer`; record_id = its name), read with `list_record_notes`, change with `update_record_note` / `delete_record_note`. Every document and master type supports them; they appear in the record's "Internal notes" card and never change the record. To notify a colleague, first `search_mention_users` and put their exact `mention_text` in the body. Never put internal remarks into `remarks`, and never copy internal notes into customer-facing text.
|
|
2725
|
+
|
|
2723
2726
|
### Purchase Cycle
|
|
2724
2727
|
Purchase Order → Purchase Receipt (receiving) / Purchase Invoice (billing) → Payment Entry
|
|
2725
2728
|
|
|
@@ -24,6 +24,11 @@ draft creation does not imply submission, stock movement or ledger posting. Chec
|
|
|
24
24
|
status and warnings. Correct validation errors using the field metadata and user information.
|
|
25
25
|
Use list/search filters and pagination; an empty or partial search is not proof of zero
|
|
26
26
|
stock. Use query_dataset/get_report for totals and stock, with the requested scope and dates.
|
|
27
|
+
Text for the customer or supplier goes in a document's printed `remarks` (Notes / Terms)
|
|
28
|
+
field. Internal team remarks ("customer disputes line 2", "@Anna please check") go in an
|
|
29
|
+
internal note instead (add_record_note / list_record_notes): never printed, visible to
|
|
30
|
+
signed-in colleagues only, and @mentions there notify colleagues. Never put internal
|
|
31
|
+
remarks into `remarks`, and never copy internal notes into a document the customer sees.
|
|
27
32
|
Generate a fresh PDF after edits. Only a successful artifact descriptor proves generation;
|
|
28
33
|
download that artifact, check its SHA-256 and deliver it through the calling application's
|
|
29
34
|
attachment mechanism. Do not invent download URLs or reuse a stale document attachment.
|
|
@@ -59,6 +59,8 @@ async def lifespan(app: FastAPI):
|
|
|
59
59
|
with db.startup_lock():
|
|
60
60
|
from api.mentions import initialize_mentions
|
|
61
61
|
initialize_mentions()
|
|
62
|
+
from api.record_notes import initialize_record_notes
|
|
63
|
+
initialize_record_notes()
|
|
62
64
|
|
|
63
65
|
# Load customer extension plugins before anything creates documents.
|
|
64
66
|
load_plugins()
|
|
@@ -18,6 +18,7 @@ from lambda_erp.database import get_db
|
|
|
18
18
|
from lambda_erp.utils import now
|
|
19
19
|
|
|
20
20
|
SOURCES = {}
|
|
21
|
+
_FALLBACKS = [] # (resolve(doctype) -> source | None, list_types() -> [doctype]) for unregistered types
|
|
21
22
|
REAL_ROLES = {"viewer", "manager", "admin"}
|
|
22
23
|
_WRITING_MENTION_NOTE = ContextVar("writing_mention_note", default=False)
|
|
23
24
|
_MENTION_START = re.compile(r"(?<![\w@./+\-])@(?=\S)")
|
|
@@ -79,6 +80,31 @@ def register_mention_source(doctype, *, label, can_access, create_note, resolve_
|
|
|
79
80
|
create_note=create_note, resolve_note=resolve_note, resolve_notes=resolve_notes)
|
|
80
81
|
|
|
81
82
|
|
|
83
|
+
def register_mention_source_fallback(resolve, list_types):
|
|
84
|
+
"""Resolve sources for record types without an explicit registration
|
|
85
|
+
(api.record_notes uses this for every document and master type)."""
|
|
86
|
+
if all(entry[0] is not resolve for entry in _FALLBACKS):
|
|
87
|
+
_FALLBACKS.append((resolve, list_types))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def find_source(doctype):
|
|
91
|
+
source = SOURCES.get(doctype)
|
|
92
|
+
for resolve, _ in _FALLBACKS:
|
|
93
|
+
if source:
|
|
94
|
+
break
|
|
95
|
+
source = resolve(doctype)
|
|
96
|
+
return source
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def supported_sources():
|
|
100
|
+
sources = {key: value["label"] for key, value in SOURCES.items()}
|
|
101
|
+
for resolve, list_types in _FALLBACKS:
|
|
102
|
+
for doctype in list_types():
|
|
103
|
+
if doctype not in sources and (source := resolve(doctype)):
|
|
104
|
+
sources[doctype] = source["label"]
|
|
105
|
+
return [{"doctype": key, "label": label} for key, label in sources.items()]
|
|
106
|
+
|
|
107
|
+
|
|
82
108
|
class Input(BaseModel):
|
|
83
109
|
model_config = ConfigDict(extra="forbid", strict=True)
|
|
84
110
|
|
|
@@ -121,7 +147,7 @@ def _identity(actor):
|
|
|
121
147
|
|
|
122
148
|
|
|
123
149
|
def _source(doctype):
|
|
124
|
-
source =
|
|
150
|
+
source = find_source(doctype)
|
|
125
151
|
if not source:
|
|
126
152
|
raise ValueError("This record type does not support mention notes.")
|
|
127
153
|
return source
|
|
@@ -141,7 +167,7 @@ def search_mention_users(args, user):
|
|
|
141
167
|
token = full_name if full_name and len(aliases.get(full_name.lower(), {})) == 1 else row["email"]
|
|
142
168
|
row["mention_text"] = "@" + token
|
|
143
169
|
return {"users": rows, "acting_user": _identity(actor),
|
|
144
|
-
"sources":
|
|
170
|
+
"sources": supported_sources()}
|
|
145
171
|
|
|
146
172
|
|
|
147
173
|
def add_record_note(args, user):
|
|
@@ -221,7 +247,7 @@ def sync_note_mentions(doctype, record_id, note_id, body, user):
|
|
|
221
247
|
|
|
222
248
|
|
|
223
249
|
def _visible(row, actor, notes=None, authors=None):
|
|
224
|
-
source =
|
|
250
|
+
source = find_source(row["doctype"])
|
|
225
251
|
note = (notes.get((row["doctype"], row["note_id"])) if notes is not None else
|
|
226
252
|
source["resolve_note"](row["note_id"], actor) if source else None)
|
|
227
253
|
if not note:
|
|
@@ -242,7 +268,7 @@ def list_my_mentions(args, user):
|
|
|
242
268
|
# source and author lookups so polling a populated inbox avoids N+1 queries.
|
|
243
269
|
notes, authors = {}, {}
|
|
244
270
|
for doctype in {row["doctype"] for row in rows}:
|
|
245
|
-
source =
|
|
271
|
+
source = find_source(doctype)
|
|
246
272
|
if not source:
|
|
247
273
|
continue
|
|
248
274
|
ids = list({row["note_id"] for row in rows if row["doctype"] == doctype})
|
|
@@ -301,7 +327,7 @@ def initialize_mentions():
|
|
|
301
327
|
("search_mention_users", search_mention_users, Search, "viewer",
|
|
302
328
|
"Find enabled ERP colleagues for visible @mentions by name/email. Returns mention_text: insert that exact text in the note body to tag the person. Also returns canonical user IDs, acting_user and supported record types. Do not guess a person when ambiguous."),
|
|
303
329
|
("add_record_note", add_record_note, Note, "manager",
|
|
304
|
-
"Save an internal note and notify people visibly @mentioned in its body, including yourself.
|
|
330
|
+
"Save an internal team note on a record and notify people visibly @mentioned in its body, including yourself. doctype is the record type slug: any document or master type (e.g. sales-invoice, purchase-order, customer, supplier, item) or a plugin type listed in search_mention_users sources; record_id is the record's name. Internal notes are never printed and do not change the record; customer-facing text belongs in the document's printed `remarks` (Notes / Terms) field instead. A note needs no mention. To tag someone, first use search_mention_users, then include each recipient's exact mention_text in body, e.g. 'salü vom johnny @Jon Torus'. Recipients are determined only from body; there is no recipient-ID argument. Unknown @handles and ordinary emails remain text. Ambiguous names require @email. CRM lead notes written through the generic document tools use the same mention processing. Use a new request_id (UUID) per intended note; reuse it unchanged on retries. No email is sent. Authorship is the authenticated ERP account."),
|
|
305
331
|
("list_my_mentions", list_my_mentions, Inbox, "viewer",
|
|
306
332
|
"List the authenticated ERP user's mentions, with record/note links. open means not done; unread is separate. Use this for 'my mentions', never guess or pass another user. Returns acting_user, unread_count and pagination."),
|
|
307
333
|
("set_mention_state", set_mention_state, State, "viewer",
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Internal notes on any ERP document or master record.
|
|
2
|
+
|
|
3
|
+
Notes are private to the team: they never print and never change the record
|
|
4
|
+
itself (no `modified` bump, so open forms are not marked stale). They plug into
|
|
5
|
+
the personal mention inbox as a fallback source, so every document and master
|
|
6
|
+
type supports @mentions without registering anything. Record types with their
|
|
7
|
+
own notes (a plugin's explicit mention source, e.g. CRM leads) keep theirs.
|
|
8
|
+
"""
|
|
9
|
+
import uuid
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from fastapi import HTTPException
|
|
13
|
+
from pydantic import Field
|
|
14
|
+
|
|
15
|
+
from api import mentions, services
|
|
16
|
+
from lambda_erp.database import get_db
|
|
17
|
+
from lambda_erp.utils import now
|
|
18
|
+
|
|
19
|
+
TABLE = "Record Note"
|
|
20
|
+
_DISABLED: set[str] = set()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def disable_record_notes(record_type):
|
|
24
|
+
"""Opt a record type out, e.g. one whose rows are themselves notes."""
|
|
25
|
+
_DISABLED.add(record_type)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _target(record_type):
|
|
29
|
+
"""(table, kind) for a record type that takes generic notes, else None."""
|
|
30
|
+
if record_type in _DISABLED or record_type in mentions.SOURCES:
|
|
31
|
+
return None
|
|
32
|
+
doctype = services.SLUG_TO_DOCTYPE.get(record_type)
|
|
33
|
+
if doctype:
|
|
34
|
+
return doctype, "document"
|
|
35
|
+
master = services.MASTER_TABLES.get(record_type)
|
|
36
|
+
if master:
|
|
37
|
+
return master[0], "master"
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def record_types():
|
|
42
|
+
return sorted({*services.SLUG_TO_DOCTYPE, *services.MASTER_TABLES} - _DISABLED - mentions.SOURCES.keys())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _reader(user):
|
|
46
|
+
return (user or {}).get("role") in mentions.REAL_ROLES
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _existing(table, ids):
|
|
50
|
+
db, found = get_db(), {}
|
|
51
|
+
ids = list(ids)
|
|
52
|
+
for start in range(0, len(ids), 250):
|
|
53
|
+
chunk = ids[start:start + 250]
|
|
54
|
+
found.update({row["name"]: row for row in db.sql(
|
|
55
|
+
f'SELECT * FROM "{table}" WHERE name IN (' + ','.join('?' for _ in chunk) + ')', chunk)})
|
|
56
|
+
return found
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _title(record_type, record):
|
|
60
|
+
table, kind = _target(record_type)
|
|
61
|
+
if kind == "master":
|
|
62
|
+
label = record.get(services.MASTER_TABLES[record_type][1])
|
|
63
|
+
return f"{table} {record['name']}" + (f" · {label}" if label and label != record["name"] else "")
|
|
64
|
+
party = record.get("customer_name") or record.get("supplier_name") or record.get("party_name")
|
|
65
|
+
return f"{table} {record['name']}" + (f" · {party}" if party else "")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def view_url(record_type, record_id, note_id=None):
|
|
69
|
+
_, kind = _target(record_type)
|
|
70
|
+
base = "/app" if kind == "document" else "/masters"
|
|
71
|
+
url = f"{base}/{record_type}/{quote(record_id, safe='')}"
|
|
72
|
+
return url + (f"#note-{note_id}" if note_id else "")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _can_access(record_type, record_id, user):
|
|
76
|
+
target = _target(record_type)
|
|
77
|
+
return bool(target and _reader(user) and get_db().exists(target[0], record_id))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _create(record_type, record_id, body, user):
|
|
81
|
+
note_id, stamp = uuid.uuid4().hex, now()
|
|
82
|
+
get_db().insert(TABLE, {
|
|
83
|
+
"name": note_id, "record_type": record_type, "record_id": record_id, "body": body,
|
|
84
|
+
"author_id": user.get("user_id") or user["name"], "created_at": stamp, "updated_at": stamp,
|
|
85
|
+
})
|
|
86
|
+
return note_id
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _resolve(record_type, note_ids, user):
|
|
90
|
+
target = _target(record_type)
|
|
91
|
+
if not note_ids or not target or not _reader(user):
|
|
92
|
+
return {}
|
|
93
|
+
rows = get_db().sql(f'SELECT name, record_id, body FROM "{TABLE}" WHERE record_type = ? AND name IN ('
|
|
94
|
+
+ ','.join('?' for _ in note_ids) + ')', [record_type, *note_ids])
|
|
95
|
+
records = _existing(target[0], {row["record_id"] for row in rows})
|
|
96
|
+
return {row["name"]: {
|
|
97
|
+
"record_id": row["record_id"], "title": _title(record_type, records[row["record_id"]]),
|
|
98
|
+
"body": row["body"], "view_url": view_url(record_type, row["record_id"], row["name"]),
|
|
99
|
+
} for row in rows if row["record_id"] in records}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _source(record_type):
|
|
103
|
+
target = _target(record_type)
|
|
104
|
+
if not target:
|
|
105
|
+
return None
|
|
106
|
+
return dict(
|
|
107
|
+
label=target[0],
|
|
108
|
+
can_access=lambda record_id, user: _can_access(record_type, record_id, user),
|
|
109
|
+
create_note=lambda record_id, body, user: _create(record_type, record_id, body, user),
|
|
110
|
+
resolve_note=lambda note_id, user: _resolve(record_type, [note_id], user).get(note_id),
|
|
111
|
+
resolve_notes=lambda note_ids, user: _resolve(record_type, note_ids, user),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ListNotes(mentions.Input):
|
|
116
|
+
record_type: str = Field(min_length=1, max_length=100)
|
|
117
|
+
record_id: str = Field(min_length=1, max_length=200)
|
|
118
|
+
limit: int = Field(default=50, ge=1, le=200)
|
|
119
|
+
offset: int = Field(default=0, ge=0)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class EditNote(mentions.Input):
|
|
123
|
+
note_id: str = Field(min_length=1, max_length=100)
|
|
124
|
+
body: str = Field(min_length=1, max_length=8000)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class DeleteNote(mentions.Input):
|
|
128
|
+
note_id: str = Field(min_length=1, max_length=100)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _require_record(record_type, record_id, user):
|
|
132
|
+
if not _target(record_type):
|
|
133
|
+
raise ValueError(f"{record_type!r} does not take internal notes. "
|
|
134
|
+
f"Supported record types: {', '.join(record_types())}.")
|
|
135
|
+
if not _can_access(record_type, record_id, user):
|
|
136
|
+
raise HTTPException(404, "Record is unavailable")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def list_record_notes(args, user):
|
|
140
|
+
data = ListNotes.model_validate(args)
|
|
141
|
+
if not _reader(user):
|
|
142
|
+
raise PermissionError("Sign in with an ERP account to read internal notes.")
|
|
143
|
+
_require_record(data.record_type, data.record_id, user)
|
|
144
|
+
db, me = get_db(), user.get("user_id") or user.get("name")
|
|
145
|
+
where = [data.record_type, data.record_id]
|
|
146
|
+
total = db.sql(f'SELECT COUNT(*) AS n FROM "{TABLE}" WHERE record_type = ? AND record_id = ?', where)[0]["n"]
|
|
147
|
+
rows = db.sql(f'SELECT * FROM "{TABLE}" WHERE record_type = ? AND record_id = ? '
|
|
148
|
+
'ORDER BY created_at DESC, name DESC LIMIT ? OFFSET ?', [*where, data.limit, data.offset])
|
|
149
|
+
authors = _existing("User", {row["author_id"] for row in rows})
|
|
150
|
+
notes = [{
|
|
151
|
+
"id": row["name"], "body": row["body"], "author_id": row["author_id"],
|
|
152
|
+
"author_name": (authors.get(row["author_id"]) or {}).get("full_name")
|
|
153
|
+
or (authors.get(row["author_id"]) or {}).get("email") or row["author_id"],
|
|
154
|
+
"created_at": row["created_at"], "updated_at": row["updated_at"],
|
|
155
|
+
"edited": row["updated_at"] != row["created_at"],
|
|
156
|
+
"can_edit": row["author_id"] == me,
|
|
157
|
+
"can_delete": row["author_id"] == me or user.get("role") == "admin",
|
|
158
|
+
"view_url": view_url(data.record_type, data.record_id, row["name"]),
|
|
159
|
+
} for row in rows]
|
|
160
|
+
return {"record_type": data.record_type, "record_id": data.record_id, "notes": notes,
|
|
161
|
+
"total": total, "has_more": data.offset + len(notes) < total}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _load_note(note_id):
|
|
165
|
+
rows = get_db().sql(f'SELECT * FROM "{TABLE}" WHERE name = ?', [note_id])
|
|
166
|
+
if not rows:
|
|
167
|
+
raise HTTPException(404, "Note not found")
|
|
168
|
+
return rows[0]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def update_record_note(args, user):
|
|
172
|
+
data, actor = EditNote.model_validate(args), mentions._actor(user)
|
|
173
|
+
body = data.body.strip()
|
|
174
|
+
if not body:
|
|
175
|
+
raise ValueError("Note body must not be blank.")
|
|
176
|
+
db = get_db()
|
|
177
|
+
with db.atomic():
|
|
178
|
+
note = _load_note(data.note_id)
|
|
179
|
+
_require_record(note["record_type"], note["record_id"], actor)
|
|
180
|
+
if note["author_id"] != actor["user_id"]:
|
|
181
|
+
raise PermissionError("Only the author can edit a note.")
|
|
182
|
+
db.sql(f'UPDATE "{TABLE}" SET body = ?, updated_at = ? WHERE name = ?', [body, now(), note["name"]])
|
|
183
|
+
notified = mentions.sync_note_mentions(note["record_type"], note["record_id"], note["name"], body, actor)
|
|
184
|
+
return {"note_id": note["name"], "notified_count": notified,
|
|
185
|
+
"view_url": view_url(note["record_type"], note["record_id"], note["name"])}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def delete_record_note(args, user):
|
|
189
|
+
data, actor = DeleteNote.model_validate(args), mentions._actor(user)
|
|
190
|
+
db = get_db()
|
|
191
|
+
with db.atomic():
|
|
192
|
+
note = _load_note(data.note_id)
|
|
193
|
+
_require_record(note["record_type"], note["record_id"], actor)
|
|
194
|
+
if note["author_id"] != actor["user_id"] and actor["role"] != "admin":
|
|
195
|
+
raise PermissionError("Only the author or an admin can delete a note.")
|
|
196
|
+
db.sql('DELETE FROM "Mention Notification" WHERE doctype = ? AND note_id = ?',
|
|
197
|
+
[note["record_type"], note["name"]])
|
|
198
|
+
db.sql(f'DELETE FROM "{TABLE}" WHERE name = ?', [note["name"]])
|
|
199
|
+
return {"success": True, "note_id": note["name"]}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def delete_record_notes_for(record_type, record_id):
|
|
203
|
+
"""Drop a hard-deleted record's notes and their inbox entries."""
|
|
204
|
+
db = get_db()
|
|
205
|
+
ids = [row["name"] for row in db.sql(
|
|
206
|
+
f'SELECT name FROM "{TABLE}" WHERE record_type = ? AND record_id = ?', [record_type, record_id])]
|
|
207
|
+
for start in range(0, len(ids), 250):
|
|
208
|
+
chunk = ids[start:start + 250]
|
|
209
|
+
db.sql('DELETE FROM "Mention Notification" WHERE doctype = ? AND note_id IN ('
|
|
210
|
+
+ ','.join('?' for _ in chunk) + ')', [record_type, *chunk])
|
|
211
|
+
db.sql(f'DELETE FROM "{TABLE}" WHERE record_type = ? AND record_id = ?', [record_type, record_id])
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
_NOT_PRINTED = ("Internal notes are visible to signed-in colleagues only; they are never printed and "
|
|
215
|
+
"never change the record. Not the printed `remarks` (Notes / Terms) field.")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def initialize_record_notes():
|
|
219
|
+
mentions.register_mention_source_fallback(_source, record_types)
|
|
220
|
+
for name, handler, schema, role, description in (
|
|
221
|
+
("list_record_notes", list_record_notes, ListNotes, "viewer",
|
|
222
|
+
"List the internal team notes on one document or master record, newest first. record_type is the "
|
|
223
|
+
"document or master type slug (e.g. sales-invoice, customer) and record_id its name. "
|
|
224
|
+
+ _NOT_PRINTED),
|
|
225
|
+
("update_record_note", update_record_note, EditNote, "manager",
|
|
226
|
+
"Replace the text of an internal note you wrote (note_id from list_record_notes). @mentions are "
|
|
227
|
+
"re-read from the new text: added colleagues are notified, removed ones lose the notification."),
|
|
228
|
+
("delete_record_note", delete_record_note, DeleteNote, "manager",
|
|
229
|
+
"Delete an internal note. Authors can delete their own notes; admins can delete any. Also removes "
|
|
230
|
+
"its mention notifications."),
|
|
231
|
+
):
|
|
232
|
+
services.register_action(name, handler, description=description,
|
|
233
|
+
parameters=schema.model_json_schema(), minimum_role=role, pass_user=True)
|
|
@@ -748,7 +748,10 @@ def delete_master_record(master_type: str, name: str) -> dict:
|
|
|
748
748
|
return {"ok": True, "status": "disabled", "reason": f"Referenced by {reference}"}
|
|
749
749
|
raise HTTPException(status_code=409, detail=f"Cannot delete {doctype} {name}: referenced by {reference}")
|
|
750
750
|
|
|
751
|
-
|
|
751
|
+
from api.record_notes import delete_record_notes_for
|
|
752
|
+
with db.atomic():
|
|
753
|
+
delete_record_notes_for(master_type, name)
|
|
754
|
+
db.delete(doctype, name=name)
|
|
752
755
|
return {"ok": True, "status": "deleted"}
|
|
753
756
|
|
|
754
757
|
|
|
@@ -50,7 +50,7 @@ _EXCLUDE = {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
_READ_ONLY = {
|
|
53
|
-
'search_mention_users', 'list_my_mentions',
|
|
53
|
+
'search_mention_users', 'list_my_mentions', 'list_record_notes',
|
|
54
54
|
'get_erp_context',
|
|
55
55
|
'list_documents', 'get_document_fields', 'get_document', 'get_master_fields',
|
|
56
56
|
'search_masters', 'get_report', 'get_current_time', 'query_dataset',
|
|
@@ -3063,6 +3063,25 @@ def _m041_chat_turn_lease(db: "Database") -> None:
|
|
|
3063
3063
|
db.ensure_column("Chat Session", "active_turn_until", "TEXT")
|
|
3064
3064
|
|
|
3065
3065
|
|
|
3066
|
+
def _m042_record_notes(db: "Database") -> None:
|
|
3067
|
+
"""Internal team notes on any document or master (see api.record_notes)."""
|
|
3068
|
+
db.conn.execute(
|
|
3069
|
+
"""CREATE TABLE IF NOT EXISTS "Record Note" (
|
|
3070
|
+
name TEXT PRIMARY KEY,
|
|
3071
|
+
record_type TEXT NOT NULL,
|
|
3072
|
+
record_id TEXT NOT NULL,
|
|
3073
|
+
body TEXT NOT NULL,
|
|
3074
|
+
author_id TEXT NOT NULL,
|
|
3075
|
+
created_at TEXT NOT NULL,
|
|
3076
|
+
updated_at TEXT NOT NULL
|
|
3077
|
+
)"""
|
|
3078
|
+
)
|
|
3079
|
+
db.conn.execute(
|
|
3080
|
+
'CREATE INDEX IF NOT EXISTS record_note_record ON "Record Note" (record_type, record_id, created_at)'
|
|
3081
|
+
)
|
|
3082
|
+
db.conn.commit()
|
|
3083
|
+
|
|
3084
|
+
|
|
3066
3085
|
Database.MIGRATIONS = [
|
|
3067
3086
|
(1, "chat_message_session_id", _m001_chat_message_session_id),
|
|
3068
3087
|
(2, "chat_session_user_id", _m002_chat_session_user_id),
|
|
@@ -3105,6 +3124,7 @@ Database.MIGRATIONS = [
|
|
|
3105
3124
|
(39, "connection_app_type", _m039_connection_app_type),
|
|
3106
3125
|
(40, "naming_series", _m040_naming_series),
|
|
3107
3126
|
(41, "chat_turn_lease", _m041_chat_turn_lease),
|
|
3127
|
+
(42, "record_notes", _m042_record_notes),
|
|
3108
3128
|
]
|
|
3109
3129
|
|
|
3110
3130
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|