sp2react 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 (79) hide show
  1. sp2react/__init__.py +18 -0
  2. sp2react/agents/__init__.py +0 -0
  3. sp2react/agents/core.py +146 -0
  4. sp2react/agents/prompts/dbmap.md +28 -0
  5. sp2react/agents/prompts/fix.md +11 -0
  6. sp2react/agents/prompts/handler.md +22 -0
  7. sp2react/agents/prompts/page.md +40 -0
  8. sp2react/agents/prompts/review.md +21 -0
  9. sp2react/agents/prompts/spec.md +79 -0
  10. sp2react/agents/tasks.py +374 -0
  11. sp2react/analysis/__init__.py +0 -0
  12. sp2react/analysis/aspx.py +254 -0
  13. sp2react/analysis/behaviors.py +357 -0
  14. sp2react/analysis/fields.py +152 -0
  15. sp2react/analysis/inventory.py +314 -0
  16. sp2react/analysis/jsscan.py +330 -0
  17. sp2react/checks.py +105 -0
  18. sp2react/cli.py +188 -0
  19. sp2react/config.py +172 -0
  20. sp2react/dbmap.py +245 -0
  21. sp2react/llm/__init__.py +0 -0
  22. sp2react/llm/providers.py +219 -0
  23. sp2react/migrate.py +255 -0
  24. sp2react/net.py +60 -0
  25. sp2react/pipeline.py +583 -0
  26. sp2react/scaffold/__init__.py +0 -0
  27. sp2react/scaffold/generate.py +279 -0
  28. sp2react/scaffold/rule_vectors.json +3826 -0
  29. sp2react/scaffold/templates/backend/app/__init__.py +0 -0
  30. sp2react/scaffold/templates/backend/app/api.py +293 -0
  31. sp2react/scaffold/templates/backend/app/auth.py +33 -0
  32. sp2react/scaffold/templates/backend/app/custom/__init__.py +22 -0
  33. sp2react/scaffold/templates/backend/app/main.py +47 -0
  34. sp2react/scaffold/templates/backend/app/odata.py +185 -0
  35. sp2react/scaffold/templates/backend/app/rules.py +256 -0
  36. sp2react/scaffold/templates/backend/app/sp_repo.py +159 -0
  37. sp2react/scaffold/templates/backend/app/sql_repo.py +437 -0
  38. sp2react/scaffold/templates/backend/app/store.py +224 -0
  39. sp2react/scaffold/templates/backend/requirements.txt +8 -0
  40. sp2react/scaffold/templates/backend/tests/conftest.py +10 -0
  41. sp2react/scaffold/templates/backend/tests/test_rule_vectors.py +38 -0
  42. sp2react/scaffold/templates/frontend/index.html +12 -0
  43. sp2react/scaffold/templates/frontend/package.json +24 -0
  44. sp2react/scaffold/templates/frontend/scripts/check-rules.ts +23 -0
  45. sp2react/scaffold/templates/frontend/src/api.ts +88 -0
  46. sp2react/scaffold/templates/frontend/src/components/sp/Banner.tsx +4 -0
  47. sp2react/scaffold/templates/frontend/src/components/sp/DataTable.tsx +38 -0
  48. sp2react/scaffold/templates/frontend/src/components/sp/Form.tsx +252 -0
  49. sp2react/scaffold/templates/frontend/src/components/sp/GenericPage.tsx +28 -0
  50. sp2react/scaffold/templates/frontend/src/components/sp/Layout.tsx +54 -0
  51. sp2react/scaffold/templates/frontend/src/components/sp/LegacyHtml.tsx +9 -0
  52. sp2react/scaffold/templates/frontend/src/components/sp/ListView.tsx +59 -0
  53. sp2react/scaffold/templates/frontend/src/components/sp/Tiles.tsx +21 -0
  54. sp2react/scaffold/templates/frontend/src/components/sp/fields.tsx +132 -0
  55. sp2react/scaffold/templates/frontend/src/custom/types.ts +10 -0
  56. sp2react/scaffold/templates/frontend/src/main.tsx +14 -0
  57. sp2react/scaffold/templates/frontend/src/runtime/format.ts +36 -0
  58. sp2react/scaffold/templates/frontend/src/runtime/rules.ts +225 -0
  59. sp2react/scaffold/templates/frontend/src/runtime/spec.ts +102 -0
  60. sp2react/scaffold/templates/frontend/src/runtime/user.tsx +21 -0
  61. sp2react/scaffold/templates/frontend/src/screens/LegacyRedirect.tsx +27 -0
  62. sp2react/scaffold/templates/frontend/src/screens/ListFormScreen.tsx +33 -0
  63. sp2react/scaffold/templates/frontend/src/styles/sp.css +95 -0
  64. sp2react/scaffold/templates/frontend/src/vite-env.d.ts +1 -0
  65. sp2react/scaffold/templates/frontend/tsconfig.json +18 -0
  66. sp2react/scaffold/templates/frontend/vite.config.ts +7 -0
  67. sp2react/sharepoint/__init__.py +0 -0
  68. sp2react/sharepoint/client.py +210 -0
  69. sp2react/sharepoint/extract.py +217 -0
  70. sp2react/spec/__init__.py +0 -0
  71. sp2react/spec/base.py +80 -0
  72. sp2react/spec/coverage.py +191 -0
  73. sp2react/spec/model.py +133 -0
  74. sp2react-0.1.0.dist-info/METADATA +238 -0
  75. sp2react-0.1.0.dist-info/RECORD +79 -0
  76. sp2react-0.1.0.dist-info/WHEEL +5 -0
  77. sp2react-0.1.0.dist-info/entry_points.txt +2 -0
  78. sp2react-0.1.0.dist-info/licenses/LICENSE +21 -0
  79. sp2react-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,437 @@
