gaard-api 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.
- gaard_api/__init__.py +0 -0
- gaard_api/admin/__init__.py +1 -0
- gaard_api/admin/database.py +513 -0
- gaard_api/admin/defaults.py +271 -0
- gaard_api/admin/models.py +253 -0
- gaard_api/admin/prompt_runtime.py +237 -0
- gaard_api/admin/security.py +45 -0
- gaard_api/admin/services.py +2142 -0
- gaard_api/admin-web/assets/main.js +2056 -0
- gaard_api/admin-web/assets/styles.css +1041 -0
- gaard_api/admin-web/index.html +13 -0
- gaard_api/admin-web/src/main.ts +2343 -0
- gaard_api/api/__init__.py +0 -0
- gaard_api/api/v1/__init__.py +0 -0
- gaard_api/api/v1/admin.py +2174 -0
- gaard_api/api/v1/prompts.py +55 -0
- gaard_api/api/v1/query.py +1109 -0
- gaard_api/api/v1/schema.py +60 -0
- gaard_api/cli.py +19 -0
- gaard_api/cli_commands.py +18 -0
- gaard_api/core/__init__.py +0 -0
- gaard_api/core/error_handlers.py +18 -0
- gaard_api/core/schema_cache.py +7 -0
- gaard_api/core/settings.py +103 -0
- gaard_api/dependencies/__init__.py +0 -0
- gaard_api/main.py +53 -0
- gaard_api/schemas/__init__.py +0 -0
- gaard_api/server_cli.py +25 -0
- gaard_api/services/__init__.py +0 -0
- gaard_api-0.1.0.dist-info/METADATA +44 -0
- gaard_api-0.1.0.dist-info/RECORD +34 -0
- gaard_api-0.1.0.dist-info/WHEEL +5 -0
- gaard_api-0.1.0.dist-info/entry_points.txt +7 -0
- gaard_api-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2174 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Annotated
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
|
7
|
+
from gaard_connectors.sqlalchemy.executor import SQLAlchemyQueryExecutor
|
|
8
|
+
from gaard_core.errors import (
|
|
9
|
+
ConfigurationError,
|
|
10
|
+
LlmProviderError,
|
|
11
|
+
QueryExecutionError,
|
|
12
|
+
SqlValidationError,
|
|
13
|
+
)
|
|
14
|
+
from gaard_core.query_pipeline.models import (
|
|
15
|
+
OutputClassification,
|
|
16
|
+
QueryRequest,
|
|
17
|
+
QueryResponse,
|
|
18
|
+
QueryResult,
|
|
19
|
+
)
|
|
20
|
+
from gaard_core.sql_validator.select_only import SelectOnlySqlValidator
|
|
21
|
+
from pydantic import BaseModel, Field
|
|
22
|
+
from sqlalchemy import select
|
|
23
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
24
|
+
from sqlalchemy.orm import Session
|
|
25
|
+
|
|
26
|
+
from gaard_api.admin.database import get_session
|
|
27
|
+
from gaard_api.admin.models import (
|
|
28
|
+
AdminSession,
|
|
29
|
+
AdminUser,
|
|
30
|
+
BusinessLogicSuggestion,
|
|
31
|
+
DatasourceConnector,
|
|
32
|
+
DatasourceSchemaCache,
|
|
33
|
+
OverviewWidget,
|
|
34
|
+
PromptTemplate,
|
|
35
|
+
)
|
|
36
|
+
from gaard_api.admin.security import (
|
|
37
|
+
create_session_token,
|
|
38
|
+
hash_password,
|
|
39
|
+
hash_token,
|
|
40
|
+
verify_password,
|
|
41
|
+
)
|
|
42
|
+
from gaard_api.admin.services import (
|
|
43
|
+
BUSINESS_LOGIC_STATUS_ACTIVE,
|
|
44
|
+
BUSINESS_LOGIC_STATUS_PENDING,
|
|
45
|
+
OVERVIEW_WIDGET_RESULT_DATA,
|
|
46
|
+
OVERVIEW_WIDGET_RESULT_INTERPRETATION,
|
|
47
|
+
OVERVIEW_WIDGET_SCALAR,
|
|
48
|
+
OVERVIEW_WIDGET_TABLE,
|
|
49
|
+
OVERVIEW_WIDGET_TIMESERIES,
|
|
50
|
+
apply_data_query_audit_retention,
|
|
51
|
+
coerce_data_query_audit_type,
|
|
52
|
+
delete_business_logic_suggestion,
|
|
53
|
+
get_active_datasource_connector,
|
|
54
|
+
get_business_logic_suggestion,
|
|
55
|
+
get_data_query_audit_retention_days,
|
|
56
|
+
get_data_query_audit_type,
|
|
57
|
+
get_datasource_connector,
|
|
58
|
+
get_datasource_connector_by_key,
|
|
59
|
+
get_or_create_datasource_schema_cache,
|
|
60
|
+
get_datasource_schema_cache,
|
|
61
|
+
get_governance_policy_config,
|
|
62
|
+
get_governance_policy_sources,
|
|
63
|
+
get_llm_config_sources,
|
|
64
|
+
get_llm_runtime_config,
|
|
65
|
+
get_query_runtime_config,
|
|
66
|
+
get_overview_widget,
|
|
67
|
+
get_prompt_template,
|
|
68
|
+
get_setting,
|
|
69
|
+
introspect_datasource_connector,
|
|
70
|
+
is_system_datasource_connector,
|
|
71
|
+
json_loads,
|
|
72
|
+
list_admin_audit_logs,
|
|
73
|
+
list_all_overview_widgets,
|
|
74
|
+
list_business_logic_suggestions,
|
|
75
|
+
list_data_query_audit_logs,
|
|
76
|
+
list_datasource_connectors,
|
|
77
|
+
list_overview_widgets,
|
|
78
|
+
list_prompt_templates,
|
|
79
|
+
learn_business_logic_from_sql_error,
|
|
80
|
+
mask_database_url,
|
|
81
|
+
record_admin_audit,
|
|
82
|
+
record_data_query_audit,
|
|
83
|
+
record_data_query_sql_error_audit,
|
|
84
|
+
selected_schema_from_cache,
|
|
85
|
+
set_business_logic_suggestion_enabled,
|
|
86
|
+
set_governance_policy_config,
|
|
87
|
+
set_llm_runtime_config,
|
|
88
|
+
set_query_runtime_config,
|
|
89
|
+
set_setting,
|
|
90
|
+
set_active_datasource_connector,
|
|
91
|
+
test_datasource_connection,
|
|
92
|
+
update_business_logic_suggestion_content,
|
|
93
|
+
update_schema_table_settings,
|
|
94
|
+
validate_datasource_url,
|
|
95
|
+
)
|
|
96
|
+
from gaard_api.api.v1.schema import get_schema_cache_key
|
|
97
|
+
from gaard_api.core.schema_cache import schema_context_cache
|
|
98
|
+
from gaard_api.core.settings import settings
|
|
99
|
+
|
|
100
|
+
router = APIRouter()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class LoginRequest(BaseModel):
|
|
104
|
+
username: str = Field(min_length=1)
|
|
105
|
+
password: str = Field(min_length=1)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class LoginResponse(BaseModel):
|
|
109
|
+
token: str
|
|
110
|
+
username: str
|
|
111
|
+
must_change_password: bool
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ChangePasswordRequest(BaseModel):
|
|
115
|
+
current_password: str = Field(min_length=1)
|
|
116
|
+
new_password: str = Field(min_length=8)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class MeResponse(BaseModel):
|
|
120
|
+
username: str
|
|
121
|
+
must_change_password: bool
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class PromptUpdateRequest(BaseModel):
|
|
125
|
+
name: str = Field(min_length=1)
|
|
126
|
+
description: str = ""
|
|
127
|
+
system_prompt: str = Field(min_length=1)
|
|
128
|
+
user_prompt_template: str = Field(min_length=1)
|
|
129
|
+
active: bool = True
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class AuditSettingsRequest(BaseModel):
|
|
133
|
+
data_query_retention_days: int = Field(ge=1, le=3650)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class SchemaCacheSettingsRequest(BaseModel):
|
|
137
|
+
ttl_seconds: int = Field(ge=1, le=86_400)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class LlmConfigRequest(BaseModel):
|
|
141
|
+
provider: str = Field(min_length=1)
|
|
142
|
+
base_url: str = Field(min_length=1)
|
|
143
|
+
api_key: str | None = None
|
|
144
|
+
clear_api_key: bool = False
|
|
145
|
+
model: str = Field(min_length=1)
|
|
146
|
+
timeout_seconds: int | None = Field(default=None, ge=1, le=600)
|
|
147
|
+
extra_body: dict[str, Any] = Field(default_factory=dict)
|
|
148
|
+
intent_classification_mode: str | None = Field(
|
|
149
|
+
default=None,
|
|
150
|
+
pattern=r"^(auto|llm)$",
|
|
151
|
+
)
|
|
152
|
+
sql_generation_mode: str | None = Field(
|
|
153
|
+
default=None,
|
|
154
|
+
pattern=r"^llm$",
|
|
155
|
+
)
|
|
156
|
+
result_interpretation_mode: str | None = Field(
|
|
157
|
+
default=None,
|
|
158
|
+
pattern=r"^llm$",
|
|
159
|
+
)
|
|
160
|
+
output_classification_mode: str | None = Field(
|
|
161
|
+
default=None,
|
|
162
|
+
pattern=r"^(auto|llm)$",
|
|
163
|
+
)
|
|
164
|
+
investigation_mode: str | None = Field(
|
|
165
|
+
default=None,
|
|
166
|
+
pattern=r"^llm$",
|
|
167
|
+
)
|
|
168
|
+
investigation_ambiguity_mode: str | None = Field(
|
|
169
|
+
default=None,
|
|
170
|
+
pattern=r"^(clarify|safe_aggregate)$",
|
|
171
|
+
)
|
|
172
|
+
query_max_rows: int | None = Field(default=None, ge=1, le=100_000)
|
|
173
|
+
query_timeout_seconds: int | None = Field(
|
|
174
|
+
default=None,
|
|
175
|
+
ge=1,
|
|
176
|
+
le=3_600,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class GovernancePolicyRequest(BaseModel):
|
|
181
|
+
final_answer: dict[str, Any] = Field(default_factory=dict)
|
|
182
|
+
sql: dict[str, Any] = Field(default_factory=dict)
|
|
183
|
+
privacy: dict[str, Any] = Field(default_factory=dict)
|
|
184
|
+
pii_column_names: dict[str, list[str]] | list[str] = Field(default_factory=dict)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class OverviewWidgetUpdateRequest(BaseModel):
|
|
188
|
+
label: str = Field(min_length=1, max_length=255)
|
|
189
|
+
widget_type: str = Field(pattern=r"^(scalar|timeseries|table)$")
|
|
190
|
+
datasource_key: str = Field(min_length=1, max_length=255)
|
|
191
|
+
question: str = Field(min_length=1)
|
|
192
|
+
result_mode: str = Field(default=OVERVIEW_WIDGET_RESULT_DATA, pattern=r"^(data|interpretation)$")
|
|
193
|
+
position: int | None = Field(default=None, ge=10)
|
|
194
|
+
grid_width: int | None = Field(default=None, ge=1, le=4)
|
|
195
|
+
active: bool | None = None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class OverviewWidgetCreateRequest(OverviewWidgetUpdateRequest):
|
|
199
|
+
widget_key: str = Field(min_length=1, max_length=255, pattern=r"^[a-zA-Z0-9_-]+$")
|
|
200
|
+
position: int = Field(default=100, ge=10)
|
|
201
|
+
grid_width: int | None = Field(default=None, ge=1, le=4)
|
|
202
|
+
active: bool = True
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class OverviewWidgetStateRequest(BaseModel):
|
|
206
|
+
active: bool
|
|
207
|
+
position: int | None = Field(default=None, ge=10)
|
|
208
|
+
grid_width: int | None = Field(default=None, ge=1, le=4)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class OverviewWidgetFromQueryRequest(BaseModel):
|
|
212
|
+
label: str = Field(min_length=1, max_length=255)
|
|
213
|
+
widget_type: str = Field(default=OVERVIEW_WIDGET_TABLE, pattern=r"^(scalar|timeseries|table)$")
|
|
214
|
+
datasource_key: str = Field(default="default", min_length=1, max_length=255)
|
|
215
|
+
question: str = Field(min_length=1)
|
|
216
|
+
sql: str = Field(min_length=1)
|
|
217
|
+
result_mode: str = Field(default=OVERVIEW_WIDGET_RESULT_DATA, pattern=r"^(data|interpretation)$")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class DatasourceConnectorRequest(BaseModel):
|
|
221
|
+
connector_key: str = Field(min_length=1, pattern=r"^[a-zA-Z0-9_-]+$")
|
|
222
|
+
name: str = Field(min_length=1)
|
|
223
|
+
database_type: str = Field(pattern=r"^(sqlite|postgresql|mysql)$")
|
|
224
|
+
database_url: str = Field(min_length=1)
|
|
225
|
+
sql_dialect: str = Field(pattern=r"^(sqlite|postgres|mysql)$")
|
|
226
|
+
active: bool = False
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class DatasourceConnectorUpdateRequest(BaseModel):
|
|
230
|
+
name: str = Field(min_length=1)
|
|
231
|
+
database_type: str = Field(pattern=r"^(sqlite|postgresql|mysql)$")
|
|
232
|
+
database_url: str = Field(min_length=1)
|
|
233
|
+
sql_dialect: str = Field(pattern=r"^(sqlite|postgres|mysql)$")
|
|
234
|
+
active: bool = False
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class DatasourceConnectionTestRequest(BaseModel):
|
|
238
|
+
database_type: str = Field(pattern=r"^(sqlite|postgresql|mysql)$")
|
|
239
|
+
database_url: str = Field(min_length=1)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class DatasourceSchemaTableSettingsRequest(BaseModel):
|
|
243
|
+
tables: dict[str, dict[str, Any]]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class BusinessLogicSuggestionUpdateRequest(BaseModel):
|
|
247
|
+
enabled: bool | None = None
|
|
248
|
+
title: str | None = Field(default=None, min_length=1)
|
|
249
|
+
rule_text: str | None = Field(default=None, min_length=1)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def serialize_datetime(value: datetime) -> str:
|
|
253
|
+
return value.isoformat()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def get_runtime_schema_cache_key(session: Session) -> str:
|
|
257
|
+
connector = get_active_datasource_connector(session)
|
|
258
|
+
|
|
259
|
+
if connector is None:
|
|
260
|
+
return get_schema_cache_key()
|
|
261
|
+
|
|
262
|
+
return get_schema_cache_key(connector.database_url, connector.sql_dialect)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def serialize_prompt(prompt: PromptTemplate) -> dict[str, Any]:
|
|
266
|
+
return {
|
|
267
|
+
"prompt_key": prompt.prompt_key,
|
|
268
|
+
"name": prompt.name,
|
|
269
|
+
"description": prompt.description,
|
|
270
|
+
"system_prompt": prompt.system_prompt,
|
|
271
|
+
"user_prompt_template": prompt.user_prompt_template,
|
|
272
|
+
"version": prompt.version,
|
|
273
|
+
"active": prompt.active,
|
|
274
|
+
"updated_by": prompt.updated_by,
|
|
275
|
+
"updated_at": serialize_datetime(prompt.updated_at),
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def serialize_datasource(connector: DatasourceConnector) -> dict[str, Any]:
|
|
280
|
+
return {
|
|
281
|
+
"id": connector.id,
|
|
282
|
+
"connector_key": connector.connector_key,
|
|
283
|
+
"name": connector.name,
|
|
284
|
+
"database_type": connector.database_type,
|
|
285
|
+
"database_url": connector.database_url,
|
|
286
|
+
"masked_database_url": mask_database_url(connector.database_url),
|
|
287
|
+
"sql_dialect": connector.sql_dialect,
|
|
288
|
+
"active": connector.active,
|
|
289
|
+
"system_managed": is_system_datasource_connector(connector),
|
|
290
|
+
"updated_by": connector.updated_by,
|
|
291
|
+
"updated_at": serialize_datetime(connector.updated_at),
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def serialize_datasource_schema(cache: DatasourceSchemaCache) -> dict[str, Any]:
|
|
296
|
+
return {
|
|
297
|
+
"schema": selected_schema_from_cache(cache).model_dump(),
|
|
298
|
+
"raw_schema": json_loads(cache.schema_json),
|
|
299
|
+
"table_settings": json_loads(cache.table_settings_json),
|
|
300
|
+
"formatted_schema": cache.formatted_schema,
|
|
301
|
+
"introspected_at": serialize_datetime(cache.introspected_at),
|
|
302
|
+
"updated_by": cache.updated_by,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def serialize_llm_config(session: Session) -> dict[str, Any]:
|
|
307
|
+
llm_config = get_llm_runtime_config(session)
|
|
308
|
+
query_config = get_query_runtime_config(session)
|
|
309
|
+
api_key_configured = bool(llm_config.api_key and llm_config.api_key != "change-me")
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
"provider": llm_config.provider,
|
|
313
|
+
"base_url": llm_config.base_url,
|
|
314
|
+
"api_key_configured": api_key_configured,
|
|
315
|
+
"api_key_preview": mask_secret(llm_config.api_key) if api_key_configured else None,
|
|
316
|
+
"model": llm_config.model,
|
|
317
|
+
"timeout_seconds": llm_config.timeout_seconds,
|
|
318
|
+
"extra_body": llm_config.extra_body,
|
|
319
|
+
"extra_body_json": json_dumps_pretty(llm_config.extra_body),
|
|
320
|
+
"intent_classification_mode": query_config.intent_classification_mode,
|
|
321
|
+
"sql_generation_mode": query_config.sql_generation_mode,
|
|
322
|
+
"result_interpretation_mode": query_config.result_interpretation_mode,
|
|
323
|
+
"output_classification_mode": query_config.output_classification_mode,
|
|
324
|
+
"investigation_mode": query_config.investigation_mode,
|
|
325
|
+
"investigation_ambiguity_mode": query_config.investigation_ambiguity_mode,
|
|
326
|
+
"query_max_rows": query_config.query_max_rows,
|
|
327
|
+
"query_timeout_seconds": query_config.query_timeout_seconds,
|
|
328
|
+
"sources": get_llm_config_sources(session),
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def mask_secret(value: str) -> str:
|
|
333
|
+
if len(value) <= 4:
|
|
334
|
+
return "****"
|
|
335
|
+
return f"****{value[-4:]}"
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def serialize_governance_policy(session: Session) -> dict[str, Any]:
|
|
339
|
+
config = get_governance_policy_config(session)
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
**config,
|
|
343
|
+
"governance_policy_json": json_dumps_pretty(config),
|
|
344
|
+
"sources": get_governance_policy_sources(session),
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def serialize_overview_widget_config(widget: OverviewWidget) -> dict[str, Any]:
|
|
349
|
+
return {
|
|
350
|
+
"widget_key": widget.widget_key,
|
|
351
|
+
"label": widget.label,
|
|
352
|
+
"widget_type": widget.widget_type,
|
|
353
|
+
"datasource_key": widget.datasource_key,
|
|
354
|
+
"question": widget.question,
|
|
355
|
+
"sql": widget.sql,
|
|
356
|
+
"result_mode": normalize_overview_widget_result_mode(widget.result_mode),
|
|
357
|
+
"position": widget.position,
|
|
358
|
+
"grid_width": normalize_overview_widget_grid_width(
|
|
359
|
+
widget.widget_type,
|
|
360
|
+
widget.grid_width,
|
|
361
|
+
),
|
|
362
|
+
"active": widget.active,
|
|
363
|
+
"updated_by": widget.updated_by,
|
|
364
|
+
"updated_at": serialize_datetime(widget.updated_at),
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def normalize_overview_widget_grid_width(
|
|
369
|
+
widget_type: str,
|
|
370
|
+
grid_width: int | None,
|
|
371
|
+
) -> int:
|
|
372
|
+
if grid_width is None:
|
|
373
|
+
return 4 if widget_type in {OVERVIEW_WIDGET_TABLE, OVERVIEW_WIDGET_TIMESERIES} else 1
|
|
374
|
+
|
|
375
|
+
return max(1, min(4, int(grid_width)))
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def normalize_overview_widget_result_mode(value: str | None) -> str:
|
|
379
|
+
if value == OVERVIEW_WIDGET_RESULT_INTERPRETATION:
|
|
380
|
+
return OVERVIEW_WIDGET_RESULT_INTERPRETATION
|
|
381
|
+
|
|
382
|
+
return OVERVIEW_WIDGET_RESULT_DATA
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def build_client_widget_key(session: Session, label: str, question: str) -> str:
|
|
386
|
+
seed = label.strip() or question.strip() or "query_widget"
|
|
387
|
+
normalized = re.sub(r"[^a-z0-9_-]+", "_", seed.lower()).strip("_-")
|
|
388
|
+
normalized = normalized[:48].strip("_-") or "query_widget"
|
|
389
|
+
base_key = f"client_{normalized}"
|
|
390
|
+
widget_key = base_key
|
|
391
|
+
suffix = 2
|
|
392
|
+
|
|
393
|
+
while get_overview_widget(session, widget_key) is not None:
|
|
394
|
+
suffix_text = f"_{suffix}"
|
|
395
|
+
widget_key = f"{base_key[:255 - len(suffix_text)]}{suffix_text}"
|
|
396
|
+
suffix += 1
|
|
397
|
+
|
|
398
|
+
return widget_key
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def serialize_overview_widget(
|
|
402
|
+
session: Session,
|
|
403
|
+
widget: OverviewWidget,
|
|
404
|
+
) -> dict[str, Any]:
|
|
405
|
+
return {
|
|
406
|
+
**serialize_overview_widget_config(widget),
|
|
407
|
+
"result": execute_overview_widget(session, widget),
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def execute_overview_widget(
|
|
412
|
+
session: Session,
|
|
413
|
+
widget: OverviewWidget,
|
|
414
|
+
) -> dict[str, Any]:
|
|
415
|
+
connector = get_datasource_connector_by_key(session, widget.datasource_key)
|
|
416
|
+
|
|
417
|
+
if connector is None:
|
|
418
|
+
return widget_error(f"Datasource '{widget.datasource_key}' does not exist.")
|
|
419
|
+
|
|
420
|
+
if not widget.sql.strip():
|
|
421
|
+
return widget_error("Widget SQL has not been generated yet.")
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
result = execute_overview_sql(session, connector, widget.sql)
|
|
425
|
+
payload = validate_overview_widget_result(widget, result)
|
|
426
|
+
payload["result_mode"] = normalize_overview_widget_result_mode(widget.result_mode)
|
|
427
|
+
|
|
428
|
+
if payload["result_mode"] == OVERVIEW_WIDGET_RESULT_INTERPRETATION:
|
|
429
|
+
interpretation = interpret_overview_widget_result(connector, widget, result)
|
|
430
|
+
payload["answer"] = interpretation
|
|
431
|
+
payload["interpretation"] = interpretation
|
|
432
|
+
payload["value"] = interpretation
|
|
433
|
+
|
|
434
|
+
return payload
|
|
435
|
+
except QueryExecutionError as exc:
|
|
436
|
+
return widget_error(exc.message, sql=exc.sql)
|
|
437
|
+
except (
|
|
438
|
+
ConfigurationError,
|
|
439
|
+
LlmProviderError,
|
|
440
|
+
SQLAlchemyError,
|
|
441
|
+
SqlValidationError,
|
|
442
|
+
ValueError,
|
|
443
|
+
TypeError,
|
|
444
|
+
) as exc:
|
|
445
|
+
return widget_error(str(exc), sql=widget.sql)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def interpret_overview_widget_result(
|
|
449
|
+
connector: DatasourceConnector,
|
|
450
|
+
widget: OverviewWidget,
|
|
451
|
+
result: QueryResult,
|
|
452
|
+
) -> str:
|
|
453
|
+
from gaard_api.api.v1.query import create_result_interpreter
|
|
454
|
+
|
|
455
|
+
query_request = build_overview_widget_query_request(
|
|
456
|
+
connector=connector,
|
|
457
|
+
widget_key=widget.widget_key,
|
|
458
|
+
question=widget.question,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
return create_result_interpreter().interpret(
|
|
462
|
+
request=query_request,
|
|
463
|
+
result=result,
|
|
464
|
+
sql=widget.sql,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def execute_overview_sql(
|
|
469
|
+
session: Session,
|
|
470
|
+
connector: DatasourceConnector,
|
|
471
|
+
sql: str,
|
|
472
|
+
) -> QueryResult:
|
|
473
|
+
SelectOnlySqlValidator(dialect=connector.sql_dialect).validate(sql)
|
|
474
|
+
|
|
475
|
+
return SQLAlchemyQueryExecutor(
|
|
476
|
+
database_url=connector.database_url,
|
|
477
|
+
max_rows=get_query_runtime_config(session).query_max_rows,
|
|
478
|
+
).execute(sql)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def generate_overview_widget_sql(
|
|
482
|
+
session: Session,
|
|
483
|
+
connector: DatasourceConnector,
|
|
484
|
+
query_request: QueryRequest,
|
|
485
|
+
actor: str,
|
|
486
|
+
) -> str:
|
|
487
|
+
schema_cache = get_or_create_datasource_schema_cache(session, connector, actor)
|
|
488
|
+
session.commit()
|
|
489
|
+
|
|
490
|
+
from gaard_api.api.v1.query import create_sql_generator
|
|
491
|
+
|
|
492
|
+
generated_sql = create_sql_generator((connector, schema_cache)).generate(query_request)
|
|
493
|
+
SelectOnlySqlValidator(dialect=connector.sql_dialect).validate(generated_sql.sql)
|
|
494
|
+
|
|
495
|
+
return generated_sql.sql
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def build_overview_widget_query_request(
|
|
499
|
+
connector: DatasourceConnector,
|
|
500
|
+
widget_key: str,
|
|
501
|
+
question: str,
|
|
502
|
+
) -> QueryRequest:
|
|
503
|
+
return QueryRequest(
|
|
504
|
+
question=question,
|
|
505
|
+
datasource_id=connector.connector_key,
|
|
506
|
+
user_id=f"overview-widget-config:{widget_key}",
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def record_overview_widget_query_audit(
|
|
511
|
+
query_request: QueryRequest,
|
|
512
|
+
widget: OverviewWidget,
|
|
513
|
+
generated_sql: str,
|
|
514
|
+
actor: str,
|
|
515
|
+
) -> None:
|
|
516
|
+
record_data_query_audit(
|
|
517
|
+
query_request,
|
|
518
|
+
QueryResponse(
|
|
519
|
+
question=query_request.question,
|
|
520
|
+
answer="Generated SQL for overview widget source.",
|
|
521
|
+
sql=generated_sql,
|
|
522
|
+
rows=[],
|
|
523
|
+
metadata={
|
|
524
|
+
"actor": actor,
|
|
525
|
+
"operation": "overview_widget.update",
|
|
526
|
+
"widget_key": widget.widget_key,
|
|
527
|
+
"widget_type": widget.widget_type,
|
|
528
|
+
"result_mode": normalize_overview_widget_result_mode(widget.result_mode),
|
|
529
|
+
},
|
|
530
|
+
),
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def record_overview_widget_sql_error_audit(
|
|
535
|
+
query_request: QueryRequest,
|
|
536
|
+
connector: DatasourceConnector,
|
|
537
|
+
sql: str,
|
|
538
|
+
error_code: str,
|
|
539
|
+
error_message: str,
|
|
540
|
+
error_detail: str,
|
|
541
|
+
actor: str,
|
|
542
|
+
) -> None:
|
|
543
|
+
audit_log = record_data_query_sql_error_audit(
|
|
544
|
+
request=query_request,
|
|
545
|
+
sql=sql,
|
|
546
|
+
error_code=error_code,
|
|
547
|
+
error_message=error_message,
|
|
548
|
+
error_detail=error_detail,
|
|
549
|
+
)
|
|
550
|
+
learn_business_logic_from_sql_error(
|
|
551
|
+
connector_id=connector.id,
|
|
552
|
+
audit_id=audit_log.id if audit_log is not None else None,
|
|
553
|
+
actor=actor,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def validate_overview_widget_result(
|
|
558
|
+
widget: OverviewWidget,
|
|
559
|
+
result: QueryResult,
|
|
560
|
+
) -> dict[str, Any]:
|
|
561
|
+
rows = result.rows
|
|
562
|
+
columns = result.columns
|
|
563
|
+
|
|
564
|
+
if widget.widget_type == OVERVIEW_WIDGET_SCALAR:
|
|
565
|
+
if len(rows) != 1 or len(columns) != 1:
|
|
566
|
+
raise ValueError("Scalar widgets must return exactly one row and one column.")
|
|
567
|
+
|
|
568
|
+
value = rows[0][columns[0]]
|
|
569
|
+
return {
|
|
570
|
+
"status": "ok",
|
|
571
|
+
"value": value,
|
|
572
|
+
"columns": columns,
|
|
573
|
+
"rows": rows,
|
|
574
|
+
"answer": json.dumps(rows[0], ensure_ascii=False, default=str),
|
|
575
|
+
"sql": widget.sql,
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if widget.widget_type == OVERVIEW_WIDGET_TIMESERIES:
|
|
579
|
+
if rows:
|
|
580
|
+
validate_timeseries_rows(columns, rows)
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
"status": "ok",
|
|
584
|
+
"columns": columns,
|
|
585
|
+
"rows": rows,
|
|
586
|
+
"answer": json.dumps(rows, ensure_ascii=False, default=str),
|
|
587
|
+
"sql": widget.sql,
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if widget.widget_type == OVERVIEW_WIDGET_TABLE:
|
|
591
|
+
if not columns and rows:
|
|
592
|
+
raise ValueError("Table widgets must return named columns.")
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
"status": "ok",
|
|
596
|
+
"columns": columns,
|
|
597
|
+
"rows": rows,
|
|
598
|
+
"answer": json.dumps(rows, ensure_ascii=False, default=str),
|
|
599
|
+
"sql": widget.sql,
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
raise ValueError(f"Unsupported widget type: {widget.widget_type}")
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def validate_timeseries_rows(
|
|
606
|
+
columns: list[str],
|
|
607
|
+
rows: list[dict[str, Any]],
|
|
608
|
+
) -> None:
|
|
609
|
+
if len(columns) < 2:
|
|
610
|
+
raise ValueError("Time-series widgets must return at least two columns.")
|
|
611
|
+
|
|
612
|
+
date_column = columns[0]
|
|
613
|
+
|
|
614
|
+
for row in rows:
|
|
615
|
+
if not is_date_like(row.get(date_column)):
|
|
616
|
+
raise ValueError("The first time-series column must contain date values.")
|
|
617
|
+
|
|
618
|
+
if len(columns) == 3 and all(is_number_like(row.get(columns[2])) for row in rows):
|
|
619
|
+
return
|
|
620
|
+
|
|
621
|
+
for row in rows:
|
|
622
|
+
values = [row.get(column) for column in columns[1:]]
|
|
623
|
+
|
|
624
|
+
if not values or not all(is_number_like(value) for value in values):
|
|
625
|
+
raise ValueError(
|
|
626
|
+
"Time-series widgets must return numeric value columns, or "
|
|
627
|
+
"date/category/value columns."
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def is_date_like(value: Any) -> bool:
|
|
632
|
+
if isinstance(value, datetime):
|
|
633
|
+
return True
|
|
634
|
+
|
|
635
|
+
if not isinstance(value, str) or not value.strip():
|
|
636
|
+
return False
|
|
637
|
+
|
|
638
|
+
normalized = value.strip().replace("Z", "+00:00")
|
|
639
|
+
|
|
640
|
+
try:
|
|
641
|
+
datetime.fromisoformat(normalized)
|
|
642
|
+
return True
|
|
643
|
+
except ValueError:
|
|
644
|
+
return False
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def is_number_like(value: Any) -> bool:
|
|
648
|
+
if isinstance(value, bool) or value is None:
|
|
649
|
+
return False
|
|
650
|
+
|
|
651
|
+
if isinstance(value, int | float):
|
|
652
|
+
return True
|
|
653
|
+
|
|
654
|
+
if isinstance(value, str):
|
|
655
|
+
try:
|
|
656
|
+
float(value)
|
|
657
|
+
return True
|
|
658
|
+
except ValueError:
|
|
659
|
+
return False
|
|
660
|
+
|
|
661
|
+
return False
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def widget_error(message: str, sql: str = "") -> dict[str, Any]:
|
|
665
|
+
return {
|
|
666
|
+
"status": "error",
|
|
667
|
+
"error": message,
|
|
668
|
+
"sql": sql,
|
|
669
|
+
"columns": [],
|
|
670
|
+
"rows": [],
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def json_dumps_pretty(value: dict[str, Any]) -> str:
|
|
675
|
+
return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def serialize_business_logic_suggestion(
|
|
679
|
+
suggestion: BusinessLogicSuggestion,
|
|
680
|
+
) -> dict[str, Any]:
|
|
681
|
+
return {
|
|
682
|
+
"id": suggestion.id,
|
|
683
|
+
"connector_id": suggestion.connector_id,
|
|
684
|
+
"source_audit_id": suggestion.source_audit_id,
|
|
685
|
+
"status": suggestion.status,
|
|
686
|
+
"safety": suggestion.safety,
|
|
687
|
+
"enabled": suggestion.enabled,
|
|
688
|
+
"error_category": suggestion.error_category,
|
|
689
|
+
"title": suggestion.title,
|
|
690
|
+
"rule_text": suggestion.rule_text,
|
|
691
|
+
"terms": json_loads(suggestion.terms_json),
|
|
692
|
+
"join_hints": json_loads(suggestion.join_hints_json),
|
|
693
|
+
"failed_identifier": suggestion.failed_identifier,
|
|
694
|
+
"repaired_identifier": suggestion.repaired_identifier,
|
|
695
|
+
"confidence": suggestion.confidence,
|
|
696
|
+
"created_at": serialize_datetime(suggestion.created_at),
|
|
697
|
+
"updated_at": serialize_datetime(suggestion.updated_at),
|
|
698
|
+
"updated_by": suggestion.updated_by,
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def get_authorization_token(authorization: str | None) -> str:
|
|
703
|
+
if authorization is None:
|
|
704
|
+
raise HTTPException(
|
|
705
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
706
|
+
detail="Missing Authorization header.",
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
scheme, _, token = authorization.partition(" ")
|
|
710
|
+
|
|
711
|
+
if scheme.lower() != "bearer" or not token:
|
|
712
|
+
raise HTTPException(
|
|
713
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
714
|
+
detail="Invalid Authorization header.",
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
return token
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def get_current_admin_allow_password_change(
|
|
721
|
+
authorization: Annotated[str | None, Header()] = None,
|
|
722
|
+
session: Session = Depends(get_session),
|
|
723
|
+
) -> AdminUser:
|
|
724
|
+
token = get_authorization_token(authorization)
|
|
725
|
+
token_hash = hash_token(token)
|
|
726
|
+
|
|
727
|
+
admin_session = session.scalar(
|
|
728
|
+
select(AdminSession).where(AdminSession.token_hash == token_hash)
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
if admin_session is None:
|
|
732
|
+
raise HTTPException(
|
|
733
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
734
|
+
detail="Invalid admin session.",
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
user = session.get(AdminUser, admin_session.user_id)
|
|
738
|
+
|
|
739
|
+
if user is None:
|
|
740
|
+
raise HTTPException(
|
|
741
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
742
|
+
detail="Invalid admin session.",
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
return user
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def get_current_admin(
|
|
749
|
+
user: AdminUser = Depends(get_current_admin_allow_password_change),
|
|
750
|
+
) -> AdminUser:
|
|
751
|
+
if user.must_change_password:
|
|
752
|
+
raise HTTPException(
|
|
753
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
754
|
+
detail="Password change is required before using the admin portal.",
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
return user
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
@router.post("/auth/login", response_model=LoginResponse)
|
|
761
|
+
def login(request: LoginRequest, session: Session = Depends(get_session)) -> LoginResponse:
|
|
762
|
+
user = session.scalar(select(AdminUser).where(AdminUser.username == request.username))
|
|
763
|
+
|
|
764
|
+
if user is None or not verify_password(request.password, user.password_hash):
|
|
765
|
+
raise HTTPException(
|
|
766
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
767
|
+
detail="Invalid username or password.",
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
token = create_session_token()
|
|
771
|
+
session.add(AdminSession(token_hash=hash_token(token), user_id=user.id))
|
|
772
|
+
record_admin_audit(
|
|
773
|
+
session=session,
|
|
774
|
+
actor=user.username,
|
|
775
|
+
action="auth.login",
|
|
776
|
+
resource_type="admin_user",
|
|
777
|
+
resource_id=user.username,
|
|
778
|
+
)
|
|
779
|
+
session.commit()
|
|
780
|
+
|
|
781
|
+
return LoginResponse(
|
|
782
|
+
token=token,
|
|
783
|
+
username=user.username,
|
|
784
|
+
must_change_password=user.must_change_password,
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
@router.get("/me", response_model=MeResponse)
|
|
789
|
+
def get_me(
|
|
790
|
+
user: AdminUser = Depends(get_current_admin_allow_password_change),
|
|
791
|
+
) -> MeResponse:
|
|
792
|
+
return MeResponse(
|
|
793
|
+
username=user.username,
|
|
794
|
+
must_change_password=user.must_change_password,
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
@router.post("/auth/change-password", response_model=MeResponse)
|
|
799
|
+
def change_password(
|
|
800
|
+
request: ChangePasswordRequest,
|
|
801
|
+
user: AdminUser = Depends(get_current_admin_allow_password_change),
|
|
802
|
+
session: Session = Depends(get_session),
|
|
803
|
+
) -> MeResponse:
|
|
804
|
+
user = session.merge(user)
|
|
805
|
+
|
|
806
|
+
if not verify_password(request.current_password, user.password_hash):
|
|
807
|
+
raise HTTPException(
|
|
808
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
809
|
+
detail="Current password is invalid.",
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
user.password_hash = hash_password(request.new_password)
|
|
813
|
+
user.must_change_password = False
|
|
814
|
+
record_admin_audit(
|
|
815
|
+
session=session,
|
|
816
|
+
actor=user.username,
|
|
817
|
+
action="auth.change_password",
|
|
818
|
+
resource_type="admin_user",
|
|
819
|
+
resource_id=user.username,
|
|
820
|
+
)
|
|
821
|
+
session.commit()
|
|
822
|
+
|
|
823
|
+
return MeResponse(username=user.username, must_change_password=False)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
@router.get("/overview")
|
|
827
|
+
def get_overview(
|
|
828
|
+
user: AdminUser = Depends(get_current_admin),
|
|
829
|
+
session: Session = Depends(get_session),
|
|
830
|
+
) -> dict[str, Any]:
|
|
831
|
+
prompts = list_prompt_templates(session)
|
|
832
|
+
retention_days = get_data_query_audit_retention_days(session)
|
|
833
|
+
widgets = [
|
|
834
|
+
serialize_overview_widget(session, widget)
|
|
835
|
+
for widget in list_overview_widgets(session)
|
|
836
|
+
]
|
|
837
|
+
|
|
838
|
+
return {
|
|
839
|
+
"admin": {
|
|
840
|
+
"username": user.username,
|
|
841
|
+
},
|
|
842
|
+
"license": {
|
|
843
|
+
"edition": "community",
|
|
844
|
+
"status": "active",
|
|
845
|
+
},
|
|
846
|
+
"prompts_count": len(prompts),
|
|
847
|
+
"data_query_audit_retention_days": retention_days,
|
|
848
|
+
"schema_cache_ttl_seconds": schema_context_cache.ttl_seconds,
|
|
849
|
+
"schema_cache_key": get_runtime_schema_cache_key(session),
|
|
850
|
+
"datasources": [
|
|
851
|
+
{
|
|
852
|
+
"connector_key": connector.connector_key,
|
|
853
|
+
"name": connector.name,
|
|
854
|
+
"database_type": connector.database_type,
|
|
855
|
+
"masked_database_url": mask_database_url(connector.database_url),
|
|
856
|
+
"active": connector.active,
|
|
857
|
+
}
|
|
858
|
+
for connector in list_datasource_connectors(session)
|
|
859
|
+
],
|
|
860
|
+
"widgets": widgets,
|
|
861
|
+
"info_widgets": [
|
|
862
|
+
widget
|
|
863
|
+
for widget in widgets
|
|
864
|
+
if widget["widget_type"] == OVERVIEW_WIDGET_SCALAR
|
|
865
|
+
][:4],
|
|
866
|
+
"runtime_widget": next(
|
|
867
|
+
(
|
|
868
|
+
widget
|
|
869
|
+
for widget in widgets
|
|
870
|
+
if widget["widget_type"] == OVERVIEW_WIDGET_TIMESERIES
|
|
871
|
+
),
|
|
872
|
+
None,
|
|
873
|
+
),
|
|
874
|
+
"table_widgets": [
|
|
875
|
+
widget
|
|
876
|
+
for widget in widgets
|
|
877
|
+
if widget["widget_type"] == OVERVIEW_WIDGET_TABLE
|
|
878
|
+
],
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
@router.get("/overview/widgets")
|
|
883
|
+
def get_overview_widget_configs(
|
|
884
|
+
user: AdminUser = Depends(get_current_admin),
|
|
885
|
+
session: Session = Depends(get_session),
|
|
886
|
+
) -> dict[str, Any]:
|
|
887
|
+
return {
|
|
888
|
+
"items": [
|
|
889
|
+
serialize_overview_widget_config(widget)
|
|
890
|
+
for widget in list_all_overview_widgets(session)
|
|
891
|
+
],
|
|
892
|
+
"datasources": [
|
|
893
|
+
{
|
|
894
|
+
"connector_key": connector.connector_key,
|
|
895
|
+
"name": connector.name,
|
|
896
|
+
"database_type": connector.database_type,
|
|
897
|
+
"masked_database_url": mask_database_url(connector.database_url),
|
|
898
|
+
"active": connector.active,
|
|
899
|
+
}
|
|
900
|
+
for connector in list_datasource_connectors(session)
|
|
901
|
+
],
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
@router.post("/overview/widgets")
|
|
906
|
+
def create_overview_widget(
|
|
907
|
+
request: OverviewWidgetCreateRequest,
|
|
908
|
+
user: AdminUser = Depends(get_current_admin),
|
|
909
|
+
session: Session = Depends(get_session),
|
|
910
|
+
) -> dict[str, Any]:
|
|
911
|
+
if get_overview_widget(session, request.widget_key) is not None:
|
|
912
|
+
raise HTTPException(
|
|
913
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
914
|
+
detail="Overview widget already exists.",
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
datasource = get_datasource_connector_by_key(session, request.datasource_key)
|
|
918
|
+
|
|
919
|
+
if datasource is None:
|
|
920
|
+
raise HTTPException(
|
|
921
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
922
|
+
detail="Datasource does not exist.",
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
query_request = build_overview_widget_query_request(
|
|
926
|
+
connector=datasource,
|
|
927
|
+
widget_key=request.widget_key,
|
|
928
|
+
question=request.question,
|
|
929
|
+
)
|
|
930
|
+
generated_sql = ""
|
|
931
|
+
next_grid_width = normalize_overview_widget_grid_width(
|
|
932
|
+
request.widget_type,
|
|
933
|
+
request.grid_width,
|
|
934
|
+
)
|
|
935
|
+
next_result_mode = normalize_overview_widget_result_mode(request.result_mode)
|
|
936
|
+
|
|
937
|
+
try:
|
|
938
|
+
generated_sql = generate_overview_widget_sql(
|
|
939
|
+
session=session,
|
|
940
|
+
connector=datasource,
|
|
941
|
+
query_request=query_request,
|
|
942
|
+
actor=user.username,
|
|
943
|
+
)
|
|
944
|
+
widget = OverviewWidget(
|
|
945
|
+
widget_key=request.widget_key,
|
|
946
|
+
label=request.label,
|
|
947
|
+
widget_type=request.widget_type,
|
|
948
|
+
datasource_key=request.datasource_key,
|
|
949
|
+
question=request.question,
|
|
950
|
+
sql=generated_sql,
|
|
951
|
+
result_mode=next_result_mode,
|
|
952
|
+
position=request.position,
|
|
953
|
+
grid_width=next_grid_width,
|
|
954
|
+
active=request.active,
|
|
955
|
+
updated_by=user.username,
|
|
956
|
+
)
|
|
957
|
+
validate_overview_widget_result(
|
|
958
|
+
widget,
|
|
959
|
+
execute_overview_sql(session, datasource, generated_sql),
|
|
960
|
+
)
|
|
961
|
+
except QueryExecutionError as exc:
|
|
962
|
+
record_overview_widget_sql_error_audit(
|
|
963
|
+
query_request=query_request,
|
|
964
|
+
connector=datasource,
|
|
965
|
+
sql=exc.sql or generated_sql,
|
|
966
|
+
error_code=exc.code,
|
|
967
|
+
error_message=exc.message,
|
|
968
|
+
error_detail=exc.error_detail,
|
|
969
|
+
actor=user.username,
|
|
970
|
+
)
|
|
971
|
+
session.rollback()
|
|
972
|
+
raise HTTPException(
|
|
973
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
974
|
+
detail=exc.message,
|
|
975
|
+
) from exc
|
|
976
|
+
except (
|
|
977
|
+
ConfigurationError,
|
|
978
|
+
SQLAlchemyError,
|
|
979
|
+
SqlValidationError,
|
|
980
|
+
ValueError,
|
|
981
|
+
TypeError,
|
|
982
|
+
) as exc:
|
|
983
|
+
session.rollback()
|
|
984
|
+
raise HTTPException(
|
|
985
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
986
|
+
detail=str(exc),
|
|
987
|
+
) from exc
|
|
988
|
+
|
|
989
|
+
session.add(widget)
|
|
990
|
+
record_admin_audit(
|
|
991
|
+
session=session,
|
|
992
|
+
actor=user.username,
|
|
993
|
+
action="overview_widget.create",
|
|
994
|
+
resource_type="overview_widget",
|
|
995
|
+
resource_id=widget.widget_key,
|
|
996
|
+
details={
|
|
997
|
+
"label": widget.label,
|
|
998
|
+
"widget_type": widget.widget_type,
|
|
999
|
+
"datasource_key": widget.datasource_key,
|
|
1000
|
+
"result_mode": widget.result_mode,
|
|
1001
|
+
"position": widget.position,
|
|
1002
|
+
"grid_width": widget.grid_width,
|
|
1003
|
+
"active": widget.active,
|
|
1004
|
+
},
|
|
1005
|
+
)
|
|
1006
|
+
session.commit()
|
|
1007
|
+
record_overview_widget_query_audit(
|
|
1008
|
+
query_request=query_request,
|
|
1009
|
+
widget=widget,
|
|
1010
|
+
generated_sql=generated_sql,
|
|
1011
|
+
actor=user.username,
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
return {
|
|
1015
|
+
"item": serialize_overview_widget(session, widget),
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
@router.post("/overview/widgets/from-query")
|
|
1020
|
+
def create_overview_widget_from_query(
|
|
1021
|
+
request: OverviewWidgetFromQueryRequest,
|
|
1022
|
+
session: Session = Depends(get_session),
|
|
1023
|
+
) -> dict[str, Any]:
|
|
1024
|
+
datasource = get_datasource_connector_by_key(session, request.datasource_key)
|
|
1025
|
+
|
|
1026
|
+
if datasource is None:
|
|
1027
|
+
raise HTTPException(
|
|
1028
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1029
|
+
detail="Datasource does not exist.",
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
widget_key = build_client_widget_key(session, request.label, request.question)
|
|
1033
|
+
grid_width = normalize_overview_widget_grid_width(
|
|
1034
|
+
request.widget_type,
|
|
1035
|
+
None,
|
|
1036
|
+
)
|
|
1037
|
+
result_mode = normalize_overview_widget_result_mode(request.result_mode)
|
|
1038
|
+
widget = OverviewWidget(
|
|
1039
|
+
widget_key=widget_key,
|
|
1040
|
+
label=request.label,
|
|
1041
|
+
widget_type=request.widget_type,
|
|
1042
|
+
datasource_key=request.datasource_key,
|
|
1043
|
+
question=request.question,
|
|
1044
|
+
sql=request.sql,
|
|
1045
|
+
result_mode=result_mode,
|
|
1046
|
+
position=100,
|
|
1047
|
+
grid_width=grid_width,
|
|
1048
|
+
active=False,
|
|
1049
|
+
updated_by="client",
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
try:
|
|
1053
|
+
validate_overview_widget_result(
|
|
1054
|
+
widget,
|
|
1055
|
+
execute_overview_sql(session, datasource, request.sql),
|
|
1056
|
+
)
|
|
1057
|
+
except QueryExecutionError as exc:
|
|
1058
|
+
session.rollback()
|
|
1059
|
+
raise HTTPException(
|
|
1060
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1061
|
+
detail=exc.message,
|
|
1062
|
+
) from exc
|
|
1063
|
+
except (
|
|
1064
|
+
ConfigurationError,
|
|
1065
|
+
SQLAlchemyError,
|
|
1066
|
+
SqlValidationError,
|
|
1067
|
+
ValueError,
|
|
1068
|
+
TypeError,
|
|
1069
|
+
) as exc:
|
|
1070
|
+
session.rollback()
|
|
1071
|
+
raise HTTPException(
|
|
1072
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1073
|
+
detail=str(exc),
|
|
1074
|
+
) from exc
|
|
1075
|
+
|
|
1076
|
+
session.add(widget)
|
|
1077
|
+
record_admin_audit(
|
|
1078
|
+
session=session,
|
|
1079
|
+
actor="client",
|
|
1080
|
+
action="overview_widget.create_from_query",
|
|
1081
|
+
resource_type="overview_widget",
|
|
1082
|
+
resource_id=widget.widget_key,
|
|
1083
|
+
details={
|
|
1084
|
+
"label": widget.label,
|
|
1085
|
+
"widget_type": widget.widget_type,
|
|
1086
|
+
"datasource_key": widget.datasource_key,
|
|
1087
|
+
"result_mode": widget.result_mode,
|
|
1088
|
+
"active": widget.active,
|
|
1089
|
+
},
|
|
1090
|
+
)
|
|
1091
|
+
session.commit()
|
|
1092
|
+
|
|
1093
|
+
return {
|
|
1094
|
+
"item": serialize_overview_widget_config(widget),
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
@router.put("/overview/widgets/{widget_key}")
|
|
1099
|
+
def update_overview_widget(
|
|
1100
|
+
widget_key: str,
|
|
1101
|
+
request: OverviewWidgetUpdateRequest,
|
|
1102
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1103
|
+
session: Session = Depends(get_session),
|
|
1104
|
+
) -> dict[str, Any]:
|
|
1105
|
+
widget = get_overview_widget(session, widget_key)
|
|
1106
|
+
|
|
1107
|
+
if widget is None:
|
|
1108
|
+
raise HTTPException(
|
|
1109
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1110
|
+
detail="Overview widget not found.",
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
datasource = get_datasource_connector_by_key(session, request.datasource_key)
|
|
1114
|
+
|
|
1115
|
+
if datasource is None:
|
|
1116
|
+
raise HTTPException(
|
|
1117
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1118
|
+
detail="Datasource does not exist.",
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
query_request = build_overview_widget_query_request(
|
|
1122
|
+
connector=datasource,
|
|
1123
|
+
widget_key=widget.widget_key,
|
|
1124
|
+
question=request.question,
|
|
1125
|
+
)
|
|
1126
|
+
generated_sql = ""
|
|
1127
|
+
next_position = request.position if request.position is not None else widget.position
|
|
1128
|
+
next_grid_width = normalize_overview_widget_grid_width(
|
|
1129
|
+
request.widget_type,
|
|
1130
|
+
request.grid_width if request.grid_width is not None else widget.grid_width,
|
|
1131
|
+
)
|
|
1132
|
+
next_result_mode = normalize_overview_widget_result_mode(request.result_mode)
|
|
1133
|
+
next_active = request.active if request.active is not None else widget.active
|
|
1134
|
+
|
|
1135
|
+
try:
|
|
1136
|
+
generated_sql = generate_overview_widget_sql(
|
|
1137
|
+
session=session,
|
|
1138
|
+
connector=datasource,
|
|
1139
|
+
query_request=query_request,
|
|
1140
|
+
actor=user.username,
|
|
1141
|
+
)
|
|
1142
|
+
probe_widget = OverviewWidget(
|
|
1143
|
+
widget_key=widget.widget_key,
|
|
1144
|
+
label=request.label,
|
|
1145
|
+
widget_type=request.widget_type,
|
|
1146
|
+
datasource_key=request.datasource_key,
|
|
1147
|
+
question=request.question,
|
|
1148
|
+
sql=generated_sql,
|
|
1149
|
+
result_mode=next_result_mode,
|
|
1150
|
+
position=next_position,
|
|
1151
|
+
grid_width=next_grid_width,
|
|
1152
|
+
active=next_active,
|
|
1153
|
+
)
|
|
1154
|
+
validate_overview_widget_result(
|
|
1155
|
+
probe_widget,
|
|
1156
|
+
execute_overview_sql(session, datasource, generated_sql),
|
|
1157
|
+
)
|
|
1158
|
+
except QueryExecutionError as exc:
|
|
1159
|
+
record_overview_widget_sql_error_audit(
|
|
1160
|
+
query_request=query_request,
|
|
1161
|
+
connector=datasource,
|
|
1162
|
+
sql=exc.sql or generated_sql,
|
|
1163
|
+
error_code=exc.code,
|
|
1164
|
+
error_message=exc.message,
|
|
1165
|
+
error_detail=exc.error_detail,
|
|
1166
|
+
actor=user.username,
|
|
1167
|
+
)
|
|
1168
|
+
session.rollback()
|
|
1169
|
+
raise HTTPException(
|
|
1170
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1171
|
+
detail=exc.message,
|
|
1172
|
+
) from exc
|
|
1173
|
+
except (
|
|
1174
|
+
ConfigurationError,
|
|
1175
|
+
SQLAlchemyError,
|
|
1176
|
+
SqlValidationError,
|
|
1177
|
+
ValueError,
|
|
1178
|
+
TypeError,
|
|
1179
|
+
) as exc:
|
|
1180
|
+
session.rollback()
|
|
1181
|
+
raise HTTPException(
|
|
1182
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1183
|
+
detail=str(exc),
|
|
1184
|
+
) from exc
|
|
1185
|
+
|
|
1186
|
+
widget.label = request.label
|
|
1187
|
+
widget.widget_type = request.widget_type
|
|
1188
|
+
widget.datasource_key = request.datasource_key
|
|
1189
|
+
widget.question = request.question
|
|
1190
|
+
widget.sql = generated_sql
|
|
1191
|
+
widget.result_mode = next_result_mode
|
|
1192
|
+
widget.position = next_position
|
|
1193
|
+
widget.grid_width = next_grid_width
|
|
1194
|
+
widget.active = next_active
|
|
1195
|
+
widget.updated_by = user.username
|
|
1196
|
+
|
|
1197
|
+
record_admin_audit(
|
|
1198
|
+
session=session,
|
|
1199
|
+
actor=user.username,
|
|
1200
|
+
action="overview_widget.update",
|
|
1201
|
+
resource_type="overview_widget",
|
|
1202
|
+
resource_id=widget.widget_key,
|
|
1203
|
+
details={
|
|
1204
|
+
"label": widget.label,
|
|
1205
|
+
"widget_type": widget.widget_type,
|
|
1206
|
+
"datasource_key": widget.datasource_key,
|
|
1207
|
+
"result_mode": widget.result_mode,
|
|
1208
|
+
"position": widget.position,
|
|
1209
|
+
"grid_width": widget.grid_width,
|
|
1210
|
+
"active": widget.active,
|
|
1211
|
+
},
|
|
1212
|
+
)
|
|
1213
|
+
session.commit()
|
|
1214
|
+
record_overview_widget_query_audit(
|
|
1215
|
+
query_request=query_request,
|
|
1216
|
+
widget=widget,
|
|
1217
|
+
generated_sql=generated_sql,
|
|
1218
|
+
actor=user.username,
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
return {
|
|
1222
|
+
"item": serialize_overview_widget(session, widget),
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
@router.patch("/overview/widgets/{widget_key}/state")
|
|
1227
|
+
def update_overview_widget_state(
|
|
1228
|
+
widget_key: str,
|
|
1229
|
+
request: OverviewWidgetStateRequest,
|
|
1230
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1231
|
+
session: Session = Depends(get_session),
|
|
1232
|
+
) -> dict[str, Any]:
|
|
1233
|
+
widget = get_overview_widget(session, widget_key)
|
|
1234
|
+
|
|
1235
|
+
if widget is None:
|
|
1236
|
+
raise HTTPException(
|
|
1237
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1238
|
+
detail="Overview widget not found.",
|
|
1239
|
+
)
|
|
1240
|
+
|
|
1241
|
+
widget.active = request.active
|
|
1242
|
+
|
|
1243
|
+
if request.position is not None:
|
|
1244
|
+
widget.position = request.position
|
|
1245
|
+
|
|
1246
|
+
if request.grid_width is not None:
|
|
1247
|
+
widget.grid_width = normalize_overview_widget_grid_width(
|
|
1248
|
+
widget.widget_type,
|
|
1249
|
+
request.grid_width,
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
widget.updated_by = user.username
|
|
1253
|
+
record_admin_audit(
|
|
1254
|
+
session=session,
|
|
1255
|
+
actor=user.username,
|
|
1256
|
+
action="overview_widget.state",
|
|
1257
|
+
resource_type="overview_widget",
|
|
1258
|
+
resource_id=widget.widget_key,
|
|
1259
|
+
details={
|
|
1260
|
+
"position": widget.position,
|
|
1261
|
+
"grid_width": widget.grid_width,
|
|
1262
|
+
"active": widget.active,
|
|
1263
|
+
},
|
|
1264
|
+
)
|
|
1265
|
+
session.commit()
|
|
1266
|
+
|
|
1267
|
+
return {
|
|
1268
|
+
"item": serialize_overview_widget_config(widget),
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
@router.delete("/overview/widgets/{widget_key}")
|
|
1273
|
+
def delete_overview_widget(
|
|
1274
|
+
widget_key: str,
|
|
1275
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1276
|
+
session: Session = Depends(get_session),
|
|
1277
|
+
) -> dict[str, Any]:
|
|
1278
|
+
widget = get_overview_widget(session, widget_key)
|
|
1279
|
+
|
|
1280
|
+
if widget is None:
|
|
1281
|
+
raise HTTPException(
|
|
1282
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1283
|
+
detail="Overview widget not found.",
|
|
1284
|
+
)
|
|
1285
|
+
|
|
1286
|
+
record_admin_audit(
|
|
1287
|
+
session=session,
|
|
1288
|
+
actor=user.username,
|
|
1289
|
+
action="overview_widget.delete",
|
|
1290
|
+
resource_type="overview_widget",
|
|
1291
|
+
resource_id=widget.widget_key,
|
|
1292
|
+
details={
|
|
1293
|
+
"label": widget.label,
|
|
1294
|
+
"widget_type": widget.widget_type,
|
|
1295
|
+
"datasource_key": widget.datasource_key,
|
|
1296
|
+
},
|
|
1297
|
+
)
|
|
1298
|
+
session.delete(widget)
|
|
1299
|
+
session.commit()
|
|
1300
|
+
|
|
1301
|
+
return {
|
|
1302
|
+
"status": "deleted",
|
|
1303
|
+
"widget_key": widget_key,
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
@router.get("/audit/data-queries")
|
|
1308
|
+
def get_data_query_audit(
|
|
1309
|
+
limit: int = 100,
|
|
1310
|
+
audit_type: str | None = None,
|
|
1311
|
+
output_classification: str | None = None,
|
|
1312
|
+
sql_contains: str | None = None,
|
|
1313
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1314
|
+
session: Session = Depends(get_session),
|
|
1315
|
+
) -> dict[str, Any]:
|
|
1316
|
+
try:
|
|
1317
|
+
parsed_audit_type = coerce_data_query_audit_type(audit_type) if audit_type else None
|
|
1318
|
+
except ValueError as exc:
|
|
1319
|
+
raise HTTPException(
|
|
1320
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1321
|
+
detail="Unsupported data query audit type.",
|
|
1322
|
+
) from exc
|
|
1323
|
+
|
|
1324
|
+
try:
|
|
1325
|
+
parsed_output_classification = (
|
|
1326
|
+
OutputClassification(output_classification)
|
|
1327
|
+
if output_classification
|
|
1328
|
+
else None
|
|
1329
|
+
)
|
|
1330
|
+
except ValueError as exc:
|
|
1331
|
+
raise HTTPException(
|
|
1332
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1333
|
+
detail="Unsupported output classification.",
|
|
1334
|
+
) from exc
|
|
1335
|
+
|
|
1336
|
+
logs = list_data_query_audit_logs(
|
|
1337
|
+
session,
|
|
1338
|
+
limit=min(limit, 500),
|
|
1339
|
+
audit_type=parsed_audit_type,
|
|
1340
|
+
output_classification=parsed_output_classification,
|
|
1341
|
+
sql_contains=sql_contains,
|
|
1342
|
+
)
|
|
1343
|
+
session.commit()
|
|
1344
|
+
|
|
1345
|
+
return {
|
|
1346
|
+
"items": [
|
|
1347
|
+
{
|
|
1348
|
+
"id": item.id,
|
|
1349
|
+
"audit_type": get_data_query_audit_type(item),
|
|
1350
|
+
"occurred_at": serialize_datetime(item.occurred_at),
|
|
1351
|
+
"user_id": item.user_id,
|
|
1352
|
+
"datasource_id": item.datasource_id,
|
|
1353
|
+
"question": item.question,
|
|
1354
|
+
"answer": item.answer,
|
|
1355
|
+
"sql": item.sql,
|
|
1356
|
+
"output_classification": item.output_classification,
|
|
1357
|
+
"metadata": json_loads(item.metadata_json),
|
|
1358
|
+
}
|
|
1359
|
+
for item in logs
|
|
1360
|
+
],
|
|
1361
|
+
"viewer": user.username,
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
@router.get("/audit/admin-events")
|
|
1366
|
+
def get_admin_audit(
|
|
1367
|
+
limit: int = 100,
|
|
1368
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1369
|
+
session: Session = Depends(get_session),
|
|
1370
|
+
) -> dict[str, Any]:
|
|
1371
|
+
logs = list_admin_audit_logs(session, limit=min(limit, 500))
|
|
1372
|
+
|
|
1373
|
+
return {
|
|
1374
|
+
"items": [
|
|
1375
|
+
{
|
|
1376
|
+
"id": item.id,
|
|
1377
|
+
"occurred_at": serialize_datetime(item.occurred_at),
|
|
1378
|
+
"actor": item.actor,
|
|
1379
|
+
"action": item.action,
|
|
1380
|
+
"resource_type": item.resource_type,
|
|
1381
|
+
"resource_id": item.resource_id,
|
|
1382
|
+
"details": json_loads(item.details_json),
|
|
1383
|
+
}
|
|
1384
|
+
for item in logs
|
|
1385
|
+
],
|
|
1386
|
+
"viewer": user.username,
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
@router.get("/audit/settings")
|
|
1391
|
+
def get_audit_settings(
|
|
1392
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1393
|
+
session: Session = Depends(get_session),
|
|
1394
|
+
) -> dict[str, Any]:
|
|
1395
|
+
return {
|
|
1396
|
+
"data_query_retention_days": get_data_query_audit_retention_days(session),
|
|
1397
|
+
"viewer": user.username,
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
@router.put("/audit/settings")
|
|
1402
|
+
def update_audit_settings(
|
|
1403
|
+
request: AuditSettingsRequest,
|
|
1404
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1405
|
+
session: Session = Depends(get_session),
|
|
1406
|
+
) -> dict[str, Any]:
|
|
1407
|
+
set_setting(
|
|
1408
|
+
session,
|
|
1409
|
+
"data_query_audit_retention_days",
|
|
1410
|
+
str(request.data_query_retention_days),
|
|
1411
|
+
user.username,
|
|
1412
|
+
)
|
|
1413
|
+
apply_data_query_audit_retention(session)
|
|
1414
|
+
record_admin_audit(
|
|
1415
|
+
session=session,
|
|
1416
|
+
actor=user.username,
|
|
1417
|
+
action="audit.retention.update",
|
|
1418
|
+
resource_type="admin_setting",
|
|
1419
|
+
resource_id="data_query_audit_retention_days",
|
|
1420
|
+
details={"data_query_retention_days": request.data_query_retention_days},
|
|
1421
|
+
)
|
|
1422
|
+
session.commit()
|
|
1423
|
+
|
|
1424
|
+
return {
|
|
1425
|
+
"data_query_retention_days": request.data_query_retention_days,
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
@router.get("/prompts")
|
|
1430
|
+
def get_prompts(
|
|
1431
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1432
|
+
session: Session = Depends(get_session),
|
|
1433
|
+
) -> dict[str, Any]:
|
|
1434
|
+
return {
|
|
1435
|
+
"items": [serialize_prompt(prompt) for prompt in list_prompt_templates(session)],
|
|
1436
|
+
"viewer": user.username,
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
@router.get("/prompts/{prompt_key}")
|
|
1441
|
+
def get_prompt(
|
|
1442
|
+
prompt_key: str,
|
|
1443
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1444
|
+
session: Session = Depends(get_session),
|
|
1445
|
+
) -> dict[str, Any]:
|
|
1446
|
+
prompt = get_prompt_template(session, prompt_key)
|
|
1447
|
+
|
|
1448
|
+
if prompt is None:
|
|
1449
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Prompt not found.")
|
|
1450
|
+
|
|
1451
|
+
return {
|
|
1452
|
+
"item": serialize_prompt(prompt),
|
|
1453
|
+
"viewer": user.username,
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
@router.put("/prompts/{prompt_key}")
|
|
1458
|
+
def update_prompt(
|
|
1459
|
+
prompt_key: str,
|
|
1460
|
+
request: PromptUpdateRequest,
|
|
1461
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1462
|
+
session: Session = Depends(get_session),
|
|
1463
|
+
) -> dict[str, Any]:
|
|
1464
|
+
prompt = get_prompt_template(session, prompt_key)
|
|
1465
|
+
|
|
1466
|
+
if prompt is None:
|
|
1467
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Prompt not found.")
|
|
1468
|
+
|
|
1469
|
+
prompt.name = request.name
|
|
1470
|
+
prompt.description = request.description
|
|
1471
|
+
prompt.system_prompt = request.system_prompt
|
|
1472
|
+
prompt.user_prompt_template = request.user_prompt_template
|
|
1473
|
+
prompt.active = request.active
|
|
1474
|
+
prompt.version += 1
|
|
1475
|
+
prompt.updated_by = user.username
|
|
1476
|
+
record_admin_audit(
|
|
1477
|
+
session=session,
|
|
1478
|
+
actor=user.username,
|
|
1479
|
+
action="prompt.update",
|
|
1480
|
+
resource_type="prompt_template",
|
|
1481
|
+
resource_id=prompt.prompt_key,
|
|
1482
|
+
details={"version": prompt.version},
|
|
1483
|
+
)
|
|
1484
|
+
session.commit()
|
|
1485
|
+
|
|
1486
|
+
return {
|
|
1487
|
+
"item": serialize_prompt(prompt),
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
@router.get("/datasources")
|
|
1492
|
+
def get_datasources(
|
|
1493
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1494
|
+
session: Session = Depends(get_session),
|
|
1495
|
+
) -> dict[str, Any]:
|
|
1496
|
+
return {
|
|
1497
|
+
"items": [
|
|
1498
|
+
serialize_datasource(connector)
|
|
1499
|
+
for connector in list_datasource_connectors(session)
|
|
1500
|
+
],
|
|
1501
|
+
"viewer": user.username,
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
@router.post("/datasources")
|
|
1506
|
+
def create_datasource(
|
|
1507
|
+
request: DatasourceConnectorRequest,
|
|
1508
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1509
|
+
session: Session = Depends(get_session),
|
|
1510
|
+
) -> dict[str, Any]:
|
|
1511
|
+
if request.connector_key == "metadata-db":
|
|
1512
|
+
raise HTTPException(
|
|
1513
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1514
|
+
detail="The metadata datasource is managed by GAARD.",
|
|
1515
|
+
)
|
|
1516
|
+
|
|
1517
|
+
try:
|
|
1518
|
+
validate_datasource_url(request.database_type, request.database_url)
|
|
1519
|
+
except ValueError as exc:
|
|
1520
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
1521
|
+
|
|
1522
|
+
existing = session.scalar(
|
|
1523
|
+
select(DatasourceConnector).where(
|
|
1524
|
+
DatasourceConnector.connector_key == request.connector_key
|
|
1525
|
+
)
|
|
1526
|
+
)
|
|
1527
|
+
|
|
1528
|
+
if existing is not None:
|
|
1529
|
+
raise HTTPException(
|
|
1530
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
1531
|
+
detail="Datasource connector key already exists.",
|
|
1532
|
+
)
|
|
1533
|
+
|
|
1534
|
+
connector = DatasourceConnector(
|
|
1535
|
+
connector_key=request.connector_key,
|
|
1536
|
+
name=request.name,
|
|
1537
|
+
database_type=request.database_type,
|
|
1538
|
+
database_url=request.database_url,
|
|
1539
|
+
sql_dialect=request.sql_dialect,
|
|
1540
|
+
active=request.active,
|
|
1541
|
+
updated_by=user.username,
|
|
1542
|
+
)
|
|
1543
|
+
session.add(connector)
|
|
1544
|
+
session.flush()
|
|
1545
|
+
|
|
1546
|
+
if request.active:
|
|
1547
|
+
set_active_datasource_connector(session, connector, user.username)
|
|
1548
|
+
|
|
1549
|
+
record_admin_audit(
|
|
1550
|
+
session=session,
|
|
1551
|
+
actor=user.username,
|
|
1552
|
+
action="datasource.create",
|
|
1553
|
+
resource_type="datasource_connector",
|
|
1554
|
+
resource_id=connector.connector_key,
|
|
1555
|
+
details={
|
|
1556
|
+
"database_type": connector.database_type,
|
|
1557
|
+
"active": connector.active,
|
|
1558
|
+
},
|
|
1559
|
+
)
|
|
1560
|
+
session.commit()
|
|
1561
|
+
|
|
1562
|
+
return {"item": serialize_datasource(connector)}
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
@router.put("/datasources/{connector_id}")
|
|
1566
|
+
def update_datasource(
|
|
1567
|
+
connector_id: int,
|
|
1568
|
+
request: DatasourceConnectorUpdateRequest,
|
|
1569
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1570
|
+
session: Session = Depends(get_session),
|
|
1571
|
+
) -> dict[str, Any]:
|
|
1572
|
+
connector = get_datasource_connector(session, connector_id)
|
|
1573
|
+
|
|
1574
|
+
if connector is None:
|
|
1575
|
+
raise HTTPException(
|
|
1576
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1577
|
+
detail="Datasource connector not found.",
|
|
1578
|
+
)
|
|
1579
|
+
|
|
1580
|
+
if is_system_datasource_connector(connector):
|
|
1581
|
+
raise HTTPException(
|
|
1582
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1583
|
+
detail="The metadata datasource is managed by GAARD.",
|
|
1584
|
+
)
|
|
1585
|
+
|
|
1586
|
+
try:
|
|
1587
|
+
validate_datasource_url(request.database_type, request.database_url)
|
|
1588
|
+
except ValueError as exc:
|
|
1589
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
1590
|
+
|
|
1591
|
+
connector.name = request.name
|
|
1592
|
+
connector.database_type = request.database_type
|
|
1593
|
+
connector.database_url = request.database_url
|
|
1594
|
+
connector.sql_dialect = request.sql_dialect
|
|
1595
|
+
connector.active = request.active
|
|
1596
|
+
connector.updated_by = user.username
|
|
1597
|
+
|
|
1598
|
+
if request.active:
|
|
1599
|
+
set_active_datasource_connector(session, connector, user.username)
|
|
1600
|
+
|
|
1601
|
+
record_admin_audit(
|
|
1602
|
+
session=session,
|
|
1603
|
+
actor=user.username,
|
|
1604
|
+
action="datasource.update",
|
|
1605
|
+
resource_type="datasource_connector",
|
|
1606
|
+
resource_id=connector.connector_key,
|
|
1607
|
+
details={
|
|
1608
|
+
"database_type": connector.database_type,
|
|
1609
|
+
"active": connector.active,
|
|
1610
|
+
},
|
|
1611
|
+
)
|
|
1612
|
+
session.commit()
|
|
1613
|
+
|
|
1614
|
+
return {"item": serialize_datasource(connector)}
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
@router.post("/datasources/{connector_id}/activate")
|
|
1618
|
+
def activate_datasource(
|
|
1619
|
+
connector_id: int,
|
|
1620
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1621
|
+
session: Session = Depends(get_session),
|
|
1622
|
+
) -> dict[str, Any]:
|
|
1623
|
+
connector = get_datasource_connector(session, connector_id)
|
|
1624
|
+
|
|
1625
|
+
if connector is None:
|
|
1626
|
+
raise HTTPException(
|
|
1627
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1628
|
+
detail="Datasource connector not found.",
|
|
1629
|
+
)
|
|
1630
|
+
|
|
1631
|
+
if is_system_datasource_connector(connector):
|
|
1632
|
+
raise HTTPException(
|
|
1633
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1634
|
+
detail="The metadata datasource cannot be activated as the query datasource.",
|
|
1635
|
+
)
|
|
1636
|
+
|
|
1637
|
+
set_active_datasource_connector(session, connector, user.username)
|
|
1638
|
+
record_admin_audit(
|
|
1639
|
+
session=session,
|
|
1640
|
+
actor=user.username,
|
|
1641
|
+
action="datasource.activate",
|
|
1642
|
+
resource_type="datasource_connector",
|
|
1643
|
+
resource_id=connector.connector_key,
|
|
1644
|
+
)
|
|
1645
|
+
session.commit()
|
|
1646
|
+
|
|
1647
|
+
return {"item": serialize_datasource(connector)}
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
@router.post("/datasources/{connector_id}/test")
|
|
1651
|
+
def test_datasource(
|
|
1652
|
+
connector_id: int,
|
|
1653
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1654
|
+
session: Session = Depends(get_session),
|
|
1655
|
+
) -> dict[str, Any]:
|
|
1656
|
+
connector = get_datasource_connector(session, connector_id)
|
|
1657
|
+
|
|
1658
|
+
if connector is None:
|
|
1659
|
+
raise HTTPException(
|
|
1660
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1661
|
+
detail="Datasource connector not found.",
|
|
1662
|
+
)
|
|
1663
|
+
|
|
1664
|
+
try:
|
|
1665
|
+
test_datasource_connection(connector)
|
|
1666
|
+
except Exception as exc:
|
|
1667
|
+
record_admin_audit(
|
|
1668
|
+
session=session,
|
|
1669
|
+
actor=user.username,
|
|
1670
|
+
action="datasource.test_failed",
|
|
1671
|
+
resource_type="datasource_connector",
|
|
1672
|
+
resource_id=connector.connector_key,
|
|
1673
|
+
details={"error": str(exc)},
|
|
1674
|
+
)
|
|
1675
|
+
session.commit()
|
|
1676
|
+
raise HTTPException(
|
|
1677
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1678
|
+
detail=f"Connection test failed: {exc}",
|
|
1679
|
+
) from exc
|
|
1680
|
+
|
|
1681
|
+
record_admin_audit(
|
|
1682
|
+
session=session,
|
|
1683
|
+
actor=user.username,
|
|
1684
|
+
action="datasource.test",
|
|
1685
|
+
resource_type="datasource_connector",
|
|
1686
|
+
resource_id=connector.connector_key,
|
|
1687
|
+
)
|
|
1688
|
+
session.commit()
|
|
1689
|
+
|
|
1690
|
+
return {"status": "ok"}
|
|
1691
|
+
|
|
1692
|
+
|
|
1693
|
+
@router.post("/datasources/test")
|
|
1694
|
+
def test_datasource_from_request(
|
|
1695
|
+
request: DatasourceConnectionTestRequest,
|
|
1696
|
+
) -> dict[str, Any]:
|
|
1697
|
+
validate_datasource_url(request.database_type, request.database_url)
|
|
1698
|
+
connector = DatasourceConnector(
|
|
1699
|
+
connector_key="__preview__",
|
|
1700
|
+
name="__preview__",
|
|
1701
|
+
database_type=request.database_type,
|
|
1702
|
+
database_url=request.database_url,
|
|
1703
|
+
sql_dialect=request.database_type if request.database_type != "postgresql" else "postgres",
|
|
1704
|
+
active=False,
|
|
1705
|
+
)
|
|
1706
|
+
test_datasource_connection(connector)
|
|
1707
|
+
return {"status": "ok"}
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
@router.post("/datasources/{connector_id}/introspect")
|
|
1711
|
+
def introspect_datasource(
|
|
1712
|
+
connector_id: int,
|
|
1713
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1714
|
+
session: Session = Depends(get_session),
|
|
1715
|
+
) -> dict[str, Any]:
|
|
1716
|
+
connector = get_datasource_connector(session, connector_id)
|
|
1717
|
+
|
|
1718
|
+
if connector is None:
|
|
1719
|
+
raise HTTPException(
|
|
1720
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1721
|
+
detail="Datasource connector not found.",
|
|
1722
|
+
)
|
|
1723
|
+
|
|
1724
|
+
try:
|
|
1725
|
+
cache = introspect_datasource_connector(session, connector, user.username)
|
|
1726
|
+
except Exception as exc:
|
|
1727
|
+
raise HTTPException(
|
|
1728
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1729
|
+
detail=f"Schema introspection failed: {exc}",
|
|
1730
|
+
) from exc
|
|
1731
|
+
|
|
1732
|
+
record_admin_audit(
|
|
1733
|
+
session=session,
|
|
1734
|
+
actor=user.username,
|
|
1735
|
+
action="datasource.introspect",
|
|
1736
|
+
resource_type="datasource_connector",
|
|
1737
|
+
resource_id=connector.connector_key,
|
|
1738
|
+
)
|
|
1739
|
+
session.commit()
|
|
1740
|
+
|
|
1741
|
+
return {"item": serialize_datasource_schema(cache)}
|
|
1742
|
+
|
|
1743
|
+
|
|
1744
|
+
@router.get("/datasources/{connector_id}/schema")
|
|
1745
|
+
def get_datasource_schema(
|
|
1746
|
+
connector_id: int,
|
|
1747
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1748
|
+
session: Session = Depends(get_session),
|
|
1749
|
+
) -> dict[str, Any]:
|
|
1750
|
+
connector = get_datasource_connector(session, connector_id)
|
|
1751
|
+
|
|
1752
|
+
if connector is None:
|
|
1753
|
+
raise HTTPException(
|
|
1754
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1755
|
+
detail="Datasource connector not found.",
|
|
1756
|
+
)
|
|
1757
|
+
|
|
1758
|
+
cache = get_datasource_schema_cache(session, connector.id)
|
|
1759
|
+
|
|
1760
|
+
if cache is None:
|
|
1761
|
+
cache = introspect_datasource_connector(session, connector, user.username)
|
|
1762
|
+
session.commit()
|
|
1763
|
+
|
|
1764
|
+
return {
|
|
1765
|
+
"item": serialize_datasource_schema(cache),
|
|
1766
|
+
"viewer": user.username,
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
@router.put("/datasources/{connector_id}/schema/tables")
|
|
1771
|
+
def update_datasource_schema_tables(
|
|
1772
|
+
connector_id: int,
|
|
1773
|
+
request: DatasourceSchemaTableSettingsRequest,
|
|
1774
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1775
|
+
session: Session = Depends(get_session),
|
|
1776
|
+
) -> dict[str, Any]:
|
|
1777
|
+
connector = get_datasource_connector(session, connector_id)
|
|
1778
|
+
|
|
1779
|
+
if connector is None:
|
|
1780
|
+
raise HTTPException(
|
|
1781
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1782
|
+
detail="Datasource connector not found.",
|
|
1783
|
+
)
|
|
1784
|
+
|
|
1785
|
+
cache = get_datasource_schema_cache(session, connector.id)
|
|
1786
|
+
|
|
1787
|
+
if cache is None:
|
|
1788
|
+
cache = introspect_datasource_connector(session, connector, user.username)
|
|
1789
|
+
|
|
1790
|
+
cache = update_schema_table_settings(
|
|
1791
|
+
session=session,
|
|
1792
|
+
cache=cache,
|
|
1793
|
+
table_settings={"tables": request.tables},
|
|
1794
|
+
actor=user.username,
|
|
1795
|
+
)
|
|
1796
|
+
record_admin_audit(
|
|
1797
|
+
session=session,
|
|
1798
|
+
actor=user.username,
|
|
1799
|
+
action="datasource.schema.update",
|
|
1800
|
+
resource_type="datasource_connector",
|
|
1801
|
+
resource_id=connector.connector_key,
|
|
1802
|
+
details={"tables": list(request.tables.keys())},
|
|
1803
|
+
)
|
|
1804
|
+
session.commit()
|
|
1805
|
+
|
|
1806
|
+
return {"item": serialize_datasource_schema(cache)}
|
|
1807
|
+
|
|
1808
|
+
|
|
1809
|
+
@router.get("/business-logic-suggestions")
|
|
1810
|
+
def get_business_logic_suggestions(
|
|
1811
|
+
connector_id: int | None = None,
|
|
1812
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1813
|
+
session: Session = Depends(get_session),
|
|
1814
|
+
) -> dict[str, Any]:
|
|
1815
|
+
connector = (
|
|
1816
|
+
get_datasource_connector(session, connector_id)
|
|
1817
|
+
if connector_id is not None
|
|
1818
|
+
else get_active_datasource_connector(session)
|
|
1819
|
+
)
|
|
1820
|
+
|
|
1821
|
+
if connector is None:
|
|
1822
|
+
raise HTTPException(
|
|
1823
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1824
|
+
detail="Datasource connector not found.",
|
|
1825
|
+
)
|
|
1826
|
+
|
|
1827
|
+
return {
|
|
1828
|
+
"datasource": serialize_datasource(connector),
|
|
1829
|
+
"items": [
|
|
1830
|
+
serialize_business_logic_suggestion(suggestion)
|
|
1831
|
+
for suggestion in list_business_logic_suggestions(session, connector.id)
|
|
1832
|
+
],
|
|
1833
|
+
"statuses": [BUSINESS_LOGIC_STATUS_PENDING, BUSINESS_LOGIC_STATUS_ACTIVE],
|
|
1834
|
+
"viewer": user.username,
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
|
|
1838
|
+
@router.put("/business-logic-suggestions/{suggestion_id}")
|
|
1839
|
+
def update_business_logic_suggestion(
|
|
1840
|
+
suggestion_id: int,
|
|
1841
|
+
request: BusinessLogicSuggestionUpdateRequest,
|
|
1842
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1843
|
+
session: Session = Depends(get_session),
|
|
1844
|
+
) -> dict[str, Any]:
|
|
1845
|
+
suggestion = get_business_logic_suggestion(session, suggestion_id)
|
|
1846
|
+
|
|
1847
|
+
if suggestion is None:
|
|
1848
|
+
raise HTTPException(
|
|
1849
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1850
|
+
detail="Business logic suggestion not found.",
|
|
1851
|
+
)
|
|
1852
|
+
|
|
1853
|
+
if request.title is not None and not request.title.strip():
|
|
1854
|
+
raise HTTPException(
|
|
1855
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1856
|
+
detail="Business logic suggestion title cannot be empty.",
|
|
1857
|
+
)
|
|
1858
|
+
|
|
1859
|
+
if request.rule_text is not None and not request.rule_text.strip():
|
|
1860
|
+
raise HTTPException(
|
|
1861
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1862
|
+
detail="Business logic suggestion rule text cannot be empty.",
|
|
1863
|
+
)
|
|
1864
|
+
|
|
1865
|
+
if request.enabled is not None:
|
|
1866
|
+
suggestion = set_business_logic_suggestion_enabled(
|
|
1867
|
+
session=session,
|
|
1868
|
+
suggestion=suggestion,
|
|
1869
|
+
enabled=request.enabled,
|
|
1870
|
+
actor=user.username,
|
|
1871
|
+
)
|
|
1872
|
+
|
|
1873
|
+
if request.title is not None or request.rule_text is not None:
|
|
1874
|
+
suggestion = update_business_logic_suggestion_content(
|
|
1875
|
+
suggestion=suggestion,
|
|
1876
|
+
title=request.title,
|
|
1877
|
+
rule_text=request.rule_text,
|
|
1878
|
+
actor=user.username,
|
|
1879
|
+
)
|
|
1880
|
+
|
|
1881
|
+
record_admin_audit(
|
|
1882
|
+
session=session,
|
|
1883
|
+
actor=user.username,
|
|
1884
|
+
action="business_logic_suggestion.update",
|
|
1885
|
+
resource_type="business_logic_suggestion",
|
|
1886
|
+
resource_id=str(suggestion.id),
|
|
1887
|
+
details={
|
|
1888
|
+
"enabled": suggestion.enabled,
|
|
1889
|
+
"status": suggestion.status,
|
|
1890
|
+
"content_updated": request.title is not None or request.rule_text is not None,
|
|
1891
|
+
"connector_id": suggestion.connector_id,
|
|
1892
|
+
},
|
|
1893
|
+
)
|
|
1894
|
+
session.commit()
|
|
1895
|
+
|
|
1896
|
+
return {"item": serialize_business_logic_suggestion(suggestion)}
|
|
1897
|
+
|
|
1898
|
+
|
|
1899
|
+
@router.delete("/business-logic-suggestions/{suggestion_id}")
|
|
1900
|
+
def remove_business_logic_suggestion(
|
|
1901
|
+
suggestion_id: int,
|
|
1902
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1903
|
+
session: Session = Depends(get_session),
|
|
1904
|
+
) -> dict[str, Any]:
|
|
1905
|
+
suggestion = get_business_logic_suggestion(session, suggestion_id)
|
|
1906
|
+
|
|
1907
|
+
if suggestion is None:
|
|
1908
|
+
raise HTTPException(
|
|
1909
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1910
|
+
detail="Business logic suggestion not found.",
|
|
1911
|
+
)
|
|
1912
|
+
|
|
1913
|
+
connector_id = suggestion.connector_id
|
|
1914
|
+
delete_business_logic_suggestion(session, suggestion)
|
|
1915
|
+
record_admin_audit(
|
|
1916
|
+
session=session,
|
|
1917
|
+
actor=user.username,
|
|
1918
|
+
action="business_logic_suggestion.delete",
|
|
1919
|
+
resource_type="business_logic_suggestion",
|
|
1920
|
+
resource_id=str(suggestion_id),
|
|
1921
|
+
details={"connector_id": connector_id},
|
|
1922
|
+
)
|
|
1923
|
+
session.commit()
|
|
1924
|
+
|
|
1925
|
+
return {"status": "deleted"}
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
@router.get("/llm-config")
|
|
1929
|
+
def get_llm_config(
|
|
1930
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1931
|
+
session: Session = Depends(get_session),
|
|
1932
|
+
) -> dict[str, Any]:
|
|
1933
|
+
return {
|
|
1934
|
+
"item": serialize_llm_config(session),
|
|
1935
|
+
"viewer": user.username,
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
|
|
1939
|
+
@router.put("/llm-config")
|
|
1940
|
+
def update_llm_config(
|
|
1941
|
+
request: LlmConfigRequest,
|
|
1942
|
+
user: AdminUser = Depends(get_current_admin),
|
|
1943
|
+
session: Session = Depends(get_session),
|
|
1944
|
+
) -> dict[str, Any]:
|
|
1945
|
+
if request.provider != "openai-compatible":
|
|
1946
|
+
raise HTTPException(
|
|
1947
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1948
|
+
detail="Only openai-compatible LLM provider is supported.",
|
|
1949
|
+
)
|
|
1950
|
+
|
|
1951
|
+
current_llm_config = get_llm_runtime_config(session)
|
|
1952
|
+
current_query_config = get_query_runtime_config(session)
|
|
1953
|
+
timeout_seconds = request.timeout_seconds or current_llm_config.timeout_seconds
|
|
1954
|
+
intent_classification_mode = (
|
|
1955
|
+
request.intent_classification_mode
|
|
1956
|
+
or current_query_config.intent_classification_mode
|
|
1957
|
+
)
|
|
1958
|
+
sql_generation_mode = request.sql_generation_mode or current_query_config.sql_generation_mode
|
|
1959
|
+
result_interpretation_mode = (
|
|
1960
|
+
request.result_interpretation_mode
|
|
1961
|
+
or current_query_config.result_interpretation_mode
|
|
1962
|
+
)
|
|
1963
|
+
output_classification_mode = (
|
|
1964
|
+
request.output_classification_mode
|
|
1965
|
+
or current_query_config.output_classification_mode
|
|
1966
|
+
)
|
|
1967
|
+
investigation_mode = request.investigation_mode or current_query_config.investigation_mode
|
|
1968
|
+
investigation_ambiguity_mode = (
|
|
1969
|
+
request.investigation_ambiguity_mode
|
|
1970
|
+
or current_query_config.investigation_ambiguity_mode
|
|
1971
|
+
)
|
|
1972
|
+
query_max_rows = request.query_max_rows or current_query_config.query_max_rows
|
|
1973
|
+
query_timeout_seconds = (
|
|
1974
|
+
request.query_timeout_seconds
|
|
1975
|
+
or current_query_config.query_timeout_seconds
|
|
1976
|
+
)
|
|
1977
|
+
api_key = None
|
|
1978
|
+
if request.clear_api_key:
|
|
1979
|
+
api_key = "change-me"
|
|
1980
|
+
elif request.api_key is not None and request.api_key.strip():
|
|
1981
|
+
api_key = request.api_key.strip()
|
|
1982
|
+
|
|
1983
|
+
set_llm_runtime_config(
|
|
1984
|
+
session=session,
|
|
1985
|
+
provider=request.provider,
|
|
1986
|
+
base_url=request.base_url,
|
|
1987
|
+
api_key=api_key,
|
|
1988
|
+
model=request.model,
|
|
1989
|
+
timeout_seconds=timeout_seconds,
|
|
1990
|
+
extra_body=request.extra_body,
|
|
1991
|
+
actor=user.username,
|
|
1992
|
+
)
|
|
1993
|
+
set_query_runtime_config(
|
|
1994
|
+
session=session,
|
|
1995
|
+
intent_classification_mode=intent_classification_mode,
|
|
1996
|
+
sql_generation_mode=sql_generation_mode,
|
|
1997
|
+
result_interpretation_mode=result_interpretation_mode,
|
|
1998
|
+
output_classification_mode=output_classification_mode,
|
|
1999
|
+
investigation_mode=investigation_mode,
|
|
2000
|
+
investigation_ambiguity_mode=investigation_ambiguity_mode,
|
|
2001
|
+
query_max_rows=query_max_rows,
|
|
2002
|
+
query_timeout_seconds=query_timeout_seconds,
|
|
2003
|
+
actor=user.username,
|
|
2004
|
+
)
|
|
2005
|
+
record_admin_audit(
|
|
2006
|
+
session=session,
|
|
2007
|
+
actor=user.username,
|
|
2008
|
+
action="llm_config.update",
|
|
2009
|
+
resource_type="admin_setting",
|
|
2010
|
+
resource_id="llm_config",
|
|
2011
|
+
details={
|
|
2012
|
+
"provider": request.provider,
|
|
2013
|
+
"base_url": request.base_url,
|
|
2014
|
+
"model": request.model,
|
|
2015
|
+
"timeout_seconds": timeout_seconds,
|
|
2016
|
+
"extra_body": request.extra_body,
|
|
2017
|
+
"intent_classification_mode": intent_classification_mode,
|
|
2018
|
+
"sql_generation_mode": sql_generation_mode,
|
|
2019
|
+
"result_interpretation_mode": result_interpretation_mode,
|
|
2020
|
+
"output_classification_mode": output_classification_mode,
|
|
2021
|
+
"investigation_mode": investigation_mode,
|
|
2022
|
+
"investigation_ambiguity_mode": investigation_ambiguity_mode,
|
|
2023
|
+
"query_max_rows": query_max_rows,
|
|
2024
|
+
"query_timeout_seconds": query_timeout_seconds,
|
|
2025
|
+
},
|
|
2026
|
+
)
|
|
2027
|
+
session.commit()
|
|
2028
|
+
|
|
2029
|
+
return {"item": serialize_llm_config(session)}
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
@router.get("/governance-policy")
|
|
2033
|
+
def get_governance_policy(
|
|
2034
|
+
user: AdminUser = Depends(get_current_admin),
|
|
2035
|
+
session: Session = Depends(get_session),
|
|
2036
|
+
) -> dict[str, Any]:
|
|
2037
|
+
return {
|
|
2038
|
+
"item": serialize_governance_policy(session),
|
|
2039
|
+
"viewer": user.username,
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
|
|
2043
|
+
@router.put("/governance-policy")
|
|
2044
|
+
def update_governance_policy(
|
|
2045
|
+
request: GovernancePolicyRequest,
|
|
2046
|
+
user: AdminUser = Depends(get_current_admin),
|
|
2047
|
+
session: Session = Depends(get_session),
|
|
2048
|
+
) -> dict[str, Any]:
|
|
2049
|
+
try:
|
|
2050
|
+
config = set_governance_policy_config(
|
|
2051
|
+
session=session,
|
|
2052
|
+
config=request.model_dump(mode="json"),
|
|
2053
|
+
actor=user.username,
|
|
2054
|
+
)
|
|
2055
|
+
except ValueError as exc:
|
|
2056
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
2057
|
+
|
|
2058
|
+
record_admin_audit(
|
|
2059
|
+
session=session,
|
|
2060
|
+
actor=user.username,
|
|
2061
|
+
action="governance_policy.update",
|
|
2062
|
+
resource_type="admin_setting",
|
|
2063
|
+
resource_id="governance_policy",
|
|
2064
|
+
details=config,
|
|
2065
|
+
)
|
|
2066
|
+
session.commit()
|
|
2067
|
+
|
|
2068
|
+
return {"item": serialize_governance_policy(session)}
|
|
2069
|
+
|
|
2070
|
+
|
|
2071
|
+
@router.get("/schema-cache")
|
|
2072
|
+
def get_schema_cache_settings(
|
|
2073
|
+
user: AdminUser = Depends(get_current_admin),
|
|
2074
|
+
session: Session = Depends(get_session),
|
|
2075
|
+
) -> dict[str, Any]:
|
|
2076
|
+
configured_ttl = int(
|
|
2077
|
+
get_setting(
|
|
2078
|
+
session,
|
|
2079
|
+
"schema_cache_ttl_seconds",
|
|
2080
|
+
str(settings.gaard_schema_cache_ttl_seconds),
|
|
2081
|
+
)
|
|
2082
|
+
)
|
|
2083
|
+
|
|
2084
|
+
return {
|
|
2085
|
+
"ttl_seconds": configured_ttl,
|
|
2086
|
+
"runtime_ttl_seconds": schema_context_cache.ttl_seconds,
|
|
2087
|
+
"cache_key": get_runtime_schema_cache_key(session),
|
|
2088
|
+
"viewer": user.username,
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
|
|
2092
|
+
@router.put("/schema-cache")
|
|
2093
|
+
def update_schema_cache_settings(
|
|
2094
|
+
request: SchemaCacheSettingsRequest,
|
|
2095
|
+
user: AdminUser = Depends(get_current_admin),
|
|
2096
|
+
session: Session = Depends(get_session),
|
|
2097
|
+
) -> dict[str, Any]:
|
|
2098
|
+
schema_context_cache.ttl_seconds = request.ttl_seconds
|
|
2099
|
+
set_setting(session, "schema_cache_ttl_seconds", str(request.ttl_seconds), user.username)
|
|
2100
|
+
record_admin_audit(
|
|
2101
|
+
session=session,
|
|
2102
|
+
actor=user.username,
|
|
2103
|
+
action="schema_cache.ttl.update",
|
|
2104
|
+
resource_type="admin_setting",
|
|
2105
|
+
resource_id="schema_cache_ttl_seconds",
|
|
2106
|
+
details={"ttl_seconds": request.ttl_seconds},
|
|
2107
|
+
)
|
|
2108
|
+
session.commit()
|
|
2109
|
+
|
|
2110
|
+
return {
|
|
2111
|
+
"ttl_seconds": request.ttl_seconds,
|
|
2112
|
+
"runtime_ttl_seconds": schema_context_cache.ttl_seconds,
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
|
|
2116
|
+
@router.post("/schema-cache/invalidate")
|
|
2117
|
+
def invalidate_schema_cache(
|
|
2118
|
+
user: AdminUser = Depends(get_current_admin),
|
|
2119
|
+
session: Session = Depends(get_session),
|
|
2120
|
+
) -> dict[str, Any]:
|
|
2121
|
+
cache_key = get_runtime_schema_cache_key(session)
|
|
2122
|
+
schema_context_cache.invalidate(cache_key)
|
|
2123
|
+
record_admin_audit(
|
|
2124
|
+
session=session,
|
|
2125
|
+
actor=user.username,
|
|
2126
|
+
action="schema_cache.invalidate",
|
|
2127
|
+
resource_type="schema_cache",
|
|
2128
|
+
resource_id=cache_key,
|
|
2129
|
+
)
|
|
2130
|
+
session.commit()
|
|
2131
|
+
|
|
2132
|
+
return {
|
|
2133
|
+
"status": "invalidated",
|
|
2134
|
+
"cache_key": cache_key,
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
|
|
2138
|
+
@router.get("/integrations")
|
|
2139
|
+
def get_integration_stubs(user: AdminUser = Depends(get_current_admin)) -> dict[str, Any]:
|
|
2140
|
+
return {
|
|
2141
|
+
"items": [
|
|
2142
|
+
{
|
|
2143
|
+
"key": "freeipa",
|
|
2144
|
+
"name": "FreeIPA identity connector",
|
|
2145
|
+
"status": "planned",
|
|
2146
|
+
},
|
|
2147
|
+
{
|
|
2148
|
+
"key": "sql_validation_rules",
|
|
2149
|
+
"name": "SQL validation rules",
|
|
2150
|
+
"status": "planned",
|
|
2151
|
+
},
|
|
2152
|
+
{
|
|
2153
|
+
"key": "result_interpretation_policies",
|
|
2154
|
+
"name": "Result interpretation policies",
|
|
2155
|
+
"status": "planned",
|
|
2156
|
+
},
|
|
2157
|
+
],
|
|
2158
|
+
"viewer": user.username,
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
|
|
2162
|
+
@router.get("/license")
|
|
2163
|
+
def get_license(user: AdminUser = Depends(get_current_admin)) -> dict[str, Any]:
|
|
2164
|
+
return {
|
|
2165
|
+
"edition": "community",
|
|
2166
|
+
"status": "active",
|
|
2167
|
+
"managed_features": {
|
|
2168
|
+
"freeipa": False,
|
|
2169
|
+
"datasource_connectors": False,
|
|
2170
|
+
"sql_validation_rules": False,
|
|
2171
|
+
"result_interpretation_policies": False,
|
|
2172
|
+
},
|
|
2173
|
+
"viewer": user.username,
|
|
2174
|
+
}
|