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
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