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
|
File without changes
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Generic list API driven by the screen specs (replaces the legacy /_api and SPServices calls)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Request
|
|
9
|
+
from fastapi.responses import JSONResponse, Response
|
|
10
|
+
|
|
11
|
+
from . import rules as R
|
|
12
|
+
from .auth import current_user
|
|
13
|
+
from .custom import HANDLERS
|
|
14
|
+
from .odata import ODataError, apply_query
|
|
15
|
+
from .store import Conflict
|
|
16
|
+
|
|
17
|
+
router = APIRouter(prefix="/api")
|
|
18
|
+
SAVE_CONFLICT = "Save Conflict. Your changes conflict with those made concurrently by another user. Refresh the page and resubmit your changes."
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _specs(request: Request):
|
|
22
|
+
return request.app.state.specs
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _repo(request: Request):
|
|
26
|
+
return request.app.state.repo
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _list(request: Request, list_name: str) -> list[dict]:
|
|
30
|
+
try:
|
|
31
|
+
return _specs(request).fields(list_name)
|
|
32
|
+
except KeyError:
|
|
33
|
+
raise HTTPException(404, f"List '{list_name}' is not part of the converted screens")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _authorize(request: Request, user: dict, method: str, path: str) -> None:
|
|
37
|
+
roles = _specs(request).roles_for(method, path)
|
|
38
|
+
if roles and not set(roles) & set(user["groups"]):
|
|
39
|
+
raise HTTPException(403, "Sorry, you don't have access to this page.")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ----------------------------------------------------------------- meta
|
|
43
|
+
@router.get("/me")
|
|
44
|
+
def me(user: dict = Depends(current_user)):
|
|
45
|
+
return user
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.get("/users")
|
|
49
|
+
def users(request: Request, search: str = "", user: dict = Depends(current_user)):
|
|
50
|
+
s = search.lower()
|
|
51
|
+
pool = getattr(_repo(request), "users", []) or []
|
|
52
|
+
return [{"id": u["id"], "login": u["login"], "title": u["title"], "email": u.get("email")}
|
|
53
|
+
for u in pool if s in u["title"].lower() or s in u["login"].lower() or s in (u.get("email") or "").lower()][:20]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@router.get("/screens")
|
|
57
|
+
def screens(request: Request):
|
|
58
|
+
return [{"id": sid, "title": s["screen"]["title"], "route": s["screen"]["route"], "kind": s["screen"]["kind"],
|
|
59
|
+
"legacy_urls": s["screen"].get("legacy_urls", [])} for sid, s in _specs(request).screens.items()]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@router.get("/specs/{screen_id}")
|
|
63
|
+
def spec(request: Request, screen_id: str):
|
|
64
|
+
s = _specs(request).screens.get(screen_id)
|
|
65
|
+
if not s:
|
|
66
|
+
raise HTTPException(404, "Unknown screen")
|
|
67
|
+
return s
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------- items
|
|
71
|
+
@router.get("/lists/{list_name}/items")
|
|
72
|
+
def query_items(request: Request, list_name: str, user: dict = Depends(current_user),
|
|
73
|
+
filter_: Optional[str] = Query(None, alias="$filter"), orderby: Optional[str] = Query(None, alias="$orderby"),
|
|
74
|
+
top: Optional[int] = Query(None, alias="$top"), skip: Optional[int] = Query(None, alias="$skip"),
|
|
75
|
+
select: Optional[str] = Query(None, alias="$select")):
|
|
76
|
+
_list(request, list_name)
|
|
77
|
+
_authorize(request, user, "GET", f"/api/lists/{list_name}/items")
|
|
78
|
+
repo = _repo(request)
|
|
79
|
+
try:
|
|
80
|
+
if hasattr(repo, "query"): # database: filter/sort/page run as SQL
|
|
81
|
+
page, total = repo.query(list_name, filter_, orderby, top, skip, select)
|
|
82
|
+
return {"value": page, "count": total}
|
|
83
|
+
items = repo.all(list_name)
|
|
84
|
+
filtered = apply_query(items, filter_)
|
|
85
|
+
page = apply_query(filtered, None, orderby, top, skip, select)
|
|
86
|
+
except ODataError as e:
|
|
87
|
+
raise HTTPException(400, str(e))
|
|
88
|
+
return {"value": page, "count": len(filtered)}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@router.get("/lists/{list_name}/items/{iid}")
|
|
92
|
+
def get_item(request: Request, list_name: str, iid: int, user: dict = Depends(current_user)):
|
|
93
|
+
_list(request, list_name)
|
|
94
|
+
_authorize(request, user, "GET", f"/api/lists/{list_name}/items/{{id}}")
|
|
95
|
+
item = _repo(request).get(list_name, iid)
|
|
96
|
+
if not item:
|
|
97
|
+
raise HTTPException(404, "Item does not exist. It may have been deleted by another user.")
|
|
98
|
+
return item
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@router.get("/lists/{list_name}/items/{iid}/state")
|
|
102
|
+
def item_state(request: Request, list_name: str, iid: int, mode: str = "edit", user: dict = Depends(current_user)):
|
|
103
|
+
"""Server-side answer to 'can this user edit this item' (lock_item) and which banners apply."""
|
|
104
|
+
_list(request, list_name)
|
|
105
|
+
item = get_item(request, list_name, iid, user)
|
|
106
|
+
rules = _specs(request).list_rules(list_name)
|
|
107
|
+
ctx = {"mode": mode, "values": item, "original": item, "user": user}
|
|
108
|
+
locked = R.lock(rules, ctx)
|
|
109
|
+
return {"locked": bool(locked), "message": R.fill_template(locked.get("message", ""), item) if locked else None,
|
|
110
|
+
"redirect": (locked.get("params") or {}).get("redirect", "display") if locked else None,
|
|
111
|
+
"banners": [R.fill_template(b.get("message", ""), item) for b in R.banners(rules, ctx)]}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _find_duplicate(repo, list_name: str, field: str, value: Any, exclude: Any) -> Optional[dict]:
|
|
115
|
+
if hasattr(repo, "query"):
|
|
116
|
+
lit = str(value).replace("'", "''")
|
|
117
|
+
rows, _ = repo.query(list_name, f"{field} eq '{lit}'", None, 5, None, None)
|
|
118
|
+
return next((it for it in rows if it["ID"] != exclude), None)
|
|
119
|
+
return next((it for it in repo.all(list_name) if str(it.get(field) or "") == str(value) and it["ID"] != exclude), None)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _coerce(field: Optional[dict], v: Any) -> Any:
|
|
123
|
+
if field and field["type"] in ("lookup", "user") and isinstance(v, str) and v.isdigit():
|
|
124
|
+
return int(v)
|
|
125
|
+
if field and field["type"] in ("number", "currency") and isinstance(v, str) and re.fullmatch(r"\s*\$?-?[\d,]+(\.\d+)?\s*", v):
|
|
126
|
+
return float(v.replace(",", "").replace("$", ""))
|
|
127
|
+
return v
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _derive(request: Request, list_name: str, rules: list[dict], values: dict, mode: str) -> None:
|
|
131
|
+
specs, repo = _specs(request), _repo(request)
|
|
132
|
+
for r in R.rules_for(rules, "derive", None, mode):
|
|
133
|
+
src = specs.field(list_name, r["params"]["from_field"])
|
|
134
|
+
ref = values.get(r["params"]["from_field"])
|
|
135
|
+
if not src or not src.get("lookup"):
|
|
136
|
+
continue
|
|
137
|
+
rid = ref.get("id") if isinstance(ref, dict) else ref
|
|
138
|
+
target = repo.get(src["lookup"]["list"], int(rid)) if rid else None
|
|
139
|
+
v = target.get(r["params"]["column"]) if target else None
|
|
140
|
+
values[r["field"]] = v.get("title") if isinstance(v, dict) else v
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _save(request: Request, list_name: str, mode: str, payload: dict, user: dict, iid: Optional[int] = None, etag: Optional[str] = None):
|
|
144
|
+
fields = _list(request, list_name)
|
|
145
|
+
specs, repo = _specs(request), _repo(request)
|
|
146
|
+
rules = specs.list_rules(list_name)
|
|
147
|
+
by_name = {f["name"]: f for f in fields}
|
|
148
|
+
original = repo.get(list_name, iid) if iid else {}
|
|
149
|
+
if iid and not original:
|
|
150
|
+
raise HTTPException(404, "Item does not exist. It may have been deleted by another user.")
|
|
151
|
+
if mode == "edit":
|
|
152
|
+
locked = R.lock(rules, {"mode": mode, "values": original, "original": original, "user": user})
|
|
153
|
+
if locked:
|
|
154
|
+
msg = R.fill_template(locked.get("message", ""), original)
|
|
155
|
+
return JSONResponse(status_code=423, content={"message": msg, "redirect": (locked.get("params") or {}).get("redirect", "display")})
|
|
156
|
+
|
|
157
|
+
values: dict = dict(original) if mode == "edit" else R.defaults(fields, rules, user)
|
|
158
|
+
incoming = {k: _coerce(by_name.get(k), v) for k, v in (payload or {}).items() if k in by_name}
|
|
159
|
+
probe = {**values, **incoming}
|
|
160
|
+
ctx = {"mode": mode, "values": probe, "original": original, "user": user}
|
|
161
|
+
for name, v in incoming.items():
|
|
162
|
+
st = R.field_state(by_name[name], rules, ctx)
|
|
163
|
+
if st["visible"] and not st["readonly"]:
|
|
164
|
+
values[name] = v
|
|
165
|
+
elif not st["visible"] and mode == "new":
|
|
166
|
+
values.pop(name, None) if name not in R.defaults(fields, rules, user) else None
|
|
167
|
+
_derive(request, list_name, rules, values, mode)
|
|
168
|
+
values.update(R.clear_hidden(fields, rules, {"mode": mode, "values": values, "original": original, "user": user}))
|
|
169
|
+
ctx = {"mode": mode, "values": values, "original": original, "user": user}
|
|
170
|
+
values.update(R.set_on_save(rules, ctx))
|
|
171
|
+
errors = R.validate(fields, rules, ctx)
|
|
172
|
+
|
|
173
|
+
for r in R.rules_for(rules, "unique", None, mode):
|
|
174
|
+
v = values.get(r["field"])
|
|
175
|
+
if R.is_empty(v) or not R.evaluate(r.get("when"), ctx):
|
|
176
|
+
continue
|
|
177
|
+
dup = _find_duplicate(repo, list_name, r["field"], v, iid)
|
|
178
|
+
if dup:
|
|
179
|
+
errors.append({"field": r["field"], "message": R.fill_template(r.get("message", ""), {**values, "duplicate_id": dup["ID"], "id": dup["ID"]}),
|
|
180
|
+
"presentation": r.get("presentation", "inline"), "rule": r["id"]})
|
|
181
|
+
for r in R.rules_for(rules, "custom", None, mode):
|
|
182
|
+
mod = HANDLERS.get(r["params"]["handler"])
|
|
183
|
+
if mod and hasattr(mod, "validate") and R.evaluate(r.get("when"), ctx):
|
|
184
|
+
errors += mod.validate(ctx) or []
|
|
185
|
+
if errors:
|
|
186
|
+
return JSONResponse(status_code=422, content={"errors": errors})
|
|
187
|
+
|
|
188
|
+
to_store = {k: values.get(k) for k in by_name if k in values}
|
|
189
|
+
try:
|
|
190
|
+
item = repo.create(list_name, to_store, user) if mode == "new" else repo.update(list_name, iid, to_store, user, etag)
|
|
191
|
+
except Conflict:
|
|
192
|
+
return JSONResponse(status_code=412, content={"message": SAVE_CONFLICT})
|
|
193
|
+
|
|
194
|
+
saved_ctx = {"mode": mode, "values": item, "original": original, "user": user}
|
|
195
|
+
for r in R.rules_for(rules, "on_save", None, mode):
|
|
196
|
+
if not R.evaluate(r.get("when"), saved_ctx):
|
|
197
|
+
continue
|
|
198
|
+
act = r["params"]["action"]
|
|
199
|
+
if act.get("type") == "create_item":
|
|
200
|
+
tfields = {f["name"]: f for f in specs.fields(act["list"])}
|
|
201
|
+
vals = {k: _coerce(tfields.get(k), R.fill_template(v, item) if isinstance(v, str) else v) for k, v in act.get("values", {}).items()}
|
|
202
|
+
repo.create(act["list"], vals, user)
|
|
203
|
+
elif act.get("type") == "custom":
|
|
204
|
+
mod = HANDLERS.get(act.get("handler", ""))
|
|
205
|
+
if mod and hasattr(mod, "on_save"):
|
|
206
|
+
mod.on_save(saved_ctx, item, repo)
|
|
207
|
+
for r in R.rules_for(rules, "custom", None, mode):
|
|
208
|
+
mod = HANDLERS.get(r["params"]["handler"])
|
|
209
|
+
if mod and hasattr(mod, "on_save") and R.evaluate(r.get("when"), saved_ctx):
|
|
210
|
+
mod.on_save(saved_ctx, item, repo)
|
|
211
|
+
return JSONResponse(status_code=201 if mode == "new" else 200, content=item)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@router.post("/lists/{list_name}/items")
|
|
215
|
+
def create_item(request: Request, list_name: str, body: dict = Body(...), user: dict = Depends(current_user)):
|
|
216
|
+
_list(request, list_name)
|
|
217
|
+
_authorize(request, user, "POST", f"/api/lists/{list_name}/items")
|
|
218
|
+
return _save(request, list_name, "new", body.get("values", body), user)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@router.patch("/lists/{list_name}/items/{iid}")
|
|
222
|
+
def update_item(request: Request, list_name: str, iid: int, body: dict = Body(...), user: dict = Depends(current_user),
|
|
223
|
+
if_match: Optional[str] = Header(None)):
|
|
224
|
+
_list(request, list_name)
|
|
225
|
+
_authorize(request, user, "PATCH", f"/api/lists/{list_name}/items/{{id}}")
|
|
226
|
+
return _save(request, list_name, "edit", body.get("values", body), user, iid, if_match)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@router.delete("/lists/{list_name}/items/{iid}", status_code=204)
|
|
230
|
+
def delete_item(request: Request, list_name: str, iid: int, user: dict = Depends(current_user)):
|
|
231
|
+
_list(request, list_name)
|
|
232
|
+
_authorize(request, user, "DELETE", f"/api/lists/{list_name}/items/{{id}}")
|
|
233
|
+
_repo(request).delete(list_name, iid)
|
|
234
|
+
return Response(status_code=204)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# -------------------------------------------------------- lookups etc.
|
|
238
|
+
@router.get("/lists/{list_name}/lookup/{field}")
|
|
239
|
+
def lookup_options(request: Request, list_name: str, field: str, parent: Optional[int] = None, q: str = "",
|
|
240
|
+
user: dict = Depends(current_user)):
|
|
241
|
+
specs, repo = _specs(request), _repo(request)
|
|
242
|
+
_list(request, list_name)
|
|
243
|
+
f = specs.field(list_name, field)
|
|
244
|
+
if not f or not f.get("lookup"):
|
|
245
|
+
raise HTTPException(404, "Not a lookup field")
|
|
246
|
+
target, col = f["lookup"]["list"], f["lookup"].get("field", "Title")
|
|
247
|
+
items = repo.all(target) if target in specs.lists else []
|
|
248
|
+
cascade = next((r for r in specs.list_rules(list_name) if r["kind"] == "cascade" and r.get("field") == field), None)
|
|
249
|
+
if cascade and parent is not None:
|
|
250
|
+
via = cascade["params"]["via"]
|
|
251
|
+
items = [it for it in items if (it.get(via) or {}).get("id") == parent]
|
|
252
|
+
elif cascade:
|
|
253
|
+
items = []
|
|
254
|
+
extra = sorted({r["params"]["column"] for r in specs.list_rules(list_name)
|
|
255
|
+
if r["kind"] == "derive" and r["params"]["from_field"] == field})
|
|
256
|
+
out = []
|
|
257
|
+
for it in items:
|
|
258
|
+
title = it.get(col)
|
|
259
|
+
title = title.get("title") if isinstance(title, dict) else title
|
|
260
|
+
if q and q.lower() not in str(title or "").lower():
|
|
261
|
+
continue
|
|
262
|
+
out.append({"id": it["ID"], "title": title, **{c: (it.get(c).get("title") if isinstance(it.get(c), dict) else it.get(c)) for c in extra}})
|
|
263
|
+
return sorted(out, key=lambda o: str(o["title"] or "").lower())
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@router.get("/lists/{list_name}/unique/{field}")
|
|
267
|
+
def unique_check(request: Request, list_name: str, field: str, value: str, exclude: int = 0, user: dict = Depends(current_user)):
|
|
268
|
+
_list(request, list_name)
|
|
269
|
+
dup = _find_duplicate(_repo(request), list_name, field, value, exclude)
|
|
270
|
+
return {"duplicate_id": dup["ID"] if dup else None}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@router.get("/lists/{list_name}/items/{iid}/attachments/{name}")
|
|
274
|
+
def get_attachment(request: Request, list_name: str, iid: int, name: str, user: dict = Depends(current_user)):
|
|
275
|
+
data = _repo(request).attachment(list_name, iid, name)
|
|
276
|
+
if data is None:
|
|
277
|
+
raise HTTPException(404, "Attachment not found")
|
|
278
|
+
return Response(content=data, media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{name}"'})
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@router.post("/lists/{list_name}/items/{iid}/attachments")
|
|
282
|
+
def add_attachment(request: Request, list_name: str, iid: int, body: dict = Body(...), user: dict = Depends(current_user)):
|
|
283
|
+
if not _specs(request).lists.get(list_name, {}).get("attachments"):
|
|
284
|
+
raise HTTPException(400, "Attachments are disabled for this list")
|
|
285
|
+
base64.b64decode(body["content_base64"], validate=True)
|
|
286
|
+
_repo(request).add_attachment(list_name, iid, body["name"], body["content_base64"])
|
|
287
|
+
return {"ok": True}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@router.delete("/lists/{list_name}/items/{iid}/attachments/{name}", status_code=204)
|
|
291
|
+
def delete_attachment(request: Request, list_name: str, iid: int, name: str, user: dict = Depends(current_user)):
|
|
292
|
+
_repo(request).delete_attachment(list_name, iid, name)
|
|
293
|
+
return Response(status_code=204)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Who is calling. SharePoint groups become the user's roles, so spec conditions like
|
|
2
|
+
{"role": "Compliance Reviewers"} keep working unchanged.
|
|
3
|
+
|
|
4
|
+
AUTH_MODE=dev (default) X-User header or DEV_USER env picks a seeded user - local testing only.
|
|
5
|
+
AUTH_MODE=header trust an identity header set by your gateway (e.g. X-MS-CLIENT-PRINCIPAL-NAME from
|
|
6
|
+
Azure App Service authentication); never expose the API directly in this mode.
|
|
7
|
+
Replace current_user() with Entra ID token validation (fastapi-azure-auth / msal) for production.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from fastapi import HTTPException, Request
|
|
14
|
+
|
|
15
|
+
AUTH_MODE = os.environ.get("AUTH_MODE", "dev")
|
|
16
|
+
IDENTITY_HEADER = os.environ.get("IDENTITY_HEADER", "X-MS-CLIENT-PRINCIPAL-NAME")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def current_user(request: Request) -> dict:
|
|
20
|
+
repo = request.app.state.repo
|
|
21
|
+
if AUTH_MODE == "dev":
|
|
22
|
+
login = request.headers.get("X-User") or request.query_params.get("as") or os.environ.get("DEV_USER", "")
|
|
23
|
+
u = repo.user_by_login(login) if login else (repo.users[0] if getattr(repo, "users", None) else None)
|
|
24
|
+
elif AUTH_MODE == "header":
|
|
25
|
+
login = request.headers.get(IDENTITY_HEADER)
|
|
26
|
+
if not login:
|
|
27
|
+
raise HTTPException(401, "Not signed in")
|
|
28
|
+
u = repo.user_by_login(login)
|
|
29
|
+
else:
|
|
30
|
+
raise HTTPException(500, f"Unknown AUTH_MODE {AUTH_MODE}")
|
|
31
|
+
if not u:
|
|
32
|
+
raise HTTPException(403, "Your account does not have access to this site.")
|
|
33
|
+
return {"id": u["id"], "login": u["login"], "title": u["title"], "email": u.get("email"), "groups": u.get("groups", [])}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Agent-written server code for behaviour the generic rule engine cannot express.
|
|
2
|
+
|
|
3
|
+
Each module here may define any of:
|
|
4
|
+
def validate(ctx: dict) -> list[dict] # extra errors: {"field", "message", "presentation"}
|
|
5
|
+
def on_save(ctx: dict, item: dict, repo) -> None
|
|
6
|
+
router: fastapi.APIRouter # custom endpoints (spec endpoints with custom=true)
|
|
7
|
+
and is bound to rules of kind "custom" through params.handler == module name.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import pkgutil
|
|
13
|
+
from types import ModuleType
|
|
14
|
+
|
|
15
|
+
HANDLERS: dict[str, ModuleType] = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load() -> dict[str, ModuleType]:
|
|
19
|
+
for m in pkgutil.iter_modules(__path__):
|
|
20
|
+
if not m.name.startswith("_"):
|
|
21
|
+
HANDLERS[m.name] = importlib.import_module(f"{__name__}.{m.name}")
|
|
22
|
+
return HANDLERS
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""FastAPI app generated by sp2react. Run: uvicorn app.main:app --reload"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
9
|
+
from fastapi.responses import FileResponse
|
|
10
|
+
from fastapi.staticfiles import StaticFiles
|
|
11
|
+
|
|
12
|
+
from . import custom
|
|
13
|
+
from .api import router
|
|
14
|
+
from .store import Specs, SqliteRepo
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_app() -> FastAPI:
|
|
18
|
+
app = FastAPI(title="Converted SharePoint screens", version="1.0")
|
|
19
|
+
app.state.specs = Specs()
|
|
20
|
+
source = os.environ.get("DATA_SOURCE", "__DEFAULT_SOURCE__")
|
|
21
|
+
if source == "database":
|
|
22
|
+
from .sql_repo import SqlRepo # your existing tables, through mapping.json
|
|
23
|
+
app.state.repo = SqlRepo(app.state.specs)
|
|
24
|
+
elif source == "sharepoint":
|
|
25
|
+
from .sp_repo import SharePointRepo
|
|
26
|
+
app.state.repo = SharePointRepo(app.state.specs)
|
|
27
|
+
else:
|
|
28
|
+
app.state.repo = SqliteRepo(app.state.specs)
|
|
29
|
+
app.include_router(router)
|
|
30
|
+
for name, mod in custom.load().items():
|
|
31
|
+
if hasattr(mod, "router"):
|
|
32
|
+
app.include_router(mod.router, prefix="/api/custom")
|
|
33
|
+
origins = [o for o in os.environ.get("CORS_ORIGINS", "http://localhost:5173").split(",") if o]
|
|
34
|
+
app.add_middleware(CORSMiddleware, allow_origins=origins, allow_methods=["*"], allow_headers=["*"], allow_credentials=True)
|
|
35
|
+
|
|
36
|
+
dist = Path(os.environ.get("FRONTEND_DIST", Path(__file__).resolve().parents[2] / "frontend" / "dist"))
|
|
37
|
+
if dist.exists():
|
|
38
|
+
app.mount("/assets", StaticFiles(directory=dist / "assets"), name="assets")
|
|
39
|
+
|
|
40
|
+
@app.get("/{path:path}", include_in_schema=False)
|
|
41
|
+
def spa(path: str):
|
|
42
|
+
f = dist / path
|
|
43
|
+
return FileResponse(f if f.is_file() else dist / "index.html")
|
|
44
|
+
return app
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
app = create_app()
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""The OData subset the legacy jQuery used against /_api, evaluated over items.
|
|
2
|
+
|
|
3
|
+
Supported: $filter with eq ne gt ge lt le, and/or/not, parentheses, startswith(f,'x'), substringof('x',f),
|
|
4
|
+
datetime'...' literals; field paths Name, NameId, Name/Title, Name/Id, AuthorId, Created, Modified, ID;
|
|
5
|
+
$orderby (asc/desc, several keys); $top; $skip; $select; $expand (accepted; lookups are always expanded).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
TOKEN = re.compile(r"\s*(?:(\()|(\))|(,)|('(?:[^']|'')*')|(datetime'[^']*')|(-?\d+(?:\.\d+)?)|([A-Za-z_][\w/]*))")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ODataError(ValueError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def tokenize(expr: str) -> list[tuple[str, str]]:
|
|
20
|
+
out, pos = [], 0
|
|
21
|
+
expr = expr.strip()
|
|
22
|
+
while pos < len(expr):
|
|
23
|
+
m = TOKEN.match(expr, pos)
|
|
24
|
+
if not m:
|
|
25
|
+
raise ODataError(f"cannot parse $filter near: {expr[pos:pos + 30]!r}")
|
|
26
|
+
pos = m.end()
|
|
27
|
+
if m.group(1):
|
|
28
|
+
out.append(("(", "("))
|
|
29
|
+
elif m.group(2):
|
|
30
|
+
out.append((")", ")"))
|
|
31
|
+
elif m.group(3):
|
|
32
|
+
out.append((",", ","))
|
|
33
|
+
elif m.group(4):
|
|
34
|
+
out.append(("str", m.group(4)[1:-1].replace("''", "'")))
|
|
35
|
+
elif m.group(5):
|
|
36
|
+
out.append(("str", m.group(5)[9:-1]))
|
|
37
|
+
elif m.group(6):
|
|
38
|
+
out.append(("num", m.group(6)))
|
|
39
|
+
else:
|
|
40
|
+
out.append(("id", m.group(7)))
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve(item: dict, path: str) -> Any:
|
|
45
|
+
if path in item:
|
|
46
|
+
v = item[path]
|
|
47
|
+
return v.get("title") if isinstance(v, dict) and "title" in v else v
|
|
48
|
+
m = re.fullmatch(r"(\w+)Id", path)
|
|
49
|
+
if m and isinstance(item.get(m.group(1)), dict):
|
|
50
|
+
return item[m.group(1)].get("id")
|
|
51
|
+
if m and m.group(1) in item and item[m.group(1)] is None:
|
|
52
|
+
return None
|
|
53
|
+
if "/" in path:
|
|
54
|
+
head, prop = path.split("/", 1)
|
|
55
|
+
v = item.get(head)
|
|
56
|
+
if isinstance(v, dict):
|
|
57
|
+
return v.get(prop.lower()) if prop.lower() in v else v.get(prop)
|
|
58
|
+
return None
|
|
59
|
+
if path == "Id":
|
|
60
|
+
return item.get("ID")
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _norm(v: Any) -> Any:
|
|
65
|
+
if isinstance(v, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}T[\d:.]+Z?", v):
|
|
66
|
+
return v[:19]
|
|
67
|
+
return v
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Parser:
|
|
71
|
+
def __init__(self, tokens):
|
|
72
|
+
self.t, self.i = tokens, 0
|
|
73
|
+
|
|
74
|
+
def peek(self, k=0):
|
|
75
|
+
return self.t[self.i + k] if self.i + k < len(self.t) else (None, None)
|
|
76
|
+
|
|
77
|
+
def take(self, kind=None):
|
|
78
|
+
tok = self.peek()
|
|
79
|
+
if kind and tok[0] != kind:
|
|
80
|
+
raise ODataError(f"expected {kind}, got {tok}")
|
|
81
|
+
self.i += 1
|
|
82
|
+
return tok
|
|
83
|
+
|
|
84
|
+
def expr(self) -> Callable[[dict], bool]:
|
|
85
|
+
"""or-expression: and-expr ('or' and-expr)*"""
|
|
86
|
+
left = self.expr_and()
|
|
87
|
+
while self.peek() == ("id", "or"):
|
|
88
|
+
self.take()
|
|
89
|
+
right = self.expr_and()
|
|
90
|
+
left = (lambda it, l=left, r=right: l(it) or r(it))
|
|
91
|
+
return left
|
|
92
|
+
|
|
93
|
+
def expr_and(self) -> Callable[[dict], bool]:
|
|
94
|
+
left = self.term()
|
|
95
|
+
while self.peek() == ("id", "and"):
|
|
96
|
+
self.take()
|
|
97
|
+
right = self.term()
|
|
98
|
+
left = (lambda it, l=left, r=right: l(it) and r(it))
|
|
99
|
+
return left
|
|
100
|
+
|
|
101
|
+
def value(self):
|
|
102
|
+
kind, v = self.take()
|
|
103
|
+
if kind == "str":
|
|
104
|
+
return v
|
|
105
|
+
if kind == "num":
|
|
106
|
+
return float(v) if "." in v else int(v)
|
|
107
|
+
if v in ("true", "false"):
|
|
108
|
+
return v == "true"
|
|
109
|
+
if v == "null":
|
|
110
|
+
return None
|
|
111
|
+
raise ODataError(f"unexpected value {v}")
|
|
112
|
+
|
|
113
|
+
def term(self) -> Callable[[dict], bool]:
|
|
114
|
+
kind, v = self.peek()
|
|
115
|
+
if kind == "(":
|
|
116
|
+
self.take()
|
|
117
|
+
e = self.expr()
|
|
118
|
+
self.take(")")
|
|
119
|
+
return e
|
|
120
|
+
if v == "not":
|
|
121
|
+
self.take()
|
|
122
|
+
inner = self.term()
|
|
123
|
+
return lambda it: not inner(it)
|
|
124
|
+
if v in ("startswith", "substringof", "endswith"):
|
|
125
|
+
self.take()
|
|
126
|
+
self.take("(")
|
|
127
|
+
if v == "substringof":
|
|
128
|
+
needle = self.value()
|
|
129
|
+
self.take(",")
|
|
130
|
+
field = self.take("id")[1]
|
|
131
|
+
self.take(")")
|
|
132
|
+
return lambda it: needle.lower() in str(resolve(it, field) or "").lower()
|
|
133
|
+
field = self.take("id")[1]
|
|
134
|
+
self.take(",")
|
|
135
|
+
needle = self.value()
|
|
136
|
+
self.take(")")
|
|
137
|
+
fn = str.startswith if v == "startswith" else str.endswith
|
|
138
|
+
return lambda it: fn(str(resolve(it, field) or ""), needle)
|
|
139
|
+
field = self.take("id")[1]
|
|
140
|
+
op = self.take("id")[1]
|
|
141
|
+
val = self.value()
|
|
142
|
+
ops = {"eq": lambda a, b: a == b, "ne": lambda a, b: a != b, "gt": lambda a, b: a is not None and a > b,
|
|
143
|
+
"ge": lambda a, b: a is not None and a >= b, "lt": lambda a, b: a is not None and a < b, "le": lambda a, b: a is not None and a <= b}
|
|
144
|
+
if op not in ops:
|
|
145
|
+
raise ODataError(f"unsupported operator {op}")
|
|
146
|
+
|
|
147
|
+
def pred(it, field=field, f=ops[op], val=val):
|
|
148
|
+
a = _norm(resolve(it, field))
|
|
149
|
+
b = _norm(val)
|
|
150
|
+
if isinstance(a, bool) and isinstance(b, int) and not isinstance(b, bool):
|
|
151
|
+
b = bool(b)
|
|
152
|
+
try:
|
|
153
|
+
return f(a, b)
|
|
154
|
+
except TypeError:
|
|
155
|
+
return False
|
|
156
|
+
return pred
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def compile_filter(expr: str | None) -> Callable[[dict], bool]:
|
|
160
|
+
if not expr:
|
|
161
|
+
return lambda it: True
|
|
162
|
+
p = Parser(tokenize(expr))
|
|
163
|
+
fn = p.expr()
|
|
164
|
+
if p.i != len(p.t):
|
|
165
|
+
raise ODataError(f"unexpected trailing tokens in $filter: {p.t[p.i:]}")
|
|
166
|
+
return fn
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def apply_query(items: list[dict], filter: str | None = None, orderby: str | None = None,
|
|
170
|
+
top: int | None = None, skip: int | None = None, select: str | None = None) -> list[dict]:
|
|
171
|
+
pred = compile_filter(filter)
|
|
172
|
+
out = [it for it in items if pred(it)]
|
|
173
|
+
if orderby:
|
|
174
|
+
for part in reversed([p.strip() for p in orderby.split(",") if p.strip()]):
|
|
175
|
+
name, _, direction = part.partition(" ")
|
|
176
|
+
out.sort(key=lambda it: (resolve(it, name) is None, resolve(it, name) if resolve(it, name) is not None else ""),
|
|
177
|
+
reverse=direction.lower() == "desc")
|
|
178
|
+
if skip:
|
|
179
|
+
out = out[skip:]
|
|
180
|
+
if top is not None:
|
|
181
|
+
out = out[:top]
|
|
182
|
+
if select:
|
|
183
|
+
keep = {s.strip().split("/")[0] for s in select.split(",")} | {"ID", "_etag"}
|
|
184
|
+
out = [{k: v for k, v in it.items() if k in keep} for it in out]
|
|
185
|
+
return out
|