gaard-api 0.2.8__tar.gz → 0.2.10__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.
Files changed (81) hide show
  1. {gaard_api-0.2.8/src/gaard_api.egg-info → gaard_api-0.2.10}/PKG-INFO +7 -5
  2. {gaard_api-0.2.8 → gaard_api-0.2.10}/pyproject.toml +10 -5
  3. gaard_api-0.2.10/src/gaard_api/admin/database.py +482 -0
  4. gaard_api-0.2.10/src/gaard_api/admin/database_initial.sql +164 -0
  5. gaard_api-0.2.10/src/gaard_api/admin/database_legacy_updates.sql +169 -0
  6. gaard_api-0.2.10/src/gaard_api/admin/database_updates.sql +10 -0
  7. gaard_api-0.2.10/src/gaard_api/admin/migration_runner.py +363 -0
  8. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/admin/models.py +23 -3
  9. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/admin/security.py +0 -1
  10. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/admin/services.py +50 -8
  11. gaard_api-0.2.10/src/gaard_api/admin-web/assets/identity.js +211 -0
  12. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/admin-web/assets/main.js +353 -37
  13. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/admin-web/assets/styles.css +88 -6
  14. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/api/v1/admin.py +418 -202
  15. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/api/v1/analysis.py +23 -22
  16. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/api/v1/prompts.py +8 -7
  17. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/api/v1/query.py +75 -26
  18. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/api/v1/schema.py +3 -4
  19. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/api_registry.py +1 -2
  20. gaard_api-0.2.10/src/gaard_api/auth_dependencies.py +179 -0
  21. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/auth_hooks.py +0 -1
  22. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/cli.py +13 -5
  23. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/cli_commands.py +2 -1
  24. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/conversations.py +85 -3
  25. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/core/error_handlers.py +0 -1
  26. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/example_database.py +1 -2
  27. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/extension_services.py +0 -1
  28. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/extensions.py +2 -2
  29. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/license.py +81 -27
  30. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/main.py +15 -4
  31. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/package_updates.py +9 -54
  32. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/query_hooks.py +5 -11
  33. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/siem.py +0 -1
  34. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/tls_http.py +4 -4
  35. {gaard_api-0.2.8 → gaard_api-0.2.10/src/gaard_api.egg-info}/PKG-INFO +7 -5
  36. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api.egg-info/SOURCES.txt +6 -0
  37. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api.egg-info/requires.txt +7 -4
  38. {gaard_api-0.2.8 → gaard_api-0.2.10}/tests/test_admin_api.py +1027 -139
  39. {gaard_api-0.2.8 → gaard_api-0.2.10}/tests/test_analysis_api.py +27 -14
  40. gaard_api-0.2.10/tests/test_api_cli.py +29 -0
  41. {gaard_api-0.2.8 → gaard_api-0.2.10}/tests/test_conversation_api.py +55 -5
  42. gaard_api-0.2.10/tests/test_database_migrations.py +525 -0
  43. {gaard_api-0.2.8 → gaard_api-0.2.10}/tests/test_error_handlers.py +3 -2
  44. {gaard_api-0.2.8 → gaard_api-0.2.10}/tests/test_license_api.py +81 -17
  45. {gaard_api-0.2.8 → gaard_api-0.2.10}/tests/test_license_service.py +100 -15
  46. {gaard_api-0.2.8 → gaard_api-0.2.10}/tests/test_prompt_runtime.py +1 -1
  47. {gaard_api-0.2.8 → gaard_api-0.2.10}/tests/test_server_cli.py +4 -3
  48. {gaard_api-0.2.8 → gaard_api-0.2.10}/tests/test_settings.py +4 -2
  49. {gaard_api-0.2.8 → gaard_api-0.2.10}/tests/test_tls_http.py +8 -7
  50. gaard_api-0.2.8/src/gaard_api/admin/database.py +0 -723
  51. gaard_api-0.2.8/src/gaard_api/admin-web/assets/identity.js +0 -109
  52. gaard_api-0.2.8/src/gaard_api/auth_dependencies.py +0 -148
  53. {gaard_api-0.2.8 → gaard_api-0.2.10}/MANIFEST.in +0 -0
  54. {gaard_api-0.2.8 → gaard_api-0.2.10}/README.md +0 -0
  55. {gaard_api-0.2.8 → gaard_api-0.2.10}/setup.cfg +0 -0
  56. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/__init__.py +0 -0
  57. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/admin/__init__.py +0 -0
  58. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/admin/defaults.py +0 -0
  59. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/admin/prompt_runtime.py +0 -0
  60. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
  61. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
  62. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
  63. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/admin-web/index.html +0 -0
  64. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/admin-web/package.json +0 -0
  65. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/api/__init__.py +0 -0
  66. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/api/v1/__init__.py +0 -0
  67. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/api/v1/dashboards.py +1 -1
  68. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/core/__init__.py +0 -0
  69. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/core/schema_cache.py +0 -0
  70. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/core/settings.py +1 -1
  71. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/example_data/__init__.py +0 -0
  72. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
  73. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
  74. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
  75. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/py.typed +0 -0
  76. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api/server_cli.py +0 -0
  77. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api.egg-info/dependency_links.txt +0 -0
  78. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api.egg-info/entry_points.txt +0 -0
  79. {gaard_api-0.2.8 → gaard_api-0.2.10}/src/gaard_api.egg-info/top_level.txt +0 -0
  80. {gaard_api-0.2.8 → gaard_api-0.2.10}/tests/test_api_registry.py +1 -1
  81. {gaard_api-0.2.8 → gaard_api-0.2.10}/tests/test_query_error_messages.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-api
