satchel-server 0.1.1__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.
- satchel/__init__.py +0 -0
- satchel/api/__init__.py +0 -0
- satchel/api/app.py +207 -0
- satchel/api/audit.py +80 -0
- satchel/api/auth.py +133 -0
- satchel/api/catalog.py +200 -0
- satchel/api/common.py +177 -0
- satchel/api/connections.py +226 -0
- satchel/api/eval_schemas.py +162 -0
- satchel/api/evals.py +320 -0
- satchel/api/health.py +61 -0
- satchel/api/hooks.py +233 -0
- satchel/api/library.py +164 -0
- satchel/api/llm.py +251 -0
- satchel/api/observability.py +375 -0
- satchel/api/proposals.py +166 -0
- satchel/api/schemas.py +568 -0
- satchel/api/sources.py +416 -0
- satchel/api/suggestions.py +362 -0
- satchel/api/workbench.py +495 -0
- satchel/api/workspace_settings.py +241 -0
- satchel/audit.py +265 -0
- satchel/cli/__init__.py +0 -0
- satchel/cli/clients.py +234 -0
- satchel/cli/config.py +163 -0
- satchel/cli/doctor.py +369 -0
- satchel/cli/evals_cmds.py +254 -0
- satchel/cli/infra.py +266 -0
- satchel/cli/main.py +627 -0
- satchel/cli/onboarding.py +793 -0
- satchel/cli/ops.py +145 -0
- satchel/cli/secrets_cmds.py +44 -0
- satchel/cli/source_cmds.py +368 -0
- satchel/cli/storage_cmds.py +56 -0
- satchel/core/__init__.py +0 -0
- satchel/core/crypto.py +121 -0
- satchel/core/item_types.py +203 -0
- satchel/core/render.py +78 -0
- satchel/core/tokens.py +8 -0
- satchel/curation/__init__.py +0 -0
- satchel/curation/apply.py +146 -0
- satchel/curation/config.py +50 -0
- satchel/curation/drafts.py +112 -0
- satchel/curation/gaps.py +370 -0
- satchel/curation/golden.py +128 -0
- satchel/curation/hygiene.py +416 -0
- satchel/curation/redact.py +119 -0
- satchel/curation/releases.py +88 -0
- satchel/curation/run.py +177 -0
- satchel/curation/signal_text.py +104 -0
- satchel/curation/suggest.py +709 -0
- satchel/data/__init__.py +0 -0
- satchel/data/connection_admin.py +392 -0
- satchel/data/connections.py +124 -0
- satchel/data/connectors/__init__.py +9 -0
- satchel/data/connectors/base.py +79 -0
- satchel/data/connectors/bigquery_conn.py +221 -0
- satchel/data/connectors/duckdb_conn.py +121 -0
- satchel/data/connectors/postgres.py +87 -0
- satchel/data/connectors/snowflake_conn.py +200 -0
- satchel/data/crawl.py +153 -0
- satchel/data/gateway.py +116 -0
- satchel/data/gcs.py +75 -0
- satchel/data/objstore.py +165 -0
- satchel/data/result_store.py +187 -0
- satchel/data/results.py +199 -0
- satchel/data/s3.py +114 -0
- satchel/data/semantic.py +349 -0
- satchel/evals/__init__.py +0 -0
- satchel/evals/gate.py +173 -0
- satchel/evals/graders.py +404 -0
- satchel/evals/harness.py +188 -0
- satchel/evals/providers.py +86 -0
- satchel/evals/retrieval.py +60 -0
- satchel/evals/runner.py +283 -0
- satchel/evals/store.py +107 -0
- satchel/evals/suite.py +156 -0
- satchel/install_hints.py +48 -0
- satchel/limits.py +216 -0
- satchel/llm/__init__.py +0 -0
- satchel/llm/chat.py +416 -0
- satchel/llm/connections.py +424 -0
- satchel/llm/embeddings.py +161 -0
- satchel/mcp/__init__.py +0 -0
- satchel/mcp/apps.py +54 -0
- satchel/mcp/auth.py +171 -0
- satchel/mcp/http.py +118 -0
- satchel/mcp/server.py +188 -0
- satchel/mcp/stdio.py +77 -0
- satchel/oauth/__init__.py +13 -0
- satchel/oauth/clients.py +216 -0
- satchel/oauth/common.py +105 -0
- satchel/oauth/flows.py +413 -0
- satchel/oauth/oidc.py +193 -0
- satchel/oauth/routes.py +335 -0
- satchel/oauth/sessions.py +77 -0
- satchel/oauth/tokens.py +181 -0
- satchel/resources/__init__.py +0 -0
- satchel/resources/compose.yml +24 -0
- satchel/resources/initdb/10-app-role.sql +18 -0
- satchel/resources/jaffle-shop/context/assets/warehouse/analytics/customers.yaml +12 -0
- satchel/resources/jaffle-shop/context/assets/warehouse/analytics/orders.yaml +13 -0
- satchel/resources/jaffle-shop/context/assets/warehouse/analytics/payments.yaml +15 -0
- satchel/resources/jaffle-shop/context/caveats/guest-checkout-customers.md +9 -0
- satchel/resources/jaffle-shop/context/caveats/refunds-pre-2023.md +8 -0
- satchel/resources/jaffle-shop/context/evals/core.yaml +113 -0
- satchel/resources/jaffle-shop/context/evals/retrieval.yaml +37 -0
- satchel/resources/jaffle-shop/context/glossary/business-terms.yaml +50 -0
- satchel/resources/jaffle-shop/context/guides/customer-analysis.md +33 -0
- satchel/resources/jaffle-shop/context/guides/payments-reconciliation.md +30 -0
- satchel/resources/jaffle-shop/context/guides/refunds-and-returns.md +31 -0
- satchel/resources/jaffle-shop/context/guides/revenue-reporting.md +38 -0
- satchel/resources/jaffle-shop/context/queries/active-customers-90d.yaml +10 -0
- satchel/resources/jaffle-shop/context/queries/average-order-value.yaml +13 -0
- satchel/resources/jaffle-shop/context/queries/monthly-revenue.yaml +15 -0
- satchel/resources/jaffle-shop/context/queries/refund-rate.yaml +21 -0
- satchel/resources/jaffle-shop/context/queries/top-customers-by-revenue.yaml +17 -0
- satchel/resources/jaffle-shop/context/satchel.config.yaml +9 -0
- satchel/resources/jaffle-shop/context/semantic/core/jaffle.mdl.json +147 -0
- satchel/resources/jaffle-shop/context/workspace.md +13 -0
- satchel/resources/jaffle-shop/seed.py +121 -0
- satchel/resources/ui/assets/MonacoImpl-CWtUooPL.js +654 -0
- satchel/resources/ui/assets/MonacoImpl-chSTM-fo.css +1 -0
- satchel/resources/ui/assets/editor.worker-CET2B_uC.js +12 -0
- satchel/resources/ui/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- satchel/resources/ui/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- satchel/resources/ui/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- satchel/resources/ui/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- satchel/resources/ui/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- satchel/resources/ui/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- satchel/resources/ui/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- satchel/resources/ui/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- satchel/resources/ui/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- satchel/resources/ui/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- satchel/resources/ui/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- satchel/resources/ui/assets/index-D3Gzu3t6.js +147 -0
- satchel/resources/ui/assets/index-jj6sxlSE.css +1 -0
- satchel/resources/ui/assets/json.worker-BAR7yJ6b.js +51 -0
- satchel/resources/ui/assets/jsonMode-CVjFmlq-.js +10 -0
- satchel/resources/ui/assets/markdown-Cimd5fb3.js +1 -0
- satchel/resources/ui/assets/sql-NEE52Syq.js +1 -0
- satchel/resources/ui/assets/yaml-CCk2xI84.js +1 -0
- satchel/resources/ui/favicon.svg +1 -0
- satchel/resources/ui/index.html +22 -0
- satchel/resources/widgets/result.html +234 -0
- satchel/retrieval/__init__.py +4 -0
- satchel/retrieval/pack.py +201 -0
- satchel/retrieval/precedence.py +74 -0
- satchel/retrieval/search.py +573 -0
- satchel/runtime.py +187 -0
- satchel/secrets.py +143 -0
- satchel/settings.py +140 -0
- satchel/signals/__init__.py +0 -0
- satchel/signals/extractors.py +423 -0
- satchel/sources/__init__.py +7 -0
- satchel/sources/adapters/__init__.py +0 -0
- satchel/sources/adapters/cube/__init__.py +134 -0
- satchel/sources/adapters/cube/assets.py +259 -0
- satchel/sources/adapters/cube/deps.txt +3 -0
- satchel/sources/adapters/cube/exprs.py +127 -0
- satchel/sources/adapters/cube/meta.py +134 -0
- satchel/sources/adapters/cube/model.py +245 -0
- satchel/sources/adapters/cube/semantic.py +156 -0
- satchel/sources/adapters/dbt/__init__.py +106 -0
- satchel/sources/adapters/dbt/assets.py +266 -0
- satchel/sources/adapters/dbt/deps.txt +2 -0
- satchel/sources/adapters/dbt/manifest.py +218 -0
- satchel/sources/adapters/dbt/semantic.py +528 -0
- satchel/sources/adapters/sqlmesh/__init__.py +79 -0
- satchel/sources/adapters/sqlmesh/deps.txt +3 -0
- satchel/sources/adapters/sqlmesh/parse.py +330 -0
- satchel/sources/adapters/sqlmesh/project.py +321 -0
- satchel/sources/admin.py +282 -0
- satchel/sources/apply.py +547 -0
- satchel/sources/automation.py +245 -0
- satchel/sources/config.py +265 -0
- satchel/sources/dbt_build.py +520 -0
- satchel/sources/git.py +276 -0
- satchel/sources/items.py +344 -0
- satchel/sources/precedence.py +249 -0
- satchel/sources/registry.py +139 -0
- satchel/sources/repo.py +88 -0
- satchel/sources/service.py +306 -0
- satchel/store/__init__.py +0 -0
- satchel/store/alembic.ini +2 -0
- satchel/store/db.py +161 -0
- satchel/store/migrations/__init__.py +0 -0
- satchel/store/migrations/env.py +24 -0
- satchel/store/migrations/script.py.mako +21 -0
- satchel/store/migrations/versions/0001_initial.py +274 -0
- satchel/store/migrations/versions/0002_api_token.py +48 -0
- satchel/store/migrations/versions/0003_query_result.py +50 -0
- satchel/store/migrations/versions/0004_oauth.py +165 -0
- satchel/store/migrations/versions/0005_session_sensitive.py +24 -0
- satchel/store/migrations/versions/0006_curation.py +126 -0
- satchel/store/migrations/versions/0007_evals.py +98 -0
- satchel/store/migrations/versions/0008_feedback_source.py +28 -0
- satchel/store/migrations/versions/0009_sources.py +74 -0
- satchel/store/migrations/versions/0010_worker_role.py +155 -0
- satchel/store/migrations/versions/0011_audit_index.py +22 -0
- satchel/store/migrations/versions/0012_llm_connection.py +63 -0
- satchel/store/migrations/versions/__init__.py +0 -0
- satchel/store/models.py +727 -0
- satchel/store/roles.py +274 -0
- satchel/store/schema.py +95 -0
- satchel/store/scopes.py +28 -0
- satchel/sync/__init__.py +3 -0
- satchel/sync/chunking.py +82 -0
- satchel/sync/embedding_sync.py +46 -0
- satchel/sync/importer.py +411 -0
- satchel/sync/parse.py +290 -0
- satchel/sync/sqlrefs.py +31 -0
- satchel/tools/__init__.py +16 -0
- satchel/tools/data_tools.py +702 -0
- satchel/tools/definitions.py +586 -0
- satchel/tools/registry.py +301 -0
- satchel/tools/text.py +123 -0
- satchel/worker/__init__.py +0 -0
- satchel/worker/main.py +162 -0
- satchel_server-0.1.1.dist-info/METADATA +73 -0
- satchel_server-0.1.1.dist-info/RECORD +223 -0
- satchel_server-0.1.1.dist-info/WHEEL +4 -0
- satchel_server-0.1.1.dist-info/entry_points.txt +7 -0
satchel/__init__.py
ADDED
|
File without changes
|
satchel/api/__init__.py
ADDED
|
File without changes
|
satchel/api/app.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""REST API (design §2, §14 M1/M3a): library, workbench, proposals, observability, catalog.
|
|
2
|
+
|
|
3
|
+
Everything is under ``/v1`` with role enforcement per design §9 (admin > curator > analyst >
|
|
4
|
+
explorer). The OAuth authorization server and OIDC login routes and ``/mcp`` are mounted here
|
|
5
|
+
too. Auth lives in :mod:`satchel.api.auth`; routers in the sibling modules. ``/openapi.json`` is
|
|
6
|
+
the contract the web UI generates its TypeScript types from (``satchel openapi``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, Depends, FastAPI, HTTPException
|
|
13
|
+
from fastapi.responses import FileResponse, HTMLResponse
|
|
14
|
+
from fastapi.routing import APIRoute
|
|
15
|
+
from sqlalchemy import Engine, select
|
|
16
|
+
|
|
17
|
+
from satchel.api import (
|
|
18
|
+
audit,
|
|
19
|
+
catalog,
|
|
20
|
+
connections,
|
|
21
|
+
evals,
|
|
22
|
+
hooks,
|
|
23
|
+
library,
|
|
24
|
+
llm,
|
|
25
|
+
observability,
|
|
26
|
+
proposals,
|
|
27
|
+
sources,
|
|
28
|
+
suggestions,
|
|
29
|
+
workbench,
|
|
30
|
+
workspace_settings,
|
|
31
|
+
)
|
|
32
|
+
from satchel.api.auth import ApiContext, Auth, Caller
|
|
33
|
+
from satchel.api.health import mount_readyz
|
|
34
|
+
from satchel.api.schemas import (
|
|
35
|
+
MeOut,
|
|
36
|
+
PackEntryOut,
|
|
37
|
+
PackOut,
|
|
38
|
+
PackRequest,
|
|
39
|
+
SearchHitOut,
|
|
40
|
+
SearchRequest,
|
|
41
|
+
)
|
|
42
|
+
from satchel.llm.chat import ChatProvider
|
|
43
|
+
from satchel.llm.embeddings import EmbeddingProvider, make_embedder
|
|
44
|
+
from satchel.oauth.clients import CimdFetcher
|
|
45
|
+
from satchel.oauth.oidc import OidcProvider
|
|
46
|
+
from satchel.oauth.routes import OAuthApp, mount_oauth
|
|
47
|
+
from satchel.retrieval import build_context_pack, search_context
|
|
48
|
+
from satchel.runtime import TraceMiddleware
|
|
49
|
+
from satchel.settings import Settings, get_settings
|
|
50
|
+
from satchel.store.db import admin_session, get_engine, workspace_session
|
|
51
|
+
from satchel.store.models import AppUser, Workspace
|
|
52
|
+
|
|
53
|
+
__all__ = ["Caller", "create_app"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Paths the SPA fallback must never answer (they are APIs, so a miss is a real 404).
|
|
57
|
+
_API_PREFIXES = (
|
|
58
|
+
"v1", "auth", "oauth", "mcp", ".well-known", "openapi.json", "docs", "redoc", "healthz",
|
|
59
|
+
"readyz",
|
|
60
|
+
)
|
|
61
|
+
_DEFAULT_UI_DIR = Path(__file__).resolve().parents[3] / "ui" / "dist" # repo checkout
|
|
62
|
+
_PACKAGED_UI_DIR = Path(__file__).resolve().parents[1] / "resources" / "ui" # built into the wheel
|
|
63
|
+
|
|
64
|
+
_NO_UI_PAGE = """<!doctype html><meta charset="utf-8"><title>satchel</title>
|
|
65
|
+
<body style="font:15px/1.5 system-ui;max-width:640px;margin:48px auto;padding:0 16px">
|
|
66
|
+
<h1>Satchel is running</h1>
|
|
67
|
+
<p>The API and MCP endpoint are up, but this install has no built curator UI.</p>
|
|
68
|
+
<p>The PyPI package includes it: <code>uv tool install --force satchel-server</code>.
|
|
69
|
+
From a satchel repo checkout, build it and point satchel at it:</p>
|
|
70
|
+
<pre>cd ui && npm ci && npm run build
|
|
71
|
+
SATCHEL_UI_DIR=$PWD/dist satchel up</pre>
|
|
72
|
+
<p>Or reinstall from the checkout after building, so the UI is bundled:
|
|
73
|
+
<code>uv tool install --force ./server</code></p>
|
|
74
|
+
<p>MCP: <code>/mcp</code> · API docs: <a href="/docs">/docs</a></p></body>"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _resolve_ui_dir(configured: str | None) -> Path | None:
|
|
78
|
+
"""Configured dir, else the wheel's bundled UI, the repo checkout, or ./ui/dist."""
|
|
79
|
+
if configured:
|
|
80
|
+
return Path(configured).expanduser()
|
|
81
|
+
for cand in (_PACKAGED_UI_DIR, _DEFAULT_UI_DIR, Path.cwd() / "ui" / "dist"):
|
|
82
|
+
if (cand / "index.html").is_file():
|
|
83
|
+
return cand
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _mount_ui(app: FastAPI, ui_dir: Path) -> bool:
|
|
88
|
+
"""Serve the built curator UI at ``/`` with an SPA fallback (registered last)."""
|
|
89
|
+
root = ui_dir.resolve()
|
|
90
|
+
index = root / "index.html"
|
|
91
|
+
if not index.is_file():
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
@app.get("/{path:path}", include_in_schema=False)
|
|
95
|
+
def spa(path: str) -> FileResponse:
|
|
96
|
+
head = path.split("/", 1)[0]
|
|
97
|
+
if head in _API_PREFIXES:
|
|
98
|
+
raise HTTPException(404, "Not Found")
|
|
99
|
+
candidate = (root / path).resolve()
|
|
100
|
+
if path and candidate.is_file() and candidate.is_relative_to(root):
|
|
101
|
+
return FileResponse(candidate)
|
|
102
|
+
return FileResponse(index, headers={"Cache-Control": "no-cache"})
|
|
103
|
+
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _operation_id(route: APIRoute) -> str:
|
|
108
|
+
return route.name # stable, readable names for the generated TS client
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def create_app(
|
|
112
|
+
engine: Engine | None = None, embedder: EmbeddingProvider | None = None, *,
|
|
113
|
+
mcp: bool = True, settings: Settings | None = None, oidc: OidcProvider | None = None,
|
|
114
|
+
cimd_fetcher: CimdFetcher | None = None, chat: ChatProvider | None = None,
|
|
115
|
+
) -> FastAPI:
|
|
116
|
+
app = FastAPI(title="satchel", version="0.1.0", generate_unique_id_function=_operation_id)
|
|
117
|
+
app.add_middleware(TraceMiddleware)
|
|
118
|
+
eng = engine or get_engine()
|
|
119
|
+
emb = embedder or make_embedder()
|
|
120
|
+
st = settings or get_settings()
|
|
121
|
+
if mcp:
|
|
122
|
+
from satchel.mcp.http import McpHttp, mount_mcp
|
|
123
|
+
|
|
124
|
+
mount_mcp(app, McpHttp(eng, emb, st)) # Streamable HTTP at /mcp, bearer auth
|
|
125
|
+
mount_oauth(app, OAuthApp(eng, st, oidc, cimd_fetcher)) # /oauth/*, /auth/*, /.well-known/*
|
|
126
|
+
|
|
127
|
+
auth = Auth(ApiContext(eng, emb, st, chat))
|
|
128
|
+
Who = Annotated[Caller, Depends(auth.require("explorer"))]
|
|
129
|
+
|
|
130
|
+
@app.get("/healthz")
|
|
131
|
+
def healthz() -> dict[str, str]:
|
|
132
|
+
return {"status": "ok"}
|
|
133
|
+
|
|
134
|
+
mount_readyz(app, eng)
|
|
135
|
+
|
|
136
|
+
me = APIRouter(tags=["me"])
|
|
137
|
+
|
|
138
|
+
@me.get("/v1/me", response_model=MeOut)
|
|
139
|
+
def get_me(who: Who) -> MeOut:
|
|
140
|
+
with admin_session(engine=eng) as s:
|
|
141
|
+
ws = s.scalars(select(Workspace).where(Workspace.id == who.workspace_id)).one()
|
|
142
|
+
u = s.get(AppUser, who.user_id) if who.user_id else None
|
|
143
|
+
return MeOut(
|
|
144
|
+
user_id=who.user_id, email=u.email if u else None,
|
|
145
|
+
name=u.display_name if u else None, role=who.effective_role,
|
|
146
|
+
workspace_id=ws.id, workspace_slug=ws.slug, workspace_name=ws.name,
|
|
147
|
+
csrf_token=who.csrf, auth=who.auth,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
app.include_router(me)
|
|
151
|
+
app.include_router(library.make_router(auth))
|
|
152
|
+
app.include_router(workbench.make_router(auth))
|
|
153
|
+
app.include_router(proposals.make_router(auth))
|
|
154
|
+
app.include_router(suggestions.make_router(auth))
|
|
155
|
+
app.include_router(observability.make_router(auth))
|
|
156
|
+
app.include_router(catalog.make_router(auth))
|
|
157
|
+
app.include_router(evals.make_router(auth))
|
|
158
|
+
app.include_router(sources.make_router(auth))
|
|
159
|
+
app.include_router(hooks.make_router(auth.ctx)) # unauthenticated push webhooks
|
|
160
|
+
app.include_router(audit.make_router(auth))
|
|
161
|
+
app.include_router(llm.make_router(auth))
|
|
162
|
+
app.include_router(connections.make_router(auth))
|
|
163
|
+
app.include_router(workspace_settings.make_router(auth))
|
|
164
|
+
|
|
165
|
+
search_r = APIRouter(tags=["retrieval"])
|
|
166
|
+
|
|
167
|
+
@search_r.post("/v1/search", response_model=list[SearchHitOut])
|
|
168
|
+
def search(req: SearchRequest, who: Who) -> list[SearchHitOut]:
|
|
169
|
+
with workspace_session(who.workspace_id, who.user_id, engine=eng) as s:
|
|
170
|
+
hits = search_context(s, req.query, types=req.types, k=req.k, embedder=emb)
|
|
171
|
+
return [
|
|
172
|
+
SearchHitOut(
|
|
173
|
+
key=h.key, type=h.type, seq=h.seq, tag=h.tag, score=h.score,
|
|
174
|
+
sources=h.sources, snippet=h.snippet,
|
|
175
|
+
)
|
|
176
|
+
for h in hits
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
@search_r.post("/v1/context-pack", response_model=PackOut)
|
|
180
|
+
def context_pack(req: PackRequest, who: Who) -> PackOut:
|
|
181
|
+
with workspace_session(who.workspace_id, who.user_id, engine=eng) as s:
|
|
182
|
+
pack = build_context_pack(
|
|
183
|
+
s, req.question, who.user_id, embedder=emb, budget_tokens=req.budget_tokens
|
|
184
|
+
)
|
|
185
|
+
return PackOut(
|
|
186
|
+
release_id=pack.release_id,
|
|
187
|
+
budget_tokens=pack.budget_tokens,
|
|
188
|
+
used_tokens=pack.used_tokens,
|
|
189
|
+
text=pack.text,
|
|
190
|
+
entries=[
|
|
191
|
+
PackEntryOut(
|
|
192
|
+
key=e.key, type=e.type, seq=e.seq, tag=e.tag, tokens=e.tokens,
|
|
193
|
+
score=e.score, text=e.text,
|
|
194
|
+
)
|
|
195
|
+
for e in pack.entries
|
|
196
|
+
],
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
app.include_router(search_r)
|
|
200
|
+
ui = _resolve_ui_dir(st.ui_dir)
|
|
201
|
+
if ui is None or not _mount_ui(app, ui):
|
|
202
|
+
|
|
203
|
+
@app.get("/", include_in_schema=False)
|
|
204
|
+
def no_ui() -> HTMLResponse:
|
|
205
|
+
return HTMLResponse(_NO_UI_PAGE)
|
|
206
|
+
|
|
207
|
+
return app
|
satchel/api/audit.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""``GET /v1/audit``: the audit trail for SIEM pulls (admin, RLS-scoped, keyset paginated).
|
|
2
|
+
|
|
3
|
+
Events are ordered by ``(created_at, id)``. Poll with ``since`` and follow ``next_cursor``
|
|
4
|
+
until it is null; remember the last event's ``created_at`` as the next ``since``. For bulk
|
|
5
|
+
export to files or object storage use ``satchel audit export``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Annotated, Any
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
from satchel import audit as audit_lib
|
|
15
|
+
from satchel.api.auth import Auth, Caller
|
|
16
|
+
from satchel.store.db import workspace_session
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuditEventOut(BaseModel):
|
|
20
|
+
id: int
|
|
21
|
+
workspace: str
|
|
22
|
+
actor_user_id: str | None
|
|
23
|
+
actor_email: str | None
|
|
24
|
+
action: str
|
|
25
|
+
target: dict[str, Any]
|
|
26
|
+
created_at: datetime
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuditPage(BaseModel):
|
|
30
|
+
events: list[AuditEventOut]
|
|
31
|
+
next_cursor: str | None = Field(
|
|
32
|
+
default=None, description="Pass as `cursor` for the next page; null on the last page."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _when(name: str, value: str | None) -> datetime | None:
|
|
37
|
+
if value is None:
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
return audit_lib.parse_when(value)
|
|
41
|
+
except ValueError as e:
|
|
42
|
+
raise HTTPException(422, f"{name}: {e}") from None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def make_router(auth: Auth) -> APIRouter:
|
|
46
|
+
r = APIRouter(tags=["audit"])
|
|
47
|
+
Adm = Annotated[Caller, Depends(auth.require("admin"))]
|
|
48
|
+
eng = auth.ctx.engine
|
|
49
|
+
|
|
50
|
+
@r.get("/v1/audit", response_model=AuditPage)
|
|
51
|
+
def list_audit(
|
|
52
|
+
who: Adm,
|
|
53
|
+
since: Annotated[str | None, Query(description="ISO-8601, inclusive")] = None,
|
|
54
|
+
until: Annotated[str | None, Query(description="ISO-8601, exclusive")] = None,
|
|
55
|
+
cursor: Annotated[str | None, Query(description="From a previous page")] = None,
|
|
56
|
+
limit: Annotated[int, Query(ge=1, le=1000)] = 500,
|
|
57
|
+
) -> AuditPage:
|
|
58
|
+
after = None
|
|
59
|
+
if cursor is not None:
|
|
60
|
+
try:
|
|
61
|
+
after = audit_lib.decode_cursor(cursor)
|
|
62
|
+
except ValueError:
|
|
63
|
+
raise HTTPException(422, "invalid cursor") from None
|
|
64
|
+
with workspace_session(who.workspace_id, who.user_id, engine=eng) as s:
|
|
65
|
+
rows, nxt = audit_lib.fetch_page(
|
|
66
|
+
s, since=_when("since", since), until=_when("until", until), after=after,
|
|
67
|
+
limit=limit, workspace_id=who.workspace_id,
|
|
68
|
+
)
|
|
69
|
+
events = [
|
|
70
|
+
AuditEventOut(
|
|
71
|
+
id=x.id, workspace=x.workspace,
|
|
72
|
+
actor_user_id=str(x.actor_user_id) if x.actor_user_id else None,
|
|
73
|
+
actor_email=x.actor_email, action=x.action, target=x.target,
|
|
74
|
+
created_at=x.created_at,
|
|
75
|
+
)
|
|
76
|
+
for x in rows
|
|
77
|
+
]
|
|
78
|
+
return AuditPage(events=events, next_cursor=audit_lib.encode_cursor(nxt) if nxt else None)
|
|
79
|
+
|
|
80
|
+
return r
|
satchel/api/auth.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""REST authentication + role enforcement (design §9).
|
|
2
|
+
|
|
3
|
+
Auth, first match wins: bearer PAT / OAuth access token; the ``satchel_session`` cookie (writes
|
|
4
|
+
must echo ``X-CSRF-Token``); and only with ``SATCHEL_DEV_AUTH=1`` the unverified
|
|
5
|
+
``X-Satchel-Workspace`` / ``X-Satchel-User`` headers. ``require(role)`` builds a dependency that
|
|
6
|
+
also enforces a minimum role.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
from fastapi import Depends, Header, HTTPException, Request
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
from sqlalchemy import Engine, select
|
|
17
|
+
|
|
18
|
+
from satchel.llm.chat import ChatProvider
|
|
19
|
+
from satchel.llm.embeddings import EmbeddingProvider
|
|
20
|
+
from satchel.mcp.auth import ROLE_RANK, authenticate_bearer
|
|
21
|
+
from satchel.oauth import sessions
|
|
22
|
+
from satchel.settings import Settings
|
|
23
|
+
from satchel.store.db import admin_session
|
|
24
|
+
from satchel.store.models import AppUser, Workspace
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ApiContext:
|
|
29
|
+
engine: Engine
|
|
30
|
+
embedder: EmbeddingProvider
|
|
31
|
+
settings: Settings
|
|
32
|
+
chat: ChatProvider | None = None # overrides the configured chat provider (tests)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Caller(BaseModel):
|
|
36
|
+
workspace_id: uuid.UUID
|
|
37
|
+
user_id: uuid.UUID | None
|
|
38
|
+
role: str | None = None
|
|
39
|
+
csrf: str | None = None # only for cookie sessions
|
|
40
|
+
auth: str = "dev" # bearer | cookie | dev
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def effective_role(self) -> str:
|
|
44
|
+
"""Dev-header calls without a user act as the lowest role."""
|
|
45
|
+
return self.role if self.role in ROLE_RANK else "explorer"
|
|
46
|
+
|
|
47
|
+
def has_role(self, minimum: str) -> bool:
|
|
48
|
+
return ROLE_RANK[self.effective_role] >= ROLE_RANK[minimum]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _unauth(detail: str) -> HTTPException:
|
|
52
|
+
return HTTPException(401, detail, headers={"WWW-Authenticate": 'Bearer realm="satchel"'})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Auth:
|
|
56
|
+
def __init__(self, ctx: ApiContext) -> None:
|
|
57
|
+
self.ctx = ctx
|
|
58
|
+
|
|
59
|
+
def caller(
|
|
60
|
+
self,
|
|
61
|
+
request: Request,
|
|
62
|
+
authorization: Annotated[str | None, Header()] = None,
|
|
63
|
+
x_satchel_workspace: Annotated[str | None, Header()] = None,
|
|
64
|
+
x_satchel_user: Annotated[str | None, Header()] = None,
|
|
65
|
+
) -> Caller:
|
|
66
|
+
eng, st = self.ctx.engine, self.ctx.settings
|
|
67
|
+
if authorization:
|
|
68
|
+
p = authenticate_bearer(authorization, engine=eng, settings=st)
|
|
69
|
+
if p is None:
|
|
70
|
+
raise _unauth("invalid, expired or revoked token")
|
|
71
|
+
return Caller(
|
|
72
|
+
workspace_id=p.workspace_id, user_id=p.user_id, role=p.role, auth="bearer"
|
|
73
|
+
)
|
|
74
|
+
cookie = request.cookies.get(sessions.COOKIE)
|
|
75
|
+
if cookie:
|
|
76
|
+
info = sessions.load_session(cookie, engine=eng)
|
|
77
|
+
if info is None:
|
|
78
|
+
raise _unauth("session expired")
|
|
79
|
+
if request.method not in sessions.SAFE_METHODS and not sessions.csrf_ok(
|
|
80
|
+
info, request.headers.get(sessions.CSRF_HEADER)
|
|
81
|
+
):
|
|
82
|
+
raise HTTPException(403, "missing or invalid X-CSRF-Token")
|
|
83
|
+
return Caller(
|
|
84
|
+
workspace_id=info.workspace_id, user_id=info.user_id, role=info.role,
|
|
85
|
+
csrf=info.csrf, auth="cookie",
|
|
86
|
+
)
|
|
87
|
+
if not st.dev_auth:
|
|
88
|
+
raise _unauth("authentication required")
|
|
89
|
+
# DEV-ONLY header auth (SATCHEL_DEV_AUTH=1): unverified. Without headers (e.g. a UI build
|
|
90
|
+
# without VITE_DEV_AUTH) it acts as the first admin of the configured workspace
|
|
91
|
+
# (SATCHEL_WORKSPACE / config file), so `SATCHEL_DEV_AUTH=1 satchel up` works with any UI
|
|
92
|
+
# build.
|
|
93
|
+
slug = x_satchel_workspace or st.workspace
|
|
94
|
+
if not slug:
|
|
95
|
+
raise HTTPException(401, "dev auth: send X-Satchel-Workspace or set SATCHEL_WORKSPACE")
|
|
96
|
+
with admin_session(engine=eng) as s:
|
|
97
|
+
ws = s.scalars(select(Workspace).where(Workspace.slug == slug)).first()
|
|
98
|
+
if ws is None:
|
|
99
|
+
raise HTTPException(404, f"unknown workspace: {slug}")
|
|
100
|
+
if not x_satchel_user and not x_satchel_workspace:
|
|
101
|
+
admin = s.scalars(
|
|
102
|
+
select(AppUser)
|
|
103
|
+
.where(AppUser.workspace_id == ws.id, AppUser.role == "admin")
|
|
104
|
+
.order_by(AppUser.created_at)
|
|
105
|
+
).first()
|
|
106
|
+
if admin is not None:
|
|
107
|
+
return Caller(workspace_id=ws.id, user_id=admin.id, role="admin", auth="dev")
|
|
108
|
+
user_id: uuid.UUID | None = None
|
|
109
|
+
role: str | None = None
|
|
110
|
+
if x_satchel_user:
|
|
111
|
+
try:
|
|
112
|
+
user_id = uuid.UUID(x_satchel_user)
|
|
113
|
+
except ValueError:
|
|
114
|
+
raise HTTPException(400, "X-Satchel-User must be a UUID") from None
|
|
115
|
+
u = s.scalars(
|
|
116
|
+
select(AppUser).where(AppUser.id == user_id, AppUser.workspace_id == ws.id)
|
|
117
|
+
).first()
|
|
118
|
+
if u is None:
|
|
119
|
+
raise HTTPException(403, "user is not a member of this workspace")
|
|
120
|
+
role = u.role
|
|
121
|
+
return Caller(workspace_id=ws.id, user_id=user_id, role=role, auth="dev")
|
|
122
|
+
|
|
123
|
+
def require(self, minimum: str) -> Callable[..., Caller]:
|
|
124
|
+
"""Dependency: an authenticated caller with at least ``minimum`` role."""
|
|
125
|
+
|
|
126
|
+
def dep(who: Annotated[Caller, Depends(self.caller)]) -> Caller:
|
|
127
|
+
if not who.has_role(minimum):
|
|
128
|
+
raise HTTPException(
|
|
129
|
+
403, f"requires the {minimum} role; your role is {who.effective_role}"
|
|
130
|
+
)
|
|
131
|
+
return who
|
|
132
|
+
|
|
133
|
+
return dep
|
satchel/api/catalog.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Catalog: connections and the asset tree with documentation / endorsement state."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
7
|
+
from sqlalchemy import ColumnElement, exists, func, or_, select
|
|
8
|
+
from sqlalchemy.orm import Session
|
|
9
|
+
|
|
10
|
+
from satchel.api.auth import Auth, Caller
|
|
11
|
+
from satchel.api.common import escape_like
|
|
12
|
+
from satchel.api.connections import secret_source
|
|
13
|
+
from satchel.api.schemas import (
|
|
14
|
+
AssetDetail,
|
|
15
|
+
AssetLinkedItem,
|
|
16
|
+
AssetNode,
|
|
17
|
+
AssetPage,
|
|
18
|
+
ConnectionOut,
|
|
19
|
+
DocRefOut,
|
|
20
|
+
)
|
|
21
|
+
from satchel.core.item_types import yaml_item_dict
|
|
22
|
+
from satchel.sources.precedence import DocRef, group_by_key, load_sources
|
|
23
|
+
from satchel.store.db import workspace_session
|
|
24
|
+
from satchel.store.models import (
|
|
25
|
+
Asset,
|
|
26
|
+
Connection,
|
|
27
|
+
ContextEntityLink,
|
|
28
|
+
ContextItem,
|
|
29
|
+
ContextVersion,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Docs:
|
|
34
|
+
"""Documentation and endorsement lookups from the *published* context.
|
|
35
|
+
|
|
36
|
+
When several sources (or a hand-written doc) describe one asset, the primary doc is used
|
|
37
|
+
(see ``satchel.sources.precedence``) and the others are listed as ``also_described_by``."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, s: Session) -> None:
|
|
40
|
+
self.doc: dict[uuid.UUID, tuple[str, bool]] = {}
|
|
41
|
+
self.refs: dict[uuid.UUID, tuple[DocRef, list[DocRef]]] = {}
|
|
42
|
+
groups = group_by_key(
|
|
43
|
+
((asset_id, item, ver) for asset_id, item, ver in s.execute(
|
|
44
|
+
select(ContextEntityLink.asset_id, ContextItem, ContextVersion)
|
|
45
|
+
.join(ContextVersion, ContextVersion.id == ContextEntityLink.version_id)
|
|
46
|
+
.join(ContextItem, ContextItem.published_version_id == ContextVersion.id)
|
|
47
|
+
.where(
|
|
48
|
+
ContextItem.type == "asset_doc",
|
|
49
|
+
ContextEntityLink.relation == "defines",
|
|
50
|
+
ContextItem.state != "deprecated",
|
|
51
|
+
)
|
|
52
|
+
)),
|
|
53
|
+
load_sources(s),
|
|
54
|
+
)
|
|
55
|
+
for asset_id, g in groups.items():
|
|
56
|
+
prim = g.primary
|
|
57
|
+
self.doc[asset_id] = (
|
|
58
|
+
prim.item.key, bool(prim.version.meta.get("endorsed")) if prim.version else False
|
|
59
|
+
)
|
|
60
|
+
self.refs[asset_id] = (prim.ref, [m.ref for m in g.others])
|
|
61
|
+
self.endorse: dict[str, tuple[bool, bool]] = {}
|
|
62
|
+
for meta, body in s.execute(
|
|
63
|
+
select(ContextVersion.meta, ContextVersion.body)
|
|
64
|
+
.join(ContextItem, ContextItem.published_version_id == ContextVersion.id)
|
|
65
|
+
.where(ContextItem.type == "endorsement", ContextItem.state != "deprecated")
|
|
66
|
+
):
|
|
67
|
+
d = yaml_item_dict("endorsement", body, meta)
|
|
68
|
+
if d.get("asset"):
|
|
69
|
+
self.endorse[str(d["asset"]).lower()] = (
|
|
70
|
+
bool(d.get("endorsed")), bool(d.get("excluded_from_ai")),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def node(self, a: Asset, conn_name: str | None) -> AssetNode:
|
|
74
|
+
doc = self.doc.get(a.id)
|
|
75
|
+
e_flag, excluded = self.endorse.get(a.fqn.lower(), (False, False))
|
|
76
|
+
prim, others = self.refs.get(a.id, (None, []))
|
|
77
|
+
return AssetNode(
|
|
78
|
+
id=a.id, connection=conn_name, kind=a.kind, fqn=a.fqn, name=a.fqn.split(".")[-1],
|
|
79
|
+
data_type=a.data_type, dropped=a.dropped_at is not None, has_doc=doc is not None,
|
|
80
|
+
doc_item_key=doc[0] if doc else None, endorsed=bool(doc and doc[1]) or e_flag,
|
|
81
|
+
excluded_from_ai=excluded,
|
|
82
|
+
primary=DocRefOut(source=prim.label, key=prim.key, primary=True) if prim else None,
|
|
83
|
+
also_described_by=[DocRefOut(source=o.label, key=o.key) for o in others],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _tree(s: Session, roots: list[Asset], conn_names: dict[uuid.UUID, str]) -> list[AssetNode]:
|
|
88
|
+
docs = _Docs(s)
|
|
89
|
+
nodes: dict[uuid.UUID, AssetNode] = {}
|
|
90
|
+
level = roots
|
|
91
|
+
out = [docs.node(a, conn_names.get(a.connection_id) if a.connection_id else None)
|
|
92
|
+
for a in roots]
|
|
93
|
+
for a, n in zip(roots, out, strict=True):
|
|
94
|
+
nodes[a.id] = n
|
|
95
|
+
for _ in range(5): # database > schema > table > column
|
|
96
|
+
if not level:
|
|
97
|
+
break
|
|
98
|
+
kids = list(
|
|
99
|
+
s.scalars(
|
|
100
|
+
select(Asset).where(Asset.parent_id.in_([a.id for a in level])).order_by(Asset.fqn)
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
for k in kids:
|
|
104
|
+
node = docs.node(k, conn_names.get(k.connection_id) if k.connection_id else None)
|
|
105
|
+
nodes[k.id] = node
|
|
106
|
+
if k.parent_id in nodes:
|
|
107
|
+
nodes[k.parent_id].children.append(node)
|
|
108
|
+
level = kids
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def make_router(auth: Auth) -> APIRouter:
|
|
113
|
+
r = APIRouter(tags=["catalog"])
|
|
114
|
+
Any_ = Annotated[Caller, Depends(auth.require("explorer"))]
|
|
115
|
+
eng = auth.ctx.engine
|
|
116
|
+
|
|
117
|
+
@r.get("/v1/connections", response_model=list[ConnectionOut])
|
|
118
|
+
def list_connections(who: Any_) -> list[ConnectionOut]:
|
|
119
|
+
with workspace_session(who.workspace_id, who.user_id, engine=eng) as s:
|
|
120
|
+
n = (
|
|
121
|
+
select(func.count())
|
|
122
|
+
.where(Asset.connection_id == Connection.id, Asset.dropped_at.is_(None))
|
|
123
|
+
.scalar_subquery()
|
|
124
|
+
)
|
|
125
|
+
return [
|
|
126
|
+
ConnectionOut(
|
|
127
|
+
id=c.id, name=c.name, kind=c.kind, default_for_ai=c.default_for_ai,
|
|
128
|
+
has_secret=c.secret_ref is not None, asset_count=cnt,
|
|
129
|
+
secret=secret_source(c.secret_ref),
|
|
130
|
+
config=dict(c.config or {}) if who.has_role("admin") else None,
|
|
131
|
+
)
|
|
132
|
+
for c, cnt in s.execute(select(Connection, n).order_by(Connection.name))
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
@r.get("/v1/assets", response_model=AssetPage)
|
|
136
|
+
def list_assets(
|
|
137
|
+
who: Any_,
|
|
138
|
+
connection: Annotated[str | None, Query(description="Connection name")] = None,
|
|
139
|
+
q: Annotated[str | None, Query(description="Substring of an asset name")] = None,
|
|
140
|
+
limit: Annotated[int, Query(ge=1, le=500)] = 100,
|
|
141
|
+
offset: Annotated[int, Query(ge=0)] = 0,
|
|
142
|
+
) -> AssetPage:
|
|
143
|
+
with workspace_session(who.workspace_id, who.user_id, engine=eng) as s:
|
|
144
|
+
conn_names = {c.id: c.name for c in s.scalars(select(Connection))}
|
|
145
|
+
conds: list[ColumnElement[bool]] = [Asset.parent_id.is_(None)]
|
|
146
|
+
if connection:
|
|
147
|
+
cid = next((i for i, n in conn_names.items() if n == connection), None)
|
|
148
|
+
if cid is None:
|
|
149
|
+
raise HTTPException(404, f"no such connection: {connection}")
|
|
150
|
+
conds.append(Asset.connection_id == cid)
|
|
151
|
+
if q and q.strip():
|
|
152
|
+
pat = f"%{escape_like(q.strip())}%"
|
|
153
|
+
child = Asset.__table__.alias("child")
|
|
154
|
+
conds.append(
|
|
155
|
+
or_(
|
|
156
|
+
Asset.fqn.ilike(pat, escape="\\"),
|
|
157
|
+
exists().where(child.c.parent_id == Asset.id,
|
|
158
|
+
child.c.fqn.ilike(pat, escape="\\")),
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
total = s.scalar(select(func.count()).select_from(Asset).where(*conds)) or 0
|
|
162
|
+
roots = list(
|
|
163
|
+
s.scalars(
|
|
164
|
+
select(Asset).where(*conds).order_by(Asset.fqn).limit(limit).offset(offset)
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
return AssetPage(total=total, limit=limit, offset=offset,
|
|
168
|
+
assets=_tree(s, roots, conn_names))
|
|
169
|
+
|
|
170
|
+
@r.get("/v1/assets/{asset_id}", response_model=AssetDetail)
|
|
171
|
+
def get_asset(asset_id: uuid.UUID, who: Any_) -> AssetDetail:
|
|
172
|
+
with workspace_session(who.workspace_id, who.user_id, engine=eng) as s:
|
|
173
|
+
a = s.get(Asset, asset_id)
|
|
174
|
+
if a is None:
|
|
175
|
+
raise HTTPException(404, "no such asset")
|
|
176
|
+
conn_names = {c.id: c.name for c in s.scalars(select(Connection))}
|
|
177
|
+
node = _tree(s, [a], conn_names)[0]
|
|
178
|
+
linked = s.execute(
|
|
179
|
+
select(ContextItem.key, ContextItem.type, ContextItem.state,
|
|
180
|
+
ContextEntityLink.relation)
|
|
181
|
+
.join(ContextVersion, ContextVersion.item_id == ContextItem.id)
|
|
182
|
+
.join(ContextEntityLink, ContextEntityLink.version_id == ContextVersion.id)
|
|
183
|
+
.where(
|
|
184
|
+
ContextEntityLink.asset_id == asset_id,
|
|
185
|
+
or_(
|
|
186
|
+
ContextVersion.id == ContextItem.published_version_id,
|
|
187
|
+
ContextVersion.id == ContextItem.current_version_id,
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
.distinct()
|
|
191
|
+
.order_by(ContextItem.key, ContextEntityLink.relation)
|
|
192
|
+
)
|
|
193
|
+
return AssetDetail(
|
|
194
|
+
**node.model_dump(), stats=a.stats, first_seen_at=a.first_seen_at,
|
|
195
|
+
last_seen_at=a.last_seen_at,
|
|
196
|
+
linked_items=[AssetLinkedItem(key=k, type=t, state=st, relation=rel)
|
|
197
|
+
for k, t, st, rel in linked],
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return r
|