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.
- sp2react/__init__.py +18 -0
- sp2react/agents/__init__.py +0 -0
- sp2react/agents/core.py +146 -0
- sp2react/agents/prompts/dbmap.md +28 -0
- sp2react/agents/prompts/fix.md +11 -0
- sp2react/agents/prompts/handler.md +22 -0
- sp2react/agents/prompts/page.md +40 -0
- sp2react/agents/prompts/review.md +21 -0
- sp2react/agents/prompts/spec.md +79 -0
- sp2react/agents/tasks.py +374 -0
- sp2react/analysis/__init__.py +0 -0
- sp2react/analysis/aspx.py +254 -0
- sp2react/analysis/behaviors.py +357 -0
- sp2react/analysis/fields.py +152 -0
- sp2react/analysis/inventory.py +314 -0
- sp2react/analysis/jsscan.py +330 -0
- sp2react/checks.py +105 -0
- sp2react/cli.py +188 -0
- sp2react/config.py +172 -0
- sp2react/dbmap.py +245 -0
- sp2react/llm/__init__.py +0 -0
- sp2react/llm/providers.py +219 -0
- sp2react/migrate.py +255 -0
- sp2react/net.py +60 -0
- sp2react/pipeline.py +583 -0
- sp2react/scaffold/__init__.py +0 -0
- sp2react/scaffold/generate.py +279 -0
- sp2react/scaffold/rule_vectors.json +3826 -0
- sp2react/scaffold/templates/backend/app/__init__.py +0 -0
- sp2react/scaffold/templates/backend/app/api.py +293 -0
- sp2react/scaffold/templates/backend/app/auth.py +33 -0
- sp2react/scaffold/templates/backend/app/custom/__init__.py +22 -0
- sp2react/scaffold/templates/backend/app/main.py +47 -0
- sp2react/scaffold/templates/backend/app/odata.py +185 -0
- sp2react/scaffold/templates/backend/app/rules.py +256 -0
- sp2react/scaffold/templates/backend/app/sp_repo.py +159 -0
- sp2react/scaffold/templates/backend/app/sql_repo.py +437 -0
- sp2react/scaffold/templates/backend/app/store.py +224 -0
- sp2react/scaffold/templates/backend/requirements.txt +8 -0
- sp2react/scaffold/templates/backend/tests/conftest.py +10 -0
- sp2react/scaffold/templates/backend/tests/test_rule_vectors.py +38 -0
- sp2react/scaffold/templates/frontend/index.html +12 -0
- sp2react/scaffold/templates/frontend/package.json +24 -0
- sp2react/scaffold/templates/frontend/scripts/check-rules.ts +23 -0
- sp2react/scaffold/templates/frontend/src/api.ts +88 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Banner.tsx +4 -0
- sp2react/scaffold/templates/frontend/src/components/sp/DataTable.tsx +38 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Form.tsx +252 -0
- sp2react/scaffold/templates/frontend/src/components/sp/GenericPage.tsx +28 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Layout.tsx +54 -0
- sp2react/scaffold/templates/frontend/src/components/sp/LegacyHtml.tsx +9 -0
- sp2react/scaffold/templates/frontend/src/components/sp/ListView.tsx +59 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Tiles.tsx +21 -0
- sp2react/scaffold/templates/frontend/src/components/sp/fields.tsx +132 -0
- sp2react/scaffold/templates/frontend/src/custom/types.ts +10 -0
- sp2react/scaffold/templates/frontend/src/main.tsx +14 -0
- sp2react/scaffold/templates/frontend/src/runtime/format.ts +36 -0
- sp2react/scaffold/templates/frontend/src/runtime/rules.ts +225 -0
- sp2react/scaffold/templates/frontend/src/runtime/spec.ts +102 -0
- sp2react/scaffold/templates/frontend/src/runtime/user.tsx +21 -0
- sp2react/scaffold/templates/frontend/src/screens/LegacyRedirect.tsx +27 -0
- sp2react/scaffold/templates/frontend/src/screens/ListFormScreen.tsx +33 -0
- sp2react/scaffold/templates/frontend/src/styles/sp.css +95 -0
- sp2react/scaffold/templates/frontend/src/vite-env.d.ts +1 -0
- sp2react/scaffold/templates/frontend/tsconfig.json +18 -0
- sp2react/scaffold/templates/frontend/vite.config.ts +7 -0
- sp2react/sharepoint/__init__.py +0 -0
- sp2react/sharepoint/client.py +210 -0
- sp2react/sharepoint/extract.py +217 -0
- sp2react/spec/__init__.py +0 -0
- sp2react/spec/base.py +80 -0
- sp2react/spec/coverage.py +191 -0
- sp2react/spec/model.py +133 -0
- sp2react-0.1.0.dist-info/METADATA +238 -0
- sp2react-0.1.0.dist-info/RECORD +79 -0
- sp2react-0.1.0.dist-info/WHEEL +5 -0
- sp2react-0.1.0.dist-info/entry_points.txt +2 -0
- sp2react-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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://...)
|