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.
Files changed (60) hide show
  1. api/__init__.py +0 -0
  2. api/attachments.py +229 -0
  3. api/auth.py +511 -0
  4. api/bootstrap.py +498 -0
  5. api/chat.py +2764 -0
  6. api/demo_limits.py +400 -0
  7. api/deps.py +7 -0
  8. api/errors.py +56 -0
  9. api/main.py +182 -0
  10. api/pdf.py +151 -0
  11. api/providers.py +116 -0
  12. api/routers/__init__.py +0 -0
  13. api/routers/accounting.py +63 -0
  14. api/routers/admin.py +122 -0
  15. api/routers/analytics.py +1009 -0
  16. api/routers/bank_reconciliation.py +31 -0
  17. api/routers/documents.py +100 -0
  18. api/routers/masters.py +396 -0
  19. api/routers/reports.py +735 -0
  20. api/routers/setup.py +387 -0
  21. api/services.py +372 -0
  22. api/templates/document.html +197 -0
  23. lambda_erp/__init__.py +3 -0
  24. lambda_erp/accounting/__init__.py +0 -0
  25. lambda_erp/accounting/bank_transaction.py +76 -0
  26. lambda_erp/accounting/budget.py +117 -0
  27. lambda_erp/accounting/chart_of_accounts.py +183 -0
  28. lambda_erp/accounting/general_ledger.py +362 -0
  29. lambda_erp/accounting/journal_entry.py +235 -0
  30. lambda_erp/accounting/payment_entry.py +515 -0
  31. lambda_erp/accounting/pos_invoice.py +342 -0
  32. lambda_erp/accounting/purchase_invoice.py +504 -0
  33. lambda_erp/accounting/revaluation.py +172 -0
  34. lambda_erp/accounting/sales_invoice.py +523 -0
  35. lambda_erp/accounting/subscription.py +132 -0
  36. lambda_erp/buying/__init__.py +0 -0
  37. lambda_erp/buying/purchase_order.py +165 -0
  38. lambda_erp/controllers/__init__.py +0 -0
  39. lambda_erp/controllers/currency.py +52 -0
  40. lambda_erp/controllers/defaults.py +51 -0
  41. lambda_erp/controllers/pricing_rule.py +103 -0
  42. lambda_erp/controllers/taxes_and_totals.py +369 -0
  43. lambda_erp/database.py +1543 -0
  44. lambda_erp/exceptions.py +37 -0
  45. lambda_erp/hooks.py +37 -0
  46. lambda_erp/model.py +462 -0
  47. lambda_erp/selling/__init__.py +0 -0
  48. lambda_erp/selling/quotation.py +263 -0
  49. lambda_erp/selling/sales_order.py +214 -0
  50. lambda_erp/simulation.py +704 -0
  51. lambda_erp/stock/__init__.py +0 -0
  52. lambda_erp/stock/delivery_note.py +254 -0
  53. lambda_erp/stock/purchase_receipt.py +356 -0
  54. lambda_erp/stock/stock_entry.py +330 -0
  55. lambda_erp/stock/stock_ledger.py +337 -0
  56. lambda_erp/utils.py +167 -0
  57. lambda_erp-0.1.0.dist-info/METADATA +454 -0
  58. lambda_erp-0.1.0.dist-info/RECORD +60 -0
  59. lambda_erp-0.1.0.dist-info/WHEEL +4 -0
  60. 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()