3
- Version: 0.2.8
3
+ Version: 0.2.10
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
8
- Requires-Dist: gaard-connectors==0.2.8
9
- Requires-Dist: gaard-llm==0.2.8
10
- Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.8
7
+ Requires-Dist: gaard-core==0.2.10
8
+ Requires-Dist: gaard-connectors==0.2.10
9
+ Requires-Dist: gaard-llm==0.2.10
10
+ Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.10
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
@@ -19,6 +19,8 @@ Provides-Extra: oracle
19
19
  Requires-Dist: oracledb>=2.0.0; extra == "oracle"
20
20
  Provides-Extra: mssql
21
21
  Requires-Dist: pyodbc>=5.0.0; extra == "mssql"
22
+ Provides-Extra: odbc
23
+ Requires-Dist: pyodbc>=5.0.0; extra == "odbc"
22
24
  Provides-Extra: ibm-db2
23
25
  Requires-Dist: ibm-db-sa>=0.4.4; extra == "ibm-db2"
24
26
  Provides-Extra: teradata
@@ -4,16 +4,16 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gaard-api"
7
- version = "0.2.8"
7
+ version = "0.2.10"
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.8",
14
- "gaard-connectors==0.2.8",
15
- "gaard-llm==0.2.8",
16
- "gaard-plugin-api>=0.2.8,<0.3.0",
13
+ "gaard-core==0.2.10",
14
+ "gaard-connectors==0.2.10",
15
+ "gaard-llm==0.2.10",
16
+ "gaard-plugin-api>=0.2.10,<0.3.0",
17
17
  "fastapi>=0.111.0",
18
18
  "uvicorn[standard]>=0.30.0",
19
19
  "pydantic>=2.7.0",
