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,256 @@
|
|
|
1
|
+
"""Spec rule engine (server side). Mirrors frontend/src/runtime/rules.ts; both are tested
|
|
2
|
+
against the same vectors so the browser and the API always agree.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from datetime import date, datetime, timedelta
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
REQUIRED_MESSAGE = "You can't leave this blank."
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_empty(v: Any) -> bool:
|
|
14
|
+
return v is None or v == "" or v == [] or (isinstance(v, dict) and v.get("id") in (None, 0, ""))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _scalar(v: Any, against: Any = None) -> Any:
|
|
18
|
+
"""Lookup/user objects compare by title when the other side is text, by id when it is a number."""
|
|
19
|
+
if isinstance(v, dict):
|
|
20
|
+
if isinstance(against, (int, float)) and not isinstance(against, bool):
|
|
21
|
+
return v.get("id")
|
|
22
|
+
return v.get("title") if "title" in v else v.get("id")
|
|
23
|
+
return v
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _num_or_date(v: Any) -> Any:
|
|
27
|
+
if isinstance(v, bool):
|
|
28
|
+
return v
|
|
29
|
+
if isinstance(v, (int, float)):
|
|
30
|
+
return v
|
|
31
|
+
if isinstance(v, str):
|
|
32
|
+
try:
|
|
33
|
+
return float(v.replace(",", "").replace("$", "")) if re.fullmatch(r"\s*[$]?-?[\d,]+(\.\d+)?\s*", v) else v
|
|
34
|
+
except ValueError:
|
|
35
|
+
return v
|
|
36
|
+
return v
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cmp(a: Any, op: str, b: Any) -> bool:
|
|
40
|
+
a, b = _num_or_date(_scalar(a, b)), _num_or_date(b)
|
|
41
|
+
if op == "eq":
|
|
42
|
+
return a == b
|
|
43
|
+
if op == "ne":
|
|
44
|
+
return a != b
|
|
45
|
+
if a is None or b is None or a == "":
|
|
46
|
+
return False
|
|
47
|
+
try:
|
|
48
|
+
if op == "gt":
|
|
49
|
+
return a > b
|
|
50
|
+
if op == "gte":
|
|
51
|
+
return a >= b
|
|
52
|
+
if op == "lt":
|
|
53
|
+
return a < b
|
|
54
|
+
if op == "lte":
|
|
55
|
+
return a <= b
|
|
56
|
+
except TypeError:
|
|
57
|
+
return False
|
|
58
|
+
raise ValueError(f"unknown operator {op}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_value(values: dict, path: str) -> Any:
|
|
62
|
+
head, _, rest = path.partition(".")
|
|
63
|
+
v = values.get(head)
|
|
64
|
+
if rest and isinstance(v, dict):
|
|
65
|
+
return v.get(rest) if rest in v else v.get(rest.lower())
|
|
66
|
+
return v
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def evaluate(cond: Optional[dict], ctx: dict) -> bool:
|
|
70
|
+
"""ctx: {mode, values, original, user: {groups: [...]}}"""
|
|
71
|
+
if not cond:
|
|
72
|
+
return True
|
|
73
|
+
if "all" in cond:
|
|
74
|
+
return all(evaluate(c, ctx) for c in cond["all"])
|
|
75
|
+
if "any" in cond:
|
|
76
|
+
return any(evaluate(c, ctx) for c in cond["any"])
|
|
77
|
+
if "not" in cond:
|
|
78
|
+
return not evaluate(cond["not"], ctx)
|
|
79
|
+
if "role" in cond:
|
|
80
|
+
return cond["role"] in (ctx.get("user") or {}).get("groups", [])
|
|
81
|
+
if "mode" in cond:
|
|
82
|
+
modes = cond["mode"] if isinstance(cond["mode"], list) else [cond["mode"]]
|
|
83
|
+
return ctx.get("mode") in modes
|
|
84
|
+
if "field" in cond:
|
|
85
|
+
v = get_value(ctx.get("values") or {}, cond["field"])
|
|
86
|
+
result = True
|
|
87
|
+
for op, arg in cond.items():
|
|
88
|
+
if op == "field":
|
|
89
|
+
continue
|
|
90
|
+
if op == "empty":
|
|
91
|
+
ok = is_empty(v) == bool(arg)
|
|
92
|
+
elif op == "notEmpty":
|
|
93
|
+
ok = (not is_empty(v)) == bool(arg)
|
|
94
|
+
elif op == "changed":
|
|
95
|
+
before = get_value(ctx.get("original") or {}, cond["field"])
|
|
96
|
+
ok = (_scalar(v) != _scalar(before)) == bool(arg)
|
|
97
|
+
elif op == "in":
|
|
98
|
+
ok = _scalar(v, (arg or [None])[0]) in arg
|
|
99
|
+
elif op == "nin":
|
|
100
|
+
ok = _scalar(v, (arg or [None])[0]) not in arg
|
|
101
|
+
else:
|
|
102
|
+
ok = _cmp(v, op, arg)
|
|
103
|
+
result = result and ok
|
|
104
|
+
return result
|
|
105
|
+
raise ValueError(f"unknown condition {cond}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def applies(rule: dict, mode: str) -> bool:
|
|
109
|
+
return not rule.get("modes") or mode in rule["modes"]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def rules_for(rules: list[dict], kind: str, field: Optional[str], mode: str) -> list[dict]:
|
|
113
|
+
return [r for r in rules if r["kind"] == kind and (field is None or r.get("field") == field) and applies(r, mode)]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def field_state(field: dict, rules: list[dict], ctx: dict) -> dict:
|
|
117
|
+
name, mode = field["name"], ctx["mode"]
|
|
118
|
+
# visible rules: those for this mode must all hold; a field whose visible rules are all scoped to
|
|
119
|
+
# other modes is hidden in this one (e.g. Status shown only on edit/display)
|
|
120
|
+
all_vis = [r for r in rules if r["kind"] == "visible" and r.get("field") == name]
|
|
121
|
+
vis_rules = [r for r in all_vis if applies(r, mode)]
|
|
122
|
+
visible = all(evaluate(r.get("when"), ctx) for r in vis_rules) if vis_rules else not all_vis or not all(r.get("modes") for r in all_vis)
|
|
123
|
+
if field.get("type") == "calculated" and mode != "display":
|
|
124
|
+
visible = False
|
|
125
|
+
readonly = mode == "display" or bool(field.get("read_only")) or any(evaluate(r.get("when"), ctx) for r in rules_for(rules, "readonly", name, mode))
|
|
126
|
+
required = bool(field.get("required")) and mode != "display" and not readonly
|
|
127
|
+
req_rule = next((r for r in rules_for(rules, "required", name, mode) if evaluate(r.get("when"), ctx)), None)
|
|
128
|
+
return {"visible": visible, "readonly": readonly, "required": required or (req_rule is not None and visible),
|
|
129
|
+
"required_rule": req_rule}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def resolve_token(token: Any, user: dict, today: Optional[date] = None) -> Any:
|
|
133
|
+
if token == "@me":
|
|
134
|
+
return {"id": user.get("id"), "login": user.get("login"), "title": user.get("title")}
|
|
135
|
+
if token == "@today":
|
|
136
|
+
return (today or date.today()).isoformat()
|
|
137
|
+
return token
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def defaults(fields: list[dict], rules: list[dict], user: dict, today: Optional[date] = None) -> dict:
|
|
141
|
+
out = {}
|
|
142
|
+
for f in fields:
|
|
143
|
+
if "default" in f:
|
|
144
|
+
out[f["name"]] = resolve_token(f["default"], user, today)
|
|
145
|
+
for r in rules_for(rules, "default", None, "new"):
|
|
146
|
+
out[r["field"]] = resolve_token(r["params"]["value"], user, today)
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def fill_template(text: str, values: dict) -> str:
|
|
151
|
+
"""'{Field}', '{Lookup.title}', '{Field|lower}', '{Field|upper}' -> values."""
|
|
152
|
+
def rep(m):
|
|
153
|
+
v = _scalar(get_value(values, m.group(1)))
|
|
154
|
+
if v is None:
|
|
155
|
+
return ""
|
|
156
|
+
if isinstance(v, float) and v.is_integer():
|
|
157
|
+
v = int(v)
|
|
158
|
+
out = str(v).lower() if isinstance(v, bool) else str(v)
|
|
159
|
+
return out.lower() if m.group(2) == "lower" else out.upper() if m.group(2) == "upper" else out
|
|
160
|
+
return re.sub(r"\{([\w.]+)(?:\|(lower|upper))?\}", rep, text or "")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def validate(fields: list[dict], rules: list[dict], ctx: dict) -> list[dict]:
|
|
164
|
+
"""-> [{field, message, presentation, rule}] in display order, schema checks first per field."""
|
|
165
|
+
errors: list[dict] = []
|
|
166
|
+
values, mode = ctx.get("values") or {}, ctx["mode"]
|
|
167
|
+
by_name = {f["name"]: f for f in fields}
|
|
168
|
+
states = {f["name"]: field_state(f, rules, ctx) for f in fields}
|
|
169
|
+
|
|
170
|
+
for f in fields:
|
|
171
|
+
st, v = states[f["name"]], values.get(f["name"])
|
|
172
|
+
if not st["visible"] or st["readonly"]:
|
|
173
|
+
continue
|
|
174
|
+
if f.get("required") and is_empty(v):
|
|
175
|
+
errors.append({"field": f["name"], "message": REQUIRED_MESSAGE, "presentation": "inline", "rule": "schema"})
|
|
176
|
+
continue
|
|
177
|
+
if is_empty(v):
|
|
178
|
+
continue
|
|
179
|
+
if f.get("max_length") and isinstance(v, str) and len(v) > f["max_length"]:
|
|
180
|
+
errors.append({"field": f["name"], "message": f"The value of this field may not contain more than {f['max_length']} characters.",
|
|
181
|
+
"presentation": "inline", "rule": "schema"})
|
|
182
|
+
if f.get("type") in ("number", "currency"):
|
|
183
|
+
n = _num_or_date(v)
|
|
184
|
+
if not isinstance(n, (int, float)):
|
|
185
|
+
errors.append({"field": f["name"], "message": "The value of this field must be a number.", "presentation": "inline", "rule": "schema"})
|
|
186
|
+
else:
|
|
187
|
+
if "min" in f and n < f["min"]:
|
|
188
|
+
errors.append({"field": f["name"], "message": f"The value of this field must be greater than or equal to {f['min']:g}.", "presentation": "inline", "rule": "schema"})
|
|
189
|
+
if "max" in f and n > f["max"]:
|
|
190
|
+
errors.append({"field": f["name"], "message": f"The value of this field must be less than or equal to {f['max']:g}.", "presentation": "inline", "rule": "schema"})
|
|
191
|
+
if f.get("type") == "choice" and f.get("choices") and not f.get("fill_in") and v not in f["choices"]:
|
|
192
|
+
errors.append({"field": f["name"], "message": "Select a valid value.", "presentation": "inline", "rule": "schema"})
|
|
193
|
+
|
|
194
|
+
for r in rules:
|
|
195
|
+
if not applies(r, mode) or mode == "display":
|
|
196
|
+
continue
|
|
197
|
+
fname = r.get("field")
|
|
198
|
+
st = states.get(fname) if fname else None
|
|
199
|
+
if st and (not st["visible"] or st["readonly"]) and r["kind"] not in ("lock_item",):
|
|
200
|
+
continue
|
|
201
|
+
if r.get("when") and not evaluate(r["when"], ctx):
|
|
202
|
+
continue
|
|
203
|
+
k, v, p = r["kind"], values.get(fname) if fname else None, r.get("params") or {}
|
|
204
|
+
failed = False
|
|
205
|
+
if k == "required":
|
|
206
|
+
failed = is_empty(v)
|
|
207
|
+
elif k == "compare":
|
|
208
|
+
other = values.get(p["other"])
|
|
209
|
+
if is_empty(v) or is_empty(other):
|
|
210
|
+
failed = False
|
|
211
|
+
else:
|
|
212
|
+
failed = not _cmp(v, p["op"], _scalar(other))
|
|
213
|
+
elif k == "length":
|
|
214
|
+
failed = not (p.get("allow_empty") and is_empty(v)) and not _cmp(len(str(v or "")), p["op"], p["value"])
|
|
215
|
+
elif k == "range":
|
|
216
|
+
failed = not is_empty(v) and not _cmp(v, p["op"], p["value"])
|
|
217
|
+
elif k == "pattern":
|
|
218
|
+
failed = not is_empty(v) and not re.fullmatch(p["regex"], str(v))
|
|
219
|
+
if failed:
|
|
220
|
+
errors.append({"field": fname, "message": fill_template(r.get("message") or REQUIRED_MESSAGE, values),
|
|
221
|
+
"presentation": r.get("presentation", "inline"), "rule": r["id"]})
|
|
222
|
+
# one error per field (the first), like SharePoint shows one message under a control
|
|
223
|
+
seen, out = set(), []
|
|
224
|
+
for e in errors:
|
|
225
|
+
key = (e["field"], e["presentation"]) if e["field"] else (e["rule"],)
|
|
226
|
+
if key in seen:
|
|
227
|
+
continue
|
|
228
|
+
seen.add(key)
|
|
229
|
+
out.append(e)
|
|
230
|
+
return out
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def lock(rules: list[dict], ctx: dict) -> Optional[dict]:
|
|
234
|
+
for r in rules_for(rules, "lock_item", None, ctx["mode"]):
|
|
235
|
+
if evaluate(r.get("when"), ctx):
|
|
236
|
+
return r
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def set_on_save(rules: list[dict], ctx: dict) -> dict:
|
|
241
|
+
return {r["field"]: resolve_token(r["params"]["value"], ctx.get("user") or {}) for r in rules_for(rules, "set_on_save", None, ctx["mode"])
|
|
242
|
+
if evaluate(r.get("when"), ctx)}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def banners(rules: list[dict], ctx: dict) -> list[dict]:
|
|
246
|
+
return [r for r in rules_for(rules, "banner", None, ctx["mode"]) if evaluate(r.get("when"), ctx)]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def clear_hidden(fields: list[dict], rules: list[dict], ctx: dict) -> dict:
|
|
250
|
+
"""Fields hidden by a visible rule with params.clear lose their value (legacy: hide row + .val(""))."""
|
|
251
|
+
out = {}
|
|
252
|
+
for f in fields:
|
|
253
|
+
clears = [r for r in rules if r["kind"] == "visible" and r.get("field") == f["name"] and (r.get("params") or {}).get("clear")]
|
|
254
|
+
if clears and ctx["mode"] != "display" and not field_state(f, rules, ctx)["visible"] and not is_empty((ctx.get("values") or {}).get(f["name"])):
|
|
255
|
+
out[f["name"]] = None
|
|
256
|
+
return out
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Optional data source: keep the data in SharePoint and use this API as a backend-for-frontend.
|
|
2
|
+
|
|
3
|
+
Select with DATA_SOURCE=sharepoint. Same interface as SqliteRepo, so the rules engine and routes
|
|
4
|
+
do not change. Auth for the service account: SP_AUTH=ntlm|cookie|bearer with SP_USERNAME/SP_PASSWORD,
|
|
5
|
+
SP_COOKIE or SP_TOKEN. Writes use the request digest from /_api/contextinfo.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
from .store import Conflict, Specs
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SharePointRepo:
|
|
19
|
+
def __init__(self, specs: Specs):
|
|
20
|
+
self.specs = specs
|
|
21
|
+
self.site = os.environ["SP_SITE_URL"].rstrip("/")
|
|
22
|
+
self.s = requests.Session()
|
|
23
|
+
self.s.headers.update({"Accept": "application/json;odata=verbose"})
|
|
24
|
+
self.s.verify = os.environ.get("SP_VERIFY_SSL", "true").lower() not in ("0", "false", "no")
|
|
25
|
+
mode = os.environ.get("SP_AUTH", "ntlm")
|
|
26
|
+
if mode == "ntlm":
|
|
27
|
+
from requests_ntlm import HttpNtlmAuth # type: ignore
|
|
28
|
+
self.s.auth = HttpNtlmAuth(os.environ["SP_USERNAME"], os.environ["SP_PASSWORD"])
|
|
29
|
+
elif mode == "cookie":
|
|
30
|
+
self.s.headers["Cookie"] = os.environ["SP_COOKIE"]
|
|
31
|
+
elif mode == "bearer":
|
|
32
|
+
self.s.headers["Authorization"] = "Bearer " + os.environ["SP_TOKEN"]
|
|
33
|
+
self.users: list[dict] = []
|
|
34
|
+
|
|
35
|
+
# -------------------------------------------------------------- helpers
|
|
36
|
+
def _title(self, list_name: str) -> str:
|
|
37
|
+
return self.specs.lists[list_name]["title"].replace("'", "''")
|
|
38
|
+
|
|
39
|
+
def _url(self, list_name: str, suffix: str = "") -> str:
|
|
40
|
+
return f"{self.site}/_api/web/lists/getbytitle('{self._title(list_name)}')/items{suffix}"
|
|
41
|
+
|
|
42
|
+
def _digest(self) -> str:
|
|
43
|
+
r = self.s.post(f"{self.site}/_api/contextinfo")
|
|
44
|
+
r.raise_for_status()
|
|
45
|
+
return r.json()["d"]["GetContextWebInformation"]["FormDigestValue"]
|
|
46
|
+
|
|
47
|
+
def _fields(self, list_name: str) -> dict[str, dict]:
|
|
48
|
+
return {f["name"]: f for f in self.specs.lists[list_name]["fields"]}
|
|
49
|
+
|
|
50
|
+
def _query(self, list_name: str) -> dict:
|
|
51
|
+
sel, exp = ["*", "Author/Id", "Author/Title", "Author/Name", "Editor/Id", "Editor/Title", "Editor/Name", "AttachmentFiles"], ["Author", "Editor", "AttachmentFiles"]
|
|
52
|
+
for f in self._fields(list_name).values():
|
|
53
|
+
if f["type"] == "lookup":
|
|
54
|
+
sel += [f"{f['name']}/Id", f"{f['name']}/{f['lookup'].get('field', 'Title')}"]
|
|
55
|
+
exp.append(f["name"])
|
|
56
|
+
if f["type"] == "user":
|
|
57
|
+
sel += [f"{f['name']}/Id", f"{f['name']}/Title", f"{f['name']}/Name"]
|
|
58
|
+
exp.append(f["name"])
|
|
59
|
+
return {"$select": ",".join(sel), "$expand": ",".join(exp)}
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def _user(u: Any) -> Optional[dict]:
|
|
63
|
+
if not u or not isinstance(u, dict) or "Id" not in u:
|
|
64
|
+
return None
|
|
65
|
+
return {"id": u["Id"], "login": (u.get("Name") or "").split("|")[-1], "title": u.get("Title")}
|
|
66
|
+
|
|
67
|
+
def _to_item(self, list_name: str, d: dict) -> dict:
|
|
68
|
+
item: dict = {"ID": d["Id"]}
|
|
69
|
+
for name, f in self._fields(list_name).items():
|
|
70
|
+
v = d.get(name)
|
|
71
|
+
if f["type"] == "lookup":
|
|
72
|
+
col = f["lookup"].get("field", "Title")
|
|
73
|
+
conv = lambda x: {"id": x["Id"], "title": x.get(col)} if x and x.get("Id") else None
|
|
74
|
+
item[name] = [conv(x) for x in v.get("results", [])] if isinstance(v, dict) and "results" in v else conv(v)
|
|
75
|
+
elif f["type"] == "user":
|
|
76
|
+
item[name] = [self._user(x) for x in v.get("results", [])] if isinstance(v, dict) and "results" in v else self._user(v)
|
|
77
|
+
elif f["type"] == "date" and v:
|
|
78
|
+
item[name] = v[:10]
|
|
79
|
+
else:
|
|
80
|
+
item[name] = v
|
|
81
|
+
etag = d.get("__metadata", {}).get("etag", '"1"')
|
|
82
|
+
item.update({"Created": d.get("Created"), "Modified": d.get("Modified"), "Author": self._user(d.get("Author")),
|
|
83
|
+
"Editor": self._user(d.get("Editor")), "_etag": etag, "_version": etag.strip('"') + ".0",
|
|
84
|
+
"Attachments": [a["FileName"] for a in (d.get("AttachmentFiles") or {}).get("results", [])]})
|
|
85
|
+
return item
|
|
86
|
+
|
|
87
|
+
def _payload(self, list_name: str, values: dict) -> dict:
|
|
88
|
+
out: dict = {}
|
|
89
|
+
for name, v in values.items():
|
|
90
|
+
f = self._fields(list_name).get(name)
|
|
91
|
+
if not f:
|
|
92
|
+
continue
|
|
93
|
+
if f["type"] in ("lookup", "user"):
|
|
94
|
+
if isinstance(v, list):
|
|
95
|
+
out[f"{name}Id"] = {"results": [x.get("id") if isinstance(x, dict) else x for x in v]}
|
|
96
|
+
else:
|
|
97
|
+
out[f"{name}Id"] = v.get("id") if isinstance(v, dict) else v
|
|
98
|
+
else:
|
|
99
|
+
out[name] = v
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
# ----------------------------------------------------------------- API
|
|
103
|
+
def user(self, uid):
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def user_by_login(self, login: str):
|
|
107
|
+
r = self.s.get(f"{self.site}/_api/web/siteusers", params={"$filter": f"substringof('{login}',LoginName)"})
|
|
108
|
+
r.raise_for_status()
|
|
109
|
+
res = r.json()["d"]["results"]
|
|
110
|
+
if not res:
|
|
111
|
+
return None
|
|
112
|
+
u = res[0]
|
|
113
|
+
g = self.s.get(f"{self.site}/_api/web/getuserbyid({u['Id']})/groups").json()["d"]["results"]
|
|
114
|
+
return {"id": u["Id"], "login": login, "title": u["Title"], "email": u.get("Email"), "groups": [x["Title"] for x in g]}
|
|
115
|
+
|
|
116
|
+
def all(self, list_name: str) -> list[dict]:
|
|
117
|
+
r = self.s.get(self._url(list_name), params={**self._query(list_name), "$top": "5000"})
|
|
118
|
+
r.raise_for_status()
|
|
119
|
+
return [self._to_item(list_name, d) for d in r.json()["d"]["results"]]
|
|
120
|
+
|
|
121
|
+
def get(self, list_name: str, iid: int) -> Optional[dict]:
|
|
122
|
+
r = self.s.get(self._url(list_name, f"({iid})"), params=self._query(list_name))
|
|
123
|
+
if r.status_code == 404:
|
|
124
|
+
return None
|
|
125
|
+
r.raise_for_status()
|
|
126
|
+
return self._to_item(list_name, r.json()["d"])
|
|
127
|
+
|
|
128
|
+
def create(self, list_name: str, values: dict, user: dict) -> dict:
|
|
129
|
+
body = {"__metadata": {"type": f"SP.Data.{self.specs.lists[list_name].get('entity', list_name + 'List')}Item"}, **self._payload(list_name, values)}
|
|
130
|
+
r = self.s.post(self._url(list_name), json=body, headers={"X-RequestDigest": self._digest(), "Content-Type": "application/json;odata=verbose"})
|
|
131
|
+
r.raise_for_status()
|
|
132
|
+
return self.get(list_name, r.json()["d"]["Id"])
|
|
133
|
+
|
|
134
|
+
def update(self, list_name: str, iid: int, values: dict, user: dict, etag: Optional[str] = None) -> dict:
|
|
135
|
+
body = {"__metadata": {"type": f"SP.Data.{self.specs.lists[list_name].get('entity', list_name + 'List')}Item"}, **self._payload(list_name, values)}
|
|
136
|
+
r = self.s.post(self._url(list_name, f"({iid})"), json=body, headers={
|
|
137
|
+
"X-RequestDigest": self._digest(), "Content-Type": "application/json;odata=verbose",
|
|
138
|
+
"X-HTTP-Method": "MERGE", "IF-MATCH": etag or "*"})
|
|
139
|
+
if r.status_code == 412:
|
|
140
|
+
raise Conflict()
|
|
141
|
+
r.raise_for_status()
|
|
142
|
+
return self.get(list_name, iid)
|
|
143
|
+
|
|
144
|
+
def delete(self, list_name: str, iid: int) -> None:
|
|
145
|
+
r = self.s.post(self._url(list_name, f"({iid})"), headers={"X-RequestDigest": self._digest(), "X-HTTP-Method": "DELETE", "IF-MATCH": "*"})
|
|
146
|
+
r.raise_for_status()
|
|
147
|
+
|
|
148
|
+
def add_attachment(self, list_name: str, iid: int, name: str, content_b64: str) -> None:
|
|
149
|
+
r = self.s.post(self._url(list_name, f"({iid})/AttachmentFiles/add(FileName='{name}')"), data=base64.b64decode(content_b64),
|
|
150
|
+
headers={"X-RequestDigest": self._digest()})
|
|
151
|
+
r.raise_for_status()
|
|
152
|
+
|
|
153
|
+
def attachment(self, list_name: str, iid: int, name: str) -> Optional[bytes]:
|
|
154
|
+
r = self.s.get(self._url(list_name, f"({iid})/AttachmentFiles('{name}')/$value"))
|
|
155
|
+
return r.content if r.ok else None
|
|
156
|
+
|
|
157
|
+
def delete_attachment(self, list_name: str, iid: int, name: str) -> None:
|
|
158
|
+
r = self.s.post(self._url(list_name, f"({iid})/AttachmentFiles('{name}')"), headers={"X-RequestDigest": self._digest(), "X-HTTP-Method": "DELETE"})
|
|
159
|
+
r.raise_for_status()
|