gaard-api 0.2.4__tar.gz → 0.2.6__tar.gz
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-0.2.4/src/gaard_api.egg-info → gaard_api-0.2.6}/PKG-INFO +5 -5
- {gaard_api-0.2.4 → gaard_api-0.2.6}/pyproject.toml +5 -5
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/api/v1/admin.py +98 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/api/v1/dashboards.py +77 -1
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/example_data/medical_poc/seed.sql +206 -50
- gaard_api-0.2.6/src/gaard_api/example_database.py +474 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/main.py +1 -1
- {gaard_api-0.2.4 → gaard_api-0.2.6/src/gaard_api.egg-info}/PKG-INFO +5 -5
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api.egg-info/requires.txt +4 -4
- {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_admin_api.py +109 -3
- gaard_api-0.2.6/tests/test_server_cli.py +215 -0
- gaard_api-0.2.4/src/gaard_api/example_database.py +0 -135
- gaard_api-0.2.4/tests/test_server_cli.py +0 -111
- {gaard_api-0.2.4 → gaard_api-0.2.6}/MANIFEST.in +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/README.md +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/setup.cfg +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/__init__.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin/__init__.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin/database.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin/defaults.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin/models.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin/prompt_runtime.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin/security.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin/services.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin-web/assets/main.js +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin-web/assets/styles.css +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin-web/index.html +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/admin-web/package.json +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/api/__init__.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/api/v1/__init__.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/api/v1/analysis.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/api/v1/prompts.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/api/v1/query.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/api/v1/schema.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/api_registry.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/auth_dependencies.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/auth_hooks.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/cli.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/cli_commands.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/conversations.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/core/__init__.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/core/error_handlers.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/core/schema_cache.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/core/settings.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/example_data/__init__.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/extension_services.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/extensions.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/license.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/package_updates.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/py.typed +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/query_hooks.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/server_cli.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/siem.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api/tls_http.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api.egg-info/SOURCES.txt +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api.egg-info/dependency_links.txt +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api.egg-info/entry_points.txt +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/src/gaard_api.egg-info/top_level.txt +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_analysis_api.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_api_registry.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_conversation_api.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_error_handlers.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_license_api.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_license_service.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_prompt_runtime.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_query_error_messages.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_settings.py +0 -0
- {gaard_api-0.2.4 → gaard_api-0.2.6}/tests/test_tls_http.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gaard-api
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: GAARD backend web services providing admin interface
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
|
-
Requires-Dist: gaard-core==0.2.
|
|
8
|
-
Requires-Dist: gaard-connectors==0.2.
|
|
9
|
-
Requires-Dist: gaard-llm==0.2.
|
|
10
|
-
Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.
|
|
7
|
+
Requires-Dist: gaard-core==0.2.6
|
|
8
|
+
Requires-Dist: gaard-connectors==0.2.6
|
|
9
|
+
Requires-Dist: gaard-llm==0.2.6
|
|
10
|
+
Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.6
|
|
11
11
|
Requires-Dist: fastapi>=0.111.0
|
|
12
12
|
Requires-Dist: uvicorn[standard]>=0.30.0
|
|
13
13
|
Requires-Dist: pydantic>=2.7.0
|
|
@@ -4,16 +4,16 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gaard-api"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.6"
|
|
8
8
|
description = "GAARD backend web services providing admin interface"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
11
11
|
|
|
12
12
|
dependencies = [
|
|
13
|
-
"gaard-core==0.2.
|
|
14
|
-
"gaard-connectors==0.2.
|
|
15
|
-
"gaard-llm==0.2.
|
|
16
|
-
"gaard-plugin-api>=0.2.
|
|
13
|
+
"gaard-core==0.2.6",
|
|
14
|
+
"gaard-connectors==0.2.6",
|
|
15
|
+
"gaard-llm==0.2.6",
|
|
16
|
+
"gaard-plugin-api>=0.2.6,<0.3.0",
|
|
17
17
|
"fastapi>=0.111.0",
|
|
18
18
|
"uvicorn[standard]>=0.30.0",
|
|
19
19
|
"pydantic>=2.7.0",
|
|
@@ -13,6 +13,7 @@ from gaard_core.errors import (
|
|
|
13
13
|
QueryExecutionError,
|
|
14
14
|
SqlValidationError,
|
|
15
15
|
)
|
|
16
|
+
from gaard_core.llm_output import remove_thinking_blocks
|
|
16
17
|
from gaard_core.query_pipeline.models import (
|
|
17
18
|
OutputClassification,
|
|
18
19
|
QueryRequest,
|
|
@@ -21,6 +22,8 @@ from gaard_core.query_pipeline.models import (
|
|
|
21
22
|
)
|
|
22
23
|
from gaard_core.sql_validator.select_only import SelectOnlySqlValidator
|
|
23
24
|
from gaard_connectors import ConnectorRegistryError
|
|
25
|
+
from gaard_llm.openai_compatible.client import OpenAICompatibleClient
|
|
26
|
+
from gaard_llm.providers.models import ChatCompletionRequest, ChatMessage
|
|
24
27
|
from gaard_plugin_api import ExtensionRecord
|
|
25
28
|
from pydantic import BaseModel, Field
|
|
26
29
|
from sqlalchemy import select
|
|
@@ -251,6 +254,11 @@ class OverviewWidgetFromQueryRequest(BaseModel):
|
|
|
251
254
|
)
|
|
252
255
|
|
|
253
256
|
|
|
257
|
+
class OverviewWidgetTitleSuggestionRequest(BaseModel):
|
|
258
|
+
question: str = Field(min_length=1)
|
|
259
|
+
sql: str | None = None
|
|
260
|
+
|
|
261
|
+
|
|
254
262
|
class DatasourceConnectorRequest(BaseModel):
|
|
255
263
|
connector_key: str = Field(min_length=1, pattern=r"^[a-zA-Z0-9_-]+$")
|
|
256
264
|
name: str = Field(min_length=1)
|
|
@@ -503,6 +511,79 @@ def build_client_widget_key(session: Session, label: str, question: str) -> str:
|
|
|
503
511
|
return widget_key
|
|
504
512
|
|
|
505
513
|
|
|
514
|
+
def normalize_metric_title(value: str, fallback: str) -> str:
|
|
515
|
+
cleaned = remove_thinking_blocks(value).strip()
|
|
516
|
+
|
|
517
|
+
if cleaned.startswith("```"):
|
|
518
|
+
cleaned = cleaned.removeprefix("```").strip()
|
|
519
|
+
if cleaned.startswith("text"):
|
|
520
|
+
cleaned = cleaned.removeprefix("text").strip()
|
|
521
|
+
if cleaned.endswith("```"):
|
|
522
|
+
cleaned = cleaned.removesuffix("```").strip()
|
|
523
|
+
|
|
524
|
+
cleaned = cleaned.strip().strip('"').strip("'")
|
|
525
|
+
cleaned = re.sub(r"\s+", " ", cleaned)
|
|
526
|
+
cleaned = cleaned.rstrip(".:;-")
|
|
527
|
+
if not cleaned:
|
|
528
|
+
cleaned = fallback.strip()
|
|
529
|
+
|
|
530
|
+
return cleaned[:80].strip() or "Saved Metric"
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def fallback_metric_title(question: str) -> str:
|
|
534
|
+
normalized = re.sub(r"\s+", " ", question.strip())
|
|
535
|
+
normalized = normalized.rstrip("?!.:;-")
|
|
536
|
+
if not normalized:
|
|
537
|
+
return "Saved Metric"
|
|
538
|
+
|
|
539
|
+
return normalized[0].upper() + normalized[1:80]
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def suggest_metric_title_with_llm(session: Session, request: OverviewWidgetTitleSuggestionRequest) -> str:
|
|
543
|
+
llm_config = get_llm_runtime_config(session)
|
|
544
|
+
if llm_config.provider != "openai-compatible":
|
|
545
|
+
raise ConfigurationError(f"Unsupported GAARD_LLM_PROVIDER: {llm_config.provider}")
|
|
546
|
+
if not llm_config.api_key or llm_config.api_key == "change-me":
|
|
547
|
+
raise ConfigurationError("GAARD_LLM_API_KEY must be configured to suggest metric titles.")
|
|
548
|
+
|
|
549
|
+
client = OpenAICompatibleClient(
|
|
550
|
+
base_url=llm_config.base_url,
|
|
551
|
+
api_key=llm_config.api_key,
|
|
552
|
+
timeout_seconds=llm_config.timeout_seconds,
|
|
553
|
+
)
|
|
554
|
+
fallback = fallback_metric_title(request.question)
|
|
555
|
+
response = client.create_chat_completion(
|
|
556
|
+
ChatCompletionRequest(
|
|
557
|
+
model=llm_config.model,
|
|
558
|
+
temperature=0.0,
|
|
559
|
+
extra_body=llm_config.extra_body,
|
|
560
|
+
messages=[
|
|
561
|
+
ChatMessage(
|
|
562
|
+
role="system",
|
|
563
|
+
content=(
|
|
564
|
+
"You write short human-friendly metric names for dashboard widgets. "
|
|
565
|
+
"Return only the metric name, without quotes, markdown, explanations, "
|
|
566
|
+
"or trailing punctuation. Use the same language as the user question. "
|
|
567
|
+
"Prefer 3 to 8 words and keep it under 80 characters."
|
|
568
|
+
),
|
|
569
|
+
),
|
|
570
|
+
ChatMessage(
|
|
571
|
+
role="user",
|
|
572
|
+
content=(
|
|
573
|
+
"User question:\n"
|
|
574
|
+
f"{request.question.strip()}\n\n"
|
|
575
|
+
"Generated SQL, if useful:\n"
|
|
576
|
+
f"{(request.sql or '').strip()}\n\n"
|
|
577
|
+
"Return the metric name only."
|
|
578
|
+
),
|
|
579
|
+
),
|
|
580
|
+
],
|
|
581
|
+
)
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
return normalize_metric_title(response.content, fallback)
|
|
585
|
+
|
|
586
|
+
|
|
506
587
|
def build_client_excel_datasource_key(session: Session, filename: str) -> str:
|
|
507
588
|
stem = Path(filename).stem or "excel"
|
|
508
589
|
normalized = re.sub(r"[^a-z0-9_-]+", "_", stem.lower()).strip("_-") or "excel"
|
|
@@ -1416,6 +1497,23 @@ def create_overview_widget_from_query(
|
|
|
1416
1497
|
}
|
|
1417
1498
|
|
|
1418
1499
|
|
|
1500
|
+
@router.post("/overview/widgets/title-suggestion")
|
|
1501
|
+
def suggest_overview_widget_title(
|
|
1502
|
+
request: OverviewWidgetTitleSuggestionRequest,
|
|
1503
|
+
_principal: AuthenticatedSession = Depends(get_current_api_user),
|
|
1504
|
+
session: Session = Depends(get_session),
|
|
1505
|
+
) -> dict[str, Any]:
|
|
1506
|
+
try:
|
|
1507
|
+
title = suggest_metric_title_with_llm(session, request)
|
|
1508
|
+
except (ConfigurationError, LlmProviderError, ValueError) as exc:
|
|
1509
|
+
raise HTTPException(
|
|
1510
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1511
|
+
detail=str(exc),
|
|
1512
|
+
) from exc
|
|
1513
|
+
|
|
1514
|
+
return {"title": title}
|
|
1515
|
+
|
|
1516
|
+
|
|
1419
1517
|
@router.put("/overview/widgets/{widget_key}")
|
|
1420
1518
|
def update_overview_widget(
|
|
1421
1519
|
widget_key: str,
|
|
@@ -48,6 +48,11 @@ class DashboardCreateRequest(BaseModel):
|
|
|
48
48
|
description: str = Field(default="", max_length=2_000)
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
class DashboardUpdateRequest(BaseModel):
|
|
52
|
+
name: str = Field(min_length=1, max_length=255)
|
|
53
|
+
description: str = Field(default="", max_length=2_000)
|
|
54
|
+
|
|
55
|
+
|
|
51
56
|
class ActiveDashboardRequest(BaseModel):
|
|
52
57
|
dashboard_id: str = Field(min_length=1, max_length=64)
|
|
53
58
|
|
|
@@ -58,6 +63,10 @@ class DashboardWidgetCreateRequest(BaseModel):
|
|
|
58
63
|
visualization_type: str = Field(pattern=r"^(number|bar|stacked_bar|line|multi_line|pie|area|table)$")
|
|
59
64
|
|
|
60
65
|
|
|
66
|
+
class SavedMetricUpdateRequest(BaseModel):
|
|
67
|
+
label: str = Field(min_length=1, max_length=255)
|
|
68
|
+
|
|
69
|
+
|
|
61
70
|
class DashboardWidgetLayoutItem(BaseModel):
|
|
62
71
|
widget_id: str = Field(min_length=1, max_length=64)
|
|
63
72
|
x: int = Field(ge=0, le=11)
|
|
@@ -308,16 +317,49 @@ def list_dashboards(
|
|
|
308
317
|
|
|
309
318
|
@router.get("/dashboards/metrics")
|
|
310
319
|
def list_saved_dashboard_metrics(
|
|
320
|
+
include_result: bool = True,
|
|
311
321
|
principal: AuthenticatedSession = Depends(get_current_api_user),
|
|
312
322
|
session: Session = Depends(get_session),
|
|
313
323
|
) -> dict[str, Any]:
|
|
314
324
|
metrics = list_saved_metrics_for_owner(session, dashboard_owner_user_id(principal))
|
|
315
325
|
return {
|
|
316
|
-
"items": [
|
|
326
|
+
"items": [
|
|
327
|
+
serialize_saved_metric(session, metric, include_result=include_result)
|
|
328
|
+
for metric in metrics
|
|
329
|
+
],
|
|
317
330
|
"viewer": dashboard_owner_username(principal),
|
|
318
331
|
}
|
|
319
332
|
|
|
320
333
|
|
|
334
|
+
@router.patch("/dashboards/metrics/{widget_key}")
|
|
335
|
+
def update_saved_dashboard_metric(
|
|
336
|
+
widget_key: str,
|
|
337
|
+
request: SavedMetricUpdateRequest,
|
|
338
|
+
principal: AuthenticatedSession = Depends(get_current_api_user),
|
|
339
|
+
session: Session = Depends(get_session),
|
|
340
|
+
) -> dict[str, Any]:
|
|
341
|
+
owner_user_id = dashboard_owner_user_id(principal)
|
|
342
|
+
metric = get_saved_metric_for_owner(session, widget_key, owner_user_id)
|
|
343
|
+
if metric is None:
|
|
344
|
+
raise HTTPException(
|
|
345
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
346
|
+
detail="Saved metric not found.",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
label = request.label.strip()
|
|
350
|
+
if not label:
|
|
351
|
+
raise HTTPException(
|
|
352
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
353
|
+
detail="Metric name is required.",
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
metric.label = label
|
|
357
|
+
metric.updated_by = dashboard_owner_username(principal)
|
|
358
|
+
session.commit()
|
|
359
|
+
|
|
360
|
+
return {"item": serialize_saved_metric(session, metric, include_result=False)}
|
|
361
|
+
|
|
362
|
+
|
|
321
363
|
@router.post("/dashboards")
|
|
322
364
|
def create_dashboard(
|
|
323
365
|
request: DashboardCreateRequest,
|
|
@@ -378,6 +420,40 @@ def set_active_dashboard(
|
|
|
378
420
|
}
|
|
379
421
|
|
|
380
422
|
|
|
423
|
+
@router.put("/dashboards/{dashboard_id}")
|
|
424
|
+
def update_dashboard(
|
|
425
|
+
dashboard_id: str,
|
|
426
|
+
request: DashboardUpdateRequest,
|
|
427
|
+
principal: AuthenticatedSession = Depends(get_current_api_user),
|
|
428
|
+
session: Session = Depends(get_session),
|
|
429
|
+
) -> dict[str, Any]:
|
|
430
|
+
dashboard = get_dashboard_for_owner(
|
|
431
|
+
session,
|
|
432
|
+
dashboard_id,
|
|
433
|
+
dashboard_owner_user_id(principal),
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
if dashboard is None:
|
|
437
|
+
raise HTTPException(
|
|
438
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
439
|
+
detail="Dashboard not found.",
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
name = request.name.strip()
|
|
443
|
+
if not name:
|
|
444
|
+
raise HTTPException(
|
|
445
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
446
|
+
detail="Dashboard name is required.",
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
dashboard.name = name
|
|
450
|
+
dashboard.description = request.description.strip()
|
|
451
|
+
dashboard.owner_username = dashboard_owner_username(principal)
|
|
452
|
+
session.commit()
|
|
453
|
+
|
|
454
|
+
return {"item": serialize_dashboard(dashboard)}
|
|
455
|
+
|
|
456
|
+
|
|
381
457
|
@router.get("/dashboards/{dashboard_id}/widgets")
|
|
382
458
|
def list_widgets_for_dashboard(
|
|
383
459
|
dashboard_id: str,
|
|
@@ -208,36 +208,132 @@ WITH RECURSIVE
|
|
|
208
208
|
seq(n) AS (
|
|
209
209
|
VALUES (1)
|
|
210
210
|
UNION ALL
|
|
211
|
-
SELECT n + 1 FROM seq WHERE n <
|
|
211
|
+
SELECT n + 1 FROM seq WHERE n < 560
|
|
212
212
|
),
|
|
213
|
-
current_bounds(year_start, today, elapsed_days) AS (
|
|
213
|
+
current_bounds(year_start, today, elapsed_days, year_days) AS (
|
|
214
214
|
SELECT
|
|
215
215
|
date('now', 'localtime', 'start of year'),
|
|
216
216
|
date('now', 'localtime'),
|
|
217
|
-
CAST(julianday(date('now', 'localtime')) - julianday(date('now', 'localtime', 'start of year')) AS INTEGER)
|
|
217
|
+
CAST(julianday(date('now', 'localtime')) - julianday(date('now', 'localtime', 'start of year')) AS INTEGER),
|
|
218
|
+
CAST(
|
|
219
|
+
julianday(date('now', 'localtime', 'start of year', '+1 year', '-1 day'))
|
|
220
|
+
- julianday(date('now', 'localtime', 'start of year'))
|
|
221
|
+
AS INTEGER
|
|
222
|
+
)
|
|
218
223
|
),
|
|
219
|
-
|
|
224
|
+
specialty_mix(n, specialization, doctor_start, doctor_count) AS (
|
|
220
225
|
SELECT
|
|
221
226
|
seq.n,
|
|
222
227
|
CASE
|
|
223
|
-
WHEN seq.n
|
|
224
|
-
WHEN seq.n %
|
|
225
|
-
WHEN seq.n %
|
|
226
|
-
|
|
227
|
-
ELSE 1 + ((seq.n * 29) % 140)
|
|
228
|
+
WHEN seq.n % 100 < 34 THEN 'cardiology'
|
|
229
|
+
WHEN seq.n % 100 < 61 THEN 'orthopedics'
|
|
230
|
+
WHEN seq.n % 100 < 80 THEN 'dermatology'
|
|
231
|
+
ELSE 'neurology'
|
|
228
232
|
END,
|
|
229
233
|
CASE
|
|
230
|
-
WHEN seq.n
|
|
231
|
-
|
|
234
|
+
WHEN seq.n % 100 < 34 THEN 1
|
|
235
|
+
WHEN seq.n % 100 < 61 THEN 13
|
|
236
|
+
WHEN seq.n % 100 < 80 THEN 19
|
|
237
|
+
ELSE 7
|
|
232
238
|
END,
|
|
233
|
-
|
|
234
|
-
WHEN seq.n = 420 THEN current_bounds.elapsed_days
|
|
235
|
-
WHEN seq.n <= 112 THEN CAST((seq.n - 1) / 8 AS INTEGER) % (current_bounds.elapsed_days + 1)
|
|
236
|
-
ELSE ((seq.n * 37 + CAST(seq.n / 9 AS INTEGER)) % (current_bounds.elapsed_days + 1))
|
|
237
|
-
END
|
|
239
|
+
6
|
|
238
240
|
FROM seq
|
|
241
|
+
),
|
|
242
|
+
specialty_ranked AS (
|
|
243
|
+
SELECT
|
|
244
|
+
specialty_mix.n,
|
|
245
|
+
specialty_mix.specialization,
|
|
246
|
+
specialty_mix.doctor_start,
|
|
247
|
+
specialty_mix.doctor_count,
|
|
248
|
+
ROW_NUMBER() OVER (
|
|
249
|
+
PARTITION BY specialty_mix.specialization
|
|
250
|
+
ORDER BY specialty_mix.n
|
|
251
|
+
) AS specialty_visit_number,
|
|
252
|
+
COUNT(*) OVER (
|
|
253
|
+
PARTITION BY specialty_mix.specialization
|
|
254
|
+
) AS specialty_visit_total
|
|
255
|
+
FROM specialty_mix
|
|
256
|
+
),
|
|
257
|
+
seasonal_offsets AS (
|
|
258
|
+
SELECT
|
|
259
|
+
specialty_ranked.n,
|
|
260
|
+
specialty_ranked.specialization,
|
|
261
|
+
specialty_ranked.doctor_start
|
|
262
|
+
+ ((specialty_ranked.specialty_visit_number * 5 + specialty_ranked.n) % specialty_ranked.doctor_count)
|
|
263
|
+
AS doctor_id,
|
|
264
|
+
CASE specialty_ranked.specialization
|
|
265
|
+
WHEN 'cardiology' THEN
|
|
266
|
+
current_bounds.year_days - CAST(
|
|
267
|
+
(
|
|
268
|
+
current_bounds.year_days
|
|
269
|
+
* (specialty_ranked.specialty_visit_total - specialty_ranked.specialty_visit_number + 1)
|
|
270
|
+
* (specialty_ranked.specialty_visit_total - specialty_ranked.specialty_visit_number + 1)
|
|
271
|
+
) / (
|
|
272
|
+
(specialty_ranked.specialty_visit_total + 1)
|
|
273
|
+
* (specialty_ranked.specialty_visit_total + 1)
|
|
274
|
+
)
|
|
275
|
+
AS INTEGER
|
|
276
|
+
) + ((specialty_ranked.n * 7) % 10)
|
|
277
|
+
WHEN 'orthopedics' THEN
|
|
278
|
+
CASE
|
|
279
|
+
WHEN specialty_ranked.specialty_visit_number % 6 IN (0, 1, 2) THEN
|
|
280
|
+
150 + ((specialty_ranked.specialty_visit_number * 11 + specialty_ranked.n) % 90)
|
|
281
|
+
WHEN specialty_ranked.specialty_visit_number % 6 IN (3, 4) THEN
|
|
282
|
+
240 + ((specialty_ranked.specialty_visit_number * 7 + specialty_ranked.n) % 60)
|
|
283
|
+
ELSE
|
|
284
|
+
5 + ((specialty_ranked.specialty_visit_number * 13 + specialty_ranked.n) % 55)
|
|
285
|
+
END
|
|
286
|
+
WHEN 'dermatology' THEN
|
|
287
|
+
CASE
|
|
288
|
+
WHEN specialty_ranked.specialty_visit_number % 5 IN (0, 1, 2, 3) THEN
|
|
289
|
+
95 + ((specialty_ranked.specialty_visit_number * 13 + specialty_ranked.n) % 120)
|
|
290
|
+
ELSE
|
|
291
|
+
220 + ((specialty_ranked.specialty_visit_number * 17 + specialty_ranked.n) % 70)
|
|
292
|
+
END
|
|
293
|
+
ELSE
|
|
294
|
+
CASE
|
|
295
|
+
WHEN specialty_ranked.specialty_visit_number % 4 IN (0, 1) THEN
|
|
296
|
+
55 + ((specialty_ranked.specialty_visit_number * 13 + specialty_ranked.n) % 70)
|
|
297
|
+
WHEN specialty_ranked.specialty_visit_number % 4 = 2 THEN
|
|
298
|
+
245 + ((specialty_ranked.specialty_visit_number * 11 + specialty_ranked.n) % 75)
|
|
299
|
+
ELSE
|
|
300
|
+
130 + ((specialty_ranked.specialty_visit_number * 17 + specialty_ranked.n) % 80)
|
|
301
|
+
END
|
|
302
|
+
END AS raw_day_offset
|
|
303
|
+
FROM specialty_ranked
|
|
239
304
|
CROSS JOIN current_bounds
|
|
240
305
|
),
|
|
306
|
+
bounded_offsets AS (
|
|
307
|
+
SELECT
|
|
308
|
+
seasonal_offsets.n,
|
|
309
|
+
seasonal_offsets.specialization,
|
|
310
|
+
seasonal_offsets.doctor_id,
|
|
311
|
+
CASE
|
|
312
|
+
WHEN seasonal_offsets.raw_day_offset < 0 THEN 0
|
|
313
|
+
WHEN seasonal_offsets.raw_day_offset > current_bounds.year_days THEN current_bounds.year_days
|
|
314
|
+
ELSE seasonal_offsets.raw_day_offset
|
|
315
|
+
END AS day_offset
|
|
316
|
+
FROM seasonal_offsets
|
|
317
|
+
CROSS JOIN current_bounds
|
|
318
|
+
),
|
|
319
|
+
appointment_offsets(n, patient_id, doctor_id, day_offset, specialization) AS (
|
|
320
|
+
SELECT
|
|
321
|
+
bounded_offsets.n,
|
|
322
|
+
CASE
|
|
323
|
+
WHEN bounded_offsets.specialization = 'cardiology' AND bounded_offsets.n % 6 = 0 THEN 7
|
|
324
|
+
WHEN bounded_offsets.specialization = 'cardiology' AND bounded_offsets.n % 10 = 0 THEN 31
|
|
325
|
+
WHEN bounded_offsets.specialization = 'cardiology' THEN 1 + ((bounded_offsets.n * 17 + bounded_offsets.day_offset) % 90)
|
|
326
|
+
WHEN bounded_offsets.specialization = 'neurology' AND bounded_offsets.n % 8 = 0 THEN 86
|
|
327
|
+
WHEN bounded_offsets.specialization = 'neurology' THEN 25 + ((bounded_offsets.n * 11 + bounded_offsets.day_offset) % 90)
|
|
328
|
+
WHEN bounded_offsets.specialization = 'orthopedics' THEN 1 + ((bounded_offsets.n * 29 + bounded_offsets.day_offset) % 140)
|
|
329
|
+
ELSE 1 + ((bounded_offsets.n * 13 + bounded_offsets.day_offset * 2) % 140)
|
|
330
|
+
END
|
|
331
|
+
AS patient_id,
|
|
332
|
+
bounded_offsets.doctor_id,
|
|
333
|
+
bounded_offsets.day_offset,
|
|
334
|
+
bounded_offsets.specialization
|
|
335
|
+
FROM bounded_offsets
|
|
336
|
+
),
|
|
241
337
|
appointment_data AS (
|
|
242
338
|
SELECT
|
|
243
339
|
appointment_offsets.n,
|
|
@@ -245,46 +341,103 @@ WITH RECURSIVE
|
|
|
245
341
|
appointment_offsets.doctor_id,
|
|
246
342
|
date(current_bounds.year_start, '+' || appointment_offsets.day_offset || ' days') AS appointment_date,
|
|
247
343
|
printf('%02d:%02d', 8 + ((appointment_offsets.n * 7) % 10), (appointment_offsets.n * 15) % 60) AS appointment_time,
|
|
248
|
-
CASE
|
|
249
|
-
WHEN
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
344
|
+
CASE appointment_offsets.specialization
|
|
345
|
+
WHEN 'cardiology' THEN
|
|
346
|
+
CASE
|
|
347
|
+
WHEN appointment_offsets.n % 6 = 0 THEN 60
|
|
348
|
+
WHEN appointment_offsets.n % 2 = 0 THEN 45
|
|
349
|
+
ELSE 30
|
|
350
|
+
END
|
|
351
|
+
WHEN 'neurology' THEN
|
|
352
|
+
CASE
|
|
353
|
+
WHEN appointment_offsets.n % 5 = 0 THEN 60
|
|
354
|
+
WHEN appointment_offsets.n % 3 = 0 THEN 30
|
|
355
|
+
ELSE 45
|
|
356
|
+
END
|
|
357
|
+
WHEN 'orthopedics' THEN
|
|
358
|
+
CASE
|
|
359
|
+
WHEN appointment_offsets.n % 7 = 0 THEN 60
|
|
360
|
+
WHEN appointment_offsets.n % 2 = 0 THEN 30
|
|
361
|
+
ELSE 45
|
|
362
|
+
END
|
|
363
|
+
ELSE
|
|
364
|
+
CASE
|
|
365
|
+
WHEN appointment_offsets.n % 5 = 0 THEN 45
|
|
366
|
+
WHEN appointment_offsets.n % 2 = 0 THEN 15
|
|
367
|
+
ELSE 30
|
|
368
|
+
END
|
|
253
369
|
END AS duration_minutes,
|
|
254
370
|
CASE
|
|
371
|
+
WHEN appointment_offsets.day_offset > current_bounds.elapsed_days THEN
|
|
372
|
+
CASE
|
|
373
|
+
WHEN appointment_offsets.n % 31 = 0 THEN 'cancelled'
|
|
374
|
+
ELSE 'scheduled'
|
|
375
|
+
END
|
|
255
376
|
WHEN appointment_offsets.day_offset = current_bounds.elapsed_days THEN
|
|
256
377
|
CASE
|
|
257
378
|
WHEN appointment_offsets.n % 5 = 0 THEN 'cancelled'
|
|
258
379
|
WHEN appointment_offsets.n % 3 = 0 THEN 'checked_in'
|
|
259
380
|
ELSE 'scheduled'
|
|
260
381
|
END
|
|
261
|
-
WHEN appointment_offsets.n %
|
|
262
|
-
WHEN appointment_offsets.n %
|
|
382
|
+
WHEN appointment_offsets.specialization = 'dermatology' AND appointment_offsets.n % 22 = 0 THEN 'no_show'
|
|
383
|
+
WHEN appointment_offsets.specialization = 'orthopedics' AND appointment_offsets.n % 18 = 0 THEN 'cancelled'
|
|
384
|
+
WHEN appointment_offsets.specialization = 'cardiology' AND appointment_offsets.n % 27 = 0 THEN 'cancelled'
|
|
385
|
+
WHEN appointment_offsets.n % 29 = 0 THEN 'no_show'
|
|
386
|
+
WHEN appointment_offsets.n % 21 = 0 THEN 'cancelled'
|
|
263
387
|
ELSE 'completed'
|
|
264
388
|
END AS status,
|
|
265
|
-
CASE appointment_offsets.
|
|
266
|
-
WHEN
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
389
|
+
CASE appointment_offsets.specialization
|
|
390
|
+
WHEN 'cardiology' THEN
|
|
391
|
+
CASE appointment_offsets.n % 5
|
|
392
|
+
WHEN 0 THEN 'telemedicine'
|
|
393
|
+
WHEN 1 THEN 'follow_up'
|
|
394
|
+
WHEN 2 THEN 'control'
|
|
395
|
+
WHEN 3 THEN 'first_visit'
|
|
396
|
+
ELSE 'preventive'
|
|
397
|
+
END
|
|
398
|
+
WHEN 'neurology' THEN
|
|
399
|
+
CASE appointment_offsets.n % 5
|
|
400
|
+
WHEN 0 THEN 'first_visit'
|
|
401
|
+
WHEN 1 THEN 'follow_up'
|
|
402
|
+
WHEN 2 THEN 'control'
|
|
403
|
+
WHEN 3 THEN 'telemedicine'
|
|
404
|
+
ELSE 'urgent'
|
|
405
|
+
END
|
|
406
|
+
WHEN 'orthopedics' THEN
|
|
407
|
+
CASE appointment_offsets.n % 5
|
|
408
|
+
WHEN 0 THEN 'urgent'
|
|
409
|
+
WHEN 1 THEN 'first_visit'
|
|
410
|
+
WHEN 2 THEN 'follow_up'
|
|
411
|
+
WHEN 3 THEN 'control'
|
|
412
|
+
ELSE 'urgent'
|
|
413
|
+
END
|
|
414
|
+
ELSE
|
|
415
|
+
CASE appointment_offsets.n % 5
|
|
416
|
+
WHEN 0 THEN 'first_visit'
|
|
417
|
+
WHEN 1 THEN 'control'
|
|
418
|
+
WHEN 2 THEN 'preventive'
|
|
419
|
+
WHEN 3 THEN 'follow_up'
|
|
420
|
+
ELSE 'telemedicine'
|
|
421
|
+
END
|
|
272
422
|
END AS visit_type,
|
|
273
423
|
CASE
|
|
274
|
-
WHEN appointment_offsets.n %
|
|
424
|
+
WHEN appointment_offsets.specialization = 'orthopedics' AND appointment_offsets.n % 7 = 0 THEN 'high'
|
|
425
|
+
WHEN appointment_offsets.specialization = 'cardiology' AND appointment_offsets.n % 9 = 0 THEN 'high'
|
|
426
|
+
WHEN appointment_offsets.specialization = 'neurology' AND appointment_offsets.n % 13 = 0 THEN 'high'
|
|
275
427
|
WHEN appointment_offsets.n % 11 = 0 THEN 'elevated'
|
|
276
428
|
ELSE 'routine'
|
|
277
429
|
END AS priority,
|
|
278
|
-
CASE
|
|
279
|
-
WHEN
|
|
280
|
-
CASE
|
|
281
|
-
WHEN
|
|
282
|
-
WHEN
|
|
283
|
-
WHEN
|
|
284
|
-
WHEN
|
|
430
|
+
CASE appointment_offsets.specialization
|
|
431
|
+
WHEN 'cardiology' THEN
|
|
432
|
+
CASE
|
|
433
|
+
WHEN appointment_offsets.day_offset > 260 THEN 'winter cardiovascular risk review'
|
|
434
|
+
WHEN appointment_offsets.n % 5 = 0 THEN 'blood pressure follow-up'
|
|
435
|
+
WHEN appointment_offsets.n % 5 = 1 THEN 'chest pain evaluation'
|
|
436
|
+
WHEN appointment_offsets.n % 5 = 2 THEN 'palpitations'
|
|
437
|
+
WHEN appointment_offsets.n % 5 = 3 THEN 'shortness of breath'
|
|
285
438
|
ELSE 'post-treatment control'
|
|
286
439
|
END
|
|
287
|
-
WHEN
|
|
440
|
+
WHEN 'neurology' THEN
|
|
288
441
|
CASE appointment_offsets.n % 5
|
|
289
442
|
WHEN 0 THEN 'migraine follow-up'
|
|
290
443
|
WHEN 1 THEN 'dizziness'
|
|
@@ -292,20 +445,23 @@ WITH RECURSIVE
|
|
|
292
445
|
WHEN 3 THEN 'limb numbness'
|
|
293
446
|
ELSE 'seizure control'
|
|
294
447
|
END
|
|
295
|
-
WHEN
|
|
296
|
-
CASE
|
|
297
|
-
WHEN
|
|
298
|
-
WHEN
|
|
299
|
-
WHEN
|
|
300
|
-
WHEN
|
|
448
|
+
WHEN 'orthopedics' THEN
|
|
449
|
+
CASE
|
|
450
|
+
WHEN appointment_offsets.day_offset BETWEEN 150 AND 240 THEN 'sports injury'
|
|
451
|
+
WHEN appointment_offsets.day_offset < 70 THEN 'winter fall injury'
|
|
452
|
+
WHEN appointment_offsets.n % 5 = 0 THEN 'knee pain'
|
|
453
|
+
WHEN appointment_offsets.n % 5 = 1 THEN 'back pain'
|
|
454
|
+
WHEN appointment_offsets.n % 5 = 2 THEN 'ankle injury'
|
|
455
|
+
WHEN appointment_offsets.n % 5 = 3 THEN 'shoulder mobility issue'
|
|
301
456
|
ELSE 'post-injury control'
|
|
302
457
|
END
|
|
303
458
|
ELSE
|
|
304
|
-
CASE
|
|
305
|
-
WHEN
|
|
306
|
-
WHEN
|
|
307
|
-
WHEN
|
|
308
|
-
WHEN
|
|
459
|
+
CASE
|
|
460
|
+
WHEN appointment_offsets.day_offset BETWEEN 95 AND 220 THEN 'sun exposure skin check'
|
|
461
|
+
WHEN appointment_offsets.n % 5 = 0 THEN 'rash assessment'
|
|
462
|
+
WHEN appointment_offsets.n % 5 = 1 THEN 'mole check'
|
|
463
|
+
WHEN appointment_offsets.n % 5 = 2 THEN 'acne treatment'
|
|
464
|
+
WHEN appointment_offsets.n % 5 = 3 THEN 'psoriasis follow-up'
|
|
309
465
|
ELSE 'allergy symptoms'
|
|
310
466
|
END
|
|
311
467
|
END AS reason
|