dotfill 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.
- dotfill/__init__.py +8 -0
- dotfill/__main__.py +6 -0
- dotfill/api.py +456 -0
- dotfill/cli.py +244 -0
- dotfill/config.py +43 -0
- dotfill/config_loader.py +450 -0
- dotfill/config_merge.py +40 -0
- dotfill/config_models.py +74 -0
- dotfill/config_paths.py +84 -0
- dotfill/entrypoints.py +91 -0
- dotfill/envdoc.py +454 -0
- dotfill/errors.py +76 -0
- dotfill/identity.py +138 -0
- dotfill/identity_facts.py +73 -0
- dotfill/identity_rules.py +148 -0
- dotfill/import_scan.py +160 -0
- dotfill/logging_config.py +32 -0
- dotfill/models.py +152 -0
- dotfill/open_paths.py +70 -0
- dotfill/paths.py +21 -0
- dotfill/resolver.py +259 -0
- dotfill/save.py +78 -0
- dotfill/server.py +51 -0
- dotfill/service_test.py +65 -0
- dotfill/static/app.css +401 -0
- dotfill/static/app.js +772 -0
- dotfill/static/import_source_state.js +62 -0
- dotfill/static/index.html +43 -0
- dotfill-0.1.0.dist-info/METADATA +204 -0
- dotfill-0.1.0.dist-info/RECORD +33 -0
- dotfill-0.1.0.dist-info/WHEEL +4 -0
- dotfill-0.1.0.dist-info/entry_points.txt +3 -0
- dotfill-0.1.0.dist-info/licenses/LICENSE +21 -0
dotfill/__init__.py
ADDED
dotfill/__main__.py
ADDED
dotfill/api.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""HTTP API for the dotfill local web UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import secrets
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Request, status
|
|
12
|
+
from fastapi.responses import JSONResponse
|
|
13
|
+
from pydantic import BaseModel, ConfigDict
|
|
14
|
+
|
|
15
|
+
from .config import resolve_url_template
|
|
16
|
+
from .config_paths import ConfigContext, resolve_config_context
|
|
17
|
+
from .envdoc import EnvDocument
|
|
18
|
+
from .errors import (
|
|
19
|
+
ConfigValidationError,
|
|
20
|
+
DotfillError,
|
|
21
|
+
DuplicateManagedVariableError,
|
|
22
|
+
ImportScanError,
|
|
23
|
+
SaveError,
|
|
24
|
+
ServiceTestError,
|
|
25
|
+
UnresolvedIdentityError,
|
|
26
|
+
UrlTemplateError,
|
|
27
|
+
)
|
|
28
|
+
from .import_scan import (
|
|
29
|
+
build_updates_from_choices,
|
|
30
|
+
scan_source_text,
|
|
31
|
+
)
|
|
32
|
+
from .models import (
|
|
33
|
+
AppState,
|
|
34
|
+
CommitImportRequest,
|
|
35
|
+
SaveTokenRequest,
|
|
36
|
+
ScanDroppedRequest,
|
|
37
|
+
ScanPathRequest,
|
|
38
|
+
SessionState,
|
|
39
|
+
TestResult,
|
|
40
|
+
)
|
|
41
|
+
from .open_paths import open_directory, open_env_location
|
|
42
|
+
from .resolver import build_app_state, service_icon, service_test_fingerprint
|
|
43
|
+
from .save import save_assignments
|
|
44
|
+
from .service_test import run_service_test
|
|
45
|
+
|
|
46
|
+
log = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
SESSION_HEADER = "X-Dotfill-Session"
|
|
49
|
+
_MUTATING_METHODS = {"DELETE", "PATCH", "POST", "PUT"}
|
|
50
|
+
_LOCAL_ORIGIN_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AppContext:
|
|
54
|
+
"""Shared mutable singleton for the FastAPI app."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
session: SessionState,
|
|
59
|
+
*,
|
|
60
|
+
config_context: ConfigContext | None = None,
|
|
61
|
+
env_path: Path | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.config_context = config_context or resolve_config_context()
|
|
64
|
+
self.env_path_override = env_path
|
|
65
|
+
self.session = session
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _require_session(
|
|
69
|
+
ctx: AppContext, provided: str | None
|
|
70
|
+
) -> None:
|
|
71
|
+
if not provided or not secrets.compare_digest(provided, ctx.session.token):
|
|
72
|
+
raise HTTPException(
|
|
73
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
74
|
+
detail="Invalid or missing session token",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _state(ctx: AppContext) -> AppState:
|
|
79
|
+
return build_app_state(
|
|
80
|
+
ctx.config_context,
|
|
81
|
+
ctx.session,
|
|
82
|
+
env_path_override=ctx.env_path_override,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_local_origin(origin: str) -> bool:
|
|
87
|
+
parsed = urlsplit(origin)
|
|
88
|
+
return parsed.scheme in {"http", "https"} and parsed.hostname in _LOCAL_ORIGIN_HOSTS
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _identity_payload(state: AppState) -> list[dict[str, object]]:
|
|
92
|
+
return [
|
|
93
|
+
{
|
|
94
|
+
"name": i.name,
|
|
95
|
+
"detected_value": i.detected_value,
|
|
96
|
+
"explicit_value": i.explicit_value,
|
|
97
|
+
"effective_value": i.effective_value,
|
|
98
|
+
"source": i.source,
|
|
99
|
+
}
|
|
100
|
+
for i in state.identities
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _derived_payload(state: AppState) -> list[dict[str, object]]:
|
|
105
|
+
return [
|
|
106
|
+
{
|
|
107
|
+
"variable_name": d.variable_name,
|
|
108
|
+
"current_value": d.current_value,
|
|
109
|
+
"computed_default": d.computed_default,
|
|
110
|
+
"source_identity_name": d.source_identity_name,
|
|
111
|
+
"status": d.status,
|
|
112
|
+
}
|
|
113
|
+
for d in state.derived
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _service_payload(state: AppState) -> list[dict[str, object]]:
|
|
118
|
+
return [
|
|
119
|
+
{
|
|
120
|
+
"service_id": s.service_id,
|
|
121
|
+
"display_name": s.display_name,
|
|
122
|
+
"token_var": s.token_var,
|
|
123
|
+
"token_present": s.token_present,
|
|
124
|
+
"masked_token": s.masked_token,
|
|
125
|
+
"resolved_token_url": s.resolved_token_url,
|
|
126
|
+
"resolved_test_url": s.resolved_test_url,
|
|
127
|
+
"test_status": s.test_status,
|
|
128
|
+
"icon": s.icon or service_icon(None),
|
|
129
|
+
}
|
|
130
|
+
for s in state.services
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _state_payload(state: AppState) -> dict[str, object]:
|
|
135
|
+
return {
|
|
136
|
+
"env_path": str(state.env_path),
|
|
137
|
+
"config": {
|
|
138
|
+
"profile": state.config_context.profile,
|
|
139
|
+
"name": state.config_name,
|
|
140
|
+
"config_dir": str(state.config_context.config_dir),
|
|
141
|
+
"common_config_path": str(state.config_context.common_config_path),
|
|
142
|
+
"user_config_path": str(state.config_context.user_config_path),
|
|
143
|
+
},
|
|
144
|
+
"identities": _identity_payload(state),
|
|
145
|
+
"derived": _derived_payload(state),
|
|
146
|
+
"services": _service_payload(state),
|
|
147
|
+
"session": {
|
|
148
|
+
"queue_test_all_on_dashboard_load": state.session.queue_test_all_on_dashboard_load,
|
|
149
|
+
"backup_created": state.session.backup_created,
|
|
150
|
+
"backup_path": str(state.session.backup_path)
|
|
151
|
+
if state.session.backup_path
|
|
152
|
+
else None,
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _service_fingerprint(
|
|
158
|
+
state: AppState,
|
|
159
|
+
*,
|
|
160
|
+
service_id: str,
|
|
161
|
+
resolved_test_url: str,
|
|
162
|
+
token: str,
|
|
163
|
+
) -> str:
|
|
164
|
+
svc_def = state.effective_config.services[service_id]
|
|
165
|
+
return service_test_fingerprint(
|
|
166
|
+
service_id=service_id,
|
|
167
|
+
token_var=svc_def.token_var,
|
|
168
|
+
resolved_test_url=resolved_test_url,
|
|
169
|
+
auth=svc_def.auth,
|
|
170
|
+
tls_verify=svc_def.tls_verify,
|
|
171
|
+
token=token,
|
|
172
|
+
session_token=state.session.token,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def create_app(ctx: AppContext) -> FastAPI:
|
|
177
|
+
app = FastAPI(title="dotfill", docs_url=None, redoc_url=None, openapi_url=None)
|
|
178
|
+
api = APIRouter(prefix="/api")
|
|
179
|
+
|
|
180
|
+
@app.middleware("http")
|
|
181
|
+
async def reject_unexpected_origin(
|
|
182
|
+
request: Request,
|
|
183
|
+
call_next, # type: ignore[no-untyped-def]
|
|
184
|
+
):
|
|
185
|
+
if request.method in _MUTATING_METHODS and request.url.path.startswith("/api/"):
|
|
186
|
+
origin = request.headers.get("origin")
|
|
187
|
+
if origin and not _is_local_origin(origin):
|
|
188
|
+
return JSONResponse(
|
|
189
|
+
status_code=403,
|
|
190
|
+
content={
|
|
191
|
+
"error": "ForbiddenOrigin",
|
|
192
|
+
"message": "Unexpected request origin",
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
return await call_next(request)
|
|
196
|
+
|
|
197
|
+
def session_dep(
|
|
198
|
+
request: Request,
|
|
199
|
+
x_dotfill_session: Annotated[str | None, Header(alias=SESSION_HEADER)] = None,
|
|
200
|
+
) -> AppContext:
|
|
201
|
+
# Allow the bootstrap endpoint to skip the header.
|
|
202
|
+
if request.url.path == "/api/bootstrap":
|
|
203
|
+
return ctx
|
|
204
|
+
_require_session(ctx, x_dotfill_session)
|
|
205
|
+
return ctx
|
|
206
|
+
|
|
207
|
+
@app.exception_handler(DotfillError)
|
|
208
|
+
async def _df_exc_handler(_: Request, exc: DotfillError) -> JSONResponse:
|
|
209
|
+
log.warning("Dotfill error: %s", exc)
|
|
210
|
+
status_map = {
|
|
211
|
+
DuplicateManagedVariableError: 409,
|
|
212
|
+
ConfigValidationError: 400,
|
|
213
|
+
UrlTemplateError: 400,
|
|
214
|
+
UnresolvedIdentityError: 409,
|
|
215
|
+
ImportScanError: 400,
|
|
216
|
+
ServiceTestError: 400,
|
|
217
|
+
SaveError: 500,
|
|
218
|
+
}
|
|
219
|
+
code = next(
|
|
220
|
+
(status_code for error_type, status_code in status_map.items() if isinstance(exc, error_type)),
|
|
221
|
+
500,
|
|
222
|
+
)
|
|
223
|
+
return JSONResponse(
|
|
224
|
+
status_code=code,
|
|
225
|
+
content={"error": type(exc).__name__, "message": str(exc)},
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
@api.get("/bootstrap")
|
|
229
|
+
def bootstrap() -> dict[str, object]:
|
|
230
|
+
"""Public endpoint: hands the SPA its session token and version."""
|
|
231
|
+
from . import __version__
|
|
232
|
+
|
|
233
|
+
return {"session_token": ctx.session.token, "version": __version__}
|
|
234
|
+
|
|
235
|
+
@api.post("/open-folder")
|
|
236
|
+
def open_folder(ctx_in: AppContext = Depends(session_dep)) -> dict[str, object]:
|
|
237
|
+
"""Open the directory containing the .env file in the system file manager."""
|
|
238
|
+
state = _state(ctx_in)
|
|
239
|
+
open_env_location(state.env_path)
|
|
240
|
+
return {"ok": True}
|
|
241
|
+
|
|
242
|
+
@api.post("/open-config-folder")
|
|
243
|
+
def open_config_folder(ctx_in: AppContext = Depends(session_dep)) -> dict[str, object]:
|
|
244
|
+
"""Open the resolved dotfill config directory in the system file manager."""
|
|
245
|
+
open_directory(ctx_in.config_context.config_dir, create=True)
|
|
246
|
+
return {"ok": True}
|
|
247
|
+
|
|
248
|
+
@api.get("/state")
|
|
249
|
+
def get_state(ctx_in: AppContext = Depends(session_dep)) -> dict[str, object]:
|
|
250
|
+
return _state_payload(_state(ctx_in))
|
|
251
|
+
|
|
252
|
+
@api.post("/token/{service_id}")
|
|
253
|
+
def save_token(
|
|
254
|
+
service_id: str,
|
|
255
|
+
body: SaveTokenRequest,
|
|
256
|
+
ctx_in: AppContext = Depends(session_dep),
|
|
257
|
+
) -> dict[str, object]:
|
|
258
|
+
state = _state(ctx_in)
|
|
259
|
+
svc = next(
|
|
260
|
+
(s for s in state.services if s.service_id == service_id),
|
|
261
|
+
None,
|
|
262
|
+
)
|
|
263
|
+
if svc is None:
|
|
264
|
+
raise HTTPException(status_code=404, detail=f"Unknown service {service_id}")
|
|
265
|
+
# Also fill in derived identity variables that are missing/empty.
|
|
266
|
+
derived_updates = {
|
|
267
|
+
d.variable_name: d.computed_default
|
|
268
|
+
for d in state.derived
|
|
269
|
+
if d.status == "missing" and d.computed_default
|
|
270
|
+
}
|
|
271
|
+
updates: dict[str, str] = dict(derived_updates)
|
|
272
|
+
updates[svc.token_var] = body.token.get_secret_value()
|
|
273
|
+
save_assignments(state.env_path, state.env_doc, updates, ctx_in.session)
|
|
274
|
+
log.info("Token saved for %s", service_id)
|
|
275
|
+
# Invalidate cached test result since the token value changed.
|
|
276
|
+
ctx_in.session.test_results.pop(service_id, None)
|
|
277
|
+
return {"ok": True, "updated": sorted(updates.keys())}
|
|
278
|
+
|
|
279
|
+
@api.post("/test/{service_id}")
|
|
280
|
+
def test_one(
|
|
281
|
+
service_id: str,
|
|
282
|
+
ctx_in: AppContext = Depends(session_dep),
|
|
283
|
+
) -> dict[str, object]:
|
|
284
|
+
state = _state(ctx_in)
|
|
285
|
+
svc_def = state.effective_config.services.get(service_id)
|
|
286
|
+
if svc_def is None:
|
|
287
|
+
raise HTTPException(status_code=404, detail=f"Unknown service {service_id}")
|
|
288
|
+
token = state.env_doc.get(svc_def.token_var)
|
|
289
|
+
if not token:
|
|
290
|
+
raise HTTPException(status_code=400, detail="No token to test")
|
|
291
|
+
identity_values = {i.name: i.effective_value for i in state.identities}
|
|
292
|
+
try:
|
|
293
|
+
resolved = resolve_url_template(svc_def.test_url_template, identity_values)
|
|
294
|
+
except UrlTemplateError as exc:
|
|
295
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
296
|
+
result = run_service_test(
|
|
297
|
+
svc_def,
|
|
298
|
+
service_id=service_id,
|
|
299
|
+
resolved_test_url=resolved,
|
|
300
|
+
token=token,
|
|
301
|
+
)
|
|
302
|
+
result.fingerprint = _service_fingerprint(
|
|
303
|
+
state,
|
|
304
|
+
service_id=service_id,
|
|
305
|
+
resolved_test_url=resolved,
|
|
306
|
+
token=token,
|
|
307
|
+
)
|
|
308
|
+
ctx_in.session.test_results[service_id] = result
|
|
309
|
+
return {
|
|
310
|
+
"service_id": service_id,
|
|
311
|
+
"status": result.status,
|
|
312
|
+
"http_status": result.http_status,
|
|
313
|
+
"error_message": result.error_message,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
@api.post("/test-all")
|
|
317
|
+
def test_all(
|
|
318
|
+
ctx_in: AppContext = Depends(session_dep),
|
|
319
|
+
) -> dict[str, object]:
|
|
320
|
+
state = _state(ctx_in)
|
|
321
|
+
identity_values = {i.name: i.effective_value for i in state.identities}
|
|
322
|
+
results: list[dict[str, object]] = []
|
|
323
|
+
for svc_id, svc_def in state.effective_config.services.items():
|
|
324
|
+
token = state.env_doc.get(svc_def.token_var)
|
|
325
|
+
if not token:
|
|
326
|
+
continue
|
|
327
|
+
try:
|
|
328
|
+
resolved = resolve_url_template(
|
|
329
|
+
svc_def.test_url_template, identity_values
|
|
330
|
+
)
|
|
331
|
+
except UrlTemplateError as exc:
|
|
332
|
+
ctx_in.session.test_results[svc_id] = TestResult(
|
|
333
|
+
status="failed", error_message=str(exc)
|
|
334
|
+
)
|
|
335
|
+
results.append(
|
|
336
|
+
{
|
|
337
|
+
"service_id": svc_id,
|
|
338
|
+
"status": "failed",
|
|
339
|
+
"error_message": str(exc),
|
|
340
|
+
}
|
|
341
|
+
)
|
|
342
|
+
continue
|
|
343
|
+
result = run_service_test(
|
|
344
|
+
svc_def,
|
|
345
|
+
service_id=svc_id,
|
|
346
|
+
resolved_test_url=resolved,
|
|
347
|
+
token=token,
|
|
348
|
+
)
|
|
349
|
+
result.fingerprint = _service_fingerprint(
|
|
350
|
+
state,
|
|
351
|
+
service_id=svc_id,
|
|
352
|
+
resolved_test_url=resolved,
|
|
353
|
+
token=token,
|
|
354
|
+
)
|
|
355
|
+
ctx_in.session.test_results[svc_id] = result
|
|
356
|
+
results.append(
|
|
357
|
+
{
|
|
358
|
+
"service_id": svc_id,
|
|
359
|
+
"status": result.status,
|
|
360
|
+
"http_status": result.http_status,
|
|
361
|
+
"error_message": result.error_message,
|
|
362
|
+
}
|
|
363
|
+
)
|
|
364
|
+
ctx_in.session.queue_test_all_on_dashboard_load = False
|
|
365
|
+
return {"results": results}
|
|
366
|
+
|
|
367
|
+
@api.post("/import/scan-path")
|
|
368
|
+
def scan_path(
|
|
369
|
+
body: ScanPathRequest,
|
|
370
|
+
ctx_in: AppContext = Depends(session_dep),
|
|
371
|
+
) -> dict[str, object]:
|
|
372
|
+
path = Path(body.path).expanduser()
|
|
373
|
+
if not path.exists():
|
|
374
|
+
raise HTTPException(status_code=400, detail=f"Path not found: {path}")
|
|
375
|
+
text = path.read_text(encoding="utf-8")
|
|
376
|
+
state = _state(ctx_in)
|
|
377
|
+
scan = scan_source_text(
|
|
378
|
+
source_label=str(path),
|
|
379
|
+
source_text=text,
|
|
380
|
+
current_doc=state.env_doc,
|
|
381
|
+
config=state.effective_config,
|
|
382
|
+
)
|
|
383
|
+
ctx_in.session.import_scans[scan.scan_id] = scan
|
|
384
|
+
return _scan_payload(scan)
|
|
385
|
+
|
|
386
|
+
@api.post("/import/scan-dropped")
|
|
387
|
+
def scan_dropped(
|
|
388
|
+
body: ScanDroppedRequest,
|
|
389
|
+
ctx_in: AppContext = Depends(session_dep),
|
|
390
|
+
) -> dict[str, object]:
|
|
391
|
+
state = _state(ctx_in)
|
|
392
|
+
scan = scan_source_text(
|
|
393
|
+
source_label=f"Dropped file: {body.filename}",
|
|
394
|
+
source_text=body.content.get_secret_value(),
|
|
395
|
+
current_doc=state.env_doc,
|
|
396
|
+
config=state.effective_config,
|
|
397
|
+
)
|
|
398
|
+
ctx_in.session.import_scans[scan.scan_id] = scan
|
|
399
|
+
return _scan_payload(scan)
|
|
400
|
+
|
|
401
|
+
@api.post("/import/commit")
|
|
402
|
+
def commit_import(
|
|
403
|
+
body: CommitImportRequest,
|
|
404
|
+
ctx_in: AppContext = Depends(session_dep),
|
|
405
|
+
) -> dict[str, object]:
|
|
406
|
+
scan = ctx_in.session.import_scans.get(body.scanId)
|
|
407
|
+
if scan is None:
|
|
408
|
+
raise HTTPException(status_code=404, detail="Unknown scan_id")
|
|
409
|
+
choices = [(c.sourceKey, c.targetKey) for c in body.mappings]
|
|
410
|
+
state = _state(ctx_in)
|
|
411
|
+
allowed_targets = {
|
|
412
|
+
service.token_var for service in state.effective_config.services.values()
|
|
413
|
+
} | set(state.effective_config.derived_variables)
|
|
414
|
+
updates = build_updates_from_choices(
|
|
415
|
+
scan,
|
|
416
|
+
choices,
|
|
417
|
+
allowed_targets=allowed_targets,
|
|
418
|
+
current_doc=state.env_doc,
|
|
419
|
+
)
|
|
420
|
+
save_assignments(state.env_path, state.env_doc, updates, ctx_in.session)
|
|
421
|
+
log.info(
|
|
422
|
+
"Import committed: %d variable(s) updated from %s",
|
|
423
|
+
len(updates),
|
|
424
|
+
scan.source_label,
|
|
425
|
+
)
|
|
426
|
+
token_vars_to_svc = {
|
|
427
|
+
s.token_var: sid for sid, s in state.effective_config.services.items()
|
|
428
|
+
}
|
|
429
|
+
for target_key in updates:
|
|
430
|
+
if target_key in token_vars_to_svc:
|
|
431
|
+
ctx_in.session.test_results.pop(token_vars_to_svc[target_key], None)
|
|
432
|
+
# Drop the scan once committed.
|
|
433
|
+
ctx_in.session.import_scans.pop(body.scanId, None)
|
|
434
|
+
return {"ok": True, "updated": sorted(updates.keys())}
|
|
435
|
+
|
|
436
|
+
app.include_router(api)
|
|
437
|
+
return app
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _scan_payload(scan) -> dict[str, object]: # type: ignore[no-untyped-def]
|
|
441
|
+
return {
|
|
442
|
+
"scan_id": scan.scan_id,
|
|
443
|
+
"source_label": scan.source_label,
|
|
444
|
+
"occupied_targets": scan.occupied_targets,
|
|
445
|
+
"rows": [
|
|
446
|
+
{
|
|
447
|
+
"source_key": r.source_key,
|
|
448
|
+
"target_key": r.target_key,
|
|
449
|
+
"mapping_kind": r.mapping_kind,
|
|
450
|
+
"locked": r.locked,
|
|
451
|
+
"status": r.status,
|
|
452
|
+
"masked_source_value": r.masked_source_value,
|
|
453
|
+
}
|
|
454
|
+
for r in scan.proposed_rows
|
|
455
|
+
],
|
|
456
|
+
}
|