queryview 0.0.2__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.
- queryview/__init__.py +0 -0
- queryview/conftest.py +86 -0
- queryview/connect.py +428 -0
- queryview/dashboard_queries.py +75 -0
- queryview/dashboards.py +156 -0
- queryview/drivers/__init__.py +10 -0
- queryview/drivers/base.py +165 -0
- queryview/drivers/clickhouse.py +138 -0
- queryview/drivers/duckdb.py +153 -0
- queryview/drivers/postgres.py +166 -0
- queryview/drivers/test_base.py +49 -0
- queryview/drivers/test_clickhouse.py +53 -0
- queryview/drivers/test_contract.py +77 -0
- queryview/drivers/test_duckdb.py +80 -0
- queryview/drivers/test_postgres.py +80 -0
- queryview/gitsync.py +374 -0
- queryview/main.py +740 -0
- queryview/mcp_server.py +294 -0
- queryview/migrations/env.py +39 -0
- queryview/migrations/script.py.mako +29 -0
- queryview/migrations/versions/9a536b7c0328_initial_schema.py +89 -0
- queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +59 -0
- queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +32 -0
- queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +98 -0
- queryview/queries.py +159 -0
- queryview/remote.py +141 -0
- queryview/static/assets/index-CvnC_D68.js +47 -0
- queryview/static/assets/index-Qe7bhycG.css +2 -0
- queryview/static/favicon.svg +1 -0
- queryview/static/index.html +14 -0
- queryview/test_api_db.py +51 -0
- queryview/test_api_export_import.py +85 -0
- queryview/test_api_gitsync.py +83 -0
- queryview/test_api_workspaces.py +44 -0
- queryview/test_connect_flow.py +123 -0
- queryview/test_connect_store.py +34 -0
- queryview/test_dashboards.py +216 -0
- queryview/test_gitsync.py +346 -0
- queryview/test_main.py +18 -0
- queryview/test_mcp_gitsync.py +72 -0
- queryview/test_migrations.py +99 -0
- queryview/test_queries.py +170 -0
- queryview/test_remote.py +260 -0
- queryview/test_validation.py +87 -0
- queryview/test_workspaces.py +109 -0
- queryview/test_yamlio.py +198 -0
- queryview/validation.py +111 -0
- queryview/workspaces.py +167 -0
- queryview/yamlio.py +245 -0
- queryview-0.0.2.dist-info/METADATA +183 -0
- queryview-0.0.2.dist-info/RECORD +54 -0
- queryview-0.0.2.dist-info/WHEEL +4 -0
- queryview-0.0.2.dist-info/entry_points.txt +3 -0
- queryview-0.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Workspace store: CRUD, name rules, encrypted-remote round trip, and the
|
|
2
|
+
delete-refuses-on-non-empty rule. The 'default' workspace is seeded by the
|
|
3
|
+
migration and is an ordinary row."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import sqlite3
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from queryview.connect import _db_path
|
|
13
|
+
from queryview.workspaces import (
|
|
14
|
+
DEFAULT_WORKSPACE,
|
|
15
|
+
WorkspaceError,
|
|
16
|
+
create_workspace,
|
|
17
|
+
delete_workspace,
|
|
18
|
+
list_workspaces,
|
|
19
|
+
resolve,
|
|
20
|
+
update_workspace,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _run(coro):
|
|
25
|
+
return asyncio.run(coro)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_default_workspace_is_seeded():
|
|
29
|
+
rec = _run(resolve(DEFAULT_WORKSPACE))
|
|
30
|
+
assert rec.name == "default"
|
|
31
|
+
assert isinstance(rec.id, int)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_resolve_unknown_is_404():
|
|
35
|
+
with pytest.raises(WorkspaceError) as e:
|
|
36
|
+
_run(resolve("no-such-ws"))
|
|
37
|
+
assert e.value.status == 404
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_create_list_and_encrypted_remote_round_trip():
|
|
41
|
+
_run(create_workspace("t2-crt", remote="https://u:tok@example.test/r.git", branch="dev"))
|
|
42
|
+
rec = _run(resolve("t2-crt"))
|
|
43
|
+
assert rec.remote == "https://u:tok@example.test/r.git" # decrypted for gitsync
|
|
44
|
+
assert rec.branch == "dev"
|
|
45
|
+
|
|
46
|
+
listed = {w["name"]: w for w in _run(list_workspaces())}
|
|
47
|
+
assert listed["t2-crt"] == {"name": "t2-crt", "branch": "dev", "configured": True}
|
|
48
|
+
assert "remote" not in listed["t2-crt"] # the URL (a secret) never leaves the store
|
|
49
|
+
|
|
50
|
+
# At rest the remote is ciphertext, not the URL.
|
|
51
|
+
con = sqlite3.connect(_db_path())
|
|
52
|
+
try:
|
|
53
|
+
stored = con.execute("SELECT remote FROM workspaces WHERE name='t2-crt'").fetchone()[0]
|
|
54
|
+
finally:
|
|
55
|
+
con.close()
|
|
56
|
+
assert "example.test" not in stored
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_create_duplicate_is_409_and_bad_names_400():
|
|
60
|
+
_run(create_workspace("t2-dup"))
|
|
61
|
+
with pytest.raises(WorkspaceError) as e:
|
|
62
|
+
_run(create_workspace("t2-dup"))
|
|
63
|
+
assert e.value.status == 409
|
|
64
|
+
with pytest.raises(WorkspaceError) as e:
|
|
65
|
+
_run(create_workspace(" "))
|
|
66
|
+
assert e.value.status == 400
|
|
67
|
+
with pytest.raises(WorkspaceError) as e:
|
|
68
|
+
_run(create_workspace("a/b")) # names travel in URL paths
|
|
69
|
+
assert e.value.status == 400
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_update_rename_set_and_clear_remote():
|
|
73
|
+
_run(create_workspace("t2-upd", remote="https://example.test/a.git"))
|
|
74
|
+
_run(update_workspace("t2-upd", new_name="t2-upd2", branch="rel"))
|
|
75
|
+
rec = _run(resolve("t2-upd2"))
|
|
76
|
+
assert rec.branch == "rel" and rec.remote == "https://example.test/a.git" # remote untouched
|
|
77
|
+
_run(update_workspace("t2-upd2", remote=None))
|
|
78
|
+
assert _run(resolve("t2-upd2")).remote is None
|
|
79
|
+
with pytest.raises(WorkspaceError) as e:
|
|
80
|
+
_run(update_workspace("t2-upd2", new_name=DEFAULT_WORKSPACE))
|
|
81
|
+
assert e.value.status == 409
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_delete_refuses_non_empty_then_deletes_empty():
|
|
85
|
+
_run(create_workspace("t2-del"))
|
|
86
|
+
rec = _run(resolve("t2-del"))
|
|
87
|
+
con = sqlite3.connect(_db_path())
|
|
88
|
+
try:
|
|
89
|
+
con.execute(
|
|
90
|
+
"INSERT INTO predefined_queries (query_name, type, query, workspace_id) "
|
|
91
|
+
"VALUES ('held', 'clickhouse', 'SELECT 1', ?)",
|
|
92
|
+
(rec.id,),
|
|
93
|
+
)
|
|
94
|
+
con.commit()
|
|
95
|
+
finally:
|
|
96
|
+
con.close()
|
|
97
|
+
with pytest.raises(WorkspaceError) as e:
|
|
98
|
+
_run(delete_workspace("t2-del"))
|
|
99
|
+
assert e.value.status == 409
|
|
100
|
+
|
|
101
|
+
con = sqlite3.connect(_db_path())
|
|
102
|
+
try:
|
|
103
|
+
con.execute("DELETE FROM predefined_queries WHERE workspace_id=?", (rec.id,))
|
|
104
|
+
con.commit()
|
|
105
|
+
finally:
|
|
106
|
+
con.close()
|
|
107
|
+
_run(delete_workspace("t2-del"))
|
|
108
|
+
with pytest.raises(WorkspaceError):
|
|
109
|
+
_run(resolve("t2-del"))
|
queryview/test_yamlio.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""yamlio: export/import round trips at the module level, plus document
|
|
2
|
+
validation (parse_document rejects malformed files before anything writes)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from queryview.yamlio import (
|
|
12
|
+
YamlIOError,
|
|
13
|
+
export_dashboard,
|
|
14
|
+
export_query,
|
|
15
|
+
export_workspace,
|
|
16
|
+
import_text,
|
|
17
|
+
parse_document,
|
|
18
|
+
slug,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _run(coro):
|
|
23
|
+
return asyncio.run(coro)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_slug_passthrough_and_encoding():
|
|
27
|
+
assert slug("top_errors") == "top_errors"
|
|
28
|
+
assert slug("top errors 2.0") == "top errors 2.0"
|
|
29
|
+
assert slug("a/b") == "a%2Fb"
|
|
30
|
+
assert slug(".hidden") == "%2Ehidden"
|
|
31
|
+
assert slug("héllo") == "h%C3%A9llo"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --- parse_document validation ---------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_parse_rejects_non_mapping_and_unknown_kind():
|
|
38
|
+
with pytest.raises(YamlIOError):
|
|
39
|
+
parse_document("- just\n- a list\n")
|
|
40
|
+
with pytest.raises(YamlIOError):
|
|
41
|
+
parse_document("kind: nope\n")
|
|
42
|
+
with pytest.raises(YamlIOError):
|
|
43
|
+
parse_document("query_name: no kind\n")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_parse_rejects_invalid_yaml():
|
|
47
|
+
with pytest.raises(YamlIOError):
|
|
48
|
+
parse_document("kind: [unclosed\n")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_parse_query_requires_fields():
|
|
52
|
+
with pytest.raises(YamlIOError):
|
|
53
|
+
parse_document("kind: query\nquery_name: x\n") # no type/query
|
|
54
|
+
with pytest.raises(YamlIOError):
|
|
55
|
+
parse_document("kind: query\ntype: clickhouse\nquery_name: ''\nquery: SELECT 1\n")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_parse_query_validates_presentation():
|
|
59
|
+
doc = "kind: query\ntype: clickhouse\nquery_name: x\nquery: SELECT 1\norder_by: notalist\n"
|
|
60
|
+
with pytest.raises(YamlIOError):
|
|
61
|
+
parse_document(doc)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_parse_query_validates_cell_view_yaml():
|
|
65
|
+
doc = 'kind: query\ntype: clickhouse\nquery_name: x\nquery: SELECT 1\ncell_view: "col: [unclosed"\n'
|
|
66
|
+
with pytest.raises(YamlIOError):
|
|
67
|
+
parse_document(doc)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_parse_dashboard_requires_fields():
|
|
71
|
+
with pytest.raises(YamlIOError):
|
|
72
|
+
parse_document("kind: dashboard\nname: d\nconnection: c\n") # no html
|
|
73
|
+
with pytest.raises(YamlIOError):
|
|
74
|
+
parse_document("kind: dashboard\nname: d\nconnection: c\nhtml: <p/>\nqueries: [notamap]\n")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_parse_workspace_validates_entries():
|
|
78
|
+
doc = "kind: workspace\nqueries:\n - query_name: missing type\n query: SELECT 1\n"
|
|
79
|
+
with pytest.raises(YamlIOError):
|
|
80
|
+
parse_document(doc)
|
|
81
|
+
with pytest.raises(YamlIOError):
|
|
82
|
+
parse_document("kind: workspace\nqueries: notalist\n")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_parse_empty_workspace_bundle_is_valid():
|
|
86
|
+
kind, payload = parse_document("kind: workspace\n")
|
|
87
|
+
assert kind == "workspace"
|
|
88
|
+
assert payload == {"queries": [], "dashboards": []}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --- Round trips ------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_query_export_import_round_trip(default_ws_id):
|
|
95
|
+
from queryview.queries import get_predefined_query, save_predefined_query
|
|
96
|
+
|
|
97
|
+
_run(
|
|
98
|
+
save_predefined_query(
|
|
99
|
+
"yio query",
|
|
100
|
+
"clickhouse",
|
|
101
|
+
"SELECT 1\nFROM t",
|
|
102
|
+
"col:\n type: link\n value: https://x/{cell}\n",
|
|
103
|
+
'[{"name": "col", "dir": "DESC"}]',
|
|
104
|
+
'["col"]',
|
|
105
|
+
workspace_id=default_ws_id,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
text = _run(export_query("clickhouse", "yio query", default_ws_id))
|
|
109
|
+
data = yaml.safe_load(text)
|
|
110
|
+
assert data["kind"] == "query" and data["type"] == "clickhouse"
|
|
111
|
+
assert data["query"] == "SELECT 1\nFROM t"
|
|
112
|
+
|
|
113
|
+
# Re-import under a changed name to prove the import writes the file's
|
|
114
|
+
# content, then compare rows field by field.
|
|
115
|
+
_run(import_text(text.replace("yio query", "yio query copy"), default_ws_id))
|
|
116
|
+
orig = _run(get_predefined_query("clickhouse", "yio query", default_ws_id))
|
|
117
|
+
copy = _run(get_predefined_query("clickhouse", "yio query copy", default_ws_id))
|
|
118
|
+
assert orig is not None and copy is not None
|
|
119
|
+
for key in ("query", "cell_view", "fields"):
|
|
120
|
+
assert copy[key] == orig[key]
|
|
121
|
+
import json
|
|
122
|
+
|
|
123
|
+
ob_copy, ob_orig = copy["order_by"], orig["order_by"]
|
|
124
|
+
assert ob_copy is not None and ob_orig is not None
|
|
125
|
+
assert json.loads(ob_copy) == json.loads(ob_orig)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_dashboard_export_import_round_trip(default_ws_id):
|
|
129
|
+
from queryview.dashboards import get_dashboard, upsert_dashboard
|
|
130
|
+
|
|
131
|
+
_run(
|
|
132
|
+
upsert_dashboard(
|
|
133
|
+
"yio dash",
|
|
134
|
+
"prod",
|
|
135
|
+
"<html>\n<body>v1</body>\n</html>",
|
|
136
|
+
{"panel": "SELECT 1"},
|
|
137
|
+
workspace_id=default_ws_id,
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
text = _run(export_dashboard("yio dash", default_ws_id))
|
|
141
|
+
data = yaml.safe_load(text)
|
|
142
|
+
assert data["kind"] == "dashboard" and data["connection"] == "prod"
|
|
143
|
+
|
|
144
|
+
# Drift the stored copy, then import the export to restore it.
|
|
145
|
+
_run(upsert_dashboard("yio dash", "other", "<p>v2</p>", {}, workspace_id=default_ws_id))
|
|
146
|
+
r = _run(import_text(text, default_ws_id))
|
|
147
|
+
assert r == {"kind": "dashboard", "queries": 0, "dashboards": 1}
|
|
148
|
+
d = _run(get_dashboard("yio dash", default_ws_id))
|
|
149
|
+
assert d is not None
|
|
150
|
+
assert d["connection"] == "prod" and "v1" in d["html"] and d["queries"] == {"panel": "SELECT 1"}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_workspace_export_import_round_trip(default_ws_id, wipe_workspace_entities):
|
|
154
|
+
"""Export one workspace's whole content and import it into another."""
|
|
155
|
+
from queryview.dashboards import get_dashboard, upsert_dashboard
|
|
156
|
+
from queryview.queries import get_predefined_query, save_predefined_query
|
|
157
|
+
from queryview.workspaces import create_workspace, delete_workspace, resolve
|
|
158
|
+
|
|
159
|
+
_run(save_predefined_query("ws q1", "clickhouse", "SELECT 1", workspace_id=default_ws_id))
|
|
160
|
+
_run(save_predefined_query("ws q2", "postgres", "SELECT 2", workspace_id=default_ws_id))
|
|
161
|
+
_run(upsert_dashboard("ws dash", "prod", "<p>hi</p>", {"a": "SELECT 3"}, workspace_id=default_ws_id))
|
|
162
|
+
text = _run(export_workspace(default_ws_id))
|
|
163
|
+
data = yaml.safe_load(text)
|
|
164
|
+
assert data["kind"] == "workspace"
|
|
165
|
+
names = {(q["type"], q["query_name"]) for q in data["queries"]}
|
|
166
|
+
assert {("clickhouse", "ws q1"), ("postgres", "ws q2")} <= names
|
|
167
|
+
|
|
168
|
+
_run(create_workspace("yio target"))
|
|
169
|
+
try:
|
|
170
|
+
target = _run(resolve("yio target"))
|
|
171
|
+
r = _run(import_text(text, target.id))
|
|
172
|
+
assert r["kind"] == "workspace" and r["queries"] >= 2 and r["dashboards"] >= 1
|
|
173
|
+
q = _run(get_predefined_query("postgres", "ws q2", target.id))
|
|
174
|
+
assert q is not None and q["query"] == "SELECT 2"
|
|
175
|
+
d = _run(get_dashboard("ws dash", target.id))
|
|
176
|
+
assert d is not None and d["queries"] == {"a": "SELECT 3"}
|
|
177
|
+
# Clean the target so delete_workspace succeeds (it must be empty).
|
|
178
|
+
wipe_workspace_entities(target.id)
|
|
179
|
+
finally:
|
|
180
|
+
_run(delete_workspace("yio target"))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_import_validates_before_writing(default_ws_id):
|
|
184
|
+
"""A workspace bundle with one bad entry writes nothing at all."""
|
|
185
|
+
from queryview.queries import get_predefined_query
|
|
186
|
+
|
|
187
|
+
doc = (
|
|
188
|
+
"kind: workspace\n"
|
|
189
|
+
"queries:\n"
|
|
190
|
+
" - type: clickhouse\n"
|
|
191
|
+
" query_name: good entry\n"
|
|
192
|
+
" query: SELECT 1\n"
|
|
193
|
+
" - type: clickhouse\n"
|
|
194
|
+
" query_name: bad entry\n"
|
|
195
|
+
)
|
|
196
|
+
with pytest.raises(YamlIOError):
|
|
197
|
+
_run(import_text(doc, default_ws_id))
|
|
198
|
+
assert _run(get_predefined_query("clickhouse", "good entry", default_ws_id)) is None
|
queryview/validation.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Strict validation for the presentation fields carried on a push, a
|
|
2
|
+
predefined-query save, or a YAML import/restore: order_by/fields (structured)
|
|
3
|
+
and cell_view (raw YAML text, checked against the contract in
|
|
4
|
+
docs/query.md#cell-views). Shared by every write path so they all enforce the
|
|
5
|
+
same rules; reads stay lenient (the client drops entries it can't use)."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
MAX_LIMIT = 10000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def presentation_error(order_by: object, fields: object) -> str | None:
|
|
15
|
+
"""Return an error message if order_by/fields are malformed, else None.
|
|
16
|
+
None inputs are valid (the field is simply absent/unchanged)."""
|
|
17
|
+
if order_by is not None:
|
|
18
|
+
if not isinstance(order_by, list):
|
|
19
|
+
return "invalid order_by: must be a list"
|
|
20
|
+
for col in order_by:
|
|
21
|
+
if not isinstance(col, dict):
|
|
22
|
+
return "invalid order_by: each entry must be an object"
|
|
23
|
+
name = col.get("name")
|
|
24
|
+
if not isinstance(name, str) or not name:
|
|
25
|
+
return "invalid order_by: name must be a non-empty string"
|
|
26
|
+
if "`" in name:
|
|
27
|
+
return "invalid order_by: name must not contain a backtick"
|
|
28
|
+
direction = col.get("dir")
|
|
29
|
+
if not isinstance(direction, str) or direction.upper() not in ("ASC", "DESC"):
|
|
30
|
+
return "invalid order_by: dir must be ASC or DESC"
|
|
31
|
+
if fields is not None:
|
|
32
|
+
if not isinstance(fields, list):
|
|
33
|
+
return "invalid fields: must be a list"
|
|
34
|
+
for f in fields:
|
|
35
|
+
if not isinstance(f, str) or not f:
|
|
36
|
+
return "invalid fields: each column must be a non-empty string"
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _cell_view_params_error(raw: object) -> str | None:
|
|
41
|
+
"""Validate the reserved `params:` selector list (see docs/query.md)."""
|
|
42
|
+
if not isinstance(raw, list):
|
|
43
|
+
return "invalid cell_view: params must be a list of selector specs"
|
|
44
|
+
for i, entry in enumerate(raw):
|
|
45
|
+
if not isinstance(entry, dict):
|
|
46
|
+
return f"invalid cell_view: params[{i}] must be a mapping"
|
|
47
|
+
name = entry.get("name")
|
|
48
|
+
if not isinstance(name, str) or not name:
|
|
49
|
+
return f"invalid cell_view: params[{i}] name must be a non-empty string"
|
|
50
|
+
options, sql = entry.get("options"), entry.get("options_sql")
|
|
51
|
+
if (options is None) == (sql is None):
|
|
52
|
+
return f"invalid cell_view: params[{i}] must declare exactly one of options or options_sql"
|
|
53
|
+
if sql is not None and (not isinstance(sql, str) or not sql.strip()):
|
|
54
|
+
return f"invalid cell_view: params[{i}] options_sql must be a non-empty string"
|
|
55
|
+
if options is not None:
|
|
56
|
+
if not isinstance(options, list) or not options:
|
|
57
|
+
return f"invalid cell_view: params[{i}] options must be a non-empty list"
|
|
58
|
+
if any(isinstance(v, (dict, list)) or v is None for v in options):
|
|
59
|
+
return f"invalid cell_view: params[{i}] options must be scalars"
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cell_view_error(cell_view: object) -> str | None:
|
|
64
|
+
"""Return an error message if a cell_view YAML text is malformed, else None.
|
|
65
|
+
None / empty text is valid (no custom views). The contract
|
|
66
|
+
(docs/query.md#cell-views): a YAML mapping whose reserved `params` key holds
|
|
67
|
+
selector specs and whose other entries are column -> {type: link|custom,
|
|
68
|
+
value: str} (extra keys are allowed)."""
|
|
69
|
+
if cell_view is None:
|
|
70
|
+
return None
|
|
71
|
+
if not isinstance(cell_view, str):
|
|
72
|
+
return "invalid cell_view: must be YAML text"
|
|
73
|
+
if not cell_view.strip():
|
|
74
|
+
return None
|
|
75
|
+
try:
|
|
76
|
+
doc = yaml.safe_load(cell_view)
|
|
77
|
+
except yaml.YAMLError as e:
|
|
78
|
+
return f"invalid cell_view: not valid YAML ({e})"
|
|
79
|
+
if doc is None:
|
|
80
|
+
return None
|
|
81
|
+
if not isinstance(doc, dict):
|
|
82
|
+
return "invalid cell_view: must be a YAML mapping of column -> {type, value}"
|
|
83
|
+
for key, entry in doc.items():
|
|
84
|
+
if not isinstance(key, str) or not key:
|
|
85
|
+
return "invalid cell_view: column names must be non-empty strings"
|
|
86
|
+
if key == "params":
|
|
87
|
+
perr = _cell_view_params_error(entry)
|
|
88
|
+
if perr is not None:
|
|
89
|
+
return perr
|
|
90
|
+
continue
|
|
91
|
+
if not isinstance(entry, dict):
|
|
92
|
+
return f"invalid cell_view: entry {key!r} must be a {{type, value}} mapping"
|
|
93
|
+
if entry.get("type") not in ("link", "custom"):
|
|
94
|
+
return f"invalid cell_view: entry {key!r} type must be 'link' or 'custom'"
|
|
95
|
+
value = entry.get("value")
|
|
96
|
+
if not isinstance(value, str) or not value:
|
|
97
|
+
return f"invalid cell_view: entry {key!r} value must be a non-empty string"
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def clamp_paging(limit: object, offset: object) -> tuple[int, int]:
|
|
102
|
+
"""Coerce/clamp limit & offset to safe ints: 0 <= limit <= MAX_LIMIT, offset >= 0."""
|
|
103
|
+
try:
|
|
104
|
+
lim = int(limit) # type: ignore[arg-type]
|
|
105
|
+
except (TypeError, ValueError):
|
|
106
|
+
lim = 100
|
|
107
|
+
try:
|
|
108
|
+
off = int(offset) # type: ignore[arg-type]
|
|
109
|
+
except (TypeError, ValueError):
|
|
110
|
+
off = 0
|
|
111
|
+
return max(0, min(lim, MAX_LIMIT)), max(0, off)
|
queryview/workspaces.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Workspace domain: named git-sync targets that own predefined queries and
|
|
2
|
+
dashboards. The remote URL may embed a token, so it is encrypted at rest with
|
|
3
|
+
connect.py's AES-GCM helpers and never returned by list_workspaces. Docs:
|
|
4
|
+
docs/workspace.md."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, ClassVar
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import text
|
|
12
|
+
from sqlmodel import Field, SQLModel, select
|
|
13
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
14
|
+
|
|
15
|
+
from .connect import _decrypt_str, _encrypt_str, _engine_for_db, _ensure_schema
|
|
16
|
+
|
|
17
|
+
DEFAULT_WORKSPACE = "default"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WorkspaceError(Exception):
|
|
21
|
+
"""Workspace failure carrying an HTTP-ish status for the API layer:
|
|
22
|
+
400 invalid input, 404 unknown workspace, 409 conflict (duplicate name,
|
|
23
|
+
delete of a non-empty workspace)."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, status: int = 400):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.status = status
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Workspace(SQLModel, table=True):
|
|
31
|
+
__tablename__: ClassVar[str] = "workspaces"
|
|
32
|
+
|
|
33
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
34
|
+
name: str = Field(unique=True, index=True)
|
|
35
|
+
remote: str | None = Field(default=None) # base64(AES-GCM(url)) — never plaintext
|
|
36
|
+
branch: str = Field(default="main")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class WorkspaceRec:
|
|
41
|
+
"""A resolved workspace with its remote decrypted, ready for gitsync."""
|
|
42
|
+
|
|
43
|
+
id: int
|
|
44
|
+
name: str
|
|
45
|
+
remote: str | None
|
|
46
|
+
branch: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _to_rec(row: Workspace) -> WorkspaceRec:
|
|
50
|
+
return WorkspaceRec(
|
|
51
|
+
id=row.id, # type: ignore[arg-type] # persisted rows always have an id
|
|
52
|
+
name=row.name,
|
|
53
|
+
remote=_decrypt_str(row.remote) if row.remote else None,
|
|
54
|
+
branch=row.branch,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _valid_name(name: str) -> str:
|
|
59
|
+
name = name.strip()
|
|
60
|
+
if not name:
|
|
61
|
+
raise WorkspaceError("workspace name is required", status=400)
|
|
62
|
+
if "/" in name:
|
|
63
|
+
# Names travel in /api/workspaces/{name} URL paths.
|
|
64
|
+
raise WorkspaceError("workspace name must not contain '/'", status=400)
|
|
65
|
+
return name
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def resolve(name: str) -> WorkspaceRec:
|
|
69
|
+
"""The named workspace, or 404. Every workspace-scoped operation starts here."""
|
|
70
|
+
await _ensure_schema()
|
|
71
|
+
async with AsyncSession(_engine_for_db()) as s:
|
|
72
|
+
row = (await s.exec(select(Workspace).where(Workspace.name == name))).first()
|
|
73
|
+
if row is None:
|
|
74
|
+
raise WorkspaceError(f"unknown workspace {name!r}", status=404)
|
|
75
|
+
return _to_rec(row)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def list_workspaces() -> list[dict[str, Any]]:
|
|
79
|
+
"""All workspaces ordered by name; exposes whether a remote is configured
|
|
80
|
+
but never the remote itself."""
|
|
81
|
+
await _ensure_schema()
|
|
82
|
+
async with AsyncSession(_engine_for_db()) as s:
|
|
83
|
+
rows = (await s.exec(select(Workspace).order_by(Workspace.name))).all()
|
|
84
|
+
return [{"name": r.name, "branch": r.branch, "configured": bool(r.remote)} for r in rows]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def create_workspace(name: str, remote: str | None = None, branch: str = "main") -> None:
|
|
88
|
+
name = _valid_name(name)
|
|
89
|
+
await _ensure_schema()
|
|
90
|
+
async with AsyncSession(_engine_for_db()) as s:
|
|
91
|
+
dup = (await s.exec(select(Workspace).where(Workspace.name == name))).first()
|
|
92
|
+
if dup is not None:
|
|
93
|
+
raise WorkspaceError(f"workspace {name!r} already exists", status=409)
|
|
94
|
+
s.add(
|
|
95
|
+
Workspace(
|
|
96
|
+
name=name,
|
|
97
|
+
remote=_encrypt_str(remote) if remote else None,
|
|
98
|
+
branch=branch.strip() or "main",
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
await s.commit()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Sentinel distinguishing "leave the remote as-is" (omitted) from "clear it"
|
|
105
|
+
# (an explicit None).
|
|
106
|
+
_UNSET: Any = object()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def update_workspace(
|
|
110
|
+
name: str,
|
|
111
|
+
new_name: str | None = None,
|
|
112
|
+
remote: Any = _UNSET,
|
|
113
|
+
branch: str | None = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Rename and/or reconfigure a workspace. Renaming is a one-row update:
|
|
116
|
+
entities and clone dirs are keyed by workspace id, so nothing else moves."""
|
|
117
|
+
await _ensure_schema()
|
|
118
|
+
async with AsyncSession(_engine_for_db()) as s:
|
|
119
|
+
row = (await s.exec(select(Workspace).where(Workspace.name == name))).first()
|
|
120
|
+
if row is None:
|
|
121
|
+
raise WorkspaceError(f"unknown workspace {name!r}", status=404)
|
|
122
|
+
if new_name is not None and new_name.strip() != name:
|
|
123
|
+
new_name = _valid_name(new_name)
|
|
124
|
+
dup = (await s.exec(select(Workspace).where(Workspace.name == new_name))).first()
|
|
125
|
+
if dup is not None:
|
|
126
|
+
raise WorkspaceError(f"workspace {new_name!r} already exists", status=409)
|
|
127
|
+
row.name = new_name
|
|
128
|
+
if remote is not _UNSET:
|
|
129
|
+
row.remote = _encrypt_str(remote) if remote else None
|
|
130
|
+
if branch is not None and branch.strip():
|
|
131
|
+
row.branch = branch.strip()
|
|
132
|
+
s.add(row)
|
|
133
|
+
await s.commit()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def _entity_count(workspace_id: int) -> int:
|
|
137
|
+
"""How many entities the workspace still owns. Raw SQL so this module
|
|
138
|
+
doesn't import the entity modules (which import nothing from here either)."""
|
|
139
|
+
async with _engine_for_db().connect() as conn:
|
|
140
|
+
n = (
|
|
141
|
+
await conn.execute(
|
|
142
|
+
text(
|
|
143
|
+
"SELECT (SELECT COUNT(*) FROM predefined_queries WHERE workspace_id = :w)"
|
|
144
|
+
" + (SELECT COUNT(*) FROM dashboards WHERE workspace_id = :w)"
|
|
145
|
+
),
|
|
146
|
+
{"w": workspace_id},
|
|
147
|
+
)
|
|
148
|
+
).scalar()
|
|
149
|
+
return int(n or 0)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def delete_workspace(name: str) -> None:
|
|
153
|
+
"""Delete an empty workspace; 409 while it still owns entities. The git
|
|
154
|
+
remote keeps its history either way — this only removes the local row."""
|
|
155
|
+
await _ensure_schema()
|
|
156
|
+
async with AsyncSession(_engine_for_db()) as s:
|
|
157
|
+
row = (await s.exec(select(Workspace).where(Workspace.name == name))).first()
|
|
158
|
+
if row is None:
|
|
159
|
+
raise WorkspaceError(f"unknown workspace {name!r}", status=404)
|
|
160
|
+
count = await _entity_count(row.id) # type: ignore[arg-type]
|
|
161
|
+
if count:
|
|
162
|
+
raise WorkspaceError(
|
|
163
|
+
f"workspace {name!r} still contains {count} entities; delete them first",
|
|
164
|
+
status=409,
|
|
165
|
+
)
|
|
166
|
+
await s.delete(row)
|
|
167
|
+
await s.commit()
|