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.
Files changed (112) hide show
  1. drscore/__init__.py +0 -0
  2. drscore/__main__.py +5 -0
  3. drscore/admin/__init__.py +0 -0
  4. drscore/admin/metadata.py +444 -0
  5. drscore/admin/service.py +296 -0
  6. drscore/admin/templates/sqladmin/LICENSE.sqladmin +27 -0
  7. drscore/admin/templates/sqladmin/flash.html +20 -0
  8. drscore/admin/templates/sqladmin/layout.html +94 -0
  9. drscore/admin/translations/vi.po +574 -0
  10. drscore/admin/web.py +731 -0
  11. drscore/app.py +135 -0
  12. drscore/audit.py +76 -0
  13. drscore/auth/__init__.py +0 -0
  14. drscore/auth/passwords.py +30 -0
  15. drscore/auth/service.py +191 -0
  16. drscore/authz/__init__.py +0 -0
  17. drscore/authz/permissions.py +152 -0
  18. drscore/authz/rowfilter.py +93 -0
  19. drscore/bi/__init__.py +0 -0
  20. drscore/bi/dataset.py +161 -0
  21. drscore/bi/superset.py +120 -0
  22. drscore/cli.py +440 -0
  23. drscore/cli_reports.py +472 -0
  24. drscore/datasources/connectors.py +251 -0
  25. drscore/datasources/secrets.py +55 -0
  26. drscore/datasources/spec.py +65 -0
  27. drscore/db/__init__.py +3 -0
  28. drscore/db/engine.py +152 -0
  29. drscore/db/migrate.py +120 -0
  30. drscore/db/models.py +568 -0
  31. drscore/db/types.py +50 -0
  32. drscore/demo/seed.py +252 -0
  33. drscore/errors.py +70 -0
  34. drscore/examples/app.example.toml +67 -0
  35. drscore/examples/database.example.toml +31 -0
  36. drscore/export/writers.py +214 -0
  37. drscore/logsetup.py +54 -0
  38. drscore/migrations/__init__.py +0 -0
  39. drscore/migrations/env.py +21 -0
  40. drscore/migrations/script.py.mako +25 -0
  41. drscore/migrations/versions/0001_initial_schema.py +369 -0
  42. drscore/migrations/versions/0002_snapshot_history.py +58 -0
  43. drscore/migrations/versions/0003_oracle_datasource.py +26 -0
  44. drscore/migrations/versions/0004_report_views.py +42 -0
  45. drscore/migrations/versions/0005_decimal_order_param_active.py +53 -0
  46. drscore/migrations/versions/0006_datasource_connection.py +46 -0
  47. drscore/migrations/versions/0007_bi_dataset.py +32 -0
  48. drscore/migrations/versions/0008_must_change_password.py +30 -0
  49. drscore/render/design.py +195 -0
  50. drscore/render/grid.py +84 -0
  51. drscore/render/html.py +53 -0
  52. drscore/render/html_filters.py +56 -0
  53. drscore/reports/cache.py +169 -0
  54. drscore/reports/executor.py +170 -0
  55. drscore/reports/normalize.py +110 -0
  56. drscore/reports/params.py +421 -0
  57. drscore/reports/service.py +307 -0
  58. drscore/reports/validate.py +153 -0
  59. drscore/settings.py +304 -0
  60. drscore/startup.py +111 -0
  61. drscore/web/deps.py +81 -0
  62. drscore/web/i18n/en.json +111 -0
  63. drscore/web/i18n/vi.json +111 -0
  64. drscore/web/i18n.py +34 -0
  65. drscore/web/routes/api.py +88 -0
  66. drscore/web/routes/auth.py +83 -0
  67. drscore/web/routes/pages.py +159 -0
  68. drscore/web/schemas.py +155 -0
  69. drscore/web/static/css/drs.css +13 -0
  70. drscore/web/static/js/home.js +21 -0
  71. drscore/web/static/js/report.js +376 -0
  72. drscore/web/static/vendor/echarts/LICENSE +222 -0
  73. drscore/web/static/vendor/echarts/NOTICE +5 -0
  74. drscore/web/static/vendor/echarts/echarts.min.js +45 -0
  75. drscore/web/static/vendor/htmx/LICENSE +13 -0
  76. drscore/web/static/vendor/htmx/htmx.min.js +1 -0
  77. drscore/web/static/vendor/superset-embedded-sdk/LICENSE +13 -0
  78. drscore/web/static/vendor/superset-embedded-sdk/embedded-sdk.min.js +2 -0
  79. drscore/web/static/vendor/tabler/LICENSE +22 -0
  80. drscore/web/static/vendor/tabler/tabler.min.css +8 -0
  81. drscore/web/static/vendor/tabler/tabler.min.js +8 -0
  82. drscore/web/static/vendor/tabler-icons/LICENSE +21 -0
  83. drscore/web/static/vendor/tabler-icons/fonts/tabler-icons.ttf +0 -0
  84. drscore/web/static/vendor/tabler-icons/fonts/tabler-icons.woff +0 -0
  85. drscore/web/static/vendor/tabler-icons/fonts/tabler-icons.woff2 +0 -0
  86. drscore/web/static/vendor/tabler-icons/tabler-icons.min.css +4 -0
  87. drscore/web/static/vendor/tabulator/LICENSE +21 -0
  88. drscore/web/static/vendor/tabulator/tabulator.min.js +3 -0
  89. drscore/web/static/vendor/tabulator/tabulator_bootstrap5.min.css +2 -0
  90. drscore/web/templates/_param_options.html +8 -0
  91. drscore/web/templates/account.html +57 -0
  92. drscore/web/templates/base.html +124 -0
  93. drscore/web/templates/error.html +31 -0
  94. drscore/web/templates/home.html +55 -0
  95. drscore/web/templates/login.html +56 -0
  96. drscore/web/templates/report.html +161 -0
  97. drscore/web/templating.py +53 -0
  98. drscore/web/views.py +372 -0
  99. drscore-1.0.0.dist-info/METADATA +328 -0
  100. drscore-1.0.0.dist-info/RECORD +112 -0
  101. drscore-1.0.0.dist-info/WHEEL +5 -0
  102. drscore-1.0.0.dist-info/licenses/LICENSE +201 -0
  103. drscore-1.0.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +95 -0
  104. drscore-1.0.0.dist-info/licenses/drscore/admin/templates/sqladmin/LICENSE.sqladmin +27 -0
  105. drscore-1.0.0.dist-info/licenses/drscore/web/static/vendor/echarts/LICENSE +222 -0
  106. drscore-1.0.0.dist-info/licenses/drscore/web/static/vendor/echarts/NOTICE +5 -0
  107. drscore-1.0.0.dist-info/licenses/drscore/web/static/vendor/htmx/LICENSE +13 -0
  108. drscore-1.0.0.dist-info/licenses/drscore/web/static/vendor/superset-embedded-sdk/LICENSE +13 -0
  109. drscore-1.0.0.dist-info/licenses/drscore/web/static/vendor/tabler/LICENSE +22 -0
  110. drscore-1.0.0.dist-info/licenses/drscore/web/static/vendor/tabler-icons/LICENSE +21 -0
  111. drscore-1.0.0.dist-info/licenses/drscore/web/static/vendor/tabulator/LICENSE +21 -0
  112. drscore-1.0.0.dist-info/top_level.txt +1 -0
drscore/__init__.py ADDED
File without changes
drscore/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from drscore.cli import main
4
+
5
+ sys.exit(main())
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