lambda-erp 0.1.0__py3-none-any.whl
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.
- api/__init__.py +0 -0
- api/attachments.py +229 -0
- api/auth.py +511 -0
- api/bootstrap.py +498 -0
- api/chat.py +2764 -0
- api/demo_limits.py +400 -0
- api/deps.py +7 -0
- api/errors.py +56 -0
- api/main.py +182 -0
- api/pdf.py +151 -0
- api/providers.py +116 -0
- api/routers/__init__.py +0 -0
- api/routers/accounting.py +63 -0
- api/routers/admin.py +122 -0
- api/routers/analytics.py +1009 -0
- api/routers/bank_reconciliation.py +31 -0
- api/routers/documents.py +100 -0
- api/routers/masters.py +396 -0
- api/routers/reports.py +735 -0
- api/routers/setup.py +387 -0
- api/services.py +372 -0
- api/templates/document.html +197 -0
- lambda_erp/__init__.py +3 -0
- lambda_erp/accounting/__init__.py +0 -0
- lambda_erp/accounting/bank_transaction.py +76 -0
- lambda_erp/accounting/budget.py +117 -0
- lambda_erp/accounting/chart_of_accounts.py +183 -0
- lambda_erp/accounting/general_ledger.py +362 -0
- lambda_erp/accounting/journal_entry.py +235 -0
- lambda_erp/accounting/payment_entry.py +515 -0
- lambda_erp/accounting/pos_invoice.py +342 -0
- lambda_erp/accounting/purchase_invoice.py +504 -0
- lambda_erp/accounting/revaluation.py +172 -0
- lambda_erp/accounting/sales_invoice.py +523 -0
- lambda_erp/accounting/subscription.py +132 -0
- lambda_erp/buying/__init__.py +0 -0
- lambda_erp/buying/purchase_order.py +165 -0
- lambda_erp/controllers/__init__.py +0 -0
- lambda_erp/controllers/currency.py +52 -0
- lambda_erp/controllers/defaults.py +51 -0
- lambda_erp/controllers/pricing_rule.py +103 -0
- lambda_erp/controllers/taxes_and_totals.py +369 -0
- lambda_erp/database.py +1543 -0
- lambda_erp/exceptions.py +37 -0
- lambda_erp/hooks.py +37 -0
- lambda_erp/model.py +462 -0
- lambda_erp/selling/__init__.py +0 -0
- lambda_erp/selling/quotation.py +263 -0
- lambda_erp/selling/sales_order.py +214 -0
- lambda_erp/simulation.py +704 -0
- lambda_erp/stock/__init__.py +0 -0
- lambda_erp/stock/delivery_note.py +254 -0
- lambda_erp/stock/purchase_receipt.py +356 -0
- lambda_erp/stock/stock_entry.py +330 -0
- lambda_erp/stock/stock_ledger.py +337 -0
- lambda_erp/utils.py +167 -0
- lambda_erp-0.1.0.dist-info/METADATA +454 -0
- lambda_erp-0.1.0.dist-info/RECORD +60 -0
- lambda_erp-0.1.0.dist-info/WHEEL +4 -0
- lambda_erp-0.1.0.dist-info/licenses/LICENSE +21 -0
api/__init__.py
ADDED
|
File without changes
|
api/attachments.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Chat attachment upload/download. Files stored on the local filesystem."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import os
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
|
8
|
+
from fastapi.responses import Response
|
|
9
|
+
|
|
10
|
+
from lambda_erp.database import get_db
|
|
11
|
+
from lambda_erp.utils import now
|
|
12
|
+
from api.auth import require_role, get_current_user
|
|
13
|
+
from api.demo_limits import demo_max_attachment_bytes, is_demo_role
|
|
14
|
+
|
|
15
|
+
router = APIRouter(prefix="/chat", tags=["chat-attachments"])
|
|
16
|
+
|
|
17
|
+
MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
18
|
+
MAX_ATTACHMENTS_PER_SESSION = 100 # sanity cap
|
|
19
|
+
ALLOWED_MIME_TYPES = {
|
|
20
|
+
"image/png", "image/jpeg", "image/gif", "image/webp",
|
|
21
|
+
"application/pdf",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
UPLOAD_ROOT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "uploads")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ensure_upload_dir(user_id: str) -> str:
|
|
28
|
+
"""Create and return the upload directory for a user."""
|
|
29
|
+
path = os.path.join(UPLOAD_ROOT, user_id or "anonymous")
|
|
30
|
+
os.makedirs(path, exist_ok=True)
|
|
31
|
+
return path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _format_bytes(n: int) -> str:
|
|
35
|
+
"""Human-friendly byte count for user-facing error messages."""
|
|
36
|
+
if n >= 1024 * 1024:
|
|
37
|
+
return f"{n / (1024 * 1024):.1f} MB"
|
|
38
|
+
if n >= 1024:
|
|
39
|
+
return f"{n / 1024:.0f} KB"
|
|
40
|
+
return f"{n} B"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _safe_ext(filename: str, mime: str) -> str:
|
|
44
|
+
"""Return a safe file extension based on filename or mime."""
|
|
45
|
+
if filename and "." in filename:
|
|
46
|
+
ext = filename.rsplit(".", 1)[1].lower()
|
|
47
|
+
if len(ext) <= 5 and ext.isalnum():
|
|
48
|
+
return ext
|
|
49
|
+
mime_ext = {
|
|
50
|
+
"image/png": "png",
|
|
51
|
+
"image/jpeg": "jpg",
|
|
52
|
+
"image/gif": "gif",
|
|
53
|
+
"image/webp": "webp",
|
|
54
|
+
"application/pdf": "pdf",
|
|
55
|
+
}
|
|
56
|
+
return mime_ext.get(mime, "bin")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@router.post("/attachments")
|
|
60
|
+
async def upload_attachment(
|
|
61
|
+
file: UploadFile = File(...),
|
|
62
|
+
session_id: str = Form(...),
|
|
63
|
+
user: dict = Depends(require_role("viewer")),
|
|
64
|
+
):
|
|
65
|
+
"""Upload a chat attachment. Returns metadata the client uses to attach it to a message."""
|
|
66
|
+
mime = (file.content_type or "application/octet-stream").lower()
|
|
67
|
+
if mime not in ALLOWED_MIME_TYPES:
|
|
68
|
+
raise HTTPException(status_code=400, detail=f"Unsupported file type: {mime}. Allowed: images, PDF.")
|
|
69
|
+
|
|
70
|
+
data = await file.read()
|
|
71
|
+
if len(data) > MAX_ATTACHMENT_SIZE:
|
|
72
|
+
raise HTTPException(status_code=413, detail=f"File too large. Maximum {MAX_ATTACHMENT_SIZE // (1024 * 1024)} MB.")
|
|
73
|
+
if not data:
|
|
74
|
+
raise HTTPException(status_code=400, detail="Empty file.")
|
|
75
|
+
|
|
76
|
+
# Tighter cap for public demo visitors: base64-encoded attachments get
|
|
77
|
+
# streamed to the LLM as prompt tokens, so a 10 MB image alone would
|
|
78
|
+
# blow the hourly budget in one call. Reject with a message the
|
|
79
|
+
# frontend surfaces as-is so the visitor can shrink and retry.
|
|
80
|
+
if is_demo_role(user.get("role")):
|
|
81
|
+
demo_cap = demo_max_attachment_bytes()
|
|
82
|
+
if len(data) > demo_cap:
|
|
83
|
+
raise HTTPException(
|
|
84
|
+
status_code=413,
|
|
85
|
+
detail=(
|
|
86
|
+
f"Demo attachments are limited to {_format_bytes(demo_cap)} "
|
|
87
|
+
f"(your file is {_format_bytes(len(data))}). "
|
|
88
|
+
"Please upload a smaller image or PDF."
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
db = get_db()
|
|
93
|
+
# Sanity-cap the number of attachments per session
|
|
94
|
+
cnt = db.sql('SELECT COUNT(*) as c FROM "Chat Attachment" WHERE session_id = ?', [session_id])
|
|
95
|
+
if cnt and cnt[0]["c"] >= MAX_ATTACHMENTS_PER_SESSION:
|
|
96
|
+
raise HTTPException(status_code=409, detail="Too many attachments in this chat.")
|
|
97
|
+
|
|
98
|
+
att_id = uuid.uuid4().hex
|
|
99
|
+
ext = _safe_ext(file.filename or "", mime)
|
|
100
|
+
user_id = user["name"]
|
|
101
|
+
upload_dir = _ensure_upload_dir(user_id)
|
|
102
|
+
file_path = os.path.join(upload_dir, f"{att_id}.{ext}")
|
|
103
|
+
|
|
104
|
+
with open(file_path, "wb") as f:
|
|
105
|
+
f.write(data)
|
|
106
|
+
|
|
107
|
+
db.sql(
|
|
108
|
+
'INSERT INTO "Chat Attachment" (id, session_id, user_id, filename, mime_type, size_bytes, file_path, created_at) '
|
|
109
|
+
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
110
|
+
[att_id, session_id, user_id, file.filename or f"file.{ext}", mime, len(data), file_path, now()],
|
|
111
|
+
)
|
|
112
|
+
db.conn.commit()
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
"id": att_id,
|
|
116
|
+
"filename": file.filename or f"file.{ext}",
|
|
117
|
+
"mime_type": mime,
|
|
118
|
+
"size_bytes": len(data),
|
|
119
|
+
"created_at": now(),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@router.get("/attachments/{attachment_id}")
|
|
124
|
+
def download_attachment(
|
|
125
|
+
attachment_id: str,
|
|
126
|
+
user: dict = Depends(get_current_user),
|
|
127
|
+
):
|
|
128
|
+
"""Download a chat attachment. Scoped to the owning user."""
|
|
129
|
+
db = get_db()
|
|
130
|
+
rows = db.sql(
|
|
131
|
+
'SELECT filename, mime_type, file_path, user_id FROM "Chat Attachment" WHERE id = ?',
|
|
132
|
+
[attachment_id],
|
|
133
|
+
)
|
|
134
|
+
if not rows:
|
|
135
|
+
raise HTTPException(status_code=404, detail="Attachment not found")
|
|
136
|
+
row = rows[0]
|
|
137
|
+
|
|
138
|
+
# Owner or admin (public_manager also allowed if it owns the attachment)
|
|
139
|
+
if row["user_id"] != user["name"] and user.get("role") != "admin":
|
|
140
|
+
raise HTTPException(status_code=403, detail="Not authorized")
|
|
141
|
+
|
|
142
|
+
if not os.path.isfile(row["file_path"]):
|
|
143
|
+
raise HTTPException(status_code=404, detail="File missing on disk")
|
|
144
|
+
|
|
145
|
+
with open(row["file_path"], "rb") as f:
|
|
146
|
+
data = f.read()
|
|
147
|
+
|
|
148
|
+
return Response(
|
|
149
|
+
content=data,
|
|
150
|
+
media_type=row["mime_type"],
|
|
151
|
+
headers={"Content-Disposition": f'inline; filename="{row["filename"]}"'},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Helpers used by the chat thinking loop
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_attachments_by_ids(attachment_ids: list[str], user_id: str) -> list[dict]:
|
|
161
|
+
"""Fetch attachment metadata + binary data for a list of IDs, scoped to user."""
|
|
162
|
+
if not attachment_ids:
|
|
163
|
+
return []
|
|
164
|
+
db = get_db()
|
|
165
|
+
placeholders = ",".join(["?"] * len(attachment_ids))
|
|
166
|
+
rows = db.sql(
|
|
167
|
+
f'SELECT id, filename, mime_type, file_path, size_bytes FROM "Chat Attachment" '
|
|
168
|
+
f'WHERE id IN ({placeholders}) AND user_id = ?',
|
|
169
|
+
list(attachment_ids) + [user_id],
|
|
170
|
+
)
|
|
171
|
+
result = []
|
|
172
|
+
for row in rows:
|
|
173
|
+
try:
|
|
174
|
+
with open(row["file_path"], "rb") as f:
|
|
175
|
+
data = f.read()
|
|
176
|
+
result.append({
|
|
177
|
+
"id": row["id"],
|
|
178
|
+
"filename": row["filename"],
|
|
179
|
+
"mime_type": row["mime_type"],
|
|
180
|
+
"size_bytes": row["size_bytes"],
|
|
181
|
+
"data": data,
|
|
182
|
+
})
|
|
183
|
+
except FileNotFoundError:
|
|
184
|
+
continue
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def build_multimodal_content(attachment: dict) -> dict:
|
|
189
|
+
"""Convert an attachment dict (with raw data) into an OpenAI multimodal content part."""
|
|
190
|
+
mime = attachment["mime_type"]
|
|
191
|
+
data_b64 = base64.b64encode(attachment["data"]).decode("ascii")
|
|
192
|
+
if mime.startswith("image/"):
|
|
193
|
+
return {
|
|
194
|
+
"type": "image_url",
|
|
195
|
+
"image_url": {"url": f"data:{mime};base64,{data_b64}"},
|
|
196
|
+
}
|
|
197
|
+
if mime == "application/pdf":
|
|
198
|
+
return {
|
|
199
|
+
"type": "file",
|
|
200
|
+
"file": {
|
|
201
|
+
"filename": attachment["filename"],
|
|
202
|
+
"file_data": f"data:{mime};base64,{data_b64}",
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
return {"type": "text", "text": f"[Unsupported attachment: {attachment['filename']}]"}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def list_session_attachments(session_id: str, user_id: str) -> list[dict]:
|
|
209
|
+
"""Return metadata (no data) for all attachments in a session."""
|
|
210
|
+
db = get_db()
|
|
211
|
+
rows = db.sql(
|
|
212
|
+
'SELECT id, filename, mime_type, size_bytes, created_at FROM "Chat Attachment" '
|
|
213
|
+
'WHERE session_id = ? AND user_id = ? ORDER BY created_at DESC',
|
|
214
|
+
[session_id, user_id],
|
|
215
|
+
)
|
|
216
|
+
return [dict(r) for r in rows]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def delete_session_attachments(session_id: str) -> None:
|
|
220
|
+
"""Delete all attachments (DB + files) for a session. Used on chat clear/delete."""
|
|
221
|
+
db = get_db()
|
|
222
|
+
rows = db.sql('SELECT file_path FROM "Chat Attachment" WHERE session_id = ?', [session_id])
|
|
223
|
+
for r in rows:
|
|
224
|
+
try:
|
|
225
|
+
os.remove(r["file_path"])
|
|
226
|
+
except OSError:
|
|
227
|
+
pass
|
|
228
|
+
db.sql('DELETE FROM "Chat Attachment" WHERE session_id = ?', [session_id])
|
|
229
|
+
db.conn.commit()
|