1
+ """Data source for an EXISTING database (MySQL, SQL Server, PostgreSQL, Oracle...) via SQLAlchemy.
2
+
3
+ Select with DATA_SOURCE=database and DB_URL (SQLAlchemy URL), e.g.
4
+ mysql+pymysql://user:password@db-host:3306/sea
5
+ mssql+pyodbc://user:password@db-host/sea?driver=ODBC+Driver+18+for+SQL+Server
6
+ The mapping (MAPPING_FILE, default ./mapping.json) says which table holds each SharePoint list and which
7
+ column holds each field. Queries are translated to SQL (filter, sort, paging and counts run in the database).
8
+
9
+ mapping.json
10
+ {
11
+ "lists": {
12
+ "VendorRequests": {
13
+ "table": "vendor_requests", "key": "id",
14
+ "fields": {"Title": "vendor_name", "Department": "department_id",
15
+ "Requester": {"column": "requester_login", "ref": "login"},
16
+ "Tags": {"column": "tags", "delimiter": ";"}},
17
+ "system": {"Created": "created_at", "Modified": "modified_at", "Author": "created_by", "Editor": "modified_by", "version": "row_version"},
18
+ "attachments": {"table": "vendor_request_files", "item": "request_id", "name": "file_name", "content": "content"}
19
+ }
20
+ },
21
+ "users": {"table": "app_users", "id": "user_id", "login": "login", "title": "display_name", "email": "email",
22
+ "groups": {"table": "user_groups", "user": "user_id", "group": "group_name"}}
23
+ }
24
+ Lookup columns hold the key of the row in the mapped target list's table. Person columns hold the users
25
+ table id (default) or the login ("ref": "login"). Without a users table, people are their login and groups
26
+ come from data/roles.json: {"Compliance Reviewers": ["CORP\\\\bob", ...]}.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import re
33
+ from datetime import date, datetime, timezone
34
+ from pathlib import Path
35
+ from typing import Any, Optional
36
+
37
+ from sqlalchemy import MetaData, Table, and_, create_engine, func, not_, or_, select
38
+ from sqlalchemy.engine import Engine
39
+
40
+ from .odata import ODataError, tokenize
41
+ from .store import DATA_DIR, ROOT, Conflict, Specs
42
+
43
+
44
+ def _now() -> datetime:
45
+ return datetime.now(timezone.utc).replace(tzinfo=None)
46
+
47
+
48
+ def _iso(v: Any) -> Any:
49
+ if isinstance(v, datetime):
50
+ return v.strftime("%Y-%m-%dT%H:%M:%SZ")
51
+ if isinstance(v, date):
52
+ return v.isoformat()
53
+ return v
54
+
55
+
56
+ class SqlRepo:
57
+ def __init__(self, specs: Specs, url: Optional[str] = None, mapping_file: Optional[Path] = None, engine: Optional[Engine] = None):
58
+ self.specs = specs
59
+ self.engine = engine or create_engine(url or os.environ["DB_URL"], pool_pre_ping=True, future=True)
60
+ mf = Path(mapping_file or os.environ.get("MAPPING_FILE", ROOT / "mapping.json"))
61
+ self.mapping = json.loads(mf.read_text(encoding="utf-8"))
62
+ self.schema = self.mapping.get("schema") or os.environ.get("DB_SCHEMA") or None
63
+ self.md = MetaData()
64
+ self._tables: dict[str, Table] = {}
65
+ roles = DATA_DIR / "roles.json"
66
+ self.roles = json.loads(roles.read_text(encoding="utf-8")) if roles.exists() else {}
67
+ self.users = self._load_users()
68
+
69
+ # ------------------------------------------------------------- mapping
70
+ def table(self, name: str) -> Table:
71
+ if name not in self._tables:
72
+ self._tables[name] = Table(name, self.md, autoload_with=self.engine, schema=self.schema)
73
+ return self._tables[name]
74
+
75
+ def lmap(self, list_name: str) -> dict:
76
+ m = self.mapping["lists"].get(list_name)
77
+ if not m:
78
+ raise KeyError(f"List {list_name} has no table in mapping.json")
79
+ return m
80
+
81
+ def fields(self, list_name: str) -> dict[str, dict]:
82
+ return {f["name"]: f for f in self.specs.lists.get(list_name, {}).get("fields", [])}
83
+
84
+ @staticmethod
85
+ def fspec(m: dict, field: str) -> Optional[dict]:
86
+ v = m["fields"].get(field)
87
+ if v is None:
88
+ return None
89
+ return {"column": v} if isinstance(v, str) else v
90
+
91
+ def col(self, list_name: str, field: str):
92
+ m = self.lmap(list_name)
93
+ t = self.table(m["table"])
94
+ if field in ("ID", "Id"):
95
+ return t.c[m["key"]]
96
+ sysmap = m.get("system", {})
97
+ if field in sysmap:
98
+ return t.c[sysmap[field]]
99
+ fs = self.fspec(m, field)
100
+ if not fs:
101
+ raise ODataError(f"Field {field} is not mapped for {list_name}")
102
+ return t.c[fs["column"]]
103
+
104
+ # --------------------------------------------------------------- users
105
+ def _load_users(self) -> list[dict]:
106
+ um = self.mapping.get("users")
107
+ out: list[dict] = []
108
+ if um:
109
+ t = self.table(um["table"])
110
+ with self.engine.connect() as c:
111
+ rows = c.execute(select(t)).mappings().all()
112
+ groups: dict[Any, list[str]] = {}
113
+ gm = um.get("groups")
114
+ if gm:
115
+ gt = self.table(gm["table"])
116
+ for g in c.execute(select(gt.c[gm["user"]], gt.c[gm["group"]])).all():
117
+ groups.setdefault(g[0], []).append(g[1])
118
+ for r in rows:
119
+ login = r[um["login"]]
120
+ out.append({"id": r[um["id"]], "login": login, "title": r.get(um.get("title", um["login"])) or login,
121
+ "email": r.get(um["email"]) if um.get("email") else None,
122
+ "groups": groups.get(r[um["id"]], []) + [g for g, members in self.roles.items() if str(login).lower() in [m.lower() for m in members]]})
123
+ return out
124
+
125
+ def user(self, ref: Any) -> Optional[dict]:
126
+ if ref in (None, ""):
127
+ return None
128
+ if isinstance(ref, dict):
129
+ ref = ref.get("id")
130
+ u = next((u for u in self.users if u["id"] == ref or str(u["login"]).lower() == str(ref).lower()), None)
131
+ if u:
132
+ return {"id": u["id"], "login": u["login"], "title": u["title"], "email": u.get("email")}
133
+ return {"id": ref, "login": str(ref), "title": str(ref)}
134
+
135
+ def user_by_login(self, login: str) -> Optional[dict]:
136
+ login = (login or "").lower()
137
+ u = next((u for u in self.users if str(u["login"]).lower() == login or str(u.get("email") or "").lower() == login), None)
138
+ if u:
139
+ return u
140
+ if not self.mapping.get("users") and login:
141
+ return {"id": login, "login": login, "title": login, "email": None,
142
+ "groups": [g for g, members in self.roles.items() if login in [m.lower() for m in members]]}
143
+ return None
144
+
145
+ # ------------------------------------------------------- row <-> item
146
+ def _titles(self, list_name: str) -> dict[Any, Any]:
147
+ m = self.lmap(list_name)
148
+ t = self.table(m["table"])
149
+ title = self.fspec(m, "Title")
150
+ with self.engine.connect() as c:
151
+ rows = c.execute(select(t.c[m["key"]], t.c[title["column"]] if title else t.c[m["key"]])).all()
152
+ return {r[0]: r[1] for r in rows}
153
+
154
+ def _to_item(self, list_name: str, row: dict, title_cache: dict) -> dict:
155
+ m = self.lmap(list_name)
156
+ item: dict = {"ID": row[m["key"]]}
157
+ for name, f in self.fields(list_name).items():
158
+ fs = self.fspec(m, name)
159
+ if not fs:
160
+ item[name] = None
161
+ continue
162
+ v = row.get(fs["column"])
163
+ if fs.get("delimiter") and isinstance(v, str):
164
+ v = [x for x in v.split(fs["delimiter"]) if x != ""]
165
+ if f["type"] == "lookup":
166
+ target = f["lookup"]["list"]
167
+ if target not in title_cache:
168
+ title_cache[target] = self._titles(target) if target in self.mapping["lists"] else {}
169
+ conv = lambda x: {"id": x, "title": title_cache[target].get(x)} if x not in (None, "") else None
170
+ item[name] = [conv(int(x) if str(x).isdigit() else x) for x in v] if isinstance(v, list) else conv(v)
171
+ elif f["type"] == "user":
172
+ item[name] = [self.user(x) for x in v] if isinstance(v, list) else self.user(v)
173
+ elif f["type"] == "boolean":
174
+ item[name] = None if v is None else bool(v)
175
+ elif f["type"] in ("number", "currency") and v is not None:
176
+ item[name] = float(v)
177
+ else:
178
+ item[name] = _iso(v)
179
+ sysmap = m.get("system", {})
180
+ item["Created"] = _iso(row.get(sysmap["Created"])) if "Created" in sysmap else None
181
+ item["Modified"] = _iso(row.get(sysmap["Modified"])) if "Modified" in sysmap else None
182
+ item["Author"] = self.user(row.get(sysmap["Author"])) if "Author" in sysmap else None
183
+ item["Editor"] = self.user(row.get(sysmap["Editor"])) if "Editor" in sysmap else None
184
+ ver = row.get(sysmap["version"]) if "version" in sysmap else None
185
+ item["_version"] = f"{ver}.0" if ver is not None else None
186
+ item["_etag"] = f'"{ver}"' if ver is not None else f'"{item["Modified"] or ""}"'
187
+ item["Attachments"] = self._attachment_names(list_name, item["ID"])
188
+ return item
189
+
190
+ def _to_row(self, list_name: str, values: dict) -> dict:
191
+ m = self.lmap(list_name)
192
+ flds = self.fields(list_name)
193
+ out: dict = {}
194
+ for name, v in values.items():
195
+ fs, f = self.fspec(m, name), flds.get(name)
196
+ if not fs or not f:
197
+ continue
198
+ ref = fs.get("ref", "id")
199
+ if f["type"] in ("lookup", "user"):
200
+ conv = lambda x: (x.get("login") if ref == "login" and f["type"] == "user" else x.get("id")) if isinstance(x, dict) else x
201
+ v = [conv(x) for x in v] if isinstance(v, list) else conv(v)
202
+ if isinstance(v, list):
203
+ v = fs.get("delimiter", ";").join(str(x) for x in v)
204
+ if f["type"] == "date" and isinstance(v, str) and v:
205
+ v = date.fromisoformat(v[:10])
206
+ out[fs["column"]] = v
207
+ return out
208
+
209
+ # --------------------------------------------------------- attachments
210
+ def _attachment_names(self, list_name: str, iid: Any) -> list[str]:
211
+ am = self.lmap(list_name).get("attachments")
212
+ if not am:
213
+ return []
214
+ t = self.table(am["table"])
215
+ with self.engine.connect() as c:
216
+ return sorted(r[0] for r in c.execute(select(t.c[am["name"]]).where(t.c[am["item"]] == iid)).all())
217
+
218
+ def attachment(self, list_name: str, iid: Any, name: str) -> Optional[bytes]:
219
+ am = self.lmap(list_name).get("attachments")
220
+ if not am:
221
+ return None
222
+ t = self.table(am["table"])
223
+ with self.engine.connect() as c:
224
+ r = c.execute(select(t.c[am["content"]]).where(and_(t.c[am["item"]] == iid, t.c[am["name"]] == name))).first()
225
+ return bytes(r[0]) if r else None
226
+
227
+ def add_attachment(self, list_name: str, iid: Any, name: str, content_b64: str) -> None:
228
+ import base64
229
+ am = self.lmap(list_name).get("attachments")
230
+ if not am:
231
+ raise KeyError("attachments are not mapped for this list")
232
+ t = self.table(am["table"])
233
+ with self.engine.begin() as c:
234
+ c.execute(t.delete().where(and_(t.c[am["item"]] == iid, t.c[am["name"]] == name)))
235
+ c.execute(t.insert().values({am["item"]: iid, am["name"]: name, am["content"]: base64.b64decode(content_b64)}))
236
+
237
+ def delete_attachment(self, list_name: str, iid: Any, name: str) -> None:
238
+ am = self.lmap(list_name).get("attachments")
239
+ if am:
240
+ t = self.table(am["table"])
241
+ with self.engine.begin() as c:
242
+ c.execute(t.delete().where(and_(t.c[am["item"]] == iid, t.c[am["name"]] == name)))
243
+
244
+ # ------------------------------------------------------------- queries
245
+ def _where(self, list_name: str, expr: Optional[str]):
246
+ """OData $filter subset -> SQL clause."""
247
+ if not expr:
248
+ return None
249
+ toks = tokenize(expr)
250
+ pos = [0]
251
+ peek = lambda: toks[pos[0]] if pos[0] < len(toks) else (None, None)
252
+
253
+ def take(kind=None):
254
+ t = peek()
255
+ if kind and t[0] != kind:
256
+ raise ODataError(f"expected {kind}, got {t}")
257
+ pos[0] += 1
258
+ return t
259
+
260
+ def value():
261
+ kind, v = take()
262
+ if kind == "str":
263
+ return v
264
+ if kind == "num":
265
+ return float(v) if "." in v else int(v)
266
+ return {"true": True, "false": False, "null": None}.get(v, v)
267
+
268
+ def column_for(path: str):
269
+ if "/" in path: # Lookup/Title -> key IN (select key from target where title ...)
270
+ head, prop = path.split("/", 1)
271
+ f = self.fields(list_name).get(head)
272
+ if f and f["type"] == "lookup":
273
+ target = f["lookup"]["list"]
274
+ tm = self.lmap(target)
275
+ tt = self.table(tm["table"])
276
+ tcol = tt.c[tm["key"]] if prop.lower() == "id" else self.col(target, prop)
277
+ return ("sub", self.col(list_name, head), tt.c[tm["key"]], tcol)
278
+ if f and f["type"] == "user" and self.mapping.get("users"):
279
+ um = self.mapping["users"]
280
+ ut = self.table(um["table"])
281
+ ucol = {"title": um.get("title", um["login"]), "name": um["login"], "id": um["id"]}.get(prop.lower(), um["login"])
282
+ return ("sub", self.col(list_name, head), ut.c[um["id"]], ut.c[ucol])
283
+ raise ODataError(f"cannot filter on {path}")
284
+ m = re.fullmatch(r"(\w+)Id", path)
285
+ if m and (m.group(1) in self.fields(list_name) or m.group(1) in ("Author", "Editor")):
286
+ return ("col", self.col(list_name, m.group(1)))
287
+ return ("col", self.col(list_name, path))
288
+
289
+ def cmp(c, op, v):
290
+ if isinstance(v, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}(T[\d:.]+Z?)?", v):
291
+ v = datetime.fromisoformat(v.rstrip("Z")) if "T" in v else date.fromisoformat(v)
292
+ return {"eq": lambda: c.is_(None) if v is None else c == v, "ne": lambda: c.is_not(None) if v is None else c != v,
293
+ "gt": lambda: c > v, "ge": lambda: c >= v, "lt": lambda: c < v, "le": lambda: c <= v}[op]()
294
+
295
+ def term():
296
+ kind, v = peek()
297
+ if kind == "(":
298
+ take()
299
+ e = or_expr()
300
+ take(")")
301
+ return e
302
+ if v == "not":
303
+ take()
304
+ return not_(term())
305
+ if v in ("startswith", "substringof", "endswith"):
306
+ take()
307
+ take("(")
308
+ if v == "substringof":
309
+ needle = value(); take(","); path = take("id")[1]; take(")")
310
+ ref = column_for(path)
311
+ pat = f"%{needle}%"
312
+ else:
313
+ path = take("id")[1]; take(","); needle = value(); take(")")
314
+ ref = column_for(path)
315
+ pat = f"{needle}%" if v == "startswith" else f"%{needle}"
316
+ if ref[0] == "sub":
317
+ return ref[1].in_(select(ref[2]).where(ref[3].like(pat)))
318
+ return ref[1].like(pat)
319
+ path = take("id")[1]
320
+ op = take("id")[1]
321
+ if op not in ("eq", "ne", "gt", "ge", "lt", "le"):
322
+ raise ODataError(f"unsupported operator {op}")
323
+ val = value()
324
+ ref = column_for(path)
325
+ if ref[0] == "sub":
326
+ return ref[1].in_(select(ref[2]).where(cmp(ref[3], op, val)))
327
+ return cmp(ref[1], op, val)
328
+
329
+ def and_expr():
330
+ left = term()
331
+ while peek() == ("id", "and"):
332
+ take()
333
+ left = and_(left, term())
334
+ return left
335
+
336
+ def or_expr():
337
+ left = and_expr()
338
+ while peek() == ("id", "or"):
339
+ take()
340
+ left = or_(left, and_expr())
341
+ return left
342
+
343
+ clause = or_expr()
344
+ if pos[0] != len(toks):
345
+ raise ODataError(f"unexpected trailing tokens in $filter: {toks[pos[0]:]}")
346
+ return clause
347
+
348
+ def query(self, list_name: str, filter: Optional[str] = None, orderby: Optional[str] = None,
349
+ top: Optional[int] = None, skip: Optional[int] = None, select_: Optional[str] = None) -> tuple[list[dict], int]:
350
+ m = self.lmap(list_name)
351
+ t = self.table(m["table"])
352
+ where = self._where(list_name, filter)
353
+ q = select(t)
354
+ cq = select(func.count()).select_from(t)
355
+ if where is not None:
356
+ q, cq = q.where(where), cq.where(where)
357
+ for part in [p.strip() for p in (orderby or "").split(",") if p.strip()]:
358
+ name, _, direction = part.partition(" ")
359
+ c = self.col(list_name, name.split("/")[0])
360
+ q = q.order_by(c.desc() if direction.lower() == "desc" else c.asc())
361
+ if not orderby:
362
+ q = q.order_by(t.c[m["key"]])
363
+ if skip:
364
+ q = q.offset(skip)
365
+ if top is not None:
366
+ q = q.limit(top)
367
+ with self.engine.connect() as c:
368
+ rows = c.execute(q).mappings().all()
369
+ total = c.execute(cq).scalar_one()
370
+ cache: dict = {}
371
+ items = [self._to_item(list_name, dict(r), cache) for r in rows]
372
+ if select_:
373
+ keep = {s.strip().split("/")[0] for s in select_.split(",")} | {"ID", "_etag"}
374
+ items = [{k: v for k, v in it.items() if k in keep} for it in items]
375
+ return items, total
376
+
377
+ # ---------------------------------------------------------------- CRUD
378
+ def all(self, list_name: str) -> list[dict]:
379
+ return self.query(list_name)[0]
380
+
381
+ def get(self, list_name: str, iid: Any) -> Optional[dict]:
382
+ m = self.lmap(list_name)
383
+ t = self.table(m["table"])
384
+ with self.engine.connect() as c:
385
+ r = c.execute(select(t).where(t.c[m["key"]] == iid)).mappings().first()
386
+ return self._to_item(list_name, dict(r), {}) if r else None
387
+
388
+ def create(self, list_name: str, values: dict, user: dict) -> dict:
389
+ m = self.lmap(list_name)
390
+ t = self.table(m["table"])
391
+ row = self._to_row(list_name, values)
392
+ sysmap = m.get("system", {})
393
+ if "Created" in sysmap:
394
+ row[sysmap["Created"]] = _now()
395
+ if "Modified" in sysmap:
396
+ row[sysmap["Modified"]] = _now()
397
+ for k in ("Author", "Editor"):
398
+ if k in sysmap:
399
+ row[sysmap[k]] = user.get("login") if m.get("user_ref", "id") == "login" else user.get("id")
400
+ if "version" in sysmap:
401
+ row[sysmap["version"]] = 1
402
+ with self.engine.begin() as c:
403
+ res = c.execute(t.insert().values(row))
404
+ new_id = res.inserted_primary_key[0] if res.inserted_primary_key else row.get(m["key"])
405
+ return self.get(list_name, new_id)
406
+
407
+ def update(self, list_name: str, iid: Any, values: dict, user: dict, etag: Optional[str] = None) -> dict:
408
+ m = self.lmap(list_name)
409
+ t = self.table(m["table"])
410
+ current = self.get(list_name, iid)
411
+ if not current:
412
+ raise KeyError(iid)
413
+ if etag not in (None, "", "*") and etag.strip('"') != current["_etag"].strip('"'):
414
+ raise Conflict()
415
+ row = self._to_row(list_name, values)
416
+ sysmap = m.get("system", {})
417
+ if "Modified" in sysmap:
418
+ row[sysmap["Modified"]] = _now()
419
+ if "Editor" in sysmap:
420
+ row[sysmap["Editor"]] = user.get("login") if m.get("user_ref", "id") == "login" else user.get("id")
421
+ cond = t.c[m["key"]] == iid
422
+ if "version" in sysmap:
423
+ vcol = t.c[sysmap["version"]]
424
+ row[sysmap["version"]] = vcol + 1
425
+ if etag not in (None, "", "*"):
426
+ cond = and_(cond, vcol == int(etag.strip('"')))
427
+ with self.engine.begin() as c:
428
+ res = c.execute(t.update().where(cond).values(row))
429
+ if res.rowcount == 0:
430
+ raise Conflict()
431
+ return self.get(list_name, iid)
432
+
433
+ def delete(self, list_name: str, iid: Any) -> None:
434
+ m = self.lmap(list_name)
435
+ t = self.table(m["table"])
436
+ with self.engine.begin() as c:
437
+ c.execute(t.delete().where(t.c[m["key"]] == iid))
@@ -0,0 +1,224 @@
1
+ """Specs (one JSON per converted screen) and the data repository behind the generic list API."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import json
6
+ import os
7
+ import sqlite3
8
+ import threading
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any, Optional
12
+
13
+ ROOT = Path(__file__).resolve().parent.parent
14
+ SPEC_DIR = Path(os.environ.get("SPEC_DIR", ROOT / "specs"))
15
+ DATA_DIR = Path(os.environ.get("DATA_DIR", ROOT / "data"))
16
+
17
+
18
+ class Specs:
19
+ def __init__(self, spec_dir: Path = SPEC_DIR):
20
+ self.screens: dict[str, dict] = {}
21
+ self.lists: dict[str, dict] = {}
22
+ self.rules: dict[str, list[dict]] = {}
23
+ self.endpoints: list[dict] = []
24
+ for f in sorted(spec_dir.glob("*.json")):
25
+ spec = json.loads(f.read_text(encoding="utf-8"))
26
+ sid = spec["screen"]["id"]
27
+ self.screens[sid] = spec
28
+ for name, l in spec["lists"].items():
29
+ cur = self.lists.setdefault(name, {"title": l["title"], "fields": [], "attachments": False})
30
+ known = {x["name"] for x in cur["fields"]}
31
+ cur["fields"] += [x for x in l["fields"] if x["name"] not in known]
32
+ cur["attachments"] = cur["attachments"] or l.get("attachments", False)
33
+ primary = spec["screen"].get("list")
34
+ for r in spec.get("rules", []):
35
+ lst = r.get("list") or primary
36
+ if lst:
37
+ self.rules.setdefault(lst, []).append({**r, "id": f"{sid}:{r['id']}"})
38
+ self.endpoints += [{**e, "screen": sid} for e in spec.get("endpoints", [])]
39
+
40
+ def fields(self, list_name: str) -> list[dict]:
41
+ if list_name not in self.lists:
42
+ raise KeyError(list_name)
43
+ return self.lists[list_name]["fields"]
44
+
45
+ def field(self, list_name: str, name: str) -> Optional[dict]:
46
+ return next((f for f in self.fields(list_name) if f["name"] == name), None)
47
+
48
+ def list_rules(self, list_name: str) -> list[dict]:
49
+ # several screens can carry the same rule for one list (e.g. a form and a dashboard): keep one copy
50
+ seen, out = set(), []
51
+ for r in self.rules.get(list_name, []):
52
+ key = json.dumps({k: r.get(k) for k in ("kind", "field", "when", "params", "message", "modes")}, sort_keys=True)
53
+ if key not in seen:
54
+ seen.add(key)
55
+ out.append(r)
56
+ return out
57
+
58
+ def roles_for(self, method: str, path: str) -> list[str]:
59
+ roles: list[str] = []
60
+ for e in self.endpoints:
61
+ if e["method"] == method and e["path"] == path:
62
+ roles += e.get("roles", [])
63
+ return sorted(set(roles))
64
+
65
+
66
+ def now() -> str:
67
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
68
+
69
+
70
+ class Conflict(Exception):
71
+ pass
72
+
73
+
74
+ class _Rows(list):
75
+ """fetchall() result with the cursor methods the repository uses."""
76
+
77
+ def fetchall(self):
78
+ return list(self)
79
+
80
+ def fetchone(self):
81
+ return self[0] if self else None
82
+
83
+
84
+ class SqliteRepo:
85
+ """Migrated copy of the SharePoint lists. Lookups/users are stored as ids and expanded on read."""
86
+
87
+ def __init__(self, specs: Specs, db_path: Optional[Path] = None):
88
+ self.specs = specs
89
+ self.path = Path(db_path or os.environ.get("DB_PATH", DATA_DIR / "app.db"))
90
+ self.path.parent.mkdir(parents=True, exist_ok=True)
91
+ self._lock = threading.RLock()
92
+ self.db = sqlite3.connect(self.path, check_same_thread=False) # one connection, serialised by self._lock
93
+ self._x("create table if not exists items (list text, id integer, data text, version integer, created text, modified text, author integer, editor integer, primary key (list, id))")
94
+ self._x("create table if not exists attachments (list text, item_id integer, name text, content blob, primary key (list, item_id, name))")
95
+ self._commit()
96
+ self.users = json.loads((DATA_DIR / "users.json").read_text(encoding="utf-8")) if (DATA_DIR / "users.json").exists() else []
97
+ if not self._x("select 1 from items limit 1").fetchone():
98
+ self.seed()
99
+
100
+ def _x(self, sql: str, params: tuple = ()):
101
+ with self._lock:
102
+ return _Rows(self.db.execute(sql, params).fetchall())
103
+
104
+ def _commit(self) -> None:
105
+ with self._lock:
106
+ self.db.commit()
107
+
108
+ # ------------------------------------------------------------- users
109
+ def user(self, uid: Any) -> Optional[dict]:
110
+ if isinstance(uid, dict):
111
+ uid = uid.get("id")
112
+ u = next((u for u in self.users if u["id"] == uid), None)
113
+ return {"id": u["id"], "login": u["login"], "title": u["title"], "email": u.get("email")} if u else None
114
+
115
+ def user_by_login(self, login: str) -> Optional[dict]:
116
+ login = (login or "").lower()
117
+ return next((u for u in self.users if u["login"].lower() == login or (u.get("email") or "").lower() == login), None)
118
+
119
+ # -------------------------------------------------------------- seed
120
+ def seed(self) -> None:
121
+ seed_dir = DATA_DIR / "seed"
122
+ for f in sorted(seed_dir.glob("*.json")) if seed_dir.exists() else []:
123
+ for row in json.loads(f.read_text(encoding="utf-8")):
124
+ meta = row.pop("_meta", {})
125
+ self._x("insert or replace into items values (?,?,?,?,?,?,?,?)",
126
+ (f.stem, row["ID"], json.dumps(row), meta.get("version", 1), meta.get("created") or now(),
127
+ meta.get("modified") or now(), meta.get("author"), meta.get("editor")))
128
+ att = DATA_DIR / "attachments" # migrated attachments: attachments/<List>/<ID>/<file>
129
+ for lst in sorted(att.iterdir()) if att.exists() else []:
130
+ for item in sorted(lst.iterdir()):
131
+ for f in sorted(item.iterdir()):
132
+ self._x("insert or replace into attachments values (?,?,?,?)", (lst.name, int(item.name), f.name, f.read_bytes()))
133
+ self._commit()
134
+
135
+ # ------------------------------------------------------------ expand
136
+ def _expand(self, list_name: str, row: tuple) -> dict:
137
+ _, iid, data, version, created, modified, author, editor = row
138
+ raw = json.loads(data)
139
+ item: dict = {"ID": iid}
140
+ fields = {f["name"]: f for f in self.specs.lists.get(list_name, {}).get("fields", [])}
141
+ for name, v in raw.items():
142
+ f = fields.get(name)
143
+ if f and f["type"] == "lookup":
144
+ item[name] = self._lookup_obj(f, v)
145
+ elif f and f["type"] == "user":
146
+ item[name] = [self.user(x) for x in v] if isinstance(v, list) else (self.user(v) if v else None)
147
+ else:
148
+ item[name] = v
149
+ for name, f in fields.items():
150
+ item.setdefault(name, None)
151
+ item.update({"Created": created, "Modified": modified, "Author": self.user(author), "Editor": self.user(editor),
152
+ "_version": f"{version}.0", "_etag": f'"{version}"'})
153
+ item["Attachments"] = [r[0] for r in self._x("select name from attachments where list=? and item_id=? order by name", (list_name, iid))]
154
+ return item
155
+
156
+ def _lookup_obj(self, f: dict, v: Any) -> Any:
157
+ target, col = f["lookup"]["list"], f["lookup"].get("field", "Title")
158
+
159
+ def one(x):
160
+ if x in (None, "", 0):
161
+ return None
162
+ xid = x.get("id") if isinstance(x, dict) else int(x)
163
+ row = self._x("select data from items where list=? and id=?", (target, xid)).fetchone()
164
+ title = json.loads(row[0]).get(col) if row else None
165
+ return {"id": xid, "title": title}
166
+ return [o for o in (one(x) for x in v) if o] if isinstance(v, list) else one(v)
167
+
168
+ def _store_value(self, f: Optional[dict], v: Any) -> Any:
169
+ if f and f["type"] in ("lookup", "user"):
170
+ if isinstance(v, list):
171
+ return [x.get("id") if isinstance(x, dict) else x for x in v]
172
+ return v.get("id") if isinstance(v, dict) else v
173
+ return v
174
+
175
+ # -------------------------------------------------------------- CRUD
176
+ def all(self, list_name: str) -> list[dict]:
177
+ rows = self._x("select * from items where list=? order by id", (list_name,)).fetchall()
178
+ return [self._expand(list_name, r) for r in rows]
179
+
180
+ def get(self, list_name: str, iid: int) -> Optional[dict]:
181
+ row = self._x("select * from items where list=? and id=?", (list_name, iid)).fetchone()
182
+ return self._expand(list_name, row) if row else None
183
+
184
+ def create(self, list_name: str, values: dict, user: dict) -> dict:
185
+ fields = {f["name"]: f for f in self.specs.lists.get(list_name, {}).get("fields", [])}
186
+ data = {k: self._store_value(fields.get(k), v) for k, v in values.items() if k in fields}
187
+ with self._lock:
188
+ nid = (self._x("select max(id) from items where list=?", (list_name,)).fetchone()[0] or 0) + 1
189
+ data["ID"] = nid
190
+ self._x("insert into items values (?,?,?,?,?,?,?,?)", (list_name, nid, json.dumps(data), 1, now(), now(), user.get("id"), user.get("id")))
191
+ self._commit()
192
+ return self.get(list_name, nid)
193
+
194
+ def update(self, list_name: str, iid: int, values: dict, user: dict, etag: Optional[str] = None) -> dict:
195
+ fields = {f["name"]: f for f in self.specs.lists.get(list_name, {}).get("fields", [])}
196
+ with self._lock:
197
+ row = self._x("select data, version from items where list=? and id=?", (list_name, iid)).fetchone()
198
+ if not row:
199
+ raise KeyError(iid)
200
+ if etag not in (None, "", "*") and etag.strip('"') != str(row[1]):
201
+ raise Conflict()
202
+ data = json.loads(row[0])
203
+ data.update({k: self._store_value(fields.get(k), v) for k, v in values.items() if k in fields})
204
+ self._x("update items set data=?, version=?, modified=?, editor=? where list=? and id=?",
205
+ (json.dumps(data), row[1] + 1, now(), user.get("id"), list_name, iid))
206
+ self._commit()
207
+ return self.get(list_name, iid)
208
+
209
+ def delete(self, list_name: str, iid: int) -> None:
210
+ self._x("delete from items where list=? and id=?", (list_name, iid))
211
+ self._x("delete from attachments where list=? and item_id=?", (list_name, iid))
212
+ self._commit()
213
+
214
+ def add_attachment(self, list_name: str, iid: int, name: str, content_b64: str) -> None:
215
+ self._x("insert or replace into attachments values (?,?,?,?)", (list_name, iid, name, base64.b64decode(content_b64)))
216
+ self._commit()
217
+
218
+ def attachment(self, list_name: str, iid: int, name: str) -> Optional[bytes]:
219
+ r = self._x("select content from attachments where list=? and item_id=? and name=?", (list_name, iid, name)).fetchone()
220
+ return r[0] if r else None
221
+
222
+ def delete_attachment(self, list_name: str, iid: int, name: str) -> None:
223
+ self._x("delete from attachments where list=? and item_id=? and name=?", (list_name, iid, name))
224
+ self._commit()
@@ -0,0 +1,8 @@
1
+ fastapi>=0.110
2
+ uvicorn[standard]>=0.27
3
+ pydantic>=2.5
4
+ requests>=2.31
5
+ # requests-ntlm>=1.2 # only for DATA_SOURCE=sharepoint with SP_AUTH=ntlm
6
+ sqlalchemy>=2.0
7
+ # pymysql>=1.1 # DATA_SOURCE=database with MySQL / MariaDB (mysql+pymysql://...)
8
+ # pyodbc>=5.0 # DATA_SOURCE=database with SQL Server (mssql+pyodbc://...)