@@ -26,6 +26,7 @@ dependencies = [
26
26
  [project.optional-dependencies]
27
27
  oracle = ["oracledb>=2.0.0"]
28
28
  mssql = ["pyodbc>=5.0.0"]
29
+ odbc = ["pyodbc>=5.0.0"]
29
30
  ibm_db2 = ["ibm-db-sa>=0.4.4"]
30
31
  teradata = ["teradatasqlalchemy>=20.0.0.9"]
31
32
  all-datasources = [
@@ -65,6 +66,7 @@ where = ["src"]
65
66
  [tool.setuptools.package-data]
66
67
  gaard_api = [
67
68
  "admin-web/**/*",
69
+ "admin/*.sql",
68
70
  "example_data/medical_poc/*.sql",
69
71
  ]
70
72
 
@@ -79,5 +81,8 @@ gaard_api = [
79
81
  line-length = 100
80
82
  target-version = "py311"
81
83
 
84
+ [tool.ruff.flake8-bugbear]
85
+ extend-immutable-calls = ["fastapi.Depends", "fastapi.File", "fastapi.param_functions.Depends", "fastapi.param_functions.File", "fastapi.params.Depends", "fastapi.params.File"]
86
+
82
87
  [tool.pytest.ini_options]
83
88
  testpaths = ["tests"]
@@ -0,0 +1,482 @@
1
+ import json
2
+ import threading
3
+ from collections.abc import Iterator
4
+ from datetime import UTC, datetime, timedelta
5
+ from importlib.resources import files
6
+ from typing import cast
7
+
8
+ from sqlalchemy import create_engine, delete, inspect, select
9
+ from sqlalchemy.engine import Engine
10
+ from sqlalchemy.orm import Session, sessionmaker
11
+
12
+ from gaard_api.admin.defaults import DEFAULT_GOVERNANCE_POLICY_CONFIG, DEFAULT_PROMPTS
13
+ from gaard_api.admin.migration_runner import (
14
+ apply_pending_sql_updates,
15
+ as_table,
16
+ execute_initial_sql,
17
+ execute_legacy_sql_phase,
18
+ has_applicable_legacy_repairs,
19
+ parse_initial_sql,
20
+ parse_legacy_sql,
21
+ parse_sql_updates,
22
+ stamp_sql_updates,
23
+ )
24
+ from gaard_api.admin.models import (
25
+ AdminSession,
26
+ AdminSetting,
27
+ AdminUser,
28
+ DatabaseMigrationTag,
29
+ DatasourceConnector,
30
+ OverviewWidget,
31
+ OverviewWidgetTag,
32
+ PromptTemplate,
33
+ WidgetTag,
34
+ )
35
+ from gaard_api.admin.security import hash_password
36
+ from gaard_api.core.settings import settings
37
+
38
+ _engine: Engine | None = None
39
+ _session_factory: sessionmaker[Session] | None = None
40
+ _engine_url: str | None = None
41
+ _initialized_url: str | None = None
42
+ _init_lock = threading.RLock()
43
+ LEGACY_PROMPT_KEYS = {"investigation_readiness"}
44
+
45
+
46
+ def get_engine() -> Engine:
47
+ global _engine, _engine_url, _session_factory
48
+
49
+ database_url = settings.gaard_metadata_database_url
50
+
51
+ if _engine is not None and _engine_url == database_url:
52
+ return _engine
53
+
54
+ connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {}
55
+ _engine = create_engine(database_url, connect_args=connect_args)
56
+ _session_factory = sessionmaker(bind=_engine, autoflush=False, expire_on_commit=False)
57
+ _engine_url = database_url
58
+
59
+ return _engine
60
+
61
+
62
+ def get_session() -> Iterator[Session]:
63
+ init_metadata_store()
64
+
65
+ if _session_factory is None:
66
+ raise RuntimeError("Admin metadata session factory is not initialized.")
67
+
68
+ session = _session_factory()
69
+ try:
70
+ yield session
71
+ finally:
72
+ session.close()
73
+
74
+
75
+ def create_session() -> Session:
76
+ init_metadata_store()
77
+
78
+ if _session_factory is None:
79
+ raise RuntimeError("Admin metadata session factory is not initialized.")
80
+
81
+ return _session_factory()
82
+
83
+
84
+ def init_metadata_store() -> None:
85
+ global _initialized_url
86
+
87
+ with _init_lock:
88
+ engine = get_engine()
89
+ if _initialized_url == _engine_url:
90
+ return
91
+
92
+ updates = parse_sql_updates(
93
+ files("gaard_api.admin").joinpath("database_updates.sql").read_text("utf-8")
94
+ )
95
+ if not updates:
96
+ raise RuntimeError("The database update file must contain its baseline tag.")
97
+ initial_commands = parse_initial_sql(
98
+ files("gaard_api.admin").joinpath("database_initial.sql").read_text("utf-8")
99
+ )
100
+ existing_tables = set(inspect(engine).get_table_names())
101
+ migration_tags = as_table(DatabaseMigrationTag.__table__)
102
+ if not existing_tables:
103
+ execute_initial_sql(engine, initial_commands)
104
+ stamp_sql_updates(engine, updates, migration_tags)
105
+ else:
106
+ migration_tags.create(engine, checkfirst=True)
107
+ legacy_commands = parse_legacy_sql(
108
+ files("gaard_api.admin")
109
+ .joinpath("database_legacy_updates.sql")
110
+ .read_text("utf-8")
111
+ )
112
+ with engine.connect() as connection:
113
+ first_tag_applied = connection.scalar(
114
+ select(migration_tags.c.tag).where(
115
+ migration_tags.c.tag == updates[0].tag
116
+ )
117
+ )
118
+ legacy_repair_needed = first_tag_applied is None or has_applicable_legacy_repairs(
119
+ engine,
120
+ legacy_commands,
121
+ )
122
+ if legacy_repair_needed:
123
+ execute_legacy_sql_phase(
124
+ engine,
125
+ legacy_commands,
126
+ "before-initial",
127
+ )
128
+ execute_initial_sql(engine, initial_commands)
129
+ execute_legacy_sql_phase(
130
+ engine,
131
+ legacy_commands,
132
+ "after-initial",
133
+ )
134
+ apply_pending_sql_updates(engine, updates, migration_tags)
135
+
136
+ if _session_factory is None:
137
+ raise RuntimeError("Admin metadata session factory is not initialized.")
138
+
139
+ with _session_factory() as session:
140
+ seed_admin_user(session)
141
+ seed_settings(session)
142
+ apply_runtime_settings(session)
143
+ seed_prompts(session)
144
+ seed_datasource_connectors(session)
145
+ seed_overview_widgets(session)
146
+ session.commit()
147
+ _initialized_url = _engine_url
148
+
149
+
150
+ def clear_expired_admin_sessions(session: Session) -> None:
151
+ """Remove sessions inactive for 30 days; called once during application startup."""
152
+ cutoff = datetime.now(UTC) - timedelta(days=30)
153
+ session.execute(
154
+ delete(AdminSession)
155
+ .where(AdminSession.last_seen < cutoff)
156
+ .execution_options(synchronize_session=False)
157
+ )
158
+
159
+
160
+ def seed_admin_user(session: Session) -> None:
161
+ user = session.scalar(
162
+ select(AdminUser).where(
163
+ AdminUser.username == "admin",
164
+ AdminUser.auth_provider == "local",
165
+ )
166
+ )
167
+
168
+ if user is not None:
169
+ return
170
+
171
+ session.add(
172
+ AdminUser(
173
+ username="admin",
174
+ password_hash=hash_password("admin"),
175
+ must_change_password=True,
176
+ is_system_admin=True,
177
+ enterprise_access=True,
178
+ )
179
+ )
180
+
181
+
182
+ def seed_settings(session: Session) -> None:
183
+ defaults = {
184
+ "gaard_intent_classification_mode": settings.gaard_intent_classification_mode,
185
+ "gaard_sql_generation_mode": settings.gaard_sql_generation_mode,
186
+ "gaard_result_interpretation_mode": settings.gaard_result_interpretation_mode,
187
+ "gaard_output_classification_mode": settings.gaard_output_classification_mode,
188
+ "gaard_query_max_rows": str(settings.gaard_query_max_rows),
189
+ "gaard_query_timeout_seconds": str(settings.gaard_query_timeout_seconds),
190
+ "gaard_analysis_loop_count": str(settings.gaard_analysis_loop_count),
191
+ "gaard_analysis_auto_enable_business_logic": (
192
+ "true" if settings.gaard_analysis_auto_enable_business_logic else "false"
193
+ ),
194
+ "gaard_llm_provider": settings.gaard_llm_provider,
195
+ "gaard_llm_base_url": settings.gaard_llm_base_url,
196
+ "gaard_llm_api_key": settings.gaard_llm_api_key,
197
+ "gaard_llm_model": settings.gaard_llm_model,
198
+ "gaard_llm_timeout_seconds": str(settings.gaard_llm_timeout_seconds),
199
+ "gaard_llm_extra_body": json.dumps(
200
+ settings.gaard_llm_extra_body,
201
+ ensure_ascii=False,
202
+ sort_keys=True,
203
+ ),
204
+ "gaard_governance_policy": json.dumps(
205
+ DEFAULT_GOVERNANCE_POLICY_CONFIG,
206
+ ensure_ascii=False,
207
+ sort_keys=True,
208
+ ),
209
+ "data_query_audit_retention_days": str(settings.gaard_audit_retention_days),
210
+ "schema_cache_ttl_seconds": str(settings.gaard_schema_cache_ttl_seconds),
211
+ }
212
+
213
+ for key, value in defaults.items():
214
+ setting = session.get(AdminSetting, key)
215
+ if setting is None:
216
+ session.add(AdminSetting(key=key, value=value))
217
+ elif setting.updated_by == "system" and setting.value != value:
218
+ setting.value = value
219
+
220
+ if session.get(AdminSetting, "license_edition") is None:
221
+ session.add(AdminSetting(key="license_edition", value="community"))
222
+
223
+
224
+ def apply_runtime_settings(session: Session) -> None:
225
+ schema_cache_ttl = session.get(AdminSetting, "schema_cache_ttl_seconds")
226
+
227
+ if schema_cache_ttl is None:
228
+ return
229
+
230
+ try:
231
+ ttl_seconds = max(1, int(schema_cache_ttl.value))
232
+ except (TypeError, ValueError):
233
+ return
234
+
235
+ from gaard_api.core.schema_cache import schema_context_cache
236
+
237
+ schema_context_cache.ttl_seconds = ttl_seconds
238
+
239
+
240
+ def seed_prompts(session: Session) -> None:
241
+ session.execute(
242
+ delete(PromptTemplate).where(PromptTemplate.prompt_key.in_(LEGACY_PROMPT_KEYS))
243
+ )
244
+
245
+ for prompt in DEFAULT_PROMPTS:
246
+ existing = session.scalar(
247
+ select(PromptTemplate).where(PromptTemplate.prompt_key == prompt["prompt_key"])
248
+ )
249
+
250
+ if existing is None:
251
+ session.add(PromptTemplate(**prompt))
252
+ continue
253
+
254
+ if existing.updated_by != "system":
255
+ continue
256
+
257
+ changed = any(
258
+ getattr(existing, field) != prompt[field]
259
+ for field in (
260
+ "name",
261
+ "description",
262
+ "system_prompt",
263
+ "user_prompt_template",
264
+ )
265
+ )
266
+ if not changed:
267
+ continue
268
+
269
+ existing.name = prompt["name"]
270
+ existing.description = prompt["description"]
271
+ existing.system_prompt = prompt["system_prompt"]
272
+ existing.user_prompt_template = prompt["user_prompt_template"]
273
+ existing.active = True
274
+ existing.version += 1
275
+ existing.updated_by = "system"
276
+
277
+
278
+ def seed_datasource_connectors(session: Session) -> None:
279
+ metadata_connector = session.scalar(
280
+ select(DatasourceConnector).where(DatasourceConnector.connector_key == "metadata-db")
281
+ )
282
+ database_type, sql_dialect = infer_datasource_type(settings.gaard_metadata_database_url)
283
+
284
+ if metadata_connector is None:
285
+ session.add(
286
+ DatasourceConnector(
287
+ connector_key="metadata-db",
288
+ name="GAARD Metadata DB",
289
+ database_type=database_type,
290
+ database_url=settings.gaard_metadata_database_url,
291
+ sql_dialect=sql_dialect,
292
+ active=False,
293
+ )
294
+ )
295
+ else:
296
+ metadata_connector.name = "GAARD Metadata DB"
297
+ metadata_connector.database_type = database_type
298
+ metadata_connector.database_url = settings.gaard_metadata_database_url
299
+ metadata_connector.sql_dialect = sql_dialect
300
+ metadata_connector.active = False
301
+ metadata_connector.updated_by = "system"
302
+
303
+
304
+ def infer_datasource_type(database_url: str) -> tuple[str, str]:
305
+ if database_url.startswith("sqlite"):
306
+ return "sqlite", "sqlite"
307
+
308
+ if database_url.startswith("postgresql"):
309
+ return "postgresql", "postgres"
310
+
311
+ if database_url.startswith("mysql"):
312
+ return "mysql", "mysql"
313
+
314
+ return "postgresql", "postgres"
315
+
316
+
317
+ def seed_overview_widgets(session: Session) -> None:
318
+ _database_type, metadata_sql_dialect = infer_datasource_type(settings.gaard_metadata_database_url)
319
+ runtime_sql = (
320
+ "SELECT DATE(occurred_at) AS day, datasource_id, COUNT(*) AS query_count "
321
+ "FROM data_query_audit_logs "
322
+ "GROUP BY DATE(occurred_at), datasource_id "
323
+ "ORDER BY day, datasource_id"
324
+ if metadata_sql_dialect == "sqlite"
325
+ else "SELECT occurred_at::date AS day, datasource_id, COUNT(*) AS query_count "
326
+ "FROM data_query_audit_logs "
327
+ "GROUP BY occurred_at::date, datasource_id "
328
+ "ORDER BY day, datasource_id"
329
+ )
330
+ defaults = [
331
+ {
332
+ "widget_key": "prompts_count",
333
+ "label": "Prompts",
334
+ "widget_type": "scalar",
335
+ "datasource_key": "metadata-db",
336
+ "question": (
337
+ "How many prompt templates are configured in GAARD metadata? "
338
+ "Return exactly one numeric value."
339
+ ),
340
+ "sql": "SELECT COUNT(*) AS value FROM prompt_templates",
341
+ "result_mode": "data",
342
+ "position": 10,
343
+ "grid_width": 1,
344
+ "grid_height": 2,
345
+ },
346
+ {
347
+ "widget_key": "audit_retention",
348
+ "label": "Audit retention",
349
+ "widget_type": "scalar",
350
+ "datasource_key": "metadata-db",
351
+ "question": (
352
+ "What is the value of the admin setting named "
353
+ "data_query_audit_retention_days? Return exactly one numeric value."
354
+ ),
355
+ "sql": (
356
+ "SELECT CAST(value AS INTEGER) AS value "
357
+ "FROM admin_settings "
358
+ "WHERE key = 'data_query_audit_retention_days'"
359
+ ),
360
+ "result_mode": "data",
361
+ "position": 20,
362
+ "grid_width": 1,
363
+ "grid_height": 2,
364
+ },
365
+ {
366
+ "widget_key": "schema_cache_ttl",
367
+ "label": "Schema cache TTL",
368
+ "widget_type": "scalar",
369
+ "datasource_key": "metadata-db",
370
+ "question": (
371
+ "What is the value of the admin setting named schema_cache_ttl_seconds? "
372
+ "Return exactly one numeric value."
373
+ ),
374
+ "sql": (
375
+ "SELECT CAST(value AS INTEGER) AS value "
376
+ "FROM admin_settings "
377
+ "WHERE key = 'schema_cache_ttl_seconds'"
378
+ ),
379
+ "result_mode": "data",
380
+ "position": 30,
381
+ "grid_width": 1,
382
+ "grid_height": 2,
383
+ },
384
+ {
385
+ "widget_key": "license_edition",
386
+ "label": "License",
387
+ "widget_type": "scalar",
388
+ "datasource_key": "metadata-db",
389
+ "question": (
390
+ "What is the value of the admin setting named license_edition? "
391
+ "Return exactly one text value."
392
+ ),
393
+ "sql": "SELECT value AS value FROM admin_settings WHERE key = 'license_edition'",
394
+ "result_mode": "data",
395
+ "position": 40,
396
+ "grid_width": 1,
397
+ "grid_height": 2,
398
+ },
399
+ {
400
+ "widget_key": "runtime_daily_queries",
401
+ "label": "Runtime",
402
+ "widget_type": "timeseries",
403
+ "datasource_key": "metadata-db",
404
+ "question": (
405
+ "For each day and datasource_id in data_query_audit_logs, how many "
406
+ "query records exist? Return columns day, datasource_id, query_count "
407
+ "ordered by day and datasource_id."
408
+ ),
409
+ "sql": runtime_sql,
410
+ "result_mode": "data",
411
+ "position": 250,
412
+ "grid_width": 12,
413
+ "grid_height": 4,
414
+ "active": False,
415
+ },
416
+ {
417
+ "widget_key": "prompt_templates_table",
418
+ "label": "Prompt templates",
419
+ "widget_type": "table",
420
+ "datasource_key": "metadata-db",
421
+ "question": (
422
+ "List configured prompt templates with prompt_key, name, version and "
423
+ "active status, ordered by prompt_key."
424
+ ),
425
+ "sql": (
426
+ "SELECT prompt_key, name, version, active "
427
+ "FROM prompt_templates "
428
+ "ORDER BY prompt_key"
429
+ ),
430
+ "result_mode": "data",
431
+ "position": 130,
432
+ "grid_width": 12,
433
+ "grid_height": 4,
434
+ },
435
+ ]
436
+
437
+ public_tag = session.get(WidgetTag, "public")
438
+ if public_tag is None:
439
+ session.add(WidgetTag(name="public"))
440
+ session.flush()
441
+
442
+ for item in defaults:
443
+ existing = session.scalar(
444
+ select(OverviewWidget).where(OverviewWidget.widget_key == item["widget_key"])
445
+ )
446
+
447
+ if existing is None:
448
+ existing = OverviewWidget(**item)
449
+ session.add(existing)
450
+ session.flush()
451
+ elif not existing.sql and existing.updated_by == "system":
452
+ existing.sql = str(item["sql"])
453
+
454
+ if (
455
+ existing is not None
456
+ and item["widget_key"] == "runtime_daily_queries"
457
+ and existing.updated_by == "system"
458
+ ):
459
+ existing.active = False
460
+
461
+ if existing is not None and existing.updated_by == "system":
462
+ existing.position = int(cast(int, item["position"]))
463
+ existing.grid_width = int(cast(int, item["grid_width"]))
464
+ existing.result_mode = str(item["result_mode"])
465
+
466
+ if session.get(
467
+ OverviewWidgetTag,
468
+ {"widget_id": existing.id, "tag_name": "public"},
469
+ ) is None:
470
+ session.add(OverviewWidgetTag(widget_id=existing.id, tag_name="public"))
471
+
472
+
473
+ def reset_metadata_store_for_tests() -> None:
474
+ global _engine, _engine_url, _initialized_url, _session_factory
475
+
476
+ if _engine is not None:
477
+ _engine.dispose()
478
+
479
+ _engine = None
480
+ _engine_url = None
481
+ _initialized_url = None
482
+ _session_factory = None