drscore 1.0.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.
- drscore/__init__.py +0 -0
- drscore/__main__.py +5 -0
- drscore/admin/__init__.py +0 -0
- drscore/admin/metadata.py +444 -0
- drscore/admin/service.py +296 -0
- drscore/admin/templates/sqladmin/LICENSE.sqladmin +27 -0
- drscore/admin/templates/sqladmin/flash.html +20 -0
- drscore/admin/templates/sqladmin/layout.html +94 -0
- drscore/admin/translations/vi.po +574 -0
- drscore/admin/web.py +731 -0
- drscore/app.py +135 -0
- drscore/audit.py +76 -0
- drscore/auth/__init__.py +0 -0
- drscore/auth/passwords.py +30 -0
- drscore/auth/service.py +191 -0
- drscore/authz/__init__.py +0 -0
- drscore/authz/permissions.py +152 -0
- drscore/authz/rowfilter.py +93 -0
- drscore/bi/__init__.py +0 -0
- drscore/bi/dataset.py +161 -0
- drscore/bi/superset.py +120 -0
- drscore/cli.py +440 -0
- drscore/cli_reports.py +472 -0
- drscore/datasources/connectors.py +251 -0
- drscore/datasources/secrets.py +55 -0
- drscore/datasources/spec.py +65 -0
- drscore/db/__init__.py +3 -0
- drscore/db/engine.py +152 -0
- drscore/db/migrate.py +120 -0
- drscore/db/models.py +568 -0
- drscore/db/types.py +50 -0
- drscore/demo/seed.py +252 -0
- drscore/errors.py +70 -0
- drscore/examples/app.example.toml +67 -0
- drscore/examples/database.example.toml +31 -0
- drscore/export/writers.py +214 -0
- drscore/logsetup.py +54 -0
- drscore/migrations/__init__.py +0 -0
- drscore/migrations/env.py +21 -0
- drscore/migrations/script.py.mako +25 -0
- drscore/migrations/versions/0001_initial_schema.py +369 -0
- drscore/migrations/versions/0002_snapshot_history.py +58 -0
- drscore/migrations/versions/0003_oracle_datasource.py +26 -0
- drscore/migrations/versions/0004_report_views.py +42 -0
- drscore/migrations/versions/0005_decimal_order_param_active.py +53 -0
- drscore/migrations/versions/0006_datasource_connection.py +46 -0
- drscore/migrations/versions/0007_bi_dataset.py +32 -0
- drscore/migrations/versions/0008_must_change_password.py +30 -0
- drscore/render/design.py +195 -0
- drscore/render/grid.py +84 -0
- drscore/render/html.py +53 -0
- drscore/render/html_filters.py +56 -0
- drscore/reports/cache.py +169 -0
- drscore/reports/executor.py +170 -0
- drscore/reports/normalize.py +110 -0
- drscore/reports/params.py +421 -0
- drscore/reports/service.py +307 -0
- drscore/reports/validate.py +153 -0
- drscore/settings.py +304 -0
- drscore/startup.py +111 -0
- drscore/web/deps.py +81 -0
- drscore/web/i18n/en.json +111 -0
- drscore/web/i18n/vi.json +111 -0
- drscore/web/i18n.py +34 -0
- drscore/web/routes/api.py +88 -0
- drscore/web/routes/auth.py +83 -0
- drscore/web/routes/pages.py +159 -0
- drscore/web/schemas.py +155 -0
- drscore/web/static/css/drs.css +13 -0
- drscore/web/static/js/home.js +21 -0
- drscore/web/static/js/report.js +376 -0
- drscore/web/static/vendor/echarts/LICENSE +222 -0
- drscore/web/static/vendor/echarts/NOTICE +5 -0
- drscore/web/static/vendor/echarts/echarts.min.js +45 -0
- drscore/web/static/vendor/htmx/LICENSE +13 -0
- drscore/web/static/vendor/htmx/htmx.min.js +1 -0
- drscore/web/static/vendor/superset-embedded-sdk/LICENSE +13 -0
- drscore/web/static/vendor/superset-embedded-sdk/embedded-sdk.min.js +2 -0
- drscore/web/static/vendor/tabler/LICENSE +22 -0
- drscore/web/static/vendor/tabler/tabler.min.css +8 -0
- drscore/web/static/vendor/tabler/tabler.min.js +8 -0
- drscore/web/static/vendor/tabler-icons/LICENSE +21 -0
- drscore/web/static/vendor/tabler-icons/fonts/tabler-icons.ttf +0 -0
- drscore/web/static/vendor/tabler-icons/fonts/tabler-icons.woff +0 -0
- drscore/web/static/vendor/tabler-icons/fonts/tabler-icons.woff2 +0 -0
- drscore/web/static/vendor/tabler-icons/tabler-icons.min.css +4 -0
- drscore/web/static/vendor/tabulator/LICENSE +21 -0
- drscore/web/static/vendor/tabulator/tabulator.min.js +3 -0
- drscore/web/static/vendor/tabulator/tabulator_bootstrap5.min.css +2 -0
- drscore/web/templates/_param_options.html +8 -0
- drscore/web/templates/account.html +57 -0
- drscore/web/templates/base.html +124 -0
- drscore/web/templates/error.html +31 -0
- drscore/web/templates/home.html +55 -0
- drscore/web/templates/login.html +56 -0
- drscore/web/templates/report.html +161 -0
- drscore/web/templating.py +53 -0
- drscore/web/views.py +372 -0
- drscore-1.0.0.dist-info/METADATA +328 -0
- drscore-1.0.0.dist-info/RECORD +112 -0
- drscore-1.0.0.dist-info/WHEEL +5 -0
- drscore-1.0.0.dist-info/licenses/LICENSE +201 -0
- drscore-1.0.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +95 -0
- drscore-1.0.0.dist-info/licenses/drscore/admin/templates/sqladmin/LICENSE.sqladmin +27 -0
- drscore-1.0.0.dist-info/licenses/drscore/web/static/vendor/echarts/LICENSE +222 -0
- drscore-1.0.0.dist-info/licenses/drscore/web/static/vendor/echarts/NOTICE +5 -0
- drscore-1.0.0.dist-info/licenses/drscore/web/static/vendor/htmx/LICENSE +13 -0
- drscore-1.0.0.dist-info/licenses/drscore/web/static/vendor/superset-embedded-sdk/LICENSE +13 -0
- drscore-1.0.0.dist-info/licenses/drscore/web/static/vendor/tabler/LICENSE +22 -0
- drscore-1.0.0.dist-info/licenses/drscore/web/static/vendor/tabler-icons/LICENSE +21 -0
- drscore-1.0.0.dist-info/licenses/drscore/web/static/vendor/tabulator/LICENSE +21 -0
- drscore-1.0.0.dist-info/top_level.txt +1 -0
drscore/__init__.py
ADDED
|
File without changes
|
drscore/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""``metadata export`` / ``metadata import``: report definitions as one JSON file (spec section 18).
|
|
2
|
+
|
|
3
|
+
The way reports designed on SQLite reach PostgreSQL (or one DRS reaches another). The file holds
|
|
4
|
+
datasources (connection settings), report groups, reports with their parameters, columns and row
|
|
5
|
+
filters, users (name, e-mail, attributes), roles with their members, and grants. Rows refer to each
|
|
6
|
+
other by code (``group_code``, ``datasource_code``, ``username``...), never by id, so ids may differ
|
|
7
|
+
between the two databases.
|
|
8
|
+
|
|
9
|
+
Never in the file: password hashes, encrypted datasource passwords (``enc:``), sessions, caches,
|
|
10
|
+
logs. A datasource whose password was encrypted arrives without one (``datasource set-password``);
|
|
11
|
+
an ``env:NAME`` reference is a name, not a secret, and is kept. Users arrive without a password.
|
|
12
|
+
|
|
13
|
+
Import upserts by code: an existing row is updated, a missing one created; nothing outside the file
|
|
14
|
+
is deleted, except that the parameters, columns and row filters of a report in the file become
|
|
15
|
+
exactly those of the file. Every created / updated row is written to the audit log. The import is
|
|
16
|
+
one transaction: on any error nothing is changed.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from collections.abc import Iterable
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from decimal import Decimal
|
|
26
|
+
from typing import Any, ClassVar, Literal
|
|
27
|
+
|
|
28
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
29
|
+
from sqlalchemy import inspect as sa_inspect
|
|
30
|
+
from sqlalchemy import Numeric, select
|
|
31
|
+
from sqlalchemy.orm import Session
|
|
32
|
+
|
|
33
|
+
from drscore.audit import audit, row_snapshot
|
|
34
|
+
from drscore.db.models import (
|
|
35
|
+
Datasource,
|
|
36
|
+
GrantGroup,
|
|
37
|
+
GrantReport,
|
|
38
|
+
Report,
|
|
39
|
+
ReportColumn,
|
|
40
|
+
ReportGroup,
|
|
41
|
+
ReportParam,
|
|
42
|
+
ReportRowFilter,
|
|
43
|
+
Role,
|
|
44
|
+
User,
|
|
45
|
+
UserAttribute,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
FORMAT = "drs-metadata"
|
|
49
|
+
VERSION = 1
|
|
50
|
+
|
|
51
|
+
# Columns that never leave the database: ids and foreign-key ids (replaced by codes), secrets,
|
|
52
|
+
# timestamps and counters that belong to the database the row lives in.
|
|
53
|
+
NEVER = {
|
|
54
|
+
"datasource_id", "group_id", "report_id", "param_id", "column_id", "row_filter_id", "user_id", "role_id",
|
|
55
|
+
"grant_id", "options_datasource_id", "password_hash", "secret_ref", "created_at", "updated_at",
|
|
56
|
+
"created_by", "updated_by", "granted_at", "failed_login_count", "locked_until", "last_login_at",
|
|
57
|
+
"must_change_password", # about a password, and passwords do not travel
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
STAMPS = {"updated_at", "updated_by"} # not a change by themselves
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class MetadataError(ValueError):
|
|
65
|
+
"""The file cannot be imported; nothing was changed."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _columns(model) -> list[str]:
|
|
69
|
+
return [a.key for a in sa_inspect(model).column_attrs if a.key not in NEVER]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _values(obj) -> dict[str, Any]:
|
|
73
|
+
out = {}
|
|
74
|
+
for key in _columns(type(obj)):
|
|
75
|
+
value = getattr(obj, key)
|
|
76
|
+
out[key] = str(value) if isinstance(value, Decimal) else value
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --------------------------------------------------------------------------------------------
|
|
81
|
+
# File format (validated with Pydantic on import)
|
|
82
|
+
|
|
83
|
+
class _Row(BaseModel):
|
|
84
|
+
"""A table row: the columns of the model, by name; unknown names are refused."""
|
|
85
|
+
|
|
86
|
+
model_config = ConfigDict(extra="allow")
|
|
87
|
+
MODEL: ClassVar[Any] = None
|
|
88
|
+
|
|
89
|
+
@model_validator(mode="after")
|
|
90
|
+
def _known_columns(self):
|
|
91
|
+
allowed = set(_columns(self.MODEL)) | set(type(self).model_fields)
|
|
92
|
+
unknown = sorted(set(self.model_extra or {}) - allowed)
|
|
93
|
+
if unknown:
|
|
94
|
+
raise ValueError(f"unknown field(s) for {self.MODEL.__tablename__}: {', '.join(unknown)}")
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def columns(self) -> dict[str, Any]:
|
|
98
|
+
return dict(self.model_extra or {})
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class DatasourceRow(_Row):
|
|
102
|
+
MODEL: ClassVar[Any] = Datasource
|
|
103
|
+
datasource_code: str
|
|
104
|
+
secret_env: str | None = None # the NAME of an environment variable holding the password
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class GroupRow(_Row):
|
|
108
|
+
MODEL: ClassVar[Any] = ReportGroup
|
|
109
|
+
group_code: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ParamRow(_Row):
|
|
113
|
+
MODEL: ClassVar[Any] = ReportParam
|
|
114
|
+
param_name: str
|
|
115
|
+
options_datasource: str | None = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ColumnRow(_Row):
|
|
119
|
+
MODEL: ClassVar[Any] = ReportColumn
|
|
120
|
+
field_name: str
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class RowFilterRow(_Row):
|
|
124
|
+
MODEL: ClassVar[Any] = ReportRowFilter
|
|
125
|
+
column_name: str
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ReportRow(_Row):
|
|
129
|
+
MODEL: ClassVar[Any] = Report
|
|
130
|
+
report_code: str
|
|
131
|
+
group: str
|
|
132
|
+
datasource: str | None = None
|
|
133
|
+
params: list[ParamRow] = []
|
|
134
|
+
columns_: list[ColumnRow] = Field([], alias="columns")
|
|
135
|
+
row_filters: list[RowFilterRow] = []
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class UserRow(_Row):
|
|
139
|
+
MODEL: ClassVar[Any] = User
|
|
140
|
+
username: str
|
|
141
|
+
attributes: dict[str, list[str]] = {}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class RoleRow(_Row):
|
|
145
|
+
MODEL: ClassVar[Any] = Role
|
|
146
|
+
role_code: str
|
|
147
|
+
members: list[str] = []
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class GrantRow(BaseModel):
|
|
151
|
+
model_config = ConfigDict(extra="forbid")
|
|
152
|
+
target: str # group_code or report_code
|
|
153
|
+
user: str | None = None
|
|
154
|
+
role: str | None = None
|
|
155
|
+
can_export: bool = True
|
|
156
|
+
can_refresh: bool = False
|
|
157
|
+
|
|
158
|
+
@model_validator(mode="after")
|
|
159
|
+
def _one_principal(self):
|
|
160
|
+
if (self.user is None) == (self.role is None):
|
|
161
|
+
raise ValueError(f"grant on {self.target}: exactly one of user or role")
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class MetadataFile(BaseModel):
|
|
166
|
+
model_config = ConfigDict(extra="forbid")
|
|
167
|
+
format: Literal["drs-metadata"]
|
|
168
|
+
version: Literal[1]
|
|
169
|
+
exported_at: str | None = None
|
|
170
|
+
exported_from: str | None = None
|
|
171
|
+
datasources: list[DatasourceRow] = []
|
|
172
|
+
groups: list[GroupRow] = []
|
|
173
|
+
reports: list[ReportRow] = []
|
|
174
|
+
users: list[UserRow] = []
|
|
175
|
+
roles: list[RoleRow] = []
|
|
176
|
+
group_grants: list[GrantRow] = []
|
|
177
|
+
report_grants: list[GrantRow] = []
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# --------------------------------------------------------------------------------------------
|
|
181
|
+
# Export
|
|
182
|
+
|
|
183
|
+
def export(s: Session, *, reports: Iterable[str] | None = None, source: str | None = None) -> dict[str, Any]:
|
|
184
|
+
"""The metadata as a JSON-ready dict. ``reports``: only these report codes (with their group,
|
|
185
|
+
datasources, grants and the users / roles those grants name)."""
|
|
186
|
+
wanted = set(reports or ())
|
|
187
|
+
report_rows = list(s.scalars(select(Report).order_by(Report.report_code)))
|
|
188
|
+
if wanted:
|
|
189
|
+
missing = wanted - {r.report_code for r in report_rows}
|
|
190
|
+
if missing:
|
|
191
|
+
raise MetadataError(f"unknown report(s): {', '.join(sorted(missing))}")
|
|
192
|
+
report_rows = [r for r in report_rows if r.report_code in wanted]
|
|
193
|
+
report_ids = {r.report_id for r in report_rows}
|
|
194
|
+
|
|
195
|
+
group_grants = list(s.scalars(select(GrantGroup).order_by(GrantGroup.grant_id)))
|
|
196
|
+
report_grants = [g for g in s.scalars(select(GrantReport).order_by(GrantReport.grant_id))
|
|
197
|
+
if g.report_id in report_ids]
|
|
198
|
+
if wanted:
|
|
199
|
+
group_ids = {r.group_id for r in report_rows}
|
|
200
|
+
groups = [g for g in s.scalars(select(ReportGroup).order_by(ReportGroup.group_code)) if g.group_id in group_ids]
|
|
201
|
+
group_grants = [g for g in group_grants if g.group_id in group_ids]
|
|
202
|
+
else:
|
|
203
|
+
groups = list(s.scalars(select(ReportGroup).order_by(ReportGroup.group_code)))
|
|
204
|
+
|
|
205
|
+
params = {rid: [] for rid in report_ids}
|
|
206
|
+
for p in s.scalars(select(ReportParam).order_by(ReportParam.sort_order, ReportParam.param_name)):
|
|
207
|
+
if p.report_id in params:
|
|
208
|
+
params[p.report_id].append(p)
|
|
209
|
+
columns = {rid: [] for rid in report_ids}
|
|
210
|
+
for c in s.scalars(select(ReportColumn).order_by(ReportColumn.sort_order, ReportColumn.field_name)):
|
|
211
|
+
if c.report_id in columns:
|
|
212
|
+
columns[c.report_id].append(c)
|
|
213
|
+
filters = {rid: [] for rid in report_ids}
|
|
214
|
+
for f in s.scalars(select(ReportRowFilter).order_by(ReportRowFilter.column_name)):
|
|
215
|
+
if f.report_id in filters:
|
|
216
|
+
filters[f.report_id].append(f)
|
|
217
|
+
|
|
218
|
+
ds_ids = {r.datasource_id for r in report_rows if r.datasource_id}
|
|
219
|
+
ds_ids |= {p.options_datasource_id for ps in params.values() for p in ps if p.options_datasource_id}
|
|
220
|
+
datasources = [d for d in s.scalars(select(Datasource).order_by(Datasource.datasource_code))
|
|
221
|
+
if not wanted or d.datasource_id in ds_ids]
|
|
222
|
+
|
|
223
|
+
roles = list(s.scalars(select(Role).order_by(Role.role_code)))
|
|
224
|
+
users = list(s.scalars(select(User).order_by(User.username)))
|
|
225
|
+
if wanted:
|
|
226
|
+
role_ids = {g.role_id for g in [*group_grants, *report_grants] if g.role_id}
|
|
227
|
+
roles = [r for r in roles if r.role_id in role_ids]
|
|
228
|
+
user_ids = {g.user_id for g in [*group_grants, *report_grants] if g.user_id}
|
|
229
|
+
user_ids |= {u.user_id for r in roles for u in r.users}
|
|
230
|
+
users = [u for u in users if u.user_id in user_ids]
|
|
231
|
+
|
|
232
|
+
def datasource(d: Datasource) -> dict:
|
|
233
|
+
row = _values(d)
|
|
234
|
+
if d.secret_ref and d.secret_ref.startswith("env:"):
|
|
235
|
+
row["secret_env"] = d.secret_ref[4:]
|
|
236
|
+
return row
|
|
237
|
+
|
|
238
|
+
def report(r: Report) -> dict:
|
|
239
|
+
return {
|
|
240
|
+
**_values(r), "group": r.group.group_code, "datasource": r.datasource.datasource_code if r.datasource else None,
|
|
241
|
+
"params": [{**_values(p), "options_datasource": p.options_datasource.datasource_code
|
|
242
|
+
if p.options_datasource else None} for p in params[r.report_id]],
|
|
243
|
+
"columns": [_values(c) for c in columns[r.report_id]],
|
|
244
|
+
"row_filters": [_values(f) for f in filters[r.report_id]],
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
def user(u: User) -> dict:
|
|
248
|
+
attributes: dict[str, list[str]] = {}
|
|
249
|
+
for a in sorted(u.attributes, key=lambda a: (a.attr_name, a.attr_value)):
|
|
250
|
+
attributes.setdefault(a.attr_name, []).append(a.attr_value)
|
|
251
|
+
return {**_values(u), "attributes": attributes}
|
|
252
|
+
|
|
253
|
+
def grant(g, target: str) -> dict:
|
|
254
|
+
return {"target": target, "user": g.user.username if g.user else None,
|
|
255
|
+
"role": g.role.role_code if g.role else None, "can_export": g.can_export, "can_refresh": g.can_refresh}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
"format": FORMAT, "version": VERSION,
|
|
259
|
+
"exported_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
260
|
+
"exported_from": source,
|
|
261
|
+
"datasources": [datasource(d) for d in datasources],
|
|
262
|
+
"groups": [_values(g) for g in groups],
|
|
263
|
+
"reports": [report(r) for r in report_rows],
|
|
264
|
+
"users": [user(u) for u in users],
|
|
265
|
+
"roles": [{**_values(r), "members": sorted(u.username for u in r.users)} for r in roles],
|
|
266
|
+
"group_grants": [grant(g, g.group.group_code) for g in group_grants],
|
|
267
|
+
"report_grants": [grant(g, g.report.report_code) for g in report_grants],
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def dumps(data: dict[str, Any]) -> str:
|
|
272
|
+
return json.dumps(data, ensure_ascii=False, indent=2, default=str) + "\n"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# --------------------------------------------------------------------------------------------
|
|
276
|
+
# Import
|
|
277
|
+
|
|
278
|
+
@dataclass
|
|
279
|
+
class ImportResult:
|
|
280
|
+
created: list[str] = field(default_factory=list)
|
|
281
|
+
updated: list[str] = field(default_factory=list)
|
|
282
|
+
unchanged: int = 0
|
|
283
|
+
removed: list[str] = field(default_factory=list)
|
|
284
|
+
notes: list[str] = field(default_factory=list)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def parse(text: str) -> MetadataFile:
|
|
288
|
+
try:
|
|
289
|
+
raw = json.loads(text)
|
|
290
|
+
except json.JSONDecodeError as exc:
|
|
291
|
+
raise MetadataError(f"not a JSON file: {exc}") from exc
|
|
292
|
+
try:
|
|
293
|
+
return MetadataFile.model_validate(raw)
|
|
294
|
+
except ValidationError as exc:
|
|
295
|
+
lines = [f"{'.'.join(map(str, e['loc'])) or 'file'}: {e['msg']}" for e in exc.errors()[:20]]
|
|
296
|
+
raise MetadataError("the file is not a DRS metadata export:\n " + "\n ".join(lines)) from exc
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class _Importer:
|
|
300
|
+
def __init__(self, s: Session, actor: str):
|
|
301
|
+
self.s = s
|
|
302
|
+
self.actor = actor
|
|
303
|
+
self.result = ImportResult()
|
|
304
|
+
|
|
305
|
+
def upsert(self, model, key: dict[str, Any], values: dict[str, Any], label: str, **refs):
|
|
306
|
+
obj = self.s.scalars(select(model).filter_by(**key)).first()
|
|
307
|
+
before = row_snapshot(obj) if obj is not None else None
|
|
308
|
+
if obj is None:
|
|
309
|
+
obj = model(**key)
|
|
310
|
+
self.s.add(obj)
|
|
311
|
+
for name, value in {**values, **refs}.items():
|
|
312
|
+
column = sa_inspect(model).columns.get(name)
|
|
313
|
+
if column is not None and value is not None and isinstance(column.type, Numeric):
|
|
314
|
+
value = Decimal(str(value))
|
|
315
|
+
setattr(obj, name, value)
|
|
316
|
+
changed = before is None or {k: v for k, v in row_snapshot(obj).items() if k not in STAMPS} != \
|
|
317
|
+
{k: v for k, v in before.items() if k not in STAMPS}
|
|
318
|
+
if changed and hasattr(obj, "updated_by"):
|
|
319
|
+
obj.updated_by = self.actor
|
|
320
|
+
self.s.flush()
|
|
321
|
+
after = row_snapshot(obj)
|
|
322
|
+
if before is None:
|
|
323
|
+
self.result.created.append(label)
|
|
324
|
+
audit(self.s, self.actor, "IMPORT", model.__tablename__, label, after=after)
|
|
325
|
+
elif changed:
|
|
326
|
+
self.result.updated.append(label)
|
|
327
|
+
audit(self.s, self.actor, "IMPORT", model.__tablename__, label, before=before, after=after)
|
|
328
|
+
else:
|
|
329
|
+
self.result.unchanged += 1
|
|
330
|
+
return obj
|
|
331
|
+
|
|
332
|
+
def lookup(self, model, column: str, code: str, what: str):
|
|
333
|
+
obj = self.s.scalars(select(model).where(getattr(model, column) == code)).first()
|
|
334
|
+
if obj is None:
|
|
335
|
+
raise MetadataError(f"{what} {code} is neither in the file nor in this database")
|
|
336
|
+
return obj
|
|
337
|
+
|
|
338
|
+
def children(self, model, report: Report, key: str, rows: list, label: str, extra=lambda row: {}):
|
|
339
|
+
"""Makes the parameters / columns / row filters of a report exactly those of the file."""
|
|
340
|
+
keep = {getattr(row, key) for row in rows}
|
|
341
|
+
for old in self.s.scalars(select(model).where(model.report_id == report.report_id)):
|
|
342
|
+
if getattr(old, key) not in keep:
|
|
343
|
+
self.result.removed.append(f"{label} {report.report_code}.{getattr(old, key)}")
|
|
344
|
+
audit(self.s, self.actor, "IMPORT_DELETE", model.__tablename__,
|
|
345
|
+
f"{report.report_code}.{getattr(old, key)}", before=row_snapshot(old))
|
|
346
|
+
self.s.delete(old)
|
|
347
|
+
self.s.flush()
|
|
348
|
+
for row in rows:
|
|
349
|
+
self.upsert(model, {"report_id": report.report_id, key: getattr(row, key)}, row.columns(),
|
|
350
|
+
f"{label} {report.report_code}.{getattr(row, key)}", **extra(row))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def import_metadata(s: Session, data: MetadataFile, actor: str) -> ImportResult:
|
|
354
|
+
"""Applies a parsed file in the caller's transaction (roll it back for a dry run)."""
|
|
355
|
+
from drscore.reports.validate import BI_TABLE_RE, PARAM_NAME_RE, REPORT_CODE_RE
|
|
356
|
+
|
|
357
|
+
imp = _Importer(s, actor)
|
|
358
|
+
for r in data.reports:
|
|
359
|
+
if not REPORT_CODE_RE.match(r.report_code):
|
|
360
|
+
raise MetadataError(f"report {r.report_code}: the code does not match ^[A-Z][A-Z0-9_]{{1,49}}$")
|
|
361
|
+
bad = [p.param_name for p in r.params if not PARAM_NAME_RE.match(p.param_name)]
|
|
362
|
+
if bad:
|
|
363
|
+
raise MetadataError(f"report {r.report_code}: parameter name(s) {', '.join(bad)} not ^[a-z][a-z0-9_]*$")
|
|
364
|
+
table = r.columns().get("bi_dataset_table")
|
|
365
|
+
if table and not BI_TABLE_RE.match(table):
|
|
366
|
+
raise MetadataError(f"report {r.report_code}: bi_dataset_table {table!r} is not a plain name")
|
|
367
|
+
|
|
368
|
+
for d in data.datasources:
|
|
369
|
+
existing = s.scalars(select(Datasource).where(Datasource.datasource_code == d.datasource_code)).first()
|
|
370
|
+
refs = {}
|
|
371
|
+
if d.secret_env:
|
|
372
|
+
refs["secret_ref"] = f"env:{d.secret_env}"
|
|
373
|
+
elif existing is None and d.columns().get("auth_method", "password") == "password" \
|
|
374
|
+
and d.columns().get("db_type") != "sqlite":
|
|
375
|
+
imp.result.notes.append(f"datasource {d.datasource_code}: no password - run "
|
|
376
|
+
f"`python -m drscore datasource set-password {d.datasource_code}`")
|
|
377
|
+
imp.upsert(Datasource, {"datasource_code": d.datasource_code}, d.columns(),
|
|
378
|
+
f"datasource {d.datasource_code}", **refs)
|
|
379
|
+
|
|
380
|
+
for g in data.groups:
|
|
381
|
+
imp.upsert(ReportGroup, {"group_code": g.group_code}, g.columns(), f"group {g.group_code}")
|
|
382
|
+
|
|
383
|
+
for r in data.reports:
|
|
384
|
+
group = imp.lookup(ReportGroup, "group_code", r.group, "group")
|
|
385
|
+
ds = imp.lookup(Datasource, "datasource_code", r.datasource, "datasource") if r.datasource else None
|
|
386
|
+
existing = s.scalars(select(Report).where(Report.report_code == r.report_code)).first()
|
|
387
|
+
refs = {"group_id": group.group_id, "datasource_id": ds.datasource_id if ds else None}
|
|
388
|
+
if existing is None:
|
|
389
|
+
refs["created_by"] = actor
|
|
390
|
+
report = imp.upsert(Report, {"report_code": r.report_code}, r.columns(), f"report {r.report_code}", **refs)
|
|
391
|
+
|
|
392
|
+
def param_refs(p: ParamRow):
|
|
393
|
+
src = imp.lookup(Datasource, "datasource_code", p.options_datasource, "datasource") \
|
|
394
|
+
if p.options_datasource else None
|
|
395
|
+
return {"options_datasource_id": src.datasource_id if src else None}
|
|
396
|
+
|
|
397
|
+
imp.children(ReportParam, report, "param_name", r.params, "parameter", param_refs)
|
|
398
|
+
imp.children(ReportColumn, report, "field_name", r.columns_, "column")
|
|
399
|
+
imp.children(ReportRowFilter, report, "column_name", r.row_filters, "row filter")
|
|
400
|
+
|
|
401
|
+
for u in data.users:
|
|
402
|
+
existing = s.scalars(select(User).where(User.username == u.username)).first()
|
|
403
|
+
values = u.columns()
|
|
404
|
+
if existing is not None:
|
|
405
|
+
values.pop("is_admin", None) # an import never changes who is an administrator
|
|
406
|
+
elif values.get("is_admin"):
|
|
407
|
+
imp.result.notes.append(f"user {u.username}: created as administrator, without a password")
|
|
408
|
+
user = imp.upsert(User, {"username": u.username}, values, f"user {u.username}")
|
|
409
|
+
have = {(a.attr_name, a.attr_value) for a in user.attributes}
|
|
410
|
+
for name, attr_values in u.attributes.items():
|
|
411
|
+
for value in attr_values:
|
|
412
|
+
if (name, value) not in have:
|
|
413
|
+
s.add(UserAttribute(user_id=user.user_id, attr_name=name, attr_value=value))
|
|
414
|
+
imp.result.created.append(f"attribute {u.username}.{name}={value}")
|
|
415
|
+
audit(s, actor, "IMPORT", "drs_user_attribute", f"{u.username}.{name}={value}")
|
|
416
|
+
s.flush()
|
|
417
|
+
|
|
418
|
+
for r in data.roles:
|
|
419
|
+
role = imp.upsert(Role, {"role_code": r.role_code}, r.columns(), f"role {r.role_code}")
|
|
420
|
+
for username in r.members:
|
|
421
|
+
user = imp.lookup(User, "username", username, "user")
|
|
422
|
+
if user not in role.users:
|
|
423
|
+
role.users.append(user)
|
|
424
|
+
imp.result.created.append(f"member {r.role_code} <- {username}")
|
|
425
|
+
audit(s, actor, "IMPORT", "drs_user_role", f"{r.role_code} <- {username}")
|
|
426
|
+
s.flush()
|
|
427
|
+
|
|
428
|
+
def grants(model, target_model, target_column, target_attr, rows: list[GrantRow], label: str):
|
|
429
|
+
for g in rows:
|
|
430
|
+
target = imp.lookup(target_model, target_column, g.target, label)
|
|
431
|
+
key = {target_attr: getattr(target, target_attr)}
|
|
432
|
+
who = f"user {g.user}" if g.user else f"role {g.role}"
|
|
433
|
+
if g.user:
|
|
434
|
+
key["user_id"] = imp.lookup(User, "username", g.user, "user").user_id
|
|
435
|
+
else:
|
|
436
|
+
key["role_id"] = imp.lookup(Role, "role_code", g.role, "role").role_id
|
|
437
|
+
existing = s.scalars(select(model).filter_by(**key)).first()
|
|
438
|
+
refs = {} if existing is not None else {"granted_by": actor}
|
|
439
|
+
imp.upsert(model, key, {"can_export": g.can_export, "can_refresh": g.can_refresh},
|
|
440
|
+
f"{label} grant {g.target} -> {who}", **refs)
|
|
441
|
+
|
|
442
|
+
grants(GrantGroup, ReportGroup, "group_code", "group_id", data.group_grants, "group")
|
|
443
|
+
grants(GrantReport, Report, "report_code", "report_id", data.report_grants, "report")
|
|
444
|
+
return imp.result
|