truthound-dashboard 1.0.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.
- truthound_dashboard/__init__.py +11 -0
- truthound_dashboard/__main__.py +6 -0
- truthound_dashboard/api/__init__.py +15 -0
- truthound_dashboard/api/deps.py +153 -0
- truthound_dashboard/api/drift.py +179 -0
- truthound_dashboard/api/error_handlers.py +287 -0
- truthound_dashboard/api/health.py +78 -0
- truthound_dashboard/api/history.py +62 -0
- truthound_dashboard/api/middleware.py +626 -0
- truthound_dashboard/api/notifications.py +561 -0
- truthound_dashboard/api/profile.py +52 -0
- truthound_dashboard/api/router.py +83 -0
- truthound_dashboard/api/rules.py +277 -0
- truthound_dashboard/api/schedules.py +329 -0
- truthound_dashboard/api/schemas.py +136 -0
- truthound_dashboard/api/sources.py +229 -0
- truthound_dashboard/api/validations.py +125 -0
- truthound_dashboard/cli.py +226 -0
- truthound_dashboard/config.py +132 -0
- truthound_dashboard/core/__init__.py +264 -0
- truthound_dashboard/core/base.py +185 -0
- truthound_dashboard/core/cache.py +479 -0
- truthound_dashboard/core/connections.py +331 -0
- truthound_dashboard/core/encryption.py +409 -0
- truthound_dashboard/core/exceptions.py +627 -0
- truthound_dashboard/core/logging.py +488 -0
- truthound_dashboard/core/maintenance.py +542 -0
- truthound_dashboard/core/notifications/__init__.py +56 -0
- truthound_dashboard/core/notifications/base.py +390 -0
- truthound_dashboard/core/notifications/channels.py +557 -0
- truthound_dashboard/core/notifications/dispatcher.py +453 -0
- truthound_dashboard/core/notifications/events.py +155 -0
- truthound_dashboard/core/notifications/service.py +744 -0
- truthound_dashboard/core/sampling.py +626 -0
- truthound_dashboard/core/scheduler.py +311 -0
- truthound_dashboard/core/services.py +1531 -0
- truthound_dashboard/core/truthound_adapter.py +659 -0
- truthound_dashboard/db/__init__.py +67 -0
- truthound_dashboard/db/base.py +108 -0
- truthound_dashboard/db/database.py +196 -0
- truthound_dashboard/db/models.py +732 -0
- truthound_dashboard/db/repository.py +237 -0
- truthound_dashboard/main.py +309 -0
- truthound_dashboard/schemas/__init__.py +150 -0
- truthound_dashboard/schemas/base.py +96 -0
- truthound_dashboard/schemas/drift.py +118 -0
- truthound_dashboard/schemas/history.py +74 -0
- truthound_dashboard/schemas/profile.py +91 -0
- truthound_dashboard/schemas/rule.py +199 -0
- truthound_dashboard/schemas/schedule.py +88 -0
- truthound_dashboard/schemas/schema.py +121 -0
- truthound_dashboard/schemas/source.py +138 -0
- truthound_dashboard/schemas/validation.py +192 -0
- truthound_dashboard/static/assets/index-BqJMyAHX.js +110 -0
- truthound_dashboard/static/assets/index-DMDxHCTs.js +465 -0
- truthound_dashboard/static/assets/index-Dm2D11TK.css +1 -0
- truthound_dashboard/static/index.html +15 -0
- truthound_dashboard/static/mockServiceWorker.js +349 -0
- truthound_dashboard-1.0.0.dist-info/METADATA +218 -0
- truthound_dashboard-1.0.0.dist-info/RECORD +62 -0
- truthound_dashboard-1.0.0.dist-info/WHEEL +4 -0
- truthound_dashboard-1.0.0.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Generic repository pattern for database operations.
|
|
2
|
+
|
|
3
|
+
This module provides a generic repository base class that implements
|
|
4
|
+
common CRUD operations, reducing boilerplate in API endpoints.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
class SourceRepository(BaseRepository[Source]):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
async with get_session() as session:
|
|
11
|
+
repo = SourceRepository(session)
|
|
12
|
+
source = await repo.get_by_id("uuid-here")
|
|
13
|
+
sources = await repo.list(limit=10)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from typing import Any, Generic, TypeVar
|
|
20
|
+
|
|
21
|
+
from sqlalchemy import func, select
|
|
22
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
23
|
+
from sqlalchemy.sql import Select
|
|
24
|
+
|
|
25
|
+
from .base import Base
|
|
26
|
+
|
|
27
|
+
# Type variable for model classes
|
|
28
|
+
ModelT = TypeVar("ModelT", bound=Base)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BaseRepository(Generic[ModelT]):
|
|
32
|
+
"""Generic repository providing common CRUD operations.
|
|
33
|
+
|
|
34
|
+
This class implements the repository pattern for database access,
|
|
35
|
+
providing a clean abstraction over SQLAlchemy operations.
|
|
36
|
+
|
|
37
|
+
Type Parameters:
|
|
38
|
+
ModelT: The SQLAlchemy model class this repository manages.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
session: The async database session.
|
|
42
|
+
model: The model class for this repository.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
model: type[ModelT]
|
|
46
|
+
|
|
47
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
48
|
+
"""Initialize repository with database session.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
session: Async database session for operations.
|
|
52
|
+
"""
|
|
53
|
+
self.session = session
|
|
54
|
+
|
|
55
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
56
|
+
"""Set model class from generic type argument."""
|
|
57
|
+
super().__init_subclass__(**kwargs)
|
|
58
|
+
# Get the model type from Generic parameters
|
|
59
|
+
for base in cls.__orig_bases__: # type: ignore
|
|
60
|
+
if hasattr(base, "__args__"):
|
|
61
|
+
cls.model = base.__args__[0]
|
|
62
|
+
break
|
|
63
|
+
|
|
64
|
+
async def get_by_id(self, id: str) -> ModelT | None:
|
|
65
|
+
"""Get a single record by ID.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
id: The record's primary key.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The model instance or None if not found.
|
|
72
|
+
"""
|
|
73
|
+
result = await self.session.execute(
|
|
74
|
+
select(self.model).where(self.model.id == id)
|
|
75
|
+
)
|
|
76
|
+
return result.scalar_one_or_none()
|
|
77
|
+
|
|
78
|
+
async def get_by_id_or_raise(self, id: str) -> ModelT:
|
|
79
|
+
"""Get a single record by ID, raising if not found.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
id: The record's primary key.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The model instance.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
ValueError: If record not found.
|
|
89
|
+
"""
|
|
90
|
+
record = await self.get_by_id(id)
|
|
91
|
+
if record is None:
|
|
92
|
+
raise ValueError(f"{self.model.__name__} with id '{id}' not found")
|
|
93
|
+
return record
|
|
94
|
+
|
|
95
|
+
async def list(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
offset: int = 0,
|
|
99
|
+
limit: int = 100,
|
|
100
|
+
order_by: Any = None,
|
|
101
|
+
filters: list[Any] | None = None,
|
|
102
|
+
) -> Sequence[ModelT]:
|
|
103
|
+
"""List records with pagination and filtering.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
offset: Number of records to skip.
|
|
107
|
+
limit: Maximum records to return.
|
|
108
|
+
order_by: Column(s) to order by.
|
|
109
|
+
filters: List of SQLAlchemy filter conditions.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Sequence of model instances.
|
|
113
|
+
"""
|
|
114
|
+
query = self._build_query(filters)
|
|
115
|
+
|
|
116
|
+
if order_by is not None:
|
|
117
|
+
query = query.order_by(order_by)
|
|
118
|
+
elif hasattr(self.model, "created_at"):
|
|
119
|
+
query = query.order_by(self.model.created_at.desc())
|
|
120
|
+
|
|
121
|
+
query = query.offset(offset).limit(limit)
|
|
122
|
+
result = await self.session.execute(query)
|
|
123
|
+
return result.scalars().all()
|
|
124
|
+
|
|
125
|
+
async def count(self, filters: list[Any] | None = None) -> int:
|
|
126
|
+
"""Count records matching filters.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
filters: List of SQLAlchemy filter conditions.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Total count of matching records.
|
|
133
|
+
"""
|
|
134
|
+
query = select(func.count()).select_from(self.model)
|
|
135
|
+
if filters:
|
|
136
|
+
for f in filters:
|
|
137
|
+
query = query.where(f)
|
|
138
|
+
result = await self.session.execute(query)
|
|
139
|
+
return result.scalar_one()
|
|
140
|
+
|
|
141
|
+
async def create(self, **kwargs: Any) -> ModelT:
|
|
142
|
+
"""Create a new record.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
**kwargs: Field values for the new record.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
The created model instance.
|
|
149
|
+
"""
|
|
150
|
+
instance = self.model(**kwargs)
|
|
151
|
+
self.session.add(instance)
|
|
152
|
+
await self.session.flush()
|
|
153
|
+
await self.session.refresh(instance)
|
|
154
|
+
return instance
|
|
155
|
+
|
|
156
|
+
async def update(self, id: str, **kwargs: Any) -> ModelT | None:
|
|
157
|
+
"""Update a record by ID.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
id: The record's primary key.
|
|
161
|
+
**kwargs: Field values to update.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
The updated model instance or None if not found.
|
|
165
|
+
"""
|
|
166
|
+
instance = await self.get_by_id(id)
|
|
167
|
+
if instance is None:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
for key, value in kwargs.items():
|
|
171
|
+
if hasattr(instance, key):
|
|
172
|
+
setattr(instance, key, value)
|
|
173
|
+
|
|
174
|
+
await self.session.flush()
|
|
175
|
+
await self.session.refresh(instance)
|
|
176
|
+
return instance
|
|
177
|
+
|
|
178
|
+
async def delete(self, id: str) -> bool:
|
|
179
|
+
"""Delete a record by ID.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
id: The record's primary key.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
True if deleted, False if not found.
|
|
186
|
+
"""
|
|
187
|
+
instance = await self.get_by_id(id)
|
|
188
|
+
if instance is None:
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
await self.session.delete(instance)
|
|
192
|
+
await self.session.flush()
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
async def bulk_create(self, items: list[dict[str, Any]]) -> list[ModelT]:
|
|
196
|
+
"""Create multiple records at once.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
items: List of dictionaries with field values.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
List of created model instances.
|
|
203
|
+
"""
|
|
204
|
+
instances = [self.model(**item) for item in items]
|
|
205
|
+
self.session.add_all(instances)
|
|
206
|
+
await self.session.flush()
|
|
207
|
+
for instance in instances:
|
|
208
|
+
await self.session.refresh(instance)
|
|
209
|
+
return instances
|
|
210
|
+
|
|
211
|
+
async def exists(self, id: str) -> bool:
|
|
212
|
+
"""Check if a record exists.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
id: The record's primary key.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
True if exists, False otherwise.
|
|
219
|
+
"""
|
|
220
|
+
query = select(func.count()).select_from(self.model).where(self.model.id == id)
|
|
221
|
+
result = await self.session.execute(query)
|
|
222
|
+
return result.scalar_one() > 0
|
|
223
|
+
|
|
224
|
+
def _build_query(self, filters: list[Any] | None = None) -> Select:
|
|
225
|
+
"""Build base query with filters.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
filters: List of SQLAlchemy filter conditions.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Select query with filters applied.
|
|
232
|
+
"""
|
|
233
|
+
query = select(self.model)
|
|
234
|
+
if filters:
|
|
235
|
+
for f in filters:
|
|
236
|
+
query = query.where(f)
|
|
237
|
+
return query
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""FastAPI application entry point.
|
|
2
|
+
|
|
3
|
+
This module defines the main FastAPI application with all middleware,
|
|
4
|
+
routers, and lifecycle management.
|
|
5
|
+
|
|
6
|
+
The application serves both the API and the React SPA static files.
|
|
7
|
+
|
|
8
|
+
Features:
|
|
9
|
+
- Phase 4 production-ready components:
|
|
10
|
+
- Rate limiting and security headers
|
|
11
|
+
- Structured logging
|
|
12
|
+
- Error handling with localization
|
|
13
|
+
- Database maintenance scheduling
|
|
14
|
+
- Cache management
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
# Run with uvicorn
|
|
18
|
+
uvicorn truthound_dashboard.main:app --reload
|
|
19
|
+
|
|
20
|
+
# Or via CLI
|
|
21
|
+
truthound serve --reload
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from collections.abc import AsyncGenerator
|
|
28
|
+
from contextlib import asynccontextmanager
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from fastapi import FastAPI
|
|
32
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
33
|
+
from fastapi.responses import FileResponse
|
|
34
|
+
from fastapi.staticfiles import StaticFiles
|
|
35
|
+
|
|
36
|
+
from truthound_dashboard import __version__
|
|
37
|
+
from truthound_dashboard.api.error_handlers import setup_error_handlers
|
|
38
|
+
from truthound_dashboard.api.middleware import (
|
|
39
|
+
RateLimitConfig,
|
|
40
|
+
RateLimitMiddleware,
|
|
41
|
+
RequestLoggingMiddleware,
|
|
42
|
+
SecurityHeadersMiddleware,
|
|
43
|
+
)
|
|
44
|
+
from truthound_dashboard.api.router import api_router
|
|
45
|
+
from truthound_dashboard.config import get_settings
|
|
46
|
+
from truthound_dashboard.core.cache import get_cache
|
|
47
|
+
from truthound_dashboard.core.logging import setup_logging
|
|
48
|
+
from truthound_dashboard.core.maintenance import get_maintenance_manager
|
|
49
|
+
from truthound_dashboard.core.scheduler import get_scheduler
|
|
50
|
+
from truthound_dashboard.db import init_db
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@asynccontextmanager
|
|
56
|
+
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
|
57
|
+
"""Application lifespan manager.
|
|
58
|
+
|
|
59
|
+
Handles startup and shutdown events:
|
|
60
|
+
- Startup:
|
|
61
|
+
- Configure logging
|
|
62
|
+
- Initialize database tables
|
|
63
|
+
- Start cache cleanup task
|
|
64
|
+
- Start validation scheduler
|
|
65
|
+
- Schedule maintenance tasks
|
|
66
|
+
- Shutdown:
|
|
67
|
+
- Stop scheduler
|
|
68
|
+
- Stop cache cleanup
|
|
69
|
+
- Cleanup resources
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
app: FastAPI application instance.
|
|
73
|
+
|
|
74
|
+
Yields:
|
|
75
|
+
None during application runtime.
|
|
76
|
+
"""
|
|
77
|
+
settings = get_settings()
|
|
78
|
+
|
|
79
|
+
# Configure logging
|
|
80
|
+
setup_logging(level=settings.log_level)
|
|
81
|
+
logger.info(f"Starting Truthound Dashboard v{__version__}")
|
|
82
|
+
|
|
83
|
+
# Initialize database
|
|
84
|
+
await init_db()
|
|
85
|
+
logger.info("Database initialized")
|
|
86
|
+
|
|
87
|
+
# Start cache cleanup
|
|
88
|
+
cache = get_cache()
|
|
89
|
+
await cache.start_cleanup_task()
|
|
90
|
+
logger.info("Cache cleanup task started")
|
|
91
|
+
|
|
92
|
+
# Start scheduler
|
|
93
|
+
scheduler = get_scheduler()
|
|
94
|
+
await scheduler.start()
|
|
95
|
+
logger.info("Validation scheduler started")
|
|
96
|
+
|
|
97
|
+
# Register maintenance tasks with scheduler
|
|
98
|
+
_register_maintenance_tasks()
|
|
99
|
+
|
|
100
|
+
yield
|
|
101
|
+
|
|
102
|
+
# Shutdown
|
|
103
|
+
logger.info("Shutting down Truthound Dashboard")
|
|
104
|
+
|
|
105
|
+
# Stop scheduler
|
|
106
|
+
await scheduler.stop()
|
|
107
|
+
logger.info("Scheduler stopped")
|
|
108
|
+
|
|
109
|
+
# Stop cache cleanup
|
|
110
|
+
await cache.stop_cleanup_task()
|
|
111
|
+
logger.info("Cache cleanup stopped")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _register_maintenance_tasks() -> None:
|
|
115
|
+
"""Register maintenance tasks with the scheduler.
|
|
116
|
+
|
|
117
|
+
Schedules daily cleanup at 3 AM and weekly vacuum on Sunday at 4 AM.
|
|
118
|
+
"""
|
|
119
|
+
from apscheduler.triggers.cron import CronTrigger
|
|
120
|
+
|
|
121
|
+
from truthound_dashboard.core.maintenance import (
|
|
122
|
+
cleanup_notification_logs,
|
|
123
|
+
cleanup_old_profiles,
|
|
124
|
+
cleanup_old_validations,
|
|
125
|
+
vacuum_database,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
scheduler = get_scheduler()
|
|
129
|
+
|
|
130
|
+
# Daily cleanup at 3 AM
|
|
131
|
+
scheduler._scheduler.add_job(
|
|
132
|
+
cleanup_old_validations,
|
|
133
|
+
trigger=CronTrigger.from_crontab("0 3 * * *"),
|
|
134
|
+
id="maintenance_validations",
|
|
135
|
+
replace_existing=True,
|
|
136
|
+
name="Cleanup old validations",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
scheduler._scheduler.add_job(
|
|
140
|
+
cleanup_old_profiles,
|
|
141
|
+
trigger=CronTrigger.from_crontab("0 3 * * *"),
|
|
142
|
+
id="maintenance_profiles",
|
|
143
|
+
replace_existing=True,
|
|
144
|
+
name="Cleanup old profiles",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
scheduler._scheduler.add_job(
|
|
148
|
+
cleanup_notification_logs,
|
|
149
|
+
trigger=CronTrigger.from_crontab("0 3 * * *"),
|
|
150
|
+
id="maintenance_notification_logs",
|
|
151
|
+
replace_existing=True,
|
|
152
|
+
name="Cleanup notification logs",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Weekly vacuum on Sunday at 4 AM
|
|
156
|
+
scheduler._scheduler.add_job(
|
|
157
|
+
vacuum_database,
|
|
158
|
+
trigger=CronTrigger.from_crontab("0 4 * * 0"),
|
|
159
|
+
id="maintenance_vacuum",
|
|
160
|
+
replace_existing=True,
|
|
161
|
+
name="Database vacuum",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
logger.info("Maintenance tasks registered")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def create_app() -> FastAPI:
|
|
168
|
+
"""Create and configure FastAPI application.
|
|
169
|
+
|
|
170
|
+
This factory function creates a fully configured FastAPI app
|
|
171
|
+
with all middleware, routes, and static file handling.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Configured FastAPI application.
|
|
175
|
+
"""
|
|
176
|
+
app = FastAPI(
|
|
177
|
+
title="Truthound Dashboard",
|
|
178
|
+
description="Open-source data quality dashboard - GX Cloud alternative",
|
|
179
|
+
version=__version__,
|
|
180
|
+
lifespan=lifespan,
|
|
181
|
+
docs_url="/docs",
|
|
182
|
+
redoc_url="/redoc",
|
|
183
|
+
openapi_url="/api/openapi.json",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Configure CORS (must be first middleware)
|
|
187
|
+
configure_cors(app)
|
|
188
|
+
|
|
189
|
+
# Add security middleware
|
|
190
|
+
configure_middleware(app)
|
|
191
|
+
|
|
192
|
+
# Register exception handlers
|
|
193
|
+
setup_error_handlers(app)
|
|
194
|
+
|
|
195
|
+
# Include API routes
|
|
196
|
+
app.include_router(api_router, prefix="/api/v1")
|
|
197
|
+
|
|
198
|
+
# Mount static files for React SPA
|
|
199
|
+
mount_static_files(app)
|
|
200
|
+
|
|
201
|
+
return app
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def configure_cors(app: FastAPI) -> None:
|
|
205
|
+
"""Configure CORS middleware.
|
|
206
|
+
|
|
207
|
+
Allows requests from the Vite dev server during development.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
app: FastAPI application.
|
|
211
|
+
"""
|
|
212
|
+
settings = get_settings()
|
|
213
|
+
|
|
214
|
+
origins = [
|
|
215
|
+
"http://localhost:5173", # Vite dev server
|
|
216
|
+
"http://127.0.0.1:5173",
|
|
217
|
+
f"http://localhost:{settings.port}", # Dashboard server
|
|
218
|
+
f"http://127.0.0.1:{settings.port}",
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
app.add_middleware(
|
|
222
|
+
CORSMiddleware,
|
|
223
|
+
allow_origins=origins,
|
|
224
|
+
allow_credentials=True,
|
|
225
|
+
allow_methods=["*"],
|
|
226
|
+
allow_headers=["*"],
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def configure_middleware(app: FastAPI) -> None:
|
|
231
|
+
"""Configure security and logging middleware.
|
|
232
|
+
|
|
233
|
+
Adds:
|
|
234
|
+
- Request logging
|
|
235
|
+
- Rate limiting
|
|
236
|
+
- Security headers
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
app: FastAPI application.
|
|
240
|
+
"""
|
|
241
|
+
# Request logging (last added = first executed)
|
|
242
|
+
app.add_middleware(RequestLoggingMiddleware)
|
|
243
|
+
|
|
244
|
+
# Rate limiting
|
|
245
|
+
rate_limit_config = RateLimitConfig(
|
|
246
|
+
requests_per_minute=120,
|
|
247
|
+
exclude_paths=[
|
|
248
|
+
"/health",
|
|
249
|
+
"/docs",
|
|
250
|
+
"/redoc",
|
|
251
|
+
"/openapi.json",
|
|
252
|
+
"/api/openapi.json",
|
|
253
|
+
],
|
|
254
|
+
)
|
|
255
|
+
app.add_middleware(RateLimitMiddleware, config=rate_limit_config)
|
|
256
|
+
|
|
257
|
+
# Security headers
|
|
258
|
+
app.add_middleware(SecurityHeadersMiddleware)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def mount_static_files(app: FastAPI) -> None:
|
|
262
|
+
"""Mount static files for React SPA.
|
|
263
|
+
|
|
264
|
+
Serves the built React application from the static directory.
|
|
265
|
+
Falls back to index.html for SPA routing.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
app: FastAPI application.
|
|
269
|
+
"""
|
|
270
|
+
static_dir = Path(__file__).parent / "static"
|
|
271
|
+
|
|
272
|
+
if not static_dir.exists():
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
index_file = static_dir / "index.html"
|
|
276
|
+
if not index_file.exists():
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
# Mount assets directory
|
|
280
|
+
assets_dir = static_dir / "assets"
|
|
281
|
+
if assets_dir.exists():
|
|
282
|
+
app.mount(
|
|
283
|
+
"/assets",
|
|
284
|
+
StaticFiles(directory=assets_dir),
|
|
285
|
+
name="assets",
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# SPA fallback route - must be last
|
|
289
|
+
@app.get("/{full_path:path}", include_in_schema=False)
|
|
290
|
+
async def serve_spa(full_path: str) -> FileResponse:
|
|
291
|
+
"""Serve SPA for all non-API routes.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
full_path: Requested path.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Static file or index.html for SPA routing.
|
|
298
|
+
"""
|
|
299
|
+
# Check if it's a static file
|
|
300
|
+
file_path = static_dir / full_path
|
|
301
|
+
if file_path.exists() and file_path.is_file():
|
|
302
|
+
return FileResponse(file_path)
|
|
303
|
+
|
|
304
|
+
# Fall back to index.html for SPA routing
|
|
305
|
+
return FileResponse(index_file)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# Create app instance
|
|
309
|
+
app = create_app()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Pydantic schemas for API request/response validation.
|
|
2
|
+
|
|
3
|
+
This module exports all schemas used in the API layer.
|
|
4
|
+
Schemas follow consistent naming conventions:
|
|
5
|
+
- *Base: Common fields
|
|
6
|
+
- *Create: POST request bodies
|
|
7
|
+
- *Update: PUT/PATCH request bodies
|
|
8
|
+
- *Response: Response bodies
|
|
9
|
+
- *ListResponse: Paginated list responses
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .base import (
|
|
13
|
+
BaseSchema,
|
|
14
|
+
ErrorResponse,
|
|
15
|
+
IDMixin,
|
|
16
|
+
ListResponseWrapper,
|
|
17
|
+
MessageResponse,
|
|
18
|
+
ResponseWrapper,
|
|
19
|
+
TimestampMixin,
|
|
20
|
+
)
|
|
21
|
+
from .drift import (
|
|
22
|
+
ColumnDriftResult,
|
|
23
|
+
DriftCompareRequest,
|
|
24
|
+
DriftComparisonListItem,
|
|
25
|
+
DriftComparisonListResponse,
|
|
26
|
+
DriftComparisonResponse,
|
|
27
|
+
DriftResult,
|
|
28
|
+
DriftSourceSummary,
|
|
29
|
+
)
|
|
30
|
+
from .history import (
|
|
31
|
+
FailureFrequencyItem,
|
|
32
|
+
HistoryQueryParams,
|
|
33
|
+
HistoryResponse,
|
|
34
|
+
HistorySummary,
|
|
35
|
+
RecentValidation,
|
|
36
|
+
TrendDataPoint,
|
|
37
|
+
)
|
|
38
|
+
from .profile import ColumnProfile, ProfileResponse
|
|
39
|
+
from .rule import (
|
|
40
|
+
RuleBase,
|
|
41
|
+
RuleCreate,
|
|
42
|
+
RuleListItem,
|
|
43
|
+
RuleListResponse,
|
|
44
|
+
RuleResponse,
|
|
45
|
+
RuleSummary,
|
|
46
|
+
RuleUpdate,
|
|
47
|
+
)
|
|
48
|
+
from .schedule import (
|
|
49
|
+
ScheduleActionResponse,
|
|
50
|
+
ScheduleBase,
|
|
51
|
+
ScheduleCreate,
|
|
52
|
+
ScheduleListItem,
|
|
53
|
+
ScheduleListResponse,
|
|
54
|
+
ScheduleResponse,
|
|
55
|
+
ScheduleUpdate,
|
|
56
|
+
)
|
|
57
|
+
from .schema import (
|
|
58
|
+
ColumnSchema,
|
|
59
|
+
SchemaLearnRequest,
|
|
60
|
+
SchemaResponse,
|
|
61
|
+
SchemaSummary,
|
|
62
|
+
SchemaUpdateRequest,
|
|
63
|
+
)
|
|
64
|
+
from .source import (
|
|
65
|
+
SourceBase,
|
|
66
|
+
SourceCreate,
|
|
67
|
+
SourceListResponse,
|
|
68
|
+
SourceResponse,
|
|
69
|
+
SourceSummary,
|
|
70
|
+
SourceType,
|
|
71
|
+
SourceUpdate,
|
|
72
|
+
)
|
|
73
|
+
from .validation import (
|
|
74
|
+
IssueSeverity,
|
|
75
|
+
ValidationIssue,
|
|
76
|
+
ValidationListItem,
|
|
77
|
+
ValidationListResponse,
|
|
78
|
+
ValidationResponse,
|
|
79
|
+
ValidationRunRequest,
|
|
80
|
+
ValidationStatus,
|
|
81
|
+
ValidationSummary,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
__all__ = [
|
|
85
|
+
# Base
|
|
86
|
+
"BaseSchema",
|
|
87
|
+
"IDMixin",
|
|
88
|
+
"TimestampMixin",
|
|
89
|
+
"ResponseWrapper",
|
|
90
|
+
"ListResponseWrapper",
|
|
91
|
+
"ErrorResponse",
|
|
92
|
+
"MessageResponse",
|
|
93
|
+
# Source
|
|
94
|
+
"SourceType",
|
|
95
|
+
"SourceBase",
|
|
96
|
+
"SourceCreate",
|
|
97
|
+
"SourceUpdate",
|
|
98
|
+
"SourceResponse",
|
|
99
|
+
"SourceListResponse",
|
|
100
|
+
"SourceSummary",
|
|
101
|
+
# Rule
|
|
102
|
+
"RuleBase",
|
|
103
|
+
"RuleCreate",
|
|
104
|
+
"RuleUpdate",
|
|
105
|
+
"RuleResponse",
|
|
106
|
+
"RuleListItem",
|
|
107
|
+
"RuleListResponse",
|
|
108
|
+
"RuleSummary",
|
|
109
|
+
# Validation
|
|
110
|
+
"ValidationStatus",
|
|
111
|
+
"IssueSeverity",
|
|
112
|
+
"ValidationIssue",
|
|
113
|
+
"ValidationRunRequest",
|
|
114
|
+
"ValidationSummary",
|
|
115
|
+
"ValidationResponse",
|
|
116
|
+
"ValidationListItem",
|
|
117
|
+
"ValidationListResponse",
|
|
118
|
+
# Schema
|
|
119
|
+
"ColumnSchema",
|
|
120
|
+
"SchemaLearnRequest",
|
|
121
|
+
"SchemaUpdateRequest",
|
|
122
|
+
"SchemaResponse",
|
|
123
|
+
"SchemaSummary",
|
|
124
|
+
# Profile
|
|
125
|
+
"ColumnProfile",
|
|
126
|
+
"ProfileResponse",
|
|
127
|
+
# History
|
|
128
|
+
"TrendDataPoint",
|
|
129
|
+
"FailureFrequencyItem",
|
|
130
|
+
"RecentValidation",
|
|
131
|
+
"HistorySummary",
|
|
132
|
+
"HistoryResponse",
|
|
133
|
+
"HistoryQueryParams",
|
|
134
|
+
# Drift
|
|
135
|
+
"DriftCompareRequest",
|
|
136
|
+
"ColumnDriftResult",
|
|
137
|
+
"DriftResult",
|
|
138
|
+
"DriftSourceSummary",
|
|
139
|
+
"DriftComparisonResponse",
|
|
140
|
+
"DriftComparisonListItem",
|
|
141
|
+
"DriftComparisonListResponse",
|
|
142
|
+
# Schedule
|
|
143
|
+
"ScheduleBase",
|
|
144
|
+
"ScheduleCreate",
|
|
145
|
+
"ScheduleUpdate",
|
|
146
|
+
"ScheduleResponse",
|
|
147
|
+
"ScheduleListItem",
|
|
148
|
+
"ScheduleListResponse",
|
|
149
|
+
"ScheduleActionResponse",
|
|
150
|
+
]
|