From d87622b744546f0e95f4bc788704ad8fbc5c0906 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Wed, 18 Mar 2026 17:56:44 -0300 Subject: [PATCH 001/123] feat(api): OAuth 2.1 server with unified API+MCP process Add RFC 8414-compliant OAuth 2.1 authorization server that runs alongside the existing MCP server in a single Uvicorn process. The API is mounted at /api and the MCP SSE transport at /mcp, sharing a common FastAPI app. OAuth implementation: - Authorization code flow with PKCE (S256) - Server-side login form (no redirect to external IdP) - JWT access tokens signed with the existing TG_JWT_HASHING_KEY - Token refresh, revocation (RFC 7009), and introspection (RFC 7662) - Discovery document at /.well-known/oauth-authorization-server Unified server architecture: - `testgen run-app` starts UI (Streamlit), scheduler, and the combined API+MCP server as a single subprocess - MCP tools and OAuth routes share authentication via common deps module - TG_BASE_URL env var controls the externally-reachable API/MCP origin Supporting changes: - TG_UI_BASE_URL env var replaces the old persisted BASE_URL setting for notification email and PDF report links - Add TG_UI_BASE_URL to deploy docker-compose examples and Helm chart - Document TG_UI_BASE_URL in configuration.md Co-Authored-By: Claude Opus 4.6 (1M context) --- .../testgen-app/templates/_environment.yaml | 4 + deploy/charts/testgen-app/values.yaml | 1 + docs/configuration.md | 12 + testgen/__main__.py | 40 +- testgen/api/__init__.py | 0 testgen/api/app.py | 28 + testgen/api/deps.py | 53 ++ testgen/api/oauth/__init__.py | 0 testgen/api/oauth/login.py | 154 ++++++ testgen/api/oauth/metadata.py | 38 ++ testgen/api/oauth/models.py | 44 ++ testgen/api/oauth/routes.py | 291 +++++++++++ testgen/api/oauth/server.py | 187 +++++++ testgen/common/auth.py | 29 +- testgen/common/notifications/monitor_run.py | 4 +- testgen/common/notifications/profiling_run.py | 6 +- testgen/common/notifications/score_drop.py | 4 +- testgen/common/notifications/test_run.py | 4 +- testgen/mcp/__init__.py | 10 +- testgen/mcp/auth.py | 2 +- testgen/mcp/permissions.py | 24 +- testgen/mcp/server.py | 66 +-- testgen/server/__init__.py | 103 ++++ testgen/settings.py | 75 ++- .../030_initialize_new_schema_structure.sql | 42 ++ .../dbsetup/075_grant_role_rights.sql | 5 +- .../dbupgrade/0178_incremental_upgrade.sql | 46 ++ testgen/ui/navigation/router.py | 8 - testgen/ui/pdf/hygiene_issue_report.py | 4 +- testgen/ui/pdf/test_result_report.py | 4 +- tests/unit/api/__init__.py | 0 tests/unit/api/oauth/__init__.py | 0 tests/unit/api/oauth/conftest.py | 6 + tests/unit/api/oauth/test_login.py | 68 +++ tests/unit/api/oauth/test_metadata.py | 55 ++ tests/unit/api/oauth/test_models.py | 33 ++ tests/unit/api/oauth/test_routes.py | 493 ++++++++++++++++++ tests/unit/api/oauth/test_server.py | 464 +++++++++++++++++ tests/unit/api/test_deps.py | 162 ++++++ .../test_monitor_run_notifications.py | 5 +- tests/unit/common/test_auth.py | 62 ++- tests/unit/conftest.py | 8 +- tests/unit/mcp/conftest.py | 8 +- tests/unit/mcp/test_auth.py | 12 +- tests/unit/mcp/test_permissions.py | 48 +- tests/unit/server/__init__.py | 0 tests/unit/server/test_server.py | 91 ++++ 47 files changed, 2631 insertions(+), 172 deletions(-) create mode 100644 testgen/api/__init__.py create mode 100644 testgen/api/app.py create mode 100644 testgen/api/deps.py create mode 100644 testgen/api/oauth/__init__.py create mode 100644 testgen/api/oauth/login.py create mode 100644 testgen/api/oauth/metadata.py create mode 100644 testgen/api/oauth/models.py create mode 100644 testgen/api/oauth/routes.py create mode 100644 testgen/api/oauth/server.py create mode 100644 testgen/server/__init__.py create mode 100644 testgen/template/dbupgrade/0178_incremental_upgrade.sql create mode 100644 tests/unit/api/__init__.py create mode 100644 tests/unit/api/oauth/__init__.py create mode 100644 tests/unit/api/oauth/conftest.py create mode 100644 tests/unit/api/oauth/test_login.py create mode 100644 tests/unit/api/oauth/test_metadata.py create mode 100644 tests/unit/api/oauth/test_models.py create mode 100644 tests/unit/api/oauth/test_routes.py create mode 100644 tests/unit/api/oauth/test_server.py create mode 100644 tests/unit/api/test_deps.py create mode 100644 tests/unit/server/__init__.py create mode 100644 tests/unit/server/test_server.py diff --git a/deploy/charts/testgen-app/templates/_environment.yaml b/deploy/charts/testgen-app/templates/_environment.yaml index 5bf01711..489dc23b 100644 --- a/deploy/charts/testgen-app/templates/_environment.yaml +++ b/deploy/charts/testgen-app/templates/_environment.yaml @@ -43,6 +43,10 @@ - name: TG_EMAIL_FROM_ADDRESS value: {{ .fromAddress | quote }} {{- end -}} +{{- if .Values.testgen.uiBaseUrl }} +- name: TG_UI_BASE_URL + value: {{ .Values.testgen.uiBaseUrl | quote }} +{{- end }} {{- end -}} {{- define "testgen.hookEnvironment" -}} diff --git a/deploy/charts/testgen-app/values.yaml b/deploy/charts/testgen-app/values.yaml index 8018a4cc..51b364d5 100644 --- a/deploy/charts/testgen-app/values.yaml +++ b/deploy/charts/testgen-app/values.yaml @@ -22,6 +22,7 @@ testgen: port: username: password: + uiBaseUrl: labels: cliHooks: diff --git a/docs/configuration.md b/docs/configuration.md index 1c3c9177..3febfa03 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -282,3 +282,15 @@ default: `dataset` When exporting to your instance of Observabilty, the key sent to the events API to identify the components. default: `default` + +### URL Configuration + +#### `TG_UI_BASE_URL` + +Externally-reachable base URL for the TestGen web UI. Used in email notification links and PDF report links so recipients can click through to the correct address. + +Must be set in production when TestGen is behind a reverse proxy or load balancer. If not set, defaults to `http://localhost:`. + +Example: `https://testgen.example.com` + +default: `http://localhost:8501` diff --git a/testgen/__main__.py b/testgen/__main__.py index 29c6d3b0..539cd01a 100644 --- a/testgen/__main__.py +++ b/testgen/__main__.py @@ -55,9 +55,7 @@ LOG = logging.getLogger("testgen") -APP_MODULES = ["ui", "scheduler"] -if settings.MCP_ENABLED: - APP_MODULES.append("mcp") +APP_MODULES = ["ui", "scheduler", "server"] VERSION_DATA = version_service.get_version() CHILDREN_POLL_INTERVAL = 10 @@ -708,7 +706,7 @@ def list_ui_plugins(): def run_ui(): from testgen.ui.scripts import patch_streamlit - use_ssl = os.path.isfile(settings.SSL_CERT_FILE) and os.path.isfile(settings.SSL_KEY_FILE) + use_ssl = settings.UI_TLS_ENABLED if settings.IS_DEBUG: patch_streamlit.patch(dev=True) @@ -768,9 +766,9 @@ def run_app(module): case "scheduler": run_scheduler() - case "mcp": - from testgen.mcp.server import run_mcp - run_mcp() + case "server": + from testgen.server import run_server + run_server() case "all": children = [ @@ -801,33 +799,5 @@ def term_children(signum, _): -@cli.command("mcp-token", help="Generate a JWT token for MCP server authentication.") -@click.option("--username", required=True, help="TestGen username") -@click.option("--password", required=True, hide_input=True, help="TestGen password") -@with_database_session -def mcp_token(username: str, password: str): - from testgen.mcp import get_server_url - from testgen.mcp.auth import authenticate_user - try: - token = authenticate_user(username, password) - except ValueError as e: - click.secho(str(e), fg="red") - sys.exit(1) - - mcp_url = f"{get_server_url()}/mcp" - - click.echo() - click.echo(token) - click.echo() - click.secho("MCP server URL:", bold=True) - click.echo(f" {mcp_url}") - click.echo() - click.secho("Pass the token as a Bearer header when connecting from any MCP client.", dim=True) - click.echo() - click.secho("Example — Claude Code:", bold=True) - click.echo(f' claude mcp add --transport http testgen {mcp_url} --header "Authorization: Bearer {token}"') - click.echo() - - if __name__ == "__main__": cli() diff --git a/testgen/api/__init__.py b/testgen/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testgen/api/app.py b/testgen/api/app.py new file mode 100644 index 00000000..31186dfb --- /dev/null +++ b/testgen/api/app.py @@ -0,0 +1,28 @@ +"""TestGen API endpoints — health, ping.""" + +from fastapi import APIRouter, Depends + +from testgen.api.deps import db_session, get_authorized_user +from testgen.common import version_service + +router = APIRouter(prefix="/api/v1", tags=["api"], dependencies=[Depends(db_session)]) + +_require_user = Depends(get_authorized_user) + + +@router.get("/health") +def health(): + version = version_service.get_version() + return { + "status": "ok", + "edition": version.edition, + "version": version.current, + } + + +@router.get("/ping") +def ping(user=_require_user): + return { + "status": "ok", + "username": user.username, + } diff --git a/testgen/api/deps.py b/testgen/api/deps.py new file mode 100644 index 00000000..224d85e2 --- /dev/null +++ b/testgen/api/deps.py @@ -0,0 +1,53 @@ +"""FastAPI dependencies for API endpoints.""" + +from fastapi import HTTPException, Security, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from testgen.common.auth import authorize_token, decode_jwt_token +from testgen.common.models import Session, _current_session_wrapper, get_current_session +from testgen.common.models.user import User + + +def db_session(): + """One DB session per request. Commits on success, rolls back on exception.""" + with Session() as session: + _current_session_wrapper.value = session + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + _current_session_wrapper.value = None + + +_bearer_scheme = HTTPBearer() +_bearer_security = Security(_bearer_scheme) + + +def get_authorized_user(credentials: HTTPAuthorizationCredentials = _bearer_security) -> User: + """Validate a Bearer token and return the authenticated User. + + Checks JWT validity, user existence, and token revocation status. + """ + _invalid = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = decode_jwt_token(credentials.credentials) + except ValueError: + raise _invalid from None + + username = payload.get("username") + if not username: + raise _invalid + + session = get_current_session() + try: + return authorize_token(credentials.credentials, username, session) + except ValueError: + raise _invalid from None diff --git a/testgen/api/oauth/__init__.py b/testgen/api/oauth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testgen/api/oauth/login.py b/testgen/api/oauth/login.py new file mode 100644 index 00000000..b45cee90 --- /dev/null +++ b/testgen/api/oauth/login.py @@ -0,0 +1,154 @@ +"""HTML login page for the OAuth authorization code flow. + +Served when an MCP client (or any OAuth client) redirects the user to +/oauth/authorize. The user enters their TestGen credentials, which are +posted back to /oauth/authorize to complete the flow. +""" + +from html import escape + + +def render_login_page( + client_id: str, + redirect_uri: str, + response_type: str, + scope: str, + state: str, + code_challenge: str, + code_challenge_method: str, + error: str = "", + client_name: str = "", +) -> str: + error_html = ( + f'
{escape(error)}
' if error else "" + ) + client_display = escape(client_name) if client_name else escape(client_id) + authorize_label = f"Authorize {client_display} to access TestGen on your behalf" + + return f"""\ + + + + + + TestGen — Sign In + + + +
+ +

{authorize_label}

+ {error_html} +
+ + + + + + + + + + + + +
+
+ +""" diff --git a/testgen/api/oauth/metadata.py b/testgen/api/oauth/metadata.py new file mode 100644 index 00000000..ed506bad --- /dev/null +++ b/testgen/api/oauth/metadata.py @@ -0,0 +1,38 @@ +"""RFC 8414 — OAuth 2.0 Authorization Server Metadata.""" + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from testgen import settings + +router = APIRouter() + + +@router.get("/.well-known/oauth-authorization-server") +def authorization_server_metadata(): + """Return OAuth 2.0 Authorization Server Metadata per RFC 8414. + + MCP clients use this for server discovery. + """ + base_url = settings.BASE_URL.rstrip("/") + + return JSONResponse(content={ + "issuer": base_url, + "authorization_endpoint": f"{base_url}/oauth/authorize", + "token_endpoint": f"{base_url}/oauth/token", + "revocation_endpoint": f"{base_url}/oauth/revoke", + "registration_endpoint": f"{base_url}/oauth/register", + "end_session_endpoint": f"{base_url}/oauth/logout", + "response_types_supported": ["code"], + "grant_types_supported": [ + "authorization_code", + "client_credentials", + "refresh_token", + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post", + "none", + ], + "code_challenge_methods_supported": ["S256"], + }) diff --git a/testgen/api/oauth/models.py b/testgen/api/oauth/models.py new file mode 100644 index 00000000..88964a6e --- /dev/null +++ b/testgen/api/oauth/models.py @@ -0,0 +1,44 @@ +import time + +from authlib.integrations.sqla_oauth2 import ( + OAuth2AuthorizationCodeMixin, + OAuth2ClientMixin, + OAuth2TokenMixin, +) +from sqlalchemy import Column, ForeignKey, String +from sqlalchemy.dialects import postgresql + +from testgen.common.models import Base + + +class OAuth2Client(Base, OAuth2ClientMixin): + __tablename__ = "oauth2_clients" + + id = Column(postgresql.UUID(as_uuid=True), primary_key=True, server_default="gen_random_uuid()") + user_id = Column(postgresql.UUID(as_uuid=True), ForeignKey("auth_users.id", ondelete="SET NULL"), nullable=True) + + # Override to widen — JWTs can exceed 255 chars + # (the mixin defines client_id as VARCHAR(48) which is fine) + + +class OAuth2AuthorizationCode(Base, OAuth2AuthorizationCodeMixin): + __tablename__ = "oauth2_authorization_codes" + + id = Column(postgresql.UUID(as_uuid=True), primary_key=True, server_default="gen_random_uuid()") + user_id = Column(postgresql.UUID(as_uuid=True), ForeignKey("auth_users.id", ondelete="CASCADE"), nullable=False) + + +class OAuth2Token(Base, OAuth2TokenMixin): + __tablename__ = "oauth2_tokens" + + id = Column(postgresql.UUID(as_uuid=True), primary_key=True, server_default="gen_random_uuid()") + user_id = Column(postgresql.UUID(as_uuid=True), ForeignKey("auth_users.id", ondelete="CASCADE"), nullable=True) + + # Override to allow longer JWTs as access tokens + access_token = Column(String(2048), unique=True, nullable=False) + + def is_refresh_token_active(self) -> bool: + if self.is_revoked(): + return False + expires_at = self.issued_at + self.expires_in * 2 + return expires_at >= time.time() diff --git a/testgen/api/oauth/routes.py b/testgen/api/oauth/routes.py new file mode 100644 index 00000000..ab995a9f --- /dev/null +++ b/testgen/api/oauth/routes.py @@ -0,0 +1,291 @@ +"""OAuth 2.1 endpoints: authorize, token, revoke, register. + +Route handlers are sync. The router-level ``db_session`` dependency establishes +a session-per-request with automatic commit/rollback. Body extraction (form/JSON) +is handled by async FastAPI dependencies that resolve before the sync handler +is called in the threadpool. +""" + +import json +import logging +import time +from urllib.parse import urlparse +from uuid import uuid4 + +from authlib.oauth2.rfc6749 import OAuth2Request +from authlib.oauth2.rfc6749.requests import BasicOAuth2Payload +from fastapi import APIRouter, Depends, Form, Query, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse + +from testgen import settings +from testgen.api.deps import db_session +from testgen.api.oauth.login import render_login_page +from testgen.api.oauth.models import OAuth2Client +from testgen.api.oauth.server import TestGenAuthorizationServer +from testgen.common.auth import create_jwt_token, decode_jwt_token, verify_password +from testgen.common.models.user import User + +LOG = logging.getLogger("testgen") + +OAUTH_SESSION_COOKIE = "dk_oauth_session" + +router = APIRouter(prefix="/oauth", tags=["oauth"], dependencies=[Depends(db_session)]) + +_server: TestGenAuthorizationServer | None = None + + +def init_routes(server: TestGenAuthorizationServer) -> None: + global _server + _server = server + + +async def _form_body(request: Request) -> dict: + """Async dependency: extract form body as dict before the sync handler runs.""" + return dict(await request.form()) + + +async def _json_body(request: Request) -> dict: + """Async dependency: extract JSON body as dict before the sync handler runs.""" + try: + return await request.json() + except Exception: + return {} + + +def _build_oauth2_request(request: Request, body: dict | None = None) -> OAuth2Request: + # Starlette lowercases header names, but authlib expects title-case (e.g. "Authorization"). + # Re-title-case keys so authlib's header lookups work. + headers = {k.title(): v for k, v in request.headers.items()} + # Pass body to constructor so request.form works (authlib grant types still use it internally), + # and also set payload for the newer authlib API. + oauth2_req = OAuth2Request( + method=request.method, + uri=str(request.url), + body=body or {}, + headers=headers, + ) + oauth2_req.payload = BasicOAuth2Payload(body or {}) + return oauth2_req + + +def _get_existing_user(request: Request) -> User | None: + """Check for an existing session cookie and return the User if valid.""" + token = request.cookies.get(OAUTH_SESSION_COOKIE) + if not token: + return None + try: + payload = decode_jwt_token(token) + return User.get(payload["username"]) + except Exception: + return None + + +def _get_client_name(client_id: str) -> str: + """Look up the OAuth client's display name from its metadata.""" + from testgen.common.models import get_current_session + + session = get_current_session() + client = session.query(OAuth2Client).filter_by(client_id=client_id).first() + if client: + metadata = client.client_metadata if hasattr(client, "client_metadata") else None + if isinstance(metadata, dict): + return metadata.get("client_name", "") + try: + return json.loads(client._client_metadata).get("client_name", "") + except Exception: # noqa: S110 + pass + return "" + + +def _issue_auth_code(request: Request, user: User, body: dict) -> RedirectResponse: + """Build an OAuth2 authorization response and return the redirect with a session cookie.""" + oauth2_request = _build_oauth2_request(request, body) + oauth2_request.user = user + + status, payload, headers = _server.create_authorization_response(oauth2_request, grant_user=user) + headers = dict(headers) # authlib returns list-of-tuples + if status == 302: + response = RedirectResponse(url=headers["Location"], status_code=302) + else: + return JSONResponse(content=payload, status_code=status, headers=headers) + + jwt_token = create_jwt_token(user.username, expiry_seconds=86400) + response.set_cookie( + key=OAUTH_SESSION_COOKIE, + value=jwt_token, + max_age=86400, + httponly=True, + samesite="lax", + secure=settings.BASE_URL.startswith("https"), + path="/", + ) + return response + + +def _security_headers() -> dict[str, str]: + return { + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'self'; style-src 'unsafe-inline'", + } + + +@router.get("/authorize") +def authorize_get( + request: Request, + response_type: str = Query(...), + client_id: str = Query(...), + redirect_uri: str = Query(None), + scope: str = Query(""), + state: str = Query(None), + code_challenge: str = Query(None), + code_challenge_method: str = Query("S256"), +): + """Show login form for authorization code flow, or skip if already logged in.""" + body = { + "response_type": response_type, + "client_id": client_id, + "redirect_uri": redirect_uri or "", + "scope": scope, + "state": state or "", + "code_challenge": code_challenge or "", + "code_challenge_method": code_challenge_method, + } + + existing_user = _get_existing_user(request) + if existing_user: + return _issue_auth_code(request, existing_user, body) + + client_name = _get_client_name(client_id) + + return HTMLResponse( + render_login_page( + client_id=client_id, + redirect_uri=redirect_uri or "", + response_type=response_type, + scope=scope, + state=state or "", + code_challenge=code_challenge or "", + code_challenge_method=code_challenge_method, + client_name=client_name, + ), + headers=_security_headers(), + ) + + +@router.post("/authorize") +def authorize_post( + request: Request, + username: str = Form(...), + password: str = Form(...), + client_id: str = Form(...), + redirect_uri: str = Form(""), + response_type: str = Form("code"), + scope: str = Form(""), + state: str = Form(""), + code_challenge: str = Form(""), + code_challenge_method: str = Form("S256"), +): + """Authenticate user and issue authorization code.""" + user = User.get(username) + if not user or not verify_password(password, user.password): + client_name = _get_client_name(client_id) + return HTMLResponse( + render_login_page( + client_id=client_id, + redirect_uri=redirect_uri, + response_type=response_type, + scope=scope, + state=state, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + error="Invalid username or password", + client_name=client_name, + ), + status_code=401, + headers=_security_headers(), + ) + + body = { + "response_type": response_type, + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": scope, + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": code_challenge_method, + } + return _issue_auth_code(request, user, body) + + +@router.get("/logout") +def logout(request: Request, redirect_uri: str = Query("/")): + """Clear the OAuth session cookie and redirect. + + Enforces same-origin on redirect_uri to prevent open redirect attacks. + """ + parsed = urlparse(redirect_uri) + if parsed.netloc and parsed.netloc != request.url.netloc: + redirect_uri = "/" + response = RedirectResponse(url=redirect_uri, status_code=302) + response.delete_cookie(key=OAUTH_SESSION_COOKIE, path="/") + return response + + +@router.post("/token") +def token(request: Request, body: dict = Depends(_form_body)): # noqa: B008 + """Exchange credentials or authorization code for an access token.""" + oauth2_request = _build_oauth2_request(request, body) + status, payload, headers = _server.create_token_response(oauth2_request) + return JSONResponse(content=payload, status_code=status, headers=dict(headers)) + + +@router.post("/revoke") +def revoke(request: Request, body: dict = Depends(_form_body)): # noqa: B008 + """Revoke an access or refresh token.""" + oauth2_request = _build_oauth2_request(request, body) + status, payload, headers = _server.create_endpoint_response("revocation", oauth2_request) + return JSONResponse(content=payload or {}, status_code=status, headers=dict(headers)) + + +@router.post("/register") +def register_client(body: dict = Depends(_json_body)): # noqa: B008 + """Dynamic client registration (RFC 7591). + + Accepts JSON body with optional client_name, redirect_uris, grant_types, scope. + Returns client_id and client_secret for the registered client. + """ + from testgen.common.models import get_current_session + + client_id = uuid4().hex[:24] + client_secret = uuid4().hex + + metadata = { + "client_name": body.get("client_name", ""), + "grant_types": body.get("grant_types", ["authorization_code", "client_credentials", "refresh_token"]), + "redirect_uris": body.get("redirect_uris", []), + "response_types": ["code"], + "scope": body.get("scope", ""), + "token_endpoint_auth_method": "client_secret_basic", + } + + client = OAuth2Client( + client_id=client_id, + client_secret=client_secret, + client_id_issued_at=int(time.time()), + ) + client.set_client_metadata(metadata) + + session = get_current_session() + session.add(client) + + return JSONResponse( + content={ + "client_id": client_id, + "client_secret": client_secret, + "client_id_issued_at": client.client_id_issued_at, + "client_secret_expires_at": 0, + **json.loads(client._client_metadata), + }, + status_code=201, + ) diff --git a/testgen/api/oauth/server.py b/testgen/api/oauth/server.py new file mode 100644 index 00000000..c9025c55 --- /dev/null +++ b/testgen/api/oauth/server.py @@ -0,0 +1,187 @@ +"""OAuth 2.1 Authorization Server built on authlib. + +Grant types: +- Authorization Code + PKCE (for MCP clients) +- Client Credentials (for automation scripts) +- Refresh Token (for token renewal) + +All DB operations use get_current_session() for thread-local session access. +""" + +import secrets +import time +from typing import ClassVar + +from authlib.oauth2.rfc6749 import AuthorizationServer, JsonRequest, OAuth2Request, grants +from authlib.oauth2.rfc6749.errors import InvalidGrantError +from authlib.oauth2.rfc7009 import RevocationEndpoint +from authlib.oauth2.rfc7636 import CodeChallenge + +from testgen.api.oauth.models import OAuth2AuthorizationCode, OAuth2Client, OAuth2Token +from testgen.common.auth import create_jwt_token +from testgen.common.models import get_current_session +from testgen.common.models.user import User + +ACCESS_TOKEN_EXPIRES_IN = 3600 # 1 hour + + +class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): + TOKEN_ENDPOINT_AUTH_METHODS: ClassVar[list[str]] = ["client_secret_basic", "client_secret_post", "none"] + + def save_authorization_code(self, code, request): + auth_code = OAuth2AuthorizationCode( + code=code, + client_id=request.client.client_id, + redirect_uri=request.redirect_uri, + scope=request.scope, + user_id=request.user.id, + code_challenge=request.data.get("code_challenge"), + code_challenge_method=request.data.get("code_challenge_method"), + ) + session = get_current_session() + session.add(auth_code) + return auth_code + + def query_authorization_code(self, code, client): + session = get_current_session() + item = session.query(OAuth2AuthorizationCode).filter_by( + code=code, client_id=client.client_id, + ).first() + if item and not item.is_expired(): + return item + return None + + def delete_authorization_code(self, authorization_code): + session = get_current_session() + session.delete(authorization_code) + + def authenticate_user(self, authorization_code): + session = get_current_session() + return session.query(User).filter(User.id == authorization_code.user_id).first() + + +class RefreshTokenGrant(grants.RefreshTokenGrant): + INCLUDE_NEW_REFRESH_TOKEN = True + + def authenticate_refresh_token(self, refresh_token): + session = get_current_session() + item = session.query(OAuth2Token).filter_by( + refresh_token=refresh_token, + ).first() + if item and not item.is_revoked(): + return item + return None + + def authenticate_user(self, credential): + session = get_current_session() + return session.query(User).filter(User.id == credential.user_id).first() + + def revoke_old_credential(self, credential): + now = int(time.time()) + credential.access_token_revoked_at = now + credential.refresh_token_revoked_at = now + + +class ClientCredentialsGrant(grants.ClientCredentialsGrant): + """Client credentials grant that resolves the client's owner as the token user. + + Ensures every token has a real User identity — no "ghost" usernames. + """ + + def validate_token_request(self): + super().validate_token_request() + client = self.request.client + if not client.user_id: + raise InvalidGrantError(description="Client has no registered owner.") + session = get_current_session() + owner = session.query(User).filter(User.id == client.user_id).first() + if owner is None: + raise InvalidGrantError(description="Client owner no longer exists.") + self.request.user = owner + + +class TestGenRevocationEndpoint(RevocationEndpoint): + def query_token(self, token_string, token_type_hint): + session = get_current_session() + if token_type_hint == "access_token": # noqa: S105 + return session.query(OAuth2Token).filter_by(access_token=token_string).first() + if token_type_hint == "refresh_token": # noqa: S105 + return session.query(OAuth2Token).filter_by(refresh_token=token_string).first() + return ( + session.query(OAuth2Token).filter_by(access_token=token_string).first() + or session.query(OAuth2Token).filter_by(refresh_token=token_string).first() + ) + + def revoke_token(self, token, request): + now = int(time.time()) + hint = request.form.get("token_type_hint") + if hint == "access_token": + token.access_token_revoked_at = now + else: + token.access_token_revoked_at = now + token.refresh_token_revoked_at = now + + +class TestGenAuthorizationServer(AuthorizationServer): + """OAuth 2.1 Authorization Server using TestGen's DB session management.""" + + def query_client(self, client_id): + session = get_current_session() + return session.query(OAuth2Client).filter_by(client_id=client_id).first() + + def save_token(self, token, request): + user_id = request.user.id if request.user else None + item = OAuth2Token( + client_id=request.client.client_id, + user_id=user_id, + **token, + ) + session = get_current_session() + session.add(item) + + def create_oauth2_request(self, request): + return request if isinstance(request, OAuth2Request) else None + + def create_json_request(self, request): + return request if isinstance(request, JsonRequest) else None + + def handle_response(self, status_code, payload, headers): + return status_code, payload, headers + + def send_signal(self, name, *args, **kwargs): + pass + + +def _generate_bearer_token( + grant_type, # noqa: ARG001 + client, + user=None, + scope=None, + expires_in=None, + include_refresh_token=True, +): + """Generate a Bearer token with a JWT access_token.""" + if user is None: + raise RuntimeError(f"Token generation requires a user (client_id={client.client_id})") + access_token = create_jwt_token(user.username, expiry_seconds=ACCESS_TOKEN_EXPIRES_IN) + token = { + "token_type": "Bearer", + "access_token": access_token, + "expires_in": expires_in or ACCESS_TOKEN_EXPIRES_IN, + } + if include_refresh_token: + token["refresh_token"] = secrets.token_urlsafe(48) + if scope: + token["scope"] = scope + return token + + +def create_authorization_server() -> TestGenAuthorizationServer: + """Create and configure the authorization server with all grant types.""" + server = TestGenAuthorizationServer() + server.register_grant(AuthorizationCodeGrant, [CodeChallenge(required=True)]) + server.register_grant(ClientCredentialsGrant) + server.register_grant(RefreshTokenGrant) + server.register_endpoint(TestGenRevocationEndpoint) + server.register_token_generator("default", _generate_bearer_token) + return server diff --git a/testgen/common/auth.py b/testgen/common/auth.py index 94c83ed0..586dc649 100644 --- a/testgen/common/auth.py +++ b/testgen/common/auth.py @@ -15,11 +15,11 @@ def get_jwt_signing_key() -> bytes: return base64.b64decode(settings.JWT_HASHING_KEY_B64.encode("ascii")) -def create_jwt_token(username: str, expiry_days: int = 30) -> str: +def create_jwt_token(username: str, expiry_seconds: int = 86400) -> str: """Create a signed JWT token with the standard TestGen payload schema.""" payload = { "username": username, - "exp_date": (datetime.now(UTC) + timedelta(days=expiry_days)).timestamp(), + "exp": (datetime.now(UTC) + timedelta(seconds=expiry_seconds)).timestamp(), } return jwt.encode(payload, get_jwt_signing_key(), algorithm="HS256") @@ -28,16 +28,33 @@ def decode_jwt_token(token_str: str) -> dict: """Decode and validate a JWT token. Returns the payload dict. Raises ValueError if the token is invalid or expired. + PyJWT auto-validates the standard ``exp`` claim during decode. """ try: - payload = jwt.decode(token_str, get_jwt_signing_key(), algorithms=["HS256"]) + return jwt.decode(token_str, get_jwt_signing_key(), algorithms=["HS256"]) except jwt.InvalidTokenError as e: raise ValueError(f"Invalid token: {e}") from e - if payload.get("exp_date", 0) <= datetime.now(UTC).timestamp(): - raise ValueError("Token has expired") - return payload +def authorize_token(token_str: str, username: str, session): + """Verify the user exists and the token isn't revoked. + + Shared implementation for API and MCP authorization. + """ + from sqlalchemy import func + + from testgen.api.oauth.models import OAuth2Token + from testgen.common.models.user import User + + user = session.query(User).filter(func.lower(User.username) == func.lower(username)).first() + if user is None: + raise ValueError("User not found") + + token_record = session.query(OAuth2Token).filter_by(access_token=token_str).first() + if token_record and token_record.access_token_revoked_at: + raise ValueError("Token has been revoked") + + return user def verify_password(password: str, hashed_password: str) -> bool: diff --git a/testgen/common/notifications/monitor_run.py b/testgen/common/notifications/monitor_run.py index c3893aad..b7b0c5ec 100644 --- a/testgen/common/notifications/monitor_run.py +++ b/testgen/common/notifications/monitor_run.py @@ -1,12 +1,12 @@ import logging +from testgen import settings from testgen.common.models import with_database_session from testgen.common.models.notification_settings import ( MonitorNotificationSettings, MonitorNotificationTrigger, ) from testgen.common.models.project import Project -from testgen.common.models.settings import PersistedSetting from testgen.common.models.table_group import TableGroup from testgen.common.models.test_result import TestResult, TestResultStatus from testgen.common.models.test_run import TestRun @@ -219,7 +219,7 @@ def send_monitor_notifications(test_run: TestRun, result_list_ct=20): view_in_testgen_url = "".join( ( - PersistedSetting.get("BASE_URL", ""), + settings.UI_BASE_URL, "/monitors?project_code=", str(table_group.project_code), "&table_group_id=", diff --git a/testgen/common/notifications/profiling_run.py b/testgen/common/notifications/profiling_run.py index c1731bac..f69521cd 100644 --- a/testgen/common/notifications/profiling_run.py +++ b/testgen/common/notifications/profiling_run.py @@ -3,6 +3,7 @@ from sqlalchemy import select +from testgen import settings from testgen.common.models import get_current_session, with_database_session from testgen.common.models.hygiene_issue import HygieneIssue from testgen.common.models.notification_settings import ( @@ -11,7 +12,6 @@ ) from testgen.common.models.profiling_run import ProfilingRun from testgen.common.models.project import Project -from testgen.common.models.settings import PersistedSetting from testgen.common.models.table_group import TableGroup from testgen.common.notifications.notifications import BaseNotificationTemplate from testgen.utils import log_and_swallow_exception @@ -259,7 +259,7 @@ def send_profiling_run_notifications(profiling_run: ProfilingRun, result_list_ct profiling_run_issues_url = "".join( ( - PersistedSetting.get("BASE_URL", ""), + settings.UI_BASE_URL, "/profiling-runs:hygiene?project_code=", str(profiling_run.project_code), "&run_id=", str(profiling_run.id), @@ -311,7 +311,7 @@ def send_profiling_run_notifications(profiling_run: ProfilingRun, result_list_ct "issues_url": profiling_run_issues_url, "results_url": "".join( ( - PersistedSetting.get("BASE_URL", ""), + settings.UI_BASE_URL, "/profiling-runs:results?project_code=", str(profiling_run.project_code), "&run_id=", diff --git a/testgen/common/notifications/score_drop.py b/testgen/common/notifications/score_drop.py index e16f4bf4..7ad496c4 100644 --- a/testgen/common/notifications/score_drop.py +++ b/testgen/common/notifications/score_drop.py @@ -3,11 +3,11 @@ from sqlalchemy import select +from testgen import settings from testgen.common.models import get_current_session, with_database_session from testgen.common.models.notification_settings import ScoreDropNotificationSettings from testgen.common.models.project import Project from testgen.common.models.scores import ScoreDefinition -from testgen.common.models.settings import PersistedSetting from testgen.common.notifications.notifications import BaseNotificationTemplate from testgen.utils import log_and_swallow_exception @@ -177,7 +177,7 @@ def send_score_drop_notifications(notification_data: list[tuple[ScoreDefinition, "definition": definition, "scorecard_url": "".join( ( - PersistedSetting.get("BASE_URL", ""), + settings.UI_BASE_URL, "/quality-dashboard:score-details?project_code=", str(definition.project_code), "&definition_id=", diff --git a/testgen/common/notifications/test_run.py b/testgen/common/notifications/test_run.py index 36535e6b..cea14f7e 100644 --- a/testgen/common/notifications/test_run.py +++ b/testgen/common/notifications/test_run.py @@ -2,12 +2,12 @@ from sqlalchemy import case, literal, select +from testgen import settings from testgen.common.models import get_current_session, with_database_session from testgen.common.models.notification_settings import ( TestRunNotificationSettings, TestRunNotificationTrigger, ) -from testgen.common.models.settings import PersistedSetting from testgen.common.models.test_definition import TestType from testgen.common.models.test_result import TestResult, TestResultStatus from testgen.common.models.test_run import TestRun @@ -323,7 +323,7 @@ def send_test_run_notifications(test_run: TestRun, result_list_ct=20, result_sta test_run_url = "".join( ( - PersistedSetting.get("BASE_URL", ""), + settings.UI_BASE_URL, "/test-runs:results?project_code=", str(tr_summary.project_code), "&run_id=", diff --git a/testgen/mcp/__init__.py b/testgen/mcp/__init__.py index bf4de795..989574e5 100644 --- a/testgen/mcp/__init__.py +++ b/testgen/mcp/__init__.py @@ -1,12 +1,6 @@ from testgen import settings -from testgen.common.models.settings import PersistedSetting def get_server_url() -> str: - """Derive the externally-reachable MCP server URL from the persisted BASE_URL.""" - base_url = PersistedSetting.get("BASE_URL", "") - if base_url: - scheme, _, host_port = base_url.partition("://") - host = host_port.split(":")[0] - return f"{scheme}://{host}:{settings.MCP_PORT}" - return f"http://localhost:{settings.MCP_PORT}" + """Derive the externally-reachable MCP server URL.""" + return f"{settings.BASE_URL}/mcp" diff --git a/testgen/mcp/auth.py b/testgen/mcp/auth.py index 71ce8b20..f9ad1f4f 100644 --- a/testgen/mcp/auth.py +++ b/testgen/mcp/auth.py @@ -14,7 +14,7 @@ def authenticate_user(username: str, password: str) -> str: if not verify_password(password, user.password): raise ValueError("Invalid username or password") - return create_jwt_token(user.username) + return create_jwt_token(user.username, expiry_seconds=3600) def validate_token(token: str) -> User: diff --git a/testgen/mcp/permissions.py b/testgen/mcp/permissions.py index ed45a645..38cc1b70 100644 --- a/testgen/mcp/permissions.py +++ b/testgen/mcp/permissions.py @@ -12,6 +12,7 @@ _NOT_SET = object() _mcp_username: contextvars.ContextVar[str | None] = contextvars.ContextVar("mcp_username", default=None) +_mcp_token: contextvars.ContextVar[str | None] = contextvars.ContextVar("mcp_token", default=None) _mcp_project_permissions: contextvars.ContextVar["ProjectPermissions | object"] = contextvars.ContextVar( "mcp_project_permissions", default=_NOT_SET ) @@ -61,18 +62,27 @@ def set_mcp_username(username: str | None) -> None: _mcp_username.set(username) -def get_current_mcp_user() -> User: - """Get the authenticated User for the current MCP request. +def set_mcp_token(token: str | None) -> None: + """Store the raw bearer token (called by JWTTokenVerifier).""" + _mcp_token.set(token) + +def get_authorized_mcp_user() -> User: + """Get the authenticated and authorized User for the current MCP request. + + Checks user existence and token revocation status. Must be called within @with_database_session scope. """ + from testgen.common.auth import authorize_token + from testgen.common.models import get_current_session + username = _mcp_username.get() if not username: raise RuntimeError("No authenticated user in MCP context") - user = User.get(username) - if user is None: - raise ValueError(f"Authenticated user not found: {username}") - return user + + token_str = _mcp_token.get() + session = get_current_session() + return authorize_token(token_str or "", username, session) def _compute_project_permissions(user: User, permission: str) -> ProjectPermissions: @@ -113,7 +123,7 @@ def mcp_permission(permission: str) -> Callable: def decorator(fn: Callable) -> Callable: @functools.wraps(fn) def wrapper(*args, **kwargs): - user = get_current_mcp_user() + user = get_authorized_mcp_user() perms = _compute_project_permissions(user, permission) if not perms.allowed_codes: return "Your role does not include the necessary permission for this operation on any project." diff --git a/testgen/mcp/server.py b/testgen/mcp/server.py index 56f7d81c..4d87e6bf 100644 --- a/testgen/mcp/server.py +++ b/testgen/mcp/server.py @@ -3,11 +3,11 @@ from mcp.server.auth.provider import AccessToken from mcp.server.auth.settings import AuthSettings from mcp.server.fastmcp import FastMCP +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from starlette.applications import Starlette -from testgen import settings from testgen.common.auth import decode_jwt_token -from testgen.common.models import with_database_session -from testgen.mcp.permissions import set_mcp_username +from testgen.mcp.permissions import set_mcp_token, set_mcp_username LOG = logging.getLogger("testgen") @@ -43,28 +43,17 @@ async def verify_token(self, token: str) -> AccessToken | None: try: payload = decode_jwt_token(token) set_mcp_username(payload["username"]) + set_mcp_token(token) return AccessToken( token=token, client_id=payload["username"], scopes=[], - expires_at=int(payload["exp_date"]), + expires_at=int(payload["exp"]), ) except (ValueError, KeyError): return None -# Uvicorn log config: strip default handlers so logs propagate to the testgen logger. -_UVICORN_LOG_CONFIG: dict = { - "version": 1, - "disable_existing_loggers": False, - "loggers": { - "uvicorn": {"handlers": [], "propagate": True}, - "uvicorn.access": {"handlers": [], "propagate": True}, - "uvicorn.error": {"handlers": [], "propagate": True}, - }, -} - - def _configure_mcp_logging() -> None: """Route FastMCP and uvicorn logs through the testgen logger.""" testgen_logger = logging.getLogger("testgen") @@ -77,28 +66,33 @@ def _configure_mcp_logging() -> None: logging.getLogger(name).parent = testgen_logger -def run_mcp() -> None: - """Start the MCP server with streamable HTTP transport.""" - from testgen.mcp import get_server_url +def build_mcp_app( + api_base_url: str, server_url: str | None = None, +) -> tuple[Starlette, StreamableHTTPSessionManager]: + """Create the MCP Starlette app with tools, resources, and prompts registered. + + Returns the Starlette app and its session manager. The caller must run + ``session_manager.run()`` as an async context manager (e.g. in the host + app's lifespan) to initialize the task group before requests arrive. + + Args: + api_base_url: OAuth issuer URL (the API server). + server_url: MCP resource server URL. Defaults to ``{api_base_url}/mcp``. + """ from testgen.mcp.prompts.workflows import compare_runs, health_check, investigate_failures, table_health from testgen.mcp.tools.discovery import get_data_inventory, list_projects, list_tables, list_test_suites from testgen.mcp.tools.reference import get_test_type, glossary_resource, test_types_resource from testgen.mcp.tools.test_results import get_failure_summary, get_test_result_history, get_test_results from testgen.mcp.tools.test_runs import get_recent_test_runs - from testgen.utils.plugins import discover - - for plugin in discover(): - plugin.load() - server_url = with_database_session(get_server_url)() + if server_url is None: + server_url = f"{api_base_url}/mcp" mcp = FastMCP( "TestGen", - host=settings.MCP_HOST, - port=settings.MCP_PORT, instructions=SERVER_INSTRUCTIONS, auth=AuthSettings( - issuer_url=server_url, + issuer_url=api_base_url, resource_server_url=server_url, ), token_verifier=JWTTokenVerifier(), @@ -126,21 +120,5 @@ def run_mcp() -> None: mcp.prompt()(table_health) mcp.prompt()(compare_runs) - LOG.info("Starting MCP server on %s:%s (auth issuer: %s)", settings.MCP_HOST, settings.MCP_PORT, server_url) - - import uvicorn - app = mcp.streamable_http_app() - - if settings.IS_DEBUG: - from starlette.middleware.cors import CORSMiddleware - - app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], - expose_headers=["Mcp-Session-Id"], - ) - - uvicorn.run(app, host=settings.MCP_HOST, port=settings.MCP_PORT, log_config=_UVICORN_LOG_CONFIG) + return app, mcp.session_manager diff --git a/testgen/server/__init__.py b/testgen/server/__init__.py new file mode 100644 index 00000000..ded2030a --- /dev/null +++ b/testgen/server/__init__.py @@ -0,0 +1,103 @@ +"""TestGen server — combined FastAPI + MCP application.""" + +import logging +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from testgen import settings + +# authlib rejects http:// URIs by default; allow in debug mode for local dev +if settings.IS_DEBUG: + os.environ.setdefault("AUTHLIB_INSECURE_TRANSPORT", "1") + +from testgen.api.app import router as api_router +from testgen.api.oauth.metadata import router as metadata_router +from testgen.api.oauth.routes import init_routes +from testgen.api.oauth.routes import router as oauth_router +from testgen.api.oauth.server import create_authorization_server +from testgen.common import version_service +from testgen.common.models import with_database_session + +LOG = logging.getLogger("testgen") + + +def create_app() -> FastAPI: + version_data = with_database_session(version_service.get_version)() + + mcp_session_manager = None + + if settings.MCP_ENABLED: + from testgen.mcp.server import build_mcp_app + + mcp_app, mcp_session_manager = build_mcp_app(settings.BASE_URL) + + @asynccontextmanager + async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + if mcp_session_manager is not None: + async with mcp_session_manager.run(): + yield + else: + yield + + app = FastAPI( + title="TestGen API", + version=version_data.current or "dev", + docs_url="/api/docs", + openapi_url="/api/openapi.json", + lifespan=lifespan, + ) + + server = create_authorization_server() + init_routes(server) + + app.include_router(metadata_router) + app.include_router(oauth_router) + app.include_router(api_router) + + if settings.MCP_ENABLED: + app.mount("", mcp_app) + + if settings.IS_DEBUG: + from starlette.middleware.cors import CORSMiddleware + + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["Mcp-Session-Id"], + ) + + return app + + +def run_server() -> None: + """Start the combined API + MCP server with uvicorn.""" + import uvicorn + + from testgen.utils.plugins import discover + + for plugin in discover(): + try: + plugin.load() + except Exception: + LOG.warning("Plugin %s failed to load (Streamlit-only?), skipping", plugin.package) + + app = create_app() + + ssl_kwargs = {} + if settings.API_TLS_ENABLED: + ssl_kwargs["ssl_certfile"] = settings.SSL_CERT_FILE + ssl_kwargs["ssl_keyfile"] = settings.SSL_KEY_FILE + + LOG.info( + "Starting server on %s:%s (TLS: %s, MCP: %s)", + settings.API_HOST, + settings.API_PORT, + "enabled" if settings.API_TLS_ENABLED else "disabled", + "enabled" if settings.MCP_ENABLED else "disabled", + ) + uvicorn.run(app, host=settings.API_HOST, port=settings.API_PORT, log_level="info", **ssl_kwargs) diff --git a/testgen/settings.py b/testgen/settings.py index 8d2b4512..635307d1 100644 --- a/testgen/settings.py +++ b/testgen/settings.py @@ -444,6 +444,31 @@ """ +def _ssl_files_present() -> bool: + return bool(SSL_CERT_FILE) and os.path.isfile(SSL_CERT_FILE) and bool(SSL_KEY_FILE) and os.path.isfile(SSL_KEY_FILE) + + +_ui_tls_env = os.getenv("TG_UI_TLS_ENABLED", "").lower() +UI_TLS_ENABLED: bool = _ui_tls_env in ("yes", "true") if _ui_tls_env else _ssl_files_present() +""" +Enable TLS for the Streamlit UI server. +When not set, auto-detects from SSL_CERT_FILE / SSL_KEY_FILE presence. + +from env variable: `TG_UI_TLS_ENABLED` +defaults to: auto-detect +""" + +_api_tls_env = os.getenv("TG_API_TLS_ENABLED", "").lower() +API_TLS_ENABLED: bool = _api_tls_env in ("yes", "true") if _api_tls_env else _ssl_files_present() +""" +Enable TLS for the API/MCP server (uvicorn). +When not set, auto-detects from SSL_CERT_FILE / SSL_KEY_FILE presence. + +from env variable: `TG_API_TLS_ENABLED` +defaults to: auto-detect +""" + + MIXPANEL_URL: str = "https://api.mixpanel.com" MIXPANEL_TIMEOUT: int = 3 MIXPANEL_TOKEN: str = "973680ddf8c2b512e6f6d1f2959149eb" @@ -501,26 +526,54 @@ Email: SMTP password """ -MCP_PORT: int = int(os.getenv("TG_MCP_PORT", "8510")) +MCP_ENABLED: bool = os.getenv("TG_MCP_ENABLED", "no").lower() in ("yes", "true") +""" +Enable the MCP server when running `testgen run-app all`. + +from env variable: `TG_MCP_ENABLED` +defaults to: `Yes` +""" + +API_PORT: int = int(os.getenv("TG_API_PORT", "8530")) """ -Port for the MCP server. +Port for the API server. -from env variable: `TG_MCP_PORT` -defaults to: `8510` +from env variable: `TG_API_PORT` +defaults to: `8530` """ -MCP_HOST: str = os.getenv("TG_MCP_HOST", "0.0.0.0") # noqa: S104 +API_HOST: str = os.getenv("TG_API_HOST", "0.0.0.0") # noqa: S104 """ -Host for the MCP server. +Host for the API server. -from env variable: `TG_MCP_HOST` +from env variable: `TG_API_HOST` defaults to: `0.0.0.0` """ -MCP_ENABLED: bool = os.getenv("TG_MCP_ENABLED", "no").lower() in ("yes", "true") +def _default_base_url() -> str: + scheme = "https" if API_TLS_ENABLED else "http" + return f"{scheme}://localhost:{API_PORT}" + + +BASE_URL: str = os.getenv("TG_BASE_URL", "") or _default_base_url() """ -Enable the MCP server when running `testgen run-app all`. +Externally-reachable base URL for the API/MCP/OAuth server. -from env variable: `TG_MCP_ENABLED` -defaults to: `Yes` +from env variable: `TG_BASE_URL` +defaults to: computed from API_TLS_ENABLED and API_PORT +""" + + +def _default_ui_base_url() -> str: + scheme = "https" if UI_TLS_ENABLED else "http" + port = os.getenv("STREAMLIT_SERVER_PORT", "8501") + return f"{scheme}://localhost:{port}" + + +UI_BASE_URL: str = os.getenv("TG_UI_BASE_URL", "") or _default_ui_base_url() +""" +Externally-reachable base URL for the Streamlit UI (used in email links, PDFs). + +from env variable: `TG_UI_BASE_URL` +defaults to: computed from UI_TLS_ENABLED and STREAMLIT_SERVER_PORT """ diff --git a/testgen/template/dbsetup/030_initialize_new_schema_structure.sql b/testgen/template/dbsetup/030_initialize_new_schema_structure.sql index b5b1eefe..b067bb28 100644 --- a/testgen/template/dbsetup/030_initialize_new_schema_structure.sql +++ b/testgen/template/dbsetup/030_initialize_new_schema_structure.sql @@ -664,6 +664,48 @@ CREATE INDEX ix_pm_user_id ON project_memberships(user_id); CREATE INDEX ix_pm_project_code ON project_memberships(project_code); CREATE INDEX ix_pm_role ON project_memberships(role); +CREATE TABLE oauth2_clients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES auth_users(id) ON DELETE SET NULL, + client_id VARCHAR(48) NOT NULL UNIQUE, + client_secret VARCHAR(120), + client_id_issued_at INTEGER NOT NULL DEFAULT 0, + client_secret_expires_at INTEGER NOT NULL DEFAULT 0, + client_metadata TEXT NOT NULL DEFAULT '{}' +); +CREATE INDEX idx_oauth2_clients_client_id ON oauth2_clients(client_id); + +CREATE TABLE oauth2_authorization_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE, + code VARCHAR(120) NOT NULL UNIQUE, + client_id VARCHAR(48) NOT NULL, + redirect_uri TEXT DEFAULT '', + response_type TEXT DEFAULT '', + scope TEXT DEFAULT '', + nonce TEXT, + auth_time INTEGER NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::INTEGER, + acr TEXT, + amr TEXT, + code_challenge TEXT, + code_challenge_method VARCHAR(48) +); + +CREATE TABLE oauth2_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES auth_users(id) ON DELETE CASCADE, + client_id VARCHAR(48) NOT NULL, + token_type VARCHAR(40), + access_token VARCHAR(2048) NOT NULL UNIQUE, + refresh_token VARCHAR(255), + scope TEXT DEFAULT '', + issued_at INTEGER NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::INTEGER, + access_token_revoked_at INTEGER NOT NULL DEFAULT 0, + refresh_token_revoked_at INTEGER NOT NULL DEFAULT 0, + expires_in INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX idx_oauth2_tokens_refresh_token ON oauth2_tokens(refresh_token); + CREATE TABLE tg_revision ( component VARCHAR(50) NOT NULL CONSTRAINT tg_revision_component_pk diff --git a/testgen/template/dbsetup/075_grant_role_rights.sql b/testgen/template/dbsetup/075_grant_role_rights.sql index af100289..35e5228c 100644 --- a/testgen/template/dbsetup/075_grant_role_rights.sql +++ b/testgen/template/dbsetup/075_grant_role_rights.sql @@ -42,7 +42,10 @@ GRANT SELECT, INSERT, DELETE, UPDATE ON {SCHEMA_NAME}.job_schedules, {SCHEMA_NAME}.settings, {SCHEMA_NAME}.notification_settings, - {SCHEMA_NAME}.test_definition_notes + {SCHEMA_NAME}.test_definition_notes, + {SCHEMA_NAME}.oauth2_clients, + {SCHEMA_NAME}.oauth2_authorization_codes, + {SCHEMA_NAME}.oauth2_tokens TO testgen_execute_role; diff --git a/testgen/template/dbupgrade/0178_incremental_upgrade.sql b/testgen/template/dbupgrade/0178_incremental_upgrade.sql new file mode 100644 index 00000000..7b2e9c8d --- /dev/null +++ b/testgen/template/dbupgrade/0178_incremental_upgrade.sql @@ -0,0 +1,46 @@ +SET SEARCH_PATH TO {SCHEMA_NAME}; + +-- OAuth2 clients (MCP apps, automation scripts) +CREATE TABLE IF NOT EXISTS oauth2_clients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES auth_users(id) ON DELETE SET NULL, + client_id VARCHAR(48) NOT NULL UNIQUE, + client_secret VARCHAR(120), + client_id_issued_at INTEGER NOT NULL DEFAULT 0, + client_secret_expires_at INTEGER NOT NULL DEFAULT 0, + client_metadata TEXT NOT NULL DEFAULT '{}' +); +CREATE INDEX IF NOT EXISTS idx_oauth2_clients_client_id ON oauth2_clients(client_id); + +-- OAuth2 authorization codes (temporary, single-use) +CREATE TABLE IF NOT EXISTS oauth2_authorization_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE, + code VARCHAR(120) NOT NULL UNIQUE, + client_id VARCHAR(48) NOT NULL, + redirect_uri TEXT DEFAULT '', + response_type TEXT DEFAULT '', + scope TEXT DEFAULT '', + nonce TEXT, + auth_time INTEGER NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::INTEGER, + acr TEXT, + amr TEXT, + code_challenge TEXT, + code_challenge_method VARCHAR(48) +); + +-- OAuth2 tokens (access + refresh) +CREATE TABLE IF NOT EXISTS oauth2_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES auth_users(id) ON DELETE CASCADE, + client_id VARCHAR(48) NOT NULL, + token_type VARCHAR(40), + access_token VARCHAR(2048) NOT NULL UNIQUE, + refresh_token VARCHAR(255), + scope TEXT DEFAULT '', + issued_at INTEGER NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::INTEGER, + access_token_revoked_at INTEGER NOT NULL DEFAULT 0, + refresh_token_revoked_at INTEGER NOT NULL DEFAULT 0, + expires_in INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_oauth2_tokens_refresh_token ON oauth2_tokens(refresh_token); diff --git a/testgen/ui/navigation/router.py b/testgen/ui/navigation/router.py index eaa43a52..9e3a0fea 100644 --- a/testgen/ui/navigation/router.py +++ b/testgen/ui/navigation/router.py @@ -2,13 +2,11 @@ import logging import time -from urllib.parse import urlparse import streamlit as st import testgen.ui.navigation.page from testgen.common.mixpanel_service import MixpanelService -from testgen.common.models.settings import PersistedSetting from testgen.ui.session import session from testgen.utils.singleton import Singleton @@ -30,12 +28,6 @@ def _init_session(self, url: str): # Clear cache on initial load or page refresh st.cache_data.clear() - try: - parsed_url = urlparse(st.context.url) - PersistedSetting.set("BASE_URL", f"{parsed_url.scheme}://{parsed_url.netloc}") - except Exception as e: - LOG.exception("Error capturing the base URL") - source = st.query_params.pop("source", None) MixpanelService().send_event(f"nav-{url}", page_load=True, source=source) diff --git a/testgen/ui/pdf/hygiene_issue_report.py b/testgen/ui/pdf/hygiene_issue_report.py index 1e67ae06..fc92bcdd 100644 --- a/testgen/ui/pdf/hygiene_issue_report.py +++ b/testgen/ui/pdf/hygiene_issue_report.py @@ -4,7 +4,7 @@ from reportlab.lib.styles import ParagraphStyle from reportlab.platypus import CondPageBreak, KeepTogether, Paragraph, Table, TableStyle -from testgen.common.models.settings import PersistedSetting +from testgen import settings from testgen.settings import ISSUE_REPORT_SOURCE_DATA_LOOKUP_LIMIT from testgen.ui.pdf.dataframe_table import DataFrameTableBuilder from testgen.ui.pdf.style import ( @@ -139,7 +139,7 @@ def build_summary_table(document, hi_data): ), ( Paragraph( - f""" + f""" View on TestGen > """, style=PARA_STYLE_LINK, diff --git a/testgen/ui/pdf/test_result_report.py b/testgen/ui/pdf/test_result_report.py index eab9cee8..173e381f 100644 --- a/testgen/ui/pdf/test_result_report.py +++ b/testgen/ui/pdf/test_result_report.py @@ -10,7 +10,7 @@ TableStyle, ) -from testgen.common.models.settings import PersistedSetting +from testgen import settings from testgen.settings import ISSUE_REPORT_SOURCE_DATA_LOOKUP_LIMIT from testgen.ui.pdf.dataframe_table import TABLE_STYLE_DATA, DataFrameTableBuilder from testgen.ui.pdf.style import ( @@ -152,7 +152,7 @@ def build_summary_table(document, tr_data): ), ( Paragraph( - f""" + f""" View on TestGen > """, style=PARA_STYLE_LINK, diff --git a/tests/unit/api/__init__.py b/tests/unit/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/api/oauth/__init__.py b/tests/unit/api/oauth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/api/oauth/conftest.py b/tests/unit/api/oauth/conftest.py new file mode 100644 index 00000000..2d39a326 --- /dev/null +++ b/tests/unit/api/oauth/conftest.py @@ -0,0 +1,6 @@ +"""Shared fixtures for OAuth tests.""" + +import os + +# authlib rejects http:// URIs by default; allow in tests +os.environ.setdefault("AUTHLIB_INSECURE_TRANSPORT", "1") diff --git a/tests/unit/api/oauth/test_login.py b/tests/unit/api/oauth/test_login.py new file mode 100644 index 00000000..1abf4bf8 --- /dev/null +++ b/tests/unit/api/oauth/test_login.py @@ -0,0 +1,68 @@ +"""Tests for testgen.api.oauth.login — HTML login page renderer.""" + +from testgen.api.oauth.login import render_login_page + + +def _render(**kwargs): + defaults = { + "client_id": "test_client", + "redirect_uri": "http://localhost/callback", + "response_type": "code", + "scope": "", + "state": "xyz", + "code_challenge": "abc", + "code_challenge_method": "S256", + } + defaults.update(kwargs) + return render_login_page(**defaults) + + +def test_login_page_contains_form(): + html = _render() + assert '
alert(1)") + assert " - - - - diff --git a/testgen/ui/components/frontend/js/main.js b/testgen/ui/components/frontend/js/main.js deleted file mode 100644 index 5f29bf99..00000000 --- a/testgen/ui/components/frontend/js/main.js +++ /dev/null @@ -1,161 +0,0 @@ -/** - * @typedef Properties - * @type {object} - * @property {string} id - id of the specific component to be rendered - * @property {string} key - user key of the specific component to be rendered - * @property {object} props - object with the props to pass to the rendered component - */ -import van from '/app/static/js/van.min.js'; -import pluginSpec from './plugins.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { isEqual, getParents } from '/app/static/js/utils.js'; - -let currentWindowVan = van; -let topWindowVan = window.top.van; - -const componentLoaders = { - breadcrumbs: () => import('/app/static/js/components/breadcrumbs.js').then(m => m.Breadcrumbs), - button: () => import('/app/static/js/components/button.js').then(m => m.Button), - expander_toggle: () => import('/app/static/js/components/expander_toggle.js').then(m => m.ExpanderToggle), - link: () => import('/app/static/js/components/link.js').then(m => m.Link), - paginator: () => import('/app/static/js/components/paginator.js').then(m => m.Paginator), - sorting_selector: () => import('/app/static/js/components/sorting_selector.js').then(m => m.SortingSelector), - sidebar: () => Promise.resolve(window.top.testgen.components.Sidebar), - test_runs: () => import('./pages/test_runs.js').then(m => m.TestRuns), - profiling_runs: () => import('./pages/profiling_runs.js').then(m => m.ProfilingRuns), - data_catalog: () => import('./pages/data_catalog.js').then(m => m.DataCatalog), - column_profiling_results: () => import('./data_profiling/column_profiling_results.js').then(m => m.ColumnProfilingResults), - column_profiling_history: () => import('./data_profiling/column_profiling_history.js').then(m => m.ColumnProfilingHistory), - project_dashboard: () => import('./pages/project_dashboard.js').then(m => m.ProjectDashboard), - test_suites: () => import('./pages/test_suites.js').then(m => m.TestSuites), - quality_dashboard: () => import('./pages/quality_dashboard.js').then(m => m.QualityDashboard), - score_details: () => import('./pages/score_details.js').then(m => m.ScoreDetails), - score_explorer: () => import('./pages/score_explorer.js').then(m => m.ScoreExplorer), - schedule_list: () => import('./pages/schedule_list.js').then(m => m.ScheduleList), - column_selector: () => import('/app/static/js/components/explorer_column_selector.js').then(m => m.ColumnSelector), - connections: () => import('./pages/connections.js').then(m => m.Connections), - table_group_wizard: () => import('./pages/table_group_wizard.js').then(m => m.TableGroupWizard), - help_menu: () => import('/app/static/js/components/help_menu.js').then(m => m.HelpMenu), - table_group_list: () => import('./pages/table_group_list.js').then(m => m.TableGroupList), - table_group_delete: () => import('./pages/table_group_delete_confirmation.js').then(m => m.TableGroupDeleteConfirmation), - run_profiling_dialog: () => import('./pages/run_profiling_dialog.js').then(m => m.RunProfilingDialog), - confirm_dialog: () => import('./pages/confirmation_dialog.js').then(m => m.ConfirmationDialog), - test_definition_summary: () => import('./pages/test_definition_summary.js').then(m => m.TestDefinitionSummary), - notification_settings: () => import('./pages/notification_settings.js').then(m => m.NotificationSettings), - monitors_dashboard: () => import('./pages/monitors_dashboard.js').then(m => m.MonitorsDashboard), - test_results_chart: () => import('./pages/test_results_chart.js').then(m => m.TestResultsChart), - test_definition_notes: () => import('./pages/test_definition_notes.js').then(m => m.TestDefinitionNotes), - schema_changes_list: () => import('./components/schema_changes_list.js').then(m => m.SchemaChangesList), - edit_monitor_settings: () => import('./pages/edit_monitor_settings.js').then(m => m.EditMonitorSettings), - import_metadata_dialog: () => import('./pages/import_metadata_dialog.js').then(m => m.ImportMetadataDialog), -}; - -const TestGenComponent = async (/** @type {string} */ id, /** @type {object} */ props) => { - const loader = window.testgen.plugins[id] ?? componentLoaders[id]; - if (loader) { - const Component = await loader(); - return Component(props); - } - return ''; -}; - -window.addEventListener('message', async (event) => { - if (event.data.type === 'streamlit:render') { - await loadPlugins(); - - const componentId = event.data.args.id; - const componentKey = event.data.args.key; - - let van = currentWindowVan; - let mountPoint = document.body; - let componentState = window.testgen.states[componentKey]; - if (shouldRenderOutsideFrame(componentId)) { - window.frameElement.style.display = 'none'; - componentState = window.top.testgen.states[componentKey]; - mountPoint = window.frameElement.parentElement; - van = topWindowVan; - } - - if (componentId === 'sidebar') { - // The parent element [data-testid="stSidebarUserContent"] randoms flickers on page navigation - // The [data-testid="stSidebarContent"] element seems to be stable - // But only when the default [data-testid="stSidebarNav"] navbar element is present - mountPoint = window.top.document.querySelector('[data-testid="stSidebarContent"]'); - - window.top.testgen.components.Sidebar.StreamlitInstance = Streamlit; - } - - if (componentState === undefined) { - document.body.dataset.component = event.data.args.id; - - componentState = {}; - for (const [ key, value ] of Object.entries(event.data.args.props)) { - componentState[key] = van.state(value); - } - - if (shouldRenderOutsideFrame(componentId)) { - window.top.testgen.states[componentKey] = componentState; - } else { - window.testgen.states[componentKey] = componentState; - } - - return van.add(mountPoint, await TestGenComponent(componentId, componentState)); - } - - for (const [ key, value ] of Object.entries(event.data.args.props)) { - if (!isEqual(componentState[key].val, value)) { - componentState[key].val = value; - } - } - } -}); - -document.addEventListener('click', (event) => { - const openedPortals = (Object.values(window.testgen.portals) ?? []).filter(portal => portal.opened.val); - if (Object.keys(openedPortals).length <= 0) { - return; - } - - const targetParents = getParents(event.target); - for (const portal of openedPortals) { - const targetEl = document.getElementById(portal.targetId); - const portalEl = document.getElementById(portal.domId); - - if (event?.target?.id !== portal.targetId && event?.target?.id !== portal.domId && !targetParents.includes(targetEl) && !targetParents.includes(portalEl)) { - portal.opened.val = false; - } - } -}); - -Streamlit.init(); - -function shouldRenderOutsideFrame(componentId) { - return 'sidebar' === componentId; -} - -async function loadPlugins() { - if (!window.testgen.pluginsLoaded) { - try { - const modules = await Promise.all(Object.values(pluginSpec).map(plugin => import(plugin.entrypoint))) - for (const pluginModule of modules) { - if (pluginModule && pluginModule.componentLoaders) { - Object.assign(window.testgen.plugins, pluginModule.componentLoaders) - } else if (pluginModule) { - console.warn(`Plugin '${pluginModule}' does not export a member 'componentLoaders'.`); - } - } - } catch (error) { - console.warn('Error loading plugins:', error); - } - } - - window.testgen.pluginsLoaded = true; -} - -window.testgen = { - states: {}, - loadedStylesheets: {}, - portals: {}, - plugins: {}, - pluginsLoaded: false, -}; diff --git a/testgen/ui/components/frontend/js/pages/breadcrumbs.js b/testgen/ui/components/frontend/js/pages/breadcrumbs.js new file mode 100644 index 00000000..c530e15a --- /dev/null +++ b/testgen/ui/components/frontend/js/pages/breadcrumbs.js @@ -0,0 +1,26 @@ +import van from '/app/static/js/van.min.js'; +import { createEmitter, isEqual } from '/app/static/js/utils.js'; +import { Breadcrumbs } from '/app/static/js/components/breadcrumbs.js'; + +export default (component) => { + const { data, setTriggerValue, parentElement } = component; + + let componentState = parentElement.state; + if (componentState === undefined) { + componentState = {}; + for (const [key, value] of Object.entries(data)) { + componentState[key] = van.state(value); + } + parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); + van.add(parentElement, Breadcrumbs(componentState)); + } else { + for (const [key, value] of Object.entries(data)) { + if (!isEqual(componentState[key].val, value)) { + componentState[key].val = value; + } + } + } + + return () => { parentElement.state = null; }; +}; diff --git a/testgen/ui/components/frontend/js/pages/run_profiling_dialog.js b/testgen/ui/components/frontend/js/pages/run_profiling_dialog.js deleted file mode 100644 index 47848545..00000000 --- a/testgen/ui/components/frontend/js/pages/run_profiling_dialog.js +++ /dev/null @@ -1,163 +0,0 @@ -/** - * @import { TableGroupStats } from '/app/static/js/components/table_group_stats.js' - * - * @typedef Result - * @type {object} - * @property {boolean} success - * @property {string?} message - * @property {boolean?} show_link - * - * @typedef Properties - * @type {object} - * @property {TableGroupStats[]} table_groups - * @property {string} selected_id - * @property {boolean} allow_selection - * @property {Result?} result - */ -import van from '/app/static/js/van.min.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { Alert } from '/app/static/js/components/alert.js'; -import { Dialog } from '/app/static/js/components/dialog.js'; -import { ExpanderToggle } from '/app/static/js/components/expander_toggle.js'; -import { Icon } from '/app/static/js/components/icon.js'; -import { emitEvent, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; -import { Code } from '/app/static/js/components/code.js'; -import { Button } from '/app/static/js/components/button.js'; -import { Select } from '/app/static/js/components/select.js'; -import { TableGroupStats } from '/app/static/js/components/table_group_stats.js'; - -const { div, span, strong } = van.tags; - -/** - * @param {Properties} props - */ -const RunProfilingDialog = (props) => { - loadStylesheet('run-profiling', stylesheet); - - const dialogProp = getValue(props.dialog); - const dialogOpen = van.state(dialogProp?.open === true); - - const wrapperId = 'run-profiling-wrapper'; - - const tableGroups = getValue(props.table_groups); - const allowSelection = getValue(props.allow_selection); - const selectedId = van.state(getValue(props.selected_id)); - const selectedTableGroup = van.derive(() => tableGroups.find(({ id }) => id === selectedId.val)); - const showCLICommand = van.state(false); - - const content = div( - { id: wrapperId }, - div( - { class: `flex-column fx-gap-3 ${allowSelection ? 'run-profiling--allow-selection' : ''}` }, - allowSelection - ? Select({ - label: 'Table Group', - value: selectedId, - options: tableGroups.map(({ id, table_groups_name }) => ({ label: table_groups_name, value: id })), - portalClass: 'run-profiling--select', - }) - : span( - 'Run profiling for the table group ', - strong({}, selectedTableGroup.val.table_groups_name), - '?', - ), - () => selectedTableGroup.val - ? div( - TableGroupStats({ class: 'mt-1 mb-3' }, selectedTableGroup.val), - ExpanderToggle({ - collapseLabel: 'Collapse', - expandLabel: 'Show CLI command', - onCollapse: () => showCLICommand.val = false, - onExpand: () => showCLICommand.val = true, - }), - Code({ class: () => showCLICommand.val ? '' : 'hidden' }, `testgen run-profile --table-group-id ${selectedTableGroup.val.id}`), - ) - : div({ style: 'margin: auto;' }, 'Select a table group to profile.'), - () => { - const result = getValue(props.result) ?? {}; - return result.message - ? Alert({ type: result.success ? 'success' : 'error' }, span(result.message)) - : ''; - }, - ), - () => !getValue(props.result) - ? div( - { class: 'flex-row fx-justify-space-between mt-3' }, - div( - { class: 'flex-row fx-gap-1' }, - Icon({ size: 16 }, 'info'), - span({ class: 'text-caption' }, ' Profiling will be performed in a background process.'), - ), - Button({ - label: 'Run Profiling', - type: 'stroked', - color: 'primary', - width: 'auto', - style: 'width: auto;', - disabled: !selectedTableGroup.val, - onclick: () => emitEvent('RunProfilingConfirmed', { payload: selectedTableGroup.val }), - }), - ) : '', - () => getValue(props.result)?.show_link - ? Button({ - type: 'stroked', - color: 'primary', - label: 'Go to Profiling Runs', - style: 'width: auto; margin-left: auto; margin-top: 12px;', - icon: 'chevron_right', - onclick: () => emitEvent('GoToProfilingRunsClicked', { payload: selectedTableGroup.val.id }), - }) - : '', - ); - - if (dialogProp) { - const dialogTitle = van.derive(() => getValue(props.dialog)?.title ?? 'Run Profiling'); - return Dialog( - { - title: dialogTitle, - open: dialogOpen, - onClose: () => { dialogOpen.val = false; emitEvent('CloseClicked', {}); }, - width: '32rem', - }, - content, - ); - } - return content; -}; - -const stylesheet = new CSSStyleSheet(); -stylesheet.replace(` -.run-profiling--allow-selection { - min-height: 225px; -} - -.run-profiling--select { - max-height: 200px !important; -} -`); - -export { RunProfilingDialog }; - -export default (component) => { - const { data, setStateValue, setTriggerValue, parentElement } = component; - - Streamlit.enableV2(setTriggerValue); - - let componentState = parentElement.state; - if (componentState === undefined) { - componentState = {}; - for (const [key, value] of Object.entries(data)) { - componentState[key] = van.state(value); - } - parentElement.state = componentState; - van.add(parentElement, RunProfilingDialog(componentState)); - } else { - for (const [key, value] of Object.entries(data)) { - if (!isEqual(componentState[key].val, value)) { - componentState[key].val = value; - } - } - } - - return () => { parentElement.state = null; }; -}; diff --git a/testgen/ui/components/frontend/js/pages/sidebar.js b/testgen/ui/components/frontend/js/pages/sidebar.js new file mode 100644 index 00000000..3d50e6eb --- /dev/null +++ b/testgen/ui/components/frontend/js/pages/sidebar.js @@ -0,0 +1,39 @@ +import van from '/app/static/js/van.min.js'; +import { isEqual } from '/app/static/js/utils.js'; + +export default (component) => { + const { data, setTriggerValue, parentElement } = component; + + const Sidebar = window.testgen.components.Sidebar; + + // Dedicated proxy so the sidebar always calls its own setTriggerValue, + // even when other v2 components overwrite the shared Streamlit singleton. + Sidebar.StreamlitInstance = { + setFrameHeight() {}, + sendData(data) { + const event = data.event; + const triggerData = Object.fromEntries( + Object.entries(data).filter(([k]) => k !== 'event'), + ); + setTriggerValue(event, triggerData); + }, + }; + + let componentState = parentElement.state; + if (componentState === undefined) { + componentState = {}; + for (const [key, value] of Object.entries(data)) { + componentState[key] = van.state(value); + } + parentElement.state = componentState; + van.add(parentElement, Sidebar(componentState)); + } else { + for (const [key, value] of Object.entries(data)) { + if (!isEqual(componentState[key].val, value)) { + componentState[key].val = value; + } + } + } + + return () => { parentElement.state = null; }; +}; diff --git a/testgen/ui/components/utils/component.py b/testgen/ui/components/utils/component.py index f0cc139f..eb7536eb 100644 --- a/testgen/ui/components/utils/component.py +++ b/testgen/ui/components/utils/component.py @@ -1,21 +1,9 @@ -import pathlib from collections.abc import Callable import streamlit as st -from streamlit.components import v1 as components from streamlit.components.v2.bidi_component.state import BidiComponentResult from streamlit.components.v2.types import ComponentRenderer -components_dir = pathlib.Path(__file__).parent.parent.joinpath("frontend") -component_function = components.declare_component("testgen", path=components_dir) - - -def component(*, id_, props, key=None, default=None, on_change=None): - component_props = props - if not component_props: - component_props = {} - return component_function(id=id_, props=component_props, key=key, default=default, on_change=on_change) - def component_v2_wrapped(renderer: ComponentRenderer) -> ComponentRenderer: def wrapped_renderer(key: str | None = None, **kwargs) -> BidiComponentResult: diff --git a/testgen/ui/components/widgets/__init__.py b/testgen/ui/components/widgets/__init__.py index 2f01e968..6b3f23a9 100644 --- a/testgen/ui/components/widgets/__init__.py +++ b/testgen/ui/components/widgets/__init__.py @@ -2,13 +2,8 @@ from streamlit.components import v2 as components_v2 -from testgen.ui.components.utils.component import component, component_v2_wrapped -from testgen.ui.components.widgets.breadcrumbs import breadcrumbs -from testgen.ui.components.widgets.button import button +from testgen.ui.components.utils.component import component_v2_wrapped from testgen.ui.components.widgets.card import card -from testgen.ui.components.widgets.empty_state import EmptyStateMessage, empty_state -from testgen.ui.components.widgets.expander_toggle import expander_toggle -from testgen.ui.components.widgets.link import link from testgen.ui.components.widgets.page import ( caption, css_class, @@ -22,13 +17,9 @@ text, whitespace, ) -from testgen.ui.components.widgets.paginator import paginator from testgen.ui.components.widgets.select import select from testgen.ui.components.widgets.sidebar import sidebar -from testgen.ui.components.widgets.sorting_selector import sorting_selector from testgen.ui.components.widgets.summary import summary_bar, summary_counts -from testgen.ui.components.widgets.testgen_component import testgen_component -from testgen.ui.components.widgets.wizard import WizardStep, wizard table_group_wizard = component_v2_wrapped(components_v2.component( name="dataops-testgen.table_group_wizard", @@ -137,3 +128,21 @@ js="pages/application_logs.js", isolate_styles=False, )) + +help_menu_widget = component_v2_wrapped(components_v2.component( + name="dataops-testgen.help_menu", + js="pages/help_menu.js", + isolate_styles=False, +)) + +breadcrumbs_widget = component_v2_wrapped(components_v2.component( + name="dataops-testgen.breadcrumbs", + js="pages/breadcrumbs.js", + isolate_styles=False, +)) + +sidebar_widget = component_v2_wrapped(components_v2.component( + name="dataops-testgen.sidebar", + js="pages/sidebar.js", + isolate_styles=False, +)) diff --git a/testgen/ui/components/widgets/breadcrumbs.py b/testgen/ui/components/widgets/breadcrumbs.py deleted file mode 100644 index 4f9012fb..00000000 --- a/testgen/ui/components/widgets/breadcrumbs.py +++ /dev/null @@ -1,36 +0,0 @@ -import typing - -from testgen.ui.components.utils.component import component -from testgen.ui.navigation.router import Router -from testgen.ui.session import session - - -def breadcrumbs( - key: str = "testgen:breadcrumbs", - breadcrumbs: list["Breadcrumb"] | None = None, -) -> None: - """ - Testgen component to display the breadcrumbs with a hash link on - each page. - - # Parameters - :param key: unique key to give the component a persisting state - :param breadcrumbs: list of dicts with label and path - """ - - data = component( - id_="breadcrumbs", - key=key, - props={"breadcrumbs": breadcrumbs}, - ) - if data: - # Prevent handling the same event multiple times - event_id = data.get("_id") - if event_id != session.breadcrumb_event_id: - session.breadcrumb_event_id = event_id - Router().navigate(to=data["href"], with_args=data["params"]) - -class Breadcrumb(typing.TypedDict): - path: str | None - params: dict - label: str diff --git a/testgen/ui/components/widgets/button.py b/testgen/ui/components/widgets/button.py deleted file mode 100644 index eeaae5be..00000000 --- a/testgen/ui/components/widgets/button.py +++ /dev/null @@ -1,56 +0,0 @@ -import typing - -from testgen.ui.components.utils.component import component - -ButtonType = typing.Literal["basic", "flat", "icon", "stroked"] -ButtonColor = typing.Literal["basic", "primary", "warn"] -TooltipPosition = typing.Literal["left", "right"] - - -def button( - type_: ButtonType = "basic", - color: ButtonColor | None = None, - label: str | None = None, - icon: str | None = None, - icon_size: int | None = None, - tooltip: str | None = None, - tooltip_position: TooltipPosition = "left", - on_click: typing.Callable[..., None] | None = None, - disabled: bool = False, - width: str | int | float | None = None, - style: str | None = None, - key: str | None = None, -) -> typing.Any: - """ - Testgen component to create custom styled buttons. - - # Parameters - :param key: unique key to give the component a persisting state - :param icon: icon name of material rounded icon fonts - :param on_click: click handler for this button - """ - color_ = color or "primary" - if not color and type_ == "icon": - color_ = "basic" - - props = {"type": type_, "disabled": disabled, "color": color_} - if type_ != "icon": - if not label: - raise ValueError(f"A label is required for {type_} buttons") - props.update({"label": label}) - - if icon: - props.update({"icon": icon, "iconSize": icon_size}) - - if tooltip: - props.update({"tooltip": tooltip, "tooltipPosition": tooltip_position}) - - if width: - props.update({"width": width}) - if isinstance(width, int | float): - props.update({"width": f"{width}px"}) - - if style: - props.update({"style": style}) - - return component(id_="button", key=key, props=props, on_change=on_click) diff --git a/testgen/ui/components/widgets/empty_state.py b/testgen/ui/components/widgets/empty_state.py deleted file mode 100644 index 726b639a..00000000 --- a/testgen/ui/components/widgets/empty_state.py +++ /dev/null @@ -1,88 +0,0 @@ -import typing -from enum import Enum - -import streamlit as st - -from testgen.ui.components.widgets.button import button -from testgen.ui.components.widgets.link import link -from testgen.ui.components.widgets.page import css_class, whitespace - -DISABLED_ACTION_TEXT = "You do not have permissions to perform this action. Contact your administrator." - - -class EmptyStateMessage(Enum): - Connection = ( - "Begin by connecting your database.", - "TestGen delivers data quality through data profiling, hygiene review, test generation, and test execution.", - ) - TableGroup = ( - "Profile your tables to detect hygiene issues", - "Create table groups for your connected databases to run data profiling and hygiene review.", - ) - Profiling = ( - "Profile your tables to detect hygiene issues", - "Run data profiling on your table groups to understand data types, column contents, and data patterns.", - ) - TestSuite = ( - "Run data validation tests", - "Automatically generate tests from data profiling results or write custom tests for your business rules.", - ) - TestExecution = ( - "Run data validation tests", - "Execute tests to assess data quality of your tables." - ) - - -def empty_state( - label: str, - icon: str, - message: EmptyStateMessage, - action_label: str, - action_disabled: bool = False, - link_href: str | None = None, - link_params: dict | None = None, - button_onclick: typing.Callable[..., None] | None = None, - button_icon: str = "add", -) -> None: - with st.container(border=True): - css_class("bg-white") - whitespace(5) - st.html(f""" -
-

{label}

-

{icon}

-

{message.value[0]}
{message.value[1]}

-
- """) - _, center_column, _ = st.columns([.4, .3, .4]) - with center_column: - if link_href: - link( - label=action_label, - href=link_href, - params=link_params or {}, - right_icon="chevron_right", - underline=False, - height=40, - style=f""" - margin: auto; - border-radius: 4px; - border: var(--button-stroked-border); - padding: 8px 8px 8px 16px; - color: {"var(--disabled-text-color)" if action_disabled else "var(--primary-color)"}; - """, - disabled=action_disabled, - tooltip=DISABLED_ACTION_TEXT if action_disabled else None, - ) - elif button_onclick: - button( - type_="stroked" if action_disabled else "flat", - color="basic" if action_disabled else "primary", - label=action_label, - icon=button_icon, - on_click=button_onclick, - style="margin: auto; width: auto;", - disabled=action_disabled, - tooltip=DISABLED_ACTION_TEXT if action_disabled else None, - ) - whitespace(5) diff --git a/testgen/ui/components/widgets/expander_toggle.py b/testgen/ui/components/widgets/expander_toggle.py deleted file mode 100644 index 21f6dcb2..00000000 --- a/testgen/ui/components/widgets/expander_toggle.py +++ /dev/null @@ -1,30 +0,0 @@ -import streamlit as st - -from testgen.ui.components.utils.component import component - - -def expander_toggle( - default: bool = False, - expand_label: str | None = None, - collapse_label: str | None = None, - key: str = "testgen:expander_toggle", -) -> bool: - """ - Testgen component to display a toggle for an expandable container. - - # Parameters - :param default: default state for the component, default=False (collapsed) - :param expand_label: label for collapsed state, default="Expand" - :param collapse_label: label for expanded state, default="Collapse" - :param key: unique key to give the component a persisting state - """ - - if key in st.session_state: - default = st.session_state[key] - - return component( - id_="expander_toggle", - key=key, - default=default, - props={"default": default, "expandLabel": expand_label, "collapseLabel": collapse_label}, - ) diff --git a/testgen/ui/components/widgets/link.py b/testgen/ui/components/widgets/link.py deleted file mode 100644 index 431af75d..00000000 --- a/testgen/ui/components/widgets/link.py +++ /dev/null @@ -1,59 +0,0 @@ -import typing - -from testgen.ui.components.utils.component import component -from testgen.ui.navigation.router import Router -from testgen.ui.session import session - -TooltipPosition = typing.Literal["left", "right"] - - -def link( - href: str, - label: str, - *, - params: dict = {}, # noqa: B006 - open_new: bool = False, - underline: bool = True, - left_icon: str | None = None, - left_icon_size: float = 20.0, - right_icon: str | None = None, - right_icon_size: float = 20.0, - height: float | None = 21.0, - width: float | None = None, - style: str | None = None, - disabled: bool = False, - tooltip: str | None = None, - tooltip_position: TooltipPosition = "left", - key: str = "testgen:link", -) -> None: - props = { - "href": href, - "params": params, - "label": label, - "height": height, - "open_new": open_new, - "underline": underline, - "disabled": disabled, - } - if left_icon: - props.update({"left_icon": left_icon, "left_icon_size": left_icon_size}) - - if right_icon: - props.update({"right_icon": right_icon, "right_icon_size": right_icon_size}) - - if style: - props.update({"style": style}) - - if width: - props.update({"width": width}) - - if tooltip: - props.update({"tooltip": tooltip, "tooltipPosition": tooltip_position}) - - clicked = component(id_="link", key=key, props=props) - if clicked: - # Prevent handling the same event multiple times - event_id = clicked.get("_id") - if event_id != session.link_event_id: - session.link_event_id = event_id - Router().navigate(to=href, with_args=params) diff --git a/testgen/ui/components/widgets/page.py b/testgen/ui/components/widgets/page.py index d1cb6cd7..737f4a55 100644 --- a/testgen/ui/components/widgets/page.py +++ b/testgen/ui/components/widgets/page.py @@ -1,5 +1,6 @@ import logging import os +import typing from datetime import date import streamlit as st @@ -8,9 +9,6 @@ import testgen.common.logs as logs from testgen import settings from testgen.common import version_service -from testgen.ui.components.widgets.breadcrumbs import Breadcrumb -from testgen.ui.components.widgets.breadcrumbs import breadcrumbs as tg_breadcrumbs -from testgen.ui.components.widgets.testgen_component import testgen_component from testgen.ui.services.rerun_service import safe_rerun from testgen.ui.session import session @@ -19,6 +17,12 @@ APP_LOGS_DIALOG_KEY = "app_logs:dialog" +class Breadcrumb(typing.TypedDict): + path: str | None + params: dict + label: str + + def page_header( title: str, help_topic: str | None = None, @@ -32,7 +36,8 @@ def page_header( no_flex_gap() st.html(f'

{title}

') if breadcrumbs: - tg_breadcrumbs(breadcrumbs=breadcrumbs) + from testgen.ui.components.widgets import breadcrumbs_widget + breadcrumbs_widget(key="breadcrumbs", data={"breadcrumbs": breadcrumbs}) with links_column: help_menu(help_topic) @@ -113,9 +118,10 @@ def open_app_logs(): flex_row_end() with st.popover("Help"): css_class("tg-header--help-wrapper") - testgen_component( - "help_menu", - props={ + from testgen.ui.components.widgets import help_menu_widget + help_menu_widget( + key="help_menu", + data={ "help_topic": help_topic, "support_email": settings.SUPPORT_EMAIL, "version": version.__dict__, @@ -123,12 +129,8 @@ def open_app_logs(): "can_edit": session.auth.user_has_permission("edit"), }, }, - on_change_handlers={ - "AppLogsClicked": lambda _: open_app_logs(), - }, - event_handlers={ - "ExternalLinkClicked": lambda _: close_help(rerun=True), - }, + on_AppLogsClicked_change=lambda _: open_app_logs(), + on_ExternalLinkClicked_change=lambda _: close_help(rerun=True), ) diff --git a/testgen/ui/components/widgets/paginator.py b/testgen/ui/components/widgets/paginator.py deleted file mode 100644 index 5a71b30b..00000000 --- a/testgen/ui/components/widgets/paginator.py +++ /dev/null @@ -1,47 +0,0 @@ -import math -import typing - -import streamlit as st - -from testgen.ui.components.utils.component import component -from testgen.ui.navigation.router import Router - - -def paginator( - count: int, - page_size: int, - page_index: int | None = None, - bind_to_query: str | None = None, - on_change: typing.Callable | None = None, - key: str = "testgen:paginator", -) -> bool: - """ - Testgen component to display pagination arrows. - - # Parameters - :param count: total number of items being paginated - :param page_size: number of items displayed per page - :param page_index: index of initial page displayed, default=0 (first page) - :param key: unique key to give the component a persisting state - """ - - def on_page_change(): - if bind_to_query: - if event_data := st.session_state[key]: - Router().set_query_params({ bind_to_query: event_data.get("page_index", 0) }) - if on_change: - on_change() - - if page_index is None and bind_to_query is not None: - bound_value = st.query_params.get(bind_to_query, "") - page_index = int(bound_value) if bound_value.isdigit() else 0 - page_index = page_index if page_index < math.ceil(count / page_size) else 0 - - event_data = component( - id_="paginator", - key=key, - default={ page_index: page_index }, - props={"count": count, "pageSize": page_size, "pageIndex": page_index}, - on_change=on_page_change, - ) - return event_data.get("page_index", 0) diff --git a/testgen/ui/components/widgets/sidebar.py b/testgen/ui/components/widgets/sidebar.py index f847dac0..6806d22c 100644 --- a/testgen/ui/components/widgets/sidebar.py +++ b/testgen/ui/components/widgets/sidebar.py @@ -5,7 +5,6 @@ from testgen.common.models import with_database_session from testgen.common.models.project import Project from testgen.common.version_service import Version -from testgen.ui.components.utils.component import component from testgen.ui.navigation.menu import Menu from testgen.ui.navigation.router import Router from testgen.ui.session import session @@ -38,10 +37,12 @@ def sidebar( :param current_page: page address to highlight the selected item :param global_context: when True, renders admin-only sidebar (no project nav) """ - component( - id_="sidebar", - props={ - "projects": [ {"code": item.project_code, "name": item.project_name} for item in projects ], + from testgen.ui.components.widgets import sidebar_widget + + sidebar_widget( + key=key, + data={ + "projects": [{"code": item.project_code, "name": item.project_name} for item in projects], "current_project": current_project, "menu": menu.filter_for_current_user().sort_items().unflatten().asdict(), "current_page": current_page, @@ -53,27 +54,16 @@ def sidebar( "global_context": global_context, "is_global_admin": is_global_admin, }, - key=key, - on_change=on_change, + on_Navigate_change=_on_navigate, ) @with_database_session -def on_change(): - # We cannot navigate directly here - # because st.switch_page uses st.rerun under the hood - # and we get a "Calling st.rerun() within a callback is a noop" error - # So we store the path and navigate on the next run - - event_data = getattr(session, SIDEBAR_KEY) - - # Prevent handling the same event multiple times - event_id = event_data.get("_id") - if event_id == session.sidebar_event_id: +def _on_navigate(payload: dict | None) -> None: + if not payload: return - session.sidebar_event_id = event_id - if event_data.get("path") == LOGOUT_PATH: + if payload.get("path") == LOGOUT_PATH: session.auth.end_user_session() # This hack is needed because the auth cookie does not immediately get cleared # We don't want to try to load the session again on the next run @@ -81,14 +71,14 @@ def on_change(): # streamlit_authenticator sets authentication_status implicitly # So we need to clear it session.authentication_status = None - + Router().queue_navigation(to="") - # Without the time.sleep, cookies sometimes don't get cleared on deployed instances + # Without the time.sleep, cookies sometimes don't get cleared on deployed instances # (even though it works fine locally) time.sleep(0.3) else: - query_params = event_data.get("params", {}) + query_params = payload.get("params", {}) Router().queue_navigation( - to=event_data.get("path") or session.auth.get_default_page(project_code=query_params.get("project_code")), + to=payload.get("path") or session.auth.get_default_page(project_code=query_params.get("project_code")), with_args=query_params, ) diff --git a/testgen/ui/components/widgets/sorting_selector.py b/testgen/ui/components/widgets/sorting_selector.py deleted file mode 100644 index 5dd1cc95..00000000 --- a/testgen/ui/components/widgets/sorting_selector.py +++ /dev/null @@ -1,106 +0,0 @@ -import itertools -import re -from collections.abc import Callable, Iterable -from typing import Any - -import streamlit as st - -from testgen.ui.components.utils.component import component -from testgen.ui.navigation.router import Router - - -def _slugfy(text) -> str: - return re.sub(r"[^a-z]+", "-", text.lower()) - - -def _state_to_str(columns, state): - state_parts = [] - state_dict = dict(state) - try: - for col_label, col_id in columns: - if col_id in state_dict: - state_parts.append(".".join((_slugfy(col_label), state_dict[col_id].lower()))) - return "-".join(state_parts) or "-" - except Exception: - return None - - -def _state_from_str(columns, state_str): - col_slug_to_id = {_slugfy(col_label): col_id for col_label, col_id in columns} - state_part_re = re.compile("".join(("(", "|".join(col_slug_to_id.keys()), r")\.(asc|desc)"))) - state = [ - [col_slug_to_id[col_slug], direction.upper()] - for col_slug, direction - in state_part_re.findall(state_str) - ] - return state - - -def sorting_selector( - columns: Iterable[tuple[str, str]], - default: Iterable[tuple[str, str]] = (), - on_change: Callable[[], Any] | None = None, - popover_label: str = "Sort", - query_param: str | None = "sort", - key: str = "testgen:sorting_selector", -) -> list[tuple[str, str]]: - """ - Renders a pop over that, when clicked, shows a list of database columns to be selected for sorting. - - # Parameters - :param columns: Iterable of 2-tuples, being: (, ) - :param default: Iterable of 2-tuples, being: (, ) - :param on_change: Callable that will be called when the component state is updated - :param popover_label: Label to be applied to the pop-over button. Default: 'Sort' - :param query_param: Name of the query parameter that will store the component state. Can be disabled by setting - to None. Default: 'sort'. - :param key: unique key to give the component a persisting state - - # Return value - Returns a list of 2-tuples, being: (, ) - """ - - state = None - - try: - state = st.session_state[key] - except KeyError: - pass - - if state is None and query_param and (state_str := st.query_params.get(query_param)): - state = _state_from_str(columns, state_str) - - if state is None: - state = default - - popover_container = st.empty() - - def handle_change() -> None: - if on_change: - on_change() - - # Hack to programmatically close popover: https://github.com/streamlit/streamlit/issues/8265#issuecomment-3001655849 - with popover_container.container(): - st.button(label=f"{popover_label} :material/keyboard_arrow_up:", disabled=True) - - with popover_container.container(): - with st.popover(popover_label): - new_state = component( - id_="sorting_selector", - key=key, - default=state, - on_change=handle_change, - props={"columns": columns, "state": state}, - ) - - # For some unknown reason, sometimes, streamlit returns None as the component state - new_state = [] if new_state is None else new_state - - if query_param: - if tuple(itertools.chain(*default)) == tuple(itertools.chain(*new_state)): - value = None - else: - value = _state_to_str(columns, new_state) - Router().set_query_params({query_param: value}) - - return new_state diff --git a/testgen/ui/components/widgets/testgen_component.py b/testgen/ui/components/widgets/testgen_component.py deleted file mode 100644 index 93dbe523..00000000 --- a/testgen/ui/components/widgets/testgen_component.py +++ /dev/null @@ -1,77 +0,0 @@ -import typing - -import streamlit as st - -from testgen.common.models import with_database_session -from testgen.ui.components.utils.component import component -from testgen.ui.navigation.router import Router -from testgen.ui.session import session - -AvailablePages = typing.Literal[ - "data_catalog", - "column_profiling_results", - "project_dashboard", - "profiling_runs", - "test_runs", - "test_suites", - "quality_dashboard", - "score_details", - "schedule_list", - "column_selector", - "connections", - "table_group_wizard", - "help_menu", - "notification_settings", - "import_metadata_dialog", -] - - -def testgen_component( - component_id: AvailablePages, - props: dict, - on_change_handlers: dict[str, typing.Callable] | None = None, - event_handlers: dict[str, typing.Callable] | None = None, -) -> dict | None: - """ - Testgen component to display a VanJS page. - - # Parameters - :param component_id: name of page - :param props: properties expected by the page - :param on_change_handlers: event handlers to be called during on_change callback (recommended, but does not support calling st.rerun()) - :param event_handlers: event handlers to be called on next run (supports calling st.rerun()) - - For both on_change_handlers and event_handlers, the "payload" data from the event is passed as the only argument to the callback function - """ - - key = f"testgen:{component_id}" - - @with_database_session - def on_change(): - event_data = st.session_state[key] - if event_data and (event := event_data.get("event")): - if event == "LinkClicked": - Router().queue_navigation(to=event_data["href"], with_args=event_data.get("params")) - elif on_change_handlers and (handler := on_change_handlers.get(event)): - # Prevent handling the same event multiple times - event_id = event_data.get("_id", "") - if event_id != session.testgen_event_id.get(component_id): - session.testgen_event_id[component_id] = event_id - handler(event_data.get("payload")) - - event_data = component( - id_=component_id, - key=key, - props=props, - on_change=on_change, - ) - if event_handlers and event_data and (event := event_data.get("event")) and (handler := event_handlers.get(event)): - # Prevent handling the same event multiple times - event_id = event_data.get("_id", "") - if event_id != session.testgen_event_id.get(component_id): - session.testgen_event_id[component_id] = event_id - # These events are not handled through the component's on_change callback - # because they may call st.rerun(), causing the "Calling st.rerun() within a callback is a noop" error - handler(event_data.get("payload")) - - return event_data diff --git a/testgen/ui/components/widgets/wizard.py b/testgen/ui/components/widgets/wizard.py deleted file mode 100644 index 31baeaa3..00000000 --- a/testgen/ui/components/widgets/wizard.py +++ /dev/null @@ -1,214 +0,0 @@ -import dataclasses -import inspect -import logging -import typing - -import streamlit as st -from streamlit.delta_generator import DeltaGenerator - -from testgen.ui.components import widgets as testgen -from testgen.ui.navigation.router import Router -from testgen.ui.services.rerun_service import safe_rerun -from testgen.ui.session import temp_value - -ResultsType = typing.TypeVar("ResultsType", bound=typing.Any | None) -StepResults = tuple[typing.Any, bool] -logger = logging.getLogger("testgen") - - -def wizard( - *, - key: str, - steps: list[typing.Callable[..., StepResults] | "WizardStep"], - on_complete: typing.Callable[..., bool], - complete_label: str = "Complete", - navigate_to: str | None = None, - navigate_to_args: dict | None = None, -) -> None: - """ - Creates a Wizard with the provided steps and handles the session for - each step internally. - - For each step callable instances of WizardStep for the current step - and previous steps are optionally provided as keyword arguments with - specific names. - - Optional arguments that can be accessed as follows: - - ``` - def step_fn(current_step: WizardStep = ..., step_0: WizardStep = ...) - ... - ``` - - For the `on_complete` callable, on top of passing each WizardStep, a - Streamlit DeltaGenerator is also passed to allow rendering content - inside the step's body. - - ``` - def on_complete(container: DeltaGenerator, step_0: WizardStep = ..., step_1: WizardStep = ...): - ... - ``` - - After the `on_complete` callback returns, the wizard state is reset. - - :param key: used to cache current step and results of each step - :param steps: a list of WizardStep instances or callable objects - :param on_complete: callable object to execute after the last step. - should return true to trigger a Streamlit rerun - :param complete_label: customize the label for the complete button - - :return: None - """ - - if navigate_to: - Router().navigate(navigate_to, navigate_to_args or {}) - - current_step_idx = 0 - wizard_state = st.session_state.get(key) - if isinstance(wizard_state, int): - current_step_idx = wizard_state - - instance = Wizard( - key=key, - steps=[ - WizardStep( - key=f"{key}:{idx}", - body=step, - results=st.session_state.get(f"{key}:{idx}", None), - ) if not isinstance(step, WizardStep) else dataclasses.replace( - step, - key=f"{key}:{idx}", - results=st.session_state.get(f"{key}:{idx}", None), - ) - for idx, step in enumerate(steps) - ], - current_step=current_step_idx, - on_complete=on_complete, - ) - - current_step = instance.current_step - current_step_index = instance.current_step_index - testgen.caption( - f"Step {current_step_index + 1} of {len(steps)}{': ' + current_step.title if current_step.title else ''}" - ) - - step_body_container = st.empty() - with step_body_container.container(): - was_complete_button_clicked, set_complete_button_clicked = temp_value(f"{key}:complete-button") - - if was_complete_button_clicked(): - instance.complete(step_body_container) - else: - instance.render() - button_left_column, _, button_right_column = st.columns([0.30, 0.40, 0.30]) - with button_left_column: - if not instance.is_first_step(): - testgen.button( - type_="stroked", - color="basic", - label="Previous", - on_click=lambda: instance.previous(), - key=f"{key}:button-prev", - ) - - with button_right_column: - next_button_label = complete_label if instance.is_last_step() else "Next" - - testgen.button( - type_="stroked" if not instance.is_last_step() else "flat", - label=next_button_label, - on_click=lambda: set_complete_button_clicked(instance.next() or instance.is_last_step()), - key=f"{key}:button-next", - disabled=not current_step.is_valid, - ) - - -class Wizard: - def __init__( - self, - *, - key: str, - steps: list["WizardStep"], - on_complete: typing.Callable[..., bool] | None = None, - current_step: int = 0, - ) -> None: - self._key = key - self._steps = steps - self._current_step = current_step - self._on_complete = on_complete - - @property - def current_step(self) -> "WizardStep": - return self._steps[self._current_step] - - @property - def current_step_index(self) -> int: - return self._current_step - - def next(self) -> None: - next_step = self._current_step + 1 - if not self.is_last_step(): - st.session_state[self._key] = next_step - return - - def previous(self) -> None: - previous_step = self._current_step - 1 - if previous_step > -1: - st.session_state[self._key] = previous_step - - def is_first_step(self) -> bool: - return self._current_step == 0 - - def is_last_step(self) -> bool: - return self._current_step == len(self._steps) - 1 - - def complete(self, container: DeltaGenerator) -> None: - if self._on_complete: - signature = inspect.signature(self._on_complete) - accepted_params = [param.name for param in signature.parameters.values()] - kwargs: dict = { - key: step for idx, step in enumerate(self._steps) - if (key := f"step_{idx}") and key in accepted_params - } - if "container" in accepted_params: - kwargs["container"] = container - - do_rerun = self._on_complete(**kwargs) - self._reset() - if do_rerun: - safe_rerun() - - def _reset(self) -> None: - del st.session_state[self._key] - for step_idx in range(len(self._steps)): - del st.session_state[f"{self._key}:{step_idx}"] - - def render(self) -> None: - step = self._steps[self._current_step] - - extra_args = {"current_step": step} - extra_args.update({f"step_{idx}": step for idx, step in enumerate(self._steps)}) - - signature = inspect.signature(step.body) - step_accepted_params = [param.name for param in signature.parameters.values() if param.name in extra_args] - extra_args = {key: value for key, value in extra_args.items() if key in step_accepted_params} - - try: - results, is_valid = step.body(**extra_args) - except TypeError as error: - logger.exception("Error on wizard step %s", self._current_step, exc_info=True, stack_info=True) - results, is_valid = None, True - - step.results = results - step.is_valid = is_valid - - st.session_state[f"{self._key}:{self._current_step}"] = step.results - - -@dataclasses.dataclass(kw_only=True, slots=True) -class WizardStep(typing.Generic[ResultsType]): - body: typing.Callable[..., StepResults] - results: ResultsType = dataclasses.field(default=None) - title: str = dataclasses.field(default="") - key: str | None = dataclasses.field(default=None) - is_valid: bool = dataclasses.field(default=True) diff --git a/testgen/ui/services/form_service.py b/testgen/ui/services/form_service.py index 948d65a1..931e3e47 100644 --- a/testgen/ui/services/form_service.py +++ b/testgen/ui/services/form_service.py @@ -1,65 +1,4 @@ -import json -import typing -from builtins import float -from pathlib import Path -from time import sleep - -import pandas as pd import streamlit as st -from pandas.api.types import is_datetime64_any_dtype -from st_aggrid import AgGrid, ColumnsAutoSizeMode, DataReturnMode, GridOptionsBuilder, GridUpdateMode, JsCode - -from testgen.ui.components import widgets as testgen -from testgen.ui.navigation.router import Router -from testgen.ui.services.rerun_service import safe_rerun - -""" -Shared rendering of UI elements -""" - -logo_file = (Path(__file__).parent.parent / "assets/dk_logo.svg").as_posix() -help_icon = (Path(__file__).parent.parent / "assets/question_mark.png").as_posix() - - -def render_refresh_button(button_container): - with button_container: - do_refresh = st.button(":material/refresh:", help="Refresh page data", use_container_width=False) - if do_refresh: - reset_post_updates("Refreshing page", as_toast=True) - - -def show_prompt(str_prompt=None): - if str_prompt: - st.markdown(f":blue[{str_prompt}]") - - -def show_subheader(str_text=None): - if str_text: - st.subheader(f":green[{str_text}]") - - -def _show_section_header(str_section_header=None): - if str_section_header: - st.markdown(f":green[**{str_section_header}**]") - - -def ut_prettify_header(str_header, expand=False): - # First drop underscores and make title-case - str_new = str_header.replace("_", " ").title() - - if expand: - # Second, expand abbreviaqtions - PRETTY_DICT = { - " Ct": " Count", - "Min ": "Minimum ", - "Max ": "Maximum ", - "Avg ": "Average ", - "Std ": "Standard ", - } - for old, new in PRETTY_DICT.items(): - str_new = str_new.replace(old, new) - - return str_new def reset_post_updates(str_message=None, as_toast=False, style="success"): @@ -70,347 +9,3 @@ def reset_post_updates(str_message=None, as_toast=False, style="success"): getattr(st, style)(str_message) else: st.success(str_message) - sleep(1.5) - - safe_rerun() - - -def render_html_list(dct_row, lst_columns, str_section_header=None, int_data_width=300, lst_labels=None): - # Renders sets of values as vertical markdown list - - if str_section_header: - # Header - _show_section_header(str_section_header) - - # Subtract the padding-left and right from the width - if int_data_width > 0: - int_data_width += -20 - - str_block = "block" if int_data_width == 0 else "inline-block" - - str_markdown = """ - -""" - str_data_width = "100%" if int_data_width == 0 else f"{int_data_width}px" - str_markdown = str_markdown.replace("<>", str_data_width) - str_markdown = str_markdown.replace("<>", str_block) - - # Prep labels - if not lst_labels: - lst_labels = [ut_prettify_header(label, expand=True) for label in lst_columns] - - for col, label in zip(lst_columns, lst_labels, strict=True): - str_use_class = "num" if type(dct_row[col]) is (int | float) else "text" - str_markdown += f"""
{label}{dct_row[col]!s}
""" - - with st.container(): - st.html(str_markdown) - st.divider() - - -def render_grid_select( - df: pd.DataFrame, - columns: list[str], - column_headers: list[str] | None = None, - id_column: str | None = None, - selection_mode: typing.Literal["single", "multiple", "disabled"] = "single", - page_size: int = 500, - reset_pagination: bool = False, - bind_to_query: bool = False, - render_highlights: bool = True, - column_styles: dict[str, dict] | None = None, - key: str = "aggrid", -) -> tuple[list[dict], dict]: - """ - :param selection_mode: one of single, multiple or disabled. defaults - to single. - :param bind_to_query: whether to bind the selected row and page to - query params. - :param key: Streamlit cache key for the grid. required when binding - selection to query. - """ - if selection_mode != "disabled" and not id_column: - raise ValueError("id_column is required when using 'single' or 'multiple' selection mode") - - # Set grid formatting - cellstyle_jscode = JsCode( - """ -function(params) { - let style = { - 'text-align': 'center', - 'vertical-align': 'middle', - 'border': '2px solid', - 'borderRadius': '15px', - 'display': 'inline-block' - }; - - if (['Failed', 'Error'].includes(params.value)) { - style.color = 'black'; - style.borderColor = 'var(--ag-odd-row-background-color)'; - style.backgroundColor = "mistyrose"; - style.fontWeight = 'bolder'; - style.display = 'flex'; - style.alignItems = 'center'; - style.justifyContent = 'center'; - return style; - } else if (params.value === 'Warning') { - style.color = 'black'; - style.borderColor = 'var(--ag-odd-row-background-color)'; - style.backgroundColor = "seashell"; - style.display = 'flex'; - style.alignItems = 'center'; - style.justifyContent = 'center'; - return style; - } else if (params.value === 'Passed') { - style.color = 'black'; - style.borderColor = 'var(--ag-odd-row-background-color)'; - style.backgroundColor = "honeydew"; - style.display = 'flex'; - style.alignItems = 'center'; - style.justifyContent = 'center'; - return style; - } else if (params.value === 'Log') { - style.color = 'black'; - style.borderColor = 'var(--ag-odd-row-background-color)'; - style.backgroundColor = "#2196F3"; - style.display = 'flex'; - style.alignItems = 'center'; - style.justifyContent = 'center'; - return style; - } else if (params.value === '✓') { - return { -// 'color': 'green', - 'text-align' : 'center', - 'fontWeight' : 'bolder', - 'fontSize' : "1.2em", - }; - } else if (params.value === '✘') { - return { -// 'color': 'red', - 'text-align' : 'center', - 'fontWeight' : 'bolder', - 'fontSize' : "1.2em", - }; - } else if (params.value === '🚫') { - return { - 'text-align' : 'center', - 'fontWeight' : 'bolder', - 'fontSize' : "1.2em", - }; - } else if (params.value === '🔇') { - return { - 'text-align' : 'center', -// 'fontWeight' : 'bolder', - 'fontSize' : "1.2em", - }; -} else if (params.value === '⌀') { - return { - 'color': 'gray', - 'text-align' : 'center', - 'fontWeight' : 'bolder', - 'fontSize' : "1.2em", - } - } -} -""" - ) - data_changed: bool = True - rendering_counter = st.session_state.get(f"{key}_counter") or 0 - previous_dataframe = st.session_state.get(f"{key}_dataframe") - - if previous_dataframe is not None: - data_changed = not df.equals(previous_dataframe) - - page_changed = st.session_state.get(f"{key}_page_change", False) - if page_changed: - st.session_state[f"{key}_page_change"] = False - - grid_container = st.container() - selected_column, paginator_column = st.columns([.5, .5]) - with paginator_column: - def on_page_change(): - # Ignore the on_change event fired during paginator initialization - if st.session_state.get(f"{key}_paginator_loaded", False): - st.session_state[f"{key}_page_change"] = True - else: - st.session_state[f"{key}_paginator_loaded"] = True - - page_index = testgen.paginator( - count=len(df), - page_size=page_size, - page_index=0 if reset_pagination else None, - bind_to_query="page" if bind_to_query else None, - on_change=on_page_change, - key=f"{key}_paginator", - ) - # Prevent flickering data when filters are changed (which triggers 2 reruns - one from filter and another from paginator) - page_index = 0 if reset_pagination else page_index - paginated_df = df.iloc[page_size * page_index : page_size * (page_index + 1)] - - dct_col_to_header = dict(zip(columns, column_headers, strict=True)) if column_headers else None - - gb = GridOptionsBuilder.from_dataframe(paginated_df) - - pre_selected_rows: typing.Any = {} - if selection_mode == "single" and bind_to_query: - bound_value = st.query_params.get("selected") - bound_items = paginated_df[paginated_df[id_column] == bound_value] - if len(bound_items) > 0: - # https://github.com/PablocFonseca/streamlit-aggrid/issues/207#issuecomment-1793039564 - pre_selected_rows = {str(bound_value): True} - else: - if data_changed and st.query_params.get("selected"): - rendering_counter += 1 - Router().set_query_params({"selected": None}) - - selection = set() - if selection_mode == "multiple": - selection = st.session_state.get(f"{key}_multiselection", set()) - pre_selected_rows = {str(item): True for item in selection} - - gb.configure_selection( - selection_mode=selection_mode, - use_checkbox=selection_mode == "multiple", - pre_selected_rows=pre_selected_rows, - ) - - if id_column: - gb.configure_grid_options(getRowId=JsCode(f"function(row) {{ return row.data['{id_column}'] }}")) - - all_columns = list(paginated_df.columns) - - for column in all_columns: - # Define common kwargs for all columns: NOTE THAT FIRST COLUMN HOLDS CHECKBOX AND SHOULD BE SHOWN! - str_header = dct_col_to_header.get(column) if dct_col_to_header else None - common_kwargs = { - "field": column, - "header_name": str_header if str_header else ut_prettify_header(column), - "hide": column not in columns, - "headerCheckboxSelection": selection_mode == "multiple" and column == columns[0], - "headerCheckboxSelectionFilteredOnly": selection_mode == "multiple" and column == columns[0], - "sortable": False, - "filter": False, - } - highlight_kwargs = { - "cellStyle": cellstyle_jscode, - "cellClassRules": { - "status-tag": JsCode( - "function(params) { return ['Failed', 'Error', 'Warning', 'Passed', 'Log'].includes(params.value); }", - ), - }, - } - - # Check if the column is a date-time column - if is_datetime64_any_dtype(paginated_df[column]): - if (paginated_df[column].dt.time == pd.Timestamp("00:00:00").time()).all(): - format_string = "yyyy-MM-dd" - else: - format_string = "yyyy-MM-dd HH:mm" - # Additional kwargs for date-time columns - date_time_kwargs = {"type": ["customDateTimeFormat"], "custom_format_string": format_string} - - # Merge common and date-time specific kwargs - all_kwargs = {**common_kwargs, **date_time_kwargs} - elif column_styles and column in column_styles: - all_kwargs = {**common_kwargs, "cellStyle": column_styles[column]} - else: - if render_highlights == True: - # Merge common and highlight-specific kwargs - all_kwargs = {**common_kwargs, **highlight_kwargs} - else: - all_kwargs = common_kwargs - - # Apply configuration using kwargs - gb.configure_column(**all_kwargs) - - # Render Grid: custom_css fixes spacing bug and tightens empty space at top of grid - with grid_container: - grid_options = gb.build() - grid_data = AgGrid( - paginated_df.copy(), - gridOptions=grid_options, - theme="balham", - enable_enterprise_modules=False, - allow_unsafe_jscode=True, - update_mode=GridUpdateMode.NO_UPDATE, - update_on=["selectionChanged"], - data_return_mode=DataReturnMode.FILTERED_AND_SORTED, - columns_auto_size_mode=ColumnsAutoSizeMode.FIT_CONTENTS, - height=400, - custom_css={ - "#gridToolBar": { - "padding-bottom": "0px !important", - }, - ".ag-row-hover .ag-cell.status-tag": { - "border-color": "var(--ag-row-hover-color) !important", - }, - ".ag-row-selected .ag-cell.status-tag": { - "border-color": "var(--ag-selected-row-background-color) !important", - }, - }, - key=f"{key}_{page_index}_{selection_mode}_{rendering_counter}", - reload_data=data_changed, - ) - - st.session_state[f"{key}_counter"] = rendering_counter - st.session_state[f"{key}_dataframe"] = df - - if selection_mode != "disabled": - selected_rows = grid_data["selected_rows"] - # During page change, there are 2 reruns and the first one does not return the selected rows - # So we ignore that run to prevent flickering the selected count - if not page_changed: - selection.difference_update(paginated_df[id_column].to_list()) - selection.update([row[id_column] for row in selected_rows]) - st.session_state[f"{key}_multiselection"] = selection - - if selection: - # We need to get the data from the original dataframe - # Otherwise changes to the dataframe (e.g., editing the current selection) do not get reflected in the returned rows - # Adding "modelUpdated" to AgGrid(update_on=...) does not work - # because it causes unnecessary reruns that cause dialogs to close abruptly - selected_df = df[df[id_column].isin(selection)] - selected_data = json.loads(selected_df.to_json(orient="records")) - - selected_id, selected_item = None, None - if selected_rows: - selected_id = selected_rows[len(selected_rows) - 1][id_column] - selected_item = next((item for item in selected_data if item[id_column] == selected_id), None) - if bind_to_query: - Router().set_query_params({"selected": selected_id}) - - if selection_mode == "multiple" and (count := len(selected_data)): - with selected_column: - testgen.caption(f"{count} item{'s' if count != 1 else ''} selected") - - return selected_data, selected_item - - return None, None diff --git a/testgen/ui/static/css/style.css b/testgen/ui/static/css/style.css index 05f5768c..01dee345 100644 --- a/testgen/ui/static/css/style.css +++ b/testgen/ui/static/css/style.css @@ -109,11 +109,6 @@ section.stSidebar > [data-testid="stSidebarContent"] { overflow: visible; } -[data-testid="stSidebarNav"], -[data-testid="stSidebarUserContent"] { - display: none; -} - .stAppViewContainer:has(.tg-no-project) > .stSidebar { display: none; } diff --git a/testgen/ui/static/js/components/sorting_selector.js b/testgen/ui/static/js/components/sorting_selector.js deleted file mode 100644 index caf9b161..00000000 --- a/testgen/ui/static/js/components/sorting_selector.js +++ /dev/null @@ -1,256 +0,0 @@ -import {Streamlit} from "../streamlit.js"; -import van from '../van.min.js'; -import { loadStylesheet } from '../utils.js'; - -/** - * @typedef ColDef - * @type {Array.} - * - * @typedef StateItem - * @type {Array.} - * - * @typedef Properties - * @type {object} - * @property {Array.} columns - * @property {Array.} state - */ -const { button, div, i, span } = van.tags; - -const SortingSelector = (/** @type {Properties} */ props) => { - loadStylesheet('sortingSelector', stylesheet); - - let defaultDirection = "ASC"; - - const columns = props.columns.val; - const prevComponentState = props.state.val || []; - - const columnLabel = columns.reduce((acc, [colLabel, colId]) => ({ ...acc, [colId]: colLabel}), {}); - - const componentState = columns.reduce( - (state, [colLabel, colId]) => ( - { ...state, [colId]: van.state(prevComponentState[colId] || { direction: "ASC", order: null })} - ), - {} - ); - - const directionIcons = { - ASC: `arrow_upward`, - DESC: `arrow_downward`, - } - - const activeColumnItem = (colId) => { - const state = componentState[colId]; - const directionIcon = van.derive(() => directionIcons[state.val.direction]); - return button( - { - class: 'flex-row', - onclick: () => { - state.val = { ...state.val, direction: state.val.direction === "DESC" ? "ASC" : "DESC" }; - }, - }, - i( - { class: `material-symbols-rounded` }, - directionIcon, - ), - span(columnLabel[colId]), - i( - { - class: `material-symbols-rounded clickable dismiss-button`, - style: `margin-left: auto;`, - onclick: (event) => { - event?.preventDefault(); - event?.stopPropagation(); - - componentState[colId].val = { direction: defaultDirection, order: null }; - }, - }, - 'close', - ), - ) - } - - const selectColumn = (colId, direction) => { - const activeColumnsCount = Object.values(componentState).filter((columnState) => columnState.val.order != null).length; - componentState[colId].val = { direction: direction, order: activeColumnsCount }; - } - - prevComponentState.forEach(([colId, direction]) => selectColumn(colId, direction)); - - const reset = () => { - columns.map( - ([colLabel, colId]) => ( - componentState[colId].val = { direction: defaultDirection, order: null } - ) - ); - } - - const externalComponentState = () => Object.entries(componentState).filter( - ([colId, colState]) => colState.val.order !== null - ).sort( - ([colIdA, colStateA], [colIdB, colStateB]) => colStateA.val.order - colStateB.val.order - ).map( - ([colId, colState]) => [colId, colState.val.direction] - ) - - const apply = () => { - Streamlit.sendData(externalComponentState()); - } - - const columnItem = (colId) => { - const state = componentState[colId]; - return button( - { - onclick: () => selectColumn(colId, defaultDirection), - hidden: state.val.order !== null, - }, - i( - { - class: `material-symbols-rounded`, - style: `color: var(--disabled-text-color);`, - }, - `expand_all` - ), - span(columnLabel[colId]), - ) - } - - const resetDisabled = () => Object.entries(componentState).filter( - ([colId, colState]) => colState.val.order != null - ).length === 0; - - const applyDisabled = () => externalComponentState().toString() === (props.state.val || []).toString(); - - return div( - { class: 'tg-sort-selector' }, - div( - { - class: `tg-sort-selector--header`, - }, - span("Selected columns") - ), - () => div( - { - class: 'tg-sort-selector--column-list', - style: `flex-grow: 1`, - }, - Object.entries(componentState) - .filter(([, colState]) => colState.val.order != null) - .sort(([, colStateA], [, colStateB]) => colStateA.val.order - colStateB.val.order) - .map(([colId,]) => activeColumnItem(colId)) - ), - div( - { class: `tg-sort-selector--header` }, - span("Available columns") - ), - div( - { - class: 'tg-sort-selector--column-list', - }, - columns.map(([colLabel, colId]) => van.derive(() => columnItem(colId))), - ), - div( - { class: `tg-sort-selector--footer` }, - button( - { - onclick: reset, - style: `color: var(--button-text-color);`, - disabled: van.derive(resetDisabled), - }, - span(`Reset`), - ), - button( - { onclick: apply, disabled: van.derive(applyDisabled) }, - span(`Apply`), - ) - ) - ); -}; - - -const stylesheet = new CSSStyleSheet(); -stylesheet.replace(` - -.tg-sort-selector { - height: 100vh; - display: flex; - flex-direction: column; - align-content: flex-end; - justify-content: space-between; -} - -.tg-sort-selector--column-list { - display: flex; - flex-direction: column; -} - -.tg-sort-selector--column-list button { - margin: 0; - border: 0; - padding: 5px 0; - text-align: left; - background: transparent; - color: var(--button-text-color); -} - -.tg-sort-selector--column-list button:hover { - background: #00000010; -} - -.tg-sort-selector--column-list button * { - vertical-align: middle; -} - -.tg-sort-selector--column-list button i { - font-size: 20px; -} - - -.tg-sort-selector--column-list { - border-bottom: 3px dotted var(--disabled-text-color); - padding-bottom: 8px; - margin-bottom: 8px; -} - -.tg-sort-selector--header { - text-align: right; - text-transform: uppercase; - font-size: 70%; - color: var(--secondary-text-color); -} - -.tg-sort-selector--footer { - display: flex; - flex-direction: row; - justify-content: space-between; - margin-top: 8px; -} - -.tg-sort-selector--footer button { - background-color: var(--button-stroked-background); - color: var(--button-stroked-text-color); - border: var(--button-stroked-border); - padding: 5px 20px; - border-radius: 5px; -} - -.tg-sort-selector--footer button[disabled] { - color: var(--disabled-text-color) !important; -} - -.dismiss-button { - margin-left: auto; - color: var(--disabled-text-color); -} -.dismiss-button:hover { - color: var(--button-text-color); -} - -@media (prefers-color-scheme: dark) { - .tg-sort-selector--column-list button:hover { - background: #FFFFFF20; - } -} - -`); - -export { SortingSelector }; diff --git a/testgen/ui/static/js/sidebar.js b/testgen/ui/static/js/sidebar.js index a5770281..9ebd4b2f 100644 --- a/testgen/ui/static/js/sidebar.js +++ b/testgen/ui/static/js/sidebar.js @@ -224,7 +224,7 @@ const AdminMenuItem = ( onclick: (event) => { event.preventDefault(); event.stopPropagation(); - emitEvent({ path: item.page, params: {} }); + emitEvent('Navigate', { payload: { path: item.page, params: {} } }); }, }, i({class: 'menu--item--icon material-symbols-rounded'}, item.icon), @@ -246,9 +246,9 @@ const AdminCTA = ({ style } = {}) => a( i({class: 'menu--admin-cta--icon material-symbols-rounded'}, 'open_in_new'), ); -function emitEvent(/** @type Object */ data) { +function emitEvent(/** @type string */ event, /** @type Object */ data = {}) { if (Sidebar.StreamlitInstance) { - Sidebar.StreamlitInstance.sendData({ ...data, _id: Math.random() }); // Identify the event so its handler is called once + Sidebar.StreamlitInstance.sendData({ event, ...data, _id: Math.random() }); // Identify the event so its handler is called once } } @@ -263,7 +263,7 @@ function navigate( // Prevent Streamlit from reacting to event event.stopPropagation(); - emitEvent({ path, params }); + emitEvent('Navigate', { payload: { path, params } }); } function isCurrentPage(/** @type string */ itemPath, /** @type string */ currentPage) { @@ -286,7 +286,7 @@ stylesheet.replace(` display: flex; flex-direction: column; justify-content: space-between; - height: calc(100% - 68px); + height: calc(100vh - 68px); font-size: 15px; } diff --git a/testgen/ui/static/js/streamlit.js b/testgen/ui/static/js/streamlit.js deleted file mode 100644 index eca3df8c..00000000 --- a/testgen/ui/static/js/streamlit.js +++ /dev/null @@ -1,40 +0,0 @@ -const Streamlit = { - _v2: false, - _customSendDataHandler: undefined, - init() { - sendMessageToStreamlit('streamlit:componentReady', { apiVersion: 1 }); - }, - enableV2(handler) { - this._v2 = true; - this._customSendDataHandler = handler; - window.testgen = window.testgen || {}; - }, - disableV2(handler) { - if (this._customSendDataHandler === handler) { - this._v2 = false; - this._customSendDataHandler = null; - } - }, - setFrameHeight(height) { - if (!this || !this._v2) { - sendMessageToStreamlit('streamlit:setFrameHeight', { height: height }); - } - }, - sendData(data) { - if (this && this._v2) { - const event = data.event; - const triggerData = Object.fromEntries(Object.entries(data).filter(([k, v]) => k !== 'event')); - this._customSendDataHandler(event, triggerData); - } else { - sendMessageToStreamlit('streamlit:setComponentValue', { value: data, dataType: 'json' }); - } - }, -}; - -function sendMessageToStreamlit(type, data) { - if (window.top) { - window.top.postMessage(Object.assign({ type: type, isStreamlitMessage: true }, data), '*'); - } -} - -export { Streamlit }; diff --git a/testgen/ui/views/dialogs/data_preview_dialog.py b/testgen/ui/views/dialogs/data_preview_dialog.py deleted file mode 100644 index 0b95715d..00000000 --- a/testgen/ui/views/dialogs/data_preview_dialog.py +++ /dev/null @@ -1,77 +0,0 @@ -import pandas as pd -import streamlit as st - -from testgen.common.database.database_service import get_flavor_service -from testgen.common.pii_masking import get_pii_columns, mask_source_data_pii -from testgen.ui.components import widgets as testgen -from testgen.ui.services.database_service import fetch_from_target_db -from testgen.ui.services.query_cache import get_connection_by_table_group -from testgen.ui.session import session -from testgen.utils import to_dataframe - - -@st.dialog(title="Data Preview") -def data_preview_dialog( - table_group_id: str, - schema_name: str, - table_name: str, - column_name: str | None = None, -) -> None: - testgen.css_class("s-dialog" if column_name else "xl-dialog") - - testgen.caption( - f"Table > Column: {table_name} > {column_name}" - if column_name else - f"Table: {table_name}" - ) - - with st.spinner("Loading data ..."): - data = get_preview_data(table_group_id, schema_name, table_name, column_name) - - if not data.empty and not session.auth.user_has_permission("view_pii"): - pii_columns = get_pii_columns(table_group_id, schema_name, table_name) - mask_source_data_pii(data, pii_columns) - - if data.empty: - st.warning("The preview data could not be loaded.") - else: - st.dataframe( - data, - width=520 if column_name else "content", - height=700, - ) - - -@st.cache_data(show_spinner=False) -def get_preview_data( - table_group_id: str, - schema_name: str, - table_name: str, - column_name: str | None = None, -) -> pd.DataFrame: - connection = get_connection_by_table_group(table_group_id) - - if connection: - flavor_service = get_flavor_service(connection.sql_flavor) - row_limiting = flavor_service.row_limiting_clause - quote = flavor_service.quote_character - query = f""" - SELECT DISTINCT - {"TOP 100" if row_limiting == "top" else ""} - {f"{quote}{column_name}{quote}" if column_name else "*"} - FROM {quote}{schema_name}{quote}.{quote}{table_name}{quote} - {"LIMIT 100" if row_limiting == "limit" else ""} - {"FETCH FIRST 100 ROWS ONLY" if row_limiting == "fetch" else ""} - """ - - try: - results = fetch_from_target_db(connection, query) - except: - return pd.DataFrame() - else: - df = to_dataframe(results) - df.index = df.index + 1 - df.fillna("", inplace=True) - return df - else: - return pd.DataFrame() diff --git a/testgen/ui/views/dialogs/import_metadata_dialog.py b/testgen/ui/views/dialogs/import_metadata_dialog.py index 209cd308..524750ea 100644 --- a/testgen/ui/views/dialogs/import_metadata_dialog.py +++ b/testgen/ui/views/dialogs/import_metadata_dialog.py @@ -1,19 +1,13 @@ import base64 import io import logging -import time -from datetime import datetime import pandas as pd -import streamlit as st -from testgen.common.models import with_database_session from testgen.common.models.table_group import TableGroup -from testgen.ui.components.widgets.testgen_component import testgen_component from testgen.ui.queries.profiling_queries import TAG_FIELDS from testgen.ui.services.database_service import execute_db_query, fetch_all_from_db -from testgen.ui.services.rerun_service import safe_rerun -from testgen.ui.session import session, temp_value +from testgen.ui.session import session LOG = logging.getLogger("testgen") @@ -374,92 +368,7 @@ def _build_update_params(row: dict, metadata_columns: list[str], is_column: bool return set_clauses, params -PREVIEW_SESSION_KEY = "import_metadata:preview" - - -def open_import_metadata_dialog(table_group_id: str) -> None: - """Clear stale preview state before opening the dialog.""" - st.session_state.pop(PREVIEW_SESSION_KEY, None) - import_metadata_dialog(table_group_id) - - -@st.dialog(title="Import Metadata", width="large") -@with_database_session -def import_metadata_dialog(table_group_id: str) -> None: - should_import, set_should_import = temp_value("import_metadata:import") - - def on_file_uploaded(payload: dict) -> None: - content = payload["content"] - blank_behavior = payload["blank_behavior"] - preview = parse_import_csv(content, table_group_id, blank_behavior) - st.session_state[PREVIEW_SESSION_KEY] = preview - - def on_file_cleared(_payload: dict) -> None: - st.session_state.pop(PREVIEW_SESSION_KEY, None) - - # Preview persists in session state (not temp_value) so it survives across reruns - preview = st.session_state.get(PREVIEW_SESSION_KEY) - - result = None - if should_import() and preview and not preview.get("error"): - try: - apply_metadata_import(preview, table_group_id) - - # Clear caches - from testgen.ui.queries.profiling_queries import get_column_by_id, get_table_by_id - from testgen.ui.views.data_catalog import get_table_group_columns, get_tag_values - - for func in [get_table_group_columns, get_table_by_id, get_column_by_id, get_tag_values]: - func.clear() - st.session_state["data_catalog:last_saved_timestamp"] = datetime.now().timestamp() - - parts = [] - if tc := preview.get("matched_tables", 0): - parts.append(f"{tc} {'table' if tc == 1 else 'tables'}") - if cc := preview.get("matched_columns", 0): - parts.append(f"{cc} {'column' if cc == 1 else 'columns'}") - summary = f"Metadata for {', '.join(parts)} imported." if parts else "No metadata was imported." - - result = { - "success": True, - "message": summary, - } - except Exception: - LOG.exception("Metadata import failed") - result = { - "success": False, - "message": "Something went wrong while importing the metadata.", - } - - st.session_state.pop(PREVIEW_SESSION_KEY, None) - - # Build preview data for JS display - preview_props = None - if preview: - if preview.get("error"): - preview_props = {"error": preview["error"]} - else: - preview_props = _build_preview_props(preview) - - testgen_component( - "import_metadata_dialog", - props={ - "preview": preview_props, - "result": result, - }, - on_change_handlers={ - "FileUploaded": on_file_uploaded, - "FileCleared": on_file_cleared, - "ImportConfirmed": lambda _: set_should_import(True), - }, - ) - - if result and result["success"]: - time.sleep(2) - safe_rerun() - - -def _build_preview_props(preview: dict) -> dict: +def build_import_preview_props(preview: dict) -> dict: formatted_rows = [] metadata_columns = preview.get("metadata_columns", []) diff --git a/testgen/ui/views/dialogs/table_create_script_dialog.py b/testgen/ui/views/dialogs/table_create_script_dialog.py index b56dc6af..2427c78a 100644 --- a/testgen/ui/views/dialogs/table_create_script_dialog.py +++ b/testgen/ui/views/dialogs/table_create_script_dialog.py @@ -1,29 +1,3 @@ - -from testgen.ui.components import widgets as testgen - - -def table_create_script_dialog_widget( - table_name: str, - data: list[dict], - dialog: dict, - on_close: callable, -) -> None: - script = generate_create_script(table_name, data) - - def on_close_clicked(*_) -> None: - on_close() - - testgen.table_create_script_dialog_widget( - key="table_create_script_dialog", - data={ - "dialog": dialog, - "table_name": table_name, - "script": script, - }, - on_CloseClicked_change=on_close_clicked, - ) - - def generate_create_script(table_name: str, data: list[dict]) -> str | None: table_data = [col for col in data if col["table_name"] == table_name] if not table_data: diff --git a/testgen/ui/views/dialogs/test_definition_notes_dialog.py b/testgen/ui/views/dialogs/test_definition_notes_dialog.py deleted file mode 100644 index 26a269c6..00000000 --- a/testgen/ui/views/dialogs/test_definition_notes_dialog.py +++ /dev/null @@ -1,39 +0,0 @@ -import streamlit as st - -from testgen.common.models import with_database_session -from testgen.common.models.test_definition import TestDefinitionNote -from testgen.ui.components import widgets as testgen -from testgen.ui.queries import test_result_queries -from testgen.ui.session import session - - -@st.dialog(title="Test Notes", on_dismiss="rerun") -@with_database_session -def test_definition_notes_dialog(test_definition_id: str, test_label: dict) -> None: - current_user = session.auth.user.username if session.auth.user else "unknown" - notes = TestDefinitionNote.get_notes(test_definition_id) - - def on_note_added(payload: dict) -> None: - TestDefinitionNote.add_note(test_definition_id, payload["text"], current_user) - test_result_queries.get_test_results.clear() - - def on_note_updated(payload: dict) -> None: - TestDefinitionNote.update_note(payload["id"], payload["text"]) - - def on_note_deleted(payload: dict) -> None: - TestDefinitionNote.delete_note(payload["id"]) - test_result_queries.get_test_results.clear() - - testgen.testgen_component( - "test_definition_notes", - props={ - "test_label": test_label, - "notes": notes, - "current_user": current_user, - }, - on_change_handlers={ - "NoteAdded": on_note_added, - "NoteUpdated": on_note_updated, - "NoteDeleted": on_note_deleted, - }, - ) diff --git a/testgen/utils/plugins.py b/testgen/utils/plugins.py index 1863d03e..f4bfbe7f 100644 --- a/testgen/utils/plugins.py +++ b/testgen/utils/plugins.py @@ -1,15 +1,9 @@ from __future__ import annotations -import dataclasses import importlib import importlib.metadata import inspect -import json -import os -import shutil from collections.abc import Generator -from pathlib import Path -from types import ModuleType from typing import ClassVar, get_args from testgen.ui.assets import get_asset_path @@ -17,36 +11,14 @@ from testgen.ui.navigation.page import Page PLUGIN_PREFIX = "testgen_" -ui_plugins_components_directory = ( - Path(__file__).parent.parent / "ui" / "components" / "frontend" / "js" / "plugin_pages" -) -ui_plugins_provision_file = Path(__file__).parent.parent / "ui" / "components" / "frontend" / "js" / "plugins.js" -ui_plugins_entrypoint_prefix = "./plugin_pages" def discover() -> Generator[Plugin, None, None]: - ui_plugins_provision_file.touch(exist_ok=True) for package_path, distribution_names in importlib.metadata.packages_distributions().items(): if package_path.startswith(PLUGIN_PREFIX): yield Plugin(package=package_path, version=importlib.metadata.version(distribution_names[0])) -def cleanup() -> None: - if ui_plugins_components_directory.exists(): - for item in ui_plugins_components_directory.iterdir(): - if item.is_symlink(): - try: - item.unlink() - except OSError as e: - ... - _reset_ui_plugin_spec() - - -def _reset_ui_plugin_spec() -> None: - ui_plugins_provision_file.touch(exist_ok=True) - ui_plugins_provision_file.write_text("export default {};") - - class Logo: image_path: str = get_asset_path("dk_logo.svg") icon_path: str = get_asset_path("dk_icon.svg") @@ -60,48 +32,6 @@ def render(self): ) -@dataclasses.dataclass -class ComponentSpec: - name: str - root: Path - entrypoint: str - - def provide(self) -> None: - ui_plugins_components_directory.mkdir(exist_ok=True) - - target = ui_plugins_components_directory / self.name - try: - if target.exists(): - if target.is_symlink(): - target.unlink() - else: - shutil.rmtree(target) - - try: - if self.root.is_dir(): - shutil.copytree(self.root, target) - else: - shutil.copy2(self.root, target) - except Exception: - os.symlink(self.root, target) - except FileExistsError: - ... - except OSError as e: - ... - - plugins_provision: dict = _read_ui_plugin_spec() - plugins_provision[self.name] = { - "name": self.name, - "entrypoint": f"{ui_plugins_entrypoint_prefix}/{self.name}/{self.entrypoint}", - } - ui_plugins_provision_file.write_text(f"""export default {json.dumps(plugins_provision, indent=2)};""") - - -def _read_ui_plugin_spec() -> dict: - contents = ui_plugins_provision_file.read_text() or "export default {};" - return json.loads(contents.replace("export default ", "")[:-1]) - - class RBACProvider: """Base RBAC provider. OS default: all permissions granted.""" @@ -122,11 +52,10 @@ class PluginSpec: auth: ClassVar[type[Authentication] | None] = None pages: ClassVar[list[type[Page]]] = [] logo: ClassVar[type[Logo] | None] = None - component: ClassVar[ComponentSpec | None] = None @classmethod def configure_ui(cls) -> None: - """Populate UI-related class attributes (pages, auth, logo, component). + """Populate UI-related class attributes (pages, auth, logo). Override this in plugins to defer Streamlit-dependent imports until Streamlit is actually running. Called by ``Plugin.load_streamlit()``, never by ``Plugin.load()``. @@ -146,7 +75,7 @@ def instance(cls) -> PluginHook: return cls._instance -def _find_plugin_spec(module: ModuleType) -> type[PluginSpec] | None: +def _find_plugin_spec(module) -> type[PluginSpec] | None: """Find the first concrete PluginSpec subclass in a module.""" for name in dir(module): cls = getattr(module, name, None) @@ -155,10 +84,10 @@ def _find_plugin_spec(module: ModuleType) -> type[PluginSpec] | None: return None -@dataclasses.dataclass class Plugin: - package: str - version: str + def __init__(self, package: str, version: str) -> None: + self.package = package + self.version = version def load(self) -> type[PluginSpec]: """Lightweight load: import plugin module and populate PluginHook.""" diff --git a/tests/unit/ui/test_import_metadata.py b/tests/unit/ui/test_import_metadata.py index 037f9278..e5f7f718 100644 --- a/tests/unit/ui/test_import_metadata.py +++ b/tests/unit/ui/test_import_metadata.py @@ -6,11 +6,11 @@ from testgen.ui.views.dialogs.import_metadata_dialog import ( DESCRIPTION_MAX_LENGTH, TAG_MAX_LENGTH, - _build_preview_props, _extract_metadata_fields, _parse_csv, _set_row_status, _truncate_fields, + build_import_preview_props, ) pytestmark = pytest.mark.unit @@ -253,7 +253,7 @@ def test_set_row_status_error_precedence(): assert "truncated" in row["_status_detail"] -# --- _build_preview_props --- +# --- build_import_preview_props --- def test_preview_props_basic(): @@ -268,7 +268,7 @@ def test_preview_props_basic(): "matched_columns": 0, "skipped_count": 0, } - result = _build_preview_props(preview) + result = build_import_preview_props(preview) assert result["table_count"] == 1 assert result["column_count"] == 0 assert result["skipped_count"] == 0 @@ -285,7 +285,7 @@ def test_preview_props_cde_true(): ], "metadata_columns": ["critical_data_element"], } - result = _build_preview_props(preview) + result = build_import_preview_props(preview) assert result["preview_rows"][0]["critical_data_element"] == "Yes" @@ -298,7 +298,7 @@ def test_preview_props_cde_false(): ], "metadata_columns": ["critical_data_element"], } - result = _build_preview_props(preview) + result = build_import_preview_props(preview) assert result["preview_rows"][0]["critical_data_element"] == "No" @@ -311,7 +311,7 @@ def test_preview_props_cde_none(): ], "metadata_columns": ["critical_data_element"], } - result = _build_preview_props(preview) + result = build_import_preview_props(preview) assert result["preview_rows"][0]["critical_data_element"] == "" @@ -324,5 +324,5 @@ def test_preview_props_unmatched_preserved(): ], "metadata_columns": ["description"], } - result = _build_preview_props(preview) + result = build_import_preview_props(preview) assert result["preview_rows"][0]["_status"] == "unmatched" From 45227602c7022a70ffb7ed581bda5b21859fa777 Mon Sep 17 00:00:00 2001 From: Luis Date: Fri, 10 Apr 2026 16:56:07 -0400 Subject: [PATCH 054/123] fix(ui): restore missing features and address review feedback in VanJS pages Add back features lost during rebase (data catalog actions, test definition bulk operations, import metadata dialog) and incorporate review feedback across all migrated VanJS pages and shared components. Co-Authored-By: Claude Opus 4.6 (1M context) --- testgen/common/models/table_group.py | 3 +- .../js/data_profiling/column_distribution.js | 7 +- .../column_profiling_history.js | 14 +- .../column_profiling_results.js | 14 +- .../js/data_profiling/data_characteristics.js | 7 +- .../frontend/js/data_profiling/data_issues.js | 9 +- .../js/data_profiling/data_profiling_utils.js | 5 +- .../js/data_profiling/metadata_tags.js | 38 +- .../js/data_profiling/table_create_script.js | 4 +- .../frontend/js/data_profiling/table_size.js | 4 +- .../frontend/js/pages/application_logs.js | 20 +- .../js/pages/column_selector_dialog.js | 9 +- .../frontend/js/pages/confirmation_dialog.js | 9 +- .../frontend/js/pages/connections.js | 16 +- .../frontend/js/pages/data_catalog.js | 123 +++-- .../js/pages/edit_monitor_settings.js | 9 +- .../frontend/js/pages/edit_table_monitors.js | 9 +- .../js/pages/generate_tests_dialog.js | 13 +- .../components/frontend/js/pages/help_menu.js | 26 + .../frontend/js/pages/hygiene_issues.js | 196 +++---- .../js/pages/import_metadata_dialog.js | 74 +-- .../frontend/js/pages/monitors_dashboard.js | 93 ++-- .../js/pages/notification_settings.js | 21 +- .../frontend/js/pages/profiling_results.js | 77 +-- .../frontend/js/pages/profiling_runs.js | 65 +-- .../frontend/js/pages/project_dashboard.js | 44 +- .../frontend/js/pages/quality_dashboard.js | 23 +- .../frontend/js/pages/run_tests_dialog.js | 13 +- .../frontend/js/pages/schedule_list.js | 26 +- .../js/pages/schema_changes_dialog.js | 5 +- .../frontend/js/pages/score_details.js | 32 +- .../frontend/js/pages/score_explorer.js | 47 +- .../js/pages/table_create_script_dialog.js | 9 +- .../pages/table_group_delete_confirmation.js | 9 +- .../frontend/js/pages/table_group_list.js | 56 +- .../frontend/js/pages/table_group_wizard.js | 6 +- .../js/pages/table_monitoring_trends.js | 228 ++++---- .../js/pages/test_definition_notes.js | 38 +- .../js/pages/test_definition_summary.js | 7 +- .../frontend/js/pages/test_definitions.js | 506 ++++++++++++------ .../frontend/js/pages/test_results.js | 363 +++++++++---- .../frontend/js/pages/test_results_chart.js | 7 +- .../components/frontend/js/pages/test_runs.js | 74 +-- .../frontend/js/pages/test_suites.js | 140 +++-- .../js/shared/application_logs_dialog.js | 18 +- .../js/shared/column_history_dialog.js | 13 +- .../frontend/js/shared/data_preview_dialog.js | 23 +- .../js/shared/profiling_results_dialog.js | 3 +- .../frontend/js/shared/source_data_dialog.js | 5 +- testgen/ui/components/frontend/js/types.js | 2 +- .../standalone/project_settings/index.js | 12 +- testgen/ui/navigation/router.py | 3 + testgen/ui/queries/profiling_queries.py | 73 +++ testgen/ui/queries/test_result_queries.py | 91 ++++ testgen/ui/scripts/patch_streamlit.py | 3 +- .../css/highlight-default-theme.min.css | 10 +- testgen/ui/static/css/shared.css | 9 + .../ui/static/js/components/breadcrumbs.js | 8 +- .../js/components/column_selector_dialog.js | 7 +- .../ui/static/js/components/crontab_input.js | 6 +- .../static/js/components/dropdown_button.js | 3 + .../ui/static/js/components/empty_state.js | 3 +- .../static/js/components/expander_toggle.js | 3 +- .../js/components/explorer_column_selector.js | 7 +- .../js/components/generate_tests_dialog.js | 9 +- testgen/ui/static/js/components/help_menu.js | 51 +- testgen/ui/static/js/components/link.js | 5 +- .../components/monitor_anomalies_summary.js | 6 +- .../js/components/monitor_settings_form.js | 13 +- .../js/components/notification_settings.js | 19 +- testgen/ui/static/js/components/paginator.js | 5 +- testgen/ui/static/js/components/portal.js | 2 +- .../js/components/run_profiling_dialog.js | 17 +- .../static/js/components/run_tests_dialog.js | 10 +- .../ui/static/js/components/schedule_list.js | 22 +- .../js/components/schema_changes_dialog.js | 5 +- .../static/js/components/score_breakdown.js | 10 +- .../ui/static/js/components/score_history.js | 5 +- .../ui/static/js/components/score_issues.js | 26 +- testgen/ui/static/js/components/select.js | 102 +++- testgen/ui/static/js/components/table.js | 17 +- .../components/table_create_script_dialog.js | 7 +- .../js/components/table_group_edit_dialog.js | 11 +- .../js/components/table_group_wizard.js | 40 +- testgen/ui/static/js/components/toggle.js | 2 +- testgen/ui/static/js/components/tooltip.js | 2 +- testgen/ui/static/js/components/tree.js | 3 +- testgen/ui/static/js/utils.js | 24 +- testgen/ui/views/connections.py | 3 +- testgen/ui/views/data_catalog.py | 221 +++++++- testgen/ui/views/dialogs/run_tests_dialog.py | 2 +- testgen/ui/views/hygiene_issues.py | 28 +- testgen/ui/views/monitors_dashboard.py | 11 +- testgen/ui/views/profiling_runs.py | 8 +- testgen/ui/views/table_groups.py | 26 +- testgen/ui/views/test_definitions.py | 127 ++++- testgen/ui/views/test_results.py | 126 ++++- testgen/ui/views/test_runs.py | 4 +- testgen/ui/views/test_suites.py | 21 +- 99 files changed, 2507 insertions(+), 1283 deletions(-) create mode 100644 testgen/ui/components/frontend/js/pages/help_menu.js diff --git a/testgen/common/models/table_group.py b/testgen/common/models/table_group.py index f0ff4c88..20a6aef4 100644 --- a/testgen/common/models/table_group.py +++ b/testgen/common/models/table_group.py @@ -8,7 +8,7 @@ from sqlalchemy.dialects import postgresql from sqlalchemy.orm import InstrumentedAttribute -from testgen.common.models import get_current_session, with_database_session +from testgen.common.models import get_current_session from testgen.common.models.custom_types import NullIfEmptyString, YNString from testgen.common.models.entity import ENTITY_HASH_FUNCS, Entity, EntityMinimal from testgen.common.models.scores import ScoreDefinition @@ -161,7 +161,6 @@ def select_minimal_where( return [TableGroupMinimal(**row) for row in results] @classmethod - @with_database_session def select_stats(cls, project_code: str, table_group_id: str | UUID | None = None) -> Iterable[TableGroupStats]: query = f""" WITH stats AS ( diff --git a/testgen/ui/components/frontend/js/data_profiling/column_distribution.js b/testgen/ui/components/frontend/js/data_profiling/column_distribution.js index 83676f69..e8882d86 100644 --- a/testgen/ui/components/frontend/js/data_profiling/column_distribution.js +++ b/testgen/ui/components/frontend/js/data_profiling/column_distribution.js @@ -16,7 +16,7 @@ import { SummaryBar } from '/app/static/js/components/summary_bar.js'; import { PercentBar } from '/app/static/js/components/percent_bar.js'; import { FrequencyBars } from '/app/static/js/components/frequency_bars.js'; import { BoxPlot } from '/app/static/js/components/box_plot.js'; -import { loadStylesheet, emitEvent, friendlyPercent, getValue } from '/app/static/js/utils.js'; +import { loadStylesheet, friendlyPercent, getValue } from '/app/static/js/utils.js'; import { formatNumber, formatTimestamp, PII_REDACTED } from '/app/static/js/display_utils.js'; const { div, span } = van.tags; @@ -34,6 +34,7 @@ const summaryHeight = 10; const boxPlotWidth = 800; const ColumnDistributionCard = (/** @type Properties */ props, /** @type Column */ item) => { + const emit = props.emit; loadStylesheet('column-distribution', stylesheet); const displayType = item.profile_run_id && item.record_ct !== 0 ? item.general_type : 'X' const columnFunction = columnTypeFunctionMap[displayType]; @@ -52,7 +53,7 @@ const ColumnDistributionCard = (/** @type Properties */ props, /** @type Column label: 'Data Preview', icon: 'pageview', width: 'auto', - onclick: () => emitEvent('DataPreviewClicked', { payload: item }), + onclick: () => emit('DataPreviewClicked', { payload: item }), }) : null, getValue(props.history) @@ -61,7 +62,7 @@ const ColumnDistributionCard = (/** @type Properties */ props, /** @type Column label: 'History', icon: 'history', width: 'auto', - onclick: () => emitEvent('HistoryClicked', { payload: item }), + onclick: () => emit('HistoryClicked', { payload: item }), }) : null, ]) diff --git a/testgen/ui/components/frontend/js/data_profiling/column_profiling_history.js b/testgen/ui/components/frontend/js/data_profiling/column_profiling_history.js index 1b2498bb..ac91939c 100644 --- a/testgen/ui/components/frontend/js/data_profiling/column_profiling_history.js +++ b/testgen/ui/components/frontend/js/data_profiling/column_profiling_history.js @@ -12,7 +12,7 @@ * @property {Column} selected_item */ import van from '/app/static/js/van.min.js'; -import { emitEvent, getValue, loadStylesheet } from '/app/static/js/utils.js'; +import { getValue, loadStylesheet } from '/app/static/js/utils.js'; import { formatTimestamp } from '/app/static/js/display_utils.js'; import { ColumnDistributionCard } from './column_distribution.js'; import { Card } from '/app/static/js/components/card.js'; @@ -20,10 +20,18 @@ import { Card } from '/app/static/js/components/card.js'; const { div, span } = van.tags; const ColumnProfilingHistory = (/** @type Properties */ props) => { + const emit = props.emit; loadStylesheet('column-profiling-history', stylesheet); const selectedRunId = van.state(null); + van.derive(() => { + const runId = getValue(props.selected_item)?.profile_run_id ?? null; + if (runId) { + selectedRunId.val = runId; + } + }); + return div( { class: 'column-history flex-row fx-align-stretch' }, () => div( @@ -36,7 +44,7 @@ const ColumnProfilingHistory = (/** @type Properties */ props) => { if (props.onRunSelected) { props.onRunSelected(run_id); } else { - emitEvent('RunSelected', { payload: run_id }); + emit('RunSelected', { payload: run_id }); } }, }, @@ -48,7 +56,7 @@ const ColumnProfilingHistory = (/** @type Properties */ props) => { () => getValue(props.selected_item) ? div( { class: 'column-history--details' }, - ColumnDistributionCard({}, getValue(props.selected_item)), + ColumnDistributionCard({ emit, }, getValue(props.selected_item)), ) : Card({ class: 'column-history--empty', diff --git a/testgen/ui/components/frontend/js/data_profiling/column_profiling_results.js b/testgen/ui/components/frontend/js/data_profiling/column_profiling_results.js index 240205a0..a3c9d929 100644 --- a/testgen/ui/components/frontend/js/data_profiling/column_profiling_results.js +++ b/testgen/ui/components/frontend/js/data_profiling/column_profiling_results.js @@ -11,11 +11,12 @@ import { getValue, loadStylesheet } from '/app/static/js/utils.js'; import { ColumnDistributionCard } from './column_distribution.js'; import { DataCharacteristicsCard } from './data_characteristics.js'; import { LatestProfilingTime } from './data_profiling_utils.js'; -import { PotentialPIICard, HygieneIssuesCard } from './data_issues.js'; +import { HygieneIssuesCard } from './data_issues.js'; const { div, h2, span } = van.tags; const ColumnProfilingResults = (/** @type Properties */ props) => { + const emit = props.emit; loadStylesheet('column-profiling-results', stylesheet); const column = van.derive(() => { @@ -43,14 +44,11 @@ const ColumnProfilingResults = (/** @type Properties */ props) => { ), column.val.column_name, ), - column.val.is_latest_profile ? LatestProfilingTime({}, column.val) : null, + column.val.is_latest_profile ? LatestProfilingTime({ emit,}, column.val) : null, ), - DataCharacteristicsCard({ border: true }, column.val), - ColumnDistributionCard({ border: true, dataPreview: !!props.data_preview?.val }, column.val), - column.val.hygiene_issues ? [ - PotentialPIICard({ border: true }, column.val), - HygieneIssuesCard({ border: true }, column.val), - ] : null, + DataCharacteristicsCard({ emit, border: true }, column.val), + ColumnDistributionCard({ emit, border: true, dataPreview: !!props.data_preview?.val }, column.val), + column.val.hygiene_issues ? HygieneIssuesCard({ emit, border: true }, column.val) : null, ); }, ); diff --git a/testgen/ui/components/frontend/js/data_profiling/data_characteristics.js b/testgen/ui/components/frontend/js/data_profiling/data_characteristics.js index afc0724c..8689fefc 100644 --- a/testgen/ui/components/frontend/js/data_profiling/data_characteristics.js +++ b/testgen/ui/components/frontend/js/data_profiling/data_characteristics.js @@ -14,12 +14,13 @@ import { Attribute } from '/app/static/js/components/attribute.js'; import { Button } from '/app/static/js/components/button.js'; import { ScoreMetric } from '/app/static/js/components/score_metric.js'; import { formatTimestamp } from '/app/static/js/display_utils.js'; -import { emitEvent, loadStylesheet } from '/app/static/js/utils.js'; +import { loadStylesheet } from '/app/static/js/utils.js'; import { getColumnIcon } from './data_profiling_utils.js'; const { b, div, span, i } = van.tags; const DataCharacteristicsCard = (/** @type Properties */ props, /** @type Column | Table */ item) => { + const emit = props.emit; loadStylesheet('data-characteristics', stylesheet); const removeDialogOpen = van.state(false); @@ -122,8 +123,10 @@ const DataCharacteristicsCard = (/** @type Properties */ props, /** @type Column label: 'Remove', color: 'warn', type: 'flat', + width: 'auto', + style: 'margin-left: auto;', onclick: () => { - emitEvent('RemoveTableConfirmed', { payload: item }); + emit('RemoveTableConfirmed', { payload: item }); removeDialogOpen.val = false; }, }), diff --git a/testgen/ui/components/frontend/js/data_profiling/data_issues.js b/testgen/ui/components/frontend/js/data_profiling/data_issues.js index 9801edd2..630c2606 100644 --- a/testgen/ui/components/frontend/js/data_profiling/data_issues.js +++ b/testgen/ui/components/frontend/js/data_profiling/data_issues.js @@ -36,6 +36,7 @@ const STATUS_COLORS = { }; const HygieneIssuesCard = (/** @type Properties */ props, /** @type Table | Column */ item) => { + const emit = props.emit; const title = `Hygiene Issues ${item.is_latest_profile ? '*' : ''}`; const attributes = [ { key: 'anomaly_name', width: 200, label: 'Issue' }, @@ -73,6 +74,7 @@ const HygieneIssuesCard = (/** @type Properties */ props, /** @type Table | Colu }; const TestIssuesCard = (/** @type Properties */ props, /** @type Table | Column */ item) => { + const emit = props.emit; const attributes = [ { key: 'test_name', width: 150, label: 'Test' }, { @@ -96,7 +98,7 @@ const TestIssuesCard = (/** @type Properties */ props, /** @type Table | Column { style: 'font-size: 12px; margin-top: 2px;' }, formatTimestamp(issue.test_run_date) ) - : Link({ + : Link({ emit, href: 'test-runs:results', params: { run_id: issue.test_run_id, @@ -126,7 +128,7 @@ const TestIssuesCard = (/** @type Properties */ props, /** @type Table | Column noneContent = span( { class: 'text-secondary flex-row fx-gap-1 fx-justify-content-flex-end' }, `No test results yet for ${item.type}.`, - props.noLinks ? null : Link({ + props.noLinks ? null : Link({ emit, href: 'test-suites', params: { project_code: item.project_code, @@ -151,6 +153,7 @@ const IssuesCard = ( /** @type object? */ linkProps, /** @type (string | object)? */ noneContent, ) => { + const emit = props.emit; const gap = 8; const minWidth = attributes.reduce((sum, { width }) => sum + width, attributes.length * gap); @@ -188,7 +191,7 @@ const IssuesCard = ( ); if (linkProps) { - actionContent = Link({ + actionContent = Link({ emit, ...linkProps, open_new: true, label: 'View details', diff --git a/testgen/ui/components/frontend/js/data_profiling/data_profiling_utils.js b/testgen/ui/components/frontend/js/data_profiling/data_profiling_utils.js index 5ca0b7f0..39342edc 100644 --- a/testgen/ui/components/frontend/js/data_profiling/data_profiling_utils.js +++ b/testgen/ui/components/frontend/js/data_profiling/data_profiling_utils.js @@ -219,11 +219,12 @@ const getColumnIcon = (/** @type Column */ column) => { * @property {boolean?} noLinks */ const LatestProfilingTime = (/** @type Properties */ props, /** @type Table | Column */ item) => { + const emit = props.emit; let text = [ 'as of latest profiling run on ', props.noLinks ? b(formatTimestamp(item.profile_run_date)) : null, ]; - let link = Link({ + let link = Link({ emit, href: 'profiling-runs:results', params: { run_id: item.profile_run_id, @@ -240,7 +241,7 @@ const LatestProfilingTime = (/** @type Properties */ props, /** @type Table | Co link = null; } else { text = `No profiling results yet for ${item.type}.`; - link = Link({ + link = Link({ emit, href: 'table-groups', params: { project_code: item.project_code, connection_id: item.connection_id }, open_new: true, diff --git a/testgen/ui/components/frontend/js/data_profiling/metadata_tags.js b/testgen/ui/components/frontend/js/data_profiling/metadata_tags.js index 88554474..fbb9ad8f 100644 --- a/testgen/ui/components/frontend/js/data_profiling/metadata_tags.js +++ b/testgen/ui/components/frontend/js/data_profiling/metadata_tags.js @@ -9,20 +9,20 @@ * @property {AutoflagSettings} autoflagSettings * @property {(() => void)?} onCancel */ -import van from '../van.min.js'; -import { EditableCard } from '../components/editable_card.js'; -import { Attribute } from '../components/attribute.js'; -import { Input } from '../components/input.js'; -import { Icon } from '../components/icon.js'; -import { withTooltip } from '../components/tooltip.js'; -import { emitEvent, loadStylesheet } from '../utils.js'; -import { RadioGroup } from '../components/radio_group.js'; -import { Checkbox } from '../components/checkbox.js'; -import { capitalize } from '../display_utils.js'; -import { Card } from '../components/card.js'; -import { Dialog } from '../components/dialog.js'; -import { Button } from '../components/button.js'; -import { Alert } from '../components/alert.js'; +import van from '/app/static/js/van.min.js'; +import { EditableCard } from '/app/static/js/components/editable_card.js'; +import { Attribute } from '/app/static/js/components/attribute.js'; +import { Input } from '/app/static/js/components/input.js'; +import { Icon } from '/app/static/js/components/icon.js'; +import { withTooltip } from '/app/static/js/components/tooltip.js'; +import { loadStylesheet } from '/app/static/js/utils.js'; +import { RadioGroup } from '/app/static/js/components/radio_group.js'; +import { Checkbox } from '/app/static/js/components/checkbox.js'; +import { capitalize } from '/app/static/js/display_utils.js'; +import { Card } from '/app/static/js/components/card.js'; +import { Dialog } from '/app/static/js/components/dialog.js'; +import { Button } from '/app/static/js/components/button.js'; +import { Alert } from '/app/static/js/components/alert.js'; const { div, span } = van.tags; @@ -79,6 +79,7 @@ const TAG_HELP = { * @returns */ const MetadataTagsCard = (props, item) => { + const emit = props.emit; loadStylesheet('metadata-tags', stylesheet); const title = `${item.type} Tags `; @@ -211,10 +212,10 @@ const MetadataTagsCard = (props, item) => { if (warnPii.val) { disableFlags.push('profile_flag_pii'); } - pendingSaveAction.val = () => emitEvent('TagsChanged', { payload: { items, tags, disable_flags: disableFlags } }); + pendingSaveAction.val = () => emit('TagsChanged', { payload: { items, tags, disable_flags: disableFlags } }); warningDialogOpen.val = true; } else { - emitEvent('TagsChanged', { payload: { items, tags } }) + emit('TagsChanged', { payload: { items, tags } }) } }, // Reset states to original values on cancel @@ -305,6 +306,7 @@ const PiiDisplay = (/** @type string|null */ value) => { * @returns */ const MetadataTagsMultiEdit = (props, selectedItems) => { + const emit = props.emit; const columnCount = van.derive(() => selectedItems.val?.reduce((count, { children }) => count + children.length, 0)); const attributes = [ @@ -410,10 +412,10 @@ const MetadataTagsMultiEdit = (props, selectedItems) => { if (warnPii.val) { disableFlags.push('profile_flag_pii'); } - pendingSaveAction.val = () => emitEvent('TagsChanged', { payload: { items, tags, disable_flags: disableFlags } });; + pendingSaveAction.val = () => emit('TagsChanged', { payload: { items, tags, disable_flags: disableFlags } });; warningDialogOpen.val = true; } else { - emitEvent('TagsChanged', { payload: { items, tags } }); + emit('TagsChanged', { payload: { items, tags } }); // Don't set multiEditMode to false here // Otherwise this event gets superseded by the ItemSelected event // Let the Streamlit rerun handle the state reset with 'last_saved_timestamp' diff --git a/testgen/ui/components/frontend/js/data_profiling/table_create_script.js b/testgen/ui/components/frontend/js/data_profiling/table_create_script.js index bd1ea50a..96258f0e 100644 --- a/testgen/ui/components/frontend/js/data_profiling/table_create_script.js +++ b/testgen/ui/components/frontend/js/data_profiling/table_create_script.js @@ -7,11 +7,11 @@ import van from '/app/static/js/van.min.js'; import { Card } from '/app/static/js/components/card.js'; import { Button } from '/app/static/js/components/button.js'; -import { emitEvent } from '/app/static/js/utils.js'; const { div } = van.tags; const TableCreateScriptCard = (/** @type Properties */ _props, /** @type Table */ item) => { + const emit = _props.emit; return Card({ title: 'Table CREATE Script with Suggested Data Types', content: div( @@ -23,7 +23,7 @@ const TableCreateScriptCard = (/** @type Properties */ _props, /** @type Table * disabled: !item.column_ct, tooltip: item.column_ct ? null : 'No columns detected in table', tooltipPosition: 'right', - onclick: () => emitEvent('CreateScriptClicked', { payload: item }), + onclick: () => emit('CreateScriptClicked', { payload: item }), }), ), }); diff --git a/testgen/ui/components/frontend/js/data_profiling/table_size.js b/testgen/ui/components/frontend/js/data_profiling/table_size.js index 84ce8e5f..7d842ddf 100644 --- a/testgen/ui/components/frontend/js/data_profiling/table_size.js +++ b/testgen/ui/components/frontend/js/data_profiling/table_size.js @@ -8,12 +8,12 @@ import van from '/app/static/js/van.min.js'; import { Card } from '/app/static/js/components/card.js'; import { Attribute } from '/app/static/js/components/attribute.js'; import { Button } from '/app/static/js/components/button.js'; -import { emitEvent } from '/app/static/js/utils.js'; import { formatNumber, formatTimestamp } from '/app/static/js/display_utils.js'; const { div, span } = van.tags; const TableSizeCard = (/** @type Properties */ _props, /** @type Table */ item) => { + const emit = _props.emit; const useApprox = item.record_ct === null; const rowCount = useApprox ? item.approx_record_ct : item.record_ct; const attributes = [ @@ -46,7 +46,7 @@ const TableSizeCard = (/** @type Properties */ _props, /** @type Table */ item) label: 'Data Preview', icon: 'pageview', width: 'auto', - onclick: () => emitEvent('DataPreviewClicked', { payload: item }), + onclick: () => emit('DataPreviewClicked', { payload: item }), }), }); }; diff --git a/testgen/ui/components/frontend/js/pages/application_logs.js b/testgen/ui/components/frontend/js/pages/application_logs.js index 78a9643a..92e4c34c 100644 --- a/testgen/ui/components/frontend/js/pages/application_logs.js +++ b/testgen/ui/components/frontend/js/pages/application_logs.js @@ -1,21 +1,19 @@ import van from '/app/static/js/van.min.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { emitEvent, getValue, isEqual } from '/app/static/js/utils.js'; +import { createEmitter, isEqual } from '/app/static/js/utils.js'; import { ApplicationLogsDialog } from '../shared/application_logs_dialog.js'; const ApplicationLogs = (props) => { + const { emit } = props; return ApplicationLogsDialog({ logsData: props.logs_data, - onClose: () => emitEvent('LogsDialogClosed', {}), - onDateChanged: (dateString) => emitEvent('DateChanged', { payload: dateString }), - onRefresh: () => emitEvent('Refresh', {}), + onClose: () => emit('LogsDialogClosed', {}), + onDateChanged: (dateString) => emit('DateChanged', { payload: dateString }), + onRefresh: () => emit('Refresh', {}), }); }; export default (component) => { - const { data, setStateValue, setTriggerValue, parentElement } = component; - - Streamlit.enableV2(setTriggerValue); + const { data, setTriggerValue, parentElement } = component; let componentState = parentElement.state; if (componentState === undefined) { @@ -24,6 +22,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, ApplicationLogs(componentState)); } else { for (const [key, value] of Object.entries(data)) { @@ -33,8 +32,5 @@ export default (component) => { } } - return () => { - parentElement.state = null; - Streamlit.disableV2(setTriggerValue); - }; + return () => { parentElement.state = null; }; }; diff --git a/testgen/ui/components/frontend/js/pages/column_selector_dialog.js b/testgen/ui/components/frontend/js/pages/column_selector_dialog.js index 59ccf561..2ab105ed 100644 --- a/testgen/ui/components/frontend/js/pages/column_selector_dialog.js +++ b/testgen/ui/components/frontend/js/pages/column_selector_dialog.js @@ -6,12 +6,12 @@ import van from '/app/static/js/van.min.js'; import { Dialog } from '/app/static/js/components/dialog.js'; import { ColumnSelector } from '/app/static/js/components/explorer_column_selector.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { emitEvent, getValue, isEqual } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual } from '/app/static/js/utils.js'; const { div } = van.tags; const ColumnSelectorDialog = (/** @type Properties */ props) => { + const { emit } = props; const dialogProp = getValue(props.dialog); const dialogOpen = van.state(dialogProp?.open === true); @@ -23,7 +23,7 @@ const ColumnSelectorDialog = (/** @type Properties */ props) => { { title: dialogTitle, open: dialogOpen, - onClose: () => { dialogOpen.val = false; emitEvent('CloseClicked', {}); }, + onClose: () => { dialogOpen.val = false; emit('CloseClicked', {}); }, width: '55rem', }, content, @@ -37,8 +37,6 @@ export { ColumnSelectorDialog }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -46,6 +44,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, ColumnSelectorDialog(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/confirmation_dialog.js b/testgen/ui/components/frontend/js/pages/confirmation_dialog.js index bba1938e..fc7000c5 100644 --- a/testgen/ui/components/frontend/js/pages/confirmation_dialog.js +++ b/testgen/ui/components/frontend/js/pages/confirmation_dialog.js @@ -20,8 +20,7 @@ */ import van from '/app/static/js/van.min.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { emitEvent, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; import { Button } from '/app/static/js/components/button.js'; import { Toggle } from '/app/static/js/components/toggle.js'; import { Alert } from '/app/static/js/components/alert.js'; @@ -33,6 +32,7 @@ const { div, span } = van.tags; * @returns */ const ConfirmationDialog = (props) => { + const { emit } = props; loadStylesheet('confirmation-dialog', stylesheet); const wrapperId = 'confirmation-dialog'; @@ -68,7 +68,7 @@ const ConfirmationDialog = (props) => { label: buttonLabel, style: 'width: auto;', disabled: actionDisabled, - onclick: () => emitEvent('ActionConfirmed', {}), + onclick: () => emit('ActionConfirmed', {}), }), ), () => { @@ -101,8 +101,6 @@ export { ConfirmationDialog }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -110,6 +108,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, ConfirmationDialog(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/connections.js b/testgen/ui/components/frontend/js/pages/connections.js index 9c3bf607..b48c7a1e 100644 --- a/testgen/ui/components/frontend/js/pages/connections.js +++ b/testgen/ui/components/frontend/js/pages/connections.js @@ -21,8 +21,7 @@ * @property {Results?} results */ import van from '/app/static/js/van.min.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { emitEvent, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; import { ConnectionForm } from '/app/static/js/components/connection_form.js'; import { TableGroupWizard } from '/app/static/js/components/table_group_wizard.js'; import { Button } from '/app/static/js/components/button.js'; @@ -37,6 +36,7 @@ const { div, span } = van.tags; * @returns */ const Connections = (props) => { + const { emit } = props; loadStylesheet('connections', stylesheet); const wrapperId = 'connections-list-wrapper'; @@ -52,7 +52,7 @@ const Connections = (props) => { div( { class: 'flex-row fx-justify-content-flex-end' }, () => getValue(props.has_table_groups) - ? Link({ + ? Link({ emit, href: 'table-groups', params: {'project_code': projectCode, "connection_id": connectionId}, label: 'Manage Table Groups', @@ -67,13 +67,14 @@ const Connections = (props) => { width: 'auto', disabled: !getValue(props.permissions).is_admin, tooltip: 'You do not have permissions to perform this action. Contact your administrator.', - onclick: () => emitEvent('SetupTableGroupClicked', {}), + onclick: () => emit('SetupTableGroupClicked', {}), }), ), div( { class: 'flex-column fx-gap-4 p-4' }, ConnectionForm( { + emit, connection: props.connection, flavors: props.flavors, disableFlavor: false, @@ -97,7 +98,7 @@ const Connections = (props) => { type: 'flat', width: 'auto', disabled: !canSave, - onclick: () => emitEvent('SaveConnectionClicked', { payload: updatedConnection.val }), + onclick: () => emit('SaveConnectionClicked', { payload: updatedConnection.val }), }); }, ), @@ -111,7 +112,7 @@ const Connections = (props) => { () => { const wizardData = getValue(props.setup_wizard); if (!wizardData) return div(); - return TableGroupWizard(wizardData); + return TableGroupWizard(wizardData, emit); }, ); } @@ -133,8 +134,6 @@ export { Connections }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -142,6 +141,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, Connections(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/data_catalog.js b/testgen/ui/components/frontend/js/pages/data_catalog.js index 1f5cc0bd..079a0ebe 100644 --- a/testgen/ui/components/frontend/js/pages/data_catalog.js +++ b/testgen/ui/components/frontend/js/pages/data_catalog.js @@ -60,6 +60,8 @@ * @property {Permissions} permissions * @property {AutoflagSettings} autoflag_settings * @property {object?} run_profiling_dialog + * @property {object?} import_metadata_dialog + * @property {object?} create_script_dialog */ import van from '/app/static/js/van.min.js'; import { Tree } from '/app/static/js/components/tree.js'; @@ -68,8 +70,7 @@ import { Attribute } from '/app/static/js/components/attribute.js'; import { Input } from '/app/static/js/components/input.js'; import { Icon } from '/app/static/js/components/icon.js'; import { withTooltip } from '/app/static/js/components/tooltip.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { emitEvent, fillViewportHeight, getRandomId, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, fillViewportHeight, getRandomId, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; import { ColumnDistributionCard } from '../data_profiling/column_distribution.js'; import { DataCharacteristicsCard } from '../data_profiling/data_characteristics.js'; import { HygieneIssuesCard, TestIssuesCard } from '../data_profiling/data_issues.js'; @@ -86,9 +87,12 @@ import { EMPTY_STATE_MESSAGE, EmptyState } from '/app/static/js/components/empty import { DropdownButton } from '/app/static/js/components/dropdown_button.js'; import { TableCreateScriptCard } from '../data_profiling/table_create_script.js'; import { MetadataTagsCard, MetadataTagsMultiEdit, TAG_KEYS } from '../data_profiling/metadata_tags.js'; +import { Dialog } from '/app/static/js/components/dialog.js'; import { RunProfilingDialog } from '/app/static/js/components/run_profiling_dialog.js'; +import { ImportMetadataDialog } from './import_metadata_dialog.js'; import { ColumnHistoryDialog } from '../shared/column_history_dialog.js'; import { DataPreviewDialog } from '../shared/data_preview_dialog.js'; +import { TableCreateScriptDialog } from '/app/static/js/components/table_create_script_dialog.js'; const { div, h2, span } = van.tags; @@ -98,8 +102,13 @@ EMPTY_IMAGE.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAA const DataCatalog = (/** @type Properties */ props) => { + const { emit } = props; loadStylesheet('data-catalog', stylesheet); + // Import dialog: persistent local state + one-time sync from Python prop + const importDialogOpen = van.state(false); + van.derive(() => { if (getValue(props.import_metadata_dialog)) importDialogOpen.val = true; }); + /** @type TreeNode[] */ const treeNodes = van.derive(() => { let columns = []; @@ -212,7 +221,7 @@ const DataCatalog = (/** @type Properties */ props) => { options: getValue(props.table_group_filter_options) ?? [], style: 'font-size: 14px;', testId: 'table-group-filter', - onChange: (value) => emitEvent('TableGroupSelected', {payload: value}), + onChange: (value) => emit('TableGroupSelected', {payload: value}), }), div( { class: 'flex-row fx-gap-2' }, @@ -225,10 +234,10 @@ const DataCatalog = (/** @type Properties */ props) => { tooltipPosition: 'left', width: 'fit-content', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('ImportClicked', {}), + onclick: () => emit('ImportClicked', {}), }) : null, - ExportOptions(treeNodes, multiSelectedItems, userCanEdit), + ExportOptions(treeNodes, multiSelectedItems, userCanEdit, emit), ), ), () => treeNodes.val.length @@ -239,12 +248,13 @@ const DataCatalog = (/** @type Properties */ props) => { }, Tree( { + emit, id: treeDomId, classes: 'tg-dh--tree', nodes: treeNodes, // Use .rawVal, so only initial value from query params is passed to tree selected: selectedItem.rawVal ? `${selectedItem.rawVal.type}_${selectedItem.rawVal.id}` : null, - onSelect: (/** @type string */ selected) => emitEvent('ItemSelected', { payload: selected }), + onSelect: (/** @type string */ selected) => emit('ItemSelected', { payload: selected }), multiSelect: multiEditMode, multiSelectToggle: userCanEdit, multiSelectToggleLabel: 'Edit multiple', @@ -358,6 +368,7 @@ const DataCatalog = (/** @type Properties */ props) => { () => multiSelectedItems.val?.length ? MetadataTagsMultiEdit( { + emit, tagOptions: getValue(props.tag_values), piiEditable: userCanViewPii, autoflagSettings: getValue(props.autoflag_settings) ?? {}, @@ -372,33 +383,70 @@ const DataCatalog = (/** @type Properties */ props) => { ) : SelectedDetails(props, selectedItem.val), ) - : ConditionalEmptyState(projectSummary, userCanEdit, userCanNavigate), + : ConditionalEmptyState(projectSummary, userCanEdit, userCanNavigate, emit), () => { const info = getValue(props.run_profiling_dialog); if (!info) return div(); - return RunProfilingDialog({ + return RunProfilingDialog({ emit, dialog: { title: info.title ?? 'Run Profiling', open: true }, table_groups: info.table_groups ?? [], allow_selection: info.allow_selection ?? false, selected_id: info.selected_id, result: info.result, - onClose: () => emitEvent('RunProfilingDialogClosed', {}), + onClose: () => emit('RunProfilingDialogClosed', {}), }); }, - ColumnHistoryDialog({ + ColumnHistoryDialog({ emit, historyData: props.history_dialog, - onClose: () => emitEvent('HistoryDialogClosed', {}), - onRunSelected: (runId) => emitEvent('HistoryRunSelected', { payload: runId }), + onClose: () => emit('HistoryDialogClosed', {}), + onRunSelected: (runId) => emit('HistoryRunSelected', { payload: runId }), }), - DataPreviewDialog({ + DataPreviewDialog({ emit, previewData: props.data_preview_dialog, - onClose: () => emitEvent('DataPreviewDialogClosed', {}), + onClose: () => emit('DataPreviewDialogClosed', {}), }), + () => { + const data = getValue(props.create_script_dialog); + if (!data) return div(); + return TableCreateScriptDialog({ emit, + dialog: { open: true, title: data.title }, + table_name: data.table_name, + script: data.script, + onClose: () => emit('CreateScriptDialogClosed', {}), + }); + }, + Dialog( + { + title: 'Import Metadata', + open: importDialogOpen, + onClose: () => { + importDialogOpen.val = false; + emit('ImportDialogClosed', {}); + }, + width: '50rem', + }, + () => { + const data = getValue(props.import_metadata_dialog); + if (!data) return span(); + return ImportMetadataDialog({ emit, + preview: data.preview, + result: data.result, + onFileUploaded: (payload) => emit('ImportFileUploaded', { payload }), + onFileCleared: () => emit('ImportFileCleared', {}), + onImportConfirmed: () => emit('ImportConfirmed', {}), + onAutoClose: () => { + importDialogOpen.val = false; + emit('ImportDialogClosed', {}); + }, + }); + }, + ), ) - : ConditionalEmptyState(projectSummary, userCanEdit, userCanNavigate); + : ConditionalEmptyState(projectSummary, userCanEdit, userCanNavigate, emit); }; -const ExportOptions = (/** @type TreeNode[] */ treeNodes, /** @type SelectedNode[] */ selectedNodes) => { + +const ExportOptions = (/** @type TreeNode[] */ treeNodes, /** @type SelectedNode[] */ selectedNodes, _userCanEdit, emit) => { return DropdownButton({ icon: 'download', label: 'Export', @@ -406,7 +454,7 @@ const ExportOptions = (/** @type TreeNode[] */ treeNodes, /** @type SelectedNode const items = [ { label: 'All columns', - onclick: () => emitEvent('ExportClicked', { payload: null }), + onclick: () => emit('ExportClicked', { payload: null }), }, { label: 'Filtered columns', @@ -425,7 +473,7 @@ const ExportOptions = (/** @type TreeNode[] */ treeNodes, /** @type SelectedNode } return array; }, []); - emitEvent('ExportClicked', { payload }); + emit('ExportClicked', { payload }); }, }, ]; @@ -444,14 +492,14 @@ const ExportOptions = (/** @type TreeNode[] */ treeNodes, /** @type SelectedNode return array; }, []); - emitEvent('ExportClicked', { payload }); + emit('ExportClicked', { payload }); }, }); } items.push({ label: 'Metadata CSV', separator: true, - onclick: () => emitEvent('ExportCsvClicked', {}), + onclick: () => emit('ExportCsvClicked', {}), }); return items; }, @@ -459,6 +507,7 @@ const ExportOptions = (/** @type TreeNode[] */ treeNodes, /** @type SelectedNode }; const SelectedDetails = (/** @type Properties */ props, /** @type Table | Column */ item) => { + const emit = props.emit; const userCanEdit = getValue(props.permissions)?.can_edit ?? false; const userCanNavigate = getValue(props.permissions)?.can_navigate ?? false; const userCanViewPii = getValue(props.permissions)?.can_view_pii ?? false; @@ -478,14 +527,14 @@ const SelectedDetails = (/** @type Properties */ props, /** @type Table | Column item.column_name, ] : item.table_name, ), - LatestProfilingTime({ noLinks: !userCanNavigate }, item), + LatestProfilingTime({ emit, noLinks: !userCanNavigate }, item), ), - DataCharacteristicsCard({ scores: true, allowRemove: true }, item), + DataCharacteristicsCard({ emit, scores: true, allowRemove: true }, item), item.type === 'column' - ? ColumnDistributionCard({ dataPreview: true, history: true }, item) - : TableSizeCard({}, item), + ? ColumnDistributionCard({ emit, dataPreview: true, history: true }, item) + : TableSizeCard({ emit,}, item), MetadataTagsCard( - { + { emit, tagOptions: getValue(props.tag_values), editable: userCanEdit, piiEditable: userCanViewPii, @@ -493,11 +542,11 @@ const SelectedDetails = (/** @type Properties */ props, /** @type Table | Column }, item, ), - HygieneIssuesCard({ noLinks: !userCanNavigate }, item), - TestIssuesCard({ noLinks: !userCanNavigate }, item), - TestSuitesCard({ noLinks: !userCanNavigate }, item), + HygieneIssuesCard({ emit, noLinks: !userCanNavigate }, item), + TestIssuesCard({ emit, noLinks: !userCanNavigate }, item), + TestSuitesCard({ emit, noLinks: !userCanNavigate }, item), item.type === 'table' - ? TableCreateScriptCard({}, item) + ? TableCreateScriptCard({ emit,}, item) : null, ) : ItemEmptyState( @@ -507,6 +556,7 @@ const SelectedDetails = (/** @type Properties */ props, /** @type Table | Column }; const TestSuitesCard = (/** @type Properties */ props, /** @type Table | Column */ item) => { + const emit = props.emit; return Card({ title: 'Related Test Suites', content: div( @@ -515,7 +565,7 @@ const TestSuitesCard = (/** @type Properties */ props, /** @type Table | Column { class: 'flex-row fx-gap-1' }, props.noLinks ? span(name) - : Link({ + : Link({ emit, href: 'test-suites:definitions', params: { test_suite_id: id, @@ -538,7 +588,7 @@ const TestSuitesCard = (/** @type Properties */ props, /** @type Table | Column `No test definitions yet for ${item.type}.`, props.noLinks ? null - : Link({ + : Link({ emit, href: 'test-suites', params: { project_code: item.project_code, @@ -553,6 +603,7 @@ const TestSuitesCard = (/** @type Properties */ props, /** @type Table | Column }; const MultiEdit = (/** @type Properties */ props, /** @type Object */ selectedItems, /** @type Object */ multiEditMode) => { + const emit = props.emit; const hasSelection = van.derive(() => selectedItems.val?.length); const columnCount = van.derive(() => selectedItems.val?.reduce((count, { children }) => count + children.length, 0)); @@ -650,7 +701,7 @@ const MultiEdit = (/** @type Properties */ props, /** @type Object */ selectedIt return object; }, {}); - emitEvent('TagsChanged', { payload: { items, tags } }); + emit('TagsChanged', { payload: { items, tags } }); // Don't set multiEditMode to false here // Otherwise this event gets superseded by the ItemSelected event // Let the Streamlit rerun handle the state reset with 'last_saved_timestamp' @@ -678,6 +729,7 @@ const ConditionalEmptyState = ( /** @type ProjectSummary */ projectSummary, /** @type boolean */ userCanEdit, /** @type boolean */ userCanNavigate, + emit, ) => { let args = { label: 'No profiling data yet', @@ -692,7 +744,7 @@ const ConditionalEmptyState = ( disabled: !userCanEdit, tooltip: userCanEdit ? null : DISABLED_ACTION_TEXT, tooltipPosition: 'bottom', - onclick: () => emitEvent('RunProfilingClicked', {}), + onclick: () => emit('RunProfilingClicked', {}), }), } if (projectSummary.connection_count <= 0) { @@ -722,7 +774,7 @@ const ConditionalEmptyState = ( }; } - return EmptyState({ + return EmptyState({ emit, icon: 'dataset', ...args, }); @@ -800,8 +852,6 @@ export { DataCatalog }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -809,6 +859,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, DataCatalog(componentState)); parentElement._cleanup = fillViewportHeight(parentElement); } else { diff --git a/testgen/ui/components/frontend/js/pages/edit_monitor_settings.js b/testgen/ui/components/frontend/js/pages/edit_monitor_settings.js index 47b34a92..0c501d3e 100644 --- a/testgen/ui/components/frontend/js/pages/edit_monitor_settings.js +++ b/testgen/ui/components/frontend/js/pages/edit_monitor_settings.js @@ -23,7 +23,7 @@ import { Button } from '/app/static/js/components/button.js'; import { Dialog } from '/app/static/js/components/dialog.js'; import { Icon } from '/app/static/js/components/icon.js'; import { MonitorSettingsForm } from '/app/static/js/components/monitor_settings_form.js'; -import { emitEvent, getValue } from '/app/static/js/utils.js'; +import { getValue } from '/app/static/js/utils.js'; const { div, span } = van.tags; @@ -33,6 +33,7 @@ const { div, span } = van.tags; * @returns */ const EditMonitorSettings = (props) => { + const emit = props.emit; const dialogOpen = van.state(false); van.derive(() => { const d = getValue(props.dialog); @@ -62,7 +63,7 @@ const EditMonitorSettings = (props) => { span(tableGroup.table_groups_name), ), MonitorSettingsForm( - { + { emit, schedule: props.schedule, monitorSuite: props.monitor_suite, cronSample: props.cron_sample, @@ -98,7 +99,7 @@ const EditMonitorSettings = (props) => { schedule: updatedSchedule.val, monitor_suite: updatedMonitorSuite.val, }; - emitEvent('SaveSettingsClicked', { payload }); + emit('SaveSettingsClicked', { payload }); }, }), ), @@ -114,7 +115,7 @@ const EditMonitorSettings = (props) => { { title: dialogTitle, open: dialogOpen, - onClose: () => { dialogOpen.val = false; emitEvent('CloseSettingsDialog', {}); }, + onClose: () => { dialogOpen.val = false; emit('CloseSettingsDialog', {}); }, width: '55rem', }, formContainer, diff --git a/testgen/ui/components/frontend/js/pages/edit_table_monitors.js b/testgen/ui/components/frontend/js/pages/edit_table_monitors.js index 761460c5..592d5e47 100644 --- a/testgen/ui/components/frontend/js/pages/edit_table_monitors.js +++ b/testgen/ui/components/frontend/js/pages/edit_table_monitors.js @@ -11,7 +11,7 @@ */ import van from '/app/static/js/van.min.js'; -import { emitEvent, getValue, loadStylesheet } from '/app/static/js/utils.js'; +import { getValue, loadStylesheet } from '/app/static/js/utils.js'; import { Button } from '/app/static/js/components/button.js'; import { Card } from '/app/static/js/components/card.js'; import { Dialog } from '/app/static/js/components/dialog.js'; @@ -26,6 +26,7 @@ const defaultMonitorOptions = [ ]; const EditTableMonitors = (/** @type Properties */ props) => { + const emit = props.emit; loadStylesheet('edit-table-monitors', stylesheet); const dialogOpen = van.state(false); @@ -241,7 +242,7 @@ const EditTableMonitors = (/** @type Properties */ props) => { new_metrics: Object.values(newMetrics.val), deleted_metric_ids: deletedMetricIds.val, }; - emitEvent('SaveTestDefinition', { payload }); + emit('SaveTestDefinition', { payload }); }, }), Button({ @@ -257,7 +258,7 @@ const EditTableMonitors = (/** @type Properties */ props) => { deleted_metric_ids: deletedMetricIds.val, close: true, }; - emitEvent('SaveTestDefinition', { payload }); + emit('SaveTestDefinition', { payload }); }, }), ), @@ -268,7 +269,7 @@ const EditTableMonitors = (/** @type Properties */ props) => { { title: dialogTitle, open: dialogOpen, - onClose: () => { dialogOpen.val = false; emitEvent('CloseEditMonitorsDialog', {}); }, + onClose: () => { dialogOpen.val = false; emit('CloseEditMonitorsDialog', {}); }, width: '55rem', }, content, diff --git a/testgen/ui/components/frontend/js/pages/generate_tests_dialog.js b/testgen/ui/components/frontend/js/pages/generate_tests_dialog.js index 50c7e8c4..510611d6 100644 --- a/testgen/ui/components/frontend/js/pages/generate_tests_dialog.js +++ b/testgen/ui/components/frontend/js/pages/generate_tests_dialog.js @@ -23,16 +23,16 @@ import van from '/app/static/js/van.min.js'; import { Button } from '/app/static/js/components/button.js'; import { Dialog } from '/app/static/js/components/dialog.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; import { Alert } from '/app/static/js/components/alert.js'; import { Code } from '/app/static/js/components/code.js'; import { ExpanderToggle } from '/app/static/js/components/expander_toggle.js'; import { Select } from '/app/static/js/components/select.js'; -import { emitEvent, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; const { div, span, strong } = van.tags; const GenerateTestsDialog = (/** @type Properties */ props) => { + const { emit } = props; loadStylesheet('generate-tests-dialog', stylesheet); const dialogProp = getValue(props.dialog); @@ -85,7 +85,7 @@ const GenerateTestsDialog = (/** @type Properties */ props) => { type: 'stroked', label: 'Lock Edited Tests', width: 'auto', - onclick: () => emitEvent('LockEditedTests', {}), + onclick: () => emit('LockEditedTests', {}), }); }, ) @@ -119,7 +119,7 @@ const GenerateTestsDialog = (/** @type Properties */ props) => { color: 'primary', width: 'auto', style: 'width: auto;', - onclick: () => emitEvent('GenerateTestsConfirmed', { + onclick: () => emit('GenerateTestsConfirmed', { payload: { test_suite_id: testSuiteId, generation_set: selectedSet.val, @@ -136,7 +136,7 @@ const GenerateTestsDialog = (/** @type Properties */ props) => { { title: dialogTitle, open: dialogOpen, - onClose: () => { dialogOpen.val = false; emitEvent('CloseClicked', {}); }, + onClose: () => { dialogOpen.val = false; emit('CloseClicked', {}); }, width: '36rem', }, content, @@ -161,8 +161,6 @@ export { GenerateTestsDialog }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -170,6 +168,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, GenerateTestsDialog(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/help_menu.js b/testgen/ui/components/frontend/js/pages/help_menu.js new file mode 100644 index 00000000..e74fbcff --- /dev/null +++ b/testgen/ui/components/frontend/js/pages/help_menu.js @@ -0,0 +1,26 @@ +import van from '/app/static/js/van.min.js'; +import { createEmitter, isEqual } from '/app/static/js/utils.js'; +import { HelpMenu } from '/app/static/js/components/help_menu.js'; + +export default (component) => { + const { data, setTriggerValue, parentElement } = component; + + let componentState = parentElement.state; + if (componentState === undefined) { + componentState = {}; + for (const [key, value] of Object.entries(data)) { + componentState[key] = van.state(value); + } + parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); + van.add(parentElement, HelpMenu(componentState)); + } else { + for (const [key, value] of Object.entries(data)) { + if (!isEqual(componentState[key].val, value)) { + componentState[key].val = value; + } + } + } + + return () => { parentElement.state = null; }; +}; diff --git a/testgen/ui/components/frontend/js/pages/hygiene_issues.js b/testgen/ui/components/frontend/js/pages/hygiene_issues.js index c3177df0..b24cec6e 100644 --- a/testgen/ui/components/frontend/js/pages/hygiene_issues.js +++ b/testgen/ui/components/frontend/js/pages/hygiene_issues.js @@ -54,8 +54,7 @@ import { DropdownButton } from '/app/static/js/components/dropdown_button.js'; import { SummaryCounts } from '/app/static/js/components/summary_counts.js'; import { Attribute } from '/app/static/js/components/attribute.js'; import { Icon } from '/app/static/js/components/icon.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { emitEvent, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; import { ProfilingResultsDialog } from '../shared/profiling_results_dialog.js'; import { SourceDataDialog } from '../shared/source_data_dialog.js'; @@ -126,16 +125,17 @@ const HygieneSourceDataHeader = (d) => { ); }; -const ExportMenu = (likelihoodFilter, tableFilter, columnFilter, issueTypeFilter, actionFilter, hasSelection, getSelectedIds) => { +const ExportMenu = (likelihoodFilter, tableFilter, columnFilter, issueTypeFilter, actionFilter, hasSelection, getSelectedIds, emit) => { return DropdownButton({ icon: 'download', label: 'Export', + buttonSize: 'small', items: () => { const items = [ - { label: 'All issues', onclick: () => emitEvent('ExportAll', {}) }, + { label: 'All issues', onclick: () => emit('ExportAll', {}) }, { label: 'Filtered issues', - onclick: () => emitEvent('ExportFiltered', { + onclick: () => emit('ExportFiltered', { payload: { likelihood: likelihoodFilter.rawVal, table_name: tableFilter.rawVal, @@ -149,7 +149,7 @@ const ExportMenu = (likelihoodFilter, tableFilter, columnFilter, issueTypeFilter if (hasSelection()) { items.push({ label: 'Selected issues', - onclick: () => emitEvent('ExportSelected', { payload: { ids: getSelectedIds() } }), + onclick: () => emit('ExportSelected', { payload: { ids: getSelectedIds() } }), }); } return items; @@ -180,6 +180,7 @@ const DetailPanel = (selectedRow) => { }; const HygieneIssues = (/** @type Properties */ props) => { + const { emit } = props; loadStylesheet('hygiene-issues', stylesheet); const items = van.derive(() => getValue(props.items) ?? []); @@ -247,6 +248,7 @@ const HygieneIssues = (/** @type Properties */ props) => { const clearAllCheckboxStates = () => { for (const state of checkboxStates.values()) state.val = false; selectAll.val = false; + selectedIdsCount.val = 0; }; let selectedIds = []; @@ -254,29 +256,33 @@ const HygieneIssues = (/** @type Properties */ props) => { const selectedIdSetForRestore = new Set(); const onSelectAllToggle = (checked) => { - selectAll.val = checked; if (checked) { + selectAll.val = true; for (const item of items.rawVal) { const state = getCheckboxState(item.id); state.val = true; selectedIdSetForRestore.add(item.id); } - selectedIds = items.rawVal.map(r => r.id); + selectedIds = [...selectedIdSetForRestore]; selectedIdsCount.val = selectedIds.length; } else { clearAllCheckboxStates(); selectedIds = []; - selectedIdsCount.val = 0; selectedIdSetForRestore.clear(); } }; - const selectAllCheckbox = Checkbox({ - label: '', - checked: () => selectAll.val, - onChange: onSelectAllToggle, - }); - const checkboxColumn = { name: '_checkbox', label: selectAllCheckbox, width: 32, align: 'center' }; + const checkboxColumn = { + name: '_checkbox', + label: () => Checkbox({ + label: '', + checked: selectAll.val, + indeterminate: !selectAll.val && selectedIdsCount.val > 0, + onChange: onSelectAllToggle, + }), + width: 32, + align: 'center', + }; const tableColumns = van.derive(() => multiSelect.val ? [checkboxColumn, ...BASE_TABLE_COLUMNS] : BASE_TABLE_COLUMNS); van.derive(() => { @@ -295,16 +301,13 @@ const HygieneIssues = (/** @type Properties */ props) => { const currentItems = items.val; if (isMulti && isSelectAll) { - selectedIdSetForRestore.clear(); - const newIds = []; for (const item of currentItems) { const state = getCheckboxState(item.id); state.val = true; selectedIdSetForRestore.add(item.id); - newIds.push(item.id); } - selectedIds = newIds; - selectedIdsCount.val = newIds.length; + selectedIds = [...selectedIdSetForRestore]; + selectedIdsCount.val = selectedIds.length; } return currentItems.map(item => { @@ -319,7 +322,7 @@ const HygieneIssues = (/** @type Properties */ props) => { const onSortChange = (newColumns) => { sortColumns.val = newColumns; - emitEvent('SortChanged', { payload: { columns: newColumns } }); + emit('SortChanged', { payload: { columns: newColumns } }); }; const tableSortOptions = van.derive(() => ({ @@ -334,32 +337,41 @@ const HygieneIssues = (/** @type Properties */ props) => { const onRowsSelected = (idxs) => { if (multiSelect.rawVal) { - const newIds = []; + const currentPageItemIds = new Set(items.rawVal.map(r => r.id)); const activeSet = new Set(); for (const i of idxs) { const item = items.rawVal[i]; - if (item) { - newIds.push(item.id); - activeSet.add(item.id); + if (item) activeSet.add(item.id); + } + // Update restore set: only modify entries for current page items + for (const id of currentPageItemIds) { + if (activeSet.has(id)) { + selectedIdSetForRestore.add(id); + } else { + selectedIdSetForRestore.delete(id); } } - selectedIdSetForRestore.clear(); - for (const id of activeSet) selectedIdSetForRestore.add(id); for (const [id, state] of checkboxStates) { - state.val = activeSet.has(id); + if (currentPageItemIds.has(id)) { + state.val = activeSet.has(id); + } } - selectedIds = newIds; - selectedIdsCount.val = newIds.length; + selectedIds = [...selectedIdSetForRestore]; + selectedIdsCount.val = selectedIds.length; // If user deselected rows while selectAll was on, turn selectAll off - if (selectAll.rawVal && newIds.length < items.rawVal.length) { + if (selectAll.rawVal && activeSet.size < currentPageItemIds.size) { selectAll.val = false; } + // Auto-enable selectAll when all items are individually selected + if (!selectAll.rawVal && totalCount.rawVal > 0 && selectedIds.length >= totalCount.rawVal) { + selectAll.val = true; + } } else { if (idxs.length > 0) { const row = items.rawVal[idxs[0]]; if (row && row.id !== selectedRowId.rawVal) { selectedRowId.val = row.id; - emitEvent('RowSelected', { payload: row.id }); + emit('RowSelected', { payload: row.id }); } } } @@ -374,7 +386,7 @@ const HygieneIssues = (/** @type Properties */ props) => { }); const emitFilterChanged = () => { - emitEvent('FilterChanged', { payload: getCurrentFilters() }); + emit('FilterChanged', { payload: getCurrentFilters() }); }; const onLikelihoodChange = (value) => { @@ -391,8 +403,8 @@ const HygieneIssues = (/** @type Properties */ props) => { emitFilterChanged(); }; - const onColumnChange = (value) => { - columnFilter.val = value; + const onColumnChange = (value, meta) => { + columnFilter.val = meta?.isCustom ? `%${value}%` : value; selectedRowId.val = null; emitFilterChanged(); }; @@ -410,8 +422,8 @@ const HygieneIssues = (/** @type Properties */ props) => { }; const getSelectedIds = () => { - if (multiSelect.val && selectedIds.length > 0) { - return [...selectedIds]; + if (multiSelect.val && selectedIdSetForRestore.size > 0) { + return [...selectedIdSetForRestore]; } return selectedRowId.rawVal ? [selectedRowId.rawVal] : []; }; @@ -427,12 +439,12 @@ const HygieneIssues = (/** @type Properties */ props) => { const onDisposition = (status) => { if (selectAll.rawVal) { - emitEvent('DispositionAll', { payload: { filters: getCurrentFilters(), status } }); + emit('DispositionAll', { payload: { filters: getCurrentFilters(), status } }); return; } const ids = getSelectedIds(); if (ids.length > 0) { - emitEvent('DispositionChanged', { payload: { ids, status } }); + emit('DispositionChanged', { payload: { ids, status } }); } }; @@ -448,13 +460,22 @@ const HygieneIssues = (/** @type Properties */ props) => { const tableHeader = div( { class: 'flex-row fx-align-center fx-gap-2 p-2' }, Toggle({ - label: 'Multi-Select', + label: () => { + return div( + { class: 'flex-column' }, + span('Multi-Select'), + () => { + if (!multiSelect.val) return ''; + if (selectAll.val) return span({ class: 'text-caption' }, () => `All ${totalCount.val} matching issues selected`); + const count = selectedIdsCount.val; + if (count > 0) return span({ class: 'text-caption' }, `${count} issue${count !== 1 ? 's' : ''} selected`); + return ''; + }, + ); + }, checked: () => multiSelect.val, onChange: (checked) => { multiSelect.val = checked; }, }), - () => multiSelect.val && selectAll.val - ? span({ class: 'text-caption' }, () => `All ${totalCount.val} matching issues selected`) - : '', div({ class: 'fx-flex' }), () => { if (!permissions.val.can_disposition) return ''; @@ -467,10 +488,26 @@ const HygieneIssues = (/** @type Properties */ props) => { Button({ type: 'icon', icon: 'restart_alt', tooltip: 'Clear action on selected', disabled, onclick: () => onDisposition('No Decision') }), ); }, + span({ style: 'width: 0px; height: 24px; border-right: 1px dashed var(--border-color);'}, ''), + () => { + const hasAnySelection = selectedIdsCount.val > 0 || !!selectedRow.val; + if (!hasAnySelection) return ''; + + return Button({ + type: 'stroked', + icon: 'download', + label: 'Issue Report', + width: 'auto', + size: 'small', + style: 'background: var(--button-generic-background-color)', + onclick: () => emit('DownloadReport', { payload: { ids: getSelectedIds() } }), + }); + }, ExportMenu( likelihoodFilter, tableFilter, columnFilter, issueTypeFilter, actionFilter, () => selectedRowId.val || selectedIdsCount.val > 0, getSelectedIds, + emit, ), ); @@ -480,11 +517,16 @@ const HygieneIssues = (/** @type Properties */ props) => { itemsPerPage: pageSize.val, pageSizeOptions: [100, 500, 1000], onPageChange: (pageIdx, newPerPage) => { - if (!selectAll.rawVal) clearAllCheckboxStates(); if (newPerPage !== pageSize.rawVal) { - emitEvent('PageChanged', { payload: { page: 0, page_size: newPerPage } }); + if (!selectAll.rawVal) { + clearAllCheckboxStates(); + selectedIds = []; + selectedIdsCount.val = 0; + selectedIdSetForRestore.clear(); + } + emit('PageChanged', { payload: { page: 0, page_size: newPerPage } }); } else { - emitEvent('PageChanged', { payload: { page: pageIdx } }); + emit('PageChanged', { payload: { page: pageIdx } }); } }, })); @@ -492,6 +534,7 @@ const HygieneIssues = (/** @type Properties */ props) => { // Build the main table once const dataTable = Table( { + emit, columns: tableColumns, header: tableHeader, highDensity: true, @@ -516,15 +559,15 @@ const HygieneIssues = (/** @type Properties */ props) => { { 'data-testid': 'hygiene-issues', class: 'flex-column' }, // Dialogs (mounted once at top, driven by props from Python) - ProfilingResultsDialog({ + ProfilingResultsDialog({ emit, profilingColumn: van.derive(() => getValue(props.profiling_column) ?? null), - onClose: () => emitEvent('ProfilingClosed', {}), + onClose: () => emit('ProfilingClosed', {}), width: '50rem', testId: 'profiling-dialog', }), - SourceDataDialog({ + SourceDataDialog({ emit, sourceData: van.derive(() => getValue(props.source_data) ?? null), - onClose: () => emitEvent('SourceDataClosed', {}), + onClose: () => emit('SourceDataClosed', {}), renderHeader: HygieneSourceDataHeader, width: '60rem', testId: 'source-data-dialog', @@ -561,7 +604,7 @@ const HygieneIssues = (/** @type Properties */ props) => { iconSize: 22, style: 'color: var(--secondary-text-color)', tooltip: () => `Recalculate scores for run ${isLatestRun.val ? 'and table group' : ''}`, - onclick: () => emitEvent('RefreshScore', {}), + onclick: () => emit('RefreshScore', {}), }), ), ), @@ -584,6 +627,7 @@ const HygieneIssues = (/** @type Properties */ props) => { options: tableOptions.val, testId: 'table-filter', style: 'min-width: 160px', + filterable: true, onChange: onTableChange, allowNull: true, }), @@ -593,6 +637,8 @@ const HygieneIssues = (/** @type Properties */ props) => { options: columnOptions.val, testId: 'column-filter', style: 'min-width: 160px', + filterable: true, + acceptNewOptions: true, onChange: onColumnChange, allowNull: true, }), @@ -602,6 +648,7 @@ const HygieneIssues = (/** @type Properties */ props) => { options: issueTypeOptions.val, testId: 'issue-type-filter', style: 'min-width: 200px', + filterable: true, onChange: onIssueTypeChange, allowNull: true, disabled: likelihoodFilter.val === 'Potential PII', @@ -627,53 +674,21 @@ const HygieneIssues = (/** @type Properties */ props) => { const sel = selectedRow.val; if (!sel) return ''; - const canViewProfiling = sel.column_name - && sel.column_name !== '(multi-column)' - && sel.column_name !== 'N/A' - && sel.table_name - && sel.table_name !== '(multi-table)'; - return div( { class: 'tg-hi--detail flex-column fx-gap-4' }, - - // Action buttons row div( - { class: 'flex-row fx-flex-wrap fx-gap-1 fx-justify-content-flex-end' }, - canViewProfiling - ? Button({ - type: 'stroked', - icon: 'query_stats', - label: 'Profiling', - width: 'auto', - style: 'background: var(--button-generic-background-color)', - onclick: () => emitEvent('ViewProfiling', { - payload: { - column_name: sel.column_name, - table_name: sel.table_name, - table_groups_id: sel.table_groups_id, - }, - }), - }) - : '', + { class: 'flex-row fx-gap-2 fx-justify-content-flex-end' }, Button({ - type: 'stroked', - icon: 'visibility', - label: 'Source Data', - width: 'auto', + type: 'stroked', icon: 'query_stats', label: 'Profiling', width: 'auto', style: 'background: var(--button-generic-background-color)', - onclick: () => emitEvent('ViewSourceData', { payload: sel.id }), + onclick: () => emit('ViewProfiling', { payload: sel.id }), }), Button({ - type: 'stroked', - icon: 'download', - label: 'Issue Report', - width: 'auto', + type: 'stroked', icon: 'visibility', label: 'Source Data', width: 'auto', style: 'background: var(--button-generic-background-color)', - onclick: () => emitEvent('DownloadReport', { payload: { ids: getSelectedIds() } }), + onclick: () => emit('ViewSourceData', { payload: sel.id }), }), ), - - // Detail content DetailPanel(sel), ); }, @@ -684,7 +699,7 @@ const HygieneIssues = (/** @type Properties */ props) => { const stylesheet = new CSSStyleSheet(); stylesheet.replace(` .tg-hi--detail { - border-top: 1px solid var(--border-color, #dddfe2); + border-top: 1px dashed var(--border-color, #dddfe2); padding-top: 16px; } @@ -701,8 +716,6 @@ export { HygieneIssues }; export default (component) => { const { data, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -710,6 +723,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, HygieneIssues(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/import_metadata_dialog.js b/testgen/ui/components/frontend/js/pages/import_metadata_dialog.js index 13084655..3d7ca4cd 100644 --- a/testgen/ui/components/frontend/js/pages/import_metadata_dialog.js +++ b/testgen/ui/components/frontend/js/pages/import_metadata_dialog.js @@ -3,37 +3,33 @@ * @type {object} * @property {object|null} preview * @property {object|null} result + * @property {Function?} onAutoClose */ -import van from '../van.min.js'; -import { Streamlit } from '../streamlit.js'; -import { emitEvent, getValue, loadStylesheet, resizeFrameHeightToElement, resizeFrameHeightOnDOMChange } from '../utils.js'; -import { RadioGroup } from '../components/radio_group.js'; -import { FileInput } from '../components/file_input.js'; -import { Button } from '../components/button.js'; -import { Alert } from '../components/alert.js'; -import { Table } from '../components/table.js'; -import { capitalize } from '../display_utils.js'; -import { withTooltip } from '../components/tooltip.js'; -import { sizeLimit } from '../form_validators.js'; +import van from '/app/static/js/van.min.js'; +import { getValue, loadStylesheet} from '/app/static/js/utils.js'; +import { RadioGroup } from '/app/static/js/components/radio_group.js'; +import { FileInput } from '/app/static/js/components/file_input.js'; +import { Button } from '/app/static/js/components/button.js'; +import { Alert } from '/app/static/js/components/alert.js'; +import { Table } from '/app/static/js/components/table.js'; +import { capitalize } from '/app/static/js/display_utils.js'; +import { withTooltip } from '/app/static/js/components/tooltip.js'; +import { sizeLimit } from '/app/static/js/form_validators.js'; const CSV_SIZE_LIMIT = 2 * 1024 * 1024; // 2 MB const { div, i, span } = van.tags; const ImportMetadataDialog = (/** @type Properties */ props) => { + const emit = props.emit; loadStylesheet('import-metadata-dialog', stylesheet); - Streamlit.setFrameHeight(1); - window.testgen.isPage = true; - - const wrapperId = 'import-metadata-wrapper'; - resizeFrameHeightToElement(wrapperId); - resizeFrameHeightOnDOMChange(wrapperId); const blankBehavior = van.state('keep'); const fileValue = van.state(null); + let autoCloseScheduled = false; return div( - { id: wrapperId, class: 'flex-column fx-gap-4' }, + { class: 'flex-column fx-gap-4' }, FileInput({ name: 'csv_file', label: 'Upload metadata CSV file', @@ -41,16 +37,21 @@ const ImportMetadataDialog = (/** @type Properties */ props) => { validators: [sizeLimit(CSV_SIZE_LIMIT)], value: fileValue, onChange: (value) => { + const hadFile = fileValue.val?.content; fileValue.val = value; if (value?.content) { - emitEvent('FileUploaded', { - payload: { - content: value.content, - blank_behavior: blankBehavior.val, - }, - }); - } else { - emitEvent('FileCleared', {}); + const payload = { content: value.content, blank_behavior: blankBehavior.val }; + if (props.onFileUploaded) { + props.onFileUploaded(payload); + } else { + emit('FileUploaded', { payload }); + } + } else if (hadFile) { + if (props.onFileCleared) { + props.onFileCleared(); + } else { + emit('FileCleared', {}); + } } }, }), @@ -68,6 +69,10 @@ const ImportMetadataDialog = (/** @type Properties */ props) => { () => { const result = getValue(props.result); if (result) { + if (result.success && props.onAutoClose && !autoCloseScheduled) { + autoCloseScheduled = true; + setTimeout(() => props.onAutoClose(), 2000); + } return Alert( { type: result.success ? 'success' : 'error', icon: result.success ? 'check_circle' : 'error' }, span(result.message), @@ -106,7 +111,7 @@ const ImportMetadataDialog = (/** @type Properties */ props) => { ), hasError ? Alert({ type: 'error', icon: 'error' }, span(preview.error)) - : PreviewTable(preview), + : PreviewTable(preview, emit), preview.pii_skipped ? Alert( { type: 'info', icon: 'info' }, @@ -129,7 +134,7 @@ const ImportMetadataDialog = (/** @type Properties */ props) => { icon: 'upload', width: 'auto', disabled: !hasMatches, - onclick: () => emitEvent('ImportConfirmed', {}), + onclick: () => props.onImportConfirmed ? props.onImportConfirmed() : emit('ImportConfirmed', {}), }), ), ); @@ -150,18 +155,19 @@ const COLUMN_LABELS = { pii_flag: 'PII', }; -const PreviewTable = (preview) => { +const PreviewTable = (preview, emit) => { const metadataColumns = preview.metadata_columns || []; const previewRows = preview.preview_rows || []; const columns = [ - { name: '_status_icon', label: '', width: 32, overflow: 'visible' }, - { name: 'table_name', label: 'Table', width: 150 }, - { name: 'column_name', label: 'Column', width: 150 }, + { name: '_status_icon', label: '', width: 32, overflow: 'visible', align: 'center' }, + { name: 'table_name', label: 'Table', width: 150, align: 'left' }, + { name: 'column_name', label: 'Column', width: 150, align: 'left' }, ...metadataColumns.map(col => ({ name: col, label: COLUMN_LABELS[col] ?? capitalize(col.replaceAll('_', ' ')), width: col === 'description' ? 200 : 120, + align: 'left', })), ]; @@ -200,8 +206,10 @@ const PreviewTable = (preview) => { return Table( { + emit, columns, - height: Math.min(300, 40 + rows.length * 40), + height: 'auto', + maxHeight: '300px', highDensity: true, rowClass: (row) => { if (row._status === 'unmatched') return 'import-row-unmatched'; diff --git a/testgen/ui/components/frontend/js/pages/monitors_dashboard.js b/testgen/ui/components/frontend/js/pages/monitors_dashboard.js index 40113e4e..436674b9 100644 --- a/testgen/ui/components/frontend/js/pages/monitors_dashboard.js +++ b/testgen/ui/components/frontend/js/pages/monitors_dashboard.js @@ -81,8 +81,7 @@ * @property {object?} schema_changes_dialog */ import van from '/app/static/js/van.min.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { emitEvent, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; import { formatDuration, formatTimestamp, humanReadableDuration, formatNumber, viewPortUnitsToPixels } from '/app/static/js/display_utils.js'; import { Button } from '/app/static/js/components/button.js'; import { Select } from '/app/static/js/components/select.js'; @@ -104,6 +103,7 @@ const { div, i, span, b } = van.tags; const SHOW_CHANGES_COLUMNS_KEY = 'testgen__monitors__showchanges'; const MonitorsDashboard = (/** @type Properties */ props) => { + const { emit } = props; loadStylesheet('monitors-dashboard', stylesheet); let renderTime = new Date(); @@ -115,7 +115,7 @@ const MonitorsDashboard = (/** @type Properties */ props) => { return { field: sort?.sort_field, order: sort?.sort_order, - onSortChange: (sort) => emitEvent('SetParamValues', { payload: { sort_field: sort.field ?? null, sort_order: sort.order ?? null } }), + onSortChange: (sort) => emit('SetParamValues', { payload: { sort_field: sort.field ?? null, sort_order: sort.order ?? null } }), }; }); const showChangesColumns = van.state(Boolean(window.localStorage?.getItem(SHOW_CHANGES_COLUMNS_KEY) === '1')); @@ -129,7 +129,7 @@ const MonitorsDashboard = (/** @type Properties */ props) => { currentPageIdx: result.current_page, itemsPerPage: result.items_per_page, totalItems: result.total_count, - onPageChange: (page, pageSize) => emitEvent('SetParamValues', { payload: { current_page: page, items_per_page: pageSize } }), + onPageChange: (page, pageSize) => emit('SetParamValues', { payload: { current_page: page, items_per_page: pageSize } }), leftContent: div( { class: 'ml-2' }, Checkbox({ @@ -143,10 +143,10 @@ const MonitorsDashboard = (/** @type Properties */ props) => { }); const autoOpenTable = getValue(props.auto_open_table); if (autoOpenTable) { - setTimeout(() => emitEvent('OpenMonitoringTrends', { payload: { table_name: autoOpenTable } }), 0); + setTimeout(() => emit('OpenMonitoringTrends', { payload: { table_name: autoOpenTable } }), 0); } - const openChartsDialog = (monitor) => emitEvent('OpenMonitoringTrends', { payload: { table_name: monitor.table_name }}); + const openChartsDialog = (monitor) => emit('OpenMonitoringTrends', { payload: { table_name: monitor.table_name }}); const deleteDialogOpen = van.state(false); const deleteConfirmed = van.state(false); @@ -213,7 +213,7 @@ const MonitorsDashboard = (/** @type Properties */ props) => { class: 'flex-row fx-gap-1 schema-changes', onclick: () => { const summary = getValue(props.summary); - emitEvent('OpenSchemaChanges', { payload: { + emit('OpenSchemaChanges', { payload: { table_name: monitor.table_name, start_time: summary?.lookback_start, end_time: summary?.lookback_end, @@ -278,7 +278,7 @@ const MonitorsDashboard = (/** @type Properties */ props) => { tooltip: 'Edit table monitors', tooltipPosition: 'top-left', style: 'color: var(--secondary-text-color);', - onclick: () => emitEvent('EditTableMonitors', { payload: { table_name: monitor.table_name }}), + onclick: () => emit('EditTableMonitors', { payload: { table_name: monitor.table_name }}), }) : null, ), @@ -308,17 +308,17 @@ const MonitorsDashboard = (/** @type Properties */ props) => { allowNull: false, style: 'font-size: 14px;', testId: 'table-group-filter', - onChange: (value) => emitEvent('SetParamValues', {payload: {table_group_id: value, table_name: null}}), + onChange: (value) => emit('SetParamValues', {payload: {table_group_id: value, table_name: null}}), }), () => getValue(props.has_monitor_test_suite) ? AnomaliesSummary(getValue(props.summary), 'Total anomalies', { onTagClick: (type) => { const current = anomalyTypeFilterValue.val; const newFilter = current.length === 1 && current[0] === type ? null : type; - emitEvent('SetParamValues', { payload: { anomaly_type_filter: newFilter, current_page: 0 } }); + emit('SetParamValues', { payload: { anomaly_type_filter: newFilter, current_page: 0 } }); }, activeTypes: anomalyTypeFilterValue, - }) + }, emit) : '', () => getValue(props.has_monitor_test_suite) && userCanEdit ? div( @@ -330,7 +330,7 @@ const MonitorsDashboard = (/** @type Properties */ props) => { color: 'basic', type: 'stroked', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('EditNotifications', {}), + onclick: () => emit('EditNotifications', {}), }), Button({ icon: 'settings', @@ -339,7 +339,7 @@ const MonitorsDashboard = (/** @type Properties */ props) => { color: 'basic', type: 'stroked', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('EditMonitorSettings', {}), + onclick: () => emit('EditMonitorSettings', {}), }), Button({ icon: 'delete', @@ -358,6 +358,8 @@ const MonitorsDashboard = (/** @type Properties */ props) => { ), () => getValue(props.has_monitor_test_suite) ? Table( { + emit, + class: 'monitors-table', header: () => div( {class: 'flex-row fx-align-flex-end fx-gap-3 p-4 pt-2 pb-2'}, Input({ @@ -370,7 +372,7 @@ const MonitorsDashboard = (/** @type Properties */ props) => { icon: 'search', testId: 'search-tables', value: tableNameFilterValue, - onChange: (value, state) => emitEvent('SetParamValues', {payload: {table_name_filter: value, current_page: 0}}), + onChange: (value, state) => emit('SetParamValues', {payload: {table_name_filter: value, current_page: 0}}), }), Select({ label: 'Anomaly type', @@ -383,7 +385,7 @@ const MonitorsDashboard = (/** @type Properties */ props) => { ], multiSelect: true, width: 200, - onChange: (values) => emitEvent('SetParamValues', { + onChange: (values) => emit('SetParamValues', { payload: { anomaly_type_filter: values.length ? values.join(',') : null, current_page: 0 }, }), }), @@ -475,7 +477,7 @@ const MonitorsDashboard = (/** @type Properties */ props) => { }, tableRows, ) - : ConditionalEmptyState(projectSummary, userCanEdit), + : ConditionalEmptyState(projectSummary, userCanEdit, emit), Dialog( { title: 'Delete Monitors', open: deleteDialogOpen, onClose: () => { deleteDialogOpen.val = false; } }, div( @@ -491,18 +493,20 @@ const MonitorsDashboard = (/** @type Properties */ props) => { { class: 'flex-row fx-justify-flex-end' }, () => Button({ label: 'Delete', - color: 'warn', - type: 'flat', + color: deleteConfirmed.val ? 'warn' : 'basic', + type: deleteConfirmed.val ? 'flat' : 'stroked', + width: 'auto', + style: 'margin-left: auto;', disabled: !deleteConfirmed.val, onclick: () => { - emitEvent('DeleteMonitorSuiteConfirmed', {}); + emit('DeleteMonitorSuiteConfirmed', {}); deleteDialogOpen.val = false; }, }), ), ), ), - NotificationSettings({ + NotificationSettings({ emit, dialog: van.derive(() => ({ title: getValue(props.notifications_dialog)?.title ?? 'Notifications', open: notificationsDialogOpen })), smtp_configured: van.derive(() => getValue(props.notifications_dialog)?.smtp_configured ?? false), event: van.derive(() => getValue(props.notifications_dialog)?.event), @@ -512,16 +516,16 @@ const MonitorsDashboard = (/** @type Properties */ props) => { scope_options: van.derive(() => getValue(props.notifications_dialog)?.scope_options ?? []), trigger_options: van.derive(() => getValue(props.notifications_dialog)?.trigger_options ?? []), result: van.derive(() => getValue(props.notifications_dialog)?.result), - onClose: () => emitEvent('NotificationsDialogClosed', {}), + onClose: () => emit('NotificationsDialogClosed', {}), }), - EditMonitorSettings({ + EditMonitorSettings({ emit, table_group: van.derive(() => getValue(props.edit_monitor_settings_dialog)?.table_group), schedule: van.derive(() => getValue(props.edit_monitor_settings_dialog)?.schedule), monitor_suite: van.derive(() => getValue(props.edit_monitor_settings_dialog)?.monitor_suite), cron_sample: van.derive(() => getValue(props.edit_monitor_settings_dialog)?.cron_sample), dialog: van.derive(() => getValue(props.edit_monitor_settings_dialog)?.dialog), }), - TableMonitoringTrend({ + TableMonitoringTrend({ emit, freshness_events: van.derive(() => getValue(props.trends_dialog)?.freshness_events ?? []), volume_events: van.derive(() => getValue(props.trends_dialog)?.volume_events ?? []), schema_events: van.derive(() => getValue(props.trends_dialog)?.schema_events ?? []), @@ -531,21 +535,21 @@ const MonitorsDashboard = (/** @type Properties */ props) => { extended_history: van.derive(() => getValue(props.trends_dialog)?.extended_history), dialog: van.derive(() => getValue(props.trends_dialog)?.dialog), }), - EditTableMonitors({ + EditTableMonitors({ emit, table_name: van.derive(() => getValue(props.edit_table_monitors_dialog)?.table_name), definitions: van.derive(() => getValue(props.edit_table_monitors_dialog)?.definitions ?? []), metric_test_type: van.derive(() => getValue(props.edit_table_monitors_dialog)?.metric_test_type), result: van.derive(() => getValue(props.edit_table_monitors_dialog)?.result), dialog: van.derive(() => getValue(props.edit_table_monitors_dialog)?.dialog), }), - SchemaChangesDialog({ + SchemaChangesDialog({ emit, window_start: van.derive(() => getValue(props.schema_changes_dialog)?.window_start), window_end: van.derive(() => getValue(props.schema_changes_dialog)?.window_end), data_structure_logs: van.derive(() => getValue(props.schema_changes_dialog)?.data_structure_logs), dialog: van.derive(() => getValue(props.schema_changes_dialog)?.dialog), }), ) - : ConditionalEmptyState(projectSummary, userCanEdit); + : ConditionalEmptyState(projectSummary, userCanEdit, emit); } /** @@ -605,7 +609,7 @@ const AnomalyTag = (anomalies, errorMessage = null, isTraining = false, isPendin * @param {ProjectSummary} projectSummary * @param {boolean} userCanEdit */ -const ConditionalEmptyState = (projectSummary, userCanEdit) => { +const ConditionalEmptyState = (projectSummary, userCanEdit, emit) => { let args = { label: 'No monitors yet for table group', message: EMPTY_STATE_MESSAGE.monitors, @@ -616,7 +620,7 @@ const ConditionalEmptyState = (projectSummary, userCanEdit) => { color: 'primary', style: 'width: unset;', disabled: !userCanEdit, - onclick: () => emitEvent('EditMonitorSettings', {}), + onclick: () => emit('EditMonitorSettings', {}), }), } if (projectSummary.connection_count <= 0) { @@ -644,7 +648,7 @@ const ConditionalEmptyState = (projectSummary, userCanEdit) => { }; } - return EmptyState({ + return EmptyState({ emit, icon: 'apps_outage', ...args, }); @@ -664,30 +668,30 @@ stylesheet.replace(` display: none; } -th.tg-table-column.action span { +.monitors-table th.tg-table-column.action span { white-space: pre-line; text-transform: none; } -.tg-table-column.table_name, -.tg-table-column.freshness_anomalies, -.tg-table-column.latest_update, -.tg-table-cell.table_name, -.tg-table-cell.freshness_anomalies, -.tg-table-cell.latest_update { +.monitors-table .tg-table-column.table_name, +.monitors-table .tg-table-column.freshness_anomalies, +.monitors-table .tg-table-column.latest_update, +.monitors-table .tg-table-cell.table_name, +.monitors-table .tg-table-cell.freshness_anomalies, +.monitors-table .tg-table-cell.latest_update { padding-left: 16px !important; } -.tg-table-column.table_name, -.tg-table-column.metric_anomalies, -.tg-table-column.schema_changes, -.tg-table-cell.table_name, -.tg-table-cell.metric_anomalies, -.tg-table-cell.schema_changes { +.monitors-table .tg-table-column.table_name, +.monitors-table .tg-table-column.metric_anomalies, +.monitors-table .tg-table-column.schema_changes, +.monitors-table .tg-table-cell.table_name, +.monitors-table .tg-table-cell.metric_anomalies, +.monitors-table .tg-table-cell.schema_changes { border-right: 1px dashed var(--border-color); } -.tg-table-cell.schema_changes { +.monitors-table .tg-table-cell.schema_changes { padding-right: 0; padding-left: 0; } @@ -727,8 +731,6 @@ export { MonitorsDashboard }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -736,6 +738,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, MonitorsDashboard(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/notification_settings.js b/testgen/ui/components/frontend/js/pages/notification_settings.js index 81495658..9b879fb7 100644 --- a/testgen/ui/components/frontend/js/pages/notification_settings.js +++ b/testgen/ui/components/frontend/js/pages/notification_settings.js @@ -40,8 +40,7 @@ import van from '/app/static/js/van.min.js'; import { Button } from '/app/static/js/components/button.js'; import { Dialog } from '/app/static/js/components/dialog.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { emitEvent, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; import { ExpansionPanel } from '/app/static/js/components/expansion_panel.js'; import { Select } from '/app/static/js/components/select.js'; import { Alert } from '/app/static/js/components/alert.js'; @@ -56,13 +55,14 @@ const minHeight = 500; const { div, span, b } = van.tags; const NotificationSettings = (/** @type Properties */ props) => { + const { emit } = props; loadStylesheet('notification-settings', stylesheet); const dialogProp = getValue(props.dialog); const dialogOpen = van.state(dialogProp?.open === true); if (!getValue(props.smtp_configured)) { - const emptyContent = EmptyState({ + const emptyContent = EmptyState({ emit, label: 'Email server not configured.', message: EMPTY_STATE_MESSAGE.notifications, class: 'notifications--empty', @@ -78,7 +78,7 @@ const NotificationSettings = (/** @type Properties */ props) => { { title: dialogTitle, open: dialogOpen, - onClose: () => { dialogOpen.val = false; emitEvent('CloseClicked', {}); }, + onClose: () => { dialogOpen.val = false; emit('CloseClicked', {}); }, width: '65rem', }, emptyContent, @@ -183,14 +183,14 @@ const NotificationSettings = (/** @type Properties */ props) => { icon: 'pause', tooltip: 'Pause notification', style: 'height: 32px;', - onclick: () => emitEvent('PauseNotification', { payload: item }), + onclick: () => emit('PauseNotification', { payload: item }), }) : Button({ type: 'stroked', icon: 'play_arrow', tooltip: 'Resume notification', style: 'height: 32px;', - onclick: () => emitEvent('ResumeNotification', { payload: item }), + onclick: () => emit('ResumeNotification', { payload: item }), }), Button({ type: 'stroked', @@ -216,7 +216,7 @@ const NotificationSettings = (/** @type Properties */ props) => { tooltip: 'Delete notification', tooltipPosition: 'top-left', style: 'height: 32px;', - onclick: () => emitEvent('DeleteNotification', { payload: item }), + onclick: () => emit('DeleteNotification', { payload: item }), }), ]) : null, ), @@ -329,7 +329,7 @@ const NotificationSettings = (/** @type Properties */ props) => { type: 'stroked', label: newNotificationItemForm.isEdit.val ? 'Save Changes' : 'Add Notification', width: 'auto', - onclick: () => emitEvent( + onclick: () => emit( newNotificationItemForm.isEdit.val ? 'UpdateNotification' : 'AddNotification', { payload: { @@ -394,7 +394,7 @@ const NotificationSettings = (/** @type Properties */ props) => { { title: dialogTitle, open: dialogOpen, - onClose: () => { dialogOpen.val = false; emitEvent('CloseClicked', {}); }, + onClose: () => { dialogOpen.val = false; emit('CloseClicked', {}); }, width: '65rem', }, content, @@ -427,8 +427,6 @@ export { NotificationSettings }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -436,6 +434,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, NotificationSettings(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/profiling_results.js b/testgen/ui/components/frontend/js/pages/profiling_results.js index 57d21724..7b6713c9 100644 --- a/testgen/ui/components/frontend/js/pages/profiling_results.js +++ b/testgen/ui/components/frontend/js/pages/profiling_results.js @@ -49,25 +49,23 @@ import { Icon } from '/app/static/js/components/icon.js'; import { Select } from '/app/static/js/components/select.js'; import { DropdownButton } from '/app/static/js/components/dropdown_button.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { emitEvent, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; import { DataCharacteristicsCard } from '../data_profiling/data_characteristics.js'; import { ColumnDistributionCard } from '../data_profiling/column_distribution.js'; -import { PotentialPIICard, HygieneIssuesCard } from '../data_profiling/data_issues.js'; +import { HygieneIssuesCard } from '../data_profiling/data_issues.js'; const { div, span, h2 } = van.tags; const TYPE_ICONS = { - A: { icon: 'font_download', style: 'color: var(--blue)' }, - B: { icon: 'toggle_on', style: 'color: var(--green)' }, - D: { icon: 'calendar_month', style: 'color: var(--orange)' }, - N: { icon: 'pin', style: 'color: var(--purple)' }, - T: { icon: 'table_view', style: 'color: var(--secondary-text-color)' }, - X: { icon: 'question_mark', style: 'color: var(--secondary-text-color)' }, + A: { icon: 'abc', size: 24 }, + B: { icon: 'toggle_off' }, + D: { icon: 'calendar_clock' }, + N: { icon: '123', size: 24 }, + T: { icon: 'calendar_clock' }, + X: { icon: 'question_mark' }, }; - const TABLE_COLUMNS = [ - { name: 'type_icon', label: '', width: 32, align: 'center' }, + { name: 'type_icon', label: '', width: 40, align: 'center', overflow: 'hidden' }, { name: 'table_name', label: 'Table', width: 180, sortable: true, overflow: 'hidden' }, { name: 'column_name', label: 'Column', width: 180, sortable: true, overflow: 'hidden' }, { name: 'db_data_type', label: 'Data Type', width: 130, sortable: true, overflow: 'hidden' }, @@ -77,10 +75,10 @@ const TABLE_COLUMNS = [ ]; const buildTableRow = (/** @type ProfilingItem */ item) => { - const iconInfo = TYPE_ICONS[item.general_type] ?? TYPE_ICONS['X']; + const iconData = TYPE_ICONS[item.general_type] || TYPE_ICONS.X; return { id: item.id, - type_icon: Icon({ style: `${iconInfo.style}; font-size: 18px` }, iconInfo.icon), + type_icon: Icon({ style: `color: #B0BEC5; font-size: ${iconData.size ?? 18}px; display: table-cell;` }, iconData.icon), table_name: item.table_name ?? '', column_name: item.column_name ?? '', db_data_type: item.db_data_type ?? '', @@ -92,16 +90,17 @@ const buildTableRow = (/** @type ProfilingItem */ item) => { }; }; -const ExportMenu = (tableFilter, columnFilter, selectedRowId) => { +const ExportMenu = (tableFilter, columnFilter, selectedRowId, emit) => { return DropdownButton({ icon: 'download', label: 'Export', + buttonSize: 'small', items: () => { const items = [ - { label: 'All results', onclick: () => emitEvent('ExportAll', {}) }, + { label: 'All results', onclick: () => emit('ExportAll', {}) }, { label: 'Filtered results', - onclick: () => emitEvent('ExportFiltered', { + onclick: () => emit('ExportFiltered', { payload: { table_name: tableFilter.rawVal, column_name: columnFilter.rawVal }, }), }, @@ -109,7 +108,7 @@ const ExportMenu = (tableFilter, columnFilter, selectedRowId) => { if (selectedRowId.val) { items.push({ label: 'Selected results', - onclick: () => emitEvent('ExportSelected', { payload: selectedRowId.rawVal }), + onclick: () => emit('ExportSelected', { payload: selectedRowId.rawVal }), }); } return items; @@ -118,6 +117,7 @@ const ExportMenu = (tableFilter, columnFilter, selectedRowId) => { }; const ProfilingResults = (/** @type Properties */ props) => { + const { emit } = props; loadStylesheet('profiling-results', stylesheet); const items = van.derive(() => getValue(props.items) ?? []); @@ -170,7 +170,7 @@ const ProfilingResults = (/** @type Properties */ props) => { const onSortChange = (newColumns) => { sortColumns.val = newColumns; - emitEvent('SortChanged', { payload: { columns: newColumns } }); + emit('SortChanged', { payload: { columns: newColumns } }); }; const tableSortOptions = van.derive(() => ({ @@ -184,29 +184,29 @@ const ProfilingResults = (/** @type Properties */ props) => { const row = items.rawVal[idxs[0]]; if (row && row.id !== selectedRowId.rawVal) { selectedRowId.val = row.id; - emitEvent('RowSelected', { payload: row.id }); + emit('RowSelected', { payload: row.id }); } } }; - const onTableFilterChange = (value) => { - tableFilter.val = value; + const onTableFilterChange = (value, meta) => { + tableFilter.val = meta?.isCustom ? `%${value}%` : value; columnFilter.val = null; selectedRowId.val = null; - emitEvent('FilterChanged', { payload: { table_name: value, column_name: null } }); + emit('FilterChanged', { payload: { table_name: tableFilter.val, column_name: null } }); }; - const onColumnFilterChange = (value) => { - columnFilter.val = value; + const onColumnFilterChange = (value, meta) => { + columnFilter.val = meta?.isCustom ? `%${value}%` : value; selectedRowId.val = null; - emitEvent('FilterChanged', { payload: { table_name: tableFilter.rawVal, column_name: value } }); + emit('FilterChanged', { payload: { table_name: tableFilter.rawVal, column_name: columnFilter.val } }); }; // Table header bar with export menu const tableHeader = div( { class: 'flex-row fx-align-center fx-gap-2 p-2' }, div({ class: 'fx-flex' }), - ExportMenu(tableFilter, columnFilter, selectedRowId), + ExportMenu(tableFilter, columnFilter, selectedRowId, emit), ); const paginatorOptions = van.derive(() => ({ @@ -216,9 +216,9 @@ const ProfilingResults = (/** @type Properties */ props) => { pageSizeOptions: [100, 500, 1000], onPageChange: (pageIdx, newPerPage) => { if (newPerPage !== pageSize.rawVal) { - emitEvent('PageChanged', { payload: { page: 0, page_size: newPerPage } }); + emit('PageChanged', { payload: { page: 0, page_size: newPerPage } }); } else { - emitEvent('PageChanged', { payload: { page: pageIdx } }); + emit('PageChanged', { payload: { page: pageIdx } }); } }, })); @@ -226,11 +226,12 @@ const ProfilingResults = (/** @type Properties */ props) => { // Pre-build the Table once const dataTable = Table( { + emit, columns: TABLE_COLUMNS, header: tableHeader, highDensity: true, dynamicWidth: true, - height: '40vh', + height: '50vh', emptyState: div( { class: 'flex-row fx-justify-center empty-table-message' }, span({ class: 'text-secondary' }, 'No profiling results found matching filters'), @@ -253,6 +254,8 @@ const ProfilingResults = (/** @type Properties */ props) => { options: tableOptions.val, testId: 'table-filter', style: 'min-width: 200px', + filterable: true, + acceptNewOptions: true, onChange: onTableFilterChange, allowNull: true, }), @@ -262,6 +265,8 @@ const ProfilingResults = (/** @type Properties */ props) => { options: columnOptions.val, testId: 'column-filter', style: 'min-width: 200px', + filterable: true, + acceptNewOptions: true, onChange: onColumnFilterChange, allowNull: true, }), @@ -281,16 +286,13 @@ const ProfilingResults = (/** @type Properties */ props) => { selectedRow.val.column_name, ), ), - DataCharacteristicsCard({ border: true }, selectedRow.val), - ColumnDistributionCard({ border: true, dataPreview: false }, selectedRow.val), + DataCharacteristicsCard({ emit, border: true }, selectedRow.val), + ColumnDistributionCard({ emit, border: true, dataPreview: false }, selectedRow.val), () => { const si = selectedItemData.val; if (!si || si.id !== selectedRowId.rawVal) return ''; if (!Array.isArray(si.hygiene_issues) || !si.hygiene_issues.length) return ''; - return div( - PotentialPIICard({ border: true }, si), - HygieneIssuesCard({ border: true }, si), - ); + return HygieneIssuesCard({ emit, border: true }, si); }, ) : '', @@ -307,7 +309,7 @@ stylesheet.replace(` font-weight: 400; } .tg-pr--detail { - border-top: 1px solid var(--border-color, #dddfe2); + border-top: 1px dashed var(--border-color, #dddfe2); padding-top: 16px; } `); @@ -317,8 +319,6 @@ export { ProfilingResults }; export default (component) => { const { data, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -326,6 +326,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, ProfilingResults(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/profiling_runs.js b/testgen/ui/components/frontend/js/pages/profiling_runs.js index 25985cc5..5128c335 100644 --- a/testgen/ui/components/frontend/js/pages/profiling_runs.js +++ b/testgen/ui/components/frontend/js/pages/profiling_runs.js @@ -51,8 +51,7 @@ import { withTooltip } from '/app/static/js/components/tooltip.js'; import { SummaryCounts } from '/app/static/js/components/summary_counts.js'; import { Link } from '/app/static/js/components/link.js'; import { Button } from '/app/static/js/components/button.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { emitEvent, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; import { formatTimestamp, formatDuration, formatNumber, DISABLED_ACTION_TEXT } from '/app/static/js/display_utils.js'; import { Checkbox } from '/app/static/js/components/checkbox.js'; import { Select } from '/app/static/js/components/select.js'; @@ -77,6 +76,7 @@ const progressStatusIcons = { }; const ProfilingRuns = (/** @type Properties */ props) => { + const { emit } = props; loadStylesheet('profilingRuns', stylesheet); const columns = ['5%', '20%', '15%', '20%', '30%', '10%']; @@ -93,7 +93,7 @@ const ProfilingRuns = (/** @type Properties */ props) => { const paginated = profilingRuns.val.slice(PAGE_SIZE * pageIndex.val, PAGE_SIZE * (pageIndex.val + 1)); const hasActiveRuns = paginated.some(({ status }) => status === 'Running'); if (!refreshIntervalId && hasActiveRuns) { - refreshIntervalId = setInterval(() => emitEvent('RefreshData', {}), REFRESH_INTERVAL); + refreshIntervalId = setInterval(() => emit('RefreshData', {}), REFRESH_INTERVAL); } else if (refreshIntervalId && !hasActiveRuns) { clearInterval(refreshIntervalId); } @@ -118,7 +118,7 @@ const ProfilingRuns = (/** @type Properties */ props) => { const closeDeleteDialog = () => { deleteDialogOpen.val = false; deleteConstraintChecked.val = false; - emitEvent('DeleteDialogClosed', {}); + emit('DeleteDialogClosed', {}); }; const scheduleDialogOpen = van.state(false); @@ -164,7 +164,7 @@ const ProfilingRuns = (/** @type Properties */ props) => { disabled: !someRunSelected, width: 'auto', onclick: () => { - emitEvent('DeleteRunsClicked', { payload: selectedItems.map(r => r.id) }); + emit('DeleteRunsClicked', { payload: selectedItems.map(r => r.id) }); }, }), ); @@ -215,10 +215,10 @@ const ProfilingRuns = (/** @type Properties */ props) => { ), ), div( - paginatedRuns.val.map(item => ProfilingRunItem(item, columns, selectedRuns[item.id], userCanEdit, projectSummary.project_code)), + paginatedRuns.val.map(item => ProfilingRunItem(item, columns, selectedRuns[item.id], userCanEdit, projectSummary.project_code, emit)), ), ), - Paginator({ + Paginator({ emit, pageIndex, count: profilingRuns.val.length, pageSize: PAGE_SIZE, @@ -235,7 +235,7 @@ const ProfilingRuns = (/** @type Properties */ props) => { 'No profiling runs found matching filters', ), ) - : ConditionalEmptyState(projectSummary, userCanEdit); + : ConditionalEmptyState(projectSummary, userCanEdit, emit); }, Dialog( { title: 'Delete Profiling Runs', open: deleteDialogOpen, onClose: closeDeleteDialog }, @@ -269,13 +269,16 @@ const ProfilingRuns = (/** @type Properties */ props) => { () => { const info = getValue(props.delete_dialog); const hasActiveJob = info?.has_active_job ?? false; + const isDisabled = hasActiveJob && !deleteConstraintChecked.val; return Button({ label: 'Delete', - color: 'warn', - type: 'flat', - disabled: hasActiveJob && !deleteConstraintChecked.val, + color: isDisabled ? 'basic' : 'warn', + type: isDisabled ? 'stroked' : 'flat', + width: 'auto', + style: 'margin-left: auto;', + disabled: isDisabled, onclick: () => { - emitEvent('RunsDeleted', { payload: info?.run_ids ?? [] }); + emit('RunsDeleted', { payload: info?.run_ids ?? [] }); closeDeleteDialog(); }, }); @@ -289,16 +292,16 @@ const ProfilingRuns = (/** @type Properties */ props) => { runProfilingDialogEl = null; return div(); } - return (runProfilingDialogEl ??= RunProfilingDialog({ + return (runProfilingDialogEl ??= RunProfilingDialog({ emit, dialog: { title: info.title ?? 'Run Profiling', open: true }, table_groups: info.table_groups ?? [], allow_selection: info.allow_selection ?? false, selected_id: info.selected_id, result: van.derive(() => getValue(props.run_profiling_dialog)?.result), - onClose: () => emitEvent('RunProfilingDialogClosed', {}), + onClose: () => emit('RunProfilingDialogClosed', {}), })); }, - ScheduleList({ + ScheduleList({ emit, dialog: van.derive(() => ({ title: getValue(props.schedule_dialog)?.title ?? 'Schedules', open: scheduleDialogOpen, @@ -309,9 +312,9 @@ const ProfilingRuns = (/** @type Properties */ props) => { arg_values: van.derive(() => getValue(props.schedule_dialog)?.arg_values ?? []), sample: van.derive(() => getValue(props.schedule_dialog)?.sample), results: van.derive(() => getValue(props.schedule_dialog)?.results), - onClose: () => emitEvent('ScheduleDialogClosed', {}), + onClose: () => emit('ScheduleDialogClosed', {}), }), - NotificationSettings({ + NotificationSettings({ emit, dialog: van.derive(() => ({ title: getValue(props.notifications_dialog)?.title ?? 'Notifications', open: notificationsDialogOpen, @@ -326,7 +329,7 @@ const ProfilingRuns = (/** @type Properties */ props) => { cde_enabled: van.derive(() => getValue(props.notifications_dialog)?.cde_enabled ?? false), total_enabled: van.derive(() => getValue(props.notifications_dialog)?.total_enabled ?? false), result: van.derive(() => getValue(props.notifications_dialog)?.result), - onClose: () => emitEvent('NotificationsDialogClosed', {}), + onClose: () => emit('NotificationsDialogClosed', {}), }), ); }; @@ -335,6 +338,7 @@ const Toolbar = ( /** @type Properties */ props, /** @type boolean */ userCanEdit, ) => { + const emit = props.emit; return div( { class: 'flex-row fx-align-flex-end fx-justify-space-between mb-4 fx-gap-4 fx-flex-wrap' }, () => Select({ @@ -344,7 +348,7 @@ const Toolbar = ( allowNull: true, style: 'font-size: 14px;', testId: 'table-group-filter', - onChange: (value) => emitEvent('FilterApplied', { payload: { table_group_id: value } }), + onChange: (value) => emit('FilterApplied', { payload: { table_group_id: value } }), }), div( { class: 'flex-row fx-gap-3' }, @@ -356,7 +360,7 @@ const Toolbar = ( tooltipPosition: 'bottom', width: 'fit-content', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('RunNotificationsClicked', {}), + onclick: () => emit('RunNotificationsClicked', {}), }), Button({ icon: 'today', @@ -366,7 +370,7 @@ const Toolbar = ( tooltipPosition: 'bottom', width: 'fit-content', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('RunSchedulesClicked', {}), + onclick: () => emit('RunSchedulesClicked', {}), }), userCanEdit ? Button({ @@ -375,7 +379,7 @@ const Toolbar = ( label: 'Run Profiling', width: 'fit-content', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('RunProfilingClicked', {}), + onclick: () => emit('RunProfilingClicked', {}), }) : '', Button({ @@ -384,7 +388,7 @@ const Toolbar = ( tooltip: 'Refresh profiling runs list', tooltipPosition: 'left', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('RefreshData', {}), + onclick: () => emit('RefreshData', {}), testId: 'profiling-runs-refresh', }), ), @@ -397,6 +401,7 @@ const ProfilingRunItem = ( /** @type boolean */ selected, /** @type boolean */ userCanEdit, /** @type string */ projectCode, + emit, ) => { const runningStep = item.progress?.find((item) => item.status === 'Running'); @@ -430,7 +435,7 @@ const ProfilingRunItem = ( label: 'Cancel', style: 'width: 64px; height: 28px; color: var(--purple); margin-left: 12px;', onclick: () => { - emitEvent('RunCanceled', { payload: item }); + emit('RunCanceled', { payload: item }); }, }) : null, ), @@ -479,7 +484,7 @@ const ProfilingRunItem = ( ) : null, ), - item.status === 'Complete' && item.column_ct ? Link({ + item.status === 'Complete' && item.column_ct ? Link({ emit, label: 'View results', href: 'profiling-runs:results', params: { 'run_id': item.id, 'project_code': projectCode }, @@ -497,7 +502,7 @@ const ProfilingRunItem = ( { label: 'Dismissed', value: item.anomalies_dismissed_ct, color: 'grey' }, ], }) : '--', - item.anomaly_ct ? Link({ + item.anomaly_ct ? Link({ emit, label: `View ${item.anomaly_ct} issues`, href: 'profiling-runs:hygiene', params: { 'run_id': item.id, 'project_code': projectCode }, @@ -571,6 +576,7 @@ const ProgressTooltip = (/** @type ProfilingRun */ item) => { const ConditionalEmptyState = ( /** @type ProjectSummary */ projectSummary, /** @type boolean */ userCanEdit, + emit, ) => { let args = { message: EMPTY_STATE_MESSAGE.profiling, @@ -584,7 +590,7 @@ const ConditionalEmptyState = ( disabled: !userCanEdit, tooltip: userCanEdit ? null : DISABLED_ACTION_TEXT, tooltipPosition: 'bottom', - onclick: () => emitEvent('RunProfilingClicked', {}), + onclick: () => emit('RunProfilingClicked', {}), }), }; @@ -608,7 +614,7 @@ const ConditionalEmptyState = ( }; } - return EmptyState({ + return EmptyState({ emit, icon: 'data_thresholding', label: 'No profiling runs yet', ...args, @@ -631,8 +637,6 @@ export { ProfilingRuns }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -640,6 +644,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, ProfilingRuns(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/project_dashboard.js b/testgen/ui/components/frontend/js/pages/project_dashboard.js index 256a80a6..e9b70576 100644 --- a/testgen/ui/components/frontend/js/pages/project_dashboard.js +++ b/testgen/ui/components/frontend/js/pages/project_dashboard.js @@ -40,8 +40,7 @@ * @property {SortOption[]} table_groups_sort_options */ import van from '/app/static/js/van.min.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; import { formatNumber, formatTimestamp, caseInsensitiveSort, caseInsensitiveIncludes } from '/app/static/js/display_utils.js'; import { Card } from '/app/static/js/components/card.js'; import { Select } from '/app/static/js/components/select.js'; @@ -58,6 +57,7 @@ const { div, h3, hr, span } = van.tags; const staleProfileDays = 60; const ProjectDashboard = (/** @type Properties */ props) => { + const { emit } = props; loadStylesheet('project-dashboard', stylesheet); const tableGroups = van.derive(() => getValue(props.table_groups)); @@ -117,19 +117,19 @@ const ProjectDashboard = (/** @type Properties */ props) => { { class: 'flex-column mt-4 fx-gap-3' }, getValue(filteredTableGroups).map(tableGroup => tableGroup.monitoring_summary - ? TableGroupCardWithMonitor(tableGroup, getValue(props.project_summary)?.project_code) - : TableGroupCard(tableGroup, getValue(props.project_summary)?.project_code) + ? TableGroupCardWithMonitor(tableGroup, getValue(props.project_summary)?.project_code, emit) + : TableGroupCard(tableGroup, getValue(props.project_summary)?.project_code, emit) ) ) : div( { class: 'mt-7 text-secondary', style: 'text-align: center;' }, 'No table groups found matching filters', ) - : ConditionalEmptyState(getValue(props.project_summary)), + : ConditionalEmptyState(getValue(props.project_summary), emit), ); } -const TableGroupCard = (/** @type TableGroupSummary */ tableGroup, /** @type string */ projectCode) => { +const TableGroupCard = (/** @type TableGroupSummary */ tableGroup, /** @type string */ projectCode, emit) => { const useApprox = tableGroup.record_ct === null || tableGroup.record_ct === undefined; return Card({ @@ -154,12 +154,12 @@ const TableGroupCard = (/** @type TableGroupSummary */ tableGroup, /** @type str ${formatNumber(useApprox ? tableGroup.approx_data_point_ct : tableGroup.data_point_ct)} data points ${useApprox ? '*' : ''}`, ), - TableGroupTestSuiteSummary(tableGroup.test_suites, projectCode), + TableGroupTestSuiteSummary(tableGroup.test_suites, projectCode, emit), ), ScoreMetric(tableGroup.dq_score, tableGroup.dq_score_profiling, tableGroup.dq_score_testing), ), hr({ class: 'tg-overview--table-group-divider' }), - TableGroupLatestProfile(tableGroup, projectCode), + TableGroupLatestProfile(tableGroup, projectCode, emit), useApprox ? span({ class: 'text-caption text-right' }, '* Approximate counts based on server statistics') : null, @@ -167,7 +167,7 @@ const TableGroupCard = (/** @type TableGroupSummary */ tableGroup, /** @type str }); }; -const TableGroupCardWithMonitor = (/** @type TableGroupSummary */ tableGroup, /** @type string */ projectCode) => { +const TableGroupCardWithMonitor = (/** @type TableGroupSummary */ tableGroup, /** @type string */ projectCode, emit) => { const useApprox = tableGroup.record_ct === null || tableGroup.record_ct === undefined; return Card({ testId: 'table-group-summary-card', @@ -195,15 +195,15 @@ const TableGroupCardWithMonitor = (/** @type TableGroupSummary */ tableGroup, /* ${useApprox ? '*' : ''}`, ), ), - AnomaliesSummary(tableGroup.monitoring_summary, 'Monitor anomalies'), + AnomaliesSummary(tableGroup.monitoring_summary, 'Monitor anomalies', {}, emit), ), ScoreMetric(tableGroup.dq_score, tableGroup.dq_score_profiling, tableGroup.dq_score_testing), ), hr({ class: 'tg-overview--table-group-divider' }), - TableGroupTestSuiteSummary(tableGroup.test_suites, projectCode), + TableGroupTestSuiteSummary(tableGroup.test_suites, projectCode, emit), hr({ class: 'tg-overview--table-group-divider' }), - TableGroupLatestProfile(tableGroup, projectCode), + TableGroupLatestProfile(tableGroup, projectCode, emit), useApprox ? span({ class: 'text-caption text-right' }, '* Approximate counts based on server statistics') : null, @@ -211,7 +211,7 @@ const TableGroupCardWithMonitor = (/** @type TableGroupSummary */ tableGroup, /* }); }; -const TableGroupLatestProfile = (/** @type TableGroupSummary */ tableGroup, /** @type string */ projectCode) => { +const TableGroupLatestProfile = (/** @type TableGroupSummary */ tableGroup, /** @type string */ projectCode, emit) => { if (!tableGroup.latest_profile_start) { return div( { class: 'mt-1 mb-1 text-secondary' }, @@ -226,7 +226,7 @@ const TableGroupLatestProfile = (/** @type TableGroupSummary */ tableGroup, /** div( { class: 'flex-row fx-gap-2', style: 'flex: 1 1 50%;' }, span('Latest profile:'), - Link({ + Link({ emit, label: formatTimestamp(tableGroup.latest_profile_start), href: 'profiling-runs:results', params: { run_id: tableGroup.latest_profile_id, project_code: projectCode }, @@ -237,7 +237,7 @@ const TableGroupLatestProfile = (/** @type TableGroupSummary */ tableGroup, /** ), div( { class: 'flex-row fx-gap-5', style: 'flex: 1 1 50%;' }, - Link({ + Link({ emit, label: `${tableGroup.latest_anomalies_ct} hygiene issues`, href: 'profiling-runs:hygiene', params: { @@ -261,7 +261,7 @@ const TableGroupLatestProfile = (/** @type TableGroupSummary */ tableGroup, /** ); }; -const TableGroupTestSuiteSummary = (/** @type TestSuiteSummary[] */testSuites, /** @type string */ projectCode) => { +const TableGroupTestSuiteSummary = (/** @type TestSuiteSummary[] */testSuites, /** @type string */ projectCode, emit) => { if (!testSuites?.length) { return div( { class: 'mt-1 mb-1 text-secondary' }, @@ -281,7 +281,7 @@ const TableGroupTestSuiteSummary = (/** @type TestSuiteSummary[] */testSuites, / { class: 'flex-row fx-align-flex-start mt-2 tg-overview--row' }, div( { class: 'flex-column', style: 'flex: 1 1 25%; word-break: break-word;' }, - Link({ + Link({ emit, label: suite.test_suite, href: 'test-suites:definitions', params: { test_suite_id: suite.id, project_code: projectCode }, @@ -289,7 +289,7 @@ const TableGroupTestSuiteSummary = (/** @type TestSuiteSummary[] */testSuites, / span({ class: 'text-caption' }, `${suite.test_ct ?? 0} tests`), ), suite.latest_run_id - ? Link({ + ? Link({ emit, label: formatTimestamp(suite.latest_run_start), href: 'test-runs:results', params: { run_id: suite.latest_run_id, project_code: projectCode }, @@ -315,7 +315,7 @@ const TableGroupTestSuiteSummary = (/** @type TestSuiteSummary[] */testSuites, / ); }; -const ConditionalEmptyState = (/** @type ProjectSummary */ project) => { +const ConditionalEmptyState = (/** @type ProjectSummary */ project, emit) => { const forConnections = { message: EMPTY_STATE_MESSAGE.connection, link: { @@ -335,7 +335,7 @@ const ConditionalEmptyState = (/** @type ProjectSummary */ project) => { const args = project.connection_count > 0 ? forTablegroups : forConnections; - return EmptyState({ + return EmptyState({ emit, icon: 'home', label: 'Your project is empty', ...args, @@ -382,8 +382,6 @@ export { ProjectDashboard }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -391,6 +389,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, ProjectDashboard(componentState)); } else { for (const [key, value] of Object.entries(data)) { @@ -401,7 +400,6 @@ export default (component) => { } return () => { - Streamlit.disableV2(setTriggerValue); parentElement.state = null; }; }; diff --git a/testgen/ui/components/frontend/js/pages/quality_dashboard.js b/testgen/ui/components/frontend/js/pages/quality_dashboard.js index b173530d..a592678b 100644 --- a/testgen/ui/components/frontend/js/pages/quality_dashboard.js +++ b/testgen/ui/components/frontend/js/pages/quality_dashboard.js @@ -13,8 +13,7 @@ * @property {Array} scores */ import van from '/app/static/js/van.min.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { emitEvent, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; import { Input } from '/app/static/js/components/input.js'; import { Select } from '/app/static/js/components/select.js'; import { Link } from '/app/static/js/components/link.js'; @@ -27,6 +26,7 @@ import { caseInsensitiveSort, caseInsensitiveIncludes } from '/app/static/js/dis const { div, span } = van.tags; const QualityDashboard = (/** @type {Properties} */ props) => { + const { emit } = props; loadStylesheet('quality-dashboard', stylesheet); const domId = 'score-dashboard-page'; @@ -65,13 +65,14 @@ const QualityDashboard = (/** @type {Properties} */ props) => { filterTerm, sortedBy, getValue(props.project_summary), + emit, ), () => getValue(scores).length ? div( { class: 'flex-row fx-flex-wrap fx-gap-4' }, getValue(scores).map(score => ScoreCard( score, - Link({ + Link({ emit, label: 'View details', right_icon: 'chevron_right', href: 'quality-dashboard:score-details', @@ -85,7 +86,7 @@ const QualityDashboard = (/** @type {Properties} */ props) => { { class: 'mt-7 text-secondary', style: 'text-align: center;' }, 'No scorecards found matching filters', ), - ) : ConditionalEmptyState(getValue(props.project_summary)), + ) : ConditionalEmptyState(getValue(props.project_summary), emit), ); }; @@ -93,7 +94,8 @@ const Toolbar = ( options, /** @type {string} */ filterBy, /** @type {string} */ sortedBy, - /** @type ProjectSummary */ projectSummary + /** @type ProjectSummary */ projectSummary, + emit, ) => { const sortOptions = [ { label: "Scorecard Name", value: "name" }, @@ -127,7 +129,7 @@ const Toolbar = ( label: 'Score Explorer', color: 'primary', style: 'background: var(--button-generic-background-color); width: unset;', - onclick: () => emitEvent('LinkClicked', { + onclick: () => emit('LinkClicked', { href: 'quality-dashboard:explorer', params: { project_code: projectSummary.project_code }, testId: 'scorecards-goto-explorer', @@ -139,13 +141,13 @@ const Toolbar = ( tooltip: 'Refresh page data', tooltipPosition: 'left', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('RefreshData', {}), + onclick: () => emit('RefreshData', {}), testId: 'scorecards-refresh', }), ); }; -const ConditionalEmptyState = (/** @type ProjectSummary */ projectSummary) => { +const ConditionalEmptyState = (/** @type ProjectSummary */ projectSummary, emit) => { let args = { message: EMPTY_STATE_MESSAGE.score, link: { @@ -175,7 +177,7 @@ const ConditionalEmptyState = (/** @type ProjectSummary */ projectSummary) => { }; } - return EmptyState({ + return EmptyState({ emit, icon: 'readiness_score', label: 'No scores yet', ...args, @@ -190,8 +192,6 @@ export { QualityDashboard }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -199,6 +199,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, QualityDashboard(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/run_tests_dialog.js b/testgen/ui/components/frontend/js/pages/run_tests_dialog.js index 5c3f60f2..04ee1523 100644 --- a/testgen/ui/components/frontend/js/pages/run_tests_dialog.js +++ b/testgen/ui/components/frontend/js/pages/run_tests_dialog.js @@ -20,17 +20,17 @@ import van from '/app/static/js/van.min.js'; import { Button } from '/app/static/js/components/button.js'; import { Dialog } from '/app/static/js/components/dialog.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; import { Alert } from '/app/static/js/components/alert.js'; import { Code } from '/app/static/js/components/code.js'; import { ExpanderToggle } from '/app/static/js/components/expander_toggle.js'; import { Icon } from '/app/static/js/components/icon.js'; import { Select } from '/app/static/js/components/select.js'; -import { emitEvent, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; const { div, span, strong } = van.tags; const RunTestsDialog = (/** @type Properties */ props) => { + const { emit } = props; loadStylesheet('run-tests-dialog', stylesheet); const dialogProp = getValue(props.dialog); @@ -86,7 +86,7 @@ const RunTestsDialog = (/** @type Properties */ props) => { width: 'auto', style: 'width: auto;', disabled: van.derive(() => !selectedTestSuite.val), - onclick: () => emitEvent('RunTestsConfirmed', { + onclick: () => emit('RunTestsConfirmed', { payload: { test_suite_id: selectedTestSuite.val?.value, test_suite_name: selectedTestSuite.val?.label, @@ -102,7 +102,7 @@ const RunTestsDialog = (/** @type Properties */ props) => { label: 'Go to Test Runs', style: 'width: auto; margin-left: auto; margin-top: 12px;', icon: 'chevron_right', - onclick: () => emitEvent('GoToTestRunsClicked', { + onclick: () => emit('GoToTestRunsClicked', { payload: { project_code: getValue(props.project_code), test_suite_id: selectedTestSuite.val?.value, @@ -118,7 +118,7 @@ const RunTestsDialog = (/** @type Properties */ props) => { { title: dialogTitle, open: dialogOpen, - onClose: () => { dialogOpen.val = false; emitEvent('CloseClicked', {}); }, + onClose: () => { dialogOpen.val = false; emit('CloseClicked', {}); }, width: '32rem', }, content, @@ -143,8 +143,6 @@ export { RunTestsDialog }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -152,6 +150,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, RunTestsDialog(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/schedule_list.js b/testgen/ui/components/frontend/js/pages/schedule_list.js index 8d96b586..1ced8e54 100644 --- a/testgen/ui/components/frontend/js/pages/schedule_list.js +++ b/testgen/ui/components/frontend/js/pages/schedule_list.js @@ -31,8 +31,7 @@ import van from '/app/static/js/van.min.js'; import { Button } from '/app/static/js/components/button.js'; import { Dialog } from '/app/static/js/components/dialog.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { emitEvent, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; import { withTooltip } from '/app/static/js/components/tooltip.js'; import { ExpansionPanel } from '/app/static/js/components/expansion_panel.js'; import { Select } from '/app/static/js/components/select.js'; @@ -44,6 +43,7 @@ const minHeight = 500; const { div, span, i } = van.tags; const ScheduleList = (/** @type Properties */ props) => { + const { emit } = props; loadStylesheet('schedule-list', stylesheet); const dialogProp = getValue(props.dialog); @@ -95,19 +95,19 @@ const ScheduleList = (/** @type Properties */ props) => { onChange: (value) => { newScheduleForm.timezone.val = value; if (newScheduleForm.expression.val && newScheduleForm.timezone.val) { - emitEvent('GetCronSample', {payload: {cron_expr: newScheduleForm.expression.val, tz: newScheduleForm.timezone.val}}); + emit('GetCronSample', {payload: {cron_expr: newScheduleForm.expression.val, tz: newScheduleForm.timezone.val}}); } }, portalClass: 'short-select-portal', }), - CrontabInput({ + CrontabInput({ emit, class: 'fx-flex', sample: props.sample, value: cronEditorValue, onChange: (value) => { newScheduleForm.expression.val = value; if (newScheduleForm.expression.val && newScheduleForm.timezone.val) { - emitEvent('GetCronSample', {payload: {cron_expr: newScheduleForm.expression.val, tz: newScheduleForm.timezone.val}}); + emit('GetCronSample', {payload: {cron_expr: newScheduleForm.expression.val, tz: newScheduleForm.timezone.val}}); } }, }), @@ -118,7 +118,7 @@ const ScheduleList = (/** @type Properties */ props) => { type: 'stroked', label: 'Add Schedule', width: '150px', - onclick: () => emitEvent('AddSchedule', {payload: { + onclick: () => emit('AddSchedule', {payload: { arg_value: newScheduleForm.argValue.val, cron_expr: newScheduleForm.expression.val, cron_tz: newScheduleForm.timezone.val, @@ -167,7 +167,7 @@ const ScheduleList = (/** @type Properties */ props) => { ), () => scheduleItems.val?.length ? div( - scheduleItems.val.map(item => ScheduleListItem(item, columns, getValue(props.permissions))), + scheduleItems.val.map(item => ScheduleListItem(item, columns, getValue(props.permissions), emit)), ) : div({ class: 'mt-5 mb-3 ml-3 text-secondary', style: 'text-align: center;' }, 'No schedules defined yet.'), ), @@ -179,7 +179,7 @@ const ScheduleList = (/** @type Properties */ props) => { { title: dialogTitle, open: dialogOpen, - onClose: () => { dialogOpen.val = false; emitEvent('CloseClicked', {}); }, + onClose: () => { dialogOpen.val = false; emit('CloseClicked', {}); }, width: '65rem', }, content, @@ -192,6 +192,7 @@ const ScheduleListItem = ( /** @type Schedule */ item, /** @type string[] */ columns, /** @type Permissions */ permissions, + emit, ) => { return div( { class: 'table-row flex-row' }, @@ -255,21 +256,21 @@ const ScheduleListItem = ( icon: 'pause', tooltip: 'Pause schedule', style: 'height: 32px;', - onclick: () => emitEvent('PauseSchedule', { payload: item }), + onclick: () => emit('PauseSchedule', { payload: item }), }) : Button({ type: 'stroked', icon: 'play_arrow', tooltip: 'Resume schedule', style: 'height: 32px;', - onclick: () => emitEvent('ResumeSchedule', { payload: item }), + onclick: () => emit('ResumeSchedule', { payload: item }), }), Button({ type: 'stroked', icon: 'delete', tooltip: 'Delete schedule', style: 'height: 32px;', - onclick: () => emitEvent('DeleteSchedule', { payload: item }), + onclick: () => emit('DeleteSchedule', { payload: item }), }), ] : null, ), @@ -289,8 +290,6 @@ export { ScheduleList }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -298,6 +297,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, ScheduleList(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/schema_changes_dialog.js b/testgen/ui/components/frontend/js/pages/schema_changes_dialog.js index 887179e7..4e580048 100644 --- a/testgen/ui/components/frontend/js/pages/schema_changes_dialog.js +++ b/testgen/ui/components/frontend/js/pages/schema_changes_dialog.js @@ -9,11 +9,12 @@ import van from '/app/static/js/van.min.js'; import { Dialog } from '/app/static/js/components/dialog.js'; import { SchemaChangesList } from '/app/static/js/components/schema_changes_list.js'; -import { emitEvent, getValue } from '/app/static/js/utils.js'; +import { getValue } from '/app/static/js/utils.js'; const { div } = van.tags; const SchemaChangesDialog = (/** @type Properties */ props) => { + const emit = props.emit; const dialogOpen = van.state(false); van.derive(() => { const d = getValue(props.dialog); @@ -42,7 +43,7 @@ const SchemaChangesDialog = (/** @type Properties */ props) => { { title: dialogTitle, open: dialogOpen, - onClose: () => { dialogOpen.val = false; emitEvent('CloseSchemaChangesDialog', {}); }, + onClose: () => { dialogOpen.val = false; emit('CloseSchemaChangesDialog', {}); }, width: '30rem', }, contentContainer, diff --git a/testgen/ui/components/frontend/js/pages/score_details.js b/testgen/ui/components/frontend/js/pages/score_details.js index 97001c46..c0111054 100644 --- a/testgen/ui/components/frontend/js/pages/score_details.js +++ b/testgen/ui/components/frontend/js/pages/score_details.js @@ -27,8 +27,7 @@ * @property {object?} notifications_dialog */ import van from '/app/static/js/van.min.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { emitEvent, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; import { ScoreCard } from '/app/static/js/components/score_card.js'; import { ScoreHistory } from '/app/static/js/components/score_history.js'; import { ScoreLegend } from '/app/static/js/components/score_legend.js'; @@ -42,6 +41,7 @@ import { ProfilingResultsDialog } from '../shared/profiling_results_dialog.js'; const { b, div, i } = van.tags; const ScoreDetails = (/** @type {Properties} */ props) => { + const { emit } = props; loadStylesheet('score-details', stylesheet); const deleteDialogOpen = van.state(false); @@ -71,8 +71,8 @@ const ScoreDetails = (/** @type {Properties} */ props) => { const score = getValue(props.score); return getValue(props.permissions)?.can_edit ?? false ? div( { class: 'flex-row tg-test-suites--card-actions' }, - Button({ type: 'icon', icon: 'notifications', tooltip: 'Configure Notifications', onclick: () => emitEvent('EditNotifications', {}) }), - Button({ type: 'icon', icon: 'edit', tooltip: 'Edit Scorecard', onclick: () => emitEvent('LinkClicked', { href: 'quality-dashboard:explorer', params: { definition_id: score.id, project_code: score.project_code } }) }), + Button({ type: 'icon', icon: 'notifications', tooltip: 'Configure Notifications', onclick: () => emit('EditNotifications', {}) }), + Button({ type: 'icon', icon: 'edit', tooltip: 'Edit Scorecard', onclick: () => emit('LinkClicked', { href: 'quality-dashboard:explorer', params: { definition_id: score.id, project_code: score.project_code } }) }), Button({ type: 'icon', icon: 'delete', tooltip: 'Delete Scorecard', onclick: () => { deleteDialogOpen.val = true; } }), ) : ''; }, @@ -81,7 +81,7 @@ const ScoreDetails = (/** @type {Properties} */ props) => { const score = getValue(props.score); const history = getValue(props.score).history; return history?.length > 0 - ? ScoreHistory({style: 'min-height: 216px; flex: 610px 0 1;', showRefresh: getValue(props.permissions)?.can_edit ?? false, score}, ...history) + ? ScoreHistory({ emit, style: 'min-height: 216px; flex: 610px 0 1;', showRefresh: getValue(props.permissions)?.can_edit ?? false, score}, ...history) : null; }, ), @@ -96,17 +96,19 @@ const ScoreDetails = (/** @type {Properties} */ props) => { getValue(props.score_type), getValue(props.category), getValue(props.drilldown), - (project_code, name, score_type, category) => emitEvent('LinkClicked', { href: 'quality-dashboard:score-details', params: { definition_id: getValue(props.score).id, project_code, score_type, category } }), + (project_code, name, score_type, category) => emit('LinkClicked', { href: 'quality-dashboard:score-details', params: { definition_id: getValue(props.score).id, project_code, score_type, category } }), + emit, ) : ScoreBreakdown( props.score, props.breakdown, props.category, props.score_type, - (project_code, name, score_type, category, drilldown) => emitEvent( + (project_code, name, score_type, category, drilldown) => emit( 'LinkClicked', { href: 'quality-dashboard:score-details', params: { definition_id: getValue(props.score).id, project_code, score_type, category, drilldown } }), + emit, ) ); }, @@ -124,8 +126,10 @@ const ScoreDetails = (/** @type {Properties} */ props) => { label: 'Delete', color: 'warn', type: 'flat', + width: 'auto', + style: 'margin-left: auto;', onclick: () => { - emitEvent('DeleteScoreConfirmed', { payload: getValue(props.score).id }); + emit('DeleteScoreConfirmed', { payload: getValue(props.score).id }); deleteDialogOpen.val = false; }, }), @@ -138,11 +142,11 @@ const ScoreDetails = (/** @type {Properties} */ props) => { open: notificationsDialogOpen, onClose: () => { notificationsDialogOpen.val = false; - emitEvent('NotificationsDialogClosed', {}) + emit('NotificationsDialogClosed', {}) }, width: '65rem', }, - NotificationSettings({ + NotificationSettings({ emit, smtp_configured: smtpConfigured, event: event, items: items, @@ -155,9 +159,9 @@ const ScoreDetails = (/** @type {Properties} */ props) => { result: result, }), ), - ProfilingResultsDialog({ + ProfilingResultsDialog({ emit, profilingColumn: props.profiling_column, - onClose: () => emitEvent('ProfilingResultsDialogClosed', {}), + onClose: () => emit('ProfilingResultsDialogClosed', {}), }), ); }; @@ -174,8 +178,6 @@ export { ScoreDetails }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -183,6 +185,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, ScoreDetails(componentState)); } else { for (const [key, value] of Object.entries(data)) { @@ -194,6 +197,5 @@ export default (component) => { return () => { parentElement.state = null; - Streamlit.disableV2(setTriggerValue); }; }; diff --git a/testgen/ui/components/frontend/js/pages/score_explorer.js b/testgen/ui/components/frontend/js/pages/score_explorer.js index 28b5e77e..f23630d6 100644 --- a/testgen/ui/components/frontend/js/pages/score_explorer.js +++ b/testgen/ui/components/frontend/js/pages/score_explorer.js @@ -52,8 +52,7 @@ * @property {object?} column_selector_dialog */ import van from '/app/static/js/van.min.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { debounce, emitEvent, getValue, loadStylesheet, afterMount, getRandomId, isEqual } from '/app/static/js/utils.js'; +import { createEmitter, debounce, getValue, loadStylesheet, afterMount, getRandomId, isEqual } from '/app/static/js/utils.js'; import { Input } from '/app/static/js/components/input.js'; import { Select } from '/app/static/js/components/select.js'; import { Button } from '/app/static/js/components/button.js'; @@ -83,6 +82,7 @@ const TRANSLATIONS = { }; const ScoreExplorer = (/** @type {Properties} */ props) => { + const { emit } = props; loadStylesheet('score-explorer', stylesheet); const domId = 'score-explorer-page'; @@ -101,17 +101,17 @@ const ScoreExplorer = (/** @type {Properties} */ props) => { }); const columnSelectorDialogOpen = van.state(false); - van.derive(() => { if (getValue(props.column_selector_dialog) != null) columnSelectorDialogOpen.val = true; }); + van.derive(() => { columnSelectorDialogOpen.val = getValue(props.column_selector_dialog) != null; }); return div( { id: domId, 'data-testid': 'score-explorer', class: 'score-explorer' }, - Toolbar(props.filter_values, getValue(props.definition), props.is_new, userCanEdit, updateToolbarFilters), + Toolbar(props.filter_values, getValue(props.definition), props.is_new, userCanEdit, updateToolbarFilters, emit), span({ class: 'mb-4', style: 'display: block;' }), () => { const isEmpty = getValue(props.is_new) && getValue(props.definition)?.filters?.length <= 0; if (isEmpty) { - return EmptyState({ + return EmptyState({ emit, class: 'explorer-empty-state', label: 'No filters or columns selected yet', icon: 'readiness_score', @@ -136,23 +136,26 @@ const ScoreExplorer = (/** @type {Properties} */ props) => { getValue(props.breakdown_score_type), getValue(props.breakdown_category), drilldown, - () => emitEvent('DrilldownChanged', { payload: null }), + () => emit('DrilldownChanged', { payload: null }), + emit, ) : ScoreBreakdown( props.score_card, props.breakdown, props.breakdown_category, props.breakdown_score_type, - (project_code, name, score_type, category, drilldown) => emitEvent('DrilldownChanged', { payload: drilldown }), + (project_code, name, score_type, category, drilldown) => emit('DrilldownChanged', { payload: drilldown }), + emit, ) ); }, ); }, ColumnSelectorDialog({ + emit, dialog: van.derive(() => ({ title: getValue(props.column_selector_dialog)?.title ?? 'Select Columns', open: columnSelectorDialogOpen })), columns: van.derive(() => getValue(props.column_selector_dialog)?.columns ?? []), - onClose: () => emitEvent('ColumnSelectorDialogClosed', {}), + onClose: () => emit('ColumnSelectorDialogClosed', {}), }), ); }; @@ -163,6 +166,7 @@ const Toolbar = ( /** @type boolean */ isNew, /** @type boolean */ userCanEdit, /** @type ... */ updates, + emit, ) => { const addFilterButtonId = 'score-explorer--add-filter-btn'; const categories = [ @@ -214,7 +218,7 @@ const Toolbar = ( filters.val[position].value.val = value filters.val = [ ...filters.val ]; }; - const refresh = debounce((payload) => emitEvent('ScoreUpdated', { payload }), 300); + const refresh = debounce((payload) => emit('ScoreUpdated', { payload }), 300); van.derive(() => { const previous = { @@ -240,7 +244,7 @@ const Toolbar = ( if (!isEqual(current, previous)) { if (current.filter_by_columns && !previous.filter_by_columns) { - emitEvent('ColumnSelectorOpened', {}); + emit('ColumnSelectorOpened', {}); } else if (!current.filter_by_columns && previous.filter_by_columns) { filterSelectorOpened.val = true; } else { @@ -295,7 +299,7 @@ const Toolbar = ( renderedFilters[key] = renderedFilters[key] ?? ( filterByColumns.val ? ColumnFilter({field, value, others}) - : Filter(idx, field, value, filterValues_[field], setFilterValue, removeFilter, !isInitialized && !value.val) + : Filter(idx, field, value, filterValues_[field], setFilterValue, removeFilter, !isInitialized && !value.val, emit) ); return renderedFilters[key]; }), @@ -320,7 +324,7 @@ const Toolbar = ( type: 'basic', color: 'primary', style: 'width: auto;', - onclick: () => emitEvent('ColumnSelectorOpened', {}), + onclick: () => emit('ColumnSelectorOpened', {}), }); const combinedTrigger = div( {class: 'flex-row fx-gap-3'}, @@ -341,7 +345,7 @@ const Toolbar = ( }, Portal( { target: addFilterButtonId, style: '', opened: filterSelectorOpened}, - FilterFieldSelector(filterableFields, undefined, addEmptyFilter), + FilterFieldSelector(filterableFields, undefined, addEmptyFilter, emit), ), ) ), @@ -357,14 +361,14 @@ const Toolbar = ( type: 'basic', color: 'primary', style: 'width: auto;', - onclick: () => emitEvent('FilterModeChanged', {payload: true}), + onclick: () => emit('FilterModeChanged', {payload: true}), }); const switchToCategoryFilterTrigger = Button({ label: 'Switch to Category Filters', type: 'basic', color: 'primary', style: 'width: auto;', - onclick: () => emitEvent('FilterModeChanged', {payload: false}), + onclick: () => emit('FilterModeChanged', {payload: false}), }); if (filterByColumns.val) { @@ -430,7 +434,7 @@ const Toolbar = ( color: 'primary', style: 'width: auto;', disabled: disableSave, - onclick: () => emitEvent('ScoreDefinitionSaved', {}), + onclick: () => emit('ScoreDefinitionSaved', {}), }); }, () => { @@ -447,7 +451,7 @@ const Toolbar = ( type: 'stroked', color: 'warn', style: 'width: auto;', - onclick: () => emitEvent('LinkClicked', { href, params }), + onclick: () => emit('LinkClicked', { href, params }), }); }, ) : '', @@ -464,7 +468,7 @@ const Filter = ( /** @type Function */ onChange, /** @type Function */ onRemove, /** @type boolean */ openOnRender = true, -) => { +emit) => { const id = `score-explorer-filter-${position}-${field}`; const opened = van.state(false); if (openOnRender) { @@ -486,7 +490,7 @@ const Filter = ( ), Portal( {target: id, opened: opened}, - () => FilterFieldSelector(getValue(options), getValue(value), onValueSelected), + () => FilterFieldSelector(getValue(options), getValue(value), onValueSelected, emit), ), i( { @@ -499,7 +503,7 @@ const Filter = ( ); }; -const FilterFieldSelector = (/** @type string[] */ options, /** @type string */ value, /** @type Function */ onSelect) => { +const FilterFieldSelector = (/** @type string[] */ options, /** @type string */ value, /** @type Function */ onSelect, emit) => { return div( { class: 'flex-column score-explorer--selector mt-1', 'data-testid': 'explorer-filter-field-selector' }, (options?.length ?? 0) > 0 @@ -574,8 +578,6 @@ export { ScoreExplorer }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -583,6 +585,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, ScoreExplorer(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/table_create_script_dialog.js b/testgen/ui/components/frontend/js/pages/table_create_script_dialog.js index 166cd448..9cd4a6e4 100644 --- a/testgen/ui/components/frontend/js/pages/table_create_script_dialog.js +++ b/testgen/ui/components/frontend/js/pages/table_create_script_dialog.js @@ -7,12 +7,12 @@ import van from '/app/static/js/van.min.js'; import { Dialog } from '/app/static/js/components/dialog.js'; import { Code } from '/app/static/js/components/code.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { emitEvent, getValue, isEqual } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual } from '/app/static/js/utils.js'; const { div, span } = van.tags; const TableCreateScriptDialog = (/** @type Properties */ props) => { + const { emit } = props; const dialogProp = getValue(props.dialog); const dialogOpen = van.state(dialogProp?.open === true); @@ -31,7 +31,7 @@ const TableCreateScriptDialog = (/** @type Properties */ props) => { { title: dialogTitle, open: dialogOpen, - onClose: () => { dialogOpen.val = false; emitEvent('CloseClicked', {}); }, + onClose: () => { dialogOpen.val = false; emit('CloseClicked', {}); }, width: '55rem', }, content, @@ -45,8 +45,6 @@ export { TableCreateScriptDialog }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -54,6 +52,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, TableCreateScriptDialog(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/table_group_delete_confirmation.js b/testgen/ui/components/frontend/js/pages/table_group_delete_confirmation.js index 31abdc92..f9b78111 100644 --- a/testgen/ui/components/frontend/js/pages/table_group_delete_confirmation.js +++ b/testgen/ui/components/frontend/js/pages/table_group_delete_confirmation.js @@ -15,8 +15,7 @@ */ import van from '/app/static/js/van.min.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { emitEvent, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; import { Button } from '/app/static/js/components/button.js'; import { Toggle } from '/app/static/js/components/toggle.js'; import { Attribute } from '/app/static/js/components/attribute.js'; @@ -29,6 +28,7 @@ const { div, h3, hr, span, b } = van.tags; * @returns */ const TableGroupDeleteConfirmation = (props) => { + const { emit } = props; loadStylesheet('tablegroup-delete-confirmation', stylesheet); const wrapperId = 'tablegroup-delete-wrapper'; @@ -88,7 +88,7 @@ const TableGroupDeleteConfirmation = (props) => { label: 'Delete', style: 'width: auto;', disabled: deleteDisabled, - onclick: () => emitEvent('DeleteTableGroupConfirmed'), + onclick: () => emit('DeleteTableGroupConfirmed'), }), ), () => { @@ -112,8 +112,6 @@ export { TableGroupDeleteConfirmation }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -121,6 +119,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, TableGroupDeleteConfirmation(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/table_group_list.js b/testgen/ui/components/frontend/js/pages/table_group_list.js index 1e1b966f..89d4a419 100644 --- a/testgen/ui/components/frontend/js/pages/table_group_list.js +++ b/testgen/ui/components/frontend/js/pages/table_group_list.js @@ -20,12 +20,11 @@ * @property {object?} notifications_dialog */ import van from '/app/static/js/van.min.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; import { Button } from '/app/static/js/components/button.js'; import { Card } from '/app/static/js/components/card.js'; import { Caption } from '/app/static/js/components/caption.js'; import { Link } from '/app/static/js/components/link.js'; -import { emitEvent, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; import { EMPTY_STATE_MESSAGE, EmptyState } from '/app/static/js/components/empty_state.js'; import { Select } from '/app/static/js/components/select.js'; import { Icon } from '/app/static/js/components/icon.js'; @@ -48,6 +47,7 @@ const { div, h4, span, b } = van.tags; * @returns {HTMLElement} */ const TableGroupList = (props) => { + const { emit } = props; loadStylesheet('tablegrouplist', stylesheet); const wrapperId = 'tablegroup-list-wrapper'; @@ -59,7 +59,7 @@ const TableGroupList = (props) => { const closeDeleteDialog = () => { deleteDialogOpen.val = false; confirmDeleteRelated.val = false; - emitEvent('DeleteDialogDismissed', {}); + emit('DeleteDialogDismissed', {}); }; const scheduleDialogOpen = van.state(false); @@ -84,7 +84,7 @@ const TableGroupList = (props) => { if (key !== wizardKey) { wizardContainer.innerHTML = ''; wizardKey = key; - van.add(wizardContainer, TableGroupWizard({ + van.add(wizardContainer, TableGroupWizard({ emit, project_code: van.derive(() => getValue(props.wizard)?.project_code), connections: van.derive(() => getValue(props.wizard)?.connections), table_group: van.derive(() => getValue(props.wizard)?.table_group), @@ -115,7 +115,7 @@ const TableGroupList = (props) => { if (key !== editDialogKey) { editDialogContainer.innerHTML = ''; editDialogKey = key; - van.add(editDialogContainer, TableGroupEditDialog({ + van.add(editDialogContainer, TableGroupEditDialog({ emit, dialog: van.derive(() => getValue(props.edit_dialog)?.dialog), connections: van.derive(() => getValue(props.edit_dialog)?.connections), table_group: van.derive(() => getValue(props.edit_dialog)?.table_group), @@ -137,7 +137,7 @@ const TableGroupList = (props) => { const projectSummary = getValue(props.project_summary); if (connections.length <= 0) { - return EmptyState({ + return EmptyState({ emit, icon: 'table_view', label: 'Your project is empty', message: EMPTY_STATE_MESSAGE.connection, @@ -152,7 +152,7 @@ const TableGroupList = (props) => { return projectSummary.table_group_count > 0 ? div( - Toolbar(permissions, connections, connectionId, tableGroupNameFilter), + Toolbar(permissions, connections, connectionId, tableGroupNameFilter, emit), tableGroups.length ? div( { class: 'flex-column fx-gap-4' }, @@ -175,7 +175,7 @@ const TableGroupList = (props) => { { class: 'flex-row fx-gap-3' }, div( { class: 'flex-column fx-flex fx-gap-3' }, - Link({ + Link({ emit, label: 'View test suites', href: 'test-suites', params: { 'project_code': projectSummary.project_code, 'table_group_id': tableGroup.id }, @@ -238,7 +238,7 @@ const TableGroupList = (props) => { type: 'stroked', color: 'primary', label: 'Run Profiling', - onclick: () => emitEvent('RunProfilingClicked', { payload: tableGroup.id }), + onclick: () => emit('RunProfilingClicked', { payload: tableGroup.id }), }), ) : '', @@ -254,7 +254,7 @@ const TableGroupList = (props) => { tooltip: 'Edit table group', tooltipPosition: 'left', color: 'basic', - onclick: () => emitEvent('EditTableGroupClicked', { payload: tableGroup.id }), + onclick: () => emit('EditTableGroupClicked', { payload: tableGroup.id }), }), Button({ type: 'icon', @@ -263,7 +263,7 @@ const TableGroupList = (props) => { tooltip: 'Delete table group', tooltipPosition: 'left', color: 'basic', - onclick: () => emitEvent('DeleteTableGroupClicked', { payload: tableGroup.id }), + onclick: () => emit('DeleteTableGroupClicked', { payload: tableGroup.id }), }), ) : undefined, @@ -274,7 +274,7 @@ const TableGroupList = (props) => { 'No table groups found matching filters', ), ) - : EmptyState({ + : EmptyState({ emit, icon: 'table_view', label: 'No table groups yet', class: 'mt-4', @@ -286,7 +286,7 @@ const TableGroupList = (props) => { color: 'primary', style: 'width: unset;', disabled: !permissions.can_edit, - onclick: () => emitEvent('AddTableGroupClicked', {}), + onclick: () => emit('AddTableGroupClicked', {}), }), }); }, @@ -331,9 +331,10 @@ const TableGroupList = (props) => { type: deleteDisabled.val ? 'stroked' : 'flat', color: deleteDisabled.val ? 'basic' : 'warn', label: 'Delete', - style: 'width: auto;', + width: 'auto', + style: 'margin-left: auto;', disabled: deleteDisabled, - onclick: () => emitEvent('DeleteTableGroupConfirmed', { payload: tableGroup.id }), + onclick: () => emit('DeleteTableGroupConfirmed', { payload: tableGroup.id }), }), ), ), @@ -342,16 +343,16 @@ const TableGroupList = (props) => { () => { const info = getValue(props.run_profiling_dialog); if (!info) return div(); - return RunProfilingDialog({ + return RunProfilingDialog({ emit, dialog: { title: info.title ?? 'Run Profiling', open: true }, table_groups: info.table_groups ?? [], allow_selection: info.allow_selection ?? false, selected_id: info.selected_id, result: info.result, - onClose: () => emitEvent('RunProfilingDialogClosed', {}), + onClose: () => emit('RunProfilingDialogClosed', {}), }); }, - ScheduleList({ + ScheduleList({ emit, dialog: van.derive(() => ({ title: getValue(props.schedule_dialog)?.title ?? 'Schedules', open: scheduleDialogOpen })), items: van.derive(() => getValue(props.schedule_dialog)?.items ?? []), permissions: van.derive(() => getValue(props.schedule_dialog)?.permissions ?? { can_edit: false }), @@ -359,9 +360,9 @@ const TableGroupList = (props) => { arg_values: van.derive(() => getValue(props.schedule_dialog)?.arg_values ?? []), sample: van.derive(() => getValue(props.schedule_dialog)?.sample), results: van.derive(() => getValue(props.schedule_dialog)?.results), - onClose: () => emitEvent('ScheduleDialogClosed', {}), + onClose: () => emit('ScheduleDialogClosed', {}), }), - NotificationSettings({ + NotificationSettings({ emit, dialog: van.derive(() => ({ title: getValue(props.notifications_dialog)?.title ?? 'Notifications', open: notificationsDialogOpen })), smtp_configured: van.derive(() => getValue(props.notifications_dialog)?.smtp_configured ?? false), event: van.derive(() => getValue(props.notifications_dialog)?.event), @@ -371,7 +372,7 @@ const TableGroupList = (props) => { scope_options: van.derive(() => getValue(props.notifications_dialog)?.scope_options ?? []), trigger_options: van.derive(() => getValue(props.notifications_dialog)?.trigger_options ?? []), result: van.derive(() => getValue(props.notifications_dialog)?.result), - onClose: () => emitEvent('NotificationsDialogClosed', {}), + onClose: () => emit('NotificationsDialogClosed', {}), }), wizardContainer, editDialogContainer, @@ -386,13 +387,13 @@ const TableGroupList = (props) => { * @param {string?} tableGroupNameFilter * @returns */ -const Toolbar = (permissions, connections, selectedConnection, tableGroupNameFilter) => { +const Toolbar = (permissions, connections, selectedConnection, tableGroupNameFilter, emit) => { const connection = van.state(selectedConnection || null); const tableGroupFilter = van.state(tableGroupNameFilter || null); van.derive(() => { if (connection.val !== selectedConnection || tableGroupFilter.val !== tableGroupNameFilter) { - emitEvent('TableGroupsFiltered', { payload: { connection_id: connection.val || null, table_group_name: tableGroupFilter.val || null } }); + emit('TableGroupsFiltered', { payload: { connection_id: connection.val || null, table_group_name: tableGroupFilter.val || null } }); } }); @@ -434,7 +435,7 @@ const Toolbar = (permissions, connections, selectedConnection, tableGroupNameFil tooltipPosition: 'bottom', width: 'fit-content', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('RunNotificationsClicked', {}), + onclick: () => emit('RunNotificationsClicked', {}), }), Button({ icon: 'today', @@ -444,7 +445,7 @@ const Toolbar = (permissions, connections, selectedConnection, tableGroupNameFil tooltipPosition: 'bottom', width: 'fit-content', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('RunSchedulesClicked', {}), + onclick: () => emit('RunSchedulesClicked', {}), }), permissions.can_edit ? Button({ @@ -453,7 +454,7 @@ const Toolbar = (permissions, connections, selectedConnection, tableGroupNameFil label: 'Add Table Group', color: 'basic', style: 'background: var(--button-generic-background-color); width: unset;', - onclick: () => emitEvent('AddTableGroupClicked', {}), + onclick: () => emit('AddTableGroupClicked', {}), }) : '', ) @@ -484,8 +485,6 @@ export { TableGroupList }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -493,6 +492,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, TableGroupList(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/table_group_wizard.js b/testgen/ui/components/frontend/js/pages/table_group_wizard.js index 8dfd5aae..d1c38e62 100644 --- a/testgen/ui/components/frontend/js/pages/table_group_wizard.js +++ b/testgen/ui/components/frontend/js/pages/table_group_wizard.js @@ -1,6 +1,5 @@ import van from '/app/static/js/van.min.js'; -import { isEqual } from '/app/static/js/utils.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; +import { createEmitter, isEqual } from '/app/static/js/utils.js'; import { TableGroupWizard } from '/app/static/js/components/table_group_wizard.js'; export { TableGroupWizard }; @@ -8,8 +7,6 @@ export { TableGroupWizard }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -18,6 +15,7 @@ export default (component) => { } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, TableGroupWizard(componentState)); } else { for (const [ key, value ] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/table_monitoring_trends.js b/testgen/ui/components/frontend/js/pages/table_monitoring_trends.js index d99ebb5f..8aa0891f 100644 --- a/testgen/ui/components/frontend/js/pages/table_monitoring_trends.js +++ b/testgen/ui/components/frontend/js/pages/table_monitoring_trends.js @@ -55,7 +55,7 @@ * @property {{ open: boolean, title: string }?} dialog */ import van from '/app/static/js/van.min.js'; -import { emitEvent, getValue, loadStylesheet, parseDate } from '/app/static/js/utils.js'; +import { getValue, loadStylesheet, parseDate } from '/app/static/js/utils.js'; import { FreshnessChart } from '/app/static/js/components/freshness_chart.js'; import { colorMap, formatNumber } from '/app/static/js/display_utils.js'; import { SchemaChangesChart } from '/app/static/js/components/schema_changes_chart.js'; @@ -91,6 +91,7 @@ const tickWidth = 90; * @param {Properties} props */ const TableMonitoringTrend = (props) => { + const emit = props.emit; loadStylesheet('table-monitoring-trends', stylesheet); const dialogOpen = van.state(false); @@ -105,124 +106,126 @@ const TableMonitoringTrend = (props) => { van.derive(() => shouldShowSidebar.val = (getValue(props.data_structure_logs)?.length ?? 0) > 0); const getDataStructureLogs = (/** @type {SchemaEvent} */ event) => { - emitEvent('ShowDataStructureLogs', { payload: { start_time: event.window_start, end_time: event.time } }); + emit('ShowDataStructureLogs', { payload: { start_time: event.window_start, end_time: event.time } }); shouldShowSidebar.val = true; schemaChartSelection.val = event; }; - const content = DualPane( - { - id: 'monitoring-trends-container', - class: () => `table-monitoring-trend-wrapper ${shouldShowSidebar.val ? 'has-sidebar' : ''}`, - minSize: 150, - maxSize: 400, - resizablePanel: 'right', - resizablePanelDomId: 'data-structure-logs-sidebar', - }, - div( - { class: '', style: 'width: 100%;' }, + const content = div( + DualPane( + { + id: 'monitoring-trends-container', + class: () => `table-monitoring-trend-wrapper ${shouldShowSidebar.val ? 'has-sidebar' : ''}`, + minSize: 150, + maxSize: 400, + resizablePanel: 'right', + resizablePanelDomId: 'data-structure-logs-sidebar', + }, + div( + { class: '', style: 'width: 100%;' }, + () => { + const extendedHistory = getValue(props.extended_history) ?? false; + return div( + { class: 'extended-history-toggle' }, + Button({ + label: extendedHistory ? 'Show default view' : 'Show more history', + icon: extendedHistory ? 'history_toggle_off' : 'history', + width: 'auto', + onclick: () => emit('ToggleExtendedHistory', { payload: {} }), + }), + ); + }, + () => { + if (!getValue(props.dialog)?.open) return div(); + return ChartsSection(props, { schemaChartSelection, getDataStructureLogs }); + }, + ), + () => { - const extendedHistory = getValue(props.extended_history) ?? false; + const _shouldShowSidebar = shouldShowSidebar.val; + const selection = schemaChartSelection.val; + if (!_shouldShowSidebar || !selection) { + return span(); + } + return div( - { class: 'extended-history-toggle' }, + { id: 'data-structure-logs-sidebar', class: 'flex-column data-structure-logs-sidebar' }, + SchemaChangesList({ + data_structure_logs: props.data_structure_logs, + window_start: selection.window_start, + window_end: selection.time, + }), Button({ - label: extendedHistory ? 'Show default view' : 'Show more history', - icon: extendedHistory ? 'history_toggle_off' : 'history', - width: 'auto', - onclick: () => emitEvent('ToggleExtendedHistory', { payload: {} }), + label: 'Hide', + style: 'margin-top: 8px; width: auto; align-self: flex-end;', + icon: 'double_arrow', + onclick: () => { + shouldShowSidebar.val = false; + schemaChartSelection.val = null; + }, }), ); }, - () => { - if (!getValue(props.dialog)?.open) return div(); - return ChartsSection(props, { schemaChartSelection, getDataStructureLogs }); - }, - ChartLegend({ - '': { - items: [ - { icon: svg({ width: 10, height: 10 }, - path({ d: 'M 8 5 A 3 3 0 0 0 2 5', fill: 'none', stroke: colorMap.emptyDark, 'stroke-width': 3, transform: 'rotate(45, 5, 5)' }), - path({ d: 'M 2 5 A 3 3 0 0 0 8 5', fill: 'none', stroke: colorMap.blueLight, 'stroke-width': 3, transform: 'rotate(45, 5, 5)' }), - circle({ cx: 5, cy: 5, r: 3, fill: 'var(--dk-dialog-background)', stroke: 'none' }) - ), label: 'Training' }, - { icon: svg({ width: 10, height: 10 }, circle({ cx: 5, cy: 5, r: 3, fill: colorMap.emptyDark, stroke: 'none' })), label: 'No change' }, - ], - }, - 'Freshness': { - items: [ - { icon: svg({ width: 10, height: 10 }, line({ x1: 4, y1: 0, x2: 4, y2: 10, stroke: colorMap.emptyDark, 'stroke-width': 2 })), label: 'Update' }, - { icon: svg({ width: 10, height: 10 }, circle({ cx: 5, cy: 5, r: 4, fill: colorMap.limeGreen })), label: 'On Time' }, - { - icon: svg( - { width: 10, height: 10, style: 'overflow: visible;' }, - rect({ x: 1.5, y: 1.5, width: 7, height: 7, fill: colorMap.red, transform: 'rotate(45 5 5)' }), - ), - label: 'Early/Late', - }, - ], - }, - 'Volume/Metrics': { - items: [ - { - icon: svg( - { width: 16, height: 10 }, - line({ x1: 0, y1: 5, x2: 16, y2: 5, stroke: colorMap.blueLight, 'stroke-width': 2 }), - circle({ cx: 8, cy: 5, r: 3, fill: colorMap.blueLight }) - ), - label: 'Actual', - }, - { - icon: svg( - { width: 10, height: 10, style: 'overflow: visible;' }, - rect({ x: 1.5, y: 1.5, width: 7, height: 7, fill: colorMap.red, transform: 'rotate(45 5 5)' }), - ), - label: 'Anomaly', - }, - { - icon: svg( - { width: 16, height: 10 }, - path({ d: 'M 0,4 L 16,2 L 16,8 L 0,6 Z', fill: colorMap.emptyDark, opacity: 0.4 }), - line({ x1: 0, y1: 5, x2: 16, y2: 5, stroke: colorMap.grey, 'stroke-width': 2 }) - ), - label: 'Prediction', - }, - ], - }, - 'Schema': { - items: [ - { icon: svg({ width: 10, height: 10 }, rect({ width: 10, height: 10, fill: colorMap.blue })), label: 'Additions' }, - { icon: svg({ width: 10, height: 10 }, rect({ width: 10, height: 10, fill: colorMap.orange })), label: 'Deletions' }, - { icon: svg({ width: 10, height: 10 }, rect({ width: 10, height: 10, fill: colorMap.purple })), label: 'Modifications' }, - ], - }, - }), ), - - () => { - const _shouldShowSidebar = shouldShowSidebar.val; - const selection = schemaChartSelection.val; - if (!_shouldShowSidebar || !selection) { - return span(); - } - - return div( - { id: 'data-structure-logs-sidebar', class: 'flex-column data-structure-logs-sidebar' }, - SchemaChangesList({ - data_structure_logs: props.data_structure_logs, - window_start: selection.window_start, - window_end: selection.time, - }), - Button({ - label: 'Hide', - style: 'margin-top: 8px; width: auto; align-self: flex-end;', - icon: 'double_arrow', - onclick: () => { - shouldShowSidebar.val = false; - schemaChartSelection.val = null; + ChartLegend({ + '': { + items: [ + { icon: svg({ width: 10, height: 10 }, + path({ d: 'M 8 5 A 3 3 0 0 0 2 5', fill: 'none', stroke: colorMap.emptyDark, 'stroke-width': 3, transform: 'rotate(45, 5, 5)' }), + path({ d: 'M 2 5 A 3 3 0 0 0 8 5', fill: 'none', stroke: colorMap.blueLight, 'stroke-width': 3, transform: 'rotate(45, 5, 5)' }), + circle({ cx: 5, cy: 5, r: 3, fill: 'var(--dk-dialog-background)', stroke: 'none' }) + ), label: 'Training' }, + { icon: svg({ width: 10, height: 10 }, circle({ cx: 5, cy: 5, r: 3, fill: colorMap.emptyDark, stroke: 'none' })), label: 'No change' }, + ], + }, + 'Freshness': { + items: [ + { icon: svg({ width: 10, height: 10 }, line({ x1: 4, y1: 0, x2: 4, y2: 10, stroke: colorMap.emptyDark, 'stroke-width': 2 })), label: 'Update' }, + { icon: svg({ width: 10, height: 10 }, circle({ cx: 5, cy: 5, r: 4, fill: colorMap.limeGreen })), label: 'On Time' }, + { + icon: svg( + { width: 10, height: 10, style: 'overflow: visible;' }, + rect({ x: 1.5, y: 1.5, width: 7, height: 7, fill: colorMap.red, transform: 'rotate(45 5 5)' }), + ), + label: 'Early/Late', }, - }), - ); - }, + ], + }, + 'Volume/Metrics': { + items: [ + { + icon: svg( + { width: 16, height: 10 }, + line({ x1: 0, y1: 5, x2: 16, y2: 5, stroke: colorMap.blueLight, 'stroke-width': 2 }), + circle({ cx: 8, cy: 5, r: 3, fill: colorMap.blueLight }) + ), + label: 'Actual', + }, + { + icon: svg( + { width: 10, height: 10, style: 'overflow: visible;' }, + rect({ x: 1.5, y: 1.5, width: 7, height: 7, fill: colorMap.red, transform: 'rotate(45 5 5)' }), + ), + label: 'Anomaly', + }, + { + icon: svg( + { width: 16, height: 10 }, + path({ d: 'M 0,4 L 16,2 L 16,8 L 0,6 Z', fill: colorMap.emptyDark, opacity: 0.4 }), + line({ x1: 0, y1: 5, x2: 16, y2: 5, stroke: colorMap.grey, 'stroke-width': 2 }) + ), + label: 'Prediction', + }, + ], + }, + 'Schema': { + items: [ + { icon: svg({ width: 10, height: 10 }, rect({ width: 10, height: 10, fill: colorMap.blue })), label: 'Additions' }, + { icon: svg({ width: 10, height: 10 }, rect({ width: 10, height: 10, fill: colorMap.orange })), label: 'Deletions' }, + { icon: svg({ width: 10, height: 10 }, rect({ width: 10, height: 10, fill: colorMap.purple })), label: 'Modifications' }, + ], + }, + }), ); const dialogTitle = van.derive(() => getValue(props.dialog)?.title ?? ''); @@ -230,7 +233,7 @@ const TableMonitoringTrend = (props) => { { title: dialogTitle, open: dialogOpen, - onClose: () => { dialogOpen.val = false; emitEvent('CloseTrendsDialog', {}); }, + onClose: () => { dialogOpen.val = false; emit('CloseTrendsDialog', {}); }, width: '75rem', }, content, @@ -814,7 +817,6 @@ stylesheet.replace(` .table-monitoring-trend-wrapper { min-height: 200px; padding-top: 24px; - padding-right: 24px; position: relative; } @@ -848,9 +850,13 @@ stylesheet.replace(` background: var(--dk-dialog-background); position: sticky; bottom: 0; + margin-top: 12px; margin-left: -24px; - margin-right: -48px; - margin-top: 24px; + margin-right: -24px; + } + + .tg-dialog-content:has(.chart-legend) { + padding-bottom: 0; } .chart-legend-group { diff --git a/testgen/ui/components/frontend/js/pages/test_definition_notes.js b/testgen/ui/components/frontend/js/pages/test_definition_notes.js index 91cc9f48..aa3ced64 100644 --- a/testgen/ui/components/frontend/js/pages/test_definition_notes.js +++ b/testgen/ui/components/frontend/js/pages/test_definition_notes.js @@ -12,16 +12,15 @@ * @property {{table: string, column: string, test: string}} test_label * @property {Array} notes * @property {string} current_user + * @property {string} test_definition_id */ -import van from '../van.min.js'; -import { Button } from '../components/button.js'; -import { Icon } from '../components/icon.js'; -import { Streamlit } from '../streamlit.js'; -import { emitEvent, getValue, loadStylesheet } from '../utils.js'; -import { ExpansionPanel } from '../components/expansion_panel.js'; -import { formatTimestamp } from '../display_utils.js'; +import van from '/app/static/js/van.min.js'; +import { Button } from '/app/static/js/components/button.js'; +import { Icon } from '/app/static/js/components/icon.js'; +import { getValue, loadStylesheet } from '/app/static/js/utils.js'; +import { ExpansionPanel } from '/app/static/js/components/expansion_panel.js'; +import { formatTimestamp } from '/app/static/js/display_utils.js'; -const minHeight = 400; const { div, span, textarea, p } = van.tags; /** @@ -29,8 +28,8 @@ const { div, span, textarea, p } = van.tags; * @returns */ const TestDefinitionNotes = (props) => { + const emit = props.emit; loadStylesheet('test-definition-notes', stylesheet); - window.testgen.isPage = true; // Form state: shared between add and edit modes const editNoteId = van.state(null); @@ -80,7 +79,7 @@ const TestDefinitionNotes = (props) => { label: 'Yes', type: 'stroked', color: 'warn', - onclick: () => emitEvent('NoteDeleted', { payload: { id: note.id } }), + onclick: () => emit('NoteDeleted', { payload: { id: note.id, test_definition_id: getValue(props.test_definition_id) } }), }), Button({ label: 'No', @@ -100,6 +99,9 @@ const TestDefinitionNotes = (props) => { isEdit.val = true; editNoteId.val = note.id; noteText.val = note.detail; + // Force expand even if panelExpanded is already true + panelExpanded.val = false; + panelExpanded.val = true; }, }), Button({ @@ -117,6 +119,8 @@ const TestDefinitionNotes = (props) => { ); }; + const panelExpanded = van.state(true); + return div( { id: 'test-definition-notes', class: 'flex-column fx-gap-2', style: 'height: 100%; overflow-y: auto;' }, () => { @@ -130,12 +134,12 @@ const TestDefinitionNotes = (props) => { span({ class: 'text-secondary' }, 'Test: '), span(label.test), ); }, - () => ExpansionPanel( + ExpansionPanel( { - title: isEdit.val + title: () => isEdit.val ? span({ class: 'tdn-editing-indicator' }, 'Edit Note') : span({ class: 'text-green' }, 'Add Note'), - expanded: isEdit.val || getValue(props.notes).length === 0, + expanded: panelExpanded, }, div( { class: 'flex-column' }, @@ -158,18 +162,19 @@ const TestDefinitionNotes = (props) => { : '', Button({ type: 'stroked', - label: isEdit.val ? 'Save Changes' : 'Add Note', + label: () => isEdit.val ? 'Save Changes' : 'Add Note', width: 'auto', disabled: () => !noteText.val.trim(), onclick: () => { const text = noteText.rawVal.trim(); + const tdId = getValue(props.test_definition_id); if (isEdit.rawVal) { const id = editNoteId.rawVal; resetForm(); - emitEvent('NoteUpdated', { payload: { id, text } }); + emit('NoteUpdated', { payload: { id, text, test_definition_id: tdId } }); } else { resetForm(); - emitEvent('NoteAdded', { payload: { text } }); + emit('NoteAdded', { payload: { text, test_definition_id: tdId } }); } }, }), @@ -179,7 +184,6 @@ const TestDefinitionNotes = (props) => { () => { const notes = getValue(props.notes); const currentUser = getValue(props.current_user); - Streamlit.setFrameHeight(Math.max(minHeight, 80 * notes.length + 200)); return notes.length > 0 ? div( diff --git a/testgen/ui/components/frontend/js/pages/test_definition_summary.js b/testgen/ui/components/frontend/js/pages/test_definition_summary.js index d6552c69..c3cb23e5 100644 --- a/testgen/ui/components/frontend/js/pages/test_definition_summary.js +++ b/testgen/ui/components/frontend/js/pages/test_definition_summary.js @@ -24,8 +24,7 @@ * @property {TestDefinition} test_definition */ import van from '/app/static/js/van.min.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; import { Alert } from '/app/static/js/components/alert.js'; import { Attribute } from '/app/static/js/components/attribute.js'; @@ -36,6 +35,7 @@ const { div, strong } = van.tags; * @returns */ const TestDefinitionSummary = (props) => { + const { emit } = props; loadStylesheet('test-definition-summary', stylesheet) const wrapperId = 'test-definition-summary'; @@ -139,8 +139,6 @@ export { TestDefinitionSummary }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -148,6 +146,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, TestDefinitionSummary(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/test_definitions.js b/testgen/ui/components/frontend/js/pages/test_definitions.js index 6a31f7f1..831710b2 100644 --- a/testgen/ui/components/frontend/js/pages/test_definitions.js +++ b/testgen/ui/components/frontend/js/pages/test_definitions.js @@ -1,6 +1,5 @@ import van from '/app/static/js/van.min.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { getValue, isEqual, emitEvent, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; import { Table } from '/app/static/js/components/table.js'; import { Dialog } from '/app/static/js/components/dialog.js'; import { Button } from '/app/static/js/components/button.js'; @@ -14,19 +13,24 @@ import { RunTestsDialog } from '/app/static/js/components/run_tests_dialog.js'; import { Textarea } from '/app/static/js/components/textarea.js'; import { Checkbox } from '/app/static/js/components/checkbox.js'; import { DropdownButton } from '/app/static/js/components/dropdown_button.js'; +import { TestDefinitionNotes } from './test_definition_notes.js'; +import { withTooltip } from '/app/static/js/components/tooltip.js'; +import { Icon } from '/app/static/js/components/icon.js'; -const { div, span, strong, input, label } = van.tags; +const { button: btn, div, i: icon, span, strong, input, label } = van.tags; const TABLE_COLUMNS = [ { name: 'table_name', label: 'Table', width: 180, sortable: true, overflow: 'hidden' }, { name: 'column_name', label: 'Column / Focus', width: 180, sortable: true, overflow: 'hidden' }, { name: 'test_name_short', label: 'Test Type', width: 160, sortable: true, overflow: 'hidden' }, - { name: 'test_active_display', label: 'Active', width: 80 }, - { name: 'lock_refresh_display', label: 'Locked', width: 80 }, + { name: 'test_active_display', label: 'Active', width: 80, align: 'center' }, + { name: 'lock_refresh_display', label: 'Locked', width: 80, align: 'center' }, { name: 'urgency', label: 'Urgency', width: 100 }, - { name: 'export_to_observability_display', label: 'Observability', width: 120 }, + { name: 'flagged_display', label: 'Flagged', width: 80, align: 'center' }, + { name: 'notes_count', label: 'Notes', width: 70, align: 'center' }, { name: 'profiling_as_of_date', label: 'Based on Profiling', width: 160 }, { name: 'last_manual_update', label: 'Last Manual Update', width: 160 }, + { name: 'export_to_observability_display', label: 'Observability', width: 120 }, ]; const SEVERITY_OPTIONS = [ @@ -66,9 +70,32 @@ const BLANK_PARAM_FIELDS = { history_lookback: null, }; +/** Composite icon button: flag with a diagonal strikethrough (pen_size_1 rotated). */ +const ClearFlagButton = ({ disabled, onclick }) => { + return withTooltip(btn( + { + class: 'tg-button tg-icon-button tg-basic-button', + disabled, + onclick, + style: 'width: 40px; position: relative;', + }, + span({ class: 'tg-button-focus-state-indicator' }, ''), + div( + { style: 'position: relative; display: inline-flex; align-items: center; justify-content: center;' }, + icon({ class: 'material-symbols-rounded', style: 'font-size: 20px;' }, 'flag'), + icon({ class: 'material-symbols-rounded', style: 'font-size: 24px; position: absolute; top: -3px; left: -3px; transform: rotate(90deg);' }, 'pen_size_1'), + ), + ), { text: 'Clear flag' }); +}; + const TestDefinitions = (/** @type object */ props) => { + const { emit } = props; loadStylesheet('test-definitions', stylesheet); + // Notes dialog: persistent local state + one-time sync from Python prop + const notesDialogOpen = van.state(false); + van.derive(() => { if (getValue(props.notes_dialog)) notesDialogOpen.val = true; }); + const permissions = van.derive(() => getValue(props.permissions) ?? {}); const canEdit = van.derive(() => getValue(permissions).can_edit ?? false); const canDisposition = van.derive(() => getValue(permissions).can_disposition ?? false); @@ -79,6 +106,7 @@ const TestDefinitions = (/** @type object */ props) => { const tableFilter = van.state(null); const columnFilter = van.state(null); const testTypeFilter = van.state(null); + const flaggedFilter = van.state(null); // Initialize filters from Python query params (runs once on mount) const filtersInitialized = van.state(false); @@ -88,13 +116,22 @@ const TestDefinitions = (/** @type object */ props) => { tableFilter.val = cf.table_name ?? null; columnFilter.val = cf.column_name ?? null; testTypeFilter.val = cf.test_type ?? null; + flaggedFilter.val = cf.flagged ?? null; filtersInitialized.val = true; }); const columnFilterOptions = van.derive(() => { const cols = filterOptions.val.columns ?? []; const table = tableFilter.val; - const filtered = table ? cols.filter(c => c.table_name === table) : cols; + let filtered; + if (!table) { + filtered = cols; + } else if (table.startsWith('%') && table.endsWith('%')) { + const partial = table.slice(1, -1).toLowerCase(); + filtered = cols.filter(c => c.table_name.toLowerCase().includes(partial)); + } else { + filtered = cols.filter(c => c.table_name === table); + } return [...new Map(filtered.map(c => [c.column_name, c])).values()] .sort((a, b) => (a.column_name ?? '').localeCompare(b.column_name ?? '')) .map(c => ({ label: c.column_name, value: c.column_name })); @@ -108,11 +145,12 @@ const TestDefinitions = (/** @type object */ props) => { (filterOptions.val.test_types ?? []).map(tt => ({ label: tt.test_name_short, value: tt.test_type })) ); - const onFilterChange = () => emitEvent('FilterChanged', { + const onFilterChange = () => emit('FilterChanged', { payload: { table_name: tableFilter.val || null, column_name: columnFilter.val || null, test_type: testTypeFilter.val || null, + flagged: flaggedFilter.val || null, }, }); @@ -145,20 +183,29 @@ const TestDefinitions = (/** @type object */ props) => { const clearAllCheckboxStates = () => { for (const state of checkboxStates.values()) state.val = false; selectAll.val = false; + selectedIdsCount.val = 0; }; let selectedIds = []; const selectedIdSetForRestore = new Set(); + const getSelectedDefinitionIds = () => { + if (multiSelectMode.val) return [...selectedIdSetForRestore]; + return selectedRowId.val ? [selectedRowId.val] : []; + }; + + // Reactive selection count for button enable/disable + const selectedIdsCount = van.state(0); const onSelectAllToggle = (checked) => { - selectAll.val = checked; if (checked) { + selectAll.val = true; for (const item of testDefinitions.rawVal) { const state = getCheckboxState(item.id); state.val = true; selectedIdSetForRestore.add(item.id); } - selectedIds = testDefinitions.rawVal.map(r => r.id); + selectedIds = [...selectedIdSetForRestore]; + selectedIdsCount.val = selectedIds.length; } else { clearAllCheckboxStates(); selectedIds = []; @@ -166,26 +213,29 @@ const TestDefinitions = (/** @type object */ props) => { } }; - const selectAllCheckbox = Checkbox({ - label: '', - checked: () => selectAll.val, - onChange: onSelectAllToggle, - }); - const checkboxColumn = { name: '_checkbox', label: selectAllCheckbox, width: 32, align: 'center' }; + const checkboxColumn = { + name: '_checkbox', + label: () => Checkbox({ + label: '', + checked: selectAll.val, + indeterminate: !selectAll.val && selectedIdsCount.val > 0, + onChange: onSelectAllToggle, + }), + width: 32, + align: 'center', + }; const tableColumns = van.derive(() => multiSelectMode.val ? [checkboxColumn, ...TABLE_COLUMNS] : TABLE_COLUMNS); - // Clear checkbox states when toggling multi-select off + // Clear checkbox states and selection when toggling multi-select off van.derive(() => { if (!multiSelectMode.val) { clearAllCheckboxStates(); selectedIds = []; + selectedIdsCount.val = 0; selectedIdSetForRestore.clear(); } }); - // Reactive selection count for button enable/disable - const selectedIdsCount = van.state(0); - const selectedRows = van.derive(() => { const count = selectedIdsCount.val; // reactive dependency if (multiSelectMode.val) { @@ -213,11 +263,11 @@ const TestDefinitions = (/** @type object */ props) => { const unlockDialogInfo = van.derive(() => getValue(props.unlock_dialog) ?? null); const copyMoveDialogInfo = van.derive(() => getValue(props.copy_move_dialog) ?? null); - van.derive(() => { if (addDialogInfo.val?.open) addDialogOpen.val = true; }); - van.derive(() => { if (editDialogInfo.val?.open) editDialogOpen.val = true; }); - van.derive(() => { if (deleteDialogInfo.val?.open) deleteDialogOpen.val = true; }); - van.derive(() => { if (unlockDialogInfo.val?.open) unlockDialogOpen.val = true; }); - van.derive(() => { if (copyMoveDialogInfo.val?.open) copyMoveDialogOpen.val = true; }); + van.derive(() => { addDialogOpen.val = !!addDialogInfo.val?.open; }); + van.derive(() => { editDialogOpen.val = !!editDialogInfo.val?.open; }); + van.derive(() => { deleteDialogOpen.val = !!deleteDialogInfo.val?.open; }); + van.derive(() => { unlockDialogOpen.val = !!unlockDialogInfo.val?.open; }); + van.derive(() => { copyMoveDialogOpen.val = !!copyMoveDialogInfo.val?.open; }); const runTestsDialogData = van.derive(() => getValue(props.run_tests_dialog) ?? null); @@ -229,20 +279,34 @@ const TestDefinitions = (/** @type object */ props) => { // When selectAll is active, sync tracking state to current page items if (isMulti && isSelectAll) { - selectedIdSetForRestore.clear(); - const newIds = []; for (const item of currentItems) { const state = getCheckboxState(item.id); state.val = true; selectedIdSetForRestore.add(item.id); - newIds.push(item.id); } - selectedIds = newIds; - selectedIdsCount.val = newIds.length; + selectedIds = [...selectedIdSetForRestore]; + selectedIdsCount.val = selectedIds.length; } return currentItems.map(item => { - const row = { ...item }; + const row = { + ...item, + test_active: item.test_active_display?.toLowerCase() === 'yes', // flag to apply row style + test_active_display: item.test_active_display?.toLowerCase() === 'yes' + ? Icon({classes: 'text-green display-table-cell'}, 'check_circle') + : Icon({classes: 'text-disabled display-table-cell'}, 'notifications_off'), + lock_refresh_display: item.lock_refresh_display?.toLowerCase() === 'yes' + ? Icon({classes: 'text-purple display-table-cell'}, 'lock') + : '', + flagged_display: item.flagged_display?.toLowerCase() === 'yes' + ? Icon({classes: 'text-error display-table-cell', filled: true}, 'flag') + : '', + notes_count: item.notes_count ? div( + {class: 'flex-row fx-justify-center'}, + Icon({}, 'sticky_note_2'), + span(item.notes_count), + ) : '', + }; if (isMulti) { const checked = getCheckboxState(item.id); row._checkbox = Checkbox({ label: '', checked, style: 'pointer-events: none' }); @@ -253,7 +317,7 @@ const TestDefinitions = (/** @type object */ props) => { const onSortChange = (newColumns) => { sortColumns.val = newColumns; - emitEvent('SortChanged', { payload: { columns: newColumns } }); + emit('SortChanged', { payload: { columns: newColumns } }); }; const tableSortOptions = van.derive(() => ({ @@ -268,26 +332,35 @@ const TestDefinitions = (/** @type object */ props) => { const onRowsSelected = (idxs) => { if (multiSelectMode.rawVal) { - const newIds = []; + const currentPageItemIds = new Set(testDefinitions.rawVal.map(r => r.id)); const activeSet = new Set(); for (const i of idxs) { const item = testDefinitions.rawVal[i]; - if (item) { - newIds.push(item.id); - activeSet.add(item.id); + if (item) activeSet.add(item.id); + } + // Update restore set: only modify entries for current page items + for (const id of currentPageItemIds) { + if (activeSet.has(id)) { + selectedIdSetForRestore.add(id); + } else { + selectedIdSetForRestore.delete(id); } } - selectedIdSetForRestore.clear(); - for (const id of activeSet) selectedIdSetForRestore.add(id); for (const [id, state] of checkboxStates) { - state.val = activeSet.has(id); + if (currentPageItemIds.has(id)) { + state.val = activeSet.has(id); + } } - selectedIds = newIds; - selectedIdsCount.val = newIds.length; + selectedIds = [...selectedIdSetForRestore]; + selectedIdsCount.val = selectedIds.length; // If user deselected rows while selectAll was on, turn selectAll off - if (selectAll.rawVal && newIds.length < testDefinitions.rawVal.length) { + if (selectAll.rawVal && activeSet.size < currentPageItemIds.size) { selectAll.val = false; } + // Auto-enable selectAll when all items are individually selected + if (!selectAll.rawVal && totalCount.rawVal > 0 && selectedIds.length >= totalCount.rawVal) { + selectAll.val = true; + } } else { if (idxs.length > 0) { const row = testDefinitions.rawVal[idxs[0]]; @@ -304,11 +377,16 @@ const TestDefinitions = (/** @type object */ props) => { itemsPerPage: pageSize.val, pageSizeOptions: [100, 500, 1000], onPageChange: (pageIdx, newPerPage) => { - if (!selectAll.rawVal) clearAllCheckboxStates(); if (newPerPage !== pageSize.rawVal) { - emitEvent('PageChanged', { payload: { page: 0, page_size: newPerPage } }); + if (!selectAll.rawVal) { + clearAllCheckboxStates(); + selectedIds = []; + selectedIdsCount.val = 0; + selectedIdSetForRestore.clear(); + } + emit('PageChanged', { payload: { page: 0, page_size: newPerPage } }); } else { - emitEvent('PageChanged', { payload: { page: pageIdx } }); + emit('PageChanged', { payload: { page: pageIdx } }); } }, })); @@ -318,14 +396,23 @@ const TestDefinitions = (/** @type object */ props) => { { class: 'flex-row fx-align-center fx-gap-2 p-2 fx-flex-wrap' }, () => canDisposition.val ? Toggle({ - label: 'Multi-Select', + label: () => { + return div( + { class: 'flex-column' }, + span('Multi-Select'), + () => { + if (!multiSelectMode.val) return ''; + if (selectAll.val) return span({ class: 'text-caption' }, () => `All ${totalCount.val} matching definitions selected`); + const count = selectedIdsCount.val; + if (count > 0) return span({ class: 'text-caption' }, `${count} definition${count !== 1 ? 's' : ''} selected`); + return ''; + }, + ); + }, checked: () => multiSelectMode.val, onChange: (v) => { multiSelectMode.val = v; }, }) : '', - () => multiSelectMode.val && selectAll.val - ? span({ class: 'text-caption' }, () => `All ${totalCount.val} matching definitions selected`) - : '', div({ class: 'fx-flex' }), // Edit buttons (left group) () => { @@ -341,13 +428,12 @@ const TestDefinitions = (/** @type object */ props) => { })); return div( { class: 'flex-row fx-gap-1' }, - Button({ type: 'icon', icon: 'edit', tooltip: 'Edit', disabled: !isSingle, onclick: () => emitEvent('EditDialogOpened', { payload: { id: selected[0]?.id } }) }), - Button({ type: 'icon', icon: 'file_copy', tooltip: 'Copy/Move', disabled: !isSingle, onclick: () => emitEvent('CopyMoveDialogOpened', { payload: minimalSelected() }) }), + Button({ type: 'icon', icon: 'file_copy', tooltip: 'Copy/Move', disabled: !hasSelection, onclick: () => emit('CopyMoveDialogOpened', { payload: isAll ? 'all' : minimalSelected() }) }), Button({ type: 'icon', icon: 'delete', tooltip: 'Delete', disabled: !hasSelection, onclick: () => isAll - ? emitEvent('DeleteAllOpened', {}) - : emitEvent('DeleteDialogOpened', { payload: selected.map(r => ({ id: r.id })) }), + ? emit('DeleteAllOpened', {}) + : emit('DeleteDialogOpened', { payload: getSelectedDefinitionIds().map(id => ({ id })) }), }), ); }, @@ -365,30 +451,46 @@ const TestDefinitions = (/** @type object */ props) => { const allUnlocked = !isAll && selected.length > 0 && selected.every(r => r.lock_refresh_display === 'No'); const emitAttribute = (attribute, value) => { if (isAll) { - emitEvent('UpdateAttributeAll', { payload: { attribute, value } }); + emit('UpdateAttributeAll', { payload: { attribute, value } }); } else { - emitEvent('UpdateAttribute', { payload: { attribute, ids: selected.map(r => r.id), value } }); + emit('UpdateAttribute', { payload: { attribute, ids: getSelectedDefinitionIds(), value } }); } }; return div( { class: 'flex-row fx-gap-1' }, Button({ type: 'icon', icon: 'check_circle', tooltip: 'Activate selected', disabled: noSelection || allActive, onclick: () => emitAttribute('test_active', true) }), Button({ type: 'icon', icon: 'notifications_off', tooltip: 'Deactivate selected', disabled: noSelection || allInactive, onclick: () => emitAttribute('test_active', false) }), + div({ class: 'td-header-separator' }), canEdit.val ? Button({ type: 'icon', icon: 'lock', tooltip: 'Lock selected', disabled: noSelection || allLocked, onclick: () => emitAttribute('lock_refresh', true) }) : '', canEdit.val ? Button({ type: 'icon', icon: 'lock_open', tooltip: 'Unlock selected', disabled: noSelection || allUnlocked, onclick: () => isAll - ? emitEvent('UnlockAllOpened', {}) - : emitEvent('UnlockDialogOpened', { payload: selected.map(r => ({ id: r.id })) }), + ? emit('UnlockAllOpened', {}) + : emit('UnlockDialogOpened', { payload: getSelectedDefinitionIds().map(id => ({ id })) }), }) : '', + canEdit.val ? div({ class: 'td-header-separator' }) : '', + Button({ + type: 'icon', icon: 'flag', tooltip: 'Flag selected', disabled: noSelection || selected.every(r => r.flagged), + onclick: () => emitAttribute('flagged', true), + }), + ClearFlagButton({ + disabled: noSelection || selected.every(r => !r.flagged), + onclick: () => emitAttribute('flagged', false), + }), ); }, - ExportMenu(props, testDefinitions), + ExportMenu( + props, + testDefinitions, + () => selectedRowId.val || selectedIdsCount.val > 0, + getSelectedDefinitionIds, + ), ); // Build table once const dataTable = Table( { + emit, columns: tableColumns, header: tableHeader, highDensity: true, @@ -405,6 +507,7 @@ const TestDefinitions = (/** @type object */ props) => { onRowsSelected, isInitiallySelected, }, + rowClass: (row, _) => !row.test_active ? 'text-disabled' : '', }, tableRows, ); @@ -416,20 +519,22 @@ const TestDefinitions = (/** @type object */ props) => { AddDialogComponent({ open: addDialogOpen, info: addDialogInfo, + validateResult: props.validate_result, onClose: () => { addDialogOpen.val = false; - emitEvent('AddDialogClosed', {}); + emit('AddDialogClosed', {}); }, - }), + }, emit), EditDialogComponent({ open: editDialogOpen, info: editDialogInfo, + validateResult: props.validate_result, onClose: () => { editDialogOpen.val = false; - emitEvent('EditDialogClosed', {}); + emit('EditDialogClosed', {}); }, - }), + }, emit), // Delete dialog Dialog( @@ -438,7 +543,7 @@ const TestDefinitions = (/** @type object */ props) => { open: deleteDialogOpen, onClose: () => { deleteDialogOpen.val = false; - emitEvent('DeleteDialogClosed', {}); + emit('DeleteDialogClosed', {}); }, }, () => { @@ -451,14 +556,16 @@ const TestDefinitions = (/** @type object */ props) => { : span('Are you sure you want to delete the selected test definition?') ), div( - { class: 'flex-row td-dialog-actions' }, + { class: 'flex-row fx-justify-flex-end fx-gap-2' }, Button({ type: 'flat', color: 'warn', label: 'Delete', + width: 'auto', + style: 'margin-left: auto;', onclick: () => { deleteDialogOpen.val = false; - emitEvent('DeleteConfirmed', { payload: { ids: info.ids } }); + emit('DeleteConfirmed', { payload: { ids: info.ids } }); }, }), ), @@ -473,7 +580,7 @@ const TestDefinitions = (/** @type object */ props) => { open: unlockDialogOpen, onClose: () => { unlockDialogOpen.val = false; - emitEvent('UnlockDialogClosed', {}); + emit('UnlockDialogClosed', {}); }, }, () => { @@ -487,14 +594,16 @@ const TestDefinitions = (/** @type object */ props) => { : span('Are you sure you want to unlock the selected test definition?') ), div( - { class: 'flex-row td-dialog-actions' }, + { class: 'flex-row fx-justify-flex-end fx-gap-2' }, Button({ type: 'stroked', color: 'basic', label: 'Unlock', + width: 'auto', + style: 'margin-left: auto;', onclick: () => { unlockDialogOpen.val = false; - emitEvent('UnlockConfirmed', { payload: { ids: info.ids } }); + emit('UnlockConfirmed', { payload: { ids: info.ids } }); }, }), ), @@ -507,24 +616,47 @@ const TestDefinitions = (/** @type object */ props) => { info: copyMoveDialogInfo, onClose: () => { copyMoveDialogOpen.val = false; - emitEvent('CopyMoveDialogClosed', {}); + emit('CopyMoveDialogClosed', {}); }, - }), + }, emit), // Run tests dialog () => { const info = runTestsDialogData.val; if (!info) return span(); - return RunTestsDialog({ + return RunTestsDialog({ emit, dialog: { title: 'Run Tests', open: true }, project_code: info.project_code, test_suites: info.test_suites ?? [], default_test_suite_id: info.default_test_suite_id, result: info.result, - onClose: () => emitEvent('RunTestsDialogClosed', {}), + onClose: () => emit('RunTestsDialogClosed', {}), }); }, + // Notes dialog + Dialog( + { + title: 'Test Notes', + open: notesDialogOpen, + onClose: () => { + notesDialogOpen.val = false; + emit('NotesDialogClosed', {}); + }, + width: '36rem', + }, + () => { + const data = getValue(props.notes_dialog); + if (!data) return span(); + return TestDefinitionNotes({ emit, + test_label: data.test_label, + notes: data.notes, + current_user: data.current_user, + test_definition_id: data.id, + }); + }, + ), + // --- Top bar: filters + Add + Run Tests --- div( { class: 'flex-row fx-align-flex-end fx-gap-3 fx-flex-wrap' }, @@ -548,8 +680,9 @@ const TestDefinitions = (/** @type object */ props) => { allowNull: true, width: 200, filterable: true, - onChange: (value) => { - columnFilter.val = value; + acceptNewOptions: true, + onChange: (value, meta) => { + columnFilter.val = meta?.isCustom ? `%${value}%` : value; onFilterChange(); }, }), @@ -565,6 +698,19 @@ const TestDefinitions = (/** @type object */ props) => { onFilterChange(); }, }), + () => Select({ + label: 'Flagged', + value: flaggedFilter.val, + options: [ + { label: 'Flagged', value: 'Flagged' }, + { label: 'Not Flagged', value: 'Not Flagged' }, + ], + allowNull: true, + onChange: (value) => { + flaggedFilter.val = value; + onFilterChange(); + }, + }), div({ class: 'fx-flex' }), () => canEdit.val ? Button({ @@ -574,7 +720,7 @@ const TestDefinitions = (/** @type object */ props) => { label: 'Add', width: 'auto', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('AddDialogOpened', {}), + onclick: () => emit('AddDialogOpened', {}), }) : '', () => canEdit.val @@ -585,7 +731,7 @@ const TestDefinitions = (/** @type object */ props) => { label: 'Run Tests', width: 'auto', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('RunTestsClicked', {}), + onclick: () => emit('RunTestsClicked', {}), }) : '', ), @@ -598,24 +744,52 @@ const TestDefinitions = (/** @type object */ props) => { { style: () => singleSelected.val && !multiSelectMode.val ? 'margin-top: 16px' : 'display: none' }, () => { const row = singleSelected.val; - return row ? div({ class: 'tg-td--detail' }, DetailPanel(row)) : ''; + if (!row) return ''; + return div( + { class: 'tg-td--detail flex-column fx-gap-4' }, + canEdit.val ? div( + { class: 'flex-row fx-gap-2 fx-justify-content-flex-end' }, + Button({ + type: 'stroked', icon: 'edit', label: 'Edit', width: 'auto', + style: 'background: var(--button-generic-background-color);', + onclick: () => emit('EditDialogOpened', { payload: { id: row.id } }), + }), + Button({ + type: 'stroked', icon: 'sticky_note_2', label: 'Notes', width: 'auto', + style: 'background: var(--button-generic-background-color);', + onclick: () => emit('NotesClicked', { payload: { id: row.id, table_name: row.table_name, column_name: row.column_name, test_name_short: row.test_name_short } }), + }), + ) : '', + DetailPanel(row), + ); }, ), ); }; // Export popover menu -const ExportMenu = (props, testDefinitions) => { +const ExportMenu = (props, testDefinitions, hasSelection, getSelectedIds) => { + const emit = props.emit; return DropdownButton({ icon: 'download', label: 'Export', - items: [ - { label: 'All tests', onclick: () => emitEvent('ExportAll', {}) }, - { - label: 'Filtered tests', - onclick: () => emitEvent('ExportFiltered', { payload: { records: testDefinitions.val } }), - }, - ], + buttonSize: 'small', + items: () => { + const items = [ + { label: 'All tests', onclick: () => emit('ExportAll', {}) }, + { + label: 'Filtered tests', + onclick: () => emit('ExportFiltered', { payload: { records: testDefinitions.val } }), + }, + ]; + if (hasSelection()) { + items.push({ + label: 'Selected tests', + onclick: () => emit('ExportSelected', { payload: { ids: getSelectedIds() } }), + }); + } + return items; + }, }); }; @@ -656,13 +830,13 @@ const DetailPanel = (row) => { }; // Add dialog — mounted once, state persists across Python reruns -const AddDialogComponent = ({ open, info, onClose }) => { +const AddDialogComponent = ({ open, info, validateResult: validateResultProp, onClose }, emit) => { const testTypes = van.derive(() => getValue(info)?.test_types ?? []); const tableGroupSchema = van.derive(() => getValue(info)?.table_group_schema ?? ''); const tableGroupsId = van.derive(() => getValue(info)?.table_groups_id ?? ''); const testSuite = van.derive(() => getValue(info)?.test_suite ?? {}); const tableColumns = van.derive(() => getValue(info)?.table_columns ?? []); - const validateResult = van.derive(() => getValue(info)?.validate_result ?? null); + const validateResult = van.derive(() => getValue(validateResultProp) ?? null); const scopeFilter = { referential: van.state(true), @@ -678,27 +852,25 @@ const AddDialogComponent = ({ open, info, onClose }) => { ); const selectedTestType = van.state(null); - const selectedTestTypeRow = van.derive(() => - selectedTestType.val ? testTypes.val.find(tt => tt.test_type === selectedTestType.val) ?? null : null - ); - - // Build blank form values when a test type is chosen const formValues = van.state(null); - van.derive(() => { - const tt = selectedTestTypeRow.val; - if (!tt) { formValues.val = null; return; } - formValues.val = { + + const buildFormValues = (testType) => { + if (!testType) return null; + const tt = testTypes.rawVal.find(t => t.test_type === testType); + if (!tt) return null; + return { ...BLANK_PARAM_FIELDS, ...tt, + id: null, default_test_description: tt.test_description, test_description: null, test_active: true, lock_refresh: false, severity: null, export_to_observability: null, - schema_name: tableGroupSchema.val, - test_suite_id: testSuite.val.id, - table_groups_id: tableGroupsId.val, + schema_name: tableGroupSchema.rawVal, + test_suite_id: testSuite.rawVal.id, + table_groups_id: tableGroupsId.rawVal, table_name: null, column_name: null, skip_errors: 0, @@ -707,14 +879,19 @@ const AddDialogComponent = ({ open, info, onClose }) => { profiling_as_of_date: null, profile_run_id: null, }; - }); + }; + + const selectTestType = (testType) => { + selectedTestType.val = testType; + formValues.val = buildFormValues(testType); + }; // Reset form state when dialog opens (transitions from closed→open) const wasOpen = van.state(false); van.derive(() => { const isOpen = open.val; if (isOpen && !wasOpen.val) { - selectedTestType.val = null; + selectTestType(null); wasOpen.val = true; } else if (!isOpen) { wasOpen.val = false; @@ -746,25 +923,32 @@ const AddDialogComponent = ({ open, info, onClose }) => { options: filteredTestTypeOptions.val, allowNull: true, filterable: true, - onChange: (value) => { selectedTestType.val = value; }, + onChange: (value) => { selectTestType(value); }, }), ), - // Form (shown after test type selected) + // Form (shown after test type selected) — imperative update + // because VanJS binding replacement doesn't work inside Dialog portals () => { + open.val; + + selectedTestType.val; const fv = formValues.val; - if (!fv) return span(); + const vr = validateResult.val; + + if (!fv) return ''; + return TestDefFormContent({ formValues: fv, - tableColumns: tableColumns.val, - testSuite: testSuite.val, - validateResult: validateResult.val, + tableColumns: tableColumns.rawVal, + testSuite: testSuite.rawVal, + validateResult: vr, mode: 'add', onFormChange: (changes) => { formValues.val = { ...formValues.rawVal, ...changes }; }, - onValidate: () => emitEvent('ValidateTest', { payload: formValues.rawVal }), - onSave: () => emitEvent('AddTestSaved', { payload: formValues.rawVal }), + onValidate: () => emit('ValidateTest', { payload: formValues.rawVal }), + onSave: () => emit('AddTestSaved', { payload: formValues.rawVal }), onCancel: onClose, }); }, @@ -773,17 +957,16 @@ const AddDialogComponent = ({ open, info, onClose }) => { }; // Edit dialog — mounted once, state persists across Python reruns -const EditDialogComponent = ({ open, info, onClose }) => { +const EditDialogComponent = ({ open, info, validateResult: validateResultProp, onClose }, emit) => { const dialogInfo = van.derive(() => getValue(info) ?? null); - const testTypes = van.derive(() => dialogInfo.val?.test_types ?? []); const tableColumns = van.derive(() => dialogInfo.val?.table_columns ?? []); const testSuite = van.derive(() => dialogInfo.val?.test_suite ?? {}); - const validateResult = van.derive(() => dialogInfo.val?.validate_result ?? null); + const validateResult = van.derive(() => getValue(validateResultProp) ?? null); - // Build formValues when dialog info changes (new definition to edit) const formValues = van.state(null); - van.derive(() => { - const di = dialogInfo.val; + + const initFormFromInfo = () => { + const di = dialogInfo.rawVal; if (!di?.test_definition) { formValues.val = null; return; } const def = di.test_definition; const ttRow = (di.test_types ?? []).find(tt => tt.test_type === def.test_type) ?? {}; @@ -793,26 +976,41 @@ const EditDialogComponent = ({ open, info, onClose }) => { column_name_prompt: ttRow.column_name_prompt ?? null, column_name_help: ttRow.column_name_help ?? null, }; + }; + + // Reset form when dialog opens (closed→open), clear when it closes + const wasOpen = van.state(false); + van.derive(() => { + const isOpen = open.val; + if (isOpen && !wasOpen.val) { + initFormFromInfo(); + wasOpen.val = true; + } else if (!isOpen) { + formValues.val = null; + wasOpen.val = false; + } }); return Dialog( { title: 'Edit Test', open, onClose, width: '52rem' }, () => { + open.val; const fv = formValues.val; - if (!fv) return span(); + const vr = validateResult.val; + if (!fv) return ''; return div( { class: 'flex-column fx-gap-4 td-form-dialog' }, TestDefFormContent({ formValues: fv, - tableColumns: tableColumns.val, - testSuite: testSuite.val, - validateResult: validateResult.val, + tableColumns: tableColumns.rawVal, + testSuite: testSuite.rawVal, + validateResult: vr, mode: 'edit', onFormChange: (changes) => { formValues.val = { ...formValues.rawVal, ...changes }; }, - onValidate: () => emitEvent('ValidateTest', { payload: formValues.rawVal }), - onSave: () => emitEvent('EditTestSaved', { payload: formValues.rawVal }), + onValidate: () => emit('ValidateTest', { payload: formValues.rawVal }), + onSave: () => emit('EditTestSaved', { payload: formValues.rawVal }), onCancel: onClose, }), ); @@ -899,15 +1097,15 @@ const TestDefFormContent = ({ formValues, tableColumns, testSuite, validateResul onChange: (value) => updateField('test_description', value || null), }), - // Toggles + // Checkboxes div( { class: 'flex-row fx-gap-4' }, - Toggle({ + Checkbox({ label: 'Test Active', checked: () => fv.val.test_active ?? true, onChange: (v) => updateField('test_active', v), }), - Toggle({ + Checkbox({ label: 'Lock Refresh', checked: () => fv.val.lock_refresh ?? false, onChange: (v) => updateField('lock_refresh', v), @@ -1004,8 +1202,9 @@ const TestDefFormContent = ({ formValues, tableColumns, testSuite, validateResul div( { class: 'td-form-params-section' }, TestDefinitionForm({ - definition: () => fv.val, + definition: formValues, onChange: (changes) => { + if (Object.keys(changes).length === 0) return; const updated = { ...fv.rawVal, ...changes }; fv.val = updated; onFormChange(changes); @@ -1032,35 +1231,39 @@ const TestDefFormContent = ({ formValues, tableColumns, testSuite, validateResul // Buttons div( - { class: 'flex-row fx-justify-space-between td-dialog-actions' }, + { class: 'flex-row fx-justify-space-between fx-gap-2' }, isValidatable ? Button({ type: 'stroked', color: 'basic', label: 'Validate', + width: 'auto', onclick: onValidate, }) - : null, - Button({ - type: 'stroked', - color: 'basic', - label: 'Cancel', - width: 'auto', - onclick: onCancel, - }), - Button({ - type: 'flat', - color: 'primary', - label: 'Save', - width: 'auto', - onclick: onSave, - }), + : span(''), + div( + { class: 'flex-row fx-gap-2' }, + Button({ + type: 'stroked', + color: 'basic', + label: 'Cancel', + width: 'auto', + onclick: onCancel, + }), + Button({ + type: 'flat', + color: 'primary', + label: 'Save', + width: 'auto', + onclick: onSave, + }), + ), ), ); }; // Copy/Move dialog — mounted once -const CopyMoveDialogComponent = ({ open, info, onClose }) => { +const CopyMoveDialogComponent = ({ open, info, onClose }, emit) => { const dialogInfo = van.derive(() => getValue(info) ?? null); const collision = van.derive(() => dialogInfo.val?.collision ?? null); @@ -1121,7 +1324,7 @@ const CopyMoveDialogComponent = ({ open, info, onClose }) => { const tsId = targetTsId.val; const di = dialogInfo.val; if (tgId && tsId && di?.selected) { - emitEvent('CopyMoveTargetChanged', { + emit('CopyMoveTargetChanged', { payload: { selected: di.selected, target_table_group_id: tgId, @@ -1227,20 +1430,20 @@ const CopyMoveDialogComponent = ({ open, info, onClose }) => { }, div( - { class: 'flex-row fx-gap-2 td-dialog-actions' }, + { class: 'flex-row fx-justify-flex-end fx-gap-2' }, () => Button({ type: 'stroked', color: 'basic', label: 'Copy', disabled: !movableIds.val.length || !targetTsId.val, - onclick: () => emitEvent('CopyConfirmed', { payload: buildPayload() }), + onclick: () => emit('CopyConfirmed', { payload: buildPayload() }), }), () => Button({ type: 'flat', color: 'primary', label: 'Move', disabled: !movableIds.val.length || !targetTsId.val, - onclick: () => emitEvent('MoveConfirmed', { payload: buildPayload() }), + onclick: () => emit('MoveConfirmed', { payload: buildPayload() }), }), ), ), @@ -1255,7 +1458,7 @@ stylesheet.replace(` } .tg-td--detail { - border-top: 1px solid var(--border-color, #dddfe2); + border-top: 1px dashed var(--border-color, #dddfe2); padding-top: 16px; } @@ -1277,12 +1480,6 @@ stylesheet.replace(` padding-top: 12px; margin-top: 4px; } - - -.td-dialog-actions { - justify-content: flex-end; - gap: 8px; -} `); export { TestDefinitions, EditDialogComponent }; @@ -1290,8 +1487,6 @@ export { TestDefinitions, EditDialogComponent }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -1299,6 +1494,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, TestDefinitions(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/test_results.js b/testgen/ui/components/frontend/js/pages/test_results.js index 6edda712..978502da 100644 --- a/testgen/ui/components/frontend/js/pages/test_results.js +++ b/testgen/ui/components/frontend/js/pages/test_results.js @@ -41,8 +41,7 @@ * @property {object} filter_options */ import van from '/app/static/js/van.min.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { emitEvent, getValue, isEqual, loadStylesheet, parseDate } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet, parseDate } from '/app/static/js/utils.js'; import { Table } from '/app/static/js/components/table.js'; import { Select } from '/app/static/js/components/select.js'; import { Tabs, Tab } from '/app/static/js/components/tabs.js'; @@ -58,8 +57,9 @@ import { SourceDataDialog } from '../shared/source_data_dialog.js'; import { TestResultsChart } from './test_results_chart.js'; import { TestDefinitionSummary } from './test_definition_summary.js'; import { EditDialogComponent } from './test_definitions.js'; +import { TestDefinitionNotes } from './test_definition_notes.js'; -const { div, span, h3, h4, p, small } = van.tags; +const { button: btn, div, i: icon, span, h3, h4, p, small } = van.tags; const STATUS_COLORS = { Passed: 'var(--green)', @@ -69,6 +69,30 @@ const STATUS_COLORS = { Log: 'var(--blue)', }; +/** Composite icon button: flag with a diagonal strikethrough (pen_size_1 rotated). */ +const ClearFlagButton = ({ disabled, onclick }) => { + return btn( + { + class: 'tg-button tg-icon-button tg-basic-button', + tooltip: 'Clear flag', + disabled, + onclick, + style: 'width: 40px; position: relative;', + }, + span({ class: 'tg-button-focus-state-indicator' }, ''), + div( + { style: 'position: relative; display: inline-flex; align-items: center; justify-content: center;' }, + icon({ class: 'material-symbols-rounded', style: 'font-size: 20px;' }, 'flag'), + icon({ class: 'material-symbols-rounded', style: 'font-size: 24px; position: absolute; top: -3px; left: -3px; transform: rotate(90deg);' }, 'pen_size_1'), + ), + ); +}; + +const FLAGGED_FILTER_OPTIONS = [ + { label: 'Flagged', value: 'Flagged' }, + { label: 'Not Flagged', value: 'Not Flagged' }, +]; + const DATA_COLUMNS = [ { name: 'table_name', label: 'Table', width: 160, sortable: true, overflow: 'hidden' }, { name: 'column_names', label: 'Columns/Focus', width: 150, sortable: true, overflow: 'hidden' }, @@ -77,6 +101,8 @@ const DATA_COLUMNS = [ { name: 'measure_uom', label: 'Unit of Measure', width: 130, overflow: 'hidden' }, { name: 'status_display', label: 'Status', width: 90, sortable: true }, { name: 'action', label: 'Action', width: 70, align: 'center' }, + { name: 'flagged_display', label: 'Flagged', width: 80, align: 'center' }, + { name: 'notes_count', label: 'Notes', width: 70, align: 'center' }, { name: 'result_message', label: 'Details', width: 200, overflow: 'hidden' }, ]; @@ -134,25 +160,35 @@ const buildTableRow = (item) => ({ : '', result_status: item.result_status ?? '', action: buildDispositionIcon(item.disposition), + flagged_display: item.flagged_display?.toLowerCase() === 'yes' + ? Icon({classes: 'text-error display-table-cell', filled: true}, 'flag') + : '', + notes_count: item.notes_count ? div( + {class: 'flex-row fx-justify-center'}, + Icon({}, 'sticky_note_2'), + span(item.notes_count), + ) : '', result_message: item.result_message ?? '', }); -const ExportMenu = (statusFilter, tableFilter, columnFilter, testTypeFilter, actionFilter, hasSelection, getSelectedIds) => { +const ExportMenu = (statusFilter, tableFilter, columnFilter, testTypeFilter, actionFilter, flaggedFilter, hasSelection, getSelectedIds, emit) => { return DropdownButton({ icon: 'download', label: 'Export', + buttonSize: 'small', items: () => { const items = [ - { label: 'All results', onclick: () => emitEvent('ExportAll', {}) }, + { label: 'All results', onclick: () => emit('ExportAll', {}) }, { label: 'Filtered results', - onclick: () => emitEvent('ExportFiltered', { + onclick: () => emit('ExportFiltered', { payload: { status: statusFilter.rawVal, table_name: tableFilter.rawVal, column_name: columnFilter.rawVal, test_type: testTypeFilter.rawVal, action: actionFilter.rawVal, + flagged: flaggedFilter.rawVal, }, }), }, @@ -160,7 +196,7 @@ const ExportMenu = (statusFilter, tableFilter, columnFilter, testTypeFilter, act if (hasSelection()) { items.push({ label: 'Selected results', - onclick: () => emitEvent('ExportSelected', { payload: { ids: getSelectedIds() } }), + onclick: () => emit('ExportSelected', { payload: { ids: getSelectedIds() } }), }); } return items; @@ -199,6 +235,7 @@ const TestResultSourceDataHeader = (d) => { // ProfilingDialog and SourceDataDialog are now shared components from ../shared/ const EditTestDialog = (props) => { + const emit = props.emit; const editDialogOpen = van.state(false); const editDialogInfo = van.derive(() => getValue(props.edit_test) ?? null); @@ -207,14 +244,16 @@ const EditTestDialog = (props) => { return EditDialogComponent({ open: editDialogOpen, info: editDialogInfo, + validateResult: props.validate_result, onClose: () => { editDialogOpen.val = false; - emitEvent('EditTestClosed', {}); + emit('EditTestClosed', {}); }, - }); + }, emit); }; const TestResults = (/** @type Properties */ props) => { + const { emit } = props; loadStylesheet('test-results', stylesheet); const items = van.derive(() => getValue(props.items) ?? []); @@ -238,6 +277,11 @@ const TestResults = (/** @type Properties */ props) => { const columnFilter = van.state(initialFilters.column_name ?? null); const testTypeFilter = van.state(initialFilters.test_type ?? null); const actionFilter = van.state(initialFilters.action ?? null); + const flaggedFilter = van.state(initialFilters.flagged ?? null); + + // Notes dialog: persistent local state + one-time sync from Python prop + const notesDialogOpen = van.state(false); + van.derive(() => { if (getValue(props.notes_dialog)) notesDialogOpen.val = true; }); // Sort state initialized from Python const initialSortState = getValue(props.sort_state) ?? []; @@ -289,22 +333,25 @@ const TestResults = (/** @type Properties */ props) => { const clearAllCheckboxStates = () => { for (const state of checkboxStates.values()) state.val = false; selectAll.val = false; + selectedIdsCount.val = 0; }; // Selection tracking (declared early — referenced by derives below) let selectedIds = []; const selectedIdSetForRestore = new Set(); + const selectedIdsCount = van.state(0); // Select All handler (declared early — used by checkbox column) const onSelectAllToggle = (checked) => { - selectAll.val = checked; if (checked) { + selectAll.val = true; for (const item of items.rawVal) { const state = getCheckboxState(item.test_result_id); state.val = true; selectedIdSetForRestore.add(item.test_result_id); } - selectedIds = items.rawVal.map(r => r.test_result_id); + selectedIds = [...selectedIdSetForRestore]; + selectedIdsCount.val = selectedIds.length; } else { clearAllCheckboxStates(); selectedIds = []; @@ -312,13 +359,18 @@ const TestResults = (/** @type Properties */ props) => { } }; - // Columns: prepend checkbox when multi-select is on (header has select-all checkbox) - const selectAllCheckbox = Checkbox({ - label: '', - checked: () => selectAll.val, - onChange: onSelectAllToggle, - }); - const checkboxColumn = { name: '_checkbox', label: selectAllCheckbox, width: 32, align: 'center' }; + // Columns: prepend checkbox when multi-select is on (header has reactive select-all checkbox) + const checkboxColumn = { + name: '_checkbox', + label: () => Checkbox({ + label: '', + checked: selectAll.val, + indeterminate: !selectAll.val && selectedIdsCount.val > 0, + onChange: onSelectAllToggle, + }), + width: 32, + align: 'center', + }; const tableColumns = van.derive(() => multiSelect.val ? [checkboxColumn, ...DATA_COLUMNS] : DATA_COLUMNS); // Clear checkbox states and selection when toggling multi-select off @@ -339,15 +391,13 @@ const TestResults = (/** @type Properties */ props) => { // When selectAll is active, sync tracking state to current items if (isMulti && isSelectAll) { - selectedIdSetForRestore.clear(); - const newIds = []; for (const item of currentItems) { const state = getCheckboxState(item.test_result_id); state.val = true; selectedIdSetForRestore.add(item.test_result_id); - newIds.push(item.test_result_id); } - selectedIds = newIds; + selectedIds = [...selectedIdSetForRestore]; + selectedIdsCount.val = selectedIds.length; } return currentItems.map(item => { @@ -362,7 +412,7 @@ const TestResults = (/** @type Properties */ props) => { const onSortChange = (newColumns) => { sortColumns.val = newColumns; - emitEvent('SortChanged', { payload: { columns: newColumns } }); + emit('SortChanged', { payload: { columns: newColumns } }); }; const tableSortOptions = van.derive(() => ({ @@ -379,31 +429,41 @@ const TestResults = (/** @type Properties */ props) => { }; const onRowsSelected = (idxs) => { if (multiSelect.rawVal) { - const newIds = []; + const currentPageItemIds = new Set(items.rawVal.map(r => r.test_result_id)); const activeSet = new Set(); for (const i of idxs) { const item = items.rawVal[i]; - if (item) { - newIds.push(item.test_result_id); - activeSet.add(item.test_result_id); + if (item) activeSet.add(item.test_result_id); + } + // Update restore set: only modify entries for current page items + for (const id of currentPageItemIds) { + if (activeSet.has(id)) { + selectedIdSetForRestore.add(id); + } else { + selectedIdSetForRestore.delete(id); } } - selectedIdSetForRestore.clear(); - for (const id of activeSet) selectedIdSetForRestore.add(id); for (const [id, state] of checkboxStates) { - state.val = activeSet.has(id); + if (currentPageItemIds.has(id)) { + state.val = activeSet.has(id); + } } - selectedIds = newIds; + selectedIds = [...selectedIdSetForRestore]; + selectedIdsCount.val = selectedIds.length; // If user deselected rows while selectAll was on, turn selectAll off - if (selectAll.rawVal && newIds.length < items.rawVal.length) { + if (selectAll.rawVal && activeSet.size < currentPageItemIds.size) { selectAll.val = false; } + // Auto-enable selectAll when all items are individually selected + if (!selectAll.rawVal && totalCount.rawVal > 0 && selectedIds.length >= totalCount.rawVal) { + selectAll.val = true; + } } else { if (idxs.length > 0) { const row = items.rawVal[idxs[0]]; if (row && row.test_result_id !== selectedRowId.rawVal) { selectedRowId.val = row.test_result_id; - emitEvent('RowSelected', { payload: row.test_result_id }); + emit('RowSelected', { payload: row.test_result_id }); } } } @@ -415,10 +475,11 @@ const TestResults = (/** @type Properties */ props) => { column_name: columnFilter.rawVal, test_type: testTypeFilter.rawVal, action: actionFilter.rawVal, + flagged: flaggedFilter.rawVal, }); const emitFilterChanged = () => { - emitEvent('FilterChanged', { payload: getCurrentFilters() }); + emit('FilterChanged', { payload: getCurrentFilters() }); }; const onStatusFilterChange = (value) => { @@ -434,8 +495,8 @@ const TestResults = (/** @type Properties */ props) => { emitFilterChanged(); }; - const onColumnFilterChange = (value) => { - columnFilter.val = value; + const onColumnFilterChange = (value, meta) => { + columnFilter.val = meta?.isCustom ? `%${value}%` : value; selectedRowId.val = null; emitFilterChanged(); }; @@ -452,9 +513,15 @@ const TestResults = (/** @type Properties */ props) => { emitFilterChanged(); }; + const onFlaggedFilterChange = (value) => { + flaggedFilter.val = value; + selectedRowId.val = null; + emitFilterChanged(); + }; + const getSelectedResultIds = () => { - if (multiSelect.val && selectedIds.length > 0) { - return [...selectedIds]; + if (multiSelect.val && selectedIdSetForRestore.size > 0) { + return [...selectedIdSetForRestore]; } return selectedRowId.rawVal ? [selectedRowId.rawVal] : []; }; @@ -465,7 +532,8 @@ const TestResults = (/** @type Properties */ props) => { // If filtering to only Passed, all are passed return statusFilter.val === 'Passed'; } - if (selectedIds.length === 0) return true; + const count = selectedIdsCount.val; // reactive dependency on selection changes + if (count === 0) return true; const currentItems = items.val; const idSet = new Set(selectedIds); return currentItems @@ -478,12 +546,12 @@ const TestResults = (/** @type Properties */ props) => { const onDisposition = (status) => { if (selectAll.rawVal) { - emitEvent('DispositionAll', { payload: { filters: getCurrentFilters(), status } }); + emit('DispositionAll', { payload: { filters: getCurrentFilters(), status } }); return; } const ids = getSelectedResultIds(); if (ids.length > 0) { - emitEvent('DispositionChanged', { payload: { test_result_ids: ids, status } }); + emit('DispositionChanged', { payload: { test_result_ids: ids, status } }); } }; @@ -491,17 +559,47 @@ const TestResults = (/** @type Properties */ props) => { const tableHeader = div( { class: 'flex-row fx-align-center fx-gap-2 p-2' }, Toggle({ - label: 'Multi-Select', + label: () => { + return div( + { class: 'flex-column' }, + span('Multi-Select'), + () => { + if (!multiSelect.val) return ''; + if (selectAll.val) return span({ class: 'text-caption' }, () => `All ${totalCount.val} matching results selected`); + const count = selectedIdsCount.val; + if (count > 0) return span({ class: 'text-caption' }, `${count} result${count !== 1 ? 's' : ''} selected`); + return ''; + }, + ); + }, checked: () => multiSelect.val, onChange: (checked) => { multiSelect.val = checked; }, }), - () => multiSelect.val && selectAll.val - ? span({ class: 'text-caption' }, () => `All ${totalCount.val} matching results selected`) - : '', div({ class: 'fx-flex' }), () => { if (!permissions.val.can_disposition) return ''; - const disabled = allSelectedArePassed.val; + // Compute disabled state directly from reactive values (not via + // an intermediate derive) so VanJS always re-renders this binding + // when the selection changes — matches the test_definitions pattern. + const isAll = selectAll.val; + const count = selectedIdsCount.val; + let disabled; + if (multiSelect.val) { + if (isAll) { + disabled = statusFilter.val === 'Passed'; + } else if (count === 0) { + disabled = true; + } else { + const currentItems = items.val; + const idSet = new Set(selectedIds); + disabled = currentItems + .filter(r => idSet.has(r.test_result_id)) + .every(r => r.result_status === 'Passed'); + } + } else { + const row = selectedRow.val; + disabled = !row || row.result_status === 'Passed'; + } return div( { class: 'flex-row fx-gap-1' }, Button({ type: 'icon', icon: 'check_circle', tooltip: 'Confirm selected as relevant', disabled, onclick: () => onDisposition('Confirmed') }), @@ -510,10 +608,51 @@ const TestResults = (/** @type Properties */ props) => { Button({ type: 'icon', icon: 'restart_alt', tooltip: 'Clear action on selected', disabled, onclick: () => onDisposition('No Decision') }), ); }, + // Flag/unflag buttons + () => { + if (!permissions.val.can_disposition) return ''; + const isAll = selectAll.val; + const count = selectedIdsCount.val; + const noSelection = !isAll && count === 0 && !selectedRow.val; + const selected = (() => { + if (isAll) return items.val; + if (count > 0) { + const idSet = new Set(selectedIds); + return items.val.filter(r => idSet.has(r.test_result_id)); + } + const row = selectedRow.val; + return row ? [row] : []; + })(); + const getTestDefinitionIds = () => [...new Set(selected.filter(r => r.test_definition_id).map(r => r.test_definition_id))]; + return div( + { class: 'flex-row fx-gap-1' }, + span({ style: 'width: 0px; height: 24px; border-right: 1px dashed var(--border-color);'}, ''), + Button({ + type: 'icon', icon: 'flag', tooltip: 'Flag selected', disabled: noSelection || selected.every(r => r.flagged), + onclick: () => emit('FlagChanged', { payload: { test_definition_ids: getTestDefinitionIds(), value: true } }), + }), + ClearFlagButton({ + disabled: noSelection || selected.every(r => !r.flagged), + onclick: () => emit('FlagChanged', { payload: { test_definition_ids: getTestDefinitionIds(), value: false } }), + }), + ); + }, + span({ style: 'width: 0px; height: 24px; border-right: 1px dashed var(--border-color);'}, ''), + () => { + const hasAnySelection = selectedIdsCount.val > 0 || !!selectedRow.val; + if (!hasAnySelection) return ''; + + return Button({ + type: 'stroked', icon: 'download', label: 'Issue Report', width: 'auto', + size: 'small', style: 'background: var(--button-generic-background-color);', + onclick: () => emit('IssueReportClicked', { payload: { ids: getSelectedResultIds() } }), + }); + }, ExportMenu( - statusFilter, tableFilter, columnFilter, testTypeFilter, actionFilter, + statusFilter, tableFilter, columnFilter, testTypeFilter, actionFilter, flaggedFilter, () => selectedRowId.val || selectedIds.length > 0, getSelectedResultIds, + emit, ), ); @@ -523,11 +662,15 @@ const TestResults = (/** @type Properties */ props) => { itemsPerPage: pageSize.val, pageSizeOptions: [100, 500, 1000], onPageChange: (pageIdx, newPerPage) => { - if (!selectAll.rawVal) clearAllCheckboxStates(); if (newPerPage !== pageSize.rawVal) { - emitEvent('PageChanged', { payload: { page: 0, page_size: newPerPage } }); + if (!selectAll.rawVal) { + clearAllCheckboxStates(); + selectedIds = []; + selectedIdSetForRestore.clear(); + } + emit('PageChanged', { payload: { page: 0, page_size: newPerPage } }); } else { - emitEvent('PageChanged', { payload: { page: pageIdx } }); + emit('PageChanged', { payload: { page: pageIdx } }); } }, })); @@ -535,6 +678,7 @@ const TestResults = (/** @type Properties */ props) => { // Build the main table once const dataTable = Table( { + emit, columns: tableColumns, header: tableHeader, highDensity: true, @@ -570,7 +714,7 @@ const TestResults = (/** @type Properties */ props) => { }); const createHistoryTable = () => Table( - { columns: HISTORY_COLUMNS, highDensity: true, height: '250px' }, + { emit, columns: HISTORY_COLUMNS, highDensity: true, height: '250px' }, historyRows, ); @@ -593,17 +737,40 @@ const TestResults = (/** @type Properties */ props) => { { 'data-testid': 'test-results', class: 'flex-column' }, // Dialogs (mounted once, driven by props from Python) - ProfilingResultsDialog({ + ProfilingResultsDialog({ emit, profilingColumn: van.derive(() => getValue(props.profiling_column) ?? null), - onClose: () => emitEvent('ProfilingClosed', {}), + onClose: () => emit('ProfilingClosed', {}), }), - SourceDataDialog({ + SourceDataDialog({ emit, sourceData: van.derive(() => getValue(props.source_data) ?? null), - onClose: () => emitEvent('SourceDataClosed', {}), + onClose: () => emit('SourceDataClosed', {}), renderHeader: TestResultSourceDataHeader, }), EditTestDialog(props), + // Notes dialog + Dialog( + { + title: 'Test Notes', + open: notesDialogOpen, + onClose: () => { + notesDialogOpen.val = false; + emit('NotesDialogClosed', {}); + }, + width: '36rem', + }, + () => { + const data = getValue(props.notes_dialog); + if (!data) return span(); + return TestDefinitionNotes({ emit, + test_label: data.test_label, + notes: data.notes, + current_user: data.current_user, + test_definition_id: data.id, + }); + }, + ), + // Header row: summary bar + score div( { class: 'flex-row fx-gap-2 fx-align-flex-end mb-2 fx-flex-wrap' }, @@ -623,7 +790,7 @@ const TestResults = (/** @type Properties */ props) => { icon: 'autorenew', tooltip: 'Recalculate score', style: 'color: var(--secondary-text-color)', - onclick: () => emitEvent('ScoreRefreshClicked', {}), + onclick: () => emit('ScoreRefreshClicked', {}), }), ), ), @@ -646,6 +813,7 @@ const TestResults = (/** @type Properties */ props) => { options: tableOptions.val, testId: 'table-filter', style: 'min-width: 180px', + filterable: true, onChange: onTableFilterChange, allowNull: true, }), @@ -655,6 +823,8 @@ const TestResults = (/** @type Properties */ props) => { options: columnOptions.val, testId: 'column-filter', style: 'min-width: 180px', + filterable: true, + acceptNewOptions: true, onChange: onColumnFilterChange, allowNull: true, }), @@ -664,6 +834,7 @@ const TestResults = (/** @type Properties */ props) => { options: testTypeOptions.val, testId: 'test-type-filter', style: 'min-width: 160px', + filterable: true, onChange: onTestTypeFilterChange, allowNull: true, }), @@ -676,6 +847,15 @@ const TestResults = (/** @type Properties */ props) => { onChange: onActionFilterChange, allowNull: true, }), + () => Select({ + label: 'Flagged', + value: flaggedFilter.val, + options: FLAGGED_FILTER_OPTIONS, + testId: 'flagged-filter', + style: 'min-width: 140px', + onChange: onFlaggedFilterChange, + allowNull: true, + }), ), // Data table @@ -694,49 +874,31 @@ const TestResults = (/** @type Properties */ props) => { return div( { class: 'tg-tr--detail flex-column fx-gap-4' }, - // Action buttons row (full width, above both columns) + // Action buttons row div( - { class: 'flex-row fx-flex-wrap fx-gap-1 fx-justify-content-flex-end' }, - row.test_definition_id && permissions.val.can_edit - ? Button({ - type: 'stroked', - icon: 'edit', - label: 'Edit Test', - width: 'auto', + { class: 'flex-row fx-gap-2 fx-justify-content-flex-end' }, + ...[ + permissions.val.can_edit ? Button({ + type: 'stroked', icon: 'edit', label: 'Edit', width: 'auto', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('EditTestClicked', { - payload: { test_suite_id: row.test_suite_id, test_definition_id: row.test_definition_id }, - }), - }) - : '', - row.test_scope === 'column' - ? Button({ - type: 'stroked', - icon: 'query_stats', - label: 'Profiling', - width: 'auto', + onclick: () => emit('EditTestClicked', { payload: { test_result_id: row.test_result_id } }), + }) : '', + row.test_definition_id ? Button({ + type: 'stroked', icon: 'sticky_note_2', label: 'Notes', width: 'auto', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('ProfilingClicked', { - payload: { column_names: row.column_names, table_name: row.table_name, table_groups_id: row.table_groups_id }, - }), - }) - : '', - Button({ - type: 'stroked', - icon: 'visibility', - label: 'Source Data', - width: 'auto', - style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('SourceDataClicked', { payload: row.test_result_id }), - }), - Button({ - type: 'stroked', - icon: 'download', - label: 'Issue Report', - width: 'auto', - style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('IssueReportClicked', { payload: row.test_result_id }), - }), + onclick: () => emit('NotesClicked', { payload: { id: row.test_definition_id, table_name: row.table_name, column_name: row.column_names, test_name_short: row.test_name_short } }), + }) : '', + Button({ + type: 'stroked', icon: 'query_stats', label: 'Profiling', width: 'auto', + style: 'background: var(--button-generic-background-color);', + onclick: () => emit('ProfilingClicked', { payload: row.test_result_id }), + }), + Button({ + type: 'stroked', icon: 'visibility', label: 'Source Data', width: 'auto', + style: 'background: var(--button-generic-background-color);', + onclick: () => emit('SourceDataClicked', { payload: row.test_result_id }), + }), + ].filter(Boolean), ), // Two-column content @@ -768,13 +930,13 @@ const TestResults = (/** @type Properties */ props) => { Tab( { label: 'History' }, si.history?.length - ? TestResultsChart({ data: chartDataState }) + ? TestResultsChart({ emit, data: chartDataState }) : div({ class: 'text-caption p-4' }, 'Test history not available.'), ), Tab( { label: 'Test Definition' }, si.test_definition - ? TestDefinitionSummary({ test_definition: testDefState }) + ? TestDefinitionSummary({ emit, test_definition: testDefState }) : div({ class: 'text-caption p-4' }, 'Test definition not available.'), ), ) @@ -798,7 +960,7 @@ stylesheet.replace(` line-height: 1.2; } .tg-tr--detail { - border-top: 1px solid var(--border-color, #dddfe2); + border-top: 1px dashed var(--border-color, #dddfe2); padding-top: 16px; } .tg-tr--detail-title { @@ -842,8 +1004,6 @@ export { TestResults }; export default (component) => { let { data, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -851,6 +1011,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, TestResults(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/test_results_chart.js b/testgen/ui/components/frontend/js/pages/test_results_chart.js index 1d75b6af..9327a793 100644 --- a/testgen/ui/components/frontend/js/pages/test_results_chart.js +++ b/testgen/ui/components/frontend/js/pages/test_results_chart.js @@ -1,6 +1,5 @@ import van from '/app/static/js/van.min.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { getValue, isEqual, loadStylesheet, parseDate } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet, parseDate } from '/app/static/js/utils.js'; import { ChartCanvas } from '/app/static/js/components/chart_canvas.js'; import { MonitoringSparklineChart, MonitoringSparklineMarkers } from '/app/static/js/components/monitoring_sparkline.js'; import { ThresholdChart } from '/app/static/js/components/threshold_chart.js'; @@ -21,6 +20,7 @@ const staleColorByStatus = { }; const TestResultsChart = (/** @type Properties */ props) => { + const { emit } = props; loadStylesheet('testResultsChart', stylesheet); const width = van.state(0); @@ -319,8 +319,6 @@ export { TestResultsChart }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -328,6 +326,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, TestResultsChart(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/test_runs.js b/testgen/ui/components/frontend/js/pages/test_runs.js index e5a84e93..22c38bbb 100644 --- a/testgen/ui/components/frontend/js/pages/test_runs.js +++ b/testgen/ui/components/frontend/js/pages/test_runs.js @@ -50,8 +50,7 @@ import { withTooltip } from '/app/static/js/components/tooltip.js'; import { SummaryBar } from '/app/static/js/components/summary_bar.js'; import { Link } from '/app/static/js/components/link.js'; import { Button } from '/app/static/js/components/button.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { emitEvent, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; import { formatTimestamp, formatDuration, DISABLED_ACTION_TEXT } from '/app/static/js/display_utils.js'; import { Checkbox } from '/app/static/js/components/checkbox.js'; import { Select } from '/app/static/js/components/select.js'; @@ -76,6 +75,7 @@ const progressStatusIcons = { }; const TestRuns = (/** @type Properties */ props) => { + const { emit } = props; loadStylesheet('testRuns', stylesheet); const columns = ['5%', '28%', '17%', '40%', '10%']; @@ -87,12 +87,14 @@ const TestRuns = (/** @type Properties */ props) => { return getValue(props.test_runs); }); let refreshIntervalId = null; + let runTestsNode = null; + const runTestsResult = van.state(null); const paginatedRuns = van.derive(() => { const paginated = testRuns.val.slice(PAGE_SIZE * pageIndex.val, PAGE_SIZE * (pageIndex.val + 1)); const hasActiveRuns = paginated.some(({ status }) => status === 'Running'); if (!refreshIntervalId && hasActiveRuns) { - refreshIntervalId = setInterval(() => emitEvent('RefreshData', {}), REFRESH_INTERVAL); + refreshIntervalId = setInterval(() => emit('RefreshData', {}), REFRESH_INTERVAL); } else if (refreshIntervalId && !hasActiveRuns) { clearInterval(refreshIntervalId); } @@ -117,7 +119,7 @@ const TestRuns = (/** @type Properties */ props) => { const closeDeleteDialog = () => { deleteDialogOpen.val = false; deleteConstraintChecked.val = false; - emitEvent('DeleteDialogClosed', {}); + emit('DeleteDialogClosed', {}); }; const scheduleDialogOpen = van.state(false); @@ -161,7 +163,7 @@ const TestRuns = (/** @type Properties */ props) => { disabled: !someRunSelected, width: 'auto', onclick: () => { - emitEvent('DeleteRunsClicked', { payload: selectedItems.map(r => r.test_run_id) }); + emit('DeleteRunsClicked', { payload: selectedItems.map(r => r.test_run_id) }); }, }), ); @@ -209,10 +211,10 @@ const TestRuns = (/** @type Properties */ props) => { ), ), div( - paginatedRuns.val.map(item => TestRunItem(item, columns, selectedRuns[item.test_run_id], userCanEdit, projectSummary.project_code)), + paginatedRuns.val.map(item => TestRunItem(item, columns, selectedRuns[item.test_run_id], userCanEdit, projectSummary.project_code, emit)), ), ), - Paginator({ + Paginator({ emit, pageIndex, count: testRuns.val.length, pageSize: PAGE_SIZE, @@ -229,7 +231,7 @@ const TestRuns = (/** @type Properties */ props) => { 'No test runs found matching filters', ), ) - : ConditionalEmptyState(projectSummary, userCanEdit); + : ConditionalEmptyState(projectSummary, userCanEdit, emit); }, Dialog( { title: 'Delete Test Runs', open: deleteDialogOpen, onClose: closeDeleteDialog }, @@ -263,13 +265,16 @@ const TestRuns = (/** @type Properties */ props) => { () => { const info = getValue(props.delete_dialog); const hasActiveJob = info?.has_active_job ?? false; + const isDisabled = hasActiveJob && !deleteConstraintChecked.val; return Button({ label: 'Delete', - color: 'warn', - type: 'flat', - disabled: hasActiveJob && !deleteConstraintChecked.val, + color: isDisabled ? 'basic' : 'warn', + type: isDisabled ? 'stroked' : 'flat', + width: 'auto', + style: 'margin-left: auto;', + disabled: isDisabled, onclick: () => { - emitEvent('RunsDeleted', { payload: info?.run_ids ?? [] }); + emit('RunsDeleted', { payload: info?.run_ids ?? [] }); closeDeleteDialog(); }, }); @@ -279,17 +284,18 @@ const TestRuns = (/** @type Properties */ props) => { ), () => { const info = getValue(props.run_tests_dialog); - if (!info) return div(); - return RunTestsDialog({ + if (!info) { runTestsNode = null; runTestsResult.val = null; return div(); } + runTestsResult.val = info.result ?? null; + return (runTestsNode ??= RunTestsDialog({ emit, dialog: { title: info.title ?? 'Run Tests', open: true }, project_code: info.project_code, test_suites: info.test_suites ?? [], default_test_suite_id: info.default_test_suite_id, - result: info.result, - onClose: () => emitEvent('RunTestsDialogClosed', {}), - }); + result: runTestsResult, + onClose: () => emit('RunTestsDialogClosed', {}), + })); }, - ScheduleList({ + ScheduleList({ emit, dialog: van.derive(() => ({ title: getValue(props.schedule_dialog)?.title ?? 'Schedules', open: scheduleDialogOpen, @@ -300,9 +306,9 @@ const TestRuns = (/** @type Properties */ props) => { arg_values: van.derive(() => getValue(props.schedule_dialog)?.arg_values ?? []), sample: van.derive(() => getValue(props.schedule_dialog)?.sample), results: van.derive(() => getValue(props.schedule_dialog)?.results), - onClose: () => emitEvent('ScheduleDialogClosed', {}), + onClose: () => emit('ScheduleDialogClosed', {}), }), - NotificationSettings({ + NotificationSettings({ emit, dialog: van.derive(() => ({ title: getValue(props.notifications_dialog)?.title ?? 'Notifications', open: notificationsDialogOpen, @@ -317,7 +323,7 @@ const TestRuns = (/** @type Properties */ props) => { cde_enabled: van.derive(() => getValue(props.notifications_dialog)?.cde_enabled ?? false), total_enabled: van.derive(() => getValue(props.notifications_dialog)?.total_enabled ?? false), result: van.derive(() => getValue(props.notifications_dialog)?.result), - onClose: () => emitEvent('NotificationsDialogClosed', {}), + onClose: () => emit('NotificationsDialogClosed', {}), }), ); }; @@ -326,6 +332,7 @@ const Toolbar = ( /** @type Properties */ props, /** @type boolean */ userCanEdit, ) => { + const emit = props.emit; return div( { class: 'flex-row fx-align-flex-end fx-justify-space-between mb-4 fx-gap-4 fx-flex-wrap' }, div( @@ -337,7 +344,7 @@ const Toolbar = ( allowNull: true, style: 'font-size: 14px;', testId: 'table-group-filter', - onChange: (value) => emitEvent('FilterApplied', { payload: { table_group_id: value } }), + onChange: (value) => emit('FilterApplied', { payload: { table_group_id: value } }), }), () => Select({ label: 'Test Suite', @@ -346,7 +353,7 @@ const Toolbar = ( allowNull: true, style: 'font-size: 14px;', testId: 'test-suite-filter', - onChange: (value) => emitEvent('FilterApplied', { payload: { test_suite_id: value } }), + onChange: (value) => emit('FilterApplied', { payload: { test_suite_id: value } }), }), ), div( @@ -359,7 +366,7 @@ const Toolbar = ( tooltipPosition: 'bottom', width: 'fit-content', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('RunNotificationsClicked', {}), + onclick: () => emit('RunNotificationsClicked', {}), }), Button({ icon: 'today', @@ -369,7 +376,7 @@ const Toolbar = ( tooltipPosition: 'bottom', width: 'fit-content', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('RunSchedulesClicked', {}), + onclick: () => emit('RunSchedulesClicked', {}), }), userCanEdit ? Button({ @@ -378,7 +385,7 @@ const Toolbar = ( label: 'Run Tests', width: 'fit-content', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('RunTestsClicked', {}), + onclick: () => emit('RunTestsClicked', {}), }) : '', Button({ @@ -387,7 +394,7 @@ const Toolbar = ( tooltip: 'Refresh test runs list', tooltipPosition: 'left', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('RefreshData', {}), + onclick: () => emit('RefreshData', {}), testId: 'test-runs-refresh', }), ), @@ -400,6 +407,7 @@ const TestRunItem = ( /** @type boolean */ selected, /** @type boolean */ userCanEdit, /** @type string */ projectCode, + emit, ) => { const runningStep = item.progress?.find((item) => item.status === 'Running'); @@ -417,7 +425,7 @@ const TestRunItem = ( : '', div( { style: `flex: ${columns[1]}` }, - Link({ + Link({ emit, label: formatTimestamp(item.test_starttime), href: 'test-runs:results', params: { 'run_id': item.test_run_id, 'project_code': projectCode }, @@ -438,7 +446,7 @@ const TestRunItem = ( label: 'Cancel', style: 'width: 64px; height: 28px; color: var(--purple); margin-left: 12px;', onclick: () => { - emitEvent('RunCanceled', { payload: item }); + emit('RunCanceled', { payload: item }); }, }) : null, ), @@ -541,6 +549,7 @@ const ProgressTooltip = (/** @type TestRun */ item) => { const ConditionalEmptyState = ( /** @type ProjectSummary */ projectSummary, /** @type boolean */ userCanEdit, + emit, ) => { let args = { message: EMPTY_STATE_MESSAGE.testExecution, @@ -554,7 +563,7 @@ const ConditionalEmptyState = ( disabled: !userCanEdit, tooltip: userCanEdit ? null : DISABLED_ACTION_TEXT, tooltipPosition: 'bottom', - onclick: () => emitEvent('RunTestsClicked', {}), + onclick: () => emit('RunTestsClicked', {}), }), }; @@ -587,7 +596,7 @@ const ConditionalEmptyState = ( }; } - return EmptyState({ + return EmptyState({ emit, icon: 'labs', label: 'No test runs yet', ...args, @@ -606,8 +615,6 @@ export { TestRuns }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -615,6 +622,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, TestRuns(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/pages/test_suites.js b/testgen/ui/components/frontend/js/pages/test_suites.js index e4f91277..db8b6f6a 100644 --- a/testgen/ui/components/frontend/js/pages/test_suites.js +++ b/testgen/ui/components/frontend/js/pages/test_suites.js @@ -19,8 +19,7 @@ * @property {object?} notifications_dialog */ import van from '/app/static/js/van.min.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; -import { emitEvent, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; import { formatTimestamp, DISABLED_ACTION_TEXT } from '/app/static/js/display_utils.js'; import { Select } from '/app/static/js/components/select.js'; import { Button } from '/app/static/js/components/button.js'; @@ -36,12 +35,14 @@ import { ScheduleList } from '/app/static/js/components/schedule_list.js'; import { NotificationSettings } from '/app/static/js/components/notification_settings.js'; import { Alert } from '/app/static/js/components/alert.js'; import { Toggle } from '/app/static/js/components/toggle.js'; +import { Checkbox } from '/app/static/js/components/checkbox.js'; import { Input } from '/app/static/js/components/input.js'; import { ExpanderToggle } from '/app/static/js/components/expander_toggle.js'; const { b, div, h4, pre, small, span, i } = van.tags; const TestSuites = (/** @type Properties */ props) => { + const { emit } = props; loadStylesheet('testsuites', stylesheet); const userCanEdit = getValue(props.permissions).can_edit; @@ -56,12 +57,14 @@ const TestSuites = (/** @type Properties */ props) => { const closeDeleteDialog = () => { deleteDialogOpen.val = false; confirmCascadeDelete.val = false; - emitEvent('DeleteDialogDismissed', {}); + emit('DeleteDialogDismissed', {}); }; // Observability export dialog state (pure JS, no Python round-trip needed) const exportDialogOpen = van.state(false); const exportTestSuite = van.state(null); + let runTestsNode = null; + const runTestsResult = van.state(null); // Add/Edit test suite form dialog state (driven by Python prop) const formDialogInfo = van.derive(() => getValue(props.form_dialog) ?? null); @@ -97,7 +100,7 @@ const TestSuites = (/** @type Properties */ props) => { const closeFormDialog = () => { formDialogOpen.val = false; - emitEvent('FormDialogClosed', {}); + emit('FormDialogClosed', {}); }; const scheduleDialogOpen = van.state(false); @@ -106,17 +109,8 @@ const TestSuites = (/** @type Properties */ props) => { const notificationsDialogOpen = van.state(false); van.derive(() => { if (getValue(props.notifications_dialog)?.open === true) notificationsDialogOpen.val = true; }); - // Page-level result message (replaces st.toast) - const pageResult = van.derive(() => getValue(props.page_result) ?? null); - return div( { id: wrapperId, 'data-testid': 'test-suites', style: 'overflow-y: auto;' }, - () => { - const result = pageResult.val; - return result - ? Alert({ type: result.success ? 'success' : 'error', style: 'margin-bottom: 16px;' }, result.message) - : ''; - }, () => { const projectSummary = getValue(props.project_summary); return projectSummary.test_suite_count > 0 @@ -130,7 +124,7 @@ const TestSuites = (/** @type Properties */ props) => { van.derive(() => { if (selectedTableGroup.val !== initialTableGroup || testSuiteNameFilter.val !== initialTestSuiteName) { - emitEvent('FilterApplied', { payload: { table_group_id: selectedTableGroup.val, test_suite_name: testSuiteNameFilter.val } }); + emit('FilterApplied', { payload: { table_group_id: selectedTableGroup.val, test_suite_name: testSuiteNameFilter.val } }); } }); @@ -168,7 +162,7 @@ const TestSuites = (/** @type Properties */ props) => { tooltipPosition: 'bottom', width: 'fit-content', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('RunNotificationsClicked', {}), + onclick: () => emit('RunNotificationsClicked', {}), }), Button({ icon: 'today', @@ -178,7 +172,7 @@ const TestSuites = (/** @type Properties */ props) => { tooltipPosition: 'bottom', width: 'fit-content', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('RunSchedulesClicked', {}), + onclick: () => emit('RunSchedulesClicked', {}), }), userCanEdit ? Button({ @@ -187,7 +181,7 @@ const TestSuites = (/** @type Properties */ props) => { label: 'Add Test Suite', width: 'fit-content', style: 'background: var(--button-generic-background-color);', - onclick: () => emitEvent('AddTestSuiteClicked', {}), + onclick: () => emit('AddTestSuiteClicked', {}), }) : '', ), @@ -227,14 +221,14 @@ const TestSuites = (/** @type Properties */ props) => { type: 'icon', icon: 'edit', tooltip: 'Edit test suite', - onclick: () => emitEvent('EditActionClicked', {payload: testSuite.id}), + onclick: () => emit('EditActionClicked', {payload: testSuite.id}), }), Button({ type: 'icon', icon: 'delete', tooltip: 'Delete test suite', tooltipPosition: 'left', - onclick: () => emitEvent('DeleteActionClicked', {payload: testSuite.id}), + onclick: () => emit('DeleteActionClicked', {payload: testSuite.id}), }), ] : '' @@ -243,7 +237,7 @@ const TestSuites = (/** @type Properties */ props) => { { class: 'flex-row fx-justify-space-between fx-flex-align-content' }, div( { class: 'flex-column' }, - Link({ + Link({ emit, href: 'test-suites:definitions', params: { test_suite_id: testSuite.id, project_code: projectSummary.project_code }, label: `View ${testSuite.test_ct ?? 0} test definitions`, @@ -259,7 +253,7 @@ const TestSuites = (/** @type Properties */ props) => { Caption({ content: 'Latest Run', style: 'margin-bottom: 2px;' }), testSuite.latest_run_start ? [ - Link({ + Link({ emit, href: 'test-runs:results', params: { run_id: testSuite.latest_run_id, project_code: projectSummary.project_code }, label: formatTimestamp(testSuite.latest_run_start), @@ -290,7 +284,7 @@ const TestSuites = (/** @type Properties */ props) => { type: 'stroked', style: 'min-width: 180px;', disabled: !parseInt(testSuite.test_ct), - onclick: () => emitEvent('RunTestsClicked', {payload: testSuite.id}), + onclick: () => emit('RunTestsClicked', {payload: testSuite.id}), }), Button({ label: parseInt(testSuite.test_ct) ? 'Regenerate Tests' : 'Generate Tests', @@ -298,7 +292,7 @@ const TestSuites = (/** @type Properties */ props) => { type: 'stroked', style: 'margin-top: 16px; min-width: 180px;', disabled: !testSuite.last_complete_profile_run_id, - onclick: () => emitEvent('GenerateTestsClicked', {payload: testSuite.id}), + onclick: () => emit('GenerateTestsClicked', {payload: testSuite.id}), }), ] : '' @@ -311,7 +305,7 @@ const TestSuites = (/** @type Properties */ props) => { 'No test suites found matching filters', ), ) - : ConditionalEmptyState(projectSummary, userCanEdit); + : ConditionalEmptyState(projectSummary, userCanEdit, emit); }, // Delete test suite dialog (driven by Python prop for is_in_use data) () => { @@ -346,10 +340,11 @@ const TestSuites = (/** @type Properties */ props) => { type: deleteDisabled.val ? 'stroked' : 'flat', color: deleteDisabled.val ? 'basic' : 'warn', label: 'Delete', - style: 'width: auto;', + width: 'auto', + style: 'margin-left: auto;', disabled: deleteDisabled, onclick: () => { - emitEvent('DeleteTestSuiteConfirmed', { payload: info.test_suite_id }); + emit('DeleteTestSuiteConfirmed', { payload: info.test_suite_id }); closeDeleteDialog(); }, }), @@ -420,13 +415,13 @@ const TestSuites = (/** @type Properties */ props) => { ), div( { class: 'flex-row fx-gap-4' }, - Toggle({ + Checkbox({ name: 'export-to-observability', label: 'Export to Observability', checked: formState.exportToObservability, onChange: (value) => { formState.exportToObservability.val = value; }, }), - Toggle({ + Checkbox({ name: 'dq-score-exclude', label: 'Exclude from quality scoring', checked: formState.dqScoreExclude, @@ -471,12 +466,12 @@ const TestSuites = (/** @type Properties */ props) => { div( { class: 'flex-row fx-justify-content-flex-end' }, Button({ - type: 'stroked', + type: 'flat', color: 'primary', label: isEdit ? 'Save' : 'Add', width: 'auto', style: 'width: auto;', - onclick: () => emitEvent('SaveTestSuiteForm', { + onclick: () => emit('SaveTestSuiteForm', { payload: { mode: info.mode, test_suite_id: info.test_suite_id ?? null, @@ -526,7 +521,7 @@ const TestSuites = (/** @type Properties */ props) => { label: 'Start', style: 'width: auto;', onclick: () => { - emitEvent('ExportActionClicked', { payload: ts.id }); + emit('ExportActionClicked', { payload: ts.id }); exportDialogOpen.val = false; }, }), @@ -536,32 +531,59 @@ const TestSuites = (/** @type Properties */ props) => { }, () => { const info = getValue(props.run_tests_dialog); - if (!info) return div(); - return RunTestsDialog({ + if (!info) { runTestsNode = null; runTestsResult.val = null; return div(); } + runTestsResult.val = info.result ?? null; + return (runTestsNode ??= RunTestsDialog({ emit, dialog: { title: info.title ?? 'Run Tests', open: true }, project_code: info.project_code, test_suites: info.test_suites ?? [], default_test_suite_id: info.default_test_suite_id, - result: info.result, - onClose: () => emitEvent('RunTestsDialogClosed', {}), - }); + result: runTestsResult, + onClose: () => emit('RunTestsDialogClosed', {}), + })); }, - () => { - const info = getValue(props.generate_tests_dialog); - if (!info) return div(); - return GenerateTestsDialog({ - dialog: { title: info.title ?? 'Generate Tests', open: true }, - test_suite_id: info.test_suite_id, - test_suite_name: info.test_suite_name, - generation_sets: info.generation_sets ?? [], - default_generation_set: info.default_generation_set, - refresh_warning: info.refresh_warning, - lock_result: info.lock_result, - result: info.result, - onClose: () => emitEvent('GenerateTestsDialogClosed', {}), - }); - }, - ScheduleList({ + // Cache the dialog element so Streamlit reruns don't recreate it + // and reset user selections (e.g. generation set dropdown). + (() => { + let _dialog = null; + let _dialogId = null; + const _dialogProps = { + refresh_warning: van.state(null), + lock_result: van.state(null), + result: van.state(null), + }; + return () => { + const info = getValue(props.generate_tests_dialog); + if (!info) { _dialog = null; _dialogId = null; return div(); } + + // Rebuild only when the dialog is for a different test suite + if (!_dialog || _dialogId !== info.test_suite_id) { + _dialogId = info.test_suite_id; + _dialogProps.refresh_warning.val = info.refresh_warning; + _dialogProps.lock_result.val = info.lock_result; + _dialogProps.result.val = info.result; + _dialog = GenerateTestsDialog({ emit, + dialog: { title: info.title ?? 'Generate Tests', open: true }, + test_suite_id: info.test_suite_id, + test_suite_name: info.test_suite_name, + generation_sets: info.generation_sets ?? [], + default_generation_set: info.default_generation_set, + refresh_warning: _dialogProps.refresh_warning, + lock_result: _dialogProps.lock_result, + result: _dialogProps.result, + onClose: () => emit('GenerateTestsDialogClosed', {}), + }); + } else { + // Update dynamic props without recreating the dialog + _dialogProps.refresh_warning.val = info.refresh_warning; + _dialogProps.lock_result.val = info.lock_result; + _dialogProps.result.val = info.result; + } + + return _dialog; + }; + })(), + ScheduleList({ emit, dialog: van.derive(() => ({ title: getValue(props.schedule_dialog)?.title ?? 'Schedules', open: scheduleDialogOpen, @@ -572,9 +594,9 @@ const TestSuites = (/** @type Properties */ props) => { arg_values: van.derive(() => getValue(props.schedule_dialog)?.arg_values ?? []), sample: van.derive(() => getValue(props.schedule_dialog)?.sample), results: van.derive(() => getValue(props.schedule_dialog)?.results), - onClose: () => emitEvent('ScheduleDialogClosed', {}), + onClose: () => emit('ScheduleDialogClosed', {}), }), - NotificationSettings({ + NotificationSettings({ emit, dialog: van.derive(() => ({ title: getValue(props.notifications_dialog)?.title ?? 'Notifications', open: notificationsDialogOpen, @@ -589,7 +611,7 @@ const TestSuites = (/** @type Properties */ props) => { cde_enabled: van.derive(() => getValue(props.notifications_dialog)?.cde_enabled ?? false), total_enabled: van.derive(() => getValue(props.notifications_dialog)?.total_enabled ?? false), result: van.derive(() => getValue(props.notifications_dialog)?.result), - onClose: () => emitEvent('NotificationsDialogClosed', {}), + onClose: () => emit('NotificationsDialogClosed', {}), }), ); }; @@ -597,6 +619,7 @@ const TestSuites = (/** @type Properties */ props) => { const ConditionalEmptyState = ( /** @type ProjectSummary */ projectSummary, /** @type boolean */ userCanEdit, + emit, ) => { let args = { message: EMPTY_STATE_MESSAGE.testSuite, @@ -610,7 +633,7 @@ const ConditionalEmptyState = ( disabled: !userCanEdit, tooltip: userCanEdit ? null : DISABLED_ACTION_TEXT, tooltipPosition: 'bottom', - onclick: () => emitEvent('AddTestSuiteClicked', {}), + onclick: () => emit('AddTestSuiteClicked', {}), }), }; @@ -634,7 +657,7 @@ const ConditionalEmptyState = ( }; } - return EmptyState({ + return EmptyState({ emit, icon: 'rule', label: 'No test suites yet', ...args, @@ -676,8 +699,6 @@ export { TestSuites }; export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -685,6 +706,7 @@ export default (component) => { componentState[key] = van.state(value); } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, TestSuites(componentState)); } else { for (const [key, value] of Object.entries(data)) { diff --git a/testgen/ui/components/frontend/js/shared/application_logs_dialog.js b/testgen/ui/components/frontend/js/shared/application_logs_dialog.js index c3fb88b0..b353dafd 100644 --- a/testgen/ui/components/frontend/js/shared/application_logs_dialog.js +++ b/testgen/ui/components/frontend/js/shared/application_logs_dialog.js @@ -88,16 +88,18 @@ const ApplicationLogsDialog = (props) => { oninput: (e) => { filterText.val = e.target.value; }, }), ), - Button({ - label: 'Refresh', - type: 'stroked', - onclick: () => props.onRefresh?.(), - }), + div( + Button({ + label: 'Refresh', + type: 'stroked', + onclick: () => props.onRefresh?.(), + }), + ), ), small({ class: 'text-caption' }, () => `Log File: ${d.log_file_name || 'N/A'}`), pre({ class: 'tg-logs--content' }, () => filteredContent.val || 'No log data available.'), div( - { class: 'flex-row fx-justify-flex-end' }, + { style: 'margin-left: auto' }, Button({ label: 'Download', icon: 'download', @@ -117,11 +119,11 @@ stylesheet.replace(` border: 1px solid var(--border-color); border-radius: 4px; font-size: 14px; - background: var(--surface-color, #fff); + background: var(--dk-card-background); color: var(--primary-text-color); } .tg-logs--content { - background: var(--surface-variant-color, #f5f5f5); + background: var(--app-background-color); border: 1px solid var(--border-color); border-radius: 4px; padding: 12px; diff --git a/testgen/ui/components/frontend/js/shared/column_history_dialog.js b/testgen/ui/components/frontend/js/shared/column_history_dialog.js index 33ee7466..85700994 100644 --- a/testgen/ui/components/frontend/js/shared/column_history_dialog.js +++ b/testgen/ui/components/frontend/js/shared/column_history_dialog.js @@ -13,6 +13,7 @@ import { ColumnProfilingHistory } from '../data_profiling/column_profiling_histo * @param {function} props.onRunSelected - called when a profiling run is selected */ const ColumnHistoryDialog = (props) => { + const emit = props.emit; const open = van.state(false); const data = van.state(null); @@ -36,13 +37,11 @@ const ColumnHistoryDialog = (props) => { return Dialog( { title, open, onClose, width: '60rem' }, - () => data.val - ? ColumnProfilingHistory({ - profiling_runs: profilingRuns, - selected_item: selectedItem, - onRunSelected: props.onRunSelected, - }) - : '', + ColumnProfilingHistory({ emit, + profiling_runs: profilingRuns, + selected_item: selectedItem, + onRunSelected: props.onRunSelected, + }), ); }; diff --git a/testgen/ui/components/frontend/js/shared/data_preview_dialog.js b/testgen/ui/components/frontend/js/shared/data_preview_dialog.js index 70c08be4..c96ff423 100644 --- a/testgen/ui/components/frontend/js/shared/data_preview_dialog.js +++ b/testgen/ui/components/frontend/js/shared/data_preview_dialog.js @@ -3,7 +3,7 @@ import { getValue, loadStylesheet } from '/app/static/js/utils.js'; import { Dialog } from '/app/static/js/components/dialog.js'; import { Table } from '/app/static/js/components/table.js'; -const { div } = van.tags; +const { div, span } = van.tags; /** * Shared dialog for displaying a data preview from a target database. @@ -14,6 +14,7 @@ const { div } = van.tags; * @param {function} props.onClose - called when dialog is closed */ const DataPreviewDialog = (props) => { + const emit = props.emit; loadStylesheet('data-preview-dialog', stylesheet); const open = van.state(false); const data = van.state(null); @@ -45,15 +46,24 @@ const DataPreviewDialog = (props) => { } if (d.rows?.length) { - const columns = d.columns.map(name => ({ name, label: name, overflow: 'hidden' })); + const columns = d.columns.map(name => ({ name, label: name, overflow: 'hidden', align: 'left' })); const tableRows = van.state(d.rows.map(row => { const obj = {}; - d.columns.forEach((col, i) => { obj[col] = row[i] ?? ''; }); + d.columns.forEach((col, i) => { + const v = row[i]; + if (v === null || v === undefined) { + obj[col] = span({ class: 'tg-dp--null' }, 'NULL'); + } else if (v === '') { + obj[col] = span({ class: 'tg-dp--empty' }, 'EMPTY'); + } else { + obj[col] = v; + } + }); return obj; })); return div( { style: 'margin-bottom: 12px' }, - Table({ columns, highDensity: true, height: '500px' }, tableRows), + Table({ emit, columns, highDensity: true, uppercaseHeader: false, height: '500px' }, tableRows), ); } @@ -78,6 +88,11 @@ stylesheet.replace(` color: var(--red, #c62828); font-size: 14px; } +.tg-dp--null, +.tg-dp--empty { + color: var(--disabled-text-color); + font-style: italic; +} `); export { DataPreviewDialog }; diff --git a/testgen/ui/components/frontend/js/shared/profiling_results_dialog.js b/testgen/ui/components/frontend/js/shared/profiling_results_dialog.js index b2dc2d05..6f8d9140 100644 --- a/testgen/ui/components/frontend/js/shared/profiling_results_dialog.js +++ b/testgen/ui/components/frontend/js/shared/profiling_results_dialog.js @@ -13,6 +13,7 @@ import { ColumnProfilingResults } from '../data_profiling/column_profiling_resul * @param {string} [props.testId] */ const ProfilingResultsDialog = (props) => { + const emit = props.emit; const open = van.state(false); const columnData = van.state(null); @@ -31,7 +32,7 @@ const ProfilingResultsDialog = (props) => { return Dialog( { title: 'Column Profiling Results', open, onClose, width: props.width || '52rem', testId: props.testId }, - () => columnJson.val ? ColumnProfilingResults({ column: columnJson }) : '', + () => columnJson.val ? ColumnProfilingResults({ emit, column: columnJson }) : '', ); }; diff --git a/testgen/ui/components/frontend/js/shared/source_data_dialog.js b/testgen/ui/components/frontend/js/shared/source_data_dialog.js index 7be85ae0..7cc609d5 100644 --- a/testgen/ui/components/frontend/js/shared/source_data_dialog.js +++ b/testgen/ui/components/frontend/js/shared/source_data_dialog.js @@ -17,6 +17,7 @@ const { div, span, h4, p, small } = van.tags; * @param {string} [props.testId] */ const SourceDataDialog = (props) => { + const emit = props.emit; loadStylesheet('source-data-dialog', stylesheet); const open = van.state(false); const data = van.state(null); @@ -59,7 +60,7 @@ const SourceDataDialog = (props) => { children.push(small({ class: 'text-caption', style: 'text-align: right; display: block; margin-bottom: 4px' }, '* Top 500 records displayed')); } - const columns = d.columns.map(name => ({ name, label: name, overflow: 'hidden' })); + const columns = d.columns.map(name => ({ name, label: name, overflow: 'hidden', align: 'left' })); const tableRows = van.state(d.rows.map(row => { const obj = {}; d.columns.forEach((col, i) => { obj[col] = row[i] ?? ''; }); @@ -68,7 +69,7 @@ const SourceDataDialog = (props) => { children.push( div( { style: 'margin-bottom: 12px' }, - Table({ columns, highDensity: true, height: '300px' }, tableRows), + Table({ emit, columns, highDensity: true, height: 'auto', maxHeight: '300px' }, tableRows), ), ); } else if (!d.message) { diff --git a/testgen/ui/components/frontend/js/types.js b/testgen/ui/components/frontend/js/types.js index 4926c190..28a78001 100644 --- a/testgen/ui/components/frontend/js/types.js +++ b/testgen/ui/components/frontend/js/types.js @@ -1,6 +1,6 @@ /** * @import { MonitorSummary } from '../js/components/monitor_anomalies_summary.js'; - * + * * @typedef FilterOption * @type {object} * @property {string} label diff --git a/testgen/ui/components/frontend/standalone/project_settings/index.js b/testgen/ui/components/frontend/standalone/project_settings/index.js index fa88c954..9a9cb77c 100644 --- a/testgen/ui/components/frontend/standalone/project_settings/index.js +++ b/testgen/ui/components/frontend/standalone/project_settings/index.js @@ -2,13 +2,12 @@ * @import {VanState} from '/app/static/js/van.min.js'; */ import van from '/app/static/js/van.min.js'; -import { Streamlit } from '/app/static/js/streamlit.js'; import { Card } from '/app/static/js/components/card.js'; import { Input } from '/app/static/js/components/input.js'; import { Button } from '/app/static/js/components/button.js'; import { required } from '/app/static/js/form_validators.js'; import { Alert } from '/app/static/js/components/alert.js'; -import { emitEvent, getValue, isEqual } from '/app/static/js/utils.js'; +import { createEmitter, getValue, isEqual } from '/app/static/js/utils.js'; const { div, span } = van.tags; @@ -29,6 +28,7 @@ const { div, span } = van.tags; * @param {Properties} props */ const ProjectSettings = (props) => { + const { emit } = props; const /** @type Properties */ form = { name: van.state(props.name.rawVal ?? ''), observability_api_key: van.state(props.observability_api_key.rawVal ?? ''), @@ -96,7 +96,7 @@ const ProjectSettings = (props) => { label: 'Test Observability Connection', width: 'auto', disabled: testObservabilityDisabled, - onclick: () => emitEvent('TestObservabilityClicked', { + onclick: () => emit('TestObservabilityClicked', { payload: { observability_api_url: form.observability_api_url.rawVal, observability_api_key: form.observability_api_key.rawVal, @@ -128,7 +128,7 @@ const ProjectSettings = (props) => { label: 'Save', width: 'auto', disabled: saveDisabled, - onclick: () => emitEvent('SaveClicked', { + onclick: () => emit('SaveClicked', { payload: Object.fromEntries(Object.entries(form).map(([fieldName, value]) => [fieldName, value.rawVal])) }), }), @@ -139,8 +139,6 @@ const ProjectSettings = (props) => { export default (component) => { const { data, setStateValue, setTriggerValue, parentElement } = component; - Streamlit.enableV2(setTriggerValue); - let componentState = parentElement.state; if (componentState === undefined) { componentState = {}; @@ -149,6 +147,7 @@ export default (component) => { } parentElement.state = componentState; + componentState.emit = createEmitter(setTriggerValue); van.add(parentElement, ProjectSettings(componentState)); } else { for (const [ key, value ] of Object.entries(data)) { @@ -159,7 +158,6 @@ export default (component) => { } return () => { - Streamlit.disableV2(setTriggerValue); parentElement.state = null; }; }; diff --git a/testgen/ui/navigation/router.py b/testgen/ui/navigation/router.py index 9e3a0fea..c53c8759 100644 --- a/testgen/ui/navigation/router.py +++ b/testgen/ui/navigation/router.py @@ -108,6 +108,9 @@ def navigate(self, /, to: str, with_args: dict = {}) -> None: # noqa: B006 if not session.current_page.startswith("quality-dashboard") and not to.startswith("quality-dashboard"): st.cache_data.clear() + if is_different_page: + st.session_state.pop("app_logs:dialog", None) + session.current_page = to st.switch_page(route.streamlit_page) diff --git a/testgen/ui/queries/profiling_queries.py b/testgen/ui/queries/profiling_queries.py index 5efe1383..8ca4e4c0 100644 --- a/testgen/ui/queries/profiling_queries.py +++ b/testgen/ui/queries/profiling_queries.py @@ -626,6 +626,79 @@ def get_profiling_anomalies( return df +def get_profiling_anomalies_by_ids(anomaly_ids: list[str]) -> pd.DataFrame: + """Fetch full profiling anomaly rows by IDs, with all joins needed for source data and PDF reports.""" + query = """ + SELECT + r.table_name, + r.column_name, + r.schema_name, + r.db_data_type, + t.anomaly_name, + t.issue_likelihood, + r.disposition, + null as action, + CASE + WHEN t.issue_likelihood = 'Possible' THEN 'Possible: speculative test that often identifies problems' + WHEN t.issue_likelihood = 'Likely' THEN 'Likely: typically indicates a data problem' + WHEN t.issue_likelihood = 'Definite' THEN 'Definite: indicates a highly-likely data problem' + WHEN t.issue_likelihood = 'Potential PII' + THEN 'Potential PII: may require privacy policies, standards and procedures for access, storage and transmission.' + END AS likelihood_explanation, + CASE + WHEN t.issue_likelihood = 'Potential PII' THEN 4 + WHEN t.issue_likelihood = 'Possible' THEN 3 + WHEN t.issue_likelihood = 'Likely' THEN 2 + WHEN t.issue_likelihood = 'Definite' THEN 1 + END AS likelihood_order, + t.anomaly_description, r.detail, t.detail_redactable, t.suggested_action, + r.anomaly_id, r.table_groups_id::VARCHAR, r.id::VARCHAR, p.profiling_starttime, r.profile_run_id::VARCHAR, + tg.table_groups_name, tg.project_code, + dcc.functional_data_type, + dcc.description as column_description, + COALESCE(dcc.critical_data_element, dtc.critical_data_element) as critical_data_element, + dcc.pii_flag, + COALESCE(dcc.data_source, dtc.data_source, tg.data_source) as data_source, + COALESCE(dcc.source_system, dtc.source_system, tg.source_system) as source_system, + COALESCE(dcc.source_process, dtc.source_process, tg.source_process) as source_process, + COALESCE(dcc.business_domain, dtc.business_domain, tg.business_domain) as business_domain, + COALESCE(dcc.stakeholder_group, dtc.stakeholder_group, tg.stakeholder_group) as stakeholder_group, + COALESCE(dcc.transform_level, dtc.transform_level, tg.transform_level) as transform_level, + COALESCE(dcc.aggregation_level, dtc.aggregation_level) as aggregation_level, + COALESCE(dcc.data_product, dtc.data_product, tg.data_product) as data_product + FROM profile_anomaly_results r + INNER JOIN profile_anomaly_types t + ON r.anomaly_id = t.id + INNER JOIN profiling_runs p + ON r.profile_run_id = p.id + INNER JOIN table_groups tg + ON r.table_groups_id = tg.id + LEFT JOIN data_column_chars dcc + ON (tg.id = dcc.table_groups_id + AND r.schema_name = dcc.schema_name + AND r.table_name = dcc.table_name + AND r.column_name = dcc.column_name) + LEFT JOIN data_table_chars dtc + ON dcc.table_id = dtc.table_id + WHERE r.id = ANY(CAST(:ids AS UUID[])); + """ + df = fetch_df_from_db(query, {"ids": anomaly_ids}) + if not df.empty: + dct_replace = {"Confirmed": "✓", "Dismissed": "✘", "Inactive": "🔇"} + df["action"] = df["disposition"].replace(dct_replace) + return df + + +def get_profiling_anomaly_lookup(anomaly_id: str) -> dict | None: + """Return key fields for a single profiling anomaly (for profiling lookups).""" + query = """ + SELECT r.column_name, r.table_name, r.table_groups_id::VARCHAR + FROM profile_anomaly_results r + WHERE r.id = :id; + """ + return fetch_one_from_db(query, {"id": anomaly_id}) + + @st.cache_data(show_spinner=False) def get_profiling_anomalies_count( profile_run_id: str, diff --git a/testgen/ui/queries/test_result_queries.py b/testgen/ui/queries/test_result_queries.py index 321b3093..109293c2 100644 --- a/testgen/ui/queries/test_result_queries.py +++ b/testgen/ui/queries/test_result_queries.py @@ -168,6 +168,97 @@ def get_test_results( return df +def get_test_results_by_ids(test_result_ids: list[str]) -> pd.DataFrame: + """Fetch full test result rows by IDs, with all joins needed for source data and PDF reports.""" + query = """ + SELECT r.table_name, + p.project_name, ts.test_suite, tg.table_groups_name, cn.connection_name, cn.project_host, cn.sql_flavor, + tt.dq_dimension, tt.test_scope, + r.schema_name, r.column_names, r.test_time::DATE as test_date, r.test_type, tt.id as test_type_id, + tt.test_name_short, tt.test_name_long, r.test_description, tt.measure_uom, tt.measure_uom_description, + c.test_operator, r.threshold_value::NUMERIC(16, 5), r.result_measure::NUMERIC(16, 5), r.result_status, + CASE + WHEN r.result_code = 0 THEN r.disposition + ELSE 'Passed' + END as disposition, + NULL::VARCHAR(1) as action, + r.input_parameters, r.result_message, CASE WHEN result_code = 0 THEN r.severity END as severity, + CASE WHEN r.result_code = 1 THEN 1 ELSE 0 END as passed_ct, + CASE WHEN r.result_code = 0 THEN 1 ELSE 0 END as exception_ct, + CASE + WHEN result_status = 'Warning' THEN 1 + END::INTEGER as warning_ct, + CASE + WHEN result_status = 'Failed' THEN 1 + END::INTEGER as failed_ct, + CASE + WHEN result_status = 'Log' THEN 1 + END::INTEGER as log_ct, + CASE + WHEN result_status = 'Error' THEN 1 + END as execution_error_ct, + p.project_code, r.table_groups_id::VARCHAR, + r.id::VARCHAR as test_result_id, r.test_run_id::VARCHAR, + c.id::VARCHAR as connection_id, r.test_suite_id::VARCHAR, + r.test_definition_id::VARCHAR, + r.auto_gen, + td.flagged, + (SELECT COUNT(*) FROM test_definition_notes tdn WHERE tdn.test_definition_id = td.id) as notes_count, + tt.threshold_description, tt.usage_notes, r.test_time, + dcc.description as column_description, + dcc.column_type as column_type, + COALESCE(dcc.critical_data_element, dtc.critical_data_element) as critical_data_element, + dcc.pii_flag, + COALESCE(dcc.data_source, dtc.data_source, tg.data_source) as data_source, + COALESCE(dcc.source_system, dtc.source_system, tg.source_system) as source_system, + COALESCE(dcc.source_process, dtc.source_process, tg.source_process) as source_process, + COALESCE(dcc.business_domain, dtc.business_domain, tg.business_domain) as business_domain, + COALESCE(dcc.stakeholder_group, dtc.stakeholder_group, tg.stakeholder_group) as stakeholder_group, + COALESCE(dcc.transform_level, dtc.transform_level, tg.transform_level) as transform_level, + COALESCE(dcc.aggregation_level, dtc.aggregation_level) as aggregation_level, + COALESCE(dcc.data_product, dtc.data_product, tg.data_product) as data_product + FROM test_results r + INNER JOIN test_types tt + ON (r.test_type = tt.test_type) + INNER JOIN test_suites ts + ON r.test_suite_id = ts.id + INNER JOIN projects p + ON (ts.project_code = p.project_code) + INNER JOIN table_groups tg + ON (ts.table_groups_id = tg.id) + INNER JOIN connections cn + ON (tg.connection_id = cn.connection_id) + LEFT JOIN cat_test_conditions c + ON (cn.sql_flavor = c.sql_flavor + AND r.test_type = c.test_type) + LEFT JOIN data_column_chars dcc + ON (tg.id = dcc.table_groups_id + AND r.schema_name = dcc.schema_name + AND r.table_name = dcc.table_name + AND r.column_names = dcc.column_name) + LEFT JOIN data_table_chars dtc + ON dcc.table_id = dtc.table_id + LEFT JOIN test_definitions td + ON (r.test_definition_id = td.id) + WHERE r.id = ANY(CAST(:ids AS UUID[])); + """ + df = fetch_df_from_db(query, {"ids": test_result_ids}) + if not df.empty: + df["test_date"] = pd.to_datetime(df["test_date"]) + df["flagged_display"] = df["flagged"].apply(lambda value: "Yes" if value else "No") + return df + + +def get_test_result_lookup(test_result_id: str) -> dict | None: + """Return key fields for a single test result (for profiling/edit lookups).""" + query = """ + SELECT r.column_names, r.table_name, r.table_groups_id::VARCHAR, r.test_definition_id::VARCHAR + FROM test_results r + WHERE r.id = :id; + """ + return fetch_one_from_db(query, {"id": test_result_id}) + + @st.cache_data(show_spinner=False) def get_test_results_count( run_id: str, diff --git a/testgen/ui/scripts/patch_streamlit.py b/testgen/ui/scripts/patch_streamlit.py index 9c6ae6dd..70f47ed3 100644 --- a/testgen/ui/scripts/patch_streamlit.py +++ b/testgen/ui/scripts/patch_streamlit.py @@ -22,6 +22,7 @@ "css/roboto-font-faces.css", "css/material-symbols-rounded.css", "css/highlight-default-theme.min.css", + "css/highlight-dark-theme.min.css", "js/scripts.js", "js/sidebar.js", "js/van.min.js", @@ -207,7 +208,7 @@ def _find_first_package_dir(project_path: Path) -> Path | None: to_replace = """ if not package_root: package_root = pyproject_path.parent""" new_value = """ if not package_root: - if _is_editable_package(dist): + if not (pyproject_path.parent / "__init__.py").exists(): package_root = _find_first_package_dir(pyproject_path.parent) if not package_root: diff --git a/testgen/ui/static/css/highlight-default-theme.min.css b/testgen/ui/static/css/highlight-default-theme.min.css index a75ea911..00348b07 100644 --- a/testgen/ui/static/css/highlight-default-theme.min.css +++ b/testgen/ui/static/css/highlight-default-theme.min.css @@ -6,4 +6,12 @@ Website: https://highlightjs.org/ License: see project LICENSE Touched: 2021 -*/pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#f3f3f3;color:#444}.hljs-comment{color:#697070}.hljs-punctuation,.hljs-tag{color:#444a}.hljs-tag .hljs-attr,.hljs-tag .hljs-name{color:#444}.hljs-attribute,.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-name,.hljs-selector-tag{font-weight:700}.hljs-deletion,.hljs-number,.hljs-quote,.hljs-selector-class,.hljs-selector-id,.hljs-string,.hljs-template-tag,.hljs-type{color:#800}.hljs-section,.hljs-title{color:#800;font-weight:700}.hljs-link,.hljs-operator,.hljs-regexp,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#ab5656}.hljs-literal{color:#695}.hljs-addition,.hljs-built_in,.hljs-bullet,.hljs-code{color:#397300}.hljs-meta{color:#1f7199}.hljs-meta .hljs-string{color:#38a}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700} \ No newline at end of file +*/ + +pre code.hljs{display:block;overflow-x:auto;padding:1em;white-space: pre-wrap;overflow-wrap: anywhere}code.hljs{padding:3px 5px} + +.hljs{color:#2f3337;background:#f6f6f6}.hljs-subst{color:#2f3337}.hljs-comment{color:#656e77}.hljs-attr,.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-section,.hljs-selector-tag{color:#015692}.hljs-attribute{color:#803378}.hljs-name,.hljs-number,.hljs-quote,.hljs-selector-id,.hljs-template-tag,.hljs-type{color:#b75501}.hljs-selector-class{color:#015692}.hljs-link,.hljs-regexp,.hljs-selector-attr,.hljs-string,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#54790d}.hljs-meta,.hljs-selector-pseudo{color:#015692}.hljs-built_in,.hljs-literal,.hljs-title{color:#b75501}.hljs-bullet,.hljs-code{color:#535a60}.hljs-meta .hljs-string{color:#54790d}.hljs-deletion{color:#c02d2e}.hljs-addition{color:#2f6f44}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700} + +@media (prefers-color-scheme: dark) { + .hljs{color:#fff;background:#1c1b1b}.hljs-subst{color:#fff}.hljs-comment{color:#999}.hljs-attr,.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-section,.hljs-selector-tag{color:#88aece}.hljs-attribute{color:#c59bc1}.hljs-name,.hljs-number,.hljs-quote,.hljs-selector-id,.hljs-template-tag,.hljs-type{color:#f08d49}.hljs-selector-class{color:#88aece}.hljs-link,.hljs-regexp,.hljs-selector-attr,.hljs-string,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#b5bd68}.hljs-meta,.hljs-selector-pseudo{color:#88aece}.hljs-built_in,.hljs-literal,.hljs-title{color:#f08d49}.hljs-bullet,.hljs-code{color:#ccc}.hljs-meta .hljs-string{color:#b5bd68}.hljs-deletion{color:#de7176}.hljs-addition{color:#76c490}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700} +} diff --git a/testgen/ui/static/css/shared.css b/testgen/ui/static/css/shared.css index f599a599..9f111b6d 100644 --- a/testgen/ui/static/css/shared.css +++ b/testgen/ui/static/css/shared.css @@ -87,6 +87,7 @@ body { --table-hover-color: #ecf0f1; --table-selection-color: rgba(0,145,234,.28); + --table-header-background: var(--dk-card-background); } @media (prefers-color-scheme: dark) { @@ -132,6 +133,10 @@ body { --select-hover-background: rgb(38, 39, 48); --app-background-color: rgb(14, 17, 23); + + --table-hover-color: rgb(38, 39, 48); + --table-selection-color: rgba(0,145,234,.28); + --table-header-background: var(--dk-card-background); } } @@ -761,6 +766,10 @@ input::-ms-clear { margin-top: 0; } +.display-table-cell { + display: table-cell !important; +} + /* Base Styles - Using standard system fonts for that Material feel */ .display, .headline, .title, .body, .label { margin: 0; diff --git a/testgen/ui/static/js/components/breadcrumbs.js b/testgen/ui/static/js/components/breadcrumbs.js index 04685d5b..5280dd22 100644 --- a/testgen/ui/static/js/components/breadcrumbs.js +++ b/testgen/ui/static/js/components/breadcrumbs.js @@ -4,21 +4,19 @@ * @property {string} path * @property {object} params * @property {string} label - * + * * @typedef Properties * @type {object} * @property {Array.} breadcrumbs * @property {string?} testId */ import van from '../van.min.js'; -import { Streamlit } from '../streamlit.js'; -import { emitEvent, getValue, loadStylesheet } from '../utils.js'; +import { getValue, loadStylesheet } from '../utils.js'; const { a, div, span } = van.tags; const Breadcrumbs = (/** @type Properties */ props) => { loadStylesheet('breadcrumbs', stylesheet); - Streamlit.setFrameHeight(24); const testId = getValue(props.testId) ?? ''; @@ -37,7 +35,7 @@ const Breadcrumbs = (/** @type Properties */ props) => { onclick: (event) => { event.preventDefault(); event.stopPropagation(); - emitEvent('LinkClicked', { href: b.path, params: b.params }); + props.emit('LinkClicked', { href: b.path, params: b.params }); }}, b.label, )); diff --git a/testgen/ui/static/js/components/column_selector_dialog.js b/testgen/ui/static/js/components/column_selector_dialog.js index 281c05f9..1f699aed 100644 --- a/testgen/ui/static/js/components/column_selector_dialog.js +++ b/testgen/ui/static/js/components/column_selector_dialog.js @@ -7,23 +7,24 @@ import van from '/app/static/js/van.min.js'; import { Dialog } from '/app/static/js/components/dialog.js'; import { ColumnSelector } from '/app/static/js/components/explorer_column_selector.js'; -import { emitEvent, getValue } from '/app/static/js/utils.js'; +import { getValue } from '/app/static/js/utils.js'; const { div } = van.tags; const ColumnSelectorDialog = (/** @type Properties */ props) => { + const emit = props.emit; const dialogProp = getValue(props.dialog); const externalOpen = dialogProp?.open; const isVanState = externalOpen != null && typeof externalOpen === 'object' && 'val' in externalOpen; const dialogOpen = isVanState ? externalOpen : van.state(dialogProp?.open === true); if (!isVanState) { - van.derive(() => { if (getValue(props.dialog)?.open === true) dialogOpen.val = true; }); + van.derive(() => { dialogOpen.val = getValue(props.dialog)?.open === true; }); } const handleClose = () => { dialogOpen.val = false; if (typeof props.onClose === 'function') props.onClose(); - else emitEvent('CloseClicked', {}); + else emit('CloseClicked', {}); }; const content = div({ style: 'height: 400px;' }, ColumnSelector(props)); diff --git a/testgen/ui/static/js/components/crontab_input.js b/testgen/ui/static/js/components/crontab_input.js index 2701209b..cd60de89 100644 --- a/testgen/ui/static/js/components/crontab_input.js +++ b/testgen/ui/static/js/components/crontab_input.js @@ -37,6 +37,7 @@ import { Link } from './link.js'; const { div, span } = van.tags; const CrontabInput = (/** @type Options */ props) => { + const emit = props.emit; loadStylesheet('crontab-input', stylesheet); const domId = van.derive(() => props.id?.val ?? `tg-crontab-wrapper-${getRandomId()}`); @@ -96,6 +97,7 @@ const CrontabInput = (/** @type Options */ props) => { hideExpression: props.hideExpression, }, expression, + emit, ), ), ); @@ -106,7 +108,7 @@ const CrontabInput = (/** @type Options */ props) => { * @param {import('../van.min.js').VanState} expr * @returns {HTMLElement} */ -const CrontabEditorPortal = ({sample, ...options}, expr) => { +const CrontabEditorPortal = ({sample, ...options}, expr, emit) => { const mode = van.state(expr.rawVal ? determineMode(expr.rawVal) : 'x_hours'); const xHoursState = { @@ -433,7 +435,7 @@ const CrontabEditorPortal = ({sample, ...options}, expr) => { () => div( {class: `flex-row fx-gap-1 text-caption ${mode.val === 'custom' ? '': 'hidden'}`}, span({}, 'Learn more about'), - Link({ + Link({ emit, open_new: true, label: 'cron expressions', href: 'https://crontab.guru/', diff --git a/testgen/ui/static/js/components/dropdown_button.js b/testgen/ui/static/js/components/dropdown_button.js index cb0ed1a3..e97fdce6 100644 --- a/testgen/ui/static/js/components/dropdown_button.js +++ b/testgen/ui/static/js/components/dropdown_button.js @@ -8,6 +8,7 @@ * @type {object} * @property {string} icon * @property {string} label + * @property {('normal' | 'small')?} buttonSize * @property {DropdownItem[] | (() => DropdownItem[])} items */ import van from '/app/static/js/van.min.js'; @@ -36,6 +37,7 @@ const DropdownButton = (props) => { label: props.label, width: 'fit-content', style: 'background-color: var(--button-generic-background-color);', + size: props.buttonSize, onclick: () => { menuOpen.val = !menuOpen.val; }, }), Portal( @@ -47,6 +49,7 @@ const DropdownButton = (props) => { ...items.map(item => div({ class: 'tg-dropdown-button--item', + style: item.separator ? 'border-top: var(--button-stroked-border);' : '', onclick: () => { menuOpen.val = false; item.onclick(); }, }, item.label), ), diff --git a/testgen/ui/static/js/components/empty_state.js b/testgen/ui/static/js/components/empty_state.js index 842f811d..d5240c7a 100644 --- a/testgen/ui/static/js/components/empty_state.js +++ b/testgen/ui/static/js/components/empty_state.js @@ -66,6 +66,7 @@ const EMPTY_STATE_MESSAGE = { }; const EmptyState = (/** @type Properties */ props) => { + const emit = props.emit; loadStylesheet('empty-state', stylesheet); return Card({ @@ -80,7 +81,7 @@ const EmptyState = (/** @type Properties */ props) => { getValue(props.button) ?? ( getValue(props.link) - ? Link({ + ? Link({ emit, class: 'tg-empty-state--link', right_icon: 'chevron_right', ...(getValue(props.link)), diff --git a/testgen/ui/static/js/components/expander_toggle.js b/testgen/ui/static/js/components/expander_toggle.js index 9062694c..3bad0c83 100644 --- a/testgen/ui/static/js/components/expander_toggle.js +++ b/testgen/ui/static/js/components/expander_toggle.js @@ -10,7 +10,6 @@ * @property {Function?} onCollapse */ import van from '../van.min.js'; -import { Streamlit } from '../streamlit.js'; import { getValue, loadStylesheet } from '../utils.js'; const { div, span, i } = van.tags; @@ -38,7 +37,7 @@ const ExpanderToggle = (/** @type Properties */ props) => { style: () => getValue(props.style) ?? '', onclick: () => { expandedState.val = !expandedState.val; - const handler = (expandedState.val ? props.onExpand : props.onCollapse) ?? Streamlit.sendData; + const handler = expandedState.val ? props.onExpand : props.onCollapse; handler(expandedState.val); } }, diff --git a/testgen/ui/static/js/components/explorer_column_selector.js b/testgen/ui/static/js/components/explorer_column_selector.js index d299337b..d99fce1b 100644 --- a/testgen/ui/static/js/components/explorer_column_selector.js +++ b/testgen/ui/static/js/components/explorer_column_selector.js @@ -20,7 +20,7 @@ * @property {Array} columns */ import van from '../van.min.js'; -import { emitEvent, getValue, isEqual, loadStylesheet, slugify } from '../utils.js'; +import { getValue, isEqual, loadStylesheet, slugify } from '../utils.js'; import { Tree } from './tree.js'; import { Icon } from './icon.js'; import { Button } from './button.js'; @@ -37,6 +37,7 @@ const TRANSLATIONS = { }; const ColumnSelector = (/** @type Properties */ props) => { + const emit = props.emit; loadStylesheet('column-selector', stylesheet); const initialSelection = van.state([]); @@ -62,7 +63,7 @@ const ColumnSelector = (/** @type Properties */ props) => { {class: 'flex-column fx-gap-2 column-selector-wrapper'}, div( {class: 'flex-row column-selector'}, - Tree({ + Tree({ emit, id: 'column-selector-tree', classes: 'column-selector--tree', multiSelect: true, @@ -93,7 +94,7 @@ const ColumnSelector = (/** @type Properties */ props) => { label: 'Apply', width: 'auto', disabled: van.derive(() => !changed.val), - onclick: () => emitEvent('ColumnFiltersUpdated', {payload: selection.val}), + onclick: () => emit('ColumnFiltersUpdated', {payload: selection.val}), }), ) ); diff --git a/testgen/ui/static/js/components/generate_tests_dialog.js b/testgen/ui/static/js/components/generate_tests_dialog.js index dae6c493..54fab2d1 100644 --- a/testgen/ui/static/js/components/generate_tests_dialog.js +++ b/testgen/ui/static/js/components/generate_tests_dialog.js @@ -28,11 +28,12 @@ import { Alert } from '/app/static/js/components/alert.js'; import { Code } from '/app/static/js/components/code.js'; import { ExpanderToggle } from '/app/static/js/components/expander_toggle.js'; import { Select } from '/app/static/js/components/select.js'; -import { emitEvent, getValue, loadStylesheet } from '/app/static/js/utils.js'; +import { getValue, loadStylesheet } from '/app/static/js/utils.js'; const { div, span, strong } = van.tags; const GenerateTestsDialog = (/** @type Properties */ props) => { + const emit = props.emit; loadStylesheet('generate-tests-dialog', stylesheet); const dialogProp = getValue(props.dialog); @@ -46,7 +47,7 @@ const GenerateTestsDialog = (/** @type Properties */ props) => { const handleClose = () => { dialogOpen.val = false; if (typeof props.onClose === 'function') props.onClose(); - else emitEvent('CloseClicked', {}); + else emit('CloseClicked', {}); }; const testSuiteId = getValue(props.test_suite_id); @@ -96,7 +97,7 @@ const GenerateTestsDialog = (/** @type Properties */ props) => { type: 'stroked', label: 'Lock Edited Tests', width: 'auto', - onclick: () => emitEvent('LockEditedTests', {}), + onclick: () => emit('LockEditedTests', {}), }); }, ) @@ -130,7 +131,7 @@ const GenerateTestsDialog = (/** @type Properties */ props) => { color: 'primary', width: 'auto', style: 'width: auto;', - onclick: () => emitEvent('GenerateTestsConfirmed', { + onclick: () => emit('GenerateTestsConfirmed', { payload: { test_suite_id: testSuiteId, generation_set: selectedSet.val, diff --git a/testgen/ui/static/js/components/help_menu.js b/testgen/ui/static/js/components/help_menu.js index 1476cd3a..2a2fd9cb 100644 --- a/testgen/ui/static/js/components/help_menu.js +++ b/testgen/ui/static/js/components/help_menu.js @@ -4,20 +4,20 @@ * @property {string} edition * @property {string} current * @property {string} latest - * + * * @typedef Permissions * @type {object} * @property {boolean} can_edit - * + * * @typedef Properties * @type {object} * @property {string} page_help * @property {string} support_email * @property {Version} version * @property {Permissions} permissions -*/ + */ import van from '../van.min.js'; -import { emitEvent, getRandomId, getValue, loadStylesheet } from '../utils.js'; +import { getRandomId, getValue, loadStylesheet } from '../utils.js'; import { Icon } from './icon.js'; const { a, div, span } = van.tags; @@ -32,21 +32,40 @@ const trainingUrl = 'https://info.datakitchen.io/data-quality-training-and-certi const HelpMenu = (/** @type Properties */ props) => { loadStylesheet('help-menu', stylesheet); + const { emit } = props; const domId = `help-menu-${getRandomId()}`; const version = getValue(props.version) ?? {}; + const HelpLink = ( + /** @type string */ url, + /** @type string */ label, + /** @type string? */ icon, + /** @type string */ classes = 'help-item', + ) => { + return a( + { + class: classes, + href: url, + target: '_blank', + onclick: () => emit('ExternalLinkClicked'), + }, + icon ? Icon({ classes: 'help-item-icon' }, icon) : null, + label, + ); + }; + return div( { id: domId }, div( { class: 'flex-column pt-3' }, - getValue(props.help_topic) + getValue(props.help_topic) ? HelpLink(`${baseHelpUrl}${getValue(props.help_topic)}`, 'Help for this Page', 'description') : null, HelpLink(baseHelpUrl, 'TestGen Help', 'help'), HelpLink(trainingUrl, 'Training Portal', 'school'), getValue(props.permissions)?.can_edit ? div( - { class: 'help-item', onclick: () => emitEvent('AppLogsClicked') }, + { class: 'help-item', onclick: () => emit('AppLogsClicked') }, Icon({ classes: 'help-item-icon' }, 'browse_activity'), 'Application Logs', ) @@ -69,7 +88,7 @@ const HelpMenu = (/** @type Properties */ props) => { version.current ? HelpLink(`${baseHelpUrl}${releaseNotesTopic}`, `${version.edition} ${version.current}`, null, null) : null, - version.latest !== version.current + version.latest !== version.current ? HelpLink( `${baseHelpUrl}${upgradeTopic}`, `New version available! ${version.latest}`, @@ -81,24 +100,6 @@ const HelpMenu = (/** @type Properties */ props) => { : null, ), ); -} - -const HelpLink = ( - /** @type string */ url, - /** @type string */ label, - /** @type string? */ icon, - /** @type string */ classes = 'help-item', -) => { - return a( - { - class: classes, - href: url, - target: '_blank', - onclick: () => emitEvent('ExternalLinkClicked'), - }, - icon ? Icon({ classes: 'help-item-icon' }, icon) : null, - label, - ); }; const stylesheet = new CSSStyleSheet(); diff --git a/testgen/ui/static/js/components/link.js b/testgen/ui/static/js/components/link.js index b33d4a67..630d6d76 100644 --- a/testgen/ui/static/js/components/link.js +++ b/testgen/ui/static/js/components/link.js @@ -20,12 +20,13 @@ * @property {((event: any) => void)?} onClick * @property {string?} testId */ -import { emitEvent, getValue, loadStylesheet } from '../utils.js'; +import { getValue, loadStylesheet } from '../utils.js'; import van from '../van.min.js'; const { a, div, i, span } = van.tags; const Link = (/** @type Properties */ props) => { + const emit = props.emit; loadStylesheet('link', stylesheet); const href = getValue(props.href); @@ -48,7 +49,7 @@ const Link = (/** @type Properties */ props) => { onclick: open_new ? null : (onClick ?? ((event) => { event.preventDefault(); event.stopPropagation(); - emitEvent('LinkClicked', { href, params }); + emit('LinkClicked', { href, params }); })), onmouseenter: props.tooltip ? (() => showTooltip.val = true) : undefined, onmouseleave: props.tooltip ? (() => showTooltip.val = false) : undefined, diff --git a/testgen/ui/static/js/components/monitor_anomalies_summary.js b/testgen/ui/static/js/components/monitor_anomalies_summary.js index 5b53a219..a9162ca0 100644 --- a/testgen/ui/static/js/components/monitor_anomalies_summary.js +++ b/testgen/ui/static/js/components/monitor_anomalies_summary.js @@ -27,7 +27,7 @@ * @property {function(string)?} onTagClick * @property {object?} activeTypes */ -import { emitEvent, getValue, loadStylesheet } from '../utils.js'; +import { getValue, loadStylesheet } from '../utils.js'; import { formatDuration, humanReadableDuration } from '../display_utils.js'; import { withTooltip } from './tooltip.js'; import van from '../van.min.js'; @@ -39,7 +39,7 @@ const { a, div, i, span } = van.tags; * @param {string?} label * @param {SummaryOptions?} options */ -const AnomaliesSummary = (summary, label = 'Anomalies', options = {}) => { +const AnomaliesSummary = (summary, label = 'Anomalies', options = {}, emit) => { loadStylesheet('anomalies-summary', summaryStylesheet); if (!summary.lookback) { @@ -104,7 +104,7 @@ const AnomaliesSummary = (summary, label = 'Anomalies', options = {}) => { onclick: summary.table_group_id ? (event) => { event.preventDefault(); event.stopPropagation(); - emitEvent('LinkClicked', { href: 'monitors', params: {project_code: summary.project_code, table_group_id: summary.table_group_id} }); + emit('LinkClicked', { href: 'monitors', params: {project_code: summary.project_code, table_group_id: summary.table_group_id} }); }: null, }, labelElement, diff --git a/testgen/ui/static/js/components/monitor_settings_form.js b/testgen/ui/static/js/components/monitor_settings_form.js index edd88d7a..8c8da4d8 100644 --- a/testgen/ui/static/js/components/monitor_settings_form.js +++ b/testgen/ui/static/js/components/monitor_settings_form.js @@ -33,7 +33,7 @@ * @property {(sch: Schedule, ts: MonitorSuite, state: FormState) => void} onChange */ import van from '../van.min.js'; -import { getValue, isEqual, loadStylesheet, emitEvent } from '../utils.js'; +import { getValue, isEqual, loadStylesheet } from '../utils.js'; import { Input } from './input.js'; import { RadioGroup } from './radio_group.js'; import { Caption } from './caption.js'; @@ -66,6 +66,7 @@ const predictLookbackConfig = { * @returns */ const MonitorSettingsForm = (props) => { + const emit = props.emit; loadStylesheet('monitor-settings-form', stylesheet); const schedule = getValue(props.schedule) ?? {}; @@ -134,6 +135,7 @@ const MonitorSettingsForm = (props) => { cronTimezone, cronExpression, scheduleActive, + emit, ), PredictionForm( { setValidity: setFieldValidity }, @@ -141,6 +143,7 @@ const MonitorSettingsForm = (props) => { predictMinLookback, predictExcludeWeekends, predictHolidayCodes, + emit, ), ); }; @@ -211,10 +214,11 @@ const ScheduleForm = ( cronTimezone, cronExpression, scheduleActive, + emit, ) => { const cronEditorValue = van.derive(() => { if (cronExpression.val && cronTimezone.val) { - emitEvent('GetCronSample', {payload: {cron_expr: cronExpression.val, tz: cronTimezone.val}}); + emit('GetCronSample', {payload: {cron_expr: cronExpression.val, tz: cronTimezone.val}}); } return { timezone: cronTimezone.val, @@ -236,7 +240,7 @@ const ScheduleForm = ( onChange: (value) => cronTimezone.val = value, portalClass: 'short-select-portal', }), - CrontabInput({ + CrontabInput({ emit, name: 'monitor_settings_schedule', sample: options.cronSample, value: cronEditorValue, @@ -275,6 +279,7 @@ const PredictionForm = ( predictMinLookback, predictExcludeWeekends, predictHolidayCodes, + emit, ) => { const excludeHolidays = van.state(!!predictHolidayCodes.val); return div( @@ -344,7 +349,7 @@ const PredictionForm = ( div( { class: 'flex-row fx-gap-1 mt-1 text-caption' }, span({}, 'See supported'), - Link({ + Link({ emit, open_new: true, label: 'codes', href: 'https://holidays.readthedocs.io/en/latest/#available-countries', diff --git a/testgen/ui/static/js/components/notification_settings.js b/testgen/ui/static/js/components/notification_settings.js index a4f74a08..b3cf9bce 100644 --- a/testgen/ui/static/js/components/notification_settings.js +++ b/testgen/ui/static/js/components/notification_settings.js @@ -40,7 +40,7 @@ import van from '/app/static/js/van.min.js'; import { Button } from '/app/static/js/components/button.js'; import { Dialog } from '/app/static/js/components/dialog.js'; -import { emitEvent, getValue, loadStylesheet } from '/app/static/js/utils.js'; +import { getValue, loadStylesheet } from '/app/static/js/utils.js'; import { ExpansionPanel } from '/app/static/js/components/expansion_panel.js'; import { Select } from '/app/static/js/components/select.js'; import { Alert } from '/app/static/js/components/alert.js'; @@ -54,6 +54,7 @@ import { EmptyState, EMPTY_STATE_MESSAGE } from '/app/static/js/components/empty const { div, span, b } = van.tags; const NotificationSettings = (/** @type Properties */ props) => { + const emit = props.emit; loadStylesheet('notification-settings', stylesheet); const dialogProp = getValue(props.dialog); @@ -67,7 +68,7 @@ const NotificationSettings = (/** @type Properties */ props) => { const handleClose = () => { dialogOpen.val = false; if (typeof props.onClose === 'function') props.onClose(); - else emitEvent('CloseClicked', {}); + else emit('CloseClicked', {}); }; const smtpConfigured = van.derive(() => getValue(props.smtp_configured)); @@ -165,14 +166,14 @@ const NotificationSettings = (/** @type Properties */ props) => { icon: 'pause', tooltip: 'Pause notification', style: 'height: 32px;', - onclick: () => emitEvent('PauseNotification', { payload: item }), + onclick: () => emit('PauseNotification', { payload: item }), }) : Button({ type: 'stroked', icon: 'play_arrow', tooltip: 'Resume notification', style: 'height: 32px;', - onclick: () => emitEvent('ResumeNotification', { payload: item }), + onclick: () => emit('ResumeNotification', { payload: item }), }), Button({ type: 'stroked', @@ -198,15 +199,15 @@ const NotificationSettings = (/** @type Properties */ props) => { tooltip: 'Delete notification', tooltipPosition: 'top-left', style: 'height: 32px;', - onclick: () => emitEvent('DeleteNotification', { payload: item }), + onclick: () => emit('DeleteNotification', { payload: item }), }), ]) : null, ), ), duplicatedMessage ? div( - { class: 'flex-row fx-gap-1 text-caption warning-text' }, - Icon({ size: 12, classes: 'warning-text' }, 'warning'), + { class: 'flex-row fx-gap-1 text-caption text-warning' }, + Icon({ size: 12, classes: 'text-warning' }, 'warning'), span({}, duplicatedMessage), ) : '', @@ -315,7 +316,7 @@ const NotificationSettings = (/** @type Properties */ props) => { type: 'stroked', label: van.derive(() => newNotificationItemForm.isEdit.val ? 'Save Changes' : 'Add Notification'), width: 'auto', - onclick: () => emitEvent( + onclick: () => emit( newNotificationItemForm.isEdit.val ? 'UpdateNotification' : 'AddNotification', { payload: { @@ -383,7 +384,7 @@ const NotificationSettings = (/** @type Properties */ props) => { div({ style: () => smtpConfigured.val ? '' : 'display: none' }, mainContent), () => smtpConfigured.val ? '' - : EmptyState({ + : EmptyState({ emit, label: 'Email server not configured.', message: EMPTY_STATE_MESSAGE.notifications, class: 'notifications--empty', diff --git a/testgen/ui/static/js/components/paginator.js b/testgen/ui/static/js/components/paginator.js index 3e58986d..cd4a4be8 100644 --- a/testgen/ui/static/js/components/paginator.js +++ b/testgen/ui/static/js/components/paginator.js @@ -9,11 +9,12 @@ */ import van from '../van.min.js'; -import { emitEvent, getValue, loadStylesheet } from '../utils.js'; +import { getValue, loadStylesheet } from '../utils.js'; const { div, span, i, button } = van.tags; const Paginator = (/** @type Properties */ props) => { + const emit = props.emit; loadStylesheet('paginator', stylesheet); const { count, pageSize } = props; @@ -76,7 +77,7 @@ const Paginator = (/** @type Properties */ props) => { }; function changePage(/** @type number */ page_index) { - emitEvent('PageChanged', { page_index }) + emit('PageChanged', { page_index }) } const stylesheet = new CSSStyleSheet(); diff --git a/testgen/ui/static/js/components/portal.js b/testgen/ui/static/js/components/portal.js index 45ae27d2..8cefe150 100644 --- a/testgen/ui/static/js/components/portal.js +++ b/testgen/ui/static/js/components/portal.js @@ -140,7 +140,7 @@ function hasFixedAncestor(el) { function hasStreamlitDialogAncestor(el) { let node = el.parentElement; while (node && node !== document.body) { - if (node.classList.contains(STREAMLIT_DIALOG_CLASS)) return true; + if (node.classList.contains(STREAMLIT_DIALOG_CLASS) || node.classList.contains('tg-dialog-overlay')) return true; node = node.parentElement; } return false; diff --git a/testgen/ui/static/js/components/run_profiling_dialog.js b/testgen/ui/static/js/components/run_profiling_dialog.js index aaa69bbd..d1b03f5a 100644 --- a/testgen/ui/static/js/components/run_profiling_dialog.js +++ b/testgen/ui/static/js/components/run_profiling_dialog.js @@ -20,7 +20,7 @@ import { Alert } from '/app/static/js/components/alert.js'; import { Dialog } from '/app/static/js/components/dialog.js'; import { ExpanderToggle } from '/app/static/js/components/expander_toggle.js'; import { Icon } from '/app/static/js/components/icon.js'; -import { emitEvent, getValue, loadStylesheet } from '/app/static/js/utils.js'; +import { getValue, loadStylesheet } from '/app/static/js/utils.js'; import { Code } from '/app/static/js/components/code.js'; import { Button } from '/app/static/js/components/button.js'; import { Select } from '/app/static/js/components/select.js'; @@ -32,6 +32,7 @@ const { div, span, strong } = van.tags; * @param {Properties} props */ const RunProfilingDialog = (props) => { + const emit = props.emit; loadStylesheet('run-profiling', stylesheet); const dialogProp = getValue(props.dialog); @@ -45,7 +46,7 @@ const RunProfilingDialog = (props) => { const handleClose = () => { dialogOpen.val = false; if (typeof props.onClose === 'function') props.onClose(); - else emitEvent('CloseClicked', {}); + else emit('CloseClicked', {}); }; const wrapperId = 'run-profiling-wrapper'; @@ -76,12 +77,16 @@ const RunProfilingDialog = (props) => { ? div( TableGroupStats({ class: 'mt-1 mb-3' }, selectedTableGroup.val), ExpanderToggle({ + default: showCLICommand, collapseLabel: 'Collapse', expandLabel: 'Show CLI command', onCollapse: () => showCLICommand.val = false, onExpand: () => showCLICommand.val = true, }), - Code({ class: () => showCLICommand.val ? '' : 'hidden' }, `testgen run-profile --table-group-id ${selectedTableGroup.val.id}`), + div( + { style: () => showCLICommand.val ? '' : 'display: none' }, + Code({}, `testgen run-profile --table-group-id ${selectedTableGroup.val.id}`), + ), ) : div({ style: 'margin: auto;' }, 'Select a table group to profile.'), () => { @@ -106,7 +111,7 @@ const RunProfilingDialog = (props) => { width: 'auto', style: 'width: auto;', disabled: !selectedTableGroup.val, - onclick: () => emitEvent('RunProfilingConfirmed', { payload: selectedTableGroup.val }), + onclick: () => emit('RunProfilingConfirmed', { payload: selectedTableGroup.val }), }), ) : '', () => getValue(props.result)?.show_link @@ -116,7 +121,7 @@ const RunProfilingDialog = (props) => { label: 'Go to Profiling Runs', style: 'width: auto; margin-left: auto; margin-top: 12px;', icon: 'chevron_right', - onclick: () => emitEvent('GoToProfilingRunsClicked', { payload: selectedTableGroup.val.id }), + onclick: () => emit('GoToProfilingRunsClicked', { payload: selectedTableGroup.val.id }), }) : '', ); @@ -128,7 +133,7 @@ const RunProfilingDialog = (props) => { title: dialogTitle, open: dialogOpen, onClose: handleClose, - width: '32rem', + width: '50rem', }, content, ); diff --git a/testgen/ui/static/js/components/run_tests_dialog.js b/testgen/ui/static/js/components/run_tests_dialog.js index 58477c62..3adb565c 100644 --- a/testgen/ui/static/js/components/run_tests_dialog.js +++ b/testgen/ui/static/js/components/run_tests_dialog.js @@ -26,11 +26,12 @@ import { Code } from '/app/static/js/components/code.js'; import { ExpanderToggle } from '/app/static/js/components/expander_toggle.js'; import { Icon } from '/app/static/js/components/icon.js'; import { Select } from '/app/static/js/components/select.js'; -import { emitEvent, getValue, loadStylesheet } from '/app/static/js/utils.js'; +import { getValue, loadStylesheet } from '/app/static/js/utils.js'; const { div, span, strong } = van.tags; const RunTestsDialog = (/** @type Properties */ props) => { + const emit = props.emit; loadStylesheet('run-tests-dialog', stylesheet); const dialogProp = getValue(props.dialog); @@ -44,7 +45,7 @@ const RunTestsDialog = (/** @type Properties */ props) => { const handleClose = () => { dialogOpen.val = false; if (typeof props.onClose === 'function') props.onClose(); - else emitEvent('CloseClicked', {}); + else emit('CloseClicked', {}); }; const testSuites = getValue(props.test_suites) ?? []; @@ -68,6 +69,7 @@ const RunTestsDialog = (/** @type Properties */ props) => { () => selectedTestSuite.val ? div( ExpanderToggle({ + default: showCLI, expandLabel: 'Show CLI command', collapseLabel: 'Collapse', onExpand: () => showCLI.val = true, @@ -100,7 +102,7 @@ const RunTestsDialog = (/** @type Properties */ props) => { width: 'auto', style: 'width: auto;', disabled: van.derive(() => !selectedTestSuite.val), - onclick: () => emitEvent('RunTestsConfirmed', { + onclick: () => emit('RunTestsConfirmed', { payload: { test_suite_id: selectedTestSuite.val?.value, test_suite_name: selectedTestSuite.val?.label, @@ -116,7 +118,7 @@ const RunTestsDialog = (/** @type Properties */ props) => { label: 'Go to Test Runs', style: 'width: auto; margin-left: auto; margin-top: 12px;', icon: 'chevron_right', - onclick: () => emitEvent('GoToTestRunsClicked', { + onclick: () => emit('GoToTestRunsClicked', { payload: { project_code: getValue(props.project_code), test_suite_id: selectedTestSuite.val?.value, diff --git a/testgen/ui/static/js/components/schedule_list.js b/testgen/ui/static/js/components/schedule_list.js index 10bda38a..ccd75e63 100644 --- a/testgen/ui/static/js/components/schedule_list.js +++ b/testgen/ui/static/js/components/schedule_list.js @@ -32,7 +32,7 @@ import van from '/app/static/js/van.min.js'; import { Button } from '/app/static/js/components/button.js'; import { Dialog } from '/app/static/js/components/dialog.js'; -import { emitEvent, getValue, loadStylesheet } from '/app/static/js/utils.js'; +import { getValue, loadStylesheet } from '/app/static/js/utils.js'; import { withTooltip } from '/app/static/js/components/tooltip.js'; import { ExpansionPanel } from '/app/static/js/components/expansion_panel.js'; import { Select } from '/app/static/js/components/select.js'; @@ -43,6 +43,7 @@ import { Alert } from '/app/static/js/components/alert.js'; const { div, span, i } = van.tags; const ScheduleList = (/** @type Properties */ props) => { + const emit = props.emit; loadStylesheet('schedule-list', stylesheet); const dialogProp = getValue(props.dialog); @@ -56,7 +57,7 @@ const ScheduleList = (/** @type Properties */ props) => { const handleClose = () => { dialogOpen.val = false; if (typeof props.onClose === 'function') props.onClose(); - else emitEvent('CloseClicked', {}); + else emit('CloseClicked', {}); }; const scheduleItems = van.derive(() => getValue(props.items) ?? []); @@ -97,19 +98,19 @@ const ScheduleList = (/** @type Properties */ props) => { onChange: (value) => { newScheduleForm.timezone.val = value; if (newScheduleForm.expression.val && newScheduleForm.timezone.val) { - emitEvent('GetCronSample', {payload: {cron_expr: newScheduleForm.expression.val, tz: newScheduleForm.timezone.val}}); + emit('GetCronSample', {payload: {cron_expr: newScheduleForm.expression.val, tz: newScheduleForm.timezone.val}}); } }, portalClass: 'short-select-portal', }), - CrontabInput({ + CrontabInput({ emit, class: 'fx-flex', sample: props.sample, value: cronEditorValue, onChange: (value) => { newScheduleForm.expression.val = value; if (newScheduleForm.expression.val && newScheduleForm.timezone.val) { - emitEvent('GetCronSample', {payload: {cron_expr: newScheduleForm.expression.val, tz: newScheduleForm.timezone.val}}); + emit('GetCronSample', {payload: {cron_expr: newScheduleForm.expression.val, tz: newScheduleForm.timezone.val}}); } }, }), @@ -120,7 +121,7 @@ const ScheduleList = (/** @type Properties */ props) => { type: 'stroked', label: 'Add Schedule', width: '150px', - onclick: () => emitEvent('AddSchedule', {payload: { + onclick: () => emit('AddSchedule', {payload: { arg_value: newScheduleForm.argValue.val, cron_expr: newScheduleForm.expression.val, cron_tz: newScheduleForm.timezone.val, @@ -169,7 +170,7 @@ const ScheduleList = (/** @type Properties */ props) => { ), () => scheduleItems.val?.length ? div( - scheduleItems.val.map(item => ScheduleListItem(item, columns, getValue(props.permissions))), + scheduleItems.val.map(item => ScheduleListItem(item, columns, getValue(props.permissions), emit)), ) : div({ class: 'mt-5 mb-3 ml-3 text-secondary', style: 'text-align: center;' }, 'No schedules defined yet.'), ), @@ -194,6 +195,7 @@ const ScheduleListItem = ( /** @type Schedule */ item, /** @type string[] */ columns, /** @type Permissions */ permissions, + emit, ) => { return div( { class: 'table-row flex-row' }, @@ -257,21 +259,21 @@ const ScheduleListItem = ( icon: 'pause', tooltip: 'Pause schedule', style: 'height: 32px;', - onclick: () => emitEvent('PauseSchedule', { payload: item }), + onclick: () => emit('PauseSchedule', { payload: item }), }) : Button({ type: 'stroked', icon: 'play_arrow', tooltip: 'Resume schedule', style: 'height: 32px;', - onclick: () => emitEvent('ResumeSchedule', { payload: item }), + onclick: () => emit('ResumeSchedule', { payload: item }), }), Button({ type: 'stroked', icon: 'delete', tooltip: 'Delete schedule', style: 'height: 32px;', - onclick: () => emitEvent('DeleteSchedule', { payload: item }), + onclick: () => emit('DeleteSchedule', { payload: item }), }), ] : null, ), diff --git a/testgen/ui/static/js/components/schema_changes_dialog.js b/testgen/ui/static/js/components/schema_changes_dialog.js index 3dc83214..c580fc77 100644 --- a/testgen/ui/static/js/components/schema_changes_dialog.js +++ b/testgen/ui/static/js/components/schema_changes_dialog.js @@ -9,11 +9,12 @@ import van from '/app/static/js/van.min.js'; import { Dialog } from '/app/static/js/components/dialog.js'; import { SchemaChangesList } from '/app/static/js/components/schema_changes_list.js'; -import { emitEvent, getValue } from '/app/static/js/utils.js'; +import { getValue } from '/app/static/js/utils.js'; const { div } = van.tags; const SchemaChangesDialog = (/** @type Properties */ props) => { + const emit = props.emit; const dialogProp = getValue(props.dialog); const externalOpen = dialogProp?.open; const isVanState = externalOpen != null && typeof externalOpen === 'object' && 'val' in externalOpen; @@ -25,7 +26,7 @@ const SchemaChangesDialog = (/** @type Properties */ props) => { const handleClose = () => { dialogOpen.val = false; if (typeof props.onClose === 'function') props.onClose(); - else emitEvent('CloseClicked', {}); + else emit('CloseClicked', {}); }; const content = div(SchemaChangesList(props)); diff --git a/testgen/ui/static/js/components/score_breakdown.js b/testgen/ui/static/js/components/score_breakdown.js index acd2ffe1..bbadce59 100644 --- a/testgen/ui/static/js/components/score_breakdown.js +++ b/testgen/ui/static/js/components/score_breakdown.js @@ -2,13 +2,13 @@ import van from '../van.min.js'; import { dot } from '../components/dot.js'; import { Caption } from '../components/caption.js'; import { Select } from '../components/select.js'; -import { emitEvent, getValue, loadStylesheet } from '../utils.js'; +import { getValue, loadStylesheet } from '../utils.js'; import { caseInsensitiveSort } from '../display_utils.js'; import { getScoreColor } from '../score_utils.js'; const { div, i, span } = van.tags; -const ScoreBreakdown = (score, breakdown, category, scoreType, onViewDetails) => { +const ScoreBreakdown = (score, breakdown, category, scoreType, onViewDetails, emit) => { loadStylesheet('score-breakdown', stylesheet); return div( @@ -27,7 +27,7 @@ const ScoreBreakdown = (score, breakdown, category, scoreType, onViewDetails) => .sort((A, B) => caseInsensitiveSort(A[1], B[1])) .map(([value, label]) => ({ value, label })), height: 32, - onChange: (value) => emitEvent('CategoryChanged', { payload: value }), + onChange: (value) => emit('CategoryChanged', { payload: value }), testId: 'groupby-selector', }); }, @@ -44,7 +44,7 @@ const ScoreBreakdown = (score, breakdown, category, scoreType, onViewDetails) => value: selectedScoreType, options: scoreTypeOptions.map((s) => ({ label: SCORE_TYPE_LABEL[s], value: s })), height: 32, - onChange: (value) => emitEvent('ScoreTypeChanged', { payload: value }), + onChange: (value) => emit('ScoreTypeChanged', { payload: value }), testId: 'score-type-selector', }); }, @@ -67,7 +67,7 @@ const ScoreBreakdown = (score, breakdown, category, scoreType, onViewDetails) => return div( breakdownValue?.items?.map((row) => div( { class: 'table-row flex-row', 'data-testid': 'score-breakdown-row' }, - columns.map((columnName) => TableCell(row, columnName, scoreValue, categoryValue, scoreTypeValue, onViewDetails)), + columns.map((columnName) => TableCell(row, columnName, scoreValue, categoryValue, scoreTypeValue, onViewDetails, emit)), )), ); }, diff --git a/testgen/ui/static/js/components/score_history.js b/testgen/ui/static/js/components/score_history.js index 93b7b115..c381b2aa 100644 --- a/testgen/ui/static/js/components/score_history.js +++ b/testgen/ui/static/js/components/score_history.js @@ -6,7 +6,7 @@ * @property {string} time */ import van from '../van.min.js'; -import { emitEvent, getValue, loadStylesheet } from '../utils.js'; +import { getValue, loadStylesheet } from '../utils.js'; import { colorMap } from '../display_utils.js'; import { LineChart } from './line_chart.js'; @@ -25,6 +25,7 @@ const TRANSLATIONS = { * @returns {HTMLElment} */ const ScoreHistory = (props, ...entries) => { + const emit = props.emit; loadStylesheet('score-trend', stylesheet); const lineColors = { @@ -61,7 +62,7 @@ const ScoreHistory = (props, ...entries) => { span(Intl.DateTimeFormat("en-US", {dateStyle: 'long', timeStyle: 'long'}).format(Date.parse(point.time))), ); }, - onRefreshClicked: getValue(props.showRefresh) ? () => emitEvent('RecalculateHistory', { payload: getValue(props.score).id }) : undefined, + onRefreshClicked: getValue(props.showRefresh) ? () => emit('RecalculateHistory', { payload: getValue(props.score).id }) : undefined, }, ...entries, ), diff --git a/testgen/ui/static/js/components/score_issues.js b/testgen/ui/static/js/components/score_issues.js index 3f38e61f..7c6e2e63 100644 --- a/testgen/ui/static/js/components/score_issues.js +++ b/testgen/ui/static/js/components/score_issues.js @@ -26,7 +26,7 @@ import { Button } from '../components/button.js'; import { Checkbox } from '../components/checkbox.js'; import { Select } from './select.js'; import { Paginator } from '../components/paginator.js'; -import { emitEvent, loadStylesheet } from '../utils.js'; +import { loadStylesheet } from '../utils.js'; import { colorMap, formatTimestamp, caseInsensitiveSort } from '../display_utils.js'; const { div, i, span } = van.tags; @@ -50,6 +50,7 @@ const IssuesTable = ( /** @type ('table_name' | 'column_name' | 'semantic_data_type' | 'dq_dimension') */ category, /** @type string */ drilldown, /** @type function */ onBack, + emit, ) => { loadStylesheet('score-issues-table', stylesheet); @@ -97,7 +98,7 @@ const IssuesTable = ( `${COLUMN_LABEL[category] ?? '-'}: ${['table_name', 'column_name'].includes(category) ? drilldownParts.slice(1).join(' > ') : drilldown}`, ), category === 'column_name' - ? ColumnProfilingButton(drilldownParts[2], drilldownParts[1], drilldownParts[0]) + ? ColumnProfilingButton(drilldownParts[2], drilldownParts[1], drilldownParts[0], emit) : null, ), ), @@ -119,13 +120,13 @@ const IssuesTable = ( label: 'Issue Reports', width: 'fit-content', style: 'margin-left: auto; background-color: var(--dk-card-background)', - onclick: () => emitEvent('IssueReportsExported', { payload: selectedIssues.val }), + onclick: () => emit('IssueReportsExported', { payload: selectedIssues.val }), disabled: () => !selectedIssues.val.length, tooltip: () => selectedIssues.val.length ? '' : 'No issues selected', }), ), ), - () => Toolbar(filters, issues, category), + () => Toolbar(filters, issues, category, emit), () => displayedIssues.val.length ? div( div( @@ -158,10 +159,10 @@ const IssuesTable = ( }), category === 'column_name' ? span({ class: 'ml-2' }) - : ColumnProfilingButton(row.column, row.table, row.table_group_id), - columns.map((columnName) => TableCell(row, columnName, score.project_code)), + : ColumnProfilingButton(row.column, row.table, row.table_group_id, emit), + columns.map((columnName) => TableCell(row, columnName, score.project_code, emit)), )), - () => Paginator({ + () => Paginator({ emit, pageIndex, count: filteredIssues.val.length, pageSize: PAGE_SIZE, @@ -184,6 +185,7 @@ const ColumnProfilingButton = ( /** @type {string} */ column_name, /** @type {string} */ table_name, /** @type {string} */ table_group_id, + emit, ) => { return Button({ type: 'icon', @@ -192,7 +194,7 @@ const ColumnProfilingButton = ( style: 'color: var(--secondary-text-color);', tooltip: 'View profiling for column', tooltipPosition: 'top-right', - onclick: () => emitEvent('ColumnProfilingClicked', { payload: { column_name, table_name, table_group_id } }), + onclick: () => emit('ColumnProfilingClicked', { payload: { column_name, table_name, table_group_id } }), }); }; @@ -253,13 +255,13 @@ const Toolbar = ( * @param {string} column * @returns {} */ -const TableCell = (row, column, projectCode) => { +const TableCell = (row, column, projectCode, emit) => { const componentByColumn = { column: IssueColumnCell, type: IssueCell, status: StatusCell, detail: DetailCell, - time: (value, row) => TimeCell(value, row, projectCode), + time: (value, row) => TimeCell(value, row, projectCode, emit), }; if (componentByColumn[column]) { @@ -306,13 +308,13 @@ const DetailCell = (value, row) => { ); }; -const TimeCell = (value, row, projectCode) => { +const TimeCell = (value, row, projectCode, emit) => { return div( { class: 'flex-column', style: `flex: 0 0 ${ISSUES_COLUMNS_SIZES.time}` }, row.issue_type === 'test' ? Caption({ content: row.name, style: 'font-size: 12px;' }) : '', - Link({ + Link({ emit, label: formatTimestamp(value), open_new: true, href: row.issue_type === 'test' ? 'test-runs:results' : 'profiling-runs:hygiene', diff --git a/testgen/ui/static/js/components/select.js b/testgen/ui/static/js/components/select.js index cea87c88..b7c21b48 100644 --- a/testgen/ui/static/js/components/select.js +++ b/testgen/ui/static/js/components/select.js @@ -24,6 +24,7 @@ * @property {number?} portalClass * @property {('top' | 'bottom')?} portalPosition * @property {boolean?} filterable + * @property {boolean?} acceptNewOptions * @property {('normal' | 'inline')?} triggerStyle */ import van from '../van.min.js'; @@ -31,7 +32,7 @@ import { getRandomId, getValue, loadStylesheet, isState, isEqual } from '../util import { Portal } from './portal.js'; import { Icon } from './icon.js'; -const { div, i, input, label, span } = van.tags; +const { div, i, input, span } = van.tags; const Select = (/** @type {Properties} */ props) => { loadStylesheet('select', stylesheet); @@ -79,7 +80,10 @@ const Select = (/** @type {Properties} */ props) => { const value = isState(props.value) ? props.value : van.state(props.value ?? null); const initialSelection = options.val?.find((op) => op.value === value.val); - const valueLabel = van.state(initialSelection?.label ?? ''); + const initialCustomLabel = getValue(props.acceptNewOptions) && !initialSelection && typeof value.val === 'string' + ? value.val.replace(/^%|%$/g, '') + : ''; + const valueLabel = van.state(initialSelection?.label ?? initialCustomLabel); const valueIcon = van.state(initialSelection?.icon ?? undefined); const changeSelection = (/** @type SelectOption */ option) => { @@ -91,10 +95,55 @@ const Select = (/** @type {Properties} */ props) => { optionsFilter.val = event.target.value; }; - // Reset filtering when closed + const handleInputKeydown = (/** @type KeyboardEvent */ event) => { + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + const typed = event.target.value.trim(); + if (!typed) { + changeSelection({ value: null, label: '' }); + return; + } + const match = getValue(options).find(op => op.label?.toLowerCase() === typed.toLowerCase()); + if (match) { + changeSelection(match); + } else if (getValue(props.acceptNewOptions)) { + opened.val = false; + valueLabel.val = typed; + props.onChange?.(typed, { isCustom: true, valid: true }); + } + } else if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + opened.val = false; + } + }; + + // Create stable filter input once (not inside reactive closure to preserve focus) + const inputEl = getValue(props.filterable) ? input({ + id: `tg-select--field--${domId.val}`, + value: valueLabel.val, + onkeyup: filterOptions, + onkeydown: handleInputKeydown, + onclick: (event) => { + event.stopPropagation(); + if (!opened.val) { + opened.val = true; + } + }, + }) : null; + + // Focus input when opened, reset filter and input text when closed van.derive(() => { - if (!opened.val) { + if (opened.val) { + if (inputEl) { + setTimeout(() => { inputEl.focus(); inputEl.select(); }, 0); + } + } else { optionsFilter.val = ''; + if (inputEl) { + inputEl.value = valueLabel.val; + } } }); @@ -105,6 +154,20 @@ const Select = (/** @type {Properties} */ props) => { const selectedOption = currentOptions.find((op) => op.value === currentValue); if (selectedOption === undefined) { + if (getValue(props.acceptNewOptions) && currentValue) { + // Custom value (e.g. "%addr%"): keep it, strip % for display + if (!isEqual(currentValue, previousValue)) { + const display = typeof currentValue === 'string' + ? currentValue.replace(/^%|%$/g, '') + : ''; + valueLabel.val = display; + valueIcon.val = undefined; + if (inputEl) { + inputEl.value = display; + } + } + return; + } currentValue = null; setTimeout(() => value.val = null, 0.1); } @@ -112,12 +175,15 @@ const Select = (/** @type {Properties} */ props) => { if (!isEqual(currentValue, previousValue)) { valueLabel.val = selectedOption?.label ?? ''; valueIcon.val = selectedOption?.icon ?? undefined; + if (inputEl) { + inputEl.value = selectedOption?.label ?? ''; + } props.onChange?.(currentValue, { valid: !!currentValue || !getValue(props.required) }); } }); - return label( + return div( { id: domId, class: () => `flex-column fx-gap-1 text-caption tg-select--label ${getValue(props.disabled) ? 'disabled' : ''}`, @@ -156,24 +222,11 @@ const Select = (/** @type {Properties} */ props) => { style: () => getValue(props.height) ? `height: ${getValue(props.height)}px;` : '', 'data-testid': 'select-input', }, - () => { - // Hack to display value again when closed - // For some reason, it goes away when opened - opened.val; - return div( - { class: 'tg-select--field--content', 'data-testid': 'select-input-display' }, - valueIcon.val - ? Icon({ classes: 'mr-2' }, valueIcon.val) - : undefined, - getValue(props.filterable) - ? input({ - id: `tg-select--field--${getRandomId()}`, - value: valueLabel.val, - onkeyup: filterOptions, - }) - : valueLabel.val, - ); - }, + div( + { class: 'tg-select--field--content', 'data-testid': 'select-input-display' }, + () => valueIcon.val ? Icon({ classes: 'mr-2' }, valueIcon.val) : '', + inputEl ?? (() => valueLabel.val), + ), div( { class: 'tg-select--field--icon', 'data-testid': 'select-input-trigger' }, i( @@ -301,7 +354,7 @@ const MultiSelect = (props) => { const isSelected = van.derive(() => (getValue(selectedValues) ?? []).includes(option.value)); return div( { - class: () => `tg-select--option fx-gap-2 ${isSelected.val ? 'selected' : ''}`, + class: () => `tg-select--option fx-gap-2 flex-row ${isSelected.val ? 'selected' : ''}`, onclick: (/** @type Event */ event) => { event.stopPropagation(); toggleOption(option.value); @@ -325,6 +378,7 @@ const stylesheet = new CSSStyleSheet(); stylesheet.replace(` .tg-select--label { position: relative; + cursor: pointer; } .tg-select--label.disabled { cursor: not-allowed; diff --git a/testgen/ui/static/js/components/table.js b/testgen/ui/static/js/components/table.js index 68cab9ba..ac53acc1 100644 --- a/testgen/ui/static/js/components/table.js +++ b/testgen/ui/static/js/components/table.js @@ -49,6 +49,7 @@ * @property {string?} width * @property {boolean?} highDensity * @property {boolean?} dynamicWidth + * @property {boolean?} uppercaseHeader * @property {SortOptions?} sort * @property {PaginatorOptions?} paginator * @property {SelectonOptions?} selection @@ -175,7 +176,7 @@ const Table = (options, rows) => { return div( { - class: () => `tg-table flex-column border border-radius-1 ${getValue(options.highDensity) ? 'tg-table-high-density' : ''} ${getValue(options.dynamicWidth) ? 'tg-table-dynamic-width' : ''} ${options.onRowsSelected ? 'tg-table-hoverable' : ''}`, + class: () => `tg-table flex-column border border-radius-1 ${getValue(options.highDensity) ? 'tg-table-high-density' : ''} ${getValue(options.dynamicWidth) ? 'tg-table-dynamic-width' : ''} ${(getValue(options.uppercaseHeader) ?? true) ? 'tg-table-uppercase-header' : ''} ${options.selection?.onRowsSelected ? 'tg-table-hoverable' : ''}`, style: () => `height: ${getValue(options.height) ? getValue(options.height) : defaultHeight}; ${getValue(options.maxHeight) ? 'max-height: ' + getValue(options.maxHeight) + ';' : ''}`, }, options.header, @@ -238,7 +239,7 @@ const Table = (options, rows) => { class: () => `${selectedRows[idx].val ? 'selected' : ''} ${options.rowClass?.(row, idx) ?? ''}`, onclick: () => onRowSelected(idx), }, - ...getValue(dataColumns).map(column => TableCell(column, row, idx)), + ...getValue(dataColumns).map(column => TableCell(column, row, idx, options.emit)), ) ), ) @@ -483,11 +484,15 @@ stylesheet.replace(` height: 100%; } +.tg-table > .tg-table-scrollable > table > .tg-table-empty-state-body { + height: 100%; +} + .tg-table > .tg-table-scrollable > table > thead { border-bottom: var(--button-stroked-border); position: sticky; top: 0; - background: var(--dk-card-background); /* Ensure header background is solid when sticky */ + background: var(--table-header-background, var(--dk-card-background)); z-index: 1; /* Ensure header is above scrolling content */ } @@ -508,10 +513,13 @@ stylesheet.replace(` .tg-table > .tg-table-scrollable > table > thead th.tg-table-column { padding: 4px 8px; height: 32px; - text-transform: uppercase; position: relative; /* Needed for absolute positioning of resizer */ } +.tg-table.tg-table-uppercase-header > .tg-table-scrollable > table > thead th.tg-table-column { + text-transform: uppercase; +} + .tg-table > .tg-table-scrollable > table > thead th .tg-column-resizer { position: absolute; right: 0; @@ -575,6 +583,7 @@ stylesheet.replace(` .tg-table.tg-table-hoverable > .tg-table-scrollable > table > tbody tr:hover { background-color: var(--table-hover-color); + cursor: pointer; } `); diff --git a/testgen/ui/static/js/components/table_create_script_dialog.js b/testgen/ui/static/js/components/table_create_script_dialog.js index 3c651c2c..b7eda22a 100644 --- a/testgen/ui/static/js/components/table_create_script_dialog.js +++ b/testgen/ui/static/js/components/table_create_script_dialog.js @@ -8,11 +8,12 @@ import van from '/app/static/js/van.min.js'; import { Dialog } from '/app/static/js/components/dialog.js'; import { Code } from '/app/static/js/components/code.js'; -import { emitEvent, getValue } from '/app/static/js/utils.js'; +import { getValue } from '/app/static/js/utils.js'; const { div, span } = van.tags; const TableCreateScriptDialog = (/** @type Properties */ props) => { + const emit = props.emit; const dialogProp = getValue(props.dialog); const externalOpen = dialogProp?.open; const isVanState = externalOpen != null && typeof externalOpen === 'object' && 'val' in externalOpen; @@ -24,7 +25,7 @@ const TableCreateScriptDialog = (/** @type Properties */ props) => { const handleClose = () => { dialogOpen.val = false; if (typeof props.onClose === 'function') props.onClose(); - else emitEvent('CloseClicked', {}); + else emit('CloseClicked', {}); }; const content = div( @@ -33,7 +34,7 @@ const TableCreateScriptDialog = (/** @type Properties */ props) => { span({ class: 'text-secondary text-caption' }, 'Table: '), span({ style: 'font-weight: 500;' }, () => getValue(props.table_name)), ), - () => Code({}, getValue(props.script) ?? ''), + () => Code({ language: 'sql' }, getValue(props.script) ?? ''), ); if (dialogProp) { diff --git a/testgen/ui/static/js/components/table_group_edit_dialog.js b/testgen/ui/static/js/components/table_group_edit_dialog.js index cae7c347..032f926e 100644 --- a/testgen/ui/static/js/components/table_group_edit_dialog.js +++ b/testgen/ui/static/js/components/table_group_edit_dialog.js @@ -21,7 +21,7 @@ import van from '../van.min.js'; import { Dialog } from './dialog.js'; import { TableGroupForm } from './table_group_form.js'; import { TableGroupTest } from './table_group_test.js'; -import { emitEvent, getValue } from '../utils.js'; +import { getValue } from '../utils.js'; import { Button } from './button.js'; import { Alert } from './alert.js'; @@ -34,6 +34,7 @@ const { div, span } = van.tags; * @param {Properties} props */ const TableGroupEditDialog = (props) => { + const emit = props.emit; const dialogProp = getValue(props.dialog); const dialogOpen = van.state(dialogProp?.open === true); van.derive(() => { if (getValue(props.dialog)?.open) dialogOpen.val = true; }); @@ -68,12 +69,12 @@ const TableGroupEditDialog = (props) => { const onClose = () => { dialogOpen.val = false; - emitEvent('CloseEditClicked', {}); + emit('CloseEditClicked', {}); }; const goToVerify = () => { phase.val = 'verify'; - emitEvent('PreviewEditTableGroupClicked', { + emit('PreviewEditTableGroupClicked', { payload: { table_group: tableGroupState.val }, }); }; @@ -104,7 +105,7 @@ const TableGroupEditDialog = (props) => { { style: () => phase.val === 'verify' ? '' : 'display:none' }, TableGroupTest(tableGroupPreview, { onVerifyAcess: () => { - emitEvent('PreviewEditTableGroupClicked', { + emit('PreviewEditTableGroupClicked', { payload: { table_group: tableGroupState.val, verify_access: true, @@ -151,7 +152,7 @@ const TableGroupEditDialog = (props) => { width: 'auto', style: 'margin-left: auto;', disabled: !verified.val, - onclick: () => emitEvent('SaveEditTableGroupClicked', { + onclick: () => emit('SaveEditTableGroupClicked', { payload: { table_group: tableGroupState.val }, }), }), diff --git a/testgen/ui/static/js/components/table_group_wizard.js b/testgen/ui/static/js/components/table_group_wizard.js index 152d35f8..bee588b6 100644 --- a/testgen/ui/static/js/components/table_group_wizard.js +++ b/testgen/ui/static/js/components/table_group_wizard.js @@ -32,7 +32,7 @@ import { Dialog } from './dialog.js'; import { TableGroupForm } from './table_group_form.js'; import { TableGroupTest } from './table_group_test.js'; import { TableGroupStats } from './table_group_stats.js'; -import { emitEvent, getValue } from '../utils.js'; +import { getValue } from '../utils.js'; import { Button } from './button.js'; import { Alert } from './alert.js'; import { Checkbox } from './checkbox.js'; @@ -60,6 +60,7 @@ const defaultSteps = [ * @param {Properties} props */ const TableGroupWizard = (props) => { + const emit = props.emit; const steps = getValue(props.steps) ?? defaultSteps; const defaultTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const stepsState = { @@ -127,7 +128,7 @@ const TableGroupWizard = (props) => { ].filter(([stepKey,]) => steps.includes(stepKey)).map(([, eventKey, stepState]) => [eventKey, stepState]); const payload = Object.fromEntries(payloadEntries); - emitEvent('SaveTableGroupClicked', { payload }); + emit('SaveTableGroupClicked', { payload }); }; const domId = 'table-group-wizard-wrapper'; @@ -190,9 +191,10 @@ const TableGroupWizard = (props) => { index: stepIndex, name: steps[stepIndex], }, + (stepName) => setStep(steps.indexOf(stepName)), ); }, - WizardStep(0, currentStepIndex, step0Form), + WizardStep(0, currentStepIndex, step0Form, emit), WizardStep(1, currentStepIndex, () => { if (isComplete.val) { return ''; @@ -209,14 +211,14 @@ const TableGroupWizard = (props) => { }); if (currentStepIndex.val === 1) { - emitEvent('PreviewTableGroupClicked', { payload: { table_group: tableGroup } }); + emit('PreviewTableGroupClicked', { payload: { table_group: tableGroup } }); } return TableGroupTest( tableGroupPreview, { onVerifyAcess: () => { - emitEvent('PreviewTableGroupClicked', { + emit('PreviewTableGroupClicked', { payload: { table_group: stepsState.tableGroup.rawVal, verify_access: true, @@ -225,7 +227,7 @@ const TableGroupWizard = (props) => { } } ); - }), + }, emit), () => { const runProfiling = van.state(stepsState.runProfiling.rawVal); van.derive(() => { @@ -242,7 +244,7 @@ const TableGroupWizard = (props) => { runProfiling, tableGroupPreview, ); - }); + }, emit); }, () => { const testSuiteState = stepsState.testSuite.rawVal; @@ -253,7 +255,7 @@ const TableGroupWizard = (props) => { const testSuiteCronSample = van.state({}); const testSuiteCrontabEditorValue = van.derive(() => { if (testSuiteSchedule.val && testSuiteScheduleTimezone.val) { - emitEvent('GetCronSampleAux', {payload: {cron_expr: testSuiteSchedule.val, tz: testSuiteScheduleTimezone.val}}); + emit('GetCronSampleAux', {payload: {cron_expr: testSuiteSchedule.val, tz: testSuiteScheduleTimezone.val}}); } return { @@ -278,7 +280,7 @@ const TableGroupWizard = (props) => { return WizardStep(3, currentStepIndex, () => { if (currentStepIndex.val === 3) { - emitEvent('GetCronSampleAux', {payload: {cron_expr: testSuiteSchedule.val, tz: testSuiteScheduleTimezone.val}}); + emit('GetCronSampleAux', {payload: {cron_expr: testSuiteSchedule.val, tz: testSuiteScheduleTimezone.val}}); } if (isComplete.val) { @@ -330,7 +332,7 @@ const TableGroupWizard = (props) => { style: 'flex: 1', onChange: (value) => testSuiteScheduleTimezone.val = value, }), - CrontabInput({ + CrontabInput({ emit, name: 'tg_test_suite_schedule', value: testSuiteCrontabEditorValue, modes: ['x_hours', 'x_days'], @@ -353,7 +355,7 @@ const TableGroupWizard = (props) => { ), ), ); - }); + }, emit); }, () => { const monitorSuiteState = stepsState.monitorSuite.rawVal; @@ -406,7 +408,7 @@ const TableGroupWizard = (props) => { onChange: (value) => generateMonitorTests.val = value, }), () => generateMonitorTests.val - ? MonitorSettingsForm({ + ? MonitorSettingsForm({ emit, schedule: { active: true, cron_expr: monitorSuiteSchedule.rawVal, @@ -444,7 +446,7 @@ const TableGroupWizard = (props) => { ), ), ); - }); + }, emit); }, () => { if (!isComplete.val) { @@ -480,7 +482,7 @@ const TableGroupWizard = (props) => { ? div( { class: 'flex-row fx-gap-1' }, div('Profiling run started.'), - Link({ + Link({ emit, open_new: true, label: 'View progress', href: 'profiling-runs', @@ -494,7 +496,7 @@ const TableGroupWizard = (props) => { div( { class: 'text-caption flex-row fx-gap-1' }, 'Run profiling or configure a schedule on the ', - Link({ + Link({ emit, open_new: true, label: 'Table Groups', href: 'table-groups', @@ -520,7 +522,7 @@ const TableGroupWizard = (props) => { results.generate_test_suite ? 'Manage test suites and schedules on the ' : 'Create test suites, generate and run tests, and configure schedules on the ', - Link({ + Link({ emit, open_new: true, label: 'Test Suites', href: 'test-suites', @@ -548,7 +550,7 @@ const TableGroupWizard = (props) => { results.generate_monitor_suite ? 'Manage monitors and view anomalies on the ' : 'Configure freshness, volume, and schema monitors on the ', - Link({ + Link({ emit, open_new: true, label: 'Monitors', href: 'monitors', @@ -568,7 +570,7 @@ const TableGroupWizard = (props) => { color: 'primary', label: 'Close', width: 'auto', - onclick: () => emitEvent('CloseClicked', {}), + onclick: () => emit('CloseClicked', {}), }), ), ); @@ -628,7 +630,7 @@ const TableGroupWizard = (props) => { { title: dialogTitle, open: dialogOpen, - onClose: () => { dialogOpen.val = false; emitEvent('CloseClicked', {}); }, + onClose: () => { dialogOpen.val = false; emit('CloseClicked', {}); }, width: '50rem', }, wizardContent, diff --git a/testgen/ui/static/js/components/toggle.js b/testgen/ui/static/js/components/toggle.js index 8d3fdbd4..eb723c38 100644 --- a/testgen/ui/static/js/components/toggle.js +++ b/testgen/ui/static/js/components/toggle.js @@ -11,7 +11,7 @@ import van from '../van.min.js'; import { loadStylesheet } from '../utils.js'; -const { input, label } = van.tags; +const { input, label, span } = van.tags; const Toggle = (/** @type Properties */ props) => { loadStylesheet('toggle', stylesheet); diff --git a/testgen/ui/static/js/components/tooltip.js b/testgen/ui/static/js/components/tooltip.js index aa65a962..3932bf84 100644 --- a/testgen/ui/static/js/components/tooltip.js +++ b/testgen/ui/static/js/components/tooltip.js @@ -104,7 +104,7 @@ const withTooltip = (/** @type HTMLElement */ component, /** @type Properties */ function hasStreamlitDialogAncestor(el) { let node = el.parentElement; while (node && node !== document.body) { - if (node.classList.contains(STREAMLIT_DIALOG_CLASS)) return true; + if (node.classList.contains(STREAMLIT_DIALOG_CLASS) || node.classList.contains('tg-dialog-overlay')) return true; node = node.parentElement; } return false; diff --git a/testgen/ui/static/js/components/tree.js b/testgen/ui/static/js/components/tree.js index fbf77c9c..b9902269 100644 --- a/testgen/ui/static/js/components/tree.js +++ b/testgen/ui/static/js/components/tree.js @@ -53,6 +53,7 @@ const { div, h3, span } = van.tags; const levelOffset = 14; const Tree = (/** @type Properties */ props, /** @type any? */ searchOptionsContent, /** @type any? */ filtersContent) => { + const emit = props.emit; loadStylesheet('tree', stylesheet); // Use only initial prop value as default and maintain internal state @@ -90,7 +91,7 @@ const Tree = (/** @type Properties */ props, /** @type any? */ searchOptionsCont id: props.id, class: () => `flex-column ${getValue(props.classes)}`, }, - Toolbar(treeNodes, multiSelect, props, searchOptionsContent, filtersContent), + Toolbar(treeNodes, multiSelect, props, searchOptionsContent, filtersContent, emit), div( { class: () => `tg-tree ${multiSelect.val ? 'multi-select' : ''}` }, () => div( diff --git a/testgen/ui/static/js/utils.js b/testgen/ui/static/js/utils.js index f4a30537..71eb7403 100644 --- a/testgen/ui/static/js/utils.js +++ b/testgen/ui/static/js/utils.js @@ -1,4 +1,3 @@ -import { Streamlit } from './streamlit.js'; import van from './van.min.js'; /** @@ -28,13 +27,6 @@ function loadStylesheet( } } -function emitEvent( - /** @type string */event, - /** @type object */data = {}, -) { - Streamlit.sendData({ event, ...data, _id: Math.random() }) // Identify the event so its handler is called once -} - // Replacement for van.val() // https://github.com/vanjs-org/van/discussions/280 const stateProto = Object.getPrototypeOf(van.state()); @@ -222,4 +214,18 @@ function parseDate(value) { return value; } -export { afterMount, debounce, emitEvent, fillViewportHeight, getRandomId, getValue, getParents, isEqual, isState, loadStylesheet, friendlyPercent, slugify, isDataURL, checkIsRequired, onFrameResized, parseDate }; +/** + * Create a component-scoped emit function bound to a specific V2 component's + * setTriggerValue. Use this instead of the global Streamlit singleton so that + * events always route to the correct widget. + * + * @param {Function} setTriggerValue - The setTriggerValue provided by Streamlit to the V2 component + * @returns {Function} + */ +function createEmitter(setTriggerValue) { + return (event, data = {}) => { + setTriggerValue(event, { ...data, _id: Math.random() }); + }; +} + +export { afterMount, createEmitter, debounce, fillViewportHeight, getRandomId, getValue, getParents, isEqual, isState, loadStylesheet, friendlyPercent, slugify, isDataURL, checkIsRequired, onFrameResized, parseDate }; diff --git a/testgen/ui/views/connections.py b/testgen/ui/views/connections.py index 1d89b9aa..e4eec99c 100644 --- a/testgen/ui/views/connections.py +++ b/testgen/ui/views/connections.py @@ -28,7 +28,6 @@ from testgen.ui.components import widgets as testgen from testgen.ui.navigation.menu import MenuItem from testgen.ui.navigation.page import Page -from testgen.ui.services.rerun_service import safe_rerun from testgen.ui.session import session, temp_value from testgen.ui.utils import get_cron_sample_handler @@ -179,6 +178,8 @@ def on_setup_table_group_clicked(*_args) -> None: success = True try: connection.save() + Connection.select_where.clear() + Connection.get.clear() message = "Changes have been saved successfully." except Exception as error: message = "Something went wrong while creating the connection." diff --git a/testgen/ui/views/data_catalog.py b/testgen/ui/views/data_catalog.py index d8f689a0..67655b3d 100644 --- a/testgen/ui/views/data_catalog.py +++ b/testgen/ui/views/data_catalog.py @@ -1,4 +1,5 @@ import json +import logging import typing from collections import defaultdict from datetime import datetime @@ -38,11 +39,18 @@ get_tables_by_table_group, ) from testgen.ui.services.database_service import execute_db_query, fetch_all_from_db, fetch_from_target_db -from testgen.ui.services.query_cache import get_project_summary +from testgen.ui.services.query_cache import get_profiling_run_summaries, get_project_summary, get_table_group_stats from testgen.ui.session import session -from testgen.ui.views.dialogs.table_create_script_dialog import table_create_script_dialog_widget +from testgen.ui.views.dialogs.import_metadata_dialog import ( + apply_metadata_import, + build_import_preview_props, + parse_import_csv, +) +from testgen.ui.views.dialogs.table_create_script_dialog import generate_create_script from testgen.utils import friendly_score, is_uuid4, make_json_safe, score +LOG = logging.getLogger("testgen") + PAGE_ICON = "dataset" PAGE_TITLE = "Data Catalog" @@ -52,6 +60,9 @@ DC_CREATE_SCRIPT_DIALOG_KEY = "dc:create_script_dialog" DC_DATA_PREVIEW_DIALOG_KEY = "dc:data_preview_dialog" DC_HISTORY_DIALOG_KEY = "dc:history_dialog" +DC_IMPORT_DIALOG_KEY = "dc:import_dialog" +DC_IMPORT_PREVIEW_KEY = "dc:import_preview" +DC_IMPORT_RESULT_KEY = "dc:import_result" class DataCatalogPage(Page): @@ -105,7 +116,7 @@ def on_run_profiling_clicked(_) -> None: run_profiling_data = None if run_profiling_tg_id := st.session_state.get(DC_RUN_PROFILING_DIALOG_KEY): - table_groups_stats = TableGroup.select_stats( + table_groups_stats = get_table_group_stats( project_code=project_code, table_group_id=run_profiling_tg_id, ) @@ -129,13 +140,13 @@ def on_run_profiling_confirmed(table_group: dict) -> None: show_link = False st.session_state[DC_RUN_PROFILING_RESULT_KEY] = {"success": success, "message": message, "show_link": show_link} if success and not show_link: - ProfilingRun.select_summary.clear() + get_profiling_run_summaries.clear() st.session_state.pop(DC_RUN_PROFILING_DIALOG_KEY, None) st.session_state.pop(DC_RUN_PROFILING_RESULT_KEY, None) def on_go_to_profiling_runs_clicked(tg_id: str) -> None: st.session_state.pop(DC_RUN_PROFILING_RESULT_KEY, None) - Router().navigate(to="profiling-runs", with_args={"project_code": project_code, "table_group_id": tg_id}) + Router().queue_navigation(to="profiling-runs", with_args={"project_code": project_code, "table_group_id": tg_id}) def on_run_profiling_dialog_closed(*_) -> None: st.session_state.pop(DC_RUN_PROFILING_DIALOG_KEY, None) @@ -144,6 +155,73 @@ def on_run_profiling_dialog_closed(*_) -> None: def on_export_clicked(items) -> None: st.session_state[DC_EXPORT_DIALOG_KEY] = items + def on_export_csv_clicked(_) -> None: + if selected_table_group: + export_metadata_csv(selected_table_group) + + def on_import_clicked(_) -> None: + if selected_table_group: + st.session_state.pop(DC_IMPORT_PREVIEW_KEY, None) + st.session_state.pop(DC_IMPORT_RESULT_KEY, None) + st.session_state[DC_IMPORT_DIALOG_KEY] = str(selected_table_group.id) + + @with_database_session + def on_import_file_uploaded(payload: dict) -> None: + tg_id = st.session_state.get(DC_IMPORT_DIALOG_KEY) + if tg_id: + try: + preview = parse_import_csv(payload["content"], tg_id, payload["blank_behavior"]) + except Exception: + LOG.exception("Failed to parse import CSV") + preview = {"error": "Something went wrong while parsing the file."} + st.session_state[DC_IMPORT_PREVIEW_KEY] = preview + + def on_import_file_cleared(_) -> None: + st.session_state.pop(DC_IMPORT_PREVIEW_KEY, None) + + @with_database_session + def on_import_confirmed(_) -> None: + tg_id = st.session_state.get(DC_IMPORT_DIALOG_KEY) + preview = st.session_state.get(DC_IMPORT_PREVIEW_KEY) + if not preview or preview.get("error"): + return + try: + apply_metadata_import(preview, tg_id) + from testgen.ui.queries.profiling_queries import get_column_by_id, get_table_by_id + for func in [get_table_group_columns, get_table_by_id, get_column_by_id, get_tag_values, TableGroup.select_minimal_where]: + func.clear() + st.session_state["data_catalog:last_saved_timestamp"] = datetime.now().timestamp() + parts = [] + if tc := preview.get("matched_tables", 0): + parts.append(f"{tc} {'table' if tc == 1 else 'tables'}") + if cc := preview.get("matched_columns", 0): + parts.append(f"{cc} {'column' if cc == 1 else 'columns'}") + summary = f"Metadata for {', '.join(parts)} imported." if parts else "No metadata was imported." + st.session_state[DC_IMPORT_RESULT_KEY] = {"success": True, "message": summary} + except Exception: + LOG.exception("Metadata import failed") + st.session_state[DC_IMPORT_RESULT_KEY] = {"success": False, "message": "Something went wrong while importing the metadata."} + st.session_state.pop(DC_IMPORT_PREVIEW_KEY, None) + + def on_import_dialog_closed(_) -> None: + st.session_state.pop(DC_IMPORT_DIALOG_KEY, None) + st.session_state.pop(DC_IMPORT_PREVIEW_KEY, None) + st.session_state.pop(DC_IMPORT_RESULT_KEY, None) + + import_dialog_data = None + if st.session_state.get(DC_IMPORT_DIALOG_KEY): + preview = st.session_state.get(DC_IMPORT_PREVIEW_KEY) + preview_props = None + if preview: + if preview.get("error"): + preview_props = {"error": preview["error"]} + else: + preview_props = build_import_preview_props(preview) + import_dialog_data = { + "preview": preview_props, + "result": st.session_state.get(DC_IMPORT_RESULT_KEY), + } + def on_create_script_clicked(item) -> None: st.session_state[DC_CREATE_SCRIPT_DIALOG_KEY] = item @@ -191,6 +269,18 @@ def on_history_dialog_closed(*_) -> None: history_dialog_data = st.session_state.get(DC_HISTORY_DIALOG_KEY) data_preview_dialog_data = st.session_state.get(DC_DATA_PREVIEW_DIALOG_KEY) + create_script_dialog_data = None + if create_script_item := st.session_state.get(DC_CREATE_SCRIPT_DIALOG_KEY): + script = generate_create_script(create_script_item["table_name"], columns) + create_script_dialog_data = { + "title": f"Table CREATE Script: {create_script_item['table_name']}", + "table_name": create_script_item["table_name"], + "script": script, + } + + def on_create_script_dialog_closed(*_) -> None: + st.session_state.pop(DC_CREATE_SCRIPT_DIALOG_KEY, None) + testgen.data_catalog_widget( key="data_catalog", data={ @@ -218,13 +308,18 @@ def on_history_dialog_closed(*_) -> None: "run_profiling_dialog": run_profiling_data, "history_dialog": history_dialog_data, "data_preview_dialog": data_preview_dialog_data, + "import_metadata_dialog": import_dialog_data, + "create_script_dialog": create_script_dialog_data, }, on_RunProfilingClicked_change=on_run_profiling_clicked, on_TableGroupSelected_change=on_table_group_selected, on_ItemSelected_change=on_item_selected, on_ExportClicked_change=on_export_clicked, + on_ExportCsvClicked_change=on_export_csv_clicked, + on_ImportClicked_change=on_import_clicked, on_RemoveTableConfirmed_change=remove_table_dialog, on_CreateScriptClicked_change=on_create_script_clicked, + on_CreateScriptDialogClosed_change=on_create_script_dialog_closed, on_DataPreviewClicked_change=on_data_preview_clicked, on_HistoryClicked_change=on_history_clicked, on_TagsChanged_change=partial(on_tags_changed, spinner_container), @@ -237,6 +332,11 @@ def on_history_dialog_closed(*_) -> None: on_HistoryDialogClosed_change=on_history_dialog_closed, # DataPreviewDialog events on_DataPreviewDialogClosed_change=on_data_preview_dialog_closed, + # ImportMetadataDialog events + on_ImportFileUploaded_change=on_import_file_uploaded, + on_ImportFileCleared_change=on_import_file_cleared, + on_ImportConfirmed_change=on_import_confirmed, + on_ImportDialogClosed_change=on_import_dialog_closed, ) if DC_EXPORT_DIALOG_KEY in st.session_state: @@ -247,13 +347,64 @@ def on_history_dialog_closed(*_) -> None: args=(selected_table_group, export_items), ) - if create_script_item := st.session_state.get(DC_CREATE_SCRIPT_DIALOG_KEY): - table_create_script_dialog_widget( - create_script_item["table_name"], - columns, - dialog={"open": True, "title": f"Table CREATE Script: {create_script_item['table_name']}"}, - on_close=lambda *_: st.session_state.pop(DC_CREATE_SCRIPT_DIALOG_KEY, None), - ) + + +@with_database_session +def export_metadata_csv(table_group: TableGroupMinimal) -> None: + def _get_csv_data(update_progress: PROGRESS_UPDATE_TYPE) -> FILE_DATA_TYPE: + table_data = fetch_all_from_db( + f""" + SELECT table_name, '' AS column_name, + description, + critical_data_element, + {", ".join(TAG_FIELDS)} + FROM data_table_chars + WHERE table_groups_id = :table_group_id + ORDER BY LOWER(table_name) + """, + {"table_group_id": str(table_group.id)}, + ) + + column_data = fetch_all_from_db( + f""" + SELECT c.table_name, c.column_name, + c.description, + c.critical_data_element, + c.excluded_data_element, + c.pii_flag, + {", ".join([ f"c.{tag}" for tag in TAG_FIELDS ])} + FROM data_column_chars c + LEFT JOIN data_table_chars t ON (c.table_id = t.table_id) + WHERE c.table_groups_id = :table_group_id + ORDER BY LOWER(c.table_name), c.ordinal_position + """, + {"table_group_id": str(table_group.id)}, + ) + + rows = [] + for row in list(table_data) + list(column_data): + csv_row = { + "Table": row["table_name"], + "Column": row["column_name"], + "Description": row["description"] or "", + "Critical Data Element": "Yes" if row["critical_data_element"] is True else "No" if row["critical_data_element"] is False else "", + "PII": "Yes" if row.get("pii_flag") else "No", + "Excluded Data Element": "Yes" if row.get("excluded_data_element") else "No", + } + for tag in TAG_FIELDS: + header = tag.replace("_", " ").title() + csv_row[header] = row[tag] or "" + rows.append(csv_row) + + df = pd.DataFrame(rows) + csv_content = df.to_csv(index=False) + update_progress(1.0) + return "Data Catalog Metadata.csv", "text/csv", csv_content + + download_dialog( + dialog_title="Download Metadata CSV", + file_content_func=_get_csv_data, + ) def on_table_group_selected(table_group_id: str | None) -> None: @@ -438,14 +589,23 @@ def on_tags_changed(spinner_container: DeltaGenerator, payload: dict) -> FILE_DA params = { key: tags.get(key) or "" for key in attributes if key in tags } if "critical_data_element" in tags: set_attributes.append("critical_data_element = :critical_data_element") - params.update({"critical_data_element": tags.get("critical_data_element")}) + params["critical_data_element"] = tags.get("critical_data_element") + + # pii_flag and excluded_data_element are column-only fields (not in data_table_chars) + column_set_attributes = list(set_attributes) + if "pii_flag" in tags: + column_set_attributes.append("pii_flag = :pii_flag") + params["pii_flag"] = tags.get("pii_flag") + if "excluded_data_element" in tags: + column_set_attributes.append("excluded_data_element = :excluded_data_element") + params["excluded_data_element"] = tags.get("excluded_data_element") params["table_ids"] = [ item["id"] for item in payload["items"] if item["type"] == "table" ] params["column_ids"] = [ item["id"] for item in payload["items"] if item["type"] == "column" ] with spinner_container: with st.spinner("Saving tags"): - if params["table_ids"]: + if params["table_ids"] and set_attributes: execute_db_query( f""" WITH selected as ( @@ -460,14 +620,14 @@ def on_tags_changed(spinner_container: DeltaGenerator, payload: dict) -> FILE_DA params, ) - if params["column_ids"]: + if params["column_ids"] and column_set_attributes: execute_db_query( f""" WITH selected as ( SELECT UNNEST(ARRAY [:column_ids]) AS column_id ) UPDATE data_column_chars - SET {', '.join(set_attributes)} + SET {', '.join(column_set_attributes)} FROM data_column_chars dcc INNER JOIN selected ON (dcc.column_id = selected.column_id::UUID) WHERE dcc.column_id = data_column_chars.column_id; @@ -475,7 +635,23 @@ def on_tags_changed(spinner_container: DeltaGenerator, payload: dict) -> FILE_DA params, ) - for func in [ get_table_group_columns, get_table_by_id, get_column_by_id, get_tag_values ]: + # Disable autodetection flags on table group if requested + disable_flags = payload.get("disable_flags", []) + if disable_flags: + table_group_id = st.query_params.get("table_group_id") + if table_group_id: + table_group = TableGroup.get(table_group_id) + changed = False + if "profile_flag_cdes" in disable_flags and table_group.profile_flag_cdes: + table_group.profile_flag_cdes = False + changed = True + if "profile_flag_pii" in disable_flags and table_group.profile_flag_pii: + table_group.profile_flag_pii = False + changed = True + if changed: + table_group.save() + + for func in [ get_table_group_columns, get_table_by_id, get_column_by_id, get_tag_values, TableGroup.select_minimal_where ]: func.clear() st.session_state["data_catalog:last_saved_timestamp"] = datetime.now().timestamp() @@ -503,6 +679,8 @@ def get_table_group_columns(table_group_id: str) -> list[dict]: table_chars.drop_date AS table_drop_date, column_chars.critical_data_element, table_chars.critical_data_element AS table_critical_data_element, + column_chars.pii_flag, + column_chars.excluded_data_element, {", ".join([ f"column_chars.{tag}" for tag in TAG_FIELDS ])}, {", ".join([ f"table_chars.{tag} AS table_{tag}" for tag in TAG_FIELDS ])} FROM data_column_chars column_chars @@ -572,6 +750,7 @@ def get_latest_test_issues(table_group_id: str, table_name: str, column_name: st test_results.test_type = test_types.test_type ) WHERE test_suites.table_groups_id = :table_group_id + AND test_suites.is_monitor = false AND table_name = :table_name {"AND column_names = :column_name" if column_name else ""} AND result_status NOT IN ('Passed', 'Log') @@ -606,6 +785,7 @@ def get_related_test_suites(table_group_id: str, table_name: str, column_name: s test_definitions.test_suite_id = test_suites.id ) WHERE test_suites.table_groups_id = :table_group_id + AND test_suites.is_monitor = false AND table_name = :table_name {"AND column_name = :column_name" if column_name else ""} GROUP BY test_suites.id @@ -691,14 +871,15 @@ def get_preview_data( return {"title": title, "status": "ERR", "message": "Connection not found."} flavor_service = get_flavor_service(connection.sql_flavor) - use_top = flavor_service.use_top + row_limiting = flavor_service.row_limiting_clause quote = flavor_service.quote_character query = f""" SELECT DISTINCT - {"TOP 100" if use_top else ""} + {"TOP 100" if row_limiting == "top" else ""} {f"{quote}{column_name}{quote}" if column_name else "*"} FROM {quote}{schema_name}{quote}.{quote}{table_name}{quote} - {"LIMIT 100" if not use_top else ""} + {"LIMIT 100" if row_limiting == "limit" else ""} + {"FETCH FIRST 100 ROWS ONLY" if row_limiting == "fetch" else ""} """ try: diff --git a/testgen/ui/views/dialogs/run_tests_dialog.py b/testgen/ui/views/dialogs/run_tests_dialog.py index 3ebc7dc4..93a87dbe 100644 --- a/testgen/ui/views/dialogs/run_tests_dialog.py +++ b/testgen/ui/views/dialogs/run_tests_dialog.py @@ -47,7 +47,7 @@ def on_run_tests_confirmed(data: dict) -> None: def on_go_to_test_runs(payload: dict) -> None: st.session_state.pop(RESULT_KEY, None) - Router().navigate(to=LINK_HREF, with_args=payload) + Router().queue_navigation(to=LINK_HREF, with_args=payload) def on_close_clicked(*_) -> None: st.session_state.pop(RESULT_KEY, None) diff --git a/testgen/ui/views/hygiene_issues.py b/testgen/ui/views/hygiene_issues.py index 3b417b62..f6837c79 100644 --- a/testgen/ui/views/hygiene_issues.py +++ b/testgen/ui/views/hygiene_issues.py @@ -250,9 +250,10 @@ def on_export_selected(payload: dict) -> None: @with_database_session def on_view_source_data(row_id: str) -> None: - row = next((item for item in items if item["id"] == row_id), None) - if not row: + anomaly_df = profiling_queries.get_profiling_anomalies_by_ids([row_id]) + if anomaly_df.empty: return + row = make_json_safe(anomaly_df.where(anomaly_df.notna(), None).to_dict(orient="records")[0]) MixpanelService().send_event( "view-source-data", @@ -293,15 +294,14 @@ def on_source_data_closed(*_) -> None: st.session_state.pop(SOURCE_DATA_KEY, None) @with_database_session - def on_view_profiling(payload: dict) -> None: - column_name = payload.get("column_name") - table_name_ = payload.get("table_name") - table_groups_id = payload.get("table_groups_id") - - if not column_name or not table_name_ or not table_groups_id: + def on_view_profiling(anomaly_id: str) -> None: + lookup = profiling_queries.get_profiling_anomaly_lookup(anomaly_id) + if not lookup: return - column = profiling_queries.get_column_by_name(column_name, table_name_, table_groups_id) + column = profiling_queries.get_column_by_name( + lookup["column_name"], lookup["table_name"], lookup["table_groups_id"], + ) if column: st.session_state[PROFILING_KEY] = make_json_safe(column) @@ -320,10 +320,16 @@ def on_refresh_score(*_) -> None: @with_database_session def on_download_report(payload: dict) -> None: ids = payload.get("ids", []) - selected_items = [item for item in items if item["id"] in ids] + if not ids: + return - if not selected_items: + anomaly_df = profiling_queries.get_profiling_anomalies_by_ids(ids) + if anomaly_df.empty: return + selected_items = [ + make_json_safe(record) + for record in anomaly_df.where(anomaly_df.notna(), None).to_dict(orient="records") + ] MixpanelService().send_event( "download-issue-report", diff --git a/testgen/ui/views/monitors_dashboard.py b/testgen/ui/views/monitors_dashboard.py index 1949c639..c08be3ad 100644 --- a/testgen/ui/views/monitors_dashboard.py +++ b/testgen/ui/views/monitors_dashboard.py @@ -8,7 +8,7 @@ from testgen.commands.test_generation import run_monitor_generation from testgen.common.freshness_service import add_business_minutes, get_schedule_params, resolve_holiday_dates -from testgen.common.models import with_database_session +from testgen.common.models import get_current_session, with_database_session from testgen.common.models.notification_settings import ( MonitorNotificationSettings, MonitorNotificationTrigger, @@ -25,6 +25,7 @@ from testgen.ui.queries.profiling_queries import get_tables_by_table_group from testgen.ui.services.database_service import execute_db_query, fetch_all_from_db, fetch_one_from_db from testgen.ui.services.query_cache import get_project_summary, get_test_type_summaries +from testgen.ui.services.rerun_service import safe_rerun from testgen.ui.session import session, temp_value from testgen.ui.utils import dict_from_kv, get_cron_sample, get_cron_sample_handler from testgen.ui.views.dialogs.manage_notifications import NotificationSettingsDialogBase @@ -649,10 +650,12 @@ def on_save_settings_clicked(payload: dict) -> None: updated_table_group = TableGroup.get(table_group.id) updated_table_group.monitor_test_suite_id = monitor_suite.id updated_table_group.save() + # Commit needed to make test suite visible to run_monitor_generation's separate DB connection + get_current_session().commit() run_monitor_generation(monitor_suite.id, ["Volume_Trend", "Schema_Drift"]) st.session_state.pop(EDIT_MONITOR_SETTINGS_DIALOG_KEY, None) - st.rerun() + safe_rerun() data = { "table_group": table_group.to_dict(json_safe=True), @@ -1083,10 +1086,10 @@ def on_save_test_definition(payload: dict) -> None: if should_close(): st.session_state.pop(EDIT_TABLE_MONITORS_DIALOG_KEY, None) - st.rerun() + safe_rerun() set_result({"success": True, "timestamp": datetime.now(UTC).isoformat()}) - st.rerun() + safe_rerun() metric_test_types = get_test_type_summaries(test_type="Metric_Trend") metric_test_type = metric_test_types[0] if metric_test_types else None diff --git a/testgen/ui/views/profiling_runs.py b/testgen/ui/views/profiling_runs.py index 48589122..077aac77 100644 --- a/testgen/ui/views/profiling_runs.py +++ b/testgen/ui/views/profiling_runs.py @@ -30,7 +30,7 @@ from testgen.ui.navigation.menu import MenuItem from testgen.ui.navigation.page import Page from testgen.ui.navigation.router import Router -from testgen.ui.services.query_cache import get_profiling_run_summaries, get_project_summary +from testgen.ui.services.query_cache import get_profiling_run_summaries, get_project_summary, get_table_group_stats from testgen.ui.session import session from testgen.ui.views.dialogs.manage_notifications import NotificationSettingsDialogBase from testgen.ui.views.dialogs.manage_schedules import ScheduleDialog @@ -89,7 +89,7 @@ def on_delete_runs_clicked(profiling_run_ids: list[str]) -> None: # Build run profiling dialog data run_profiling_data = None if st.session_state.get(RUN_PROFILING_DIALOG_KEY): - table_groups_stats = TableGroup.select_stats(project_code=project_code) + table_groups_stats = get_table_group_stats(project_code=project_code) run_profiling_data = { "open": st.session_state[RUN_PROFILING_DIALOG_OPEN_COUNT_KEY], "title": "Run Profiling", @@ -132,13 +132,13 @@ def on_run_profiling_confirmed(table_group: dict) -> None: show_link = False st.session_state[RUN_PROFILING_RESULT_KEY] = {"success": success, "message": message, "show_link": show_link} if success and not show_link: - ProfilingRun.select_summary.clear() + get_profiling_run_summaries.clear() st.session_state.pop(RUN_PROFILING_DIALOG_KEY, None) st.session_state.pop(RUN_PROFILING_RESULT_KEY, None) def on_go_to_profiling_runs_clicked(tg_id: str) -> None: st.session_state.pop(RUN_PROFILING_RESULT_KEY, None) - Router().navigate(to="profiling-runs", with_args={"project_code": project_code, "table_group_id": tg_id}) + Router().queue_navigation(to="profiling-runs", with_args={"project_code": project_code, "table_group_id": tg_id}) def on_run_profiling_dialog_closed(*_) -> None: st.session_state.pop(RUN_PROFILING_DIALOG_KEY, None) diff --git a/testgen/ui/views/table_groups.py b/testgen/ui/views/table_groups.py index 5a14deae..aeb5acfb 100644 --- a/testgen/ui/views/table_groups.py +++ b/testgen/ui/views/table_groups.py @@ -7,11 +7,10 @@ from sqlalchemy.exc import IntegrityError from testgen.commands.test_generation import run_monitor_generation -from testgen.common.models import with_database_session +from testgen.common.models import get_current_session, with_database_session from testgen.common.models.connection import Connection from testgen.common.models.job_execution import JobExecution from testgen.common.models.notification_settings import ProfilingRunNotificationSettings -from testgen.common.models.profiling_run import ProfilingRun from testgen.common.models.scheduler import RUN_MONITORS_JOB_KEY, RUN_TESTS_JOB_KEY, JobSchedule from testgen.common.models.table_group import TableGroup, TableGroupMinimal from testgen.common.models.test_run import TestRun @@ -21,7 +20,7 @@ from testgen.ui.navigation.page import Page from testgen.ui.navigation.router import Router from testgen.ui.queries import table_group_queries -from testgen.ui.services.query_cache import get_project_summary +from testgen.ui.services.query_cache import get_profiling_run_summaries, get_project_summary, get_table_group_stats from testgen.ui.session import session, temp_value from testgen.ui.utils import get_cron_sample_handler from testgen.ui.views.connections import FLAVOR_OPTIONS, format_connection @@ -49,7 +48,6 @@ class TableGroupsPage(Page): order=0, ) - @with_database_session def render( self, project_code: str, @@ -105,7 +103,7 @@ def on_run_notifications_clicked(*_) -> None: run_profiling_data = None if run_profiling_tg_id := st.session_state.get(TG_RUN_PROFILING_DIALOG_KEY): - table_groups_stats = TableGroup.select_stats( + table_groups_stats = get_table_group_stats( project_code=project_code, table_group_id=run_profiling_tg_id, ) @@ -139,13 +137,13 @@ def on_run_profiling_confirmed(table_group: dict) -> None: show_link = False st.session_state[TG_RUN_PROFILING_RESULT_KEY] = {"success": success, "message": message, "show_link": show_link} if success and not show_link: - ProfilingRun.select_summary.clear() + get_profiling_run_summaries.clear() st.session_state.pop(TG_RUN_PROFILING_DIALOG_KEY, None) st.session_state.pop(TG_RUN_PROFILING_RESULT_KEY, None) def on_go_to_profiling_runs_clicked(tg_id: str) -> None: st.session_state.pop(TG_RUN_PROFILING_RESULT_KEY, None) - Router().navigate(to="profiling-runs", with_args={"project_code": project_code, "table_group_id": tg_id}) + Router().queue_navigation(to="profiling-runs", with_args={"project_code": project_code, "table_group_id": tg_id}) def on_run_profiling_dialog_closed(*_) -> None: st.session_state.pop(TG_RUN_PROFILING_DIALOG_KEY, None) @@ -274,6 +272,7 @@ def on_save_table_group_clicked(payload: dict): set_run_profiling(run_profiling) def on_close_clicked(_params: dict) -> None: + TableGroup.select_minimal_where.clear() for key in ["tg_wizard_mode", "tg_wizard_connection_id", "tg_wizard_table_group_id"]: st.session_state.pop(key, None) @@ -418,6 +417,8 @@ def on_close_clicked(_params: dict) -> None: predict_holiday_codes=monitor_test_suite_data.get("predict_holiday_codes") or None, ) monitor_test_suite.save() + # Commit needed to make test suite visible to run_monitor_generation's separate DB connection + get_current_session().commit() run_monitor_generation(monitor_test_suite.id, ["Volume_Trend", "Schema_Drift"]) JobSchedule( @@ -449,9 +450,14 @@ def on_close_clicked(_params: dict) -> None: message = "Profiling run encountered errors" LOG.exception(message) - except IntegrityError: + except IntegrityError as error: + get_current_session().rollback() success = False - message = "A Table Group with the same name already exists." + if "table_groups_name_unique" in str(error.orig): + message = "A Table Group with the same name already exists." + else: + message = "Something went wrong while creating the table group." + LOG.exception(message) else: success = False message = "Verify the table group before saving" @@ -554,6 +560,7 @@ def on_close_edit(_params: dict) -> None: save_data_chars(table_group.id) except Exception: LOG.exception("Data characteristics refresh encountered errors") + TableGroup.select_minimal_where.clear() st.toast(f"Table group '{table_group.table_groups_name}' saved.", icon=":material/check:") for key in ["tg_wizard_mode", "tg_wizard_table_group_id"]: st.session_state.pop(key, None) @@ -621,6 +628,7 @@ def _execute_delete(self, table_group_id: str) -> None: table_group_name = st.session_state.get("tg_delete_dialog", {}).get("table_group", {}).get("table_groups_name", "") if not (ProfilingRun.has_active_job_for(TableGroup, table_group_id) or TestRun.has_active_job_for(TableGroup, table_group_id)): TableGroup.cascade_delete([table_group_id]) + TableGroup.select_minimal_where.clear() st.toast(f"Table Group {table_group_name} has been deleted.", icon=":material/check:") else: st.toast("This Table Group is in use by a running process and cannot be deleted.", icon=":material/error:") diff --git a/testgen/ui/views/test_definitions.py b/testgen/ui/views/test_definitions.py index b88d58cd..96bc3bca 100644 --- a/testgen/ui/views/test_definitions.py +++ b/testgen/ui/views/test_definitions.py @@ -13,7 +13,12 @@ from testgen.common.models import with_database_session from testgen.common.models.connection import Connection from testgen.common.models.table_group import TableGroup, TableGroupMinimal -from testgen.common.models.test_definition import TestDefinition, TestDefinitionMinimal, TestDefinitionSummary +from testgen.common.models.test_definition import ( + TestDefinition, + TestDefinitionMinimal, + TestDefinitionNote, + TestDefinitionSummary, +) from testgen.common.models.test_suite import TestSuite from testgen.ui.components import widgets as testgen from testgen.ui.components.widgets.download_dialog import ( @@ -49,6 +54,7 @@ TD_RUN_TESTS_RESULT_KEY = "td:run_tests_result" TD_VALIDATE_RESULT_KEY = "td:validate_result" TD_COPY_MOVE_COLLISION_KEY = "td:copy_move_collision" +TD_NOTES_DIALOG_KEY = "td:notes_dialog" def _parse_sort_param(sort: str | None) -> tuple[list | None, list[dict]]: @@ -82,7 +88,6 @@ class TestDefinitionsPage(Page): lambda: "test_suite_id" in st.query_params or "test-suites", ] - @with_database_session def render( self, test_suite_id: str, @@ -170,6 +175,8 @@ def render( } # Build dialog states + validate_result = st.session_state.pop(TD_VALIDATE_RESULT_KEY, None) + add_dialog = None if st.session_state.get(TD_ADD_DIALOG_KEY): add_dialog = { @@ -179,7 +186,6 @@ def render( "table_groups_id": str(table_group.id), "table_group_schema": table_group.table_group_schema, "test_suite": test_suite_info, - "validate_result": st.session_state.pop(TD_VALIDATE_RESULT_KEY, None), } edit_dialog = None @@ -191,7 +197,6 @@ def render( "table_columns": table_columns, "table_group_schema": table_group.table_group_schema, "test_suite": test_suite_info, - "validate_result": st.session_state.pop(TD_VALIDATE_RESULT_KEY, None), } delete_dialog = None @@ -230,6 +235,10 @@ def render( "result": st.session_state.get(TD_RUN_TESTS_RESULT_KEY), } + notes_dialog = None + if notes_state := st.session_state.get(TD_NOTES_DIALOG_KEY): + notes_dialog = _load_notes_dialog_data(notes_state.get("id") or notes_state, df) + # --- Event handlers --- def on_add_dialog_opened(*_) -> None: @@ -270,7 +279,16 @@ def on_unlock_all_opened(*_) -> None: all_ids = get_test_definition_ids(test_suite, table_name, column_name, test_type, flagged_filter=flagged) st.session_state[TD_UNLOCK_DIALOG_KEY] = [{"id": id_} for id_ in all_ids] - def on_copy_move_dialog_opened(selected: list) -> None: + @with_database_session + def on_copy_move_dialog_opened(selected) -> None: + if selected == "all": + all_ids = get_test_definition_ids(test_suite, table_name, column_name, test_type, flagged_filter=flagged) + results = TestDefinition.select_where(TestDefinition.id.in_(all_ids)) + selected = [ + {"id": str(r.id), "table_name": r.table_name, "column_name": r.column_name, + "test_type": r.test_type, "lock_refresh": r.lock_refresh} + for r in results + ] # selected contains minimal row dicts (id, table_name, column_name, test_type, lock_refresh) st.session_state[TD_COPY_MOVE_DIALOG_KEY] = selected st.session_state.pop(TD_COPY_MOVE_COLLISION_KEY, None) @@ -296,16 +314,18 @@ def on_copy_move_dialog_closed(*_) -> None: @with_database_session def on_add_test_saved(test_def: dict) -> None: test_def["last_manual_update"] = datetime.now(UTC) - TestDefinition(**test_def).save() - get_test_suite_columns.clear() + td_columns = set(TestDefinition.__table__.columns.keys()) + TestDefinition(**{k: v for k, v in test_def.items() if k in td_columns}).save() + st.cache_data.clear() st.session_state.pop(TD_ADD_DIALOG_KEY, None) st.session_state.pop(TD_VALIDATE_RESULT_KEY, None) @with_database_session def on_edit_test_saved(test_def: dict) -> None: test_def["last_manual_update"] = datetime.now(UTC) - TestDefinition(**test_def).save() - get_test_suite_columns.clear() + td_columns = set(TestDefinition.__table__.columns.keys()) + TestDefinition(**{k: v for k, v in test_def.items() if k in td_columns}).save() + st.cache_data.clear() st.session_state.pop(TD_EDIT_DIALOG_KEY, None) st.session_state.pop(TD_VALIDATE_RESULT_KEY, None) @@ -313,12 +333,14 @@ def on_edit_test_saved(test_def: dict) -> None: def on_delete_confirmed(payload: dict) -> None: ids = payload.get("ids", []) TestDefinition.delete_where(TestDefinition.id.in_(ids)) + st.cache_data.clear() st.session_state.pop(TD_DELETE_DIALOG_KEY, None) @with_database_session def on_unlock_confirmed(payload: dict) -> None: ids = payload.get("ids", []) TestDefinition.set_status_attribute("lock_refresh", ids, False) + st.cache_data.clear() st.session_state.pop(TD_UNLOCK_DIALOG_KEY, None) @with_database_session @@ -327,6 +349,7 @@ def on_update_attribute(payload: dict) -> None: ids = payload["ids"] value = payload["value"] TestDefinition.set_status_attribute(attribute, ids, value) + st.cache_data.clear() @with_database_session def on_update_attribute_all(payload: dict) -> None: @@ -335,6 +358,7 @@ def on_update_attribute_all(payload: dict) -> None: all_ids = get_test_definition_ids(test_suite, table_name, column_name, test_type, flagged_filter=flagged) if all_ids: TestDefinition.set_status_attribute(attribute, all_ids, value) + st.cache_data.clear() @with_database_session def on_copy_confirmed(payload: dict) -> None: @@ -344,6 +368,7 @@ def on_copy_confirmed(payload: dict) -> None: target_table = payload.get("target_table_name") target_col = payload.get("target_column_name") TestDefinition.copy(ids, target_tg_id, target_ts_id, target_table, target_col) + st.cache_data.clear() get_test_suite_columns.clear() st.session_state.pop(TD_COPY_MOVE_DIALOG_KEY, None) st.session_state.pop(TD_COPY_MOVE_COLLISION_KEY, None) @@ -356,6 +381,7 @@ def on_move_confirmed(payload: dict) -> None: target_table = payload.get("target_table_name") target_col = payload.get("target_column_name") TestDefinition.move(ids, target_tg_id, target_ts_id, target_table, target_col) + st.cache_data.clear() get_test_suite_columns.clear() st.session_state.pop(TD_COPY_MOVE_DIALOG_KEY, None) st.session_state.pop(TD_COPY_MOVE_COLLISION_KEY, None) @@ -412,7 +438,34 @@ def on_run_tests_dialog_closed(*_) -> None: def on_go_to_test_runs(payload: dict) -> None: st.session_state.pop(TD_RUN_TESTS_RESULT_KEY, None) - Router().navigate(to="test-runs", with_args=payload) + Router().queue_navigation(to="test-runs", with_args=payload) + + def on_notes_clicked(payload: dict) -> None: + st.session_state[TD_NOTES_DIALOG_KEY] = payload + + @with_database_session + def on_note_added(payload: dict) -> None: + td_id = payload["test_definition_id"] + current_user = session.auth.user.username if session.auth.user else "unknown" + TestDefinitionNote.add_note(td_id, payload["text"], current_user) + st.session_state[TD_NOTES_DIALOG_KEY] = _load_notes_dialog_data(td_id, df) + st.cache_data.clear() + + @with_database_session + def on_note_updated(payload: dict) -> None: + TestDefinitionNote.update_note(payload["id"], payload["text"]) + td_id = payload["test_definition_id"] + st.session_state[TD_NOTES_DIALOG_KEY] = _load_notes_dialog_data(td_id, df) + + @with_database_session + def on_note_deleted(payload: dict) -> None: + TestDefinitionNote.delete_note(payload["id"]) + td_id = payload["test_definition_id"] + st.session_state[TD_NOTES_DIALOG_KEY] = _load_notes_dialog_data(td_id, df) + st.cache_data.clear() + + def on_notes_dialog_closed(*_) -> None: + st.session_state.pop(TD_NOTES_DIALOG_KEY, None) def on_export_all(*_) -> None: download_dialog( @@ -429,6 +482,18 @@ def on_export_filtered(payload: dict) -> None: args=(test_suite, table_group.table_group_schema, pd.DataFrame(records)), ) + @with_database_session + def on_export_selected(payload: dict) -> None: + ids = payload.get("ids", []) + if ids: + data = get_test_definitions(test_suite) + data = data[data["id"].isin(ids)] + download_dialog( + dialog_title="Download Excel Report", + file_content_func=get_excel_report_data, + args=(test_suite, table_group.table_group_schema, data), + ) + def on_filter_changed(filters: dict) -> None: Router().set_query_params({**filters, "page": "0"}) @@ -478,12 +543,14 @@ def on_sort_changed(payload: dict) -> None: "can_edit": user_can_edit, "can_disposition": user_can_disposition, }, + "validate_result": validate_result, "add_dialog": add_dialog, "edit_dialog": edit_dialog, "delete_dialog": delete_dialog, "unlock_dialog": unlock_dialog, "copy_move_dialog": copy_move_dialog, "run_tests_dialog": run_tests_data, + "notes_dialog": notes_dialog, }, on_AddDialogOpened_change=on_add_dialog_opened, on_EditDialogOpened_change=on_edit_dialog_opened, @@ -513,12 +580,44 @@ def on_sort_changed(payload: dict) -> None: on_GoToTestRunsClicked_change=on_go_to_test_runs, on_ExportAll_change=on_export_all, on_ExportFiltered_change=on_export_filtered, + on_ExportSelected_change=on_export_selected, + on_NotesClicked_change=on_notes_clicked, + on_NoteAdded_change=on_note_added, + on_NoteUpdated_change=on_note_updated, + on_NoteDeleted_change=on_note_deleted, + on_NotesDialogClosed_change=on_notes_dialog_closed, on_FilterChanged_change=on_filter_changed, on_PageChanged_change=on_page_changed, on_SortChanged_change=on_sort_changed, ) +def _load_notes_dialog_data(td_id_or_state, df: pd.DataFrame) -> dict: + """Build notes dialog data from a test definition ID or existing state dict.""" + if isinstance(td_id_or_state, dict): + td_id = td_id_or_state.get("id") + test_label = { + "table": td_id_or_state.get("table_name", ""), + "column": td_id_or_state.get("column_name", ""), + "test": td_id_or_state.get("test_name_short", ""), + } + else: + td_id = td_id_or_state + row_df = df[df["id"] == str(td_id)] + if row_df.empty: + test_label = {"table": "", "column": "", "test": ""} + else: + row = row_df.iloc[0] + test_label = {"table": row["table_name"], "column": row["column_name"], "test": row["test_name_short"]} + + current_user = session.auth.user.username if session.auth.user else "unknown" + notes = TestDefinitionNote.get_notes(td_id) + return { + "id": str(td_id), + "test_label": test_label, + "notes": notes, + "current_user": current_user, + } @with_database_session @@ -572,7 +671,7 @@ def get_excel_report_data( def run_test_type_lookup_query(test_type: str | None = None) -> pd.DataFrame: query = f""" SELECT - tt.id, tt.test_type, tt.id as cat_test_id, + tt.id, tt.test_type, tt.test_name_short, tt.test_name_long, tt.test_description, tt.measure_uom, COALESCE(tt.measure_uom_description, '') as measure_uom_description, tt.default_parm_columns, tt.default_severity, @@ -675,6 +774,12 @@ def get_test_definitions( df["test_active_display"] = df["test_active"].apply(lambda value: "Yes" if value else "No") df["lock_refresh_display"] = df["lock_refresh"].apply(lambda value: "Yes" if value else "No") df["flagged_display"] = df["flagged"].apply(lambda value: "Yes" if value else "No") + if not df.empty: + notes_counts = TestDefinitionNote.get_notes_count_by_ids([str(td_id) for td_id in df["id"]]) + df["notes_count"] = df["id"].map(notes_counts).fillna(0).astype(int) + else: + df["notes_count"] = pd.Series(dtype=int) + df["urgency"] = df.apply(lambda row: row["severity"] or test_suite.severity or row["default_severity"], axis=1) df["final_test_description"] = df.apply( lambda row: row["test_description"] or row["default_test_description"], axis=1 diff --git a/testgen/ui/views/test_results.py b/testgen/ui/views/test_results.py index b1292a0e..a978cfbc 100644 --- a/testgen/ui/views/test_results.py +++ b/testgen/ui/views/test_results.py @@ -12,7 +12,7 @@ from testgen.common.mixpanel_service import MixpanelService from testgen.common.models import with_database_session from testgen.common.models.table_group import TableGroup -from testgen.common.models.test_definition import TestDefinition, TestDefinitionSummary +from testgen.common.models.test_definition import TestDefinition, TestDefinitionNote, TestDefinitionSummary from testgen.common.models.test_run import TestRun from testgen.common.models.test_suite import TestSuite, TestSuiteMinimal from testgen.ui.components import widgets as testgen @@ -46,7 +46,9 @@ SOURCE_DATA_KEY = "tr:source_data" PROFILING_KEY = "tr:profiling" EDIT_TEST_KEY = "tr:edit_test" +VALIDATE_RESULT_KEY = "tr:validate_result" ISSUE_REPORT_KEY = "tr:issue_report" +NOTES_DIALOG_KEY = "tr:notes_dialog" DISPOSITION_MAP = {"Confirmed": "✓", "Dismissed": "✘", "Inactive": "🔇", "Passed": ""} @@ -234,6 +236,10 @@ def render( source_data = st.session_state.get(SOURCE_DATA_KEY) edit_test = st.session_state.get(EDIT_TEST_KEY) + notes_dialog = None + if notes_state := st.session_state.get(NOTES_DIALOG_KEY): + notes_dialog = _load_notes_dialog_data(notes_state.get("id") or notes_state, df) + # Event handlers @with_database_session def on_row_selected(item_id: str) -> None: @@ -297,20 +303,50 @@ def on_flag_changed(payload: dict) -> None: TestDefinition.set_status_attribute("flagged", test_definition_ids, value) st.cache_data.clear() + def on_notes_clicked(payload: dict) -> None: + st.session_state[NOTES_DIALOG_KEY] = payload + + @with_database_session + def on_note_added(payload: dict) -> None: + td_id = payload["test_definition_id"] + current_user = session.auth.user.username if session.auth.user else "unknown" + TestDefinitionNote.add_note(td_id, payload["text"], current_user) + st.session_state[NOTES_DIALOG_KEY] = _load_notes_dialog_data(td_id, df) + st.cache_data.clear() + + @with_database_session + def on_note_updated(payload: dict) -> None: + TestDefinitionNote.update_note(payload["id"], payload["text"]) + td_id = payload["test_definition_id"] + st.session_state[NOTES_DIALOG_KEY] = _load_notes_dialog_data(td_id, df) + + @with_database_session + def on_note_deleted(payload: dict) -> None: + TestDefinitionNote.delete_note(payload["id"]) + td_id = payload["test_definition_id"] + st.session_state[NOTES_DIALOG_KEY] = _load_notes_dialog_data(td_id, df) + st.cache_data.clear() + + def on_notes_dialog_closed(*_) -> None: + st.session_state.pop(NOTES_DIALOG_KEY, None) + @with_database_session def on_source_data_clicked(item_id: str) -> None: - row_df = df[df["test_result_id"] == item_id] - if not row_df.empty: - row = json.loads(row_df.to_json(orient="records", date_unit="s"))[0] + result_df = test_result_queries.get_test_results_by_ids([item_id]) + if not result_df.empty: + row = json.loads(result_df.to_json(orient="records", date_unit="s"))[0] MixpanelService().send_event("view-source-data", page=PAGE_PATH, test_type=row.get("test_name_short")) mask_pii = not session.auth.user_has_permission("view_pii") st.session_state[SOURCE_DATA_KEY] = _build_source_data(row, mask_pii=mask_pii) @with_database_session - def on_profiling_clicked(payload: dict) -> None: + def on_profiling_clicked(test_result_id: str) -> None: import testgen.ui.queries.profiling_queries as profiling_queries + lookup = test_result_queries.get_test_result_lookup(test_result_id) + if not lookup: + return column = profiling_queries.get_column_by_name( - payload["column_names"], payload["table_name"], payload["table_groups_id"], + lookup["column_names"], lookup["table_name"], lookup["table_groups_id"], ) if column: st.session_state[PROFILING_KEY] = make_json_safe(column) @@ -323,9 +359,13 @@ def on_source_data_closed(*_) -> None: @with_database_session def on_edit_test_clicked(payload: dict) -> None: - st.session_state[EDIT_TEST_KEY] = _build_edit_test_dialog_data( - payload.get("test_definition_id"), test_suite, - ) + test_result_id = payload.get("test_result_id") + if test_result_id: + lookup = test_result_queries.get_test_result_lookup(test_result_id) + td_id = lookup["test_definition_id"] if lookup else None + else: + td_id = payload.get("test_definition_id") + st.session_state[EDIT_TEST_KEY] = _build_edit_test_dialog_data(td_id, test_suite) @with_database_session def on_edit_test_saved(test_def: dict) -> None: @@ -333,18 +373,38 @@ def on_edit_test_saved(test_def: dict) -> None: filtered = {k: v for k, v in test_def.items() if k in valid_columns} TestDefinition(**filtered).save() st.session_state.pop(EDIT_TEST_KEY, None) + st.session_state.pop(VALIDATE_RESULT_KEY, None) st.cache_data.clear() + @with_database_session + def on_validate_test(test_def: dict) -> None: + from testgen.ui.views.test_definitions import validate_test + + table_group = TableGroup.get_minimal(test_suite.table_groups_id) + try: + validate_test(test_def, table_group) + st.session_state[VALIDATE_RESULT_KEY] = {"success": True, "message": "Validation is successful."} + except Exception as e: + st.session_state[VALIDATE_RESULT_KEY] = { + "success": False, + "message": f"Test validation failed with error: {e}", + } + def on_edit_test_closed(*_) -> None: st.session_state.pop(EDIT_TEST_KEY, None) + st.session_state.pop(VALIDATE_RESULT_KEY, None) @with_database_session - def on_issue_report_clicked(item_id: str) -> None: - row_df = df[df["test_result_id"] == item_id] - if not row_df.empty: - row = json.loads(row_df.to_json(orient="records", date_unit="s"))[0] - MixpanelService().send_event("download-issue-report", page=PAGE_PATH, issue_count=1) - st.session_state[ISSUE_REPORT_KEY] = [row] + def on_issue_report_clicked(payload: dict) -> None: + ids = payload.get("ids", []) + if not ids: + return + result_df = test_result_queries.get_test_results_by_ids(ids) + if result_df.empty: + return + rows = json.loads(result_df.to_json(orient="records", date_unit="s")) + MixpanelService().send_event("download-issue-report", page=PAGE_PATH, issue_count=len(rows)) + st.session_state[ISSUE_REPORT_KEY] = rows @with_database_session def on_score_refresh(*_) -> None: @@ -414,6 +474,8 @@ def on_sort_changed(payload: dict) -> None: "profiling_column": make_json_safe(profiling_column) if profiling_column else None, "source_data": make_json_safe(source_data) if source_data else None, "edit_test": make_json_safe(edit_test) if edit_test else None, + "validate_result": st.session_state.pop(VALIDATE_RESULT_KEY, None), + "notes_dialog": notes_dialog, "page": current_page, "total_count": total_count, "page_size": current_page_size, @@ -425,6 +487,11 @@ def on_sort_changed(payload: dict) -> None: on_DispositionChanged_change=on_disposition_changed, on_DispositionAll_change=on_disposition_all, on_FlagChanged_change=on_flag_changed, + on_NotesClicked_change=on_notes_clicked, + on_NoteAdded_change=on_note_added, + on_NoteUpdated_change=on_note_updated, + on_NoteDeleted_change=on_note_deleted, + on_NotesDialogClosed_change=on_notes_dialog_closed, on_SourceDataClicked_change=on_source_data_clicked, on_ProfilingClicked_change=on_profiling_clicked, on_ProfilingClosed_change=on_profiling_closed, @@ -432,6 +499,7 @@ def on_sort_changed(payload: dict) -> None: on_EditTestClicked_change=on_edit_test_clicked, on_EditTestSaved_change=on_edit_test_saved, on_EditTestClosed_change=on_edit_test_closed, + on_ValidateTest_change=on_validate_test, on_IssueReportClicked_change=on_issue_report_clicked, on_ScoreRefreshClicked_change=on_score_refresh, on_ExportAll_change=on_export_all, @@ -484,6 +552,34 @@ def _build_edit_test_dialog_data(test_definition_id: str | None, test_suite_mini } +def _load_notes_dialog_data(td_id_or_state: dict | str, df: pd.DataFrame) -> dict: + """Build notes dialog data from a test definition ID or existing state dict.""" + if isinstance(td_id_or_state, dict): + td_id = td_id_or_state.get("id") + test_label = { + "table": td_id_or_state.get("table_name", ""), + "column": td_id_or_state.get("column_name", ""), + "test": td_id_or_state.get("test_name_short", ""), + } + else: + td_id = td_id_or_state + row_df = df[df["test_definition_id"] == str(td_id)] + if row_df.empty: + test_label = {"table": "", "column": "", "test": ""} + else: + row = row_df.iloc[0] + test_label = {"table": row["table_name"], "column": row["column_names"], "test": row["test_name_short"]} + + current_user = session.auth.user.username if session.auth.user else "unknown" + notes = TestDefinitionNote.get_notes(td_id) + return { + "id": str(td_id), + "test_label": test_label, + "notes": notes, + "current_user": current_user, + } + + @with_database_session def _build_source_data(row: dict, mask_pii: bool = False) -> dict: """Fetch source data for a test result row and return a JSON-safe dict for JS rendering.""" diff --git a/testgen/ui/views/test_runs.py b/testgen/ui/views/test_runs.py index db3699c4..9dcb78b3 100644 --- a/testgen/ui/views/test_runs.py +++ b/testgen/ui/views/test_runs.py @@ -131,8 +131,10 @@ def on_run_tests_confirmed(data: dict) -> None: st.session_state.pop(TR_RUN_TESTS_RESULT_KEY, None) def on_go_to_test_runs(payload: dict) -> None: + st.session_state.pop(TR_RUN_TESTS_DIALOG_KEY, None) st.session_state.pop(TR_RUN_TESTS_RESULT_KEY, None) - Router().navigate(to="test-runs", with_args=payload) + st.cache_data.clear() + Router().queue_navigation(to="test-runs", with_args=payload) def on_run_tests_dialog_closed(*_) -> None: st.session_state.pop(TR_RUN_TESTS_DIALOG_KEY, None) diff --git a/testgen/ui/views/test_suites.py b/testgen/ui/views/test_suites.py index 5f4992e4..2123de27 100644 --- a/testgen/ui/views/test_suites.py +++ b/testgen/ui/views/test_suites.py @@ -50,7 +50,6 @@ class TestSuitesPage(Page): order=2, ) - @with_database_session def render(self, project_code: str, table_group_id: str | None = None, test_suite_name: str | None = None, **_kwargs) -> None: testgen.page_header( PAGE_TITLE, @@ -126,7 +125,7 @@ def on_run_notifications_clicked(*_) -> None: run_tests_data = { "title": "Run Tests", "project_code": project_code, - "test_suites": [{"value": str(ts.id), "label": ts.test_suite} for ts in test_suites], + "test_suites": [{"value": str(ts.id), "label": ts.test_suite} for ts in test_suites if str(ts.id) == str(run_tests_ts_id)], "default_test_suite_id": str(run_tests_ts_id) if run_tests_ts_id else None, "result": st.session_state.get(RUN_TESTS_RESULT_KEY), } @@ -182,8 +181,10 @@ def on_run_tests_confirmed(data: dict) -> None: st.session_state.pop(RUN_TESTS_RESULT_KEY, None) def on_go_to_test_runs(payload: dict) -> None: + st.session_state.pop(RUN_TESTS_DIALOG_KEY, None) st.session_state.pop(RUN_TESTS_RESULT_KEY, None) - Router().navigate(to="test-runs", with_args=payload) + st.cache_data.clear() + Router().queue_navigation(to="test-runs", with_args=payload) def on_run_tests_dialog_closed(*_) -> None: st.session_state.pop(RUN_TESTS_DIALOG_KEY, None) @@ -194,6 +195,7 @@ def on_lock_edited_tests(*_) -> None: lock_edited_tests(ts_id) st.session_state[GENERATE_TESTS_LOCK_RESULT_KEY] = "Edited tests have been successfully locked." + @with_database_session def on_generate_tests_confirmed(data: dict) -> None: selected_id = data.get("test_suite_id") selected_set = data.get("generation_set", "") @@ -287,8 +289,13 @@ def on_close_form_dialog(*_) -> None: ) -def on_test_suites_filtered(table_group_id: str | None = None) -> None: - Router().set_query_params({ "table_group_id": table_group_id }) +class TestSuiteFilters(typing.TypedDict): + table_group_id: str + test_suite_name: str + + +def on_test_suites_filtered(filters: TestSuiteFilters) -> None: + Router().set_query_params(filters) @with_database_session @@ -311,7 +318,7 @@ def save_test_suite_form(data: dict) -> None: test_suite.save() st.session_state.pop("ts_form_dialog:result", None) st.session_state.pop(EDIT_DIALOG_KEY, None) - TestSuite.select_summary.clear() + get_test_suite_summaries.clear() st.session_state[PAGE_RESULT_KEY] = {"success": True, "message": "Changes have been saved successfully."} else: table_group = TableGroup.get(data.get("table_groups_id")) @@ -330,7 +337,7 @@ def save_test_suite_form(data: dict) -> None: test_suite.save() st.session_state.pop("ts_form_dialog:result", None) st.session_state.pop(ADD_DIALOG_KEY, None) - TestSuite.select_summary.clear() + get_test_suite_summaries.clear() st.session_state[PAGE_RESULT_KEY] = {"success": True, "message": "New test suite added successfully."} From c07b22fa6b07ec265985911547acfff263b1dae4 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Wed, 15 Apr 2026 12:05:48 -0300 Subject: [PATCH 055/123] =?UTF-8?q?fix(test-types):=20fix=20LOV=5FAll=20SQ?= =?UTF-8?q?L=20for=20SAP=20HANA=20=E2=80=94=20use=20STRING=5FAGG=20not=20L?= =?UTF-8?q?ISTAGG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP HANA doesn't support LISTAGG or WITHIN GROUP. Use STRING_AGG with ORDER BY inside the function call, matching the SAP HANA SQL reference. Fixed both execution measure and source data lookup templates. Co-Authored-By: Claude Opus 4.6 (1M context) --- testgen/template/dbsetup_test_types/test_types_LOV_All.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testgen/template/dbsetup_test_types/test_types_LOV_All.yaml b/testgen/template/dbsetup_test_types/test_types_LOV_All.yaml index 57dd6788..6a343ebc 100644 --- a/testgen/template/dbsetup_test_types/test_types_LOV_All.yaml +++ b/testgen/template/dbsetup_test_types/test_types_LOV_All.yaml @@ -110,7 +110,7 @@ test_types: test_type: LOV_All sql_flavor: sap_hana measure: |- - (SELECT LISTAGG(sub_val, '|') WITHIN GROUP (ORDER BY sub_val) FROM (SELECT DISTINCT {COLUMN_NAME} AS sub_val FROM "{SCHEMA_NAME}"."{TABLE_NAME}" WHERE {SUBSET_CONDITION})) + (SELECT STRING_AGG(sub_val, '|' ORDER BY sub_val) FROM (SELECT DISTINCT {COLUMN_NAME} AS sub_val FROM "{SCHEMA_NAME}"."{TABLE_NAME}" WHERE {SUBSET_CONDITION})) test_operator: <> test_condition: |- {THRESHOLD_VALUE} @@ -200,6 +200,6 @@ test_types: lookup_type: null lookup_redactable_columns: lov lookup_query: |- - SELECT LISTAGG("{COLUMN_NAME}", '|') WITHIN GROUP (ORDER BY "{COLUMN_NAME}") AS lov FROM (SELECT DISTINCT "{COLUMN_NAME}" FROM "{TARGET_SCHEMA}"."{TABLE_NAME}") HAVING LISTAGG("{COLUMN_NAME}", '|') WITHIN GROUP (ORDER BY "{COLUMN_NAME}") <> {THRESHOLD_VALUE} LIMIT {LIMIT} + SELECT STRING_AGG("{COLUMN_NAME}", '|' ORDER BY "{COLUMN_NAME}") AS lov FROM (SELECT DISTINCT "{COLUMN_NAME}" FROM "{TARGET_SCHEMA}"."{TABLE_NAME}") HAVING STRING_AGG("{COLUMN_NAME}", '|' ORDER BY "{COLUMN_NAME}") <> {THRESHOLD_VALUE} LIMIT {LIMIT} error_type: Test Results test_templates: [] From 79c4acc07b04e21159ea45191fcdb44714256fe4 Mon Sep 17 00:00:00 2001 From: Luis Date: Fri, 10 Apr 2026 16:57:09 -0400 Subject: [PATCH 056/123] refactor(ui): add loading indicators to connection and table group actions Add loading state to button component and use it in Test Connect and Verify Access actions to provide visual feedback during async operations. Co-Authored-By: Claude Opus 4.6 (1M context) --- testgen/common/models/test_definition.py | 3 +- .../frontend/js/pages/hygiene_issues.js | 2 +- .../frontend/js/pages/monitors_dashboard.js | 1 + .../frontend/js/pages/score_details.js | 2 +- .../frontend/js/pages/score_explorer.js | 6 ++ .../frontend/js/pages/test_definitions.js | 29 +++++-- .../frontend/js/pages/test_results.js | 82 +++++++------------ .../frontend/js/pages/test_suites.js | 24 +++++- .../frontend/js/shared/data_preview_dialog.js | 21 +---- .../frontend/js/shared/source_data_dialog.js | 30 +++---- testgen/ui/queries/profiling_queries.py | 6 +- testgen/ui/queries/test_result_queries.py | 47 +++++++++++ testgen/ui/static/css/shared.css | 2 +- testgen/ui/static/js/components/button.js | 35 +++++++- testgen/ui/static/js/components/code.js | 1 + .../static/js/components/connection_form.js | 19 ++++- testgen/ui/static/js/components/select.js | 15 ++-- .../static/js/components/table_group_test.js | 24 ++++-- .../components/wizard_progress_indicator.js | 20 ++--- testgen/ui/views/data_catalog.py | 45 ++++++++-- testgen/ui/views/hygiene_issues.py | 3 +- testgen/ui/views/profiling_results.py | 12 +-- testgen/ui/views/profiling_runs.py | 12 ++- testgen/ui/views/score_details.py | 16 +++- testgen/ui/views/score_explorer.py | 38 ++++++++- testgen/ui/views/table_groups.py | 10 ++- testgen/ui/views/test_definitions.py | 36 ++++++-- testgen/ui/views/test_results.py | 32 +++++++- testgen/ui/views/test_runs.py | 11 ++- testgen/ui/views/test_suites.py | 13 ++- 30 files changed, 420 insertions(+), 177 deletions(-) diff --git a/testgen/common/models/test_definition.py b/testgen/common/models/test_definition.py index d67eb41a..12e939cc 100644 --- a/testgen/common/models/test_definition.py +++ b/testgen/common/models/test_definition.py @@ -360,9 +360,10 @@ def copy( target_table_name: str | None = None, target_column_name: str | None = None, ) -> None: - modified_columns = [cls.table_groups_id, cls.profile_run_id, cls.test_suite_id, cls.last_auto_gen_date] + modified_columns = [cls.id, cls.table_groups_id, cls.profile_run_id, cls.test_suite_id, cls.last_auto_gen_date] select_columns = [ + func.gen_random_uuid().label("id"), literal(target_table_group_id).label("table_groups_id"), case( (cls.table_groups_id == target_table_group_id, cls.profile_run_id), diff --git a/testgen/ui/components/frontend/js/pages/hygiene_issues.js b/testgen/ui/components/frontend/js/pages/hygiene_issues.js index b24cec6e..64ab30f0 100644 --- a/testgen/ui/components/frontend/js/pages/hygiene_issues.js +++ b/testgen/ui/components/frontend/js/pages/hygiene_issues.js @@ -314,7 +314,7 @@ const HygieneIssues = (/** @type Properties */ props) => { const row = buildTableRow(item); if (isMulti) { const checked = getCheckboxState(item.id); - row._checkbox = Checkbox({ label: '', checked, style: 'pointer-events: none' }); + row._checkbox = () => Checkbox({ label: '', checked, style: 'pointer-events: none' }); } return row; }); diff --git a/testgen/ui/components/frontend/js/pages/monitors_dashboard.js b/testgen/ui/components/frontend/js/pages/monitors_dashboard.js index 436674b9..5809d7ec 100644 --- a/testgen/ui/components/frontend/js/pages/monitors_dashboard.js +++ b/testgen/ui/components/frontend/js/pages/monitors_dashboard.js @@ -304,6 +304,7 @@ const MonitorsDashboard = (/** @type Properties */ props) => { span({ class: `has-monitors dot text-disabled ${option.has_monitors ? '' : 'invisible'}` }), option.label, ), + rawLabel: option.label, })), allowNull: false, style: 'font-size: 14px;', diff --git a/testgen/ui/components/frontend/js/pages/score_details.js b/testgen/ui/components/frontend/js/pages/score_details.js index c0111054..37297505 100644 --- a/testgen/ui/components/frontend/js/pages/score_details.js +++ b/testgen/ui/components/frontend/js/pages/score_details.js @@ -106,7 +106,7 @@ const ScoreDetails = (/** @type {Properties} */ props) => { props.score_type, (project_code, name, score_type, category, drilldown) => emit( 'LinkClicked', - { href: 'quality-dashboard:score-details', params: { definition_id: getValue(props.score).id, project_code, score_type, category, drilldown } + { href: 'quality-dashboard:score-details', params: { definition_id: getValue(props.score).id, project_code, score_type, category, drilldown: drilldown ?? '' } }), emit, ) diff --git a/testgen/ui/components/frontend/js/pages/score_explorer.js b/testgen/ui/components/frontend/js/pages/score_explorer.js index f23630d6..81aeb4ee 100644 --- a/testgen/ui/components/frontend/js/pages/score_explorer.js +++ b/testgen/ui/components/frontend/js/pages/score_explorer.js @@ -50,6 +50,7 @@ * @property {boolean} is_new * @property {Permissions} permissions * @property {object?} column_selector_dialog + * @property {object?} profiling_column */ import van from '/app/static/js/van.min.js'; import { createEmitter, debounce, getValue, loadStylesheet, afterMount, getRandomId, isEqual } from '/app/static/js/utils.js'; @@ -64,6 +65,7 @@ import { IssuesTable } from '/app/static/js/components/score_issues.js'; import { EmptyState, EMPTY_STATE_MESSAGE } from '/app/static/js/components/empty_state.js'; import { ColumnFilter } from '/app/static/js/components/explorer_column_selector.js'; import { ColumnSelectorDialog } from '/app/static/js/components/column_selector_dialog.js'; +import { ProfilingResultsDialog } from '../shared/profiling_results_dialog.js'; const { div, i, span } = van.tags; @@ -157,6 +159,10 @@ const ScoreExplorer = (/** @type {Properties} */ props) => { columns: van.derive(() => getValue(props.column_selector_dialog)?.columns ?? []), onClose: () => emit('ColumnSelectorDialogClosed', {}), }), + ProfilingResultsDialog({ emit, + profilingColumn: props.profiling_column, + onClose: () => emit('ProfilingResultsDialogClosed', {}), + }), ); }; diff --git a/testgen/ui/components/frontend/js/pages/test_definitions.js b/testgen/ui/components/frontend/js/pages/test_definitions.js index 831710b2..abb0d141 100644 --- a/testgen/ui/components/frontend/js/pages/test_definitions.js +++ b/testgen/ui/components/frontend/js/pages/test_definitions.js @@ -309,7 +309,7 @@ const TestDefinitions = (/** @type object */ props) => { }; if (isMulti) { const checked = getCheckboxState(item.id); - row._checkbox = Checkbox({ label: '', checked, style: 'pointer-events: none' }); + row._checkbox = () => Checkbox({ label: '', checked, style: 'pointer-events: none' }); } return row; }); @@ -419,7 +419,8 @@ const TestDefinitions = (/** @type object */ props) => { if (!canEdit.val) return ''; const selected = selectedRows.val; const isAll = selectAll.val; - const hasSelection = isAll || selected.length > 0; + const count = selectedIdsCount.val; + const hasSelection = isAll || (multiSelectMode.val ? count > 0 : selected.length > 0); const isSingle = !isAll && selected.length === 1; // Only send minimal fields to avoid serialization issues const minimalSelected = () => selected.map(r => ({ @@ -444,11 +445,14 @@ const TestDefinitions = (/** @type object */ props) => { if (!canDisposition.val) return ''; const selected = selectedRows.val; const isAll = selectAll.val; - const noSelection = !isAll && !selected.length; - const allActive = !isAll && selected.length > 0 && selected.every(r => r.test_active_display === 'Yes'); - const allInactive = !isAll && selected.length > 0 && selected.every(r => r.test_active_display === 'No'); - const allLocked = !isAll && selected.length > 0 && selected.every(r => r.lock_refresh_display === 'Yes'); - const allUnlocked = !isAll && selected.length > 0 && selected.every(r => r.lock_refresh_display === 'No'); + const count = selectedIdsCount.val; + // Use cross-page count in multi-select; current-page items in single-select + const noSelection = multiSelectMode.val ? !isAll && count === 0 : !selected.length; + // Skip per-item attribute checks in multi-select (can't see all pages) + const allActive = !multiSelectMode.val && selected.length > 0 && selected.every(r => r.test_active_display === 'Yes'); + const allInactive = !multiSelectMode.val && selected.length > 0 && selected.every(r => r.test_active_display === 'No'); + const allLocked = !multiSelectMode.val && selected.length > 0 && selected.every(r => r.lock_refresh_display === 'Yes'); + const allUnlocked = !multiSelectMode.val && selected.length > 0 && selected.every(r => r.lock_refresh_display === 'No'); const emitAttribute = (attribute, value) => { if (isAll) { emit('UpdateAttributeAll', { payload: { attribute, value } }); @@ -470,11 +474,12 @@ const TestDefinitions = (/** @type object */ props) => { }) : '', canEdit.val ? div({ class: 'td-header-separator' }) : '', Button({ - type: 'icon', icon: 'flag', tooltip: 'Flag selected', disabled: noSelection || selected.every(r => r.flagged), + type: 'icon', icon: 'flag', tooltip: 'Flag selected', + disabled: noSelection || (!multiSelectMode.val && selected.length > 0 && selected.every(r => r.flagged)), onclick: () => emitAttribute('flagged', true), }), ClearFlagButton({ - disabled: noSelection || selected.every(r => !r.flagged), + disabled: noSelection || (!multiSelectMode.val && selected.length > 0 && selected.every(r => !r.flagged)), onclick: () => emitAttribute('flagged', false), }), ); @@ -1322,6 +1327,8 @@ const CopyMoveDialogComponent = ({ open, info, onClose }, emit) => { van.derive(() => { const tgId = targetTgId.val; const tsId = targetTsId.val; + const tableName = targetTableName.val; + const colName = targetColumnName.val; const di = dialogInfo.val; if (tgId && tsId && di?.selected) { emit('CopyMoveTargetChanged', { @@ -1329,6 +1336,8 @@ const CopyMoveDialogComponent = ({ open, info, onClose }, emit) => { selected: di.selected, target_table_group_id: tgId, target_test_suite_id: tsId, + target_table_name: tableName || null, + target_column_name: colName || null, }, }); } @@ -1435,6 +1444,7 @@ const CopyMoveDialogComponent = ({ open, info, onClose }, emit) => { type: 'stroked', color: 'basic', label: 'Copy', + width: 'auto', disabled: !movableIds.val.length || !targetTsId.val, onclick: () => emit('CopyConfirmed', { payload: buildPayload() }), }), @@ -1442,6 +1452,7 @@ const CopyMoveDialogComponent = ({ open, info, onClose }, emit) => { type: 'flat', color: 'primary', label: 'Move', + width: 'auto', disabled: !movableIds.val.length || !targetTsId.val, onclick: () => emit('MoveConfirmed', { payload: buildPayload() }), }), diff --git a/testgen/ui/components/frontend/js/pages/test_results.js b/testgen/ui/components/frontend/js/pages/test_results.js index 978502da..3c8aa0fe 100644 --- a/testgen/ui/components/frontend/js/pages/test_results.js +++ b/testgen/ui/components/frontend/js/pages/test_results.js @@ -58,6 +58,7 @@ import { TestResultsChart } from './test_results_chart.js'; import { TestDefinitionSummary } from './test_definition_summary.js'; import { EditDialogComponent } from './test_definitions.js'; import { TestDefinitionNotes } from './test_definition_notes.js'; +import { withTooltip } from '/app/static/js/components/tooltip.js'; const { button: btn, div, i: icon, span, h3, h4, p, small } = van.tags; @@ -71,7 +72,7 @@ const STATUS_COLORS = { /** Composite icon button: flag with a diagonal strikethrough (pen_size_1 rotated). */ const ClearFlagButton = ({ disabled, onclick }) => { - return btn( + return withTooltip(btn( { class: 'tg-button tg-icon-button tg-basic-button', tooltip: 'Clear flag', @@ -85,7 +86,7 @@ const ClearFlagButton = ({ disabled, onclick }) => { icon({ class: 'material-symbols-rounded', style: 'font-size: 20px;' }, 'flag'), icon({ class: 'material-symbols-rounded', style: 'font-size: 24px; position: absolute; top: -3px; left: -3px; transform: rotate(90deg);' }, 'pen_size_1'), ), - ); + ), { text: 'Clear flag' }); }; const FLAGGED_FILTER_OPTIONS = [ @@ -404,7 +405,7 @@ const TestResults = (/** @type Properties */ props) => { const row = buildTableRow(item); if (isMulti) { const checked = getCheckboxState(item.test_result_id); - row._checkbox = Checkbox({ label: '', checked, style: 'pointer-events: none' }); + row._checkbox = () => Checkbox({ label: '', checked, style: 'pointer-events: none' }); } return row; }); @@ -526,24 +527,6 @@ const TestResults = (/** @type Properties */ props) => { return selectedRowId.rawVal ? [selectedRowId.rawVal] : []; }; - const allSelectedArePassed = van.derive(() => { - if (multiSelect.val) { - if (selectAll.val) { - // If filtering to only Passed, all are passed - return statusFilter.val === 'Passed'; - } - const count = selectedIdsCount.val; // reactive dependency on selection changes - if (count === 0) return true; - const currentItems = items.val; - const idSet = new Set(selectedIds); - return currentItems - .filter(r => idSet.has(r.test_result_id)) - .every(r => r.result_status === 'Passed'); - } - const row = selectedRow.val; - return !row || row.result_status === 'Passed'; - }); - const onDisposition = (status) => { if (selectAll.rawVal) { emit('DispositionAll', { payload: { filters: getCurrentFilters(), status } }); @@ -578,28 +561,13 @@ const TestResults = (/** @type Properties */ props) => { div({ class: 'fx-flex' }), () => { if (!permissions.val.can_disposition) return ''; - // Compute disabled state directly from reactive values (not via - // an intermediate derive) so VanJS always re-renders this binding - // when the selection changes — matches the test_definitions pattern. const isAll = selectAll.val; const count = selectedIdsCount.val; - let disabled; - if (multiSelect.val) { - if (isAll) { - disabled = statusFilter.val === 'Passed'; - } else if (count === 0) { - disabled = true; - } else { - const currentItems = items.val; - const idSet = new Set(selectedIds); - disabled = currentItems - .filter(r => idSet.has(r.test_result_id)) - .every(r => r.result_status === 'Passed'); - } - } else { - const row = selectedRow.val; - disabled = !row || row.result_status === 'Passed'; - } + // In multi-select mode, just check if there's a selection — we can't + // reliably determine item status across pages with server-side pagination. + const disabled = multiSelect.val + ? !isAll && count === 0 + : (() => { const row = selectedRow.val; return !row || row.result_status === 'Passed'; })(); return div( { class: 'flex-row fx-gap-1' }, Button({ type: 'icon', icon: 'check_circle', tooltip: 'Confirm selected as relevant', disabled, onclick: () => onDisposition('Confirmed') }), @@ -614,26 +582,32 @@ const TestResults = (/** @type Properties */ props) => { const isAll = selectAll.val; const count = selectedIdsCount.val; const noSelection = !isAll && count === 0 && !selectedRow.val; - const selected = (() => { - if (isAll) return items.val; - if (count > 0) { - const idSet = new Set(selectedIds); - return items.val.filter(r => idSet.has(r.test_result_id)); + + const onFlag = (value) => { + if (isAll) { + emit('FlagAll', { payload: { filters: getCurrentFilters(), value } }); + } else if (count > 0) { + // Multi-select: send result IDs — backend resolves to definition IDs + emit('FlagChanged', { payload: { test_result_ids: getSelectedResultIds(), value } }); + } else { + // Single-select: send definition ID directly + const row = selectedRow.rawVal; + if (row?.test_definition_id) { + emit('FlagChanged', { payload: { test_definition_ids: [row.test_definition_id], value } }); + } } - const row = selectedRow.val; - return row ? [row] : []; - })(); - const getTestDefinitionIds = () => [...new Set(selected.filter(r => r.test_definition_id).map(r => r.test_definition_id))]; + }; + return div( { class: 'flex-row fx-gap-1' }, span({ style: 'width: 0px; height: 24px; border-right: 1px dashed var(--border-color);'}, ''), Button({ - type: 'icon', icon: 'flag', tooltip: 'Flag selected', disabled: noSelection || selected.every(r => r.flagged), - onclick: () => emit('FlagChanged', { payload: { test_definition_ids: getTestDefinitionIds(), value: true } }), + type: 'icon', icon: 'flag', tooltip: 'Flag selected', disabled: noSelection, + onclick: () => onFlag(true), }), ClearFlagButton({ - disabled: noSelection || selected.every(r => !r.flagged), - onclick: () => emit('FlagChanged', { payload: { test_definition_ids: getTestDefinitionIds(), value: false } }), + disabled: noSelection, + onclick: () => onFlag(false), }), ); }, diff --git a/testgen/ui/components/frontend/js/pages/test_suites.js b/testgen/ui/components/frontend/js/pages/test_suites.js index db8b6f6a..2a2c8b63 100644 --- a/testgen/ui/components/frontend/js/pages/test_suites.js +++ b/testgen/ui/components/frontend/js/pages/test_suites.js @@ -38,6 +38,7 @@ import { Toggle } from '/app/static/js/components/toggle.js'; import { Checkbox } from '/app/static/js/components/checkbox.js'; import { Input } from '/app/static/js/components/input.js'; import { ExpanderToggle } from '/app/static/js/components/expander_toggle.js'; +import { required } from '/app/static/js/form_validators.js'; const { b, div, h4, pre, small, span, i } = van.tags; @@ -83,6 +84,12 @@ const TestSuites = (/** @type Properties */ props) => { componentName: van.state(''), }; + const formValidity = { + testSuiteName: van.state(false), + tableGroupId: van.state(false), + }; + const saveDisabled = van.derive(() => !formValidity.testSuiteName.val || !formValidity.tableGroupId.val); + van.derive(() => { const info = formDialogInfo.val; if (!info?.open) return; @@ -96,6 +103,8 @@ const TestSuites = (/** @type Properties */ props) => { formState.componentKey.val = v.component_key ?? ''; formState.componentType.val = v.component_type ?? 'dataset'; formState.componentName.val = v.component_name ?? ''; + formValidity.testSuiteName.val = !!v.test_suite; + formValidity.tableGroupId.val = !!v.table_groups_id; }); const closeFormDialog = () => { @@ -149,7 +158,7 @@ const TestSuites = (/** @type Properties */ props) => { width: 300, clearable: true, value: testSuiteNameFilter, - onChange: (value) => testSuiteNameFilter.val = value || null, + onChange: (value) => testSuiteNameFilter.val = value || '', }), ), div( @@ -382,16 +391,24 @@ const TestSuites = (/** @type Properties */ props) => { value: formState.testSuiteName, disabled: isEdit, style: 'flex: 1;', - onChange: (value) => { formState.testSuiteName.val = value; }, + validators: [required], + onChange: (value, validity) => { + formState.testSuiteName.val = value; + formValidity.testSuiteName.val = validity.valid; + }, }), Select({ label: 'Table Group', value: formState.tableGroupId, options: tableGroups, allowNull: false, + required: true, disabled: isEdit, style: 'flex: 1;', - onChange: (value) => { formState.tableGroupId.val = value; }, + onChange: (value) => { + formState.tableGroupId.val = value; + formValidity.tableGroupId.val = !!value; + }, portalClass: 'ts-form--select', }), ), @@ -471,6 +488,7 @@ const TestSuites = (/** @type Properties */ props) => { label: isEdit ? 'Save' : 'Add', width: 'auto', style: 'width: auto;', + disabled: saveDisabled, onclick: () => emit('SaveTestSuiteForm', { payload: { mode: info.mode, diff --git a/testgen/ui/components/frontend/js/shared/data_preview_dialog.js b/testgen/ui/components/frontend/js/shared/data_preview_dialog.js index c96ff423..acb188ed 100644 --- a/testgen/ui/components/frontend/js/shared/data_preview_dialog.js +++ b/testgen/ui/components/frontend/js/shared/data_preview_dialog.js @@ -2,6 +2,7 @@ import van from '/app/static/js/van.min.js'; import { getValue, loadStylesheet } from '/app/static/js/utils.js'; import { Dialog } from '/app/static/js/components/dialog.js'; import { Table } from '/app/static/js/components/table.js'; +import { Alert } from '/app/static/js/components/alert.js'; const { div, span } = van.tags; @@ -39,10 +40,10 @@ const DataPreviewDialog = (props) => { if (!d) return ''; if (d.status === 'ND' || d.status === 'NA') { - return div({ class: 'tg-dp--info-msg' }, d.message); + return Alert({ type: 'info', class: 'tg-sd--msg' }, d.message); } if (d.status === 'ERR') { - return div({ class: 'tg-dp--error-msg' }, d.message); + return Alert({ type: 'error', class: 'tg-sd--msg' }, d.message); } if (d.rows?.length) { @@ -67,27 +68,13 @@ const DataPreviewDialog = (props) => { ); } - return div({ class: 'tg-dp--info-msg' }, 'No data available.'); + return Alert({ type: 'info', class: 'tg-sd--msg' }, 'No data available.'); }, ); }; const stylesheet = new CSSStyleSheet(); stylesheet.replace(` -.tg-dp--info-msg { - padding: 8px 12px; - background: var(--blue-light, #e3f2fd); - border-radius: 4px; - color: var(--primary-text-color); - font-size: 14px; -} -.tg-dp--error-msg { - padding: 8px 12px; - background: var(--red-light, #ffebee); - border-radius: 4px; - color: var(--red, #c62828); - font-size: 14px; -} .tg-dp--null, .tg-dp--empty { color: var(--disabled-text-color); diff --git a/testgen/ui/components/frontend/js/shared/source_data_dialog.js b/testgen/ui/components/frontend/js/shared/source_data_dialog.js index 7cc609d5..8645c18c 100644 --- a/testgen/ui/components/frontend/js/shared/source_data_dialog.js +++ b/testgen/ui/components/frontend/js/shared/source_data_dialog.js @@ -3,8 +3,9 @@ import { getValue, loadStylesheet } from '/app/static/js/utils.js'; import { Dialog } from '/app/static/js/components/dialog.js'; import { Table } from '/app/static/js/components/table.js'; import { Code } from '/app/static/js/components/code.js'; +import { Alert } from '/app/static/js/components/alert.js'; -const { div, span, h4, p, small } = van.tags; +const { div, h4, small } = van.tags; /** * Shared dialog for displaying source data (used by test_results and hygiene_issues). @@ -49,12 +50,12 @@ const SourceDataDialog = (props) => { // Status-based content if (d.status === 'ND' || d.status === 'NA') { - children.push(div({ class: 'tg-sd--info-msg' }, d.message)); + children.push(Alert({ type: 'info', class: 'tg-sd--msg' }, d.message)); } else if (d.status === 'ERR') { - children.push(div({ class: 'tg-sd--error-msg' }, d.message)); + children.push(Alert({ type: 'error', class: 'tg-sd--msg' }, d.message)); } else if (d.rows?.length) { if (d.message) { - children.push(div({ class: 'tg-sd--info-msg mb-2' }, d.message)); + children.push(Alert({ type: 'info', class: 'tg-sd--msg' }, d.message)); } if (d.truncated) { children.push(small({ class: 'text-caption', style: 'text-align: right; display: block; margin-bottom: 4px' }, '* Top 500 records displayed')); @@ -73,13 +74,13 @@ const SourceDataDialog = (props) => { ), ); } else if (!d.message) { - children.push(div({ class: 'tg-sd--error-msg' }, 'An unknown error was encountered.')); + children.push(Alert({ type: 'error', class: 'tg-sd--msg' }, 'An unknown error was encountered.')); } if (d.sql_query) { children.push( h4({ style: 'margin: 12px 0 4px' }, 'SQL Query'), - Code({ language: 'sql' }, d.sql_query), + Code({ language: 'sql', class: 'tg-sg--sql-query-code' }, d.sql_query), ); } @@ -90,19 +91,12 @@ const SourceDataDialog = (props) => { const stylesheet = new CSSStyleSheet(); stylesheet.replace(` -.tg-sd--info-msg { - padding: 8px 12px; - background: var(--blue-light, #e3f2fd); - border-radius: 4px; - color: var(--primary-text-color); - font-size: 14px; +.tg-sd--msg { + font-size: 14px !important; } -.tg-sd--error-msg { - padding: 8px 12px; - background: var(--red-light, #ffebee); - border-radius: 4px; - color: var(--red, #c62828); - font-size: 14px; + +.tg-sg--sql-query-code { + max-height: 300px; } `); diff --git a/testgen/ui/queries/profiling_queries.py b/testgen/ui/queries/profiling_queries.py index 8ca4e4c0..ba6eed40 100644 --- a/testgen/ui/queries/profiling_queries.py +++ b/testgen/ui/queries/profiling_queries.py @@ -289,7 +289,8 @@ def get_column_by_id( condition = "WHERE column_chars.column_id = :column_id" params = {"column_id": column_id} - return get_columns_by_condition(condition, params, include_tags, include_has_test_runs, include_active_tests, include_scores)[0] + results = get_columns_by_condition(condition, params, include_tags, include_has_test_runs, include_active_tests, include_scores) + return results[0] if results else None @st.cache_data(show_spinner="Loading data ...") @@ -312,7 +313,8 @@ def get_column_by_name( "table_name": table_name, "table_group_id": table_group_id, } - return get_columns_by_condition(condition, params, include_tags, include_has_test_runs, include_active_tests, include_scores)[0] + results = get_columns_by_condition(condition, params, include_tags, include_has_test_runs, include_active_tests, include_scores) + return results[0] if results else None def get_columns_by_id( diff --git a/testgen/ui/queries/test_result_queries.py b/testgen/ui/queries/test_result_queries.py index 109293c2..860a6a08 100644 --- a/testgen/ui/queries/test_result_queries.py +++ b/testgen/ui/queries/test_result_queries.py @@ -322,6 +322,53 @@ def get_test_result_ids( return df["test_result_id"].tolist() +def get_test_definition_ids_for_results(test_result_ids: list[str]) -> list[str]: + """Resolve test result IDs to their distinct test definition IDs.""" + if not test_result_ids: + return [] + query = """ + SELECT DISTINCT r.test_definition_id::VARCHAR + FROM test_results r + WHERE r.id = ANY(CAST(:ids AS UUID[])) + AND r.test_definition_id IS NOT NULL; + """ + df = fetch_df_from_db(query, {"ids": test_result_ids}) + return df["test_definition_id"].tolist() + + +def get_test_definition_ids_for_run( + run_id: str, + test_statuses: list[str] | None = None, + test_type_id: str | None = None, + table_name: str | None = None, + column_name: str | None = None, + action: Literal["Confirmed", "Dismissed", "Muted", "No Action"] | None = None, + flagged: bool | None = None, +) -> list[str]: + """Get distinct test definition IDs for all results matching the given filters.""" + where_clause = _build_where_clause(test_statuses, test_type_id, table_name, column_name, action) + flagged_join = "" + flagged_clause = "" + if flagged is not None: + flagged_join = "INNER JOIN test_definitions td ON (r.test_definition_id = td.id)" + flagged_clause = "AND td.flagged = :flagged" + query = f""" + SELECT DISTINCT r.test_definition_id::VARCHAR + FROM test_results r + {flagged_join} + WHERE + r.test_run_id = :run_id + AND r.test_definition_id IS NOT NULL + {where_clause} + {flagged_clause}; + """ + params = _build_params(run_id, test_statuses, test_type_id, table_name, column_name, action) + if flagged is not None: + params["flagged"] = flagged + df = fetch_df_from_db(query, params) + return df["test_definition_id"].tolist() + + @st.cache_data(show_spinner=False) def get_filter_options(run_id: str) -> dict: query = """ diff --git a/testgen/ui/static/css/shared.css b/testgen/ui/static/css/shared.css index 9f111b6d..4d5b1f0b 100644 --- a/testgen/ui/static/css/shared.css +++ b/testgen/ui/static/css/shared.css @@ -303,7 +303,7 @@ body { } .fx-justify-flex-end { - justify-items: flex-end; + justify-content: flex-end; } .fx-justify-content-flex-end { diff --git a/testgen/ui/static/js/components/button.js b/testgen/ui/static/js/components/button.js index 2df63f75..bafb654a 100644 --- a/testgen/ui/static/js/components/button.js +++ b/testgen/ui/static/js/components/button.js @@ -12,10 +12,12 @@ * @property {(string|null)} id * @property {(Function|null)} onclick * @property {(bool)} disabled + * @property {(bool)} loading + * @property {('normal' | 'small')?} size * @property {string?} style * @property {string?} testId */ -import { emitEvent, getValue, loadStylesheet } from '../utils.js'; +import { getValue, loadStylesheet } from '../utils.js'; import van from '../van.min.js'; import { withTooltip } from './tooltip.js'; @@ -35,15 +37,17 @@ const Button = (/** @type Properties */ props) => { const width = getValue(props.width); const isIconOnly = getValue(props.type) === BUTTON_TYPE.ICON || (getValue(props.icon) && !getValue(props.label)); - const onClickHandler = props.onclick || (() => emitEvent('ButtonClicked')); + const onClickHandler = props.onclick || (() => {}); + const isDisabled = () => getValue(props.disabled) || getValue(props.loading); + return withTooltip( button( { id: getValue(props.id) ?? undefined, - class: () => `tg-button tg-${getValue(props.type)}-button tg-${getValue(props.color) ?? 'basic'}-button ${getValue(props.type) !== 'icon' && isIconOnly ? 'tg-icon-button' : ''}`, + class: () => `tg-button tg-${getValue(props.size ?? 'normal')}-button tg-${getValue(props.type)}-button tg-${getValue(props.color) ?? 'basic'}-button ${getValue(props.type) !== 'icon' && isIconOnly ? 'tg-icon-button' : ''}`, style: () => `width: ${isIconOnly ? '' : (width ?? '100%')}; ${getValue(props.style)}`, onclick: onClickHandler, - disabled: props.disabled, + disabled: isDisabled, 'data-testid': getValue(props.testId) ?? '', }, span({class: 'tg-button-focus-state-indicator'}, ''), @@ -52,6 +56,7 @@ const Button = (/** @type Properties */ props) => { style: () => `font-size: ${getValue(props.iconSize) ?? DEFAULT_ICON_SIZE}px;` }, props.icon) : undefined, !isIconOnly ? span(props.label) : undefined, + () => getValue(props.loading) ? span({ class: 'tg-button-spinner' }) : '', ), { text: props.tooltip, position: props.tooltipPosition }, ); }; @@ -78,6 +83,12 @@ button.tg-button { font-size: 14px; } +button.tg-button.tg-small-button { + height: 32px; + padding-top: 4px; + padding-bottom: 4px; +} + button.tg-button .tg-button-focus-state-indicator { border-radius: inherit; overflow: hidden; @@ -187,6 +198,22 @@ button.tg-button.tg-warn-button.tg-stroked-button { background: var(--button-warn-stroked-background); } /* ... */ + +/* Loading spinner */ +.tg-button-spinner { + width: 16px; + height: 16px; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: tg-spin 0.6s linear infinite; + margin-left: 8px; + flex-shrink: 0; +} + +@keyframes tg-spin { + to { transform: rotate(360deg); } +} `); export { Button }; diff --git a/testgen/ui/static/js/components/code.js b/testgen/ui/static/js/components/code.js index e3f401d9..414bd968 100644 --- a/testgen/ui/static/js/components/code.js +++ b/testgen/ui/static/js/components/code.js @@ -63,6 +63,7 @@ const stylesheet = new CSSStyleSheet(); stylesheet.replace(` .tg-code { position: relative; + overflow-y: auto; } .tg-code pre { margin: 0; diff --git a/testgen/ui/static/js/components/connection_form.js b/testgen/ui/static/js/components/connection_form.js index 53100d97..f5b5247f 100644 --- a/testgen/ui/static/js/components/connection_form.js +++ b/testgen/ui/static/js/components/connection_form.js @@ -61,7 +61,7 @@ import van from '../van.min.js'; import { Button } from './button.js'; import { Alert } from './alert.js'; -import { getValue, emitEvent, loadStylesheet, isEqual } from '../utils.js'; +import { getValue, loadStylesheet, isEqual } from '../utils.js'; import { Input } from './input.js'; import { Slider } from './slider.js'; import { Select } from './select.js'; @@ -94,6 +94,7 @@ const defaultPorts = { * @returns {HTMLElement} */ const ConnectionForm = (props, saveButton) => { + const emit = props.emit; loadStylesheet('connectionform', stylesheet); const connection = getValue(props.connection); @@ -101,8 +102,13 @@ const ConnectionForm = (props, saveButton) => { const defaultPort = defaultPorts[connection?.sql_flavor]; const connectionStatus = van.state(undefined); + const testingConnection = van.state(false); van.derive(() => { - connectionStatus.val = getValue(props.connection)?.status; + const status = getValue(props.connection)?.status; + connectionStatus.val = status; + if (status !== undefined) { + testingConnection.val = false; + } }); const connectionFlavor = van.state(connection?.sql_flavor_code); @@ -142,7 +148,7 @@ const ConnectionForm = (props, saveButton) => { const currentValue = updatedConnection.rawVal; if (shouldRefreshUrl(previousValue, currentValue)) { - emitEvent('ConnectionUpdated', {payload: updatedConnection.rawVal}); + emit('ConnectionUpdated', {payload: updatedConnection.rawVal}); } }); @@ -373,7 +379,12 @@ const ConnectionForm = (props, saveButton) => { color: 'basic', type: 'stroked', width: 'auto', - onclick: () => emitEvent('TestConnectionClicked', { payload: updatedConnection.val }), + loading: testingConnection, + onclick: () => { + testingConnection.val = true; + connectionStatus.val = undefined; + emit('TestConnectionClicked', { payload: updatedConnection.val }); + }, }), saveButton, ), diff --git a/testgen/ui/static/js/components/select.js b/testgen/ui/static/js/components/select.js index b7c21b48..70bc57bd 100644 --- a/testgen/ui/static/js/components/select.js +++ b/testgen/ui/static/js/components/select.js @@ -5,6 +5,7 @@ * @property {string} value * @property {string?} icon * @property {string?} caption + * @property {string?} rawLabel * * @typedef Properties * @type {object} @@ -65,11 +66,11 @@ const Select = (/** @type {Properties} */ props) => { const filteredOptions_ = []; for (let i = 0; i < allOptions.length; i++) { const option = allOptions[i]; - if (option.label === filterTerm) { + if ((option.rawLabel ?? option.label) === filterTerm) { return allOptions; } - if (option.label.toLowerCase().includes(filterTerm.toLowerCase())) { + if ((option.rawLabel ?? option.label).toLowerCase().includes(filterTerm.toLowerCase())) { filteredOptions_.push(option); } } @@ -83,7 +84,7 @@ const Select = (/** @type {Properties} */ props) => { const initialCustomLabel = getValue(props.acceptNewOptions) && !initialSelection && typeof value.val === 'string' ? value.val.replace(/^%|%$/g, '') : ''; - const valueLabel = van.state(initialSelection?.label ?? initialCustomLabel); + const valueLabel = van.state(initialSelection?.rawLabel ?? initialSelection?.label ?? initialCustomLabel); const valueIcon = van.state(initialSelection?.icon ?? undefined); const changeSelection = (/** @type SelectOption */ option) => { @@ -104,7 +105,7 @@ const Select = (/** @type {Properties} */ props) => { changeSelection({ value: null, label: '' }); return; } - const match = getValue(options).find(op => op.label?.toLowerCase() === typed.toLowerCase()); + const match = getValue(options).find(op => (op.rawLabel ?? op.label)?.toLowerCase() === typed.toLowerCase()); if (match) { changeSelection(match); } else if (getValue(props.acceptNewOptions)) { @@ -173,10 +174,10 @@ const Select = (/** @type {Properties} */ props) => { } if (!isEqual(currentValue, previousValue)) { - valueLabel.val = selectedOption?.label ?? ''; + valueLabel.val = selectedOption?.rawLabel ?? selectedOption?.label ?? ''; valueIcon.val = selectedOption?.icon ?? undefined; if (inputEl) { - inputEl.value = selectedOption?.label ?? ''; + inputEl.value = selectedOption?.rawLabel ?? selectedOption?.label ?? ''; } props.onChange?.(currentValue, { valid: !!currentValue || !getValue(props.required) }); @@ -258,7 +259,7 @@ const Select = (/** @type {Properties} */ props) => { div( {class: 'flex-row fx-gap-2'}, option.icon ? Icon({}, option.icon) : '', - span(option.label), + option.label ? span(option.label) : span(option.rawLabel), ), option.caption ? span({class: 'text-small text-secondary'}, option.caption) : '', ) diff --git a/testgen/ui/static/js/components/table_group_test.js b/testgen/ui/static/js/components/table_group_test.js index 94aa4898..3915368c 100644 --- a/testgen/ui/static/js/components/table_group_test.js +++ b/testgen/ui/static/js/components/table_group_test.js @@ -35,23 +35,37 @@ const { div, span } = van.tags; * @returns {HTMLElement} */ const TableGroupTest = (preview, options) => { + const verifyingAccess = van.state(false); + van.derive(() => { + const p = getValue(preview); + if (p && Object.values(p.tables ?? {}).some(({ can_access }) => can_access != null)) { + verifyingAccess.val = false; + } + }); + return div( { class: 'flex-column fx-gap-2' }, div( { class: 'flex-row fx-justify-space-between fx-align-flex-end' }, span({ class: 'text-caption text-right' }, '* Approximate row counts based on server statistics'), - options.onVerifyAcess - ? div( + () => { + const p = getValue(preview); + if (!options.onVerifyAcess || !p) return ''; + return div( { class: 'flex-row' }, span({ class: 'fx-flex' }), Button({ label: 'Verify Access', width: 'fit-content', type: 'stroked', - onclick: options.onVerifyAcess, + loading: verifyingAccess, + onclick: () => { + verifyingAccess.val = true; + options.onVerifyAcess(); + }, }), - ) - : '', + ); + }, ), () => getValue(preview) ? TableGroupStats({ hideWarning: true, hideApproxCaption: true }, getValue(preview).stats) diff --git a/testgen/ui/static/js/components/wizard_progress_indicator.js b/testgen/ui/static/js/components/wizard_progress_indicator.js index 0d066932..2c08b24d 100644 --- a/testgen/ui/static/js/components/wizard_progress_indicator.js +++ b/testgen/ui/static/js/components/wizard_progress_indicator.js @@ -58,13 +58,13 @@ const WizardProgressIndicator = (steps, currentStep, onStepClick) => { onclick: () => onStepClick?.(step.includedSteps[0]), }, stepIndex === 0 - ? div({ style: 'position: absolute; width: 50%; height: 50%; left: 0px; background: var(--dk-dialog-background); z-index: 2;' }, '') + ? div({ style: 'position: absolute; width: 50%; height: 50%; left: 0px; background: var(--portal-background, white); z-index: 2;' }, '') : '', stepIndex === steps.length - 1 - ? div({ style: 'position: absolute; width: 50%; height: 50%; right: 0px; background: var(--dk-dialog-background); z-index: 2;' }, '') + ? div({ style: 'position: absolute; width: 50%; height: 50%; right: 0px; background: var(--portal-background, white); z-index: 2;' }, '') : '', div( - { class: 'flex-row fx-justify-center', style: `position: relative; z-index: 3; border: 2px solid var(--secondary-text-color); background: var(--dk-dialog-background); border-radius: 50%; height: 24px; width: 24px;` }, + { class: 'flex-row fx-justify-center', style: `position: relative; z-index: 3; border: 2px solid var(--secondary-text-color); background: var(--portal-background, white); border-radius: 50%; height: 24px; width: 24px;` }, div({ style: 'width: 14px; height: 14px; border-radius: 50%; background: var(--secondary-text-color);' }, ''), ), span({}, title), @@ -76,13 +76,13 @@ const WizardProgressIndicator = (steps, currentStep, onStepClick) => { style: 'position: relative; cursor: default;', }, stepIndex === 0 - ? div({ style: 'position: absolute; width: 50%; height: 50%; left: 0px; background: var(--dk-dialog-background); z-index: 2;' }, '') + ? div({ style: 'position: absolute; width: 50%; height: 50%; left: 0px; background: var(--portal-background, white); z-index: 2;' }, '') : '', stepIndex === steps.length - 1 - ? div({ style: 'position: absolute; width: 50%; height: 50%; right: 0px; background: var(--dk-dialog-background); z-index: 2;' }, '') + ? div({ style: 'position: absolute; width: 50%; height: 50%; right: 0px; background: var(--portal-background, white); z-index: 2;' }, '') : '', div( - { class: 'flex-row', style: `position: relative; z-index: 3; color: var(--empty-light); border: 2px solid var(--disabled-text-color); background: var(--dk-dialog-background); border-radius: 50%;` }, + { class: 'flex-row', style: `position: relative; z-index: 3; color: var(--empty-light); border: 2px solid var(--disabled-text-color); background: var(--portal-background, white); border-radius: 50%;` }, i({style: 'width: 20px; height: 20px;'}, ''), ), span({}, title), @@ -95,10 +95,10 @@ const WizardProgressIndicator = (steps, currentStep, onStepClick) => { onclick: () => onStepClick?.(step.includedSteps[0]), }, stepIndex === 0 - ? div({ style: 'position: absolute; width: 50%; height: 50%; left: 0px; background: var(--dk-dialog-background); z-index: 2;' }, '') + ? div({ style: 'position: absolute; width: 50%; height: 50%; left: 0px; background: var(--portal-background, white); z-index: 2;' }, '') : '', stepIndex === steps.length - 1 - ? div({ style: 'position: absolute; width: 50%; height: 50%; right: 0px; background: var(--dk-dialog-background); z-index: 2;' }, '') + ? div({ style: 'position: absolute; width: 50%; height: 50%; right: 0px; background: var(--portal-background, white); z-index: 2;' }, '') : '', div( { class: 'flex-row', style: `position: relative; z-index: 3; color: var(--empty-light); border: 2px solid ${colorMap.green}; background: ${colorMap.green}; border-radius: 50%;` }, @@ -116,10 +116,10 @@ const WizardProgressIndicator = (steps, currentStep, onStepClick) => { const skippedStepIndicator = (title, stepIndex) => div( { class: `flex-column fx-align-flex-center fx-gap-1 ${currentPhysicalIndex === stepIndex ? 'step-icon-current' : 'text-secondary'}`, style: 'position: relative;' }, stepIndex === 0 - ? div({ style: 'position: absolute; width: 50%; height: 50%; left: 0px; background: var(--dk-dialog-background); z-index: 2;' }, '') + ? div({ style: 'position: absolute; width: 50%; height: 50%; left: 0px; background: var(--portal-background, white); z-index: 2;' }, '') : '', stepIndex === steps.length - 1 - ? div({ style: 'position: absolute; width: 50%; height: 50%; right: 0px; background: var(--dk-dialog-background); z-index: 2;' }, '') + ? div({ style: 'position: absolute; width: 50%; height: 50%; right: 0px; background: var(--portal-background, white); z-index: 2;' }, '') : '', div( { class: 'flex-row', style: `position: relative; z-index: 3; color: var(--empty-light); border: 2px solid var(--grey); background: var(--grey); border-radius: 50%;` }, diff --git a/testgen/ui/views/data_catalog.py b/testgen/ui/views/data_catalog.py index 67655b3d..ebb2fb20 100644 --- a/testgen/ui/views/data_catalog.py +++ b/testgen/ui/views/data_catalog.py @@ -10,13 +10,19 @@ from sqlalchemy.sql.expression import func as sa_func from streamlit.delta_generator import DeltaGenerator -from testgen.commands.run_profiling import run_profiling_in_background from testgen.common.database.database_service import get_flavor_service -from testgen.common.models import with_database_session +from testgen.common.models import database_session, with_database_session from testgen.common.models.connection import Connection +from testgen.common.models.job_execution import JobExecution from testgen.common.models.profiling_run import ProfilingRun from testgen.common.models.table_group import TableGroup, TableGroupMinimal -from testgen.common.pii_masking import get_pii_columns, mask_hygiene_detail, mask_profiling_pii +from testgen.common.pii_masking import ( + PII_REDACTED, + get_pii_columns, + mask_hygiene_detail, + mask_profiling_pii, + mask_source_data_pii, +) from testgen.ui.components import widgets as testgen from testgen.ui.components.widgets.download_dialog import ( FILE_DATA_TYPE, @@ -133,7 +139,13 @@ def on_run_profiling_confirmed(table_group: dict) -> None: message = f"Profiling run started for table group '{table_group['table_groups_name']}'." show_link = session.current_page != "profiling-runs" try: - run_profiling_in_background(table_group["id"]) + with database_session(): + JobExecution.submit( + job_key="run-profile", + kwargs={"table_group_id": str(table_group["id"])}, + source="ui", + project_code=project_code, + ) except Exception as error: success = False message = f"Profiling run could not be started: {error!s}." @@ -145,6 +157,7 @@ def on_run_profiling_confirmed(table_group: dict) -> None: st.session_state.pop(DC_RUN_PROFILING_RESULT_KEY, None) def on_go_to_profiling_runs_clicked(tg_id: str) -> None: + st.session_state.pop(DC_RUN_PROFILING_DIALOG_KEY, None) st.session_state.pop(DC_RUN_PROFILING_RESULT_KEY, None) Router().queue_navigation(to="profiling-runs", with_args={"project_code": project_code, "table_group_id": tg_id}) @@ -233,6 +246,12 @@ def on_data_preview_clicked(item) -> None: item["table_name"], item.get("column_name"), ) + if preview_data.get("rows") and not session.auth.user_has_permission("view_pii"): + pii_columns = get_pii_columns(item["table_group_id"], item["schema_name"], item["table_name"]) + if pii_columns: + df = pd.DataFrame(preview_data["rows"], columns=preview_data["columns"]) + mask_source_data_pii(df, pii_columns) + preview_data["rows"] = make_json_safe(df.values.tolist()) st.session_state[DC_DATA_PREVIEW_DIALOG_KEY] = preview_data def on_data_preview_dialog_closed(*_) -> None: @@ -260,6 +279,9 @@ def on_history_run_selected(run_id: str) -> None: history_data["table_name"], history_data["column_name"], ) + if column and not session.auth.user_has_permission("view_pii"): + pii_columns = get_pii_columns(history_data["table_group_id"], table_name=history_data["table_name"]) + mask_profiling_pii(column, pii_columns) history_data["selected_item"] = column st.session_state[DC_HISTORY_DIALOG_KEY] = history_data @@ -460,7 +482,7 @@ def get_excel_report_data(update_progress: PROGRESS_UPDATE_TYPE, table_group: Ta for key in ["min_date", "max_date", "add_date", "last_mod_date", "drop_date"]: data[key] = data[key].apply( - lambda val: val.strftime("%b %-d %Y, %-I:%M %p") if not pd.isna(val) else None + lambda val: val.strftime("%b %-d %Y, %-I:%M %p") if not pd.isna(val) and not isinstance(val, str) else val ) for key in ["data_source", "source_system", "source_process", "business_domain", "stakeholder_group", "transform_level", "aggregation_level", "data_product"]: @@ -478,13 +500,13 @@ def get_excel_report_data(update_progress: PROGRESS_UPDATE_TYPE, table_group: Ta ) data["top_freq_values"] = data["top_freq_values"].apply( lambda val: "\n".join([ f"{part.split(" | ")[1]} | {part.split(" | ")[0]}" for part in val[2:].split("\n| ") ]) - if not pd.isna(val) - else None + if not pd.isna(val) and val != PII_REDACTED + else val ) data["top_patterns"] = data["top_patterns"].apply( lambda val: "".join([ f"{part}{'\n' if index % 2 else ' | '}" for index, part in enumerate(val.split(" | ")) ]) - if not pd.isna(val) - else None + if not pd.isna(val) and val != PII_REDACTED + else val ) file_columns = { @@ -822,7 +844,12 @@ def _build_history_dialog_data( first_run_id = profiling_runs_data[0]["run_id"] selected_item = _get_history_run_column(first_run_id, schema_name, table_name, column_name) + if selected_item and not session.auth.user_has_permission("view_pii"): + pii_columns = get_pii_columns(table_group_id, table_name=table_name) + mask_profiling_pii(selected_item, pii_columns) + return make_json_safe({ + "table_group_id": table_group_id, "table_name": table_name, "column_name": column_name, "schema_name": schema_name, diff --git a/testgen/ui/views/hygiene_issues.py b/testgen/ui/views/hygiene_issues.py index f6837c79..339ff170 100644 --- a/testgen/ui/views/hygiene_issues.py +++ b/testgen/ui/views/hygiene_issues.py @@ -11,7 +11,7 @@ from testgen.common.models import with_database_session from testgen.common.models.hygiene_issue import HygieneIssue from testgen.common.models.profiling_run import ProfilingRun -from testgen.common.pii_masking import mask_hygiene_detail +from testgen.common.pii_masking import get_pii_columns, mask_hygiene_detail, mask_profiling_pii from testgen.ui.components import widgets as testgen from testgen.ui.components.widgets.download_dialog import ( FILE_DATA_TYPE, @@ -303,6 +303,7 @@ def on_view_profiling(anomaly_id: str) -> None: lookup["column_name"], lookup["table_name"], lookup["table_groups_id"], ) if column: + mask_profiling_pii(column, get_pii_columns(lookup["table_groups_id"], table_name=lookup["table_name"])) st.session_state[PROFILING_KEY] = make_json_safe(column) def on_profiling_closed(*_) -> None: diff --git a/testgen/ui/views/profiling_results.py b/testgen/ui/views/profiling_results.py index e9188e8e..2f35b1d1 100644 --- a/testgen/ui/views/profiling_results.py +++ b/testgen/ui/views/profiling_results.py @@ -9,7 +9,7 @@ from testgen.common.date_service import parse_fuzzy_date from testgen.common.models import with_database_session from testgen.common.models.profiling_run import ProfilingRun -from testgen.common.pii_masking import get_pii_columns, mask_hygiene_detail, mask_profiling_pii +from testgen.common.pii_masking import PII_REDACTED, get_pii_columns, mask_hygiene_detail, mask_profiling_pii from testgen.ui.components import widgets as testgen from testgen.ui.components.widgets.download_dialog import ( FILE_DATA_TYPE, @@ -281,7 +281,7 @@ def get_excel_report_data( for key in ["min_date", "max_date"]: data[key] = data[key].apply( - lambda val: parse_fuzzy_date(val) if not pd.isna(val) and val != "NaT" else None + lambda val: parse_fuzzy_date(val) if not pd.isna(val) and val != "NaT" and val != PII_REDACTED else val ) data["hygiene_issues"] = data["hygiene_issues"].apply(lambda val: "Yes" if val else None) @@ -291,13 +291,13 @@ def get_excel_report_data( data["top_freq_values"] = data["top_freq_values"].apply( lambda val: "\n".join([ f"{part.split(' | ')[1]} | {part.split(' | ')[0]}" for part in val[2:].split("\n| ") ]) - if val - else None + if val and val != PII_REDACTED + else val ) data["top_patterns"] = data["top_patterns"].apply( lambda val: "".join([ f"{part}{chr(10) if index % 2 else ' | '}" for index, part in enumerate(val.split(" | ")) ]) - if val - else None + if val and val != PII_REDACTED + else val ) columns = { diff --git a/testgen/ui/views/profiling_runs.py b/testgen/ui/views/profiling_runs.py index 077aac77..c6429f77 100644 --- a/testgen/ui/views/profiling_runs.py +++ b/testgen/ui/views/profiling_runs.py @@ -16,8 +16,7 @@ DELETE_DIALOG_OPEN_COUNT_KEY = "pr:delete_dialog_open_count" import testgen.ui.services.form_service as fm -from testgen.commands.run_profiling import run_profiling_in_background -from testgen.common.models import with_database_session +from testgen.common.models import database_session, with_database_session from testgen.common.models.job_execution import JobExecution from testgen.common.models.notification_settings import ( ProfilingRunNotificationSettings, @@ -125,7 +124,13 @@ def on_run_profiling_confirmed(table_group: dict) -> None: message = f"Profiling run started for table group '{table_group['table_groups_name']}'." show_link = session.current_page != "profiling-runs" try: - run_profiling_in_background(table_group["id"]) + with database_session(): + JobExecution.submit( + job_key="run-profile", + kwargs={"table_group_id": str(table_group["id"])}, + source="ui", + project_code=project_code, + ) except Exception as error: success = False message = f"Profiling run could not be started: {error!s}." @@ -137,6 +142,7 @@ def on_run_profiling_confirmed(table_group: dict) -> None: st.session_state.pop(RUN_PROFILING_RESULT_KEY, None) def on_go_to_profiling_runs_clicked(tg_id: str) -> None: + st.session_state.pop(RUN_PROFILING_DIALOG_KEY, None) st.session_state.pop(RUN_PROFILING_RESULT_KEY, None) Router().queue_navigation(to="profiling-runs", with_args={"project_code": project_code, "table_group_id": tg_id}) diff --git a/testgen/ui/views/score_details.py b/testgen/ui/views/score_details.py index b8a4817e..c8ecde1b 100644 --- a/testgen/ui/views/score_details.py +++ b/testgen/ui/views/score_details.py @@ -23,7 +23,7 @@ ScoreTypes, SelectedIssue, ) -from testgen.common.pii_masking import mask_hygiene_detail +from testgen.common.pii_masking import get_pii_columns, mask_hygiene_detail, mask_profiling_pii from testgen.ui.components import widgets as testgen from testgen.ui.components.widgets.download_dialog import FILE_DATA_TYPE, download_dialog, zip_multi_file_data from testgen.ui.navigation.page import Page @@ -139,6 +139,9 @@ def on_notifications_dialog_closed(*_) -> None: def on_column_profiling_clicked(payload: dict) -> None: column = get_column_by_name(payload["column_name"], payload["table_name"], payload["table_group_id"]) if column: + if not session.auth.user_has_permission("view_pii"): + pii_columns = get_pii_columns(payload["table_group_id"], table_name=payload["table_name"]) + mask_profiling_pii(column, pii_columns) st.session_state[SD_COLUMN_PROFILING_DIALOG_KEY] = make_json_safe(column) def on_profiling_results_dialog_closed(*_) -> None: @@ -166,7 +169,7 @@ def on_profiling_results_dialog_closed(*_) -> None: on_CategoryChanged_change=select_category, on_ScoreTypeChanged_change=select_score_type, on_IssueReportsExported_change=export_issue_reports, - on_ColumnProflingClicked_change=on_column_profiling_clicked, + on_ColumnProfilingClicked_change=on_column_profiling_clicked, on_RecalculateHistory_change=recalculate_score_history, on_ProfilingResultsDialogClosed_change=on_profiling_results_dialog_closed, # NotificationSettings events @@ -213,15 +216,20 @@ def export_issue_reports(selected_issues: list[SelectedIssue]) -> None: def get_report_file_data(update_progress, issue) -> FILE_DATA_TYPE: + mask_pii = not session.auth.user_has_permission("view_pii") + if mask_pii: + issue = {**issue} + mask_hygiene_detail([issue]) + with BytesIO() as buffer: if issue["issue_type"] == "hygiene": issue_id = issue["id"][:8] timestamp = pd.Timestamp(issue["profiling_starttime"]).strftime("%Y%m%d_%H%M%S") - hygiene_issue_report.create_report(buffer, issue) + hygiene_issue_report.create_report(buffer, issue, mask_pii=mask_pii) else: issue_id = issue["test_result_id"][:8] timestamp = pd.Timestamp(issue["test_date"]).strftime("%Y%m%d_%H%M%S") - test_result_report.create_report(buffer, issue) + test_result_report.create_report(buffer, issue, mask_pii=mask_pii) update_progress(1.0) buffer.seek(0) diff --git a/testgen/ui/views/score_explorer.py b/testgen/ui/views/score_explorer.py index 57872bec..25e28b2c 100644 --- a/testgen/ui/views/score_explorer.py +++ b/testgen/ui/views/score_explorer.py @@ -23,12 +23,13 @@ SelectedIssue, ) from testgen.common.models.test_run import TestRun -from testgen.common.pii_masking import mask_hygiene_detail +from testgen.common.pii_masking import get_pii_columns, mask_hygiene_detail, mask_profiling_pii from testgen.ui.components import widgets as testgen from testgen.ui.components.widgets.download_dialog import FILE_DATA_TYPE, download_dialog, zip_multi_file_data from testgen.ui.navigation.page import Page from testgen.ui.navigation.router import Router from testgen.ui.pdf import hygiene_issue_report, test_result_report +from testgen.ui.queries.profiling_queries import get_column_by_name from testgen.ui.queries.scoring_queries import ( get_all_score_cards, get_column_filters, @@ -36,11 +37,18 @@ get_score_category_values, ) from testgen.ui.session import session -from testgen.utils import format_score_card, format_score_card_breakdown, format_score_card_issues, try_json +from testgen.utils import ( + format_score_card, + format_score_card_breakdown, + format_score_card_issues, + make_json_safe, + try_json, +) PAGE_PATH = "quality-dashboard:explorer" SE_COLUMN_SELECTOR_DIALOG_KEY = "se:column_selector_open" +SE_COLUMN_PROFILING_DIALOG_KEY = "se:column_profiling_payload" class ScoreExplorerPage(Page): path = PAGE_PATH @@ -199,6 +207,20 @@ def on_column_filters_updated(filters: list[dict]) -> None: def on_column_selector_dialog_closed(*_) -> None: st.session_state.pop(SE_COLUMN_SELECTOR_DIALOG_KEY, None) + @with_database_session + def on_column_profiling_clicked(payload: dict) -> None: + column = get_column_by_name(payload["column_name"], payload["table_name"], payload["table_group_id"]) + if column: + if not session.auth.user_has_permission("view_pii"): + pii_columns = get_pii_columns(payload["table_group_id"], table_name=payload["table_name"]) + mask_profiling_pii(column, pii_columns) + st.session_state[SE_COLUMN_PROFILING_DIALOG_KEY] = make_json_safe(column) + + def on_profiling_results_dialog_closed(*_) -> None: + st.session_state.pop(SE_COLUMN_PROFILING_DIALOG_KEY, None) + + profiling_column = st.session_state.get(SE_COLUMN_PROFILING_DIALOG_KEY) + testgen.score_explorer_widget( key="score_explorer", data={ @@ -215,6 +237,7 @@ def on_column_selector_dialog_closed(*_) -> None: "can_edit": user_can_edit, }, "column_selector_dialog": column_selector_data, + "profiling_column": profiling_column, }, on_ScoreUpdated_change=set_score_definition, on_CategoryChanged_change=set_breakdown_category, @@ -224,6 +247,8 @@ def on_column_selector_dialog_closed(*_) -> None: on_ScoreDefinitionSaved_change=save_score_definition, on_ColumnSelectorOpened_change=on_column_selector_opened, on_FilterModeChanged_change=change_score_definition_filter_mode, + on_ColumnProfilingClicked_change=on_column_profiling_clicked, + on_ProfilingResultsDialogClosed_change=on_profiling_results_dialog_closed, # ColumnSelectorDialog events on_ColumnFiltersUpdated_change=on_column_filters_updated, on_ColumnSelectorDialogClosed_change=on_column_selector_dialog_closed, @@ -282,15 +307,20 @@ def export_issue_reports(selected_issues: list[SelectedIssue]) -> None: def get_report_file_data(update_progress, issue) -> FILE_DATA_TYPE: + mask_pii = not session.auth.user_has_permission("view_pii") + if mask_pii: + issue = {**issue} + mask_hygiene_detail([issue]) + with BytesIO() as buffer: if issue["issue_type"] == "hygiene": issue_id = issue["id"][:8] timestamp = pd.Timestamp(issue["profiling_starttime"]).strftime("%Y%m%d_%H%M%S") - hygiene_issue_report.create_report(buffer, issue) + hygiene_issue_report.create_report(buffer, issue, mask_pii=mask_pii) else: issue_id = issue["test_result_id"][:8] timestamp = pd.Timestamp(issue["test_date"]).strftime("%Y%m%d_%H%M%S") - test_result_report.create_report(buffer, issue) + test_result_report.create_report(buffer, issue, mask_pii=mask_pii) update_progress(1.0) buffer.seek(0) diff --git a/testgen/ui/views/table_groups.py b/testgen/ui/views/table_groups.py index aeb5acfb..57eaa71e 100644 --- a/testgen/ui/views/table_groups.py +++ b/testgen/ui/views/table_groups.py @@ -11,6 +11,7 @@ from testgen.common.models.connection import Connection from testgen.common.models.job_execution import JobExecution from testgen.common.models.notification_settings import ProfilingRunNotificationSettings +from testgen.common.models.profiling_run import ProfilingRun from testgen.common.models.scheduler import RUN_MONITORS_JOB_KEY, RUN_TESTS_JOB_KEY, JobSchedule from testgen.common.models.table_group import TableGroup, TableGroupMinimal from testgen.common.models.test_run import TestRun @@ -125,12 +126,18 @@ def on_run_notifications_clicked(*_) -> None: notifications_data = ns_obj.build_data() notifications_data["open"] = True + @with_database_session def on_run_profiling_confirmed(table_group: dict) -> None: success = True message = f"Profiling run started for table group '{table_group['table_groups_name']}'." show_link = session.current_page != "profiling-runs" try: - run_profiling_in_background(table_group["id"]) + JobExecution.submit( + job_key="run-profile", + kwargs={"table_group_id": str(table_group["id"])}, + source="ui", + project_code=project_code, + ) except Exception as error: success = False message = f"Profiling run could not be started: {error!s}." @@ -142,6 +149,7 @@ def on_run_profiling_confirmed(table_group: dict) -> None: st.session_state.pop(TG_RUN_PROFILING_RESULT_KEY, None) def on_go_to_profiling_runs_clicked(tg_id: str) -> None: + st.session_state.pop(TG_RUN_PROFILING_DIALOG_KEY, None) st.session_state.pop(TG_RUN_PROFILING_RESULT_KEY, None) Router().queue_navigation(to="profiling-runs", with_args={"project_code": project_code, "table_group_id": tg_id}) diff --git a/testgen/ui/views/test_definitions.py b/testgen/ui/views/test_definitions.py index 96bc3bca..d4b64833 100644 --- a/testgen/ui/views/test_definitions.py +++ b/testgen/ui/views/test_definitions.py @@ -7,11 +7,11 @@ import streamlit as st from sqlalchemy import and_, asc, case, desc, func, or_, tuple_ -from testgen.commands.run_test_execution import run_test_execution_in_background from testgen.common import date_service from testgen.common.database.database_service import get_flavor_service, replace_params from testgen.common.models import with_database_session from testgen.common.models.connection import Connection +from testgen.common.models.job_execution import JobExecution from testgen.common.models.table_group import TableGroup, TableGroupMinimal from testgen.common.models.test_definition import ( TestDefinition, @@ -54,6 +54,7 @@ TD_RUN_TESTS_RESULT_KEY = "td:run_tests_result" TD_VALIDATE_RESULT_KEY = "td:validate_result" TD_COPY_MOVE_COLLISION_KEY = "td:copy_move_collision" +TD_COPY_MOVE_OVERWRITE_KEY = "td:copy_move_overwrite" TD_NOTES_DIALOG_KEY = "td:notes_dialog" @@ -292,6 +293,7 @@ def on_copy_move_dialog_opened(selected) -> None: # selected contains minimal row dicts (id, table_name, column_name, test_type, lock_refresh) st.session_state[TD_COPY_MOVE_DIALOG_KEY] = selected st.session_state.pop(TD_COPY_MOVE_COLLISION_KEY, None) + st.session_state.pop(TD_COPY_MOVE_OVERWRITE_KEY, None) def on_add_dialog_closed(*_) -> None: st.session_state.pop(TD_ADD_DIALOG_KEY, None) @@ -310,6 +312,7 @@ def on_unlock_dialog_closed(*_) -> None: def on_copy_move_dialog_closed(*_) -> None: st.session_state.pop(TD_COPY_MOVE_DIALOG_KEY, None) st.session_state.pop(TD_COPY_MOVE_COLLISION_KEY, None) + st.session_state.pop(TD_COPY_MOVE_OVERWRITE_KEY, None) @with_database_session def on_add_test_saved(test_def: dict) -> None: @@ -367,11 +370,15 @@ def on_copy_confirmed(payload: dict) -> None: target_ts_id = payload["target_test_suite_id"] target_table = payload.get("target_table_name") target_col = payload.get("target_column_name") + overwrite_ids = st.session_state.pop(TD_COPY_MOVE_OVERWRITE_KEY, []) + if overwrite_ids: + TestDefinition.delete_where(TestDefinition.id.in_(overwrite_ids)) TestDefinition.copy(ids, target_tg_id, target_ts_id, target_table, target_col) st.cache_data.clear() get_test_suite_columns.clear() st.session_state.pop(TD_COPY_MOVE_DIALOG_KEY, None) st.session_state.pop(TD_COPY_MOVE_COLLISION_KEY, None) + st.session_state.pop(TD_COPY_MOVE_OVERWRITE_KEY, None) @with_database_session def on_move_confirmed(payload: dict) -> None: @@ -380,24 +387,35 @@ def on_move_confirmed(payload: dict) -> None: target_ts_id = payload["target_test_suite_id"] target_table = payload.get("target_table_name") target_col = payload.get("target_column_name") + overwrite_ids = st.session_state.pop(TD_COPY_MOVE_OVERWRITE_KEY, []) + if overwrite_ids: + TestDefinition.delete_where(TestDefinition.id.in_(overwrite_ids)) TestDefinition.move(ids, target_tg_id, target_ts_id, target_table, target_col) st.cache_data.clear() get_test_suite_columns.clear() st.session_state.pop(TD_COPY_MOVE_DIALOG_KEY, None) st.session_state.pop(TD_COPY_MOVE_COLLISION_KEY, None) + st.session_state.pop(TD_COPY_MOVE_OVERWRITE_KEY, None) @with_database_session def on_copy_move_target_changed(payload: dict) -> None: selected = payload["selected"] target_tg_id = payload["target_table_group_id"] target_ts_id = payload["target_test_suite_id"] - collision_df = get_test_definitions_collision(selected, target_tg_id, target_ts_id) + target_table = payload.get("target_table_name") + target_col = payload.get("target_column_name") + collision_df = get_test_definitions_collision(selected, target_tg_id, target_ts_id, target_table, target_col) + overwrite_ids = [] if collision_df.empty: st.session_state[TD_COPY_MOVE_COLLISION_KEY] = [] else: + unlocked = collision_df[collision_df["lock_refresh"] == False] + selected_ids = {str(item["id"]) for item in selected} + overwrite_ids = [id_ for id_ in unlocked["id"].tolist() if str(id_) not in selected_ids] # Only send the fields JS needs (lock_refresh, table_name, column_name, test_type) cols = ["table_name", "column_name", "test_type", "lock_refresh"] st.session_state[TD_COPY_MOVE_COLLISION_KEY] = collision_df[cols].to_dict("records") + st.session_state[TD_COPY_MOVE_OVERWRITE_KEY] = overwrite_ids @with_database_session def on_validate_test(test_def: dict) -> None: @@ -421,7 +439,12 @@ def on_run_tests_confirmed(data: dict) -> None: message = f"Test run started for test suite '{selected_name}'." show_link = session.current_page != "test-runs" try: - run_test_execution_in_background(selected_id) + JobExecution.submit( + job_key="run-tests", + kwargs={"test_suite_id": str(selected_id)}, + source="ui", + project_code=project_code, + ) except Exception as error: success = False message = f"Test run could not be started: {error!s}." @@ -437,6 +460,7 @@ def on_run_tests_dialog_closed(*_) -> None: st.session_state.pop(TD_RUN_TESTS_RESULT_KEY, None) def on_go_to_test_runs(payload: dict) -> None: + st.session_state.pop(TD_RUN_TESTS_DIALOG_KEY, None) st.session_state.pop(TD_RUN_TESTS_RESULT_KEY, None) Router().queue_navigation(to="test-runs", with_args=payload) @@ -855,14 +879,16 @@ def get_test_definitions_collision( test_definitions: list[dict], target_table_group_id: str, target_test_suite_id: str, + target_table_name: str | None = None, + target_column_name: str | None = None, ) -> pd.DataFrame: table_tests = [ - (item["table_name"], item["test_type"]) + (target_table_name or item["table_name"], item["test_type"]) for item in test_definitions if item["column_name"] is None and item["table_name"] is not None ] column_tests = [ - (item["table_name"], item["column_name"], item["test_type"]) + (target_table_name or item["table_name"], target_column_name or item["column_name"], item["test_type"]) for item in test_definitions if item["column_name"] is not None ] diff --git a/testgen/ui/views/test_results.py b/testgen/ui/views/test_results.py index a978cfbc..8791ae34 100644 --- a/testgen/ui/views/test_results.py +++ b/testgen/ui/views/test_results.py @@ -15,6 +15,7 @@ from testgen.common.models.test_definition import TestDefinition, TestDefinitionNote, TestDefinitionSummary from testgen.common.models.test_run import TestRun from testgen.common.models.test_suite import TestSuite, TestSuiteMinimal +from testgen.common.pii_masking import get_pii_columns, mask_profiling_pii from testgen.ui.components import widgets as testgen from testgen.ui.components.widgets.download_dialog import ( FILE_DATA_TYPE, @@ -297,12 +298,39 @@ def on_disposition_all(payload: dict) -> None: @with_database_session def on_flag_changed(payload: dict) -> None: - test_definition_ids = payload.get("test_definition_ids", []) value = payload.get("value", False) + test_definition_ids = payload.get("test_definition_ids", []) + if not test_definition_ids: + # Multi-select: resolve test_result_ids to definition IDs + test_result_ids = payload.get("test_result_ids", []) + test_definition_ids = test_result_queries.get_test_definition_ids_for_results(test_result_ids) if test_definition_ids: TestDefinition.set_status_attribute("flagged", test_definition_ids, value) st.cache_data.clear() + @with_database_session + def on_flag_all(payload: dict) -> None: + value = payload.get("value", False) + filters = payload.get("filters", {}) + filter_status = filters.get("status") + filter_test_statuses = _parse_status_filter(filter_status) + filter_action = _map_action_filter(filters.get("action")) + filter_flagged_str = filters.get("flagged") + filter_flagged = True if filter_flagged_str == "Flagged" else False if filter_flagged_str == "Not Flagged" else None + + all_def_ids = test_result_queries.get_test_definition_ids_for_run( + run_id, + test_statuses=filter_test_statuses, + test_type_id=filters.get("test_type"), + table_name=filters.get("table_name"), + column_name=filters.get("column_name"), + action=filter_action, + flagged=filter_flagged, + ) + if all_def_ids: + TestDefinition.set_status_attribute("flagged", all_def_ids, value) + st.cache_data.clear() + def on_notes_clicked(payload: dict) -> None: st.session_state[NOTES_DIALOG_KEY] = payload @@ -349,6 +377,7 @@ def on_profiling_clicked(test_result_id: str) -> None: lookup["column_names"], lookup["table_name"], lookup["table_groups_id"], ) if column: + mask_profiling_pii(column, get_pii_columns(lookup["table_groups_id"], table_name=lookup["table_name"])) st.session_state[PROFILING_KEY] = make_json_safe(column) def on_profiling_closed(*_) -> None: @@ -487,6 +516,7 @@ def on_sort_changed(payload: dict) -> None: on_DispositionChanged_change=on_disposition_changed, on_DispositionAll_change=on_disposition_all, on_FlagChanged_change=on_flag_changed, + on_FlagAll_change=on_flag_all, on_NotesClicked_change=on_notes_clicked, on_NoteAdded_change=on_note_added, on_NoteUpdated_change=on_note_updated, diff --git a/testgen/ui/views/test_runs.py b/testgen/ui/views/test_runs.py index 9dcb78b3..7f7230ab 100644 --- a/testgen/ui/views/test_runs.py +++ b/testgen/ui/views/test_runs.py @@ -7,8 +7,7 @@ import streamlit as st import testgen.ui.services.form_service as fm -from testgen.commands.run_test_execution import run_test_execution_in_background -from testgen.common.models import with_database_session +from testgen.common.models import database_session, with_database_session from testgen.common.models.job_execution import JobExecution from testgen.common.models.notification_settings import ( TestRunNotificationSettings, @@ -119,7 +118,13 @@ def on_run_tests_confirmed(data: dict) -> None: message = f"Test run started for test suite '{selected_name}'." show_link = session.current_page != "test-runs" try: - run_test_execution_in_background(selected_id) + with database_session(): + JobExecution.submit( + job_key="run-tests", + kwargs={"test_suite_id": str(selected_id)}, + source="ui", + project_code=project_code, + ) except Exception as error: success = False message = f"Test run could not be started: {error!s}." diff --git a/testgen/ui/views/test_suites.py b/testgen/ui/views/test_suites.py index 2123de27..3c92e850 100644 --- a/testgen/ui/views/test_suites.py +++ b/testgen/ui/views/test_suites.py @@ -3,9 +3,9 @@ import streamlit as st from testgen.commands.run_observability_exporter import export_test_results -from testgen.commands.run_test_execution import run_test_execution_in_background from testgen.commands.test_generation import run_test_generation -from testgen.common.models import with_database_session +from testgen.common.models import database_session, with_database_session +from testgen.common.models.job_execution import JobExecution from testgen.common.models.notification_settings import TestRunNotificationSettings from testgen.common.models.table_group import TableGroup from testgen.common.models.test_run import TestRun @@ -169,7 +169,13 @@ def on_run_tests_confirmed(data: dict) -> None: message = f"Test run started for test suite '{selected_name}'." show_link = session.current_page != "test-runs" try: - run_test_execution_in_background(selected_id) + with database_session(): + JobExecution.submit( + job_key="run-tests", + kwargs={"test_suite_id": str(selected_id)}, + source="ui", + project_code=project_code, + ) except Exception as error: success = False message = f"Test run could not be started: {error!s}." @@ -362,6 +368,7 @@ def execute_ts_delete(test_suite_id: str) -> None: TestSuite.cascade_delete([test_suite_id]) st.session_state[PAGE_RESULT_KEY] = {"success": True, "message": f"Test Suite {test_suite_name} has been deleted."} st.session_state.pop("ts_delete_dialog", None) + get_test_suite_summaries.clear() @with_database_session From 5966a219e4c6d299731a7570cd7607b89a49bd97 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Tue, 14 Apr 2026 14:57:02 -0300 Subject: [PATCH 057/123] feat(api): add static API docs build script (TG-1031) Add deploy/build_docs.py to export the OpenAPI spec as JSON for hosting a static Redoc page alongside the product docs. create_app() now accepts an optional version parameter to skip the DB call in CI. Co-Authored-By: Claude Opus 4.6 (1M context) --- deploy/build_docs.py | 52 ++++++++++++++++++++++++++++++++++++++ invocations/dev.py | 13 +++++++++- testgen/server/__init__.py | 8 +++--- 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 deploy/build_docs.py diff --git a/deploy/build_docs.py b/deploy/build_docs.py new file mode 100644 index 00000000..f49c9be4 --- /dev/null +++ b/deploy/build_docs.py @@ -0,0 +1,52 @@ +"""Export the TestGen OpenAPI spec as a JSON file. + +Usage (from the enterprise repo root): + python testgen/deploy/build_docs.py [--output PATH] [--version VERSION] + +The output JSON is committed to docs/api/openapi.json and served by a static +Redoc HTML shell alongside it. The CI "Update Repo" job regenerates this on +every release. +""" + +import argparse +import json +from pathlib import Path + +from testgen.server import create_app + +_REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _read_version_from_pyproject() -> str: + try: + import tomllib # Python 3.11+ + except ImportError: + import tomli as tomllib # type: ignore[no-redef] + + with open(_REPO_ROOT / "pyproject.toml", "rb") as f: + return tomllib.load(f)["project"]["version"] + + +def main() -> None: + parser = argparse.ArgumentParser(description="Export the TestGen OpenAPI spec as JSON.") + parser.add_argument( + "--output", + type=Path, + default=Path("docs/api/openapi.json"), + help="Output JSON file path (default: docs/api/openapi.json, relative to cwd)", + ) + parser.add_argument("--version", help="API version string (default: read from pyproject.toml)") + args = parser.parse_args() + + version = args.version or _read_version_from_pyproject() + app = create_app(version=version) + spec = app.openapi() + + output: Path = args.output + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(spec, indent=2) + "\n", encoding="utf-8") + print(f"Exported OpenAPI spec -> {output} (v{version})") + + +if __name__ == "__main__": + main() diff --git a/invocations/dev.py b/invocations/dev.py index ba5cefdc..cbe1293f 100644 --- a/invocations/dev.py +++ b/invocations/dev.py @@ -1,4 +1,4 @@ -__all__ = ["build_public_image", "clean", "install", "lint"] +__all__ = ["build_docs", "build_public_image", "clean", "install", "lint"] import re from os.path import exists, join @@ -72,6 +72,17 @@ def clean(ctx: Context) -> None: print("Cleaning finished!") +@task(name="build-docs", pre=(install,)) +def build_docs(ctx: Context, version: str = "", output: str = "") -> None: + """Exports the OpenAPI spec as JSON for the static API docs.""" + args = [] + if version: + args.append(f"--version {version}") + if output: + args.append(f"--output {output}") + ctx.run(f"python deploy/build_docs.py {' '.join(args)}") + + @task( pre=(required_tools, prep_dk_builer), iterable=["label"], diff --git a/testgen/server/__init__.py b/testgen/server/__init__.py index 00d7caf7..f55fca29 100644 --- a/testgen/server/__init__.py +++ b/testgen/server/__init__.py @@ -68,8 +68,8 @@ def patched_openapi() -> dict: app.openapi = patched_openapi # type: ignore[method-assign] -def create_app() -> FastAPI: - version_data = with_database_session(version_service.get_version)() +def create_app(version: str | None = None) -> FastAPI: + version_data = None if version else with_database_session(version_service.get_version)() mcp_session_manager = None @@ -94,7 +94,7 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: ] app = FastAPI( - title=f"{version_data.edition} API", + title=f"{version_data.edition} API" if version_data else "DataOps TestGen API", summary="REST API for DataOps TestGen.", description=( "Automate profiling, test execution, and test generation jobs. " @@ -102,7 +102,7 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: "**Authentication**: OAuth 2.0 authorization code flow. " "See `GET /.well-known/oauth-authorization-server` for discovery." ), - version=version_data.current or "dev", + version=version or version_data.current or "dev", contact={"name": "DataKitchen Support", "email": "support@datakitchen.io", "url": "https://datakitchen.io"}, terms_of_service="https://datakitchen.io/terms-of-service/", docs_url=None, From 09352910c44b6a84915ceb94cfa970d9443b3669 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Fri, 10 Apr 2026 10:57:58 -0300 Subject: [PATCH 058/123] =?UTF-8?q?feat(mcp):=20add=20test=20definition=20?= =?UTF-8?q?tools=20=E2=80=94=20list=5Ftests,=20get=5Ftest,=20list=5Ftest?= =?UTF-8?q?=5Fnotes,=20list=5Ftest=5Ftypes=20(TG-1026)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four new MCP tools for browsing and inspecting test definitions: - list_tests: paginated listing with filters, flagged status, notes count - get_test: full detail view with config, review status, last result, parameters - list_test_notes: notes for a test definition with formatted timestamps - list_test_types: browse available types with scope/dimension/run_type filters Supporting changes: - Add Entity._paginate() for reusable count+offset pagination - Add TestDefinition.list_for_suite() and get_for_project() model methods - Extract resolve_test_type() and build_markdown_table() into shared common module - Add format_page_info/format_page_footer pagination helpers - Move parameter help descriptions from get_test into get_test_type Co-Authored-By: Claude Opus 4.6 (1M context) --- testgen/common/models/entity.py | 29 +- testgen/common/models/test_definition.py | 83 ++- testgen/mcp/server.py | 8 +- testgen/mcp/tools/common.py | 57 +- testgen/mcp/tools/reference.py | 27 +- testgen/mcp/tools/test_definitions.py | 364 ++++++++++++ testgen/mcp/tools/test_results.py | 28 +- tests/unit/mcp/test_tools_test_definitions.py | 556 ++++++++++++++++++ tests/unit/mcp/test_tools_test_results.py | 5 +- 9 files changed, 1123 insertions(+), 34 deletions(-) create mode 100644 testgen/mcp/tools/test_definitions.py create mode 100644 tests/unit/mcp/test_tools_test_definitions.py diff --git a/testgen/common/models/entity.py b/testgen/common/models/entity.py index 888658d7..4b56caab 100644 --- a/testgen/common/models/entity.py +++ b/testgen/common/models/entity.py @@ -4,7 +4,7 @@ from uuid import UUID import streamlit as st -from sqlalchemy import delete, select +from sqlalchemy import delete, func, select from sqlalchemy.dialects import postgresql from sqlalchemy.orm import InstrumentedAttribute from sqlalchemy.sql.elements import BinaryExpression, BooleanClauseList @@ -109,6 +109,31 @@ def _select_columns_where( query = query.where(*clauses).order_by(*order_by) return get_current_session().execute(query).all() + @classmethod + def _paginate( + cls, + query, + *, + page: int, + limit: int, + data_class: type | None = None, + ) -> tuple[list, int]: + """Count + paginate a pre-built query. + + Returns (items, total). If *data_class* is given, each row is + unpacked into an instance of that class. + """ + session = get_current_session() + total = session.scalar(select(func.count()).select_from(query.subquery())) or 0 + rows = session.execute(query.offset((page - 1) * limit).limit(limit)).all() + if data_class is not None: + return [data_class(**row) for row in rows], total + return list(rows), total + + @classmethod + def has_running_process(cls, ids: list[str]) -> bool: + raise NotImplementedError + @classmethod def delete_where(cls, *clauses) -> None: query = delete(cls).where(*clauses) @@ -126,7 +151,7 @@ def cascade_delete(cls, ids: list[str]) -> None: @classmethod def columns(cls) -> list[str]: return list(cls.__annotations__.keys()) - + def refresh(self) -> None: db_session = get_current_session() db_session.refresh(self) diff --git a/testgen/common/models/test_definition.py b/testgen/common/models/test_definition.py index 12e939cc..b6945ed5 100644 --- a/testgen/common/models/test_definition.py +++ b/testgen/common/models/test_definition.py @@ -224,7 +224,12 @@ class TestDefinition(Entity): flagged: bool = Column(Boolean, default=False, nullable=False) external_id: UUID | None = Column(postgresql.UUID(as_uuid=True)) - _default_order_by = (asc(func.lower(schema_name)), asc(func.lower(table_name)), asc(func.lower(column_name)), asc(test_type)) + _default_order_by = ( + asc(func.lower(schema_name)), + asc(func.lower(table_name)), + asc(func.lower(column_name)), + asc(test_type), + ) _summary_columns = ( *TestDefinitionSummary.__annotations__.keys(), *[key for key in TestTypeSummary.__annotations__.keys() if key != "default_test_description"], @@ -261,6 +266,32 @@ def get(cls, identifier: str | UUID) -> TestDefinitionSummary | None: ) return TestDefinitionSummary(**result) if result else None + @classmethod + def get_for_project( + cls, identifier: UUID, project_codes: list[str] | None = None, + ) -> TestDefinitionSummary | None: + """Fetch a test definition with project-level access check. + + Returns None if the definition doesn't exist or the user lacks access. + """ + from testgen.common.models.test_suite import TestSuite + + select_columns = [ + getattr(cls, col, None) or getattr(TestType, col) if isinstance(col, str) else col + for col in cls._summary_columns + ] + query = ( + select(*select_columns) + .join(TestType, cls.test_type == TestType.test_type) + .where(cls.id == identifier) + ) + if project_codes is not None: + query = query.join(TestSuite, cls.test_suite_id == TestSuite.id).where( + TestSuite.project_code.in_(project_codes) + ) + result = get_current_session().execute(query).first() + return TestDefinitionSummary(**result) if result else None + @classmethod @st.cache_data(show_spinner=False, hash_funcs=ENTITY_HASH_FUNCS) def select_where( @@ -289,6 +320,42 @@ def select_minimal_where( ) return [TestDefinitionMinimal(**row) for row in results] + @classmethod + def list_for_suite( + cls, + test_suite_id: UUID, + project_codes: list[str] | None = None, + table_name: str | None = None, + test_type: str | None = None, + test_active: bool | None = None, + page: int = 1, + limit: int = 50, + ) -> tuple[list[TestDefinitionSummary], int]: + """Paginated test definitions for a suite with optional filters.""" + select_columns = [ + getattr(cls, col, None) or getattr(TestType, col) if isinstance(col, str) else col + for col in cls._summary_columns + ] + query = ( + select(*select_columns) + .join(TestType, cls.test_type == TestType.test_type) + .where(cls.test_suite_id == test_suite_id) + ) + if project_codes is not None: + from testgen.common.models.test_suite import TestSuite + + query = query.join(TestSuite, cls.test_suite_id == TestSuite.id).where( + TestSuite.project_code.in_(project_codes) + ) + if table_name: + query = query.where(cls.table_name == table_name) + if test_type: + query = query.where(cls.test_type == test_type) + if test_active is not None: + query = query.where(cls.test_active == test_active) + query = query.order_by(*cls._default_order_by) + return cls._paginate(query, page=page, limit=limit, data_class=TestDefinitionSummary) + _yn_columns: ClassVar = {"test_active", "lock_refresh"} @classmethod @@ -463,9 +530,7 @@ def add_note(cls, test_definition_id: str | UUID, detail: str, username: str) -> @classmethod def update_note(cls, note_id: str | UUID, detail: str) -> None: db_session = get_current_session() - db_session.execute( - update(cls).where(cls.id == note_id).values(detail=detail, updated_at=func.now()) - ) + db_session.execute(update(cls).where(cls.id == note_id).values(detail=detail, updated_at=func.now())) @classmethod def delete_note(cls, note_id: str | UUID) -> None: @@ -490,9 +555,13 @@ def get_notes_count_by_ids(cls, test_definition_ids: list[str]) -> dict[str, int @classmethod def get_notes(cls, test_definition_id: str | UUID) -> list[dict]: db_session = get_current_session() - results = db_session.execute( - select(cls).where(cls.test_definition_id == test_definition_id).order_by(cls.created_at.desc()) - ).scalars().all() + results = ( + db_session.execute( + select(cls).where(cls.test_definition_id == test_definition_id).order_by(cls.created_at.desc()) + ) + .scalars() + .all() + ) return [ { "id": str(note.id), diff --git a/testgen/mcp/server.py b/testgen/mcp/server.py index 0a4b3000..fe4f6594 100644 --- a/testgen/mcp/server.py +++ b/testgen/mcp/server.py @@ -73,7 +73,8 @@ def _configure_mcp_logging() -> None: def build_mcp_app( - api_base_url: str, server_url: str | None = None, + api_base_url: str, + server_url: str | None = None, ) -> tuple[Starlette, StreamableHTTPSessionManager]: """Create the MCP Starlette app with tools, resources, and prompts registered. @@ -90,6 +91,7 @@ def build_mcp_app( from testgen.mcp.tools.discovery import get_data_inventory, list_projects, list_tables, list_test_suites from testgen.mcp.tools.reference import get_test_type, glossary_resource, test_types_resource from testgen.mcp.tools.source_data import get_source_data, get_source_data_query + from testgen.mcp.tools.test_definitions import get_test, list_test_notes, list_test_types, list_tests from testgen.mcp.tools.test_results import get_failure_summary, get_test_result_history, get_test_results from testgen.mcp.tools.test_runs import get_recent_test_runs @@ -128,6 +130,10 @@ def safe_prompt(fn): safe_tool(get_test_type) safe_tool(get_source_data) safe_tool(get_source_data_query) + safe_tool(list_tests) + safe_tool(get_test) + safe_tool(list_test_notes) + safe_tool(list_test_types) # Resources (2) safe_resource("testgen://test-types", test_types_resource) diff --git a/testgen/mcp/tools/common.py b/testgen/mcp/tools/common.py index 612983e2..5d9a3ec1 100644 --- a/testgen/mcp/tools/common.py +++ b/testgen/mcp/tools/common.py @@ -2,6 +2,7 @@ import pandas as pd +from testgen.common.models.test_definition import TestType from testgen.common.models.test_result import TestResultStatus from testgen.mcp.exceptions import MCPUserError @@ -21,19 +22,65 @@ def parse_result_status(value: str) -> TestResultStatus: raise MCPUserError(f"Invalid status `{value}`. Valid values: {valid}") from err +def resolve_test_type(short_name: str) -> str: + """Resolve a test type short name to its internal code.""" + matches = TestType.select_where(TestType.test_name_short == short_name) + if not matches: + raise MCPUserError( + f"Unknown test type: `{short_name}`. Use the testgen://test-types resource to see available types." + ) + return matches[0].test_type + + +def _escape_pipe(value: str) -> str: + return value.replace("|", "\\|") + + +def build_markdown_table( + headers: list[str], + rows: list[list[str | None]], + null_display: str = "—", +) -> str: + """Build a markdown table from plain lists with pipe-escaping and null handling.""" + if not rows: + return "_No rows._" + + def _cell(value: str | None) -> str: + return _escape_pipe(str(value)) if value is not None else null_display + + header = "| " + " | ".join(_escape_pipe(h) for h in headers) + " |" + separator = "| " + " | ".join("---" for _ in headers) + " |" + body = ["| " + " | ".join(_cell(v) for v in row) + " |" for row in rows] + return "\n".join([header, separator, *body]) + + +def format_page_info(total: int, page: int, limit: int) -> str: + """Shared pagination summary line for MCP tool output.""" + if total == 0: + return "" + start = (page - 1) * limit + 1 + end = min(start + limit - 1, total) + return f"Showing {start}\u2013{end} of {total} (page {page}).\n" + + +def format_page_footer(total: int, page: int, limit: int) -> str: + """Pagination footer hint — returns empty string if on the last page.""" + total_pages = (total + limit - 1) // limit + if page >= total_pages: + return "" + return f"\n_Page {page} of {total_pages}. Use `page={page + 1}` for more._" + + def dataframe_to_markdown(df: pd.DataFrame, null_display: str = "_NULL_") -> str: """Convert a DataFrame to a markdown table string.""" if df is None or df.empty: return "_No rows._" - def _escape(value: str) -> str: - return value.replace("|", "\\|") - cols = list(df.columns) - header = "| " + " | ".join(_escape(str(c)) for c in cols) + " |" + header = "| " + " | ".join(_escape_pipe(str(c)) for c in cols) + " |" separator = "| " + " | ".join("---" for _ in cols) + " |" rows = [] for _, row in df.iterrows(): - cells = " | ".join(_escape(str(v)) if pd.notna(v) else null_display for v in row) + cells = " | ".join(_escape_pipe(str(v)) if pd.notna(v) else null_display for v in row) rows.append(f"| {cells} |") return "\n".join([header, separator, *rows]) diff --git a/testgen/mcp/tools/reference.py b/testgen/mcp/tools/reference.py index 9887effa..ca454b5d 100644 --- a/testgen/mcp/tools/reference.py +++ b/testgen/mcp/tools/reference.py @@ -1,5 +1,6 @@ from testgen.common.models import with_database_session from testgen.common.models.test_definition import TestType +from testgen.mcp.tools.common import build_markdown_table @with_database_session @@ -34,12 +35,36 @@ def get_test_type(test_type: str) -> str: lines.append(f"- **Scope:** {tt.test_scope}") if tt.except_message: lines.append(f"- **Exception Message:** {tt.except_message}") + + # Parameters + _append_type_parameters(lines, tt) + if tt.usage_notes: - lines.append(f"- **Usage Notes:** {tt.usage_notes}") + lines.append(f"\n## Usage Notes\n\n{tt.usage_notes}") return "\n".join(lines) +def _append_type_parameters(lines: list[str], tt: TestType) -> None: + """Add parameter definitions section from test type metadata.""" + parm_columns = [c.strip() for c in tt.default_parm_columns.split(",")] if tt.default_parm_columns else [] + if not parm_columns: + return + + parm_prompts = [p.strip() for p in tt.default_parm_prompts.split(",")] if tt.default_parm_prompts else [] + parm_help = [h.strip() for h in tt.default_parm_help.split("|")] if tt.default_parm_help else [] + + headers = ["Parameter", "Field", "Description"] + rows = [] + for i, field_name in enumerate(parm_columns): + label = parm_prompts[i] if i < len(parm_prompts) else field_name + help_text = parm_help[i] if i < len(parm_help) else None + rows.append([label, f"`{field_name}`", help_text]) + + lines.append("\n## Parameters\n") + lines.append(build_markdown_table(headers, rows)) + + @with_database_session def test_types_resource() -> str: """Reference table of all test types with their descriptions and data quality dimensions.""" diff --git a/testgen/mcp/tools/test_definitions.py b/testgen/mcp/tools/test_definitions.py new file mode 100644 index 00000000..71730c23 --- /dev/null +++ b/testgen/mcp/tools/test_definitions.py @@ -0,0 +1,364 @@ +from testgen.common.models import with_database_session +from testgen.common.models.test_definition import TestDefinition, TestDefinitionNote, TestDefinitionSummary, TestType +from testgen.common.models.test_result import TestResult +from testgen.mcp.exceptions import MCPUserError +from testgen.mcp.permissions import get_project_permissions, mcp_permission +from testgen.mcp.tools.common import ( + build_markdown_table, + format_page_footer, + format_page_info, + parse_uuid, + resolve_test_type, +) + +_VALID_SCOPES = {"column", "table", "referential", "custom"} +_VALID_RUN_TYPES = {"CAT", "QUERY"} + + +def _format_timestamp(value: str | None) -> str: + """Format an ISO timestamp string to 'YYYY-MM-DD HH:MM' or '—'.""" + if not value: + return "—" + return value[:16].replace("T", " ") + + +@with_database_session +@mcp_permission("view") +def list_tests( + test_suite_id: str, + table_name: str | None = None, + test_type: str | None = None, + test_active: bool | None = None, + limit: int = 50, + page: int = 1, +) -> str: + """List test definitions in a test suite. + + Args: + test_suite_id: The UUID of the test suite. + table_name: Filter by table name (exact match). + test_type: Filter by test type (e.g. 'Alpha Truncation', 'Row Count'). + test_active: Filter by active status (true/false). Omit to show all. + limit: Maximum number of results per page (default 50). + page: Page number, starting from 1 (default 1). + """ + suite_uuid = parse_uuid(test_suite_id, "test_suite_id") + test_type_code = resolve_test_type(test_type) if test_type else None + perms = get_project_permissions() + + items, total = TestDefinition.list_for_suite( + test_suite_id=suite_uuid, + project_codes=perms.allowed_codes, + table_name=table_name, + test_type=test_type_code, + test_active=test_active, + page=page, + limit=limit, + ) + + if not items: + filters = [] + if table_name: + filters.append(f"table={table_name}") + if test_type: + filters.append(f"type={test_type}") + if test_active is not None: + filters.append(f"active={test_active}") + filter_str = f" (filters: {', '.join(filters)})" if filters else "" + if page > 1: + return f"No tests on page {page} (total: {total}){filter_str}." + return f"No test definitions found for test suite `{test_suite_id}`{filter_str}." + + type_names = {tt.test_type: tt.test_name_short for tt in TestType.select_where(TestType.active == "Y")} + notes_counts = TestDefinitionNote.get_notes_count_by_ids([str(td.id) for td in items]) + + headers = ["Test Type", "Table", "Column", "Active", "Severity", "Locked", "Manual", "Flagged", "Notes", "ID"] + rows = [] + for td in items: + note_ct = notes_counts.get(str(td.id), 0) + rows.append( + [ + type_names.get(td.test_type, td.test_type), + f"`{td.table_name}`" if td.table_name else "—", + f"`{td.column_name}`" if td.column_name else "—", + "Yes" if td.test_active else "No", + td.severity or td.default_severity or "—", + "Yes" if td.lock_refresh else "No", + "No" if td.last_auto_gen_date else "Yes", + "Yes" if td.flagged else "No", + str(note_ct) if note_ct else "—", + f"`{td.id}`", + ] + ) + + lines = [f"# Test Definitions for suite `{test_suite_id}`\n"] + lines.append(format_page_info(total, page, limit)) + lines.append(build_markdown_table(headers, rows)) + footer = format_page_footer(total, page, limit) + if footer: + lines.append(footer) + + return "\n".join(lines) + + +@with_database_session +@mcp_permission("view") +def get_test(test_definition_id: str) -> str: + """Get full details of a test definition, including configuration, parameters, and last result. + + Args: + test_definition_id: The UUID of the test definition. + """ + def_uuid = parse_uuid(test_definition_id, "test_definition_id") + perms = get_project_permissions() + + td = TestDefinition.get_for_project(def_uuid, perms.allowed_codes) + if td is None: + return f"Test definition `{test_definition_id}` not found." + + # Look up full test type for fields not on the summary dataclass (dq_dimension, run_type) + test_type_map = {tt.test_type: tt for tt in TestType.select_where(TestType.active == "Y")} + tt = test_type_map.get(td.test_type) + test_name = tt.test_name_short if tt else td.test_type + + # Header + if td.column_name: + lines = [f"# {test_name} on `{td.column_name}` in `{td.table_name}`\n"] + else: + lines = [f"# {test_name} on `{td.table_name}`\n"] + + lines.append(f"- **ID:** `{td.id}`") + lines.append(f"- **Test Type:** {test_name}") + lines.append(f"- **Table:** `{td.table_name}`") + if td.column_name: + lines.append(f"- **Column:** `{td.column_name}`") + lines.append(f"- **Schema:** `{td.schema_name}`") + if td.test_scope: + lines.append(f"- **Scope:** {td.test_scope}") + if tt and tt.dq_dimension: + lines.append(f"- **Quality Dimension:** {tt.dq_dimension}") + + # Configuration + lines.append("\n## Configuration\n") + lines.append(f"- **Active:** {'Yes' if td.test_active else 'No'}") + severity = td.severity or (f"{td.default_severity} (test type default)" if td.default_severity else None) + if severity: + lines.append(f"- **Severity:** {severity}") + lines.append(f"- **Locked:** {'Yes' if td.lock_refresh else 'No'}") + if td.export_to_observability is None: + from testgen.common.models.test_suite import TestSuite + + suite = TestSuite.get(td.test_suite_id) + inherited = suite.export_to_observability if suite else None + lines.append(f"- **Export to Observability:** {'Yes' if inherited else 'No'} (inherited from suite)") + else: + lines.append(f"- **Export to Observability:** {'Yes' if td.export_to_observability else 'No'}") + + # Review status + notes = TestDefinitionNote.get_notes(def_uuid) + flag_str = "Flagged" if td.flagged else "Not Flagged" + note_str = f"{len(notes)} Notes" if notes else "No Notes" + lines.append(f"- **Review:** {flag_str}, {note_str}") + + # Origin and last update + if td.last_manual_update and td.last_auto_gen_date: + lines.append(f"- **Last Updated:** {max(td.last_manual_update, td.last_auto_gen_date)} (auto-generated, edited)") + elif td.last_manual_update: + lines.append(f"- **Last Updated:** {td.last_manual_update} (manual edit)") + elif td.last_auto_gen_date: + lines.append(f"- **Last Updated:** {td.last_auto_gen_date} (auto-generated)") + + # Parameters (editable fields from test type metadata) + _append_parameters_section(lines, td) + + # Custom SQL + if td.custom_query: + lines.append("\n## Custom SQL\n") + lines.append(f"```sql\n{td.custom_query}\n```") + + # Reference match (referential tests) + _append_match_section(lines, td) + + # Last result + results = TestResult.select_history( + test_definition_id=def_uuid, + project_codes=perms.allowed_codes, + limit=1, + ) + lines.append("\n## Last Result\n") + if results: + r = results[0] + status_str = r.status.value if r.status else "—" + lines.append(f"- **Date:** {r.test_time or '—'}") + lines.append(f"- **Status:** {status_str}") + if r.message: + lines.append(f"- **Message:** {r.message}") + else: + lines.append("_No results recorded for this test definition._") + + # Description + description = td.test_description or td.default_test_description + if description: + lines.append("\n## Description\n") + lines.append(description) + if td.usage_notes: + lines.append("\n## Usage Notes\n") + lines.append(td.usage_notes) + + return "\n".join(lines) + + +@with_database_session +@mcp_permission("view") +def list_test_notes(test_definition_id: str) -> str: + """List notes attached to a test definition, newest first. + + Args: + test_definition_id: The UUID of the test definition. + """ + def_uuid = parse_uuid(test_definition_id, "test_definition_id") + perms = get_project_permissions() + + td = TestDefinition.get_for_project(def_uuid, perms.allowed_codes) + if td is None: + return f"Test definition `{test_definition_id}` not found." + + notes = TestDefinitionNote.get_notes(def_uuid) + if not notes: + return f"No notes for test definition `{test_definition_id}`." + + test_type_map = {tt.test_type: tt.test_name_short for tt in TestType.select_where(TestType.active == "Y")} + test_name = test_type_map.get(td.test_type, td.test_type) + + if td.column_name: + heading = f"# Notes for {test_name} on `{td.column_name}` in `{td.table_name}`\n" + else: + heading = f"# Notes for {test_name} on `{td.table_name}`\n" + + headers = ["Date", "Author", "Note", "Updated"] + rows = [ + [ + _format_timestamp(n["created_at"]), + n["created_by"] or "—", + n["detail"], + _format_timestamp(n["updated_at"]), + ] + for n in notes + ] + + lines = [ + heading, + f"{len(notes)} note{'s' if len(notes) != 1 else ''}.\n", + build_markdown_table(headers, rows), + ] + return "\n".join(lines) + + +def _append_parameters_section(lines: list[str], td: TestDefinitionSummary) -> None: + """Build the editable parameters table from test type metadata.""" + parm_columns = [c.strip() for c in td.default_parm_columns.split(",")] if td.default_parm_columns else [] + if not parm_columns: + return + + parm_prompts = [p.strip() for p in td.default_parm_prompts.split(",")] if td.default_parm_prompts else [] + + headers = ["Parameter", "Field", "Value"] + rows = [] + for i, field_name in enumerate(parm_columns): + label = parm_prompts[i] if i < len(parm_prompts) else field_name + value = getattr(td, field_name, None) + rows.append([label, f"`{field_name}`", str(value) if value is not None else None]) + + lines.append("\n## Parameters\n") + lines.append(build_markdown_table(headers, rows)) + + +def _append_match_section(lines: list[str], td: TestDefinitionSummary) -> None: + """Append reference match section for referential tests.""" + match_fields = [ + ("Match Schema", td.match_schema_name), + ("Match Table", td.match_table_name), + ("Match Columns", td.match_column_names), + ("Match Subset Condition", td.match_subset_condition), + ("Match Grouping Columns", td.match_groupby_names), + ("Match Having Condition", td.match_having_condition), + ] + populated = [(label, value) for label, value in match_fields if value] + if not populated: + return + + lines.append("\n## Reference Match\n") + for label, value in populated: + lines.append(f"- **{label}:** `{value}`") + + +@with_database_session +def list_test_types( + scope: str | None = None, + quality_dimension: str | None = None, + run_type: str | None = None, +) -> str: + """List available test types with optional filtering. + + Args: + scope: Filter by test scope ('column', 'table', 'referential', 'custom'). + quality_dimension: Filter by quality dimension (e.g. 'Accuracy', 'Completeness'). + run_type: Filter by execution type ('CAT' for catalog-based, 'QUERY' for custom SQL). + """ + if scope and scope not in _VALID_SCOPES: + valid = ", ".join(sorted(_VALID_SCOPES)) + raise MCPUserError(f"Invalid scope `{scope}`. Valid values: {valid}") + if run_type and run_type not in _VALID_RUN_TYPES: + valid = ", ".join(sorted(_VALID_RUN_TYPES)) + raise MCPUserError(f"Invalid run_type `{run_type}`. Valid values: {valid}") + + clauses = [TestType.active == "Y"] + if scope: + clauses.append(TestType.test_scope == scope) + if quality_dimension: + clauses.append(TestType.dq_dimension == quality_dimension) + if run_type: + clauses.append(TestType.run_type == run_type) + + test_types = TestType.select_where(*clauses) + + if not test_types: + filters = [] + if scope: + filters.append(f"scope={scope}") + if quality_dimension: + filters.append(f"dimension={quality_dimension}") + if run_type: + filters.append(f"run_type={run_type}") + filter_str = f" (filters: {', '.join(filters)})" if filters else "" + return f"No test types found{filter_str}." + + filters_desc = [] + if scope: + filters_desc.append(f"scope: {scope}") + if quality_dimension: + filters_desc.append(f"dimension: {quality_dimension}") + if run_type: + filters_desc.append(f"run_type: {run_type}") + filter_suffix = f" ({', '.join(filters_desc)})" if filters_desc else "" + + headers = ["Test Type", "Quality Dimension", "Scope", "Run Type", "Description"] + rows = [] + for tt in test_types: + rows.append( + [ + tt.test_name_short or "", + tt.dq_dimension or "", + tt.test_scope or "", + tt.run_type or "", + tt.test_description or "", + ] + ) + + lines = [ + "# Test Types\n", + f"Showing {len(rows)} test type(s){filter_suffix}.\n", + build_markdown_table(headers, rows), + ] + + return "\n".join(lines) diff --git a/testgen/mcp/tools/test_results.py b/testgen/mcp/tools/test_results.py index 09f86345..1aa89e0a 100644 --- a/testgen/mcp/tools/test_results.py +++ b/testgen/mcp/tools/test_results.py @@ -4,15 +4,7 @@ from testgen.common.models.test_run import TestRun from testgen.mcp.exceptions import MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission -from testgen.mcp.tools.common import parse_result_status, parse_uuid - - -def _resolve_test_type(short_name: str) -> str: - """Resolve a test type short name to its internal code.""" - matches = TestType.select_where(TestType.test_name_short == short_name) - if not matches: - raise MCPUserError(f"Unknown test type: `{short_name}`. Use the testgen://test-types resource to see available types.") - return matches[0].test_type +from testgen.mcp.tools.common import parse_result_status, parse_uuid, resolve_test_type @with_database_session @@ -43,7 +35,7 @@ def get_test_results( status_enum = parse_result_status(status) if status else None offset = (page - 1) * limit - test_type_code = _resolve_test_type(test_type) if test_type else None + test_type_code = resolve_test_type(test_type) if test_type else None perms = get_project_permissions() @@ -182,7 +174,9 @@ def get_test_result_history( perms = get_project_permissions() - results = TestResult.select_history(test_definition_id=def_uuid, limit=limit, offset=offset, project_codes=perms.allowed_codes) + results = TestResult.select_history( + test_definition_id=def_uuid, limit=limit, offset=offset, project_codes=perms.allowed_codes + ) if not results: return f"No historical results found for test definition `{test_definition_id}`." @@ -199,11 +193,13 @@ def get_test_result_history( if first.column_names: lines.append(f"- **Column:** `{first.column_names}`") - lines.extend([ - f"\nShowing {len(results)} result(s), newest first (page {page}).\n", - "| Date | Measure | Threshold | Status |", - "|---|---|---|---|", - ]) + lines.extend( + [ + f"\nShowing {len(results)} result(s), newest first (page {page}).\n", + "| Date | Measure | Threshold | Status |", + "|---|---|---|---|", + ] + ) for r in results: date_str = str(r.test_time) if r.test_time else "—" diff --git a/tests/unit/mcp/test_tools_test_definitions.py b/tests/unit/mcp/test_tools_test_definitions.py new file mode 100644 index 00000000..4910bd40 --- /dev/null +++ b/tests/unit/mcp/test_tools_test_definitions.py @@ -0,0 +1,556 @@ +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest + +from testgen.mcp.exceptions import MCPUserError + +# -- list_tests --------------------------------------------------------------- + + +@patch("testgen.mcp.tools.test_definitions.TestDefinitionNote") +@patch("testgen.mcp.tools.test_definitions.TestType") +@patch("testgen.mcp.tools.test_definitions.TestDefinition") +def test_list_tests_basic(mock_td, mock_tt, mock_notes, db_session_mock): + suite_id = str(uuid4()) + item = MagicMock() + item.test_type = "Alpha_Trunc" + item.table_name = "orders" + item.column_name = "customer_name" + item.test_active = True + item.severity = "Warning" + item.default_severity = None + item.threshold_value = "10.0" + item.lock_refresh = True + item.last_auto_gen_date = "2026-04-01" + item.flagged = True + item.id = uuid4() + mock_td.list_for_suite.return_value = ([item], 1) + mock_notes.get_notes_count_by_ids.return_value = {str(item.id): 2} + + tt = MagicMock() + tt.test_type = "Alpha_Trunc" + tt.test_name_short = "Alpha Truncation" + mock_tt.select_where.return_value = [tt] + + from testgen.mcp.tools.test_definitions import list_tests + + result = list_tests(suite_id) + + assert "Alpha Truncation" in result + assert "`orders`" in result + assert "`customer_name`" in result + assert "Warning" in result + assert "Locked" in result # header + assert "Manual" in result # header + mock_td.list_for_suite.assert_called_once() + call_kwargs = mock_td.list_for_suite.call_args + assert call_kwargs.kwargs["test_suite_id"] is not None + assert call_kwargs.kwargs["page"] == 1 + assert call_kwargs.kwargs["limit"] == 50 + + +@patch("testgen.mcp.tools.test_definitions.TestDefinition") +def test_list_tests_empty(mock_td, db_session_mock): + suite_id = str(uuid4()) + mock_td.list_for_suite.return_value = ([], 0) + + from testgen.mcp.tools.test_definitions import list_tests + + result = list_tests(suite_id) + + assert "No test definitions found" in result + assert suite_id in result + + +@patch("testgen.mcp.tools.test_definitions.TestDefinition") +def test_list_tests_empty_with_filters(mock_td, db_session_mock): + suite_id = str(uuid4()) + mock_td.list_for_suite.return_value = ([], 0) + + from testgen.mcp.tools.test_definitions import list_tests + + result = list_tests(suite_id, table_name="orders") + + assert "No test definitions found" in result + assert "table=orders" in result + + +@patch("testgen.mcp.tools.test_definitions.TestDefinition") +def test_list_tests_empty_page_beyond(mock_td, db_session_mock): + suite_id = str(uuid4()) + mock_td.list_for_suite.return_value = ([], 5) + + from testgen.mcp.tools.test_definitions import list_tests + + result = list_tests(suite_id, page=3) + + assert "No tests on page 3" in result + assert "total: 5" in result + + +@patch("testgen.mcp.tools.common.TestType") +@patch("testgen.mcp.tools.test_definitions.TestType") +@patch("testgen.mcp.tools.test_definitions.TestDefinition") +def test_list_tests_with_test_type_filter(mock_td, mock_tt, mock_tt_common, db_session_mock): + suite_id = str(uuid4()) + mock_td.list_for_suite.return_value = ([], 0) + + tt = MagicMock() + tt.test_type = "Alpha_Trunc" + tt.test_name_short = "Alpha Truncation" + mock_tt_common.select_where.return_value = [tt] + + from testgen.mcp.tools.test_definitions import list_tests + + result = list_tests(suite_id, test_type="Alpha Truncation") + + call_kwargs = mock_td.list_for_suite.call_args.kwargs + assert call_kwargs["test_type"] == "Alpha_Trunc" + + +def test_list_tests_invalid_uuid(db_session_mock): + from testgen.mcp.tools.test_definitions import list_tests + + with pytest.raises(MCPUserError, match="not a valid UUID"): + list_tests("not-a-uuid") + + +@patch("testgen.mcp.tools.test_definitions.TestDefinition") +def test_list_tests_passes_project_codes(mock_td, db_session_mock): + suite_id = str(uuid4()) + mock_td.list_for_suite.return_value = ([], 0) + + from testgen.mcp.tools.test_definitions import list_tests + + list_tests(suite_id) + + call_kwargs = mock_td.list_for_suite.call_args.kwargs + assert call_kwargs["project_codes"] == ["demo"] + + +# -- get_test ----------------------------------------------------------------- + + +@patch("testgen.mcp.tools.test_definitions.TestDefinitionNote") +@patch("testgen.mcp.tools.test_definitions.TestResult") +@patch("testgen.mcp.tools.test_definitions.TestType") +@patch("testgen.mcp.tools.test_definitions.TestDefinition") +def test_get_test_basic(mock_td, mock_tt, mock_tr, mock_notes, db_session_mock): + td_id = uuid4() + context = {"project_code": "demo", "connection_id": uuid4(), "table_groups_id": uuid4()} + td = MagicMock() + td.id = td_id + td.test_type = "Alpha_Trunc" + td.table_name = "orders" + td.column_name = "customer_name" + td.schema_name = "public" + td.test_scope = "column" + td.test_suite_id = uuid4() + td.test_active = True + td.severity = "Warning" + td.default_severity = None + td.lock_refresh = False + td.export_to_observability = True + td.measure_uom = "Values over max" + td.flagged = False + td.last_auto_gen_date = None + td.last_manual_update = None + td.default_parm_columns = None + td.custom_query = None + td.match_schema_name = None + td.match_table_name = None + td.match_column_names = None + td.match_subset_condition = None + td.match_groupby_names = None + td.match_having_condition = None + td.test_description = None + td.default_test_description = "Checks for truncated alpha values" + td.usage_notes = None + mock_td.get_for_project.return_value = td + mock_notes.get_notes.return_value = [] + + tt = MagicMock() + tt.test_type = "Alpha_Trunc" + tt.test_name_short = "Alpha Truncation" + tt.dq_dimension = "Accuracy" + mock_tt.select_where.return_value = [tt] + + mock_tr.select_history.return_value = [] + + from testgen.mcp.tools.test_definitions import get_test + + result = get_test(str(td_id)) + + assert "Alpha Truncation" in result + assert "`customer_name`" in result + assert "`orders`" in result + assert "Accuracy" in result + assert "Checks for truncated alpha values" in result + assert "No results recorded" in result + assert "Not Flagged, No Notes" in result + + +@patch("testgen.mcp.tools.test_definitions.TestDefinition") +def test_get_test_not_found(mock_td, db_session_mock): + td_id = str(uuid4()) + mock_td.get_for_project.return_value = None + + from testgen.mcp.tools.test_definitions import get_test + + result = get_test(td_id) + + assert "not found" in result + + +@patch("testgen.mcp.tools.test_definitions.TestDefinitionNote") +@patch("testgen.mcp.tools.test_definitions.TestResult") +@patch("testgen.mcp.tools.test_definitions.TestType") +@patch("testgen.mcp.tools.test_definitions.TestDefinition") +def test_get_test_with_last_result(mock_td, mock_tt, mock_tr, mock_notes, db_session_mock): + td_id = uuid4() + + td = MagicMock() + td.id = td_id + td.test_type = "Row_Ct" + td.table_name = "orders" + td.column_name = None + td.schema_name = "public" + td.test_scope = "table" + td.test_suite_id = uuid4() + td.test_active = True + td.severity = None + td.default_severity = "Fail" + td.lock_refresh = False + td.export_to_observability = False + td.measure_uom = "Row count" + td.flagged = False + td.last_auto_gen_date = None + td.last_manual_update = None + td.default_parm_columns = None + td.custom_query = None + td.match_schema_name = None + td.match_table_name = None + td.match_column_names = None + td.match_subset_condition = None + td.match_groupby_names = None + td.match_having_condition = None + td.test_description = None + td.default_test_description = None + td.usage_notes = None + mock_td.get_for_project.return_value = td + mock_notes.get_notes.return_value = [] + + tt = MagicMock() + tt.test_type = "Row_Ct" + tt.test_name_short = "Row Count" + tt.dq_dimension = "Completeness" + mock_tt.select_where.return_value = [tt] + + last = MagicMock() + last.test_time = "2026-04-01 12:00:00" + last.status = MagicMock(value="Failed") + last.result_measure = "0" + last.threshold_value = "100" + last.message = "Table is empty" + mock_tr.select_history.return_value = [last] + + from testgen.mcp.tools.test_definitions import get_test + + result = get_test(str(td_id)) + + assert "Row Count" in result + assert "2026-04-01" in result + assert "Failed" in result + assert "Table is empty" in result + + +@patch("testgen.mcp.tools.test_definitions.TestDefinitionNote") +@patch("testgen.mcp.tools.test_definitions.TestResult") +@patch("testgen.mcp.tools.test_definitions.TestType") +@patch("testgen.mcp.tools.test_definitions.TestDefinition") +def test_get_test_with_parameters(mock_td, mock_tt, mock_tr, mock_notes, db_session_mock): + td_id = uuid4() + + td = MagicMock() + td.id = td_id + td.test_type = "Alpha_Trunc" + td.table_name = "orders" + td.column_name = "name" + td.schema_name = "public" + td.test_scope = "column" + td.test_suite_id = uuid4() + td.test_active = True + td.severity = None + td.default_severity = None + td.lock_refresh = False + td.export_to_observability = False + td.measure_uom = None + td.flagged = False + td.last_auto_gen_date = None + td.last_manual_update = None + td.default_parm_columns = "threshold_value,baseline_value" + td.default_parm_prompts = "Threshold,Baseline" + td.default_parm_help = "Max allowed value|Reference baseline" + td.threshold_value = "5.0" + td.baseline_value = "3.0" + td.custom_query = None + td.match_schema_name = None + td.match_table_name = None + td.match_column_names = None + td.match_subset_condition = None + td.match_groupby_names = None + td.match_having_condition = None + td.test_description = None + td.default_test_description = None + td.usage_notes = None + mock_td.get_for_project.return_value = td + mock_notes.get_notes.return_value = [] + + tt = MagicMock() + tt.test_type = "Alpha_Trunc" + tt.test_name_short = "Alpha Truncation" + tt.dq_dimension = None + mock_tt.select_where.return_value = [tt] + + mock_tr.select_history.return_value = [] + + from testgen.mcp.tools.test_definitions import get_test + + result = get_test(str(td_id)) + + assert "Parameters" in result + assert "Threshold" in result + assert "Baseline" in result + assert "5.0" in result + + +@patch("testgen.mcp.tools.test_definitions.TestDefinitionNote") +@patch("testgen.mcp.tools.test_definitions.TestResult") +@patch("testgen.mcp.tools.test_definitions.TestType") +@patch("testgen.mcp.tools.test_definitions.TestDefinition") +def test_get_test_flagged_with_notes(mock_td, mock_tt, mock_tr, mock_notes, db_session_mock): + from datetime import datetime + + td_id = uuid4() + + td = MagicMock() + td.id = td_id + td.test_type = "Alpha_Trunc" + td.table_name = "orders" + td.column_name = "name" + td.schema_name = "public" + td.test_scope = "column" + td.test_suite_id = uuid4() + td.test_active = True + td.severity = None + td.default_severity = None + td.lock_refresh = False + td.export_to_observability = False + td.measure_uom = None + td.flagged = True + td.last_auto_gen_date = datetime(2026, 3, 15) + td.last_manual_update = None + td.default_parm_columns = None + td.custom_query = None + td.match_schema_name = None + td.match_table_name = None + td.match_column_names = None + td.match_subset_condition = None + td.match_groupby_names = None + td.match_having_condition = None + td.test_description = None + td.default_test_description = None + td.usage_notes = None + mock_td.get_for_project.return_value = td + mock_notes.get_notes.return_value = [{"id": "1", "detail": "needs review"}, {"id": "2", "detail": "checked"}] + + tt = MagicMock() + tt.test_type = "Alpha_Trunc" + tt.test_name_short = "Alpha Truncation" + tt.dq_dimension = None + mock_tt.select_where.return_value = [tt] + + mock_tr.select_history.return_value = [] + + from testgen.mcp.tools.test_definitions import get_test + + result = get_test(str(td_id)) + + assert "Flagged, 2 Notes" in result + assert "auto-generated" in result + assert "2026-03-15" in result + + +def test_get_test_invalid_uuid(db_session_mock): + from testgen.mcp.tools.test_definitions import get_test + + with pytest.raises(MCPUserError, match="not a valid UUID"): + get_test("garbage") + + +@patch("testgen.mcp.tools.test_definitions.TestDefinition") +def test_get_test_passes_project_codes(mock_td, db_session_mock): + td_id = str(uuid4()) + mock_td.get_for_project.return_value = None + + from testgen.mcp.tools.test_definitions import get_test + + get_test(td_id) + + call_args = mock_td.get_for_project.call_args + assert call_args.args[1] == ["demo"] + + +# -- list_test_notes ---------------------------------------------------------- + + +@patch("testgen.mcp.tools.test_definitions.TestType") +@patch("testgen.mcp.tools.test_definitions.TestDefinitionNote") +@patch("testgen.mcp.tools.test_definitions.TestDefinition") +def test_list_test_notes_basic(mock_td, mock_notes, mock_tt, db_session_mock): + td_id = str(uuid4()) + + td = MagicMock() + td.test_type = "Alpha_Trunc" + td.table_name = "orders" + td.column_name = "name" + mock_td.get_for_project.return_value = td + + tt = MagicMock() + tt.test_type = "Alpha_Trunc" + tt.test_name_short = "Alpha Truncation" + mock_tt.select_where.return_value = [tt] + + mock_notes.get_notes.return_value = [ + {"detail": "Threshold looks wrong", "created_by": "alice", "created_at": "2026-04-01T10:00:00", "updated_at": None}, + {"detail": "Confirmed with team", "created_by": "bob", "created_at": "2026-04-02T14:30:00", "updated_at": "2026-04-03T09:00:00"}, + ] + + from testgen.mcp.tools.test_definitions import list_test_notes + + result = list_test_notes(td_id) + + assert "Alpha Truncation" in result + assert "`name`" in result + assert "`orders`" in result + assert "2 notes" in result + assert "Threshold looks wrong" in result + assert "alice" in result + assert "2026-04-01 10:00" in result + assert "2026-04-03 09:00" in result + + +@patch("testgen.mcp.tools.test_definitions.TestDefinitionNote") +@patch("testgen.mcp.tools.test_definitions.TestDefinition") +def test_list_test_notes_empty(mock_td, mock_notes, db_session_mock): + td_id = str(uuid4()) + td = MagicMock() + mock_td.get_for_project.return_value = td + mock_notes.get_notes.return_value = [] + + from testgen.mcp.tools.test_definitions import list_test_notes + + result = list_test_notes(td_id) + + assert "No notes" in result + + +@patch("testgen.mcp.tools.test_definitions.TestDefinition") +def test_list_test_notes_not_found(mock_td, db_session_mock): + td_id = str(uuid4()) + mock_td.get_for_project.return_value = None + + from testgen.mcp.tools.test_definitions import list_test_notes + + result = list_test_notes(td_id) + + assert "not found" in result + + +def test_list_test_notes_invalid_uuid(db_session_mock): + from testgen.mcp.tools.test_definitions import list_test_notes + + with pytest.raises(MCPUserError, match="not a valid UUID"): + list_test_notes("garbage") + + +# -- list_test_types ---------------------------------------------------------- + + +@patch("testgen.mcp.tools.test_definitions.TestType") +def test_list_test_types_basic(mock_tt, db_session_mock): + tt = MagicMock() + tt.test_name_short = "Alpha Truncation" + tt.dq_dimension = "Accuracy" + tt.test_scope = "column" + tt.run_type = "CAT" + tt.test_description = "Checks for truncated values" + mock_tt.select_where.return_value = [tt] + + from testgen.mcp.tools.test_definitions import list_test_types + + result = list_test_types() + + assert "Alpha Truncation" in result + assert "Accuracy" in result + assert "column" in result + assert "CAT" in result + + +@patch("testgen.mcp.tools.test_definitions.TestType") +def test_list_test_types_empty(mock_tt, db_session_mock): + mock_tt.select_where.return_value = [] + + from testgen.mcp.tools.test_definitions import list_test_types + + result = list_test_types() + + assert "No test types found" in result + + +@patch("testgen.mcp.tools.test_definitions.TestType") +def test_list_test_types_with_scope_filter(mock_tt, db_session_mock): + mock_tt.select_where.return_value = [] + mock_tt.test_scope = "column" + mock_tt.active = "Y" + + from testgen.mcp.tools.test_definitions import list_test_types + + result = list_test_types(scope="column") + + assert "No test types found" in result + assert "scope=column" in result + + +def test_list_test_types_invalid_scope(db_session_mock): + from testgen.mcp.tools.test_definitions import list_test_types + + with pytest.raises(MCPUserError, match="Invalid scope"): + list_test_types(scope="invalid") + + +def test_list_test_types_invalid_run_type(db_session_mock): + from testgen.mcp.tools.test_definitions import list_test_types + + with pytest.raises(MCPUserError, match="Invalid run_type"): + list_test_types(run_type="INVALID") + + +@patch("testgen.mcp.tools.test_definitions.TestType") +def test_list_test_types_filter_description(mock_tt, db_session_mock): + tt = MagicMock() + tt.test_name_short = "Row Count" + tt.dq_dimension = "Completeness" + tt.test_scope = "table" + tt.run_type = "CAT" + tt.test_description = "Checks row count" + mock_tt.select_where.return_value = [tt] + + from testgen.mcp.tools.test_definitions import list_test_types + + result = list_test_types(scope="table", quality_dimension="Completeness", run_type="CAT") + + assert "scope: table" in result + assert "dimension: Completeness" in result + assert "run_type: CAT" in result diff --git a/tests/unit/mcp/test_tools_test_results.py b/tests/unit/mcp/test_tools_test_results.py index 69e890b6..d1a0c476 100644 --- a/tests/unit/mcp/test_tools_test_results.py +++ b/tests/unit/mcp/test_tools_test_results.py @@ -92,16 +92,17 @@ def test_get_test_results_empty(mock_result, mock_test_run_cls, db_session_mock) assert "No test results found" in result +@patch("testgen.mcp.tools.common.TestType") @patch("testgen.mcp.tools.test_results.TestRun") @patch("testgen.mcp.tools.test_results.TestType") @patch("testgen.mcp.tools.test_results.TestResult") -def test_get_test_results_with_filters(mock_result, mock_tt_cls, mock_test_run_cls, db_session_mock): +def test_get_test_results_with_filters(mock_result, mock_tt_cls, mock_test_run_cls, mock_tt_common, db_session_mock): mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() - tt = MagicMock() tt.test_type = "Alpha_Trunc" tt.test_name_short = "Alpha Truncation" mock_tt_cls.select_where.return_value = [tt] + mock_tt_common.select_where.return_value = [tt] mock_result.select_results.return_value = [] from testgen.mcp.tools.test_results import get_test_results From 3a9ffca570030d48c52ef3c0c9c7c307c1fd3d5b Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Wed, 15 Apr 2026 10:35:40 -0300 Subject: [PATCH 059/123] fix(mcp): address MR review feedback (TG-1026) - Hide custom_query for tests where it's auto-populated (not in default_parm_columns) - Drive match section by default_parm_columns instead of showing all populated fields - Add dq_dimension to TestTypeSummary, eliminating redundant TestType queries in get_test, list_tests, list_test_notes - Add ORDER BY guard + strip ORDER BY from count query in _paginate - Remove run_type filter/column from list_test_types (internal runtime detail) - Validate quality_dimension against 7 known values and list them in docstring - Update list_for_suite docstring to mention project-level access check Co-Authored-By: Claude Opus 4.6 (1M context) --- testgen/common/models/entity.py | 6 +- testgen/common/models/test_definition.py | 3 +- testgen/mcp/tools/test_definitions.py | 60 ++++++--------- tests/unit/mcp/test_tools_test_definitions.py | 75 +++++-------------- 4 files changed, 51 insertions(+), 93 deletions(-) diff --git a/testgen/common/models/entity.py b/testgen/common/models/entity.py index 4b56caab..99e37c0a 100644 --- a/testgen/common/models/entity.py +++ b/testgen/common/models/entity.py @@ -122,9 +122,13 @@ def _paginate( Returns (items, total). If *data_class* is given, each row is unpacked into an instance of that class. + + The caller must supply ORDER BY on the query for stable pagination. """ + if not query._order_by_clauses: + raise ValueError("Paginated queries require ORDER BY for stable page distribution.") session = get_current_session() - total = session.scalar(select(func.count()).select_from(query.subquery())) or 0 + total = session.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0 rows = session.execute(query.offset((page - 1) * limit).limit(limit)).all() if data_class is not None: return [data_class(**row) for row in rows], total diff --git a/testgen/common/models/test_definition.py b/testgen/common/models/test_definition.py index b6945ed5..16eb2c53 100644 --- a/testgen/common/models/test_definition.py +++ b/testgen/common/models/test_definition.py @@ -46,6 +46,7 @@ class TestTypeSummary(EntityMinimal): default_parm_required: str default_severity: str test_scope: TestScope + dq_dimension: str usage_notes: str @@ -331,7 +332,7 @@ def list_for_suite( page: int = 1, limit: int = 50, ) -> tuple[list[TestDefinitionSummary], int]: - """Paginated test definitions for a suite with optional filters.""" + """Paginated test definitions for a suite with project-level access check and optional filters.""" select_columns = [ getattr(cls, col, None) or getattr(TestType, col) if isinstance(col, str) else col for col in cls._summary_columns diff --git a/testgen/mcp/tools/test_definitions.py b/testgen/mcp/tools/test_definitions.py index 71730c23..e262f20c 100644 --- a/testgen/mcp/tools/test_definitions.py +++ b/testgen/mcp/tools/test_definitions.py @@ -12,7 +12,7 @@ ) _VALID_SCOPES = {"column", "table", "referential", "custom"} -_VALID_RUN_TYPES = {"CAT", "QUERY"} +_VALID_DIMENSIONS = {"Accuracy", "Completeness", "Consistency", "Recency", "Timeliness", "Uniqueness", "Validity"} def _format_timestamp(value: str | None) -> str: @@ -69,7 +69,6 @@ def list_tests( return f"No tests on page {page} (total: {total}){filter_str}." return f"No test definitions found for test suite `{test_suite_id}`{filter_str}." - type_names = {tt.test_type: tt.test_name_short for tt in TestType.select_where(TestType.active == "Y")} notes_counts = TestDefinitionNote.get_notes_count_by_ids([str(td.id) for td in items]) headers = ["Test Type", "Table", "Column", "Active", "Severity", "Locked", "Manual", "Flagged", "Notes", "ID"] @@ -78,7 +77,7 @@ def list_tests( note_ct = notes_counts.get(str(td.id), 0) rows.append( [ - type_names.get(td.test_type, td.test_type), + td.test_name_short or td.test_type, f"`{td.table_name}`" if td.table_name else "—", f"`{td.column_name}`" if td.column_name else "—", "Yes" if td.test_active else "No", @@ -116,10 +115,7 @@ def get_test(test_definition_id: str) -> str: if td is None: return f"Test definition `{test_definition_id}` not found." - # Look up full test type for fields not on the summary dataclass (dq_dimension, run_type) - test_type_map = {tt.test_type: tt for tt in TestType.select_where(TestType.active == "Y")} - tt = test_type_map.get(td.test_type) - test_name = tt.test_name_short if tt else td.test_type + test_name = td.test_name_short or td.test_type # Header if td.column_name: @@ -135,8 +131,8 @@ def get_test(test_definition_id: str) -> str: lines.append(f"- **Schema:** `{td.schema_name}`") if td.test_scope: lines.append(f"- **Scope:** {td.test_scope}") - if tt and tt.dq_dimension: - lines.append(f"- **Quality Dimension:** {tt.dq_dimension}") + if td.dq_dimension: + lines.append(f"- **Quality Dimension:** {td.dq_dimension}") # Configuration lines.append("\n## Configuration\n") @@ -171,12 +167,12 @@ def get_test(test_definition_id: str) -> str: # Parameters (editable fields from test type metadata) _append_parameters_section(lines, td) - # Custom SQL - if td.custom_query: + # Custom SQL (only show when the test type exposes it as an editable parameter) + if td.custom_query and "custom_query" in (td.default_parm_columns or ""): lines.append("\n## Custom SQL\n") lines.append(f"```sql\n{td.custom_query}\n```") - # Reference match (referential tests) + # Reference match (only fields listed in default_parm_columns) _append_match_section(lines, td) # Last result @@ -227,8 +223,7 @@ def list_test_notes(test_definition_id: str) -> str: if not notes: return f"No notes for test definition `{test_definition_id}`." - test_type_map = {tt.test_type: tt.test_name_short for tt in TestType.select_where(TestType.active == "Y")} - test_name = test_type_map.get(td.test_type, td.test_type) + test_name = td.test_name_short or td.test_type if td.column_name: heading = f"# Notes for {test_name} on `{td.column_name}` in `{td.table_name}`\n" @@ -274,16 +269,18 @@ def _append_parameters_section(lines: list[str], td: TestDefinitionSummary) -> N def _append_match_section(lines: list[str], td: TestDefinitionSummary) -> None: - """Append reference match section for referential tests.""" + """Append reference match section, filtered by default_parm_columns.""" + parm_columns = {c.strip() for c in td.default_parm_columns.split(",")} if td.default_parm_columns else set() + match_fields = [ - ("Match Schema", td.match_schema_name), - ("Match Table", td.match_table_name), - ("Match Columns", td.match_column_names), - ("Match Subset Condition", td.match_subset_condition), - ("Match Grouping Columns", td.match_groupby_names), - ("Match Having Condition", td.match_having_condition), + ("Match Schema", "match_schema_name", td.match_schema_name), + ("Match Table", "match_table_name", td.match_table_name), + ("Match Columns", "match_column_names", td.match_column_names), + ("Match Subset Condition", "match_subset_condition", td.match_subset_condition), + ("Match Grouping Columns", "match_groupby_names", td.match_groupby_names), + ("Match Having Condition", "match_having_condition", td.match_having_condition), ] - populated = [(label, value) for label, value in match_fields if value] + populated = [(label, value) for label, col, value in match_fields if col in parm_columns and value] if not populated: return @@ -296,29 +293,25 @@ def _append_match_section(lines: list[str], td: TestDefinitionSummary) -> None: def list_test_types( scope: str | None = None, quality_dimension: str | None = None, - run_type: str | None = None, ) -> str: """List available test types with optional filtering. Args: scope: Filter by test scope ('column', 'table', 'referential', 'custom'). - quality_dimension: Filter by quality dimension (e.g. 'Accuracy', 'Completeness'). - run_type: Filter by execution type ('CAT' for catalog-based, 'QUERY' for custom SQL). + quality_dimension: Filter by quality dimension ('Accuracy', 'Completeness', 'Consistency', 'Recency', 'Timeliness', 'Uniqueness', 'Validity'). """ if scope and scope not in _VALID_SCOPES: valid = ", ".join(sorted(_VALID_SCOPES)) raise MCPUserError(f"Invalid scope `{scope}`. Valid values: {valid}") - if run_type and run_type not in _VALID_RUN_TYPES: - valid = ", ".join(sorted(_VALID_RUN_TYPES)) - raise MCPUserError(f"Invalid run_type `{run_type}`. Valid values: {valid}") + if quality_dimension and quality_dimension not in _VALID_DIMENSIONS: + valid = ", ".join(sorted(_VALID_DIMENSIONS)) + raise MCPUserError(f"Invalid quality_dimension `{quality_dimension}`. Valid values: {valid}") clauses = [TestType.active == "Y"] if scope: clauses.append(TestType.test_scope == scope) if quality_dimension: clauses.append(TestType.dq_dimension == quality_dimension) - if run_type: - clauses.append(TestType.run_type == run_type) test_types = TestType.select_where(*clauses) @@ -328,8 +321,6 @@ def list_test_types( filters.append(f"scope={scope}") if quality_dimension: filters.append(f"dimension={quality_dimension}") - if run_type: - filters.append(f"run_type={run_type}") filter_str = f" (filters: {', '.join(filters)})" if filters else "" return f"No test types found{filter_str}." @@ -338,11 +329,9 @@ def list_test_types( filters_desc.append(f"scope: {scope}") if quality_dimension: filters_desc.append(f"dimension: {quality_dimension}") - if run_type: - filters_desc.append(f"run_type: {run_type}") filter_suffix = f" ({', '.join(filters_desc)})" if filters_desc else "" - headers = ["Test Type", "Quality Dimension", "Scope", "Run Type", "Description"] + headers = ["Test Type", "Quality Dimension", "Scope", "Description"] rows = [] for tt in test_types: rows.append( @@ -350,7 +339,6 @@ def list_test_types( tt.test_name_short or "", tt.dq_dimension or "", tt.test_scope or "", - tt.run_type or "", tt.test_description or "", ] ) diff --git a/tests/unit/mcp/test_tools_test_definitions.py b/tests/unit/mcp/test_tools_test_definitions.py index 4910bd40..222d1d6d 100644 --- a/tests/unit/mcp/test_tools_test_definitions.py +++ b/tests/unit/mcp/test_tools_test_definitions.py @@ -9,12 +9,12 @@ @patch("testgen.mcp.tools.test_definitions.TestDefinitionNote") -@patch("testgen.mcp.tools.test_definitions.TestType") @patch("testgen.mcp.tools.test_definitions.TestDefinition") -def test_list_tests_basic(mock_td, mock_tt, mock_notes, db_session_mock): +def test_list_tests_basic(mock_td, mock_notes, db_session_mock): suite_id = str(uuid4()) item = MagicMock() item.test_type = "Alpha_Trunc" + item.test_name_short = "Alpha Truncation" item.table_name = "orders" item.column_name = "customer_name" item.test_active = True @@ -28,11 +28,6 @@ def test_list_tests_basic(mock_td, mock_tt, mock_notes, db_session_mock): mock_td.list_for_suite.return_value = ([item], 1) mock_notes.get_notes_count_by_ids.return_value = {str(item.id): 2} - tt = MagicMock() - tt.test_type = "Alpha_Trunc" - tt.test_name_short = "Alpha Truncation" - mock_tt.select_where.return_value = [tt] - from testgen.mcp.tools.test_definitions import list_tests result = list_tests(suite_id) @@ -134,14 +129,14 @@ def test_list_tests_passes_project_codes(mock_td, db_session_mock): @patch("testgen.mcp.tools.test_definitions.TestDefinitionNote") @patch("testgen.mcp.tools.test_definitions.TestResult") -@patch("testgen.mcp.tools.test_definitions.TestType") @patch("testgen.mcp.tools.test_definitions.TestDefinition") -def test_get_test_basic(mock_td, mock_tt, mock_tr, mock_notes, db_session_mock): +def test_get_test_basic(mock_td, mock_tr, mock_notes, db_session_mock): td_id = uuid4() - context = {"project_code": "demo", "connection_id": uuid4(), "table_groups_id": uuid4()} td = MagicMock() td.id = td_id td.test_type = "Alpha_Trunc" + td.test_name_short = "Alpha Truncation" + td.dq_dimension = "Accuracy" td.table_name = "orders" td.column_name = "customer_name" td.schema_name = "public" @@ -170,12 +165,6 @@ def test_get_test_basic(mock_td, mock_tt, mock_tr, mock_notes, db_session_mock): mock_td.get_for_project.return_value = td mock_notes.get_notes.return_value = [] - tt = MagicMock() - tt.test_type = "Alpha_Trunc" - tt.test_name_short = "Alpha Truncation" - tt.dq_dimension = "Accuracy" - mock_tt.select_where.return_value = [tt] - mock_tr.select_history.return_value = [] from testgen.mcp.tools.test_definitions import get_test @@ -205,14 +194,15 @@ def test_get_test_not_found(mock_td, db_session_mock): @patch("testgen.mcp.tools.test_definitions.TestDefinitionNote") @patch("testgen.mcp.tools.test_definitions.TestResult") -@patch("testgen.mcp.tools.test_definitions.TestType") @patch("testgen.mcp.tools.test_definitions.TestDefinition") -def test_get_test_with_last_result(mock_td, mock_tt, mock_tr, mock_notes, db_session_mock): +def test_get_test_with_last_result(mock_td, mock_tr, mock_notes, db_session_mock): td_id = uuid4() td = MagicMock() td.id = td_id td.test_type = "Row_Ct" + td.test_name_short = "Row Count" + td.dq_dimension = "Completeness" td.table_name = "orders" td.column_name = None td.schema_name = "public" @@ -241,12 +231,6 @@ def test_get_test_with_last_result(mock_td, mock_tt, mock_tr, mock_notes, db_ses mock_td.get_for_project.return_value = td mock_notes.get_notes.return_value = [] - tt = MagicMock() - tt.test_type = "Row_Ct" - tt.test_name_short = "Row Count" - tt.dq_dimension = "Completeness" - mock_tt.select_where.return_value = [tt] - last = MagicMock() last.test_time = "2026-04-01 12:00:00" last.status = MagicMock(value="Failed") @@ -267,14 +251,15 @@ def test_get_test_with_last_result(mock_td, mock_tt, mock_tr, mock_notes, db_ses @patch("testgen.mcp.tools.test_definitions.TestDefinitionNote") @patch("testgen.mcp.tools.test_definitions.TestResult") -@patch("testgen.mcp.tools.test_definitions.TestType") @patch("testgen.mcp.tools.test_definitions.TestDefinition") -def test_get_test_with_parameters(mock_td, mock_tt, mock_tr, mock_notes, db_session_mock): +def test_get_test_with_parameters(mock_td, mock_tr, mock_notes, db_session_mock): td_id = uuid4() td = MagicMock() td.id = td_id td.test_type = "Alpha_Trunc" + td.test_name_short = "Alpha Truncation" + td.dq_dimension = None td.table_name = "orders" td.column_name = "name" td.schema_name = "public" @@ -307,12 +292,6 @@ def test_get_test_with_parameters(mock_td, mock_tt, mock_tr, mock_notes, db_sess mock_td.get_for_project.return_value = td mock_notes.get_notes.return_value = [] - tt = MagicMock() - tt.test_type = "Alpha_Trunc" - tt.test_name_short = "Alpha Truncation" - tt.dq_dimension = None - mock_tt.select_where.return_value = [tt] - mock_tr.select_history.return_value = [] from testgen.mcp.tools.test_definitions import get_test @@ -327,9 +306,8 @@ def test_get_test_with_parameters(mock_td, mock_tt, mock_tr, mock_notes, db_sess @patch("testgen.mcp.tools.test_definitions.TestDefinitionNote") @patch("testgen.mcp.tools.test_definitions.TestResult") -@patch("testgen.mcp.tools.test_definitions.TestType") @patch("testgen.mcp.tools.test_definitions.TestDefinition") -def test_get_test_flagged_with_notes(mock_td, mock_tt, mock_tr, mock_notes, db_session_mock): +def test_get_test_flagged_with_notes(mock_td, mock_tr, mock_notes, db_session_mock): from datetime import datetime td_id = uuid4() @@ -337,6 +315,8 @@ def test_get_test_flagged_with_notes(mock_td, mock_tt, mock_tr, mock_notes, db_s td = MagicMock() td.id = td_id td.test_type = "Alpha_Trunc" + td.test_name_short = "Alpha Truncation" + td.dq_dimension = None td.table_name = "orders" td.column_name = "name" td.schema_name = "public" @@ -365,12 +345,6 @@ def test_get_test_flagged_with_notes(mock_td, mock_tt, mock_tr, mock_notes, db_s mock_td.get_for_project.return_value = td mock_notes.get_notes.return_value = [{"id": "1", "detail": "needs review"}, {"id": "2", "detail": "checked"}] - tt = MagicMock() - tt.test_type = "Alpha_Trunc" - tt.test_name_short = "Alpha Truncation" - tt.dq_dimension = None - mock_tt.select_where.return_value = [tt] - mock_tr.select_history.return_value = [] from testgen.mcp.tools.test_definitions import get_test @@ -405,23 +379,18 @@ def test_get_test_passes_project_codes(mock_td, db_session_mock): # -- list_test_notes ---------------------------------------------------------- -@patch("testgen.mcp.tools.test_definitions.TestType") @patch("testgen.mcp.tools.test_definitions.TestDefinitionNote") @patch("testgen.mcp.tools.test_definitions.TestDefinition") -def test_list_test_notes_basic(mock_td, mock_notes, mock_tt, db_session_mock): +def test_list_test_notes_basic(mock_td, mock_notes, db_session_mock): td_id = str(uuid4()) td = MagicMock() td.test_type = "Alpha_Trunc" + td.test_name_short = "Alpha Truncation" td.table_name = "orders" td.column_name = "name" mock_td.get_for_project.return_value = td - tt = MagicMock() - tt.test_type = "Alpha_Trunc" - tt.test_name_short = "Alpha Truncation" - mock_tt.select_where.return_value = [tt] - mock_notes.get_notes.return_value = [ {"detail": "Threshold looks wrong", "created_by": "alice", "created_at": "2026-04-01T10:00:00", "updated_at": None}, {"detail": "Confirmed with team", "created_by": "bob", "created_at": "2026-04-02T14:30:00", "updated_at": "2026-04-03T09:00:00"}, @@ -484,7 +453,6 @@ def test_list_test_types_basic(mock_tt, db_session_mock): tt.test_name_short = "Alpha Truncation" tt.dq_dimension = "Accuracy" tt.test_scope = "column" - tt.run_type = "CAT" tt.test_description = "Checks for truncated values" mock_tt.select_where.return_value = [tt] @@ -495,7 +463,6 @@ def test_list_test_types_basic(mock_tt, db_session_mock): assert "Alpha Truncation" in result assert "Accuracy" in result assert "column" in result - assert "CAT" in result @patch("testgen.mcp.tools.test_definitions.TestType") @@ -530,11 +497,11 @@ def test_list_test_types_invalid_scope(db_session_mock): list_test_types(scope="invalid") -def test_list_test_types_invalid_run_type(db_session_mock): +def test_list_test_types_invalid_quality_dimension(db_session_mock): from testgen.mcp.tools.test_definitions import list_test_types - with pytest.raises(MCPUserError, match="Invalid run_type"): - list_test_types(run_type="INVALID") + with pytest.raises(MCPUserError, match="Invalid quality_dimension"): + list_test_types(quality_dimension="NotADimension") @patch("testgen.mcp.tools.test_definitions.TestType") @@ -543,14 +510,12 @@ def test_list_test_types_filter_description(mock_tt, db_session_mock): tt.test_name_short = "Row Count" tt.dq_dimension = "Completeness" tt.test_scope = "table" - tt.run_type = "CAT" tt.test_description = "Checks row count" mock_tt.select_where.return_value = [tt] from testgen.mcp.tools.test_definitions import list_test_types - result = list_test_types(scope="table", quality_dimension="Completeness", run_type="CAT") + result = list_test_types(scope="table", quality_dimension="Completeness") assert "scope: table" in result assert "dimension: Completeness" in result - assert "run_type: CAT" in result From 90231f41ad3772ed3f72b62a1502a026f1e00571 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Wed, 15 Apr 2026 22:06:17 -0300 Subject: [PATCH 060/123] refactor(mcp): add ParamFieldsMixin and display_name property (TG-1026) - Extract ParamFieldsMixin with param_fields/param_columns properties, shared by TestTypeSummary (dataclass) and TestType (ORM model) - Add display_name property to TestDefinitionSummary - Use param_fields in get_test, get_test_type, and list_test_notes - Always show all declared parameters (even empty) so LLM knows editable fields - Show Custom SQL and Reference Match sections based on param_columns membership Co-Authored-By: Claude Opus 4.6 (1M context) --- testgen/common/models/test_definition.py | 34 ++++++++++++- testgen/mcp/tools/reference.py | 12 +---- testgen/mcp/tools/test_definitions.py | 49 ++++++++++--------- tests/unit/mcp/test_tools_test_definitions.py | 14 ++++++ 4 files changed, 73 insertions(+), 36 deletions(-) diff --git a/testgen/common/models/test_definition.py b/testgen/common/models/test_definition.py index 16eb2c53..c74cee29 100644 --- a/testgen/common/models/test_definition.py +++ b/testgen/common/models/test_definition.py @@ -1,6 +1,7 @@ from collections.abc import Iterable from dataclasses import dataclass from datetime import datetime +from itertools import zip_longest from typing import ClassVar, Literal from uuid import UUID, uuid4 @@ -34,8 +35,32 @@ TestRunStatus = Literal["Running", "Complete", "Error", "Cancelled"] +class ParamFieldsMixin: + """Parsed access to default_parm_columns/prompts/help metadata. + + Mixed into both TestTypeSummary (dataclass) and TestType (ORM model). + """ + + @property + def param_columns(self) -> set[str]: + """Column names declared as editable parameters for this test type.""" + return {column for column, _, _ in self.param_fields} + + @property + def param_fields(self) -> list[tuple[str, str, str]]: + """Parsed parameter metadata as (column, prompt, help) tuples, preserving order.""" + if not self.default_parm_columns: + return [] + columns = [c.strip() for c in self.default_parm_columns.split(",")] + prompts = [p.strip() for p in self.default_parm_prompts.split(",")] if self.default_parm_prompts else [] + helps = [h.strip() for h in self.default_parm_help.split("|")] if self.default_parm_help else [] + # Pad prompts with column names (sensible fallback) and helps with "" + prompts.extend(columns[len(prompts):]) + return list(zip_longest(columns, prompts, helps, fillvalue="")) + + @dataclass -class TestTypeSummary(EntityMinimal): +class TestTypeSummary(ParamFieldsMixin, EntityMinimal): test_name_short: str default_test_description: str measure_uom: str @@ -98,6 +123,11 @@ class TestDefinitionSummary(TestTypeSummary): prediction: dict[str, dict[str, float]] | None flagged: bool + @property + def display_name(self) -> str: + """Human-readable test type name, falling back to the internal code.""" + return self.test_name_short or self.test_type + @dataclass class TestDefinitionMinimal(EntityMinimal): @@ -125,7 +155,7 @@ def process_bind_param(self, value: str | None, _dialect) -> str | None: return value or None -class TestType(Entity): +class TestType(ParamFieldsMixin, Entity): __tablename__ = "test_types" _get_by = "test_type" diff --git a/testgen/mcp/tools/reference.py b/testgen/mcp/tools/reference.py index ca454b5d..c0c0c73c 100644 --- a/testgen/mcp/tools/reference.py +++ b/testgen/mcp/tools/reference.py @@ -47,19 +47,11 @@ def get_test_type(test_type: str) -> str: def _append_type_parameters(lines: list[str], tt: TestType) -> None: """Add parameter definitions section from test type metadata.""" - parm_columns = [c.strip() for c in tt.default_parm_columns.split(",")] if tt.default_parm_columns else [] - if not parm_columns: + if not tt.param_fields: return - parm_prompts = [p.strip() for p in tt.default_parm_prompts.split(",")] if tt.default_parm_prompts else [] - parm_help = [h.strip() for h in tt.default_parm_help.split("|")] if tt.default_parm_help else [] - headers = ["Parameter", "Field", "Description"] - rows = [] - for i, field_name in enumerate(parm_columns): - label = parm_prompts[i] if i < len(parm_prompts) else field_name - help_text = parm_help[i] if i < len(parm_help) else None - rows.append([label, f"`{field_name}`", help_text]) + rows = [[prompt, f"`{column}`", help_text or None] for column, prompt, help_text in tt.param_fields] lines.append("\n## Parameters\n") lines.append(build_markdown_table(headers, rows)) diff --git a/testgen/mcp/tools/test_definitions.py b/testgen/mcp/tools/test_definitions.py index e262f20c..cd2d0562 100644 --- a/testgen/mcp/tools/test_definitions.py +++ b/testgen/mcp/tools/test_definitions.py @@ -77,7 +77,7 @@ def list_tests( note_ct = notes_counts.get(str(td.id), 0) rows.append( [ - td.test_name_short or td.test_type, + td.display_name, f"`{td.table_name}`" if td.table_name else "—", f"`{td.column_name}`" if td.column_name else "—", "Yes" if td.test_active else "No", @@ -115,7 +115,7 @@ def get_test(test_definition_id: str) -> str: if td is None: return f"Test definition `{test_definition_id}` not found." - test_name = td.test_name_short or td.test_type + test_name = td.display_name # Header if td.column_name: @@ -153,7 +153,7 @@ def get_test(test_definition_id: str) -> str: # Review status notes = TestDefinitionNote.get_notes(def_uuid) flag_str = "Flagged" if td.flagged else "Not Flagged" - note_str = f"{len(notes)} Notes" if notes else "No Notes" + note_str = f"{len(notes)} Note{'s' if len(notes) != 1 else ''}" if notes else "No Notes" lines.append(f"- **Review:** {flag_str}, {note_str}") # Origin and last update @@ -167,10 +167,13 @@ def get_test(test_definition_id: str) -> str: # Parameters (editable fields from test type metadata) _append_parameters_section(lines, td) - # Custom SQL (only show when the test type exposes it as an editable parameter) - if td.custom_query and "custom_query" in (td.default_parm_columns or ""): + # Custom SQL (only show when the test type declares it as an editable parameter) + if "custom_query" in td.param_columns: lines.append("\n## Custom SQL\n") - lines.append(f"```sql\n{td.custom_query}\n```") + if td.custom_query: + lines.append(f"```sql\n{td.custom_query}\n```") + else: + lines.append("_No custom SQL defined._") # Reference match (only fields listed in default_parm_columns) _append_match_section(lines, td) @@ -223,7 +226,7 @@ def list_test_notes(test_definition_id: str) -> str: if not notes: return f"No notes for test definition `{test_definition_id}`." - test_name = td.test_name_short or td.test_type + test_name = td.display_name if td.column_name: heading = f"# Notes for {test_name} on `{td.column_name}` in `{td.table_name}`\n" @@ -250,28 +253,26 @@ def list_test_notes(test_definition_id: str) -> str: def _append_parameters_section(lines: list[str], td: TestDefinitionSummary) -> None: - """Build the editable parameters table from test type metadata.""" - parm_columns = [c.strip() for c in td.default_parm_columns.split(",")] if td.default_parm_columns else [] - if not parm_columns: - return + """Build the editable parameters table from test type metadata. - parm_prompts = [p.strip() for p in td.default_parm_prompts.split(",")] if td.default_parm_prompts else [] + Always shows all parameters declared in param_columns, even when the + value is empty — this tells the LLM/user which fields can be edited. + """ + if not td.param_fields: + return headers = ["Parameter", "Field", "Value"] rows = [] - for i, field_name in enumerate(parm_columns): - label = parm_prompts[i] if i < len(parm_prompts) else field_name - value = getattr(td, field_name, None) - rows.append([label, f"`{field_name}`", str(value) if value is not None else None]) + for column, prompt, _help in td.param_fields: + value = getattr(td, column, None) + rows.append([prompt, f"`{column}`", str(value) if value is not None else "—"]) lines.append("\n## Parameters\n") lines.append(build_markdown_table(headers, rows)) def _append_match_section(lines: list[str], td: TestDefinitionSummary) -> None: - """Append reference match section, filtered by default_parm_columns.""" - parm_columns = {c.strip() for c in td.default_parm_columns.split(",")} if td.default_parm_columns else set() - + """Append reference match section — shows all match fields declared in param_columns.""" match_fields = [ ("Match Schema", "match_schema_name", td.match_schema_name), ("Match Table", "match_table_name", td.match_table_name), @@ -280,13 +281,13 @@ def _append_match_section(lines: list[str], td: TestDefinitionSummary) -> None: ("Match Grouping Columns", "match_groupby_names", td.match_groupby_names), ("Match Having Condition", "match_having_condition", td.match_having_condition), ] - populated = [(label, value) for label, col, value in match_fields if col in parm_columns and value] - if not populated: + relevant = [(label, value) for label, col, value in match_fields if col in td.param_columns] + if not relevant: return lines.append("\n## Reference Match\n") - for label, value in populated: - lines.append(f"- **{label}:** `{value}`") + for label, value in relevant: + lines.append(f"- **{label}:** {f'`{value}`' if value else '—'}") @with_database_session @@ -345,7 +346,7 @@ def list_test_types( lines = [ "# Test Types\n", - f"Showing {len(rows)} test type(s){filter_suffix}.\n", + f"Showing {len(rows)} test type{'s' if len(rows) != 1 else ''}{filter_suffix}.\n", build_markdown_table(headers, rows), ] diff --git a/tests/unit/mcp/test_tools_test_definitions.py b/tests/unit/mcp/test_tools_test_definitions.py index 222d1d6d..6c775719 100644 --- a/tests/unit/mcp/test_tools_test_definitions.py +++ b/tests/unit/mcp/test_tools_test_definitions.py @@ -15,6 +15,7 @@ def test_list_tests_basic(mock_td, mock_notes, db_session_mock): item = MagicMock() item.test_type = "Alpha_Trunc" item.test_name_short = "Alpha Truncation" + item.display_name = "Alpha Truncation" item.table_name = "orders" item.column_name = "customer_name" item.test_active = True @@ -136,6 +137,7 @@ def test_get_test_basic(mock_td, mock_tr, mock_notes, db_session_mock): td.id = td_id td.test_type = "Alpha_Trunc" td.test_name_short = "Alpha Truncation" + td.display_name = "Alpha Truncation" td.dq_dimension = "Accuracy" td.table_name = "orders" td.column_name = "customer_name" @@ -152,6 +154,8 @@ def test_get_test_basic(mock_td, mock_tr, mock_notes, db_session_mock): td.last_auto_gen_date = None td.last_manual_update = None td.default_parm_columns = None + td.param_columns = set() + td.param_fields = [] td.custom_query = None td.match_schema_name = None td.match_table_name = None @@ -202,6 +206,7 @@ def test_get_test_with_last_result(mock_td, mock_tr, mock_notes, db_session_mock td.id = td_id td.test_type = "Row_Ct" td.test_name_short = "Row Count" + td.display_name = "Row Count" td.dq_dimension = "Completeness" td.table_name = "orders" td.column_name = None @@ -218,6 +223,8 @@ def test_get_test_with_last_result(mock_td, mock_tr, mock_notes, db_session_mock td.last_auto_gen_date = None td.last_manual_update = None td.default_parm_columns = None + td.param_columns = set() + td.param_fields = [] td.custom_query = None td.match_schema_name = None td.match_table_name = None @@ -259,6 +266,7 @@ def test_get_test_with_parameters(mock_td, mock_tr, mock_notes, db_session_mock) td.id = td_id td.test_type = "Alpha_Trunc" td.test_name_short = "Alpha Truncation" + td.display_name = "Alpha Truncation" td.dq_dimension = None td.table_name = "orders" td.column_name = "name" @@ -275,6 +283,8 @@ def test_get_test_with_parameters(mock_td, mock_tr, mock_notes, db_session_mock) td.last_auto_gen_date = None td.last_manual_update = None td.default_parm_columns = "threshold_value,baseline_value" + td.param_columns = {"threshold_value", "baseline_value"} + td.param_fields = [("threshold_value", "Threshold", ""), ("baseline_value", "Baseline", "")] td.default_parm_prompts = "Threshold,Baseline" td.default_parm_help = "Max allowed value|Reference baseline" td.threshold_value = "5.0" @@ -316,6 +326,7 @@ def test_get_test_flagged_with_notes(mock_td, mock_tr, mock_notes, db_session_mo td.id = td_id td.test_type = "Alpha_Trunc" td.test_name_short = "Alpha Truncation" + td.display_name = "Alpha Truncation" td.dq_dimension = None td.table_name = "orders" td.column_name = "name" @@ -332,6 +343,8 @@ def test_get_test_flagged_with_notes(mock_td, mock_tr, mock_notes, db_session_mo td.last_auto_gen_date = datetime(2026, 3, 15) td.last_manual_update = None td.default_parm_columns = None + td.param_columns = set() + td.param_fields = [] td.custom_query = None td.match_schema_name = None td.match_table_name = None @@ -387,6 +400,7 @@ def test_list_test_notes_basic(mock_td, mock_notes, db_session_mock): td = MagicMock() td.test_type = "Alpha_Trunc" td.test_name_short = "Alpha Truncation" + td.display_name = "Alpha Truncation" td.table_name = "orders" td.column_name = "name" mock_td.get_for_project.return_value = td From efa18c7cf74f1b6641c072f6d5550ca753798328 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Wed, 15 Apr 2026 23:00:33 -0300 Subject: [PATCH 061/123] refactor(mcp): replace ad-hoc markdown with MdDoc builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a lightweight MdDoc class (markdown.py) that centralises all escaping and formatting for MCP tool responses: - Contextual escaping: table cells escape | and \; field values and bullet items escape inline markdown chars (*, _, [], `, \); headings and text() pass through unescaped (caller-controlled) - Auto datetime formatting: datetime objects and ISO 8601 strings → YYYY-MM-DD HH:MM UTC via a strict full-match regex - field() escapes both label and value; code=True uses double-backtick fence when the value itself contains a backtick - Consecutive field() calls merge into one tight block; sections separated by blank lines in render() - table_from_dataframe() defers pandas import Migrates all 7 MCP tool files to MdDoc. Removes _escape_pipe, build_markdown_table, and dataframe_to_markdown from common.py. Adds 59 unit tests for MdDoc. Co-Authored-By: Claude Opus 4.6 (1M context) --- testgen/mcp/tools/common.py | 43 +- testgen/mcp/tools/discovery.py | 48 +- testgen/mcp/tools/markdown.py | 217 +++++++++ testgen/mcp/tools/reference.py | 69 ++- testgen/mcp/tools/source_data.py | 53 +- testgen/mcp/tools/test_definitions.py | 189 ++++---- testgen/mcp/tools/test_results.py | 110 ++--- testgen/mcp/tools/test_runs.py | 30 +- tests/unit/mcp/test_markdown.py | 452 ++++++++++++++++++ tests/unit/mcp/test_tools_common.py | 58 +-- tests/unit/mcp/test_tools_source_data.py | 2 +- tests/unit/mcp/test_tools_test_definitions.py | 2 +- 12 files changed, 907 insertions(+), 366 deletions(-) create mode 100644 testgen/mcp/tools/markdown.py create mode 100644 tests/unit/mcp/test_markdown.py diff --git a/testgen/mcp/tools/common.py b/testgen/mcp/tools/common.py index 5d9a3ec1..823ea7d8 100644 --- a/testgen/mcp/tools/common.py +++ b/testgen/mcp/tools/common.py @@ -1,7 +1,5 @@ from uuid import UUID -import pandas as pd - from testgen.common.models.test_definition import TestType from testgen.common.models.test_result import TestResultStatus from testgen.mcp.exceptions import MCPUserError @@ -32,35 +30,13 @@ def resolve_test_type(short_name: str) -> str: return matches[0].test_type -def _escape_pipe(value: str) -> str: - return value.replace("|", "\\|") - - -def build_markdown_table( - headers: list[str], - rows: list[list[str | None]], - null_display: str = "—", -) -> str: - """Build a markdown table from plain lists with pipe-escaping and null handling.""" - if not rows: - return "_No rows._" - - def _cell(value: str | None) -> str: - return _escape_pipe(str(value)) if value is not None else null_display - - header = "| " + " | ".join(_escape_pipe(h) for h in headers) + " |" - separator = "| " + " | ".join("---" for _ in headers) + " |" - body = ["| " + " | ".join(_cell(v) for v in row) + " |" for row in rows] - return "\n".join([header, separator, *body]) - - def format_page_info(total: int, page: int, limit: int) -> str: """Shared pagination summary line for MCP tool output.""" if total == 0: return "" start = (page - 1) * limit + 1 end = min(start + limit - 1, total) - return f"Showing {start}\u2013{end} of {total} (page {page}).\n" + return f"Showing {start}\u2013{end} of {total} (page {page})." def format_page_footer(total: int, page: int, limit: int) -> str: @@ -68,19 +44,4 @@ def format_page_footer(total: int, page: int, limit: int) -> str: total_pages = (total + limit - 1) // limit if page >= total_pages: return "" - return f"\n_Page {page} of {total_pages}. Use `page={page + 1}` for more._" - - -def dataframe_to_markdown(df: pd.DataFrame, null_display: str = "_NULL_") -> str: - """Convert a DataFrame to a markdown table string.""" - if df is None or df.empty: - return "_No rows._" - - cols = list(df.columns) - header = "| " + " | ".join(_escape_pipe(str(c)) for c in cols) + " |" - separator = "| " + " | ".join("---" for _ in cols) + " |" - rows = [] - for _, row in df.iterrows(): - cells = " | ".join(_escape_pipe(str(v)) if pd.notna(v) else null_display for v in row) - rows.append(f"| {cells} |") - return "\n".join([header, separator, *rows]) + return f"_Page {page} of {total_pages}. Use `page={page + 1}` for more._" diff --git a/testgen/mcp/tools/discovery.py b/testgen/mcp/tools/discovery.py index 8e78654b..5f6371ac 100644 --- a/testgen/mcp/tools/discovery.py +++ b/testgen/mcp/tools/discovery.py @@ -5,6 +5,7 @@ from testgen.common.models.test_suite import TestSuite from testgen.mcp.permissions import get_project_permissions, mcp_permission from testgen.mcp.tools.common import parse_uuid +from testgen.mcp.tools.markdown import MdDoc @with_database_session @@ -38,11 +39,12 @@ def list_projects() -> str: if not projects: return "No projects found." - lines = ["# Projects\n"] + doc = MdDoc() + doc.heading(1, "Projects") for project in projects: - lines.append(f"- **{project.project_name}** (`{project.project_code}`)") + doc.field(project.project_name, project.project_code, code=True) - return "\n".join(lines) + return doc.render() @with_database_session @@ -68,32 +70,33 @@ def list_test_suites(project_code: str) -> str: run_ids = [s.latest_run_id for s in summaries if s.latest_run_id] job_exec_map = TestRun.get_job_execution_ids(run_ids) if run_ids else {} - lines = [f"# Test Suites for `{project_code}`\n"] + doc = MdDoc() + doc.heading(1, f"Test Suites for `{project_code}`") for s in summaries: - lines.append(f"## {s.test_suite} (id: `{s.id}`)") - lines.append(f"- Connection: {s.connection_name}") - lines.append(f"- Table Group: {s.table_groups_name}") + doc.heading(2, f"{s.test_suite} (id: `{s.id}`)") + doc.field("Connection", s.connection_name) + doc.field("Table Group", s.table_groups_name) if s.test_suite_description: - lines.append(f"- Description: {s.test_suite_description}") - lines.append(f"- Test definitions: {s.test_ct or 0}") + doc.field("Description", s.test_suite_description) + doc.field("Test definitions", s.test_ct or 0) if s.latest_run_id: run_id = job_exec_map.get(s.latest_run_id) or s.latest_run_id - lines.append(f"- Latest run: `{run_id}` ({s.latest_run_start})") - lines.append( - f" - {s.last_run_test_ct or 0} tests: " + doc.field("Latest run", f"`{run_id}` ({s.latest_run_start})") + results_summary = ( + f"{s.last_run_test_ct or 0} tests: " f"{s.last_run_passed_ct or 0} passed, " f"{s.last_run_failed_ct or 0} failed, " f"{s.last_run_warning_ct or 0} warnings, " f"{s.last_run_error_ct or 0} errors" ) + doc.field("Results", results_summary) if s.last_run_dismissed_ct: - lines.append(f" - {s.last_run_dismissed_ct} dismissed") + doc.field("Dismissed", s.last_run_dismissed_ct) else: - lines.append("- _No completed runs._") - lines.append("") + doc.text("_No completed runs._") - return "\n".join(lines) + return doc.render() @with_database_session @@ -120,14 +123,13 @@ def list_tables(table_group_id: str, limit: int = 200, page: int = 1) -> str: return f"No tables on page {page} (total: {total})." return f"No tables found for table group `{table_group_id}`." - lines = [f"# Tables in Table Group `{table_group_id}`\n"] - lines.append(f"Total tables: {total}. Showing {len(table_names)} (page {page}).\n") - - for name in table_names: - lines.append(f"- `{name}`") + doc = MdDoc() + doc.heading(1, f"Tables in Table Group `{table_group_id}`") + doc.text(f"Total tables: {total}. Showing {len(table_names)} (page {page}).") + doc.bullets([f"`{name}`" for name in table_names]) total_pages = (total + limit - 1) // limit if page < total_pages: - lines.append(f"\n_Page {page} of {total_pages}. Use `page={page + 1}` for more._") + doc.text(f"_Page {page} of {total_pages}. Use `page={page + 1}` for more._") - return "\n".join(lines) + return doc.render() diff --git a/testgen/mcp/tools/markdown.py b/testgen/mcp/tools/markdown.py new file mode 100644 index 00000000..ceac0ded --- /dev/null +++ b/testgen/mcp/tools/markdown.py @@ -0,0 +1,217 @@ +"""Lightweight Markdown document builder for MCP tool responses. + +All escaping and formatting happens inside the builder — callers never +touch raw markdown syntax. +""" + +from __future__ import annotations + +import re +from datetime import datetime +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import pandas as pd + +# --------------------------------------------------------------------------- +# Escape / format helpers +# +# Table cells auto-escape | and \ (structural) and replace newlines. +# field(), bullets(), text(), and headings don't escape — caller controls +# content. Use escape() for untrusted data, code() for code spans. +# --------------------------------------------------------------------------- + +_INLINE_RE = re.compile(r"([\\*_\[\]`])") +_TABLE_CELL_RE = re.compile(r"([\\|])") +_ISO_DT_RE = re.compile(r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$") + + +def _escape_inline(value: str) -> str: + """Escape characters that trigger markdown inline formatting.""" + return _INLINE_RE.sub(r"\\\1", value) + + +def _escape_table_cell(value: str) -> str: + """Escape all markdown-significant characters in a table cell.""" + return _TABLE_CELL_RE.sub(r"\\\1", value) + + +def _format_dt(value: object) -> str | None: + """Return 'YYYY-MM-DD HH:MM UTC' for datetime objects and ISO strings, else None.""" + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d %H:%M") + " UTC" + if isinstance(value, str) and _ISO_DT_RE.match(value): + return value[:16].replace("T", " ") + " UTC" + return None + + +def _format_part(value: object) -> str: + """Format a single value for text() parts — datetime-aware, no escaping.""" + if value is None: + return "\u2014" + return dt_str if (dt_str := _format_dt(value)) else str(value) + + +# --------------------------------------------------------------------------- +# MdDoc +# --------------------------------------------------------------------------- + + +class MdDoc: + """Markdown document builder for MCP tool responses.""" + + def __init__(self) -> None: + self._sections: list[str] = [] + + # -- structural elements ------------------------------------------------ + + def heading(self, level: int, text: str) -> MdDoc: + """Add a heading (levels 1-3). Text is not escaped.""" + self._sections.append(f"{'#' * level} {text}") + return self + + def field(self, label: str, value: object, *, code: bool = False) -> MdDoc: + """Add a bullet field: ``- **Label:** value``. + + * ``None`` → em-dash + * ``datetime`` / ISO string → ``YYYY-MM-DD HH:MM UTC`` + * ``code=True`` → wraps value in backticks + * Otherwise → ``str()`` + + No escaping — caller controls content. Use ``escape()`` for untrusted data. + Consecutive ``field()`` calls merge into one tight block. + """ + display = self._format_field_value(value, code=code) + line = f"- **{label}:** {display}" + if self._sections and self._sections[-1].startswith("- **"): + self._sections[-1] += "\n" + line + else: + self._sections.append(line) + return self + + def text(self, *parts: object) -> MdDoc: + """Add a plain text paragraph from one or more parts joined by spaces. + + * Strings pass through as-is (no escaping — caller controls content) + * ``datetime`` / ISO string → ``YYYY-MM-DD HH:MM UTC`` + * ``None`` → em-dash + * Numbers → ``str()`` + """ + if parts: + formatted = " ".join(_format_part(p) for p in parts) + self._sections.append(formatted) + return self + + def table( + self, + headers: list[str], + rows: list[list[object]], + *, + code: list[int] | None = None, + null_display: str = "\u2014", + ) -> MdDoc: + """Add a markdown table. + + Cells are escaped (pipes, backslashes, newlines) and datetime-formatted. + *code* is a list of column indices whose non-null values are wrapped in backtick code spans. + """ + if not rows: + self._sections.append("_No rows._") + return self + code_cols = set(code) if code else set() + header_line = "| " + " | ".join(_escape_table_cell(str(h)) for h in headers) + " |" + separator = "| " + " | ".join("---" for _ in headers) + " |" + body_lines = [] + for row in rows: + cells = [] + for i, v in enumerate(row): + if i in code_cols and v is not None: + # Code spans protect their content — skip table-cell escaping + s = str(v).replace("\n", " ") + cells.append(self.code(s)) + else: + cells.append(self._format_cell(v, null_display)) + + body_lines.append("| " + " | ".join(cells) + " |") + self._sections.append("\n".join([header_line, separator, *body_lines])) + return self + + def table_from_dataframe( + self, + df: pd.DataFrame | None, + *, + null_display: str = "_NULL_", + ) -> MdDoc: + """Add a markdown table from a pandas DataFrame.""" + import pandas as _pd + + if df is None or df.empty: + self._sections.append("_No rows._") + return self + headers = list(df.columns) + rows: list[list[object]] = [] + for _, row in df.iterrows(): + rows.append([None if _pd.isna(v) else v for v in row]) + return self.table(headers, rows, null_display=null_display) + + def bullets(self, items: list[object]) -> MdDoc: + """Add a bullet list. No escaping — caller controls content.""" + lines = [f"- {_format_part(item)}" for item in items] + self._sections.append("\n".join(lines)) + return self + + def code_block(self, content: str, language: str = "") -> MdDoc: + """Add a fenced code block. Uses longer fence if content contains triple backticks.""" + fence = "````" if "```" in content else "```" + self._sections.append(f"{fence}{language}\n{content}\n{fence}") + return self + + # -- escaping ----------------------------------------------------------- + + @staticmethod + def escape(value: str) -> str: + """Escape markdown inline formatting characters in a string. + + Use this for untrusted or user-generated data passed to ``field()``, + ``bullets()``, or ``text()``. Not needed for table cells (those are + always escaped) or code blocks. + """ + return _escape_inline(value) + + @staticmethod + def code(value: str | None) -> str: + """Wrap a string in a backtick code span. + + Handles embedded backticks (double-fence) and newlines (replaced + with literal ``\\n``). Returns em-dash for empty/None values. + """ + if not value: + return "\u2014" + s = value.replace("\n", "\\n") + return f"`` {s} ``" if "`" in s else f"`{s}`" + + # -- output ------------------------------------------------------------- + + def render(self) -> str: + """Join all sections with blank-line separation.""" + return "\n\n".join(self._sections) + + # -- private helpers ---------------------------------------------------- + + @staticmethod + def _format_field_value(value: object, *, code: bool = False) -> str: + if value is None: + return "\u2014" + if dt_str := _format_dt(value): + return MdDoc.code(dt_str) if code else dt_str + s = str(value) + return MdDoc.code(s) if code else s + + @staticmethod + def _format_cell(value: object, null_display: str) -> str: + if value is None: + return null_display + if dt_str := _format_dt(value): + return dt_str + s = str(value).replace("\n", " ") + return _escape_table_cell(s) diff --git a/testgen/mcp/tools/reference.py b/testgen/mcp/tools/reference.py index c0c0c73c..ed399108 100644 --- a/testgen/mcp/tools/reference.py +++ b/testgen/mcp/tools/reference.py @@ -1,6 +1,6 @@ from testgen.common.models import with_database_session from testgen.common.models.test_definition import TestType -from testgen.mcp.tools.common import build_markdown_table +from testgen.mcp.tools.markdown import MdDoc @with_database_session @@ -16,45 +16,45 @@ def get_test_type(test_type: str) -> str: if not tt: return f"Test type `{test_type}` not found. Use `testgen://test-types` to see available types." - lines = [ - f"# {tt.test_name_short}\n", - ] + doc = MdDoc() + doc.heading(1, tt.test_name_short) if tt.test_name_long: - lines.append(f"- **Full Name:** {tt.test_name_long}") + doc.field("Full Name", tt.test_name_long) if tt.test_description: - lines.append(f"- **Description:** {tt.test_description}") + doc.field("Description", tt.test_description) if tt.measure_uom: - lines.append(f"- **Unit of Measure:** {tt.measure_uom}") + doc.field("Unit of Measure", tt.measure_uom) if tt.measure_uom_description: - lines.append(f"- **Measure Description:** {tt.measure_uom_description}") + doc.field("Measure Description", tt.measure_uom_description) if tt.threshold_description: - lines.append(f"- **Threshold:** {tt.threshold_description}") + doc.field("Threshold", tt.threshold_description) if tt.dq_dimension: - lines.append(f"- **Quality Dimension:** {tt.dq_dimension}") + doc.field("Quality Dimension", tt.dq_dimension) if tt.test_scope: - lines.append(f"- **Scope:** {tt.test_scope}") + doc.field("Scope", tt.test_scope) if tt.except_message: - lines.append(f"- **Exception Message:** {tt.except_message}") + doc.field("Exception Message", tt.except_message) - # Parameters - _append_type_parameters(lines, tt) + _append_type_parameters(doc, tt) if tt.usage_notes: - lines.append(f"\n## Usage Notes\n\n{tt.usage_notes}") + doc.heading(2, "Usage Notes") + doc.text(tt.usage_notes) - return "\n".join(lines) + return doc.render() -def _append_type_parameters(lines: list[str], tt: TestType) -> None: +def _append_type_parameters(doc: MdDoc, tt: TestType) -> None: """Add parameter definitions section from test type metadata.""" if not tt.param_fields: return - headers = ["Parameter", "Field", "Description"] - rows = [[prompt, f"`{column}`", help_text or None] for column, prompt, help_text in tt.param_fields] - - lines.append("\n## Parameters\n") - lines.append(build_markdown_table(headers, rows)) + doc.heading(2, "Parameters") + doc.table( + headers=["Parameter", "Field", "Description"], + rows=[[prompt, column, help_text or None] for column, prompt, help_text in tt.param_fields], + code=[1], + ) @with_database_session @@ -65,20 +65,17 @@ def test_types_resource() -> str: if not test_types: return "No test types found." - lines = [ - "# TestGen Test Types Reference\n", - "| Test Type | Quality Dimension | Scope | Description |", - "|---|---|---|---|", - ] - - for tt in test_types: - desc = tt.test_description or "" - lines.append( - f"| {tt.test_name_short or ''} | " - f"{tt.dq_dimension or ''} | {tt.test_scope or ''} | {desc} |" - ) - - return "\n".join(lines) + doc = MdDoc() + doc.heading(1, "TestGen Test Types Reference") + doc.table( + headers=["Test Type", "Quality Dimension", "Scope", "Description"], + rows=[ + [tt.test_name_short, tt.dq_dimension, tt.test_scope, tt.test_description] + for tt in test_types + ], + ) + + return doc.render() def glossary_resource() -> str: diff --git a/testgen/mcp/tools/source_data.py b/testgen/mcp/tools/source_data.py index 562dca39..1cd5a63f 100644 --- a/testgen/mcp/tools/source_data.py +++ b/testgen/mcp/tools/source_data.py @@ -9,7 +9,8 @@ ) from testgen.mcp.exceptions import MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission -from testgen.mcp.tools.common import dataframe_to_markdown, parse_uuid +from testgen.mcp.tools.common import parse_uuid +from testgen.mcp.tools.markdown import MdDoc def _resolve_context(test_definition_id: str, reference_date: str | None) -> dict: @@ -65,17 +66,16 @@ def get_source_data_query( "This test type does not have a defined lookup query." ) - lines = [ - f"# Source Data Query for Test Definition `{test_definition_id}`\n", - f"- **Test type:** `{context.get('test_type')}`", - f"- **Table:** `{context.get('schema_name')}`.`{context.get('table_name')}`", - ] + doc = MdDoc() + doc.heading(1, f"Source Data Query for Test Definition `{test_definition_id}`") + doc.field("Test type", context.get("test_type"), code=True) + doc.field("Table", f"{context.get('schema_name')}.{context.get('table_name')}", code=True) if context.get("column_names"): - lines.append(f"- **Column:** `{context['column_names']}`") - lines.append(f"- **Limit:** {limit}") - lines.append(f"\n```sql\n{query}\n```") + doc.field("Column", context["column_names"], code=True) + doc.field("Limit", limit) + doc.code_block(query, language="sql") - return "\n".join(lines) + return doc.render() @with_database_session @@ -104,30 +104,33 @@ def get_source_data( result: SourceDataResult = fetch_test_result_source_data(context, limit, mask_pii) - lines = [f"# Source Data for Test Definition `{test_definition_id}`\n"] - lines.append(f"- **Test type:** `{context.get('test_type')}`") - lines.append(f"- **Table:** `{context.get('schema_name')}`.`{context.get('table_name')}`") + doc = MdDoc() + doc.heading(1, f"Source Data for Test Definition `{test_definition_id}`") + doc.field("Test type", context.get("test_type"), code=True) + doc.field("Table", f"{context.get('schema_name')}.{context.get('table_name')}", code=True) if context.get("column_names"): - lines.append(f"- **Column:** `{context['column_names']}`") + doc.field("Column", context["column_names"], code=True) if result.status == "OK": row_count = len(result.df) if result.df is not None else 0 - lines.append(f"- **Rows returned:** {row_count}") + doc.field("Rows returned", row_count) if mask_pii: - lines.append("- _PII columns have been redacted._") - lines.append("") - lines.append(dataframe_to_markdown(result.df)) + doc.text("_PII columns have been redacted._") + doc.table_from_dataframe(result.df) if result.query: - lines.append(f"\n**Query used:**\n```sql\n{result.query}\n```") + doc.text("**Query used:**") + doc.code_block(result.query, language="sql") elif result.status == "NA": - lines.append(f"\n{result.message}") + doc.text(result.message) elif result.status == "ND": - lines.append(f"\n{result.message}") + doc.text(result.message) if result.query: - lines.append(f"\n**Query used:**\n```sql\n{result.query}\n```") + doc.text("**Query used:**") + doc.code_block(result.query, language="sql") elif result.status == "ERR": - lines.append(f"\n**Error:** {result.message}") + doc.text(f"**Error:** {result.message}") if result.query: - lines.append(f"\n**Query used:**\n```sql\n{result.query}\n```") + doc.text("**Query used:**") + doc.code_block(result.query, language="sql") - return "\n".join(lines) + return doc.render() diff --git a/testgen/mcp/tools/test_definitions.py b/testgen/mcp/tools/test_definitions.py index cd2d0562..8389d444 100644 --- a/testgen/mcp/tools/test_definitions.py +++ b/testgen/mcp/tools/test_definitions.py @@ -3,25 +3,13 @@ from testgen.common.models.test_result import TestResult from testgen.mcp.exceptions import MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission -from testgen.mcp.tools.common import ( - build_markdown_table, - format_page_footer, - format_page_info, - parse_uuid, - resolve_test_type, -) +from testgen.mcp.tools.common import format_page_footer, format_page_info, parse_uuid, resolve_test_type +from testgen.mcp.tools.markdown import MdDoc _VALID_SCOPES = {"column", "table", "referential", "custom"} _VALID_DIMENSIONS = {"Accuracy", "Completeness", "Consistency", "Recency", "Timeliness", "Uniqueness", "Validity"} -def _format_timestamp(value: str | None) -> str: - """Format an ISO timestamp string to 'YYYY-MM-DD HH:MM' or '—'.""" - if not value: - return "—" - return value[:16].replace("T", " ") - - @with_database_session @mcp_permission("view") def list_tests( @@ -78,26 +66,27 @@ def list_tests( rows.append( [ td.display_name, - f"`{td.table_name}`" if td.table_name else "—", - f"`{td.column_name}`" if td.column_name else "—", + td.table_name, + td.column_name or None, "Yes" if td.test_active else "No", - td.severity or td.default_severity or "—", + td.severity or td.default_severity or None, "Yes" if td.lock_refresh else "No", "No" if td.last_auto_gen_date else "Yes", "Yes" if td.flagged else "No", - str(note_ct) if note_ct else "—", - f"`{td.id}`", + str(note_ct) if note_ct else None, + str(td.id), ] ) - lines = [f"# Test Definitions for suite `{test_suite_id}`\n"] - lines.append(format_page_info(total, page, limit)) - lines.append(build_markdown_table(headers, rows)) + doc = MdDoc() + doc.heading(1, f"Test Definitions for suite `{test_suite_id}`") + doc.text(format_page_info(total, page, limit)) + doc.table(headers, rows, code=[1, 2, 9]) footer = format_page_footer(total, page, limit) if footer: - lines.append(footer) + doc.text(footer) - return "\n".join(lines) + return doc.render() @with_database_session @@ -117,66 +106,68 @@ def get_test(test_definition_id: str) -> str: test_name = td.display_name + doc = MdDoc() + # Header if td.column_name: - lines = [f"# {test_name} on `{td.column_name}` in `{td.table_name}`\n"] + doc.heading(1, f"{test_name} on `{td.column_name}` in `{td.table_name}`") else: - lines = [f"# {test_name} on `{td.table_name}`\n"] + doc.heading(1, f"{test_name} on `{td.table_name}`") - lines.append(f"- **ID:** `{td.id}`") - lines.append(f"- **Test Type:** {test_name}") - lines.append(f"- **Table:** `{td.table_name}`") + doc.field("ID", td.id, code=True) + doc.field("Test Type", test_name) + doc.field("Table", td.table_name, code=True) if td.column_name: - lines.append(f"- **Column:** `{td.column_name}`") - lines.append(f"- **Schema:** `{td.schema_name}`") + doc.field("Column", td.column_name, code=True) + doc.field("Schema", td.schema_name, code=True) if td.test_scope: - lines.append(f"- **Scope:** {td.test_scope}") + doc.field("Scope", td.test_scope) if td.dq_dimension: - lines.append(f"- **Quality Dimension:** {td.dq_dimension}") + doc.field("Quality Dimension", td.dq_dimension) # Configuration - lines.append("\n## Configuration\n") - lines.append(f"- **Active:** {'Yes' if td.test_active else 'No'}") + doc.heading(2, "Configuration") + doc.field("Active", "Yes" if td.test_active else "No") severity = td.severity or (f"{td.default_severity} (test type default)" if td.default_severity else None) if severity: - lines.append(f"- **Severity:** {severity}") - lines.append(f"- **Locked:** {'Yes' if td.lock_refresh else 'No'}") + doc.field("Severity", severity) + doc.field("Locked", "Yes" if td.lock_refresh else "No") if td.export_to_observability is None: from testgen.common.models.test_suite import TestSuite suite = TestSuite.get(td.test_suite_id) inherited = suite.export_to_observability if suite else None - lines.append(f"- **Export to Observability:** {'Yes' if inherited else 'No'} (inherited from suite)") + doc.field("Export to Observability", f"{'Yes' if inherited else 'No'} (inherited from suite)") else: - lines.append(f"- **Export to Observability:** {'Yes' if td.export_to_observability else 'No'}") + doc.field("Export to Observability", "Yes" if td.export_to_observability else "No") # Review status notes = TestDefinitionNote.get_notes(def_uuid) flag_str = "Flagged" if td.flagged else "Not Flagged" - note_str = f"{len(notes)} Note{'s' if len(notes) != 1 else ''}" if notes else "No Notes" - lines.append(f"- **Review:** {flag_str}, {note_str}") + note_str = f"{len(notes)} Notes" if notes else "No Notes" + doc.field("Review", f"{flag_str}, {note_str}") # Origin and last update if td.last_manual_update and td.last_auto_gen_date: - lines.append(f"- **Last Updated:** {max(td.last_manual_update, td.last_auto_gen_date)} (auto-generated, edited)") + doc.field("Last Updated", f"{max(td.last_manual_update, td.last_auto_gen_date)} (auto-generated, edited)") elif td.last_manual_update: - lines.append(f"- **Last Updated:** {td.last_manual_update} (manual edit)") + doc.field("Last Updated", f"{td.last_manual_update} (manual edit)") elif td.last_auto_gen_date: - lines.append(f"- **Last Updated:** {td.last_auto_gen_date} (auto-generated)") + doc.field("Last Updated", f"{td.last_auto_gen_date} (auto-generated)") # Parameters (editable fields from test type metadata) - _append_parameters_section(lines, td) + _append_parameters_section(doc, td) # Custom SQL (only show when the test type declares it as an editable parameter) if "custom_query" in td.param_columns: - lines.append("\n## Custom SQL\n") + doc.heading(2, "Custom SQL") if td.custom_query: - lines.append(f"```sql\n{td.custom_query}\n```") + doc.code_block(td.custom_query, language="sql") else: - lines.append("_No custom SQL defined._") + doc.text("_No custom SQL defined._") - # Reference match (only fields listed in default_parm_columns) - _append_match_section(lines, td) + # Reference match (only fields listed in param_columns) + _append_match_section(doc, td) # Last result results = TestResult.select_history( @@ -184,27 +175,26 @@ def get_test(test_definition_id: str) -> str: project_codes=perms.allowed_codes, limit=1, ) - lines.append("\n## Last Result\n") + doc.heading(2, "Last Result") if results: r = results[0] - status_str = r.status.value if r.status else "—" - lines.append(f"- **Date:** {r.test_time or '—'}") - lines.append(f"- **Status:** {status_str}") + doc.field("Date", r.test_time) + doc.field("Status", r.status.value if r.status else None) if r.message: - lines.append(f"- **Message:** {r.message}") + doc.field("Message", r.message) else: - lines.append("_No results recorded for this test definition._") + doc.text("_No results recorded for this test definition._") # Description description = td.test_description or td.default_test_description if description: - lines.append("\n## Description\n") - lines.append(description) + doc.heading(2, "Description") + doc.text(description) if td.usage_notes: - lines.append("\n## Usage Notes\n") - lines.append(td.usage_notes) + doc.heading(2, "Usage Notes") + doc.text(td.usage_notes) - return "\n".join(lines) + return doc.render() @with_database_session @@ -228,31 +218,24 @@ def list_test_notes(test_definition_id: str) -> str: test_name = td.display_name + doc = MdDoc() if td.column_name: - heading = f"# Notes for {test_name} on `{td.column_name}` in `{td.table_name}`\n" + doc.heading(1, f"Notes for {test_name} on `{td.column_name}` in `{td.table_name}`") else: - heading = f"# Notes for {test_name} on `{td.table_name}`\n" - - headers = ["Date", "Author", "Note", "Updated"] - rows = [ - [ - _format_timestamp(n["created_at"]), - n["created_by"] or "—", - n["detail"], - _format_timestamp(n["updated_at"]), - ] - for n in notes - ] - - lines = [ - heading, - f"{len(notes)} note{'s' if len(notes) != 1 else ''}.\n", - build_markdown_table(headers, rows), - ] - return "\n".join(lines) + doc.heading(1, f"Notes for {test_name} on `{td.table_name}`") + + doc.text(f"{len(notes)} note(s).") + doc.table( + headers=["Date", "Author", "Note", "Updated"], + rows=[ + [n["created_at"], n["created_by"], n["detail"], n["updated_at"]] + for n in notes + ], + ) + return doc.render() -def _append_parameters_section(lines: list[str], td: TestDefinitionSummary) -> None: +def _append_parameters_section(doc: MdDoc, td: TestDefinitionSummary) -> None: """Build the editable parameters table from test type metadata. Always shows all parameters declared in param_columns, even when the @@ -261,17 +244,16 @@ def _append_parameters_section(lines: list[str], td: TestDefinitionSummary) -> N if not td.param_fields: return - headers = ["Parameter", "Field", "Value"] rows = [] for column, prompt, _help in td.param_fields: value = getattr(td, column, None) - rows.append([prompt, f"`{column}`", str(value) if value is not None else "—"]) + rows.append([prompt, column, str(value) if value is not None else None]) - lines.append("\n## Parameters\n") - lines.append(build_markdown_table(headers, rows)) + doc.heading(2, "Parameters") + doc.table(["Parameter", "Field", "Value"], rows, code=[1]) -def _append_match_section(lines: list[str], td: TestDefinitionSummary) -> None: +def _append_match_section(doc: MdDoc, td: TestDefinitionSummary) -> None: """Append reference match section — shows all match fields declared in param_columns.""" match_fields = [ ("Match Schema", "match_schema_name", td.match_schema_name), @@ -285,9 +267,9 @@ def _append_match_section(lines: list[str], td: TestDefinitionSummary) -> None: if not relevant: return - lines.append("\n## Reference Match\n") + doc.heading(2, "Reference Match") for label, value in relevant: - lines.append(f"- **{label}:** {f'`{value}`' if value else '—'}") + doc.field(label, value, code=bool(value)) @with_database_session @@ -332,22 +314,15 @@ def list_test_types( filters_desc.append(f"dimension: {quality_dimension}") filter_suffix = f" ({', '.join(filters_desc)})" if filters_desc else "" - headers = ["Test Type", "Quality Dimension", "Scope", "Description"] - rows = [] - for tt in test_types: - rows.append( - [ - tt.test_name_short or "", - tt.dq_dimension or "", - tt.test_scope or "", - tt.test_description or "", - ] - ) - - lines = [ - "# Test Types\n", - f"Showing {len(rows)} test type{'s' if len(rows) != 1 else ''}{filter_suffix}.\n", - build_markdown_table(headers, rows), - ] + doc = MdDoc() + doc.heading(1, "Test Types") + doc.text(f"Showing {len(test_types)} test type(s){filter_suffix}.") + doc.table( + headers=["Test Type", "Quality Dimension", "Scope", "Description"], + rows=[ + [tt.test_name_short, tt.dq_dimension, tt.test_scope, tt.test_description] + for tt in test_types + ], + ) - return "\n".join(lines) + return doc.render() diff --git a/testgen/mcp/tools/test_results.py b/testgen/mcp/tools/test_results.py index 1aa89e0a..b76a98de 100644 --- a/testgen/mcp/tools/test_results.py +++ b/testgen/mcp/tools/test_results.py @@ -5,6 +5,7 @@ from testgen.mcp.exceptions import MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission from testgen.mcp.tools.common import parse_result_status, parse_uuid, resolve_test_type +from testgen.mcp.tools.markdown import MdDoc @with_database_session @@ -62,29 +63,28 @@ def get_test_results( type_names = {tt.test_type: tt.test_name_short for tt in TestType.select_where(TestType.active == "Y")} - lines = [f"# Test Results for run `{job_execution_id}`\n"] - lines.append(f"Showing {len(results)} result(s) (page {page}).\n") + doc = MdDoc() + doc.heading(1, f"Test Results for run `{job_execution_id}`") + doc.text(f"Showing {len(results)} result(s) (page {page}).") for r in results: status_str = r.status.value if r.status else "Unknown" test_name = type_names.get(r.test_type, r.test_type) if r.column_names: - title = f"## [{status_str}] {test_name} on `{r.column_names}` in `{r.table_name}`" + doc.heading(2, f"[{status_str}] {test_name} on `{r.column_names}` in `{r.table_name}`") else: - title = f"## [{status_str}] {test_name} on `{r.table_name}`" - lines.append(title) - lines.append(f"- Test definition: `{r.test_definition_id}`") + doc.heading(2, f"[{status_str}] {test_name} on `{r.table_name}`") + doc.field("Test definition", r.test_definition_id, code=True) if r.column_names: - lines.append(f"- Column: `{r.column_names}`") + doc.field("Column", r.column_names, code=True) if r.result_measure is not None: - lines.append(f"- Measured value: {r.result_measure}") + doc.field("Measured value", r.result_measure) if r.threshold_value is not None: - lines.append(f"- Threshold: {r.threshold_value}") + doc.field("Threshold", r.threshold_value) if r.message: - lines.append(f"- Message: {r.message}") - lines.append("") + doc.field("Message", r.message) - return "\n".join(lines) + return doc.render() @with_database_session @@ -116,43 +116,38 @@ def get_failure_summary(job_execution_id: str, group_by: str = "test_type") -> s if group_by == "test_type": type_names = {tt.test_type: tt.test_name_short for tt in TestType.select_where(TestType.active == "Y")} - lines = [ - f"# Failure Summary for run `{job_execution_id}`\n", - f"**Total confirmed failures (Failed + Warning):** {total}\n", - ] + doc = MdDoc() + doc.heading(1, f"Failure Summary for run `{job_execution_id}`") + doc.text(f"**Total confirmed failures (Failed + Warning):** {total}") if group_by == "test_type": - lines.append("| Test Type | Severity | Count |") - lines.append("|---|---|---|") - else: - group_label = {"table": "Table Name", "column": "Column"}[group_by] - lines.append(f"| {group_label} | Count |") - lines.append("|---|---|") - - for row in failures: - count = row[-1] - if group_by == "column": - # Row is (table_name, column_names, count) - table, column = row[0], row[1] - label = f"`{column}` in `{table}`" if column else f"`{table}` (table-level)" - lines.append(f"| {label} | {count} |") - elif group_by == "test_type": - # Row is (test_type, status, count) - code = row[0] - status = row[1] + headers = ["Test Type", "Severity", "Count"] + rows = [] + for row in failures: + code, status, count = row[0], row[1], row[-1] name = type_names.get(code, code) severity = status.value if status else "Unknown" - lines.append(f"| {name} | {severity} | {count} |") - else: - lines.append(f"| `{row[0]}` | {count} |") + rows.append([name, severity, count]) + elif group_by == "column": + headers = ["Column", "Count"] + rows = [] + for row in failures: + table, column, count = row[0], row[1], row[-1] + label = f"{MdDoc.code(column)} in {MdDoc.code(table)}" if column else f"{MdDoc.code(table)} (table-level)" + rows.append([label, count]) + else: + headers = ["Table Name", "Count"] + rows = [[row[0], row[-1]] for row in failures] + + doc.table(headers, rows, code=[0] if group_by == "table" else None) if group_by == "test_type": - lines.append( - "\nCheck `testgen://test-types` to understand what each test type checks " + doc.text( + "Check `testgen://test-types` to understand what each test type checks " "and `get_test_type(test_type='...')` to fetch more details." ) - return "\n".join(lines) + return doc.render() @with_database_session @@ -185,27 +180,20 @@ def get_test_result_history( first = results[0] test_name = type_names.get(first.test_type, first.test_type) - lines = [ - "# Test Result History\n", - f"- **Test Type:** {test_name}", - f"- **Table:** `{first.table_name}`", - ] + + doc = MdDoc() + doc.heading(1, "Test Result History") + doc.field("Test Type", test_name) + doc.field("Table", first.table_name, code=True) if first.column_names: - lines.append(f"- **Column:** `{first.column_names}`") - - lines.extend( - [ - f"\nShowing {len(results)} result(s), newest first (page {page}).\n", - "| Date | Measure | Threshold | Status |", - "|---|---|---|---|", - ] + doc.field("Column", first.column_names, code=True) + doc.text(f"Showing {len(results)} result(s), newest first (page {page}).") + doc.table( + headers=["Date", "Measure", "Threshold", "Status"], + rows=[ + [r.test_time, r.result_measure, r.threshold_value, r.status.value if r.status else None] + for r in results + ], ) - for r in results: - date_str = str(r.test_time) if r.test_time else "—" - measure = r.result_measure if r.result_measure is not None else "—" - threshold = r.threshold_value if r.threshold_value is not None else "—" - status_str = r.status.value if r.status else "—" - lines.append(f"| {date_str} | {measure} | {threshold} | {status_str} |") - - return "\n".join(lines) + return doc.render() diff --git a/testgen/mcp/tools/test_runs.py b/testgen/mcp/tools/test_runs.py index c9408423..12f098ed 100644 --- a/testgen/mcp/tools/test_runs.py +++ b/testgen/mcp/tools/test_runs.py @@ -2,6 +2,7 @@ from testgen.common.models.test_run import TestRun from testgen.common.models.test_suite import TestSuite from testgen.mcp.permissions import get_project_permissions, mcp_permission +from testgen.mcp.tools.markdown import MdDoc @with_database_session @@ -45,35 +46,36 @@ def get_recent_test_runs(project_code: str, test_suite: str | None = None, limit runs.append(s) seen[s.test_suite] = count + 1 - lines = [f"# Recent Test Runs for `{project_code}`\n"] + doc = MdDoc() if test_suite: - lines[0] = f"# Recent Test Runs for `{project_code}` / `{test_suite}`\n" - lines.append(f"Showing {len(runs)} run(s) ({limit} per suite).\n") + doc.heading(1, f"Recent Test Runs for `{project_code}` / `{test_suite}`") + else: + doc.heading(1, f"Recent Test Runs for `{project_code}`") + doc.text(f"Showing {len(runs)} run(s) ({limit} per suite).") current_suite = None for run in runs: if run.test_suite != current_suite: current_suite = run.test_suite - lines.append(f"## {current_suite}\n") + doc.heading(2, current_suite) passed = run.passed_ct or 0 failed = run.failed_ct or 0 warning = run.warning_ct or 0 errors = run.error_ct or 0 - lines.append(f"### {run.test_starttime} — {run.status_label}") - lines.append(f"- **Run ID:** `{run.job_execution_id}`") - lines.append(f"- **Started:** {run.test_starttime} | **Ended:** {run.test_endtime}") - lines.append(f"- **Results:** {run.test_ct or 0} tests — {passed} passed, {failed} failed, {warning} warnings, {errors} errors") + doc.heading(3, f"{run.test_starttime} — {run.status_label}") + doc.field("Run ID", run.job_execution_id, code=True) + doc.field("Started", run.test_starttime) + doc.field("Ended", run.test_endtime) + doc.field("Results", f"{run.test_ct or 0} tests — {passed} passed, {failed} failed, {warning} warnings, {errors} errors") if run.dismissed_ct: - lines.append(f"- **Dismissed:** {run.dismissed_ct}") + doc.field("Dismissed", run.dismissed_ct) if run.dq_score_testing is not None: - lines.append(f"- **Testing Score:** {run.dq_score_testing:.1f}") + doc.field("Testing Score", f"{run.dq_score_testing:.1f}") - lines.append("") + doc.text("Use `get_test_results(job_execution_id='...')` for detailed results of a specific run.") - lines.append("Use `get_test_results(job_execution_id='...')` for detailed results of a specific run.") - - return "\n".join(lines) + return doc.render() diff --git a/tests/unit/mcp/test_markdown.py b/tests/unit/mcp/test_markdown.py new file mode 100644 index 00000000..8d4f2771 --- /dev/null +++ b/tests/unit/mcp/test_markdown.py @@ -0,0 +1,452 @@ +from datetime import datetime + +import pandas as pd + +from testgen.mcp.tools.markdown import ( + MdDoc, + _escape_inline, + _escape_table_cell, + _format_dt, +) + +# --- _escape_inline --- + + +def test_escape_inline_backslash(): + assert _escape_inline(r"a\b") == r"a\\b" + + +def test_escape_inline_asterisk(): + assert _escape_inline("a*b") == r"a\*b" + + +def test_escape_inline_underscore(): + assert _escape_inline("a_b") == r"a\_b" + + +def test_escape_inline_brackets(): + assert _escape_inline("[link](url)") == r"\[link\](url)" + + +def test_escape_inline_backtick(): + assert _escape_inline("use `code`") == r"use \`code\`" + + +def test_escape_inline_plain_text(): + assert _escape_inline("hello world 123") == "hello world 123" + + +def test_escape_inline_multiple_special_chars(): + assert _escape_inline("**bold** and _italic_") == r"\*\*bold\*\* and \_italic\_" + + +# --- _escape_table_cell --- + + +def test_escape_table_cell_pipe(): + assert _escape_table_cell("a|b") == r"a\|b" + + +def test_escape_table_cell_backslash(): + assert _escape_table_cell(r"a\b") == r"a\\b" + + +def test_escape_table_cell_plain(): + assert _escape_table_cell("hello 123") == "hello 123" + + +def test_escape_table_cell_asterisk_not_escaped(): + assert _escape_table_cell("a*b") == "a*b" + + +# --- _format_dt --- + + +def test_format_dt_datetime_object(): + dt = datetime(2025, 3, 15, 10, 30, 45) + assert _format_dt(dt) == "2025-03-15 10:30 UTC" + + +def test_format_dt_iso_string_with_t(): + assert _format_dt("2025-03-15T10:30:45") == "2025-03-15 10:30 UTC" + + +def test_format_dt_iso_string_with_space(): + assert _format_dt("2025-03-15 10:30:45") == "2025-03-15 10:30 UTC" + + +def test_format_dt_non_datetime_string(): + assert _format_dt("just a string") is None + + +def test_format_dt_none(): + assert _format_dt(None) is None + + +def test_format_dt_integer(): + assert _format_dt(42) is None + + +# --- MdDoc.heading --- + + +def test_heading_level_1(): + doc = MdDoc() + doc.heading(1, "Title") + assert doc.render() == "# Title" + + +def test_heading_level_2(): + doc = MdDoc() + doc.heading(2, "Section") + assert doc.render() == "## Section" + + +def test_heading_level_3(): + doc = MdDoc() + doc.heading(3, "Subsection") + assert doc.render() == "### Subsection" + + +# --- MdDoc.field --- + + +def test_field_string(): + doc = MdDoc() + doc.field("Name", "Alice") + assert doc.render() == "- **Name:** Alice" + + +def test_field_none(): + doc = MdDoc() + doc.field("Value", None) + assert doc.render() == "- **Value:** \u2014" + + +def test_field_datetime(): + doc = MdDoc() + doc.field("Started", datetime(2025, 3, 15, 10, 30)) + assert doc.render() == "- **Started:** 2025-03-15 10:30 UTC" + + +def test_field_iso_string(): + doc = MdDoc() + doc.field("Date", "2025-03-15T10:30:00") + assert doc.render() == "- **Date:** 2025-03-15 10:30 UTC" + + +def test_field_code(): + doc = MdDoc() + doc.field("ID", "abc-123", code=True) + assert doc.render() == "- **ID:** `abc-123`" + + +def test_field_code_datetime(): + doc = MdDoc() + doc.field("Time", datetime(2025, 1, 1, 12, 0), code=True) + assert doc.render() == "- **Time:** `2025-01-01 12:00 UTC`" + + +def test_field_no_escaping(): + doc = MdDoc() + doc.field("Column", "amount_*total*") + assert doc.render() == "- **Column:** amount_*total*" + + +def test_field_code_preserves_special_chars(): + doc = MdDoc() + doc.field("Column", "amount_*total*", code=True) + assert doc.render() == "- **Column:** `amount_*total*`" + + +def test_field_code_backtick_in_value_uses_double_fence(): + doc = MdDoc() + doc.field("Column", "col`name", code=True) + assert doc.render() == "- **Column:** `` col`name ``" + + +def test_consecutive_fields_merge(): + doc = MdDoc() + doc.field("A", "1") + doc.field("B", "2") + result = doc.render() + assert result == "- **A:** 1\n- **B:** 2" + assert "\n\n" not in result + + +def test_field_after_heading_starts_new_section(): + doc = MdDoc() + doc.heading(1, "Title") + doc.field("A", "1") + assert doc.render() == "# Title\n\n- **A:** 1" + + +# --- MdDoc.text --- + + +def test_text_single_string(): + doc = MdDoc() + doc.text("Hello world.") + assert doc.render() == "Hello world." + + +def test_text_multiple_parts(): + doc = MdDoc() + doc.text("Showing", 5, "results.") + assert doc.render() == "Showing 5 results." + + +def test_text_datetime_part(): + doc = MdDoc() + doc.text("Since", datetime(2025, 3, 15, 10, 30)) + assert doc.render() == "Since 2025-03-15 10:30 UTC" + + +def test_text_iso_string_part(): + doc = MdDoc() + doc.text("Since", "2025-03-15T10:30:00") + assert doc.render() == "Since 2025-03-15 10:30 UTC" + + +def test_text_none_part(): + doc = MdDoc() + doc.text("Value:", None) + assert doc.render() == "Value: \u2014" + + +def test_text_empty_skipped(): + doc = MdDoc() + doc.text() + assert doc.render() == "" + + +# --- MdDoc.table --- + + +def test_table_basic(): + doc = MdDoc() + doc.table(["Name", "Score"], [["Alice", 95], ["Bob", 87]]) + result = doc.render() + assert "| Name | Score |" in result + assert "| --- | --- |" in result + assert "| Alice | 95 |" in result + assert "| Bob | 87 |" in result + + +def test_table_empty_rows(): + doc = MdDoc() + doc.table(["A", "B"], []) + assert doc.render() == "_No rows._" + + +def test_table_null_display(): + doc = MdDoc() + doc.table(["A"], [[None]], null_display="N/A") + assert "| N/A |" in doc.render() + + +def test_table_newline_in_cell_replaced(): + doc = MdDoc() + doc.table(["Col"], [["line1\nline2"]]) + result = doc.render() + assert "line1 line2" in result + assert "\n" not in result.split("\n")[2] # data row has no raw newline + + +def test_table_code_columns(): + doc = MdDoc() + doc.table(["Name", "Table"], [["test1", "my_table"]], code=[1]) + result = doc.render() + assert "| test1 | `my_table` |" in result + + +def test_table_code_columns_null_skipped(): + doc = MdDoc() + doc.table(["Name", "Table"], [["test1", None]], code=[1]) + result = doc.render() + assert "| test1 | \u2014 |" in result + + +def test_table_escapes_pipes(): + doc = MdDoc() + doc.table(["Col"], [["a|b"]]) + assert r"| a\|b |" in doc.render() + + +def test_table_escapes_pipes_in_headers(): + doc = MdDoc() + doc.table(["Col|Name"], [["x"]]) + assert r"| Col\|Name |" in doc.render() + + +def test_table_datetime_in_cell(): + doc = MdDoc() + doc.table(["Date"], [[datetime(2025, 3, 15, 10, 30)]]) + assert "| 2025-03-15 10:30 UTC |" in doc.render() + + +def test_table_none_default_display(): + doc = MdDoc() + doc.table(["A"], [[None]]) + assert "| \u2014 |" in doc.render() + + +# --- MdDoc.table_from_dataframe --- + + +def test_table_from_dataframe_basic(): + df = pd.DataFrame({"name": ["Alice", "Bob"], "score": [95, 87]}) + doc = MdDoc() + doc.table_from_dataframe(df) + result = doc.render() + assert "| name | score |" in result + assert "| Alice | 95 |" in result + assert "| Bob | 87 |" in result + + +def test_table_from_dataframe_none(): + doc = MdDoc() + doc.table_from_dataframe(None) + assert doc.render() == "_No rows._" + + +def test_table_from_dataframe_empty(): + doc = MdDoc() + doc.table_from_dataframe(pd.DataFrame({"col": []})) + assert doc.render() == "_No rows._" + + +def test_table_from_dataframe_nan_values(): + df = pd.DataFrame({"a": [1, None], "b": [None, "x"]}) + doc = MdDoc() + doc.table_from_dataframe(df) + result = doc.render() + lines = result.split("\n") + data_rows = lines[2:] + assert "| 1.0 | _NULL_ |" == data_rows[0] + assert "| _NULL_ | x |" == data_rows[1] + + +def test_table_from_dataframe_escapes_pipes(): + df = pd.DataFrame({"col": ["a|b", "no pipes"]}) + doc = MdDoc() + doc.table_from_dataframe(df) + result = doc.render() + assert r"a\|b" in result + + +def test_table_from_dataframe_custom_null_display(): + df = pd.DataFrame({"a": [None]}) + doc = MdDoc() + doc.table_from_dataframe(df, null_display="") + assert "| |" in doc.render() + + +# --- MdDoc.bullets --- + + +def test_bullets_basic(): + doc = MdDoc() + doc.bullets(["one", "two", "three"]) + assert doc.render() == "- one\n- two\n- three" + + +def test_bullets_no_escaping(): + doc = MdDoc() + doc.bullets(["amount_*total*"]) + assert doc.render() == "- amount_*total*" + + +def test_bullets_preserves_backticks(): + doc = MdDoc() + doc.bullets(["`orders`", "`customers`"]) + assert "- `orders`" in doc.render() + assert "- `customers`" in doc.render() + + +# --- MdDoc.code_block --- + + +def test_code_block_basic(): + doc = MdDoc() + doc.code_block("SELECT 1;", language="sql") + assert doc.render() == "```sql\nSELECT 1;\n```" + + +def test_code_block_no_language(): + doc = MdDoc() + doc.code_block("hello") + assert doc.render() == "```\nhello\n```" + + +def test_code_block_fence_upgrade(): + doc = MdDoc() + doc.code_block("contains ``` triple backticks") + result = doc.render() + assert result.startswith("````\n") + assert result.endswith("\n````") + assert "contains ``` triple backticks" in result + + +# --- MdDoc.render (multi-section) --- + + +def test_render_multiple_sections(): + doc = MdDoc() + doc.heading(1, "Title") + doc.field("Key", "val") + doc.text("A paragraph.") + doc.table(["A"], [["x"]]) + result = doc.render() + sections = result.split("\n\n") + assert sections[0] == "# Title" + assert sections[1] == "- **Key:** val" + assert sections[2] == "A paragraph." + assert "| A |" in sections[3] + + +def test_render_empty_doc(): + assert MdDoc().render() == "" + + +# --- MdDoc.code --- + + +def test_code_basic(): + assert MdDoc.code("my_table") == "`my_table`" + + +def test_code_with_backtick(): + assert MdDoc.code("col`name") == "`` col`name ``" + + +def test_code_with_newline(): + assert MdDoc.code("line1\nline2") == r"`line1\nline2`" + + +def test_code_empty(): + assert MdDoc.code("") == "\u2014" + + +def test_code_none(): + assert MdDoc.code(None) == "\u2014" + + +# --- MdDoc.escape --- + + +def test_escape_for_untrusted_data(): + doc = MdDoc() + doc.field("Note", MdDoc.escape("user typed *bold* and _italic_")) + assert r"\*bold\*" in doc.render() + assert r"\_italic\_" in doc.render() + + +def test_fluent_chaining(): + result = ( + MdDoc() + .heading(1, "Title") + .text("Hello.") + .render() + ) + assert result == "# Title\n\nHello." diff --git a/tests/unit/mcp/test_tools_common.py b/tests/unit/mcp/test_tools_common.py index 12cc071c..57ae2fc6 100644 --- a/tests/unit/mcp/test_tools_common.py +++ b/tests/unit/mcp/test_tools_common.py @@ -1,11 +1,10 @@ from uuid import UUID -import pandas as pd import pytest from testgen.common.models.test_result import TestResultStatus from testgen.mcp.exceptions import MCPUserError -from testgen.mcp.tools.common import dataframe_to_markdown, parse_result_status, parse_uuid +from testgen.mcp.tools.common import parse_result_status, parse_uuid # --- parse_uuid --- @@ -56,58 +55,3 @@ def test_parse_result_status_invalid_lists_valid_values(): parse_result_status("nope") for status in TestResultStatus: assert status.value in str(exc_info.value) - - -# --- dataframe_to_markdown --- - - -def test_dataframe_to_markdown_basic(): - df = pd.DataFrame({"name": ["Alice", "Bob"], "score": [95, 87]}) - result = dataframe_to_markdown(df) - - assert "| name | score |" in result - assert "| --- | --- |" in result - assert "| Alice | 95 |" in result - assert "| Bob | 87 |" in result - - -def test_dataframe_to_markdown_none(): - assert dataframe_to_markdown(None) == "_No rows._" - - -def test_dataframe_to_markdown_empty(): - df = pd.DataFrame({"col": []}) - assert dataframe_to_markdown(df) == "_No rows._" - - -def test_dataframe_to_markdown_null_values(): - df = pd.DataFrame({"a": [1, None], "b": [None, "x"]}) - result = dataframe_to_markdown(df) - - lines = result.split("\n") - data_rows = lines[2:] - assert "| 1.0 | _NULL_ |" == data_rows[0] - assert "| _NULL_ | x |" == data_rows[1] - - -def test_dataframe_to_markdown_custom_null_display(): - df = pd.DataFrame({"a": [None]}) - result = dataframe_to_markdown(df, null_display="") - - assert "| |" in result - - -def test_dataframe_to_markdown_escapes_pipes_in_values(): - df = pd.DataFrame({"col": ['{"a"|"b"}', "no pipes"]}) - result = dataframe_to_markdown(df) - - lines = result.split("\n") - assert r'| {"a"\|"b"} |' == lines[2] - assert "| no pipes |" == lines[3] - - -def test_dataframe_to_markdown_escapes_pipes_in_headers(): - df = pd.DataFrame({"col|name": [1]}) - result = dataframe_to_markdown(df) - - assert r"| col\|name |" in result diff --git a/tests/unit/mcp/test_tools_source_data.py b/tests/unit/mcp/test_tools_source_data.py index 15c872ef..e21a363c 100644 --- a/tests/unit/mcp/test_tools_source_data.py +++ b/tests/unit/mcp/test_tools_source_data.py @@ -38,7 +38,7 @@ def test_get_source_data_query_basic(mock_td, mock_build, db_session_mock): assert f"# Source Data Query for Test Definition `{td_id}`" in result assert "Alpha_Trunc" in result - assert "`public`.`orders`" in result + assert "public.orders" in result assert "`customer_name`" in result assert "SELECT * FROM orders" in result mock_build.assert_called_once() diff --git a/tests/unit/mcp/test_tools_test_definitions.py b/tests/unit/mcp/test_tools_test_definitions.py index 6c775719..ae0cbfe3 100644 --- a/tests/unit/mcp/test_tools_test_definitions.py +++ b/tests/unit/mcp/test_tools_test_definitions.py @@ -417,7 +417,7 @@ def test_list_test_notes_basic(mock_td, mock_notes, db_session_mock): assert "Alpha Truncation" in result assert "`name`" in result assert "`orders`" in result - assert "2 notes" in result + assert "2 note(s)" in result assert "Threshold looks wrong" in result assert "alice" in result assert "2026-04-01 10:00" in result From d44643ea7fbb6144daaec79b9fa422499113375d Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Fri, 17 Apr 2026 10:12:15 -0300 Subject: [PATCH 062/123] feat(ui): show pending jobs in runs lists with job-centric queries (TG-1034) Runs lists now query from job_executions instead of profiling_runs/test_runs, so pending and in-progress jobs appear immediately after submission. Adds server-side pagination (page size 20), tiered auto-refresh (6s/30s/60s based on job status), cancel button for all active statuses, and status display via JobStatus labels. Delete dialog state moved to client-side JS. Downstream callers (MCP tool, notification handler) updated for new return type and field names. Co-Authored-By: Claude Opus 4.6 (1M context) --- testgen/common/models/job_execution.py | 2 + testgen/common/models/profiling_run.py | 185 ++++++++-------- testgen/common/models/test_run.py | 194 ++++++++--------- testgen/common/notifications/test_run.py | 26 +-- testgen/mcp/tools/test_runs.py | 8 +- .../frontend/js/pages/profiling_runs.js | 202 ++++++++++-------- .../components/frontend/js/pages/test_runs.js | 201 +++++++++-------- testgen/ui/services/query_cache.py | 15 +- testgen/ui/views/profiling_runs.py | 76 ++++--- testgen/ui/views/test_runs.py | 76 ++++--- .../test_test_run_notifications.py | 2 +- tests/unit/mcp/test_tools_test_runs.py | 24 ++- 12 files changed, 513 insertions(+), 498 deletions(-) diff --git a/testgen/common/models/job_execution.py b/testgen/common/models/job_execution.py index 4a0b01e2..9f2d0399 100644 --- a/testgen/common/models/job_execution.py +++ b/testgen/common/models/job_execution.py @@ -35,6 +35,8 @@ class JobExecution(Base): id: UUID = Column(postgresql.UUID(as_uuid=True), primary_key=True, default=uuid4) job_key: str = Column(String(100), nullable=False) + # args and kwargs are internal dispatch details passed to the job handler. + # Do not query or filter on them — external code should not depend on their structure. args: list[Any] = Column(postgresql.JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb")) kwargs: dict[str, Any] = Column(postgresql.JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb")) source: str = Column(String(20), nullable=False) diff --git a/testgen/common/models/profiling_run.py b/testgen/common/models/profiling_run.py index 14d47a6c..3a707086 100644 --- a/testgen/common/models/profiling_run.py +++ b/testgen/common/models/profiling_run.py @@ -1,7 +1,7 @@ from collections.abc import Iterable from dataclasses import dataclass from datetime import UTC, datetime -from typing import Literal, NamedTuple, Self, TypedDict +from typing import ClassVar, Literal, NamedTuple, Self, TypedDict from uuid import UUID, uuid4 import streamlit as st @@ -44,26 +44,43 @@ class ProfilingRunMinimal(EntityMinimal): @dataclass class ProfilingRunSummary(EntityMinimal): - id: UUID - profiling_starttime: datetime - profiling_endtime: datetime - table_groups_name: str - status: ProfilingRunStatus + job_execution_id: UUID + profiling_run_id: UUID | None + status: JobStatus + created_at: datetime + started_at: datetime | None + completed_at: datetime | None + error_message: str | None progress: list[ProgressStep] - process_id: int - job_execution_id: UUID | None - log_message: str - table_group_schema: str - table_ct: int - column_ct: int - record_ct: int - data_point_ct: int - anomaly_ct: int - anomalies_definite_ct: int - anomalies_likely_ct: int - anomalies_possible_ct: int - anomalies_dismissed_ct: int - dq_score_profiling: float + table_groups_name: str | None + table_group_schema: str | None + process_id: int | None + log_message: str | None + table_ct: int | None + column_ct: int | None + record_ct: int | None + data_point_ct: int | None + anomaly_ct: int | None + anomalies_definite_ct: int | None + anomalies_likely_ct: int | None + anomalies_possible_ct: int | None + anomalies_dismissed_ct: int | None + dq_score_profiling: float | None + total_count: int + + STATUS_LABEL: ClassVar[dict[str, str]] = { + JobStatus.COMPLETED: "Completed", + JobStatus.CANCELED: "Canceled", + JobStatus.CANCEL_REQUESTED: "Canceling", + JobStatus.PENDING: "Pending", + JobStatus.CLAIMED: "Starting", + JobStatus.RUNNING: "Running", + JobStatus.ERROR: "Error", + } + + @property + def status_label(self) -> str: + return self.STATUS_LABEL.get(self.status, self.status) class LatestProfilingRun(NamedTuple): @@ -162,94 +179,74 @@ def select_summary( cls, project_code: str, table_group_id: str | UUID | None = None, - profiling_run_ids: list[str|UUID] | None = None, - ) -> Iterable[ProfilingRunSummary]: - if (table_group_id and not is_uuid4(table_group_id)) or ( - profiling_run_ids and not all(is_uuid4(run_id) for run_id in profiling_run_ids) - ): - return [] + page: int = 1, + page_size: int = 20, + ) -> tuple[list[ProfilingRunSummary], int]: + if table_group_id and not is_uuid4(table_group_id): + return [], 0 query = f""" WITH profile_anomalies AS ( SELECT profile_anomaly_results.profile_run_id, - SUM( - CASE - WHEN COALESCE(profile_anomaly_results.disposition, 'Confirmed') = 'Confirmed' - AND profile_anomaly_types.issue_likelihood = 'Definite' THEN 1 - ELSE 0 - END - ) AS definite_ct, - SUM( - CASE - WHEN COALESCE(profile_anomaly_results.disposition, 'Confirmed') = 'Confirmed' - AND profile_anomaly_types.issue_likelihood = 'Likely' THEN 1 - ELSE 0 - END - ) AS likely_ct, - SUM( - CASE - WHEN COALESCE(profile_anomaly_results.disposition, 'Confirmed') = 'Confirmed' - AND profile_anomaly_types.issue_likelihood IN ('Possible', 'Potential PII') THEN 1 - ELSE 0 - END - ) AS possible_ct, - SUM( - CASE - WHEN COALESCE(profile_anomaly_results.disposition, 'Confirmed') IN ('Dismissed', 'Inactive') THEN 1 - ELSE 0 - END - ) AS dismissed_ct + SUM(CASE WHEN COALESCE(profile_anomaly_results.disposition, 'Confirmed') = 'Confirmed' + AND profile_anomaly_types.issue_likelihood = 'Definite' THEN 1 ELSE 0 END) AS definite_ct, + SUM(CASE WHEN COALESCE(profile_anomaly_results.disposition, 'Confirmed') = 'Confirmed' + AND profile_anomaly_types.issue_likelihood = 'Likely' THEN 1 ELSE 0 END) AS likely_ct, + SUM(CASE WHEN COALESCE(profile_anomaly_results.disposition, 'Confirmed') = 'Confirmed' + AND profile_anomaly_types.issue_likelihood IN ('Possible', 'Potential PII') + THEN 1 ELSE 0 END) AS possible_ct, + SUM(CASE WHEN COALESCE(profile_anomaly_results.disposition, 'Confirmed') + IN ('Dismissed', 'Inactive') THEN 1 ELSE 0 END) AS dismissed_ct FROM profile_anomaly_results - LEFT JOIN profile_anomaly_types ON ( - profile_anomaly_types.id = profile_anomaly_results.anomaly_id - ) + LEFT JOIN profile_anomaly_types + ON profile_anomaly_types.id = profile_anomaly_results.anomaly_id GROUP BY profile_anomaly_results.profile_run_id ) - SELECT profiling_runs.id, - je.started_at AS profiling_starttime, - COALESCE(je.completed_at, NOW()) AS profiling_endtime, - table_groups.table_groups_name, - CASE je.status - WHEN 'completed' THEN 'Complete' - WHEN 'error' THEN 'Error' - WHEN 'canceled' THEN 'Cancelled' - WHEN 'cancel_requested' THEN 'Cancelled' - WHEN 'running' THEN 'Running' - WHEN 'pending' THEN 'Running' - WHEN 'claimed' THEN 'Running' - END AS status, - profiling_runs.progress, - profiling_runs.process_id, - profiling_runs.job_execution_id, - je.error_message AS log_message, - table_groups.table_group_schema, - profiling_runs.table_ct, - profiling_runs.column_ct, - profiling_runs.record_ct, - profiling_runs.data_point_ct, - profiling_runs.anomaly_ct, - profile_anomalies.definite_ct AS anomalies_definite_ct, - profile_anomalies.likely_ct AS anomalies_likely_ct, - profile_anomalies.possible_ct AS anomalies_possible_ct, - profile_anomalies.dismissed_ct AS anomalies_dismissed_ct, - profiling_runs.dq_score_profiling - FROM profiling_runs - LEFT JOIN job_executions je ON je.id = profiling_runs.job_execution_id - LEFT JOIN table_groups ON (profiling_runs.table_groups_id = table_groups.id) - LEFT JOIN profile_anomalies ON (profiling_runs.id = profile_anomalies.profile_run_id) - WHERE profiling_runs.project_code = :project_code - {"AND profiling_runs.table_groups_id = :table_group_id" if table_group_id else ""} - {"AND profiling_runs.id IN :profiling_run_ids" if profiling_run_ids else ""} - ORDER BY je.started_at DESC; + SELECT + je.id AS job_execution_id, + pr.id AS profiling_run_id, + je.status, + je.created_at, + je.started_at, + je.completed_at, + je.error_message, + COALESCE(pr.progress, '[]'::jsonb) AS progress, + tg.table_groups_name, + tg.table_group_schema, + pr.process_id, + pr.log_message, + pr.table_ct, + pr.column_ct, + pr.record_ct, + pr.data_point_ct, + pr.anomaly_ct, + pa.definite_ct AS anomalies_definite_ct, + pa.likely_ct AS anomalies_likely_ct, + pa.possible_ct AS anomalies_possible_ct, + pa.dismissed_ct AS anomalies_dismissed_ct, + pr.dq_score_profiling, + COUNT(*) OVER() AS total_count + FROM job_executions je + LEFT JOIN profiling_runs pr ON pr.job_execution_id = je.id + LEFT JOIN table_groups tg ON tg.id = pr.table_groups_id + LEFT JOIN profile_anomalies pa ON pa.profile_run_id = pr.id + WHERE je.job_key = 'run-profile' + AND je.project_code = :project_code + {" AND tg.id = :table_group_id" if table_group_id else ""} + ORDER BY je.created_at DESC + LIMIT :limit OFFSET :offset; """ params = { "project_code": project_code, "table_group_id": table_group_id, - "profiling_run_ids": tuple(profiling_run_ids or []), + "limit": page_size, + "offset": (page - 1) * page_size, } db_session = get_current_session() results = db_session.execute(text(query), params).mappings().all() - return [ProfilingRunSummary(**row) for row in results] + items = [ProfilingRunSummary(**row) for row in results] + total = items[0].total_count if items else 0 + return items, total _ACTIVE_JOB_STATUSES = (JobStatus.PENDING, JobStatus.CLAIMED, JobStatus.RUNNING, JobStatus.CANCEL_REQUESTED) diff --git a/testgen/common/models/test_run.py b/testgen/common/models/test_run.py index 5efaf207..d2d0ac09 100644 --- a/testgen/common/models/test_run.py +++ b/testgen/common/models/test_run.py @@ -1,4 +1,3 @@ -from collections.abc import Iterable from dataclasses import dataclass from datetime import UTC, datetime from typing import ClassVar, Literal, NamedTuple, Self, TypedDict @@ -46,30 +45,38 @@ class TestRunMinimal(EntityMinimal): @dataclass class TestRunSummary(EntityMinimal): - test_run_id: UUID - test_starttime: datetime - test_endtime: datetime - table_groups_name: str - test_suite: str + job_execution_id: UUID + test_run_id: UUID | None + status: JobStatus + created_at: datetime + started_at: datetime | None + completed_at: datetime | None + error_message: str | None + progress: list[ProgressStep] + table_groups_name: str | None + test_suite: str | None project_code: str project_name: str - status: TestRunStatus - progress: list[ProgressStep] - process_id: int - job_execution_id: UUID | None - log_message: str - test_ct: int - passed_ct: int - warning_ct: int - failed_ct: int - error_ct: int - log_ct: int - dismissed_ct: int - dq_score_testing: float + process_id: int | None + log_message: str | None + test_ct: int | None + passed_ct: int | None + warning_ct: int | None + failed_ct: int | None + error_ct: int | None + log_ct: int | None + dismissed_ct: int | None + dq_score_testing: float | None + total_count: int STATUS_LABEL: ClassVar[dict[str, str]] = { - "Complete": "Completed", - "Cancelled": "Canceled", + JobStatus.COMPLETED: "Completed", + JobStatus.CANCELED: "Canceled", + JobStatus.CANCEL_REQUESTED: "Canceling", + JobStatus.PENDING: "Pending", + JobStatus.CLAIMED: "Starting", + JobStatus.RUNNING: "Running", + JobStatus.ERROR: "Error", } @property @@ -206,112 +213,87 @@ def select_summary( project_code: str | None = None, table_group_id: str | None = None, test_suite_id: str | None = None, - test_run_ids: list[str] | None = None, - ) -> Iterable[TestRunSummary]: + test_run_ids: list[str | UUID] | None = None, + page: int = 1, + page_size: int = 20, + ) -> tuple[list[TestRunSummary], int]: if ( (table_group_id and not is_uuid4(table_group_id)) or (test_suite_id and not is_uuid4(test_suite_id)) or (test_run_ids and not all(is_uuid4(run_id) for run_id in test_run_ids)) ): - return [] + return [], 0 query = f""" WITH run_results AS ( SELECT test_run_id, - SUM( - CASE - WHEN COALESCE(disposition, 'Confirmed') = 'Confirmed' - AND result_status = 'Passed' THEN 1 - ELSE 0 - END - ) AS passed_ct, - SUM( - CASE - WHEN COALESCE(disposition, 'Confirmed') = 'Confirmed' - AND result_status = 'Warning' THEN 1 - ELSE 0 - END - ) AS warning_ct, - SUM( - CASE - WHEN COALESCE(disposition, 'Confirmed') = 'Confirmed' - AND result_status = 'Failed' THEN 1 - ELSE 0 - END - ) AS failed_ct, - SUM( - CASE - WHEN COALESCE(disposition, 'Confirmed') = 'Confirmed' - AND result_status = 'Error' THEN 1 - ELSE 0 - END - ) AS error_ct, - SUM( - CASE - WHEN COALESCE(disposition, 'Confirmed') = 'Confirmed' - AND result_status = 'Log' THEN 1 - ELSE 0 - END - ) AS log_ct, - SUM( - CASE - WHEN COALESCE(disposition, 'Confirmed') IN ('Dismissed', 'Inactive') THEN 1 - ELSE 0 - END - ) AS dismissed_ct + SUM(CASE WHEN COALESCE(disposition, 'Confirmed') = 'Confirmed' + AND result_status = 'Passed' THEN 1 ELSE 0 END) AS passed_ct, + SUM(CASE WHEN COALESCE(disposition, 'Confirmed') = 'Confirmed' + AND result_status = 'Warning' THEN 1 ELSE 0 END) AS warning_ct, + SUM(CASE WHEN COALESCE(disposition, 'Confirmed') = 'Confirmed' + AND result_status = 'Failed' THEN 1 ELSE 0 END) AS failed_ct, + SUM(CASE WHEN COALESCE(disposition, 'Confirmed') = 'Confirmed' + AND result_status = 'Error' THEN 1 ELSE 0 END) AS error_ct, + SUM(CASE WHEN COALESCE(disposition, 'Confirmed') = 'Confirmed' + AND result_status = 'Log' THEN 1 ELSE 0 END) AS log_ct, + SUM(CASE WHEN COALESCE(disposition, 'Confirmed') IN ('Dismissed', 'Inactive') + THEN 1 ELSE 0 END) AS dismissed_ct FROM test_results GROUP BY test_run_id ) - SELECT test_runs.id AS test_run_id, - je.started_at AS test_starttime, - COALESCE(je.completed_at, NOW()) AS test_endtime, - table_groups.table_groups_name, - test_suites.test_suite, - test_suites.project_code, - projects.project_name, - CASE je.status - WHEN 'completed' THEN 'Complete' - WHEN 'error' THEN 'Error' - WHEN 'canceled' THEN 'Cancelled' - WHEN 'cancel_requested' THEN 'Cancelled' - WHEN 'running' THEN 'Running' - WHEN 'pending' THEN 'Running' - WHEN 'claimed' THEN 'Running' - END AS status, - test_runs.progress, - test_runs.process_id, - test_runs.job_execution_id, - je.error_message AS log_message, - test_runs.test_ct, - run_results.passed_ct, - run_results.warning_ct, - run_results.failed_ct, - run_results.error_ct, - run_results.log_ct, - run_results.dismissed_ct, - test_runs.dq_score_test_run AS dq_score_testing - FROM test_runs - LEFT JOIN job_executions je ON je.id = test_runs.job_execution_id - LEFT JOIN run_results ON (test_runs.id = run_results.test_run_id) - INNER JOIN test_suites ON (test_runs.test_suite_id = test_suites.id) - INNER JOIN table_groups ON (test_suites.table_groups_id = table_groups.id) - INNER JOIN projects ON (test_suites.project_code = projects.project_code) - WHERE test_suites.is_monitor IS NOT TRUE - {" AND test_suites.project_code = :project_code" if project_code else ""} - {" AND test_suites.table_groups_id = :table_group_id" if table_group_id else ""} - {" AND test_suites.id = :test_suite_id" if test_suite_id else ""} - {" AND test_runs.id IN :test_run_ids" if test_run_ids else ""} - ORDER BY je.started_at DESC; + SELECT + je.id AS job_execution_id, + tr.id AS test_run_id, + je.status, + je.created_at, + je.started_at, + je.completed_at, + je.error_message, + COALESCE(tr.progress, '[]'::jsonb) AS progress, + tg.table_groups_name, + ts.test_suite, + je.project_code, + p.project_name, + tr.process_id, + tr.log_message, + tr.test_ct, + rr.passed_ct, + rr.warning_ct, + rr.failed_ct, + rr.error_ct, + rr.log_ct, + rr.dismissed_ct, + tr.dq_score_test_run AS dq_score_testing, + COUNT(*) OVER() AS total_count + FROM job_executions je + LEFT JOIN test_runs tr ON tr.job_execution_id = je.id + LEFT JOIN test_suites ts ON ts.id = tr.test_suite_id + LEFT JOIN table_groups tg ON tg.id = ts.table_groups_id + LEFT JOIN projects p ON p.project_code = je.project_code + LEFT JOIN run_results rr ON rr.test_run_id = tr.id + WHERE je.job_key = 'run-tests' + AND (ts.is_monitor IS NOT TRUE OR ts.id IS NULL) + {" AND je.project_code = :project_code" if project_code else ""} + {" AND ts.table_groups_id = :table_group_id" if table_group_id else ""} + {" AND ts.id = :test_suite_id" if test_suite_id else ""} + {" AND tr.id IN :test_run_ids" if test_run_ids else ""} + ORDER BY je.created_at DESC + LIMIT :limit OFFSET :offset; """ params = { "project_code": project_code, "table_group_id": table_group_id, "test_suite_id": test_suite_id, "test_run_ids": tuple(test_run_ids or []), + "limit": page_size, + "offset": (page - 1) * page_size, } db_session = get_current_session() results = db_session.execute(text(query), params).mappings().all() - return [TestRunSummary(**row) for row in results] + items = [TestRunSummary(**row) for row in results] + total = items[0].total_count if items else 0 + return items, total def get_monitoring_summary(self, table_name: str | None = None) -> TestRunMonitorSummary: freshness_anomalies = func.sum(case( diff --git a/testgen/common/notifications/test_run.py b/testgen/common/notifications/test_run.py index fef9749e..7ecdf25a 100644 --- a/testgen/common/notifications/test_run.py +++ b/testgen/common/notifications/test_run.py @@ -21,7 +21,7 @@ class TestRunEmailTemplate(BaseNotificationTemplate): def get_subject_template(self) -> str: return ( - "[TestGen] Test Run {{format_status test_run.status}}: {{test_run.test_suite}}" + "[TestGen] Test Run {{test_run.status_label}}: {{test_run.test_suite}}" "{{#with test_run}}" '{{#if failed_ct}} | {{format_number failed_ct}} {{pluralize failed_ct "failure" "failures"}}{{/if}}' '{{#if warning_ct}} | {{format_number warning_ct}} {{pluralize warning_ct "warning" "warnings"}}{{/if}}' @@ -32,9 +32,9 @@ def get_subject_template(self) -> str: def get_title_template(self): return """ TestGen Test Run - {{format_status test_run.status}} + {{#if (eq test_run.status 'error')}} text-red {{/if}} + {{#if (eq test_run.status 'canceled')}} text-purple {{/if}} + ">{{test_run.status_label}} """ def get_main_content_template(self): @@ -59,11 +59,11 @@ def get_main_content_template(self): Start Time - {{format_dt test_run.test_starttime}} + {{format_dt test_run.started_at}} Duration - {{format_duration test_run.test_starttime test_run.test_endtime}} + {{format_duration test_run.started_at test_run.completed_at}} @@ -78,7 +78,7 @@ def get_main_content_template(self): - {{#if (eq test_run.status 'Complete')}} + {{#if (eq test_run.status 'completed')}} {{#if (eq notification_trigger 'on_changes')}} Test run has new failures, warnings, or errors. {{/if}} @@ -89,15 +89,15 @@ def get_main_content_template(self): Test run has failures, warnings, or errors. {{/if}} {{/if}} - {{#if (eq test_run.status 'Error')}} + {{#if (eq test_run.status 'error')}} Test execution encountered an error. {{/if}} - {{#if (eq test_run.status 'Cancelled')}} + {{#if (eq test_run.status 'canceled')}} Test run was canceled. {{/if}} - {{#if (eq test_run.status 'Complete')}} + {{#if (eq test_run.status 'completed')}} @@ -134,12 +134,12 @@ def get_main_content_template(self): {{/if}} - {{#if (eq test_run.status 'Error')}} + {{#if (eq test_run.status 'error')}} {{/if}} - {{#if (eq test_run.status 'Complete')}} + {{#if (eq test_run.status 'completed')}}
{{test_run.log_message}}
View on TestGen > @@ -321,7 +321,7 @@ def send_test_run_notifications(test_run: TestRun, result_list_ct=20, result_sta result_list_by_status[status] = [{**r} for r in get_current_session().execute(query)] - tr_summary, = TestRun.select_summary(test_run_ids=[test_run.id]) + (tr_summary,), _ = TestRun.select_summary(test_run_ids=[test_run.id]) test_run_url = "".join( ( diff --git a/testgen/mcp/tools/test_runs.py b/testgen/mcp/tools/test_runs.py index 12f098ed..5509c1cb 100644 --- a/testgen/mcp/tools/test_runs.py +++ b/testgen/mcp/tools/test_runs.py @@ -31,7 +31,7 @@ def get_recent_test_runs(project_code: str, test_suite: str | None = None, limit return f"Test suite `{test_suite}` not found in project `{project_code}`." test_suite_id = str(suites[0].id) - summaries = TestRun.select_summary(project_code=project_code, test_suite_id=test_suite_id) + summaries, _ = TestRun.select_summary(project_code=project_code, test_suite_id=test_suite_id, page_size=1000) if not summaries: scope = f" for suite `{test_suite}`" if test_suite else "" @@ -64,10 +64,10 @@ def get_recent_test_runs(project_code: str, test_suite: str | None = None, limit warning = run.warning_ct or 0 errors = run.error_ct or 0 - doc.heading(3, f"{run.test_starttime} — {run.status_label}") + doc.heading(3, f"{run.created_at} — {run.status_label}") doc.field("Run ID", run.job_execution_id, code=True) - doc.field("Started", run.test_starttime) - doc.field("Ended", run.test_endtime) + doc.field("Started", run.created_at) + doc.field("Ended", run.completed_at or "In progress") doc.field("Results", f"{run.test_ct or 0} tests — {passed} passed, {failed} failed, {warning} warnings, {errors} errors") if run.dismissed_ct: diff --git a/testgen/ui/components/frontend/js/pages/profiling_runs.js b/testgen/ui/components/frontend/js/pages/profiling_runs.js index 5128c335..d8bb7d48 100644 --- a/testgen/ui/components/frontend/js/pages/profiling_runs.js +++ b/testgen/ui/components/frontend/js/pages/profiling_runs.js @@ -10,26 +10,29 @@ * * @typedef ProfilingRun * @type {object} - * @property {string} id - * @property {number} profiling_starttime - * @property {number} profiling_endtime - * @property {string} table_groups_name - * @property {'Running'|'Complete'|'Error'|'Cancelled'} status - * @property {ProgressStep[]} progress - * @property {string} log_message - * @property {string} process_id * @property {string} job_execution_id + * @property {string?} profiling_run_id + * @property {string} status + * @property {string} status_label + * @property {number} created_at + * @property {number?} started_at + * @property {number?} completed_at + * @property {string?} error_message + * @property {ProgressStep[]} progress + * @property {string} table_groups_name * @property {string} table_group_schema - * @property {number} column_ct - * @property {number} table_ct - * @property {number} record_ct - * @property {number} data_point_ct - * @property {number} anomaly_ct - * @property {number} anomalies_definite_ct - * @property {number} anomalies_likely_ct - * @property {number} anomalies_possible_ct - * @property {number} anomalies_dismissed_ct - * @property {string} dq_score_profiling + * @property {string?} log_message + * @property {string?} process_id + * @property {number?} column_ct + * @property {number?} table_ct + * @property {number?} record_ct + * @property {number?} data_point_ct + * @property {number?} anomaly_ct + * @property {number?} anomalies_definite_ct + * @property {number?} anomalies_likely_ct + * @property {number?} anomalies_possible_ct + * @property {number?} anomalies_dismissed_ct + * @property {string?} dq_score_profiling * * @typedef Permissions * @type {object} @@ -39,12 +42,14 @@ * @type {object} * @property {ProjectSummary} project_summary * @property {ProfilingRun[]} profiling_runs + * @property {number} total_count + * @property {number} page + * @property {number} page_size * @property {FilterOption[]} table_group_options * @property {Permissions} permissions * @property {object?} run_profiling_dialog * @property {object?} schedule_dialog * @property {object?} notifications_dialog - * @property {object?} delete_dialog */ import van from '/app/static/js/van.min.js'; import { withTooltip } from '/app/static/js/components/tooltip.js'; @@ -64,9 +69,16 @@ import { ScheduleList } from '/app/static/js/components/schedule_list.js'; import { NotificationSettings } from '/app/static/js/components/notification_settings.js'; const { b, div, i, span, strong } = van.tags; -const PAGE_SIZE = 100; const SCROLL_CONTAINER = window.top.document.querySelector('.stMain'); -const REFRESH_INTERVAL = 15000 // 15 seconds + +const STARTING_STATUSES = new Set(['pending', 'claimed']); +const RUNNING_STATUSES = new Set(['running', 'cancel_requested']); +const ACTIVE_STATUSES = new Set([...STARTING_STATUSES, ...RUNNING_STATUSES]); +const CANCELABLE_STATUSES = new Set(['pending', 'claimed', 'running']); + +const REFRESH_STARTING = 6000; +const REFRESH_RUNNING = 30000; +const REFRESH_DEFAULT = 60000; const progressStatusIcons = { Pending: { color: 'grey', icon: 'more_horiz', size: 22 }, @@ -82,43 +94,39 @@ const ProfilingRuns = (/** @type Properties */ props) => { const columns = ['5%', '20%', '15%', '20%', '30%', '10%']; const userCanEdit = getValue(props.permissions)?.can_edit ?? false; - const pageIndex = van.state(0); - const profilingRuns = van.derive(() => { - pageIndex.val = 0; - return getValue(props.profiling_runs); - }); + const profilingRuns = van.derive(() => getValue(props.profiling_runs)); let refreshIntervalId = null; - const paginatedRuns = van.derive(() => { - const paginated = profilingRuns.val.slice(PAGE_SIZE * pageIndex.val, PAGE_SIZE * (pageIndex.val + 1)); - const hasActiveRuns = paginated.some(({ status }) => status === 'Running'); - if (!refreshIntervalId && hasActiveRuns) { - refreshIntervalId = setInterval(() => emit('RefreshData', {}), REFRESH_INTERVAL); - } else if (refreshIntervalId && !hasActiveRuns) { - clearInterval(refreshIntervalId); + let currentRefreshRate = null; + van.derive(() => { + const items = profilingRuns.val; + const hasStarting = items.some(({ status }) => STARTING_STATUSES.has(status)); + const hasRunning = items.some(({ status }) => RUNNING_STATUSES.has(status)); + const rate = hasStarting ? REFRESH_STARTING : hasRunning ? REFRESH_RUNNING : REFRESH_DEFAULT; + if (rate !== currentRefreshRate) { + if (refreshIntervalId) clearInterval(refreshIntervalId); + refreshIntervalId = setInterval(() => emit('RefreshData', {}), rate); + currentRefreshRate = rate; } - return paginated; }); const selectedRuns = {}; const initializeSelectedStates = (items) => { for (const profilingRun of items) { - if (selectedRuns[profilingRun.id] == undefined) { - selectedRuns[profilingRun.id] = van.state(false); + if (selectedRuns[profilingRun.job_execution_id] == undefined) { + selectedRuns[profilingRun.job_execution_id] = van.state(false); } } }; initializeSelectedStates(profilingRuns.val); van.derive(() => initializeSelectedStates(profilingRuns.val)); - const deleteDialogOpen = van.state(false); + const runsToDelete = van.state([]); const deleteConstraintChecked = van.state(false); - van.derive(() => { if (getValue(props.delete_dialog)?.open) deleteDialogOpen.val = true; }); const closeDeleteDialog = () => { - deleteDialogOpen.val = false; + runsToDelete.val = []; deleteConstraintChecked.val = false; - emit('DeleteDialogClosed', {}); }; const scheduleDialogOpen = van.state(false); @@ -143,7 +151,7 @@ const ProfilingRuns = (/** @type Properties */ props) => { div( { class: 'table pb-0', style: 'overflow-y: auto;' }, () => { - const selectedItems = profilingRuns.val.filter(i => selectedRuns[i.id]?.val ?? false); + const selectedItems = profilingRuns.val.filter(i => selectedRuns[i.job_execution_id]?.val ?? false); const someRunSelected = selectedItems.length > 0; const tooltipText = !someRunSelected ? 'No runs selected' : undefined; @@ -164,7 +172,7 @@ const ProfilingRuns = (/** @type Properties */ props) => { disabled: !someRunSelected, width: 'auto', onclick: () => { - emit('DeleteRunsClicked', { payload: selectedItems.map(r => r.id) }); + runsToDelete.val = [...selectedItems]; }, }), ); @@ -173,7 +181,7 @@ const ProfilingRuns = (/** @type Properties */ props) => { { class: 'table-header flex-row' }, () => { const items = profilingRuns.val; - const selectedItems = items.filter(i => selectedRuns[i.id]?.val ?? false); + const selectedItems = items.filter(i => selectedRuns[i.job_execution_id]?.val ?? false); const allSelected = selectedItems.length === items.length; const partiallySelected = selectedItems.length > 0 && selectedItems.length < items.length; @@ -187,7 +195,7 @@ const ProfilingRuns = (/** @type Properties */ props) => { ? Checkbox({ checked: allSelected, indeterminate: partiallySelected, - onChange: (checked) => items.forEach(item => selectedRuns[item.id].val = checked), + onChange: (checked) => items.forEach(item => selectedRuns[item.job_execution_id].val = checked), testId: 'select-all-profiling-run', }) : '', @@ -215,20 +223,25 @@ const ProfilingRuns = (/** @type Properties */ props) => { ), ), div( - paginatedRuns.val.map(item => ProfilingRunItem(item, columns, selectedRuns[item.id], userCanEdit, projectSummary.project_code, emit)), + profilingRuns.val.map(item => ProfilingRunItem(item, columns, selectedRuns[item.job_execution_id], userCanEdit, projectSummary.project_code, emit)), ), ), - Paginator({ emit, - pageIndex, - count: profilingRuns.val.length, - pageSize: PAGE_SIZE, - onChange: (newIndex) => { - if (newIndex !== pageIndex.val) { - pageIndex.val = newIndex; - SCROLL_CONTAINER.scrollTop = 0; - } - }, - }), + () => { + const totalCount = getValue(props.total_count) ?? 0; + const pageSize = getValue(props.page_size) ?? 100; + const currentPage = (getValue(props.page) ?? 1) - 1; + return Paginator({ + pageIndex: van.state(currentPage), + count: totalCount, + pageSize, + onChange: (newIndex) => { + if (newIndex !== currentPage) { + emit('PageChanged', { payload: newIndex + 1 }); + SCROLL_CONTAINER.scrollTop = 0; + } + }, + }); + }, ) : div( { class: 'pt-7 text-secondary', style: 'text-align: center;' }, @@ -238,23 +251,21 @@ const ProfilingRuns = (/** @type Properties */ props) => { : ConditionalEmptyState(projectSummary, userCanEdit, emit); }, Dialog( - { title: 'Delete Profiling Runs', open: deleteDialogOpen, onClose: closeDeleteDialog }, + { title: 'Delete Profiling Runs', open: van.derive(() => runsToDelete.val.length > 0), onClose: closeDeleteDialog }, div( { class: 'flex-column fx-gap-4' }, () => { - const info = getValue(props.delete_dialog); - if (!info) return div(); - const runCount = info.run_ids?.length ?? 0; - const hasActiveJob = info.has_active_job ?? false; + const runs = runsToDelete.val; + const hasRunning = runs.some(r => ACTIVE_STATUSES.has(r.status)); return div( { class: 'flex-column fx-gap-3' }, - div('Are you sure you want to delete ', b(runCount), ` profiling run${runCount !== 1 ? 's' : ''}?`), - hasActiveJob + div('Are you sure you want to delete ', b(runs.length), ` profiling run${runs.length !== 1 ? 's' : ''}?`), + hasRunning ? div( { class: 'flex-column fx-gap-2' }, div({ style: 'color: var(--orange);' }, 'Any running processes will be canceled.'), Checkbox({ - label: runCount === 1 + label: runs.length === 1 ? 'Yes, cancel and delete the profiling run' : 'Yes, cancel and delete the profiling runs', checked: deleteConstraintChecked, @@ -267,9 +278,7 @@ const ProfilingRuns = (/** @type Properties */ props) => { div( { class: 'flex-row fx-justify-flex-end' }, () => { - const info = getValue(props.delete_dialog); - const hasActiveJob = info?.has_active_job ?? false; - const isDisabled = hasActiveJob && !deleteConstraintChecked.val; + const isDisabled = runsToDelete.val.some(r => ACTIVE_STATUSES.has(r.status)) && !deleteConstraintChecked.val; return Button({ label: 'Delete', color: isDisabled ? 'basic' : 'warn', @@ -278,7 +287,7 @@ const ProfilingRuns = (/** @type Properties */ props) => { style: 'margin-left: auto;', disabled: isDisabled, onclick: () => { - emit('RunsDeleted', { payload: info?.run_ids ?? [] }); + emit('RunsDeleted', { payload: runsToDelete.val.map(r => r.job_execution_id) }); closeDeleteDialog(); }, }); @@ -403,7 +412,8 @@ const ProfilingRunItem = ( /** @type string */ projectCode, emit, ) => { - const runningStep = item.progress?.find((item) => item.status === 'Running'); + const runningStep = item.progress?.find((step) => step.status === 'Running'); + const displayTime = item.created_at; return div( { class: 'table-row flex-row', 'data-testid': 'profiling-run-item' }, @@ -419,10 +429,10 @@ const ProfilingRunItem = ( : '', div( { style: `flex: 0 0 ${columns[1]}; max-width: ${columns[1]}; word-wrap: break-word;` }, - div({ 'data-testid': 'profiling-run-item-starttime' }, formatTimestamp(item.profiling_starttime)), + div({ 'data-testid': 'profiling-run-item-starttime' }, formatTimestamp(displayTime)), div( { class: 'text-caption mt-1', 'data-testid': 'profiling-run-item-tablegroup' }, - item.table_groups_name, + item.table_groups_name || '--', ), ), div( @@ -430,23 +440,23 @@ const ProfilingRunItem = ( div( { class: 'flex-row' }, ProfilingRunStatus(item), - item.status === 'Running' && item.job_execution_id && userCanEdit ? Button({ + CANCELABLE_STATUSES.has(item.status) && userCanEdit ? Button({ type: 'stroked', label: 'Cancel', style: 'width: 64px; height: 28px; color: var(--purple); margin-left: 12px;', onclick: () => { - emit('RunCanceled', { payload: item }); + emit('RunCanceled', { payload: { job_execution_id: item.job_execution_id, profiling_run_id: item.profiling_run_id } }); }, }) : null, ), - item.profiling_endtime + item.completed_at && item.started_at ? div( { class: 'text-caption mt-1', 'data-testid': 'profiling-run-item-duration' }, - formatDuration(item.profiling_starttime, item.profiling_endtime), + formatDuration(item.started_at, item.completed_at), ) : div( { class: 'text-caption mt-1' }, - item.status === 'Running' && runningStep + item.status === 'running' && runningStep ? [ div( runningStep.label, @@ -462,11 +472,11 @@ const ProfilingRunItem = ( ), div( { style: `flex: 0 0 ${columns[3]}; max-width: ${columns[3]};` }, - div({ 'data-testid': 'profiling-run-item-schema' }, item.table_group_schema), + div({ 'data-testid': 'profiling-run-item-schema' }, item.table_group_schema || '--'), div( { class: 'text-caption mt-1 mb-1', - style: item.status === 'Complete' && !item.column_ct ? 'color: var(--red);' : '', + style: item.status === 'completed' && !item.column_ct ? 'color: var(--red);' : '', 'data-testid': 'profiling-run-item-counts', }, item.column_ct !== null @@ -484,10 +494,10 @@ const ProfilingRunItem = ( ) : null, ), - item.status === 'Complete' && item.column_ct ? Link({ emit, + item.status === 'completed' && item.column_ct && item.profiling_run_id ? Link({ emit, label: 'View results', href: 'profiling-runs:results', - params: { 'run_id': item.id, 'project_code': projectCode }, + params: { 'run_id': item.profiling_run_id, 'project_code': projectCode }, underline: true, right_icon: 'chevron_right', }) : null, @@ -502,10 +512,10 @@ const ProfilingRunItem = ( { label: 'Dismissed', value: item.anomalies_dismissed_ct, color: 'grey' }, ], }) : '--', - item.anomaly_ct ? Link({ emit, + item.anomaly_ct && item.profiling_run_id ? Link({ emit, label: `View ${item.anomaly_ct} issues`, href: 'profiling-runs:hygiene', - params: { 'run_id': item.id, 'project_code': projectCode }, + params: { 'run_id': item.profiling_run_id, 'project_code': projectCode }, underline: true, right_icon: 'chevron_right', style: 'margin-top: 4px;', @@ -522,31 +532,35 @@ const ProfilingRunItem = ( } const ProfilingRunStatus = (/** @type ProfilingRun */ item) => { - const attributeMap = { - Running: { label: 'Running', color: 'blue' }, - Complete: { label: 'Completed', color: '' }, - Error: { label: 'Error', color: 'red' }, - Cancelled: { label: 'Canceled', color: 'purple' }, + const statusColorMap = { + pending: 'grey', + claimed: 'grey', + running: 'blue', + completed: '', + error: 'red', + canceled: 'purple', + cancel_requested: 'grey', }; - const attributes = attributeMap[item.status] || { label: 'Unknown', color: 'grey' }; + const color = statusColorMap[item.status] ?? 'grey'; const hasProgressError = item.progress?.some(({error}) => !!error); + const errorMessage = item.error_message || item.log_message; return span( { class: 'flex-row', - style: `color: var(--${attributes.color});`, + style: `color: var(--${color});`, 'data-testid': 'profiling-run-item-status' }, - attributes.label, - item.status === 'Complete' && hasProgressError + item.status_label, + item.status === 'completed' && hasProgressError ? withTooltip( Icon({ style: 'font-size: 18px; margin-left: 4px; vertical-align: middle; color: var(--orange);' }, 'warning' ), { text: ProgressTooltip(item) }, ) : null, - item.status === 'Error' && item.log_message + item.status === 'error' && errorMessage ? withTooltip( Icon({ style: 'font-size: 18px; margin-left: 4px;' }, 'info'), - { text: item.log_message, width: 250, style: 'word-break: break-word;' }, + { text: errorMessage, width: 250, style: 'word-break: break-word;' }, ) : null, ); @@ -614,7 +628,7 @@ const ConditionalEmptyState = ( }; } - return EmptyState({ emit, + return EmptyState({ emit, icon: 'data_thresholding', label: 'No profiling runs yet', ...args, diff --git a/testgen/ui/components/frontend/js/pages/test_runs.js b/testgen/ui/components/frontend/js/pages/test_runs.js index 22c38bbb..410c341a 100644 --- a/testgen/ui/components/frontend/js/pages/test_runs.js +++ b/testgen/ui/components/frontend/js/pages/test_runs.js @@ -10,24 +10,27 @@ * * @typedef TestRun * @type {object} - * @property {string} test_run_id - * @property {number} test_starttime - * @property {number} test_endtime + * @property {string} job_execution_id + * @property {string?} test_run_id + * @property {string} status + * @property {string} status_label + * @property {number} created_at + * @property {number?} started_at + * @property {number?} completed_at + * @property {string?} error_message + * @property {ProgressStep[]} progress * @property {string} table_groups_name * @property {string} test_suite - * @property {'Running'|'Complete'|'Error'|'Cancelled'} status - * @property {ProgressStep[]} progress - * @property {string} log_message - * @property {string} process_id - * @property {string} job_execution_id - * @property {number} test_ct - * @property {number} passed_ct - * @property {number} warning_ct - * @property {number} failed_ct - * @property {number} error_ct - * @property {number} log_ct - * @property {number} dismissed_ct - * @property {string} dq_score_testing + * @property {string?} log_message + * @property {string?} process_id + * @property {number?} test_ct + * @property {number?} passed_ct + * @property {number?} warning_ct + * @property {number?} failed_ct + * @property {number?} error_ct + * @property {number?} log_ct + * @property {number?} dismissed_ct + * @property {string?} dq_score_testing * * @typedef Permissions * @type {object} @@ -37,13 +40,15 @@ * @type {object} * @property {ProjectSummary} project_summary * @property {TestRun[]} test_runs + * @property {number} total_count + * @property {number} page + * @property {number} page_size * @property {FilterOption[]} table_group_options * @property {FilterOption[]} test_suite_options * @property {Permissions} permissions * @property {object?} run_tests_dialog * @property {object?} schedule_dialog * @property {object?} notifications_dialog - * @property {object?} delete_dialog */ import van from '/app/static/js/van.min.js'; import { withTooltip } from '/app/static/js/components/tooltip.js'; @@ -63,9 +68,16 @@ import { ScheduleList } from '/app/static/js/components/schedule_list.js'; import { NotificationSettings } from '/app/static/js/components/notification_settings.js'; const { b, div, i, span, strong } = van.tags; -const PAGE_SIZE = 100; const SCROLL_CONTAINER = window.top.document.querySelector('.stMain'); -const REFRESH_INTERVAL = 15000 // 15 seconds + +const STARTING_STATUSES = new Set(['pending', 'claimed']); +const RUNNING_STATUSES = new Set(['running', 'cancel_requested']); +const ACTIVE_STATUSES = new Set([...STARTING_STATUSES, ...RUNNING_STATUSES]); +const CANCELABLE_STATUSES = new Set(['pending', 'claimed', 'running']); + +const REFRESH_STARTING = 6000; +const REFRESH_RUNNING = 30000; +const REFRESH_DEFAULT = 60000; const progressStatusIcons = { Pending: { color: 'grey', icon: 'more_horiz', size: 22 }, @@ -81,45 +93,41 @@ const TestRuns = (/** @type Properties */ props) => { const columns = ['5%', '28%', '17%', '40%', '10%']; const userCanEdit = getValue(props.permissions)?.can_edit ?? false; - const pageIndex = van.state(0); - const testRuns = van.derive(() => { - pageIndex.val = 0; - return getValue(props.test_runs); - }); + const testRuns = van.derive(() => getValue(props.test_runs)); let refreshIntervalId = null; let runTestsNode = null; const runTestsResult = van.state(null); - const paginatedRuns = van.derive(() => { - const paginated = testRuns.val.slice(PAGE_SIZE * pageIndex.val, PAGE_SIZE * (pageIndex.val + 1)); - const hasActiveRuns = paginated.some(({ status }) => status === 'Running'); - if (!refreshIntervalId && hasActiveRuns) { - refreshIntervalId = setInterval(() => emit('RefreshData', {}), REFRESH_INTERVAL); - } else if (refreshIntervalId && !hasActiveRuns) { - clearInterval(refreshIntervalId); + let currentRefreshRate = null; + van.derive(() => { + const items = testRuns.val; + const hasStarting = items.some(({ status }) => STARTING_STATUSES.has(status)); + const hasRunning = items.some(({ status }) => RUNNING_STATUSES.has(status)); + const rate = hasStarting ? REFRESH_STARTING : hasRunning ? REFRESH_RUNNING : REFRESH_DEFAULT; + if (rate !== currentRefreshRate) { + if (refreshIntervalId) clearInterval(refreshIntervalId); + refreshIntervalId = setInterval(() => emit('RefreshData', {}), rate); + currentRefreshRate = rate; } - return paginated; }); const selectedRuns = {}; const initializeSelectedStates = (items) => { for (const testRun of items) { - if (selectedRuns[testRun.test_run_id] == undefined) { - selectedRuns[testRun.test_run_id] = van.state(false); + if (selectedRuns[testRun.job_execution_id] == undefined) { + selectedRuns[testRun.job_execution_id] = van.state(false); } } }; initializeSelectedStates(testRuns.val); van.derive(() => initializeSelectedStates(testRuns.val)); - const deleteDialogOpen = van.state(false); + const runsToDelete = van.state([]); const deleteConstraintChecked = van.state(false); - van.derive(() => { if (getValue(props.delete_dialog)?.open) deleteDialogOpen.val = true; }); const closeDeleteDialog = () => { - deleteDialogOpen.val = false; + runsToDelete.val = []; deleteConstraintChecked.val = false; - emit('DeleteDialogClosed', {}); }; const scheduleDialogOpen = van.state(false); @@ -142,7 +150,7 @@ const TestRuns = (/** @type Properties */ props) => { div( { class: 'table pb-0' }, () => { - const selectedItems = testRuns.val.filter(i => selectedRuns[i.test_run_id]?.val ?? false); + const selectedItems = testRuns.val.filter(i => selectedRuns[i.job_execution_id]?.val ?? false); const someRunSelected = selectedItems.length > 0; const tooltipText = !someRunSelected ? 'No runs selected' : undefined; @@ -163,7 +171,7 @@ const TestRuns = (/** @type Properties */ props) => { disabled: !someRunSelected, width: 'auto', onclick: () => { - emit('DeleteRunsClicked', { payload: selectedItems.map(r => r.test_run_id) }); + runsToDelete.val = [...selectedItems]; }, }), ); @@ -173,7 +181,7 @@ const TestRuns = (/** @type Properties */ props) => { { class: 'table-header flex-row' }, () => { const items = testRuns.val; - const selectedItems = items.filter(i => selectedRuns[i.test_run_id]?.val ?? false); + const selectedItems = items.filter(i => selectedRuns[i.job_execution_id]?.val ?? false); const allSelected = selectedItems.length === items.length; const partiallySelected = selectedItems.length > 0 && selectedItems.length < items.length; @@ -187,7 +195,7 @@ const TestRuns = (/** @type Properties */ props) => { ? Checkbox({ checked: allSelected, indeterminate: partiallySelected, - onChange: (checked) => items.forEach(item => selectedRuns[item.test_run_id].val = checked), + onChange: (checked) => items.forEach(item => selectedRuns[item.job_execution_id].val = checked), testId: 'select-all-test-run', }) : '', @@ -211,20 +219,25 @@ const TestRuns = (/** @type Properties */ props) => { ), ), div( - paginatedRuns.val.map(item => TestRunItem(item, columns, selectedRuns[item.test_run_id], userCanEdit, projectSummary.project_code, emit)), + testRuns.val.map(item => TestRunItem(item, columns, selectedRuns[item.job_execution_id], userCanEdit, projectSummary.project_code, emit)), ), ), - Paginator({ emit, - pageIndex, - count: testRuns.val.length, - pageSize: PAGE_SIZE, - onChange: (newIndex) => { - if (newIndex !== pageIndex.val) { - pageIndex.val = newIndex; - SCROLL_CONTAINER.scrollTop = 0; - } - }, - }), + () => { + const totalCount = getValue(props.total_count) ?? 0; + const pageSize = getValue(props.page_size) ?? 100; + const currentPage = (getValue(props.page) ?? 1) - 1; + return Paginator({ + pageIndex: van.state(currentPage), + count: totalCount, + pageSize, + onChange: (newIndex) => { + if (newIndex !== currentPage) { + emit('PageChanged', { payload: newIndex + 1 }); + SCROLL_CONTAINER.scrollTop = 0; + } + }, + }); + }, ) : div( { class: 'pt-7 text-secondary', style: 'text-align: center;' }, @@ -234,23 +247,21 @@ const TestRuns = (/** @type Properties */ props) => { : ConditionalEmptyState(projectSummary, userCanEdit, emit); }, Dialog( - { title: 'Delete Test Runs', open: deleteDialogOpen, onClose: closeDeleteDialog }, + { title: 'Delete Test Runs', open: van.derive(() => runsToDelete.val.length > 0), onClose: closeDeleteDialog }, div( { class: 'flex-column fx-gap-4' }, () => { - const info = getValue(props.delete_dialog); - if (!info) return div(); - const runCount = info.run_ids?.length ?? 0; - const hasActiveJob = info.has_active_job ?? false; + const runs = runsToDelete.val; + const hasRunning = runs.some(r => ACTIVE_STATUSES.has(r.status)); return div( { class: 'flex-column fx-gap-3' }, - div('Are you sure you want to delete ', b(runCount), ` test run${runCount !== 1 ? 's' : ''}?`), - hasActiveJob + div('Are you sure you want to delete ', b(runs.length), ` test run${runs.length !== 1 ? 's' : ''}?`), + hasRunning ? div( { class: 'flex-column fx-gap-2' }, div({ style: 'color: var(--orange);' }, 'Any running processes will be canceled.'), Checkbox({ - label: runCount === 1 + label: runs.length === 1 ? 'Yes, cancel and delete the test run' : 'Yes, cancel and delete the test runs', checked: deleteConstraintChecked, @@ -263,9 +274,7 @@ const TestRuns = (/** @type Properties */ props) => { div( { class: 'flex-row fx-justify-flex-end' }, () => { - const info = getValue(props.delete_dialog); - const hasActiveJob = info?.has_active_job ?? false; - const isDisabled = hasActiveJob && !deleteConstraintChecked.val; + const isDisabled = runsToDelete.val.some(r => ACTIVE_STATUSES.has(r.status)) && !deleteConstraintChecked.val; return Button({ label: 'Delete', color: isDisabled ? 'basic' : 'warn', @@ -274,7 +283,7 @@ const TestRuns = (/** @type Properties */ props) => { style: 'margin-left: auto;', disabled: isDisabled, onclick: () => { - emit('RunsDeleted', { payload: info?.run_ids ?? [] }); + emit('RunsDeleted', { payload: runsToDelete.val.map(r => r.job_execution_id) }); closeDeleteDialog(); }, }); @@ -409,7 +418,9 @@ const TestRunItem = ( /** @type string */ projectCode, emit, ) => { - const runningStep = item.progress?.find((item) => item.status === 'Running'); + const hasResults = !!item.test_ct; + const runningStep = item.progress?.find((step) => step.status === 'Running'); + const displayTime = item.created_at; return div( { class: 'table-row flex-row' }, @@ -425,15 +436,19 @@ const TestRunItem = ( : '', div( { style: `flex: ${columns[1]}` }, - Link({ emit, - label: formatTimestamp(item.test_starttime), - href: 'test-runs:results', - params: { 'run_id': item.test_run_id, 'project_code': projectCode }, - underline: true, - }), + hasResults + ? Link({ emit, + label: formatTimestamp(displayTime), + href: 'test-runs:results', + params: { 'run_id': item.test_run_id, 'project_code': projectCode }, + underline: true, + }) + : span(formatTimestamp(displayTime)), div( { class: 'text-caption mt-1' }, - `${item.table_groups_name} > ${item.test_suite}`, + item.table_groups_name && item.test_suite + ? `${item.table_groups_name} > ${item.test_suite}` + : item.test_suite || '--', ), ), div( @@ -441,23 +456,23 @@ const TestRunItem = ( div( { class: 'flex-row' }, TestRunStatus(item), - item.status === 'Running' && item.job_execution_id && userCanEdit ? Button({ + CANCELABLE_STATUSES.has(item.status) && userCanEdit ? Button({ type: 'stroked', label: 'Cancel', style: 'width: 64px; height: 28px; color: var(--purple); margin-left: 12px;', onclick: () => { - emit('RunCanceled', { payload: item }); + emit('RunCanceled', { payload: { job_execution_id: item.job_execution_id, test_run_id: item.test_run_id } }); }, }) : null, ), - item.test_endtime + item.completed_at && item.started_at ? div( { class: 'text-caption mt-1' }, - formatDuration(item.test_starttime, item.test_endtime), + formatDuration(item.started_at, item.completed_at), ) : div( { class: 'text-caption mt-1' }, - item.status === 'Running' && runningStep + item.status === 'running' && runningStep ? [ div( runningStep.label, @@ -496,30 +511,34 @@ const TestRunItem = ( }; const TestRunStatus = (/** @type TestRun */ item) => { - const attributeMap = { - Running: { label: 'Running', color: 'blue' }, - Complete: { label: 'Completed', color: '' }, - Error: { label: 'Error', color: 'red' }, - Cancelled: { label: 'Canceled', color: 'purple' }, + const statusColorMap = { + pending: 'grey', + claimed: 'grey', + running: 'blue', + completed: '', + error: 'red', + canceled: 'purple', + cancel_requested: 'grey', }; - const attributes = attributeMap[item.status] || { label: 'Unknown', color: 'grey' }; + const color = statusColorMap[item.status] ?? 'grey'; const hasProgressError = item.progress?.some(({error}) => !!error); + const errorMessage = item.error_message || item.log_message; return span( { class: 'flex-row', - style: `color: var(--${attributes.color});`, + style: `color: var(--${color});`, }, - attributes.label, - item.status === 'Complete' && hasProgressError + item.status_label, + item.status === 'completed' && hasProgressError ? withTooltip( Icon({ style: 'font-size: 18px; margin-left: 4px; vertical-align: middle; color: var(--orange);' }, 'warning' ), { text: ProgressTooltip(item) }, ) : null, - item.status === 'Error' && item.log_message + item.status === 'error' && errorMessage ? withTooltip( Icon({ style: 'font-size: 18px; margin-left: 4px;' }, 'info'), - { text: item.log_message, width: 250, style: 'word-break: break-word;' }, + { text: errorMessage, width: 250, style: 'word-break: break-word;' }, ) : null, ); @@ -596,7 +615,7 @@ const ConditionalEmptyState = ( }; } - return EmptyState({ emit, + return EmptyState({ emit, icon: 'labs', label: 'No test runs yet', ...args, diff --git a/testgen/ui/services/query_cache.py b/testgen/ui/services/query_cache.py index 8768ba4f..e7878e17 100644 --- a/testgen/ui/services/query_cache.py +++ b/testgen/ui/services/query_cache.py @@ -85,13 +85,15 @@ def get_test_run_summaries( project_code: str | None = None, table_group_id: str | UUID | None = None, test_suite_id: str | int | None = None, - test_run_ids: list[str | UUID] | None = None, -) -> Iterable[TestRunSummary]: + page: int = 1, + page_size: int = 20, +) -> tuple[list[TestRunSummary], int]: return TestRun.select_summary( project_code=project_code, table_group_id=table_group_id, test_suite_id=test_suite_id, - test_run_ids=test_run_ids, + page=page, + page_size=page_size, ) @@ -119,6 +121,7 @@ def get_table_group_summaries( def get_profiling_run_summaries( project_code: str, table_group_id: str | UUID | None = None, - profiling_run_ids: list[str | UUID] | None = None, -) -> Iterable[ProfilingRunSummary]: - return ProfilingRun.select_summary(project_code, table_group_id, profiling_run_ids) + page: int = 1, + page_size: int = 20, +) -> tuple[list[ProfilingRunSummary], int]: + return ProfilingRun.select_summary(project_code, table_group_id, page=page, page_size=page_size) diff --git a/testgen/ui/views/profiling_runs.py b/testgen/ui/views/profiling_runs.py index c6429f77..9b54a288 100644 --- a/testgen/ui/views/profiling_runs.py +++ b/testgen/ui/views/profiling_runs.py @@ -1,23 +1,20 @@ import logging import typing from collections.abc import Iterable -from functools import partial import streamlit as st RUN_PROFILING_DIALOG_KEY = "pr:run_profiling_dialog" RUN_SCHEDULES_DIALOG_KEY = "pr:run_schedules_dialog" RUN_NOTIFICATIONS_DIALOG_KEY = "pr:run_notifications_dialog" -DELETE_DIALOG_KEY = "pr:delete_dialog" RUN_PROFILING_RESULT_KEY = "pr:run_profiling_result" RUN_PROFILING_DIALOG_OPEN_COUNT_KEY = "pr:run_profiling_dialog_open_count" RUN_SCHEDULES_DIALOG_OPEN_COUNT_KEY = "pr:run_schedules_dialog_open_count" RUN_NOTIFICATIONS_DIALOG_OPEN_COUNT_KEY = "pr:run_notifications_dialog_open_count" -DELETE_DIALOG_OPEN_COUNT_KEY = "pr:delete_dialog_open_count" import testgen.ui.services.form_service as fm -from testgen.common.models import database_session, with_database_session -from testgen.common.models.job_execution import JobExecution +from testgen.common.models import database_session, get_current_session, with_database_session +from testgen.common.models.job_execution import JobExecution, JobStatus from testgen.common.models.notification_settings import ( ProfilingRunNotificationSettings, ProfilingRunNotificationTrigger, @@ -59,9 +56,11 @@ def render(self, project_code: str, table_group_id: str | None = None, **_kwargs "data-profiling", ) + page = int(st.query_params.get("page", 1)) + with st.spinner("Loading data ..."): project_summary = get_project_summary(project_code) - profiling_runs = get_profiling_run_summaries(project_code, table_group_id) + profiling_runs, total_count = get_profiling_run_summaries(project_code, table_group_id, page=page) table_groups = TableGroup.select_minimal_where(TableGroup.project_code == project_code) schedule_obj = ProfilingScheduleDialog(project_code) @@ -81,10 +80,6 @@ def on_run_notifications_clicked(*_) -> None: st.session_state[RUN_NOTIFICATIONS_DIALOG_KEY] = True st.session_state[RUN_NOTIFICATIONS_DIALOG_OPEN_COUNT_KEY] = st.session_state.get(RUN_NOTIFICATIONS_DIALOG_OPEN_COUNT_KEY, 0) + 1 - def on_delete_runs_clicked(profiling_run_ids: list[str]) -> None: - st.session_state[DELETE_DIALOG_KEY] = profiling_run_ids - st.session_state[DELETE_DIALOG_OPEN_COUNT_KEY] = st.session_state.get(DELETE_DIALOG_OPEN_COUNT_KEY, 0) + 1 - # Build run profiling dialog data run_profiling_data = None if st.session_state.get(RUN_PROFILING_DIALOG_KEY): @@ -110,15 +105,6 @@ def on_delete_runs_clicked(profiling_run_ids: list[str]) -> None: notifications_data = ns_obj.build_data() notifications_data["open"] = st.session_state[RUN_NOTIFICATIONS_DIALOG_OPEN_COUNT_KEY] - # Build delete dialog data - delete_dialog_data = None - if run_ids := st.session_state.get(DELETE_DIALOG_KEY): - delete_dialog_data = { - "open": st.session_state[DELETE_DIALOG_OPEN_COUNT_KEY], - "run_ids": run_ids, - "has_active_job": ProfilingRun.has_active_job_for(ProfilingRun, *run_ids), - } - def on_run_profiling_confirmed(table_group: dict) -> None: success = True message = f"Profiling run started for table group '{table_group['table_groups_name']}'." @@ -138,6 +124,7 @@ def on_run_profiling_confirmed(table_group: dict) -> None: st.session_state[RUN_PROFILING_RESULT_KEY] = {"success": success, "message": message, "show_link": show_link} if success and not show_link: get_profiling_run_summaries.clear() + Router().set_query_params({"page": 1}) st.session_state.pop(RUN_PROFILING_DIALOG_KEY, None) st.session_state.pop(RUN_PROFILING_RESULT_KEY, None) @@ -158,8 +145,8 @@ def on_notifications_dialog_closed(*_) -> None: ns_obj.clear_state() st.session_state.pop(RUN_NOTIFICATIONS_DIALOG_KEY, None) - def on_delete_dialog_closed(*_) -> None: - st.session_state.pop(DELETE_DIALOG_KEY, None) + def on_page_changed(new_page: int) -> None: + Router().set_query_params({"page": new_page}) testgen.profiling_runs_widget( key="profiling_runs", @@ -168,9 +155,13 @@ def on_delete_dialog_closed(*_) -> None: "profiling_runs": [ { **run.to_dict(json_safe=True), + "status_label": run.status_label, "dq_score_profiling": friendly_score(run.dq_score_profiling), } for run in profiling_runs ], + "total_count": total_count, + "page": page, + "page_size": 20, "table_group_options": [ { "value": str(table_group.id), @@ -184,16 +175,14 @@ def on_delete_dialog_closed(*_) -> None: "run_profiling_dialog": run_profiling_data, "schedule_dialog": schedule_data, "notifications_dialog": notifications_data, - "delete_dialog": delete_dialog_data, }, + on_PageChanged_change=on_page_changed, on_FilterApplied_change=on_profiling_runs_filtered, on_RunNotificationsClicked_change=on_run_notifications_clicked, on_RunSchedulesClicked_change=on_run_schedules_clicked, on_RunProfilingClicked_change=on_run_profiling_clicked, on_RefreshData_change=refresh_data, - on_DeleteRunsClicked_change=on_delete_runs_clicked, - on_RunsDeleted_change=partial(on_delete_runs, project_code, table_group_id), - on_DeleteDialogClosed_change=on_delete_dialog_closed, + on_RunsDeleted_change=on_delete_runs, on_RunCanceled_change=on_cancel_run, # RunProfilingDialog events on_RunProfilingConfirmed_change=on_run_profiling_confirmed, @@ -220,7 +209,7 @@ class ProfilingRunFilters(typing.TypedDict): table_group_id: str def on_profiling_runs_filtered(filters: ProfilingRunFilters) -> None: - Router().set_query_params(filters) + Router().set_query_params({**filters, "page": 1}) def refresh_data(*_) -> None: @@ -285,28 +274,37 @@ def _get_component_props(self) -> dict[str, typing.Any]: @with_database_session -def on_cancel_run(profiling_run: dict) -> None: - if (job_execution_id := profiling_run.get("job_execution_id")) and (job_exec := JobExecution.get(job_execution_id)) and job_exec.request_cancel(): +def on_cancel_run(payload: dict) -> None: + job_execution_id = payload.get("job_execution_id") + if not job_execution_id: + fm.reset_post_updates(str_message=":red[This run cannot be canceled.]", as_toast=True) + return + + job_exec = JobExecution.get(job_execution_id) + if job_exec and job_exec.request_cancel(): # Stopgap: also update the run status so the UI reflects cancellation immediately. - # The proper flow is subprocess catches SIGTERM and updates its own status on exit. - ProfilingRun.cancel_run(profiling_run["id"]) + if profiling_run_id := payload.get("profiling_run_id"): + ProfilingRun.cancel_run(profiling_run_id) fm.reset_post_updates(str_message=":green[Cancellation requested.]", as_toast=True) else: fm.reset_post_updates(str_message=":red[This run cannot be canceled.]", as_toast=True) @with_database_session -def on_delete_runs(project_code: str, table_group_id: str, profiling_run_ids: list[str]) -> None: +def on_delete_runs(job_execution_ids: list[str]) -> None: try: - profiling_runs = get_profiling_run_summaries(project_code, table_group_id, profiling_run_ids) - for profiling_run in profiling_runs: - if profiling_run.status == "Running" and profiling_run.job_execution_id: - job_exec = JobExecution.get(profiling_run.job_execution_id) - if job_exec: - job_exec.request_cancel() - ProfilingRun.cascade_delete(profiling_run_ids) + for je_id in job_execution_ids: + job_exec = JobExecution.get(je_id) + if not job_exec: + continue + if job_exec.status in (JobStatus.PENDING, JobStatus.CLAIMED, JobStatus.RUNNING, JobStatus.CANCEL_REQUESTED): + job_exec.request_cancel() + profiling_run = next(iter(ProfilingRun.select_where(ProfilingRun.job_execution_id == je_id)), None) + if profiling_run: + ProfilingRun.cascade_delete([str(profiling_run.id)]) + get_current_session().delete(job_exec) get_profiling_run_summaries.clear() - st.session_state.pop(DELETE_DIALOG_KEY, None) + Router().set_query_params({"page": 1}) except Exception: LOG.exception("Failed to delete profiling runs") st.toast("Unable to delete the selected profiling runs, try again.", icon=":material/error:") diff --git a/testgen/ui/views/test_runs.py b/testgen/ui/views/test_runs.py index 7f7230ab..5d7428f7 100644 --- a/testgen/ui/views/test_runs.py +++ b/testgen/ui/views/test_runs.py @@ -1,14 +1,13 @@ import logging import typing from collections.abc import Iterable -from functools import partial from typing import Any import streamlit as st import testgen.ui.services.form_service as fm -from testgen.common.models import database_session, with_database_session -from testgen.common.models.job_execution import JobExecution +from testgen.common.models import database_session, get_current_session, with_database_session +from testgen.common.models.job_execution import JobExecution, JobStatus from testgen.common.models.notification_settings import ( TestRunNotificationSettings, TestRunNotificationTrigger, @@ -34,9 +33,7 @@ TR_RUN_TESTS_DIALOG_KEY = "tr:run_tests_dialog" TR_RUN_SCHEDULES_DIALOG_KEY = "tr:run_schedules_dialog" TR_RUN_NOTIFICATIONS_DIALOG_KEY = "tr:run_notifications_dialog" -TR_DELETE_DIALOG_KEY = "tr:delete_dialog" TR_RUN_TESTS_RESULT_KEY = "tr:run_tests_result" -TR_DELETE_DIALOG_OPEN_COUNT_KEY = "tr:delete_dialog_open_count" class TestRunsPage(Page): @@ -58,9 +55,11 @@ def render(self, project_code: str, table_group_id: str | None = None, test_suit "data-quality-testing", ) + page = int(st.query_params.get("page", 1)) + with st.spinner("Loading data ..."): project_summary = get_project_summary(project_code) - test_runs = get_test_run_summaries(project_code, table_group_id, test_suite_id) + test_runs, total_count = get_test_run_summaries(project_code, table_group_id, test_suite_id, page=page) table_groups = TableGroup.select_minimal_where(TableGroup.project_code == project_code) test_suites = TestSuite.select_minimal_where(TestSuite.project_code == project_code, TestSuite.is_monitor.isnot(True)) @@ -73,10 +72,6 @@ def on_run_schedules_clicked(*_) -> None: def on_run_notifications_clicked(*_) -> None: st.session_state[TR_RUN_NOTIFICATIONS_DIALOG_KEY] = True - def on_delete_runs_clicked(test_run_ids: list[str]) -> None: - st.session_state[TR_DELETE_DIALOG_KEY] = test_run_ids - st.session_state[TR_DELETE_DIALOG_OPEN_COUNT_KEY] = st.session_state.get(TR_DELETE_DIALOG_OPEN_COUNT_KEY, 0) + 1 - schedule_obj = TestRunScheduleDialog(project_code) ns_obj = TestRunNotificationSettingsDialog( TestRunNotificationSettings, {"project_code": project_code} @@ -102,15 +97,6 @@ def on_delete_runs_clicked(test_run_ids: list[str]) -> None: notifications_data = ns_obj.build_data() notifications_data["open"] = True - # Build delete dialog data - delete_dialog_data = None - if run_ids := st.session_state.get(TR_DELETE_DIALOG_KEY): - delete_dialog_data = { - "open": st.session_state[TR_DELETE_DIALOG_OPEN_COUNT_KEY], - "run_ids": run_ids, - "has_active_job": TestRun.has_active_job_for(TestRun, *run_ids), - } - def on_run_tests_confirmed(data: dict) -> None: selected_id = data.get("test_suite_id") selected_name = data.get("test_suite_name") @@ -132,6 +118,7 @@ def on_run_tests_confirmed(data: dict) -> None: st.session_state[TR_RUN_TESTS_RESULT_KEY] = {"success": success, "message": message, "show_link": show_link} if success and not show_link: st.cache_data.clear() + Router().set_query_params({"page": 1}) st.session_state.pop(TR_RUN_TESTS_DIALOG_KEY, None) st.session_state.pop(TR_RUN_TESTS_RESULT_KEY, None) @@ -153,8 +140,8 @@ def on_notifications_dialog_closed(*_) -> None: ns_obj.clear_state() st.session_state.pop(TR_RUN_NOTIFICATIONS_DIALOG_KEY, None) - def on_delete_dialog_closed(*_) -> None: - st.session_state.pop(TR_DELETE_DIALOG_KEY, None) + def on_page_changed(new_page: int) -> None: + Router().set_query_params({"page": new_page}) testgen.test_runs_widget( key="test_runs", @@ -163,9 +150,13 @@ def on_delete_dialog_closed(*_) -> None: "test_runs": [ { **run.to_dict(json_safe=True), + "status_label": run.status_label, "dq_score_testing": friendly_score(run.dq_score_testing), } for run in test_runs ], + "total_count": total_count, + "page": page, + "page_size": 20, "table_group_options": [ { "value": str(table_group.id), @@ -187,16 +178,14 @@ def on_delete_dialog_closed(*_) -> None: "run_tests_dialog": run_tests_data, "schedule_dialog": schedule_data, "notifications_dialog": notifications_data, - "delete_dialog": delete_dialog_data, }, + on_PageChanged_change=on_page_changed, on_FilterApplied_change=on_test_runs_filtered, on_RunSchedulesClicked_change=on_run_schedules_clicked, on_RunNotificationsClicked_change=on_run_notifications_clicked, on_RunTestsClicked_change=on_run_tests_clicked, on_RefreshData_change=refresh_data, - on_DeleteRunsClicked_change=on_delete_runs_clicked, - on_RunsDeleted_change=partial(on_delete_runs, project_code, table_group_id, test_suite_id), - on_DeleteDialogClosed_change=on_delete_dialog_closed, + on_RunsDeleted_change=on_delete_runs, on_RunCanceled_change=on_cancel_run, # RunTestsDialog events on_RunTestsConfirmed_change=on_run_tests_confirmed, @@ -224,7 +213,7 @@ class TestRunFilters(typing.TypedDict): test_suite_id: str def on_test_runs_filtered(filters: TestRunFilters) -> None: - Router().set_query_params(filters) + Router().set_query_params({**filters, "page": 1}) def refresh_data(*_) -> None: @@ -297,28 +286,37 @@ def get_job_arguments(self, arg_value: str) -> tuple[list[typing.Any], dict[str, @with_database_session -def on_cancel_run(test_run: dict) -> None: - if (job_execution_id := test_run.get("job_execution_id")) and (job_exec := JobExecution.get(job_execution_id)) and job_exec.request_cancel(): +def on_cancel_run(payload: dict) -> None: + job_execution_id = payload.get("job_execution_id") + if not job_execution_id: + fm.reset_post_updates(str_message=":red[This run cannot be canceled.]", as_toast=True) + return + + job_exec = JobExecution.get(job_execution_id) + if job_exec and job_exec.request_cancel(): # Stopgap: also update the run status so the UI reflects cancellation immediately. - # The proper flow is subprocess catches SIGTERM and updates its own status on exit. - TestRun.cancel_run(test_run["test_run_id"]) + if test_run_id := payload.get("test_run_id"): + TestRun.cancel_run(test_run_id) fm.reset_post_updates(str_message=":green[Cancellation requested.]", as_toast=True) else: fm.reset_post_updates(str_message=":red[This run cannot be canceled.]", as_toast=True) @with_database_session -def on_delete_runs(project_code: str, table_group_id: str, test_suite_id: str, test_run_ids: list[str]) -> None: +def on_delete_runs(job_execution_ids: list[str]) -> None: try: - test_runs = get_test_run_summaries(project_code, table_group_id, test_suite_id, test_run_ids) - for test_run in test_runs: - if test_run.status == "Running" and test_run.job_execution_id: - job_exec = JobExecution.get(test_run.job_execution_id) - if job_exec: - job_exec.request_cancel() - TestRun.cascade_delete(test_run_ids) + for je_id in job_execution_ids: + job_exec = JobExecution.get(je_id) + if not job_exec: + continue + if job_exec.status in (JobStatus.PENDING, JobStatus.CLAIMED, JobStatus.RUNNING, JobStatus.CANCEL_REQUESTED): + job_exec.request_cancel() + test_run = next(iter(TestRun.select_where(TestRun.job_execution_id == je_id)), None) + if test_run: + TestRun.cascade_delete([str(test_run.id)]) + get_current_session().delete(job_exec) get_test_run_summaries.clear() - st.session_state.pop(TR_DELETE_DIALOG_KEY, None) + Router().set_query_params({"page": 1}) except Exception: LOG.exception("Failed to delete test run") st.toast("Something went wrong while deleting the test run.", icon=":material/error:") diff --git a/tests/unit/common/notifications/test_test_run_notifications.py b/tests/unit/common/notifications/test_test_run_notifications.py index 06cd75f9..e78719b4 100644 --- a/tests/unit/common/notifications/test_test_run_notifications.py +++ b/tests/unit/common/notifications/test_test_run_notifications.py @@ -152,7 +152,7 @@ def test_send_test_run_notification( diff_mock.return_value = create_diff(**diff_mock_args) get_prev_mock.return_value = TestRun(id="tr-prev-id") summary = Mock(project_code="test_project") - select_summary_mock.return_value = [summary] + select_summary_mock.return_value = ([summary], 1) send_test_run_notifications(test_run) diff --git a/tests/unit/mcp/test_tools_test_runs.py b/tests/unit/mcp/test_tools_test_runs.py index 6a5e5259..a03b5604 100644 --- a/tests/unit/mcp/test_tools_test_runs.py +++ b/tests/unit/mcp/test_tools_test_runs.py @@ -11,8 +11,10 @@ def _make_run_summary(**overrides): defaults = { "test_run_id": uuid4(), "job_execution_id": uuid4(), "test_suite": "Quality Suite", "project_name": "Demo", - "table_groups_name": "core_tables", "status": "Complete", - "test_starttime": "2024-01-15T10:00:00", "test_endtime": "2024-01-15T10:05:00", + "table_groups_name": "core_tables", "status": "completed", + "status_label": "Completed", + "created_at": "2024-01-15T10:00:00", + "started_at": "2024-01-15T10:00:00", "completed_at": "2024-01-15T10:05:00", "test_ct": 50, "passed_ct": 45, "failed_ct": 3, "warning_ct": 2, "error_ct": 0, "log_ct": 0, "dismissed_ct": 0, "dq_score_testing": 92.5, } @@ -25,7 +27,7 @@ def _make_run_summary(**overrides): def test_get_recent_test_runs_default_limit(mock_suite, mock_run, db_session_mock): """Default limit=1 returns one run per suite.""" runs = [_make_run_summary(test_run_id=uuid4()) for _ in range(7)] - mock_run.select_summary.return_value = runs + mock_run.select_summary.return_value = (runs, len(runs)) from testgen.mcp.tools.test_runs import get_recent_test_runs @@ -35,7 +37,7 @@ def test_get_recent_test_runs_default_limit(mock_suite, mock_run, db_session_moc assert "1 run(s)" in result assert "Quality Suite" in result assert "92.5" in result - mock_run.select_summary.assert_called_once_with(project_code="demo", test_suite_id=None) + mock_run.select_summary.assert_called_once_with(project_code="demo", test_suite_id=None, page_size=1000) @patch("testgen.mcp.tools.test_runs.TestRun") @@ -43,7 +45,7 @@ def test_get_recent_test_runs_default_limit(mock_suite, mock_run, db_session_moc def test_get_recent_test_runs_custom_limit(mock_suite, mock_run, db_session_mock): """Custom limit returns up to N runs per suite.""" runs = [_make_run_summary() for _ in range(3)] - mock_run.select_summary.return_value = runs + mock_run.select_summary.return_value = (runs, len(runs)) from testgen.mcp.tools.test_runs import get_recent_test_runs @@ -62,7 +64,7 @@ def test_get_recent_test_runs_per_suite_grouping(mock_suite, mock_run, db_sessio _make_run_summary(test_suite="Suite B", test_run_id=uuid4()), _make_run_summary(test_suite="Suite B", test_run_id=uuid4()), ] - mock_run.select_summary.return_value = runs + mock_run.select_summary.return_value = (runs, len(runs)) from testgen.mcp.tools.test_runs import get_recent_test_runs @@ -81,13 +83,13 @@ def test_get_recent_test_runs_with_suite_name(mock_suite, mock_run, db_session_m suite_minimal = MagicMock() suite_minimal.id = suite_id mock_suite.select_minimal_where.return_value = [suite_minimal] - mock_run.select_summary.return_value = [_make_run_summary(test_suite="My Suite")] + mock_run.select_summary.return_value = ([_make_run_summary(test_suite="My Suite")], 1) from testgen.mcp.tools.test_runs import get_recent_test_runs result = get_recent_test_runs("demo", test_suite="My Suite") - mock_run.select_summary.assert_called_once_with(project_code="demo", test_suite_id=str(suite_id)) + mock_run.select_summary.assert_called_once_with(project_code="demo", test_suite_id=str(suite_id), page_size=1000) assert "My Suite" in result @@ -107,7 +109,7 @@ def test_get_recent_test_runs_suite_not_found(mock_suite, mock_run, db_session_m @patch("testgen.mcp.tools.test_runs.TestRun") @patch("testgen.mcp.tools.test_runs.TestSuite") def test_get_recent_test_runs_no_runs(mock_suite, mock_run, db_session_mock): - mock_run.select_summary.return_value = [] + mock_run.select_summary.return_value = ([], 0) from testgen.mcp.tools.test_runs import get_recent_test_runs @@ -119,7 +121,7 @@ def test_get_recent_test_runs_no_runs(mock_suite, mock_run, db_session_mock): @patch("testgen.mcp.tools.test_runs.TestRun") @patch("testgen.mcp.tools.test_runs.TestSuite") def test_get_recent_test_runs_shows_failure_counts(mock_suite, mock_run, db_session_mock): - mock_run.select_summary.return_value = [_make_run_summary(failed_ct=5, warning_ct=2)] + mock_run.select_summary.return_value = ([_make_run_summary(failed_ct=5, warning_ct=2)], 1) from testgen.mcp.tools.test_runs import get_recent_test_runs @@ -135,7 +137,7 @@ def test_get_recent_test_runs_outputs_job_execution_id(mock_suite, mock_run, db_ """Output should contain job_execution_id, not test_run_id.""" job_exec_id = uuid4() run = _make_run_summary(job_execution_id=job_exec_id) - mock_run.select_summary.return_value = [run] + mock_run.select_summary.return_value = ([run], 1) from testgen.mcp.tools.test_runs import get_recent_test_runs From 3c23625fb2c3ffbd13248404a249556f3cec4cee Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Fri, 17 Apr 2026 14:27:16 -0300 Subject: [PATCH 063/123] refactor(deploy): rename build_docs to build_api_docs (TG-1031) Address submodule MR review: - Rename script and invoke task for clarity - Drop enterprise-repo-specific references from docstring Co-Authored-By: Claude Opus 4.7 (1M context) --- deploy/{build_docs.py => build_api_docs.py} | 8 +++----- invocations/dev.py | 8 ++++---- 2 files changed, 7 insertions(+), 9 deletions(-) rename deploy/{build_docs.py => build_api_docs.py} (82%) diff --git a/deploy/build_docs.py b/deploy/build_api_docs.py similarity index 82% rename from deploy/build_docs.py rename to deploy/build_api_docs.py index f49c9be4..4203db63 100644 --- a/deploy/build_docs.py +++ b/deploy/build_api_docs.py @@ -1,11 +1,9 @@ """Export the TestGen OpenAPI spec as a JSON file. -Usage (from the enterprise repo root): - python testgen/deploy/build_docs.py [--output PATH] [--version VERSION] +Usage: + python deploy/build_api_docs.py [--output PATH] [--version VERSION] -The output JSON is committed to docs/api/openapi.json and served by a static -Redoc HTML shell alongside it. The CI "Update Repo" job regenerates this on -every release. +The output JSON is served by a static Redoc HTML shell alongside it. """ import argparse diff --git a/invocations/dev.py b/invocations/dev.py index cbe1293f..37011fec 100644 --- a/invocations/dev.py +++ b/invocations/dev.py @@ -1,4 +1,4 @@ -__all__ = ["build_docs", "build_public_image", "clean", "install", "lint"] +__all__ = ["build_api_docs", "build_public_image", "clean", "install", "lint"] import re from os.path import exists, join @@ -72,15 +72,15 @@ def clean(ctx: Context) -> None: print("Cleaning finished!") -@task(name="build-docs", pre=(install,)) -def build_docs(ctx: Context, version: str = "", output: str = "") -> None: +@task(name="build-api-docs", pre=(install,)) +def build_api_docs(ctx: Context, version: str = "", output: str = "") -> None: """Exports the OpenAPI spec as JSON for the static API docs.""" args = [] if version: args.append(f"--version {version}") if output: args.append(f"--output {output}") - ctx.run(f"python deploy/build_docs.py {' '.join(args)}") + ctx.run(f"python deploy/build_api_docs.py {' '.join(args)}") @task( From ff21b67d5e14fe6d5281b8f6e0d807fb25338ab8 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Fri, 17 Apr 2026 15:10:12 -0300 Subject: [PATCH 064/123] ci(lint): ignore T201 print rule for deploy/ scripts (TG-1031) Deploy scripts are CLI-style and legitimately print to stdout, matching the existing pattern for invocations/ and tasks/ directories. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index adfa06c8..f69ba971 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -264,6 +264,7 @@ ignore = ["TRY003", "S608", "S404", "F841", "B023"] "tasks.py" = ["F403"] "tests*" = ["S101", "T201", "ARG001"] "invocations/**" = ["ARG001", "T201"] +"deploy/**" = ["T201"] "testgen/common/encrypt.py" = ["S413"] "testgen/ui/pdf/dk_logo.py" = ["T201"] From 029560e6413d75eebefbf631d0ef223a96228d76 Mon Sep 17 00:00:00 2001 From: Luis Date: Fri, 17 Apr 2026 17:20:44 -0400 Subject: [PATCH 065/123] fix(test definitions): add missing profiling button --- .../frontend/js/pages/test_definitions.js | 24 ++++++++++++++----- testgen/ui/views/test_definitions.py | 23 +++++++++++++++++- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/testgen/ui/components/frontend/js/pages/test_definitions.js b/testgen/ui/components/frontend/js/pages/test_definitions.js index abb0d141..cd9451d6 100644 --- a/testgen/ui/components/frontend/js/pages/test_definitions.js +++ b/testgen/ui/components/frontend/js/pages/test_definitions.js @@ -16,6 +16,7 @@ import { DropdownButton } from '/app/static/js/components/dropdown_button.js'; import { TestDefinitionNotes } from './test_definition_notes.js'; import { withTooltip } from '/app/static/js/components/tooltip.js'; import { Icon } from '/app/static/js/components/icon.js'; +import { ProfilingResultsDialog } from '../shared/profiling_results_dialog.js'; const { button: btn, div, i: icon, span, strong, input, label } = van.tags; @@ -639,6 +640,12 @@ const TestDefinitions = (/** @type object */ props) => { }); }, + // Profiling results dialog + ProfilingResultsDialog({ emit, + profilingColumn: van.derive(() => getValue(props.profiling_column) ?? null), + onClose: () => emit('ProfilingClosed', {}), + }), + // Notes dialog Dialog( { @@ -752,19 +759,24 @@ const TestDefinitions = (/** @type object */ props) => { if (!row) return ''; return div( { class: 'tg-td--detail flex-column fx-gap-4' }, - canEdit.val ? div( + div( { class: 'flex-row fx-gap-2 fx-justify-content-flex-end' }, - Button({ + canEdit.val ? Button({ type: 'stroked', icon: 'edit', label: 'Edit', width: 'auto', style: 'background: var(--button-generic-background-color);', onclick: () => emit('EditDialogOpened', { payload: { id: row.id } }), - }), - Button({ + }) : '', + canEdit.val ? Button({ type: 'stroked', icon: 'sticky_note_2', label: 'Notes', width: 'auto', style: 'background: var(--button-generic-background-color);', onclick: () => emit('NotesClicked', { payload: { id: row.id, table_name: row.table_name, column_name: row.column_name, test_name_short: row.test_name_short } }), - }), - ) : '', + }) : '', + row.column_name ? Button({ + type: 'stroked', icon: 'query_stats', label: 'Profiling', width: 'auto', + style: 'background: var(--button-generic-background-color);', + onclick: () => emit('ProfilingClicked', { payload: { table_name: row.table_name, column_name: row.column_name, table_groups_id: row.table_groups_id } }), + }) : '', + ), DetailPanel(row), ); }, diff --git a/testgen/ui/views/test_definitions.py b/testgen/ui/views/test_definitions.py index d4b64833..b69884b7 100644 --- a/testgen/ui/views/test_definitions.py +++ b/testgen/ui/views/test_definitions.py @@ -20,6 +20,7 @@ TestDefinitionSummary, ) from testgen.common.models.test_suite import TestSuite +from testgen.common.pii_masking import get_pii_columns, mask_profiling_pii from testgen.ui.components import widgets as testgen from testgen.ui.components.widgets.download_dialog import ( FILE_DATA_TYPE, @@ -29,9 +30,10 @@ ) from testgen.ui.navigation.page import Page from testgen.ui.navigation.router import Router +from testgen.ui.queries import profiling_queries from testgen.ui.services.database_service import fetch_all_from_db, fetch_df_from_db, fetch_from_target_db from testgen.ui.session import session -from testgen.utils import to_dataframe +from testgen.utils import make_json_safe, to_dataframe LOG = logging.getLogger("testgen") @@ -56,6 +58,7 @@ TD_COPY_MOVE_COLLISION_KEY = "td:copy_move_collision" TD_COPY_MOVE_OVERWRITE_KEY = "td:copy_move_overwrite" TD_NOTES_DIALOG_KEY = "td:notes_dialog" +TD_PROFILING_KEY = "td:profiling" def _parse_sort_param(sort: str | None) -> tuple[list | None, list[dict]]: @@ -491,6 +494,21 @@ def on_note_deleted(payload: dict) -> None: def on_notes_dialog_closed(*_) -> None: st.session_state.pop(TD_NOTES_DIALOG_KEY, None) + @with_database_session + def on_profiling_clicked(payload: dict) -> None: + column_name = payload.get("column_name") + table_name = payload.get("table_name") + table_groups_id = payload.get("table_groups_id") + if not (column_name and table_name and table_groups_id): + return + column = profiling_queries.get_column_by_name(column_name, table_name, table_groups_id) + if column: + mask_profiling_pii(column, get_pii_columns(table_groups_id, table_name=table_name)) + st.session_state[TD_PROFILING_KEY] = make_json_safe(column) + + def on_profiling_closed(*_) -> None: + st.session_state.pop(TD_PROFILING_KEY, None) + def on_export_all(*_) -> None: download_dialog( dialog_title="Download Excel Report", @@ -575,6 +593,7 @@ def on_sort_changed(payload: dict) -> None: "copy_move_dialog": copy_move_dialog, "run_tests_dialog": run_tests_data, "notes_dialog": notes_dialog, + "profiling_column": st.session_state.get(TD_PROFILING_KEY), }, on_AddDialogOpened_change=on_add_dialog_opened, on_EditDialogOpened_change=on_edit_dialog_opened, @@ -610,6 +629,8 @@ def on_sort_changed(payload: dict) -> None: on_NoteUpdated_change=on_note_updated, on_NoteDeleted_change=on_note_deleted, on_NotesDialogClosed_change=on_notes_dialog_closed, + on_ProfilingClicked_change=on_profiling_clicked, + on_ProfilingClosed_change=on_profiling_closed, on_FilterChanged_change=on_filter_changed, on_PageChanged_change=on_page_changed, on_SortChanged_change=on_sort_changed, From b2029478bc5727874430cd21a685343c12dccfb4 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Fri, 17 Apr 2026 19:44:44 -0300 Subject: [PATCH 066/123] feat(TG-1043): make OAuth refresh tokens usable for API automation - Stop advertising client_credentials in /.well-known metadata and /oauth/register defaults (the grant was unreachable for public DCR clients anyway) - Move ACCESS_TOKEN_EXPIRES_IN to settings and add REFRESH_TOKEN_EXPIRES_IN (30 days, independent of access token lifetime) - Disable refresh token rotation (INCLUDE_NEW_REFRESH_TOKEN = False) so clients with read-only secret stores can reuse the same refresh token - Only revoke access_token_revoked_at on refresh so the refresh token stays live; refresh-side liveness now independent of access-side Co-Authored-By: Claude Opus 4.7 (1M context) --- testgen/api/oauth/metadata.py | 1 - testgen/api/oauth/models.py | 5 +++-- testgen/api/oauth/routes.py | 2 +- testgen/api/oauth/server.py | 17 ++++++++--------- testgen/settings.py | 6 ++++++ tests/unit/api/oauth/test_metadata.py | 2 +- tests/unit/api/oauth/test_models.py | 14 ++++++++++---- tests/unit/api/oauth/test_server.py | 13 +++++++------ 8 files changed, 36 insertions(+), 24 deletions(-) diff --git a/testgen/api/oauth/metadata.py b/testgen/api/oauth/metadata.py index f06c3005..31efefbd 100644 --- a/testgen/api/oauth/metadata.py +++ b/testgen/api/oauth/metadata.py @@ -26,7 +26,6 @@ def authorization_server_metadata(): "response_types_supported": ["code"], "grant_types_supported": [ "authorization_code", - "client_credentials", "refresh_token", ], "token_endpoint_auth_methods_supported": [ diff --git a/testgen/api/oauth/models.py b/testgen/api/oauth/models.py index 88964a6e..49b2c961 100644 --- a/testgen/api/oauth/models.py +++ b/testgen/api/oauth/models.py @@ -8,6 +8,7 @@ from sqlalchemy import Column, ForeignKey, String from sqlalchemy.dialects import postgresql +from testgen import settings from testgen.common.models import Base @@ -38,7 +39,7 @@ class OAuth2Token(Base, OAuth2TokenMixin): access_token = Column(String(2048), unique=True, nullable=False) def is_refresh_token_active(self) -> bool: - if self.is_revoked(): + if self.refresh_token_revoked_at: return False - expires_at = self.issued_at + self.expires_in * 2 + expires_at = self.issued_at + settings.REFRESH_TOKEN_EXPIRES_IN return expires_at >= time.time() diff --git a/testgen/api/oauth/routes.py b/testgen/api/oauth/routes.py index d7b2c130..6b468171 100644 --- a/testgen/api/oauth/routes.py +++ b/testgen/api/oauth/routes.py @@ -256,7 +256,7 @@ def register_client(body: dict = Depends(_json_body)): # noqa: B008 metadata = { "client_name": body.get("client_name", ""), - "grant_types": body.get("grant_types", ["authorization_code", "client_credentials", "refresh_token"]), + "grant_types": body.get("grant_types", ["authorization_code", "refresh_token"]), "redirect_uris": body.get("redirect_uris", []), "response_types": ["code"], "scope": body.get("scope", ""), diff --git a/testgen/api/oauth/server.py b/testgen/api/oauth/server.py index c9025c55..498f0f91 100644 --- a/testgen/api/oauth/server.py +++ b/testgen/api/oauth/server.py @@ -17,13 +17,12 @@ from authlib.oauth2.rfc7009 import RevocationEndpoint from authlib.oauth2.rfc7636 import CodeChallenge +from testgen import settings from testgen.api.oauth.models import OAuth2AuthorizationCode, OAuth2Client, OAuth2Token from testgen.common.auth import create_jwt_token from testgen.common.models import get_current_session from testgen.common.models.user import User -ACCESS_TOKEN_EXPIRES_IN = 3600 # 1 hour - class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): TOKEN_ENDPOINT_AUTH_METHODS: ClassVar[list[str]] = ["client_secret_basic", "client_secret_post", "none"] @@ -61,14 +60,14 @@ def authenticate_user(self, authorization_code): class RefreshTokenGrant(grants.RefreshTokenGrant): - INCLUDE_NEW_REFRESH_TOKEN = True + INCLUDE_NEW_REFRESH_TOKEN = False def authenticate_refresh_token(self, refresh_token): session = get_current_session() item = session.query(OAuth2Token).filter_by( refresh_token=refresh_token, ).first() - if item and not item.is_revoked(): + if item and item.is_refresh_token_active(): return item return None @@ -77,9 +76,9 @@ def authenticate_user(self, credential): return session.query(User).filter(User.id == credential.user_id).first() def revoke_old_credential(self, credential): - now = int(time.time()) - credential.access_token_revoked_at = now - credential.refresh_token_revoked_at = now + # Rotation is off (INCLUDE_NEW_REFRESH_TOKEN=False): keep the refresh token + # live so clients can reuse it until its independent expiry. + credential.access_token_revoked_at = int(time.time()) class ClientCredentialsGrant(grants.ClientCredentialsGrant): @@ -163,11 +162,11 @@ def _generate_bearer_token( """Generate a Bearer token with a JWT access_token.""" if user is None: raise RuntimeError(f"Token generation requires a user (client_id={client.client_id})") - access_token = create_jwt_token(user.username, expiry_seconds=ACCESS_TOKEN_EXPIRES_IN) + access_token = create_jwt_token(user.username, expiry_seconds=settings.ACCESS_TOKEN_EXPIRES_IN) token = { "token_type": "Bearer", "access_token": access_token, - "expires_in": expires_in or ACCESS_TOKEN_EXPIRES_IN, + "expires_in": expires_in or settings.ACCESS_TOKEN_EXPIRES_IN, } if include_refresh_token: token["refresh_token"] = secrets.token_urlsafe(48) diff --git a/testgen/settings.py b/testgen/settings.py index 9e00ffd0..853176d9 100644 --- a/testgen/settings.py +++ b/testgen/settings.py @@ -493,6 +493,12 @@ def _ssl_files_present() -> bool: defaults to: 5 """ +ACCESS_TOKEN_EXPIRES_IN: int = 3600 # 1 hour +REFRESH_TOKEN_EXPIRES_IN: int = 2_592_000 # 30 days +""" +Lifetime of OAuth access and refresh tokens. +""" + JWT_HASHING_KEY_B64: str = os.getenv("TG_JWT_HASHING_KEY") """ Random key used to sign/verify the authentication token diff --git a/tests/unit/api/oauth/test_metadata.py b/tests/unit/api/oauth/test_metadata.py index 64c2844b..8ead936f 100644 --- a/tests/unit/api/oauth/test_metadata.py +++ b/tests/unit/api/oauth/test_metadata.py @@ -42,8 +42,8 @@ def test_metadata_lists_supported_grant_types(): data = client.get("/.well-known/oauth-authorization-server").json() assert "authorization_code" in data["grant_types_supported"] - assert "client_credentials" in data["grant_types_supported"] assert "refresh_token" in data["grant_types_supported"] + assert "client_credentials" not in data["grant_types_supported"] assert data["code_challenge_methods_supported"] == ["S256"] diff --git a/tests/unit/api/oauth/test_models.py b/tests/unit/api/oauth/test_models.py index 7218f747..d546feb6 100644 --- a/tests/unit/api/oauth/test_models.py +++ b/tests/unit/api/oauth/test_models.py @@ -22,12 +22,18 @@ def test_is_refresh_token_active_returns_true_when_valid(): assert token.is_refresh_token_active() is True -def test_is_refresh_token_active_returns_false_when_revoked(): - now = int(time.time()) - token = _make_token(access_token_revoked_at=now, refresh_token_revoked_at=now) +def test_is_refresh_token_active_returns_false_when_refresh_revoked(): + token = _make_token(refresh_token_revoked_at=int(time.time())) assert token.is_refresh_token_active() is False +def test_is_refresh_token_active_ignores_access_revocation(): + # Rotation is off: the access token is revoked on every refresh, but the + # refresh token must stay live so clients can reuse it. + token = _make_token(access_token_revoked_at=int(time.time())) + assert token.is_refresh_token_active() is True + + def test_is_refresh_token_active_returns_false_when_expired(): - token = _make_token(issued_at=int(time.time()) - 100000, expires_in=100) + token = _make_token(issued_at=int(time.time()) - (31 * 86400)) assert token.is_refresh_token_active() is False diff --git a/tests/unit/api/oauth/test_server.py b/tests/unit/api/oauth/test_server.py index 5e4768c3..85030c64 100644 --- a/tests/unit/api/oauth/test_server.py +++ b/tests/unit/api/oauth/test_server.py @@ -7,8 +7,8 @@ import pytest from authlib.oauth2.rfc6749 import grants +from testgen import settings from testgen.api.oauth.server import ( - ACCESS_TOKEN_EXPIRES_IN, AuthorizationCodeGrant, ClientCredentialsGrant, RefreshTokenGrant, @@ -132,7 +132,7 @@ def test_refresh_token_authenticate_returns_none_for_revoked(mock_get_session): mock_get_session.return_value = mock_session mock_token = MagicMock() - mock_token.is_revoked.return_value = True + mock_token.is_refresh_token_active.return_value = False mock_session.query.return_value.filter_by.return_value.first.return_value = mock_token grant = RefreshTokenGrant.__new__(RefreshTokenGrant) @@ -155,16 +155,17 @@ def test_refresh_token_authenticate_returns_none_when_not_found(mock_get_session assert result is None -def test_revoke_old_credential_sets_timestamps(): +def test_revoke_old_credential_revokes_access_but_keeps_refresh_live(): grant = RefreshTokenGrant.__new__(RefreshTokenGrant) credential = MagicMock() + credential.refresh_token_revoked_at = 0 before = int(time.time()) grant.revoke_old_credential(credential) after = int(time.time()) assert before <= credential.access_token_revoked_at <= after - assert before <= credential.refresh_token_revoked_at <= after + assert credential.refresh_token_revoked_at == 0 @patch("testgen.api.oauth.server.get_current_session") @@ -284,10 +285,10 @@ def test_generate_bearer_token_with_user(mock_jwt): assert token["token_type"] == "Bearer" # noqa: S105 assert token["access_token"] == "jwt_access_token" # noqa: S105 - assert token["expires_in"] == ACCESS_TOKEN_EXPIRES_IN + assert token["expires_in"] == settings.ACCESS_TOKEN_EXPIRES_IN assert "refresh_token" in token assert token["scope"] == "read" - mock_jwt.assert_called_once_with("alice", expiry_seconds=ACCESS_TOKEN_EXPIRES_IN) + mock_jwt.assert_called_once_with("alice", expiry_seconds=settings.ACCESS_TOKEN_EXPIRES_IN) def test_generate_bearer_token_raises_when_no_user(): From 5a1358861f13b5b684b2a7921a3ac0b4bf283440 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Tue, 21 Apr 2026 14:42:34 -0400 Subject: [PATCH 067/123] fix(deps): bump pillow to 12.2.0 for CVE-2026-40192 Pillow 12.1.1 was flagged by Trivy as HIGH severity (CVE-2026-40192, DoS via decompression bomb in FITS image processing). 12.2.0 contains the fix. Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f69ba971..2d9153ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,7 @@ dependencies = [ "matplotlib==3.9.2", "scipy==1.14.1", "jinja2==3.1.6", - "pillow==12.1.1", + "pillow==12.2.0", "protobuf==6.33.5", # MCP server From 09ee0ba6956f39b350fa7d42a79c542170244da9 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Wed, 22 Apr 2026 13:49:36 -0300 Subject: [PATCH 068/123] feat: migrate run-page URLs to job_execution_id with dual-ID routing (TG-1046) Outgoing URLs (frontend links, emails, PDFs, dashboard shortcuts) now emit job_execution_id. Target pages still accept the legacy run PK via ProfilingRun/TestRun.get_minimal dual-ID acceptance, so old bookmarks and in-flight emails keep working. Prep for the run-table dedup follow-up. Also fixes a notification crash when mark_completed() hasn't run yet: tr_summary.completed_at falls back to NOW() in the email context (same band-aid as d04550a0 which was dropped during TG-1034's select_summary refactor). Co-Authored-By: Claude Opus 4.7 (1M context) --- testgen/common/models/profiling_run.py | 4 +++- testgen/common/models/table_group.py | 3 +++ testgen/common/models/test_run.py | 6 +++++- testgen/common/models/test_suite.py | 3 +++ testgen/common/notifications/profiling_run.py | 4 ++-- testgen/common/notifications/test_run.py | 7 ++++++- .../components/frontend/js/pages/profiling_runs.js | 4 ++-- .../frontend/js/pages/project_dashboard.js | 13 +++++++------ .../ui/components/frontend/js/pages/test_runs.js | 2 +- .../ui/components/frontend/js/pages/test_suites.js | 4 ++-- testgen/ui/components/frontend/js/types.js | 1 + testgen/ui/pdf/hygiene_issue_report.py | 2 +- testgen/ui/pdf/test_result_report.py | 2 +- testgen/ui/queries/profiling_queries.py | 1 + testgen/ui/queries/test_result_queries.py | 3 +++ testgen/ui/views/hygiene_issues.py | 2 ++ testgen/ui/views/profiling_results.py | 2 ++ testgen/ui/views/test_results.py | 2 ++ .../test_profiling_run_notifications.py | 8 +++++++- .../notifications/test_test_run_notifications.py | 1 + 20 files changed, 55 insertions(+), 19 deletions(-) diff --git a/testgen/common/models/profiling_run.py b/testgen/common/models/profiling_run.py index 3a707086..4f562f2f 100644 --- a/testgen/common/models/profiling_run.py +++ b/testgen/common/models/profiling_run.py @@ -141,7 +141,9 @@ def get_minimal(cls, run_id: str | UUID) -> ProfilingRunMinimal | None: return None query = ( - select(cls._minimal_columns).join(TableGroup, cls.table_groups_id == TableGroup.id).where(cls.id == run_id) + select(cls._minimal_columns) + .join(TableGroup, cls.table_groups_id == TableGroup.id) + .where((cls.id == run_id) | (cls.job_execution_id == run_id)) ) result = get_current_session().execute(query).first() return ProfilingRunMinimal(**result) if result else None diff --git a/testgen/common/models/table_group.py b/testgen/common/models/table_group.py index 20a6aef4..ff226fd1 100644 --- a/testgen/common/models/table_group.py +++ b/testgen/common/models/table_group.py @@ -60,6 +60,7 @@ class TableGroupSummary(EntityMinimal): dq_score_profiling: float dq_score_testing: float latest_profile_id: UUID + latest_profile_job_execution_id: UUID | None latest_profile_start: datetime latest_anomalies_ct: int latest_anomalies_definite_ct: int @@ -211,6 +212,7 @@ def select_summary(cls, project_code: str, for_dashboard: bool = False) -> Itera latest_profile AS ( SELECT latest_run.table_groups_id, latest_run.id, + latest_run.job_execution_id, latest_run.profiling_starttime, latest_run.anomaly_ct, SUM( @@ -316,6 +318,7 @@ def select_summary(cls, project_code: str, for_dashboard: bool = False) -> Itera groups.dq_score_profiling, groups.dq_score_testing, latest_profile.id AS latest_profile_id, + latest_profile.job_execution_id AS latest_profile_job_execution_id, latest_profile.profiling_starttime AS latest_profile_start, latest_profile.anomaly_ct AS latest_anomalies_ct, latest_profile.definite_ct AS latest_anomalies_definite_ct, diff --git a/testgen/common/models/test_run.py b/testgen/common/models/test_run.py index d2d0ac09..4f56bc8d 100644 --- a/testgen/common/models/test_run.py +++ b/testgen/common/models/test_run.py @@ -164,7 +164,11 @@ def get_minimal(cls, run_id: str | UUID) -> TestRunMinimal | None: if not is_uuid4(run_id): return None - query = select(cls._minimal_columns).join(TestSuite).where(cls.id == run_id) + query = ( + select(cls._minimal_columns) + .join(TestSuite) + .where((cls.id == run_id) | (cls.job_execution_id == run_id)) + ) result = get_current_session().execute(query).first() return TestRunMinimal(**result) if result else None diff --git a/testgen/common/models/test_suite.py b/testgen/common/models/test_suite.py index 4584b5ba..6ef777c5 100644 --- a/testgen/common/models/test_suite.py +++ b/testgen/common/models/test_suite.py @@ -43,6 +43,7 @@ class TestSuiteSummary(EntityMinimal): test_ct: int last_complete_profile_run_id: UUID latest_run_id: UUID + latest_run_job_execution_id: UUID | None latest_run_start: datetime last_run_test_ct: int last_run_passed_ct: int @@ -115,6 +116,7 @@ def select_summary(cls, project_code: str, table_group_id: str | UUID | None = N WITH last_run AS ( SELECT test_runs.test_suite_id, test_runs.id, + test_runs.job_execution_id, test_runs.test_starttime, test_runs.test_ct, SUM( @@ -185,6 +187,7 @@ def select_summary(cls, project_code: str, table_group_id: str | UUID | None = N test_defs.count AS test_ct, last_complete_profile_run_id, last_run.id AS latest_run_id, + last_run.job_execution_id AS latest_run_job_execution_id, last_run.test_starttime AS latest_run_start, last_run.test_ct AS last_run_test_ct, last_run.passed_ct AS last_run_passed_ct, diff --git a/testgen/common/notifications/profiling_run.py b/testgen/common/notifications/profiling_run.py index f16f7cb1..e3b76bf7 100644 --- a/testgen/common/notifications/profiling_run.py +++ b/testgen/common/notifications/profiling_run.py @@ -265,7 +265,7 @@ def send_profiling_run_notifications(profiling_run: ProfilingRun, result_list_ct settings.UI_BASE_URL, "/profiling-runs:hygiene?project_code=", str(profiling_run.project_code), - "&run_id=", str(profiling_run.id), + "&run_id=", str(profiling_run.job_execution_id), "&source=email" ) ) @@ -318,7 +318,7 @@ def send_profiling_run_notifications(profiling_run: ProfilingRun, result_list_ct "/profiling-runs:results?project_code=", str(profiling_run.project_code), "&run_id=", - str(profiling_run.id), + str(profiling_run.job_execution_id), "&source=email" ) ), diff --git a/testgen/common/notifications/test_run.py b/testgen/common/notifications/test_run.py index 7ecdf25a..77a36087 100644 --- a/testgen/common/notifications/test_run.py +++ b/testgen/common/notifications/test_run.py @@ -1,4 +1,5 @@ import logging +from datetime import UTC, datetime from sqlalchemy import case, literal, select @@ -322,6 +323,10 @@ def send_test_run_notifications(test_run: TestRun, result_list_ct=20, result_sta result_list_by_status[status] = [{**r} for r in get_current_session().execute(query)] (tr_summary,), _ = TestRun.select_summary(test_run_ids=[test_run.id]) + # Notifications fire before the scheduler calls mark_completed(); fall back to NOW() so + # the duration helper in the email template doesn't crash. See job-execution-callbacks followup. + if tr_summary.completed_at is None: + tr_summary.completed_at = datetime.now(UTC) test_run_url = "".join( ( @@ -329,7 +334,7 @@ def send_test_run_notifications(test_run: TestRun, result_list_ct=20, result_sta "/test-runs:results?project_code=", str(tr_summary.project_code), "&run_id=", - str(test_run.id), + str(test_run.job_execution_id), "&source=email" ) ) diff --git a/testgen/ui/components/frontend/js/pages/profiling_runs.js b/testgen/ui/components/frontend/js/pages/profiling_runs.js index d8bb7d48..665a9f33 100644 --- a/testgen/ui/components/frontend/js/pages/profiling_runs.js +++ b/testgen/ui/components/frontend/js/pages/profiling_runs.js @@ -497,7 +497,7 @@ const ProfilingRunItem = ( item.status === 'completed' && item.column_ct && item.profiling_run_id ? Link({ emit, label: 'View results', href: 'profiling-runs:results', - params: { 'run_id': item.profiling_run_id, 'project_code': projectCode }, + params: { 'run_id': item.job_execution_id, 'project_code': projectCode }, underline: true, right_icon: 'chevron_right', }) : null, @@ -515,7 +515,7 @@ const ProfilingRunItem = ( item.anomaly_ct && item.profiling_run_id ? Link({ emit, label: `View ${item.anomaly_ct} issues`, href: 'profiling-runs:hygiene', - params: { 'run_id': item.profiling_run_id, 'project_code': projectCode }, + params: { 'run_id': item.job_execution_id, 'project_code': projectCode }, underline: true, right_icon: 'chevron_right', style: 'margin-top: 4px;', diff --git a/testgen/ui/components/frontend/js/pages/project_dashboard.js b/testgen/ui/components/frontend/js/pages/project_dashboard.js index e9b70576..b339519a 100644 --- a/testgen/ui/components/frontend/js/pages/project_dashboard.js +++ b/testgen/ui/components/frontend/js/pages/project_dashboard.js @@ -17,6 +17,7 @@ * @property {string?} dq_score_profiling * @property {string?} dq_score_testing * @property {string?} latest_profile_id + * @property {string?} latest_profile_job_execution_id * @property {number?} latest_profile_start * @property {number} latest_anomalies_ct * @property {number} latest_anomalies_definite_ct @@ -226,10 +227,10 @@ const TableGroupLatestProfile = (/** @type TableGroupSummary */ tableGroup, /** div( { class: 'flex-row fx-gap-2', style: 'flex: 1 1 50%;' }, span('Latest profile:'), - Link({ emit, + Link({ emit, label: formatTimestamp(tableGroup.latest_profile_start), href: 'profiling-runs:results', - params: { run_id: tableGroup.latest_profile_id, project_code: projectCode }, + params: { run_id: tableGroup.latest_profile_job_execution_id, project_code: projectCode }, }), daysAgo > staleProfileDays ? span({ class: 'text-error' }, `(${daysAgo} days ago)`) @@ -237,11 +238,11 @@ const TableGroupLatestProfile = (/** @type TableGroupSummary */ tableGroup, /** ), div( { class: 'flex-row fx-gap-5', style: 'flex: 1 1 50%;' }, - Link({ emit, + Link({ emit, label: `${tableGroup.latest_anomalies_ct} hygiene issues`, href: 'profiling-runs:hygiene', params: { - run_id: tableGroup.latest_profile_id, + run_id: tableGroup.latest_profile_job_execution_id, project_code: projectCode, }, width: 150, @@ -289,10 +290,10 @@ const TableGroupTestSuiteSummary = (/** @type TestSuiteSummary[] */testSuites, / span({ class: 'text-caption' }, `${suite.test_ct ?? 0} tests`), ), suite.latest_run_id - ? Link({ emit, + ? Link({ emit, label: formatTimestamp(suite.latest_run_start), href: 'test-runs:results', - params: { run_id: suite.latest_run_id, project_code: projectCode }, + params: { run_id: suite.latest_run_job_execution_id, project_code: projectCode }, style: 'flex: 1 1 25%;', }) : span({ style: 'flex: 1 1 25%;' }, '--'), diff --git a/testgen/ui/components/frontend/js/pages/test_runs.js b/testgen/ui/components/frontend/js/pages/test_runs.js index 410c341a..0418a33c 100644 --- a/testgen/ui/components/frontend/js/pages/test_runs.js +++ b/testgen/ui/components/frontend/js/pages/test_runs.js @@ -440,7 +440,7 @@ const TestRunItem = ( ? Link({ emit, label: formatTimestamp(displayTime), href: 'test-runs:results', - params: { 'run_id': item.test_run_id, 'project_code': projectCode }, + params: { 'run_id': item.job_execution_id, 'project_code': projectCode }, underline: true, }) : span(formatTimestamp(displayTime)), diff --git a/testgen/ui/components/frontend/js/pages/test_suites.js b/testgen/ui/components/frontend/js/pages/test_suites.js index 2a2c8b63..360a09f1 100644 --- a/testgen/ui/components/frontend/js/pages/test_suites.js +++ b/testgen/ui/components/frontend/js/pages/test_suites.js @@ -262,9 +262,9 @@ const TestSuites = (/** @type Properties */ props) => { Caption({ content: 'Latest Run', style: 'margin-bottom: 2px;' }), testSuite.latest_run_start ? [ - Link({ emit, + Link({ emit, href: 'test-runs:results', - params: { run_id: testSuite.latest_run_id, project_code: projectSummary.project_code }, + params: { run_id: testSuite.latest_run_job_execution_id, project_code: projectSummary.project_code }, label: formatTimestamp(testSuite.latest_run_start), class: 'mb-4', }), diff --git a/testgen/ui/components/frontend/js/types.js b/testgen/ui/components/frontend/js/types.js index 28a78001..4e30a495 100644 --- a/testgen/ui/components/frontend/js/types.js +++ b/testgen/ui/components/frontend/js/types.js @@ -39,6 +39,7 @@ * @property {number} test_ct * @property {string} last_complete_profile_run_id * @property {string} latest_run_id + * @property {string?} latest_run_job_execution_id * @property {string} latest_run_start * @property {number} last_run_test_ct * @property {number} last_run_passed_ct diff --git a/testgen/ui/pdf/hygiene_issue_report.py b/testgen/ui/pdf/hygiene_issue_report.py index 82f4c248..03b22bb6 100644 --- a/testgen/ui/pdf/hygiene_issue_report.py +++ b/testgen/ui/pdf/hygiene_issue_report.py @@ -143,7 +143,7 @@ def build_summary_table(document, hi_data): ), ( Paragraph( - f""" + f""" View on TestGen > """, style=PARA_STYLE_LINK, diff --git a/testgen/ui/pdf/test_result_report.py b/testgen/ui/pdf/test_result_report.py index c4165268..b69d5e83 100644 --- a/testgen/ui/pdf/test_result_report.py +++ b/testgen/ui/pdf/test_result_report.py @@ -159,7 +159,7 @@ def build_summary_table(document, tr_data): ), ( Paragraph( - f""" + f""" View on TestGen > """, style=PARA_STYLE_LINK, diff --git a/testgen/ui/queries/profiling_queries.py b/testgen/ui/queries/profiling_queries.py index ba6eed40..948ddede 100644 --- a/testgen/ui/queries/profiling_queries.py +++ b/testgen/ui/queries/profiling_queries.py @@ -655,6 +655,7 @@ def get_profiling_anomalies_by_ids(anomaly_ids: list[str]) -> pd.DataFrame: END AS likelihood_order, t.anomaly_description, r.detail, t.detail_redactable, t.suggested_action, r.anomaly_id, r.table_groups_id::VARCHAR, r.id::VARCHAR, p.profiling_starttime, r.profile_run_id::VARCHAR, + p.job_execution_id::VARCHAR as job_execution_id, tg.table_groups_name, tg.project_code, dcc.functional_data_type, dcc.description as column_description, diff --git a/testgen/ui/queries/test_result_queries.py b/testgen/ui/queries/test_result_queries.py index 860a6a08..e75ad19f 100644 --- a/testgen/ui/queries/test_result_queries.py +++ b/testgen/ui/queries/test_result_queries.py @@ -199,6 +199,7 @@ def get_test_results_by_ids(test_result_ids: list[str]) -> pd.DataFrame: END as execution_error_ct, p.project_code, r.table_groups_id::VARCHAR, r.id::VARCHAR as test_result_id, r.test_run_id::VARCHAR, + tr.job_execution_id::VARCHAR as job_execution_id, c.id::VARCHAR as connection_id, r.test_suite_id::VARCHAR, r.test_definition_id::VARCHAR, r.auto_gen, @@ -218,6 +219,8 @@ def get_test_results_by_ids(test_result_ids: list[str]) -> pd.DataFrame: COALESCE(dcc.aggregation_level, dtc.aggregation_level) as aggregation_level, COALESCE(dcc.data_product, dtc.data_product, tg.data_product) as data_product FROM test_results r + INNER JOIN test_runs tr + ON (r.test_run_id = tr.id) INNER JOIN test_types tt ON (r.test_type = tt.test_type) INNER JOIN test_suites ts diff --git a/testgen/ui/views/hygiene_issues.py b/testgen/ui/views/hygiene_issues.py index 339ff170..ef3ae2d8 100644 --- a/testgen/ui/views/hygiene_issues.py +++ b/testgen/ui/views/hygiene_issues.py @@ -107,6 +107,8 @@ def render( ) return + run_id = str(run.id) + run_date = date_service.get_timezoned_timestamp(st.session_state, run.profiling_starttime) session.set_sidebar_project(run.project_code) diff --git a/testgen/ui/views/profiling_results.py b/testgen/ui/views/profiling_results.py index 2f35b1d1..af0d747f 100644 --- a/testgen/ui/views/profiling_results.py +++ b/testgen/ui/views/profiling_results.py @@ -92,6 +92,8 @@ def render( ) return + run_id = str(run.id) + run_date = date_service.get_timezoned_timestamp(st.session_state, run.profiling_starttime) session.set_sidebar_project(run.project_code) diff --git a/testgen/ui/views/test_results.py b/testgen/ui/views/test_results.py index 8791ae34..2ddc31dc 100644 --- a/testgen/ui/views/test_results.py +++ b/testgen/ui/views/test_results.py @@ -149,6 +149,8 @@ def render( ) return + run_id = str(run.id) + run_date = date_service.get_timezoned_timestamp(st.session_state, run.test_starttime) session.set_sidebar_project(run.project_code) diff --git a/tests/unit/common/notifications/test_profiling_run_notifications.py b/tests/unit/common/notifications/test_profiling_run_notifications.py index 5320b997..afcd841d 100644 --- a/tests/unit/common/notifications/test_profiling_run_notifications.py +++ b/tests/unit/common/notifications/test_profiling_run_notifications.py @@ -100,7 +100,13 @@ def test_send_profiling_run_notification( hi_count_mock, send_mock, ): - profiling_run = ProfilingRun(id="pr-id", table_groups_id="tg-id", status=profiling_run_status, project_code="proj") + profiling_run = ProfilingRun( + id="pr-id", + job_execution_id="pr-id", + table_groups_id="tg-id", + status=profiling_run_status, + project_code="proj", + ) get_prev_mock.return_value = ProfilingRun(id="pr-prev-id") if has_prev_run else None new_count = iter(count()) priorities = ("Definite", "Likely", "Possible", "High", "Moderate") diff --git a/tests/unit/common/notifications/test_test_run_notifications.py b/tests/unit/common/notifications/test_test_run_notifications.py index e78719b4..2a90d02f 100644 --- a/tests/unit/common/notifications/test_test_run_notifications.py +++ b/tests/unit/common/notifications/test_test_run_notifications.py @@ -134,6 +134,7 @@ def test_send_test_run_notification( test_run = TestRun( id="tr-id", + job_execution_id="tr-id", status=test_run_status, test_suite_id="ts-id", failed_ct=failed_ct, From bc1f6bc72a63f10f89e049d368c9e8e450a9b5d1 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Thu, 23 Apr 2026 22:10:36 -0300 Subject: [PATCH 069/123] feat(mcp): cross-run analysis and trends (TG-1027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `search_test_results`, `get_failure_trend`, `get_test_run_diff` MCP tools. - Extend `get_failure_summary` with a cross-run aggregation path (project_code / test_suite_id / since) alongside the existing single-run path. - Add `parse_since` to `common/date_service.py` — calendar-day-aligned relative expressions ('7 days', '2 weeks', '1 month') and ISO dates, returning `date`. - Extend `TestResult` with `search_results`, `failure_trend`, `diff_with_details` model methods plus supporting dataclasses. - Introduce `BucketInterval(StrEnum)` for the day/week bucket parameter. - Adopt a `*clauses` pattern in `search_results` and `failure_trend` — filters are caller-built WHERE expressions; model kwargs are algorithm-only (pagination, window bounds, bucket size). Documented in project CLAUDE.md. - Monitor suites are excluded from all new read tools (and the shared `select_failures` path) via `TestSuite.is_monitor.isnot(True)`; monitor runs are rejected from `get_test_run_diff` via the unified not-found-or-inaccessible error to avoid information leakage. - Permission check always runs before suite-match check in `get_test_run_diff`; both checks reuse a single `TestSuite.select_where` lookup. - `failure_trend` uses rolling-today weekly bucketing with `exclude_today=True` as the default at the tool layer for row-to-row comparability; set False to include today's partial data (fires an in-progress note in the output). Co-Authored-By: Claude Opus 4.7 (1M context) --- testgen/common/date_service.py | 59 +++- testgen/common/models/test_result.py | 290 +++++++++++++++- testgen/mcp/server.py | 12 +- testgen/mcp/tools/common.py | 9 + testgen/mcp/tools/test_results.py | 385 ++++++++++++++++++++- tests/unit/common/test_date_service.py | 77 ++++- tests/unit/mcp/test_tools_test_results.py | 394 +++++++++++++++++++++- 7 files changed, 1192 insertions(+), 34 deletions(-) diff --git a/testgen/common/date_service.py b/testgen/common/date_service.py index 1976e1d6..72503ad3 100644 --- a/testgen/common/date_service.py +++ b/testgen/common/date_service.py @@ -1,7 +1,64 @@ -from datetime import UTC, datetime +import calendar +import re +from datetime import UTC, date, datetime, timedelta import pandas as pd +_RELATIVE_SINCE_RE = re.compile( + r"^\s*(\d+)\s*(d|day|days|w|week|weeks|mo|month|months)\s*$", + re.IGNORECASE, +) + + +def _subtract_months(d: date, months: int) -> date: + """Subtract calendar months, clamping the day to the last valid day of the target month.""" + zero_indexed = d.month - 1 - months + new_year = d.year + zero_indexed // 12 + new_month = zero_indexed % 12 + 1 + last_day = calendar.monthrange(new_year, new_month)[1] + return date(new_year, new_month, min(d.day, last_day)) + + +def parse_since(since: str, *, today: date | None = None) -> date: + """Parse a relative expression or ISO date into a calendar ``date``. + + Accepted forms: + - Relative: "7 days", "2 weeks", "30d", "1 month", "3mo" + - ISO-8601 date: "2026-04-01" + + Raises ``ValueError`` on any other input. + + Relative expressions always represent a window ending today inclusive: + - "N days" = N calendar days ending today (e.g. "14 days" → today - 13 days). + - "N weeks" = N*7 calendar days ending today. + - "N months" = same day-of-month N calendar months ago, clamped to the target + month's last valid day (e.g. "1 month" on 03-31 → 02-28). + + The caller owns any time-of-day or timezone concerns (e.g. for SQL comparisons, + Postgres coerces a ``date`` bind param to the start of that day). + """ + if not isinstance(since, str) or not since.strip(): + raise ValueError("expected a non-empty string") + + anchor = today or datetime.now(UTC).date() + match = _RELATIVE_SINCE_RE.match(since) + if match: + amount = int(match.group(1)) + unit = match.group(2).lower() + if unit.startswith("d"): + return anchor - timedelta(days=amount - 1) + if unit.startswith("w"): + return anchor - timedelta(days=amount * 7 - 1) + return _subtract_months(anchor, amount) + + try: + return date.fromisoformat(since.strip()) + except ValueError as err: + raise ValueError( + f"expected a relative expression like '7 days', '2 weeks', '1 month', " + f"or an ISO-8601 date; got `{since}`" + ) from err + def parse_fuzzy_date(value: str | int) -> datetime | None: if type(value) == str: diff --git a/testgen/common/models/test_result.py b/testgen/common/models/test_result.py index 9e8bc2d5..7e69d05d 100644 --- a/testgen/common/models/test_result.py +++ b/testgen/common/models/test_result.py @@ -1,7 +1,7 @@ import enum from collections import defaultdict -from dataclasses import dataclass -from datetime import datetime +from dataclasses import dataclass, field +from datetime import date, datetime, timedelta from typing import Self from uuid import UUID, uuid4 @@ -12,6 +12,7 @@ from testgen.common.models import get_current_session from testgen.common.models.entity import Entity +from testgen.common.models.test_definition import TestType from testgen.common.models.test_suite import TestSuite @@ -23,6 +24,11 @@ class TestResultStatus(enum.Enum): Failed = "Failed" +class BucketInterval(enum.StrEnum): + DAY = "day" + WEEK = "week" + + @dataclass class ResultStatusCounts: """Counts of test results by outcome status, with dismissed/inactive separated.""" @@ -38,6 +44,68 @@ class ResultStatusCounts: TestResultDiffType = tuple[TestResultStatus, TestResultStatus, list[UUID]] +@dataclass +class TestResultSearchRow: + """Cross-run test result row for MCP ``search_test_results``.""" + + test_definition_id: UUID + test_run_id: UUID + job_execution_id: UUID | None + test_time: datetime + test_suite_id: UUID + test_suite_name: str + test_type: str + test_name_short: str | None + table_name: str | None + column_names: str | None + status: TestResultStatus | None + result_measure: str | None + threshold_value: str | None + result_message: str | None + + +@dataclass +class TrendBucket: + """One time-bucket of failure aggregates for ``get_failure_trend``.""" + + bucket: date + failed_ct: int + warning_ct: int + total_ct: int + + @property + def failure_rate(self) -> float: + return (self.failed_ct + self.warning_ct) / self.total_ct if self.total_ct else 0.0 + + +@dataclass +class DiffRow: + """One test definition's status across two runs for ``get_test_run_diff``.""" + + test_definition_id: UUID + test_type: str + test_name_short: str | None + table_name: str | None + column_names: str | None + status_a: TestResultStatus | None + status_b: TestResultStatus | None + measure_a: str | None + measure_b: str | None + + +@dataclass +class RunDiff: + """Categorized diff between two test runs.""" + + total_a: int + total_b: int + regressions: list[DiffRow] = field(default_factory=list) + improvements: list[DiffRow] = field(default_factory=list) + persistent_failures: list[DiffRow] = field(default_factory=list) + new_tests: list[DiffRow] = field(default_factory=list) + removed_tests: list[DiffRow] = field(default_factory=list) + + class TestResult(Entity): __tablename__ = "test_results" @@ -100,19 +168,29 @@ def select_results( @classmethod def select_failures( cls, - test_run_id: UUID, + *, project_codes: list[str] | None = None, + test_suite_id: UUID | None = None, + test_run_id: UUID | None = None, + since: date | None = None, group_by: str = "test_type", ) -> list[tuple]: allowed = {"test_type", "table_name", "column_names"} if group_by not in allowed: raise ValueError(f"group_by must be one of {allowed}") + if test_run_id is None and test_suite_id is None and since is None: + raise ValueError("Provide test_run_id, test_suite_id, or since to scope the query.") where = [ - cls.test_run_id == test_run_id, cls.status.in_([TestResultStatus.Failed, TestResultStatus.Warning]), func.coalesce(cls.disposition, "Confirmed") == "Confirmed", ] + if test_run_id is not None: + where.append(cls.test_run_id == test_run_id) + if test_suite_id is not None: + where.append(cls.test_suite_id == test_suite_id) + if since is not None: + where.append(cls.test_time >= since) # Column grouping includes table_name for context → (table, column, count) if group_by == "column_names": @@ -122,11 +200,13 @@ def select_failures( else: group_cols = (getattr(cls, group_by),) - query = select(*group_cols, func.count().label("failure_count")).where(*where) + query = ( + select(*group_cols, func.count().label("failure_count")) + .join(TestSuite, cls.test_suite_id == TestSuite.id) + .where(*where, TestSuite.is_monitor.isnot(True)) + ) if project_codes is not None: - query = query.join(TestSuite, cls.test_suite_id == TestSuite.id).where( - TestSuite.project_code.in_(project_codes) - ) + query = query.where(TestSuite.project_code.in_(project_codes)) query = query.group_by(*group_cols).order_by(func.count().desc()) return get_current_session().execute(query).all() @@ -187,3 +267,197 @@ def diff(cls, test_run_id_a: UUID, test_run_id_b: UUID) -> list[TestResultDiffTy diff[(run_a_status, run_b_status)].append(result_id) return [(*statuses, id_list) for statuses, id_list in diff.items()] + + @classmethod + def search_results( + cls, + *clauses, + page: int = 1, + limit: int = 50, + ) -> tuple[list[TestResultSearchRow], int]: + """Paginated cross-run search over test results, scoped by caller-supplied WHERE clauses. + + Monitor suites and dismissed/inactive results are always filtered out. All other + scoping is up to the caller. + """ + # TestRun has its own top-level import of TestResult, so we import it here to avoid the cycle. + from testgen.common.models.test_run import TestRun + + query = ( + select( + cls.test_definition_id.label("test_definition_id"), + cls.test_run_id.label("test_run_id"), + TestRun.job_execution_id.label("job_execution_id"), + cls.test_time.label("test_time"), + TestSuite.id.label("test_suite_id"), + TestSuite.test_suite.label("test_suite_name"), + cls.test_type.label("test_type"), + TestType.test_name_short.label("test_name_short"), + cls.table_name.label("table_name"), + cls.column_names.label("column_names"), + cls.status.label("status"), + cls.result_measure.label("result_measure"), + cls.threshold_value.label("threshold_value"), + cls.message.label("result_message"), + ) + .join(TestSuite, cls.test_suite_id == TestSuite.id) + .join(TestRun, cls.test_run_id == TestRun.id) + .outerjoin(TestType, cls.test_type == TestType.test_type) + .where( + TestSuite.is_monitor.isnot(True), + func.coalesce(cls.disposition, "Confirmed") == "Confirmed", + *clauses, + ) + ) + query = query.order_by(desc(cls.test_time), cls.table_name, cls.column_names) + return cls._paginate(query, page=page, limit=limit, data_class=TestResultSearchRow) + + @classmethod + def failure_trend( + cls, + *clauses, + start_date: date, + end_date: date, + bucket: BucketInterval = BucketInterval.DAY, + ) -> list[TrendBucket]: + """Time-series of test result counts per bucket, scoped by caller-supplied WHERE clauses. + + Analyzes test results in the inclusive window ``[start_date, end_date]``. + + Daily buckets are calendar-aligned (``date_trunc('day', ...)``). + + Weekly buckets are rolling 7-day windows ending on ``end_date`` inclusive, earlier + buckets step back in 7-day increments. The oldest bucket is dropped if it would be + incomplete — i.e. its 7-day window is not fully inside ``start_date``. + + Monitor suites and dismissed/inactive results are always filtered out. + """ + # Naive midnight — matches the naive TIMESTAMP column so Postgres compares in the session's TZ + # without any implicit UTC-based conversion. + upper_bound = datetime.combine(end_date + timedelta(days=1), datetime.min.time()) + + # Always query at daily granularity; aggregate in Python. + day_expr = func.date_trunc("day", cls.test_time).label("day") + query = ( + select( + day_expr, + cls.status.label("status"), + func.count().label("n"), + ) + .join(TestSuite, cls.test_suite_id == TestSuite.id) + .where( + TestSuite.is_monitor.isnot(True), + cls.test_time >= start_date, + cls.test_time < upper_bound, + func.coalesce(cls.disposition, "Confirmed") == "Confirmed", + *clauses, + ) + .group_by(day_expr, cls.status) + .order_by(day_expr) + ) + + # Normalize the SQL-returned timestamp (date_trunc returns a timestamp in Postgres) to a date. + daily: dict[date, dict[str, int]] = {} + for row in get_current_session().execute(query): + day_date = row.day.date() if isinstance(row.day, datetime) else row.day + slot = daily.setdefault(day_date, {"failed": 0, "warning": 0, "total": 0}) + slot["total"] += row.n + if row.status == TestResultStatus.Failed: + slot["failed"] += row.n + elif row.status == TestResultStatus.Warning: + slot["warning"] += row.n + + if bucket == BucketInterval.DAY: + buckets = daily + else: + buckets = {} + for day_date, counts in daily.items(): + days_ago = (end_date - day_date).days + weeks_ago = days_ago // 7 + bucket_end = end_date - timedelta(days=weeks_ago * 7) + bucket_start = bucket_end - timedelta(days=6) + if bucket_start < start_date: + continue # drop incomplete oldest bucket + slot = buckets.setdefault(bucket_start, {"failed": 0, "warning": 0, "total": 0}) + for k, v in counts.items(): + slot[k] += v + + return [ + TrendBucket( + bucket=bucket_date, + failed_ct=counts["failed"], + warning_ct=counts["warning"], + total_ct=counts["total"], + ) + for bucket_date, counts in sorted(buckets.items()) + ] + + @classmethod + def diff_with_details(cls, test_run_id_a: UUID, test_run_id_b: UUID) -> RunDiff: + """Compare two runs by ``test_definition_id`` and return categorized diff rows.""" + + def _fetch(run_id: UUID) -> dict[UUID, dict]: + query = ( + select( + cls.test_definition_id.label("test_definition_id"), + cls.test_type.label("test_type"), + TestType.test_name_short.label("test_name_short"), + cls.table_name.label("table_name"), + cls.column_names.label("column_names"), + cls.status.label("status"), + cls.result_measure.label("result_measure"), + ) + .outerjoin(TestType, cls.test_type == TestType.test_type) + .where( + cls.test_run_id == run_id, + func.coalesce(cls.disposition, "Confirmed") == "Confirmed", + ) + ) + return { + row.test_definition_id: { + "test_type": row.test_type, + "test_name_short": row.test_name_short, + "table_name": row.table_name, + "column_names": row.column_names, + "status": row.status, + "measure": row.result_measure, + } + for row in get_current_session().execute(query) + } + + def _row(tid: UUID, info_a: dict | None, info_b: dict | None) -> DiffRow: + base = info_b or info_a # prefer B for display fields (test_type, table, column names) + return DiffRow( + test_definition_id=tid, + test_type=base["test_type"], + test_name_short=base["test_name_short"], + table_name=base["table_name"], + column_names=base["column_names"], + status_a=info_a["status"] if info_a else None, + status_b=info_b["status"] if info_b else None, + measure_a=info_a["measure"] if info_a else None, + measure_b=info_b["measure"] if info_b else None, + ) + + results_a = _fetch(test_run_id_a) + results_b = _fetch(test_run_id_b) + failing = {TestResultStatus.Failed, TestResultStatus.Warning} + diff = RunDiff(total_a=len(results_a), total_b=len(results_b)) + + for tid in results_a.keys() & results_b.keys(): + info_a, info_b = results_a[tid], results_b[tid] + row = _row(tid, info_a, info_b) + if info_a["status"] == TestResultStatus.Passed and info_b["status"] in failing: + diff.regressions.append(row) + elif info_a["status"] in failing and info_b["status"] == TestResultStatus.Passed: + diff.improvements.append(row) + elif info_a["status"] in failing and info_b["status"] in failing: + diff.persistent_failures.append(row) + + for tid in results_b.keys() - results_a.keys(): + diff.new_tests.append(_row(tid, None, results_b[tid])) + + for tid in results_a.keys() - results_b.keys(): + diff.removed_tests.append(_row(tid, results_a[tid], None)) + + return diff diff --git a/testgen/mcp/server.py b/testgen/mcp/server.py index fe4f6594..33d76b12 100644 --- a/testgen/mcp/server.py +++ b/testgen/mcp/server.py @@ -92,7 +92,14 @@ def build_mcp_app( from testgen.mcp.tools.reference import get_test_type, glossary_resource, test_types_resource from testgen.mcp.tools.source_data import get_source_data, get_source_data_query from testgen.mcp.tools.test_definitions import get_test, list_test_notes, list_test_types, list_tests - from testgen.mcp.tools.test_results import get_failure_summary, get_test_result_history, get_test_results + from testgen.mcp.tools.test_results import ( + get_failure_summary, + get_failure_trend, + get_test_result_history, + get_test_results, + get_test_run_diff, + search_test_results, + ) from testgen.mcp.tools.test_runs import get_recent_test_runs if server_url is None: @@ -127,6 +134,9 @@ def safe_prompt(fn): safe_tool(get_test_results) safe_tool(get_test_result_history) safe_tool(get_failure_summary) + safe_tool(search_test_results) + safe_tool(get_failure_trend) + safe_tool(get_test_run_diff) safe_tool(get_test_type) safe_tool(get_source_data) safe_tool(get_source_data_query) diff --git a/testgen/mcp/tools/common.py b/testgen/mcp/tools/common.py index 823ea7d8..fee46856 100644 --- a/testgen/mcp/tools/common.py +++ b/testgen/mcp/tools/common.py @@ -1,5 +1,7 @@ +from datetime import date from uuid import UUID +from testgen.common.date_service import parse_since from testgen.common.models.test_definition import TestType from testgen.common.models.test_result import TestResultStatus from testgen.mcp.exceptions import MCPUserError @@ -20,6 +22,13 @@ def parse_result_status(value: str) -> TestResultStatus: raise MCPUserError(f"Invalid status `{value}`. Valid values: {valid}") from err +def parse_since_arg(value: str, label: str = "since", *, today: date | None = None) -> date: + try: + return parse_since(value, today=today) + except ValueError as err: + raise MCPUserError(f"Invalid `{label}`: {err}") from err + + def resolve_test_type(short_name: str) -> str: """Resolve a test type short name to its internal code.""" matches = TestType.select_where(TestType.test_name_short == short_name) diff --git a/testgen/mcp/tools/test_results.py b/testgen/mcp/tools/test_results.py index b76a98de..1f19b499 100644 --- a/testgen/mcp/tools/test_results.py +++ b/testgen/mcp/tools/test_results.py @@ -1,12 +1,24 @@ +from datetime import UTC, datetime, timedelta + from testgen.common.models import with_database_session from testgen.common.models.test_definition import TestType -from testgen.common.models.test_result import TestResult +from testgen.common.models.test_result import BucketInterval, TestResult, TestResultStatus from testgen.common.models.test_run import TestRun +from testgen.common.models.test_suite import TestSuite from testgen.mcp.exceptions import MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission -from testgen.mcp.tools.common import parse_result_status, parse_uuid, resolve_test_type +from testgen.mcp.tools.common import ( + format_page_footer, + format_page_info, + parse_result_status, + parse_since_arg, + parse_uuid, + resolve_test_type, +) from testgen.mcp.tools.markdown import MdDoc +_DEFAULT_SEARCH_STATUSES = [TestResultStatus.Failed, TestResultStatus.Warning] + @with_database_session @mcp_permission("view") @@ -89,35 +101,82 @@ def get_test_results( @with_database_session @mcp_permission("view") -def get_failure_summary(job_execution_id: str, group_by: str = "test_type") -> str: - """Get a summary of test failures (Failed and Warning) grouped by test type, table name, or column. +def get_failure_summary( + *, + project_code: str | None = None, + test_suite_id: str | None = None, + job_execution_id: str | None = None, + since: str | None = None, + group_by: str = "test_type", +) -> str: + """Summarize test failures (Failed and Warning) grouped by test type, table, or column. + + Supply a ``job_execution_id`` for a single-run summary, or combine ``project_code``, + ``test_suite_id``, and ``since`` to aggregate across multiple runs. At least one + scope parameter is required. Args: - job_execution_id: The UUID of the job execution for the test run. + project_code: Scope to a project the caller can view. Ignored if ``job_execution_id`` is set. + test_suite_id: UUID of a test suite to scope the aggregation to. + job_execution_id: UUID of a job execution to scope to a single run. + since: Include runs since this point in time — e.g. '7 days', '2 weeks', '2026-04-01'. group_by: Group failures by 'test_type', 'table', or 'column' (default: 'test_type'). """ - job_uuid = parse_uuid(job_execution_id, "job_execution_id") - test_run = TestRun.get_by_id_or_job(job_uuid) - if not test_run: - raise MCPUserError(f"No test run found for job execution `{job_execution_id}`.") - perms = get_project_permissions() - # Map public param names to model field names + if not any((job_execution_id, project_code, test_suite_id, since)): + raise MCPUserError( + "Provide at least one of 'job_execution_id', 'project_code', 'test_suite_id', or 'since' to scope the summary." + ) + model_group_map = {"table": "table_name", "column": "column_names"} model_group_by = model_group_map.get(group_by, group_by) - failures = TestResult.select_failures(test_run_id=test_run.id, group_by=model_group_by, project_codes=perms.allowed_codes) + + scope_label: str + test_run_id = None + test_suite_uuid = parse_uuid(test_suite_id, "test_suite_id") if test_suite_id else None + since_date = parse_since_arg(since) if since else None + + if job_execution_id: + job_uuid = parse_uuid(job_execution_id, "job_execution_id") + test_run = TestRun.get_by_id_or_job(job_uuid) + if not test_run: + raise MCPUserError(f"No test run found for job execution `{job_execution_id}`.") + test_run_id = test_run.id + scope_label = f"run `{job_execution_id}`" + project_codes = perms.allowed_codes + else: + if project_code: + perms.verify_access(project_code, not_found=f"Project `{project_code}` not found or not accessible.") + project_codes = [project_code] + else: + project_codes = perms.allowed_codes + scope_parts = [] + if project_code: + scope_parts.append(f"project `{project_code}`") + if test_suite_id: + scope_parts.append(f"suite `{test_suite_id}`") + if since: + scope_parts.append(f"since {since}") + scope_label = ", ".join(scope_parts) or "accessible projects" + + failures = TestResult.select_failures( + test_run_id=test_run_id, + group_by=model_group_by, + project_codes=project_codes, + test_suite_id=test_suite_uuid, + since=since_date, + ) if not failures: - return f"No confirmed failures found for run `{job_execution_id}`." + return f"No confirmed failures found for {scope_label}." total = sum(row[-1] for row in failures) - if group_by == "test_type": type_names = {tt.test_type: tt.test_name_short for tt in TestType.select_where(TestType.active == "Y")} doc = MdDoc() - doc.heading(1, f"Failure Summary for run `{job_execution_id}`") + doc.heading(1, f"Failure Summary — {scope_label}") doc.text(f"**Total confirmed failures (Failed + Warning):** {total}") if group_by == "test_type": @@ -197,3 +256,299 @@ def get_test_result_history( ) return doc.render() + + +@with_database_session +@mcp_permission("view") +def search_test_results( + *, + project_code: str | None = None, + test_suite_id: str | None = None, + table_group_id: str | None = None, + table_name: str | None = None, + column_name: str | None = None, + test_type: str | None = None, + status: list[str] | None = None, + since: str | None = None, + limit: int = 50, + page: int = 1, +) -> str: + """Search test results across multiple runs with flexible filters. + + To drill into a single run, use ``get_test_results``. For a single test's history, use + ``get_test_result_history``. + + Args: + project_code: Scope to a project the caller can view. + test_suite_id: UUID of a test suite to scope to. + table_group_id: UUID of a table group to scope to. + table_name: Filter by table name. + column_name: Filter by column name. + test_type: Filter by test type short name (e.g. 'Pattern Match'). + status: Filter by result statuses (defaults to ['Failed', 'Warning']). + since: Include results since this point — e.g. '7 days', '2 weeks', '2026-04-01'. + limit: Maximum results per page (default 50). + page: Page number, starting from 1 (default 1). + """ + perms = get_project_permissions() + if project_code: + perms.verify_access(project_code, not_found=f"Project `{project_code}` not found or not accessible.") + project_codes = [project_code] + else: + project_codes = perms.allowed_codes + + suite_uuid = parse_uuid(test_suite_id, "test_suite_id") if test_suite_id else None + table_group_uuid = parse_uuid(table_group_id, "table_group_id") if table_group_id else None + since_date = parse_since_arg(since) if since else None + type_code = resolve_test_type(test_type) if test_type else None + + # Treat empty list the same as None — an empty IN (…) would silently match nothing. + if not status: + status_enums = list(_DEFAULT_SEARCH_STATUSES) + else: + status_enums = [parse_result_status(s) for s in status] + + clauses = [ + TestSuite.project_code.in_(project_codes), + TestResult.status.in_(status_enums), + ] + if suite_uuid is not None: + clauses.append(TestResult.test_suite_id == suite_uuid) + if table_group_uuid is not None: + clauses.append(TestResult.table_groups_id == table_group_uuid) + if table_name: + clauses.append(TestResult.table_name == table_name) + if column_name: + clauses.append(TestResult.column_names == column_name) + if type_code: + clauses.append(TestResult.test_type == type_code) + if since_date is not None: + clauses.append(TestResult.test_time >= since_date) + + rows, total = TestResult.search_results(*clauses, page=page, limit=limit) + + if not rows: + return "No test results match the supplied filters." + + doc = MdDoc() + doc.heading(1, "Test Result Search") + doc.text(format_page_info(total, page, limit)) + + for r in rows: + display_name = r.test_name_short or r.test_type + status_str = r.status.value if r.status else "Unknown" + if r.column_names: + doc.heading(2, f"[{status_str}] {display_name} on `{r.column_names}` in `{r.table_name}`") + else: + doc.heading(2, f"[{status_str}] {display_name} on `{r.table_name}`") + doc.field("Run", r.job_execution_id or r.test_run_id, code=True) + doc.field("Run time", r.test_time) + doc.field("Test suite", r.test_suite_name) + doc.field("Test definition", r.test_definition_id, code=True) + if r.result_measure is not None: + doc.field("Measured value", r.result_measure) + if r.threshold_value is not None: + doc.field("Threshold", r.threshold_value) + if r.result_message: + doc.field("Message", r.result_message) + + footer = format_page_footer(total, page, limit) + if footer: + doc.text(footer) + + return doc.render() + + +@with_database_session +@mcp_permission("view") +def get_failure_trend( + *, + project_code: str | None = None, + test_suite_id: str | None = None, + table_group_id: str | None = None, + table_name: str | None = None, + test_type: str | None = None, + since: str = "30 days", + bucket: BucketInterval = BucketInterval.DAY, + exclude_today: bool = True, +) -> str: + """Time-series of test result counts by time bucket — use this to see whether failures are trending up or down. + + Args: + project_code: Scope to a project the caller can view. + test_suite_id: UUID of a test suite to scope to. + table_group_id: UUID of a table group to scope to. + table_name: Filter by table name. + test_type: Filter by test type short name. + since: Include runs since this point — e.g. '30 days', '2 weeks', '2026-04-01' (default '30 days'). + bucket: Time bucket size — 'day' or 'week' (default 'day'). + exclude_today: If True (default), buckets end yesterday; set False to also compute today's incomplete data. + """ + try: + bucket = BucketInterval(bucket) + except ValueError as err: + valid = ", ".join(v.value for v in BucketInterval) + raise MCPUserError(f"Invalid `bucket`: `{bucket}`. Valid values: {valid}") from err + + perms = get_project_permissions() + if project_code: + perms.verify_access(project_code, not_found=f"Project `{project_code}` not found or not accessible.") + project_codes = [project_code] + else: + project_codes = perms.allowed_codes + + anchor_today = datetime.now(UTC).date() + if exclude_today: + anchor_today -= timedelta(days=1) + + suite_uuid = parse_uuid(test_suite_id, "test_suite_id") if test_suite_id else None + table_group_uuid = parse_uuid(table_group_id, "table_group_id") if table_group_id else None + since_date = parse_since_arg(since, today=anchor_today) + type_code = resolve_test_type(test_type) if test_type else None + + # Build WHERE clauses at the tool layer. Model stays agnostic to specific filter concepts. + clauses = [TestSuite.project_code.in_(project_codes)] + if suite_uuid is not None: + clauses.append(TestResult.test_suite_id == suite_uuid) + if table_group_uuid is not None: + clauses.append(TestResult.table_groups_id == table_group_uuid) + if table_name: + clauses.append(TestResult.table_name == table_name) + if type_code: + clauses.append(TestResult.test_type == type_code) + + buckets = TestResult.failure_trend( + *clauses, + start_date=since_date, + end_date=anchor_today, + bucket=bucket, + ) + + if not buckets: + return f"No test results found in the selected window (since {since})." + + doc = MdDoc() + doc.heading(1, f"Failure Trend — by {bucket}") + doc.text(f"Window: since {since}. Failure rate = (Failed + Warning) / Total.") + doc.table( + headers=["Bucket", "Failed", "Warning", "Total", "Failure rate"], + rows=[ + [b.bucket, b.failed_ct, b.warning_ct, b.total_ct, f"{b.failure_rate:.1%}"] + for b in buckets + ], + ) + + # For weekly buckets, surface the partial-window gap if we dropped data at the oldest end. + if bucket == "week": + first_bucket_date = buckets[0].bucket + if first_bucket_date > since_date: + dropped_end = first_bucket_date - timedelta(days=1) + doc.text( + f"_Note: the partial week from {since_date} to {dropped_end} was excluded " + f"because it does not form a complete 7-day bucket._" + ) + + # Flag the most recent bucket as "in progress" if it contains today — its counts may grow. + today = datetime.now(UTC).date() + last_bucket_start = buckets[-1].bucket + last_bucket_end = last_bucket_start + timedelta(days=(0 if bucket == "day" else 6)) + if last_bucket_start <= today <= last_bucket_end: + doc.text( + f"_Note: the most recent bucket includes today ({today}) and is still in progress; " + f"its counts may grow before the bucket closes._" + ) + + return doc.render() + + +@with_database_session +@mcp_permission("view") +def get_test_run_diff(job_execution_id_a: str, job_execution_id_b: str) -> str: + """Compare two test runs and report regressions, improvements, persistent failures, and added/removed tests. + + Args: + job_execution_id_a: UUID of the older (baseline) run. + job_execution_id_b: UUID of the newer run to compare against the baseline. + """ + uuid_a = parse_uuid(job_execution_id_a, "job_execution_id_a") + uuid_b = parse_uuid(job_execution_id_b, "job_execution_id_b") + + run_a = TestRun.get_by_id_or_job(uuid_a) + run_b = TestRun.get_by_id_or_job(uuid_b) + + # Permission check first — unify "not found" and "inaccessible" (also covers monitor suites, + # which are hidden from this tool the same way they're hidden from the inventory tools). + perms = get_project_permissions() + suite_ids = [r.test_suite_id for r in (run_a, run_b) if r is not None] + suites_by_id: dict = {} + if suite_ids: + suites_by_id = { + s.id: s for s in TestSuite.select_where(TestSuite.id.in_(suite_ids)) + } + + def _accessible(run) -> bool: + if run is None: + return False + suite = suites_by_id.get(run.test_suite_id) + if suite is None or suite.is_monitor: + return False + return perms.has_access(suite.project_code) + + if not _accessible(run_a): + raise MCPUserError(f"Run `{job_execution_id_a}` not found or not accessible.") + if not _accessible(run_b): + raise MCPUserError(f"Run `{job_execution_id_b}` not found or not accessible.") + + # Both runs confirmed accessible — safe to reveal suite IDs in the compatibility message. + if run_a.test_suite_id != run_b.test_suite_id: + raise MCPUserError( + "Both runs must belong to the same test suite to be comparable. " + f"Run A is in suite `{run_a.test_suite_id}`, run B is in suite `{run_b.test_suite_id}`. " + "Use `get_recent_test_runs(test_suite=...)` to pick two runs of the same suite." + ) + + diff = TestResult.diff_with_details(run_a.id, run_b.id) + + doc = MdDoc() + doc.heading(1, "Test Run Diff") + doc.field("Run A", job_execution_id_a, code=True) + doc.field("Run B", job_execution_id_b, code=True) + doc.table( + headers=["Category", "Count"], + rows=[ + ["Regressions (A passed → B failed/warning)", len(diff.regressions)], + ["Improvements (A failed/warning → B passed)", len(diff.improvements)], + ["Persistent failures", len(diff.persistent_failures)], + ["New tests (only in B)", len(diff.new_tests)], + ["Removed tests (only in A)", len(diff.removed_tests)], + ["Total in A", diff.total_a], + ["Total in B", diff.total_b], + ], + ) + + def _section(title: str, rows: list) -> None: + if not rows: + return + doc.heading(2, title) + doc.table( + headers=["Test Type", "Table", "Column", "A → B", "Measure A", "Measure B"], + rows=[ + [ + row.test_name_short or row.test_type, + row.table_name, + row.column_names, + f"{row.status_a.value if row.status_a else '—'} → {row.status_b.value if row.status_b else '—'}", + row.measure_a, + row.measure_b, + ] + for row in rows + ], + ) + + _section("Regressions", diff.regressions) + _section("Improvements", diff.improvements) + _section("Persistent Failures", diff.persistent_failures) + _section("New Tests", diff.new_tests) + _section("Removed Tests", diff.removed_tests) + + return doc.render() diff --git a/tests/unit/common/test_date_service.py b/tests/unit/common/test_date_service.py index a986f323..d9f8af96 100644 --- a/tests/unit/common/test_date_service.py +++ b/tests/unit/common/test_date_service.py @@ -1,9 +1,14 @@ -from datetime import UTC, datetime +from datetime import UTC, date, datetime from unittest.mock import patch import pytest -from testgen.common.date_service import as_iso_timestamp, get_now_as_iso_timestamp, parse_fuzzy_date +from testgen.common.date_service import ( + as_iso_timestamp, + get_now_as_iso_timestamp, + parse_fuzzy_date, + parse_since, +) pytestmark = pytest.mark.unit @@ -54,3 +59,71 @@ def test_returns_value_unchanged_for_other_types(self): def test_returns_none_for_none(self): assert parse_fuzzy_date(None) is None + + +@pytest.mark.parametrize( + "expression, expected_date", + [ + # N calendar days ending today inclusive → start = today - (N-1). + # Today in the test is 2026-04-22 (Wed). + ("1 day", date(2026, 4, 22)), + ("7 days", date(2026, 4, 16)), + ("7d", date(2026, 4, 16)), + ("14 days", date(2026, 4, 9)), + # N*7 calendar days ending today inclusive. + ("1 week", date(2026, 4, 16)), + ("2 weeks", date(2026, 4, 9)), + ("2w", date(2026, 4, 9)), + # Whitespace tolerated. + (" 5 days ", date(2026, 4, 18)), + ], +) +def test_parse_since_fixed_duration_units(expression, expected_date): + """Day/week expressions are calendar-day-aligned and return a plain date.""" + result = parse_since(expression, today=date(2026, 4, 22)) + assert result == expected_date + assert isinstance(result, date) and not isinstance(result, datetime) + + +@pytest.mark.parametrize( + "expression, today, expected_date", + [ + # Same day-of-month in target: 04/22 - 2 months → 02/22 + ("2 months", date(2026, 4, 22), date(2026, 2, 22)), + # Single-month shorthand + ("1 month", date(2026, 4, 22), date(2026, 3, 22)), + # Clamp: 03/31 - 1 month → 02/28 (Feb has no 31st) + ("1 month", date(2026, 3, 31), date(2026, 2, 28)), + # Year underflow + ("1 month", date(2026, 1, 15), date(2025, 12, 15)), + # Multi-year underflow + ("14 months", date(2026, 1, 15), date(2024, 11, 15)), + # "mo" shorthand + ("3mo", date(2026, 4, 22), date(2026, 1, 22)), + ], +) +def test_parse_since_calendar_months(expression, today, expected_date): + assert parse_since(expression, today=today) == expected_date + + +def test_parse_since_iso_date(): + assert parse_since("2026-04-01") == date(2026, 4, 1) + + +@pytest.mark.parametrize( + "bad", + [ + "", + " ", + "bogus", + "days", + "3 fortnights", + "yesterday", + # Time-of-day is not accepted — use ISO date only. + "2026-04-01T12:30:00", + "2026-04-01T12:30:00Z", + ], +) +def test_parse_since_rejects_invalid(bad): + with pytest.raises(ValueError): + parse_since(bad) diff --git a/tests/unit/mcp/test_tools_test_results.py b/tests/unit/mcp/test_tools_test_results.py index d1a0c476..c50c5b01 100644 --- a/tests/unit/mcp/test_tools_test_results.py +++ b/tests/unit/mcp/test_tools_test_results.py @@ -1,3 +1,4 @@ +from datetime import date from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -197,7 +198,7 @@ def test_get_failure_summary_by_test_type(mock_result, mock_tt_cls, mock_test_ru from testgen.mcp.tools.test_results import get_failure_summary - result = get_failure_summary(str(uuid4())) + result = get_failure_summary(job_execution_id=str(uuid4())) assert "Failed + Warning" in result assert "8" in result @@ -217,7 +218,7 @@ def test_get_failure_summary_empty(mock_result, mock_test_run_cls, db_session_mo from testgen.mcp.tools.test_results import get_failure_summary - result = get_failure_summary(str(uuid4())) + result = get_failure_summary(job_execution_id=str(uuid4())) assert "No confirmed failures" in result @@ -230,7 +231,7 @@ def test_get_failure_summary_by_table(mock_result, mock_test_run_cls, db_session from testgen.mcp.tools.test_results import get_failure_summary - result = get_failure_summary(str(uuid4()), group_by="table") + result = get_failure_summary(job_execution_id=str(uuid4()), group_by="table") assert "Table Name" in result assert "orders" in result @@ -245,7 +246,7 @@ def test_get_failure_summary_by_column(mock_result, mock_test_run_cls, db_sessio from testgen.mcp.tools.test_results import get_failure_summary - result = get_failure_summary(str(uuid4()), group_by="column") + result = get_failure_summary(job_execution_id=str(uuid4()), group_by="column") assert "Column" in result assert "`total_value` in `orders`" in result @@ -257,7 +258,7 @@ def test_get_failure_summary_invalid_uuid(db_session_mock): from testgen.mcp.tools.test_results import get_failure_summary with pytest.raises(MCPUserError, match="not a valid UUID"): - get_failure_summary("bad-uuid") + get_failure_summary(job_execution_id="bad-uuid") @patch("testgen.mcp.tools.test_results.TestRun") @@ -267,7 +268,7 @@ def test_get_failure_summary_run_not_found(mock_test_run_cls, db_session_mock): from testgen.mcp.tools.test_results import get_failure_summary with pytest.raises(MCPUserError, match="No test run found"): - get_failure_summary(str(uuid4())) + get_failure_summary(job_execution_id=str(uuid4())) @patch("testgen.mcp.tools.test_results.TestRun") @@ -285,7 +286,7 @@ def test_get_failure_summary_passes_project_codes( from testgen.mcp.tools.test_results import get_failure_summary - get_failure_summary(str(uuid4())) + get_failure_summary(job_execution_id=str(uuid4())) call_kwargs = mock_result.select_failures.call_args.kwargs assert call_kwargs["project_codes"] == ["proj_a"] @@ -366,3 +367,382 @@ def test_get_test_result_history_passes_project_codes( call_kwargs = mock_result.select_history.call_args.kwargs assert call_kwargs["project_codes"] == ["proj_a"] + + +# ---------------------------------------------------------------------- +# get_failure_summary — cross-run additions +# ---------------------------------------------------------------------- + + +def test_get_failure_summary_requires_some_scope(db_session_mock): + from testgen.mcp.tools.test_results import get_failure_summary + + with pytest.raises(MCPUserError, match="at least one of"): + get_failure_summary() + + +@patch("testgen.mcp.tools.test_results.TestResult") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_get_failure_summary_cross_run_by_project(mock_compute, mock_result, db_session_mock): + mock_compute.return_value = ProjectPermissions( + memberships={"proj_a": "role_a"}, + permission="view", + ) + mock_result.select_failures.return_value = [] + + from testgen.mcp.tools.test_results import get_failure_summary + + get_failure_summary(project_code="proj_a", since="7 days") + + call_kwargs = mock_result.select_failures.call_args.kwargs + assert call_kwargs["project_codes"] == ["proj_a"] + assert call_kwargs["test_run_id"] is None + assert call_kwargs["since"] is not None + + +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_get_failure_summary_rejects_inaccessible_project(mock_compute, db_session_mock): + mock_compute.return_value = ProjectPermissions( + memberships={"proj_a": "role_a"}, + permission="view", + ) + + from testgen.mcp.tools.test_results import get_failure_summary + + with pytest.raises(MCPUserError, match="not found or not accessible"): + get_failure_summary(project_code="proj_b") + + +# ---------------------------------------------------------------------- +# search_test_results +# ---------------------------------------------------------------------- + + +def _mock_search_row(**overrides): + row = MagicMock() + row.test_definition_id = uuid4() + row.test_run_id = uuid4() + row.job_execution_id = uuid4() + row.test_time = "2026-04-15T10:00:00" + row.test_suite_id = uuid4() + row.test_suite_name = "Sales Suite" + row.test_type = "Pattern_Match" + row.test_name_short = "Pattern Match" + row.table_name = "orders" + row.column_names = "customer_id" + row.status = TestResultStatus.Failed + row.result_measure = "12" + row.threshold_value = "0" + row.result_message = "Bad pattern" + for k, v in overrides.items(): + setattr(row, k, v) + return row + + +@patch("testgen.mcp.tools.test_results.TestResult.search_results") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_search_test_results_happy_path(mock_compute, mock_search_results, db_session_mock): + mock_compute.return_value = ProjectPermissions( + memberships={"proj_a": "role_a"}, + permission="view", + ) + mock_search_results.return_value = ([_mock_search_row()], 1) + + from testgen.mcp.tools.test_results import search_test_results + + out = search_test_results(project_code="proj_a", since="7 days") + + assert "Pattern Match" in out + assert "Sales Suite" in out + assert "on `customer_id` in `orders`" in out + # Defaults to Failed + Warning — result_status clause present in *args. + args_repr = " ".join(str(c) for c in mock_search_results.call_args.args).lower() + assert "result_status in" in args_repr + # project_codes scoping present + assert "project_code in" in args_repr + + +@patch("testgen.mcp.tools.test_results.TestResult.search_results") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_search_test_results_empty(mock_compute, mock_search_results, db_session_mock): + mock_compute.return_value = ProjectPermissions( + memberships={"proj_a": "role_a"}, + permission="view", + ) + mock_search_results.return_value = ([], 0) + + from testgen.mcp.tools.test_results import search_test_results + + out = search_test_results() + + assert "No test results match" in out + + +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_search_test_results_rejects_unknown_project(mock_compute, db_session_mock): + mock_compute.return_value = ProjectPermissions( + memberships={"proj_a": "role_a"}, + permission="view", + ) + + from testgen.mcp.tools.test_results import search_test_results + + with pytest.raises(MCPUserError, match="not found or not accessible"): + search_test_results(project_code="proj_b") + + +@patch("testgen.mcp.tools.test_results.TestResult.search_results") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_search_test_results_paginates(mock_compute, mock_search_results, db_session_mock): + mock_compute.return_value = ProjectPermissions( + memberships={"proj_a": "role_a"}, + permission="view", + ) + # total > limit → footer expected + rows = [_mock_search_row() for _ in range(2)] + mock_search_results.return_value = (rows, 100) + + from testgen.mcp.tools.test_results import search_test_results + + out = search_test_results(limit=2, page=1) + assert "Showing 1" in out and "2 of 100" in out + assert "Use `page=2` for more" in out + + +# ---------------------------------------------------------------------- +# get_failure_trend +# ---------------------------------------------------------------------- + + +@patch("testgen.mcp.tools.test_results.TestResult.failure_trend") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_get_failure_trend_happy_path(mock_compute, mock_failure_trend, db_session_mock): + mock_compute.return_value = ProjectPermissions( + memberships={"proj_a": "role_a"}, + permission="view", + ) + b1 = MagicMock(failed_ct=3, warning_ct=1, total_ct=10) + b1.bucket = date(2026, 4, 1) + b1.failure_rate = 0.4 + mock_failure_trend.return_value = [b1] + + from testgen.mcp.tools.test_results import get_failure_trend + + out = get_failure_trend(since="30 days") + + assert "Failure Trend" in out + assert "40.0%" in out + assert mock_failure_trend.call_args.kwargs["bucket"] == "day" + # project_codes is now a caller-built clause, not a kwarg. + clauses = mock_failure_trend.call_args.args + assert any("project_code" in str(c) for c in clauses) + + +@patch("testgen.mcp.tools.test_results.TestResult.failure_trend") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_get_failure_trend_empty(mock_compute, mock_failure_trend, db_session_mock): + mock_compute.return_value = ProjectPermissions( + memberships={"proj_a": "role_a"}, + permission="view", + ) + mock_failure_trend.return_value = [] + + from testgen.mcp.tools.test_results import get_failure_trend + + out = get_failure_trend(since="30 days") + assert "No test results found" in out + + +def test_get_failure_trend_invalid_bucket(db_session_mock): + from testgen.mcp.tools.test_results import get_failure_trend + + with pytest.raises(MCPUserError, match="Invalid"): + get_failure_trend(bucket="month") + + +@patch("testgen.mcp.tools.test_results.TestResult.failure_trend") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_get_failure_trend_exclude_today_shifts_end_date(mock_compute, mock_failure_trend, db_session_mock): + """exclude_today=True (default) passes yesterday as end_date; False passes today.""" + from datetime import UTC, datetime, timedelta + + mock_compute.return_value = ProjectPermissions( + memberships={"proj_a": "role_a"}, permission="view", + ) + mock_failure_trend.return_value = [] + + from testgen.mcp.tools.test_results import get_failure_trend + + real_today = datetime.now(UTC).date() + + # Default: exclude_today=True → end_date is yesterday. + get_failure_trend(since="14 days") + assert mock_failure_trend.call_args.kwargs["end_date"] == real_today - timedelta(days=1) + + # Explicit exclude_today=False → end_date is today. + get_failure_trend(since="14 days", exclude_today=False) + assert mock_failure_trend.call_args.kwargs["end_date"] == real_today + + +# ---------------------------------------------------------------------- +# get_test_run_diff +# ---------------------------------------------------------------------- + + +def _mock_diff_row(status_a, status_b, **overrides): + row = MagicMock() + row.test_definition_id = uuid4() + row.test_type = "Pattern_Match" + row.test_name_short = "Pattern Match" + row.table_name = "orders" + row.column_names = "customer_id" + row.status_a = status_a + row.status_b = status_b + row.measure_a = "5" + row.measure_b = "12" + for k, v in overrides.items(): + setattr(row, k, v) + return row + + +@patch("testgen.mcp.tools.test_results.TestSuite") +@patch("testgen.mcp.tools.test_results.TestResult") +@patch("testgen.mcp.tools.test_results.TestRun") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_get_test_run_diff_happy_path( + mock_compute, mock_test_run_cls, mock_result, mock_test_suite_cls, db_session_mock, +): + mock_compute.return_value = ProjectPermissions( + memberships={"proj_a": "role_a"}, + permission="view", + ) + suite_id = uuid4() + run_a = MagicMock(id=uuid4(), test_suite_id=suite_id) + run_b = MagicMock(id=uuid4(), test_suite_id=suite_id) + mock_test_run_cls.get_by_id_or_job.side_effect = [run_a, run_b] + mock_test_suite_cls.id = MagicMock() # support .in_(...) on attribute mock + mock_test_suite_cls.select_where.return_value = [MagicMock(id=suite_id, project_code="proj_a", is_monitor=False)] + + diff = MagicMock() + diff.total_a = 100 + diff.total_b = 100 + diff.regressions = [_mock_diff_row(TestResultStatus.Passed, TestResultStatus.Failed)] + diff.improvements = [] + diff.persistent_failures = [] + diff.new_tests = [] + diff.removed_tests = [] + mock_result.diff_with_details.return_value = diff + + from testgen.mcp.tools.test_results import get_test_run_diff + + out = get_test_run_diff(str(uuid4()), str(uuid4())) + + assert "Test Run Diff" in out + assert "Regressions" in out + assert "Pattern Match" in out + assert "Passed → Failed" in out + + +@patch("testgen.mcp.tools.test_results.TestSuite") +@patch("testgen.mcp.tools.test_results.TestRun") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_get_test_run_diff_run_not_found( + mock_compute, mock_test_run_cls, mock_test_suite_cls, db_session_mock, +): + """One run missing, other accessible — unified error without leaking which side failed.""" + mock_compute.return_value = ProjectPermissions( + memberships={"proj_a": "role_a"}, + permission="view", + ) + suite_id = uuid4() + mock_test_run_cls.get_by_id_or_job.side_effect = [None, MagicMock(id=uuid4(), test_suite_id=suite_id)] + mock_test_suite_cls.id = MagicMock() + mock_test_suite_cls.select_where.return_value = [MagicMock(id=suite_id, project_code="proj_a", is_monitor=False)] + + from testgen.mcp.tools.test_results import get_test_run_diff + + with pytest.raises(MCPUserError, match="not found or not accessible"): + get_test_run_diff(str(uuid4()), str(uuid4())) + + +@patch("testgen.mcp.tools.test_results.TestSuite") +@patch("testgen.mcp.tools.test_results.TestRun") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_get_test_run_diff_rejects_inaccessible_project( + mock_compute, mock_test_run_cls, mock_test_suite_cls, db_session_mock, +): + """Runs in an inaccessible project produce the same unified message, not a separate one.""" + mock_compute.return_value = ProjectPermissions( + memberships={"proj_a": "role_a"}, + permission="view", + ) + suite_id = uuid4() + run = MagicMock(id=uuid4(), test_suite_id=suite_id) + mock_test_run_cls.get_by_id_or_job.side_effect = [run, run] + mock_test_suite_cls.id = MagicMock() + mock_test_suite_cls.select_where.return_value = [MagicMock(id=suite_id, project_code="proj_forbidden", is_monitor=False)] + + from testgen.mcp.tools.test_results import get_test_run_diff + + with pytest.raises(MCPUserError, match="not found or not accessible"): + get_test_run_diff(str(uuid4()), str(uuid4())) + + +@patch("testgen.mcp.tools.test_results.TestSuite") +@patch("testgen.mcp.tools.test_results.TestRun") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_get_test_run_diff_rejects_different_suites( + mock_compute, mock_test_run_cls, mock_test_suite_cls, db_session_mock, +): + """Both runs accessible but in different suites → suite-mismatch error.""" + mock_compute.return_value = ProjectPermissions( + memberships={"proj_a": "role_a"}, + permission="view", + ) + suite_id_a = uuid4() + suite_id_b = uuid4() + run_a = MagicMock(id=uuid4(), test_suite_id=suite_id_a) + run_b = MagicMock(id=uuid4(), test_suite_id=suite_id_b) + mock_test_run_cls.get_by_id_or_job.side_effect = [run_a, run_b] + mock_test_suite_cls.id = MagicMock() + mock_test_suite_cls.select_where.return_value = [ + MagicMock(id=suite_id_a, project_code="proj_a", is_monitor=False), + MagicMock(id=suite_id_b, project_code="proj_a", is_monitor=False), + ] + + from testgen.mcp.tools.test_results import get_test_run_diff + + with pytest.raises(MCPUserError, match="must belong to the same test suite"): + get_test_run_diff(str(uuid4()), str(uuid4())) + + +def test_get_test_run_diff_invalid_uuid(db_session_mock): + from testgen.mcp.tools.test_results import get_test_run_diff + + with pytest.raises(MCPUserError, match="not a valid UUID"): + get_test_run_diff("bad-uuid", str(uuid4())) + + +@patch("testgen.mcp.tools.test_results.TestSuite") +@patch("testgen.mcp.tools.test_results.TestRun") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_get_test_run_diff_rejects_monitor_suite( + mock_compute, mock_test_run_cls, mock_test_suite_cls, db_session_mock, +): + """Monitor suites are hidden from this tool, same as inaccessible projects — unified message.""" + mock_compute.return_value = ProjectPermissions( + memberships={"proj_a": "role_a"}, + permission="view", + ) + suite_id = uuid4() + run = MagicMock(id=uuid4(), test_suite_id=suite_id) + mock_test_run_cls.get_by_id_or_job.side_effect = [run, run] + mock_test_suite_cls.id = MagicMock() + mock_test_suite_cls.select_where.return_value = [ + MagicMock(id=suite_id, project_code="proj_a", is_monitor=True) + ] + + from testgen.mcp.tools.test_results import get_test_run_diff + + with pytest.raises(MCPUserError, match="not found or not accessible"): + get_test_run_diff(str(uuid4()), str(uuid4())) From 32d3f47c5d35aa59c31ee98868e28531a422b4a5 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Fri, 24 Apr 2026 09:35:56 -0300 Subject: [PATCH 070/123] fix(mcp): exclude monitor suites from existing read tools (TG-1049) Retrofit pre-TG-1027 MCP read tools to filter out is_monitor=True suites, matching the UI convention and the pattern already applied in TG-1027's new cross-run tools. The filter is pushed into the five model methods these tools call: - TestResult.select_results (get_test_results) - TestResult.select_failures (get_failure_summary) - TestResult.select_history (get_test_result_history) - TestDefinition.get_for_project (get_test) - TestDefinition.list_for_suite (list_tests) Monitor suites now behave identically to inaccessible projects across all five tools: the user-facing response is "not found" / "no results" rather than a leak of monitor data. Co-Authored-By: Claude Opus 4.7 (1M context) --- testgen/common/models/test_definition.py | 26 ++++----- testgen/common/models/test_result.py | 35 +++++++++--- tests/unit/mcp/test_model_test_definition.py | 56 ++++++++++++++++++++ tests/unit/mcp/test_model_test_result.py | 45 ++++++++++++++++ 4 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 tests/unit/mcp/test_model_test_definition.py diff --git a/testgen/common/models/test_definition.py b/testgen/common/models/test_definition.py index c74cee29..04c1f95b 100644 --- a/testgen/common/models/test_definition.py +++ b/testgen/common/models/test_definition.py @@ -303,7 +303,7 @@ def get_for_project( ) -> TestDefinitionSummary | None: """Fetch a test definition with project-level access check. - Returns None if the definition doesn't exist or the user lacks access. + Returns None if the definition doesn't exist, belongs to a monitor suite, or the user lacks access. """ from testgen.common.models.test_suite import TestSuite @@ -314,12 +314,11 @@ def get_for_project( query = ( select(*select_columns) .join(TestType, cls.test_type == TestType.test_type) - .where(cls.id == identifier) + .join(TestSuite, cls.test_suite_id == TestSuite.id) + .where(cls.id == identifier, TestSuite.is_monitor.isnot(True)) ) if project_codes is not None: - query = query.join(TestSuite, cls.test_suite_id == TestSuite.id).where( - TestSuite.project_code.in_(project_codes) - ) + query = query.where(TestSuite.project_code.in_(project_codes)) result = get_current_session().execute(query).first() return TestDefinitionSummary(**result) if result else None @@ -362,7 +361,13 @@ def list_for_suite( page: int = 1, limit: int = 50, ) -> tuple[list[TestDefinitionSummary], int]: - """Paginated test definitions for a suite with project-level access check and optional filters.""" + """Paginated test definitions for a suite, with optional filters. + + Monitor suites are always filtered out — callers requesting a monitor suite get an empty page. + Project-level access is enforced when ``project_codes`` is set. + """ + from testgen.common.models.test_suite import TestSuite + select_columns = [ getattr(cls, col, None) or getattr(TestType, col) if isinstance(col, str) else col for col in cls._summary_columns @@ -370,14 +375,11 @@ def list_for_suite( query = ( select(*select_columns) .join(TestType, cls.test_type == TestType.test_type) - .where(cls.test_suite_id == test_suite_id) + .join(TestSuite, cls.test_suite_id == TestSuite.id) + .where(cls.test_suite_id == test_suite_id, TestSuite.is_monitor.isnot(True)) ) if project_codes is not None: - from testgen.common.models.test_suite import TestSuite - - query = query.join(TestSuite, cls.test_suite_id == TestSuite.id).where( - TestSuite.project_code.in_(project_codes) - ) + query = query.where(TestSuite.project_code.in_(project_codes)) if table_name: query = query.where(cls.table_name == table_name) if test_type: diff --git a/testgen/common/models/test_result.py b/testgen/common/models/test_result.py index 7e69d05d..dd036f98 100644 --- a/testgen/common/models/test_result.py +++ b/testgen/common/models/test_result.py @@ -147,6 +147,11 @@ def select_results( limit: int = 50, offset: int = 0, ) -> list[Self]: + """Paginated results for a single run, with optional status/table/type filters. + + Monitor suites and dismissed/inactive results are always filtered out. + Project-level access is enforced when ``project_codes`` is set. + """ clauses = [ cls.test_run_id == test_run_id, func.coalesce(cls.disposition, "Confirmed") == "Confirmed", @@ -157,11 +162,13 @@ def select_results( clauses.append(cls.table_name == table_name) if test_type: clauses.append(cls.test_type == test_type) - query = select(cls).where(*clauses) + query = ( + select(cls) + .join(TestSuite, cls.test_suite_id == TestSuite.id) + .where(*clauses, TestSuite.is_monitor.isnot(True)) + ) if project_codes is not None: - query = query.join(TestSuite, cls.test_suite_id == TestSuite.id).where( - TestSuite.project_code.in_(project_codes) - ) + query = query.where(TestSuite.project_code.in_(project_codes)) query = query.order_by(cls.status, cls.table_name, cls.column_names).offset(offset).limit(limit) return get_current_session().scalars(query).all() @@ -175,6 +182,11 @@ def select_failures( since: date | None = None, group_by: str = "test_type", ) -> list[tuple]: + """Failed/Warning counts scoped by run, suite, or date, grouped by test_type, table, or column. + + Monitor suites and dismissed/inactive results are always filtered out. + Project-level access is enforced when ``project_codes`` is set. + """ allowed = {"test_type", "table_name", "column_names"} if group_by not in allowed: raise ValueError(f"group_by must be one of {allowed}") @@ -238,11 +250,18 @@ def select_history( limit: int = 20, offset: int = 0, ) -> list[Self]: - query = select(cls).where(cls.test_definition_id == test_definition_id) + """Historical results for a test definition, newest first. + + Monitor suites are always filtered out. + Project-level access is enforced when ``project_codes`` is set. + """ + query = ( + select(cls) + .join(TestSuite, cls.test_suite_id == TestSuite.id) + .where(cls.test_definition_id == test_definition_id, TestSuite.is_monitor.isnot(True)) + ) if project_codes is not None: - query = query.join(TestSuite, cls.test_suite_id == TestSuite.id).where( - TestSuite.project_code.in_(project_codes) - ) + query = query.where(TestSuite.project_code.in_(project_codes)) query = query.order_by(desc(cls.test_time)).offset(offset).limit(limit) return get_current_session().scalars(query).all() diff --git a/tests/unit/mcp/test_model_test_definition.py b/tests/unit/mcp/test_model_test_definition.py new file mode 100644 index 00000000..224f3be3 --- /dev/null +++ b/tests/unit/mcp/test_model_test_definition.py @@ -0,0 +1,56 @@ +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from sqlalchemy.dialects import postgresql + +from testgen.common.models.test_definition import TestDefinition + + +@pytest.fixture +def session_mock(): + with ( + patch("testgen.common.models.test_definition.get_current_session") as td_mock, + patch("testgen.common.models.entity.get_current_session") as entity_mock, + ): + entity_mock.return_value = td_mock.return_value + yield td_mock.return_value + + +def _compiled_sql(captured_query) -> str: + return str(captured_query.compile(dialect=postgresql.dialect())) + + +def test_get_for_project_excludes_monitor_suites(session_mock): + session_mock.execute.return_value.first.return_value = None + + TestDefinition.get_for_project(uuid4()) + + sql = _compiled_sql(session_mock.execute.call_args[0][0]) + assert "test_suites.is_monitor IS NOT true" in sql + assert "JOIN test_suites" in sql + + +def test_get_for_project_excludes_monitor_suites_with_project_codes(session_mock): + session_mock.execute.return_value.first.return_value = None + + TestDefinition.get_for_project(uuid4(), project_codes=["demo"]) + + sql = _compiled_sql(session_mock.execute.call_args[0][0]) + assert "test_suites.is_monitor IS NOT true" in sql + assert "test_suites.project_code IN" in sql + + +def test_list_for_suite_excludes_monitor_suites(session_mock): + session_mock.scalar.return_value = 0 + session_mock.execute.return_value.all.return_value = [] + + TestDefinition.list_for_suite(test_suite_id=uuid4()) + + # _paginate wraps the original query as a subquery for counting — the is_monitor + # filter is preserved in the compiled SQL for either call, so check both. + queries = [call[0][0] for call in session_mock.scalar.call_args_list] + queries += [call[0][0] for call in session_mock.execute.call_args_list] + sql_joined = "\n".join(_compiled_sql(q) for q in queries) + assert "test_suites.is_monitor IS NOT true" in sql_joined + assert "JOIN test_suites" in sql_joined diff --git a/tests/unit/mcp/test_model_test_result.py b/tests/unit/mcp/test_model_test_result.py index f04949b4..ec037820 100644 --- a/tests/unit/mcp/test_model_test_result.py +++ b/tests/unit/mcp/test_model_test_result.py @@ -2,6 +2,7 @@ from uuid import uuid4 import pytest +from sqlalchemy.dialects import postgresql from testgen.common.models.test_result import TestResult, TestResultStatus @@ -12,6 +13,10 @@ def session_mock(): yield mock.return_value +def _compiled_sql(captured_query) -> str: + return str(captured_query.compile(dialect=postgresql.dialect())) + + def test_select_results_basic(session_mock): mock_results = [MagicMock(spec=TestResult)] session_mock.scalars.return_value.all.return_value = mock_results @@ -101,3 +106,43 @@ def test_select_history_empty(session_mock): results = TestResult.select_history(test_definition_id=uuid4(), limit=10) assert results == [] + + +def test_select_results_excludes_monitor_suites(session_mock): + session_mock.scalars.return_value.all.return_value = [] + + TestResult.select_results(test_run_id=uuid4()) + + sql = _compiled_sql(session_mock.scalars.call_args[0][0]) + assert "test_suites.is_monitor IS NOT true" in sql + assert "JOIN test_suites" in sql + + +def test_select_results_excludes_monitor_suites_with_project_codes(session_mock): + session_mock.scalars.return_value.all.return_value = [] + + TestResult.select_results(test_run_id=uuid4(), project_codes=["demo"]) + + sql = _compiled_sql(session_mock.scalars.call_args[0][0]) + assert "test_suites.is_monitor IS NOT true" in sql + assert "test_suites.project_code IN" in sql + + +def test_select_failures_excludes_monitor_suites(session_mock): + session_mock.execute.return_value.all.return_value = [] + + TestResult.select_failures(test_run_id=uuid4(), group_by="test_type") + + sql = _compiled_sql(session_mock.execute.call_args[0][0]) + assert "test_suites.is_monitor IS NOT true" in sql + assert "JOIN test_suites" in sql + + +def test_select_history_excludes_monitor_suites(session_mock): + session_mock.scalars.return_value.all.return_value = [] + + TestResult.select_history(test_definition_id=uuid4()) + + sql = _compiled_sql(session_mock.scalars.call_args[0][0]) + assert "test_suites.is_monitor IS NOT true" in sql + assert "JOIN test_suites" in sql From 1ceeb20b8a3adf0284f8abbd5d0dc878f83c2332 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Fri, 24 Apr 2026 13:04:32 -0300 Subject: [PATCH 071/123] feat: JobExecution post-completion callbacks and run-score-update job (TG-1048) Moves notifications and DQ-score rollup out of run handlers into a registry of post-completion callbacks fired wherever a JE reaches a terminal status (exec_job, _proc_wrapper, _handle_cancellation). Score rollup becomes a standalone run-score-update job enqueued by a callback; callers without a scheduler (quick-start, functional tests) invoke it directly. Splits commands/exec_job.py into exec_job (engine), job_registry (dispatch + callback registries), job_runner (submit_and_wait), and run_score_update. Unifies the cancellation path: scheduler startup only request_cancels stale JEs; the polling loop finalizes them via _handle_cancellation. Drops init_ui's cancel-and-notify loop and the completed_at-NULL band-aid. API hides source='system' jobs from GET/cancel/list endpoints. Co-Authored-By: Claude Opus 4.7 (1M context) --- testgen/__main__.py | 25 +--- testgen/api/deps.py | 7 +- testgen/api/jobs.py | 3 +- testgen/commands/exec_job.py | 106 +++------------- testgen/commands/job_registry.py | 106 ++++++++++++++++ testgen/commands/job_runner.py | 83 ++++++++++++ testgen/commands/run_profiling.py | 25 +--- testgen/commands/run_quick_start.py | 35 +++-- testgen/commands/run_score_update.py | 78 ++++++++++++ testgen/commands/run_test_execution.py | 35 +---- testgen/commands/test_generation.py | 2 +- testgen/common/models/job_execution.py | 20 ++- testgen/common/models/profiling_run.py | 13 -- testgen/common/models/test_run.py | 13 -- testgen/common/notifications/test_run.py | 5 - testgen/scheduler/cli_scheduler.py | 28 +++- tests/unit/api/test_jobs.py | 6 +- tests/unit/commands/test_exec_job.py | 134 ++++++++++++++------ tests/unit/commands/test_job_runner.py | 77 +++++++++++ tests/unit/scheduler/test_scheduler_cli.py | 2 +- tests/unit/scheduler/test_scheduler_poll.py | 2 +- 21 files changed, 537 insertions(+), 268 deletions(-) create mode 100644 testgen/commands/job_registry.py create mode 100644 testgen/commands/job_runner.py create mode 100644 testgen/commands/run_score_update.py create mode 100644 tests/unit/commands/test_job_runner.py diff --git a/testgen/__main__.py b/testgen/__main__.py index 1dd2fed2..ea171ff3 100644 --- a/testgen/__main__.py +++ b/testgen/__main__.py @@ -10,7 +10,8 @@ from click.core import Context from testgen import settings -from testgen.commands.exec_job import exec_job, submit_and_wait +from testgen.commands.exec_job import exec_job +from testgen.commands.job_runner import submit_and_wait from testgen.commands.run_get_entities import ( run_get_results, run_get_test_suite, @@ -28,14 +29,12 @@ ) from testgen.commands.run_launch_db_config import run_launch_db_config from testgen.commands.run_observability_exporter import run_observability_exporter -from testgen.commands.run_profiling import run_profiling from testgen.commands.run_quick_start import ( run_monitor_increment, run_quick_start, run_quick_start_increment, run_with_job_execution, ) -from testgen.commands.run_test_execution import run_test_execution from testgen.commands.run_test_metadata_exporter import run_test_metadata_exporter from testgen.commands.run_upgrade_db_config import get_schema_revision, is_db_revision_up_to_date, run_upgrade_db_config from testgen.commands.test_generation import run_monitor_generation, run_test_generation @@ -49,14 +48,10 @@ version_service, ) from testgen.common.models import database_session, with_database_session -from testgen.common.models.profiling_run import ProfilingRun from testgen.common.models.settings import PersistedSetting from testgen.common.models.table_group import TableGroup -from testgen.common.models.test_run import TestRun from testgen.common.models.test_suite import TestSuite from testgen.common.notifications.base import smtp_configured -from testgen.common.notifications.profiling_run import send_profiling_run_notifications -from testgen.common.notifications.test_run import send_test_run_notifications from testgen.scheduler import run_scheduler from testgen.utils import plugins @@ -446,19 +441,19 @@ def quick_start( test_suite_id = "9df7489d-92b3-49f9-95ca-512160d7896f" click.echo(f"run-profile with table_group_id: {table_group_id}") - run_with_job_execution(run_profiling, "run-profile", now_date + time_delta, table_group_id=table_group_id) + run_with_job_execution("run-profile", now_date + time_delta, table_group_id=table_group_id) LOG.info(f"run-test-generation with test_suite_id: {test_suite_id}") with_database_session(run_test_generation)(test_suite_id, "Standard") - run_with_job_execution(run_test_execution, "run-tests", now_date + time_delta, test_suite_id=test_suite_id) + run_with_job_execution("run-tests", now_date + time_delta, test_suite_id=test_suite_id) total_iterations = 3 for iteration in range(1, total_iterations + 1): click.echo(f"Running iteration: {iteration} / {total_iterations}") run_date = now_date + timedelta(days=-10 * (total_iterations - iteration)) # 10 day increments run_quick_start_increment(iteration) - run_with_job_execution(run_test_execution, "run-tests", run_date, test_suite_id=test_suite_id) + run_with_job_execution("run-tests", run_date, test_suite_id=test_suite_id) monitor_iterations = 68 # ~5 weeks monitor_interval = timedelta(hours=12) @@ -473,7 +468,7 @@ def quick_start( if monitor_run_date.weekday() < 5 and monitor_run_date.hour < 12: weekday_morning_count += 1 run_monitor_increment(monitor_run_date, iteration, weekday_morning_count) - run_with_job_execution(run_test_execution, "run-monitors", monitor_run_date, test_suite_id=monitor_test_suite_id) + run_with_job_execution("run-monitors", monitor_run_date, test_suite_id=monitor_test_suite_id) monitor_run_date += monitor_interval click.echo("Quick start has successfully finished.") @@ -722,14 +717,6 @@ def run_ui(): @with_database_session def init_ui(): - try: - for profiling_run_id in ProfilingRun.cancel_all_running(): - send_profiling_run_notifications(ProfilingRun.get(profiling_run_id)) - for test_run_id in TestRun.cancel_all_running(): - send_test_run_notifications(TestRun.get(test_run_id)) - except Exception: - LOG.warning("Failed to cancel 'Running' profiling/test runs") - PersistedSetting.set("SMTP_CONFIGURED", smtp_configured()) init_ui() diff --git a/testgen/api/deps.py b/testgen/api/deps.py index 8e73cc05..8daac06f 100644 --- a/testgen/api/deps.py +++ b/testgen/api/deps.py @@ -115,6 +115,7 @@ def dependency(test_suite_id: UUID, user: User = _require_user) -> TestSuite: def resolve_job(permission: str, *extra_filters): """Resolve a JobExecution by ``job_id`` path param and verify project permission. + Internally-submitted jobs (source='system') are never exposed via the API. Extra ORM clauses are appended to the WHERE clause, e.g. to restrict by job_key. Mismatches surface as the same 404 — no information leakage. """ @@ -123,7 +124,11 @@ def resolve_job(permission: str, *extra_filters): from testgen.common.models.job_execution import JobExecution def dependency(job_id: UUID, user: User = _require_user) -> JobExecution: - query = select(JobExecution).where(JobExecution.id == job_id, *extra_filters) + query = select(JobExecution).where( + JobExecution.id == job_id, + JobExecution.source != "system", + *extra_filters, + ) job = get_current_session().scalars(query).first() if job and has_project_permission(user, job.project_code, permission): return job diff --git a/testgen/api/jobs.py b/testgen/api/jobs.py index 924c123d..c0a52dc3 100644 --- a/testgen/api/jobs.py +++ b/testgen/api/jobs.py @@ -104,7 +104,8 @@ def list_jobs( ): """List job executions for a project, with optional filters and pagination.""" items, total = JobExecution.list_for_project( - project_code=project_code, + project_code, + JobExecution.source != "system", job_key=job_key, status=status, page=page, diff --git a/testgen/commands/exec_job.py b/testgen/commands/exec_job.py index c4a16405..4f63a494 100644 --- a/testgen/commands/exec_job.py +++ b/testgen/commands/exec_job.py @@ -1,14 +1,16 @@ -"""Central job dispatch: exec_job() for subprocess execution, submit_and_wait() for CLI wrappers.""" +"""Subprocess entry point for the scheduler's `testgen exec-job ` command. + +Owns the end-to-end lifecycle of a single claimed job: dispatch to its handler, +transition the JobExecution to a terminal state, and fire final callbacks. +Concrete wiring (which handler runs for which job_key, which callbacks fire +after termination) lives in `job_registry.py`. +""" import logging import sys -import time -from collections.abc import Callable from uuid import UUID -from testgen.commands.run_profiling import run_profiling -from testgen.commands.run_test_execution import run_test_execution -from testgen.commands.test_generation import run_test_generation +from testgen.commands.job_registry import JOB_DISPATCH, run_final_callbacks from testgen.common.job_context import JobContext, job_context from testgen.common.models import database_session from testgen.common.models.job_execution import JobExecution, JobStatus @@ -16,16 +18,9 @@ LOG = logging.getLogger("testgen") -TERMINAL_STATUSES = frozenset({JobStatus.COMPLETED, JobStatus.ERROR, JobStatus.CANCELED}) +FINAL_STATUSES = frozenset({JobStatus.COMPLETED, JobStatus.ERROR, JobStatus.CANCELED}) POLL_INTERVAL = 2 -JOB_DISPATCH: dict[str, Callable] = { - "run-profile": run_profiling, - "run-tests": run_test_execution, - "run-monitors": run_test_execution, - "run-test-generation": run_test_generation, -} - def exec_job(job_execution_id: UUID) -> None: """Execute a queued job. Called as a subprocess by the scheduler. @@ -52,91 +47,22 @@ def exec_job(job_execution_id: UUID) -> None: try: with database_session(): job_exec = JobExecution.get(job_execution_id) - job_context.set(JobContext(job_id=job_execution_id, source=job_exec.source.upper())) + job_context.set(JobContext(job_id=job_execution_id, source=job_exec.source)) handler(**job_exec.kwargs) with database_session(): job_exec = JobExecution.get(job_execution_id) - job_exec.mark_completed() + transitioned = job_exec.mark_completed() + if transitioned: + run_final_callbacks(job_exec) except Exception as e: LOG.exception("Job %s failed", job_execution_id) with database_session(): job_exec = JobExecution.get(job_execution_id) - job_exec.mark_interrupted(get_exception_message(e)) + transitioned = job_exec.mark_interrupted(get_exception_message(e)) + if transitioned: + run_final_callbacks(job_exec) except Exception: LOG.exception("Unrecoverable error executing job %s", job_execution_id) sys.exit(1) - - -def submit_and_wait( - job_key: str, - kwargs: dict, - project_code: str, - no_wait: bool = False, -) -> None: - """Submit a job to the queue and optionally wait for completion. - - Manages its own session lifecycle — callers must NOT wrap this in @with_database_session. - The submit is committed in its own session so the scheduler can see the row immediately. - """ - import click - - with database_session(): - job_exec = JobExecution.submit( - job_key=job_key, - kwargs=kwargs, - source="cli", - project_code=project_code, - ) - job_id = job_exec.id - - click.echo(f"Submitted job {job_id} ({job_key})") - - if no_wait: - return - - click.echo("Waiting for completion...") - while True: - time.sleep(POLL_INTERVAL) - with database_session(): - job_exec = JobExecution.get(job_id) - if job_exec and job_exec.status in TERMINAL_STATUSES: - break - - match job_exec.status: - case JobStatus.COMPLETED: - _print_run_summary(job_id, job_key) - case JobStatus.ERROR: - _print_run_summary(job_id, job_key) - click.echo(f"Job {job_id} failed: {job_exec.error_message}", err=True) - sys.exit(1) - case JobStatus.CANCELED: - click.echo(f"Job {job_id} was canceled.", err=True) - sys.exit(1) - - -def _print_run_summary(job_id: UUID, job_key: str) -> None: - """Print the linked run record summary, matching the old CLI output format.""" - import click - from sqlalchemy import select - - from testgen.common.models import get_current_session - from testgen.common.models.profiling_run import ProfilingRun - from testgen.common.models.test_run import TestRun - - with database_session(): - session = get_current_session() - match job_key: - case "run-profile": - run = session.scalars(select(ProfilingRun).where(ProfilingRun.job_execution_id == job_id)).first() - if run: - status_msg = "Profiling encountered an error. Check log for details." if run.status == "Error" else "Profiling completed." - click.echo(f"\n {status_msg}\n Run ID: {run.id}\n ") - case "run-tests" | "run-monitors": - run = session.scalars(select(TestRun).where(TestRun.job_execution_id == job_id)).first() - if run: - status_msg = "Test execution encountered an error. Check log for details." if run.status == "Error" else "Test execution completed." - click.echo(f"\n {status_msg}\n Run ID: {run.id}\n ") - case "run-test-generation": - click.echo("Test generation completed.") diff --git a/testgen/commands/job_registry.py b/testgen/commands/job_registry.py new file mode 100644 index 00000000..a199491c --- /dev/null +++ b/testgen/commands/job_registry.py @@ -0,0 +1,106 @@ +"""Wiring between the JobExecution engine and the concrete job handlers. + +Two registries keyed by `job_key`: + - `JOB_DISPATCH`: maps a job to its handler (`exec_job` resolves this). + - `JOB_FINAL_CALLBACKS`: maps a job to post-terminal-transition callbacks + (notifications, follow-up job submissions). `run_final_callbacks` iterates. + +`run_final_callbacks` is invoked wherever a JE reaches a terminal status: +`exec_job` after mark_completed/mark_interrupted, `_proc_wrapper`'s nonzero-exit +safety net, and `_handle_cancellation`'s no-subprocess branch. +""" + +import logging +from collections.abc import Callable + +from sqlalchemy import select + +from testgen.commands.run_profiling import run_profiling +from testgen.commands.run_score_update import run_score_update +from testgen.commands.run_test_execution import run_test_execution +from testgen.commands.test_generation import run_test_generation +from testgen.common.models import database_session +from testgen.common.models.job_execution import JobExecution, JobStatus +from testgen.common.models.profiling_run import ProfilingRun +from testgen.common.models.test_run import TestRun +from testgen.common.notifications.monitor_run import send_monitor_notifications +from testgen.common.notifications.profiling_run import send_profiling_run_notifications +from testgen.common.notifications.test_run import send_test_run_notifications + +LOG = logging.getLogger("testgen") + +FinalCallback = Callable[[JobExecution], None] + +JOB_DISPATCH: dict[str, Callable] = { + "run-profile": run_profiling, + "run-tests": run_test_execution, + "run-monitors": run_test_execution, + "run-test-generation": run_test_generation, + "run-score-update": run_score_update, +} + + +def run_final_callbacks(job_exec: JobExecution) -> None: + """Fire registered callbacks for a job that just settled into a final status. + + Callbacks are best-effort: failures are logged and do not propagate. The + job execution is already in its final state regardless of callback outcomes. + """ + for callback in JOB_FINAL_CALLBACKS.get(job_exec.job_key, []): + try: + callback(job_exec) + except Exception: + LOG.exception("Callback %s failed for job %s", callback.__name__, job_exec.id) + + +def _notify_profiling_run(job_exec: JobExecution) -> None: + with database_session() as session: + profiling_run = session.scalars( + select(ProfilingRun).where(ProfilingRun.job_execution_id == job_exec.id) + ).first() + if not profiling_run: + LOG.warning("No profiling_run found for job %s; skipping notification", job_exec.id) + return + send_profiling_run_notifications(profiling_run) + + +def _notify_test_run(job_exec: JobExecution) -> None: + with database_session() as session: + test_run = session.scalars(select(TestRun).where(TestRun.job_execution_id == job_exec.id)).first() + if not test_run: + LOG.warning("No test_run found for job %s; skipping notification", job_exec.id) + return + send_test_run_notifications(test_run) + + +def _notify_monitor_run(job_exec: JobExecution) -> None: + with database_session() as session: + test_run = session.scalars(select(TestRun).where(TestRun.job_execution_id == job_exec.id)).first() + if not test_run: + LOG.warning("No test_run found for job %s; skipping monitor notification", job_exec.id) + return + send_monitor_notifications(test_run) + + +def _enqueue_score_update(job_exec: JobExecution) -> None: + """Enqueue a score rollup for the just-completed run.""" + if job_exec.status != JobStatus.COMPLETED: + return + + with database_session(): + JobExecution.submit( + job_key="run-score-update", + kwargs={ + "parent_job_id": str(job_exec.id), + "parent_job_key": job_exec.job_key, + }, + source="system", + project_code=job_exec.project_code, + ) + + +JOB_FINAL_CALLBACKS: dict[str, list[FinalCallback]] = { + "run-profile": [_notify_profiling_run, _enqueue_score_update], + "run-tests": [_notify_test_run, _enqueue_score_update], + "run-monitors": [_notify_monitor_run], +} diff --git a/testgen/commands/job_runner.py b/testgen/commands/job_runner.py new file mode 100644 index 00000000..37a96dc5 --- /dev/null +++ b/testgen/commands/job_runner.py @@ -0,0 +1,83 @@ +"""CLI-facing job submission: `submit_and_wait` posts a job for the scheduler +to execute and (optionally) polls until it reaches a terminal state. +""" + +import logging +import sys +import time +from uuid import UUID + +import click +from sqlalchemy import select + +from testgen.commands.exec_job import FINAL_STATUSES, POLL_INTERVAL +from testgen.common.models import database_session, get_current_session +from testgen.common.models.job_execution import JobExecution, JobStatus +from testgen.common.models.profiling_run import ProfilingRun +from testgen.common.models.test_run import TestRun + +LOG = logging.getLogger("testgen") + + +def submit_and_wait( + job_key: str, + kwargs: dict, + project_code: str, + no_wait: bool = False, +) -> None: + """Submit a job to the queue and optionally wait for completion. + + Manages its own session lifecycle — callers must NOT wrap this in @with_database_session. + The submit is committed in its own session so the scheduler can see the row immediately. + """ + with database_session(): + job_exec = JobExecution.submit( + job_key=job_key, + kwargs=kwargs, + source="cli", + project_code=project_code, + ) + job_id = job_exec.id + + click.echo(f"Submitted job {job_id} ({job_key})") + + if no_wait: + return + + click.echo("Waiting for completion...") + while True: + time.sleep(POLL_INTERVAL) + with database_session(): + job_exec = JobExecution.get(job_id) + if job_exec and job_exec.status in FINAL_STATUSES: + break + + match job_exec.status: + case JobStatus.COMPLETED: + _print_run_summary(job_id, job_key) + case JobStatus.ERROR: + _print_run_summary(job_id, job_key) + click.echo(f"Job {job_id} failed: {job_exec.error_message}", err=True) + sys.exit(1) + case JobStatus.CANCELED: + click.echo(f"Job {job_id} was canceled.", err=True) + sys.exit(1) + + +def _print_run_summary(job_id: UUID, job_key: str) -> None: + """Print the linked run record summary, matching the old CLI output format.""" + with database_session(): + session = get_current_session() + match job_key: + case "run-profile": + run = session.scalars(select(ProfilingRun).where(ProfilingRun.job_execution_id == job_id)).first() + if run: + status_msg = "Profiling encountered an error. Check log for details." if run.status == "Error" else "Profiling completed." + click.echo(f"\n {status_msg}\n Run ID: {run.id}\n ") + case "run-tests" | "run-monitors": + run = session.scalars(select(TestRun).where(TestRun.job_execution_id == job_id)).first() + if run: + status_msg = "Test execution encountered an error. Check log for details." if run.status == "Error" else "Test execution completed." + click.echo(f"\n {status_msg}\n Run ID: {run.id}\n ") + case "run-test-generation": + click.echo("Test generation completed.") diff --git a/testgen/commands/run_profiling.py b/testgen/commands/run_profiling.py index b289a864..2125defc 100644 --- a/testgen/commands/run_profiling.py +++ b/testgen/commands/run_profiling.py @@ -10,9 +10,7 @@ calculate_sampling_params, ) from testgen.commands.queries.refresh_data_chars_query import ColumnChars -from testgen.commands.queries.rollup_scores_query import RollupScoresSQL from testgen.commands.run_refresh_data_chars import run_data_chars_refresh -from testgen.commands.run_refresh_score_cards_results import run_refresh_score_cards_results from testgen.commands.test_generation import run_monitor_generation, run_test_generation from testgen.common import ( execute_db_queries, @@ -30,7 +28,6 @@ from testgen.common.models.profiling_run import ProfilingRun from testgen.common.models.table_group import TableGroup from testgen.common.models.test_suite import TestSuite -from testgen.common.notifications.profiling_run import send_profiling_run_notifications from testgen.utils import get_exception_message LOG = logging.getLogger("testgen") @@ -106,8 +103,6 @@ def run_profiling( profiling_run.status = "Error" profiling_run.save() session.commit() - - send_profiling_run_notifications(profiling_run) raise else: LOG.info("Setting profiling run status to Completed") @@ -116,20 +111,17 @@ def run_profiling( profiling_run.save() session.commit() - send_profiling_run_notifications(profiling_run) - _rollup_profiling_scores(profiling_run, table_group) _generate_tests(table_group) finally: MixpanelService().send_event( "run-profiling", - source=job_context.get().source, + source=job_context.get().source.upper(), username=username, sql_flavor=connection.sql_flavor_code, sampling=table_group.profile_use_sampling, table_count=profiling_run.table_ct or 0, column_count=profiling_run.column_ct or 0, run_duration=(profiling_run.profiling_endtime - profiling_run.profiling_starttime).total_seconds(), - scoring_duration=(datetime.now(UTC) + time_delta - profiling_run.profiling_endtime).total_seconds(), ) return profiling_run.id @@ -311,21 +303,6 @@ def _run_hygiene_issue_detection(sql_generator: ProfilingSQL) -> None: profiling_run.set_progress("hygiene_issues", "Completed") -def _rollup_profiling_scores(profiling_run: ProfilingRun, table_group: TableGroup) -> None: - try: - LOG.info("Rolling up profiling scores") - execute_db_queries( - RollupScoresSQL(profiling_run.id, table_group.id).rollup_profiling_scores(), - ) - run_refresh_score_cards_results( - project_code=table_group.project_code, - add_history_entry=True, - refresh_date=profiling_run.profiling_starttime, - ) - except Exception: - LOG.exception("Error rolling up profiling scores") - - @with_database_session def _generate_tests(table_group: TableGroup) -> None: is_first_profile_run = not table_group.last_complete_profile_run_id diff --git a/testgen/commands/run_quick_start.py b/testgen/commands/run_quick_start.py index 2d0e3581..233da7c7 100644 --- a/testgen/commands/run_quick_start.py +++ b/testgen/commands/run_quick_start.py @@ -1,14 +1,15 @@ import logging import math import random -from collections.abc import Callable from datetime import UTC, datetime from typing import Any import click from testgen import settings +from testgen.commands.job_registry import JOB_DISPATCH from testgen.commands.run_launch_db_config import get_app_db_params_mapping, run_launch_db_config +from testgen.commands.run_score_update import run_score_update from testgen.commands.test_generation import run_monitor_generation from testgen.common.credentials import get_tg_schema from testgen.common.database.database_service import ( @@ -19,7 +20,7 @@ ) from testgen.common.database.flavor.flavor_service import ConnectionParams from testgen.common.job_context import JobContext, job_context -from testgen.common.models import database_session, get_current_session, with_database_session +from testgen.common.models import database_session, with_database_session from testgen.common.models.job_execution import JobExecution, JobStatus from testgen.common.models.scores import ScoreDefinition from testgen.common.models.settings import PersistedSetting @@ -30,36 +31,50 @@ LOG = logging.getLogger("testgen") random.seed(42) +SCOREABLE_JOB_KEYS = frozenset({"run-profile", "run-tests"}) + def run_with_job_execution( - handler: Callable, job_key: str, run_date: datetime | None = None, **handler_kwargs: Any, ) -> None: - """Wrap a run command with a synthetic JE so quick-start runs link to job_executions.""" + """Run a handler inline under a synthetic JE, then roll up DQ scores. + + Quick-start doesn't have a scheduler, so we bypass exec_job's subprocess + flow: create the JE directly, call the handler (which links the run row + to the JE via job_context), then invoke `run_score_update` inline for the + run/profile job types. No notifications — this is seed data, nobody's + watching an inbox. + """ effective_date = run_date or datetime.now(UTC) wall_start = datetime.now(UTC) - with database_session(): + # Match the source a real trigger would use so demo data mirrors production attribution. + source = "scheduler" if job_key == "run-monitors" else "ui" + + with database_session() as session: je = JobExecution( job_key=job_key, kwargs={k: str(v) for k, v in handler_kwargs.items()}, - source="quick-start", + source=source, project_code=settings.PROJECT_KEY, status=JobStatus.COMPLETED.value, started_at=effective_date, ) - get_current_session().add(je) - get_current_session().flush([je]) + session.add(je) + session.flush([je]) je_id = je.id - job_context.set(JobContext(job_id=je_id, source="QUICK-START")) - handler(**handler_kwargs, run_date=run_date) + job_context.set(JobContext(job_id=je_id, source=source)) + JOB_DISPATCH[job_key](**handler_kwargs, run_date=run_date) with database_session(): je = JobExecution.get(je_id) je.completed_at = effective_date + (datetime.now(UTC) - wall_start) + if job_key in SCOREABLE_JOB_KEYS: + run_score_update(parent_job_id=str(je_id), parent_job_key=job_key) + def _get_max_date(iteration: int): if iteration == 0: diff --git a/testgen/commands/run_score_update.py b/testgen/commands/run_score_update.py new file mode 100644 index 00000000..230a4a9d --- /dev/null +++ b/testgen/commands/run_score_update.py @@ -0,0 +1,78 @@ +"""Score rollup job: recalculates DQ scores after a profiling or test run completes. + +Invoked via the scheduler (enqueued by a final callback on the parent run), or +called directly by callers without a running scheduler (quick-start, functional +tests). Identified by the parent JE id; `parent_job_key` selects the flavor. +""" + +import logging +from uuid import UUID + +from sqlalchemy import select + +from testgen.commands.queries.rollup_scores_query import RollupScoresSQL +from testgen.commands.run_refresh_score_cards_results import run_refresh_score_cards_results +from testgen.common import execute_db_queries +from testgen.common.models import database_session +from testgen.common.models.profiling_run import ProfilingRun +from testgen.common.models.test_run import TestRun +from testgen.common.models.test_suite import TestSuite + +LOG = logging.getLogger("testgen") + + +def run_score_update(parent_job_id: str, parent_job_key: str) -> None: + """Roll up scores for the run linked to the given parent job execution.""" + parent_je_id = UUID(parent_job_id) + match parent_job_key: + case "run-profile": + _rollup_profiling(parent_je_id) + case "run-tests": + _rollup_test(parent_je_id) + case _: + raise ValueError(f"run_score_update: unsupported parent_job_key {parent_job_key!r}") + + +def _rollup_profiling(parent_je_id: UUID) -> None: + with database_session() as session: + profiling_run = session.scalars( + select(ProfilingRun).where(ProfilingRun.job_execution_id == parent_je_id) + ).first() + if not profiling_run: + LOG.error("No profiling_run found for job execution %s; skipping score rollup", parent_je_id) + return + run_id = str(profiling_run.id) + table_group_id = profiling_run.table_groups_id + project_code = profiling_run.project_code + refresh_date = profiling_run.profiling_starttime + + LOG.info("Rolling up profiling scores for job execution %s", parent_je_id) + execute_db_queries(RollupScoresSQL(run_id, table_group_id).rollup_profiling_scores()) + run_refresh_score_cards_results( + project_code=project_code, + add_history_entry=True, + refresh_date=refresh_date, + ) + + +def _rollup_test(parent_je_id: UUID) -> None: + with database_session() as session: + row = session.execute( + select(TestRun.id, TestRun.test_starttime, TestSuite.table_groups_id, TestSuite.project_code) + .join(TestSuite, TestRun.test_suite_id == TestSuite.id) + .where(TestRun.job_execution_id == parent_je_id) + ).first() + if not row: + LOG.error("No test_run found for job execution %s; skipping score rollup", parent_je_id) + return + run_id, refresh_date, table_group_id, project_code = row + + LOG.info("Rolling up test scores for job execution %s", parent_je_id) + execute_db_queries( + RollupScoresSQL(str(run_id), table_group_id).rollup_test_scores(update_prevalence=True, update_table_group=True), + ) + run_refresh_score_cards_results( + project_code=project_code, + add_history_entry=True, + refresh_date=refresh_date, + ) diff --git a/testgen/commands/run_test_execution.py b/testgen/commands/run_test_execution.py index d4d12423..e1c3bb85 100644 --- a/testgen/commands/run_test_execution.py +++ b/testgen/commands/run_test_execution.py @@ -7,8 +7,6 @@ from uuid import UUID from testgen.commands.queries.execute_tests_query import TestExecutionDef, TestExecutionSQL -from testgen.commands.queries.rollup_scores_query import RollupScoresSQL -from testgen.commands.run_refresh_score_cards_results import run_refresh_score_cards_results from testgen.commands.test_generation import run_monitor_generation from testgen.commands.test_thresholds_prediction import TestThresholdsPrediction from testgen.common import ( @@ -26,8 +24,6 @@ from testgen.common.models.table_group import TableGroup from testgen.common.models.test_run import TestRun from testgen.common.models.test_suite import TestSuite -from testgen.common.notifications.monitor_run import send_monitor_notifications -from testgen.common.notifications.test_run import send_test_run_notifications from testgen.utils import get_exception_message from .run_refresh_data_chars import run_data_chars_refresh @@ -141,8 +137,6 @@ def run_test_execution( test_run.status = "Error" test_run.save() session.commit() - - send_test_run_notifications(test_run) raise else: LOG.info("Setting test run status to Completed") @@ -155,28 +149,21 @@ def run_test_execution( test_suite.last_complete_test_run_id = test_run.id test_suite.save() session.commit() - - if not test_suite.is_monitor: - send_test_run_notifications(test_run) - _rollup_test_scores(test_run, table_group) - else: - send_monitor_notifications(test_run) finally: - scoring_endtime = datetime.now(UTC) + time_delta + prediction_start = datetime.now(UTC) + time_delta try: TestThresholdsPrediction(test_suite, test_run.test_starttime).run() except Exception: LOG.exception("Error predicting test thresholds") - + MixpanelService().send_event( "run-monitors" if test_suite.is_monitor else "run-tests", - source=job_context.get().source, + source=job_context.get().source.upper(), username=username, sql_flavor=connection.sql_flavor_code, test_count=test_run.test_ct, run_duration=(test_run.test_endtime - test_run.test_starttime.replace(tzinfo=UTC)).total_seconds(), - scoring_duration=(scoring_endtime - test_run.test_endtime).total_seconds(), - prediction_duration=(datetime.now(UTC) + time_delta - scoring_endtime).total_seconds(), + prediction_duration=(datetime.now(UTC) + time_delta - prediction_start).total_seconds(), ) return test_run.id @@ -365,17 +352,3 @@ def update_single_progress(progress: ThreadedProgress) -> None: ) -def _rollup_test_scores(test_run: TestRun, table_group: TableGroup) -> None: - try: - LOG.info("Rolling up test scores") - sql_generator = RollupScoresSQL(test_run.id, table_group.id) - execute_db_queries( - sql_generator.rollup_test_scores(update_prevalence=True, update_table_group=True), - ) - run_refresh_score_cards_results( - project_code=table_group.project_code, - add_history_entry=True, - refresh_date=test_run.test_starttime, - ) - except Exception: - LOG.exception("Error rolling up test scores") diff --git a/testgen/commands/test_generation.py b/testgen/commands/test_generation.py index 6a8b86ae..e164758f 100644 --- a/testgen/commands/test_generation.py +++ b/testgen/commands/test_generation.py @@ -61,7 +61,7 @@ def run_test_generation( finally: MixpanelService().send_event( "generate-tests", - source=job_context.get().source, + source=job_context.get().source.upper(), sql_flavor=connection.sql_flavor, generation_set=generation_set, ) diff --git a/testgen/common/models/job_execution.py b/testgen/common/models/job_execution.py index 9f2d0399..3b6abca4 100644 --- a/testgen/common/models/job_execution.py +++ b/testgen/common/models/job_execution.py @@ -101,19 +101,14 @@ def claim_actionable(cls, limit: int = 5) -> list[Self]: return rows @classmethod - def cancel_all_stale(cls) -> int: - """Cancel job executions left in non-terminal states from a previous process.""" + def find_stale(cls) -> list[Self]: + """Return job executions left in non-terminal states from a previous process.""" session = get_current_session() - result = session.execute( - update(cls) - .where(cls.status.in_([JobStatus.PENDING, JobStatus.CLAIMED, JobStatus.RUNNING, JobStatus.CANCEL_REQUESTED])) - .values( - status=JobStatus.CANCELED.value, - completed_at=datetime.now(UTC), - error_message="Canceled: stale job from previous scheduler session", + return list(session.scalars( + select(cls).where( + cls.status.in_([JobStatus.PENDING, JobStatus.CLAIMED, JobStatus.RUNNING, JobStatus.CANCEL_REQUESTED]) ) - ) - return result.rowcount + ).all()) @classmethod def get(cls, execution_id: UUID) -> Self | None: @@ -125,6 +120,7 @@ def get(cls, execution_id: UUID) -> Self | None: def list_for_project( cls, project_code: str, + *extra_filters, job_key: str | None = None, status: str | None = None, page: int = 1, @@ -132,7 +128,7 @@ def list_for_project( ) -> tuple[list[Self], int]: """List job executions for a project with optional filters and pagination.""" session = get_current_session() - query = select(cls).where(cls.project_code == project_code) + query = select(cls).where(cls.project_code == project_code, *extra_filters) if job_key: query = query.where(cls.job_key == job_key) if status: diff --git a/testgen/common/models/profiling_run.py b/testgen/common/models/profiling_run.py index 4f562f2f..b0975ca7 100644 --- a/testgen/common/models/profiling_run.py +++ b/testgen/common/models/profiling_run.py @@ -272,19 +272,6 @@ def has_active_job_for(cls, entity_cls: type[Entity], *entity_ids: str | int | U raise ValueError(f"Unsupported entity: {entity_cls.__name__}") return get_current_session().execute(query).scalar() > 0 - @classmethod - def cancel_all_running(cls) -> list[UUID]: - query = ( - update(cls) - .where(cls.status == "Running") - .values(status="Cancelled", profiling_endtime=datetime.now(UTC)) - .returning(cls.id) - ) - db_session = get_current_session() - rows = db_session.execute(query) - db_session.flush() - return [r.id for r in rows] - @classmethod def cancel_run(cls, run_id: str | UUID) -> None: query = update(cls).where(cls.id == run_id).values(status="Cancelled", profiling_endtime=datetime.now(UTC)) diff --git a/testgen/common/models/test_run.py b/testgen/common/models/test_run.py index 4f56bc8d..020f5371 100644 --- a/testgen/common/models/test_run.py +++ b/testgen/common/models/test_run.py @@ -371,19 +371,6 @@ def has_active_job_for(cls, entity_cls: type[Entity], *entity_ids: str | int | U raise ValueError(f"Unsupported entity: {entity_cls.__name__}") return get_current_session().execute(query).scalar() > 0 - @classmethod - def cancel_all_running(cls) -> list[UUID]: - query = ( - update(cls) - .where(cls.status == "Running") - .values(status="Cancelled", test_endtime=datetime.now(UTC)) - .returning(cls.id) - ) - db_session = get_current_session() - rows = db_session.execute(query) - db_session.flush() - return [r.id for r in rows] - @classmethod def cancel_run(cls, run_id: str | UUID) -> None: query = update(cls).where(cls.id == run_id).values(status="Cancelled", test_endtime=datetime.now(UTC)) diff --git a/testgen/common/notifications/test_run.py b/testgen/common/notifications/test_run.py index 77a36087..af6a15d3 100644 --- a/testgen/common/notifications/test_run.py +++ b/testgen/common/notifications/test_run.py @@ -1,5 +1,4 @@ import logging -from datetime import UTC, datetime from sqlalchemy import case, literal, select @@ -323,10 +322,6 @@ def send_test_run_notifications(test_run: TestRun, result_list_ct=20, result_sta result_list_by_status[status] = [{**r} for r in get_current_session().execute(query)] (tr_summary,), _ = TestRun.select_summary(test_run_ids=[test_run.id]) - # Notifications fire before the scheduler calls mark_completed(); fall back to NOW() so - # the duration helper in the email template doesn't crash. See job-execution-callbacks followup. - if tr_summary.completed_at is None: - tr_summary.completed_at = datetime.now(UTC) test_run_url = "".join( ( diff --git a/testgen/scheduler/cli_scheduler.py b/testgen/scheduler/cli_scheduler.py index c1d3b122..bd2719ec 100644 --- a/testgen/scheduler/cli_scheduler.py +++ b/testgen/scheduler/cli_scheduler.py @@ -11,7 +11,7 @@ from uuid import UUID from testgen import settings -from testgen.commands.exec_job import JOB_DISPATCH +from testgen.commands.job_registry import JOB_DISPATCH, run_final_callbacks from testgen.common.models import database_session, with_database_session from testgen.common.models.job_execution import JobExecution, JobStatus from testgen.common.models.scheduler import JobSchedule @@ -114,6 +114,9 @@ def _poll_loop(self): self._handle_cancellation(job_exec) case _: LOG.error("Unexpected status '%s' for job %s", job_exec.status, job_exec.id) + # Scheduler-internal failure: force the JE terminal to avoid a hung row. + # Skip final callbacks — we don't know the real run state, and a + # still-live subprocess could legitimately complete after this. except Exception: LOG.exception("Error processing job execution %s", job_exec.id) try: @@ -132,7 +135,8 @@ def _handle_cancellation(self, job_exec: JobExecution): pass # Process already exited — _proc_wrapper will finalize else: with database_session(): - job_exec.mark_canceled() + if job_exec.mark_canceled(): + run_final_callbacks(job_exec) def _dispatch(self, job_exec: JobExecution): if job_exec.job_key not in JOB_DISPATCH: @@ -164,11 +168,13 @@ def _proc_wrapper(self, proc: subprocess.Popen, job_exec: JobExecution): LOG.info("Job PID %d ended with code %d", proc.pid, ret_code) if ret_code != 0: with database_session(): - job_exec.mark_interrupted(f"Process {proc.pid} exited with code {ret_code}") + if job_exec.mark_interrupted(f"Process {proc.pid} exited with code {ret_code}"): + run_final_callbacks(job_exec) except Exception: LOG.exception("Error monitoring job PID %d", proc.pid) with database_session(): - job_exec.mark_interrupted(f"Process monitoring error for PID {proc.pid}") + if job_exec.mark_interrupted(f"Process monitoring error for PID {proc.pid}"): + run_final_callbacks(job_exec) finally: with self._running_jobs_cond: del self._running_jobs[job_exec.id] @@ -224,10 +230,18 @@ def run_scheduler(): while not check_db_is_ready(): time.sleep(10) + requested = 0 with database_session(): - stale_count = JobExecution.cancel_all_stale() - if stale_count: - LOG.info("Canceled %d stale job execution(s) from previous session", stale_count) + stale = JobExecution.find_stale() + for job_exec in stale: + with database_session(): + if job_exec.request_cancel(): + requested += 1 + if stale: + LOG.info( + "Found %d stale job execution(s) from previous session; requested cancel on %d", + len(stale), requested, + ) scheduler = CliScheduler() scheduler.run() diff --git a/tests/unit/api/test_jobs.py b/tests/unit/api/test_jobs.py index fabdf5ca..1a5685af 100644 --- a/tests/unit/api/test_jobs.py +++ b/tests/unit/api/test_jobs.py @@ -1,7 +1,7 @@ """Tests for testgen.api.jobs — job submission, status polling, and cancellation.""" from datetime import UTC, datetime -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch from uuid import uuid4 import pytest @@ -165,7 +165,7 @@ def test_list_jobs_returns_paginated_results(mock_je_cls): result = list_jobs(project_code="DEFAULT", job_key=None, status=None, page=1, limit=20) mock_je_cls.list_for_project.assert_called_once_with( - project_code="DEFAULT", job_key=None, status=None, page=1, limit=20, + "DEFAULT", ANY, job_key=None, status=None, page=1, limit=20, ) assert result.total == 2 assert result.page == 1 @@ -180,7 +180,7 @@ def test_list_jobs_passes_filters(mock_je_cls): result = list_jobs(project_code="DEFAULT", job_key="run-profile", status="completed", page=2, limit=10) mock_je_cls.list_for_project.assert_called_once_with( - project_code="DEFAULT", job_key="run-profile", status="completed", page=2, limit=10, + "DEFAULT", ANY, job_key="run-profile", status="completed", page=2, limit=10, ) assert result.total == 0 assert result.items == [] diff --git a/tests/unit/commands/test_exec_job.py b/tests/unit/commands/test_exec_job.py index ed665da9..30dacb2a 100644 --- a/tests/unit/commands/test_exec_job.py +++ b/tests/unit/commands/test_exec_job.py @@ -3,13 +3,12 @@ import pytest -from testgen.commands.exec_job import JOB_DISPATCH, exec_job, submit_and_wait -from testgen.common.models.job_execution import JobExecution, JobStatus +from testgen.commands.exec_job import exec_job +from testgen.commands.job_registry import JOB_DISPATCH, JOB_FINAL_CALLBACKS, run_final_callbacks +from testgen.common.models.job_execution import JobExecution pytestmark = pytest.mark.unit -EXEC_JOB_MODULE = "testgen.commands.exec_job" - @pytest.fixture def mock_session(): @@ -136,54 +135,117 @@ def test_exec_job_exits_on_missing_record(mock_session): exec_job(uuid4()) -def test_submit_and_wait_creates_job(mock_session): - job = _make_job_exec() - job.status = JobStatus.COMPLETED - mock_session.flush = Mock() +def test_job_dispatch_has_all_job_keys(): + assert "run-profile" in JOB_DISPATCH + assert "run-tests" in JOB_DISPATCH + assert "run-monitors" in JOB_DISPATCH + assert "run-test-generation" in JOB_DISPATCH + assert "run-score-update" in JOB_DISPATCH + + +def test_exec_job_fires_final_callbacks_on_success(mock_session): + job = _make_job_exec(job_key="run-tests") + job.mark_running.return_value = True + job.mark_completed.return_value = True + cb1, cb2 = Mock(), Mock() with ( - patch.object(JobExecution, "submit", return_value=job) as submit_mock, patch.object(JobExecution, "get", return_value=job), + patch.dict(JOB_DISPATCH, {"run-tests": Mock(return_value="ok")}), + patch.dict(JOB_FINAL_CALLBACKS, {"run-tests": [cb1, cb2]}), ): - submit_and_wait("run-tests", {"test_suite_id": "suite-123"}, "DEFAULT", no_wait=False) + exec_job(job.id) - submit_mock.assert_called_once_with( - job_key="run-tests", - kwargs={"test_suite_id": "suite-123"}, - source="cli", - project_code="DEFAULT", - ) + cb1.assert_called_once_with(job) + cb2.assert_called_once_with(job) -def test_submit_and_wait_no_wait_returns_immediately(mock_session): - job = _make_job_exec() - mock_session.flush = Mock() +def test_exec_job_runs_callbacks_in_registered_order(mock_session): + job = _make_job_exec(job_key="run-tests") + job.mark_running.return_value = True + job.mark_completed.return_value = True + order = [] + cb1 = Mock(side_effect=lambda _: order.append("cb1")) + cb2 = Mock(side_effect=lambda _: order.append("cb2")) with ( - patch.object(JobExecution, "submit", return_value=job) as submit_mock, + patch.object(JobExecution, "get", return_value=job), + patch.dict(JOB_DISPATCH, {"run-tests": Mock(return_value="ok")}), + patch.dict(JOB_FINAL_CALLBACKS, {"run-tests": [cb1, cb2]}), ): - submit_and_wait("run-tests", {"test_suite_id": "suite-123"}, "DEFAULT", no_wait=True) + exec_job(job.id) - submit_mock.assert_called_once() + assert order == ["cb1", "cb2"] -def test_submit_and_wait_exits_on_error(mock_session): - job = _make_job_exec() - job.status = JobStatus.ERROR - job.error_message = "something broke" - mock_session.flush = Mock() +def test_exec_job_skips_callbacks_when_mark_completed_fails(mock_session): + job = _make_job_exec(job_key="run-tests") + job.mark_running.return_value = True + job.mark_completed.return_value = False + cb = Mock() with ( - patch.object(JobExecution, "submit", return_value=job), patch.object(JobExecution, "get", return_value=job), - patch(f"{EXEC_JOB_MODULE}.time.sleep"), - pytest.raises(SystemExit, match="1"), + patch.dict(JOB_DISPATCH, {"run-tests": Mock(return_value="ok")}), + patch.dict(JOB_FINAL_CALLBACKS, {"run-tests": [cb]}), ): - submit_and_wait("run-tests", {"test_suite_id": "suite-123"}, "DEFAULT", no_wait=False) + exec_job(job.id) + cb.assert_not_called() -def test_job_dispatch_has_all_job_keys(): - assert "run-profile" in JOB_DISPATCH - assert "run-tests" in JOB_DISPATCH - assert "run-monitors" in JOB_DISPATCH - assert "run-test-generation" in JOB_DISPATCH + +def test_exec_job_fires_callbacks_on_interrupted(mock_session): + job = _make_job_exec(job_key="run-tests") + job.mark_running.return_value = True + job.mark_interrupted.return_value = True + cb = Mock() + + with ( + patch.object(JobExecution, "get", return_value=job), + patch.dict(JOB_DISPATCH, {"run-tests": Mock(side_effect=RuntimeError("boom"))}), + patch.dict(JOB_FINAL_CALLBACKS, {"run-tests": [cb]}), + ): + exec_job(job.id) + + cb.assert_called_once_with(job) + + +def test_exec_job_skips_callbacks_when_mark_interrupted_fails(mock_session): + job = _make_job_exec(job_key="run-tests") + job.mark_running.return_value = True + job.mark_interrupted.return_value = False + cb = Mock() + + with ( + patch.object(JobExecution, "get", return_value=job), + patch.dict(JOB_DISPATCH, {"run-tests": Mock(side_effect=RuntimeError("boom"))}), + patch.dict(JOB_FINAL_CALLBACKS, {"run-tests": [cb]}), + ): + exec_job(job.id) + + cb.assert_not_called() + + +def test_run_final_callbacks_isolates_failures(): + job = _make_job_exec(job_key="run-tests") + failing = Mock(side_effect=RuntimeError("boom"), __name__="failing_cb") + succeeding = Mock(__name__="succeeding_cb") + + with patch.dict(JOB_FINAL_CALLBACKS, {"run-tests": [failing, succeeding]}): + run_final_callbacks(job) + + failing.assert_called_once_with(job) + succeeding.assert_called_once_with(job) + + +def test_run_final_callbacks_noop_for_unknown_job_key(): + job = _make_job_exec(job_key="something-unregistered") + + with patch.dict(JOB_FINAL_CALLBACKS, {}, clear=False): + run_final_callbacks(job) + + +def test_registered_callbacks_cover_notification_job_keys(): + assert "run-profile" in JOB_FINAL_CALLBACKS + assert "run-tests" in JOB_FINAL_CALLBACKS + assert "run-monitors" in JOB_FINAL_CALLBACKS diff --git a/tests/unit/commands/test_job_runner.py b/tests/unit/commands/test_job_runner.py new file mode 100644 index 00000000..3ac4ffa5 --- /dev/null +++ b/tests/unit/commands/test_job_runner.py @@ -0,0 +1,77 @@ +from unittest.mock import MagicMock, Mock, patch +from uuid import uuid4 + +import pytest + +from testgen.commands.job_runner import submit_and_wait +from testgen.common.models.job_execution import JobExecution, JobStatus + +pytestmark = pytest.mark.unit + +JOB_RUNNER_MODULE = "testgen.commands.job_runner" + + +@pytest.fixture +def mock_session(): + session = MagicMock() + session.__enter__ = Mock(return_value=session) + session.__exit__ = Mock(return_value=False) + with patch("testgen.common.models.Session", return_value=session): + yield session + + +def _make_job_exec(job_key="run-tests", status="claimed", **kwargs): + job = MagicMock(spec=JobExecution) + job.id = uuid4() + job.job_key = job_key + job.kwargs = {"test_suite_id": "suite-123"} + job.source = "api" + job.status = status + job.configure_mock(**kwargs) + return job + + +def test_submit_and_wait_creates_job(mock_session): + job = _make_job_exec() + job.status = JobStatus.COMPLETED + mock_session.flush = Mock() + + with ( + patch.object(JobExecution, "submit", return_value=job) as submit_mock, + patch.object(JobExecution, "get", return_value=job), + ): + submit_and_wait("run-tests", {"test_suite_id": "suite-123"}, "DEFAULT", no_wait=False) + + submit_mock.assert_called_once_with( + job_key="run-tests", + kwargs={"test_suite_id": "suite-123"}, + source="cli", + project_code="DEFAULT", + ) + + +def test_submit_and_wait_no_wait_returns_immediately(mock_session): + job = _make_job_exec() + mock_session.flush = Mock() + + with ( + patch.object(JobExecution, "submit", return_value=job) as submit_mock, + ): + submit_and_wait("run-tests", {"test_suite_id": "suite-123"}, "DEFAULT", no_wait=True) + + submit_mock.assert_called_once() + + +def test_submit_and_wait_exits_on_error(mock_session): + job = _make_job_exec() + job.status = JobStatus.ERROR + job.error_message = "something broke" + mock_session.flush = Mock() + + with ( + patch.object(JobExecution, "submit", return_value=job), + patch.object(JobExecution, "get", return_value=job), + patch(f"{JOB_RUNNER_MODULE}.time.sleep"), + pytest.raises(SystemExit, match="1"), + ): + submit_and_wait("run-tests", {"test_suite_id": "suite-123"}, "DEFAULT", no_wait=False) diff --git a/tests/unit/scheduler/test_scheduler_cli.py b/tests/unit/scheduler/test_scheduler_cli.py index f4e040ed..ce2acec3 100644 --- a/tests/unit/scheduler/test_scheduler_cli.py +++ b/tests/unit/scheduler/test_scheduler_cli.py @@ -49,7 +49,7 @@ def db_jobs(scheduler_instance): @pytest.fixture def job_data(): - with patch.dict("testgen.commands.exec_job.JOB_DISPATCH", {"test-job": Mock()}): + with patch.dict("testgen.commands.job_registry.JOB_DISPATCH", {"test-job": Mock()}): yield { "cron_expr": "*/5 9-17 * * *", "cron_tz": "UTC", diff --git a/tests/unit/scheduler/test_scheduler_poll.py b/tests/unit/scheduler/test_scheduler_poll.py index 4ee3411e..75ef1755 100644 --- a/tests/unit/scheduler/test_scheduler_poll.py +++ b/tests/unit/scheduler/test_scheduler_poll.py @@ -4,7 +4,7 @@ import pytest -from testgen.commands.exec_job import JOB_DISPATCH +from testgen.commands.job_registry import JOB_DISPATCH from testgen.common.models.job_execution import JobExecution, JobStatus from testgen.scheduler.cli_scheduler import CliScheduler From f44b447b1d47f23784370171634c59f50a8d80d4 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Fri, 24 Apr 2026 17:28:29 -0300 Subject: [PATCH 072/123] fix(mcp): surface thresholds in test run diff (TG-1027) Address CR feedback on !477: - Add Threshold A / Threshold B columns to get_test_run_diff so threshold changes between runs are visible alongside measure changes. - Drop "short name" from search_test_results / get_failure_trend docstrings; "test type" is the user-facing term across MCP. --- testgen/common/models/test_result.py | 6 ++++++ testgen/mcp/tools/test_results.py | 8 +++++--- tests/unit/mcp/test_tools_test_results.py | 10 +++++++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/testgen/common/models/test_result.py b/testgen/common/models/test_result.py index dd036f98..90538adf 100644 --- a/testgen/common/models/test_result.py +++ b/testgen/common/models/test_result.py @@ -91,6 +91,8 @@ class DiffRow: status_b: TestResultStatus | None measure_a: str | None measure_b: str | None + threshold_a: str | None + threshold_b: str | None @dataclass @@ -425,6 +427,7 @@ def _fetch(run_id: UUID) -> dict[UUID, dict]: cls.column_names.label("column_names"), cls.status.label("status"), cls.result_measure.label("result_measure"), + cls.threshold_value.label("threshold_value"), ) .outerjoin(TestType, cls.test_type == TestType.test_type) .where( @@ -440,6 +443,7 @@ def _fetch(run_id: UUID) -> dict[UUID, dict]: "column_names": row.column_names, "status": row.status, "measure": row.result_measure, + "threshold": row.threshold_value, } for row in get_current_session().execute(query) } @@ -456,6 +460,8 @@ def _row(tid: UUID, info_a: dict | None, info_b: dict | None) -> DiffRow: status_b=info_b["status"] if info_b else None, measure_a=info_a["measure"] if info_a else None, measure_b=info_b["measure"] if info_b else None, + threshold_a=info_a["threshold"] if info_a else None, + threshold_b=info_b["threshold"] if info_b else None, ) results_a = _fetch(test_run_id_a) diff --git a/testgen/mcp/tools/test_results.py b/testgen/mcp/tools/test_results.py index 1f19b499..336bba7e 100644 --- a/testgen/mcp/tools/test_results.py +++ b/testgen/mcp/tools/test_results.py @@ -284,7 +284,7 @@ def search_test_results( table_group_id: UUID of a table group to scope to. table_name: Filter by table name. column_name: Filter by column name. - test_type: Filter by test type short name (e.g. 'Pattern Match'). + test_type: Filter by test type (e.g. 'Pattern Match'). status: Filter by result statuses (defaults to ['Failed', 'Warning']). since: Include results since this point — e.g. '7 days', '2 weeks', '2026-04-01'. limit: Maximum results per page (default 50). @@ -379,7 +379,7 @@ def get_failure_trend( test_suite_id: UUID of a test suite to scope to. table_group_id: UUID of a table group to scope to. table_name: Filter by table name. - test_type: Filter by test type short name. + test_type: Filter by test type. since: Include runs since this point — e.g. '30 days', '2 weeks', '2026-04-01' (default '30 days'). bucket: Time bucket size — 'day' or 'week' (default 'day'). exclude_today: If True (default), buckets end yesterday; set False to also compute today's incomplete data. @@ -531,7 +531,7 @@ def _section(title: str, rows: list) -> None: return doc.heading(2, title) doc.table( - headers=["Test Type", "Table", "Column", "A → B", "Measure A", "Measure B"], + headers=["Test Type", "Table", "Column", "A → B", "Measure A", "Measure B", "Threshold A", "Threshold B"], rows=[ [ row.test_name_short or row.test_type, @@ -540,6 +540,8 @@ def _section(title: str, rows: list) -> None: f"{row.status_a.value if row.status_a else '—'} → {row.status_b.value if row.status_b else '—'}", row.measure_a, row.measure_b, + row.threshold_a, + row.threshold_b, ] for row in rows ], diff --git a/tests/unit/mcp/test_tools_test_results.py b/tests/unit/mcp/test_tools_test_results.py index c50c5b01..f9dadf6a 100644 --- a/tests/unit/mcp/test_tools_test_results.py +++ b/tests/unit/mcp/test_tools_test_results.py @@ -600,6 +600,8 @@ def _mock_diff_row(status_a, status_b, **overrides): row.status_b = status_b row.measure_a = "5" row.measure_b = "12" + row.threshold_a = "0" + row.threshold_b = "0" for k, v in overrides.items(): setattr(row, k, v) return row @@ -626,7 +628,11 @@ def test_get_test_run_diff_happy_path( diff = MagicMock() diff.total_a = 100 diff.total_b = 100 - diff.regressions = [_mock_diff_row(TestResultStatus.Passed, TestResultStatus.Failed)] + diff.regressions = [ + _mock_diff_row( + TestResultStatus.Passed, TestResultStatus.Failed, threshold_a="1", threshold_b="3", + ) + ] diff.improvements = [] diff.persistent_failures = [] diff.new_tests = [] @@ -641,6 +647,8 @@ def test_get_test_run_diff_happy_path( assert "Regressions" in out assert "Pattern Match" in out assert "Passed → Failed" in out + assert "Threshold A" in out and "Threshold B" in out + assert "| 1 | 3 |" in out # threshold columns populated when thresholds changed @patch("testgen.mcp.tools.test_results.TestSuite") From de9e98e44a78c08b425ca9fa373cfbac498b07ed Mon Sep 17 00:00:00 2001 From: Luis Date: Fri, 24 Apr 2026 17:47:15 -0400 Subject: [PATCH 073/123] fix(ui): clear intervals before leaving the current page --- .../frontend/js/pages/profiling_runs.js | 15 ++++-- .../components/frontend/js/pages/test_runs.js | 15 ++++-- testgen/ui/static/js/page_lifecycle.js | 46 +++++++++++++++++++ testgen/ui/static/js/timers.js | 23 ++++++++++ 4 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 testgen/ui/static/js/page_lifecycle.js create mode 100644 testgen/ui/static/js/timers.js diff --git a/testgen/ui/components/frontend/js/pages/profiling_runs.js b/testgen/ui/components/frontend/js/pages/profiling_runs.js index 665a9f33..74bfa329 100644 --- a/testgen/ui/components/frontend/js/pages/profiling_runs.js +++ b/testgen/ui/components/frontend/js/pages/profiling_runs.js @@ -67,9 +67,12 @@ import { Dialog } from '/app/static/js/components/dialog.js'; import { RunProfilingDialog } from '/app/static/js/components/run_profiling_dialog.js'; import { ScheduleList } from '/app/static/js/components/schedule_list.js'; import { NotificationSettings } from '/app/static/js/components/notification_settings.js'; +import { enterPage, exitPage } from '/app/static/js/page_lifecycle.js'; +import { setIntervalWithSignal } from '/app/static/js/timers.js'; const { b, div, i, span, strong } = van.tags; const SCROLL_CONTAINER = window.top.document.querySelector('.stMain'); +const PAGE_KEY = 'profilingRuns'; const STARTING_STATUSES = new Set(['pending', 'claimed']); const RUNNING_STATUSES = new Set(['running', 'cancel_requested']); @@ -88,8 +91,8 @@ const progressStatusIcons = { }; const ProfilingRuns = (/** @type Properties */ props) => { - const { emit } = props; - loadStylesheet('profilingRuns', stylesheet); + const { emit, signal } = props; + loadStylesheet(PAGE_KEY, stylesheet); const columns = ['5%', '20%', '15%', '20%', '30%', '10%']; const userCanEdit = getValue(props.permissions)?.can_edit ?? false; @@ -105,7 +108,7 @@ const ProfilingRuns = (/** @type Properties */ props) => { const rate = hasStarting ? REFRESH_STARTING : hasRunning ? REFRESH_RUNNING : REFRESH_DEFAULT; if (rate !== currentRefreshRate) { if (refreshIntervalId) clearInterval(refreshIntervalId); - refreshIntervalId = setInterval(() => emit('RefreshData', {}), rate); + refreshIntervalId = setIntervalWithSignal(() => emit('RefreshData', {}), rate, signal); currentRefreshRate = rate; } }); @@ -659,6 +662,7 @@ export default (component) => { } parentElement.state = componentState; componentState.emit = createEmitter(setTriggerValue); + componentState.signal = enterPage(PAGE_KEY); van.add(parentElement, ProfilingRuns(componentState)); } else { for (const [key, value] of Object.entries(data)) { @@ -668,5 +672,8 @@ export default (component) => { } } - return () => { parentElement.state = null; }; + return () => { + exitPage(PAGE_KEY); + parentElement.state = null; + }; }; diff --git a/testgen/ui/components/frontend/js/pages/test_runs.js b/testgen/ui/components/frontend/js/pages/test_runs.js index 0418a33c..4a5b06a3 100644 --- a/testgen/ui/components/frontend/js/pages/test_runs.js +++ b/testgen/ui/components/frontend/js/pages/test_runs.js @@ -66,9 +66,12 @@ import { Dialog } from '/app/static/js/components/dialog.js'; import { RunTestsDialog } from '/app/static/js/components/run_tests_dialog.js'; import { ScheduleList } from '/app/static/js/components/schedule_list.js'; import { NotificationSettings } from '/app/static/js/components/notification_settings.js'; +import { enterPage, exitPage } from '/app/static/js/page_lifecycle.js'; +import { setIntervalWithSignal } from '/app/static/js/timers.js'; const { b, div, i, span, strong } = van.tags; const SCROLL_CONTAINER = window.top.document.querySelector('.stMain'); +const PAGE_KEY = 'testRuns'; const STARTING_STATUSES = new Set(['pending', 'claimed']); const RUNNING_STATUSES = new Set(['running', 'cancel_requested']); @@ -87,8 +90,8 @@ const progressStatusIcons = { }; const TestRuns = (/** @type Properties */ props) => { - const { emit } = props; - loadStylesheet('testRuns', stylesheet); + const { emit, signal } = props; + loadStylesheet(PAGE_KEY, stylesheet); const columns = ['5%', '28%', '17%', '40%', '10%']; const userCanEdit = getValue(props.permissions)?.can_edit ?? false; @@ -106,7 +109,7 @@ const TestRuns = (/** @type Properties */ props) => { const rate = hasStarting ? REFRESH_STARTING : hasRunning ? REFRESH_RUNNING : REFRESH_DEFAULT; if (rate !== currentRefreshRate) { if (refreshIntervalId) clearInterval(refreshIntervalId); - refreshIntervalId = setInterval(() => emit('RefreshData', {}), rate); + refreshIntervalId = setIntervalWithSignal(() => emit('RefreshData', {}), rate, signal); currentRefreshRate = rate; } }); @@ -642,6 +645,7 @@ export default (component) => { } parentElement.state = componentState; componentState.emit = createEmitter(setTriggerValue); + componentState.signal = enterPage(PAGE_KEY); van.add(parentElement, TestRuns(componentState)); } else { for (const [key, value] of Object.entries(data)) { @@ -651,5 +655,8 @@ export default (component) => { } } - return () => { parentElement.state = null; }; + return () => { + exitPage(PAGE_KEY); + parentElement.state = null; + }; }; diff --git a/testgen/ui/static/js/page_lifecycle.js b/testgen/ui/static/js/page_lifecycle.js new file mode 100644 index 00000000..027e206d --- /dev/null +++ b/testgen/ui/static/js/page_lifecycle.js @@ -0,0 +1,46 @@ +/** + * Per-page AbortController registry. Pages call enterPage() on mount to obtain + * an AbortSignal, then exitPage() on teardown to abort any side effects tied + * to that signal (intervals, fetches, event listeners). + */ + +const controllers = new Map(); + +/** + * Begin a page's lifetime. If a controller is still registered for this key + * (previous teardown never ran), abort it before creating a new one. + * @param {string} pageKey + * @returns {AbortSignal} + */ +function enterPage(pageKey) { + controllers.get(pageKey)?.abort(); + const controller = new AbortController(); + controllers.set(pageKey, controller); + return controller.signal; +} + +/** + * End a page's lifetime: abort all listeners attached to the signal and + * drop the controller. Safe to call if no controller is registered. + * @param {string} pageKey + */ +function exitPage(pageKey) { + const controller = controllers.get(pageKey); + if (!controller) { + return; + } + controller.abort(); + controllers.delete(pageKey); +} + +/** + * Read the current signal for a page, or null if the page isn't active. + * Lets descendant components register cleanup without prop drilling. + * @param {string} pageKey + * @returns {AbortSignal | null} + */ +function getPageSignal(pageKey) { + return controllers.get(pageKey)?.signal ?? null; +} + +export { enterPage, exitPage, getPageSignal }; diff --git a/testgen/ui/static/js/timers.js b/testgen/ui/static/js/timers.js new file mode 100644 index 00000000..c6a8bb29 --- /dev/null +++ b/testgen/ui/static/js/timers.js @@ -0,0 +1,23 @@ +/** + * Timer helpers that integrate with AbortSignal so callers can tear down + * long-lived timers through a single controller. + */ + +/** + * setInterval that clears itself when the given signal aborts. + * Returns null if the signal is already aborted. + * @param {Function} fn + * @param {number} ms + * @param {AbortSignal} [signal] + * @returns {(number | null)} + */ +function setIntervalWithSignal(fn, ms, signal) { + if (signal?.aborted) { + return null; + } + const id = setInterval(fn, ms); + signal?.addEventListener('abort', () => clearInterval(id), { once: true }); + return id; +} + +export { setIntervalWithSignal }; From ee3ab2caf618c9320323cbcbf4c98a004ae58bb4 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Mon, 27 Apr 2026 16:48:30 -0300 Subject: [PATCH 074/123] feat(mcp): list_test_results rename + test_suite_id shortcut (TG-1055) - Rename get_test_results to list_test_results, aligning with the list_X / search_X pattern (TG-1026, TG-1029). - Add test_suite_id shortcut to list_test_results: when supplied (mutually exclusive with job_execution_id), resolves to the latest completed run for the suite, with monitor-suite exclusion via TestSuite.get_regular. - Apply the unified "not found or not accessible" error pattern to the job_execution_id branch (closes an existence-leak where forbidden-project runs and monitor-suite runs returned a different message than non-existent runs). - Sweep job_execution_id parameter docstrings on list_test_results, get_failure_summary, and get_test_run_diff to name the run kind explicitly ("UUID of a test run, e.g. from get_recent_test_runs") so the LLM can chain output values labeled "Run ID" into JE-taking tools. - Standardize ID-source hints on "e.g. from \`tool\`" form across the modified docstrings. - Update investigate_failures, table_health, and compare_runs prompts to use the suite shortcut where applicable. - Document the conventions in .claude/docs/mcp-patterns.md (new "Tool naming: get / list / search" section, plus run-kind and ID-hint rules under "MCP tool docstrings"). Co-Authored-By: Claude Opus 4.7 (1M context) --- testgen/mcp/prompts/workflows.py | 13 +- testgen/mcp/server.py | 6 +- testgen/mcp/tools/source_data.py | 4 +- testgen/mcp/tools/test_results.py | 66 +++++-- testgen/mcp/tools/test_runs.py | 2 +- tests/unit/mcp/test_tools_test_results.py | 206 +++++++++++++++++++--- 6 files changed, 238 insertions(+), 59 deletions(-) diff --git a/testgen/mcp/prompts/workflows.py b/testgen/mcp/prompts/workflows.py index 57f7281b..a4061a7b 100644 --- a/testgen/mcp/prompts/workflows.py +++ b/testgen/mcp/prompts/workflows.py @@ -32,7 +32,7 @@ def investigate_failures(test_suite: str | None = None) -> str: 2. Call `get_recent_test_runs(...)` to find the latest run per suite{f" for suite `{test_suite}`" if test_suite else ""}. 3. Call `get_failure_summary(job_execution_id='...')` to see failures grouped by test type. 4. For each failure category, call `get_test_type(test_type='...')` to understand what the test checks. -5. Call `get_test_results(job_execution_id='...', status='Failed')` to see individual failure details. +5. Call `list_test_results(test_suite_id='...', status='Failed')` to drill into the specific failing tests in the latest run. 6. For key failures, call `get_source_data(test_definition_id='...')` to see the actual rows violating the test criteria. This shows current data from the connected database — rows may have been fixed since the test ran. 7. Analyze the patterns: @@ -54,13 +54,12 @@ def table_health(table_name: str) -> str: 1. Call `get_data_inventory()` to discover all table groups. 2. For each table group, call `list_tables(table_group_id='...')` to check if it contains `{table_name}`. -3. For each relevant test suite, call `get_recent_test_runs(...)` to find the latest run. -4. Call `get_test_results(job_execution_id='...', table_name='{table_name}')` to get all results for this table. -5. Summarize the table's health: +3. For each relevant test suite, call `list_test_results(test_suite_id='...', table_name='{table_name}')` to see results from the latest run for this table. +4. Summarize the table's health: - Which tests pass and which fail? - What data quality dimensions are affected? - Are there patterns in the failures (e.g., specific columns)? -6. Provide recommendations for improving data quality for this table. +5. Provide recommendations for improving data quality for this table. """ @@ -77,8 +76,8 @@ def compare_runs(test_suite: str | None = None) -> str: 1. Call `get_data_inventory()` to understand the project structure. 2. Call `list_test_suites(project_code='...')` to find suites{suite_filter} and their latest runs. -3. For the most recent completed run, call `get_test_results(job_execution_id='...')` to get all results. -4. For the previous run, call `get_test_results(job_execution_id='...')` to get all results. +3. For the most recent completed run, call `list_test_results(test_suite_id='...')` to get all results. +4. For the previous run, call `list_test_results(job_execution_id='...')` to get all results. 5. Compare the two runs: - **Regressions:** Tests that passed before but now fail. - **Improvements:** Tests that failed before but now pass. diff --git a/testgen/mcp/server.py b/testgen/mcp/server.py index 33d76b12..35753268 100644 --- a/testgen/mcp/server.py +++ b/testgen/mcp/server.py @@ -32,7 +32,7 @@ INVESTIGATING FAILURES -Use get_test_results to find failures, then get_source_data to see relevant data from the connected database. +Use list_test_results to find failures, then get_source_data to see relevant data from the connected database. Results reflect the current state of the data — values may have changed since the test ran. Use get_source_data_query to preview the SQL without executing it. @@ -96,8 +96,8 @@ def build_mcp_app( get_failure_summary, get_failure_trend, get_test_result_history, - get_test_results, get_test_run_diff, + list_test_results, search_test_results, ) from testgen.mcp.tools.test_runs import get_recent_test_runs @@ -131,7 +131,7 @@ def safe_prompt(fn): safe_tool(list_tables) safe_tool(list_test_suites) safe_tool(get_recent_test_runs) - safe_tool(get_test_results) + safe_tool(list_test_results) safe_tool(get_test_result_history) safe_tool(get_failure_summary) safe_tool(search_test_results) diff --git a/testgen/mcp/tools/source_data.py b/testgen/mcp/tools/source_data.py index 1cd5a63f..36f0387a 100644 --- a/testgen/mcp/tools/source_data.py +++ b/testgen/mcp/tools/source_data.py @@ -52,7 +52,7 @@ def get_source_data_query( Some test types (e.g. Freshness Trend, Schema Drift) do not have source data lookups. Args: - test_definition_id: UUID of the test definition (from get_test_results output). + test_definition_id: UUID of a test definition, e.g. from ``list_test_results``. reference_date: ISO 8601 date used as the test reference point (default: now). limit: Maximum rows the query would return (default 100, max 500). """ @@ -92,7 +92,7 @@ def get_source_data( Some test types (e.g. Freshness Trend, Schema Drift) do not have source data lookups. Args: - test_definition_id: UUID of the test definition (from get_test_results output). + test_definition_id: UUID of a test definition, e.g. from ``list_test_results``. reference_date: ISO 8601 date used as the test reference point (default: now). limit: Maximum rows to return (default 100, max 500). """ diff --git a/testgen/mcp/tools/test_results.py b/testgen/mcp/tools/test_results.py index 336bba7e..5841cc09 100644 --- a/testgen/mcp/tools/test_results.py +++ b/testgen/mcp/tools/test_results.py @@ -22,36 +22,63 @@ @with_database_session @mcp_permission("view") -def get_test_results( - job_execution_id: str, +def list_test_results( + job_execution_id: str | None = None, + test_suite_id: str | None = None, status: str | None = None, table_name: str | None = None, test_type: str | None = None, limit: int = 50, page: int = 1, ) -> str: - """Get individual test results for a test run, with optional filters. + """List individual test results for a test run, with optional filters. + + Provide either ``job_execution_id`` for a specific run, or ``test_suite_id`` to use + the latest completed run of that suite. Args: - job_execution_id: The UUID of the job execution for the test run. + job_execution_id: UUID of a test run, e.g. from ``get_recent_test_runs`` or + ``list_test_suites``. + test_suite_id: UUID of a test suite. Resolves to the latest completed test run + for the suite. Mutually exclusive with ``job_execution_id``. status: Filter by result status (Passed, Failed, Warning, Error, Log). table_name: Filter by table name. test_type: Filter by test type (e.g. 'Alpha Truncation', 'Unique Percent'). limit: Maximum number of results per page (default 50). page: Page number, starting from 1 (default 1). """ - job_uuid = parse_uuid(job_execution_id, "job_execution_id") - test_run = TestRun.get_by_id_or_job(job_uuid) - if not test_run: - raise MCPUserError(f"No test run found for job execution `{job_execution_id}`.") + if job_execution_id and test_suite_id: + raise MCPUserError("Pass either `job_execution_id` or `test_suite_id`, not both.") + if not job_execution_id and not test_suite_id: + raise MCPUserError("Provide either `job_execution_id` or `test_suite_id`.") + + perms = get_project_permissions() + + resolved_via_suite = False + if test_suite_id: + suite_uuid = parse_uuid(test_suite_id, "test_suite_id") + suite = TestSuite.get_regular(suite_uuid) + if suite is None or not perms.has_access(suite.project_code): + raise MCPUserError(f"Test suite `{test_suite_id}` not found or not accessible.") + if suite.last_complete_test_run_id is None: + raise MCPUserError(f"No completed test runs found for test suite `{test_suite_id}`.") + test_run = TestRun.get_by_id_or_job(suite.last_complete_test_run_id) + if test_run is None: + raise MCPUserError(f"No completed test runs found for test suite `{test_suite_id}`.") + resolved_via_suite = True + run_id_label = str(test_run.job_execution_id) + else: + job_uuid = parse_uuid(job_execution_id, "job_execution_id") + test_run = TestRun.get_by_id_or_job(job_uuid) + suite = TestSuite.get_regular(test_run.test_suite_id) if test_run else None + if test_run is None or suite is None or not perms.has_access(suite.project_code): + raise MCPUserError(f"Test run `{job_execution_id}` not found or not accessible.") + run_id_label = job_execution_id status_enum = parse_result_status(status) if status else None offset = (page - 1) * limit - test_type_code = resolve_test_type(test_type) if test_type else None - perms = get_project_permissions() - results = TestResult.select_results( test_run_id=test_run.id, status=status_enum, @@ -71,12 +98,14 @@ def get_test_results( if test_type: filters.append(f"type={test_type}") filter_str = f" (filters: {', '.join(filters)})" if filters else "" - return f"No test results found for run `{job_execution_id}`{filter_str}." + return f"No test results found for run `{run_id_label}`{filter_str}." type_names = {tt.test_type: tt.test_name_short for tt in TestType.select_where(TestType.active == "Y")} doc = MdDoc() - doc.heading(1, f"Test Results for run `{job_execution_id}`") + doc.heading(1, f"Test Results for run `{run_id_label}`") + if resolved_via_suite: + doc.text(f"_Latest completed run of test suite `{test_suite_id}`._") doc.text(f"Showing {len(results)} result(s) (page {page}).") for r in results: @@ -118,7 +147,8 @@ def get_failure_summary( Args: project_code: Scope to a project the caller can view. Ignored if ``job_execution_id`` is set. test_suite_id: UUID of a test suite to scope the aggregation to. - job_execution_id: UUID of a job execution to scope to a single run. + job_execution_id: UUID of a test run, e.g. from ``get_recent_test_runs``, + to scope the summary to a single run. since: Include runs since this point in time — e.g. '7 days', '2 weeks', '2026-04-01'. group_by: Group failures by 'test_type', 'table', or 'column' (default: 'test_type'). """ @@ -219,7 +249,7 @@ def get_test_result_history( """Get the historical results of a specific test definition across runs, showing how measure and status changed over time. Args: - test_definition_id: The UUID of the test definition (from get_test_results output). + test_definition_id: UUID of a test definition, e.g. from ``list_test_results``. limit: Maximum number of historical results per page (default 20). page: Page number, starting from 1 (default 1). """ @@ -275,7 +305,7 @@ def search_test_results( ) -> str: """Search test results across multiple runs with flexible filters. - To drill into a single run, use ``get_test_results``. For a single test's history, use + To drill into a single run, use ``list_test_results``. For a single test's history, use ``get_test_result_history``. Args: @@ -467,8 +497,8 @@ def get_test_run_diff(job_execution_id_a: str, job_execution_id_b: str) -> str: """Compare two test runs and report regressions, improvements, persistent failures, and added/removed tests. Args: - job_execution_id_a: UUID of the older (baseline) run. - job_execution_id_b: UUID of the newer run to compare against the baseline. + job_execution_id_a: UUID of the older (baseline) test run, e.g. from ``get_recent_test_runs``. + job_execution_id_b: UUID of the newer test run. """ uuid_a = parse_uuid(job_execution_id_a, "job_execution_id_a") uuid_b = parse_uuid(job_execution_id_b, "job_execution_id_b") diff --git a/testgen/mcp/tools/test_runs.py b/testgen/mcp/tools/test_runs.py index 5509c1cb..6df736a4 100644 --- a/testgen/mcp/tools/test_runs.py +++ b/testgen/mcp/tools/test_runs.py @@ -76,6 +76,6 @@ def get_recent_test_runs(project_code: str, test_suite: str | None = None, limit if run.dq_score_testing is not None: doc.field("Testing Score", f"{run.dq_score_testing:.1f}") - doc.text("Use `get_test_results(job_execution_id='...')` for detailed results of a specific run.") + doc.text("Use `list_test_results(job_execution_id='...')` for detailed results of a specific run.") return doc.render() diff --git a/tests/unit/mcp/test_tools_test_results.py b/tests/unit/mcp/test_tools_test_results.py index f9dadf6a..7e7c2e87 100644 --- a/tests/unit/mcp/test_tools_test_results.py +++ b/tests/unit/mcp/test_tools_test_results.py @@ -16,12 +16,14 @@ def _mock_test_run(test_run_id=None): return run +@patch("testgen.mcp.tools.test_results.TestSuite") @patch("testgen.mcp.tools.test_results.TestRun") @patch("testgen.mcp.tools.test_results.TestType") @patch("testgen.mcp.tools.test_results.TestResult") -def test_get_test_results_basic(mock_result, mock_tt_cls, mock_test_run_cls, db_session_mock): +def test_list_test_results_basic(mock_result, mock_tt_cls, mock_test_run_cls, mock_suite_cls, db_session_mock): job_id = str(uuid4()) mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() + mock_suite_cls.get_regular.return_value = _mock_test_suite() r1 = MagicMock() r1.status = TestResultStatus.Failed @@ -39,9 +41,9 @@ def test_get_test_results_basic(mock_result, mock_tt_cls, mock_test_run_cls, db_ tt.test_name_short = "Alpha Truncation" mock_tt_cls.select_where.return_value = [tt] - from testgen.mcp.tools.test_results import get_test_results + from testgen.mcp.tools.test_results import list_test_results - result = get_test_results(job_id) + result = list_test_results(job_id) assert "Alpha Truncation" in result assert "Alpha_Trunc" not in result @@ -50,11 +52,13 @@ def test_get_test_results_basic(mock_result, mock_tt_cls, mock_test_run_cls, db_ assert "Truncation detected" in result +@patch("testgen.mcp.tools.test_results.TestSuite") @patch("testgen.mcp.tools.test_results.TestRun") @patch("testgen.mcp.tools.test_results.TestType") @patch("testgen.mcp.tools.test_results.TestResult") -def test_get_test_results_table_level_title(mock_result, mock_tt_cls, mock_test_run_cls, db_session_mock): +def test_list_test_results_table_level_title(mock_result, mock_tt_cls, mock_test_run_cls, mock_suite_cls, db_session_mock): mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() + mock_suite_cls.get_regular.return_value = _mock_test_suite() r1 = MagicMock() r1.status = TestResultStatus.Passed @@ -72,33 +76,39 @@ def test_get_test_results_table_level_title(mock_result, mock_tt_cls, mock_test_ tt.test_name_short = "Row Count" mock_tt_cls.select_where.return_value = [tt] - from testgen.mcp.tools.test_results import get_test_results + from testgen.mcp.tools.test_results import list_test_results - result = get_test_results(str(uuid4())) + result = list_test_results(str(uuid4())) assert "Row Count on `orders`" in result assert "` in `" not in result +@patch("testgen.mcp.tools.test_results.TestSuite") @patch("testgen.mcp.tools.test_results.TestRun") @patch("testgen.mcp.tools.test_results.TestResult") -def test_get_test_results_empty(mock_result, mock_test_run_cls, db_session_mock): +def test_list_test_results_empty(mock_result, mock_test_run_cls, mock_suite_cls, db_session_mock): mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() + mock_suite_cls.get_regular.return_value = _mock_test_suite() mock_result.select_results.return_value = [] - from testgen.mcp.tools.test_results import get_test_results + from testgen.mcp.tools.test_results import list_test_results - result = get_test_results(str(uuid4())) + result = list_test_results(str(uuid4())) assert "No test results found" in result +@patch("testgen.mcp.tools.test_results.TestSuite") @patch("testgen.mcp.tools.common.TestType") @patch("testgen.mcp.tools.test_results.TestRun") @patch("testgen.mcp.tools.test_results.TestType") @patch("testgen.mcp.tools.test_results.TestResult") -def test_get_test_results_with_filters(mock_result, mock_tt_cls, mock_test_run_cls, mock_tt_common, db_session_mock): +def test_list_test_results_with_filters( + mock_result, mock_tt_cls, mock_test_run_cls, mock_tt_common, mock_suite_cls, db_session_mock +): mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() + mock_suite_cls.get_regular.return_value = _mock_test_suite() tt = MagicMock() tt.test_type = "Alpha_Trunc" tt.test_name_short = "Alpha Truncation" @@ -106,79 +116,219 @@ def test_get_test_results_with_filters(mock_result, mock_tt_cls, mock_test_run_c mock_tt_common.select_where.return_value = [tt] mock_result.select_results.return_value = [] - from testgen.mcp.tools.test_results import get_test_results + from testgen.mcp.tools.test_results import list_test_results - result = get_test_results(str(uuid4()), status="Failed", table_name="orders", test_type="Alpha Truncation") + result = list_test_results(str(uuid4()), status="Failed", table_name="orders", test_type="Alpha Truncation") assert "status=Failed" in result assert "table=orders" in result assert "type=Alpha Truncation" in result -def test_get_test_results_invalid_uuid(db_session_mock): - from testgen.mcp.tools.test_results import get_test_results +def test_list_test_results_invalid_uuid(db_session_mock): + from testgen.mcp.tools.test_results import list_test_results with pytest.raises(MCPUserError, match="not a valid UUID"): - get_test_results("not-a-uuid") + list_test_results("not-a-uuid") +@patch("testgen.mcp.tools.test_results.TestSuite") @patch("testgen.mcp.tools.test_results.TestRun") -def test_get_test_results_invalid_status(mock_test_run_cls, db_session_mock): +def test_list_test_results_invalid_status(mock_test_run_cls, mock_suite_cls, db_session_mock): mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() + mock_suite_cls.get_regular.return_value = _mock_test_suite() - from testgen.mcp.tools.test_results import get_test_results + from testgen.mcp.tools.test_results import list_test_results with pytest.raises(MCPUserError, match="Invalid status"): - get_test_results(str(uuid4()), status="BadStatus") + list_test_results(str(uuid4()), status="BadStatus") @patch("testgen.mcp.tools.test_results.TestRun") -def test_get_test_results_run_not_found(mock_test_run_cls, db_session_mock): +def test_list_test_results_run_not_found(mock_test_run_cls, db_session_mock): mock_test_run_cls.get_by_id_or_job.return_value = None - from testgen.mcp.tools.test_results import get_test_results + from testgen.mcp.tools.test_results import list_test_results - with pytest.raises(MCPUserError, match="No test run found"): - get_test_results(str(uuid4())) + with pytest.raises(MCPUserError, match="not found or not accessible"): + list_test_results(str(uuid4())) +@patch("testgen.mcp.tools.test_results.TestSuite") +@patch("testgen.mcp.tools.test_results.TestRun") +def test_list_test_results_run_in_monitor_suite_rejected(mock_test_run_cls, mock_suite_cls, db_session_mock): + # Run exists, but the resolved suite is monitor → TestSuite.get_regular returns None. + mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() + mock_suite_cls.get_regular.return_value = None + + from testgen.mcp.tools.test_results import list_test_results + + with pytest.raises(MCPUserError, match="not found or not accessible"): + list_test_results(job_execution_id=str(uuid4())) + + +@patch("testgen.mcp.tools.test_results.TestSuite") +@patch("testgen.mcp.tools.test_results.TestRun") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_list_test_results_run_in_forbidden_project( + mock_compute, mock_test_run_cls, mock_suite_cls, db_session_mock +): + mock_compute.return_value = ProjectPermissions(memberships={"proj_a": "role_a"}, permission="view") + mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() + mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="forbidden_project") + + from testgen.mcp.tools.test_results import list_test_results + + with pytest.raises(MCPUserError, match="not found or not accessible"): + list_test_results(job_execution_id=str(uuid4())) + + +@patch("testgen.mcp.tools.test_results.TestSuite") @patch("testgen.mcp.tools.test_results.TestRun") @patch("testgen.mcp.tools.test_results.TestResult") @patch("testgen.mcp.permissions._compute_project_permissions") -def test_get_test_results_passes_project_codes(mock_compute, mock_result, mock_test_run_cls, db_session_mock): +def test_list_test_results_passes_project_codes( + mock_compute, mock_result, mock_test_run_cls, mock_suite_cls, db_session_mock +): mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", ) mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() + mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="proj_a") mock_result.select_results.return_value = [] - from testgen.mcp.tools.test_results import get_test_results + from testgen.mcp.tools.test_results import list_test_results - get_test_results(str(uuid4())) + list_test_results(str(uuid4())) call_kwargs = mock_result.select_results.call_args.kwargs assert call_kwargs["project_codes"] == ["proj_a"] +@patch("testgen.mcp.tools.test_results.TestSuite") @patch("testgen.mcp.tools.test_results.TestRun") @patch("testgen.mcp.tools.test_results.TestResult") @patch("testgen.mcp.tools.test_results.TestType") -def test_get_test_results_resolves_via_get_by_id_or_job(mock_tt_cls, mock_result, mock_test_run_cls, db_session_mock): +def test_list_test_results_resolves_via_get_by_id_or_job( + mock_tt_cls, mock_result, mock_test_run_cls, mock_suite_cls, db_session_mock +): """Verify the resolved test_run.id is passed to select_results.""" resolved_run_id = uuid4() mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run(resolved_run_id) + mock_suite_cls.get_regular.return_value = _mock_test_suite() mock_result.select_results.return_value = [] - from testgen.mcp.tools.test_results import get_test_results + from testgen.mcp.tools.test_results import list_test_results job_id = str(uuid4()) - get_test_results(job_id) + list_test_results(job_id) call_kwargs = mock_result.select_results.call_args.kwargs assert call_kwargs["test_run_id"] == resolved_run_id +def _mock_test_suite(suite_id=None, project_code="demo", last_complete_test_run_id=None): + """Create a mock TestSuite for the test_suite_id branch tests.""" + suite = MagicMock() + suite.id = suite_id or uuid4() + suite.project_code = project_code + suite.last_complete_test_run_id = last_complete_test_run_id + return suite + + +def test_list_test_results_both_args_rejected(db_session_mock): + from testgen.mcp.tools.test_results import list_test_results + + with pytest.raises(MCPUserError, match="Pass either"): + list_test_results(job_execution_id=str(uuid4()), test_suite_id=str(uuid4())) + + +def test_list_test_results_neither_arg_rejected(db_session_mock): + from testgen.mcp.tools.test_results import list_test_results + + with pytest.raises(MCPUserError, match="Provide either"): + list_test_results() + + +@patch("testgen.mcp.tools.test_results.TestSuite") +def test_list_test_results_by_suite_id_monitor_or_missing(mock_suite_cls, db_session_mock): + # TestSuite.get_regular returns None for monitor suites and unknown ids alike. + mock_suite_cls.get_regular.return_value = None + + from testgen.mcp.tools.test_results import list_test_results + + with pytest.raises(MCPUserError, match="not found or not accessible"): + list_test_results(test_suite_id=str(uuid4())) + + +@patch("testgen.mcp.tools.test_results.TestSuite") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_list_test_results_by_suite_id_inaccessible_project(mock_compute, mock_suite_cls, db_session_mock): + mock_compute.return_value = ProjectPermissions(memberships={"proj_a": "role_a"}, permission="view") + mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="forbidden_project") + + from testgen.mcp.tools.test_results import list_test_results + + with pytest.raises(MCPUserError, match="not found or not accessible"): + list_test_results(test_suite_id=str(uuid4())) + + +@patch("testgen.mcp.tools.test_results.TestSuite") +def test_list_test_results_by_suite_id_no_completed_runs(mock_suite_cls, db_session_mock): + mock_suite_cls.get_regular.return_value = _mock_test_suite(last_complete_test_run_id=None) + + from testgen.mcp.tools.test_results import list_test_results + + with pytest.raises(MCPUserError, match="No completed test runs"): + list_test_results(test_suite_id=str(uuid4())) + + +@patch("testgen.mcp.tools.test_results.TestSuite") +@patch("testgen.mcp.tools.test_results.TestRun") +@patch("testgen.mcp.tools.test_results.TestType") +@patch("testgen.mcp.tools.test_results.TestResult") +def test_list_test_results_by_suite_id_resolves_latest_run( + mock_result, mock_tt_cls, mock_test_run_cls, mock_suite_cls, db_session_mock +): + last_run_id = uuid4() + mock_suite_cls.get_regular.return_value = _mock_test_suite(last_complete_test_run_id=last_run_id) + + resolved_run_id = uuid4() + resolved_je_id = uuid4() + resolved_run = _mock_test_run(resolved_run_id) + resolved_run.job_execution_id = resolved_je_id + mock_test_run_cls.get_by_id_or_job.return_value = resolved_run + + r1 = MagicMock() + r1.status = TestResultStatus.Failed + r1.test_type = "Alpha_Trunc" + r1.test_definition_id = uuid4() + r1.table_name = "orders" + r1.column_names = "name" + r1.result_measure = "5" + r1.threshold_value = "1" + r1.message = None + mock_result.select_results.return_value = [r1] + + tt = MagicMock() + tt.test_type = "Alpha_Trunc" + tt.test_name_short = "Alpha Truncation" + mock_tt_cls.select_where.return_value = [tt] + + from testgen.mcp.tools.test_results import list_test_results + + suite_id = str(uuid4()) + result = list_test_results(test_suite_id=suite_id) + + # Resolution chain: suite.last_complete_test_run_id → TestRun.get_by_id_or_job → test_run.id → select_results + mock_test_run_cls.get_by_id_or_job.assert_called_once_with(last_run_id) + assert mock_result.select_results.call_args.kwargs["test_run_id"] == resolved_run_id + # Output indicates which run the suite was resolved to. + assert str(resolved_je_id) in result + assert f"Latest completed run of test suite `{suite_id}`" in result + + @patch("testgen.mcp.tools.test_results.TestRun") @patch("testgen.mcp.tools.test_results.TestType") @patch("testgen.mcp.tools.test_results.TestResult") From 45636fc5efee23e5cfffdd313858ce6af57dcec4 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Mon, 27 Apr 2026 20:51:51 -0300 Subject: [PATCH 075/123] refactor(mcp): typed exception for not-found-or-inaccessible (TG-1057) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encapsulate the "not found or not accessible" wording in a dedicated MCPResourceNotAccessible exception class, sourced from a single place. Tools raise by type with structured (resource, identifier) instead of composing the f-string by hand. This locks in the security-sensitive contract that the message must not distinguish missing from inaccessible. Also fixes a missing access check in get_failure_summary's job_execution_id branch: a forbidden run was previously found, then silently zero-filtered downstream — confirming its existence. It now goes through the same suite/access verification as list_test_results and surfaces the unified message. Co-Authored-By: Claude Opus 4.7 (1M context) --- testgen/mcp/exceptions.py | 19 +++++++ testgen/mcp/permissions.py | 8 ++- testgen/mcp/tools/source_data.py | 4 +- testgen/mcp/tools/test_results.py | 21 ++++---- tests/unit/mcp/test_permissions.py | 11 +++- tests/unit/mcp/test_tools_source_data.py | 4 +- tests/unit/mcp/test_tools_test_results.py | 63 +++++++++++++++++++---- 7 files changed, 102 insertions(+), 28 deletions(-) diff --git a/testgen/mcp/exceptions.py b/testgen/mcp/exceptions.py index dc8d1444..fc89f98a 100644 --- a/testgen/mcp/exceptions.py +++ b/testgen/mcp/exceptions.py @@ -24,6 +24,25 @@ class MCPPermissionDenied(MCPUserError): """Raised when access is denied due to insufficient project permissions.""" +class MCPResourceNotAccessible(MCPPermissionDenied): + """Resource is unknown OR inaccessible — message must not distinguish. + + Use whenever a tool looks up a specific resource by identifier and either + the resource doesn't exist or the caller can't access it. A unified message + prevents existence-leak via error wording. + """ + + def __init__(self, resource: str, identifier: str | None = None): + self.resource = resource + self.identifier = identifier + message = ( + f"{resource} `{identifier}` not found or not accessible." + if identifier is not None + else f"{resource} not found or not accessible." + ) + super().__init__(message) + + def mcp_error_handler(fn): """Wrap an MCP handler (tool, resource, or prompt) with safe error handling. diff --git a/testgen/mcp/permissions.py b/testgen/mcp/permissions.py index 4651c25e..4848954f 100644 --- a/testgen/mcp/permissions.py +++ b/testgen/mcp/permissions.py @@ -38,12 +38,14 @@ def has_access(self, project_code: str) -> bool: """For filtering lists — no exception, just a bool.""" return project_code in self.allowed_codes - def verify_access(self, project_code: str, not_found: str) -> None: + def verify_access(self, project_code: str, not_found: "str | MCPPermissionDenied") -> None: """Raise MCPPermissionDenied if user can't access this project. - Has access: passes. - Has membership but wrong role: raises with denial message. - - No membership: raises with not_found (hides project existence). + - No membership: raises ``not_found`` (hides project existence). + Pass either a free-form string or a fully-typed exception + instance (e.g. ``MCPResourceNotAccessible("Project", code)``). """ if project_code in self.allowed_codes: return @@ -51,6 +53,8 @@ def verify_access(self, project_code: str, not_found: str) -> None: raise MCPPermissionDenied( "Your role on this project does not include the necessary permission for this operation." ) + if isinstance(not_found, MCPPermissionDenied): + raise not_found raise MCPPermissionDenied(not_found) diff --git a/testgen/mcp/tools/source_data.py b/testgen/mcp/tools/source_data.py index 36f0387a..c9c8f401 100644 --- a/testgen/mcp/tools/source_data.py +++ b/testgen/mcp/tools/source_data.py @@ -7,7 +7,7 @@ build_test_result_query, fetch_test_result_source_data, ) -from testgen.mcp.exceptions import MCPUserError +from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission from testgen.mcp.tools.common import parse_uuid from testgen.mcp.tools.markdown import MdDoc @@ -20,7 +20,7 @@ def _resolve_context(test_definition_id: str, reference_date: str | None) -> dic context = TestDefinition.get_source_data_context(td_uuid, project_codes=perms.allowed_codes) if context is None: - raise MCPUserError(f"Test definition `{test_definition_id}` not found or not accessible.") + raise MCPResourceNotAccessible("Test definition", test_definition_id) if reference_date: try: diff --git a/testgen/mcp/tools/test_results.py b/testgen/mcp/tools/test_results.py index 5841cc09..552adb0d 100644 --- a/testgen/mcp/tools/test_results.py +++ b/testgen/mcp/tools/test_results.py @@ -5,7 +5,7 @@ from testgen.common.models.test_result import BucketInterval, TestResult, TestResultStatus from testgen.common.models.test_run import TestRun from testgen.common.models.test_suite import TestSuite -from testgen.mcp.exceptions import MCPUserError +from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission from testgen.mcp.tools.common import ( format_page_footer, @@ -59,7 +59,7 @@ def list_test_results( suite_uuid = parse_uuid(test_suite_id, "test_suite_id") suite = TestSuite.get_regular(suite_uuid) if suite is None or not perms.has_access(suite.project_code): - raise MCPUserError(f"Test suite `{test_suite_id}` not found or not accessible.") + raise MCPResourceNotAccessible("Test suite", test_suite_id) if suite.last_complete_test_run_id is None: raise MCPUserError(f"No completed test runs found for test suite `{test_suite_id}`.") test_run = TestRun.get_by_id_or_job(suite.last_complete_test_run_id) @@ -72,7 +72,7 @@ def list_test_results( test_run = TestRun.get_by_id_or_job(job_uuid) suite = TestSuite.get_regular(test_run.test_suite_id) if test_run else None if test_run is None or suite is None or not perms.has_access(suite.project_code): - raise MCPUserError(f"Test run `{job_execution_id}` not found or not accessible.") + raise MCPResourceNotAccessible("Test run", job_execution_id) run_id_label = job_execution_id status_enum = parse_result_status(status) if status else None @@ -170,14 +170,15 @@ def get_failure_summary( if job_execution_id: job_uuid = parse_uuid(job_execution_id, "job_execution_id") test_run = TestRun.get_by_id_or_job(job_uuid) - if not test_run: - raise MCPUserError(f"No test run found for job execution `{job_execution_id}`.") + suite = TestSuite.get_regular(test_run.test_suite_id) if test_run else None + if test_run is None or suite is None or not perms.has_access(suite.project_code): + raise MCPResourceNotAccessible("Test run", job_execution_id) test_run_id = test_run.id scope_label = f"run `{job_execution_id}`" project_codes = perms.allowed_codes else: if project_code: - perms.verify_access(project_code, not_found=f"Project `{project_code}` not found or not accessible.") + perms.verify_access(project_code, not_found=MCPResourceNotAccessible("Project", project_code)) project_codes = [project_code] else: project_codes = perms.allowed_codes @@ -322,7 +323,7 @@ def search_test_results( """ perms = get_project_permissions() if project_code: - perms.verify_access(project_code, not_found=f"Project `{project_code}` not found or not accessible.") + perms.verify_access(project_code, not_found=MCPResourceNotAccessible("Project", project_code)) project_codes = [project_code] else: project_codes = perms.allowed_codes @@ -422,7 +423,7 @@ def get_failure_trend( perms = get_project_permissions() if project_code: - perms.verify_access(project_code, not_found=f"Project `{project_code}` not found or not accessible.") + perms.verify_access(project_code, not_found=MCPResourceNotAccessible("Project", project_code)) project_codes = [project_code] else: project_codes = perms.allowed_codes @@ -525,9 +526,9 @@ def _accessible(run) -> bool: return perms.has_access(suite.project_code) if not _accessible(run_a): - raise MCPUserError(f"Run `{job_execution_id_a}` not found or not accessible.") + raise MCPResourceNotAccessible("Run", job_execution_id_a) if not _accessible(run_b): - raise MCPUserError(f"Run `{job_execution_id_b}` not found or not accessible.") + raise MCPResourceNotAccessible("Run", job_execution_id_b) # Both runs confirmed accessible — safe to reveal suite IDs in the compatibility message. if run_a.test_suite_id != run_b.test_suite_id: diff --git a/tests/unit/mcp/test_permissions.py b/tests/unit/mcp/test_permissions.py index 24903535..b63dedfe 100644 --- a/tests/unit/mcp/test_permissions.py +++ b/tests/unit/mcp/test_permissions.py @@ -3,7 +3,7 @@ import pytest -from testgen.mcp.exceptions import MCPPermissionDenied +from testgen.mcp.exceptions import MCPPermissionDenied, MCPResourceNotAccessible from testgen.mcp.permissions import ( _NOT_SET, ProjectPermissions, @@ -181,6 +181,15 @@ def test_verify_access_no_membership_raises_not_found(): perms.verify_access("secret", not_found="not found") +def test_verify_access_accepts_typed_not_found_exception(): + perms = ProjectPermissions( + memberships={"proj_a": "role_a"}, + permission="view", + ) + with pytest.raises(MCPResourceNotAccessible, match="Project `secret` not found or not accessible"): + perms.verify_access("secret", not_found=MCPResourceNotAccessible("Project", "secret")) + + # --- ProjectPermissions.has_access --- diff --git a/tests/unit/mcp/test_tools_source_data.py b/tests/unit/mcp/test_tools_source_data.py index e21a363c..97dda741 100644 --- a/tests/unit/mcp/test_tools_source_data.py +++ b/tests/unit/mcp/test_tools_source_data.py @@ -5,7 +5,7 @@ import pytest from testgen.common.source_data_service import SourceDataResult -from testgen.mcp.exceptions import MCPUserError +from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError from testgen.mcp.permissions import ProjectPermissions @@ -101,7 +101,7 @@ def test_get_source_data_query_not_found(mock_td, db_session_mock): from testgen.mcp.tools.source_data import get_source_data_query - with pytest.raises(MCPUserError, match="not found or not accessible"): + with pytest.raises(MCPResourceNotAccessible, match="Test definition .* not found or not accessible"): get_source_data_query(str(uuid4())) diff --git a/tests/unit/mcp/test_tools_test_results.py b/tests/unit/mcp/test_tools_test_results.py index 7e7c2e87..e2bf55e0 100644 --- a/tests/unit/mcp/test_tools_test_results.py +++ b/tests/unit/mcp/test_tools_test_results.py @@ -5,7 +5,7 @@ import pytest from testgen.common.models.test_result import TestResultStatus -from testgen.mcp.exceptions import MCPUserError +from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError from testgen.mcp.permissions import ProjectPermissions @@ -150,7 +150,7 @@ def test_list_test_results_run_not_found(mock_test_run_cls, db_session_mock): from testgen.mcp.tools.test_results import list_test_results - with pytest.raises(MCPUserError, match="not found or not accessible"): + with pytest.raises(MCPResourceNotAccessible, match="Test run .* not found or not accessible"): list_test_results(str(uuid4())) @@ -258,7 +258,7 @@ def test_list_test_results_by_suite_id_monitor_or_missing(mock_suite_cls, db_ses from testgen.mcp.tools.test_results import list_test_results - with pytest.raises(MCPUserError, match="not found or not accessible"): + with pytest.raises(MCPResourceNotAccessible, match="Test suite .* not found or not accessible"): list_test_results(test_suite_id=str(uuid4())) @@ -329,11 +329,15 @@ def test_list_test_results_by_suite_id_resolves_latest_run( assert f"Latest completed run of test suite `{suite_id}`" in result +@patch("testgen.mcp.tools.test_results.TestSuite") @patch("testgen.mcp.tools.test_results.TestRun") @patch("testgen.mcp.tools.test_results.TestType") @patch("testgen.mcp.tools.test_results.TestResult") -def test_get_failure_summary_by_test_type(mock_result, mock_tt_cls, mock_test_run_cls, db_session_mock): +def test_get_failure_summary_by_test_type( + mock_result, mock_tt_cls, mock_test_run_cls, mock_suite_cls, db_session_mock, +): mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() + mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="demo") mock_result.select_failures.return_value = [ ("Alpha_Trunc", TestResultStatus.Failed, 5), ("Unique_Pct", TestResultStatus.Warning, 3), @@ -360,10 +364,12 @@ def test_get_failure_summary_by_test_type(mock_result, mock_tt_cls, mock_test_ru assert "get_test_type" in result +@patch("testgen.mcp.tools.test_results.TestSuite") @patch("testgen.mcp.tools.test_results.TestRun") @patch("testgen.mcp.tools.test_results.TestResult") -def test_get_failure_summary_empty(mock_result, mock_test_run_cls, db_session_mock): +def test_get_failure_summary_empty(mock_result, mock_test_run_cls, mock_suite_cls, db_session_mock): mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() + mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="demo") mock_result.select_failures.return_value = [] from testgen.mcp.tools.test_results import get_failure_summary @@ -373,10 +379,12 @@ def test_get_failure_summary_empty(mock_result, mock_test_run_cls, db_session_mo assert "No confirmed failures" in result +@patch("testgen.mcp.tools.test_results.TestSuite") @patch("testgen.mcp.tools.test_results.TestRun") @patch("testgen.mcp.tools.test_results.TestResult") -def test_get_failure_summary_by_table(mock_result, mock_test_run_cls, db_session_mock): +def test_get_failure_summary_by_table(mock_result, mock_test_run_cls, mock_suite_cls, db_session_mock): mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() + mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="demo") mock_result.select_failures.return_value = [("orders", 10)] from testgen.mcp.tools.test_results import get_failure_summary @@ -388,10 +396,12 @@ def test_get_failure_summary_by_table(mock_result, mock_test_run_cls, db_session assert "get_test_type" not in result +@patch("testgen.mcp.tools.test_results.TestSuite") @patch("testgen.mcp.tools.test_results.TestRun") @patch("testgen.mcp.tools.test_results.TestResult") -def test_get_failure_summary_by_column(mock_result, mock_test_run_cls, db_session_mock): +def test_get_failure_summary_by_column(mock_result, mock_test_run_cls, mock_suite_cls, db_session_mock): mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() + mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="demo") mock_result.select_failures.return_value = [("orders", "total_value", 34), ("orders", None, 2)] from testgen.mcp.tools.test_results import get_failure_summary @@ -417,21 +427,52 @@ def test_get_failure_summary_run_not_found(mock_test_run_cls, db_session_mock): from testgen.mcp.tools.test_results import get_failure_summary - with pytest.raises(MCPUserError, match="No test run found"): + with pytest.raises(MCPResourceNotAccessible, match="Test run .* not found or not accessible"): get_failure_summary(job_execution_id=str(uuid4())) +@patch("testgen.mcp.tools.test_results.TestSuite") +@patch("testgen.mcp.tools.test_results.TestRun") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_get_failure_summary_run_in_forbidden_project( + mock_compute, mock_test_run_cls, mock_suite_cls, db_session_mock, +): + mock_compute.return_value = ProjectPermissions(memberships={"proj_a": "role_a"}, permission="view") + mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() + mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="forbidden_project") + + from testgen.mcp.tools.test_results import get_failure_summary + + with pytest.raises(MCPResourceNotAccessible, match="Test run .* not found or not accessible"): + get_failure_summary(job_execution_id=str(uuid4())) + + +@patch("testgen.mcp.tools.test_results.TestSuite") +@patch("testgen.mcp.tools.test_results.TestRun") +def test_get_failure_summary_run_in_monitor_suite_rejected(mock_test_run_cls, mock_suite_cls, db_session_mock): + # Run exists, but the resolved suite is monitor → TestSuite.get_regular returns None. + mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() + mock_suite_cls.get_regular.return_value = None + + from testgen.mcp.tools.test_results import get_failure_summary + + with pytest.raises(MCPResourceNotAccessible, match="Test run .* not found or not accessible"): + get_failure_summary(job_execution_id=str(uuid4())) + + +@patch("testgen.mcp.tools.test_results.TestSuite") @patch("testgen.mcp.tools.test_results.TestRun") @patch("testgen.mcp.tools.test_results.TestResult") @patch("testgen.mcp.permissions._compute_project_permissions") def test_get_failure_summary_passes_project_codes( - mock_compute, mock_result, mock_test_run_cls, db_session_mock, + mock_compute, mock_result, mock_test_run_cls, mock_suite_cls, db_session_mock, ): mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", ) mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() + mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="proj_a") mock_result.select_failures.return_value = [] from testgen.mcp.tools.test_results import get_failure_summary @@ -559,7 +600,7 @@ def test_get_failure_summary_rejects_inaccessible_project(mock_compute, db_sessi from testgen.mcp.tools.test_results import get_failure_summary - with pytest.raises(MCPUserError, match="not found or not accessible"): + with pytest.raises(MCPResourceNotAccessible, match="Project .* not found or not accessible"): get_failure_summary(project_code="proj_b") @@ -819,7 +860,7 @@ def test_get_test_run_diff_run_not_found( from testgen.mcp.tools.test_results import get_test_run_diff - with pytest.raises(MCPUserError, match="not found or not accessible"): + with pytest.raises(MCPResourceNotAccessible, match="Run .* not found or not accessible"): get_test_run_diff(str(uuid4()), str(uuid4())) From 543a9c89fd31df86aaa78dd9d0b25b5cf7845111 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Mon, 27 Apr 2026 21:17:58 -0300 Subject: [PATCH 076/123] fix(mcp): close test_suite_id leak in get_failure_summary; normalize "Test run" label (TG-1057) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review follow-up. The test_suite_id branch of get_failure_summary was passing the suite UUID to select_failures without verifying the caller has access to the suite's project. The model filters by project_codes so no data leaks, but a forbidden suite returned "No confirmed failures" (same as an accessible empty suite) — diverging from list_test_results, which validates the suite and surfaces the unified "not found or not accessible" message. Apply the same TestSuite.get_regular() + perms.has_access() check, and normalize get_test_run_diff's resource label from "Run" to "Test run" so the wording is consistent across tools. Co-Authored-By: Claude Opus 4.7 (1M context) --- testgen/mcp/tools/test_results.py | 8 +++++-- tests/unit/mcp/test_tools_test_results.py | 26 ++++++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/testgen/mcp/tools/test_results.py b/testgen/mcp/tools/test_results.py index 552adb0d..f8355ba8 100644 --- a/testgen/mcp/tools/test_results.py +++ b/testgen/mcp/tools/test_results.py @@ -182,6 +182,10 @@ def get_failure_summary( project_codes = [project_code] else: project_codes = perms.allowed_codes + if test_suite_uuid is not None: + suite = TestSuite.get_regular(test_suite_uuid) + if suite is None or not perms.has_access(suite.project_code): + raise MCPResourceNotAccessible("Test suite", test_suite_id) scope_parts = [] if project_code: scope_parts.append(f"project `{project_code}`") @@ -526,9 +530,9 @@ def _accessible(run) -> bool: return perms.has_access(suite.project_code) if not _accessible(run_a): - raise MCPResourceNotAccessible("Run", job_execution_id_a) + raise MCPResourceNotAccessible("Test run", job_execution_id_a) if not _accessible(run_b): - raise MCPResourceNotAccessible("Run", job_execution_id_b) + raise MCPResourceNotAccessible("Test run", job_execution_id_b) # Both runs confirmed accessible — safe to reveal suite IDs in the compatibility message. if run_a.test_suite_id != run_b.test_suite_id: diff --git a/tests/unit/mcp/test_tools_test_results.py b/tests/unit/mcp/test_tools_test_results.py index e2bf55e0..8e234271 100644 --- a/tests/unit/mcp/test_tools_test_results.py +++ b/tests/unit/mcp/test_tools_test_results.py @@ -604,6 +604,30 @@ def test_get_failure_summary_rejects_inaccessible_project(mock_compute, db_sessi get_failure_summary(project_code="proj_b") +@patch("testgen.mcp.tools.test_results.TestSuite") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_get_failure_summary_rejects_inaccessible_test_suite(mock_compute, mock_suite_cls, db_session_mock): + """test_suite_id branch validates suite access — same contract as list_test_results.""" + mock_compute.return_value = ProjectPermissions(memberships={"proj_a": "role_a"}, permission="view") + mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="forbidden_project") + + from testgen.mcp.tools.test_results import get_failure_summary + + with pytest.raises(MCPResourceNotAccessible, match="Test suite .* not found or not accessible"): + get_failure_summary(test_suite_id=str(uuid4())) + + +@patch("testgen.mcp.tools.test_results.TestSuite") +def test_get_failure_summary_rejects_unknown_or_monitor_test_suite(mock_suite_cls, db_session_mock): + # TestSuite.get_regular returns None for monitor suites and unknown ids alike. + mock_suite_cls.get_regular.return_value = None + + from testgen.mcp.tools.test_results import get_failure_summary + + with pytest.raises(MCPResourceNotAccessible, match="Test suite .* not found or not accessible"): + get_failure_summary(test_suite_id=str(uuid4())) + + # ---------------------------------------------------------------------- # search_test_results # ---------------------------------------------------------------------- @@ -860,7 +884,7 @@ def test_get_test_run_diff_run_not_found( from testgen.mcp.tools.test_results import get_test_run_diff - with pytest.raises(MCPResourceNotAccessible, match="Run .* not found or not accessible"): + with pytest.raises(MCPResourceNotAccessible, match="Test run .* not found or not accessible"): get_test_run_diff(str(uuid4()), str(uuid4())) From bd632ec0aaca1c2fb2a78211601f828e5c7c4bbf Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Wed, 22 Apr 2026 21:54:58 -0400 Subject: [PATCH 077/123] fix: sqlalchemy 2.0 upgrade --- testgen/__main__.py | 8 ++++-- testgen/api/oauth/routes.py | 3 +- testgen/api/oauth/server.py | 28 +++++++++++-------- testgen/common/auth.py | 6 ++-- testgen/common/database/database_service.py | 17 ++++++++++- testgen/common/models/job_execution.py | 4 +-- testgen/common/notifications/profiling_run.py | 2 +- testgen/ui/services/database_service.py | 16 +++++++---- testgen/ui/views/data_catalog.py | 4 +-- testgen/ui/views/dialogs/manage_schedules.py | 9 ++++-- 10 files changed, 65 insertions(+), 32 deletions(-) diff --git a/testgen/__main__.py b/testgen/__main__.py index 8f68f337..11912876 100644 --- a/testgen/__main__.py +++ b/testgen/__main__.py @@ -97,6 +97,12 @@ def invoke(self, ctx: Context): except Exception: LOG.exception("There was an unexpected error") + def format_epilog(self, ctx: Context, formatter: click.HelpFormatter) -> None: + # Schema revision is a DB round-trip; defer until `--help` is actually + # requested rather than evaluating at module-load for every CLI invocation. + formatter.write_paragraph() + formatter.write_text(f"Schema revision: {get_schema_revision()}") + @click.group( cls=CliGroup, @@ -104,8 +110,6 @@ def invoke(self, ctx: Context): {VERSION_DATA.edition} {VERSION_DATA.current or ""} {f"New version available! {VERSION_DATA.latest}" if VERSION_DATA.latest != VERSION_DATA.current else ""} - - Schema revision: {get_schema_revision()} """ ) @click.option( diff --git a/testgen/api/oauth/routes.py b/testgen/api/oauth/routes.py index 6b468171..d2ea828b 100644 --- a/testgen/api/oauth/routes.py +++ b/testgen/api/oauth/routes.py @@ -16,6 +16,7 @@ from authlib.oauth2.rfc6749.requests import BasicOAuth2Payload from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from sqlalchemy import select from testgen import settings from testgen.api.deps import db_session @@ -87,7 +88,7 @@ def _get_existing_user(request: Request) -> User | None: def _get_client_name(client_id: str) -> str: """Look up the OAuth client's display name from its metadata.""" session = get_current_session() - client = session.query(OAuth2Client).filter_by(client_id=client_id).first() + client = session.scalars(select(OAuth2Client).where(OAuth2Client.client_id == client_id)).first() if client: return client.client_metadata.get("client_name", "") return "" diff --git a/testgen/api/oauth/server.py b/testgen/api/oauth/server.py index 498f0f91..32ca120a 100644 --- a/testgen/api/oauth/server.py +++ b/testgen/api/oauth/server.py @@ -16,6 +16,7 @@ from authlib.oauth2.rfc6749.errors import InvalidGrantError from authlib.oauth2.rfc7009 import RevocationEndpoint from authlib.oauth2.rfc7636 import CodeChallenge +from sqlalchemy import select from testgen import settings from testgen.api.oauth.models import OAuth2AuthorizationCode, OAuth2Client, OAuth2Token @@ -43,8 +44,11 @@ def save_authorization_code(self, code, request): def query_authorization_code(self, code, client): session = get_current_session() - item = session.query(OAuth2AuthorizationCode).filter_by( - code=code, client_id=client.client_id, + item = session.scalars( + select(OAuth2AuthorizationCode).where( + OAuth2AuthorizationCode.code == code, + OAuth2AuthorizationCode.client_id == client.client_id, + ) ).first() if item and not item.is_expired(): return item @@ -56,7 +60,7 @@ def delete_authorization_code(self, authorization_code): def authenticate_user(self, authorization_code): session = get_current_session() - return session.query(User).filter(User.id == authorization_code.user_id).first() + return session.scalars(select(User).where(User.id == authorization_code.user_id)).first() class RefreshTokenGrant(grants.RefreshTokenGrant): @@ -64,8 +68,8 @@ class RefreshTokenGrant(grants.RefreshTokenGrant): def authenticate_refresh_token(self, refresh_token): session = get_current_session() - item = session.query(OAuth2Token).filter_by( - refresh_token=refresh_token, + item = session.scalars( + select(OAuth2Token).where(OAuth2Token.refresh_token == refresh_token) ).first() if item and item.is_refresh_token_active(): return item @@ -73,7 +77,7 @@ def authenticate_refresh_token(self, refresh_token): def authenticate_user(self, credential): session = get_current_session() - return session.query(User).filter(User.id == credential.user_id).first() + return session.scalars(select(User).where(User.id == credential.user_id)).first() def revoke_old_credential(self, credential): # Rotation is off (INCLUDE_NEW_REFRESH_TOKEN=False): keep the refresh token @@ -93,7 +97,7 @@ def validate_token_request(self): if not client.user_id: raise InvalidGrantError(description="Client has no registered owner.") session = get_current_session() - owner = session.query(User).filter(User.id == client.user_id).first() + owner = session.scalars(select(User).where(User.id == client.user_id)).first() if owner is None: raise InvalidGrantError(description="Client owner no longer exists.") self.request.user = owner @@ -103,12 +107,12 @@ class TestGenRevocationEndpoint(RevocationEndpoint): def query_token(self, token_string, token_type_hint): session = get_current_session() if token_type_hint == "access_token": # noqa: S105 - return session.query(OAuth2Token).filter_by(access_token=token_string).first() + return session.scalars(select(OAuth2Token).where(OAuth2Token.access_token == token_string)).first() if token_type_hint == "refresh_token": # noqa: S105 - return session.query(OAuth2Token).filter_by(refresh_token=token_string).first() + return session.scalars(select(OAuth2Token).where(OAuth2Token.refresh_token == token_string)).first() return ( - session.query(OAuth2Token).filter_by(access_token=token_string).first() - or session.query(OAuth2Token).filter_by(refresh_token=token_string).first() + session.scalars(select(OAuth2Token).where(OAuth2Token.access_token == token_string)).first() + or session.scalars(select(OAuth2Token).where(OAuth2Token.refresh_token == token_string)).first() ) def revoke_token(self, token, request): @@ -126,7 +130,7 @@ class TestGenAuthorizationServer(AuthorizationServer): def query_client(self, client_id): session = get_current_session() - return session.query(OAuth2Client).filter_by(client_id=client_id).first() + return session.scalars(select(OAuth2Client).where(OAuth2Client.client_id == client_id)).first() def save_token(self, token, request): user_id = request.user.id if request.user else None diff --git a/testgen/common/auth.py b/testgen/common/auth.py index 586dc649..1dcdc479 100644 --- a/testgen/common/auth.py +++ b/testgen/common/auth.py @@ -41,16 +41,16 @@ def authorize_token(token_str: str, username: str, session): Shared implementation for API and MCP authorization. """ - from sqlalchemy import func + from sqlalchemy import func, select from testgen.api.oauth.models import OAuth2Token from testgen.common.models.user import User - user = session.query(User).filter(func.lower(User.username) == func.lower(username)).first() + user = session.scalars(select(User).where(func.lower(User.username) == func.lower(username))).first() if user is None: raise ValueError("User not found") - token_record = session.query(OAuth2Token).filter_by(access_token=token_str).first() + token_record = session.scalars(select(OAuth2Token).where(OAuth2Token.access_token == token_str)).first() if token_record and token_record.access_token_revoked_at: raise ValueError("Token has been revoked") diff --git a/testgen/common/database/database_service.py b/testgen/common/database/database_service.py index eba7d73b..de0bdc65 100644 --- a/testgen/common/database/database_service.py +++ b/testgen/common/database/database_service.py @@ -14,7 +14,7 @@ from sqlalchemy import Connection, Engine, Row, create_engine, text from sqlalchemy.engine import RowMapping from sqlalchemy.exc import ProgrammingError, SQLAlchemyError -from sqlalchemy.pool import PoolProxiedConnection +from sqlalchemy.pool import NullPool, PoolProxiedConnection from testgen import settings from testgen.common.credentials import ( @@ -73,6 +73,12 @@ def quote_csv_items(csv_row: str, quote_character: str = '"') -> str: def empty_cache() -> None: + # dispose() closes all idle pool connections immediately, avoiding handing + # out stale ones on the next checkout. + if engine_cache.app_db is not None: + engine_cache.app_db.dispose() + if engine_cache.target_db is not None: + engine_cache.target_db.dispose() engine_cache.app_db = None engine_cache.target_db = None @@ -121,6 +127,10 @@ def create_database( ), {"database_name": database_name}, ) + # pg_terminate_backend just killed any pooled connections to this DB. + # Dispose the cached app/target engines so they don't hand out dead + # connections on the next checkout. + empty_cache() connection.execute(text(f"DROP DATABASE IF EXISTS {database_name}")) if drop_users_and_roles: if user := params.get("TESTGEN_USER"): @@ -395,6 +405,11 @@ def _init_app_db_connection( # Force UTC so TIMESTAMP-without-tz inserts aren't silently shifted. "options": "-c TimeZone=UTC", }, + # Admin operations (schema_admin / database_admin) are one-shot. + # NullPool closes the connection on `.close()` instead of parking + # it in a pool we never dispose — avoids leaking idle PG + # connections across a long CLI run. + poolclass=None if user_type == "normal" else NullPool, ) if user_type == "normal": engine_cache.app_db = engine diff --git a/testgen/common/models/job_execution.py b/testgen/common/models/job_execution.py index 9f2d0399..2da36c8d 100644 --- a/testgen/common/models/job_execution.py +++ b/testgen/common/models/job_execution.py @@ -169,8 +169,8 @@ def _transition(self, *targets: JobStatus, **values: Any) -> bool: .where(cls.id == self.id, cls.status.in_(all_valid_from)) .values(status=case(*cases), **values) .returning(cls) - ).first() - if row: + ).scalar_one_or_none() + if row is not None: for col in cls.__table__.columns: setattr(self, col.key, getattr(row, col.key)) return True diff --git a/testgen/common/notifications/profiling_run.py b/testgen/common/notifications/profiling_run.py index f16f7cb1..c1a55ed5 100644 --- a/testgen/common/notifications/profiling_run.py +++ b/testgen/common/notifications/profiling_run.py @@ -331,7 +331,7 @@ def send_profiling_run_notifications(profiling_run: ProfilingRun, result_list_ct }, "issue_count": sum(c.total for c in counts.values()), "hygiene_issues_summary": hygiene_issues_summary, - **dict(get_current_session().execute(labels_query).one()), + **get_current_session().execute(labels_query).mappings().one(), } for ns in notifications: diff --git a/testgen/ui/services/database_service.py b/testgen/ui/services/database_service.py index 98d6b251..f8dbd6cc 100644 --- a/testgen/ui/services/database_service.py +++ b/testgen/ui/services/database_service.py @@ -60,8 +60,14 @@ def fetch_from_target_db(connection: Connection, query: str, params: dict | None resolved = resolve_connection_params(connection_params) engine = flavor_service.create_engine(connection_params) - with engine.connect() as conn: - for pre_query, pre_params in flavor_service.get_pre_connection_queries(resolved): - conn.execute(text(pre_query), pre_params) - cursor: CursorResult = conn.execute(text(query), params) - return cursor.mappings().fetchall() + # Each call creates a fresh engine for ad-hoc target-DB access (test connection, + # preview, data catalog reads). Dispose on exit so the engine's pool doesn't + # leak an idle connection until GC — these add up over a long Streamlit session. + try: + with engine.connect() as conn: + for pre_query, pre_params in flavor_service.get_pre_connection_queries(resolved): + conn.execute(text(pre_query), pre_params) + cursor: CursorResult = conn.execute(text(query), params) + return cursor.mappings().fetchall() + finally: + engine.dispose() diff --git a/testgen/ui/views/data_catalog.py b/testgen/ui/views/data_catalog.py index 34bd0c3e..80ebb16b 100644 --- a/testgen/ui/views/data_catalog.py +++ b/testgen/ui/views/data_catalog.py @@ -918,8 +918,8 @@ def get_preview_data( if not results: return {"title": title, "status": "ND", "message": "No data found."} - columns_list = list(results[0]._mapping.keys()) - rows = [list(row) for row in results] + columns_list = list(results[0].keys()) + rows = [list(row.values()) for row in results] return { "title": title, "columns": columns_list, diff --git a/testgen/ui/views/dialogs/manage_schedules.py b/testgen/ui/views/dialogs/manage_schedules.py index 0aa9e31f..4caf4c59 100644 --- a/testgen/ui/views/dialogs/manage_schedules.py +++ b/testgen/ui/views/dialogs/manage_schedules.py @@ -3,6 +3,7 @@ import cron_converter import cron_descriptor import streamlit as st +from sqlalchemy import select from sqlalchemy.exc import IntegrityError from testgen.common.models import Session, with_database_session @@ -40,9 +41,11 @@ def build_data(self) -> dict: user_can_edit = session.auth.user_has_permission("edit") with Session() as db_session: - scheduled_jobs = ( - db_session.query(JobSchedule) - .where(JobSchedule.project_code == self.project_code, JobSchedule.key == self.job_key) + scheduled_jobs = db_session.scalars( + select(JobSchedule).where( + JobSchedule.project_code == self.project_code, + JobSchedule.key == self.job_key, + ) ) scheduled_jobs_json = [ { From d6d5d8ec81b90ba8120328df77a2da669b97ac7e Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Tue, 28 Apr 2026 22:07:01 -0400 Subject: [PATCH 078/123] fix(test-suites): table group filter not working --- .../frontend/js/pages/test_suites.js | 126 ++++++++---------- testgen/ui/views/test_suites.py | 1 + 2 files changed, 59 insertions(+), 68 deletions(-) diff --git a/testgen/ui/components/frontend/js/pages/test_suites.js b/testgen/ui/components/frontend/js/pages/test_suites.js index 360a09f1..f9899747 100644 --- a/testgen/ui/components/frontend/js/pages/test_suites.js +++ b/testgen/ui/components/frontend/js/pages/test_suites.js @@ -125,77 +125,67 @@ const TestSuites = (/** @type Properties */ props) => { return projectSummary.test_suite_count > 0 ? div( { class: 'tg-test-suites'}, - () => { - const initialTableGroup = getValue(props.table_group_filter_options)?.find((op) => op.selected)?.value ?? null; - const initialTestSuiteName = getValue(props.test_suite_name) || null; - const selectedTableGroup = van.state(initialTableGroup); - const testSuiteNameFilter = van.state(initialTestSuiteName); - - van.derive(() => { - if (selectedTableGroup.val !== initialTableGroup || testSuiteNameFilter.val !== initialTestSuiteName) { - emit('FilterApplied', { payload: { table_group_id: selectedTableGroup.val, test_suite_name: testSuiteNameFilter.val } }); - } - }); - - return div( - { class: 'flex-row fx-align-flex-end fx-justify-space-between fx-gap-4 fx-flex-wrap mb-4' }, - div( - { class: 'flex-row fx-align-flex-end fx-gap-3' }, - Select({ - label: 'Table Group', - value: selectedTableGroup, - options: getValue(props.table_group_filter_options) ?? [], - allowNull: true, - style: 'font-size: 14px;', - testId: 'table-group-filter', - onChange: (value) => selectedTableGroup.val = value, - }), - Input({ - testId: 'test-suite-name-filter', - icon: 'search', - label: '', - placeholder: 'Search test suite names', - width: 300, - clearable: true, - value: testSuiteNameFilter, - onChange: (value) => testSuiteNameFilter.val = value || '', - }), - ), - div( - { class: 'flex-row fx-gap-3' }, - Button({ - icon: 'notifications', - type: 'stroked', - label: 'Notifications', - tooltip: 'Configure email notifications for test runs', - tooltipPosition: 'bottom', - width: 'fit-content', - style: 'background: var(--button-generic-background-color);', - onclick: () => emit('RunNotificationsClicked', {}), - }), - Button({ - icon: 'today', + div( + { class: 'flex-row fx-align-flex-end fx-justify-space-between fx-gap-4 fx-flex-wrap mb-4' }, + div( + { class: 'flex-row fx-align-flex-end fx-gap-3' }, + () => Select({ + label: 'Table Group', + value: getValue(props.table_group_filter_options)?.find((op) => op.selected)?.value ?? null, + options: getValue(props.table_group_filter_options) ?? [], + allowNull: true, + style: 'font-size: 14px;', + testId: 'table-group-filter', + onChange: (value) => { + console.log(value) + emit('FilterApplied', { payload: { table_group_id: value } }) + }, + }), + () => Input({ + testId: 'test-suite-name-filter', + icon: 'search', + label: '', + placeholder: 'Search test suite names', + width: 300, + clearable: true, + value: getValue(props.test_suite_name) || null, + onChange: (value) => emit('FilterApplied', { payload: { test_suite_name: value || null } }), + }), + ), + div( + { class: 'flex-row fx-gap-3' }, + Button({ + icon: 'notifications', + type: 'stroked', + label: 'Notifications', + tooltip: 'Configure email notifications for test runs', + tooltipPosition: 'bottom', + width: 'fit-content', + style: 'background: var(--button-generic-background-color);', + onclick: () => emit('RunNotificationsClicked', {}), + }), + Button({ + icon: 'today', + type: 'stroked', + label: 'Schedules', + tooltip: 'Manage when test suites should run', + tooltipPosition: 'bottom', + width: 'fit-content', + style: 'background: var(--button-generic-background-color);', + onclick: () => emit('RunSchedulesClicked', {}), + }), + userCanEdit + ? Button({ + icon: 'add', type: 'stroked', - label: 'Schedules', - tooltip: 'Manage when test suites should run', - tooltipPosition: 'bottom', + label: 'Add Test Suite', width: 'fit-content', style: 'background: var(--button-generic-background-color);', - onclick: () => emit('RunSchedulesClicked', {}), - }), - userCanEdit - ? Button({ - icon: 'add', - type: 'stroked', - label: 'Add Test Suite', - width: 'fit-content', - style: 'background: var(--button-generic-background-color);', - onclick: () => emit('AddTestSuiteClicked', {}), - }) - : '', - ), - ); - }, + onclick: () => emit('AddTestSuiteClicked', {}), + }) + : '', + ), + ), () => getValue(testSuites)?.length ? div( { class: 'flex-column fx-gap-4' }, diff --git a/testgen/ui/views/test_suites.py b/testgen/ui/views/test_suites.py index 3c92e850..605774f6 100644 --- a/testgen/ui/views/test_suites.py +++ b/testgen/ui/views/test_suites.py @@ -246,6 +246,7 @@ def on_close_form_dialog(*_) -> None: "selected": str(table_group_id) == str(table_group.id), } for table_group in table_groups ], + "test_suite_name": test_suite_name, "permissions": { "can_edit": user_can_edit, }, From 614659aa9a6820cf0a47561d9cb804bb57803c74 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Tue, 28 Apr 2026 22:31:01 -0400 Subject: [PATCH 079/123] fix(pii): mask profiling dialogs based on user permissions --- testgen/ui/views/hygiene_issues.py | 3 ++- testgen/ui/views/test_definitions.py | 3 ++- testgen/ui/views/test_results.py | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/testgen/ui/views/hygiene_issues.py b/testgen/ui/views/hygiene_issues.py index ef3ae2d8..af28aeac 100644 --- a/testgen/ui/views/hygiene_issues.py +++ b/testgen/ui/views/hygiene_issues.py @@ -305,7 +305,8 @@ def on_view_profiling(anomaly_id: str) -> None: lookup["column_name"], lookup["table_name"], lookup["table_groups_id"], ) if column: - mask_profiling_pii(column, get_pii_columns(lookup["table_groups_id"], table_name=lookup["table_name"])) + if not session.auth.user_has_permission("view_pii"): + mask_profiling_pii(column, get_pii_columns(lookup["table_groups_id"], table_name=lookup["table_name"])) st.session_state[PROFILING_KEY] = make_json_safe(column) def on_profiling_closed(*_) -> None: diff --git a/testgen/ui/views/test_definitions.py b/testgen/ui/views/test_definitions.py index b69884b7..d992d5ab 100644 --- a/testgen/ui/views/test_definitions.py +++ b/testgen/ui/views/test_definitions.py @@ -503,7 +503,8 @@ def on_profiling_clicked(payload: dict) -> None: return column = profiling_queries.get_column_by_name(column_name, table_name, table_groups_id) if column: - mask_profiling_pii(column, get_pii_columns(table_groups_id, table_name=table_name)) + if not session.auth.user_has_permission("view_pii"): + mask_profiling_pii(column, get_pii_columns(table_groups_id, table_name=table_name)) st.session_state[TD_PROFILING_KEY] = make_json_safe(column) def on_profiling_closed(*_) -> None: diff --git a/testgen/ui/views/test_results.py b/testgen/ui/views/test_results.py index 2ddc31dc..04c31518 100644 --- a/testgen/ui/views/test_results.py +++ b/testgen/ui/views/test_results.py @@ -379,7 +379,8 @@ def on_profiling_clicked(test_result_id: str) -> None: lookup["column_names"], lookup["table_name"], lookup["table_groups_id"], ) if column: - mask_profiling_pii(column, get_pii_columns(lookup["table_groups_id"], table_name=lookup["table_name"])) + if not session.auth.user_has_permission("view_pii"): + mask_profiling_pii(column, get_pii_columns(lookup["table_groups_id"], table_name=lookup["table_name"])) st.session_state[PROFILING_KEY] = make_json_safe(column) def on_profiling_closed(*_) -> None: From f5bc0d9a9fb72164499e870badcffb803e3d7e46 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Wed, 29 Apr 2026 00:49:55 -0400 Subject: [PATCH 080/123] fix(scores-details): broken issue report and query bug --- .../template/score_cards/get_score_card_issues_by_column.sql | 3 ++- .../score_cards/get_score_card_issues_by_dimension.sql | 3 ++- testgen/ui/queries/scoring_queries.py | 1 + testgen/ui/static/js/components/score_issues.js | 3 +++ 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/testgen/template/score_cards/get_score_card_issues_by_column.sql b/testgen/template/score_cards/get_score_card_issues_by_column.sql index c2955a5f..85b8d26e 100644 --- a/testgen/template/score_cards/get_score_card_issues_by_column.sql +++ b/testgen/template/score_cards/get_score_card_issues_by_column.sql @@ -68,7 +68,8 @@ tests AS ( INNER JOIN score_test_runs ON ( score_test_runs.test_run_id = test_results.test_run_id AND score_test_runs.table_name = test_results.table_name - AND score_test_runs.column_name = test_results.column_names + -- NULL-safe match: table-scope tests (e.g. Dupe_Rows) have column_names = NULL + AND score_test_runs.column_name IS NOT DISTINCT FROM test_results.column_names ) INNER JOIN test_suites ON (test_suites.id = test_results.test_suite_id) INNER JOIN test_types ON (test_types.test_type = test_results.test_type) diff --git a/testgen/template/score_cards/get_score_card_issues_by_dimension.sql b/testgen/template/score_cards/get_score_card_issues_by_dimension.sql index 74830695..c8704e6d 100644 --- a/testgen/template/score_cards/get_score_card_issues_by_dimension.sql +++ b/testgen/template/score_cards/get_score_card_issues_by_dimension.sql @@ -69,7 +69,8 @@ tests AS ( INNER JOIN score_test_runs ON ( score_test_runs.test_run_id = test_results.test_run_id AND score_test_runs.table_name = test_results.table_name - AND score_test_runs.column_name = test_results.column_names + -- NULL-safe match: table-scope tests (e.g. Dupe_Rows) have column_names = NULL + AND score_test_runs.column_name IS NOT DISTINCT FROM test_results.column_names ) INNER JOIN test_suites ON (test_suites.id = test_results.test_suite_id) INNER JOIN test_types ON (test_types.test_type = test_results.test_type) diff --git a/testgen/ui/queries/scoring_queries.py b/testgen/ui/queries/scoring_queries.py index 4d1b2aec..67a075d0 100644 --- a/testgen/ui/queries/scoring_queries.py +++ b/testgen/ui/queries/scoring_queries.py @@ -42,6 +42,7 @@ def get_score_card_issue_reports(selected_issues: list["SelectedIssue"]) -> list groups.table_groups_name, results.disposition, results.profile_run_id::VARCHAR, + runs.job_execution_id::VARCHAR, types.suggested_action, results.table_groups_id::VARCHAR, results.project_code, diff --git a/testgen/ui/static/js/components/score_issues.js b/testgen/ui/static/js/components/score_issues.js index 7c6e2e63..d844321e 100644 --- a/testgen/ui/static/js/components/score_issues.js +++ b/testgen/ui/static/js/components/score_issues.js @@ -187,6 +187,9 @@ const ColumnProfilingButton = ( /** @type {string} */ table_group_id, emit, ) => { + if (!column_name) { + return div({ style: 'min-width: 36px;' }); + } return Button({ type: 'icon', icon: 'insert_chart', From 4eee69e3d4cc69e3286d97eec518603d3d159127 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Wed, 29 Apr 2026 01:39:00 -0400 Subject: [PATCH 081/123] fix(profiling): hide button for non-column results --- .../components/frontend/js/pages/hygiene_issues.js | 12 +++++++----- .../ui/components/frontend/js/pages/test_results.js | 5 +++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/testgen/ui/components/frontend/js/pages/hygiene_issues.js b/testgen/ui/components/frontend/js/pages/hygiene_issues.js index 64ab30f0..4ee6810b 100644 --- a/testgen/ui/components/frontend/js/pages/hygiene_issues.js +++ b/testgen/ui/components/frontend/js/pages/hygiene_issues.js @@ -678,11 +678,13 @@ const HygieneIssues = (/** @type Properties */ props) => { { class: 'tg-hi--detail flex-column fx-gap-4' }, div( { class: 'flex-row fx-gap-2 fx-justify-content-flex-end' }, - Button({ - type: 'stroked', icon: 'query_stats', label: 'Profiling', width: 'auto', - style: 'background: var(--button-generic-background-color)', - onclick: () => emit('ViewProfiling', { payload: sel.id }), - }), + sel.table_name !== '(multi-table)' + ? Button({ + type: 'stroked', icon: 'query_stats', label: 'Profiling', width: 'auto', + style: 'background: var(--button-generic-background-color)', + onclick: () => emit('ViewProfiling', { payload: sel.id }), + }) + : '', Button({ type: 'stroked', icon: 'visibility', label: 'Source Data', width: 'auto', style: 'background: var(--button-generic-background-color)', diff --git a/testgen/ui/components/frontend/js/pages/test_results.js b/testgen/ui/components/frontend/js/pages/test_results.js index 3c8aa0fe..d2a578fd 100644 --- a/testgen/ui/components/frontend/js/pages/test_results.js +++ b/testgen/ui/components/frontend/js/pages/test_results.js @@ -862,11 +862,12 @@ const TestResults = (/** @type Properties */ props) => { style: 'background: var(--button-generic-background-color);', onclick: () => emit('NotesClicked', { payload: { id: row.test_definition_id, table_name: row.table_name, column_name: row.column_names, test_name_short: row.test_name_short } }), }) : '', - Button({ + row.column_names ? Button({ type: 'stroked', icon: 'query_stats', label: 'Profiling', width: 'auto', style: 'background: var(--button-generic-background-color);', onclick: () => emit('ProfilingClicked', { payload: row.test_result_id }), - }), + }) + : '', Button({ type: 'stroked', icon: 'visibility', label: 'Source Data', width: 'auto', style: 'background: var(--button-generic-background-color);', From b908fdabbf4cbdd60b3ab6b2f3b6816520efb666 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Wed, 29 Apr 2026 02:03:32 -0400 Subject: [PATCH 082/123] fix(score-issues): support View for (none) category --- testgen/common/models/scores.py | 20 +++++++++++++++---- .../get_score_card_issues_by_column.sql | 4 ++-- .../get_score_card_issues_by_dimension.sql | 4 ++-- .../static/js/components/score_breakdown.js | 15 ++++++++------ .../ui/static/js/components/score_issues.js | 19 +++++++++++++++--- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/testgen/common/models/scores.py b/testgen/common/models/scores.py index 617f3fdb..f9901719 100644 --- a/testgen/common/models/scores.py +++ b/testgen/common/models/scores.py @@ -52,6 +52,10 @@ ] ScoreTypes = Literal["score", "cde_score"] +# Sentinel passed by the breakdown UI when the user drills down into a bucket whose +# grouping value is NULL (e.g. table-scope tests that have no column_name). +SCORE_CARD_NULL_DRILLDOWN = "__null__" + class ScoreCategory(enum.Enum): table_groups_name = "table_groups_name" @@ -387,25 +391,33 @@ def get_score_card_issues( value_ = value filters = self._get_raw_query_filters(cde_only=score_type == "cde_score") if group_by == "table_name": - table_group_id, value_ = value.split(".") + table_group_id, value_ = value.split(".", 1) filters.append(f"table_groups_id = '{table_group_id}'") elif group_by == "column_name": - table_group_id, table_name, value_ = value.split(".") + table_group_id, table_name, value_ = value.split(".", 2) filters.append(f"table_groups_id = '{table_group_id}'") filters.append(f"table_name = '{table_name}'") filters = " AND ".join(filters) + # Drilldown rows for buckets where the grouping value is NULL (e.g. table-scope + # tests that have no column_name) arrive as the SCORE_CARD_NULL_DRILLDOWN sentinel. + # Translate that to an IS NULL filter so the join still matches those rows. + is_null_drilldown = value_ == SCORE_CARD_NULL_DRILLDOWN + value_filter = f"{group_by} IS NULL" if is_null_drilldown else f"{group_by} = :value" dq_dimension_filter = "" if group_by == "dq_dimension": - dq_dimension_filter = " AND dq_dimension = :value" + dq_dimension_filter = ( + " AND dq_dimension IS NULL" if is_null_drilldown else " AND dq_dimension = :value" + ) query = ( read_template_sql_file(query_template_file, sub_directory="score_cards") .replace("{filters}", filters) + .replace("{value_filter}", value_filter) .replace("{group_by}", group_by) .replace("{dq_dimension_filter}", dq_dimension_filter) ) - params = {"value": value_} + params = {} if is_null_drilldown else {"value": value_} results = get_current_session().execute(text(query), params).mappings().all() return [dict(row) for row in results] diff --git a/testgen/template/score_cards/get_score_card_issues_by_column.sql b/testgen/template/score_cards/get_score_card_issues_by_column.sql index 85b8d26e..33005cd7 100644 --- a/testgen/template/score_cards/get_score_card_issues_by_column.sql +++ b/testgen/template/score_cards/get_score_card_issues_by_column.sql @@ -4,7 +4,7 @@ WITH score_profiling_runs AS ( table_name, column_name FROM v_dq_profile_scoring_latest_by_column - WHERE {filters} AND {group_by} = :value + WHERE {filters} AND {value_filter} ), anomalies AS ( SELECT results.id::VARCHAR AS id, @@ -45,7 +45,7 @@ score_test_runs AS ( column_name FROM v_dq_test_scoring_latest_by_column WHERE {filters} - AND {group_by} = :value + AND {value_filter} ), tests AS ( SELECT test_results.id::VARCHAR AS id, diff --git a/testgen/template/score_cards/get_score_card_issues_by_dimension.sql b/testgen/template/score_cards/get_score_card_issues_by_dimension.sql index c8704e6d..3e9d8bc3 100644 --- a/testgen/template/score_cards/get_score_card_issues_by_dimension.sql +++ b/testgen/template/score_cards/get_score_card_issues_by_dimension.sql @@ -4,7 +4,7 @@ WITH score_profiling_runs AS ( table_name, column_name FROM v_dq_profile_scoring_latest_by_dimension - WHERE {filters} AND {group_by} = :value + WHERE {filters} AND {value_filter} ), anomalies AS ( SELECT results.id::VARCHAR AS id, @@ -46,7 +46,7 @@ score_test_runs AS ( column_name FROM v_dq_test_scoring_latest_by_dimension WHERE {filters} - AND {group_by} = :value + AND {value_filter} ), tests AS ( SELECT test_results.id::VARCHAR AS id, diff --git a/testgen/ui/static/js/components/score_breakdown.js b/testgen/ui/static/js/components/score_breakdown.js index 38379eae..83a99e34 100644 --- a/testgen/ui/static/js/components/score_breakdown.js +++ b/testgen/ui/static/js/components/score_breakdown.js @@ -8,6 +8,11 @@ import { getScoreColor } from '../score_utils.js'; const { div, i, span } = van.tags; +// Mirrors SCORE_CARD_NULL_DRILLDOWN in testgen/common/models/scores.py — used to +// pass through buckets whose grouping value is NULL so the backend can rewrite +// the issues filter as `IS NULL`. +const NULL_DRILLDOWN = '__null__'; + const ScoreBreakdown = (score, breakdown, category, scoreType, onViewDetails, emit) => { loadStylesheet('score-breakdown', stylesheet); @@ -149,16 +154,14 @@ const ScoreCell = (value) => { }; const IssueCountCell = (value, row, score, category, scoreType, onViewDetails) => { - let drilldown = row[category]; + let drilldown = row[category] ?? NULL_DRILLDOWN; if (category === 'table_name') { - drilldown = `${row.table_groups_id}.${row.table_name}`; + drilldown = `${row.table_groups_id}.${row.table_name ?? NULL_DRILLDOWN}`; } else if (category === 'column_name') { - drilldown = `${row.table_groups_id}.${row.table_name}.${row.column_name}`; + drilldown = `${row.table_groups_id}.${row.table_name}.${row.column_name ?? NULL_DRILLDOWN}`; } - // Hide View for rows where the grouping value is null/empty — drilldown filtering - // needs a non-empty value on the backend and router, so the link would dead-end. - const canDrillDown = value && drilldown && onViewDetails; + const canDrillDown = value && onViewDetails; return div( { class: 'flex-row', style: `flex: ${BREAKDOWN_COLUMNS_SIZES.issue_ct}`, 'data-testid': 'score-breakdown-cell' }, diff --git a/testgen/ui/static/js/components/score_issues.js b/testgen/ui/static/js/components/score_issues.js index d844321e..5fc0247d 100644 --- a/testgen/ui/static/js/components/score_issues.js +++ b/testgen/ui/static/js/components/score_issues.js @@ -31,6 +31,12 @@ import { colorMap, formatTimestamp, caseInsensitiveSort } from '../display_utils const { div, i, span } = van.tags; const PAGE_SIZE = 100; + +// Mirrors SCORE_CARD_NULL_DRILLDOWN in testgen/common/models/scores.py — encodes +// drilldowns into buckets whose grouping value is NULL. +const NULL_DRILLDOWN = '__null__'; +// Display label for the NULL bucket (e.g. table-scope tests have no column). +const NULL_DRILLDOWN_LABEL = '(none)'; const SCROLL_CONTAINER = window.top.document.querySelector('.stMain'); const statusColors = { 'Potential PII': colorMap.grey, @@ -54,7 +60,10 @@ const IssuesTable = ( ) => { loadStylesheet('score-issues-table', stylesheet); - const drilldownParts = drilldown.split('.'); + // Decode any NULL_DRILLDOWN sentinels back to null so equality filters match + // the actual NULL values on issue rows (test_results.column_names IS NULL, etc.). + const drilldownParts = drilldown.split('.').map(part => part === NULL_DRILLDOWN ? null : part); + const drilldownDisplay = (part) => part === null ? NULL_DRILLDOWN_LABEL : part; const pageIndex = van.state(0); const filters = { table: van.state(['table_name', 'column_name'].includes(category) ? drilldownParts[1] : null), @@ -95,9 +104,13 @@ const IssuesTable = ( span(`Hygiene / Test Issues (${issues.length ?? 0}) for`), span( { class: 'text-primary' }, - `${COLUMN_LABEL[category] ?? '-'}: ${['table_name', 'column_name'].includes(category) ? drilldownParts.slice(1).join(' > ') : drilldown}`, + `${COLUMN_LABEL[category] ?? '-'}: ${ + ['table_name', 'column_name'].includes(category) + ? drilldownParts.slice(1).map(drilldownDisplay).join(' > ') + : drilldownDisplay(drilldownParts[0]) + }`, ), - category === 'column_name' + category === 'column_name' && drilldownParts[2] !== null ? ColumnProfilingButton(drilldownParts[2], drilldownParts[1], drilldownParts[0], emit) : null, ), From e63fa692a51c16cb0b9ae0389a4543d6c496dff0 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Wed, 29 Apr 2026 08:03:06 -0500 Subject: [PATCH 083/123] =?UTF-8?q?feat(mcp):=20profiling=20L1=20=E2=80=94?= =?UTF-8?q?=20table=20overview,=20column=20profiles,=20summary=20(TG-1028)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `get_table`, `list_column_profiles`, `list_profiling_summaries` MCP tools — the orientation-level profiling surface (MCP-03 in the roadmap). - Extend `get_data_inventory` with a per-TG profiling fragment so the LLM can navigate from project root to profiling tools in one pass. - Add `profiling_overview` workflow prompt. - Add `ProfileResult` ORM model (partial mapping — L1 columns only; L2/L3 will extend rather than re-introduce raw SQL). - Add `DataTable.get_profiling_overview` and `DataColumnChars.list_for_table_group`. The latter follows the `*clauses` pattern; `profiling_run_id` stays a kwarg because it changes the snapshot read, not a WHERE filter. - Extend `TableGroup.select_summary` with pagination and a `table_group_id` filter; switch timestamp source from `profiling_runs.profiling_starttime` to `job_executions.started_at` per the MCP-00 / TG-1046 direction. - Promote `_resolve_table_group` to `mcp/tools/common.py` as `resolve_table_group`; adopt in `list_tables` so an inaccessible TG raises `MCPResourceNotAccessible` instead of returning "No tables found". Recipe for `resolve_` helpers added to mcp-roadmap.md. - Normalize JE field labels in MCP output: "Profiling Run" / "Test Run" (was a mix of "Profiling job" / "Run ID" / "Run"). Matches the UI vocabulary; argument names remain `job_execution_id`. - Drop "Table ID" from `get_table` output — internal PK no MCP tool consumes; surfacing it was a hallucination vector. - PII gating in `list_column_profiles`: full pii_flag category for `view_pii` holders; redacted to "Y"/"—" otherwise. Co-Authored-By: Claude Opus 4.7 (1M context) --- testgen/common/models/data_column.py | 142 +++++- testgen/common/models/data_table.py | 158 ++++++- testgen/common/models/entity.py | 11 +- testgen/common/models/profile_result.py | 35 ++ testgen/common/models/table_group.py | 53 ++- testgen/mcp/prompts/workflows.py | 14 + testgen/mcp/server.py | 15 +- testgen/mcp/services/inventory_service.py | 46 +- testgen/mcp/tools/common.py | 18 +- testgen/mcp/tools/discovery.py | 12 +- testgen/mcp/tools/profiling.py | 255 +++++++++++ testgen/mcp/tools/test_results.py | 6 +- testgen/mcp/tools/test_runs.py | 2 +- testgen/ui/services/query_cache.py | 3 +- tests/unit/mcp/test_inventory_service.py | 84 ++++ tests/unit/mcp/test_tools_discovery.py | 36 +- tests/unit/mcp/test_tools_profiling.py | 511 ++++++++++++++++++++++ 17 files changed, 1333 insertions(+), 68 deletions(-) create mode 100644 testgen/common/models/profile_result.py create mode 100644 testgen/mcp/tools/profiling.py create mode 100644 tests/unit/mcp/test_tools_profiling.py diff --git a/testgen/common/models/data_column.py b/testgen/common/models/data_column.py index f47791f6..2eb526da 100644 --- a/testgen/common/models/data_column.py +++ b/testgen/common/models/data_column.py @@ -1,31 +1,145 @@ +from dataclasses import dataclass +from datetime import datetime from uuid import UUID, uuid4 -from sqlalchemy import Boolean, Column, ForeignKey, String, asc +from sqlalchemy import ( + Boolean, + Column, + Float, + ForeignKey, + Integer, + String, + and_, + asc, + func, + select, +) from sqlalchemy.dialects import postgresql -from testgen.common.models.entity import Entity +from testgen.common.models.entity import Entity, EntityMinimal +from testgen.common.models.hygiene_issue import HygieneIssue +from testgen.common.models.profile_result import ProfileResult + + +@dataclass +class ColumnProfileSummary(EntityMinimal): + column_name: str + table_name: str + general_type: str | None + functional_data_type: str | None + datatype_suggestion: str | None + pii_flag: str | None + critical_data_element: bool | None + record_ct: int | None + null_value_ct: int | None + distinct_value_ct: int | None + filled_value_ct: int | None + dq_score_profiling: float | None + dq_score_testing: float | None + anomaly_count: int class DataColumnChars(Entity): __tablename__ = "data_column_chars" id: UUID = Column("column_id", postgresql.UUID(as_uuid=True), primary_key=True, default=uuid4) + table_id: UUID = Column(postgresql.UUID(as_uuid=True), ForeignKey("data_table_chars.table_id")) table_groups_id: UUID = Column(postgresql.UUID(as_uuid=True), ForeignKey("table_groups.id")) schema_name: str = Column(String) table_name: str = Column(String) column_name: str = Column(String) + ordinal_position: int | None = Column(Integer) + general_type: str | None = Column(String) + column_type: str | None = Column(String) + db_data_type: str | None = Column(String) + functional_data_type: str | None = Column(String) + critical_data_element: bool | None = Column(Boolean) excluded_data_element: bool | None = Column(Boolean, nullable=True) pii_flag: str | None = Column(String(50), nullable=True) + drop_date: datetime | None = Column(postgresql.TIMESTAMP) + last_complete_profile_run_id: UUID | None = Column(postgresql.UUID(as_uuid=True)) + dq_score_profiling: float | None = Column(Float) + dq_score_testing: float | None = Column(Float) + + _default_order_by = (asc(ordinal_position), asc(column_name)) + + # Unmapped columns: description, data_source, source_system, source_process, + # business_domain, stakeholder_group, transform_level, aggregation_level, + # data_product, add_date, last_mod_date, test_ct, last_test_date, + # tests_last_run, tests_7_days_prior, tests_30_days_prior, fails_last_run, + # fails_7_days_prior, fails_30_days_prior, warnings_last_run, + # warnings_7_days_prior, warnings_30_days_prior, valid_profile_issue_ct, + # valid_test_issue_ct + + @classmethod + def list_for_table_group( + cls, + *clauses, + profiling_run_id: UUID | None = None, + page: int, + limit: int, + ) -> tuple[list[ColumnProfileSummary], int]: + profile_run_filter = ( + ProfileResult.profile_run_id == profiling_run_id + if profiling_run_id is not None + else ProfileResult.profile_run_id == cls.last_complete_profile_run_id + ) + + anomaly_subq = ( + select( + HygieneIssue.profile_run_id.label("profile_run_id"), + HygieneIssue.schema_name.label("schema_name"), + HygieneIssue.table_name.label("table_name"), + HygieneIssue.column_name.label("column_name"), + func.count().label("anomaly_count"), + ) + .where(func.coalesce(HygieneIssue.disposition, "Confirmed") == "Confirmed") + .group_by( + HygieneIssue.profile_run_id, + HygieneIssue.schema_name, + HygieneIssue.table_name, + HygieneIssue.column_name, + ) + .subquery() + ) + + query = ( + select( + cls.column_name, + cls.table_name, + cls.general_type, + cls.functional_data_type, + ProfileResult.datatype_suggestion, + cls.pii_flag, + cls.critical_data_element, + ProfileResult.record_ct, + ProfileResult.null_value_ct, + ProfileResult.distinct_value_ct, + ProfileResult.filled_value_ct, + cls.dq_score_profiling, + cls.dq_score_testing, + func.coalesce(anomaly_subq.c.anomaly_count, 0).label("anomaly_count"), + ) + .outerjoin( + ProfileResult, + and_( + profile_run_filter, + ProfileResult.schema_name == cls.schema_name, + ProfileResult.table_name == cls.table_name, + ProfileResult.column_name == cls.column_name, + ), + ) + .outerjoin( + anomaly_subq, + and_( + anomaly_subq.c.profile_run_id == ProfileResult.profile_run_id, + anomaly_subq.c.schema_name == cls.schema_name, + anomaly_subq.c.table_name == cls.table_name, + anomaly_subq.c.column_name == cls.column_name, + ), + ) + .where(cls.drop_date.is_(None), *clauses) + .order_by(asc(cls.table_name), asc(cls.ordinal_position), asc(cls.column_name)) + ) - _default_order_by = (asc(id),) - - # Unmapped columns: table_id, ordinal_position, general_type, column_type, - # db_data_type, functional_data_type, description, critical_data_element, - # data_source, source_system, source_process, business_domain, - # stakeholder_group, transform_level, aggregation_level, data_product, - # add_date, last_mod_date, drop_date, test_ct, last_test_date, - # tests_last_run, tests_7_days_prior, tests_30_days_prior, - # fails_last_run, fails_7_days_prior, fails_30_days_prior, - # warnings_last_run, warnings_7_days_prior, warnings_30_days_prior, - # last_complete_profile_run_id, valid_profile_issue_ct, - # valid_test_issue_ct, dq_score_profiling, dq_score_testing + return cls._paginate(query, page=page, limit=limit, data_class=ColumnProfileSummary) diff --git a/testgen/common/models/data_table.py b/testgen/common/models/data_table.py index 2046c2c9..b9323a27 100644 --- a/testgen/common/models/data_table.py +++ b/testgen/common/models/data_table.py @@ -1,27 +1,79 @@ +from dataclasses import dataclass, field +from datetime import datetime from uuid import UUID, uuid4 -from sqlalchemy import BigInteger, Column, ForeignKey, String, asc, func, select +from sqlalchemy import ( + BigInteger, + Boolean, + Column, + Float, + ForeignKey, + String, + and_, + asc, + case, + func, + select, +) from sqlalchemy.dialects import postgresql from testgen.common.models import get_current_session +from testgen.common.models.data_column import DataColumnChars from testgen.common.models.entity import Entity +from testgen.common.models.hygiene_issue import HygieneIssue +from testgen.common.models.job_execution import JobExecution +from testgen.common.models.profile_result import ProfileResult +from testgen.common.models.profiling_run import ProfilingRun from testgen.common.models.table_group import TableGroup +@dataclass +class TableColumnSummary: + column_name: str + general_type: str | None + functional_data_type: str | None + db_data_type: str | None + has_nulls: bool | None + + +@dataclass +class TableProfilingOverview: + id: UUID + table_groups_id: UUID + schema_name: str | None + table_name: str + record_ct: int | None + column_ct: int | None + dq_score_profiling: float | None + dq_score_testing: float | None + cde_count: int + anomaly_count: int + latest_profile_id: UUID | None + latest_profile_started_at: datetime | None + latest_profile_job_execution_id: UUID | None + columns: list[TableColumnSummary] = field(default_factory=list) + + class DataTable(Entity): __tablename__ = "data_table_chars" id: UUID = Column("table_id", postgresql.UUID(as_uuid=True), primary_key=True, default=uuid4) table_groups_id: UUID = Column(postgresql.UUID(as_uuid=True), ForeignKey("table_groups.id")) + schema_name: str | None = Column(String) table_name: str = Column(String) - column_ct: int = Column(BigInteger) + column_ct: int | None = Column(BigInteger) + record_ct: int | None = Column(BigInteger) + approx_record_ct: int | None = Column(BigInteger) + critical_data_element: bool | None = Column(Boolean) + drop_date: datetime | None = Column(postgresql.TIMESTAMP) + last_complete_profile_run_id: UUID | None = Column(postgresql.UUID(as_uuid=True)) + dq_score_profiling: float | None = Column(Float) + dq_score_testing: float | None = Column(Float) - # Unmapped columns: schema_name, functional_table_type, description, - # critical_data_element, data_source, source_system, source_process, - # business_domain, stakeholder_group, transform_level, aggregation_level, - # data_product, add_date, drop_date, last_refresh_date, approx_record_ct, - # record_ct, last_complete_profile_run_id, last_profile_record_ct, - # dq_score_profiling, dq_score_testing + # Unmapped columns: functional_table_type, description, data_source, + # source_system, source_process, business_domain, stakeholder_group, + # transform_level, aggregation_level, data_product, add_date, + # last_refresh_date, last_profile_record_ct @classmethod def select_table_names( @@ -45,3 +97,93 @@ def count_tables(cls, table_groups_id: UUID, project_codes: list[str] | None = N TableGroup.project_code.in_(project_codes) ) return get_current_session().scalar(query) or 0 + + @classmethod + def get_profiling_overview( + cls, table_groups_id: UUID, table_name: str, + ) -> TableProfilingOverview | None: + session = get_current_session() + + header_query = ( + select( + cls.id, + cls.table_groups_id, + cls.schema_name, + cls.table_name, + cls.record_ct, + cls.column_ct, + cls.dq_score_profiling, + cls.dq_score_testing, + cls.last_complete_profile_run_id.label("latest_profile_id"), + JobExecution.started_at.label("latest_profile_started_at"), + JobExecution.id.label("latest_profile_job_execution_id"), + ) + .outerjoin(ProfilingRun, ProfilingRun.id == cls.last_complete_profile_run_id) + .outerjoin(JobExecution, JobExecution.id == ProfilingRun.job_execution_id) + .where( + cls.table_groups_id == table_groups_id, + cls.table_name == table_name, + cls.drop_date.is_(None), + ) + ) + header = session.execute(header_query).mappings().first() + if not header: + return None + + columns_query = ( + select( + DataColumnChars.column_name, + DataColumnChars.general_type, + DataColumnChars.functional_data_type, + DataColumnChars.db_data_type, + case( + (ProfileResult.null_value_ct.is_(None), None), + (ProfileResult.null_value_ct > 0, True), + else_=False, + ).label("has_nulls"), + ) + .outerjoin( + ProfileResult, + and_( + ProfileResult.profile_run_id == DataColumnChars.last_complete_profile_run_id, + ProfileResult.schema_name == DataColumnChars.schema_name, + ProfileResult.table_name == DataColumnChars.table_name, + ProfileResult.column_name == DataColumnChars.column_name, + ), + ) + .where( + DataColumnChars.table_id == header["id"], + DataColumnChars.drop_date.is_(None), + ) + .order_by(asc(DataColumnChars.ordinal_position), asc(DataColumnChars.column_name)) + ) + columns = [TableColumnSummary(**row) for row in session.execute(columns_query).mappings().all()] + + cde_count = session.scalar( + select(func.count()) + .select_from(DataColumnChars) + .where( + DataColumnChars.table_id == header["id"], + DataColumnChars.critical_data_element.is_(True), + DataColumnChars.drop_date.is_(None), + ) + ) or 0 + + anomaly_count = 0 + if header["latest_profile_id"]: + anomaly_count = session.scalar( + select(func.count()) + .select_from(HygieneIssue) + .where( + HygieneIssue.profile_run_id == header["latest_profile_id"], + HygieneIssue.table_name == table_name, + func.coalesce(HygieneIssue.disposition, "Confirmed") == "Confirmed", + ) + ) or 0 + + return TableProfilingOverview( + **header, + cde_count=cde_count, + anomaly_count=anomaly_count, + columns=columns, + ) diff --git a/testgen/common/models/entity.py b/testgen/common/models/entity.py index 99e37c0a..6384c587 100644 --- a/testgen/common/models/entity.py +++ b/testgen/common/models/entity.py @@ -39,13 +39,18 @@ class Entity(Base): _default_order_by: tuple[str | InstrumentedAttribute] = ("id",) @classmethod - @st.cache_data(show_spinner=False) - def get(cls, identifier: str | int | UUID) -> Self | None: + @st.cache_data(show_spinner=False, hash_funcs=ENTITY_HASH_FUNCS) + def get(cls, identifier: str | int | UUID, *clauses) -> Self | None: + """Fetch by primary key, optionally narrowed by extra WHERE clauses. + + Returns ``None`` when no row matches both the identifier and any + provided ``*clauses``. + """ get_by_column = getattr(cls, cls._get_by) if isinstance(get_by_column.property.columns[0].type, postgresql.UUID) and not is_uuid4(identifier): return None - query = select(cls).where(get_by_column == identifier) + query = select(cls).where(get_by_column == identifier, *clauses) return get_current_session().scalars(query).first() @classmethod diff --git a/testgen/common/models/profile_result.py b/testgen/common/models/profile_result.py new file mode 100644 index 00000000..5826e63c --- /dev/null +++ b/testgen/common/models/profile_result.py @@ -0,0 +1,35 @@ +from uuid import UUID, uuid4 + +from sqlalchemy import BigInteger, Column, ForeignKey, Integer, String, asc +from sqlalchemy.dialects import postgresql + +from testgen.common.models.entity import Entity + + +class ProfileResult(Entity): + __tablename__ = "profile_results" + + id: UUID = Column(postgresql.UUID(as_uuid=True), primary_key=True, default=uuid4) + profile_run_id: UUID = Column(postgresql.UUID(as_uuid=True), ForeignKey("profiling_runs.id")) + table_groups_id: UUID = Column(postgresql.UUID(as_uuid=True), ForeignKey("table_groups.id")) + schema_name: str = Column(String) + table_name: str = Column(String) + column_name: str = Column(String) + position: int = Column(Integer) + + general_type: str | None = Column(String) + functional_data_type: str | None = Column(String) + datatype_suggestion: str | None = Column(String) + db_data_type: str | None = Column(String) + pii_flag: str | None = Column(String(50)) + + record_ct: int | None = Column(BigInteger) + value_ct: int | None = Column(BigInteger) + null_value_ct: int | None = Column(BigInteger) + distinct_value_ct: int | None = Column(BigInteger) + filled_value_ct: int | None = Column(BigInteger) + + _default_order_by = (asc(position), asc(column_name)) + + # Additional columns exist on this table (type-specific profile stats). + # They'll be mapped here as new MCP tools need them (L2+). diff --git a/testgen/common/models/table_group.py b/testgen/common/models/table_group.py index ff226fd1..fbe6a3ed 100644 --- a/testgen/common/models/table_group.py +++ b/testgen/common/models/table_group.py @@ -13,6 +13,7 @@ from testgen.common.models.entity import ENTITY_HASH_FUNCS, Entity, EntityMinimal from testgen.common.models.scores import ScoreDefinition from testgen.common.models.test_suite import TestSuite +from testgen.utils import is_uuid4 @dataclass @@ -51,6 +52,7 @@ class TableGroupStats(EntityMinimal): class TableGroupSummary(EntityMinimal): id: UUID table_groups_name: str + connection_name: str | None table_ct: int column_ct: int approx_record_ct: int @@ -59,9 +61,9 @@ class TableGroupSummary(EntityMinimal): data_point_ct: int dq_score_profiling: float dq_score_testing: float - latest_profile_id: UUID + latest_profile_id: UUID | None latest_profile_job_execution_id: UUID | None - latest_profile_start: datetime + latest_profile_start: datetime | None latest_anomalies_ct: int latest_anomalies_definite_ct: int latest_anomalies_likely_ct: int @@ -86,6 +88,7 @@ class TableGroupSummary(EntityMinimal): monitor_volume_is_pending: bool | None monitor_schema_is_pending: bool | None monitor_metric_is_pending: bool | None + total_count: int = 0 class TableGroup(Entity): @@ -196,7 +199,19 @@ def select_stats(cls, project_code: str, table_group_id: str | UUID | None = Non return [TableGroupStats(**row) for row in results] @classmethod - def select_summary(cls, project_code: str, for_dashboard: bool = False) -> Iterable[TableGroupSummary]: + def select_summary( + cls, + project_code: str, + table_group_id: str | UUID | None = None, + for_dashboard: bool = False, + page: int | None = None, + page_size: int | None = None, + ) -> tuple[list[TableGroupSummary], int]: + if table_group_id is not None and not is_uuid4(table_group_id): + return [], 0 + + paginate = page is not None and page_size is not None + query = f""" WITH stats AS ( SELECT table_groups_id, @@ -213,7 +228,7 @@ def select_summary(cls, project_code: str, for_dashboard: bool = False) -> Itera SELECT latest_run.table_groups_id, latest_run.id, latest_run.job_execution_id, - latest_run.profiling_starttime, + MAX(latest_je.started_at) AS started_at, latest_run.anomaly_ct, SUM( CASE @@ -246,6 +261,9 @@ def select_summary(cls, project_code: str, for_dashboard: bool = False) -> Itera LEFT JOIN profiling_runs latest_run ON ( groups.last_complete_profile_run_id = latest_run.id ) + LEFT JOIN job_executions latest_je ON ( + latest_run.job_execution_id = latest_je.id + ) LEFT JOIN profile_anomaly_results latest_anomalies ON ( latest_run.id = latest_anomalies.profile_run_id ) @@ -309,6 +327,7 @@ def select_summary(cls, project_code: str, for_dashboard: bool = False) -> Itera ) SELECT groups.id, groups.table_groups_name, + connections.connection_name, stats.table_ct, stats.column_ct, stats.approx_record_ct, @@ -319,7 +338,7 @@ def select_summary(cls, project_code: str, for_dashboard: bool = False) -> Itera groups.dq_score_testing, latest_profile.id AS latest_profile_id, latest_profile.job_execution_id AS latest_profile_job_execution_id, - latest_profile.profiling_starttime AS latest_profile_start, + latest_profile.started_at AS latest_profile_start, latest_profile.anomaly_ct AS latest_anomalies_ct, latest_profile.definite_ct AS latest_anomalies_definite_ct, latest_profile.likely_ct AS latest_anomalies_likely_ct, @@ -343,19 +362,31 @@ def select_summary(cls, project_code: str, for_dashboard: bool = False) -> Itera monitor_tables.freshness_is_pending AS monitor_freshness_is_pending, monitor_tables.volume_is_pending AS monitor_volume_is_pending, monitor_tables.schema_is_pending AS monitor_schema_is_pending, - monitor_tables.metric_is_pending AS monitor_metric_is_pending + monitor_tables.metric_is_pending AS monitor_metric_is_pending, + COUNT(*) OVER() AS total_count FROM table_groups AS groups + LEFT JOIN connections ON (groups.connection_id = connections.connection_id) LEFT JOIN stats ON (groups.id = stats.table_groups_id) LEFT JOIN latest_profile ON (groups.id = latest_profile.table_groups_id) LEFT JOIN monitor_tables ON (groups.id = monitor_tables.table_group_id) LEFT JOIN lookback_windows ON (groups.id = lookback_windows.table_group_id) WHERE groups.project_code = :project_code - {"AND groups.include_in_dashboard IS TRUE" if for_dashboard else ""}; + {"AND groups.id = :table_group_id" if table_group_id else ""} + {"AND groups.include_in_dashboard IS TRUE" if for_dashboard else ""} + ORDER BY LOWER(groups.table_groups_name) + {"LIMIT :limit OFFSET :offset" if paginate else ""}; """ - params = {"project_code": project_code} - db_session = get_current_session() - results = db_session.execute(text(query), params).mappings().all() - return [TableGroupSummary(**row) for row in results] + params: dict = {"project_code": project_code} + if table_group_id: + params["table_group_id"] = str(table_group_id) + if paginate: + params["limit"] = page_size + params["offset"] = (page - 1) * page_size + + results = get_current_session().execute(text(query), params).mappings().all() + items = [TableGroupSummary(**row) for row in results] + total = items[0].total_count if items else 0 + return items, total @classmethod def is_in_use(cls, ids: list[str]) -> bool: diff --git a/testgen/mcp/prompts/workflows.py b/testgen/mcp/prompts/workflows.py index a4061a7b..6d336f19 100644 --- a/testgen/mcp/prompts/workflows.py +++ b/testgen/mcp/prompts/workflows.py @@ -63,6 +63,20 @@ def table_health(table_name: str) -> str: """ +def profiling_overview() -> str: + """Explore the profiling results for a table group — understand data shapes, types, null rates, and anomalies.""" + return """\ +Please perform a profiling exploration: + +1. Call `get_data_inventory()` to see projects and table groups, with profiling status per group. +2. Pick a table group that has been profiled. +3. Call `list_profiling_summaries(table_group_id='...')` for the quality health overview (scores, anomaly counts, last profiled). +4. Call `get_table(table_group_id='...', table_name='...')` for structural metadata, the column list, and table-level highlights. +5. Call `list_column_profiles(table_group_id='...', table_name='...')` to scan all columns — datatype, null rates, distinct counts, quality scores, and anomaly counts per column. +6. Summarize findings: which tables/columns have quality concerns, and which trends are worth investigating further. +""" + + def compare_runs(test_suite: str | None = None) -> str: """Compare the two most recent test runs to identify regressions and improvements. diff --git a/testgen/mcp/server.py b/testgen/mcp/server.py index 35753268..d17c9a9f 100644 --- a/testgen/mcp/server.py +++ b/testgen/mcp/server.py @@ -87,8 +87,15 @@ def build_mcp_app( server_url: MCP resource server URL. Defaults to ``{api_base_url}/mcp``. """ from testgen.mcp.exceptions import mcp_error_handler - from testgen.mcp.prompts.workflows import compare_runs, health_check, investigate_failures, table_health + from testgen.mcp.prompts.workflows import ( + compare_runs, + health_check, + investigate_failures, + profiling_overview, + table_health, + ) from testgen.mcp.tools.discovery import get_data_inventory, list_projects, list_tables, list_test_suites + from testgen.mcp.tools.profiling import get_table, list_column_profiles, list_profiling_summaries from testgen.mcp.tools.reference import get_test_type, glossary_resource, test_types_resource from testgen.mcp.tools.source_data import get_source_data, get_source_data_query from testgen.mcp.tools.test_definitions import get_test, list_test_notes, list_test_types, list_tests @@ -144,16 +151,20 @@ def safe_prompt(fn): safe_tool(get_test) safe_tool(list_test_notes) safe_tool(list_test_types) + safe_tool(get_table) + safe_tool(list_column_profiles) + safe_tool(list_profiling_summaries) # Resources (2) safe_resource("testgen://test-types", test_types_resource) safe_resource("testgen://glossary", glossary_resource) - # Prompts (4) + # Prompts safe_prompt(health_check) safe_prompt(investigate_failures) safe_prompt(table_health) safe_prompt(compare_runs) + safe_prompt(profiling_overview) app = mcp.streamable_http_app() return app, mcp.session_manager diff --git a/testgen/mcp/services/inventory_service.py b/testgen/mcp/services/inventory_service.py index 55d40045..e845eca6 100644 --- a/testgen/mcp/services/inventory_service.py +++ b/testgen/mcp/services/inventory_service.py @@ -1,10 +1,13 @@ +from uuid import UUID + from sqlalchemy import and_, select from testgen.common.models import get_current_session from testgen.common.models.connection import Connection from testgen.common.models.project import Project -from testgen.common.models.table_group import TableGroup +from testgen.common.models.table_group import TableGroup, TableGroupSummary from testgen.common.models.test_suite import TestSuite +from testgen.utils import friendly_score, score def get_inventory( @@ -91,6 +94,12 @@ def get_inventory( view_codes_set = set(view_project_codes) + profiling_by_tg: dict[UUID, TableGroupSummary] = {} + for code in view_codes_set: + summaries, _ = TableGroup.select_summary(code) + for summary in summaries: + profiling_by_tg[summary.id] = summary + # Format as Markdown lines = ["# Data Inventory\n"] @@ -115,17 +124,25 @@ def get_inventory( continue for group_id, group in conn["groups"].items(): + summary = profiling_by_tg.get(group_id) if can_view else None + if compact_groups or not can_view: - lines.append( + line = ( f"- **{group['name']}**: id: `{group_id}`, schema: `{group['schema']}`, " f"test suites: {len(group['suites'])}" ) + if summary: + line += f", {_profiling_summary_fragment(summary)}" + lines.append(line) continue lines.append( f"#### Table Group: {group['name']} (id: `{group_id}`, schema: `{group['schema']}`)\n" ) + if summary: + lines.append(f"_{_profiling_summary_fragment(summary)}_\n") + if not group["suites"]: lines.append("_No test suites._\n") continue @@ -139,7 +156,30 @@ def get_inventory( lines.append( "---\n" "Use `list_tables(table_group_id='...')` to see tables in a group.\n" - "Use `list_test_suites(project_code='...')` for suite details and latest run stats." + "Use `list_test_suites(project_code='...')` for suite details and latest run stats.\n" + "Use `list_profiling_summaries(table_group_id='...')` for the quality score rollup and anomaly counts." ) return "\n".join(lines) + + +def _profiling_summary_fragment(summary: TableGroupSummary) -> str: + """Compact one-liner of profiling metadata for a table group.""" + if not summary.latest_profile_id: + return "not profiled yet" + + anomaly_total = ( + (summary.latest_anomalies_definite_ct or 0) + + (summary.latest_anomalies_likely_ct or 0) + + (summary.latest_anomalies_possible_ct or 0) + ) + combined = friendly_score(score(summary.dq_score_profiling, summary.dq_score_testing)) + profiled_at = ( + summary.latest_profile_start.strftime("%Y-%m-%d") + if summary.latest_profile_start else "—" + ) + return ( + f"Score {combined}, anomalies {anomaly_total}, " + f"last profiled {profiled_at}, " + f"profiling run `{summary.latest_profile_job_execution_id}`" + ) diff --git a/testgen/mcp/tools/common.py b/testgen/mcp/tools/common.py index fee46856..3fb3bd78 100644 --- a/testgen/mcp/tools/common.py +++ b/testgen/mcp/tools/common.py @@ -2,9 +2,11 @@ from uuid import UUID from testgen.common.date_service import parse_since +from testgen.common.models.table_group import TableGroup from testgen.common.models.test_definition import TestType from testgen.common.models.test_result import TestResultStatus -from testgen.mcp.exceptions import MCPUserError +from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError +from testgen.mcp.permissions import get_project_permissions def parse_uuid(value: str, label: str = "ID") -> UUID: @@ -54,3 +56,17 @@ def format_page_footer(total: int, page: int, limit: int) -> str: if page >= total_pages: return "" return f"_Page {page} of {total_pages}. Use `page={page + 1}` for more._" + + +# Entity resolution helpers — see mcp-roadmap.md "Entity Resolution Helpers" guideline. +# Extract a new resolve_ here when a second caller needs the same parse-uuid + +# perm-scoped lookup + collapsed-error pattern. + +def resolve_table_group(table_group_id: str) -> TableGroup: + """Resolve a TG ID, collapsing missing-or-inaccessible into one error path.""" + tg_uuid = parse_uuid(table_group_id, "table_group_id") + perms = get_project_permissions() + tg = TableGroup.get(tg_uuid, TableGroup.project_code.in_(perms.allowed_codes)) + if tg is None: + raise MCPResourceNotAccessible("Table group", table_group_id) + return tg diff --git a/testgen/mcp/tools/discovery.py b/testgen/mcp/tools/discovery.py index 5f6371ac..8909b3dd 100644 --- a/testgen/mcp/tools/discovery.py +++ b/testgen/mcp/tools/discovery.py @@ -4,7 +4,7 @@ from testgen.common.models.test_run import TestRun from testgen.common.models.test_suite import TestSuite from testgen.mcp.permissions import get_project_permissions, mcp_permission -from testgen.mcp.tools.common import parse_uuid +from testgen.mcp.tools.common import resolve_table_group from testgen.mcp.tools.markdown import MdDoc @@ -109,14 +109,12 @@ def list_tables(table_group_id: str, limit: int = 200, page: int = 1) -> str: limit: Maximum number of tables per page (default 200). page: Page number, starting from 1 (default 1). """ - group_uuid = parse_uuid(table_group_id, "table_group_id") - - perms = get_project_permissions() - project_codes = perms.allowed_codes + tg = resolve_table_group(table_group_id) + project_codes = [tg.project_code] offset = (page - 1) * limit - table_names = DataTable.select_table_names(group_uuid, limit=limit, offset=offset, project_codes=project_codes) - total = DataTable.count_tables(group_uuid, project_codes=project_codes) + table_names = DataTable.select_table_names(tg.id, limit=limit, offset=offset, project_codes=project_codes) + total = DataTable.count_tables(tg.id, project_codes=project_codes) if not table_names: if page > 1: diff --git a/testgen/mcp/tools/profiling.py b/testgen/mcp/tools/profiling.py new file mode 100644 index 00000000..69bf9bf1 --- /dev/null +++ b/testgen/mcp/tools/profiling.py @@ -0,0 +1,255 @@ +from uuid import UUID + +from testgen.common.models import with_database_session +from testgen.common.models.data_column import ColumnProfileSummary, DataColumnChars +from testgen.common.models.data_table import DataTable +from testgen.common.models.profiling_run import ProfilingRun +from testgen.common.models.table_group import TableGroup, TableGroupSummary +from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError +from testgen.mcp.permissions import get_project_permissions, mcp_permission +from testgen.mcp.tools.common import ( + format_page_footer, + format_page_info, + parse_uuid, + resolve_table_group, +) +from testgen.mcp.tools.markdown import MdDoc +from testgen.utils import friendly_score + + +@with_database_session +@mcp_permission("catalog") +def get_table(table_group_id: str, table_name: str) -> str: + """Get an overview of a table with profiling highlights: structural metadata, column list, quality scores, and anomaly count from the latest profiling run. + + Args: + table_group_id: UUID of the table group, e.g. from `get_data_inventory`. + table_name: Table name exactly as stored in TestGen (case-sensitive). + """ + tg = resolve_table_group(table_group_id) + + overview = DataTable.get_profiling_overview(tg.id, table_name) + if overview is None: + raise MCPUserError(f"Table `{table_name}` not found in this table group.") + + fq_name = f"{overview.schema_name}.{overview.table_name}" if overview.schema_name else overview.table_name + + doc = MdDoc() + doc.heading(1, f"Table: {fq_name}") + doc.field("Record count", overview.record_ct) + doc.field("Column count", overview.column_ct) + doc.field("Critical data elements", overview.cde_count) + doc.field("Profiling Score", friendly_score(overview.dq_score_profiling)) + doc.field("Testing Score", friendly_score(overview.dq_score_testing)) + doc.field("Anomalies (confirmed)", overview.anomaly_count) + doc.field("Last profiled", overview.latest_profile_started_at) + doc.field("Profiling Run", overview.latest_profile_job_execution_id, code=True) + + if overview.columns: + doc.heading(2, "Columns") + doc.table( + ["Column", "Type", "Functional type", "DB type", "Has nulls"], + [ + [c.column_name, c.general_type, c.functional_data_type, c.db_data_type, c.has_nulls] + for c in overview.columns + ], + code=[0], + ) + else: + doc.text("_No columns recorded for this table._") + + return doc.render() + + +@with_database_session +@mcp_permission("catalog") +def list_column_profiles( + table_group_id: str, + table_name: str | None = None, + columns: list[str] | None = None, + job_execution_id: str | None = None, + limit: int = 100, + page: int = 1, +) -> str: + """List per-column profile headers (~14 fields each) — the Layer 1 scan of profiling results so an LLM can pick which columns to drill into. + + PII details (category, full value) require `view_pii` permission; otherwise the + PII flag is redacted to a boolean-like value. + + Args: + table_group_id: UUID of the table group, e.g. from `get_data_inventory`. + table_name: Optional — scope to one table (case-sensitive). + columns: Optional — specific column names to include (case-sensitive). + job_execution_id: UUID of a profiling run, e.g. from `get_table` or + `list_profiling_summaries`. When omitted, each column uses its own + latest run. + limit: Page size (default 100). + page: Page number starting at 1 (default 1). + """ + tg = resolve_table_group(table_group_id) + + profiling_run_id: UUID | None = None + if job_execution_id: + run_uuid = parse_uuid(job_execution_id, "job_execution_id") + profiling_run = ProfilingRun.get_by_id_or_job(run_uuid) + if profiling_run is None or profiling_run.table_groups_id != tg.id: + raise MCPResourceNotAccessible("Profiling run", job_execution_id) + profiling_run_id = profiling_run.id + + clauses = [DataColumnChars.table_groups_id == tg.id] + if table_name: + clauses.append(DataColumnChars.table_name == table_name) + if columns: + clauses.append(DataColumnChars.column_name.in_(columns)) + + data, total = DataColumnChars.list_for_table_group( + *clauses, + profiling_run_id=profiling_run_id, + page=page, + limit=limit, + ) + + if not data: + if page > 1: + return f"No column profiles on page {page} (total: {total})." + return f"No column profiles found for table group `{table_group_id}`." + + has_view_pii = tg.project_code in get_project_permissions().codes_allowed_to("view_pii") + + doc = MdDoc() + scope_descriptor = f"table group `{table_group_id}`" + if table_name: + scope_descriptor = f"table `{table_name}` in {scope_descriptor}" + doc.heading(1, f"Column profiles for {scope_descriptor}") + + page_info = format_page_info(total, page, limit) + if page_info: + doc.text(page_info) + + headers = [ + "Column", "Table", "Type", "Functional type", "Suggestion", + "PII", "CDE", + "Records", "Nulls", "Distinct", "Filled", + "Profiling Score", "Testing Score", "Anomalies", + ] + rows = [_render_column_profile_row(c, has_view_pii=has_view_pii) for c in data] + doc.table(headers, rows, code=[0, 1]) + + footer = format_page_footer(total, page, limit) + if footer: + doc.text(footer) + + return doc.render() + + +@with_database_session +@mcp_permission("catalog") +def list_profiling_summaries( + table_group_id: str | None = None, + project_code: str | None = None, + limit: int = 20, + page: int = 1, +) -> str: + """List aggregated profiling health summaries for a table group or across a project — quality scores, anomaly counts, record counts, last profiled date. + + Args: + table_group_id: UUID of a specific table group, e.g. from + `get_data_inventory`. Returns just that group's summary. Mutually + exclusive with `project_code`. + project_code: Project code to summarize all table groups within, e.g. + from `list_projects`. Returns all groups, paginated. Mutually + exclusive with `table_group_id`. + limit: Page size when iterating table groups in a project (default 20). + page: Page number starting at 1 (default 1). + """ + if table_group_id and project_code: + raise MCPUserError("Pass either `table_group_id` or `project_code`, not both.") + if not table_group_id and not project_code: + raise MCPUserError("Provide either `table_group_id` or `project_code`.") + + if table_group_id: + tg = resolve_table_group(table_group_id) + summaries, _ = TableGroup.select_summary(tg.project_code, table_group_id=tg.id) + if not summaries: + return f"No table group found for `{table_group_id}`." + + doc = MdDoc() + doc.heading(1, f"Profiling summary for table group `{table_group_id}`") + for s in summaries: + _render_table_group_summary(doc, s) + return doc.render() + + perms = get_project_permissions() + perms.verify_access( + project_code, + not_found=MCPResourceNotAccessible("Project", project_code), + ) + summaries, total = TableGroup.select_summary(project_code, page=page, page_size=limit) + if not summaries: + if page > 1: + return f"No table groups on page {page} (total: {total})." + return f"No table groups in project `{project_code}`." + + doc = MdDoc() + doc.heading(1, f"Profiling summary for project `{project_code}`") + page_info = format_page_info(total, page, limit) + if page_info: + doc.text(page_info) + for s in summaries: + _render_table_group_summary(doc, s) + footer = format_page_footer(total, page, limit) + if footer: + doc.text(footer) + return doc.render() + + +def _render_column_profile_row(c: ColumnProfileSummary, *, has_view_pii: bool) -> list: + if has_view_pii: + pii_display = c.pii_flag + else: + pii_display = "Y" if c.pii_flag else None + + return [ + c.column_name, + c.table_name, + c.general_type, + c.functional_data_type, + c.datatype_suggestion, + pii_display, + "Y" if c.critical_data_element else None, + c.record_ct, + c.null_value_ct, + c.distinct_value_ct, + c.filled_value_ct, + friendly_score(c.dq_score_profiling), + friendly_score(c.dq_score_testing), + c.anomaly_count, + ] + + +def _render_table_group_summary(doc: MdDoc, s: TableGroupSummary) -> None: + doc.heading(2, s.table_groups_name) + if s.connection_name: + doc.field("Connection", s.connection_name) + doc.field("Table group", s.id, code=True) + + if not s.latest_profile_id: + doc.text("_Not profiled yet._") + return + + doc.field("Tables", s.table_ct or 0) + doc.field("Columns", s.column_ct or 0) + doc.field("Records", s.record_ct or 0) + doc.field("Profiling Score", friendly_score(s.dq_score_profiling)) + doc.field("Testing Score", friendly_score(s.dq_score_testing)) + doc.field( + "Anomalies (confirmed)", + f"{(s.latest_anomalies_definite_ct or 0) + (s.latest_anomalies_likely_ct or 0) + (s.latest_anomalies_possible_ct or 0)} total " + f"— {s.latest_anomalies_definite_ct or 0} definite, " + f"{s.latest_anomalies_likely_ct or 0} likely, " + f"{s.latest_anomalies_possible_ct or 0} possible", + ) + doc.field("Last profiled", s.latest_profile_start) + doc.field("Profiling Run", s.latest_profile_job_execution_id, code=True) + if s.monitor_lookback_end: + doc.field("Last monitored", s.monitor_lookback_end) diff --git a/testgen/mcp/tools/test_results.py b/testgen/mcp/tools/test_results.py index f8355ba8..01f7363d 100644 --- a/testgen/mcp/tools/test_results.py +++ b/testgen/mcp/tools/test_results.py @@ -376,7 +376,7 @@ def search_test_results( doc.heading(2, f"[{status_str}] {display_name} on `{r.column_names}` in `{r.table_name}`") else: doc.heading(2, f"[{status_str}] {display_name} on `{r.table_name}`") - doc.field("Run", r.job_execution_id or r.test_run_id, code=True) + doc.field("Test Run", r.job_execution_id or r.test_run_id, code=True) doc.field("Run time", r.test_time) doc.field("Test suite", r.test_suite_name) doc.field("Test definition", r.test_definition_id, code=True) @@ -546,8 +546,8 @@ def _accessible(run) -> bool: doc = MdDoc() doc.heading(1, "Test Run Diff") - doc.field("Run A", job_execution_id_a, code=True) - doc.field("Run B", job_execution_id_b, code=True) + doc.field("Test Run A", job_execution_id_a, code=True) + doc.field("Test Run B", job_execution_id_b, code=True) doc.table( headers=["Category", "Count"], rows=[ diff --git a/testgen/mcp/tools/test_runs.py b/testgen/mcp/tools/test_runs.py index 6df736a4..598d1d72 100644 --- a/testgen/mcp/tools/test_runs.py +++ b/testgen/mcp/tools/test_runs.py @@ -65,7 +65,7 @@ def get_recent_test_runs(project_code: str, test_suite: str | None = None, limit errors = run.error_ct or 0 doc.heading(3, f"{run.created_at} — {run.status_label}") - doc.field("Run ID", run.job_execution_id, code=True) + doc.field("Test Run", run.job_execution_id, code=True) doc.field("Started", run.created_at) doc.field("Ended", run.completed_at or "In progress") doc.field("Results", f"{run.test_ct or 0} tests — {passed} passed, {failed} failed, {warning} warnings, {errors} errors") diff --git a/testgen/ui/services/query_cache.py b/testgen/ui/services/query_cache.py index e7878e17..7dca4918 100644 --- a/testgen/ui/services/query_cache.py +++ b/testgen/ui/services/query_cache.py @@ -112,7 +112,8 @@ def get_table_group_summaries( project_code: str, for_dashboard: bool = False, ) -> Iterable[TableGroupSummary]: - return TableGroup.select_summary(project_code, for_dashboard) + items, _ = TableGroup.select_summary(project_code, for_dashboard=for_dashboard) + return items # -- ProfilingRun ------------------------------------------------------------- diff --git a/tests/unit/mcp/test_inventory_service.py b/tests/unit/mcp/test_inventory_service.py index 715ef476..43094360 100644 --- a/tests/unit/mcp/test_inventory_service.py +++ b/tests/unit/mcp/test_inventory_service.py @@ -10,6 +10,13 @@ def session_mock(): yield mock.return_value +@pytest.fixture(autouse=True) +def table_group_select_summary_mock(): + with patch("testgen.mcp.services.inventory_service.TableGroup.select_summary") as mock: + mock.return_value = ([], 0) + yield mock + + def _make_row(project_code="demo", project_name="Demo", connection_id=1, connection_name="main", table_group_id=None, table_groups_name="core", table_group_schema="public", test_suite_id=None, test_suite="Quality"): @@ -138,3 +145,80 @@ def test_get_inventory_with_view_shows_all_details(mock_select, session_mock): assert "Visible Suite" in result assert str(suite_id) in result assert "requires `view` permission" not in result + + +# ---------------------------------------------------------------------- +# Profiling fragment merge (TG-1028) +# ---------------------------------------------------------------------- + + +def _profiling_summary(tg_id, *, profiled=True, anomalies=(2, 2, 11)): + s = MagicMock() + s.id = tg_id + s.dq_score_profiling = 95.0 + s.dq_score_testing = 80.0 + s.latest_profile_id = uuid4() if profiled else None + s.latest_profile_job_execution_id = uuid4() if profiled else None + s.latest_profile_start = MagicMock() + s.latest_profile_start.strftime.return_value = "2026-04-23" + definite, likely, possible = anomalies + s.latest_anomalies_definite_ct = definite + s.latest_anomalies_likely_ct = likely + s.latest_anomalies_possible_ct = possible + return s + + +@patch("testgen.mcp.services.inventory_service.select") +def test_get_inventory_includes_profiling_fragment_when_view( + mock_select, session_mock, table_group_select_summary_mock, +): + """With view permission, the per-TG profiling one-liner appears under the TG.""" + tg_id = uuid4() + summary = _profiling_summary(tg_id) + table_group_select_summary_mock.return_value = ([summary], 1) + session_mock.execute.return_value.all.return_value = [_make_row(table_group_id=tg_id)] + + from testgen.mcp.services.inventory_service import get_inventory + result = get_inventory(project_codes=["demo"], view_project_codes=["demo"]) + + assert "Score" in result + assert "anomalies 15" in result # 2+2+11 + assert "last profiled 2026-04-23" in result + assert f"profiling run `{summary.latest_profile_job_execution_id}`" in result + + +@patch("testgen.mcp.services.inventory_service.select") +def test_get_inventory_omits_profiling_fragment_without_view( + mock_select, session_mock, table_group_select_summary_mock, +): + """Catalog-only access skips the profiling fragment entirely (no select_summary lookup).""" + tg_id = uuid4() + session_mock.execute.return_value.all.return_value = [_make_row(table_group_id=tg_id)] + + from testgen.mcp.services.inventory_service import get_inventory + result = get_inventory(project_codes=["demo"], view_project_codes=[]) + + assert "anomalies" not in result + assert "last profiled" not in result + assert "profiling run" not in result + # select_summary should not be called for projects we can't view. + table_group_select_summary_mock.assert_not_called() + + +@patch("testgen.mcp.services.inventory_service.select") +def test_get_inventory_never_profiled_fragment( + mock_select, session_mock, table_group_select_summary_mock, +): + """Never-profiled TG renders 'not profiled yet' instead of score/anomaly counts.""" + tg_id = uuid4() + table_group_select_summary_mock.return_value = ( + [_profiling_summary(tg_id, profiled=False)], 1, + ) + session_mock.execute.return_value.all.return_value = [_make_row(table_group_id=tg_id)] + + from testgen.mcp.services.inventory_service import get_inventory + result = get_inventory(project_codes=["demo"], view_project_codes=["demo"]) + + assert "not profiled yet" in result + assert "anomalies" not in result + assert "Score" not in result diff --git a/tests/unit/mcp/test_tools_discovery.py b/tests/unit/mcp/test_tools_discovery.py index 49c8430f..4ed20201 100644 --- a/tests/unit/mcp/test_tools_discovery.py +++ b/tests/unit/mcp/test_tools_discovery.py @@ -3,7 +3,7 @@ import pytest -from testgen.mcp.exceptions import MCPPermissionDenied +from testgen.mcp.exceptions import MCPPermissionDenied, MCPResourceNotAccessible from testgen.mcp.permissions import ProjectPermissions @@ -196,23 +196,31 @@ def test_list_test_suites_raises_denial_for_insufficient_permission( list_test_suites("secret_project") +@patch("testgen.mcp.tools.common.TableGroup") +def test_list_tables_rejects_inaccessible_group(mock_tg_cls, db_session_mock): + """Inaccessible or non-existent TG raises MCPResourceNotAccessible — same path.""" + mock_tg_cls.get.return_value = None + + from testgen.mcp.tools.discovery import list_tables + + with pytest.raises(MCPResourceNotAccessible, match="Table group .* not found or not accessible"): + list_tables(str(uuid4())) + + @patch("testgen.mcp.tools.discovery.DataTable") -@patch("testgen.mcp.permissions._compute_project_permissions") -def test_list_tables_returns_not_found_for_inaccessible_group( - mock_compute, mock_dt, db_session_mock, -): - mock_compute.return_value = ProjectPermissions( - memberships={"proj_a": "role_a"}, - permission="catalog", - ) - mock_dt.select_table_names.return_value = [] - mock_dt.count_tables.return_value = 0 +@patch("testgen.mcp.tools.common.TableGroup") +def test_list_tables_scopes_data_lookup_to_resolved_tg_project(mock_tg_cls, mock_dt, db_session_mock): + """After resolution, data lookup is scoped to just the TG's project, not all allowed projects.""" + tg = MagicMock() + tg.id = uuid4() + tg.project_code = "proj_a" + mock_tg_cls.get.return_value = tg + mock_dt.select_table_names.return_value = ["customers"] + mock_dt.count_tables.return_value = 1 from testgen.mcp.tools.discovery import list_tables - result = list_tables(str(uuid4())) + list_tables(str(uuid4())) - assert "No tables found" in result - mock_dt.select_table_names.assert_called_once() call_kwargs = mock_dt.select_table_names.call_args assert call_kwargs.kwargs["project_codes"] == ["proj_a"] diff --git a/tests/unit/mcp/test_tools_profiling.py b/tests/unit/mcp/test_tools_profiling.py new file mode 100644 index 00000000..464b4a6a --- /dev/null +++ b/tests/unit/mcp/test_tools_profiling.py @@ -0,0 +1,511 @@ +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest + +from testgen.common.models.data_column import ColumnProfileSummary +from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError +from testgen.mcp.permissions import ProjectPermissions + +# ---------------------------------------------------------------------- +# Fixtures / helpers +# ---------------------------------------------------------------------- + + +def _mock_table_group(tg_id=None, project_code="demo"): + tg = MagicMock() + tg.id = tg_id or uuid4() + tg.project_code = project_code + return tg + + +def _mock_overview(**overrides): + overview = MagicMock() + overview.id = uuid4() + overview.table_groups_id = uuid4() + overview.schema_name = "demo" + overview.table_name = "orders" + overview.record_ct = 1000 + overview.column_ct = 5 + overview.cde_count = 2 + overview.dq_score_profiling = 95.0 + overview.dq_score_testing = 90.0 + overview.anomaly_count = 3 + overview.latest_profile_id = uuid4() + overview.latest_profile_started_at = "2026-04-23 12:00:00" + overview.latest_profile_job_execution_id = uuid4() + overview.columns = [ + MagicMock( + column_name="id", general_type="N", functional_data_type="ID-Unique", + db_data_type="integer", has_nulls=False, + ), + MagicMock( + column_name="customer_name", general_type="A", functional_data_type="Person Given Name", + db_data_type="varchar(50)", has_nulls=True, + ), + ] + for k, v in overrides.items(): + setattr(overview, k, v) + return overview + + +def _column_summary(**overrides) -> ColumnProfileSummary: + defaults = { + "column_name": "customer_name", + "table_name": "customers", + "general_type": "A", + "functional_data_type": "Person Given Name", + "datatype_suggestion": "VARCHAR(20)", + "pii_flag": "B/NAME/Individual", + "critical_data_element": False, + "record_ct": 500, + "null_value_ct": 0, + "distinct_value_ct": 260, + "filled_value_ct": 0, + "dq_score_profiling": 100.0, + "dq_score_testing": 98.5, + "anomaly_count": 1, + } + defaults.update(overrides) + return ColumnProfileSummary(**defaults) + + +def _mock_summary(**overrides): + s = MagicMock() + s.id = uuid4() + s.table_groups_name = "demo-tg" + s.connection_name = "main" + s.table_ct = 5 + s.column_ct = 69 + s.record_ct = 1903 + s.dq_score_profiling = 98.6 + s.dq_score_testing = 81.4 + s.latest_profile_id = uuid4() + s.latest_profile_job_execution_id = uuid4() + s.latest_profile_start = "2026-04-23 23:24" + s.latest_anomalies_definite_ct = 2 + s.latest_anomalies_likely_ct = 2 + s.latest_anomalies_possible_ct = 11 + s.monitor_lookback_end = None + for k, v in overrides.items(): + setattr(s, k, v) + return s + + +# ---------------------------------------------------------------------- +# get_table +# ---------------------------------------------------------------------- + + +@patch("testgen.mcp.tools.profiling.DataTable") +@patch("testgen.mcp.tools.common.TableGroup") +def test_get_table_happy_path(mock_tg_cls, mock_dt_cls, db_session_mock): + mock_tg_cls.get.return_value = _mock_table_group() + mock_dt_cls.get_profiling_overview.return_value = _mock_overview() + + from testgen.mcp.tools.profiling import get_table + result = get_table(str(uuid4()), "orders") + + assert "Table: demo.orders" in result + assert "Record count" in result + assert "Profiling Score" in result + assert "Profiling Run" in result + assert "Columns" in result + assert "customer_name" in result + + +@patch("testgen.mcp.tools.profiling.DataTable") +@patch("testgen.mcp.tools.common.TableGroup") +def test_get_table_does_not_surface_internal_table_id(mock_tg_cls, mock_dt_cls, db_session_mock): + """`Table ID` (data_table_chars.id) is an internal PK no MCP tool consumes — must not appear.""" + overview = _mock_overview() + mock_tg_cls.get.return_value = _mock_table_group() + mock_dt_cls.get_profiling_overview.return_value = overview + + from testgen.mcp.tools.profiling import get_table + result = get_table(str(uuid4()), "orders") + + assert "Table ID" not in result + assert str(overview.id) not in result + + +@patch("testgen.mcp.tools.profiling.DataTable") +@patch("testgen.mcp.tools.common.TableGroup") +def test_get_table_schema_less_heading(mock_tg_cls, mock_dt_cls, db_session_mock): + """When schema_name is None the heading falls back to bare table name.""" + mock_tg_cls.get.return_value = _mock_table_group() + mock_dt_cls.get_profiling_overview.return_value = _mock_overview(schema_name=None, table_name="orders") + + from testgen.mcp.tools.profiling import get_table + result = get_table(str(uuid4()), "orders") + + assert "Table: orders" in result + assert "Table: ." not in result + + +@patch("testgen.mcp.tools.profiling.DataTable") +@patch("testgen.mcp.tools.common.TableGroup") +def test_get_table_no_columns(mock_tg_cls, mock_dt_cls, db_session_mock): + mock_tg_cls.get.return_value = _mock_table_group() + mock_dt_cls.get_profiling_overview.return_value = _mock_overview(columns=[]) + + from testgen.mcp.tools.profiling import get_table + result = get_table(str(uuid4()), "orders") + + assert "_No columns recorded for this table._" in result + + +@patch("testgen.mcp.tools.profiling.DataTable") +@patch("testgen.mcp.tools.common.TableGroup") +def test_get_table_table_not_found(mock_tg_cls, mock_dt_cls, db_session_mock): + mock_tg_cls.get.return_value = _mock_table_group() + mock_dt_cls.get_profiling_overview.return_value = None + + from testgen.mcp.tools.profiling import get_table + with pytest.raises(MCPUserError, match="not found in this table group"): + get_table(str(uuid4()), "ghost_table") + + +def test_get_table_invalid_uuid(db_session_mock): + from testgen.mcp.tools.profiling import get_table + + with pytest.raises(MCPUserError, match="not a valid UUID"): + get_table("not-a-uuid", "orders") + + +@patch("testgen.mcp.tools.common.TableGroup") +def test_get_table_inaccessible_tg(mock_tg_cls, db_session_mock): + """Inaccessible TG and unknown TG collapse to the same message.""" + mock_tg_cls.get.return_value = None + + from testgen.mcp.tools.profiling import get_table + with pytest.raises(MCPResourceNotAccessible, match="Table group .* not found or not accessible"): + get_table(str(uuid4()), "orders") + + +# ---------------------------------------------------------------------- +# list_column_profiles +# ---------------------------------------------------------------------- + + +@patch("testgen.mcp.tools.profiling.DataColumnChars") +@patch("testgen.mcp.tools.common.TableGroup") +def test_list_column_profiles_happy_path(mock_tg_cls, mock_dcc_cls, db_session_mock): + mock_tg_cls.get.return_value = _mock_table_group() + mock_dcc_cls.list_for_table_group.return_value = ([_column_summary()], 1) + + from testgen.mcp.tools.profiling import list_column_profiles + result = list_column_profiles(str(uuid4())) + + assert "Column profiles for table group" in result + assert "customer_name" in result + assert "Profiling Score" in result + + +@patch("testgen.mcp.tools.profiling.DataColumnChars") +@patch("testgen.mcp.tools.common.TableGroup") +def test_list_column_profiles_scoped_to_table(mock_tg_cls, mock_dcc_cls, db_session_mock): + mock_tg_cls.get.return_value = _mock_table_group() + mock_dcc_cls.list_for_table_group.return_value = ([_column_summary()], 1) + + from testgen.mcp.tools.profiling import list_column_profiles + result = list_column_profiles(str(uuid4()), table_name="customers") + + assert "Column profiles for table `customers`" in result + + +@patch("testgen.mcp.tools.profiling.DataColumnChars") +@patch("testgen.mcp.tools.common.TableGroup") +def test_list_column_profiles_empty_first_page(mock_tg_cls, mock_dcc_cls, db_session_mock): + mock_tg_cls.get.return_value = _mock_table_group() + mock_dcc_cls.list_for_table_group.return_value = ([], 0) + + from testgen.mcp.tools.profiling import list_column_profiles + result = list_column_profiles(str(uuid4())) + + assert "No column profiles found" in result + + +@patch("testgen.mcp.tools.profiling.DataColumnChars") +@patch("testgen.mcp.tools.common.TableGroup") +def test_list_column_profiles_empty_overshoot_page(mock_tg_cls, mock_dcc_cls, db_session_mock): + mock_tg_cls.get.return_value = _mock_table_group() + mock_dcc_cls.list_for_table_group.return_value = ([], 69) + + from testgen.mcp.tools.profiling import list_column_profiles + result = list_column_profiles(str(uuid4()), page=99) + + assert "No column profiles on page 99 (total: 69)." == result + + +@patch("testgen.mcp.tools.profiling.DataColumnChars") +@patch("testgen.mcp.tools.common.TableGroup") +def test_list_column_profiles_paginates(mock_tg_cls, mock_dcc_cls, db_session_mock): + mock_tg_cls.get.return_value = _mock_table_group() + rows = [_column_summary(column_name=f"col_{i}") for i in range(2)] + mock_dcc_cls.list_for_table_group.return_value = (rows, 100) + + from testgen.mcp.tools.profiling import list_column_profiles + result = list_column_profiles(str(uuid4()), limit=2, page=1) + + assert "Showing 1" in result and "2 of 100" in result + assert "Use `page=2` for more" in result + + +@patch("testgen.mcp.tools.profiling.ProfilingRun") +@patch("testgen.mcp.tools.profiling.DataColumnChars") +@patch("testgen.mcp.tools.common.TableGroup") +def test_list_column_profiles_with_valid_job_execution_id( + mock_tg_cls, mock_dcc_cls, mock_pr_cls, db_session_mock, +): + tg = _mock_table_group() + pr = MagicMock() + pr.id = uuid4() + pr.table_groups_id = tg.id + + mock_tg_cls.get.return_value = tg + mock_pr_cls.get_by_id_or_job.return_value = pr + mock_dcc_cls.list_for_table_group.return_value = ([_column_summary()], 1) + + from testgen.mcp.tools.profiling import list_column_profiles + list_column_profiles(str(uuid4()), job_execution_id=str(uuid4())) + + assert mock_dcc_cls.list_for_table_group.call_args.kwargs["profiling_run_id"] == pr.id + + +@patch("testgen.mcp.tools.profiling.ProfilingRun") +@patch("testgen.mcp.tools.common.TableGroup") +def test_list_column_profiles_rejects_je_from_different_tg( + mock_tg_cls, mock_pr_cls, db_session_mock, +): + """JE belonging to a different TG → 'not found or not accessible' (existence hidden).""" + tg = _mock_table_group() + pr = MagicMock() + pr.id = uuid4() + pr.table_groups_id = uuid4() # different TG + + mock_tg_cls.get.return_value = tg + mock_pr_cls.get_by_id_or_job.return_value = pr + + from testgen.mcp.tools.profiling import list_column_profiles + with pytest.raises(MCPResourceNotAccessible, match="Profiling run .* not found or not accessible"): + list_column_profiles(str(uuid4()), job_execution_id=str(uuid4())) + + +@patch("testgen.mcp.tools.profiling.ProfilingRun") +@patch("testgen.mcp.tools.common.TableGroup") +def test_list_column_profiles_rejects_unknown_je(mock_tg_cls, mock_pr_cls, db_session_mock): + mock_tg_cls.get.return_value = _mock_table_group() + mock_pr_cls.get_by_id_or_job.return_value = None + + from testgen.mcp.tools.profiling import list_column_profiles + with pytest.raises(MCPResourceNotAccessible, match="Profiling run .* not found or not accessible"): + list_column_profiles(str(uuid4()), job_execution_id=str(uuid4())) + + +@patch("testgen.mcp.tools.common.TableGroup") +def test_list_column_profiles_invalid_je_uuid(mock_tg_cls, db_session_mock): + mock_tg_cls.get.return_value = _mock_table_group() + + from testgen.mcp.tools.profiling import list_column_profiles + with pytest.raises(MCPUserError, match="Invalid job_execution_id"): + list_column_profiles(str(uuid4()), job_execution_id="bad-uuid") + + +def test_list_column_profiles_invalid_tg_uuid(db_session_mock): + from testgen.mcp.tools.profiling import list_column_profiles + + with pytest.raises(MCPUserError, match="Invalid table_group_id"): + list_column_profiles("bad-uuid") + + +@patch("testgen.mcp.tools.common.TableGroup") +def test_list_column_profiles_inaccessible_tg(mock_tg_cls, db_session_mock): + mock_tg_cls.get.return_value = None + + from testgen.mcp.tools.profiling import list_column_profiles + with pytest.raises(MCPResourceNotAccessible, match="Table group .* not found or not accessible"): + list_column_profiles(str(uuid4())) + + +# ---------------------------------------------------------------------- +# list_column_profiles — PII redaction +# ---------------------------------------------------------------------- + + +@patch("testgen.mcp.tools.profiling.DataColumnChars") +@patch("testgen.mcp.tools.common.TableGroup") +def test_list_column_profiles_redacts_pii_without_view_pii(mock_tg_cls, mock_dcc_cls, db_session_mock): + """User without view_pii sees a coarse 'Y'/'—' for pii_flag, never the category string.""" + mock_tg_cls.get.return_value = _mock_table_group() + mock_dcc_cls.list_for_table_group.return_value = ( + [_column_summary(column_name="first_name", pii_flag="B/NAME/Individual")], 1, + ) + + from testgen.mcp.tools.profiling import list_column_profiles + result = list_column_profiles(str(uuid4())) + + # The default conftest user has role_a, which the test perm matrix grants 'view' + # and 'catalog' but NOT 'view_pii' — so redaction kicks in. + assert "B/NAME/Individual" not in result + assert "Individual" not in result + # The redacted "Y" should be present for the pii row. + assert "| Y |" in result + + +@patch("testgen.mcp.tools.profiling.get_project_permissions") +@patch("testgen.mcp.tools.profiling.DataColumnChars") +@patch("testgen.mcp.tools.common.TableGroup") +def test_list_column_profiles_shows_full_pii_with_view_pii( + mock_tg_cls, mock_dcc_cls, mock_perms, db_session_mock, +): + mock_tg_cls.get.return_value = _mock_table_group() + mock_dcc_cls.list_for_table_group.return_value = ( + [_column_summary(column_name="first_name", pii_flag="B/NAME/Individual")], 1, + ) + + perms = MagicMock() + perms.codes_allowed_to.return_value = ["demo"] + mock_perms.return_value = perms + + from testgen.mcp.tools.profiling import list_column_profiles + result = list_column_profiles(str(uuid4())) + + assert "B/NAME/Individual" in result + + +# ---------------------------------------------------------------------- +# _render_column_profile_row — direct rendering tests +# ---------------------------------------------------------------------- + + +def test_render_row_redacts_pii_when_permission_missing(): + from testgen.mcp.tools.profiling import _render_column_profile_row + row = _render_column_profile_row(_column_summary(pii_flag="B/NAME/Individual"), has_view_pii=False) + assert row[5] == "Y" + assert "Individual" not in (row[5] or "") + + +def test_render_row_shows_full_pii_with_permission(): + from testgen.mcp.tools.profiling import _render_column_profile_row + row = _render_column_profile_row(_column_summary(pii_flag="B/NAME/Individual"), has_view_pii=True) + assert row[5] == "B/NAME/Individual" + + +def test_render_row_falsy_pii_renders_none_in_either_mode(): + from testgen.mcp.tools.profiling import _render_column_profile_row + assert _render_column_profile_row(_column_summary(pii_flag=None), has_view_pii=False)[5] is None + assert _render_column_profile_row(_column_summary(pii_flag=None), has_view_pii=True)[5] is None + + +def test_render_row_cde_collapsed_to_y_or_none(): + from testgen.mcp.tools.profiling import _render_column_profile_row + row_yes = _render_column_profile_row(_column_summary(critical_data_element=True), has_view_pii=False) + row_no = _render_column_profile_row(_column_summary(critical_data_element=False), has_view_pii=False) + assert row_yes[6] == "Y" + assert row_no[6] is None + + +# ---------------------------------------------------------------------- +# list_profiling_summaries +# ---------------------------------------------------------------------- + + +@patch("testgen.mcp.tools.profiling.TableGroup") +@patch("testgen.mcp.tools.common.TableGroup") +def test_list_profiling_summaries_table_group_mode(mock_common_tg, mock_profiling_tg, db_session_mock): + mock_common_tg.get.return_value = _mock_table_group() + mock_profiling_tg.select_summary.return_value = ([_mock_summary()], 1) + + from testgen.mcp.tools.profiling import list_profiling_summaries + tg_id = str(uuid4()) + result = list_profiling_summaries(table_group_id=tg_id) + + assert f"Profiling summary for table group `{tg_id}`" in result + assert "demo-tg" in result + assert "Tables" in result + assert "Profiling Run" in result + # Single-TG mode skips pagination header. + assert "Showing" not in result + + +@patch("testgen.mcp.tools.profiling.TableGroup") +@patch("testgen.mcp.tools.common.TableGroup") +def test_list_profiling_summaries_never_profiled_tg(mock_common_tg, mock_profiling_tg, db_session_mock): + mock_common_tg.get.return_value = _mock_table_group() + mock_profiling_tg.select_summary.return_value = ([_mock_summary(latest_profile_id=None)], 1) + + from testgen.mcp.tools.profiling import list_profiling_summaries + result = list_profiling_summaries(table_group_id=str(uuid4())) + + assert "_Not profiled yet._" in result + # Field block omitted when never profiled. + assert "Profiling Score" not in result + assert "Anomalies" not in result + + +@patch("testgen.mcp.tools.profiling.TableGroup") +def test_list_profiling_summaries_project_mode(mock_tg_cls, db_session_mock): + """With project_code we hit verify_access + paginated select_summary.""" + mock_tg_cls.select_summary.return_value = ([_mock_summary(), _mock_summary()], 2) + + from testgen.mcp.tools.profiling import list_profiling_summaries + result = list_profiling_summaries(project_code="demo") + + assert "Profiling summary for project `demo`" in result + assert "demo-tg" in result + assert "Showing 1" in result and "2 of 2" in result + + +@patch("testgen.mcp.tools.profiling.TableGroup") +def test_list_profiling_summaries_project_mode_empty_first_page(mock_tg_cls, db_session_mock): + mock_tg_cls.select_summary.return_value = ([], 0) + + from testgen.mcp.tools.profiling import list_profiling_summaries + result = list_profiling_summaries(project_code="demo") + + assert "No table groups in project `demo`." == result + + +@patch("testgen.mcp.tools.profiling.TableGroup") +def test_list_profiling_summaries_project_mode_empty_overshoot_page(mock_tg_cls, db_session_mock): + mock_tg_cls.select_summary.return_value = ([], 5) + + from testgen.mcp.tools.profiling import list_profiling_summaries + result = list_profiling_summaries(project_code="demo", page=99) + + assert "No table groups on page 99 (total: 5)." == result + + +def test_list_profiling_summaries_both_args_rejected(db_session_mock): + from testgen.mcp.tools.profiling import list_profiling_summaries + + with pytest.raises(MCPUserError, match="Pass either"): + list_profiling_summaries(table_group_id=str(uuid4()), project_code="demo") + + +def test_list_profiling_summaries_neither_arg_rejected(db_session_mock): + from testgen.mcp.tools.profiling import list_profiling_summaries + + with pytest.raises(MCPUserError, match="Provide either"): + list_profiling_summaries() + + +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_list_profiling_summaries_rejects_inaccessible_project(mock_compute, db_session_mock): + mock_compute.return_value = ProjectPermissions( + memberships={"demo": "role_a"}, permission="catalog", + ) + + from testgen.mcp.tools.profiling import list_profiling_summaries + with pytest.raises(MCPResourceNotAccessible, match="Project .* not found or not accessible"): + list_profiling_summaries(project_code="forbidden_project") + + +@patch("testgen.mcp.tools.common.TableGroup") +def test_list_profiling_summaries_inaccessible_tg(mock_tg_cls, db_session_mock): + mock_tg_cls.get.return_value = None + + from testgen.mcp.tools.profiling import list_profiling_summaries + with pytest.raises(MCPResourceNotAccessible, match="Table group .* not found or not accessible"): + list_profiling_summaries(table_group_id=str(uuid4())) From 2d8d17b18cc226c284b48d2b33a6c0ea13e3ee5b Mon Sep 17 00:00:00 2001 From: Diogo Basto Date: Wed, 29 Apr 2026 15:09:46 +0100 Subject: [PATCH 084/123] feat(TG-1042): DQ score weighting for table importance and semantic data types Adds configurable importance weights to DQ scoring: - New use_dq_score_weights toggle on projects (default ON for new, OFF for existing) - dq_score_weight on data_table_chars, dq_score_weight and dq_score_pii_weight on data_column_chars - dq_score_weight_defaults lookup table with 5 table, 32 column, and 4 PII defaults - Profiling populates weights from defaults using ILIKE (tables) and exact match (columns/PII) - Four scoring views expose weighted_record_ct / weighted_dq_record_ct - RollupScoresSQL and score card queries use weighted counts when the flag is on - run_recalculate_project_scores job re-rolls scores when the toggle changes - Drop/recreate pattern for all scoring views to avoid CREATE OR REPLACE column-rename errors - Unit tests for ProfilingSQL.update_profiling_results, calculate_sampling_params, run_recalculate_project_scores, ProjectSettingsPage.update_project, and JOB_DISPATCH Co-Authored-By: Claude Sonnet 4.6 --- testgen/commands/job_registry.py | 2 + testgen/commands/queries/profiling_query.py | 1 + .../run_recalculate_project_scores.py | 51 +++++++ testgen/common/models/project.py | 3 +- .../030_initialize_new_schema_structure.sql | 71 ++++++++- .../dbsetup/060_create_standard_views.sql | 54 ++++++- .../dbsetup/075_grant_role_rights.sql | 1 + .../dbupgrade/0186_incremental_upgrade.sql | 75 +++++++++ .../profiling/dq_score_weight_update.sql | 55 +++++++ .../rollup_scores_profile_run.sql | 22 ++- .../rollup_scores_profile_table_group.sql | 33 +++- .../rollup_scores/rollup_scores_test_run.sql | 22 ++- .../rollup_scores_test_table_group.sql | 35 ++++- .../get_category_scores_by_column.sql | 6 +- .../get_category_scores_by_dimension.sql | 6 +- ...et_historical_overall_scores_by_column.sql | 12 +- .../get_overall_scores_by_column.sql | 14 +- .../get_score_card_breakdown_by_column.sql | 16 +- .../get_score_card_breakdown_by_dimension.sql | 16 +- .../standalone/project_settings/index.js | 13 +- testgen/ui/views/project_settings.py | 13 ++ .../commands/queries/test_profiling_query.py | 74 ++++++++- tests/unit/commands/test_exec_job.py | 1 + .../test_recalculate_project_scores.py | 142 ++++++++++++++++++ tests/unit/ui/conftest.py | 4 + tests/unit/ui/test_project_settings.py | 88 +++++++++++ 26 files changed, 770 insertions(+), 60 deletions(-) create mode 100644 testgen/commands/run_recalculate_project_scores.py create mode 100644 testgen/template/dbupgrade/0186_incremental_upgrade.sql create mode 100644 testgen/template/profiling/dq_score_weight_update.sql create mode 100644 tests/unit/commands/test_recalculate_project_scores.py create mode 100644 tests/unit/ui/test_project_settings.py diff --git a/testgen/commands/job_registry.py b/testgen/commands/job_registry.py index a199491c..45d5bfe7 100644 --- a/testgen/commands/job_registry.py +++ b/testgen/commands/job_registry.py @@ -16,6 +16,7 @@ from sqlalchemy import select from testgen.commands.run_profiling import run_profiling +from testgen.commands.run_recalculate_project_scores import run_recalculate_project_scores from testgen.commands.run_score_update import run_score_update from testgen.commands.run_test_execution import run_test_execution from testgen.commands.test_generation import run_test_generation @@ -37,6 +38,7 @@ "run-monitors": run_test_execution, "run-test-generation": run_test_generation, "run-score-update": run_score_update, + "recalculate-project-scores": run_recalculate_project_scores, } diff --git a/testgen/commands/queries/profiling_query.py b/testgen/commands/queries/profiling_query.py index 95c60433..d3f02a16 100644 --- a/testgen/commands/queries/profiling_query.py +++ b/testgen/commands/queries/profiling_query.py @@ -173,6 +173,7 @@ def update_profiling_results(self) -> list[tuple[str, dict]]: queries.append(self._get_query("pii_flag_update.sql")) if self.table_group.profile_flag_cdes: queries.append(self._get_query("cde_flagger_query.sql")) + queries.append(self._get_query("dq_score_weight_update.sql")) return queries def update_hygiene_issue_counts(self) -> tuple[str, dict]: diff --git a/testgen/commands/run_recalculate_project_scores.py b/testgen/commands/run_recalculate_project_scores.py new file mode 100644 index 00000000..4802203a --- /dev/null +++ b/testgen/commands/run_recalculate_project_scores.py @@ -0,0 +1,51 @@ +"""Recalculate all DQ scores for a project. + +Used when the use_dq_score_weights toggle changes so that existing rollup +results reflect the new weighting configuration without requiring new runs. +""" +import logging + +from sqlalchemy import select + +from testgen.commands.queries.rollup_scores_query import RollupScoresSQL +from testgen.commands.run_refresh_score_cards_results import run_refresh_score_cards_results +from testgen.common import execute_db_queries +from testgen.common.models import database_session +from testgen.common.models.table_group import TableGroup +from testgen.common.models.test_suite import TestSuite + +LOG = logging.getLogger("testgen") + + +def run_recalculate_project_scores(project_code: str) -> None: + with database_session() as session: + table_groups = session.scalars( + select(TableGroup).where(TableGroup.project_code == project_code) + ).all() + + for tg in table_groups: + tg_id = str(tg.id) + + if tg.last_complete_profile_run_id: + LOG.info("Recalculating profiling scores for table group %s", tg_id) + execute_db_queries( + RollupScoresSQL(str(tg.last_complete_profile_run_id), tg_id).rollup_profiling_scores() + ) + + with database_session() as session: + test_suites = session.scalars( + select(TestSuite).where( + TestSuite.table_groups_id == tg.id, + TestSuite.last_complete_test_run_id != None, + ) + ).all() + + for i, ts in enumerate(test_suites): + LOG.info("Recalculating test scores for test suite %s in table group %s", ts.id, tg_id) + execute_db_queries( + RollupScoresSQL(str(ts.last_complete_test_run_id), tg_id).rollup_test_scores( + update_table_group=(i == len(test_suites) - 1), + ) + ) + + run_refresh_score_cards_results(project_code=project_code) diff --git a/testgen/common/models/project.py b/testgen/common/models/project.py index ca832d63..df69dfc6 100644 --- a/testgen/common/models/project.py +++ b/testgen/common/models/project.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from uuid import UUID, uuid4 -from sqlalchemy import Column, String, asc, func, select, text +from sqlalchemy import Boolean, Column, String, asc, func, select, text from sqlalchemy.dialects import postgresql from testgen.common.models import get_current_session @@ -39,6 +39,7 @@ class Project(Entity): project_name: str = Column(String) observability_api_url: str = Column(NullIfEmptyString) observability_api_key: str = Column(NullIfEmptyString) + use_dq_score_weights: bool = Column(Boolean, default=True) _get_by = "project_code" _default_order_by = (asc(func.lower(project_name)),) diff --git a/testgen/template/dbsetup/030_initialize_new_schema_structure.sql b/testgen/template/dbsetup/030_initialize_new_schema_structure.sql index 7349b570..1b8cc2e8 100644 --- a/testgen/template/dbsetup/030_initialize_new_schema_structure.sql +++ b/testgen/template/dbsetup/030_initialize_new_schema_structure.sql @@ -55,8 +55,9 @@ CREATE TABLE projects ( CONSTRAINT projects_project_code_pk PRIMARY KEY, project_name VARCHAR(50), - observability_api_key TEXT, - observability_api_url TEXT DEFAULT '' + observability_api_key TEXT, + observability_api_url TEXT DEFAULT '', + use_dq_score_weights BOOLEAN DEFAULT TRUE ); CREATE TABLE connections ( @@ -433,7 +434,8 @@ CREATE TABLE data_table_chars ( last_complete_profile_run_id UUID, last_profile_record_ct BIGINT, dq_score_profiling FLOAT, - dq_score_testing FLOAT + dq_score_testing FLOAT, + dq_score_weight FLOAT DEFAULT 1.0 ); CREATE TABLE data_column_chars ( @@ -480,9 +482,70 @@ CREATE TABLE data_column_chars ( valid_profile_issue_ct BIGINT DEFAULT 0, valid_test_issue_ct BIGINT DEFAULT 0, dq_score_profiling FLOAT, - dq_score_testing FLOAT + dq_score_testing FLOAT, + dq_score_weight FLOAT DEFAULT 1.0, + dq_score_pii_weight FLOAT DEFAULT 1.0 ); +CREATE TABLE dq_score_weight_defaults ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + weight_scope VARCHAR(10) NOT NULL, + semantic_type VARCHAR(50) NOT NULL, + default_weight FLOAT NOT NULL DEFAULT 1.0, + UNIQUE (weight_scope, semantic_type) +); + +-- Table-level defaults (matched via ILIKE on functional_table_type suffix) +INSERT INTO dq_score_weight_defaults (weight_scope, semantic_type, default_weight) VALUES + ('table', '%entity', 10.0), + ('table', '%domain', 5.0), + ('table', '%bridge', 5.0), + ('table', '%summary', 1.5), + ('table', '%transaction', 1.0); + +-- Column-level defaults (exact match on functional_data_type) +INSERT INTO dq_score_weight_defaults (weight_scope, semantic_type, default_weight) VALUES + ('column', 'ID', 3.0), + ('column', 'ID-SK', 3.0), + ('column', 'ID-Unique', 3.0), + ('column', 'ID-Unique-SK', 3.0), + ('column', 'ID-FK', 2.5), + ('column', 'ID-Secondary', 2.0), + ('column', 'ID-Group', 1.5), + ('column', 'Email', 2.0), + ('column', 'Phone', 2.0), + ('column', 'Person Full Name', 2.0), + ('column', 'Person Given Name', 1.5), + ('column', 'Person Last Name', 1.5), + ('column', 'Entity Name', 2.0), + ('column', 'Address', 1.5), + ('column', 'City', 1.5), + ('column', 'State', 1.5), + ('column', 'Zip', 1.5), + ('column', 'Date Stamp', 1.5), + ('column', 'DateTime Stamp', 1.5), + ('column', 'Process Date Stamp', 1.0), + ('column', 'Process DateTime Stamp', 1.0), + ('column', 'Transactional Date', 1.5), + ('column', 'Measurement', 1.5), + ('column', 'Measurement Pct', 1.5), + ('column', 'Code', 1.5), + ('column', 'Boolean', 1.0), + ('column', 'Category', 1.0), + ('column', 'Flag', 0.75), + ('column', 'Attribute', 0.75), + ('column', 'Description', 0.5), + ('column', 'Constant', 0.5), + ('column', 'Sequence', 0.5); + +-- PII-level defaults (matched on LEFT(pii_flag, 1)) +-- A/B/C = auto-detected risk tiers; M = user-set 'MANUAL' +INSERT INTO dq_score_weight_defaults (weight_scope, semantic_type, default_weight) VALUES + ('pii', 'A', 3.0), + ('pii', 'B', 2.0), + ('pii', 'C', 1.5), + ('pii', 'M', 3.0); + CREATE TABLE test_types ( id VARCHAR, test_type VARCHAR(200) NOT NULL diff --git a/testgen/template/dbsetup/060_create_standard_views.sql b/testgen/template/dbsetup/060_create_standard_views.sql index a36d0897..53f0d85c 100644 --- a/testgen/template/dbsetup/060_create_standard_views.sql +++ b/testgen/template/dbsetup/060_create_standard_views.sql @@ -127,6 +127,11 @@ SELECT pr.profiling_starttime as profiling_run_date, dcc.valid_profile_issue_ct as issue_ct, dtc.last_profile_record_ct as record_ct, + (dtc.last_profile_record_ct + * CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END + ) AS weighted_record_ct, dcc.dq_score_profiling AS good_data_pct FROM data_column_chars dcc INNER JOIN table_groups tg @@ -135,6 +140,8 @@ INNER JOIN data_table_chars dtc ON (dcc.table_id = dtc.table_id) INNER JOIN profiling_runs pr ON (tg.last_complete_profile_run_id = pr.id) +INNER JOIN projects proj + ON (tg.project_code = proj.project_code) WHERE dcc.drop_date IS NULL; @@ -161,6 +168,11 @@ SELECT tg.project_code, pr.column_name, pr.run_date, MAX(pr.record_ct) as record_ct, + MAX(pr.record_ct + * CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END + ) AS weighted_record_ct, COUNT(p.anomaly_id) as issue_ct, SUM_LN(COALESCE(p.dq_prevalence, 0.0)) as good_data_pct FROM profile_results pr @@ -172,6 +184,8 @@ INNER JOIN data_column_chars dcc AND pr.column_name = dcc.column_name) INNER JOIN data_table_chars dtc ON (dcc.table_id = dtc.table_id) +INNER JOIN projects proj + ON (tg.project_code = proj.project_code) LEFT JOIN (profile_anomaly_results p INNER JOIN profile_anomaly_types t ON p.anomaly_id = t.id) @@ -201,7 +215,7 @@ GROUP BY pr.profile_run_id, pr.table_groups_id, DROP VIEW IF EXISTS v_dq_test_scoring_latest_by_column; -CREATE OR REPLACE VIEW v_dq_test_scoring_latest_by_column +CREATE VIEW v_dq_test_scoring_latest_by_column AS SELECT tg.project_code, @@ -224,12 +238,19 @@ SELECT SUM(CASE WHEN r.result_code = 1 THEN 1 ELSE 0 END) as passed_ct, SUM(CASE WHEN r.result_code = 0 THEN 1 ELSE 0 END) as issue_ct, MAX(r.dq_record_ct) as dq_record_ct, + MAX(r.dq_record_ct + * CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END + ) AS weighted_dq_record_ct, SUM_LN(COALESCE(r.dq_prevalence, 0.0)) as good_data_pct FROM test_results r INNER JOIN test_suites s ON (r.test_run_id = s.last_complete_test_run_id) INNER JOIN table_groups tg ON r.table_groups_id = tg.id +INNER JOIN projects proj + ON (tg.project_code = proj.project_code) LEFT JOIN data_table_chars dtc ON (r.table_groups_id = dtc.table_groups_id AND r.table_name = dtc.table_name) @@ -256,7 +277,7 @@ GROUP BY r.table_groups_id, r.table_name, r.column_names, DROP VIEW IF EXISTS v_dq_test_scoring_latest_by_dimension; -CREATE OR REPLACE VIEW v_dq_test_scoring_latest_by_dimension +CREATE VIEW v_dq_test_scoring_latest_by_dimension AS WITH dimension_rollup AS (SELECT r.test_run_id, r.test_suite_id, r.table_groups_id, r.test_time, @@ -298,10 +319,17 @@ SELECT SUM(r.passed_ct) as passed_ct, SUM(r.issue_ct) as issue_ct, MAX(r.dq_record_ct) as dq_record_ct, + MAX(r.dq_record_ct + * CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END + ) AS weighted_dq_record_ct, SUM_LN(COALESCE(1.0-r.good_data_pct, 0)) as good_data_pct FROM dimension_rollup r INNER JOIN table_groups tg ON r.table_groups_id = tg.id +INNER JOIN projects proj + ON (tg.project_code = proj.project_code) LEFT JOIN data_table_chars dtc ON (r.table_groups_id = dtc.table_groups_id AND r.table_name = dtc.table_name) @@ -326,7 +354,9 @@ GROUP BY r.table_groups_id, r.test_run_id, r.test_suite_id, -- ============================================================================== -- | Scoring History Views -- ============================================================================== -CREATE OR REPLACE VIEW v_dq_profile_scoring_history_by_column +DROP VIEW IF EXISTS v_dq_profile_scoring_history_by_column; + +CREATE VIEW v_dq_profile_scoring_history_by_column AS SELECT tg.project_code, sr.definition_id, @@ -348,6 +378,11 @@ SELECT tg.project_code, pr.column_name, pr.run_date, MAX(pr.record_ct) as record_ct, + MAX(pr.record_ct + * CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END + ) AS weighted_record_ct, COUNT(p.anomaly_id) as issue_ct, SUM_LN(COALESCE(p.dq_prevalence, 0.0)) as good_data_pct FROM profile_results pr @@ -361,6 +396,8 @@ INNER JOIN data_table_chars dtc ON (dcc.table_id = dtc.table_id) INNER JOIN table_groups tg ON (pr.table_groups_id = tg.id) +INNER JOIN projects proj + ON (tg.project_code = proj.project_code) LEFT JOIN (profile_anomaly_results p INNER JOIN profile_anomaly_types t ON p.anomaly_id = t.id) @@ -385,7 +422,9 @@ GROUP BY pr.profile_run_id, dcc.functional_data_type, pr.run_date, tg.project_code ; -CREATE OR REPLACE VIEW v_dq_test_scoring_history_by_column +DROP VIEW IF EXISTS v_dq_test_scoring_history_by_column; + +CREATE VIEW v_dq_test_scoring_history_by_column AS SELECT tg.project_code, @@ -410,6 +449,11 @@ SELECT SUM(CASE WHEN r.result_code = 1 THEN 1 ELSE 0 END) as passed_ct, SUM(CASE WHEN r.result_code = 0 THEN 1 ELSE 0 END) as issue_ct, MAX(r.dq_record_ct) as dq_record_ct, + MAX(r.dq_record_ct + * CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END + ) AS weighted_dq_record_ct, SUM_LN(COALESCE(r.dq_prevalence, 0.0)) as good_data_pct FROM test_results r INNER JOIN test_suites s @@ -418,6 +462,8 @@ INNER JOIN score_history_latest_runs sr ON (r.test_run_id = sr.last_test_run_id) INNER JOIN table_groups tg ON r.table_groups_id = tg.id +INNER JOIN projects proj + ON (tg.project_code = proj.project_code) LEFT JOIN data_table_chars dtc ON (r.table_groups_id = dtc.table_groups_id AND r.table_name = dtc.table_name) diff --git a/testgen/template/dbsetup/075_grant_role_rights.sql b/testgen/template/dbsetup/075_grant_role_rights.sql index 5ca8163b..a18f20b0 100644 --- a/testgen/template/dbsetup/075_grant_role_rights.sql +++ b/testgen/template/dbsetup/075_grant_role_rights.sql @@ -30,6 +30,7 @@ GRANT SELECT, INSERT, DELETE, UPDATE ON {SCHEMA_NAME}.projects, {SCHEMA_NAME}.data_table_chars, {SCHEMA_NAME}.data_column_chars, + {SCHEMA_NAME}.dq_score_weight_defaults, {SCHEMA_NAME}.data_structure_log, {SCHEMA_NAME}.auth_users, {SCHEMA_NAME}.score_definitions, diff --git a/testgen/template/dbupgrade/0186_incremental_upgrade.sql b/testgen/template/dbupgrade/0186_incremental_upgrade.sql new file mode 100644 index 00000000..17647925 --- /dev/null +++ b/testgen/template/dbupgrade/0186_incremental_upgrade.sql @@ -0,0 +1,75 @@ +SET SEARCH_PATH TO {SCHEMA_NAME}; + +-- TG-1042: DQ Score Weighting + +ALTER TABLE projects + ADD COLUMN use_dq_score_weights BOOLEAN DEFAULT FALSE; +-- New projects default ON; existing projects stay OFF for backward compatibility +ALTER TABLE projects + ALTER COLUMN use_dq_score_weights SET DEFAULT TRUE; + +ALTER TABLE data_table_chars + ADD COLUMN dq_score_weight FLOAT DEFAULT 1.0; + +ALTER TABLE data_column_chars + ADD COLUMN dq_score_weight FLOAT DEFAULT 1.0, + ADD COLUMN dq_score_pii_weight FLOAT DEFAULT 1.0; + +CREATE TABLE dq_score_weight_defaults ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + weight_scope VARCHAR(10) NOT NULL, + semantic_type VARCHAR(50) NOT NULL, + default_weight FLOAT NOT NULL DEFAULT 1.0, + UNIQUE (weight_scope, semantic_type) +); + +-- Table-level defaults (matched via ILIKE on functional_table_type suffix) +INSERT INTO dq_score_weight_defaults (weight_scope, semantic_type, default_weight) VALUES + ('table', '%entity', 10.0), + ('table', '%domain', 5.0), + ('table', '%bridge', 5.0), + ('table', '%summary', 1.5), + ('table', '%transaction', 1.0); + +-- Column-level defaults (exact match on functional_data_type) +INSERT INTO dq_score_weight_defaults (weight_scope, semantic_type, default_weight) VALUES + ('column', 'ID', 3.0), + ('column', 'ID-SK', 3.0), + ('column', 'ID-Unique', 3.0), + ('column', 'ID-Unique-SK', 3.0), + ('column', 'ID-FK', 2.5), + ('column', 'ID-Secondary', 2.0), + ('column', 'ID-Group', 1.5), + ('column', 'Email', 2.0), + ('column', 'Phone', 2.0), + ('column', 'Person Full Name', 2.0), + ('column', 'Person Given Name', 1.5), + ('column', 'Person Last Name', 1.5), + ('column', 'Entity Name', 2.0), + ('column', 'Address', 1.5), + ('column', 'City', 1.5), + ('column', 'State', 1.5), + ('column', 'Zip', 1.5), + ('column', 'Date Stamp', 1.5), + ('column', 'DateTime Stamp', 1.5), + ('column', 'Process Date Stamp', 1.0), + ('column', 'Process DateTime Stamp', 1.0), + ('column', 'Transactional Date', 1.5), + ('column', 'Measurement', 1.5), + ('column', 'Measurement Pct', 1.5), + ('column', 'Code', 1.5), + ('column', 'Boolean', 1.0), + ('column', 'Category', 1.0), + ('column', 'Flag', 0.75), + ('column', 'Attribute', 0.75), + ('column', 'Description', 0.5), + ('column', 'Constant', 0.5), + ('column', 'Sequence', 0.5); + +-- PII-level defaults (matched on LEFT(pii_flag, 1)) +-- A/B/C = auto-detected risk tiers; M = user-set 'MANUAL' +INSERT INTO dq_score_weight_defaults (weight_scope, semantic_type, default_weight) VALUES + ('pii', 'A', 3.0), + ('pii', 'B', 2.0), + ('pii', 'C', 1.5), + ('pii', 'M', 3.0); diff --git a/testgen/template/profiling/dq_score_weight_update.sql b/testgen/template/profiling/dq_score_weight_update.sql new file mode 100644 index 00000000..fb5568ed --- /dev/null +++ b/testgen/template/profiling/dq_score_weight_update.sql @@ -0,0 +1,55 @@ +-- Update table weights from functional_table_type on data_table_chars. +-- Uses ILIKE so both cumulative-entity and window-entity match %entity. +UPDATE data_table_chars dtc + SET dq_score_weight = COALESCE(w.default_weight, 1.0) + FROM dq_score_weight_defaults w + WHERE dtc.table_groups_id = :TABLE_GROUPS_ID + AND w.weight_scope = 'table' + AND dtc.functional_table_type ILIKE w.semantic_type; + +-- Reset table weight to 1.0 for rows that no longer match any pattern. +UPDATE data_table_chars dtc + SET dq_score_weight = 1.0 + WHERE dtc.table_groups_id = :TABLE_GROUPS_ID + AND dtc.dq_score_weight != 1.0 + AND NOT EXISTS ( + SELECT 1 FROM dq_score_weight_defaults w + WHERE w.weight_scope = 'table' + AND dtc.functional_table_type ILIKE w.semantic_type + ); + +-- Update column weights from functional_data_type on data_column_chars. +UPDATE data_column_chars dcc + SET dq_score_weight = COALESCE(w.default_weight, 1.0) + FROM dq_score_weight_defaults w + WHERE dcc.table_groups_id = :TABLE_GROUPS_ID + AND w.weight_scope = 'column' + AND dcc.functional_data_type = w.semantic_type; + +-- Reset column weight to 1.0 for rows with no matching functional_data_type. +UPDATE data_column_chars dcc + SET dq_score_weight = 1.0 + WHERE dcc.table_groups_id = :TABLE_GROUPS_ID + AND dcc.dq_score_weight != 1.0 + AND NOT EXISTS ( + SELECT 1 FROM dq_score_weight_defaults w + WHERE w.weight_scope = 'column' + AND dcc.functional_data_type = w.semantic_type + ); + +-- Update PII weights from pii_flag on data_column_chars. +-- Keys on the first character: 'A'/'B'/'C' for auto-detected risk tiers, 'M' for user-set 'MANUAL'. +UPDATE data_column_chars dcc + SET dq_score_pii_weight = COALESCE(w.default_weight, 1.0) + FROM dq_score_weight_defaults w + WHERE dcc.table_groups_id = :TABLE_GROUPS_ID + AND dcc.pii_flag IS NOT NULL + AND w.weight_scope = 'pii' + AND w.semantic_type = LEFT(dcc.pii_flag, 1); + +-- Reset PII weight to 1.0 where pii_flag is NULL or no longer matches. +UPDATE data_column_chars dcc + SET dq_score_pii_weight = 1.0 + WHERE dcc.table_groups_id = :TABLE_GROUPS_ID + AND dcc.dq_score_pii_weight != 1.0 + AND dcc.pii_flag IS NULL; diff --git a/testgen/template/rollup_scores/rollup_scores_profile_run.sql b/testgen/template/rollup_scores/rollup_scores_profile_run.sql index bc7ae926..d697ddcd 100644 --- a/testgen/template/rollup_scores/rollup_scores_profile_run.sql +++ b/testgen/template/rollup_scores/rollup_scores_profile_run.sql @@ -8,11 +8,29 @@ UPDATE profiling_runs -- Roll up scoring to profiling run WITH score_detail AS (SELECT pr.profile_run_id, pr.table_name, pr.column_name, - MAX(pr.record_ct) as row_ct, - (1.0 - SUM_LN(COALESCE(p.dq_prevalence, 0.0))) * MAX(pr.record_ct) as affected_data_points + MAX(pr.record_ct + * CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END) as row_ct, + (1.0 - SUM_LN(COALESCE(p.dq_prevalence, 0.0))) + * MAX(pr.record_ct + * CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END) as affected_data_points FROM profile_results pr INNER JOIN profiling_runs r ON (pr.profile_run_id = r.id) + INNER JOIN table_groups tg + ON (pr.table_groups_id = tg.id) + INNER JOIN projects proj + ON (tg.project_code = proj.project_code) + LEFT JOIN data_column_chars dcc + ON (pr.table_groups_id = dcc.table_groups_id + AND pr.schema_name = dcc.schema_name + AND pr.table_name = dcc.table_name + AND pr.column_name = dcc.column_name) + LEFT JOIN data_table_chars dtc + ON (dcc.table_id = dtc.table_id) LEFT JOIN profile_anomaly_results p ON (pr.profile_run_id = p.profile_run_id AND pr.column_name = p.column_name diff --git a/testgen/template/rollup_scores/rollup_scores_profile_table_group.sql b/testgen/template/rollup_scores/rollup_scores_profile_table_group.sql index d11f5df0..ccd67104 100644 --- a/testgen/template/rollup_scores/rollup_scores_profile_table_group.sql +++ b/testgen/template/rollup_scores/rollup_scores_profile_table_group.sql @@ -32,9 +32,18 @@ UPDATE data_column_chars WITH score_detail AS (SELECT dcc.column_id, COUNT(p.id) as valid_issue_ct, - MAX(pr.record_ct) as row_ct, - COALESCE( (1.0 - SUM_LN(COALESCE(p.dq_prevalence, 0.0))) * MAX(pr.record_ct), 0) as affected_data_points + MAX(pr.record_ct + * CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END) as row_ct, + COALESCE( (1.0 - SUM_LN(COALESCE(p.dq_prevalence, 0.0))) + * MAX(pr.record_ct + * CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END), 0) as affected_data_points FROM table_groups tg + INNER JOIN projects proj + ON (tg.project_code = proj.project_code) INNER JOIN profiling_runs r ON (tg.last_complete_profile_run_id = r.id) INNER JOIN profile_results pr @@ -44,6 +53,8 @@ WITH score_detail AND pr.schema_name = dcc.schema_name AND pr.table_name = dcc.table_name AND pr.column_name = dcc.column_name) + LEFT JOIN data_table_chars dtc + ON (dcc.table_id = dtc.table_id) LEFT JOIN profile_anomaly_results p ON (pr.profile_run_id = p.profile_run_id AND pr.column_name = p.column_name @@ -69,9 +80,19 @@ UPDATE data_table_chars -- Roll up latest scores to data_table_chars WITH score_detail AS (SELECT dcc.column_id, dcc.table_id, - MAX(pr.record_ct) as row_ct, - COALESCE((1.0 - SUM_LN(COALESCE(p.dq_prevalence, 0.0))) * MAX(pr.record_ct), 0) as affected_data_points + MAX(pr.record_ct) as raw_row_ct, + MAX(pr.record_ct + * CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END) as row_ct, + COALESCE((1.0 - SUM_LN(COALESCE(p.dq_prevalence, 0.0))) + * MAX(pr.record_ct + * CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END), 0) as affected_data_points FROM table_groups tg + INNER JOIN projects proj + ON (tg.project_code = proj.project_code) INNER JOIN profiling_runs r ON (tg.last_complete_profile_run_id = r.id) INNER JOIN profile_results pr @@ -81,6 +102,8 @@ WITH score_detail AND pr.schema_name = dcc.schema_name AND pr.table_name = dcc.table_name AND pr.column_name = dcc.column_name) + LEFT JOIN data_table_chars dtc + ON (dcc.table_id = dtc.table_id) LEFT JOIN profile_anomaly_results p ON (pr.profile_run_id = p.profile_run_id AND pr.column_name = p.column_name @@ -92,7 +115,7 @@ score_calc AS ( SELECT table_id, SUM(affected_data_points) as sum_affected_data_points, SUM(row_ct) as sum_data_points, - MAX(row_ct) as record_ct + MAX(raw_row_ct) as record_ct FROM score_detail GROUP BY table_id ) UPDATE data_table_chars diff --git a/testgen/template/rollup_scores/rollup_scores_test_run.sql b/testgen/template/rollup_scores/rollup_scores_test_run.sql index a16e860c..a4eba52c 100644 --- a/testgen/template/rollup_scores/rollup_scores_test_run.sql +++ b/testgen/template/rollup_scores/rollup_scores_test_run.sql @@ -8,9 +8,27 @@ UPDATE test_runs -- Roll up scoring to test run WITH score_detail AS (SELECT r.test_run_id, r.table_name, r.column_names, - MAX(r.dq_record_ct) as row_ct, - (1.0 - SUM_LN(COALESCE(r.dq_prevalence, 0.0))) * MAX(r.dq_record_ct) as affected_data_points + MAX(r.dq_record_ct + * CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END) as row_ct, + (1.0 - SUM_LN(COALESCE(r.dq_prevalence, 0.0))) + * MAX(r.dq_record_ct + * CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END) as affected_data_points FROM test_results r + INNER JOIN table_groups tg + ON (r.table_groups_id = tg.id) + INNER JOIN projects proj + ON (tg.project_code = proj.project_code) + LEFT JOIN data_table_chars dtc + ON (r.table_groups_id = dtc.table_groups_id + AND r.table_name = dtc.table_name) + LEFT JOIN data_column_chars dcc + ON (r.table_groups_id = dcc.table_groups_id + AND r.table_name = dcc.table_name + AND r.column_names = dcc.column_name) WHERE r.test_run_id = :RUN_ID AND COALESCE(r.disposition, 'Confirmed') = 'Confirmed' GROUP BY r.test_run_id, r.table_name, r.column_names ), diff --git a/testgen/template/rollup_scores/rollup_scores_test_table_group.sql b/testgen/template/rollup_scores/rollup_scores_test_table_group.sql index ce1ec3b5..6009e5d0 100644 --- a/testgen/template/rollup_scores/rollup_scores_test_table_group.sql +++ b/testgen/template/rollup_scores/rollup_scores_test_table_group.sql @@ -33,10 +33,22 @@ WITH score_calc AS (SELECT dcc.column_id, SUM(CASE WHEN r.result_code = 0 THEN 1 ELSE 0 END) as issue_ct, -- Use AVG instead of MAX because column counts may differ by test_run - AVG(r.dq_record_ct) as row_ct, + AVG(r.dq_record_ct) + * MAX(CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END) as row_ct, -- bad data pct * record count = affected_data_points - (1.0 - SUM_LN(COALESCE(r.dq_prevalence, 0.0))) * AVG(r.dq_record_ct) as affected_data_points + (1.0 - SUM_LN(COALESCE(r.dq_prevalence, 0.0))) * AVG(r.dq_record_ct) + * MAX(CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END) as affected_data_points FROM data_column_chars dcc + INNER JOIN table_groups tg + ON (dcc.table_groups_id = tg.id) + INNER JOIN projects proj + ON (tg.project_code = proj.project_code) + LEFT JOIN data_table_chars dtc + ON (dcc.table_id = dtc.table_id) LEFT JOIN (test_results r INNER JOIN test_suites ts ON (r.test_suite_id = ts.id @@ -64,10 +76,20 @@ UPDATE data_table_chars WITH score_detail AS (SELECT dtc.table_id, r.column_names, -- Use AVG instead of MAX because column counts may differ by test_run - AVG(r.dq_record_ct) as row_ct, + AVG(r.dq_record_ct) + * MAX(CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END) as row_ct, -- bad data pct * record count = affected_data_points - (1.0 - SUM_LN(COALESCE(r.dq_prevalence, 0.0))) * AVG(r.dq_record_ct) as affected_data_points + (1.0 - SUM_LN(COALESCE(r.dq_prevalence, 0.0))) * AVG(r.dq_record_ct) + * MAX(CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END) as affected_data_points FROM data_table_chars dtc + INNER JOIN table_groups tg + ON (dtc.table_groups_id = tg.id) + INNER JOIN projects proj + ON (tg.project_code = proj.project_code) LEFT JOIN (test_results r INNER JOIN test_suites ts ON (r.test_suite_id = ts.id @@ -75,6 +97,11 @@ WITH score_detail ON (dtc.table_groups_id = ts.table_groups_id AND dtc.schema_name = r.schema_name AND dtc.table_name = r.table_name) + LEFT JOIN data_column_chars dcc + ON (dtc.table_groups_id = dcc.table_groups_id + AND dtc.schema_name = dcc.schema_name + AND dtc.table_name = dcc.table_name + AND r.column_names = dcc.column_name) WHERE dtc.table_groups_id = :TABLE_GROUPS_ID AND COALESCE(ts.dq_score_exclude, FALSE) = FALSE AND COALESCE(r.disposition, 'Confirmed') = 'Confirmed' diff --git a/testgen/template/score_cards/get_category_scores_by_column.sql b/testgen/template/score_cards/get_category_scores_by_column.sql index 9e2b093f..9148d202 100644 --- a/testgen/template/score_cards/get_category_scores_by_column.sql +++ b/testgen/template/score_cards/get_category_scores_by_column.sql @@ -4,7 +4,7 @@ SELECT FROM ( SELECT {category} AS category, - SUM(COALESCE(good_data_pct * record_ct, 0)) / NULLIF(SUM(COALESCE(record_ct, 0)), 0) AS score + SUM(COALESCE(good_data_pct * weighted_record_ct, 0)) / NULLIF(SUM(COALESCE(weighted_record_ct, 0)), 0) AS score FROM v_dq_profile_scoring_latest_by_column WHERE NULLIF({category}, '') IS NOT NULL AND {filters} GROUP BY {category} @@ -12,9 +12,9 @@ FROM ( FULL OUTER JOIN ( SELECT {category} AS category, - SUM(COALESCE(good_data_pct * dq_record_ct, 0)) / NULLIF(SUM(COALESCE(dq_record_ct, 0)), 0) AS score + SUM(COALESCE(good_data_pct * weighted_dq_record_ct, 0)) / NULLIF(SUM(COALESCE(weighted_dq_record_ct, 0)), 0) AS score FROM v_dq_test_scoring_latest_by_column WHERE NULLIF({category}, '') IS NOT NULL AND {filters} GROUP BY {category} ) AS test_category_scores - ON (test_category_scores.category = profiling_category_scores.category) \ No newline at end of file + ON (test_category_scores.category = profiling_category_scores.category) diff --git a/testgen/template/score_cards/get_category_scores_by_dimension.sql b/testgen/template/score_cards/get_category_scores_by_dimension.sql index 1e501c10..d37e4473 100644 --- a/testgen/template/score_cards/get_category_scores_by_dimension.sql +++ b/testgen/template/score_cards/get_category_scores_by_dimension.sql @@ -4,7 +4,7 @@ SELECT FROM ( SELECT {category} AS category, - SUM(COALESCE(good_data_pct * record_ct, 0)) / NULLIF(SUM(COALESCE(record_ct, 0)), 0) AS score + SUM(COALESCE(good_data_pct * weighted_record_ct, 0)) / NULLIF(SUM(COALESCE(weighted_record_ct, 0)), 0) AS score FROM v_dq_profile_scoring_latest_by_dimension WHERE NULLIF({category}, '') IS NOT NULL AND {filters} GROUP BY {category} @@ -12,9 +12,9 @@ FROM ( FULL OUTER JOIN ( SELECT {category} AS category, - SUM(COALESCE(good_data_pct * dq_record_ct, 0)) / NULLIF(SUM(COALESCE(dq_record_ct, 0)), 0) AS score + SUM(COALESCE(good_data_pct * weighted_dq_record_ct, 0)) / NULLIF(SUM(COALESCE(weighted_dq_record_ct, 0)), 0) AS score FROM v_dq_test_scoring_latest_by_dimension WHERE NULLIF({category}, '') IS NOT NULL AND {filters} GROUP BY {category} ) AS test_category_scores - ON (test_category_scores.category = profiling_category_scores.category) \ No newline at end of file + ON (test_category_scores.category = profiling_category_scores.category) diff --git a/testgen/template/score_cards/get_historical_overall_scores_by_column.sql b/testgen/template/score_cards/get_historical_overall_scores_by_column.sql index 06485458..3d447749 100644 --- a/testgen/template/score_cards/get_historical_overall_scores_by_column.sql +++ b/testgen/template/score_cards/get_historical_overall_scores_by_column.sql @@ -9,9 +9,9 @@ FROM ( project_code, history.definition_id, history.last_run_time, - SUM(good_data_pct * record_ct) / NULLIF(SUM(record_ct), 0) AS score, - SUM(CASE critical_data_element WHEN true THEN (good_data_pct * record_ct) ELSE 0 END) - / NULLIF(SUM(CASE critical_data_element WHEN true THEN record_ct ELSE 0 END), 0) AS cde_score + SUM(good_data_pct * weighted_record_ct) / NULLIF(SUM(weighted_record_ct), 0) AS score, + SUM(CASE critical_data_element WHEN true THEN (good_data_pct * weighted_record_ct) ELSE 0 END) + / NULLIF(SUM(CASE critical_data_element WHEN true THEN weighted_record_ct ELSE 0 END), 0) AS cde_score FROM v_dq_profile_scoring_history_by_column INNER JOIN score_definition_results_history AS history ON ( @@ -29,9 +29,9 @@ FULL OUTER JOIN ( project_code, history.definition_id, history.last_run_time, - SUM(good_data_pct * dq_record_ct) / NULLIF(SUM(dq_record_ct), 0) AS score, - SUM(CASE critical_data_element WHEN true THEN (good_data_pct * dq_record_ct) ELSE 0 END) - / NULLIF(SUM(CASE critical_data_element WHEN true THEN dq_record_ct ELSE 0 END), 0) AS cde_score + SUM(good_data_pct * weighted_dq_record_ct) / NULLIF(SUM(weighted_dq_record_ct), 0) AS score, + SUM(CASE critical_data_element WHEN true THEN (good_data_pct * weighted_dq_record_ct) ELSE 0 END) + / NULLIF(SUM(CASE critical_data_element WHEN true THEN weighted_dq_record_ct ELSE 0 END), 0) AS cde_score FROM v_dq_test_scoring_history_by_column INNER JOIN score_definition_results_history AS history ON ( diff --git a/testgen/template/score_cards/get_overall_scores_by_column.sql b/testgen/template/score_cards/get_overall_scores_by_column.sql index 4ffc8b73..f3196fb5 100644 --- a/testgen/template/score_cards/get_overall_scores_by_column.sql +++ b/testgen/template/score_cards/get_overall_scores_by_column.sql @@ -6,9 +6,9 @@ SELECT FROM ( SELECT project_code, - SUM(good_data_pct * record_ct) / NULLIF(SUM(record_ct), 0) AS score, - SUM(CASE critical_data_element WHEN true THEN (good_data_pct * record_ct) ELSE 0 END) - / NULLIF(SUM(CASE critical_data_element WHEN true THEN record_ct ELSE 0 END), 0) AS cde_score + SUM(good_data_pct * weighted_record_ct) / NULLIF(SUM(weighted_record_ct), 0) AS score, + SUM(CASE critical_data_element WHEN true THEN (good_data_pct * weighted_record_ct) ELSE 0 END) + / NULLIF(SUM(CASE critical_data_element WHEN true THEN weighted_record_ct ELSE 0 END), 0) AS cde_score FROM v_dq_profile_scoring_latest_by_column WHERE {filters} GROUP BY project_code @@ -16,11 +16,11 @@ FROM ( FULL OUTER JOIN ( SELECT project_code, - SUM(good_data_pct * dq_record_ct) / NULLIF(SUM(dq_record_ct), 0) AS score, - SUM(CASE critical_data_element WHEN true THEN (good_data_pct * dq_record_ct) ELSE 0 END) - / NULLIF(SUM(CASE critical_data_element WHEN true THEN dq_record_ct ELSE 0 END), 0) AS cde_score + SUM(good_data_pct * weighted_dq_record_ct) / NULLIF(SUM(weighted_dq_record_ct), 0) AS score, + SUM(CASE critical_data_element WHEN true THEN (good_data_pct * weighted_dq_record_ct) ELSE 0 END) + / NULLIF(SUM(CASE critical_data_element WHEN true THEN weighted_dq_record_ct ELSE 0 END), 0) AS cde_score FROM v_dq_test_scoring_latest_by_column WHERE {filters} GROUP BY project_code ) AS test_scores - ON (test_scores.project_code = profiling_scores.project_code) \ No newline at end of file + ON (test_scores.project_code = profiling_scores.project_code) diff --git a/testgen/template/score_cards/get_score_card_breakdown_by_column.sql b/testgen/template/score_cards/get_score_card_breakdown_by_column.sql index 51af2a07..9d2b1403 100644 --- a/testgen/template/score_cards/get_score_card_breakdown_by_column.sql +++ b/testgen/template/score_cards/get_score_card_breakdown_by_column.sql @@ -4,8 +4,8 @@ profiling_records AS ( project_code, {columns}, SUM(issue_ct) AS issue_ct, - SUM(record_ct) AS data_point_ct, - SUM(record_ct * good_data_pct) / NULLIF(SUM(record_ct), 0) AS score + SUM(weighted_record_ct) AS data_point_ct, + SUM(weighted_record_ct * good_data_pct) / NULLIF(SUM(weighted_record_ct), 0) AS score FROM v_dq_profile_scoring_latest_by_column WHERE {filters} GROUP BY project_code, {columns} @@ -15,17 +15,17 @@ test_records AS ( project_code, {columns}, SUM(issue_ct) AS issue_ct, - SUM(dq_record_ct) AS data_point_ct, - SUM(dq_record_ct * good_data_pct) / NULLIF(SUM(dq_record_ct), 0) AS score + SUM(weighted_dq_record_ct) AS data_point_ct, + SUM(weighted_dq_record_ct * good_data_pct) / NULLIF(SUM(weighted_dq_record_ct), 0) AS score FROM v_dq_test_scoring_latest_by_column WHERE {filters} GROUP BY project_code, {columns} ), parent AS ( - SELECT + SELECT COALESCE(profiling_records.project_code, test_records.project_code) AS project_code, - SUM(COALESCE(profiling_records.record_ct, 0)) AS profiling_data_points, - SUM(COALESCE(test_records.dq_record_ct, 0)) AS test_data_points + SUM(COALESCE(profiling_records.weighted_record_ct, 0)) AS profiling_data_points, + SUM(COALESCE(test_records.weighted_dq_record_ct, 0)) AS test_data_points FROM v_dq_profile_scoring_latest_by_column AS profiling_records FULL OUTER JOIN v_dq_test_scoring_latest_by_column AS test_records ON ( test_records.project_code = profiling_records.project_code @@ -50,4 +50,4 @@ FULL OUTER JOIN test_records INNER JOIN parent ON (parent.project_code = profiling_records.project_code OR parent.project_code = test_records.project_code) ORDER BY impact DESC -LIMIT 100 \ No newline at end of file +LIMIT 100 diff --git a/testgen/template/score_cards/get_score_card_breakdown_by_dimension.sql b/testgen/template/score_cards/get_score_card_breakdown_by_dimension.sql index 436e7ff7..8557638f 100644 --- a/testgen/template/score_cards/get_score_card_breakdown_by_dimension.sql +++ b/testgen/template/score_cards/get_score_card_breakdown_by_dimension.sql @@ -4,8 +4,8 @@ profiling_records AS ( project_code, {columns}, SUM(issue_ct) AS issue_ct, - SUM(record_ct) AS data_point_ct, - SUM(record_ct * good_data_pct) / NULLIF(SUM(record_ct), 0) AS score + SUM(weighted_record_ct) AS data_point_ct, + SUM(weighted_record_ct * good_data_pct) / NULLIF(SUM(weighted_record_ct), 0) AS score FROM v_dq_profile_scoring_latest_by_dimension WHERE {filters} GROUP BY project_code, {columns} @@ -15,17 +15,17 @@ test_records AS ( project_code, {columns}, SUM(issue_ct) AS issue_ct, - SUM(dq_record_ct) AS data_point_ct, - SUM(dq_record_ct * good_data_pct) / NULLIF(SUM(dq_record_ct), 0) AS score + SUM(weighted_dq_record_ct) AS data_point_ct, + SUM(weighted_dq_record_ct * good_data_pct) / NULLIF(SUM(weighted_dq_record_ct), 0) AS score FROM v_dq_test_scoring_latest_by_dimension WHERE {filters} GROUP BY project_code, {columns} ), parent AS ( - SELECT + SELECT COALESCE(profiling_records.project_code, test_records.project_code) AS project_code, - SUM(COALESCE(profiling_records.record_ct, 0)) AS profiling_data_points, - SUM(COALESCE(test_records.dq_record_ct, 0)) AS test_data_points + SUM(COALESCE(profiling_records.weighted_record_ct, 0)) AS profiling_data_points, + SUM(COALESCE(test_records.weighted_dq_record_ct, 0)) AS test_data_points FROM v_dq_profile_scoring_latest_by_column AS profiling_records FULL OUTER JOIN v_dq_test_scoring_latest_by_column AS test_records ON ( test_records.project_code = profiling_records.project_code @@ -50,4 +50,4 @@ FULL OUTER JOIN test_records INNER JOIN parent ON (parent.project_code = profiling_records.project_code OR parent.project_code = test_records.project_code) ORDER BY impact DESC -LIMIT 100 \ No newline at end of file +LIMIT 100 diff --git a/testgen/ui/components/frontend/standalone/project_settings/index.js b/testgen/ui/components/frontend/standalone/project_settings/index.js index 9a9cb77c..a151fecf 100644 --- a/testgen/ui/components/frontend/standalone/project_settings/index.js +++ b/testgen/ui/components/frontend/standalone/project_settings/index.js @@ -7,6 +7,7 @@ import { Input } from '/app/static/js/components/input.js'; import { Button } from '/app/static/js/components/button.js'; import { required } from '/app/static/js/form_validators.js'; import { Alert } from '/app/static/js/components/alert.js'; +import { Checkbox } from '/app/static/js/components/checkbox.js'; import { createEmitter, getValue, isEqual } from '/app/static/js/utils.js'; const { div, span } = van.tags; @@ -17,20 +18,22 @@ const { div, span } = van.tags; * @property {boolean} successful * @property {string} message * @property {string?} details - * + * * @typedef Properties * @type {object} * @property {VanState} name + * @property {VanState} use_dq_score_weights * @property {VanState} observability_api_url * @property {VanState} observability_api_key * @property {VanState} observability_test_results - * + * * @param {Properties} props */ const ProjectSettings = (props) => { const { emit } = props; const /** @type Properties */ form = { name: van.state(props.name.rawVal ?? ''), + use_dq_score_weights: van.state(props.use_dq_score_weights.rawVal ?? true), observability_api_key: van.state(props.observability_api_key.rawVal ?? ''), observability_api_url: van.state(props.observability_api_url.rawVal ?? ''), }; @@ -61,6 +64,12 @@ const ProjectSettings = (props) => { formValidity.name.val = validity.valid; }, }), + Checkbox({ + label: 'Use weighted DQ scoring', + checked: form.use_dq_score_weights, + help: 'When enabled, DQ scores weight tables and columns by their semantic importance. Dimension tables and key columns receive higher weights.', + onChange: (checked) => { form.use_dq_score_weights.val = checked; }, + }), ), }), ), diff --git a/testgen/ui/views/project_settings.py b/testgen/ui/views/project_settings.py index 2b2fb227..b7a65c1c 100644 --- a/testgen/ui/views/project_settings.py +++ b/testgen/ui/views/project_settings.py @@ -6,6 +6,7 @@ from testgen.commands.run_observability_exporter import test_observability_exporter from testgen.common.models import with_database_session +from testgen.common.models.job_execution import JobExecution from testgen.common.models.project import Project from testgen.ui.components import widgets as testgen from testgen.ui.navigation.menu import MenuItem @@ -50,6 +51,7 @@ def on_observability_connection_test(payload: dict) -> None: key="project_settings", data={ "name": self.project.project_name, + "use_dq_score_weights": self.project.use_dq_score_weights, "observability_api_url": self.project.observability_api_url, "observability_api_key": self.project.observability_api_key, "observability_test_results": get_test_results(), @@ -67,11 +69,22 @@ def update_project(self, project_code: str, edited_project: dict) -> None: if new_project_name.lower() in existing_names: raise ValueError(f"Another project named {new_project_name} exists") + weights_changed = self.project.use_dq_score_weights != edited_project.get("use_dq_score_weights", True) + self.project.project_name = new_project_name + self.project.use_dq_score_weights = edited_project.get("use_dq_score_weights", True) self.project.observability_api_url = edited_project.get("observability_api_url") self.project.observability_api_key = edited_project.get("observability_api_key") self.project.save() + if weights_changed: + JobExecution.submit( + job_key="recalculate-project-scores", + kwargs={"project_code": project_code}, + source="user", + project_code=project_code, + ) + def test_observability_connection(self, project_code: str, edited_project: dict) -> "ObservabilityConnectionStatus": try: test_observability_exporter( diff --git a/tests/unit/commands/queries/test_profiling_query.py b/tests/unit/commands/queries/test_profiling_query.py index 61ad7df0..93d3e6b8 100644 --- a/tests/unit/commands/queries/test_profiling_query.py +++ b/tests/unit/commands/queries/test_profiling_query.py @@ -1,10 +1,82 @@ +from unittest.mock import MagicMock, patch + import pytest -from testgen.commands.queries.profiling_query import calculate_sampling_params +from testgen.commands.queries.profiling_query import ProfilingSQL, calculate_sampling_params pytestmark = pytest.mark.unit +# --- ProfilingSQL.update_profiling_results --- + + +def _make_profiling_sql(profile_flag_pii=False, profile_flag_cdes=False): + connection = MagicMock() + table_group = MagicMock() + table_group.profile_flag_pii = profile_flag_pii + table_group.profile_flag_cdes = profile_flag_cdes + profiling_run = MagicMock() + return ProfilingSQL(connection, table_group, profiling_run) + + +@pytest.mark.parametrize("profile_flag_pii,profile_flag_cdes", [ + (False, False), + (True, False), + (False, True), + (True, True), +]) +def test_update_profiling_results_weight_query_is_always_last(profile_flag_pii, profile_flag_cdes): + sql = _make_profiling_sql(profile_flag_pii=profile_flag_pii, profile_flag_cdes=profile_flag_cdes) + + with patch.object(sql, "_get_query", side_effect=lambda name, *_args, **_kw: (name, {})): + queries = sql.update_profiling_results() + + templates = [q[0] for q in queries] + assert templates[-1] == "dq_score_weight_update.sql" + + +def test_update_profiling_results_includes_pii_queries_when_flag_set(): + sql = _make_profiling_sql(profile_flag_pii=True) + + with patch.object(sql, "_get_query", side_effect=lambda name, *_args, **_kw: (name, {})): + queries = sql.update_profiling_results() + + templates = [q[0] for q in queries] + assert "pii_flag.sql" in templates + assert "pii_flag_update.sql" in templates + + +def test_update_profiling_results_excludes_pii_queries_when_flag_unset(): + sql = _make_profiling_sql(profile_flag_pii=False) + + with patch.object(sql, "_get_query", side_effect=lambda name, *_args, **_kw: (name, {})): + queries = sql.update_profiling_results() + + templates = [q[0] for q in queries] + assert "pii_flag.sql" not in templates + assert "pii_flag_update.sql" not in templates + + +def test_update_profiling_results_includes_cde_query_when_flag_set(): + sql = _make_profiling_sql(profile_flag_cdes=True) + + with patch.object(sql, "_get_query", side_effect=lambda name, *_args, **_kw: (name, {})): + queries = sql.update_profiling_results() + + templates = [q[0] for q in queries] + assert "cde_flagger_query.sql" in templates + + +def test_update_profiling_results_excludes_cde_query_when_flag_unset(): + sql = _make_profiling_sql(profile_flag_cdes=False) + + with patch.object(sql, "_get_query", side_effect=lambda name, *_args, **_kw: (name, {})): + queries = sql.update_profiling_results() + + templates = [q[0] for q in queries] + assert "cde_flagger_query.sql" not in templates + + # --- calculate_sampling_params --- diff --git a/tests/unit/commands/test_exec_job.py b/tests/unit/commands/test_exec_job.py index 30dacb2a..21ae898a 100644 --- a/tests/unit/commands/test_exec_job.py +++ b/tests/unit/commands/test_exec_job.py @@ -141,6 +141,7 @@ def test_job_dispatch_has_all_job_keys(): assert "run-monitors" in JOB_DISPATCH assert "run-test-generation" in JOB_DISPATCH assert "run-score-update" in JOB_DISPATCH + assert "recalculate-project-scores" in JOB_DISPATCH def test_exec_job_fires_final_callbacks_on_success(mock_session): diff --git a/tests/unit/commands/test_recalculate_project_scores.py b/tests/unit/commands/test_recalculate_project_scores.py new file mode 100644 index 00000000..192856c4 --- /dev/null +++ b/tests/unit/commands/test_recalculate_project_scores.py @@ -0,0 +1,142 @@ +from unittest.mock import MagicMock, Mock, call, patch +from uuid import uuid4 + +import pytest + +from testgen.commands.run_recalculate_project_scores import run_recalculate_project_scores + +pytestmark = pytest.mark.unit + +MODULE = "testgen.commands.run_recalculate_project_scores" + + +def _make_db_ctx(scalars_result): + session = MagicMock() + session.scalars.return_value.all.return_value = scalars_result + ctx = MagicMock() + ctx.__enter__ = Mock(return_value=session) + ctx.__exit__ = Mock(return_value=False) + return ctx + + +def _make_table_group(last_complete_profile_run_id=None): + tg = MagicMock() + tg.id = uuid4() + tg.last_complete_profile_run_id = last_complete_profile_run_id + return tg + + +def _make_test_suite(last_complete_test_run_id=None): + ts = MagicMock() + ts.id = uuid4() + ts.last_complete_test_run_id = last_complete_test_run_id or uuid4() + return ts + + +def test_no_table_groups_only_calls_refresh(): + with ( + patch(f"{MODULE}.database_session", side_effect=[_make_db_ctx([])]), + patch(f"{MODULE}.execute_db_queries") as mock_exec, + patch(f"{MODULE}.RollupScoresSQL") as mock_rollup_cls, + patch(f"{MODULE}.run_refresh_score_cards_results") as mock_refresh, + ): + run_recalculate_project_scores("proj") + + mock_exec.assert_not_called() + mock_rollup_cls.assert_not_called() + mock_refresh.assert_called_once_with(project_code="proj") + + +def test_table_group_without_profile_run_skips_profiling_rollup(): + tg = _make_table_group(last_complete_profile_run_id=None) + + with ( + patch(f"{MODULE}.database_session", side_effect=[_make_db_ctx([tg]), _make_db_ctx([])]), + patch(f"{MODULE}.execute_db_queries") as mock_exec, + patch(f"{MODULE}.RollupScoresSQL") as mock_rollup_cls, + patch(f"{MODULE}.run_refresh_score_cards_results"), + ): + run_recalculate_project_scores("proj") + + mock_rollup_cls.assert_not_called() + mock_exec.assert_not_called() + + +def test_table_group_with_profile_run_calls_profiling_rollup(): + run_id = uuid4() + tg = _make_table_group(last_complete_profile_run_id=run_id) + + mock_rollup = MagicMock() + mock_rollup.rollup_profiling_scores.return_value = ["q1"] + + with ( + patch(f"{MODULE}.database_session", side_effect=[_make_db_ctx([tg]), _make_db_ctx([])]), + patch(f"{MODULE}.execute_db_queries") as mock_exec, + patch(f"{MODULE}.RollupScoresSQL", return_value=mock_rollup), + patch(f"{MODULE}.run_refresh_score_cards_results"), + ): + run_recalculate_project_scores("proj") + + mock_rollup.rollup_profiling_scores.assert_called_once() + mock_exec.assert_called_once_with(["q1"]) + + +def test_one_test_suite_calls_rollup_with_update_table_group_true(): + tg = _make_table_group() + ts = _make_test_suite() + + mock_rollup = MagicMock() + mock_rollup.rollup_profiling_scores.return_value = [] + mock_rollup.rollup_test_scores.return_value = ["q2"] + + with ( + patch(f"{MODULE}.database_session", side_effect=[_make_db_ctx([tg]), _make_db_ctx([ts])]), + patch(f"{MODULE}.execute_db_queries"), + patch(f"{MODULE}.RollupScoresSQL", return_value=mock_rollup), + patch(f"{MODULE}.run_refresh_score_cards_results"), + ): + run_recalculate_project_scores("proj") + + mock_rollup.rollup_test_scores.assert_called_once_with(update_table_group=True) + + +def test_multiple_test_suites_only_last_has_update_table_group_true(): + tg = _make_table_group() + ts1 = _make_test_suite() + ts2 = _make_test_suite() + + mock_rollup = MagicMock() + mock_rollup.rollup_profiling_scores.return_value = [] + mock_rollup.rollup_test_scores.return_value = [] + + with ( + patch(f"{MODULE}.database_session", side_effect=[_make_db_ctx([tg]), _make_db_ctx([ts1, ts2])]), + patch(f"{MODULE}.execute_db_queries"), + patch(f"{MODULE}.RollupScoresSQL", return_value=mock_rollup), + patch(f"{MODULE}.run_refresh_score_cards_results"), + ): + run_recalculate_project_scores("proj") + + calls = mock_rollup.rollup_test_scores.call_args_list + assert calls == [ + call(update_table_group=False), + call(update_table_group=True), + ] + + +def test_refresh_always_called_at_end(): + tg = _make_table_group(last_complete_profile_run_id=uuid4()) + ts = _make_test_suite() + mock_rollup = MagicMock() + mock_rollup.rollup_profiling_scores.return_value = [] + mock_rollup.rollup_test_scores.return_value = [] + + with ( + patch(f"{MODULE}.database_session", side_effect=[_make_db_ctx([tg]), _make_db_ctx([ts])]), + patch(f"{MODULE}.execute_db_queries"), + patch(f"{MODULE}.RollupScoresSQL", return_value=mock_rollup), + patch(f"{MODULE}.run_refresh_score_cards_results") as mock_refresh, + ): + run_recalculate_project_scores("proj") + + mock_refresh.assert_called_once_with(project_code="proj") diff --git a/tests/unit/ui/conftest.py b/tests/unit/ui/conftest.py index 1aa05727..0e70ee57 100644 --- a/tests/unit/ui/conftest.py +++ b/tests/unit/ui/conftest.py @@ -5,3 +5,7 @@ # The testgen_component module triggers component registration at import time, which # requires a Streamlit runtime. We mock it so pure-logic tests can import freely. sys.modules.setdefault("testgen.ui.components.widgets.testgen_component", MagicMock()) + +# widgets/__init__.py calls components_v2.component() at module level; stub it out so +# views that import widgets don't fail collection without a Streamlit runtime. +sys.modules.setdefault("testgen.ui.components.widgets", MagicMock()) diff --git a/tests/unit/ui/test_project_settings.py b/tests/unit/ui/test_project_settings.py new file mode 100644 index 00000000..e38f4488 --- /dev/null +++ b/tests/unit/ui/test_project_settings.py @@ -0,0 +1,88 @@ +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from testgen.ui.views.project_settings import ProjectSettingsPage + +pytestmark = pytest.mark.unit + +MODULE = "testgen.ui.views.project_settings" + + +@pytest.fixture +def mock_session(): + session = MagicMock() + session.__enter__ = Mock(return_value=session) + session.__exit__ = Mock(return_value=False) + session.scalars.return_value.all.return_value = [] + with patch("testgen.common.models.Session", return_value=session): + yield session + + +def _make_page(use_dq_score_weights=True): + page = ProjectSettingsPage.__new__(ProjectSettingsPage) + page.project = MagicMock() + page.project.use_dq_score_weights = use_dq_score_weights + page.project.project_name = "My Project" + return page + + +def test_update_project_submits_recalculate_job_when_weights_toggled_on(mock_session): + page = _make_page(use_dq_score_weights=False) + + with patch(f"{MODULE}.JobExecution") as mock_je: + page.update_project("proj", {"name": "My Project", "use_dq_score_weights": True}) + + mock_je.submit.assert_called_once_with( + job_key="recalculate-project-scores", + kwargs={"project_code": "proj"}, + source="user", + project_code="proj", + ) + + +def test_update_project_submits_recalculate_job_when_weights_toggled_off(mock_session): + page = _make_page(use_dq_score_weights=True) + + with patch(f"{MODULE}.JobExecution") as mock_je: + page.update_project("proj", {"name": "My Project", "use_dq_score_weights": False}) + + mock_je.submit.assert_called_once_with( + job_key="recalculate-project-scores", + kwargs={"project_code": "proj"}, + source="user", + project_code="proj", + ) + + +def test_update_project_does_not_submit_job_when_weights_unchanged(mock_session): + page = _make_page(use_dq_score_weights=True) + + with patch(f"{MODULE}.JobExecution") as mock_je: + page.update_project("proj", {"name": "My Project", "use_dq_score_weights": True}) + + mock_je.submit.assert_not_called() + + +def test_update_project_saves_weight_setting(mock_session): + page = _make_page(use_dq_score_weights=False) + + with patch(f"{MODULE}.JobExecution"): + page.update_project("proj", {"name": "My Project", "use_dq_score_weights": True}) + + assert page.project.use_dq_score_weights is True + page.project.save.assert_called_once() + + +def test_update_project_raises_on_duplicate_name(mock_session): + page = _make_page() + mock_session.scalars.return_value.all.return_value = [ + MagicMock(project_name="Other Project"), + ] + + with ( + patch(f"{MODULE}.Project") as mock_project_cls, + pytest.raises(ValueError, match="Other Project"), + ): + mock_project_cls.select_where.return_value = [MagicMock(project_name="Other Project")] + page.update_project("proj", {"name": "Other Project", "use_dq_score_weights": True}) From 5f25546dcb7abdca2ba2ebc5a6cfbab3027a7c42 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Wed, 29 Apr 2026 10:53:53 -0400 Subject: [PATCH 085/123] fix: lint errors --- testgen/__main__.py | 19 +++++++++++-------- testgen/commands/run_launch_db_config.py | 2 +- testgen/commands/run_quick_start.py | 2 +- .../flavor/redshift_flavor_service.py | 6 +----- testgen/common/models/entity.py | 1 + testgen/common/models/settings.py | 3 ++- testgen/common/read_file.py | 2 +- testgen/common/standalone_postgres.py | 1 + testgen/ui/app.py | 2 +- testgen/ui/pdf/dataframe_table.py | 1 + 10 files changed, 21 insertions(+), 18 deletions(-) diff --git a/testgen/__main__.py b/testgen/__main__.py index 0d89eb6f..e3e0ced7 100644 --- a/testgen/__main__.py +++ b/testgen/__main__.py @@ -10,7 +10,6 @@ from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from importlib.metadata import version as pkg_version -from pathlib import Path import click from click.core import Context @@ -53,18 +52,22 @@ get_tg_schema, version_service, ) +from testgen.common.models import database_session, with_database_session +from testgen.common.models.settings import PersistedSetting +from testgen.common.models.table_group import TableGroup +from testgen.common.models.test_suite import TestSuite +from testgen.common.notifications.base import smtp_configured from testgen.common.standalone_postgres import ( STANDALONE_URI_ENV_VAR, - get_home_dir as get_testgen_home, get_server_uri, is_standalone_mode, +) +from testgen.common.standalone_postgres import ( + get_home_dir as get_testgen_home, +) +from testgen.common.standalone_postgres import ( start_server as start_standalone_postgres, ) -from testgen.common.models import database_session, with_database_session -from testgen.common.models.settings import PersistedSetting -from testgen.common.models.table_group import TableGroup -from testgen.common.models.test_suite import TestSuite -from testgen.common.notifications.base import smtp_configured from testgen.scheduler import run_scheduler from testgen.utils import plugins @@ -92,7 +95,7 @@ def invoke(self, ctx: Context): except Exception: LOG.exception("There was an unexpected error") - def format_epilog(self, ctx: Context, formatter: click.HelpFormatter) -> None: + def format_epilog(self, _ctx: Context, formatter: click.HelpFormatter) -> None: # Schema revision is a DB round-trip; defer until `--help` is actually # requested rather than evaluating at module-load for every CLI invocation. formatter.write_paragraph() diff --git a/testgen/commands/run_launch_db_config.py b/testgen/commands/run_launch_db_config.py index 41115afd..ba0a08b4 100644 --- a/testgen/commands/run_launch_db_config.py +++ b/testgen/commands/run_launch_db_config.py @@ -4,12 +4,12 @@ from testgen import settings from testgen.common import create_database, execute_db_queries from testgen.common.credentials import get_tg_db, get_tg_schema -from testgen.common.standalone_postgres import get_home_dir, is_standalone_mode from testgen.common.database.database_service import get_queries_for_command from testgen.common.encrypt import EncryptText, encrypt_ui_password from testgen.common.models import with_database_session from testgen.common.read_file import get_template_files from testgen.common.read_yaml_metadata_records import import_metadata_records_from_yaml +from testgen.common.standalone_postgres import get_home_dir, is_standalone_mode LOG = logging.getLogger("testgen") diff --git a/testgen/commands/run_quick_start.py b/testgen/commands/run_quick_start.py index dda84278..5d0aed18 100644 --- a/testgen/commands/run_quick_start.py +++ b/testgen/commands/run_quick_start.py @@ -10,7 +10,6 @@ from testgen.commands.job_registry import JOB_DISPATCH from testgen.commands.run_launch_db_config import get_app_db_params_mapping, run_launch_db_config from testgen.commands.run_score_update import run_score_update -from testgen.common.standalone_postgres import get_home_dir, is_standalone_mode from testgen.commands.test_generation import run_monitor_generation from testgen.common.credentials import get_tg_schema from testgen.common.database.database_service import ( @@ -28,6 +27,7 @@ from testgen.common.models.table_group import TableGroup from testgen.common.notifications.base import smtp_configured from testgen.common.read_file import read_template_sql_file +from testgen.common.standalone_postgres import get_home_dir, is_standalone_mode LOG = logging.getLogger("testgen") random.seed(42) diff --git a/testgen/common/database/flavor/redshift_flavor_service.py b/testgen/common/database/flavor/redshift_flavor_service.py index 77459a0f..24394ee8 100644 --- a/testgen/common/database/flavor/redshift_flavor_service.py +++ b/testgen/common/database/flavor/redshift_flavor_service.py @@ -2,14 +2,10 @@ from sqlalchemy.dialects import registry as _dialect_registry from sqlalchemy.dialects.postgresql.psycopg2 import PGDialect_psycopg2 -from sqlalchemy.engine import Engine -from sqlalchemy.engine import create_engine as sqlalchemy_create_engine from testgen.common.database.flavor.flavor_service import ( - ConnectionParams, FlavorService, ResolvedConnectionParams, - resolve_connection_params, ) @@ -22,7 +18,7 @@ class _RedshiftDialect(PGDialect_psycopg2): """ name = "redshift_pg" - def _set_backslash_escapes(self, connection): + def _set_backslash_escapes(self, _connection): self._backslash_escapes = False diff --git a/testgen/common/models/entity.py b/testgen/common/models/entity.py index 35297911..77306820 100644 --- a/testgen/common/models/entity.py +++ b/testgen/common/models/entity.py @@ -12,6 +12,7 @@ from testgen.common.models import Base, get_current_session from testgen.utils import is_uuid4, make_json_safe + def _hash_clause(x): # Don't use literal_binds=True — SA 2.0 can't render UUID POSTCOMPILE IN-lists # that way and raises CompileError when Streamlit hashes cached args. diff --git a/testgen/common/models/settings.py b/testgen/common/models/settings.py index b66b7c23..4280070b 100644 --- a/testgen/common/models/settings.py +++ b/testgen/common/models/settings.py @@ -1,7 +1,8 @@ from typing import Any from sqlalchemy import Column, String, select -from sqlalchemy.dialects.postgresql import JSONB, insert as pg_insert +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import insert as pg_insert from testgen.common.models import Base, get_current_session diff --git a/testgen/common/read_file.py b/testgen/common/read_file.py index ada6a86a..4c05b66a 100644 --- a/testgen/common/read_file.py +++ b/testgen/common/read_file.py @@ -4,8 +4,8 @@ import re from collections.abc import Generator from functools import cache -from importlib.resources.abc import Traversable from importlib.resources import as_file, files +from importlib.resources.abc import Traversable import yaml diff --git a/testgen/common/standalone_postgres.py b/testgen/common/standalone_postgres.py index ecfcb8ce..af7c7c27 100644 --- a/testgen/common/standalone_postgres.py +++ b/testgen/common/standalone_postgres.py @@ -91,6 +91,7 @@ def _reinitialize_orm_engine(base_uri: str | None = None) -> None: must replace that engine so the ORM connects via Unix socket. """ from sqlalchemy import create_engine + from testgen.common import models uri = _build_connection_string(settings.DATABASE_NAME, base_uri) diff --git a/testgen/ui/app.py b/testgen/ui/app.py index 1cd3ab34..be61a443 100644 --- a/testgen/ui/app.py +++ b/testgen/ui/app.py @@ -7,9 +7,9 @@ from testgen import settings from testgen.common import version_service from testgen.common.docker_service import check_basic_configuration -from testgen.common.standalone_postgres import STANDALONE_URI_ENV_VAR, ensure_standalone_setup, is_standalone_mode from testgen.common.models import get_current_session, with_database_session from testgen.common.models.project import Project +from testgen.common.standalone_postgres import STANDALONE_URI_ENV_VAR, ensure_standalone_setup, is_standalone_mode from testgen.ui import bootstrap from testgen.ui.assets import get_asset_path from testgen.ui.components import widgets as testgen diff --git a/testgen/ui/pdf/dataframe_table.py b/testgen/ui/pdf/dataframe_table.py index 9f4966e8..e8cbf096 100644 --- a/testgen/ui/pdf/dataframe_table.py +++ b/testgen/ui/pdf/dataframe_table.py @@ -1,4 +1,5 @@ from collections.abc import Iterable + import pandas from pandas.core.dtypes.common import is_numeric_dtype from reportlab.lib import colors, enums From 459fdfe60ebd8d7b4a4662701b71287ba192effe Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Wed, 29 Apr 2026 11:38:12 -0400 Subject: [PATCH 086/123] test: fix mocks for sqlalchemy 2 test: fix mocks for sqlalchemy 2 --- tests/unit/api/oauth/test_routes.py | 4 +-- tests/unit/api/oauth/test_server.py | 34 +++++++++---------- tests/unit/api/test_deps.py | 18 ++++++---- .../unit/common/models/test_job_execution.py | 8 ++--- .../test_profiling_run_notifications.py | 10 +++--- tests/unit/common/test_auth.py | 21 +++++++----- tests/unit/scheduler/test_scheduler_poll.py | 6 ++-- 7 files changed, 56 insertions(+), 45 deletions(-) diff --git a/tests/unit/api/oauth/test_routes.py b/tests/unit/api/oauth/test_routes.py index 7dcd0685..a567de1d 100644 --- a/tests/unit/api/oauth/test_routes.py +++ b/tests/unit/api/oauth/test_routes.py @@ -132,7 +132,7 @@ def test_get_client_name_returns_name_from_metadata(mock_get_session): mock_get_session.return_value = mock_session mock_client = MagicMock() mock_client.client_metadata = {"client_name": "My App"} - mock_session.query.return_value.filter_by.return_value.first.return_value = mock_client + mock_session.scalars.return_value.first.return_value = mock_client assert _get_client_name("client123") == "My App" @@ -143,7 +143,7 @@ def test_get_client_name_returns_empty_when_not_found(mock_get_session): mock_session = MagicMock() mock_get_session.return_value = mock_session - mock_session.query.return_value.filter_by.return_value.first.return_value = None + mock_session.scalars.return_value.first.return_value = None assert _get_client_name("nonexistent") == "" diff --git a/tests/unit/api/oauth/test_server.py b/tests/unit/api/oauth/test_server.py index 85030c64..c9810e15 100644 --- a/tests/unit/api/oauth/test_server.py +++ b/tests/unit/api/oauth/test_server.py @@ -50,7 +50,7 @@ def test_auth_code_query_returns_valid_code(mock_get_session): mock_code = MagicMock() mock_code.is_expired.return_value = False - mock_session.query.return_value.filter_by.return_value.first.return_value = mock_code + mock_session.scalars.return_value.first.return_value = mock_code grant = AuthorizationCodeGrant.__new__(AuthorizationCodeGrant) client = MagicMock() @@ -67,7 +67,7 @@ def test_auth_code_query_returns_none_for_expired(mock_get_session): mock_code = MagicMock() mock_code.is_expired.return_value = True - mock_session.query.return_value.filter_by.return_value.first.return_value = mock_code + mock_session.scalars.return_value.first.return_value = mock_code grant = AuthorizationCodeGrant.__new__(AuthorizationCodeGrant) client = MagicMock() @@ -95,7 +95,7 @@ def test_auth_code_authenticate_user(mock_get_session): mock_session = MagicMock() mock_get_session.return_value = mock_session mock_user = MagicMock() - mock_session.query.return_value.filter.return_value.first.return_value = mock_user + mock_session.scalars.return_value.first.return_value = mock_user grant = AuthorizationCodeGrant.__new__(AuthorizationCodeGrant) auth_code = MagicMock() @@ -104,7 +104,7 @@ def test_auth_code_authenticate_user(mock_get_session): result = grant.authenticate_user(auth_code) assert result is mock_user - mock_session.query.assert_called_once() + mock_session.scalars.assert_called_once() # --- RefreshTokenGrant --- @@ -116,8 +116,8 @@ def test_refresh_token_authenticate_valid_token(mock_get_session): mock_get_session.return_value = mock_session mock_token = MagicMock() - mock_token.is_revoked.return_value = False - mock_session.query.return_value.filter_by.return_value.first.return_value = mock_token + mock_token.is_refresh_token_active.return_value = True + mock_session.scalars.return_value.first.return_value = mock_token grant = RefreshTokenGrant.__new__(RefreshTokenGrant) @@ -133,7 +133,7 @@ def test_refresh_token_authenticate_returns_none_for_revoked(mock_get_session): mock_token = MagicMock() mock_token.is_refresh_token_active.return_value = False - mock_session.query.return_value.filter_by.return_value.first.return_value = mock_token + mock_session.scalars.return_value.first.return_value = mock_token grant = RefreshTokenGrant.__new__(RefreshTokenGrant) @@ -146,7 +146,7 @@ def test_refresh_token_authenticate_returns_none_for_revoked(mock_get_session): def test_refresh_token_authenticate_returns_none_when_not_found(mock_get_session): mock_session = MagicMock() mock_get_session.return_value = mock_session - mock_session.query.return_value.filter_by.return_value.first.return_value = None + mock_session.scalars.return_value.first.return_value = None grant = RefreshTokenGrant.__new__(RefreshTokenGrant) @@ -173,7 +173,7 @@ def test_refresh_token_authenticate_user(mock_get_session): mock_session = MagicMock() mock_get_session.return_value = mock_session mock_user = MagicMock() - mock_session.query.return_value.filter.return_value.first.return_value = mock_user + mock_session.scalars.return_value.first.return_value = mock_user grant = RefreshTokenGrant.__new__(RefreshTokenGrant) credential = MagicMock() @@ -193,7 +193,7 @@ def test_server_query_client_returns_client(mock_get_session): mock_get_session.return_value = mock_session mock_client = MagicMock() - mock_session.query.return_value.filter_by.return_value.first.return_value = mock_client + mock_session.scalars.return_value.first.return_value = mock_client server = TestGenAuthorizationServer() result = server.query_client("test_client_id") @@ -205,7 +205,7 @@ def test_server_query_client_returns_client(mock_get_session): def test_server_query_client_returns_none_when_not_found(mock_get_session): mock_session = MagicMock() mock_get_session.return_value = mock_session - mock_session.query.return_value.filter_by.return_value.first.return_value = None + mock_session.scalars.return_value.first.return_value = None server = TestGenAuthorizationServer() result = server.query_client("nonexistent") @@ -345,7 +345,7 @@ def test_client_credentials_resolves_owner(mock_get_session): mock_owner = MagicMock() mock_owner.username = "owner_user" - mock_session.query.return_value.filter.return_value.first.return_value = mock_owner + mock_session.scalars.return_value.first.return_value = mock_owner grant = ClientCredentialsGrant.__new__(ClientCredentialsGrant) grant.request = MagicMock() @@ -379,7 +379,7 @@ def test_client_credentials_rejects_deleted_owner(mock_get_session): mock_session = MagicMock() mock_get_session.return_value = mock_session - mock_session.query.return_value.filter.return_value.first.return_value = None + mock_session.scalars.return_value.first.return_value = None grant = ClientCredentialsGrant.__new__(ClientCredentialsGrant) grant.request = MagicMock() @@ -400,13 +400,13 @@ def test_revocation_query_token_by_access_token(mock_get_session): mock_session = MagicMock() mock_get_session.return_value = mock_session mock_token = MagicMock() - mock_session.query.return_value.filter_by.return_value.first.return_value = mock_token + mock_session.scalars.return_value.first.return_value = mock_token ep = TestGenRevocationEndpoint.__new__(TestGenRevocationEndpoint) result = ep.query_token("tok_abc", "access_token") assert result is mock_token - mock_session.query.return_value.filter_by.assert_called_with(access_token="tok_abc") # noqa: S106 + mock_session.scalars.assert_called_once() @patch("testgen.api.oauth.server.get_current_session") @@ -414,13 +414,13 @@ def test_revocation_query_token_by_refresh_token(mock_get_session): mock_session = MagicMock() mock_get_session.return_value = mock_session mock_token = MagicMock() - mock_session.query.return_value.filter_by.return_value.first.return_value = mock_token + mock_session.scalars.return_value.first.return_value = mock_token ep = TestGenRevocationEndpoint.__new__(TestGenRevocationEndpoint) result = ep.query_token("ref_abc", "refresh_token") assert result is mock_token - mock_session.query.return_value.filter_by.assert_called_with(refresh_token="ref_abc") # noqa: S106 + mock_session.scalars.assert_called_once() def test_revocation_revoke_token_sets_timestamps(): diff --git a/tests/unit/api/test_deps.py b/tests/unit/api/test_deps.py index aa691ebf..d38586fc 100644 --- a/tests/unit/api/test_deps.py +++ b/tests/unit/api/test_deps.py @@ -30,6 +30,13 @@ def _make_credentials(token): # --- get_authorized_user --- +def _set_scalars_results(mock_session, *results): + """Configure mock_session.scalars to return successive `.first()` values per call.""" + mock_session.scalars.side_effect = [ + MagicMock(first=MagicMock(return_value=r)) for r in results + ] + + @patch("testgen.common.auth.settings") @patch("testgen.api.deps.get_current_session") def test_get_authorized_user_returns_user_for_valid_token(mock_get_session, mock_settings): @@ -39,9 +46,8 @@ def test_get_authorized_user_returns_user_for_valid_token(mock_get_session, mock mock_user = MagicMock() mock_user.username = "testuser" - mock_session.query.return_value.filter.return_value.first.return_value = mock_user - # No OAuth2Token record — not revoked - mock_session.query.return_value.filter_by.return_value.first.return_value = None + # authorize_token: 1st scalars() = User lookup, 2nd = OAuth2Token revocation lookup + _set_scalars_results(mock_session, mock_user, None) token = _make_token("testuser") result = get_authorized_user(_make_credentials(token)) @@ -82,7 +88,8 @@ def test_get_authorized_user_raises_401_when_user_not_found(mock_get_session, mo mock_settings.JWT_HASHING_KEY_B64 = JWT_KEY mock_session = MagicMock() mock_get_session.return_value = mock_session - mock_session.query.return_value.filter.return_value.first.return_value = None + # User lookup returns None — revocation lookup never reached + _set_scalars_results(mock_session, None) token = _make_token("nonexistent_user") creds = _make_credentials(token) @@ -104,12 +111,11 @@ def test_get_authorized_user_rejects_revoked_token(mock_get_session, mock_settin mock_user = MagicMock() mock_user.username = "testuser" - mock_session.query.return_value.filter.return_value.first.return_value = mock_user # Token record exists and is revoked mock_token_record = MagicMock() mock_token_record.access_token_revoked_at = 1700000000 - mock_session.query.return_value.filter_by.return_value.first.return_value = mock_token_record + _set_scalars_results(mock_session, mock_user, mock_token_record) token = _make_token("testuser") creds = _make_credentials(token) diff --git a/tests/unit/common/models/test_job_execution.py b/tests/unit/common/models/test_job_execution.py index 71db61b5..7fc90446 100644 --- a/tests/unit/common/models/test_job_execution.py +++ b/tests/unit/common/models/test_job_execution.py @@ -126,7 +126,7 @@ def test_get_by_id(mock_session): def test_mark_running(mock_session): job = JobExecution(id=uuid4(), status="claimed") - mock_session.execute.return_value.first.return_value = _returning_row(job, status="running") + mock_session.execute.return_value.scalar_one_or_none.return_value = _returning_row(job, status="running") job.mark_running() @@ -135,7 +135,7 @@ def test_mark_running(mock_session): def test_mark_completed(mock_session): job = JobExecution(id=uuid4(), status="running") - mock_session.execute.return_value.first.return_value = _returning_row(job, status="completed") + mock_session.execute.return_value.scalar_one_or_none.return_value = _returning_row(job, status="completed") job.mark_completed() @@ -144,7 +144,7 @@ def test_mark_completed(mock_session): def test_mark_interrupted_error(mock_session): job = JobExecution(id=uuid4(), status="running") - mock_session.execute.return_value.first.return_value = _returning_row(job, status="error", error_message="Something went wrong") + mock_session.execute.return_value.scalar_one_or_none.return_value = _returning_row(job, status="error", error_message="Something went wrong") job.mark_interrupted("Something went wrong") @@ -154,7 +154,7 @@ def test_mark_interrupted_error(mock_session): def test_mark_interrupted_canceled(mock_session): job = JobExecution(id=uuid4(), status="cancel_requested") - mock_session.execute.return_value.first.return_value = _returning_row(job, status="canceled") + mock_session.execute.return_value.scalar_one_or_none.return_value = _returning_row(job, status="canceled") job.mark_interrupted("Process exited with code -15") diff --git a/tests/unit/common/notifications/test_profiling_run_notifications.py b/tests/unit/common/notifications/test_profiling_run_notifications.py index afcd841d..15cd4e8b 100644 --- a/tests/unit/common/notifications/test_profiling_run_notifications.py +++ b/tests/unit/common/notifications/test_profiling_run_notifications.py @@ -119,11 +119,11 @@ def test_send_profiling_run_notification( for hi, _ in hi_list: hi_count_dict[hi.priority].total += 1 hi_count_mock.return_value = hi_count_dict - db_session_mock.execute().one.return_value = ( - ("project_name", "proj-name"), - ("table_groups_name", "t-group-name"), - ("table_group_schema", "t-group-schema"), - ) + db_session_mock.execute().mappings().one.return_value = { + "project_name": "proj-name", + "table_groups_name": "t-group-name", + "table_group_schema": "t-group-schema", + } send_profiling_run_notifications(profiling_run) diff --git a/tests/unit/common/test_auth.py b/tests/unit/common/test_auth.py index e6fbae8c..3d6bab34 100644 --- a/tests/unit/common/test_auth.py +++ b/tests/unit/common/test_auth.py @@ -74,12 +74,19 @@ def test_verify_password_wrong(): # --- authorize_token --- +def _set_scalars_results(mock_session, *results): + """Configure mock_session.scalars to return successive `.first()` values per call.""" + mock_session.scalars.side_effect = [ + MagicMock(first=MagicMock(return_value=r)) for r in results + ] + + def test_authorize_token_returns_user(): mock_session = MagicMock() mock_user = MagicMock() mock_user.username = "testuser" - mock_session.query.return_value.filter.return_value.first.return_value = mock_user - mock_session.query.return_value.filter_by.return_value.first.return_value = None + # 1st scalars() = User lookup, 2nd = OAuth2Token revocation lookup + _set_scalars_results(mock_session, mock_user, None) result = authorize_token("some_token", "testuser", mock_session) assert result is mock_user @@ -88,11 +95,9 @@ def test_authorize_token_returns_user(): def test_authorize_token_rejects_revoked(): mock_session = MagicMock() mock_user = MagicMock() - mock_session.query.return_value.filter.return_value.first.return_value = mock_user - mock_token_record = MagicMock() mock_token_record.access_token_revoked_at = 1700000000 - mock_session.query.return_value.filter_by.return_value.first.return_value = mock_token_record + _set_scalars_results(mock_session, mock_user, mock_token_record) with pytest.raises(ValueError, match="Token has been revoked"): authorize_token("revoked_token", "testuser", mock_session) @@ -102,8 +107,7 @@ def test_authorize_token_allows_unknown_token(): """When no OAuth2Token record exists (e.g. session cookie), authorization passes.""" mock_session = MagicMock() mock_user = MagicMock() - mock_session.query.return_value.filter.return_value.first.return_value = mock_user - mock_session.query.return_value.filter_by.return_value.first.return_value = None + _set_scalars_results(mock_session, mock_user, None) result = authorize_token("session_cookie_jwt", "testuser", mock_session) assert result is mock_user @@ -111,7 +115,8 @@ def test_authorize_token_allows_unknown_token(): def test_authorize_token_raises_when_user_not_found(): mock_session = MagicMock() - mock_session.query.return_value.filter.return_value.first.return_value = None + # User lookup returns None — token check is never reached + _set_scalars_results(mock_session, None) with pytest.raises(ValueError, match="User not found"): authorize_token("some_token", "ghost", mock_session) diff --git a/tests/unit/scheduler/test_scheduler_poll.py b/tests/unit/scheduler/test_scheduler_poll.py index 75ef1755..20b3bfeb 100644 --- a/tests/unit/scheduler/test_scheduler_poll.py +++ b/tests/unit/scheduler/test_scheduler_poll.py @@ -78,7 +78,7 @@ def test_dispatch_unknown_job_key(scheduler_instance, mock_session): source="ui", status="claimed", ) - mock_session.execute.return_value.first.return_value = _returning_row(job_exec, status="error") + mock_session.execute.return_value.scalar_one_or_none.return_value = _returning_row(job_exec, status="error") with patch.dict(JOB_DISPATCH, {}, clear=True): scheduler_instance._dispatch(job_exec) @@ -111,7 +111,7 @@ def test_proc_wrapper_success_is_noop(scheduler_instance, job_exec, mock_session def test_proc_wrapper_failure(scheduler_instance, job_exec, mock_session): job_exec.status = "running" - mock_session.execute.return_value.first.return_value = _returning_row(job_exec, status="error") + mock_session.execute.return_value.scalar_one_or_none.return_value = _returning_row(job_exec, status="error") proc_mock = Mock() proc_mock.pid = 555 proc_mock.wait.return_value = 1 @@ -129,7 +129,7 @@ def test_proc_wrapper_failure(scheduler_instance, job_exec, mock_session): def test_proc_wrapper_exception(scheduler_instance, job_exec, mock_session): job_exec.status = "running" - mock_session.execute.return_value.first.return_value = _returning_row(job_exec, status="error") + mock_session.execute.return_value.scalar_one_or_none.return_value = _returning_row(job_exec, status="error") proc_mock = Mock() proc_mock.pid = 555 proc_mock.wait.side_effect = OSError("broken") From 9cd8e430a05dfb0d39399424a3f4f117f2ebe1ba Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Wed, 29 Apr 2026 13:27:31 -0400 Subject: [PATCH 087/123] fix: redshift and mssql warnings --- testgen/common/database/flavor/mssql_flavor_service.py | 6 ++++++ testgen/common/database/flavor/redshift_flavor_service.py | 1 + 2 files changed, 7 insertions(+) diff --git a/testgen/common/database/flavor/mssql_flavor_service.py b/testgen/common/database/flavor/mssql_flavor_service.py index 570c8b5c..b673943c 100644 --- a/testgen/common/database/flavor/mssql_flavor_service.py +++ b/testgen/common/database/flavor/mssql_flavor_service.py @@ -36,8 +36,14 @@ def get_connection_string_from_fields(self, params: ResolvedConnectionParams) -> return connection_url.render_as_string(hide_password=False) def get_pre_connection_queries(self, params: ResolvedConnectionParams) -> list[tuple[str, dict | None]]: # noqa: ARG002 + # ANSI_DEFAULTS turns on ANSI_NULLS / ANSI_PADDING / QUOTED_IDENTIFIER (good) + # *and* ANSI_WARNINGS (bad here). pyodbc>=5.2 escalates SQL Server's 01003 + # "Null value is eliminated by an aggregate" warning into a pyodbc.Error, + # which breaks profiling/CAT queries that aggregate over nullable columns. + # Target connections are read-only, so disabling ANSI_WARNINGS is safe. return [ ("SET ANSI_DEFAULTS ON;", None), + ("SET ANSI_WARNINGS OFF;", None), ("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;", None), ] diff --git a/testgen/common/database/flavor/redshift_flavor_service.py b/testgen/common/database/flavor/redshift_flavor_service.py index 24394ee8..9673005d 100644 --- a/testgen/common/database/flavor/redshift_flavor_service.py +++ b/testgen/common/database/flavor/redshift_flavor_service.py @@ -17,6 +17,7 @@ class _RedshiftDialect(PGDialect_psycopg2): the check so connections succeed. """ name = "redshift_pg" + supports_statement_cache = True def _set_backslash_escapes(self, _connection): self._backslash_escapes = False From e2bca92f56d417459da9e16c98a6f10a8865e854 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Wed, 29 Apr 2026 15:29:41 -0400 Subject: [PATCH 088/123] fix: remove stale pinned version in dockerfile --- deploy/testgen-base.dockerfile | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/deploy/testgen-base.dockerfile b/deploy/testgen-base.dockerfile index fd207bcd..2fafe213 100644 --- a/deploy/testgen-base.dockerfile +++ b/deploy/testgen-base.dockerfile @@ -29,9 +29,7 @@ RUN apk update && apk upgrade && apk add --no-cache \ unixodbc=2.3.14-r0 \ unixodbc-dev=2.3.14-r0 \ libarrow=21.0.0-r4 \ - apache-arrow-dev=21.0.0-r4 \ - # Pinned versions for security - xz=5.8.2-r0 + apache-arrow-dev=21.0.0-r4 COPY --chmod=775 ./deploy/install_linuxodbc.sh /tmp/dk/install_linuxodbc.sh RUN /tmp/dk/install_linuxodbc.sh From 851a13411f18c6b9d325e2757485c3ed31025fc5 Mon Sep 17 00:00:00 2001 From: testgen-ci-bot Date: Wed, 29 Apr 2026 20:41:15 +0000 Subject: [PATCH 089/123] ci: bump base image to v15 --- deploy/testgen.dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/testgen.dockerfile b/deploy/testgen.dockerfile index f5a58270..743f9edf 100644 --- a/deploy/testgen.dockerfile +++ b/deploy/testgen.dockerfile @@ -1,4 +1,4 @@ -ARG TESTGEN_BASE_LABEL=v14 +ARG TESTGEN_BASE_LABEL=v15 FROM datakitchen/dataops-testgen-base:${TESTGEN_BASE_LABEL} AS release-image From a8f7187e4ba154156f49760b2e86c74e815a14f6 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Wed, 29 Apr 2026 17:05:34 -0400 Subject: [PATCH 090/123] fix: minor tweaks to weighted scoring --- testgen/commands/run_recalculate_project_scores.py | 3 ++- testgen/template/dbupgrade/0186_incremental_upgrade.sql | 2 +- testgen/template/rollup_scores/rollup_scores_test_run.sql | 2 ++ .../components/frontend/standalone/project_settings/index.js | 4 ++-- testgen/ui/views/project_settings.py | 1 + 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/testgen/commands/run_recalculate_project_scores.py b/testgen/commands/run_recalculate_project_scores.py index 4802203a..27268943 100644 --- a/testgen/commands/run_recalculate_project_scores.py +++ b/testgen/commands/run_recalculate_project_scores.py @@ -36,7 +36,8 @@ def run_recalculate_project_scores(project_code: str) -> None: test_suites = session.scalars( select(TestSuite).where( TestSuite.table_groups_id == tg.id, - TestSuite.last_complete_test_run_id != None, + TestSuite.is_monitor.isnot(True), + TestSuite.last_complete_test_run_id.isnot(None), ) ).all() diff --git a/testgen/template/dbupgrade/0186_incremental_upgrade.sql b/testgen/template/dbupgrade/0186_incremental_upgrade.sql index 17647925..17df5c65 100644 --- a/testgen/template/dbupgrade/0186_incremental_upgrade.sql +++ b/testgen/template/dbupgrade/0186_incremental_upgrade.sql @@ -1,6 +1,6 @@ SET SEARCH_PATH TO {SCHEMA_NAME}; --- TG-1042: DQ Score Weighting +-- DQ Score Weighting ALTER TABLE projects ADD COLUMN use_dq_score_weights BOOLEAN DEFAULT FALSE; diff --git a/testgen/template/rollup_scores/rollup_scores_test_run.sql b/testgen/template/rollup_scores/rollup_scores_test_run.sql index a4eba52c..8bd1d20f 100644 --- a/testgen/template/rollup_scores/rollup_scores_test_run.sql +++ b/testgen/template/rollup_scores/rollup_scores_test_run.sql @@ -24,9 +24,11 @@ WITH score_detail ON (tg.project_code = proj.project_code) LEFT JOIN data_table_chars dtc ON (r.table_groups_id = dtc.table_groups_id + AND r.schema_name = dtc.schema_name AND r.table_name = dtc.table_name) LEFT JOIN data_column_chars dcc ON (r.table_groups_id = dcc.table_groups_id + AND r.schema_name = dcc.schema_name AND r.table_name = dcc.table_name AND r.column_names = dcc.column_name) WHERE r.test_run_id = :RUN_ID diff --git a/testgen/ui/components/frontend/standalone/project_settings/index.js b/testgen/ui/components/frontend/standalone/project_settings/index.js index a151fecf..447641f8 100644 --- a/testgen/ui/components/frontend/standalone/project_settings/index.js +++ b/testgen/ui/components/frontend/standalone/project_settings/index.js @@ -65,9 +65,9 @@ const ProjectSettings = (props) => { }, }), Checkbox({ - label: 'Use weighted DQ scoring', + label: 'Use weighted data quality scoring', checked: form.use_dq_score_weights, - help: 'When enabled, DQ scores weight tables and columns by their semantic importance. Dimension tables and key columns receive higher weights.', + help: 'When enabled, data quality scores weight tables and columns by their semantic importance. Dimension tables and key columns receive higher weights.', onChange: (checked) => { form.use_dq_score_weights.val = checked; }, }), ), diff --git a/testgen/ui/views/project_settings.py b/testgen/ui/views/project_settings.py index b7a65c1c..8ed1a45e 100644 --- a/testgen/ui/views/project_settings.py +++ b/testgen/ui/views/project_settings.py @@ -84,6 +84,7 @@ def update_project(self, project_code: str, edited_project: dict) -> None: source="user", project_code=project_code, ) + st.toast("Scores will be recalculated in the background.") def test_observability_connection(self, project_code: str, edited_project: dict) -> "ObservabilityConnectionStatus": try: From 7a129a20187c373812810fdc511058e6985f5000 Mon Sep 17 00:00:00 2001 From: Luis Date: Wed, 29 Apr 2026 09:29:40 -0400 Subject: [PATCH 091/123] fix(ui): runs cache invalidation, toolbar stability, and related UI bugs - run_profiling_dialog / run_tests_dialog: always clear the runs-summary cache when a run starts successfully; previously skipped when also navigating to the runs page so the list could appear stale. - run_tests_dialog: narrow from st.cache_data.clear() to the targeted get_test_run_summaries.clear(). - profiling_runs / test_runs views: clear the runs-summary cache after a cancel request so the UI reflects the new state immediately. - table_group_list: mount Toolbar once into a stable container so debounced filter input is not dropped across reruns; track lastSent for filter comparison; make connections options reactive. - portal: add overflow-y: auto on bottom-positioned content so dropdowns scroll instead of overflowing the viewport. - profiling_runs / test_runs pages: fix select-all checkbox key (was item.job_execution_id; corrected to item.id / item.test_run_id to match the per-row selection map). --- .../frontend/js/pages/table_group_list.js | 121 ++++++++++++------ testgen/ui/static/js/components/portal.js | 2 +- .../ui/views/dialogs/run_profiling_dialog.py | 5 +- testgen/ui/views/dialogs/run_tests_dialog.py | 8 +- testgen/ui/views/profiling_runs.py | 3 +- testgen/ui/views/table_groups.py | 2 +- testgen/ui/views/test_runs.py | 1 + 7 files changed, 96 insertions(+), 46 deletions(-) diff --git a/testgen/ui/components/frontend/js/pages/table_group_list.js b/testgen/ui/components/frontend/js/pages/table_group_list.js index 89d4a419..e5c126ba 100644 --- a/testgen/ui/components/frontend/js/pages/table_group_list.js +++ b/testgen/ui/components/frontend/js/pages/table_group_list.js @@ -126,18 +126,41 @@ const TableGroupList = (props) => { } }); + // Toolbar must persist across reruns: filter inputs debounce on `oninput`, + // and recreating the input element while a debounce timer is pending drops + // the user's typed value (the timer commits to a discarded derive). We + // mount it once into a stable container and tear down only when the page + // transitions out of the populated state. + const toolbarContainer = div({ style: 'display: contents' }); + let toolbarMounted = false; + van.derive(() => { + const connections = getValue(props.connections) ?? []; + const projectSummary = getValue(props.project_summary); + const shouldShow = connections.length > 0 && (projectSummary?.table_group_count ?? 0) > 0; + if (shouldShow && !toolbarMounted) { + van.add(toolbarContainer, Toolbar( + getValue(props.permissions) ?? {can_edit: false}, + props.connections, + getValue(props.connection_id), + getValue(props.table_group_name), + emit, + )); + toolbarMounted = true; + } else if (!shouldShow && toolbarMounted) { + toolbarContainer.innerHTML = ''; + toolbarMounted = false; + } + }); + return div( { id: wrapperId, 'data-testid': 'table-group-list', class: 'tg-tablegroups' }, () => { const permissions = getValue(props.permissions) ?? {can_edit: false}; const connections = getValue(props.connections) ?? []; - const connectionId = getValue(props.connection_id); - const tableGroupNameFilter = getValue(props.table_group_name); - const tableGroups = getValue(props.table_groups) ?? []; const projectSummary = getValue(props.project_summary); if (connections.length <= 0) { - return EmptyState({ emit, + return EmptyState({ emit, icon: 'table_view', label: 'Your project is empty', message: EMPTY_STATE_MESSAGE.connection, @@ -150,13 +173,41 @@ const TableGroupList = (props) => { }); } - return projectSummary.table_group_count > 0 - ? div( - Toolbar(permissions, connections, connectionId, tableGroupNameFilter, emit), - tableGroups.length - ? div( - { class: 'flex-column fx-gap-4' }, - ...tableGroups.map((tableGroup) => Card({ + if ((projectSummary?.table_group_count ?? 0) <= 0) { + return EmptyState({ emit, + icon: 'table_view', + label: 'No table groups yet', + class: 'mt-4', + message: EMPTY_STATE_MESSAGE.tableGroup, + button: Button({ + type: 'stroked', + icon: 'add', + label: 'Add Table Group', + color: 'primary', + style: 'width: unset;', + disabled: !permissions.can_edit, + onclick: () => emit('AddTableGroupClicked', {}), + }), + }); + } + + return ''; + }, + toolbarContainer, + () => { + const connections = getValue(props.connections) ?? []; + const projectSummary = getValue(props.project_summary); + if (connections.length <= 0 || (projectSummary?.table_group_count ?? 0) <= 0) { + return ''; + } + + const permissions = getValue(props.permissions) ?? {can_edit: false}; + const tableGroups = getValue(props.table_groups) ?? []; + + return tableGroups.length + ? div( + { class: 'flex-column fx-gap-4' }, + ...tableGroups.map((tableGroup) => Card({ testId: 'table-group-card', class: '', title: div( @@ -268,27 +319,11 @@ const TableGroupList = (props) => { ) : undefined, })), - ) - : div( - { class: 'mt-7 text-secondary', style: 'text-align: center;' }, - 'No table groups found matching filters', - ), ) - : EmptyState({ emit, - icon: 'table_view', - label: 'No table groups yet', - class: 'mt-4', - message: EMPTY_STATE_MESSAGE.tableGroup, - button: Button({ - type: 'stroked', - icon: 'add', - label: 'Add Table Group', - color: 'primary', - style: 'width: unset;', - disabled: !permissions.can_edit, - onclick: () => emit('AddTableGroupClicked', {}), - }), - }); + : div( + { class: 'mt-7 text-secondary', style: 'text-align: center;' }, + 'No table groups found matching filters', + ); }, () => { const info = deleteDialogInfo.val; @@ -391,9 +426,19 @@ const Toolbar = (permissions, connections, selectedConnection, tableGroupNameFil const connection = van.state(selectedConnection || null); const tableGroupFilter = van.state(tableGroupNameFilter || null); + // Track last value sent to Streamlit so the comparison stays correct across + // reruns (Toolbar is now mounted once; captured initial values would go stale). + let lastSent = { + connection_id: selectedConnection || null, + table_group_name: tableGroupNameFilter || null, + }; van.derive(() => { - if (connection.val !== selectedConnection || tableGroupFilter.val !== tableGroupNameFilter) { - emit('TableGroupsFiltered', { payload: { connection_id: connection.val || null, table_group_name: tableGroupFilter.val || null } }); + const newConnection = connection.val || null; + const newFilter = tableGroupFilter.val || null; + if (newConnection !== lastSent.connection_id || newFilter !== lastSent.table_group_name) { + const payload = { connection_id: newConnection, table_group_name: newFilter }; + emit('TableGroupsFiltered', { payload }); + lastSent = payload; } }); @@ -401,16 +446,16 @@ const Toolbar = (permissions, connections, selectedConnection, tableGroupNameFil { class: 'flex-row fx-align-flex-end fx-justify-space-between fx-gap-4 fx-flex-wrap mb-4' }, div( {class: 'flex-row fx-align-flex-end fx-gap-3'}, - (getValue(connections) ?? [])?.length > 1 + () => (getValue(connections) ?? [])?.length > 1 ? Select({ testId: 'connection-select', label: 'Connection', allowNull: true, value: connection, - options: getValue(connections)?.map((connection) => ({ - label: connection.connection_name, - value: String(connection.connection_id), - })) ?? [], + options: (getValue(connections) ?? []).map((conn) => ({ + label: conn.connection_name, + value: String(conn.connection_id), + })), onChange: (value) => connection.val = value, }) : '', diff --git a/testgen/ui/static/js/components/portal.js b/testgen/ui/static/js/components/portal.js index 8cefe150..aca28080 100644 --- a/testgen/ui/static/js/components/portal.js +++ b/testgen/ui/static/js/components/portal.js @@ -151,7 +151,7 @@ function calculateBottomPosition(anchor, align, fixed = false) { const top = fixed ? r.bottom : r.bottom + window.scrollY; const left = fixed ? r.left : r.left + window.scrollX; const right = window.innerWidth - r.right; - const constrain = fixed ? `max-height: calc(100vh - ${r.bottom}px - 8px);` : ''; + const constrain = fixed ? `max-height: calc(100vh - ${r.bottom}px - 8px); overflow-y: auto;` : ''; return `min-width: ${r.width}px; top: ${top}px; ${constrain} ${align === 'left' ? `left: ${left}px;` : `right: ${right}px;`}`; } diff --git a/testgen/ui/views/dialogs/run_profiling_dialog.py b/testgen/ui/views/dialogs/run_profiling_dialog.py index ce4e488b..9dfbb3ff 100644 --- a/testgen/ui/views/dialogs/run_profiling_dialog.py +++ b/testgen/ui/views/dialogs/run_profiling_dialog.py @@ -40,9 +40,10 @@ def on_run_profiling_confirmed(table_group: dict) -> None: message = f"Profiling run could not be started: {error!s}." show_link = False st.session_state[RESULT_KEY] = {"success": success, "message": message, "show_link": show_link} - if success and not show_link: + if success: get_profiling_run_summaries.clear() - on_close() + if not show_link: + on_close() def on_go_to_profiling_runs_clicked(payload: dict) -> None: st.session_state.pop(RESULT_KEY, None) diff --git a/testgen/ui/views/dialogs/run_tests_dialog.py b/testgen/ui/views/dialogs/run_tests_dialog.py index 93a87dbe..29b224e9 100644 --- a/testgen/ui/views/dialogs/run_tests_dialog.py +++ b/testgen/ui/views/dialogs/run_tests_dialog.py @@ -5,6 +5,7 @@ from testgen.common.models.test_suite import TestSuite from testgen.ui.components import widgets as testgen from testgen.ui.navigation.router import Router +from testgen.ui.services.query_cache import get_test_run_summaries from testgen.ui.session import session LINK_HREF = "test-runs" @@ -41,9 +42,10 @@ def on_run_tests_confirmed(data: dict) -> None: message = f"Test run could not be started: {e!s}." show_link = False st.session_state[RESULT_KEY] = {"success": success, "message": message, "show_link": show_link} - if success and not show_link: - st.cache_data.clear() - on_close() + if success: + get_test_run_summaries.clear() + if not show_link: + on_close() def on_go_to_test_runs(payload: dict) -> None: st.session_state.pop(RESULT_KEY, None) diff --git a/testgen/ui/views/profiling_runs.py b/testgen/ui/views/profiling_runs.py index 9b54a288..e612d6e6 100644 --- a/testgen/ui/views/profiling_runs.py +++ b/testgen/ui/views/profiling_runs.py @@ -122,7 +122,7 @@ def on_run_profiling_confirmed(table_group: dict) -> None: message = f"Profiling run could not be started: {error!s}." show_link = False st.session_state[RUN_PROFILING_RESULT_KEY] = {"success": success, "message": message, "show_link": show_link} - if success and not show_link: + if success: get_profiling_run_summaries.clear() Router().set_query_params({"page": 1}) st.session_state.pop(RUN_PROFILING_DIALOG_KEY, None) @@ -285,6 +285,7 @@ def on_cancel_run(payload: dict) -> None: # Stopgap: also update the run status so the UI reflects cancellation immediately. if profiling_run_id := payload.get("profiling_run_id"): ProfilingRun.cancel_run(profiling_run_id) + get_profiling_run_summaries.clear() fm.reset_post_updates(str_message=":green[Cancellation requested.]", as_toast=True) else: fm.reset_post_updates(str_message=":red[This run cannot be canceled.]", as_toast=True) diff --git a/testgen/ui/views/table_groups.py b/testgen/ui/views/table_groups.py index 57eaa71e..92092f03 100644 --- a/testgen/ui/views/table_groups.py +++ b/testgen/ui/views/table_groups.py @@ -217,7 +217,7 @@ def on_get_cron_sample(payload): on_DeleteTableGroupConfirmed_change=self._execute_delete, on_DeleteDialogDismissed_change=lambda *_: st.session_state.pop("tg_delete_dialog", None), on_RunProfilingClicked_change=on_run_profiling_clicked, - on_TableGroupsFiltered_change=lambda params: self.router.queue_navigation( + on_TableGroupsFiltered_change=lambda params: params is not None and self.router.queue_navigation( to="table-groups", with_args={"project_code": project_code, **params}, ), diff --git a/testgen/ui/views/test_runs.py b/testgen/ui/views/test_runs.py index 5d7428f7..b53a0d48 100644 --- a/testgen/ui/views/test_runs.py +++ b/testgen/ui/views/test_runs.py @@ -297,6 +297,7 @@ def on_cancel_run(payload: dict) -> None: # Stopgap: also update the run status so the UI reflects cancellation immediately. if test_run_id := payload.get("test_run_id"): TestRun.cancel_run(test_run_id) + get_test_run_summaries.clear() fm.reset_post_updates(str_message=":green[Cancellation requested.]", as_toast=True) else: fm.reset_post_updates(str_message=":red[This run cannot be canceled.]", as_toast=True) From 798b6d3cdda2e0bbd14ebba995e7cae4aa7d16e5 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Thu, 30 Apr 2026 11:26:59 -0400 Subject: [PATCH 092/123] fix(mcp): tighten get_failure_summary scope validation (TG-1058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject `project_code` alone — the model requires a row-scope (`test_run_id` / `test_suite_id` / `since`), so a `project_code`-only call hit a ValueError that the MCP error handler masked as the generic "An unexpected error occurred." - Reject `group_by` of `table` or `column` without a single-suite scope. Cross-suite aggregation would collapse rows from different physical tables whose names happen to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- testgen/mcp/tools/test_results.py | 20 ++++++--- tests/unit/mcp/test_tools_test_results.py | 55 ++++++++++++++++++++++- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/testgen/mcp/tools/test_results.py b/testgen/mcp/tools/test_results.py index f8355ba8..25804275 100644 --- a/testgen/mcp/tools/test_results.py +++ b/testgen/mcp/tools/test_results.py @@ -140,9 +140,13 @@ def get_failure_summary( ) -> str: """Summarize test failures (Failed and Warning) grouped by test type, table, or column. - Supply a ``job_execution_id`` for a single-run summary, or combine ``project_code``, - ``test_suite_id``, and ``since`` to aggregate across multiple runs. At least one - scope parameter is required. + Supply a ``job_execution_id`` for a single-run summary. Alternatively, provide + ``test_suite_id`` or ``project_code`` to aggregate across multiple runs. Use + ``since`` to narrow the results by recency (required when ``test_suite_id`` is + not provided). + + Table- and column-grouped summaries require a single-suite scope + (``job_execution_id`` or ``test_suite_id``). Args: project_code: Scope to a project the caller can view. Ignored if ``job_execution_id`` is set. @@ -154,9 +158,15 @@ def get_failure_summary( """ perms = get_project_permissions() - if not any((job_execution_id, project_code, test_suite_id, since)): + if not any((job_execution_id, test_suite_id, since)): + raise MCPUserError( + "Provide 'job_execution_id' for a single run, or 'test_suite_id' or 'project_code' " + "to aggregate across runs. 'since' is required when 'test_suite_id' is not provided." + ) + if group_by in ("table", "column") and not (job_execution_id or test_suite_id): raise MCPUserError( - "Provide at least one of 'job_execution_id', 'project_code', 'test_suite_id', or 'since' to scope the summary." + f"'{group_by}' grouping requires a single-suite scope. " + "Provide 'job_execution_id' or 'test_suite_id'." ) model_group_map = {"table": "table_name", "column": "column_names"} diff --git a/tests/unit/mcp/test_tools_test_results.py b/tests/unit/mcp/test_tools_test_results.py index 8e234271..78b1562b 100644 --- a/tests/unit/mcp/test_tools_test_results.py +++ b/tests/unit/mcp/test_tools_test_results.py @@ -568,10 +568,37 @@ def test_get_test_result_history_passes_project_codes( def test_get_failure_summary_requires_some_scope(db_session_mock): from testgen.mcp.tools.test_results import get_failure_summary - with pytest.raises(MCPUserError, match="at least one of"): + with pytest.raises(MCPUserError, match="single run"): get_failure_summary() +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_get_failure_summary_rejects_project_code_alone(mock_compute, db_session_mock): + mock_compute.return_value = ProjectPermissions( + memberships={"proj_a": "role_a"}, + permission="view", + ) + + from testgen.mcp.tools.test_results import get_failure_summary + + with pytest.raises(MCPUserError, match="'since' is required"): + get_failure_summary(project_code="proj_a") + + +@pytest.mark.parametrize("group_by", ["table", "column"]) +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_get_failure_summary_rejects_cross_suite_table_or_column_grouping(mock_compute, db_session_mock, group_by): + mock_compute.return_value = ProjectPermissions( + memberships={"proj_a": "role_a"}, + permission="view", + ) + + from testgen.mcp.tools.test_results import get_failure_summary + + with pytest.raises(MCPUserError, match="single-suite scope"): + get_failure_summary(project_code="proj_a", since="7 days", group_by=group_by) + + @patch("testgen.mcp.tools.test_results.TestResult") @patch("testgen.mcp.permissions._compute_project_permissions") def test_get_failure_summary_cross_run_by_project(mock_compute, mock_result, db_session_mock): @@ -591,6 +618,30 @@ def test_get_failure_summary_cross_run_by_project(mock_compute, mock_result, db_ assert call_kwargs["since"] is not None +@patch("testgen.mcp.tools.test_results.TestSuite") +@patch("testgen.mcp.tools.test_results.TestResult") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_get_failure_summary_cross_run_by_project_and_suite( + mock_compute, mock_result, mock_suite_cls, db_session_mock +): + mock_compute.return_value = ProjectPermissions( + memberships={"proj_a": "role_a"}, + permission="view", + ) + mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="proj_a") + mock_result.select_failures.return_value = [] + + from testgen.mcp.tools.test_results import get_failure_summary + + get_failure_summary(project_code="proj_a", test_suite_id=str(uuid4())) + + call_kwargs = mock_result.select_failures.call_args.kwargs + assert call_kwargs["project_codes"] == ["proj_a"] + assert call_kwargs["test_suite_id"] is not None + assert call_kwargs["test_run_id"] is None + assert call_kwargs["since"] is None + + @patch("testgen.mcp.permissions._compute_project_permissions") def test_get_failure_summary_rejects_inaccessible_project(mock_compute, db_session_mock): mock_compute.return_value = ProjectPermissions( @@ -601,7 +652,7 @@ def test_get_failure_summary_rejects_inaccessible_project(mock_compute, db_sessi from testgen.mcp.tools.test_results import get_failure_summary with pytest.raises(MCPResourceNotAccessible, match="Project .* not found or not accessible"): - get_failure_summary(project_code="proj_b") + get_failure_summary(project_code="proj_b", since="7 days") @patch("testgen.mcp.tools.test_results.TestSuite") From c7245a67badc0c920f6dc5a64e396f1cada51b19 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Wed, 1 Apr 2026 01:04:06 -0400 Subject: [PATCH 093/123] ci: upgrade github chart releaser --- .github/actions/publish_charts/action.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/publish_charts/action.yaml b/.github/actions/publish_charts/action.yaml index 6edbdc48..27d67a4b 100644 --- a/.github/actions/publish_charts/action.yaml +++ b/.github/actions/publish_charts/action.yaml @@ -22,7 +22,7 @@ runs: helm repo add bitnami https://charts.bitnami.com/bitnami - name: Run chart-releaser - uses: helm/chart-releaser-action@v1.6.0 + uses: helm/chart-releaser-action@v1.7.0 with: charts_dir: deploy/charts skip_existing: 'true' From 491d4489557316ed3985f1b68cef5fa92ca80d09 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Wed, 1 Apr 2026 01:04:25 -0400 Subject: [PATCH 094/123] fix(snowflake): make key-pair default - add deprecation warning for password --- .../static/js/components/connection_form.js | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/testgen/ui/static/js/components/connection_form.js b/testgen/ui/static/js/components/connection_form.js index f5b5247f..4c5ec91c 100644 --- a/testgen/ui/static/js/components/connection_form.js +++ b/testgen/ui/static/js/components/connection_form.js @@ -1049,7 +1049,7 @@ const SnowflakeForm = ( const isValid = van.state(false); const clearPrivateKeyPhrase = van.state(connection.rawVal?.private_key_passphrase === clearSentinel); const connectByUrl = van.state(connection.rawVal.connect_by_url ?? false); - const connectByKey = van.state(connection.rawVal?.connect_by_key ?? false); + const connectByKey = van.state(originalConnection?.connection_id ? (connection.rawVal?.connect_by_key ?? false) : true); const connectionHost = van.state(connection.rawVal.project_host ?? ''); const connectionPort = van.state(connection.rawVal.project_port || defaultPorts[flavor.flavor]); const connectionDatabase = van.state(connection.rawVal.project_db ?? ''); @@ -1206,14 +1206,33 @@ const SnowflakeForm = ( RadioGroup({ label: 'Connection Strategy', options: [ - {label: 'Connect By Password', value: false}, {label: 'Connect By Key-Pair', value: true}, + {label: 'Connect By Password', value: false}, ], value: connectByKey, onChange: (value) => connectByKey.val = value, layout: 'inline', }), + () => !connectByKey.val + ? Alert( + { type: 'warn', icon: 'warning', class: 'mt-1' }, + span( + 'Snowflake is phasing out password authentication for service accounts and will block it between August and October 2026. ', + 'Use key-pair authentication to ensure uninterrupted access. ', + van.tags.a( + { + href: 'https://docs.snowflake.com/en/user-guide/security-mfa-rollout', + target: '_blank', + rel: 'noopener noreferrer', + style: 'color: inherit; text-decoration: underline;', + }, + 'Learn more', + ), + ), + ) + : '', + Input({ name: 'db_user', label: 'Username', From 90b0db1c5918653f7da49575c0f67c3a1ae1f29f Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Thu, 30 Apr 2026 13:40:17 -0400 Subject: [PATCH 095/123] fix: display loading spinner for table group preview --- testgen/ui/static/js/components/button.js | 19 +------- testgen/ui/static/js/components/spinner.js | 43 +++++++++++++++++++ .../js/components/table_group_edit_dialog.js | 2 +- .../static/js/components/table_group_test.js | 33 +++++++------- .../js/components/table_group_wizard.js | 2 +- 5 files changed, 64 insertions(+), 35 deletions(-) create mode 100644 testgen/ui/static/js/components/spinner.js diff --git a/testgen/ui/static/js/components/button.js b/testgen/ui/static/js/components/button.js index bafb654a..e839fc88 100644 --- a/testgen/ui/static/js/components/button.js +++ b/testgen/ui/static/js/components/button.js @@ -20,6 +20,7 @@ import { getValue, loadStylesheet } from '../utils.js'; import van from '../van.min.js'; import { withTooltip } from './tooltip.js'; +import { Spinner } from './spinner.js'; const { button, i, span } = van.tags; const BUTTON_TYPE = { @@ -56,7 +57,7 @@ const Button = (/** @type Properties */ props) => { style: () => `font-size: ${getValue(props.iconSize) ?? DEFAULT_ICON_SIZE}px;` }, props.icon) : undefined, !isIconOnly ? span(props.label) : undefined, - () => getValue(props.loading) ? span({ class: 'tg-button-spinner' }) : '', + () => getValue(props.loading) ? Spinner({ classes: 'ml-2' }) : '', ), { text: props.tooltip, position: props.tooltipPosition }, ); }; @@ -198,22 +199,6 @@ button.tg-button.tg-warn-button.tg-stroked-button { background: var(--button-warn-stroked-background); } /* ... */ - -/* Loading spinner */ -.tg-button-spinner { - width: 16px; - height: 16px; - border: 2px solid transparent; - border-top-color: currentColor; - border-radius: 50%; - animation: tg-spin 0.6s linear infinite; - margin-left: 8px; - flex-shrink: 0; -} - -@keyframes tg-spin { - to { transform: rotate(360deg); } -} `); export { Button }; diff --git a/testgen/ui/static/js/components/spinner.js b/testgen/ui/static/js/components/spinner.js new file mode 100644 index 00000000..3c62ab9c --- /dev/null +++ b/testgen/ui/static/js/components/spinner.js @@ -0,0 +1,43 @@ +/** + * @typedef Properties + * @type {object} + * @property {number?} size + * @property {string?} classes + */ +import { getValue, loadStylesheet } from '../utils.js'; +import van from '../van.min.js'; + +const { span } = van.tags; +const DEFAULT_SIZE = 16; + +const Spinner = (/** @type Properties */ props = {}) => { + loadStylesheet('spinner', stylesheet); + + return span({ + class: () => `tg-spinner ${getValue(props.classes) ?? ''}`, + style: () => { + const size = getValue(props.size) || DEFAULT_SIZE; + return `width: ${size}px; height: ${size}px;`; + }, + role: 'status', + 'aria-label': 'Loading', + }); +}; + +const stylesheet = new CSSStyleSheet(); +stylesheet.replace(` +.tg-spinner { + display: inline-block; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: tg-spin 0.6s linear infinite; + flex-shrink: 0; +} + +@keyframes tg-spin { + to { transform: rotate(360deg); } +} +`); + +export { Spinner }; diff --git a/testgen/ui/static/js/components/table_group_edit_dialog.js b/testgen/ui/static/js/components/table_group_edit_dialog.js index 032f926e..a5af0c5d 100644 --- a/testgen/ui/static/js/components/table_group_edit_dialog.js +++ b/testgen/ui/static/js/components/table_group_edit_dialog.js @@ -104,7 +104,7 @@ const TableGroupEditDialog = (props) => { div( { style: () => phase.val === 'verify' ? '' : 'display:none' }, TableGroupTest(tableGroupPreview, { - onVerifyAcess: () => { + onVerifyAccess: () => { emit('PreviewEditTableGroupClicked', { payload: { table_group: tableGroupState.val, diff --git a/testgen/ui/static/js/components/table_group_test.js b/testgen/ui/static/js/components/table_group_test.js index 3915368c..00f875f9 100644 --- a/testgen/ui/static/js/components/table_group_test.js +++ b/testgen/ui/static/js/components/table_group_test.js @@ -17,7 +17,7 @@ * * @typedef ComponentOptions * @type {object} - * @property {(() => void)?} onVerifyAcess + * @property {(() => void)?} onVerifyAccess */ import van from '../van.min.js'; import { getValue } from '../utils.js'; @@ -25,6 +25,7 @@ import { formatNumber } from '../display_utils.js'; import { Alert } from '../components/alert.js'; import { Icon } from '../components/icon.js'; import { Button } from '../components/button.js'; +import { Spinner } from '../components/spinner.js'; import { TableGroupStats } from './table_group_stats.js'; const { div, span } = van.tags; @@ -45,28 +46,28 @@ const TableGroupTest = (preview, options) => { return div( { class: 'flex-column fx-gap-2' }, - div( - { class: 'flex-row fx-justify-space-between fx-align-flex-end' }, - span({ class: 'text-caption text-right' }, '* Approximate row counts based on server statistics'), - () => { - const p = getValue(preview); - if (!options.onVerifyAcess || !p) return ''; - return div( - { class: 'flex-row' }, - span({ class: 'fx-flex' }), - Button({ + () => getValue(preview) + ? div( + { class: 'flex-row fx-justify-space-between fx-align-flex-end' }, + span({ class: 'text-caption text-right' }, '* Approximate row counts based on server statistics'), + options.onVerifyAccess + ? Button({ label: 'Verify Access', width: 'fit-content', type: 'stroked', loading: verifyingAccess, onclick: () => { verifyingAccess.val = true; - options.onVerifyAcess(); + options.onVerifyAccess(); }, - }), - ); - }, - ), + }) + : '', + ) + : div( + { class: 'flex-row fx-justify-center fx-align-center fx-gap-2 p-3 text-secondary' }, + Spinner({ size: 20 }), + span('Loading preview...'), + ), () => getValue(preview) ? TableGroupStats({ hideWarning: true, hideApproxCaption: true }, getValue(preview).stats) : '', diff --git a/testgen/ui/static/js/components/table_group_wizard.js b/testgen/ui/static/js/components/table_group_wizard.js index bee588b6..007e629b 100644 --- a/testgen/ui/static/js/components/table_group_wizard.js +++ b/testgen/ui/static/js/components/table_group_wizard.js @@ -217,7 +217,7 @@ const TableGroupWizard = (props) => { return TableGroupTest( tableGroupPreview, { - onVerifyAcess: () => { + onVerifyAccess: () => { emit('PreviewTableGroupClicked', { payload: { table_group: stepsState.tableGroup.rawVal, From 9846922fc5e3de273bb620e33f2ad9f922ec3def Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Thu, 30 Apr 2026 13:54:18 -0400 Subject: [PATCH 096/123] fix(profiling-run): don't display empty sections in email notifications --- testgen/common/notifications/profiling_run.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/testgen/common/notifications/profiling_run.py b/testgen/common/notifications/profiling_run.py index 03a125a8..2a4c254c 100644 --- a/testgen/common/notifications/profiling_run.py +++ b/testgen/common/notifications/profiling_run.py @@ -152,7 +152,7 @@ def get_main_content_template(self): def get_result_table_template(self): return """ - {{#if count.total}} + {{#if (len issues)}}
{{label}} - {{#if (len issues)}} @@ -203,7 +202,6 @@ def get_result_table_template(self): indicates new issues - {{/if}}
Table
{{/if}} From 5ef81866b2723b50774130a22dc88e89b1bbb742 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Thu, 30 Apr 2026 16:28:47 -0400 Subject: [PATCH 097/123] fix: make text consistent for Add/Save --- testgen/ui/components/frontend/js/pages/test_definitions.js | 2 +- testgen/ui/static/js/components/table_group_wizard.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/testgen/ui/components/frontend/js/pages/test_definitions.js b/testgen/ui/components/frontend/js/pages/test_definitions.js index cd9451d6..de1858b7 100644 --- a/testgen/ui/components/frontend/js/pages/test_definitions.js +++ b/testgen/ui/components/frontend/js/pages/test_definitions.js @@ -1270,7 +1270,7 @@ const TestDefFormContent = ({ formValues, tableColumns, testSuite, validateResul Button({ type: 'flat', color: 'primary', - label: 'Save', + label: mode === 'edit' ? 'Save' : 'Add', width: 'auto', onclick: onSave, }), diff --git a/testgen/ui/static/js/components/table_group_wizard.js b/testgen/ui/static/js/components/table_group_wizard.js index 007e629b..05873061 100644 --- a/testgen/ui/static/js/components/table_group_wizard.js +++ b/testgen/ui/static/js/components/table_group_wizard.js @@ -49,7 +49,7 @@ import { WizardProgressIndicator } from './wizard_progress_indicator.js'; const { div, span, strong } = van.tags; const lastStepCustomButtonText = { - monitorSuite: (_, states) => states?.runProfiling?.val === true ? 'Save & Run' : 'Save', + monitorSuite: (_, states) => states?.runProfiling?.val === true ? 'Finish Setup' : 'Add', }; const defaultSteps = [ 'tableGroup', From 842c9e2a9e1b6dd6149055fd294ded5ef67eddd1 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Thu, 30 Apr 2026 16:31:55 -0400 Subject: [PATCH 098/123] fix: hide SAP Hana option for Docker deployment --- testgen/ui/views/connections.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/testgen/ui/views/connections.py b/testgen/ui/views/connections.py index a9221e52..dcb5514a 100644 --- a/testgen/ui/views/connections.py +++ b/testgen/ui/views/connections.py @@ -15,6 +15,7 @@ PyODBCError = None from sqlalchemy.exc import DatabaseError, DBAPIError +from testgen import settings import testgen.ui.services.database_service as db from testgen.common.database.database_service import empty_cache, get_flavor_service from testgen.common.database.flavor.flavor_service import resolve_connection_params @@ -208,7 +209,7 @@ def on_setup_table_group_clicked(*_args) -> None: "project_code": project_code, "connection": self._format_connection(connection, should_test=should_check_status()), "has_table_groups": has_table_groups, - "flavors": [asdict(flavor) for flavor in FLAVOR_OPTIONS], + "flavors": [asdict(flavor) for flavor in VISIBLE_FLAVOR_OPTIONS], "permissions": { "is_admin": user_is_admin, }, @@ -645,3 +646,9 @@ class ConnectionFlavor: icon=get_asset_data_url("flavors/snowflake.svg"), ), ] + +# SAP HANA is hidden in the Docker image because pyhdbcli is glibc-only and fails to load on Alpine/musl. +VISIBLE_FLAVOR_OPTIONS = [ + f for f in FLAVOR_OPTIONS + if not (settings.CHECK_FOR_LATEST_VERSION == "docker" and f.value == "sap_hana") +] From 5f1b2298cd757bda10a57b79e4e48dbb5158279e Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Thu, 30 Apr 2026 16:48:34 -0400 Subject: [PATCH 099/123] fix(app-logs): filter by text not working --- .../ui/components/frontend/js/shared/application_logs_dialog.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testgen/ui/components/frontend/js/shared/application_logs_dialog.js b/testgen/ui/components/frontend/js/shared/application_logs_dialog.js index b353dafd..30b4e0f1 100644 --- a/testgen/ui/components/frontend/js/shared/application_logs_dialog.js +++ b/testgen/ui/components/frontend/js/shared/application_logs_dialog.js @@ -85,7 +85,7 @@ const ApplicationLogsDialog = (props) => { Input({ label: 'Filter by Text', value: filterText, - oninput: (e) => { filterText.val = e.target.value; }, + onChange: (value) => { filterText.val = value; }, }), ), div( From a6e01b927d166729db7e6f0740197516fce32dc0 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Thu, 30 Apr 2026 16:57:55 -0400 Subject: [PATCH 100/123] fix(synapse): disable pre-connection queries --- testgen/common/database/flavor/mssql_flavor_service.py | 8 +++++++- testgen/ui/views/connections.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/testgen/common/database/flavor/mssql_flavor_service.py b/testgen/common/database/flavor/mssql_flavor_service.py index b673943c..7f248e43 100644 --- a/testgen/common/database/flavor/mssql_flavor_service.py +++ b/testgen/common/database/flavor/mssql_flavor_service.py @@ -35,7 +35,13 @@ def get_connection_string_from_fields(self, params: ResolvedConnectionParams) -> return connection_url.render_as_string(hide_password=False) - def get_pre_connection_queries(self, params: ResolvedConnectionParams) -> list[tuple[str, dict | None]]: # noqa: ARG002 + def get_pre_connection_queries(self, params: ResolvedConnectionParams) -> list[tuple[str, dict | None]]: + # Synapse dedicated SQL pool rejects these SET commands: ANSI_DEFAULTS isn't + # implemented, ANSI_WARNINGS can't be turned off, and only READ UNCOMMITTED + # isolation is allowed (and is the default). Each one would log a warning. + if params.sql_flavor_code == "synapse_mssql": + return [] + # ANSI_DEFAULTS turns on ANSI_NULLS / ANSI_PADDING / QUOTED_IDENTIFIER (good) # *and* ANSI_WARNINGS (bad here). pyodbc>=5.2 escalates SQL Server's 01003 # "Null value is eliminated by an aggregate" warning into a pyodbc.Error, diff --git a/testgen/ui/views/connections.py b/testgen/ui/views/connections.py index dcb5514a..35fa58cf 100644 --- a/testgen/ui/views/connections.py +++ b/testgen/ui/views/connections.py @@ -15,8 +15,8 @@ PyODBCError = None from sqlalchemy.exc import DatabaseError, DBAPIError -from testgen import settings import testgen.ui.services.database_service as db +from testgen import settings from testgen.common.database.database_service import empty_cache, get_flavor_service from testgen.common.database.flavor.flavor_service import resolve_connection_params from testgen.common.models import get_current_session, with_database_session From 6a80d6ad4cd0adb215b2a8f328daea08956b57f2 Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 30 Apr 2026 20:56:13 -0400 Subject: [PATCH 101/123] fix(ui): visual glitches and inconsistencies --- .../frontend/js/pages/profiling_results.js | 8 ++++- .../js/components/monitor_settings_form.js | 12 +++++-- testgen/ui/views/hygiene_issues.py | 13 +++++--- testgen/ui/views/profiling_results.py | 33 ++++++++++++++++++- testgen/ui/views/table_groups.py | 4 +-- 5 files changed, 60 insertions(+), 10 deletions(-) diff --git a/testgen/ui/components/frontend/js/pages/profiling_results.js b/testgen/ui/components/frontend/js/pages/profiling_results.js index 7b6713c9..10adb4eb 100644 --- a/testgen/ui/components/frontend/js/pages/profiling_results.js +++ b/testgen/ui/components/frontend/js/pages/profiling_results.js @@ -53,6 +53,7 @@ import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js import { DataCharacteristicsCard } from '../data_profiling/data_characteristics.js'; import { ColumnDistributionCard } from '../data_profiling/column_distribution.js'; import { HygieneIssuesCard } from '../data_profiling/data_issues.js'; +import { DataPreviewDialog } from '../shared/data_preview_dialog.js'; const { div, span, h2 } = van.tags; @@ -287,7 +288,7 @@ const ProfilingResults = (/** @type Properties */ props) => { ), ), DataCharacteristicsCard({ emit, border: true }, selectedRow.val), - ColumnDistributionCard({ emit, border: true, dataPreview: false }, selectedRow.val), + ColumnDistributionCard({ emit, border: true, dataPreview: true }, selectedRow.val), () => { const si = selectedItemData.val; if (!si || si.id !== selectedRowId.rawVal) return ''; @@ -297,6 +298,11 @@ const ProfilingResults = (/** @type Properties */ props) => { ) : '', ), + DataPreviewDialog({ + emit, + previewData: props.data_preview_dialog, + onClose: () => emit('DataPreviewDialogClosed', {}), + }), ); }; diff --git a/testgen/ui/static/js/components/monitor_settings_form.js b/testgen/ui/static/js/components/monitor_settings_form.js index 8c8da4d8..c7994afe 100644 --- a/testgen/ui/static/js/components/monitor_settings_form.js +++ b/testgen/ui/static/js/components/monitor_settings_form.js @@ -81,6 +81,7 @@ const MonitorSettingsForm = (props) => { const predictMinLookback = van.state(monitorSuite.predict_min_lookback ?? predictLookbackConfig.default); const predictExcludeWeekends = van.state(monitorSuite.predict_exclude_weekends ?? false); const predictHolidayCodes = van.state(monitorSuite.predict_holiday_codes); + const excludeHolidays = van.state(!!monitorSuite.predict_holiday_codes); const updatedSchedule = van.derive(() => { return { @@ -99,7 +100,7 @@ const MonitorSettingsForm = (props) => { predict_sensitivity: predictSensitivity.val, predict_min_lookback: predictMinLookback.val, predict_exclude_weekends: predictExcludeWeekends.val, - predict_holiday_codes: predictHolidayCodes.val, + predict_holiday_codes: excludeHolidays.val ? predictHolidayCodes.val : null, }; }); @@ -117,6 +118,12 @@ const MonitorSettingsForm = (props) => { validityPerField.val = {...validityPerField.rawVal, [field]: validity}; } + van.derive(() => { + if (!excludeHolidays.val) { + setFieldValidity('predict_holiday_codes', true); + } + }); + return div( { class: 'flex-column fx-gap-4' }, MainForm( @@ -143,6 +150,7 @@ const MonitorSettingsForm = (props) => { predictMinLookback, predictExcludeWeekends, predictHolidayCodes, + excludeHolidays, emit, ), ); @@ -279,9 +287,9 @@ const PredictionForm = ( predictMinLookback, predictExcludeWeekends, predictHolidayCodes, + excludeHolidays, emit, ) => { - const excludeHolidays = van.state(!!predictHolidayCodes.val); return div( { class: 'flex-column fx-gap-4 border border-radius-1 p-3', style: 'position: relative;' }, Caption({content: 'Prediction Model', style: 'position: absolute; top: -10px; background: var(--app-background-color); padding: 0px 8px;' }), diff --git a/testgen/ui/views/hygiene_issues.py b/testgen/ui/views/hygiene_issues.py index af28aeac..2fadc4a1 100644 --- a/testgen/ui/views/hygiene_issues.py +++ b/testgen/ui/views/hygiene_issues.py @@ -204,14 +204,20 @@ def render( def on_row_selected(item_id: str) -> None: Router().set_query_params({"selected": item_id}) + def _clear_disposition_caches() -> None: + _get_anomaly_disposition.clear() + _get_profiling_anomaly_summary.clear() + profiling_queries.get_profiling_anomalies.clear() + profiling_queries.get_profiling_anomalies_count.clear() + profiling_queries.get_profiling_anomaly_ids.clear() + @with_database_session def on_disposition_changed(payload: dict) -> None: ids = payload.get("ids", []) status = payload.get("status", "No Decision") if ids: _update_anomaly_disposition(ids, status) - _get_anomaly_disposition.clear() - _get_profiling_anomaly_summary.clear() + _clear_disposition_caches() @with_database_session def on_disposition_all(payload: dict) -> None: @@ -228,8 +234,7 @@ def on_disposition_all(payload: dict) -> None: ) if all_ids: _update_anomaly_disposition(all_ids, disposition) - _get_anomaly_disposition.clear() - _get_profiling_anomaly_summary.clear() + _clear_disposition_caches() def on_filter_changed(payload: dict) -> None: Router().set_query_params({ diff --git a/testgen/ui/views/profiling_results.py b/testgen/ui/views/profiling_results.py index 859f5ec6..83608a12 100644 --- a/testgen/ui/views/profiling_results.py +++ b/testgen/ui/views/profiling_results.py @@ -9,7 +9,13 @@ from testgen.common.date_service import parse_fuzzy_date from testgen.common.models import with_database_session from testgen.common.models.profiling_run import ProfilingRun -from testgen.common.pii_masking import PII_REDACTED, get_pii_columns, mask_hygiene_detail, mask_profiling_pii +from testgen.common.pii_masking import ( + PII_REDACTED, + get_pii_columns, + mask_hygiene_detail, + mask_profiling_pii, + mask_source_data_pii, +) from testgen.ui.components import widgets as testgen from testgen.ui.components.widgets.download_dialog import ( FILE_DATA_TYPE, @@ -20,11 +26,14 @@ from testgen.ui.navigation.page import Page from testgen.ui.navigation.router import Router from testgen.ui.session import session +from testgen.ui.views.data_catalog import get_preview_data +from testgen.utils import make_json_safe PAGE_SIZE = 500 SELECTED_ITEM_KEY = "prf:selected_item" EXPORT_FILTERS_KEY = "prf:export_filters" +DATA_PREVIEW_DIALOG_KEY = "prf:data_preview_dialog" # Maps JS column names to SQL ORDER BY expressions SORT_FIELD_MAP = { @@ -231,6 +240,25 @@ def on_sort_changed(payload: dict) -> None: st.session_state.pop(SELECTED_ITEM_KEY, None) Router().set_query_params({"sort": sort_value, "page": "0", "selected": None}) + @with_database_session + def on_data_preview_clicked(item: dict) -> None: + preview_data = get_preview_data( + item["table_group_id"], + item["schema_name"], + item["table_name"], + item.get("column_name"), + ) + if preview_data.get("rows") and not session.auth.user_has_permission("view_pii"): + pii_columns = get_pii_columns(item["table_group_id"], item["schema_name"], item["table_name"]) + if pii_columns: + preview_df = pd.DataFrame(preview_data["rows"], columns=preview_data["columns"]) + mask_source_data_pii(preview_df, pii_columns) + preview_data["rows"] = make_json_safe(preview_df.values.tolist()) + st.session_state[DATA_PREVIEW_DIALOG_KEY] = preview_data + + def on_data_preview_dialog_closed(*_) -> None: + st.session_state.pop(DATA_PREVIEW_DIALOG_KEY, None) + testgen.profiling_results_widget( key="profiling_results", data={ @@ -245,6 +273,7 @@ def on_sort_changed(payload: dict) -> None: "page_size": current_page_size, "sort_state": sort_state, "filter_options": filter_options, + "data_preview_dialog": st.session_state.get(DATA_PREVIEW_DIALOG_KEY), }, on_RowSelected_change=on_row_selected, on_FilterChanged_change=on_filter_changed, @@ -253,6 +282,8 @@ def on_sort_changed(payload: dict) -> None: on_ExportSelected_change=on_export_selected, on_PageChanged_change=on_page_changed, on_SortChanged_change=on_sort_changed, + on_DataPreviewClicked_change=on_data_preview_clicked, + on_DataPreviewDialogClosed_change=on_data_preview_dialog_closed, ) diff --git a/testgen/ui/views/table_groups.py b/testgen/ui/views/table_groups.py index 92092f03..ef93062c 100644 --- a/testgen/ui/views/table_groups.py +++ b/testgen/ui/views/table_groups.py @@ -22,6 +22,7 @@ from testgen.ui.navigation.router import Router from testgen.ui.queries import table_group_queries from testgen.ui.services.query_cache import get_profiling_run_summaries, get_project_summary, get_table_group_stats +from testgen.ui.services.rerun_service import safe_rerun from testgen.ui.session import session, temp_value from testgen.ui.utils import get_cron_sample_handler from testgen.ui.views.connections import FLAVOR_OPTIONS, format_connection @@ -568,11 +569,10 @@ def on_close_edit(_params: dict) -> None: save_data_chars(table_group.id) except Exception: LOG.exception("Data characteristics refresh encountered errors") - TableGroup.select_minimal_where.clear() st.toast(f"Table group '{table_group.table_groups_name}' saved.", icon=":material/check:") for key in ["tg_wizard_mode", "tg_wizard_table_group_id"]: st.session_state.pop(key, None) - return None, {} + safe_rerun() else: result = {"success": False, "message": "Verify the table group before saving."} From a93fc1ac96b918430d7b7fe1a98368e7a44de259 Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Thu, 30 Apr 2026 21:36:24 -0400 Subject: [PATCH 102/123] fix: input-validation cleanup across MCP tools and API list_jobs QA found three bugs that surfaced as either generic 500s or silently wrong responses. All three share an "input flowed into a layer that couldn't validate it" shape: - MCP paginated tools accepted any page/limit and let Postgres reject negative offsets, surfacing as "An unexpected error occurred." Adds validate_page / validate_limit helpers and wires them into all paginated tools. The source data tools previously silent-clamped limit; now they reject for consistency. - get_source_data showed "PII columns have been redacted." even when no PII column was in the result. Now plumbed through SourceDataResult as pii_redacted: bool, set by the masking helper based on actual rewrites. - API list_jobs accepted ?status=BOGUS as a plain str and returned an empty page. Now typed as JobStatus enum so unknown values return 422. Help text standardized on "Maximum number of per page (default N, max M)." for the page-based tools. Co-Authored-By: Claude Opus 4.7 (1M context) --- testgen/api/jobs.py | 4 +- testgen/common/pii_masking.py | 12 ++++-- testgen/common/source_data_service.py | 34 +++++++++------- testgen/mcp/tools/common.py | 10 +++++ testgen/mcp/tools/discovery.py | 6 ++- testgen/mcp/tools/source_data.py | 8 ++-- testgen/mcp/tools/test_definitions.py | 13 +++++- testgen/mcp/tools/test_results.py | 15 +++++-- testgen/mcp/tools/test_runs.py | 4 +- tests/unit/api/test_jobs.py | 44 ++++++++++++++++++++- tests/unit/common/test_pii_masking.py | 18 ++++++++- tests/unit/mcp/test_tools_common.py | 35 ++++++++++++++++- tests/unit/mcp/test_tools_source_data.py | 50 ++++++++++++++++++------ 13 files changed, 208 insertions(+), 45 deletions(-) diff --git a/testgen/api/jobs.py b/testgen/api/jobs.py index c0a52dc3..7131fdf1 100644 --- a/testgen/api/jobs.py +++ b/testgen/api/jobs.py @@ -11,7 +11,7 @@ resolve_test_suite, ) from testgen.api.schemas import ErrorResponse, JobKey, JobListResponse, JobResponse, JobSource, JobSubmittedResponse -from testgen.common.models.job_execution import JobExecution +from testgen.common.models.job_execution import JobExecution, JobStatus from testgen.common.models.table_group import TableGroup from testgen.common.models.test_suite import TestSuite @@ -98,7 +98,7 @@ def cancel_job(job: JobExecution = resolve_job("edit")): # noqa: B008 def list_jobs( project_code: str = resolve_project_code("view"), job_key: JobKey | None = Query(default=None), # noqa: B008 - status: str | None = Query(default=None), + status: JobStatus | None = Query(default=None), # noqa: B008 page: int = Query(default=1, ge=1), limit: int = Query(default=20, ge=1, le=100), ): diff --git a/testgen/common/pii_masking.py b/testgen/common/pii_masking.py index 04a6a2ee..9dc0419f 100644 --- a/testgen/common/pii_masking.py +++ b/testgen/common/pii_masking.py @@ -35,15 +35,21 @@ def get_pii_columns(table_group_id: str, schema: str | None = None, table_name: return {row["column_name"] for row in results} -def mask_source_data_pii(df: pd.DataFrame, pii_columns: set[str]) -> None: - """In-place mask values in PII columns with PII_REDACTED.""" +def mask_source_data_pii(df: pd.DataFrame, pii_columns: set[str]) -> bool: + """In-place mask values in PII columns with PII_REDACTED. + + Returns True if at least one column was rewritten, False otherwise. + """ if df.empty or not pii_columns: - return + return False + masked = False for col in pii_columns: # Match case-insensitively since column names may differ in case for df_col in df.columns: if df_col.lower() == col.lower(): df[df_col] = PII_REDACTED + masked = True + return masked def mask_hygiene_detail(data: pd.DataFrame | list[dict], pii_columns: set[str] | None = None) -> None: diff --git a/testgen/common/source_data_service.py b/testgen/common/source_data_service.py index 93372209..c59d0b47 100644 --- a/testgen/common/source_data_service.py +++ b/testgen/common/source_data_service.py @@ -39,6 +39,7 @@ class SourceDataResult: message: str | None query: str | None df: pd.DataFrame | None + pii_redacted: bool = False # --------------------------------------------------------------------------- @@ -76,16 +77,17 @@ def fetch_test_result_source_data( df = to_dataframe(results) if limit: df = df.sample(n=min(len(df), limit)).sort_index() + redacted = False if mask_pii: if is_custom: - _mask_lookup_pii(df, issue_data["table_groups_id"], issue_data["table_name"]) + redacted = _mask_lookup_pii(df, issue_data["table_groups_id"], issue_data["table_name"]) # Mask user-defined redactable columns from the test definition lookup_data = _get_lookup_data_custom(issue_data["test_definition_id"]) if lookup_data and lookup_data.lookup_redactable_columns: redactable = {col.strip() for col in lookup_data.lookup_redactable_columns.split(",")} - mask_source_data_pii(df, redactable) + redacted = mask_source_data_pii(df, redactable) or redacted else: - _mask_lookup_pii( + redacted = _mask_lookup_pii( df, issue_data["table_groups_id"], issue_data["table_name"], @@ -93,7 +95,7 @@ def fetch_test_result_source_data( test_type_id=issue_data.get("test_type_id"), error_type="Test Results", ) - return SourceDataResult("OK", None, lookup_query, df) + return SourceDataResult("OK", None, lookup_query, df, pii_redacted=redacted) else: return SourceDataResult( "ND", "Data that violates test criteria is not present in the current dataset.", lookup_query, None, @@ -155,8 +157,9 @@ def fetch_hygiene_source_data( df = to_dataframe(results) if limit: df = df.sample(n=min(len(df), limit)).sort_index() + redacted = False if mask_pii: - _mask_lookup_pii( + redacted = _mask_lookup_pii( df, issue_data["table_groups_id"], issue_data["table_name"], @@ -164,7 +167,7 @@ def fetch_hygiene_source_data( test_type_id=issue_data.get("anomaly_id"), error_type="Profile Anomaly", ) - return SourceDataResult("OK", None, lookup_query, df) + return SourceDataResult("OK", None, lookup_query, df, pii_redacted=redacted) else: return SourceDataResult( "ND", @@ -336,10 +339,10 @@ def _mask_lookup_pii( column_name: str | None = None, test_type_id: str | None = None, error_type: Literal["Profile Anomaly", "Test Results"] | None = None, -) -> None: - """Apply PII masking to a source data lookup DataFrame.""" +) -> bool: + """Apply PII masking to a source data lookup DataFrame. Returns True if any masking occurred.""" pii_columns = get_pii_columns(table_group_id, table_name=table_name) - mask_source_data_pii(df, pii_columns) + masked = mask_source_data_pii(df, pii_columns) # Row-level masking: if result has a column_name column listing which source column # each row is about (e.g., table-level recency queries), mask value columns in rows @@ -348,10 +351,12 @@ def _mask_lookup_pii( pii_lower = {c.lower() for c in pii_columns} value_cols = [c for c in df.columns if c != "column_name"] pii_rows = df["column_name"].str.lower().isin(pii_lower) - for col in value_cols: - if df[col].dtype != object: - df[col] = df[col].astype(object) - df.loc[pii_rows, col] = PII_REDACTED + if pii_rows.any() and value_cols: + for col in value_cols: + if df[col].dtype != object: + df[col] = df[col].astype(object) + df.loc[pii_rows, col] = PII_REDACTED + masked = True # Also mask redactable columns if the test's target column is PII if column_name and test_type_id and error_type and column_name.lower() in {c.lower() for c in pii_columns}: @@ -370,4 +375,5 @@ def _mask_lookup_pii( ).mappings().first() if result and result["lookup_redactable_columns"]: redactable = {col.strip() for col in result["lookup_redactable_columns"].split(",")} - mask_source_data_pii(df, redactable) + masked = mask_source_data_pii(df, redactable) or masked + return masked diff --git a/testgen/mcp/tools/common.py b/testgen/mcp/tools/common.py index fee46856..0f985b68 100644 --- a/testgen/mcp/tools/common.py +++ b/testgen/mcp/tools/common.py @@ -22,6 +22,16 @@ def parse_result_status(value: str) -> TestResultStatus: raise MCPUserError(f"Invalid status `{value}`. Valid values: {valid}") from err +def validate_page(value: int) -> None: + if value < 1: + raise MCPUserError(f"Invalid page `{value}`: must be >= 1.") + + +def validate_limit(value: int, max_limit: int) -> None: + if not 1 <= value <= max_limit: + raise MCPUserError(f"Invalid limit `{value}`: must be between 1 and {max_limit}.") + + def parse_since_arg(value: str, label: str = "since", *, today: date | None = None) -> date: try: return parse_since(value, today=today) diff --git a/testgen/mcp/tools/discovery.py b/testgen/mcp/tools/discovery.py index 5f6371ac..757f0fdd 100644 --- a/testgen/mcp/tools/discovery.py +++ b/testgen/mcp/tools/discovery.py @@ -4,7 +4,7 @@ from testgen.common.models.test_run import TestRun from testgen.common.models.test_suite import TestSuite from testgen.mcp.permissions import get_project_permissions, mcp_permission -from testgen.mcp.tools.common import parse_uuid +from testgen.mcp.tools.common import parse_uuid, validate_limit, validate_page from testgen.mcp.tools.markdown import MdDoc @@ -106,10 +106,12 @@ def list_tables(table_group_id: str, limit: int = 200, page: int = 1) -> str: Args: table_group_id: The table group UUID. - limit: Maximum number of tables per page (default 200). + limit: Maximum number of tables per page (default 200, max 500). page: Page number, starting from 1 (default 1). """ group_uuid = parse_uuid(table_group_id, "table_group_id") + validate_page(page) + validate_limit(limit, 500) perms = get_project_permissions() project_codes = perms.allowed_codes diff --git a/testgen/mcp/tools/source_data.py b/testgen/mcp/tools/source_data.py index c9c8f401..fa003190 100644 --- a/testgen/mcp/tools/source_data.py +++ b/testgen/mcp/tools/source_data.py @@ -9,7 +9,7 @@ ) from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission -from testgen.mcp.tools.common import parse_uuid +from testgen.mcp.tools.common import parse_uuid, validate_limit from testgen.mcp.tools.markdown import MdDoc @@ -56,7 +56,7 @@ def get_source_data_query( reference_date: ISO 8601 date used as the test reference point (default: now). limit: Maximum rows the query would return (default 100, max 500). """ - limit = min(max(limit, 1), 500) + validate_limit(limit, 500) context = _resolve_context(test_definition_id, reference_date) query = build_test_result_query(context, limit) @@ -96,7 +96,7 @@ def get_source_data( reference_date: ISO 8601 date used as the test reference point (default: now). limit: Maximum rows to return (default 100, max 500). """ - limit = min(max(limit, 1), 500) + validate_limit(limit, 500) context = _resolve_context(test_definition_id, reference_date) perms = get_project_permissions() @@ -114,7 +114,7 @@ def get_source_data( if result.status == "OK": row_count = len(result.df) if result.df is not None else 0 doc.field("Rows returned", row_count) - if mask_pii: + if result.pii_redacted: doc.text("_PII columns have been redacted._") doc.table_from_dataframe(result.df) if result.query: diff --git a/testgen/mcp/tools/test_definitions.py b/testgen/mcp/tools/test_definitions.py index 8389d444..d2061c1f 100644 --- a/testgen/mcp/tools/test_definitions.py +++ b/testgen/mcp/tools/test_definitions.py @@ -3,7 +3,14 @@ from testgen.common.models.test_result import TestResult from testgen.mcp.exceptions import MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission -from testgen.mcp.tools.common import format_page_footer, format_page_info, parse_uuid, resolve_test_type +from testgen.mcp.tools.common import ( + format_page_footer, + format_page_info, + parse_uuid, + resolve_test_type, + validate_limit, + validate_page, +) from testgen.mcp.tools.markdown import MdDoc _VALID_SCOPES = {"column", "table", "referential", "custom"} @@ -27,10 +34,12 @@ def list_tests( table_name: Filter by table name (exact match). test_type: Filter by test type (e.g. 'Alpha Truncation', 'Row Count'). test_active: Filter by active status (true/false). Omit to show all. - limit: Maximum number of results per page (default 50). + limit: Maximum number of tests per page (default 50, max 200). page: Page number, starting from 1 (default 1). """ suite_uuid = parse_uuid(test_suite_id, "test_suite_id") + validate_page(page) + validate_limit(limit, 200) test_type_code = resolve_test_type(test_type) if test_type else None perms = get_project_permissions() diff --git a/testgen/mcp/tools/test_results.py b/testgen/mcp/tools/test_results.py index 25804275..9c35684d 100644 --- a/testgen/mcp/tools/test_results.py +++ b/testgen/mcp/tools/test_results.py @@ -14,6 +14,8 @@ parse_since_arg, parse_uuid, resolve_test_type, + validate_limit, + validate_page, ) from testgen.mcp.tools.markdown import MdDoc @@ -44,13 +46,15 @@ def list_test_results( status: Filter by result status (Passed, Failed, Warning, Error, Log). table_name: Filter by table name. test_type: Filter by test type (e.g. 'Alpha Truncation', 'Unique Percent'). - limit: Maximum number of results per page (default 50). + limit: Maximum number of test results per page (default 50, max 200). page: Page number, starting from 1 (default 1). """ if job_execution_id and test_suite_id: raise MCPUserError("Pass either `job_execution_id` or `test_suite_id`, not both.") if not job_execution_id and not test_suite_id: raise MCPUserError("Provide either `job_execution_id` or `test_suite_id`.") + validate_page(page) + validate_limit(limit, 200) perms = get_project_permissions() @@ -265,10 +269,12 @@ def get_test_result_history( Args: test_definition_id: UUID of a test definition, e.g. from ``list_test_results``. - limit: Maximum number of historical results per page (default 20). + limit: Maximum number of historical results per page (default 20, max 200). page: Page number, starting from 1 (default 1). """ def_uuid = parse_uuid(test_definition_id, "test_definition_id") + validate_page(page) + validate_limit(limit, 200) offset = (page - 1) * limit perms = get_project_permissions() @@ -332,9 +338,12 @@ def search_test_results( test_type: Filter by test type (e.g. 'Pattern Match'). status: Filter by result statuses (defaults to ['Failed', 'Warning']). since: Include results since this point — e.g. '7 days', '2 weeks', '2026-04-01'. - limit: Maximum results per page (default 50). + limit: Maximum number of test results per page (default 50, max 200). page: Page number, starting from 1 (default 1). """ + validate_page(page) + validate_limit(limit, 200) + perms = get_project_permissions() if project_code: perms.verify_access(project_code, not_found=MCPResourceNotAccessible("Project", project_code)) diff --git a/testgen/mcp/tools/test_runs.py b/testgen/mcp/tools/test_runs.py index 6df736a4..0c3df01b 100644 --- a/testgen/mcp/tools/test_runs.py +++ b/testgen/mcp/tools/test_runs.py @@ -2,6 +2,7 @@ from testgen.common.models.test_run import TestRun from testgen.common.models.test_suite import TestSuite from testgen.mcp.permissions import get_project_permissions, mcp_permission +from testgen.mcp.tools.common import validate_limit from testgen.mcp.tools.markdown import MdDoc @@ -13,10 +14,11 @@ def get_recent_test_runs(project_code: str, test_suite: str | None = None, limit Args: project_code: The project code to query. test_suite: Optional test suite name to filter by. - limit: Maximum runs per test suite (default 1). + limit: Maximum runs per test suite (default 1, max 100). """ if not project_code: return "Missing required parameter `project_code`." + validate_limit(limit, 100) perms = get_project_permissions() perms.verify_access(project_code, not_found=f"No completed test runs found in project `{project_code}`.") diff --git a/tests/unit/api/test_jobs.py b/tests/unit/api/test_jobs.py index 1a5685af..18d260a4 100644 --- a/tests/unit/api/test_jobs.py +++ b/tests/unit/api/test_jobs.py @@ -5,12 +5,15 @@ from uuid import uuid4 import pytest -from fastapi import HTTPException +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from testgen.api.deps import db_session, get_authorized_user from testgen.api.jobs import ( cancel_job, get_job_status, list_jobs, + router, submit_profiling, submit_test_generation, submit_test_run, @@ -194,3 +197,42 @@ def test_list_jobs_empty_project(mock_je_cls): assert result.total == 0 assert result.items == [] + + +# --- list_jobs HTTP-level query validation --- + + +def _client_with_overrides() -> TestClient: + """Build a TestClient that bypasses auth and db_session so query validation runs unimpeded.""" + app = FastAPI() + app.include_router(router) + app.dependency_overrides[db_session] = lambda: iter([None]) + app.dependency_overrides[get_authorized_user] = lambda: MagicMock(id=uuid4()) + return app + + +@patch("testgen.api.deps.has_project_permission", return_value=True) +@patch(f"{MODULE}.JobExecution") +def test_list_jobs_rejects_unknown_status(mock_je_cls, _mock_perm): + mock_je_cls.list_for_project.return_value = ([], 0) + client = TestClient(_client_with_overrides()) + + resp = client.get("/api/v1/projects/DEFAULT/jobs?status=BOGUS") + + assert resp.status_code == 422 + body = resp.json() + assert body["detail"][0]["loc"] == ["query", "status"] + assert body["detail"][0]["type"] == "enum" + + +@patch("testgen.api.deps.has_project_permission", return_value=True) +@patch(f"{MODULE}.JobExecution") +def test_list_jobs_accepts_valid_status(mock_je_cls, _mock_perm): + mock_je_cls.list_for_project.return_value = ([], 0) + client = TestClient(_client_with_overrides()) + + resp = client.get("/api/v1/projects/DEFAULT/jobs?status=completed") + + assert resp.status_code == 200 + # Verify the status string was forwarded to the model layer. + assert mock_je_cls.list_for_project.call_args.kwargs["status"] == "completed" diff --git a/tests/unit/common/test_pii_masking.py b/tests/unit/common/test_pii_masking.py index b336ad43..88426985 100644 --- a/tests/unit/common/test_pii_masking.py +++ b/tests/unit/common/test_pii_masking.py @@ -10,11 +10,27 @@ def test_masks_pii_columns(self): "ssn": ["123-45-6789", "987-65-4321"], "age": [30, 25], }) - mask_source_data_pii(df, {"ssn"}) + result = mask_source_data_pii(df, {"ssn"}) + assert result is True assert df["ssn"].tolist() == [PII_REDACTED, PII_REDACTED] assert df["age"].tolist() == [30, 25] assert df["name"].tolist() == ["Alice", "Bob"] + def test_returns_false_when_no_pii_columns_in_df(self): + df = pd.DataFrame({"name": ["Alice"], "age": [30]}) + result = mask_source_data_pii(df, {"ssn", "email"}) + assert result is False + + def test_returns_false_for_empty_dataframe(self): + df = pd.DataFrame(columns=["name", "ssn"]) + result = mask_source_data_pii(df, {"ssn"}) + assert result is False + + def test_returns_false_for_empty_pii_set(self): + df = pd.DataFrame({"col_a": [1, 2]}) + result = mask_source_data_pii(df, set()) + assert result is False + def test_preserves_non_pii_columns(self): df = pd.DataFrame({"col_a": [1, 2], "col_b": ["x", "y"]}) mask_source_data_pii(df, {"col_a"}) diff --git a/tests/unit/mcp/test_tools_common.py b/tests/unit/mcp/test_tools_common.py index 57ae2fc6..8aaf8865 100644 --- a/tests/unit/mcp/test_tools_common.py +++ b/tests/unit/mcp/test_tools_common.py @@ -4,7 +4,7 @@ from testgen.common.models.test_result import TestResultStatus from testgen.mcp.exceptions import MCPUserError -from testgen.mcp.tools.common import parse_result_status, parse_uuid +from testgen.mcp.tools.common import parse_result_status, parse_uuid, validate_limit, validate_page # --- parse_uuid --- @@ -55,3 +55,36 @@ def test_parse_result_status_invalid_lists_valid_values(): parse_result_status("nope") for status in TestResultStatus: assert status.value in str(exc_info.value) + + +# --- validate_page --- + + +@pytest.mark.parametrize("ok", [1, 2, 99]) +def test_validate_page_accepts_positive(ok): + validate_page(ok) # does not raise + + +@pytest.mark.parametrize("bad", [0, -1, -100]) +def test_validate_page_rejects_below_one(bad): + with pytest.raises(MCPUserError, match=f"Invalid page `{bad}`"): + validate_page(bad) + + +# --- validate_limit --- + + +@pytest.mark.parametrize("ok", [1, 50, 100]) +def test_validate_limit_accepts_in_range(ok): + validate_limit(ok, 100) # does not raise + + +@pytest.mark.parametrize("bad", [0, -1, 101, 1000]) +def test_validate_limit_rejects_out_of_range(bad): + with pytest.raises(MCPUserError, match=f"Invalid limit `{bad}`"): + validate_limit(bad, 100) + + +def test_validate_limit_message_includes_max(): + with pytest.raises(MCPUserError, match="between 1 and 200"): + validate_limit(0, 200) diff --git a/tests/unit/mcp/test_tools_source_data.py b/tests/unit/mcp/test_tools_source_data.py index 97dda741..07dfb3d7 100644 --- a/tests/unit/mcp/test_tools_source_data.py +++ b/tests/unit/mcp/test_tools_source_data.py @@ -73,19 +73,12 @@ def test_get_source_data_query_no_column(mock_td, mock_build, db_session_mock): assert "Column" not in result -@patch("testgen.mcp.tools.source_data.build_test_result_query") -@patch("testgen.mcp.tools.source_data.TestDefinition") -def test_get_source_data_query_clamps_limit(mock_td, mock_build, db_session_mock): - context = _make_context() - mock_td.get_source_data_context.return_value = context - mock_build.return_value = "SELECT 1" - +@pytest.mark.parametrize("bad_limit", [-1, 0, 9999]) +def test_get_source_data_query_rejects_out_of_range_limit(bad_limit, db_session_mock): from testgen.mcp.tools.source_data import get_source_data_query - get_source_data_query(str(uuid4()), limit=9999) - - _, call_args = mock_build.call_args - assert call_args == {"limit": 500} if call_args else mock_build.call_args[0][1] <= 500 + with pytest.raises(MCPUserError, match="Invalid limit"): + get_source_data_query(str(uuid4()), limit=bad_limit) def test_get_source_data_query_invalid_uuid(db_session_mock): @@ -179,6 +172,41 @@ def test_get_source_data_ok_with_pii_masking(mock_td, mock_fetch, db_session_moc assert isinstance(call_args[0][2], bool) +@patch("testgen.mcp.tools.source_data.fetch_test_result_source_data") +@patch("testgen.mcp.tools.source_data.TestDefinition") +def test_get_source_data_banner_only_when_redaction_happened(mock_td, mock_fetch, db_session_mock): + """The PII banner must key on whether masking actually changed the df, not on the mask_pii flag.""" + mock_td.get_source_data_context.return_value = _make_context() + + df = pd.DataFrame({"col": ["unredacted"]}) + mock_fetch.return_value = SourceDataResult( + status="OK", message=None, query="SELECT 1", df=df, pii_redacted=False, + ) + + from testgen.mcp.tools.source_data import get_source_data + + result = get_source_data(str(uuid4())) + + assert "PII columns have been redacted" not in result + + +@patch("testgen.mcp.tools.source_data.fetch_test_result_source_data") +@patch("testgen.mcp.tools.source_data.TestDefinition") +def test_get_source_data_banner_shown_when_redaction_happened(mock_td, mock_fetch, db_session_mock): + mock_td.get_source_data_context.return_value = _make_context() + + df = pd.DataFrame({"col": ["[PII Redacted]"]}) + mock_fetch.return_value = SourceDataResult( + status="OK", message=None, query="SELECT 1", df=df, pii_redacted=True, + ) + + from testgen.mcp.tools.source_data import get_source_data + + result = get_source_data(str(uuid4())) + + assert "PII columns have been redacted" in result + + @patch("testgen.mcp.tools.source_data.fetch_test_result_source_data") @patch("testgen.mcp.tools.source_data.TestDefinition") def test_get_source_data_na_status(mock_td, mock_fetch, db_session_mock): From c01f6b2f1abab14ec7ffdd2a1e92c5930ad7ef0b Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Thu, 30 Apr 2026 22:01:48 -0400 Subject: [PATCH 103/123] =?UTF-8?q?fix(mcp):=20TG-1028=20reviewer=20feedba?= =?UTF-8?q?ck=20=E2=80=94=20hygiene-issue=20rename,=20PII=20parsing,=20per?= =?UTF-8?q?f=20scoping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename "anomaly" -> "hygiene issue" everywhere we own the name (dataclass fields, SQL aliases, output labels, prompts, tests, and the consuming dashboard JS). Physical DB names (profile_anomaly_*, anomaly_id, anomaly_ct) and the legitimate monitor-trend "*_anomalies" fields are kept — those are the correct domain. - Scope DataColumnChars.list_for_table_group's hygiene subquery by table_groups_id (and profile_run_id when supplied) so it can't fall into a full hygiene_issues scan. - Coalesce column-level CDE with the parent table's CDE to match UI behavior (a column reads as CDE when either it or its table is flagged). - Drop the view_pii gating in list_column_profiles — view_pii governs raw PII values, not category metadata, and the UI never gated this. Render pii_flag through a new _format_pii helper that mirrors PiiDisplay in metadata_tags.js (e.g. "PII (Moderate Risk - Name / Individual)"; "MANUAL" -> "PII"; collapses redundant detail). - Remove "so an LLM can …" docstring phrasing per reviewer. Held for follow-up (per discussion): - DataColumnChars list-for-older-runs uses current column metadata (food-for-thought thread). - get_table CDE-per-column inclusion vs current cde_count. - monitor_lookback_end exposure in list_profiling_summaries. Verified: ruff clean, 358/358 unit tests pass, MCP smoke against worktree-2 (get_data_inventory, list_profiling_summaries, get_table, list_column_profiles) all return the new wording and parse PII correctly. Co-Authored-By: Claude Opus 4.7 (1M context) --- testgen/common/models/data_column.py | 47 +++++++--- testgen/common/models/data_table.py | 8 +- testgen/common/models/table_group.py | 20 ++-- testgen/mcp/prompts/workflows.py | 6 +- testgen/mcp/services/inventory_service.py | 12 +-- testgen/mcp/tools/profiling.py | 61 ++++++++----- .../frontend/js/pages/project_dashboard.js | 22 ++--- tests/unit/mcp/test_inventory_service.py | 18 ++-- tests/unit/mcp/test_tools_profiling.py | 91 ++++++------------- 9 files changed, 145 insertions(+), 140 deletions(-) diff --git a/testgen/common/models/data_column.py b/testgen/common/models/data_column.py index 2eb526da..0280a28b 100644 --- a/testgen/common/models/data_column.py +++ b/testgen/common/models/data_column.py @@ -11,6 +11,7 @@ String, and_, asc, + case, func, select, ) @@ -36,7 +37,7 @@ class ColumnProfileSummary(EntityMinimal): filled_value_ct: int | None dq_score_profiling: float | None dq_score_testing: float | None - anomaly_count: int + hygiene_issue_count: int class DataColumnChars(Entity): @@ -75,25 +76,36 @@ class DataColumnChars(Entity): def list_for_table_group( cls, *clauses, + table_groups_id: UUID, profiling_run_id: UUID | None = None, page: int, limit: int, ) -> tuple[list[ColumnProfileSummary], int]: + # Local import: data_table imports DataColumnChars at module top. + from testgen.common.models.data_table import DataTable + profile_run_filter = ( ProfileResult.profile_run_id == profiling_run_id if profiling_run_id is not None else ProfileResult.profile_run_id == cls.last_complete_profile_run_id ) - anomaly_subq = ( + hygiene_subq_clauses = [ + HygieneIssue.table_groups_id == table_groups_id, + func.coalesce(HygieneIssue.disposition, "Confirmed") == "Confirmed", + ] + if profiling_run_id is not None: + hygiene_subq_clauses.append(HygieneIssue.profile_run_id == profiling_run_id) + + hygiene_subq = ( select( HygieneIssue.profile_run_id.label("profile_run_id"), HygieneIssue.schema_name.label("schema_name"), HygieneIssue.table_name.label("table_name"), HygieneIssue.column_name.label("column_name"), - func.count().label("anomaly_count"), + func.count().label("hygiene_issue_count"), ) - .where(func.coalesce(HygieneIssue.disposition, "Confirmed") == "Confirmed") + .where(*hygiene_subq_clauses) .group_by( HygieneIssue.profile_run_id, HygieneIssue.schema_name, @@ -103,6 +115,12 @@ def list_for_table_group( .subquery() ) + cde_coalesced = case( + (cls.critical_data_element.is_(True), True), + (DataTable.critical_data_element.is_(True), True), + else_=False, + ).label("critical_data_element") + query = ( select( cls.column_name, @@ -111,15 +129,16 @@ def list_for_table_group( cls.functional_data_type, ProfileResult.datatype_suggestion, cls.pii_flag, - cls.critical_data_element, + cde_coalesced, ProfileResult.record_ct, ProfileResult.null_value_ct, ProfileResult.distinct_value_ct, ProfileResult.filled_value_ct, cls.dq_score_profiling, cls.dq_score_testing, - func.coalesce(anomaly_subq.c.anomaly_count, 0).label("anomaly_count"), + func.coalesce(hygiene_subq.c.hygiene_issue_count, 0).label("hygiene_issue_count"), ) + .outerjoin(DataTable, DataTable.id == cls.table_id) .outerjoin( ProfileResult, and_( @@ -130,15 +149,19 @@ def list_for_table_group( ), ) .outerjoin( - anomaly_subq, + hygiene_subq, and_( - anomaly_subq.c.profile_run_id == ProfileResult.profile_run_id, - anomaly_subq.c.schema_name == cls.schema_name, - anomaly_subq.c.table_name == cls.table_name, - anomaly_subq.c.column_name == cls.column_name, + hygiene_subq.c.profile_run_id == ProfileResult.profile_run_id, + hygiene_subq.c.schema_name == cls.schema_name, + hygiene_subq.c.table_name == cls.table_name, + hygiene_subq.c.column_name == cls.column_name, ), ) - .where(cls.drop_date.is_(None), *clauses) + .where( + cls.table_groups_id == table_groups_id, + cls.drop_date.is_(None), + *clauses, + ) .order_by(asc(cls.table_name), asc(cls.ordinal_position), asc(cls.column_name)) ) diff --git a/testgen/common/models/data_table.py b/testgen/common/models/data_table.py index b9323a27..21ea22b3 100644 --- a/testgen/common/models/data_table.py +++ b/testgen/common/models/data_table.py @@ -47,7 +47,7 @@ class TableProfilingOverview: dq_score_profiling: float | None dq_score_testing: float | None cde_count: int - anomaly_count: int + hygiene_issue_count: int latest_profile_id: UUID | None latest_profile_started_at: datetime | None latest_profile_job_execution_id: UUID | None @@ -169,9 +169,9 @@ def get_profiling_overview( ) ) or 0 - anomaly_count = 0 + hygiene_issue_count = 0 if header["latest_profile_id"]: - anomaly_count = session.scalar( + hygiene_issue_count = session.scalar( select(func.count()) .select_from(HygieneIssue) .where( @@ -184,6 +184,6 @@ def get_profiling_overview( return TableProfilingOverview( **header, cde_count=cde_count, - anomaly_count=anomaly_count, + hygiene_issue_count=hygiene_issue_count, columns=columns, ) diff --git a/testgen/common/models/table_group.py b/testgen/common/models/table_group.py index fbe6a3ed..bf1b3bdc 100644 --- a/testgen/common/models/table_group.py +++ b/testgen/common/models/table_group.py @@ -64,11 +64,11 @@ class TableGroupSummary(EntityMinimal): latest_profile_id: UUID | None latest_profile_job_execution_id: UUID | None latest_profile_start: datetime | None - latest_anomalies_ct: int - latest_anomalies_definite_ct: int - latest_anomalies_likely_ct: int - latest_anomalies_possible_ct: int - latest_anomalies_dismissed_ct: int + latest_hygiene_issues_ct: int + latest_hygiene_issues_definite_ct: int + latest_hygiene_issues_likely_ct: int + latest_hygiene_issues_possible_ct: int + latest_hygiene_issues_dismissed_ct: int monitor_test_suite_id: UUID | None monitor_lookback: int | None monitor_lookback_start: datetime | None @@ -339,11 +339,11 @@ def select_summary( latest_profile.id AS latest_profile_id, latest_profile.job_execution_id AS latest_profile_job_execution_id, latest_profile.started_at AS latest_profile_start, - latest_profile.anomaly_ct AS latest_anomalies_ct, - latest_profile.definite_ct AS latest_anomalies_definite_ct, - latest_profile.likely_ct AS latest_anomalies_likely_ct, - latest_profile.possible_ct AS latest_anomalies_possible_ct, - latest_profile.dismissed_ct AS latest_anomalies_dismissed_ct, + latest_profile.anomaly_ct AS latest_hygiene_issues_ct, + latest_profile.definite_ct AS latest_hygiene_issues_definite_ct, + latest_profile.likely_ct AS latest_hygiene_issues_likely_ct, + latest_profile.possible_ct AS latest_hygiene_issues_possible_ct, + latest_profile.dismissed_ct AS latest_hygiene_issues_dismissed_ct, groups.monitor_test_suite_id AS monitor_test_suite_id, lookback_windows.lookback AS monitor_lookback, lookback_windows.lookback_start AS monitor_lookback_start, diff --git a/testgen/mcp/prompts/workflows.py b/testgen/mcp/prompts/workflows.py index 6d336f19..ff76b7b8 100644 --- a/testgen/mcp/prompts/workflows.py +++ b/testgen/mcp/prompts/workflows.py @@ -64,15 +64,15 @@ def table_health(table_name: str) -> str: def profiling_overview() -> str: - """Explore the profiling results for a table group — understand data shapes, types, null rates, and anomalies.""" + """Explore the profiling results for a table group — understand data shapes, types, null rates, and hygiene issues.""" return """\ Please perform a profiling exploration: 1. Call `get_data_inventory()` to see projects and table groups, with profiling status per group. 2. Pick a table group that has been profiled. -3. Call `list_profiling_summaries(table_group_id='...')` for the quality health overview (scores, anomaly counts, last profiled). +3. Call `list_profiling_summaries(table_group_id='...')` for the quality health overview (scores, hygiene issue counts, last profiled). 4. Call `get_table(table_group_id='...', table_name='...')` for structural metadata, the column list, and table-level highlights. -5. Call `list_column_profiles(table_group_id='...', table_name='...')` to scan all columns — datatype, null rates, distinct counts, quality scores, and anomaly counts per column. +5. Call `list_column_profiles(table_group_id='...', table_name='...')` to scan all columns — datatype, null rates, distinct counts, quality scores, and hygiene issue counts per column. 6. Summarize findings: which tables/columns have quality concerns, and which trends are worth investigating further. """ diff --git a/testgen/mcp/services/inventory_service.py b/testgen/mcp/services/inventory_service.py index e845eca6..a20aef31 100644 --- a/testgen/mcp/services/inventory_service.py +++ b/testgen/mcp/services/inventory_service.py @@ -157,7 +157,7 @@ def get_inventory( "---\n" "Use `list_tables(table_group_id='...')` to see tables in a group.\n" "Use `list_test_suites(project_code='...')` for suite details and latest run stats.\n" - "Use `list_profiling_summaries(table_group_id='...')` for the quality score rollup and anomaly counts." + "Use `list_profiling_summaries(table_group_id='...')` for the quality score rollup and hygiene issue counts." ) return "\n".join(lines) @@ -168,10 +168,10 @@ def _profiling_summary_fragment(summary: TableGroupSummary) -> str: if not summary.latest_profile_id: return "not profiled yet" - anomaly_total = ( - (summary.latest_anomalies_definite_ct or 0) - + (summary.latest_anomalies_likely_ct or 0) - + (summary.latest_anomalies_possible_ct or 0) + hygiene_issue_total = ( + (summary.latest_hygiene_issues_definite_ct or 0) + + (summary.latest_hygiene_issues_likely_ct or 0) + + (summary.latest_hygiene_issues_possible_ct or 0) ) combined = friendly_score(score(summary.dq_score_profiling, summary.dq_score_testing)) profiled_at = ( @@ -179,7 +179,7 @@ def _profiling_summary_fragment(summary: TableGroupSummary) -> str: if summary.latest_profile_start else "—" ) return ( - f"Score {combined}, anomalies {anomaly_total}, " + f"Score {combined}, hygiene issues {hygiene_issue_total}, " f"last profiled {profiled_at}, " f"profiling run `{summary.latest_profile_job_execution_id}`" ) diff --git a/testgen/mcp/tools/profiling.py b/testgen/mcp/tools/profiling.py index 69bf9bf1..abaa7cfa 100644 --- a/testgen/mcp/tools/profiling.py +++ b/testgen/mcp/tools/profiling.py @@ -20,7 +20,7 @@ @with_database_session @mcp_permission("catalog") def get_table(table_group_id: str, table_name: str) -> str: - """Get an overview of a table with profiling highlights: structural metadata, column list, quality scores, and anomaly count from the latest profiling run. + """Get an overview of a table with profiling highlights: structural metadata, column list, quality scores, and hygiene issue count from the latest profiling run. Args: table_group_id: UUID of the table group, e.g. from `get_data_inventory`. @@ -41,7 +41,7 @@ def get_table(table_group_id: str, table_name: str) -> str: doc.field("Critical data elements", overview.cde_count) doc.field("Profiling Score", friendly_score(overview.dq_score_profiling)) doc.field("Testing Score", friendly_score(overview.dq_score_testing)) - doc.field("Anomalies (confirmed)", overview.anomaly_count) + doc.field("Hygiene issues (confirmed)", overview.hygiene_issue_count) doc.field("Last profiled", overview.latest_profile_started_at) doc.field("Profiling Run", overview.latest_profile_job_execution_id, code=True) @@ -71,10 +71,7 @@ def list_column_profiles( limit: int = 100, page: int = 1, ) -> str: - """List per-column profile headers (~14 fields each) — the Layer 1 scan of profiling results so an LLM can pick which columns to drill into. - - PII details (category, full value) require `view_pii` permission; otherwise the - PII flag is redacted to a boolean-like value. + """List per-column profile headers (~14 fields each) — the Layer 1 scan of profiling results across columns in a table group. Args: table_group_id: UUID of the table group, e.g. from `get_data_inventory`. @@ -96,7 +93,7 @@ def list_column_profiles( raise MCPResourceNotAccessible("Profiling run", job_execution_id) profiling_run_id = profiling_run.id - clauses = [DataColumnChars.table_groups_id == tg.id] + clauses = [] if table_name: clauses.append(DataColumnChars.table_name == table_name) if columns: @@ -104,6 +101,7 @@ def list_column_profiles( data, total = DataColumnChars.list_for_table_group( *clauses, + table_groups_id=tg.id, profiling_run_id=profiling_run_id, page=page, limit=limit, @@ -114,8 +112,6 @@ def list_column_profiles( return f"No column profiles on page {page} (total: {total})." return f"No column profiles found for table group `{table_group_id}`." - has_view_pii = tg.project_code in get_project_permissions().codes_allowed_to("view_pii") - doc = MdDoc() scope_descriptor = f"table group `{table_group_id}`" if table_name: @@ -130,9 +126,9 @@ def list_column_profiles( "Column", "Table", "Type", "Functional type", "Suggestion", "PII", "CDE", "Records", "Nulls", "Distinct", "Filled", - "Profiling Score", "Testing Score", "Anomalies", + "Profiling Score", "Testing Score", "Hygiene issues", ] - rows = [_render_column_profile_row(c, has_view_pii=has_view_pii) for c in data] + rows = [_render_column_profile_row(c) for c in data] doc.table(headers, rows, code=[0, 1]) footer = format_page_footer(total, page, limit) @@ -150,7 +146,7 @@ def list_profiling_summaries( limit: int = 20, page: int = 1, ) -> str: - """List aggregated profiling health summaries for a table group or across a project — quality scores, anomaly counts, record counts, last profiled date. + """List aggregated profiling health summaries for a table group or across a project — quality scores, hygiene issue counts, record counts, last profiled date. Args: table_group_id: UUID of a specific table group, e.g. from @@ -203,19 +199,36 @@ def list_profiling_summaries( return doc.render() -def _render_column_profile_row(c: ColumnProfileSummary, *, has_view_pii: bool) -> list: - if has_view_pii: - pii_display = c.pii_flag - else: - pii_display = "Y" if c.pii_flag else None +_PII_RISK_MAP = {"A": "High", "B": "Moderate", "C": "Low"} +_PII_TYPE_MAP = {"ID": "ID", "NAME": "Name", "DEMO": "Demographic", "CONTACT": "Contact"} + + +def _format_pii(value: str | None) -> str | None: + """Render a `pii_flag` value as a human label. Mirrors `PiiDisplay` in metadata_tags.js.""" + if not value: + return None + if value == "MANUAL": + return "PII" + risk, _, rest = value.partition("/") + type_code, _, detail = rest.partition("/") + risk_label = _PII_RISK_MAP.get(risk, "Moderate") + type_label = _PII_TYPE_MAP.get(type_code) + caption = f"{risk_label} Risk" + if type_label: + caption += f" - {type_label}" + if detail and detail != type_label: + caption += f" / {detail}" + return f"PII ({caption})" + +def _render_column_profile_row(c: ColumnProfileSummary) -> list: return [ c.column_name, c.table_name, c.general_type, c.functional_data_type, c.datatype_suggestion, - pii_display, + _format_pii(c.pii_flag), "Y" if c.critical_data_element else None, c.record_ct, c.null_value_ct, @@ -223,7 +236,7 @@ def _render_column_profile_row(c: ColumnProfileSummary, *, has_view_pii: bool) - c.filled_value_ct, friendly_score(c.dq_score_profiling), friendly_score(c.dq_score_testing), - c.anomaly_count, + c.hygiene_issue_count, ] @@ -243,11 +256,11 @@ def _render_table_group_summary(doc: MdDoc, s: TableGroupSummary) -> None: doc.field("Profiling Score", friendly_score(s.dq_score_profiling)) doc.field("Testing Score", friendly_score(s.dq_score_testing)) doc.field( - "Anomalies (confirmed)", - f"{(s.latest_anomalies_definite_ct or 0) + (s.latest_anomalies_likely_ct or 0) + (s.latest_anomalies_possible_ct or 0)} total " - f"— {s.latest_anomalies_definite_ct or 0} definite, " - f"{s.latest_anomalies_likely_ct or 0} likely, " - f"{s.latest_anomalies_possible_ct or 0} possible", + "Hygiene issues (confirmed)", + f"{(s.latest_hygiene_issues_definite_ct or 0) + (s.latest_hygiene_issues_likely_ct or 0) + (s.latest_hygiene_issues_possible_ct or 0)} total " + f"— {s.latest_hygiene_issues_definite_ct or 0} definite, " + f"{s.latest_hygiene_issues_likely_ct or 0} likely, " + f"{s.latest_hygiene_issues_possible_ct or 0} possible", ) doc.field("Last profiled", s.latest_profile_start) doc.field("Profiling Run", s.latest_profile_job_execution_id, code=True) diff --git a/testgen/ui/components/frontend/js/pages/project_dashboard.js b/testgen/ui/components/frontend/js/pages/project_dashboard.js index b339519a..ea8bd237 100644 --- a/testgen/ui/components/frontend/js/pages/project_dashboard.js +++ b/testgen/ui/components/frontend/js/pages/project_dashboard.js @@ -19,11 +19,11 @@ * @property {string?} latest_profile_id * @property {string?} latest_profile_job_execution_id * @property {number?} latest_profile_start - * @property {number} latest_anomalies_ct - * @property {number} latest_anomalies_definite_ct - * @property {number} latest_anomalies_likely_ct - * @property {number} latest_anomalies_possible_ct - * @property {number} latest_anomalies_dismissed_ct + * @property {number} latest_hygiene_issues_ct + * @property {number} latest_hygiene_issues_definite_ct + * @property {number} latest_hygiene_issues_likely_ct + * @property {number} latest_hygiene_issues_possible_ct + * @property {number} latest_hygiene_issues_dismissed_ct * @property {number?} latest_tests_start * @property {TestSuiteSummary[]} test_suites * @property {MonitorSummary?} monitoring_summary @@ -239,7 +239,7 @@ const TableGroupLatestProfile = (/** @type TableGroupSummary */ tableGroup, /** div( { class: 'flex-row fx-gap-5', style: 'flex: 1 1 50%;' }, Link({ emit, - label: `${tableGroup.latest_anomalies_ct} hygiene issues`, + label: `${tableGroup.latest_hygiene_issues_ct} hygiene issues`, href: 'profiling-runs:hygiene', params: { run_id: tableGroup.latest_profile_job_execution_id, @@ -248,13 +248,13 @@ const TableGroupLatestProfile = (/** @type TableGroupSummary */ tableGroup, /** width: 150, style: 'flex: 0 0 auto;', }), - tableGroup.latest_anomalies_ct + tableGroup.latest_hygiene_issues_ct ? SummaryCounts({ items: [ - { label: 'Definite', value: parseInt(tableGroup.latest_anomalies_definite_ct), color: 'red' }, - { label: 'Likely', value: parseInt(tableGroup.latest_anomalies_likely_ct), color: 'orange' }, - { label: 'Possible', value: parseInt(tableGroup.latest_anomalies_possible_ct), color: 'yellow' }, - { label: 'Dismissed', value: parseInt(tableGroup.latest_anomalies_dismissed_ct), color: 'grey' }, + { label: 'Definite', value: parseInt(tableGroup.latest_hygiene_issues_definite_ct), color: 'red' }, + { label: 'Likely', value: parseInt(tableGroup.latest_hygiene_issues_likely_ct), color: 'orange' }, + { label: 'Possible', value: parseInt(tableGroup.latest_hygiene_issues_possible_ct), color: 'yellow' }, + { label: 'Dismissed', value: parseInt(tableGroup.latest_hygiene_issues_dismissed_ct), color: 'grey' }, ], }) : '', diff --git a/tests/unit/mcp/test_inventory_service.py b/tests/unit/mcp/test_inventory_service.py index 43094360..ed0ba1bd 100644 --- a/tests/unit/mcp/test_inventory_service.py +++ b/tests/unit/mcp/test_inventory_service.py @@ -152,7 +152,7 @@ def test_get_inventory_with_view_shows_all_details(mock_select, session_mock): # ---------------------------------------------------------------------- -def _profiling_summary(tg_id, *, profiled=True, anomalies=(2, 2, 11)): +def _profiling_summary(tg_id, *, profiled=True, hygiene_issues=(2, 2, 11)): s = MagicMock() s.id = tg_id s.dq_score_profiling = 95.0 @@ -161,10 +161,10 @@ def _profiling_summary(tg_id, *, profiled=True, anomalies=(2, 2, 11)): s.latest_profile_job_execution_id = uuid4() if profiled else None s.latest_profile_start = MagicMock() s.latest_profile_start.strftime.return_value = "2026-04-23" - definite, likely, possible = anomalies - s.latest_anomalies_definite_ct = definite - s.latest_anomalies_likely_ct = likely - s.latest_anomalies_possible_ct = possible + definite, likely, possible = hygiene_issues + s.latest_hygiene_issues_definite_ct = definite + s.latest_hygiene_issues_likely_ct = likely + s.latest_hygiene_issues_possible_ct = possible return s @@ -182,7 +182,7 @@ def test_get_inventory_includes_profiling_fragment_when_view( result = get_inventory(project_codes=["demo"], view_project_codes=["demo"]) assert "Score" in result - assert "anomalies 15" in result # 2+2+11 + assert "hygiene issues 15" in result # 2+2+11 assert "last profiled 2026-04-23" in result assert f"profiling run `{summary.latest_profile_job_execution_id}`" in result @@ -198,7 +198,7 @@ def test_get_inventory_omits_profiling_fragment_without_view( from testgen.mcp.services.inventory_service import get_inventory result = get_inventory(project_codes=["demo"], view_project_codes=[]) - assert "anomalies" not in result + assert "hygiene issues" not in result assert "last profiled" not in result assert "profiling run" not in result # select_summary should not be called for projects we can't view. @@ -209,7 +209,7 @@ def test_get_inventory_omits_profiling_fragment_without_view( def test_get_inventory_never_profiled_fragment( mock_select, session_mock, table_group_select_summary_mock, ): - """Never-profiled TG renders 'not profiled yet' instead of score/anomaly counts.""" + """Never-profiled TG renders 'not profiled yet' instead of score/hygiene issue counts.""" tg_id = uuid4() table_group_select_summary_mock.return_value = ( [_profiling_summary(tg_id, profiled=False)], 1, @@ -220,5 +220,5 @@ def test_get_inventory_never_profiled_fragment( result = get_inventory(project_codes=["demo"], view_project_codes=["demo"]) assert "not profiled yet" in result - assert "anomalies" not in result + assert "hygiene issues" not in result assert "Score" not in result diff --git a/tests/unit/mcp/test_tools_profiling.py b/tests/unit/mcp/test_tools_profiling.py index 464b4a6a..5627d562 100644 --- a/tests/unit/mcp/test_tools_profiling.py +++ b/tests/unit/mcp/test_tools_profiling.py @@ -30,7 +30,7 @@ def _mock_overview(**overrides): overview.cde_count = 2 overview.dq_score_profiling = 95.0 overview.dq_score_testing = 90.0 - overview.anomaly_count = 3 + overview.hygiene_issue_count = 3 overview.latest_profile_id = uuid4() overview.latest_profile_started_at = "2026-04-23 12:00:00" overview.latest_profile_job_execution_id = uuid4() @@ -64,7 +64,7 @@ def _column_summary(**overrides) -> ColumnProfileSummary: "filled_value_ct": 0, "dq_score_profiling": 100.0, "dq_score_testing": 98.5, - "anomaly_count": 1, + "hygiene_issue_count": 1, } defaults.update(overrides) return ColumnProfileSummary(**defaults) @@ -83,9 +83,9 @@ def _mock_summary(**overrides): s.latest_profile_id = uuid4() s.latest_profile_job_execution_id = uuid4() s.latest_profile_start = "2026-04-23 23:24" - s.latest_anomalies_definite_ct = 2 - s.latest_anomalies_likely_ct = 2 - s.latest_anomalies_possible_ct = 11 + s.latest_hygiene_issues_definite_ct = 2 + s.latest_hygiene_issues_likely_ct = 2 + s.latest_hygiene_issues_possible_ct = 11 s.monitor_lookback_end = None for k, v in overrides.items(): setattr(s, k, v) @@ -329,49 +329,26 @@ def test_list_column_profiles_inaccessible_tg(mock_tg_cls, db_session_mock): # ---------------------------------------------------------------------- -# list_column_profiles — PII redaction +# _format_pii — parser mirroring PiiDisplay in metadata_tags.js # ---------------------------------------------------------------------- -@patch("testgen.mcp.tools.profiling.DataColumnChars") -@patch("testgen.mcp.tools.common.TableGroup") -def test_list_column_profiles_redacts_pii_without_view_pii(mock_tg_cls, mock_dcc_cls, db_session_mock): - """User without view_pii sees a coarse 'Y'/'—' for pii_flag, never the category string.""" - mock_tg_cls.get.return_value = _mock_table_group() - mock_dcc_cls.list_for_table_group.return_value = ( - [_column_summary(column_name="first_name", pii_flag="B/NAME/Individual")], 1, - ) - - from testgen.mcp.tools.profiling import list_column_profiles - result = list_column_profiles(str(uuid4())) - - # The default conftest user has role_a, which the test perm matrix grants 'view' - # and 'catalog' but NOT 'view_pii' — so redaction kicks in. - assert "B/NAME/Individual" not in result - assert "Individual" not in result - # The redacted "Y" should be present for the pii row. - assert "| Y |" in result - - -@patch("testgen.mcp.tools.profiling.get_project_permissions") -@patch("testgen.mcp.tools.profiling.DataColumnChars") -@patch("testgen.mcp.tools.common.TableGroup") -def test_list_column_profiles_shows_full_pii_with_view_pii( - mock_tg_cls, mock_dcc_cls, mock_perms, db_session_mock, -): - mock_tg_cls.get.return_value = _mock_table_group() - mock_dcc_cls.list_for_table_group.return_value = ( - [_column_summary(column_name="first_name", pii_flag="B/NAME/Individual")], 1, - ) - - perms = MagicMock() - perms.codes_allowed_to.return_value = ["demo"] - mock_perms.return_value = perms - - from testgen.mcp.tools.profiling import list_column_profiles - result = list_column_profiles(str(uuid4())) - - assert "B/NAME/Individual" in result +@pytest.mark.parametrize( + "value,expected", + [ + (None, None), + ("", None), + ("MANUAL", "PII"), + ("A/ID/Passport", "PII (High Risk - ID / Passport)"), + ("B/NAME/Individual", "PII (Moderate Risk - Name / Individual)"), + ("C/CONTACT", "PII (Low Risk - Contact)"), + ("B/ID/ID", "PII (Moderate Risk - ID)"), # detail collapses when equal to type label + ("X/UNKNOWN/Detail", "PII (Moderate Risk / Detail)"), # unknown risk falls back; unknown type drops label + ], +) +def test_format_pii(value, expected): + from testgen.mcp.tools.profiling import _format_pii + assert _format_pii(value) == expected # ---------------------------------------------------------------------- @@ -379,29 +356,21 @@ def test_list_column_profiles_shows_full_pii_with_view_pii( # ---------------------------------------------------------------------- -def test_render_row_redacts_pii_when_permission_missing(): - from testgen.mcp.tools.profiling import _render_column_profile_row - row = _render_column_profile_row(_column_summary(pii_flag="B/NAME/Individual"), has_view_pii=False) - assert row[5] == "Y" - assert "Individual" not in (row[5] or "") - - -def test_render_row_shows_full_pii_with_permission(): +def test_render_row_renders_parsed_pii_label(): from testgen.mcp.tools.profiling import _render_column_profile_row - row = _render_column_profile_row(_column_summary(pii_flag="B/NAME/Individual"), has_view_pii=True) - assert row[5] == "B/NAME/Individual" + row = _render_column_profile_row(_column_summary(pii_flag="B/NAME/Individual")) + assert row[5] == "PII (Moderate Risk - Name / Individual)" -def test_render_row_falsy_pii_renders_none_in_either_mode(): +def test_render_row_falsy_pii_renders_none(): from testgen.mcp.tools.profiling import _render_column_profile_row - assert _render_column_profile_row(_column_summary(pii_flag=None), has_view_pii=False)[5] is None - assert _render_column_profile_row(_column_summary(pii_flag=None), has_view_pii=True)[5] is None + assert _render_column_profile_row(_column_summary(pii_flag=None))[5] is None def test_render_row_cde_collapsed_to_y_or_none(): from testgen.mcp.tools.profiling import _render_column_profile_row - row_yes = _render_column_profile_row(_column_summary(critical_data_element=True), has_view_pii=False) - row_no = _render_column_profile_row(_column_summary(critical_data_element=False), has_view_pii=False) + row_yes = _render_column_profile_row(_column_summary(critical_data_element=True)) + row_no = _render_column_profile_row(_column_summary(critical_data_element=False)) assert row_yes[6] == "Y" assert row_no[6] is None @@ -441,7 +410,7 @@ def test_list_profiling_summaries_never_profiled_tg(mock_common_tg, mock_profili assert "_Not profiled yet._" in result # Field block omitted when never profiled. assert "Profiling Score" not in result - assert "Anomalies" not in result + assert "Hygiene issues" not in result @patch("testgen.mcp.tools.profiling.TableGroup") From 8d3acc041c2d924b3201c931feeb85b85840c430 Mon Sep 17 00:00:00 2001 From: Diogo Basto Date: Thu, 30 Apr 2026 21:00:15 +0100 Subject: [PATCH 104/123] feat(TG-1045): add impact_dimension as DQ scoring classification axis Adds impact_dimension as a second classification axis for DQ scoring alongside the existing dq_dimension. Includes: - Schema: impact_dimension column on profile_anomaly_types, test_types, test_results (override), and profile_anomaly_results (denormalized) - Scoring views and SQL templates for impact_dimension breakdown, category scores, and issue drilldown - Score card UI: impact_dimension breakdown category, default breakdown for new and existing scorecards, issue report PDF fields - Test definition edit form: impact_dimension override for CUSTOM, Condition_Flag, and referential test types - Unit tests for impact_dimension model logic Co-Authored-By: Claude Sonnet 4.6 --- testgen/common/models/scores.py | 25 +- testgen/common/models/test_definition.py | 7 +- .../030_initialize_new_schema_structure.sql | 10 +- .../050_populate_new_schema_metadata.sql | 6 +- .../dbsetup/060_create_standard_views.sql | 138 ++++++++ ..._anomaly_types_Boolean_Value_Mismatch.yaml | 1 + ...anomaly_types_Char_Column_Date_Values.yaml | 1 + ...nomaly_types_Char_Column_Number_Units.yaml | 1 + ...omaly_types_Char_Column_Number_Values.yaml | 1 + ...anomaly_types_Column_Pattern_Mismatch.yaml | 1 + ...anomaly_types_Delimited_Data_Embedded.yaml | 1 + ...ile_anomaly_types_Inconsistent_Casing.yaml | 1 + ...rofile_anomaly_types_Invalid_Zip3_USA.yaml | 1 + ...profile_anomaly_types_Invalid_Zip_USA.yaml | 1 + .../profile_anomaly_types_Leading_Spaces.yaml | 1 + ...le_anomaly_types_Multiple_Types_Major.yaml | 1 + ...le_anomaly_types_Multiple_Types_Minor.yaml | 1 + .../profile_anomaly_types_No_Values.yaml | 1 + ..._anomaly_types_Non_Alpha_Name_Address.yaml | 1 + ...anomaly_types_Non_Alpha_Prefixed_Name.yaml | 1 + ...file_anomaly_types_Non_Printing_Chars.yaml | 1 + ...ile_anomaly_types_Non_Standard_Blanks.yaml | 1 + ...le_anomaly_types_Potential_Duplicates.yaml | 1 + .../profile_anomaly_types_Potential_PII.yaml | 1 + .../profile_anomaly_types_Quoted_Values.yaml | 1 + ...rofile_anomaly_types_Recency_One_Year.yaml | 1 + ...file_anomaly_types_Recency_Six_Months.yaml | 1 + ...nomaly_types_Small_Divergent_Value_Ct.yaml | 1 + ..._anomaly_types_Small_Missing_Value_Ct.yaml | 1 + ..._anomaly_types_Small_Numeric_Value_Ct.yaml | 1 + ...maly_types_Standardized_Value_Matches.yaml | 1 + .../profile_anomaly_types_Suggested_Type.yaml | 1 + ..._anomaly_types_Table_Pattern_Mismatch.yaml | 1 + ...ofile_anomaly_types_Unexpected_Emails.yaml | 1 + ...le_anomaly_types_Unexpected_US_States.yaml | 1 + ...le_anomaly_types_Unlikely_Date_Values.yaml | 1 + ...le_anomaly_types_Variant_Coded_Values.yaml | 1 + .../test_types_Aggregate_Balance.yaml | 1 + .../test_types_Aggregate_Balance_Percent.yaml | 1 + .../test_types_Aggregate_Balance_Range.yaml | 1 + .../test_types_Aggregate_Minimum.yaml | 1 + .../test_types_Alpha_Trunc.yaml | 1 + .../test_types_Avg_Shift.yaml | 1 + .../dbsetup_test_types/test_types_CUSTOM.yaml | 1 + .../test_types_Combo_Match.yaml | 1 + .../test_types_Condition_Flag.yaml | 1 + .../test_types_Constant.yaml | 1 + .../test_types_Daily_Record_Ct.yaml | 1 + .../test_types_Dec_Trunc.yaml | 1 + .../test_types_Distinct_Date_Ct.yaml | 1 + .../test_types_Distinct_Value_Ct.yaml | 1 + .../test_types_Distribution_Shift.yaml | 1 + .../test_types_Dupe_Rows.yaml | 1 + .../test_types_Email_Format.yaml | 1 + .../test_types_Freshness_Trend.yaml | 1 + .../test_types_Future_Date.yaml | 1 + .../test_types_Future_Date_1Y.yaml | 1 + .../test_types_Incr_Avg_Shift.yaml | 1 + .../test_types_LOV_All.yaml | 1 + .../test_types_LOV_Match.yaml | 1 + .../test_types_Metric_Trend.yaml | 1 + .../test_types_Min_Date.yaml | 1 + .../test_types_Min_Val.yaml | 1 + .../test_types_Missing_Pct.yaml | 1 + .../test_types_Monthly_Rec_Ct.yaml | 1 + .../test_types_Outlier_Pct_Above.yaml | 1 + .../test_types_Outlier_Pct_Below.yaml | 1 + .../test_types_Pattern_Match.yaml | 1 + .../test_types_Recency.yaml | 1 + .../test_types_Required.yaml | 1 + .../dbsetup_test_types/test_types_Row_Ct.yaml | 1 + .../test_types_Row_Ct_Pct.yaml | 1 + .../test_types_Schema_Drift.yaml | 1 + .../test_types_Street_Addr_Pattern.yaml | 1 + .../test_types_Table_Freshness.yaml | 1 + .../test_types_Timeframe_Combo_Gain.yaml | 1 + .../test_types_Timeframe_Combo_Match.yaml | 1 + .../test_types_US_State.yaml | 1 + .../dbsetup_test_types/test_types_Unique.yaml | 1 + .../test_types_Unique_Pct.yaml | 1 + .../test_types_Valid_Characters.yaml | 1 + .../test_types_Valid_Month.yaml | 1 + .../test_types_Valid_US_Zip.yaml | 1 + .../test_types_Valid_US_Zip3.yaml | 1 + .../test_types_Variability_Decrease.yaml | 1 + .../test_types_Variability_Increase.yaml | 1 + .../test_types_Volume_Trend.yaml | 1 + .../test_types_Weekly_Rec_Ct.yaml | 1 + .../dbupgrade/0187_incremental_upgrade.sql | 73 ++++ .../execution/update_test_results.sql | 3 +- .../profile_anomalies_screen_column.sql | 6 +- .../profile_anomalies_screen_multi_column.sql | 5 +- .../profile_anomalies_screen_table.sql | 8 +- .../profile_anomalies_screen_table_dates.sql | 5 +- .../profile_anomalies_screen_variants.sql | 5 +- ...et_category_scores_by_impact_dimension.sql | 20 ++ ...ore_card_breakdown_by_impact_dimension.sql | 53 +++ ..._score_card_issues_by_impact_dimension.sql | 98 ++++++ .../frontend/js/pages/score_details.js | 4 +- .../frontend/js/pages/score_explorer.js | 20 +- .../frontend/js/pages/test_definitions.js | 25 +- testgen/ui/pdf/hygiene_issue_report.py | 1 + testgen/ui/pdf/test_result_report.py | 1 + testgen/ui/queries/profiling_queries.py | 6 +- testgen/ui/queries/scoring_queries.py | 9 +- testgen/ui/queries/test_result_queries.py | 4 +- .../static/js/components/score_breakdown.js | 2 + testgen/ui/views/hygiene_issues.py | 2 + testgen/ui/views/score_details.py | 2 +- testgen/ui/views/score_explorer.py | 6 +- testgen/ui/views/test_definitions.py | 2 + testgen/ui/views/test_results.py | 1 + testgen/utils/__init__.py | 1 + .../common/models/test_impact_dimension.py | 317 ++++++++++++++++++ 114 files changed, 905 insertions(+), 43 deletions(-) create mode 100644 testgen/template/dbupgrade/0187_incremental_upgrade.sql create mode 100644 testgen/template/score_cards/get_category_scores_by_impact_dimension.sql create mode 100644 testgen/template/score_cards/get_score_card_breakdown_by_impact_dimension.sql create mode 100644 testgen/template/score_cards/get_score_card_issues_by_impact_dimension.sql create mode 100644 tests/unit/common/models/test_impact_dimension.py diff --git a/testgen/common/models/scores.py b/testgen/common/models/scores.py index f9901719..b5fc9545 100644 --- a/testgen/common/models/scores.py +++ b/testgen/common/models/scores.py @@ -24,6 +24,7 @@ "column_name", "table_name", "dq_dimension", + "impact_dimension", "semantic_data_type", "table_groups_name", "data_location", @@ -39,6 +40,7 @@ "column_name", "table_name", "dq_dimension", + "impact_dimension", "semantic_data_type", "table_groups_name", "data_location", @@ -67,6 +69,7 @@ class ScoreCategory(enum.Enum): stakeholder_group = "stakeholder_group" transform_level = "transform_level" dq_dimension = "dq_dimension" + impact_dimension = "impact_dimension" data_product = "data_product" @@ -117,7 +120,7 @@ def from_table_group(cls, table_group: TableGroup) -> Self: definition.name = table_group.table_groups_name definition.total_score = True definition.cde_score = True - definition.category = ScoreCategory.dq_dimension + definition.category = ScoreCategory.impact_dimension definition.criteria = ScoreDefinitionCriteria( operand="AND", filters=[ @@ -245,6 +248,8 @@ def as_score_card(self) -> ScoreCard: categories_query_template_file = "get_category_scores_by_column.sql" if self.category == ScoreCategory.dq_dimension: categories_query_template_file = "get_category_scores_by_dimension.sql" + elif self.category == ScoreCategory.impact_dimension: + categories_query_template_file = "get_category_scores_by_impact_dimension.sql" filters = " AND ".join(self._get_raw_query_filters()) overall_scores = get_current_session().execute( @@ -330,6 +335,8 @@ def get_score_card_breakdown( query_template_file = "get_score_card_breakdown_by_column.sql" if group_by == "dq_dimension": query_template_file = "get_score_card_breakdown_by_dimension.sql" + elif group_by == "impact_dimension": + query_template_file = "get_score_card_breakdown_by_impact_dimension.sql" columns = { "table_name": ["table_groups_id", "table_name"], @@ -341,7 +348,7 @@ def get_score_card_breakdown( join_condition = " AND ".join([f"test_records.{column} = profiling_records.{column}" for column in columns]) else: join_condition = f"""(test_records.{group_by} = profiling_records.{group_by} - OR (test_records.{group_by} IS NULL + OR (test_records.{group_by} IS NULL AND profiling_records.{group_by} IS NULL))""" profile_records_filters = self._get_raw_query_filters( @@ -387,6 +394,8 @@ def get_score_card_issues( query_template_file = "get_score_card_issues_by_column.sql" if group_by == "dq_dimension": query_template_file = "get_score_card_issues_by_dimension.sql" + elif group_by == "impact_dimension": + query_template_file = "get_score_card_issues_by_impact_dimension.sql" value_ = value filters = self._get_raw_query_filters(cde_only=score_type == "cde_score") @@ -409,6 +418,15 @@ def get_score_card_issues( dq_dimension_filter = ( " AND dq_dimension IS NULL" if is_null_drilldown else " AND dq_dimension = :value" ) + profiling_impact_dimension_filter = "" + test_impact_dimension_filter = "" + if group_by == "impact_dimension": + profiling_impact_dimension_filter = ( + " AND types.impact_dimension IS NULL" if is_null_drilldown else " AND types.impact_dimension = :value" + ) + test_impact_dimension_filter = ( + " AND test_results.impact_dimension IS NULL" if is_null_drilldown else " AND test_results.impact_dimension = :value" + ) query = ( read_template_sql_file(query_template_file, sub_directory="score_cards") @@ -416,6 +434,8 @@ def get_score_card_issues( .replace("{value_filter}", value_filter) .replace("{group_by}", group_by) .replace("{dq_dimension_filter}", dq_dimension_filter) + .replace("{profiling_impact_dimension_filter}", profiling_impact_dimension_filter) + .replace("{test_impact_dimension_filter}", test_impact_dimension_filter) ) params = {} if is_null_drilldown else {"value": value_} results = get_current_session().execute(text(query), params).mappings().all() @@ -639,6 +659,7 @@ class ScoreDefinitionBreakdownItem(Base): table_name: str = Column(String, nullable=True) column_name: str = Column(String, nullable=True) dq_dimension: str = Column(String, nullable=True) + impact_dimension: str = Column(String, nullable=True) semantic_data_type: str = Column(String, nullable=True) table_groups_name: str = Column(String, nullable=True) data_location: str = Column(String, nullable=True) diff --git a/testgen/common/models/test_definition.py b/testgen/common/models/test_definition.py index c4c2ca10..72e2fcce 100644 --- a/testgen/common/models/test_definition.py +++ b/testgen/common/models/test_definition.py @@ -72,6 +72,7 @@ class TestTypeSummary(ParamFieldsMixin, EntityMinimal): default_severity: str test_scope: TestScope dq_dimension: str + default_impact_dimension: str usage_notes: str @@ -122,6 +123,7 @@ class TestDefinitionSummary(TestTypeSummary): export_to_observability: bool prediction: dict[str, dict[str, float]] | None flagged: bool + impact_dimension: str | None @property def display_name(self) -> str: @@ -182,6 +184,7 @@ class TestType(ParamFieldsMixin, Entity): run_type: TestRunType = Column(String) test_scope: TestScope = Column(String) dq_dimension: str = Column(String) + impact_dimension: str = Column(String) health_dimension: str = Column(String) threshold_description: str = Column(String) usage_notes: str = Column(String) @@ -254,6 +257,7 @@ class TestDefinition(Entity): prediction: dict[str, dict[str, float]] | None = Column(postgresql.JSONB) flagged: bool = Column(Boolean, default=False, nullable=False) external_id: UUID | None = Column(postgresql.UUID(as_uuid=True)) + impact_dimension: str | None = Column(String, nullable=True) _default_order_by = ( asc(func.lower(schema_name)), @@ -263,8 +267,9 @@ class TestDefinition(Entity): ) _summary_columns = ( *TestDefinitionSummary.__annotations__.keys(), - *[key for key in TestTypeSummary.__annotations__.keys() if key != "default_test_description"], + *[key for key in TestTypeSummary.__annotations__.keys() if key not in ("default_test_description", "default_impact_dimension")], TestType.test_description.label("default_test_description"), + TestType.impact_dimension.label("default_impact_dimension"), ) _minimal_columns = TestDefinitionMinimal.__annotations__.keys() _update_exclude_columns = ( diff --git a/testgen/template/dbsetup/030_initialize_new_schema_structure.sql b/testgen/template/dbsetup/030_initialize_new_schema_structure.sql index 1b8cc2e8..1e7217df 100644 --- a/testgen/template/dbsetup/030_initialize_new_schema_structure.sql +++ b/testgen/template/dbsetup/030_initialize_new_schema_structure.sql @@ -242,6 +242,7 @@ CREATE TABLE test_definitions ( export_to_observability VARCHAR(5), flagged BOOLEAN DEFAULT FALSE NOT NULL, external_id UUID, + impact_dimension VARCHAR(20), CONSTRAINT test_definitions_test_suites_test_suite_id_fk FOREIGN KEY (test_suite_id) REFERENCES test_suites ); @@ -351,7 +352,8 @@ CREATE TABLE profile_anomaly_types ( suggested_action VARCHAR(1000), dq_score_prevalence_formula TEXT, dq_score_risk_factor TEXT, - dq_dimension VARCHAR(50) + dq_dimension VARCHAR(50), + impact_dimension VARCHAR(20) ); CREATE TABLE profile_anomaly_results ( @@ -370,7 +372,8 @@ CREATE TABLE profile_anomaly_results ( anomaly_id VARCHAR(10), detail VARCHAR, disposition VARCHAR(20), -- Confirmed, Dismissed, Inactive - dq_prevalence FLOAT + dq_prevalence FLOAT, + impact_dimension VARCHAR(20) ); @@ -572,6 +575,7 @@ CREATE TABLE test_types ( run_type VARCHAR(10), test_scope VARCHAR, dq_dimension VARCHAR(50), + impact_dimension VARCHAR(20), health_dimension VARCHAR(50), threshold_description VARCHAR(200), result_visualization VARCHAR(50) DEFAULT 'line_chart', @@ -656,6 +660,7 @@ CREATE TABLE test_results ( dq_prevalence FLOAT, dq_record_ct BIGINT, observability_status VARCHAR(10), + impact_dimension VARCHAR(20), CONSTRAINT test_results_test_suites_project_code_test_suite_fk FOREIGN KEY (test_suite_id) REFERENCES test_suites ); @@ -835,6 +840,7 @@ CREATE TABLE IF NOT EXISTS score_definition_results_breakdown ( table_name TEXT DEFAULT NULL, column_name TEXT DEFAULT NULL, dq_dimension TEXT DEFAULT NULL, + impact_dimension TEXT DEFAULT NULL, semantic_data_type TEXT DEFAULT NULL, table_groups_name TEXT DEFAULT NULL, data_location TEXT DEFAULT NULL, diff --git a/testgen/template/dbsetup/050_populate_new_schema_metadata.sql b/testgen/template/dbsetup/050_populate_new_schema_metadata.sql index 4c7d0b79..32d329a6 100644 --- a/testgen/template/dbsetup/050_populate_new_schema_metadata.sql +++ b/testgen/template/dbsetup/050_populate_new_schema_metadata.sql @@ -6,9 +6,9 @@ SET SEARCH_PATH TO {SCHEMA_NAME}; -- Drop constraints that prohibit record deletion -ALTER TABLE test_templates DROP CONSTRAINT test_templates_test_types_test_type_fk; -ALTER TABLE test_results DROP CONSTRAINT test_results_test_types_test_type_fk; -ALTER TABLE cat_test_conditions DROP CONSTRAINT cat_test_conditions_cat_tests_test_type_fk; +ALTER TABLE test_templates DROP CONSTRAINT IF EXISTS test_templates_test_types_test_type_fk; +ALTER TABLE test_results DROP CONSTRAINT IF EXISTS test_results_test_types_test_type_fk; +ALTER TABLE cat_test_conditions DROP CONSTRAINT IF EXISTS cat_test_conditions_cat_tests_test_type_fk; TRUNCATE TABLE profile_anomaly_types; diff --git a/testgen/template/dbsetup/060_create_standard_views.sql b/testgen/template/dbsetup/060_create_standard_views.sql index 53f0d85c..fffaa445 100644 --- a/testgen/template/dbsetup/060_create_standard_views.sql +++ b/testgen/template/dbsetup/060_create_standard_views.sql @@ -351,6 +351,144 @@ GROUP BY r.table_groups_id, r.test_run_id, r.test_suite_id, tg.project_code; +DROP VIEW IF EXISTS v_dq_profile_scoring_latest_by_impact_dimension; + +CREATE VIEW v_dq_profile_scoring_latest_by_impact_dimension +AS +SELECT tg.project_code, + pr.table_groups_id, + pr.profile_run_id, + tg.table_groups_name, + tg.data_location, + COALESCE(dcc.data_source, dtc.data_source, tg.data_source) as data_source, + COALESCE(dcc.source_system, dtc.source_system, tg.source_system) as source_system, + COALESCE(dcc.source_process, dtc.source_process, tg.source_process) as source_process, + COALESCE(dcc.business_domain, dtc.business_domain, tg.business_domain) as business_domain, + COALESCE(dcc.stakeholder_group, dtc.stakeholder_group, tg.stakeholder_group) as stakeholder_group, + COALESCE(dcc.transform_level, dtc.transform_level, tg.transform_level) as transform_level, + COALESCE(dcc.critical_data_element, dtc.critical_data_element) as critical_data_element, + COALESCE(dcc.data_product, dtc.data_product, tg.data_product) as data_product, + dcc.functional_data_type as semantic_data_type, + t.impact_dimension, + pr.table_name, + pr.column_name, + pr.run_date, + MAX(pr.record_ct) as record_ct, + MAX(pr.record_ct + * CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END + ) AS weighted_record_ct, + COUNT(p.anomaly_id) as issue_ct, + SUM_LN(COALESCE(p.dq_prevalence, 0.0)) as good_data_pct + FROM profile_results pr +INNER JOIN table_groups tg + ON (pr.profile_run_id = tg.last_complete_profile_run_id) +INNER JOIN data_column_chars dcc + ON (pr.table_groups_id = dcc.table_groups_id + AND pr.table_name = dcc.table_name + AND pr.column_name = dcc.column_name) +INNER JOIN data_table_chars dtc + ON (dcc.table_id = dtc.table_id) +INNER JOIN projects proj + ON (tg.project_code = proj.project_code) +LEFT JOIN (profile_anomaly_results p + INNER JOIN profile_anomaly_types t + ON p.anomaly_id = t.id) + ON (pr.profile_run_id = p.profile_run_id + AND pr.column_name = p.column_name + AND pr.table_name = p.table_name) +WHERE (p.disposition = 'Confirmed' OR p.disposition IS NULL) + AND dcc.drop_date IS NULL +GROUP BY pr.profile_run_id, pr.table_groups_id, + pr.table_name, pr.column_name, + tg.table_groups_name, tg.data_location, + COALESCE(dcc.data_source, dtc.data_source, tg.data_source), + COALESCE(dcc.source_system, dtc.source_system, tg.source_system), + COALESCE(dcc.source_process, dtc.source_process, tg.source_process), + COALESCE(dcc.business_domain, dtc.business_domain, tg.business_domain), + COALESCE(dcc.stakeholder_group, dtc.stakeholder_group, tg.stakeholder_group), + COALESCE(dcc.transform_level, dtc.transform_level, tg.transform_level), + COALESCE(dcc.critical_data_element, dtc.critical_data_element), + COALESCE(dcc.data_product, dtc.data_product, tg.data_product), + dcc.functional_data_type, t.impact_dimension, pr.run_date, + tg.project_code; + + +DROP VIEW IF EXISTS v_dq_test_scoring_latest_by_impact_dimension; + +CREATE VIEW v_dq_test_scoring_latest_by_impact_dimension +AS +WITH impact_dimension_rollup + AS (SELECT r.test_run_id, r.test_suite_id, r.table_groups_id, r.test_time, + r.table_name, r.column_names, r.impact_dimension, + COUNT(*) as test_ct, + SUM(CASE WHEN r.result_code = 1 THEN 1 ELSE 0 END) as passed_ct, + SUM(CASE WHEN r.result_code = 0 THEN 1 ELSE 0 END) as issue_ct, + MAX(r.dq_record_ct) as dq_record_ct, + SUM_LN(COALESCE(r.dq_prevalence::NUMERIC, 0)) as good_data_pct + FROM test_results r + INNER JOIN test_suites s + ON (r.test_run_id = s.last_complete_test_run_id) + WHERE r.dq_prevalence IS NOT NULL + AND s.dq_score_exclude = FALSE + AND COALESCE(r.disposition, 'Confirmed') = 'Confirmed' + GROUP BY r.test_run_id, r.test_suite_id, r.table_groups_id, r.test_time, + r.table_name, r.column_names, r.impact_dimension) +SELECT + tg.project_code, + r.table_groups_id, + r.test_suite_id, + r.test_run_id, + tg.table_groups_name, + tg.data_location, + COALESCE(dcc.data_source, dtc.data_source, tg.data_source) as data_source, + COALESCE(dcc.source_system, dtc.source_system, tg.source_system) as source_system, + COALESCE(dcc.source_process, dtc.source_process, tg.source_process) as source_process, + COALESCE(dcc.business_domain, dtc.business_domain, tg.business_domain) as business_domain, + COALESCE(dcc.stakeholder_group, dtc.stakeholder_group, tg.stakeholder_group) as stakeholder_group, + COALESCE(dcc.transform_level, dtc.transform_level, tg.transform_level) as transform_level, + COALESCE(dcc.critical_data_element, dtc.critical_data_element) as critical_data_element, + COALESCE(dcc.data_product, dtc.data_product, tg.data_product) as data_product, + dcc.functional_data_type as semantic_data_type, + r.impact_dimension, + r.test_time, r.table_name, dcc.column_name, + SUM(r.test_ct) as test_ct, + SUM(r.passed_ct) as passed_ct, + SUM(r.issue_ct) as issue_ct, + MAX(r.dq_record_ct) as dq_record_ct, + MAX(r.dq_record_ct + * CASE WHEN proj.use_dq_score_weights + THEN COALESCE(dtc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_weight, 1.0) * COALESCE(dcc.dq_score_pii_weight, 1.0) + ELSE 1.0 END + ) AS weighted_dq_record_ct, + SUM_LN(COALESCE(1.0-r.good_data_pct, 0)) as good_data_pct + FROM impact_dimension_rollup r +INNER JOIN table_groups tg + ON r.table_groups_id = tg.id +INNER JOIN projects proj + ON (tg.project_code = proj.project_code) +LEFT JOIN data_table_chars dtc + ON (r.table_groups_id = dtc.table_groups_id + AND r.table_name = dtc.table_name) +LEFT JOIN data_column_chars dcc + ON (r.table_groups_id = dcc.table_groups_id + AND r.table_name = dcc.table_name + AND r.column_names = dcc.column_name) +WHERE dcc.drop_date IS NULL +GROUP BY r.table_groups_id, r.test_run_id, r.test_suite_id, + tg.table_groups_name, dcc.data_source, dtc.data_source, + tg.data_source, tg.data_location, dcc.data_source, dtc.data_source, + tg.data_source, dcc.source_system, dtc.source_system, tg.source_system, + dcc.source_process, dtc.source_process, tg.source_process, dcc.business_domain, + dtc.business_domain, tg.business_domain, dcc.stakeholder_group, dtc.stakeholder_group, + tg.stakeholder_group, dcc.transform_level, dtc.transform_level, tg.transform_level, + dcc.critical_data_element, dtc.critical_data_element, + dcc.data_product, dtc.data_product, tg.data_product, + dcc.functional_data_type, r.impact_dimension, r.test_time, r.table_name, dcc.column_name, + tg.project_code; + + -- ============================================================================== -- | Scoring History Views -- ============================================================================== diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Boolean_Value_Mismatch.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Boolean_Value_Mismatch.yaml index 1f184a75..c35be242 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Boolean_Value_Mismatch.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Boolean_Value_Mismatch.yaml @@ -23,6 +23,7 @@ profile_anomaly_types: dq_score_prevalence_formula: null dq_score_risk_factor: '0.66' dq_dimension: Validity + impact_dimension: Usability target_data_lookups: - id: '1353' test_id: '1015' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Char_Column_Date_Values.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Char_Column_Date_Values.yaml index 7e25517d..a4a44110 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Char_Column_Date_Values.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Char_Column_Date_Values.yaml @@ -21,6 +21,7 @@ profile_anomaly_types: p.date_ct::FLOAT/NULLIF(p.record_ct, 0)::FLOAT dq_score_risk_factor: '0.66' dq_dimension: Validity + impact_dimension: Usability target_data_lookups: - id: '1350' test_id: '1012' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Char_Column_Number_Units.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Char_Column_Number_Units.yaml index da49a9c1..6c1a683f 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Char_Column_Number_Units.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Char_Column_Number_Units.yaml @@ -15,4 +15,5 @@ profile_anomaly_types: dq_score_prevalence_formula: null dq_score_risk_factor: '0.33' dq_dimension: Consistency + impact_dimension: Usability target_data_lookups: [] diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Char_Column_Number_Values.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Char_Column_Number_Values.yaml index d5d5ce14..e23891b6 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Char_Column_Number_Values.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Char_Column_Number_Values.yaml @@ -21,6 +21,7 @@ profile_anomaly_types: p.numeric_ct::FLOAT/NULLIF(p.record_ct, 0)::FLOAT dq_score_risk_factor: '0.66' dq_dimension: Validity + impact_dimension: Usability target_data_lookups: - id: '1349' test_id: '1011' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Column_Pattern_Mismatch.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Column_Pattern_Mismatch.yaml index 00e37271..87441e8a 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Column_Pattern_Mismatch.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Column_Pattern_Mismatch.yaml @@ -28,6 +28,7 @@ profile_anomaly_types: (p.record_ct - SPLIT_PART(p.top_patterns, '|', 1)::BIGINT)::FLOAT/NULLIF(p.record_ct, 0)::FLOAT dq_score_risk_factor: '0.66' dq_dimension: Validity + impact_dimension: Usability target_data_lookups: - id: '1345' test_id: '1007' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Delimited_Data_Embedded.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Delimited_Data_Embedded.yaml index caf0ea32..570ed5ad 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Delimited_Data_Embedded.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Delimited_Data_Embedded.yaml @@ -16,6 +16,7 @@ profile_anomaly_types: dq_score_prevalence_formula: null dq_score_risk_factor: '0.66' dq_dimension: Validity + impact_dimension: Usability target_data_lookups: - id: '1363' test_id: '1025' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Inconsistent_Casing.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Inconsistent_Casing.yaml index c6f5e139..176a3565 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Inconsistent_Casing.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Inconsistent_Casing.yaml @@ -16,6 +16,7 @@ profile_anomaly_types: LEAST(p.mixed_case_ct, p.upper_case_ct)::FLOAT/NULLIF(p.record_ct, 0)::FLOAT dq_score_risk_factor: '1.0' dq_dimension: Validity + impact_dimension: Usability target_data_lookups: - id: '1410' test_id: '1028' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Invalid_Zip3_USA.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Invalid_Zip3_USA.yaml index 8f8215c0..ed042ca9 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Invalid_Zip3_USA.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Invalid_Zip3_USA.yaml @@ -19,6 +19,7 @@ profile_anomaly_types: (NULLIF(p.record_ct, 0)::INT - SPLIT_PART(p.top_patterns, ' | ', 1)::BIGINT)::FLOAT/NULLIF(p.record_ct, 0)::FLOAT dq_score_risk_factor: '1' dq_dimension: Validity + impact_dimension: Conformance target_data_lookups: - id: '1362' test_id: '1024' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Invalid_Zip_USA.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Invalid_Zip_USA.yaml index a4aeaa62..2e13f4a8 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Invalid_Zip_USA.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Invalid_Zip_USA.yaml @@ -15,6 +15,7 @@ profile_anomaly_types: dq_score_prevalence_formula: null dq_score_risk_factor: '1.0' dq_dimension: Validity + impact_dimension: Conformance target_data_lookups: - id: '1341' test_id: '1003' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Leading_Spaces.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Leading_Spaces.yaml index 3f74cb98..d63a84e1 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Leading_Spaces.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Leading_Spaces.yaml @@ -16,6 +16,7 @@ profile_anomaly_types: p.lead_space_ct::FLOAT/NULLIF(p.record_ct, 0)::FLOAT dq_score_risk_factor: '0.66' dq_dimension: Validity + impact_dimension: Usability target_data_lookups: - id: '1347' test_id: '1009' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Multiple_Types_Major.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Multiple_Types_Major.yaml index ddb6b8d6..cb4fa797 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Multiple_Types_Major.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Multiple_Types_Major.yaml @@ -15,6 +15,7 @@ profile_anomaly_types: dq_score_prevalence_formula: null dq_score_risk_factor: null dq_dimension: Consistency + impact_dimension: Usability target_data_lookups: - id: '1343' test_id: '1005' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Multiple_Types_Minor.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Multiple_Types_Minor.yaml index 17df28f3..80e9fa80 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Multiple_Types_Minor.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Multiple_Types_Minor.yaml @@ -15,6 +15,7 @@ profile_anomaly_types: dq_score_prevalence_formula: null dq_score_risk_factor: null dq_dimension: Consistency + impact_dimension: Usability target_data_lookups: - id: '1342' test_id: '1004' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_No_Values.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_No_Values.yaml index 0580df8c..46c6f955 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_No_Values.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_No_Values.yaml @@ -18,6 +18,7 @@ profile_anomaly_types: 1.0 dq_score_risk_factor: '0.33' dq_dimension: Completeness + impact_dimension: Conformance target_data_lookups: - id: '1344' test_id: '1006' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Non_Alpha_Name_Address.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Non_Alpha_Name_Address.yaml index 47297f76..820e6423 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Non_Alpha_Name_Address.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Non_Alpha_Name_Address.yaml @@ -16,6 +16,7 @@ profile_anomaly_types: (non_alpha_ct - zero_length_ct)::FLOAT/NULLIF(p.record_ct, 0)::FLOAT dq_score_risk_factor: '1.0' dq_dimension: Validity + impact_dimension: Conformance target_data_lookups: - id: '1411' test_id: '1029' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Non_Alpha_Prefixed_Name.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Non_Alpha_Prefixed_Name.yaml index 1ad2aeb0..22ed1cd9 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Non_Alpha_Prefixed_Name.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Non_Alpha_Prefixed_Name.yaml @@ -17,6 +17,7 @@ profile_anomaly_types: 0.25 dq_score_risk_factor: '1.0' dq_dimension: Validity + impact_dimension: Conformance target_data_lookups: - id: '1412' test_id: '1030' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Non_Printing_Chars.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Non_Printing_Chars.yaml index 3c2783fb..34821875 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Non_Printing_Chars.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Non_Printing_Chars.yaml @@ -16,6 +16,7 @@ profile_anomaly_types: non_printing_ct::FLOAT/NULLIF(p.record_ct, 0)::FLOAT dq_score_risk_factor: '1.0' dq_dimension: Validity + impact_dimension: Usability target_data_lookups: - id: '1277' test_id: '1031' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Non_Standard_Blanks.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Non_Standard_Blanks.yaml index b68be96d..4e1c104b 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Non_Standard_Blanks.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Non_Standard_Blanks.yaml @@ -16,6 +16,7 @@ profile_anomaly_types: p.filled_value_ct::FLOAT/NULLIF(p.record_ct, 0)::FLOAT dq_score_risk_factor: '1.0' dq_dimension: Completeness + impact_dimension: Usability target_data_lookups: - id: '1340' test_id: '1002' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Potential_Duplicates.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Potential_Duplicates.yaml index b135f21a..46383270 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Potential_Duplicates.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Potential_Duplicates.yaml @@ -17,6 +17,7 @@ profile_anomaly_types: (p.value_ct - p.distinct_value_ct)::FLOAT/NULLIF(p.record_ct, 0)::FLOAT dq_score_risk_factor: '0.33' dq_dimension: Uniqueness + impact_dimension: Regularity target_data_lookups: - id: '1354' test_id: '1016' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Potential_PII.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Potential_PII.yaml index e5742a68..c33bfae9 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Potential_PII.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Potential_PII.yaml @@ -16,6 +16,7 @@ profile_anomaly_types: dq_score_risk_factor: CASE LEFT(p.pii_flag, 1) WHEN 'A' THEN 1 WHEN 'B' THEN 0.66 WHEN 'C' THEN 0.33 END dq_dimension: Validity + impact_dimension: Conformance target_data_lookups: - id: '1408' test_id: '1100' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Quoted_Values.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Quoted_Values.yaml index 7c91fc79..b7ac31bc 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Quoted_Values.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Quoted_Values.yaml @@ -16,6 +16,7 @@ profile_anomaly_types: p.quoted_value_ct::FLOAT/NULLIF(p.record_ct, 0)::FLOAT dq_score_risk_factor: '0.66' dq_dimension: Validity + impact_dimension: Usability target_data_lookups: - id: '1348' test_id: '1010' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Recency_One_Year.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Recency_One_Year.yaml index 53a16368..d24286ca 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Recency_One_Year.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Recency_One_Year.yaml @@ -16,6 +16,7 @@ profile_anomaly_types: dq_score_prevalence_formula: null dq_score_risk_factor: null dq_dimension: Timeliness + impact_dimension: Regularity target_data_lookups: - id: '1357' test_id: '1019' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Recency_Six_Months.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Recency_Six_Months.yaml index 00467a7d..a94f7474 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Recency_Six_Months.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Recency_Six_Months.yaml @@ -16,6 +16,7 @@ profile_anomaly_types: dq_score_prevalence_formula: null dq_score_risk_factor: null dq_dimension: Timeliness + impact_dimension: Regularity target_data_lookups: - id: '1358' test_id: '1020' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Small_Divergent_Value_Ct.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Small_Divergent_Value_Ct.yaml index 39841b8e..25c6065a 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Small_Divergent_Value_Ct.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Small_Divergent_Value_Ct.yaml @@ -21,6 +21,7 @@ profile_anomaly_types: (p.record_ct - fn_parsefreq(p.top_freq_values, 1, 2)::BIGINT)::FLOAT/NULLIF(p.record_ct, 0)::FLOAT dq_score_risk_factor: '0.33' dq_dimension: Validity + impact_dimension: Regularity target_data_lookups: - id: '1286' test_id: '1014' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Small_Missing_Value_Ct.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Small_Missing_Value_Ct.yaml index 5a0d5ac8..b8093ab0 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Small_Missing_Value_Ct.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Small_Missing_Value_Ct.yaml @@ -24,6 +24,7 @@ profile_anomaly_types: (p.null_value_ct + filled_value_ct + zero_length_ct)::FLOAT/NULLIF(p.record_ct, 0)::FLOAT dq_score_risk_factor: '0.33' dq_dimension: Completeness + impact_dimension: Regularity target_data_lookups: - id: '1285' test_id: '1013' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Small_Numeric_Value_Ct.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Small_Numeric_Value_Ct.yaml index b205e34d..0b868784 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Small_Numeric_Value_Ct.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Small_Numeric_Value_Ct.yaml @@ -18,6 +18,7 @@ profile_anomaly_types: p.numeric_ct::FLOAT/NULLIF(p.record_ct, 0)::FLOAT dq_score_risk_factor: '0.66' dq_dimension: Validity + impact_dimension: Regularity target_data_lookups: - id: '1361' test_id: '1023' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Standardized_Value_Matches.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Standardized_Value_Matches.yaml index 0d0c3a3c..870862a4 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Standardized_Value_Matches.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Standardized_Value_Matches.yaml @@ -17,6 +17,7 @@ profile_anomaly_types: (p.distinct_value_ct - p.distinct_std_value_ct)::FLOAT/NULLIF(p.value_ct, 0) dq_score_risk_factor: '0.66' dq_dimension: Uniqueness + impact_dimension: Usability target_data_lookups: - id: '1355' test_id: '1017' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Suggested_Type.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Suggested_Type.yaml index 551391eb..b623888b 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Suggested_Type.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Suggested_Type.yaml @@ -17,6 +17,7 @@ profile_anomaly_types: dq_score_prevalence_formula: null dq_score_risk_factor: null dq_dimension: null + impact_dimension: Usability target_data_lookups: - id: '1339' test_id: '1001' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Table_Pattern_Mismatch.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Table_Pattern_Mismatch.yaml index 0a917305..d72d9875 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Table_Pattern_Mismatch.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Table_Pattern_Mismatch.yaml @@ -22,6 +22,7 @@ profile_anomaly_types: dq_score_prevalence_formula: null dq_score_risk_factor: null dq_dimension: Validity + impact_dimension: Usability target_data_lookups: - id: '1346' test_id: '1008' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Unexpected_Emails.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Unexpected_Emails.yaml index ced5139f..9c9dd4f8 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Unexpected_Emails.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Unexpected_Emails.yaml @@ -17,6 +17,7 @@ profile_anomaly_types: dq_score_prevalence_formula: null dq_score_risk_factor: '0.33' dq_dimension: Consistency + impact_dimension: Conformance target_data_lookups: - id: '1360' test_id: '1022' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Unexpected_US_States.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Unexpected_US_States.yaml index b98e4d61..b86117ab 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Unexpected_US_States.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Unexpected_US_States.yaml @@ -19,6 +19,7 @@ profile_anomaly_types: dq_score_prevalence_formula: null dq_score_risk_factor: '0.33' dq_dimension: Consistency + impact_dimension: Conformance target_data_lookups: - id: '1359' test_id: '1021' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Unlikely_Date_Values.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Unlikely_Date_Values.yaml index 84d3bc5b..c5f9c540 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Unlikely_Date_Values.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Unlikely_Date_Values.yaml @@ -19,6 +19,7 @@ profile_anomaly_types: (COALESCE(p.before_100yr_date_ct,0)+COALESCE(p.distant_future_date_ct, 0))::FLOAT/NULLIF(p.record_ct, 0) dq_score_risk_factor: '0.66' dq_dimension: Accuracy + impact_dimension: Regularity target_data_lookups: - id: '1356' test_id: '1018' diff --git a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Variant_Coded_Values.yaml b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Variant_Coded_Values.yaml index a5b8519f..72265501 100644 --- a/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Variant_Coded_Values.yaml +++ b/testgen/template/dbsetup_anomaly_types/profile_anomaly_types_Variant_Coded_Values.yaml @@ -18,6 +18,7 @@ profile_anomaly_types: dq_score_prevalence_formula: null dq_score_risk_factor: null dq_dimension: Consistency + impact_dimension: Usability target_data_lookups: - id: '1396' test_id: '1027' diff --git a/testgen/template/dbsetup_test_types/test_types_Aggregate_Balance.yaml b/testgen/template/dbsetup_test_types/test_types_Aggregate_Balance.yaml index 83e0ec45..89882477 100644 --- a/testgen/template/dbsetup_test_types/test_types_Aggregate_Balance.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Aggregate_Balance.yaml @@ -28,6 +28,7 @@ test_types: run_type: QUERY test_scope: referential dq_dimension: Consistency + impact_dimension: Reliability health_dimension: Data Drift threshold_description: |- Expected count of group totals not matching aggregate value diff --git a/testgen/template/dbsetup_test_types/test_types_Aggregate_Balance_Percent.yaml b/testgen/template/dbsetup_test_types/test_types_Aggregate_Balance_Percent.yaml index 59b127bb..b15b0114 100644 --- a/testgen/template/dbsetup_test_types/test_types_Aggregate_Balance_Percent.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Aggregate_Balance_Percent.yaml @@ -28,6 +28,7 @@ test_types: run_type: QUERY test_scope: referential dq_dimension: Consistency + impact_dimension: Reliability health_dimension: Data Drift threshold_description: |- Expected count of group totals not matching aggregate value diff --git a/testgen/template/dbsetup_test_types/test_types_Aggregate_Balance_Range.yaml b/testgen/template/dbsetup_test_types/test_types_Aggregate_Balance_Range.yaml index c868d3cd..1fe4cdc4 100644 --- a/testgen/template/dbsetup_test_types/test_types_Aggregate_Balance_Range.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Aggregate_Balance_Range.yaml @@ -28,6 +28,7 @@ test_types: run_type: QUERY test_scope: referential dq_dimension: Consistency + impact_dimension: Reliability health_dimension: Data Drift threshold_description: |- Expected count of group totals not matching aggregate value diff --git a/testgen/template/dbsetup_test_types/test_types_Aggregate_Minimum.yaml b/testgen/template/dbsetup_test_types/test_types_Aggregate_Minimum.yaml index 49e1b39a..8607dec0 100644 --- a/testgen/template/dbsetup_test_types/test_types_Aggregate_Minimum.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Aggregate_Minimum.yaml @@ -28,6 +28,7 @@ test_types: run_type: QUERY test_scope: referential dq_dimension: Accuracy + impact_dimension: Conformance health_dimension: Data Drift threshold_description: |- Expected count of group totals below aggregate value diff --git a/testgen/template/dbsetup_test_types/test_types_Alpha_Trunc.yaml b/testgen/template/dbsetup_test_types/test_types_Alpha_Trunc.yaml index 23d43989..41ab1ab7 100644 --- a/testgen/template/dbsetup_test_types/test_types_Alpha_Trunc.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Alpha_Trunc.yaml @@ -27,6 +27,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Validity + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- Maximum length expected diff --git a/testgen/template/dbsetup_test_types/test_types_Avg_Shift.yaml b/testgen/template/dbsetup_test_types/test_types_Avg_Shift.yaml index a224d3b6..49a3c5b9 100644 --- a/testgen/template/dbsetup_test_types/test_types_Avg_Shift.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Avg_Shift.yaml @@ -28,6 +28,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Consistency + impact_dimension: Regularity health_dimension: Data Drift threshold_description: |- Standardized Difference Measure diff --git a/testgen/template/dbsetup_test_types/test_types_CUSTOM.yaml b/testgen/template/dbsetup_test_types/test_types_CUSTOM.yaml index 8e752a67..3257b114 100644 --- a/testgen/template/dbsetup_test_types/test_types_CUSTOM.yaml +++ b/testgen/template/dbsetup_test_types/test_types_CUSTOM.yaml @@ -29,6 +29,7 @@ test_types: run_type: QUERY test_scope: custom dq_dimension: Accuracy + impact_dimension: Conformance health_dimension: Data Drift threshold_description: |- Expected count of errors found by custom query diff --git a/testgen/template/dbsetup_test_types/test_types_Combo_Match.yaml b/testgen/template/dbsetup_test_types/test_types_Combo_Match.yaml index 18bdde5d..cdc5bfde 100644 --- a/testgen/template/dbsetup_test_types/test_types_Combo_Match.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Combo_Match.yaml @@ -28,6 +28,7 @@ test_types: run_type: QUERY test_scope: referential dq_dimension: Validity + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- Expected count of non-matching value combinations diff --git a/testgen/template/dbsetup_test_types/test_types_Condition_Flag.yaml b/testgen/template/dbsetup_test_types/test_types_Condition_Flag.yaml index 110b2226..733ef0b5 100644 --- a/testgen/template/dbsetup_test_types/test_types_Condition_Flag.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Condition_Flag.yaml @@ -28,6 +28,7 @@ test_types: run_type: CAT test_scope: custom dq_dimension: Validity + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- Count of records that don't meet test condition diff --git a/testgen/template/dbsetup_test_types/test_types_Constant.yaml b/testgen/template/dbsetup_test_types/test_types_Constant.yaml index 7141bcfa..2bb8e6df 100644 --- a/testgen/template/dbsetup_test_types/test_types_Constant.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Constant.yaml @@ -27,6 +27,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Validity + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- Count of records with unexpected values diff --git a/testgen/template/dbsetup_test_types/test_types_Daily_Record_Ct.yaml b/testgen/template/dbsetup_test_types/test_types_Daily_Record_Ct.yaml index eeb64f32..fb9fe8bb 100644 --- a/testgen/template/dbsetup_test_types/test_types_Daily_Record_Ct.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Daily_Record_Ct.yaml @@ -27,6 +27,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Completeness + impact_dimension: Reliability health_dimension: Volume threshold_description: |- Missing calendar days within min/max range diff --git a/testgen/template/dbsetup_test_types/test_types_Dec_Trunc.yaml b/testgen/template/dbsetup_test_types/test_types_Dec_Trunc.yaml index ac988b64..e717d8fb 100644 --- a/testgen/template/dbsetup_test_types/test_types_Dec_Trunc.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Dec_Trunc.yaml @@ -28,6 +28,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Validity + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- Minimum expected sum of all fractional values diff --git a/testgen/template/dbsetup_test_types/test_types_Distinct_Date_Ct.yaml b/testgen/template/dbsetup_test_types/test_types_Distinct_Date_Ct.yaml index 1a9d8c82..4ddc1dd4 100644 --- a/testgen/template/dbsetup_test_types/test_types_Distinct_Date_Ct.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Distinct_Date_Ct.yaml @@ -28,6 +28,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Timeliness + impact_dimension: Reliability health_dimension: Recency threshold_description: |- Minimum distinct date count expected diff --git a/testgen/template/dbsetup_test_types/test_types_Distinct_Value_Ct.yaml b/testgen/template/dbsetup_test_types/test_types_Distinct_Value_Ct.yaml index ea1195ec..e7737220 100644 --- a/testgen/template/dbsetup_test_types/test_types_Distinct_Value_Ct.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Distinct_Value_Ct.yaml @@ -27,6 +27,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Validity + impact_dimension: Regularity health_dimension: Schema Drift threshold_description: |- Expected distinct value count diff --git a/testgen/template/dbsetup_test_types/test_types_Distribution_Shift.yaml b/testgen/template/dbsetup_test_types/test_types_Distribution_Shift.yaml index 6823fc52..627cd8a3 100644 --- a/testgen/template/dbsetup_test_types/test_types_Distribution_Shift.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Distribution_Shift.yaml @@ -29,6 +29,7 @@ test_types: run_type: QUERY test_scope: referential dq_dimension: Consistency + impact_dimension: Regularity health_dimension: Data Drift threshold_description: |- Expected maximum divergence level between 0 and 1 diff --git a/testgen/template/dbsetup_test_types/test_types_Dupe_Rows.yaml b/testgen/template/dbsetup_test_types/test_types_Dupe_Rows.yaml index 138abb10..57c778cc 100644 --- a/testgen/template/dbsetup_test_types/test_types_Dupe_Rows.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Dupe_Rows.yaml @@ -29,6 +29,7 @@ test_types: run_type: QUERY test_scope: table dq_dimension: Uniqueness + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- Expected count of duplicate value combinations diff --git a/testgen/template/dbsetup_test_types/test_types_Email_Format.yaml b/testgen/template/dbsetup_test_types/test_types_Email_Format.yaml index 1d49d881..ab0a8704 100644 --- a/testgen/template/dbsetup_test_types/test_types_Email_Format.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Email_Format.yaml @@ -28,6 +28,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Validity + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- Expected count of invalid email addresses diff --git a/testgen/template/dbsetup_test_types/test_types_Freshness_Trend.yaml b/testgen/template/dbsetup_test_types/test_types_Freshness_Trend.yaml index e151fa6c..1ad7fae4 100644 --- a/testgen/template/dbsetup_test_types/test_types_Freshness_Trend.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Freshness_Trend.yaml @@ -29,6 +29,7 @@ test_types: run_type: QUERY test_scope: table dq_dimension: Recency + impact_dimension: Reliability health_dimension: Recency threshold_description: |- Expected time window diff --git a/testgen/template/dbsetup_test_types/test_types_Future_Date.yaml b/testgen/template/dbsetup_test_types/test_types_Future_Date.yaml index af804c97..938091da 100644 --- a/testgen/template/dbsetup_test_types/test_types_Future_Date.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Future_Date.yaml @@ -27,6 +27,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Timeliness + impact_dimension: Conformance health_dimension: Recency threshold_description: |- Expected count of future dates diff --git a/testgen/template/dbsetup_test_types/test_types_Future_Date_1Y.yaml b/testgen/template/dbsetup_test_types/test_types_Future_Date_1Y.yaml index ae400acb..01a42a83 100644 --- a/testgen/template/dbsetup_test_types/test_types_Future_Date_1Y.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Future_Date_1Y.yaml @@ -27,6 +27,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Timeliness + impact_dimension: Conformance health_dimension: Recency threshold_description: |- Expected count of future dates beyond one year diff --git a/testgen/template/dbsetup_test_types/test_types_Incr_Avg_Shift.yaml b/testgen/template/dbsetup_test_types/test_types_Incr_Avg_Shift.yaml index 707d20a6..eddb6227 100644 --- a/testgen/template/dbsetup_test_types/test_types_Incr_Avg_Shift.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Incr_Avg_Shift.yaml @@ -28,6 +28,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Accuracy + impact_dimension: Regularity health_dimension: Data Drift threshold_description: |- Maximum Z-Score (number of SD's beyond mean) expected diff --git a/testgen/template/dbsetup_test_types/test_types_LOV_All.yaml b/testgen/template/dbsetup_test_types/test_types_LOV_All.yaml index 6a343ebc..2cf10836 100644 --- a/testgen/template/dbsetup_test_types/test_types_LOV_All.yaml +++ b/testgen/template/dbsetup_test_types/test_types_LOV_All.yaml @@ -25,6 +25,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Validity + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- List of values expected, in form ('Val1','Val2) diff --git a/testgen/template/dbsetup_test_types/test_types_LOV_Match.yaml b/testgen/template/dbsetup_test_types/test_types_LOV_Match.yaml index ef37b028..768dd65b 100644 --- a/testgen/template/dbsetup_test_types/test_types_LOV_Match.yaml +++ b/testgen/template/dbsetup_test_types/test_types_LOV_Match.yaml @@ -131,6 +131,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Validity + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- List of values expected, in form ('Val1','Val2) diff --git a/testgen/template/dbsetup_test_types/test_types_Metric_Trend.yaml b/testgen/template/dbsetup_test_types/test_types_Metric_Trend.yaml index 524d5135..31e17846 100644 --- a/testgen/template/dbsetup_test_types/test_types_Metric_Trend.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Metric_Trend.yaml @@ -24,6 +24,7 @@ test_types: run_type: CAT test_scope: table dq_dimension: Validity + impact_dimension: Regularity health_dimension: null threshold_description: |- Expected aggregate metric range. diff --git a/testgen/template/dbsetup_test_types/test_types_Min_Date.yaml b/testgen/template/dbsetup_test_types/test_types_Min_Date.yaml index 2a64f34a..a2762969 100644 --- a/testgen/template/dbsetup_test_types/test_types_Min_Date.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Min_Date.yaml @@ -27,6 +27,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Validity + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- Expected count of dates prior to minimum diff --git a/testgen/template/dbsetup_test_types/test_types_Min_Val.yaml b/testgen/template/dbsetup_test_types/test_types_Min_Val.yaml index 56d505ff..3a852155 100644 --- a/testgen/template/dbsetup_test_types/test_types_Min_Val.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Min_Val.yaml @@ -27,6 +27,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Validity + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- Expected count of values under limit diff --git a/testgen/template/dbsetup_test_types/test_types_Missing_Pct.yaml b/testgen/template/dbsetup_test_types/test_types_Missing_Pct.yaml index 6ddf86a0..d85d0908 100644 --- a/testgen/template/dbsetup_test_types/test_types_Missing_Pct.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Missing_Pct.yaml @@ -28,6 +28,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Completeness + impact_dimension: Regularity health_dimension: Data Drift threshold_description: |- Expected maximum Cohen's H Difference diff --git a/testgen/template/dbsetup_test_types/test_types_Monthly_Rec_Ct.yaml b/testgen/template/dbsetup_test_types/test_types_Monthly_Rec_Ct.yaml index ec0fffa4..8fd1fcdb 100644 --- a/testgen/template/dbsetup_test_types/test_types_Monthly_Rec_Ct.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Monthly_Rec_Ct.yaml @@ -28,6 +28,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Completeness + impact_dimension: Reliability health_dimension: Volume threshold_description: |- Expected maximum count of calendar months without dates present diff --git a/testgen/template/dbsetup_test_types/test_types_Outlier_Pct_Above.yaml b/testgen/template/dbsetup_test_types/test_types_Outlier_Pct_Above.yaml index cb8ebf91..6b26ccb1 100644 --- a/testgen/template/dbsetup_test_types/test_types_Outlier_Pct_Above.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Outlier_Pct_Above.yaml @@ -27,6 +27,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Accuracy + impact_dimension: Regularity health_dimension: Data Drift threshold_description: |- Expected maximum pct records over upper 2 SD limit diff --git a/testgen/template/dbsetup_test_types/test_types_Outlier_Pct_Below.yaml b/testgen/template/dbsetup_test_types/test_types_Outlier_Pct_Below.yaml index b2b32d67..a2354e6e 100644 --- a/testgen/template/dbsetup_test_types/test_types_Outlier_Pct_Below.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Outlier_Pct_Below.yaml @@ -27,6 +27,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Accuracy + impact_dimension: Regularity health_dimension: Data Drift threshold_description: |- Expected maximum pct records over lower 2 SD limit diff --git a/testgen/template/dbsetup_test_types/test_types_Pattern_Match.yaml b/testgen/template/dbsetup_test_types/test_types_Pattern_Match.yaml index 3cd3359d..b3d0862f 100644 --- a/testgen/template/dbsetup_test_types/test_types_Pattern_Match.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Pattern_Match.yaml @@ -27,6 +27,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Validity + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- Expected count of pattern mismatches diff --git a/testgen/template/dbsetup_test_types/test_types_Recency.yaml b/testgen/template/dbsetup_test_types/test_types_Recency.yaml index 9607a3ac..088a3a92 100644 --- a/testgen/template/dbsetup_test_types/test_types_Recency.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Recency.yaml @@ -28,6 +28,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Timeliness + impact_dimension: Reliability health_dimension: Recency threshold_description: |- Expected maximum count of days preceding test date diff --git a/testgen/template/dbsetup_test_types/test_types_Required.yaml b/testgen/template/dbsetup_test_types/test_types_Required.yaml index f11ceb36..625b135f 100644 --- a/testgen/template/dbsetup_test_types/test_types_Required.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Required.yaml @@ -27,6 +27,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Completeness + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- Expected count of missing values diff --git a/testgen/template/dbsetup_test_types/test_types_Row_Ct.yaml b/testgen/template/dbsetup_test_types/test_types_Row_Ct.yaml index 4a373834..b5c4459d 100644 --- a/testgen/template/dbsetup_test_types/test_types_Row_Ct.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Row_Ct.yaml @@ -25,6 +25,7 @@ test_types: run_type: CAT test_scope: table dq_dimension: Completeness + impact_dimension: Reliability health_dimension: Volume threshold_description: |- Expected minimum row count diff --git a/testgen/template/dbsetup_test_types/test_types_Row_Ct_Pct.yaml b/testgen/template/dbsetup_test_types/test_types_Row_Ct_Pct.yaml index 6b176c7a..05efdf4c 100644 --- a/testgen/template/dbsetup_test_types/test_types_Row_Ct_Pct.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Row_Ct_Pct.yaml @@ -26,6 +26,7 @@ test_types: run_type: CAT test_scope: table dq_dimension: Completeness + impact_dimension: Reliability health_dimension: Volume threshold_description: |- Expected percent window below or above baseline diff --git a/testgen/template/dbsetup_test_types/test_types_Schema_Drift.yaml b/testgen/template/dbsetup_test_types/test_types_Schema_Drift.yaml index e1e23dcd..e5c908a7 100644 --- a/testgen/template/dbsetup_test_types/test_types_Schema_Drift.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Schema_Drift.yaml @@ -24,6 +24,7 @@ test_types: run_type: METADATA test_scope: tablegroup dq_dimension: null + impact_dimension: Reliability health_dimension: null threshold_description: null result_visualization: binary_chart diff --git a/testgen/template/dbsetup_test_types/test_types_Street_Addr_Pattern.yaml b/testgen/template/dbsetup_test_types/test_types_Street_Addr_Pattern.yaml index 31004340..7956ef0a 100644 --- a/testgen/template/dbsetup_test_types/test_types_Street_Addr_Pattern.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Street_Addr_Pattern.yaml @@ -28,6 +28,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Validity + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- Expected percent of records that match standard street address pattern diff --git a/testgen/template/dbsetup_test_types/test_types_Table_Freshness.yaml b/testgen/template/dbsetup_test_types/test_types_Table_Freshness.yaml index 27e89cf0..76823e83 100644 --- a/testgen/template/dbsetup_test_types/test_types_Table_Freshness.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Table_Freshness.yaml @@ -28,6 +28,7 @@ test_types: run_type: QUERY test_scope: table dq_dimension: Recency + impact_dimension: Reliability health_dimension: Recency threshold_description: |- Most recent prior table fingerprint diff --git a/testgen/template/dbsetup_test_types/test_types_Timeframe_Combo_Gain.yaml b/testgen/template/dbsetup_test_types/test_types_Timeframe_Combo_Gain.yaml index c03bfd5f..61346177 100644 --- a/testgen/template/dbsetup_test_types/test_types_Timeframe_Combo_Gain.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Timeframe_Combo_Gain.yaml @@ -29,6 +29,7 @@ test_types: run_type: QUERY test_scope: referential dq_dimension: Consistency + impact_dimension: Reliability health_dimension: Data Drift threshold_description: |- Expected count of missing value combinations diff --git a/testgen/template/dbsetup_test_types/test_types_Timeframe_Combo_Match.yaml b/testgen/template/dbsetup_test_types/test_types_Timeframe_Combo_Match.yaml index 1c9851dc..e3d2086a 100644 --- a/testgen/template/dbsetup_test_types/test_types_Timeframe_Combo_Match.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Timeframe_Combo_Match.yaml @@ -27,6 +27,7 @@ test_types: run_type: QUERY test_scope: referential dq_dimension: Consistency + impact_dimension: Reliability health_dimension: Data Drift threshold_description: |- Expected count of non-matching value combinations diff --git a/testgen/template/dbsetup_test_types/test_types_US_State.yaml b/testgen/template/dbsetup_test_types/test_types_US_State.yaml index 21acdc38..a14181e8 100644 --- a/testgen/template/dbsetup_test_types/test_types_US_State.yaml +++ b/testgen/template/dbsetup_test_types/test_types_US_State.yaml @@ -28,6 +28,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Validity + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- Expected count of values that are not US state abbreviations diff --git a/testgen/template/dbsetup_test_types/test_types_Unique.yaml b/testgen/template/dbsetup_test_types/test_types_Unique.yaml index d02a9e38..abf22dae 100644 --- a/testgen/template/dbsetup_test_types/test_types_Unique.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Unique.yaml @@ -28,6 +28,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Uniqueness + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- Expected count of duplicate values diff --git a/testgen/template/dbsetup_test_types/test_types_Unique_Pct.yaml b/testgen/template/dbsetup_test_types/test_types_Unique_Pct.yaml index 77f8aae5..6e8767ae 100644 --- a/testgen/template/dbsetup_test_types/test_types_Unique_Pct.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Unique_Pct.yaml @@ -28,6 +28,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Uniqueness + impact_dimension: Conformance health_dimension: Data Drift threshold_description: |- Expected maximum Cohen's H Difference diff --git a/testgen/template/dbsetup_test_types/test_types_Valid_Characters.yaml b/testgen/template/dbsetup_test_types/test_types_Valid_Characters.yaml index 09d90d0a..6110a2f9 100644 --- a/testgen/template/dbsetup_test_types/test_types_Valid_Characters.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Valid_Characters.yaml @@ -28,6 +28,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Validity + impact_dimension: Usability health_dimension: Schema Drift threshold_description: |- Threshold Invalid Value Count diff --git a/testgen/template/dbsetup_test_types/test_types_Valid_Month.yaml b/testgen/template/dbsetup_test_types/test_types_Valid_Month.yaml index 343587b7..a5a8fbcd 100644 --- a/testgen/template/dbsetup_test_types/test_types_Valid_Month.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Valid_Month.yaml @@ -28,6 +28,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Validity + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- Expected count of invalid months diff --git a/testgen/template/dbsetup_test_types/test_types_Valid_US_Zip.yaml b/testgen/template/dbsetup_test_types/test_types_Valid_US_Zip.yaml index a42d0aa2..e5225b67 100644 --- a/testgen/template/dbsetup_test_types/test_types_Valid_US_Zip.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Valid_US_Zip.yaml @@ -27,6 +27,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Validity + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- Threshold Invalid Value Count diff --git a/testgen/template/dbsetup_test_types/test_types_Valid_US_Zip3.yaml b/testgen/template/dbsetup_test_types/test_types_Valid_US_Zip3.yaml index 31a6d9ab..5d174ae7 100644 --- a/testgen/template/dbsetup_test_types/test_types_Valid_US_Zip3.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Valid_US_Zip3.yaml @@ -27,6 +27,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Validity + impact_dimension: Conformance health_dimension: Schema Drift threshold_description: |- Threshold Invalid Zip3 Count diff --git a/testgen/template/dbsetup_test_types/test_types_Variability_Decrease.yaml b/testgen/template/dbsetup_test_types/test_types_Variability_Decrease.yaml index 74b91f96..dda3e907 100644 --- a/testgen/template/dbsetup_test_types/test_types_Variability_Decrease.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Variability_Decrease.yaml @@ -28,6 +28,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Accuracy + impact_dimension: Regularity health_dimension: Data Drift threshold_description: |- Expected minimum pct of baseline Standard Deviation (SD) diff --git a/testgen/template/dbsetup_test_types/test_types_Variability_Increase.yaml b/testgen/template/dbsetup_test_types/test_types_Variability_Increase.yaml index 1992ec41..73b0b48d 100644 --- a/testgen/template/dbsetup_test_types/test_types_Variability_Increase.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Variability_Increase.yaml @@ -28,6 +28,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Accuracy + impact_dimension: Regularity health_dimension: Data Drift threshold_description: |- Expected maximum pct of baseline Standard Deviation (SD) diff --git a/testgen/template/dbsetup_test_types/test_types_Volume_Trend.yaml b/testgen/template/dbsetup_test_types/test_types_Volume_Trend.yaml index e748f130..521688f6 100644 --- a/testgen/template/dbsetup_test_types/test_types_Volume_Trend.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Volume_Trend.yaml @@ -25,6 +25,7 @@ test_types: run_type: CAT test_scope: table dq_dimension: Completeness + impact_dimension: Reliability health_dimension: Volume threshold_description: |- Expected row count range. diff --git a/testgen/template/dbsetup_test_types/test_types_Weekly_Rec_Ct.yaml b/testgen/template/dbsetup_test_types/test_types_Weekly_Rec_Ct.yaml index 3c288eaf..73a115dc 100644 --- a/testgen/template/dbsetup_test_types/test_types_Weekly_Rec_Ct.yaml +++ b/testgen/template/dbsetup_test_types/test_types_Weekly_Rec_Ct.yaml @@ -28,6 +28,7 @@ test_types: run_type: CAT test_scope: column dq_dimension: Completeness + impact_dimension: Reliability health_dimension: Volume threshold_description: |- Expected maximum count of calendar weeks without dates present diff --git a/testgen/template/dbupgrade/0187_incremental_upgrade.sql b/testgen/template/dbupgrade/0187_incremental_upgrade.sql new file mode 100644 index 00000000..b159b98d --- /dev/null +++ b/testgen/template/dbupgrade/0187_incremental_upgrade.sql @@ -0,0 +1,73 @@ +SET SEARCH_PATH TO {SCHEMA_NAME}; + +-- TG-1045: Add impact_dimension as second classification axis for DQ scoring + +ALTER TABLE test_types + ADD COLUMN IF NOT EXISTS impact_dimension VARCHAR(20); + +ALTER TABLE profile_anomaly_types + ADD COLUMN IF NOT EXISTS impact_dimension VARCHAR(20); + +ALTER TABLE test_definitions + ADD COLUMN IF NOT EXISTS impact_dimension VARCHAR(20); + +ALTER TABLE test_results + ADD COLUMN IF NOT EXISTS impact_dimension VARCHAR(20); + +ALTER TABLE profile_anomaly_results + ADD COLUMN IF NOT EXISTS impact_dimension VARCHAR(20); + +ALTER TABLE score_definition_results_breakdown + ADD COLUMN IF NOT EXISTS impact_dimension TEXT DEFAULT NULL; + +-- Populate impact_dimension on test_types from default assignments +UPDATE test_types SET impact_dimension = 'Reliability' WHERE test_type IN ( + 'Daily_Record_Ct', 'Distinct_Date_Ct', 'Monthly_Rec_Ct', 'Recency', 'Row_Ct', + 'Row_Ct_Pct', 'Weekly_Rec_Ct', 'Aggregate_Balance', 'Aggregate_Balance_Percent', + 'Aggregate_Balance_Range', 'Timeframe_Combo_Gain', 'Timeframe_Combo_Match', + 'Table_Freshness', 'Schema_Drift', 'Volume_Trend', 'Freshness_Trend' +); +UPDATE test_types SET impact_dimension = 'Conformance' WHERE test_type IN ( + 'Alpha_Trunc', 'Condition_Flag', 'Constant', 'CUSTOM', 'Dec_Trunc', 'Email_Format', + 'Future_Date', 'Future_Date_1Y', 'LOV_All', 'LOV_Match', 'Min_Date', 'Min_Val', + 'Pattern_Match', 'Required', 'Street_Addr_Pattern', 'Unique', 'Unique_Pct', + 'US_State', 'Valid_Month', 'Valid_US_Zip', 'Valid_US_Zip3', 'Aggregate_Minimum', + 'Combo_Match', 'Dupe_Rows' +); +UPDATE test_types SET impact_dimension = 'Regularity' WHERE test_type IN ( + 'Avg_Shift', 'Distinct_Value_Ct', 'Incr_Avg_Shift', 'Missing_Pct', + 'Outlier_Pct_Above', 'Outlier_Pct_Below', 'Variability_Increase', + 'Variability_Decrease', 'Distribution_Shift', 'Metric_Trend' +); +UPDATE test_types SET impact_dimension = 'Usability' WHERE test_type IN ( + 'Valid_Characters' +); + +-- Populate impact_dimension on profile_anomaly_types from default assignments +UPDATE profile_anomaly_types SET impact_dimension = 'Conformance' WHERE anomaly_type IN ( + 'No_Values', 'Invalid_Zip_USA', 'Unexpected_US_States', 'Unexpected_Emails', + 'Invalid_Zip3_USA', 'Non_Alpha_Name_Address', 'Non_Alpha_Prefixed_Name', 'Potential_PII' +); +UPDATE profile_anomaly_types SET impact_dimension = 'Regularity' WHERE anomaly_type IN ( + 'Small_Missing_Value_Ct', 'Small_Divergent_Value_Ct', 'Potential_Duplicates', + 'Unlikely_Date_Values', 'Recency_One_Year', 'Recency_Six_Months', 'Small_Numeric_Value_Ct' +); +UPDATE profile_anomaly_types SET impact_dimension = 'Usability' WHERE anomaly_type IN ( + 'Suggested_Type', 'Non_Standard_Blanks', 'Multiple_Types_Minor', 'Multiple_Types_Major', + 'Column_Pattern_Mismatch', 'Table_Pattern_Mismatch', 'Leading_Spaces', 'Quoted_Values', + 'Char_Column_Number_Values', 'Char_Column_Date_Values', 'Boolean_Value_Mismatch', + 'Standardized_Value_Matches', 'Delimited_Data_Embedded', 'Char_Column_Number_Units', + 'Variant_Coded_Values', 'Inconsistent_Casing', 'Non_Printing_Chars' +); + +-- Backfill test_results from test_types (no definition override on historical data) +UPDATE test_results tr +SET impact_dimension = tt.impact_dimension +FROM test_types tt +WHERE tr.test_type = tt.test_type; + +-- Backfill profile_anomaly_results from profile_anomaly_types +UPDATE profile_anomaly_results ar +SET impact_dimension = at.impact_dimension +FROM profile_anomaly_types at +WHERE ar.anomaly_id = at.id; diff --git a/testgen/template/execution/update_test_results.sql b/testgen/template/execution/update_test_results.sql index f5fbf7ad..60d7f76f 100644 --- a/testgen/template/execution/update_test_results.sql +++ b/testgen/template/execution/update_test_results.sql @@ -47,7 +47,8 @@ SET test_description = COALESCE(r.test_description, d.test_description, tt.test_ ), table_groups_id = d.table_groups_id, test_suite_id = s.id, - auto_gen = d.last_auto_gen_date IS NOT NULL + auto_gen = d.last_auto_gen_date IS NOT NULL, + impact_dimension = COALESCE(d.impact_dimension, tt.impact_dimension) FROM test_results r INNER JOIN test_suites s ON r.test_suite_id = s.id INNER JOIN test_definitions d ON r.test_definition_id = d.id diff --git a/testgen/template/profiling/profile_anomalies_screen_column.sql b/testgen/template/profiling/profile_anomalies_screen_column.sql index f1faf012..ef6cbfde 100644 --- a/testgen/template/profiling/profile_anomalies_screen_column.sql +++ b/testgen/template/profiling/profile_anomalies_screen_column.sql @@ -1,6 +1,6 @@ INSERT INTO profile_anomaly_results (project_code, table_groups_id, profile_run_id, anomaly_id, - schema_name, table_name, column_name, column_type, db_data_type, detail) + schema_name, table_name, column_name, column_type, db_data_type, detail, impact_dimension) SELECT p.project_code, p.table_groups_id, p.profile_run_id, @@ -10,8 +10,10 @@ SELECT p.project_code, p.column_name, p.column_type, p.db_data_type, - {DETAIL_EXPRESSION} AS detail + {DETAIL_EXPRESSION} AS detail, + at.impact_dimension FROM profile_results p +INNER JOIN profile_anomaly_types at ON at.id = :ANOMALY_ID LEFT JOIN v_inactive_anomalies i ON (p.table_groups_id = i.table_groups_id AND p.schema_name = i.schema_name diff --git a/testgen/template/profiling/profile_anomalies_screen_multi_column.sql b/testgen/template/profiling/profile_anomalies_screen_multi_column.sql index 7c2cfed4..91935016 100644 --- a/testgen/template/profiling/profile_anomalies_screen_multi_column.sql +++ b/testgen/template/profiling/profile_anomalies_screen_multi_column.sql @@ -48,11 +48,12 @@ WITH mults AS ( SELECT p.project_code, ) INSERT INTO profile_anomaly_results (project_code, table_groups_id, profile_run_id, anomaly_id, - schema_name, table_name, column_name, column_type, db_data_type, detail) + schema_name, table_name, column_name, column_type, db_data_type, detail, impact_dimension) SELECT project_code, table_groups_id, profile_run_id, anomaly_id, schema_name, '(multi-table)' as table_name, column_name, '(multiple)' as column_type, '(multiple)' as db_data_type, - detail || ' , Tables: ' || table_list AS detail + detail || ' , Tables: ' || table_list AS detail, + (SELECT impact_dimension FROM profile_anomaly_types WHERE id = :ANOMALY_ID) FROM subset GROUP BY project_code, table_groups_id, profile_run_id, anomaly_id, schema_name, column_name, table_list, detail; diff --git a/testgen/template/profiling/profile_anomalies_screen_table.sql b/testgen/template/profiling/profile_anomalies_screen_table.sql index 646d2a00..be6d9e76 100644 --- a/testgen/template/profiling/profile_anomalies_screen_table.sql +++ b/testgen/template/profiling/profile_anomalies_screen_table.sql @@ -1,6 +1,6 @@ INSERT INTO profile_anomaly_results (project_code, table_groups_id, profile_run_id, anomaly_id, - schema_name, table_name, column_name, detail, disposition) + schema_name, table_name, column_name, detail, disposition, impact_dimension) SELECT p.project_code, p.table_groups_id, p.profile_run_id, @@ -9,8 +9,10 @@ SELECT p.project_code, p.table_name, '(Table)' as column_name, {DETAIL_EXPRESSION} AS detail, - CASE WHEN i.anomaly_id IS NULL THEN NULL ELSE 'Inactive' END as disposition + CASE WHEN i.anomaly_id IS NULL THEN NULL ELSE 'Inactive' END as disposition, + at.impact_dimension FROM profile_results p +INNER JOIN profile_anomaly_types at ON at.id = :ANOMALY_ID LEFT JOIN v_inactive_anomalies i ON (p.table_groups_id = i.table_groups_id AND p.schema_name = i.schema_name @@ -18,5 +20,5 @@ LEFT JOIN v_inactive_anomalies i AND :ANOMALY_ID = i.anomaly_id) WHERE p.profile_run_id = :PROFILE_RUN_ID GROUP BY p.project_code, p.table_groups_id, p.profile_run_id, - p.schema_name, p.table_name + p.schema_name, p.table_name, at.impact_dimension HAVING {ANOMALY_CRITERIA}; diff --git a/testgen/template/profiling/profile_anomalies_screen_table_dates.sql b/testgen/template/profiling/profile_anomalies_screen_table_dates.sql index f4ba10f6..2bb3adde 100644 --- a/testgen/template/profiling/profile_anomalies_screen_table_dates.sql +++ b/testgen/template/profiling/profile_anomalies_screen_table_dates.sql @@ -1,6 +1,6 @@ INSERT INTO profile_anomaly_results (project_code, table_groups_id, profile_run_id, anomaly_id, - schema_name, table_name, column_name, detail) + schema_name, table_name, column_name, detail, impact_dimension) SELECT p.project_code, p.table_groups_id, p.profile_run_id, @@ -15,7 +15,8 @@ SELECT p.project_code, || CASE WHEN COUNT(p.column_name) > 2 THEN ', Columns: ' || STRING_AGG(p.column_name, ', ' ORDER BY p.position) ELSE '' - END as detail + END as detail, + (SELECT impact_dimension FROM profile_anomaly_types WHERE id = :ANOMALY_ID) FROM profile_results p LEFT JOIN v_inactive_anomalies i ON (p.table_groups_id = i.table_groups_id diff --git a/testgen/template/profiling/profile_anomalies_screen_variants.sql b/testgen/template/profiling/profile_anomalies_screen_variants.sql index e4b69be2..bc00627a 100644 --- a/testgen/template/profiling/profile_anomalies_screen_variants.sql +++ b/testgen/template/profiling/profile_anomalies_screen_variants.sql @@ -1,6 +1,6 @@ INSERT INTO profile_anomaly_results (project_code, table_groups_id, profile_run_id, anomaly_id, - schema_name, table_name, column_name, column_type, db_data_type, detail) + schema_name, table_name, column_name, column_type, db_data_type, detail, impact_dimension) WITH all_matches AS ( SELECT p.project_code, p.table_groups_id, @@ -38,5 +38,6 @@ WITH all_matches SELECT project_code, table_groups_id, profile_run_id, :ANOMALY_ID AS anomaly_id, schema_name, table_name, column_name, column_type, db_data_type, - {DETAIL_EXPRESSION} AS detail + {DETAIL_EXPRESSION} AS detail, + (SELECT impact_dimension FROM profile_anomaly_types WHERE id = :ANOMALY_ID) FROM all_matches; diff --git a/testgen/template/score_cards/get_category_scores_by_impact_dimension.sql b/testgen/template/score_cards/get_category_scores_by_impact_dimension.sql new file mode 100644 index 00000000..a4a4fe54 --- /dev/null +++ b/testgen/template/score_cards/get_category_scores_by_impact_dimension.sql @@ -0,0 +1,20 @@ +SELECT + COALESCE(profiling_category_scores.category, test_category_scores.category) AS label, + (COALESCE(profiling_category_scores.score, 1) * COALESCE(test_category_scores.score, 1)) AS score +FROM ( + SELECT + {category} AS category, + SUM(COALESCE(good_data_pct * weighted_record_ct, 0)) / NULLIF(SUM(COALESCE(weighted_record_ct, 0)), 0) AS score + FROM v_dq_profile_scoring_latest_by_impact_dimension + WHERE NULLIF({category}, '') IS NOT NULL AND {filters} + GROUP BY {category} +) AS profiling_category_scores +FULL OUTER JOIN ( + SELECT + {category} AS category, + SUM(COALESCE(good_data_pct * weighted_dq_record_ct, 0)) / NULLIF(SUM(COALESCE(weighted_dq_record_ct, 0)), 0) AS score + FROM v_dq_test_scoring_latest_by_impact_dimension + WHERE NULLIF({category}, '') IS NOT NULL AND {filters} + GROUP BY {category} +) AS test_category_scores + ON (test_category_scores.category = profiling_category_scores.category) diff --git a/testgen/template/score_cards/get_score_card_breakdown_by_impact_dimension.sql b/testgen/template/score_cards/get_score_card_breakdown_by_impact_dimension.sql new file mode 100644 index 00000000..a83225b5 --- /dev/null +++ b/testgen/template/score_cards/get_score_card_breakdown_by_impact_dimension.sql @@ -0,0 +1,53 @@ +WITH +profiling_records AS ( + SELECT + project_code, + {columns}, + SUM(issue_ct) AS issue_ct, + SUM(weighted_record_ct) AS data_point_ct, + SUM(weighted_record_ct * good_data_pct) / NULLIF(SUM(weighted_record_ct), 0) AS score + FROM v_dq_profile_scoring_latest_by_impact_dimension + WHERE {filters} + GROUP BY project_code, {columns} +), +test_records AS ( + SELECT + project_code, + {columns}, + SUM(issue_ct) AS issue_ct, + SUM(weighted_dq_record_ct) AS data_point_ct, + SUM(weighted_dq_record_ct * good_data_pct) / NULLIF(SUM(weighted_dq_record_ct), 0) AS score + FROM v_dq_test_scoring_latest_by_impact_dimension + WHERE {filters} + GROUP BY project_code, {columns} +), +parent AS ( + SELECT + COALESCE(profiling_records.project_code, test_records.project_code) AS project_code, + SUM(COALESCE(profiling_records.weighted_record_ct, 0)) AS profiling_data_points, + SUM(COALESCE(test_records.weighted_dq_record_ct, 0)) AS test_data_points + FROM v_dq_profile_scoring_latest_by_column AS profiling_records + FULL OUTER JOIN v_dq_test_scoring_latest_by_column AS test_records ON ( + test_records.project_code = profiling_records.project_code + AND test_records.table_groups_id = profiling_records.table_groups_id + AND test_records.table_name = profiling_records.table_name + AND test_records.column_name = profiling_records.column_name + ) + WHERE {records_count_filters} + GROUP BY COALESCE(profiling_records.project_code, test_records.project_code) +) +SELECT + {non_null_columns}, + 100 * ( + COALESCE(profiling_records.data_point_ct * (1 - profiling_records.score) / NULLIF(parent.profiling_data_points, 0), 0) + + COALESCE(test_records.data_point_ct * (1 - test_records.score) / NULLIF(parent.test_data_points, 0), 0) + ) AS impact, + (COALESCE(profiling_records.score, 1) * COALESCE(test_records.score, 1)) AS score, + (COALESCE(profiling_records.issue_ct, 0) + COALESCE(test_records.issue_ct, 0)) AS issue_ct +FROM profiling_records +FULL OUTER JOIN test_records + ON (test_records.project_code = profiling_records.project_code AND {join_condition}) +INNER JOIN parent + ON (parent.project_code = profiling_records.project_code OR parent.project_code = test_records.project_code) +ORDER BY impact DESC +LIMIT 100 diff --git a/testgen/template/score_cards/get_score_card_issues_by_impact_dimension.sql b/testgen/template/score_cards/get_score_card_issues_by_impact_dimension.sql new file mode 100644 index 00000000..974b6997 --- /dev/null +++ b/testgen/template/score_cards/get_score_card_issues_by_impact_dimension.sql @@ -0,0 +1,98 @@ +WITH score_profiling_runs AS ( + SELECT + profile_run_id, + table_name, + column_name + FROM v_dq_profile_scoring_latest_by_impact_dimension + WHERE {filters} AND {value_filter} +), +anomalies AS ( + SELECT results.id::VARCHAR AS id, + runs.table_groups_id::VARCHAR AS table_group_id, + results.table_name AS table, + results.column_name AS column, + types.anomaly_name AS type, + types.issue_likelihood AS status, + results.detail, + types.detail_redactable, + dcc.pii_flag, + EXTRACT( + EPOCH + FROM runs.profiling_starttime + )::INT AS time, + '' AS name, + runs.id::text AS run_id, + 'hygiene' AS issue_type + FROM profile_anomaly_results AS results + INNER JOIN profile_anomaly_types AS types ON (types.id = results.anomaly_id) + INNER JOIN profiling_runs AS runs ON (runs.id = results.profile_run_id) + LEFT JOIN data_column_chars AS dcc ON ( + results.table_groups_id = dcc.table_groups_id + AND results.schema_name = dcc.schema_name + AND results.table_name = dcc.table_name + AND results.column_name = dcc.column_name + ) + INNER JOIN score_profiling_runs ON ( + score_profiling_runs.profile_run_id = runs.id + AND score_profiling_runs.table_name = results.table_name + AND score_profiling_runs.column_name = results.column_name + ) + WHERE COALESCE(results.disposition, 'Confirmed') = 'Confirmed' + {profiling_impact_dimension_filter} +), +score_test_runs AS ( + SELECT test_run_id, + table_name, + column_name + FROM v_dq_test_scoring_latest_by_impact_dimension + WHERE {filters} + AND {value_filter} +), +tests AS ( + SELECT test_results.id::VARCHAR AS id, + test_suites.table_groups_id::VARCHAR AS table_group_id, + test_results.table_name AS table, + test_results.column_names AS column, + test_types.test_name_short AS type, + result_status AS status, + result_message AS detail, + NULL::BOOLEAN AS detail_redactable, + NULL AS pii_flag, + EXTRACT( + EPOCH + FROM test_time + )::INT AS time, + test_suites.test_suite AS name, + test_results.test_run_id::text AS run_id, + 'test' AS issue_type + FROM test_results + INNER JOIN score_test_runs ON ( + score_test_runs.test_run_id = test_results.test_run_id + AND score_test_runs.table_name = test_results.table_name + -- NULL-safe match: table-scope tests (e.g. Dupe_Rows) have column_names = NULL + AND score_test_runs.column_name IS NOT DISTINCT FROM test_results.column_names + ) + INNER JOIN test_suites ON (test_suites.id = test_results.test_suite_id) + INNER JOIN test_types ON (test_types.test_type = test_results.test_type) + WHERE result_status IN ('Failed', 'Warning') + AND COALESCE(test_results.disposition, 'Confirmed') = 'Confirmed' + {test_impact_dimension_filter} +) +SELECT * +FROM ( + SELECT * FROM anomalies + UNION ALL + SELECT * FROM tests +) issues +ORDER BY + CASE + issues.status + WHEN 'Definite' THEN 1 + WHEN 'Failed' THEN 2 + WHEN 'Likely' THEN 3 + WHEN 'Possible' THEN 4 + WHEN 'Warning' THEN 5 + ELSE 6 + END, + LOWER(issues.table), + LOWER(issues.column) diff --git a/testgen/ui/components/frontend/js/pages/score_details.js b/testgen/ui/components/frontend/js/pages/score_details.js index 37297505..c8cf3ea0 100644 --- a/testgen/ui/components/frontend/js/pages/score_details.js +++ b/testgen/ui/components/frontend/js/pages/score_details.js @@ -17,7 +17,7 @@ * * @typedef Properties * @type {object} - * @property {('table_name' | 'column_name' | 'semantic_data_type' | 'dq_dimension')} category + * @property {('table_name' | 'column_name' | 'semantic_data_type' | 'dq_dimension' | 'impact_dimension')} category * @property {('score' | 'cde_score')} score_type * @property {any} drilldown * @property {Score} score @@ -146,7 +146,7 @@ const ScoreDetails = (/** @type {Properties} */ props) => { }, width: '65rem', }, - NotificationSettings({ emit, + NotificationSettings({ emit, smtp_configured: smtpConfigured, event: event, items: items, diff --git a/testgen/ui/components/frontend/js/pages/score_explorer.js b/testgen/ui/components/frontend/js/pages/score_explorer.js index 81aeb4ee..f1744d34 100644 --- a/testgen/ui/components/frontend/js/pages/score_explorer.js +++ b/testgen/ui/components/frontend/js/pages/score_explorer.js @@ -80,6 +80,7 @@ const TRANSLATIONS = { transform_level: 'Transform Level', aggregation_level: 'Aggregation Level', dq_dimension: 'Quality Dimension', + impact_dimension: 'Impact Dimension', data_product: 'Data Product', }; @@ -113,14 +114,14 @@ const ScoreExplorer = (/** @type {Properties} */ props) => { const isEmpty = getValue(props.is_new) && getValue(props.definition)?.filters?.length <= 0; if (isEmpty) { - return EmptyState({ emit, + return EmptyState({ emit, class: 'explorer-empty-state', label: 'No filters or columns selected yet', icon: 'readiness_score', message: EMPTY_STATE_MESSAGE.explorer, }); } - + return div( {class: 'flex-column'}, ScoreCard(props.score_card), @@ -128,7 +129,7 @@ const ScoreExplorer = (/** @type {Properties} */ props) => { () => { const drilldown = getValue(props.drilldown); const issuesValue = getValue(props.issues); - + return ( (issuesValue && getValue(props.drilldown)) ? IssuesTable( @@ -185,16 +186,17 @@ const Toolbar = ( 'stakeholder_group', 'transform_level', 'dq_dimension', + 'impact_dimension', 'data_product', ]; - const filterableFields = categories.filter((c) => c !== 'dq_dimension'); + const filterableFields = categories.filter((c) => c !== 'dq_dimension' && c !== 'impact_dimension'); const filters = van.state(definition.filters.map((f, idx) => ({key: `${f.field}-${idx}-${getRandomId()}`, field: f.field, value: van.state(f.value), others: f.others ?? [] }))); const filterByColumns = van.state(definition.filter_by_columns); const filterSelectorOpened = van.state(false); const displayTotalScore = van.state(definition.total_score ?? true); const displayCDEScore = van.state(definition.cde_score ?? true); const displayCategory = van.state(!!definition.category); - const selectedCategory = van.state(definition.category ?? undefined); + const selectedCategory = van.state(definition.category ?? 'impact_dimension'); const scoreName = van.state(definition.name ?? ''); const disableSave = van.derive(() => { const appliedFilters = getValue(filters); @@ -298,7 +300,7 @@ const Toolbar = ( if (filters_?.length <= 0) { return ''; } - + return div( { class: 'flex-row fx-flex-wrap fx-gap-3' }, filters_.map(({ key, field, value, others }, idx) => { @@ -338,15 +340,15 @@ const Toolbar = ( span({class: 'text-caption'}, 'Or'), columnsSelectorTrigger, ); - + if (filters_?.length <= 0 && filterByColumns_ == undefined) { return combinedTrigger; } - + if (filterByColumns_) { return columnsSelectorTrigger; } - + return fieldFilterTrigger; }, Portal( diff --git a/testgen/ui/components/frontend/js/pages/test_definitions.js b/testgen/ui/components/frontend/js/pages/test_definitions.js index de1858b7..aeff2ab9 100644 --- a/testgen/ui/components/frontend/js/pages/test_definitions.js +++ b/testgen/ui/components/frontend/js/pages/test_definitions.js @@ -660,7 +660,7 @@ const TestDefinitions = (/** @type object */ props) => { () => { const data = getValue(props.notes_dialog); if (!data) return span(); - return TestDefinitionNotes({ emit, + return TestDefinitionNotes({ emit, test_label: data.test_label, notes: data.notes, current_user: data.current_user, @@ -1062,6 +1062,16 @@ const TestDefFormContent = ({ formValues, tableColumns, testSuite, validateResul { label: 'No', value: false }, ]; + const inheritedImpactDimension = formValues.default_impact_dimension ?? 'Conformance'; + const impactDimensionOptions = [ + { label: `Inherited (${inheritedImpactDimension})`, value: null }, + { label: 'Reliability', value: 'Reliability' }, + { label: 'Conformance', value: 'Conformance' }, + { label: 'Regularity', value: 'Regularity' }, + { label: 'Usability', value: 'Usability' }, + ]; + const showImpactDimensionOverride = testType === 'CUSTOM' || testType === 'Condition_Flag' || testScope === 'referential'; + const tableNameOptions = [ ...new Set((tableColumns ?? []).map(c => c.table_name).filter(Boolean)) ].sort((a, b) => a.localeCompare(b)).map(t => ({ label: t, value: t })); @@ -1129,7 +1139,7 @@ const TestDefFormContent = ({ formValues, tableColumns, testSuite, validateResul }), ), - // Severity + Observability selects + // Severity + Observability + Impact Dimension selects div( { class: 'flex-row fx-gap-3 fx-flex-wrap' }, div( @@ -1152,6 +1162,17 @@ const TestDefFormContent = ({ formValues, tableColumns, testSuite, validateResul onChange: (value) => updateField('export_to_observability', value), }), ), + showImpactDimensionOverride ? div( + { style: 'flex: calc(50% - 8px) 0 0;' }, + () => Select({ + label: 'Impact Dimension Override', + value: fv.val.impact_dimension ?? null, + options: impactDimensionOptions, + allowNull: false, + helpText: 'Override the default impact classification for this test. Affects how the test result is categorized in score breakdowns.', + onChange: (value) => updateField('impact_dimension', value), + }), + ) : null, ), // Schema (read-only) diff --git a/testgen/ui/pdf/hygiene_issue_report.py b/testgen/ui/pdf/hygiene_issue_report.py index 03b22bb6..6c47afaf 100644 --- a/testgen/ui/pdf/hygiene_issue_report.py +++ b/testgen/ui/pdf/hygiene_issue_report.py @@ -111,6 +111,7 @@ def build_summary_table(document, hi_data): ("Database/Schema", hi_data["schema_name"], "Action", hi_data["disposition"] or "No Decision"), ("Table", hi_data["table_name"], "Data Type", hi_data["db_data_type"]), ("Column", hi_data["column_name"], "Semantic Data Type", hi_data["functional_data_type"]), + ("DQ Dimension", hi_data.get("dq_dimension"), "Impact Dimension", hi_data.get("impact_dimension")), ( "Column Tags", ( diff --git a/testgen/ui/pdf/test_result_report.py b/testgen/ui/pdf/test_result_report.py index b69d5e83..af8de818 100644 --- a/testgen/ui/pdf/test_result_report.py +++ b/testgen/ui/pdf/test_result_report.py @@ -126,6 +126,7 @@ def build_summary_table(document, tr_data): ("Test Run Date", test_timestamp, None, "Test Suite", tr_data["test_suite"]), ("Database/Schema", tr_data["schema_name"], None, "Table Group", tr_data["table_groups_name"]), ("Table", tr_data["table_name"], None, "Data Quality Dimension", tr_data["dq_dimension"]), + ("Impact Dimension", tr_data["impact_dimension"], None, None, None), ("Column", tr_data["column_names"], None, "Action", tr_data["disposition"] or "No Decision"), ( "Column Tags", diff --git a/testgen/ui/queries/profiling_queries.py b/testgen/ui/queries/profiling_queries.py index 4122487b..70e5f681 100644 --- a/testgen/ui/queries/profiling_queries.py +++ b/testgen/ui/queries/profiling_queries.py @@ -142,7 +142,7 @@ def get_table_by_id( ) -> dict | None: if not is_uuid4(table_id): return None - + condition = "WHERE table_id = :table_id" params = {"table_id": table_id} return get_tables_by_condition(condition, params, include_tags, include_has_test_runs, include_active_tests, include_scores)[0] @@ -172,7 +172,7 @@ def get_tables_by_table_group( ) -> list[dict]: if not is_uuid4(table_group_id): return None - + condition = "WHERE table_chars.table_groups_id = :table_group_id" params = {"table_group_id": table_group_id} return get_tables_by_condition(condition, params, include_tags, include_has_test_runs, include_active_tests, include_scores) @@ -605,6 +605,7 @@ def get_profiling_anomalies( WHEN t.issue_likelihood = 'Definite' THEN 1 END AS likelihood_order, t.anomaly_description, r.detail, t.detail_redactable, t.suggested_action, + t.dq_dimension, r.impact_dimension, r.anomaly_id, r.table_groups_id::VARCHAR, r.id::VARCHAR, p.profiling_starttime, r.profile_run_id::VARCHAR, tg.table_groups_name, tg.project_code, @@ -674,6 +675,7 @@ def get_profiling_anomalies_by_ids(anomaly_ids: list[str]) -> pd.DataFrame: WHEN t.issue_likelihood = 'Definite' THEN 1 END AS likelihood_order, t.anomaly_description, r.detail, t.detail_redactable, t.suggested_action, + t.dq_dimension, r.impact_dimension, r.anomaly_id, r.table_groups_id::VARCHAR, r.id::VARCHAR, p.profiling_starttime, r.profile_run_id::VARCHAR, p.job_execution_id::VARCHAR as job_execution_id, tg.table_groups_name, tg.project_code, diff --git a/testgen/ui/queries/scoring_queries.py b/testgen/ui/queries/scoring_queries.py index 67a075d0..e4561aa8 100644 --- a/testgen/ui/queries/scoring_queries.py +++ b/testgen/ui/queries/scoring_queries.py @@ -58,7 +58,8 @@ def get_score_card_issue_reports(selected_issues: list["SelectedIssue"]) -> list COALESCE(column_chars.stakeholder_group, table_chars.stakeholder_group, groups.stakeholder_group) as stakeholder_group, COALESCE(column_chars.transform_level, table_chars.transform_level, groups.transform_level) as transform_level, COALESCE(column_chars.aggregation_level, table_chars.aggregation_level) as aggregation_level, - COALESCE(column_chars.data_product, table_chars.data_product, groups.data_product) as data_product + COALESCE(column_chars.data_product, table_chars.data_product, groups.data_product) as data_product, + types.impact_dimension FROM profile_anomaly_results results INNER JOIN profile_anomaly_types types ON results.anomaly_id = types.id @@ -124,7 +125,9 @@ def get_score_card_issue_reports(selected_issues: list["SelectedIssue"]) -> list COALESCE(column_chars.stakeholder_group, table_chars.stakeholder_group, groups.stakeholder_group) as stakeholder_group, COALESCE(column_chars.transform_level, table_chars.transform_level, groups.transform_level) as transform_level, COALESCE(column_chars.aggregation_level, table_chars.aggregation_level) as aggregation_level, - COALESCE(column_chars.data_product, table_chars.data_product, groups.data_product) as data_product + COALESCE(column_chars.data_product, table_chars.data_product, groups.data_product) as data_product, + COALESCE(results.impact_dimension, types.impact_dimension) as impact_dimension, + test_runs.job_execution_id::VARCHAR FROM test_results results INNER JOIN test_types types ON (results.test_type = types.test_type) @@ -132,6 +135,8 @@ def get_score_card_issue_reports(selected_issues: list["SelectedIssue"]) -> list ON (results.test_suite_id = suites.id) INNER JOIN table_groups groups ON (results.table_groups_id = groups.id) + LEFT JOIN test_runs + ON (results.test_run_id = test_runs.id) LEFT JOIN data_column_chars column_chars ON (groups.id = column_chars.table_groups_id AND results.schema_name = column_chars.schema_name diff --git a/testgen/ui/queries/test_result_queries.py b/testgen/ui/queries/test_result_queries.py index e75ad19f..c93f9126 100644 --- a/testgen/ui/queries/test_result_queries.py +++ b/testgen/ui/queries/test_result_queries.py @@ -85,7 +85,7 @@ def get_test_results( ) SELECT r.table_name, p.project_name, ts.test_suite, tg.table_groups_name, cn.connection_name, cn.project_host, cn.sql_flavor, - tt.dq_dimension, tt.test_scope, + tt.dq_dimension, r.impact_dimension, tt.test_scope, r.schema_name, r.column_names, r.test_time::DATE as test_date, r.test_type, tt.id as test_type_id, tt.test_name_short, tt.test_name_long, r.test_description, tt.measure_uom, tt.measure_uom_description, c.test_operator, r.threshold_value::NUMERIC(16, 5), r.result_measure::NUMERIC(16, 5), r.result_status, @@ -173,7 +173,7 @@ def get_test_results_by_ids(test_result_ids: list[str]) -> pd.DataFrame: query = """ SELECT r.table_name, p.project_name, ts.test_suite, tg.table_groups_name, cn.connection_name, cn.project_host, cn.sql_flavor, - tt.dq_dimension, tt.test_scope, + tt.dq_dimension, r.impact_dimension, tt.test_scope, r.schema_name, r.column_names, r.test_time::DATE as test_date, r.test_type, tt.id as test_type_id, tt.test_name_short, tt.test_name_long, r.test_description, tt.measure_uom, tt.measure_uom_description, c.test_operator, r.threshold_value::NUMERIC(16, 5), r.result_measure::NUMERIC(16, 5), r.result_status, diff --git a/testgen/ui/static/js/components/score_breakdown.js b/testgen/ui/static/js/components/score_breakdown.js index 83a99e34..6158fed7 100644 --- a/testgen/ui/static/js/components/score_breakdown.js +++ b/testgen/ui/static/js/components/score_breakdown.js @@ -186,6 +186,7 @@ const CATEGORIES = { column_name: 'Columns', semantic_data_type: 'Semantic Data Types', dq_dimension: 'Quality Dimensions', + impact_dimension: 'Impact Dimensions', table_groups_name: 'Table Group', data_location: 'Data Location', data_source: 'Data Source', @@ -203,6 +204,7 @@ const BREAKDOWN_COLUMN_LABEL = { column_name: 'Table | Column', semantic_data_type: 'Semantic Data Type', dq_dimension: 'Quality Dimension', + impact_dimension: 'Impact Dimension', impact: '', score: 'Individual Score', issue_ct: 'Issue Count', diff --git a/testgen/ui/views/hygiene_issues.py b/testgen/ui/views/hygiene_issues.py index 2fadc4a1..12d03c19 100644 --- a/testgen/ui/views/hygiene_issues.py +++ b/testgen/ui/views/hygiene_issues.py @@ -493,6 +493,8 @@ def get_excel_report_data( "anomaly_name": {"header": "Issue Type"}, "issue_likelihood": {"header": "Likelihood"}, "anomaly_description": {"header": "Description", "wrap": True}, + "dq_dimension": {"header": "Quality dimension"}, + "impact_dimension": {"header": "Impact dimension"}, "action": {}, "detail": {}, "suggested_action": {"wrap": True}, diff --git a/testgen/ui/views/score_details.py b/testgen/ui/views/score_details.py index 5cd36a7b..e6f574ed 100644 --- a/testgen/ui/views/score_details.py +++ b/testgen/ui/views/score_details.py @@ -89,7 +89,7 @@ def render( category = ( score_definition.category.value if score_definition.category - else ScoreCategory.dq_dimension.value + else ScoreCategory.impact_dimension.value ) if not score_type or score_type not in typing.get_args(ScoreTypes): diff --git a/testgen/ui/views/score_explorer.py b/testgen/ui/views/score_explorer.py index edc32a14..5eca56ff 100644 --- a/testgen/ui/views/score_explorer.py +++ b/testgen/ui/views/score_explorer.py @@ -144,8 +144,8 @@ def render( if not breakdown_category or breakdown_category not in typing.get_args(Categories): breakdown_category = ( score_definition.category.value - if score_definition.category - else ScoreCategory.dq_dimension.value + if score_definition.category + else ScoreCategory.impact_dimension.value ) if not breakdown_score_type or breakdown_score_type not in typing.get_args(ScoreTypes): @@ -288,7 +288,7 @@ def export_issue_reports(selected_issues: list[SelectedIssue]) -> None: page=PAGE_PATH, issue_count=len(selected_issues), ) - + issues_data = get_score_card_issue_reports(selected_issues) dialog_title = "Download Issue Reports" if len(issues_data) == 1: diff --git a/testgen/ui/views/test_definitions.py b/testgen/ui/views/test_definitions.py index d992d5ab..71404adf 100644 --- a/testgen/ui/views/test_definitions.py +++ b/testgen/ui/views/test_definitions.py @@ -18,6 +18,7 @@ TestDefinitionMinimal, TestDefinitionNote, TestDefinitionSummary, + TestType, ) from testgen.common.models.test_suite import TestSuite from testgen.common.pii_masking import get_pii_columns, mask_profiling_pii @@ -791,6 +792,7 @@ def get_test_definitions( sort_expressions = { "flagged": lambda d: sort_funcs[d](case((TestDefinition.flagged == True, 0), else_=1)), + "test_name_short": lambda d: sort_funcs[d](func.lower(TestType.test_name_short)), } order_by = [] diff --git a/testgen/ui/views/test_results.py b/testgen/ui/views/test_results.py index 04c31518..3cdfcfcb 100644 --- a/testgen/ui/views/test_results.py +++ b/testgen/ui/views/test_results.py @@ -871,6 +871,7 @@ def get_excel_report_data( "test_name_short": {"header": "Test type"}, "test_description": {"header": "Description", "wrap": True}, "dq_dimension": {"header": "Quality dimension"}, + "impact_dimension": {"header": "Impact dimension"}, "measure_uom": {"header": "Unit of measure (UOM)"}, "measure_uom_description": {"header": "UOM description"}, "threshold_value": {}, diff --git a/testgen/utils/__init__.py b/testgen/utils/__init__.py index 862e01d6..7f3b71d5 100644 --- a/testgen/utils/__init__.py +++ b/testgen/utils/__init__.py @@ -166,6 +166,7 @@ def format_score_card(score_card: ScoreCard | None) -> ScoreCard: "transform_level": "Transform Level", "aggregation_level": "Aggregation Level", "dq_dimension": "Quality Dimension", + "impact_dimension": "Impact Dimension", "data_product": "Data Product", } if not score_card: diff --git a/tests/unit/common/models/test_impact_dimension.py b/tests/unit/common/models/test_impact_dimension.py new file mode 100644 index 00000000..d4b95c31 --- /dev/null +++ b/tests/unit/common/models/test_impact_dimension.py @@ -0,0 +1,317 @@ +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +from testgen.common.models.scores import ( + SCORE_CARD_NULL_DRILLDOWN, + SCORE_CATEGORIES, + ScoreCategory, + ScoreDefinition, + ScoreDefinitionCriteria, +) +from testgen.common.models.test_definition import TestDefinition, TestDefinitionSummary, TestType, TestTypeSummary +from testgen.common.read_file import get_template_files +from testgen.utils import format_score_card, format_score_card_breakdown, format_score_card_issues + +pytestmark = pytest.mark.unit + +VALID_IMPACT_DIMENSIONS = {"Reliability", "Conformance", "Regularity", "Usability"} + + +# --- YAML completeness --- + +def _load_yaml_files(subfolder: str, mask: str) -> list[tuple[str, dict]]: + results = [] + for entry in get_template_files(mask, sub_directory=subfolder): + with entry.open("r") as f: + data = yaml.safe_load(f) + results.append((entry.name, data)) + return results + + +@pytest.fixture(scope="module") +def test_type_yamls(): + return _load_yaml_files("dbsetup_test_types", r"test_types_.*\.yaml") + + +@pytest.fixture(scope="module") +def anomaly_type_yamls(): + return _load_yaml_files("dbsetup_anomaly_types", r"profile_anomaly_types_.*\.yaml") + + +def test_all_test_type_yamls_have_impact_dimension(test_type_yamls): + missing = [name for name, data in test_type_yamls if "impact_dimension" not in data.get("test_types", {})] + assert not missing, f"Missing impact_dimension in test type YAMLs: {missing}" + + +def test_all_test_type_yaml_impact_dimensions_are_valid(test_type_yamls): + invalid = [ + (name, data["test_types"]["impact_dimension"]) + for name, data in test_type_yamls + if data.get("test_types", {}).get("impact_dimension") not in VALID_IMPACT_DIMENSIONS + ] + assert not invalid, f"Invalid impact_dimension values: {invalid}" + + +def test_all_anomaly_type_yamls_have_impact_dimension(anomaly_type_yamls): + missing = [ + name for name, data in anomaly_type_yamls + if "impact_dimension" not in data.get("profile_anomaly_types", {}) + ] + assert not missing, f"Missing impact_dimension in anomaly type YAMLs: {missing}" + + +def test_all_anomaly_type_yaml_impact_dimensions_are_valid(anomaly_type_yamls): + invalid = [ + (name, data["profile_anomaly_types"]["impact_dimension"]) + for name, data in anomaly_type_yamls + if data.get("profile_anomaly_types", {}).get("impact_dimension") not in VALID_IMPACT_DIMENSIONS + ] + assert not invalid, f"Invalid impact_dimension values: {invalid}" + + +# --- ScoreCategory --- + +def test_impact_dimension_in_score_category_enum(): + assert ScoreCategory.impact_dimension.value == "impact_dimension" + + +def test_impact_dimension_in_score_categories_list(): + assert "impact_dimension" in SCORE_CATEGORIES + + +# --- ORM annotations --- + +def test_test_type_orm_has_impact_dimension_column(): + assert hasattr(TestType, "impact_dimension") + + +def test_test_definition_orm_has_impact_dimension_column(): + assert hasattr(TestDefinition, "impact_dimension") + + +def test_test_definition_summary_has_impact_dimension_annotation(): + assert "impact_dimension" in TestDefinitionSummary.__annotations__ + + +def test_test_type_summary_has_default_impact_dimension_annotation(): + assert "default_impact_dimension" in TestTypeSummary.__annotations__ + + +def test_summary_columns_include_default_impact_dimension_label(): + from testgen.common.models.test_definition import TestDefinition as TD + labels = { + col.key if hasattr(col, "key") else getattr(col, "name", None) + for col in TD._summary_columns + if hasattr(col, "key") or hasattr(col, "name") + } + assert "default_impact_dimension" in labels + + +# --- format_score_card categories_label --- + +def _make_definition(category: ScoreCategory | None = None) -> MagicMock: + defn = MagicMock() + defn.total_score = True + defn.cde_score = True + defn.category = category + return defn + + +def test_format_score_card_impact_dimension_label(): + defn = _make_definition(ScoreCategory.impact_dimension) + card = { + "id": None, "project_code": "p", "name": "n", + "score": None, "cde_score": None, "profiling_score": None, "testing_score": None, + "categories": [], "history": [], "definition": defn, + } + result = format_score_card(card) + assert result["categories_label"] == "Impact Dimension" + + +def test_format_score_card_dq_dimension_label_unchanged(): + defn = _make_definition(ScoreCategory.dq_dimension) + card = { + "id": None, "project_code": "p", "name": "n", + "score": None, "cde_score": None, "profiling_score": None, "testing_score": None, + "categories": [], "history": [], "definition": defn, + } + result = format_score_card(card) + assert result["categories_label"] == "Quality Dimension" + + +def test_format_score_card_no_category_gives_none_label(): + defn = _make_definition(None) + card = { + "id": None, "project_code": "p", "name": "n", + "score": None, "cde_score": None, "profiling_score": None, "testing_score": None, + "categories": [], "history": [], "definition": defn, + } + result = format_score_card(card) + assert result["categories_label"] is None + + +# --- format_score_card_breakdown / format_score_card_issues --- + +def test_format_score_card_breakdown_impact_dimension_column(): + row = {"impact_dimension": "Reliability", "impact": 0.5, "score": 0.9, "issue_ct": 3, "table_groups_id": None} + result = format_score_card_breakdown([row], "impact_dimension") + assert result["columns"] == ["impact_dimension", "impact", "score", "issue_ct"] + assert result["items"][0]["impact_dimension"] == "Reliability" + + +def test_format_score_card_issues_impact_dimension_includes_column(): + row = {"type": "Null Values", "status": "Definite", "detail": "x", "time": 1000, "column": "col_a", "id": "1", "table_group_id": "tg", "table": "t", "name": "", "run_id": "r", "issue_type": "hygiene"} + result = format_score_card_issues([row], "impact_dimension") + # impact_dimension is not column_name, so "column" should be in columns + assert "column" in result["columns"] + + +# --- get_score_card_issues: template routing and filter generation --- + +def _make_score_definition(group_by_field: bool = True) -> ScoreDefinition: + defn = ScoreDefinition() + defn.project_code = "proj" + defn.criteria = ScoreDefinitionCriteria.from_filters( + [{"field": "table_groups_name", "value": "tg1", "others": []}], + group_by_field=group_by_field, + ) + return defn + + + +def test_get_score_card_issues_uses_impact_dimension_template(): + defn = _make_score_definition() + with patch("testgen.common.models.scores.get_current_session") as mock_session_fn: + mock_result = MagicMock() + mock_result.mappings.return_value.all.return_value = [] + mock_session_fn.return_value.execute.return_value = mock_result + with patch("testgen.common.models.scores.read_template_sql_file", return_value="SELECT 1") as mock_read: + defn.get_score_card_issues("score", "impact_dimension", "Reliability") + mock_read.assert_called_once_with( + "get_score_card_issues_by_impact_dimension.sql", sub_directory="score_cards" + ) + + +def test_get_score_card_issues_uses_dq_dimension_template(): + defn = _make_score_definition() + with patch("testgen.common.models.scores.get_current_session") as mock_session_fn: + mock_result = MagicMock() + mock_result.mappings.return_value.all.return_value = [] + mock_session_fn.return_value.execute.return_value = mock_result + with patch("testgen.common.models.scores.read_template_sql_file", return_value="SELECT 1") as mock_read: + defn.get_score_card_issues("score", "dq_dimension", "Accuracy") + mock_read.assert_called_once_with( + "get_score_card_issues_by_dimension.sql", sub_directory="score_cards" + ) + + +def test_get_score_card_issues_impact_dimension_filter_normal_value(): + defn = _make_score_definition() + # Use a real template so we can inspect placeholder replacement + with patch("testgen.common.models.scores.get_current_session") as mock_session_fn: + mock_result = MagicMock() + mock_result.mappings.return_value.all.return_value = [] + captured_query = {} + def capture(q, params=None): + captured_query["sql"] = str(q) + return mock_result + mock_session_fn.return_value.execute.side_effect = capture + + template = ( + "WHERE {filters} AND {value_filter}" + "{profiling_impact_dimension_filter}" + "{test_impact_dimension_filter}" + ) + with patch("testgen.common.models.scores.read_template_sql_file", return_value=template): + defn.get_score_card_issues("score", "impact_dimension", "Reliability") + + sql = captured_query["sql"] + assert "types.impact_dimension = :value" in sql + assert "test_results.impact_dimension = :value" in sql + + +def test_get_score_card_issues_impact_dimension_filter_null_drilldown(): + defn = _make_score_definition() + with patch("testgen.common.models.scores.get_current_session") as mock_session_fn: + mock_result = MagicMock() + mock_result.mappings.return_value.all.return_value = [] + captured_query = {} + def capture(q, params=None): + captured_query["sql"] = str(q) + return mock_result + mock_session_fn.return_value.execute.side_effect = capture + + template = ( + "WHERE {filters} AND {value_filter}" + "{profiling_impact_dimension_filter}" + "{test_impact_dimension_filter}" + ) + with patch("testgen.common.models.scores.read_template_sql_file", return_value=template): + defn.get_score_card_issues("score", "impact_dimension", SCORE_CARD_NULL_DRILLDOWN) + + sql = captured_query["sql"] + assert "types.impact_dimension IS NULL" in sql + assert "test_results.impact_dimension IS NULL" in sql + + +def test_get_score_card_issues_dq_dimension_filter_does_not_leak_impact_placeholders(): + """dq_dimension path must leave impact_dimension placeholders empty.""" + defn = _make_score_definition() + with patch("testgen.common.models.scores.get_current_session") as mock_session_fn: + mock_result = MagicMock() + mock_result.mappings.return_value.all.return_value = [] + captured_query = {} + def capture(q, params=None): + captured_query["sql"] = str(q) + return mock_result + mock_session_fn.return_value.execute.side_effect = capture + + template = ( + "WHERE {filters} AND {value_filter}" + "{dq_dimension_filter}" + "{profiling_impact_dimension_filter}" + "{test_impact_dimension_filter}" + ) + with patch("testgen.common.models.scores.read_template_sql_file", return_value=template): + defn.get_score_card_issues("score", "dq_dimension", "Accuracy") + + sql = captured_query["sql"] + assert "impact_dimension" not in sql + + +# --- get_score_card_breakdown: join condition for impact_dimension --- + +def test_get_score_card_breakdown_impact_dimension_uses_null_safe_join(): + defn = _make_score_definition() + with patch("testgen.common.models.scores.get_current_session") as mock_session_fn: + mock_result = MagicMock() + mock_result.mappings.return_value.all.return_value = [] + captured_query = {} + def capture(q): + captured_query["sql"] = str(q) + return mock_result + mock_session_fn.return_value.execute.side_effect = capture + + template = "{join_condition}" + with patch("testgen.common.models.scores.read_template_sql_file", return_value=template): + defn.get_score_card_breakdown("score", "impact_dimension") + + sql = captured_query["sql"] + # impact_dimension uses the OR IS NULL pattern, not a simple equality join + assert "IS NULL" in sql + assert "impact_dimension" in sql + + +def test_get_score_card_breakdown_uses_impact_dimension_template(): + defn = _make_score_definition() + with patch("testgen.common.models.scores.get_current_session") as mock_session_fn: + mock_result = MagicMock() + mock_result.mappings.return_value.all.return_value = [] + mock_session_fn.return_value.execute.return_value = mock_result + with patch("testgen.common.models.scores.read_template_sql_file", return_value="{join_condition}{columns}{group_by}{filters}{records_count_filters}{non_null_columns}") as mock_read: + defn.get_score_card_breakdown("score", "impact_dimension") + mock_read.assert_called_once_with( + "get_score_card_breakdown_by_impact_dimension.sql", sub_directory="score_cards" + ) From b50260c70f84c26381b627ba64aadc74f37f8ff5 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Thu, 30 Apr 2026 22:38:32 -0400 Subject: [PATCH 105/123] fix(impact dimensions): updates from review --- testgen/mcp/tools/reference.py | 6 ++++-- testgen/mcp/tools/test_definitions.py | 20 ++++++++++++++----- .../dbupgrade/0187_incremental_upgrade.sql | 2 +- testgen/ui/pdf/hygiene_issue_report.py | 15 +++++++------- testgen/ui/pdf/test_result_report.py | 6 +++--- testgen/ui/queries/scoring_queries.py | 14 +++++++++---- .../static/js/components/score_breakdown.js | 2 +- .../ui/static/js/components/score_issues.js | 5 +++-- testgen/ui/views/hygiene_issues.py | 2 +- testgen/ui/views/test_definitions.py | 2 +- testgen/ui/views/test_results.py | 2 +- tests/unit/mcp/test_tools_reference.py | 6 ++++++ tests/unit/mcp/test_tools_test_definitions.py | 12 +++++++++++ 13 files changed, 66 insertions(+), 28 deletions(-) diff --git a/testgen/mcp/tools/reference.py b/testgen/mcp/tools/reference.py index ed399108..114cfe45 100644 --- a/testgen/mcp/tools/reference.py +++ b/testgen/mcp/tools/reference.py @@ -28,6 +28,8 @@ def get_test_type(test_type: str) -> str: doc.field("Measure Description", tt.measure_uom_description) if tt.threshold_description: doc.field("Threshold", tt.threshold_description) + if tt.impact_dimension: + doc.field("Impact Dimension", tt.impact_dimension) if tt.dq_dimension: doc.field("Quality Dimension", tt.dq_dimension) if tt.test_scope: @@ -68,9 +70,9 @@ def test_types_resource() -> str: doc = MdDoc() doc.heading(1, "TestGen Test Types Reference") doc.table( - headers=["Test Type", "Quality Dimension", "Scope", "Description"], + headers=["Test Type", "Impact Dimension", "Quality Dimension", "Scope", "Description"], rows=[ - [tt.test_name_short, tt.dq_dimension, tt.test_scope, tt.test_description] + [tt.test_name_short, tt.impact_dimension, tt.dq_dimension, tt.test_scope, tt.test_description] for tt in test_types ], ) diff --git a/testgen/mcp/tools/test_definitions.py b/testgen/mcp/tools/test_definitions.py index d2061c1f..7863c331 100644 --- a/testgen/mcp/tools/test_definitions.py +++ b/testgen/mcp/tools/test_definitions.py @@ -14,7 +14,8 @@ from testgen.mcp.tools.markdown import MdDoc _VALID_SCOPES = {"column", "table", "referential", "custom"} -_VALID_DIMENSIONS = {"Accuracy", "Completeness", "Consistency", "Recency", "Timeliness", "Uniqueness", "Validity"} +_VALID_IMPACT_DIMENSIONS = {"Reliability", "Conformance", "Regularity", "Usability"} +_VALID_DQ_DIMENSIONS = {"Accuracy", "Completeness", "Consistency", "Recency", "Timeliness", "Uniqueness", "Validity"} @with_database_session @@ -131,6 +132,8 @@ def get_test(test_definition_id: str) -> str: doc.field("Schema", td.schema_name, code=True) if td.test_scope: doc.field("Scope", td.test_scope) + if td.impact_dimension or td.default_impact_dimension: + doc.field("Impact Dimension", td.impact_dimension or td.default_impact_dimension) if td.dq_dimension: doc.field("Quality Dimension", td.dq_dimension) @@ -284,24 +287,31 @@ def _append_match_section(doc: MdDoc, td: TestDefinitionSummary) -> None: @with_database_session def list_test_types( scope: str | None = None, + impact_dimension: str | None = None, quality_dimension: str | None = None, ) -> str: """List available test types with optional filtering. Args: scope: Filter by test scope ('column', 'table', 'referential', 'custom'). + impact_dimension: Filter by impact dimension ('Reliability', 'Conformance', 'Regularity', 'Usability'). quality_dimension: Filter by quality dimension ('Accuracy', 'Completeness', 'Consistency', 'Recency', 'Timeliness', 'Uniqueness', 'Validity'). """ if scope and scope not in _VALID_SCOPES: valid = ", ".join(sorted(_VALID_SCOPES)) raise MCPUserError(f"Invalid scope `{scope}`. Valid values: {valid}") - if quality_dimension and quality_dimension not in _VALID_DIMENSIONS: - valid = ", ".join(sorted(_VALID_DIMENSIONS)) + if impact_dimension and impact_dimension not in _VALID_IMPACT_DIMENSIONS: + valid = ", ".join(sorted(_VALID_IMPACT_DIMENSIONS)) + raise MCPUserError(f"Invalid impact_dimension `{impact_dimension}`. Valid values: {valid}") + if quality_dimension and quality_dimension not in _VALID_DQ_DIMENSIONS: + valid = ", ".join(sorted(_VALID_DQ_DIMENSIONS)) raise MCPUserError(f"Invalid quality_dimension `{quality_dimension}`. Valid values: {valid}") clauses = [TestType.active == "Y"] if scope: clauses.append(TestType.test_scope == scope) + if impact_dimension: + clauses.append(TestType.impact_dimension == impact_dimension) if quality_dimension: clauses.append(TestType.dq_dimension == quality_dimension) @@ -327,9 +337,9 @@ def list_test_types( doc.heading(1, "Test Types") doc.text(f"Showing {len(test_types)} test type(s){filter_suffix}.") doc.table( - headers=["Test Type", "Quality Dimension", "Scope", "Description"], + headers=["Test Type", "Impact Dimension", "Quality Dimension", "Scope", "Description"], rows=[ - [tt.test_name_short, tt.dq_dimension, tt.test_scope, tt.test_description] + [tt.test_name_short, tt.impact_dimension, tt.dq_dimension, tt.test_scope, tt.test_description] for tt in test_types ], ) diff --git a/testgen/template/dbupgrade/0187_incremental_upgrade.sql b/testgen/template/dbupgrade/0187_incremental_upgrade.sql index b159b98d..01248988 100644 --- a/testgen/template/dbupgrade/0187_incremental_upgrade.sql +++ b/testgen/template/dbupgrade/0187_incremental_upgrade.sql @@ -1,6 +1,6 @@ SET SEARCH_PATH TO {SCHEMA_NAME}; --- TG-1045: Add impact_dimension as second classification axis for DQ scoring +-- Add impact_dimension as second classification axis for DQ scoring ALTER TABLE test_types ADD COLUMN IF NOT EXISTS impact_dimension VARCHAR(20); diff --git a/testgen/ui/pdf/hygiene_issue_report.py b/testgen/ui/pdf/hygiene_issue_report.py index 6c47afaf..a6d24fe3 100644 --- a/testgen/ui/pdf/hygiene_issue_report.py +++ b/testgen/ui/pdf/hygiene_issue_report.py @@ -64,11 +64,12 @@ def build_summary_table(document, hi_data): ("SPAN", (3, 3), (4, 3)), ("SPAN", (3, 4), (4, 4)), ("SPAN", (3, 5), (4, 5)), - ("SPAN", (1, 6), (4, 6)), - ("SPAN", (0, 7), (4, 7)), + ("SPAN", (3, 6), (4, 6)), + ("SPAN", (1, 7), (4, 7)), + ("SPAN", (0, 8), (4, 8)), # Link cell - ("BACKGROUND", (0, 7), (4, 7), colors.white), + ("BACKGROUND", (0, 8), (4, 8), colors.white), # Status cell *[ @@ -108,10 +109,10 @@ def build_summary_table(document, hi_data): ), ("Profiling Date", profiling_timestamp, "Table Group", hi_data["table_groups_name"]), - ("Database/Schema", hi_data["schema_name"], "Action", hi_data["disposition"] or "No Decision"), - ("Table", hi_data["table_name"], "Data Type", hi_data["db_data_type"]), - ("Column", hi_data["column_name"], "Semantic Data Type", hi_data["functional_data_type"]), - ("DQ Dimension", hi_data.get("dq_dimension"), "Impact Dimension", hi_data.get("impact_dimension")), + ("Database/Schema", hi_data["schema_name"], "Data Type", hi_data["db_data_type"]), + ("Table", hi_data["table_name"], "Semantic Data Type", hi_data["functional_data_type"]), + ("Column", hi_data["column_name"], "Impact Dimension", hi_data.get("impact_dimension")), + ("Action", hi_data["disposition"] or "No Decision","Quality Dimension", hi_data.get("dq_dimension")), ( "Column Tags", ( diff --git a/testgen/ui/pdf/test_result_report.py b/testgen/ui/pdf/test_result_report.py index af8de818..a7485c7c 100644 --- a/testgen/ui/pdf/test_result_report.py +++ b/testgen/ui/pdf/test_result_report.py @@ -125,9 +125,9 @@ def build_summary_table(document, tr_data): ("Test Run Date", test_timestamp, None, "Test Suite", tr_data["test_suite"]), ("Database/Schema", tr_data["schema_name"], None, "Table Group", tr_data["table_groups_name"]), - ("Table", tr_data["table_name"], None, "Data Quality Dimension", tr_data["dq_dimension"]), - ("Impact Dimension", tr_data["impact_dimension"], None, None, None), - ("Column", tr_data["column_names"], None, "Action", tr_data["disposition"] or "No Decision"), + ("Table", tr_data["table_name"], None, "Impact Dimension", tr_data["impact_dimension"]), + ("Column", tr_data["column_names"], None, "Quality Dimension", tr_data["dq_dimension"]), + ("Action", tr_data["disposition"] or "No Decision", None, None), ( "Column Tags", ( diff --git a/testgen/ui/queries/scoring_queries.py b/testgen/ui/queries/scoring_queries.py index e4561aa8..26ff23a5 100644 --- a/testgen/ui/queries/scoring_queries.py +++ b/testgen/ui/queries/scoring_queries.py @@ -59,7 +59,8 @@ def get_score_card_issue_reports(selected_issues: list["SelectedIssue"]) -> list COALESCE(column_chars.transform_level, table_chars.transform_level, groups.transform_level) as transform_level, COALESCE(column_chars.aggregation_level, table_chars.aggregation_level) as aggregation_level, COALESCE(column_chars.data_product, table_chars.data_product, groups.data_product) as data_product, - types.impact_dimension + types.impact_dimension, + types.dq_dimension FROM profile_anomaly_results results INNER JOIN profile_anomaly_types types ON results.anomaly_id = types.id @@ -107,6 +108,7 @@ def get_score_card_issue_reports(selected_issues: list["SelectedIssue"]) -> list ELSE 'Passed' END as disposition, results.test_run_id::VARCHAR, + test_runs.job_execution_id::VARCHAR, types.usage_notes, types.test_type, results.auto_gen, @@ -126,9 +128,7 @@ def get_score_card_issue_reports(selected_issues: list["SelectedIssue"]) -> list COALESCE(column_chars.transform_level, table_chars.transform_level, groups.transform_level) as transform_level, COALESCE(column_chars.aggregation_level, table_chars.aggregation_level) as aggregation_level, COALESCE(column_chars.data_product, table_chars.data_product, groups.data_product) as data_product, - COALESCE(results.impact_dimension, types.impact_dimension) as impact_dimension, - test_runs.job_execution_id::VARCHAR - FROM test_results results + COALESCE(results.impact_dimension, types.impact_dimension) as impact_dimension FROM test_results results INNER JOIN test_types types ON (results.test_type = types.test_type) INNER JOIN test_suites suites @@ -162,6 +162,12 @@ def get_score_category_values(project_code: str) -> dict[ScoreCategory, list[str "Uniqueness", "Validity", ], + "impact_dimension": [ + "Reliability", + "Conformance", + "Regularity", + "Usability", + ], }) categories = [ "table_groups_name", diff --git a/testgen/ui/static/js/components/score_breakdown.js b/testgen/ui/static/js/components/score_breakdown.js index 6158fed7..717c4d36 100644 --- a/testgen/ui/static/js/components/score_breakdown.js +++ b/testgen/ui/static/js/components/score_breakdown.js @@ -83,7 +83,7 @@ const ScoreBreakdown = (score, breakdown, category, scoreType, onViewDetails, em * Translate the column names for the table. * * @param {Array} columns - * @param {('table_name' | 'column_name' | 'semantic_data_type' | 'dq_dimension')} category + * @param {('table_name' | 'column_name' | 'semantic_data_type' | 'dq_dimension' | 'impact_dimension')} category * @param {('score' | 'cde_score')} scoreType * @returns {} */ diff --git a/testgen/ui/static/js/components/score_issues.js b/testgen/ui/static/js/components/score_issues.js index 5fc0247d..b558f0ad 100644 --- a/testgen/ui/static/js/components/score_issues.js +++ b/testgen/ui/static/js/components/score_issues.js @@ -53,7 +53,7 @@ const IssuesTable = ( /** @type string[] */ columns, /** @type Score */ score, /** @type ('score' | 'cde_score') */ scoreType, - /** @type ('table_name' | 'column_name' | 'semantic_data_type' | 'dq_dimension') */ category, + /** @type ('table_name' | 'column_name' | 'semantic_data_type' | 'dq_dimension' | 'impact_dimension') */ category, /** @type string */ drilldown, /** @type function */ onBack, emit, @@ -217,7 +217,7 @@ const ColumnProfilingButton = ( const Toolbar = ( /** @type {object} */ filters, /** @type Issue[] */ issues, - /** @type ('table_name' | 'column_name' | 'semantic_data_type' | 'dq_dimension') */ category, + /** @type ('table_name' | 'column_name' | 'semantic_data_type' | 'dq_dimension' | 'impact_dimension') */ category, ) => { const filterOptions = { table: [ ...new Set(issues.map(({ table }) => table)) ] @@ -357,6 +357,7 @@ const COLUMN_LABEL = { column_name: 'Table > Column', semantic_data_type: 'Semantic Data Type', dq_dimension: 'Quality Dimension', + impact_dimension: 'Impact Dimension', }; const ISSUES_COLUMN_LABEL = { diff --git a/testgen/ui/views/hygiene_issues.py b/testgen/ui/views/hygiene_issues.py index 12d03c19..50dbcf50 100644 --- a/testgen/ui/views/hygiene_issues.py +++ b/testgen/ui/views/hygiene_issues.py @@ -493,8 +493,8 @@ def get_excel_report_data( "anomaly_name": {"header": "Issue Type"}, "issue_likelihood": {"header": "Likelihood"}, "anomaly_description": {"header": "Description", "wrap": True}, - "dq_dimension": {"header": "Quality dimension"}, "impact_dimension": {"header": "Impact dimension"}, + "dq_dimension": {"header": "Quality dimension"}, "action": {}, "detail": {}, "suggested_action": {"wrap": True}, diff --git a/testgen/ui/views/test_definitions.py b/testgen/ui/views/test_definitions.py index 71404adf..e0d8d267 100644 --- a/testgen/ui/views/test_definitions.py +++ b/testgen/ui/views/test_definitions.py @@ -722,7 +722,7 @@ def run_test_type_lookup_query(test_type: str | None = None) -> pd.DataFrame: tt.test_name_short, tt.test_name_long, tt.test_description, tt.measure_uom, COALESCE(tt.measure_uom_description, '') as measure_uom_description, tt.default_parm_columns, tt.default_severity, - tt.run_type, tt.test_scope, tt.dq_dimension, tt.threshold_description, + tt.run_type, tt.test_scope, tt.dq_dimension, tt.impact_dimension, tt.threshold_description, tt.column_name_prompt, tt.column_name_help, tt.default_parm_prompts, tt.default_parm_help, tt.usage_notes, CASE tt.test_scope diff --git a/testgen/ui/views/test_results.py b/testgen/ui/views/test_results.py index 3cdfcfcb..014e4182 100644 --- a/testgen/ui/views/test_results.py +++ b/testgen/ui/views/test_results.py @@ -870,8 +870,8 @@ def get_excel_report_data( "column_names": {"header": "Columns/Focus"}, "test_name_short": {"header": "Test type"}, "test_description": {"header": "Description", "wrap": True}, - "dq_dimension": {"header": "Quality dimension"}, "impact_dimension": {"header": "Impact dimension"}, + "dq_dimension": {"header": "Quality dimension"}, "measure_uom": {"header": "Unit of measure (UOM)"}, "measure_uom_description": {"header": "UOM description"}, "threshold_value": {}, diff --git a/tests/unit/mcp/test_tools_reference.py b/tests/unit/mcp/test_tools_reference.py index bbfcdead..519e6e61 100644 --- a/tests/unit/mcp/test_tools_reference.py +++ b/tests/unit/mcp/test_tools_reference.py @@ -11,6 +11,7 @@ def test_get_test_type_found(mock_tt_cls, db_session_mock): tt.measure_uom = "Pct" tt.measure_uom_description = "Percentage of truncated values" tt.threshold_description = "Maximum allowed truncation rate" + tt.impact_dimension = "Conformance" tt.dq_dimension = "Accuracy" tt.test_scope = "column" tt.except_message = "Alpha truncation detected" @@ -23,6 +24,7 @@ def test_get_test_type_found(mock_tt_cls, db_session_mock): assert "Alpha Truncation" in result assert "Alpha_Trunc" not in result + assert "Conformance" in result assert "Accuracy" in result assert "column" in result assert "truncated" in result.lower() @@ -44,12 +46,14 @@ def test_test_types_resource(mock_tt_cls, db_session_mock): tt1 = MagicMock() tt1.test_type = "Alpha_Trunc" tt1.test_name_short = "Alpha Truncation" + tt1.impact_dimension = "Conformance" tt1.dq_dimension = "Accuracy" tt1.test_scope = "column" tt1.test_description = "Checks truncation" tt2 = MagicMock() tt2.test_type = "Unique_Pct" tt2.test_name_short = "Unique Percent" + tt2.impact_dimension = "Usability" tt2.dq_dimension = "Uniqueness" tt2.test_scope = "column" tt2.test_description = "Checks unique percentage" @@ -63,6 +67,8 @@ def test_test_types_resource(mock_tt_cls, db_session_mock): assert "Unique Percent" in result assert "Alpha_Trunc" not in result assert "Unique_Pct" not in result + assert "Conformance" in result + assert "Usability" in result assert "Accuracy" in result assert "Uniqueness" in result diff --git a/tests/unit/mcp/test_tools_test_definitions.py b/tests/unit/mcp/test_tools_test_definitions.py index ae0cbfe3..5dea0d03 100644 --- a/tests/unit/mcp/test_tools_test_definitions.py +++ b/tests/unit/mcp/test_tools_test_definitions.py @@ -138,6 +138,8 @@ def test_get_test_basic(mock_td, mock_tr, mock_notes, db_session_mock): td.test_type = "Alpha_Trunc" td.test_name_short = "Alpha Truncation" td.display_name = "Alpha Truncation" + td.impact_dimension = "Reliability" + td.default_impact_dimension = "Conformance" td.dq_dimension = "Accuracy" td.table_name = "orders" td.column_name = "customer_name" @@ -178,6 +180,7 @@ def test_get_test_basic(mock_td, mock_tr, mock_notes, db_session_mock): assert "Alpha Truncation" in result assert "`customer_name`" in result assert "`orders`" in result + assert "Reliability" in result assert "Accuracy" in result assert "Checks for truncated alpha values" in result assert "No results recorded" in result @@ -207,6 +210,8 @@ def test_get_test_with_last_result(mock_td, mock_tr, mock_notes, db_session_mock td.test_type = "Row_Ct" td.test_name_short = "Row Count" td.display_name = "Row Count" + td.impact_dimension = None + td.default_impact_dimension = "Conformance" td.dq_dimension = "Completeness" td.table_name = "orders" td.column_name = None @@ -267,6 +272,8 @@ def test_get_test_with_parameters(mock_td, mock_tr, mock_notes, db_session_mock) td.test_type = "Alpha_Trunc" td.test_name_short = "Alpha Truncation" td.display_name = "Alpha Truncation" + td.impact_dimension = None + td.default_impact_dimension = "Conformance" td.dq_dimension = None td.table_name = "orders" td.column_name = "name" @@ -327,6 +334,8 @@ def test_get_test_flagged_with_notes(mock_td, mock_tr, mock_notes, db_session_mo td.test_type = "Alpha_Trunc" td.test_name_short = "Alpha Truncation" td.display_name = "Alpha Truncation" + td.impact_dimension = None + td.default_impact_dimension = "Conformance" td.dq_dimension = None td.table_name = "orders" td.column_name = "name" @@ -465,6 +474,7 @@ def test_list_test_notes_invalid_uuid(db_session_mock): def test_list_test_types_basic(mock_tt, db_session_mock): tt = MagicMock() tt.test_name_short = "Alpha Truncation" + tt.impact_dimension = "Conformance" tt.dq_dimension = "Accuracy" tt.test_scope = "column" tt.test_description = "Checks for truncated values" @@ -475,6 +485,7 @@ def test_list_test_types_basic(mock_tt, db_session_mock): result = list_test_types() assert "Alpha Truncation" in result + assert "Conformance" in result assert "Accuracy" in result assert "column" in result @@ -522,6 +533,7 @@ def test_list_test_types_invalid_quality_dimension(db_session_mock): def test_list_test_types_filter_description(mock_tt, db_session_mock): tt = MagicMock() tt.test_name_short = "Row Count" + tt.impact_dimension = "Regularity" tt.dq_dimension = "Completeness" tt.test_scope = "table" tt.test_description = "Checks row count" From a8191306b09fb29a181cb39f8ad236b1bfc76211 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Thu, 30 Apr 2026 19:46:38 -0400 Subject: [PATCH 106/123] =?UTF-8?q?feat(mcp):=20execution=20tools=20?= =?UTF-8?q?=E2=80=94=20run,=20cancel,=20generate=20(TG-1030)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds five MCP tools wrapping the job-execution queue: run_tests, run_profiling, generate_tests, cancel_test_run, cancel_profiling_run. All require ``edit`` permission and tag submitted jobs with source=mcp. Cancel tools filter by job_key so a profiling-run UUID handed to cancel_test_run resolves to the same unified "not found or not accessible" error rather than leaking existence. Also makes JobExecution.request_cancel() idempotent: a re-request on an already-cancel-requested job now returns True without re-issuing the transition. Fixes the misleading "Cannot cancel" message that fired on double-cancel across UI, REST, and MCP. Co-Authored-By: Claude Opus 4.7 (1M context) --- testgen/common/models/job_execution.py | 5 +- testgen/mcp/server.py | 12 + testgen/mcp/tools/execution.py | 165 +++++++ .../unit/common/models/test_job_execution.py | 26 ++ tests/unit/mcp/conftest.py | 1 + tests/unit/mcp/test_tools_execution.py | 431 ++++++++++++++++++ 6 files changed, 639 insertions(+), 1 deletion(-) create mode 100644 testgen/mcp/tools/execution.py create mode 100644 tests/unit/mcp/test_tools_execution.py diff --git a/testgen/common/models/job_execution.py b/testgen/common/models/job_execution.py index ca092a93..b1bedc37 100644 --- a/testgen/common/models/job_execution.py +++ b/testgen/common/models/job_execution.py @@ -180,7 +180,10 @@ def mark_canceled(self) -> bool: return self._transition(JobStatus.CANCELED, completed_at=datetime.now(UTC)) def request_cancel(self) -> bool: - return self._transition(JobStatus.CANCEL_REQUESTED) + # Idempotent: a re-request on an already-cancel-requested job succeeds + # without re-issuing the transition, so the caller doesn't have to + # special-case "cancel pressed twice." + return self.status == JobStatus.CANCEL_REQUESTED.value or self._transition(JobStatus.CANCEL_REQUESTED) def mark_completed(self) -> bool: return self._transition(JobStatus.COMPLETED, completed_at=datetime.now(UTC)) diff --git a/testgen/mcp/server.py b/testgen/mcp/server.py index d17c9a9f..19e3a539 100644 --- a/testgen/mcp/server.py +++ b/testgen/mcp/server.py @@ -95,6 +95,13 @@ def build_mcp_app( table_health, ) from testgen.mcp.tools.discovery import get_data_inventory, list_projects, list_tables, list_test_suites + from testgen.mcp.tools.execution import ( + cancel_profiling_run, + cancel_test_run, + generate_tests, + run_profiling, + run_tests, + ) from testgen.mcp.tools.profiling import get_table, list_column_profiles, list_profiling_summaries from testgen.mcp.tools.reference import get_test_type, glossary_resource, test_types_resource from testgen.mcp.tools.source_data import get_source_data, get_source_data_query @@ -154,6 +161,11 @@ def safe_prompt(fn): safe_tool(get_table) safe_tool(list_column_profiles) safe_tool(list_profiling_summaries) + safe_tool(run_tests) + safe_tool(run_profiling) + safe_tool(cancel_test_run) + safe_tool(cancel_profiling_run) + safe_tool(generate_tests) # Resources (2) safe_resource("testgen://test-types", test_types_resource) diff --git a/testgen/mcp/tools/execution.py b/testgen/mcp/tools/execution.py new file mode 100644 index 00000000..1a906892 --- /dev/null +++ b/testgen/mcp/tools/execution.py @@ -0,0 +1,165 @@ +"""MCP tools for triggering and canceling TestGen jobs.""" + +from sqlalchemy import select + +from testgen.api.schemas import JobKey, JobSource +from testgen.common.models import get_current_session, with_database_session +from testgen.common.models.job_execution import JobExecution +from testgen.common.models.table_group import TableGroup +from testgen.common.models.test_suite import TestSuite +from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError +from testgen.mcp.permissions import get_project_permissions, mcp_permission +from testgen.mcp.tools.common import parse_uuid +from testgen.mcp.tools.markdown import MdDoc + + +@with_database_session +@mcp_permission("edit") +def run_tests(test_suite_id: str) -> str: + """Submit a test run for a test suite. Returns immediately with a job_execution_id; + use ``get_recent_test_runs`` to track status. + + Args: + test_suite_id: UUID of the test suite to run, e.g. from ``list_test_suites``. + """ + suite_uuid = parse_uuid(test_suite_id, "test_suite_id") + + suite = TestSuite.get_regular(suite_uuid) + perms = get_project_permissions() + if suite is None or not perms.has_access(suite.project_code): + raise MCPResourceNotAccessible("Test suite", test_suite_id) + + job = JobExecution.submit( + job_key=JobKey.run_tests, + kwargs={"test_suite_id": str(suite.id)}, + source=JobSource.mcp, + project_code=suite.project_code, + ) + return _render_submission("Test run", suite.test_suite, "Test suite", job, "get_recent_test_runs") + + +@with_database_session +@mcp_permission("edit") +def run_profiling(table_group_id: str) -> str: + """Submit a profiling run for a table group. Returns immediately with a job_execution_id; + use ``list_profiling_summaries`` to track status. + + Args: + table_group_id: UUID of the table group to profile, e.g. from ``get_data_inventory``. + """ + group_uuid = parse_uuid(table_group_id, "table_group_id") + + table_group = TableGroup.get(group_uuid) + perms = get_project_permissions() + if table_group is None or not perms.has_access(table_group.project_code): + raise MCPResourceNotAccessible("Table group", table_group_id) + + job = JobExecution.submit( + job_key=JobKey.run_profile, + kwargs={"table_group_id": str(table_group.id)}, + source=JobSource.mcp, + project_code=table_group.project_code, + ) + return _render_submission( + "Profiling run", table_group.table_groups_name, "Table group", job, "list_profiling_summaries" + ) + + +@with_database_session +@mcp_permission("edit") +def generate_tests(test_suite_id: str) -> str: + """Submit a test-generation job for a test suite. Auto-creates test definitions from the latest + profiling results for the table group; locked and manually created test definitions are preserved. + Returns immediately with a job_execution_id. + + Args: + test_suite_id: UUID of the test suite to generate tests for, e.g. from ``list_test_suites``. + """ + suite_uuid = parse_uuid(test_suite_id, "test_suite_id") + + suite = TestSuite.get_regular(suite_uuid) + perms = get_project_permissions() + if suite is None or not perms.has_access(suite.project_code): + raise MCPResourceNotAccessible("Test suite", test_suite_id) + + job = JobExecution.submit( + job_key=JobKey.run_test_generation, + kwargs={"test_suite_id": str(suite.id), "generation_set": "Standard"}, + source=JobSource.mcp, + project_code=suite.project_code, + ) + return _render_submission( + "Test generation", + suite.test_suite, + "Test suite", + job, + "list_tests", + poll_hint="to verify the new definitions appear", + ) + + +@with_database_session +@mcp_permission("edit") +def cancel_test_run(job_execution_id: str) -> str: + """Request cancellation of a queued or running test run. + + Args: + job_execution_id: UUID of a test run, e.g. from ``get_recent_test_runs``. + """ + return _cancel_job(job_execution_id, JobKey.run_tests, "Test run", "get_recent_test_runs") + + +@with_database_session +@mcp_permission("edit") +def cancel_profiling_run(job_execution_id: str) -> str: + """Request cancellation of a queued or running profiling run. + + Args: + job_execution_id: UUID of a profiling run, e.g. from ``list_profiling_summaries``. + """ + return _cancel_job(job_execution_id, JobKey.run_profile, "Profiling run", "list_profiling_summaries") + + +def _render_submission( + kind: str, + scope_name: str, + scope_label: str, + job: JobExecution, + poll_tool: str, + poll_hint: str = "to track status", +) -> str: + doc = MdDoc() + doc.heading(1, f"{kind} submitted for `{scope_name}`") + doc.field("Job ID", job.id, code=True) + doc.field(scope_label, scope_name) + doc.field("Status", "Pending") + doc.text(f"Use `{poll_tool}` {poll_hint}.") + return doc.render() + + +def _cancel_job(job_execution_id: str, expected_job_key: JobKey, kind: str, poll_tool: str) -> str: + job_uuid = parse_uuid(job_execution_id, "job_execution_id") + + job = get_current_session().scalars( + select(JobExecution).where( + JobExecution.id == job_uuid, + JobExecution.job_key == expected_job_key, + JobExecution.source != "system", + ) + ).first() + + perms = get_project_permissions() + if job is None or not perms.has_access(job.project_code): + raise MCPResourceNotAccessible(kind, job_execution_id) + + if not job.request_cancel(): + raise MCPUserError( + f"Cannot cancel — current status is `{job.status}`. Only queued or running jobs can be canceled." + ) + + doc = MdDoc() + doc.heading(1, f"{kind} cancellation requested") + doc.field("Job ID", job.id, code=True) + doc.field("Status", job.status) + doc.text(f"Use `{poll_tool}` to confirm cancellation.") + return doc.render() diff --git a/tests/unit/common/models/test_job_execution.py b/tests/unit/common/models/test_job_execution.py index 7fc90446..cf54eef1 100644 --- a/tests/unit/common/models/test_job_execution.py +++ b/tests/unit/common/models/test_job_execution.py @@ -159,3 +159,29 @@ def test_mark_interrupted_canceled(mock_session): job.mark_interrupted("Process exited with code -15") assert job.status == "canceled" + + +def test_request_cancel_pending_to_cancel_requested(mock_session): + job = JobExecution(id=uuid4(), status="pending") + mock_session.execute.return_value.scalar_one_or_none.return_value = _returning_row(job, status="cancel_requested") + + assert job.request_cancel() is True + assert job.status == "cancel_requested" + + +def test_request_cancel_idempotent_when_already_requested(mock_session): + """A re-request on a job already in cancel_requested returns True without hitting the DB.""" + job = JobExecution(id=uuid4(), status="cancel_requested") + + assert job.request_cancel() is True + assert job.status == "cancel_requested" + mock_session.execute.assert_not_called() + + +def test_request_cancel_terminal_state_returns_false(mock_session): + """Truly uncancelable states (completed/error/canceled) still return False.""" + job = JobExecution(id=uuid4(), status="completed") + mock_session.execute.return_value.scalar_one_or_none.return_value = None + + assert job.request_cancel() is False + assert job.status == "completed" diff --git a/tests/unit/mcp/conftest.py b/tests/unit/mcp/conftest.py index becc9861..cc932043 100644 --- a/tests/unit/mcp/conftest.py +++ b/tests/unit/mcp/conftest.py @@ -9,6 +9,7 @@ TEST_PERM_MATRIX = { "view": ["role_a", "role_b"], "catalog": ["role_a", "role_b", "role_c"], + "edit": ["role_a"], } diff --git a/tests/unit/mcp/test_tools_execution.py b/tests/unit/mcp/test_tools_execution.py new file mode 100644 index 00000000..f0c4cd39 --- /dev/null +++ b/tests/unit/mcp/test_tools_execution.py @@ -0,0 +1,431 @@ +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest + +from testgen.mcp.exceptions import MCPPermissionDenied, MCPResourceNotAccessible, MCPUserError +from testgen.mcp.permissions import ProjectPermissions + + +def _mock_test_suite(suite_id=None, project_code="demo", name="Quality Suite"): + suite = MagicMock() + suite.id = suite_id or uuid4() + suite.project_code = project_code + suite.test_suite = name + return suite + + +def _mock_table_group(group_id=None, project_code="demo", name="core_tables"): + tg = MagicMock() + tg.id = group_id or uuid4() + tg.project_code = project_code + tg.table_groups_name = name + return tg + + +def _mock_job(job_id=None, project_code="demo", status="pending", request_cancel_returns=True): + job = MagicMock() + job.id = job_id or uuid4() + job.project_code = project_code + job.status = status + job.request_cancel.return_value = request_cancel_returns + return job + + +def _patch_cancel_lookup(job): + """Patch the SQLAlchemy lookup chain inside _cancel_job to return ``job``.""" + session = MagicMock() + session.scalars.return_value.first.return_value = job + return patch("testgen.mcp.tools.execution.get_current_session", return_value=session) + + +# --- run_tests -------------------------------------------------------------- + + +@patch("testgen.mcp.tools.execution.JobExecution") +@patch("testgen.mcp.tools.execution.TestSuite") +def test_run_tests_submits_job(mock_suite_cls, mock_job_exec, db_session_mock): + suite_id = uuid4() + suite = _mock_test_suite(suite_id=suite_id) + mock_suite_cls.get_regular.return_value = suite + submitted = MagicMock(id=uuid4()) + mock_job_exec.submit.return_value = submitted + + from testgen.mcp.tools.execution import run_tests + + result = run_tests(str(suite_id)) + + mock_job_exec.submit.assert_called_once() + call_kwargs = mock_job_exec.submit.call_args.kwargs + assert call_kwargs["job_key"] == "run-tests" + assert call_kwargs["kwargs"] == {"test_suite_id": str(suite_id)} + assert call_kwargs["source"] == "mcp" + assert call_kwargs["project_code"] == "demo" + + assert "Test run submitted for `Quality Suite`" in result + assert str(submitted.id) in result + assert "Pending" in result + assert "get_recent_test_runs" in result + + +def test_run_tests_invalid_uuid(db_session_mock): + from testgen.mcp.tools.execution import run_tests + + with pytest.raises(MCPUserError, match="not a valid UUID"): + run_tests("not-a-uuid") + + +@patch("testgen.mcp.tools.execution.JobExecution") +@patch("testgen.mcp.tools.execution.TestSuite") +def test_run_tests_suite_not_found(mock_suite_cls, mock_job_exec, db_session_mock): + """Both a missing suite and a monitor suite resolve to None via get_regular — + same unified error in both cases.""" + mock_suite_cls.get_regular.return_value = None + + from testgen.mcp.tools.execution import run_tests + + with pytest.raises(MCPResourceNotAccessible, match="Test suite .* not found or not accessible"): + run_tests(str(uuid4())) + mock_job_exec.submit.assert_not_called() + + +@patch("testgen.mcp.tools.execution.JobExecution") +@patch("testgen.mcp.tools.execution.TestSuite") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_run_tests_forbidden_project_unified_error( + mock_compute, mock_suite_cls, mock_job_exec, db_session_mock +): + """Suite exists but user lacks edit on its project — same 'not found or not accessible'.""" + mock_compute.return_value = ProjectPermissions(memberships={"other": "role_a"}, permission="edit") + mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="forbidden") + + from testgen.mcp.tools.execution import run_tests + + with pytest.raises(MCPResourceNotAccessible, match="not found or not accessible"): + run_tests(str(uuid4())) + mock_job_exec.submit.assert_not_called() + + +# --- run_profiling ---------------------------------------------------------- + + +@patch("testgen.mcp.tools.execution.JobExecution") +@patch("testgen.mcp.tools.execution.TableGroup") +def test_run_profiling_submits_job(mock_tg_cls, mock_job_exec, db_session_mock): + group_id = uuid4() + tg = _mock_table_group(group_id=group_id) + mock_tg_cls.get.return_value = tg + submitted = MagicMock(id=uuid4()) + mock_job_exec.submit.return_value = submitted + + from testgen.mcp.tools.execution import run_profiling + + result = run_profiling(str(group_id)) + + call_kwargs = mock_job_exec.submit.call_args.kwargs + assert call_kwargs["job_key"] == "run-profile" + assert call_kwargs["kwargs"] == {"table_group_id": str(group_id)} + assert call_kwargs["source"] == "mcp" + assert call_kwargs["project_code"] == "demo" + + assert "Profiling run submitted for `core_tables`" in result + assert str(submitted.id) in result + assert "Pending" in result + assert "list_profiling_summaries" in result + + +def test_run_profiling_invalid_uuid(db_session_mock): + from testgen.mcp.tools.execution import run_profiling + + with pytest.raises(MCPUserError, match="not a valid UUID"): + run_profiling("not-a-uuid") + + +@patch("testgen.mcp.tools.execution.JobExecution") +@patch("testgen.mcp.tools.execution.TableGroup") +def test_run_profiling_table_group_not_found(mock_tg_cls, mock_job_exec, db_session_mock): + mock_tg_cls.get.return_value = None + + from testgen.mcp.tools.execution import run_profiling + + with pytest.raises(MCPResourceNotAccessible, match="Table group .* not found or not accessible"): + run_profiling(str(uuid4())) + mock_job_exec.submit.assert_not_called() + + +@patch("testgen.mcp.tools.execution.JobExecution") +@patch("testgen.mcp.tools.execution.TableGroup") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_run_profiling_forbidden_project_unified_error( + mock_compute, mock_tg_cls, mock_job_exec, db_session_mock +): + mock_compute.return_value = ProjectPermissions(memberships={"other": "role_a"}, permission="edit") + mock_tg_cls.get.return_value = _mock_table_group(project_code="forbidden") + + from testgen.mcp.tools.execution import run_profiling + + with pytest.raises(MCPResourceNotAccessible, match="not found or not accessible"): + run_profiling(str(uuid4())) + mock_job_exec.submit.assert_not_called() + + +# --- generate_tests --------------------------------------------------------- + + +@patch("testgen.mcp.tools.execution.JobExecution") +@patch("testgen.mcp.tools.execution.TestSuite") +def test_generate_tests_submits_job(mock_suite_cls, mock_job_exec, db_session_mock): + suite_id = uuid4() + suite = _mock_test_suite(suite_id=suite_id) + mock_suite_cls.get_regular.return_value = suite + submitted = MagicMock(id=uuid4()) + mock_job_exec.submit.return_value = submitted + + from testgen.mcp.tools.execution import generate_tests + + result = generate_tests(str(suite_id)) + + call_kwargs = mock_job_exec.submit.call_args.kwargs + assert call_kwargs["job_key"] == "run-test-generation" + assert call_kwargs["kwargs"] == {"test_suite_id": str(suite_id), "generation_set": "Standard"} + assert call_kwargs["source"] == "mcp" + + assert "Test generation submitted for `Quality Suite`" in result + assert str(submitted.id) in result + assert "Pending" in result + assert "list_tests" in result + assert "verify the new definitions appear" in result + + +def test_generate_tests_invalid_uuid(db_session_mock): + from testgen.mcp.tools.execution import generate_tests + + with pytest.raises(MCPUserError, match="not a valid UUID"): + generate_tests("not-a-uuid") + + +@patch("testgen.mcp.tools.execution.JobExecution") +@patch("testgen.mcp.tools.execution.TestSuite") +def test_generate_tests_suite_not_found(mock_suite_cls, mock_job_exec, db_session_mock): + mock_suite_cls.get_regular.return_value = None + + from testgen.mcp.tools.execution import generate_tests + + with pytest.raises(MCPResourceNotAccessible, match="Test suite .* not found or not accessible"): + generate_tests(str(uuid4())) + mock_job_exec.submit.assert_not_called() + + +@patch("testgen.mcp.tools.execution.JobExecution") +@patch("testgen.mcp.tools.execution.TestSuite") +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_generate_tests_forbidden_project_unified_error( + mock_compute, mock_suite_cls, mock_job_exec, db_session_mock +): + mock_compute.return_value = ProjectPermissions(memberships={"other": "role_a"}, permission="edit") + mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="forbidden") + + from testgen.mcp.tools.execution import generate_tests + + with pytest.raises(MCPResourceNotAccessible, match="not found or not accessible"): + generate_tests(str(uuid4())) + mock_job_exec.submit.assert_not_called() + + +# --- decorator-level denial (no edit on any project) ----------------------- + + +@pytest.mark.parametrize( + "tool_name, args", + [ + ("run_tests", (str(uuid4()),)), + ("run_profiling", (str(uuid4()),)), + ("generate_tests", (str(uuid4()),)), + ("cancel_test_run", (str(uuid4()),)), + ("cancel_profiling_run", (str(uuid4()),)), + ], +) +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_decorator_denies_when_user_has_no_edit_on_any_project( + mock_compute, tool_name, args, db_session_mock +): + """@mcp_permission('edit') raises MCPPermissionDenied — distinct from the + per-tool MCPResourceNotAccessible — when the user has no edit on any project.""" + mock_compute.return_value = ProjectPermissions(memberships={"some_project": "role_c"}, permission="edit") + + from testgen.mcp.tools import execution + + tool = getattr(execution, tool_name) + with pytest.raises(MCPPermissionDenied, match="does not include the necessary permission"): + tool(*args) + + +# --- cancel_test_run -------------------------------------------------------- + + +def test_cancel_test_run_invalid_uuid(db_session_mock): + from testgen.mcp.tools.execution import cancel_test_run + + with pytest.raises(MCPUserError, match="not a valid UUID"): + cancel_test_run("not-a-uuid") + + +def test_cancel_test_run_not_found(db_session_mock): + from testgen.mcp.tools.execution import cancel_test_run + + with _patch_cancel_lookup(None): + with pytest.raises(MCPResourceNotAccessible, match="Test run .* not found or not accessible"): + cancel_test_run(str(uuid4())) + + +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_cancel_test_run_forbidden_project_unified_error(mock_compute, db_session_mock): + mock_compute.return_value = ProjectPermissions(memberships={"other": "role_a"}, permission="edit") + job = _mock_job(project_code="forbidden") + + from testgen.mcp.tools.execution import cancel_test_run + + with _patch_cancel_lookup(job): + with pytest.raises(MCPResourceNotAccessible, match="not found or not accessible"): + cancel_test_run(str(uuid4())) + job.request_cancel.assert_not_called() + + +def test_cancel_test_run_terminal_status(db_session_mock): + job = _mock_job(status="completed", request_cancel_returns=False) + + from testgen.mcp.tools.execution import cancel_test_run + + with _patch_cancel_lookup(job): + with pytest.raises(MCPUserError, match=r"Cannot cancel.*current status is `completed`"): + cancel_test_run(str(uuid4())) + + +def test_cancel_test_run_success(db_session_mock): + job_id = uuid4() + job = _mock_job(job_id=job_id, status="pending") + + def fake_request_cancel(): + job.status = "cancel_requested" + return True + + job.request_cancel.side_effect = fake_request_cancel + + from testgen.mcp.tools.execution import cancel_test_run + + with _patch_cancel_lookup(job): + result = cancel_test_run(str(job_id)) + + assert "Test run cancellation requested" in result + assert str(job_id) in result + assert "cancel_requested" in result + assert "get_recent_test_runs" in result + + +def test_cancel_test_run_filters_by_job_key(db_session_mock): + """Verify the WHERE clause filters by job_key='run-tests'. A profiling-run UUID + handed to cancel_test_run resolves to None and surfaces as 'not found or not accessible'.""" + from sqlalchemy.sql.elements import BinaryExpression + + captured: dict = {} + + class FakeSession: + def scalars(self, query): + captured["clauses"] = list(query.whereclause.clauses) if query.whereclause is not None else [] + return MagicMock(first=MagicMock(return_value=None)) + + fake = FakeSession() + + from testgen.mcp.tools.execution import cancel_test_run + + with patch("testgen.mcp.tools.execution.get_current_session", return_value=fake): + with pytest.raises(MCPResourceNotAccessible): + cancel_test_run(str(uuid4())) + + rendered = [str(c) for c in captured["clauses"] if isinstance(c, BinaryExpression)] + assert any("job_executions.job_key" in s for s in rendered), rendered + + +# --- cancel_profiling_run --------------------------------------------------- + + +def test_cancel_profiling_run_invalid_uuid(db_session_mock): + from testgen.mcp.tools.execution import cancel_profiling_run + + with pytest.raises(MCPUserError, match="not a valid UUID"): + cancel_profiling_run("not-a-uuid") + + +def test_cancel_profiling_run_not_found(db_session_mock): + from testgen.mcp.tools.execution import cancel_profiling_run + + with _patch_cancel_lookup(None): + with pytest.raises(MCPResourceNotAccessible, match="Profiling run .* not found or not accessible"): + cancel_profiling_run(str(uuid4())) + + +@patch("testgen.mcp.permissions._compute_project_permissions") +def test_cancel_profiling_run_forbidden_project_unified_error(mock_compute, db_session_mock): + mock_compute.return_value = ProjectPermissions(memberships={"other": "role_a"}, permission="edit") + job = _mock_job(project_code="forbidden") + + from testgen.mcp.tools.execution import cancel_profiling_run + + with _patch_cancel_lookup(job): + with pytest.raises(MCPResourceNotAccessible, match="not found or not accessible"): + cancel_profiling_run(str(uuid4())) + job.request_cancel.assert_not_called() + + +def test_cancel_profiling_run_terminal_status(db_session_mock): + job = _mock_job(status="error", request_cancel_returns=False) + + from testgen.mcp.tools.execution import cancel_profiling_run + + with _patch_cancel_lookup(job): + with pytest.raises(MCPUserError, match=r"Cannot cancel.*current status is `error`"): + cancel_profiling_run(str(uuid4())) + + +def test_cancel_profiling_run_success(db_session_mock): + job_id = uuid4() + job = _mock_job(job_id=job_id, status="running") + + def fake_request_cancel(): + job.status = "cancel_requested" + return True + + job.request_cancel.side_effect = fake_request_cancel + + from testgen.mcp.tools.execution import cancel_profiling_run + + with _patch_cancel_lookup(job): + result = cancel_profiling_run(str(job_id)) + + assert "Profiling run cancellation requested" in result + assert str(job_id) in result + assert "cancel_requested" in result + assert "list_profiling_summaries" in result + + +def test_cancel_profiling_run_filters_by_job_key(db_session_mock): + """Verify the WHERE clause filters by job_key='run-profile'.""" + from sqlalchemy.sql.elements import BinaryExpression + + captured: dict = {} + + class FakeSession: + def scalars(self, query): + captured["clauses"] = list(query.whereclause.clauses) if query.whereclause is not None else [] + return MagicMock(first=MagicMock(return_value=None)) + + fake = FakeSession() + + from testgen.mcp.tools.execution import cancel_profiling_run + + with patch("testgen.mcp.tools.execution.get_current_session", return_value=fake): + with pytest.raises(MCPResourceNotAccessible): + cancel_profiling_run(str(uuid4())) + + rendered = [str(c) for c in captured["clauses"] if isinstance(c, BinaryExpression)] + assert any("job_executions.job_key" in s for s in rendered), rendered From d4012870ed3a98f127c84a88a25407ee2b3f10b6 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Thu, 30 Apr 2026 19:55:45 -0400 Subject: [PATCH 107/123] fix(mcp): queries failing in sqlalchemy 2 --- testgen/common/models/entity.py | 2 +- testgen/common/models/test_definition.py | 2 +- tests/unit/mcp/test_model_test_definition.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/testgen/common/models/entity.py b/testgen/common/models/entity.py index 4cf81209..8f055bda 100644 --- a/testgen/common/models/entity.py +++ b/testgen/common/models/entity.py @@ -141,7 +141,7 @@ def _paginate( raise ValueError("Paginated queries require ORDER BY for stable page distribution.") session = get_current_session() total = session.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0 - rows = session.execute(query.offset((page - 1) * limit).limit(limit)).all() + rows = session.execute(query.offset((page - 1) * limit).limit(limit)).mappings().all() if data_class is not None: return [data_class(**row) for row in rows], total return list(rows), total diff --git a/testgen/common/models/test_definition.py b/testgen/common/models/test_definition.py index 72e2fcce..9470685c 100644 --- a/testgen/common/models/test_definition.py +++ b/testgen/common/models/test_definition.py @@ -324,7 +324,7 @@ def get_for_project( ) if project_codes is not None: query = query.where(TestSuite.project_code.in_(project_codes)) - result = get_current_session().execute(query).first() + result = get_current_session().execute(query).mappings().first() return TestDefinitionSummary(**result) if result else None @classmethod diff --git a/tests/unit/mcp/test_model_test_definition.py b/tests/unit/mcp/test_model_test_definition.py index 224f3be3..bc77630a 100644 --- a/tests/unit/mcp/test_model_test_definition.py +++ b/tests/unit/mcp/test_model_test_definition.py @@ -22,7 +22,7 @@ def _compiled_sql(captured_query) -> str: def test_get_for_project_excludes_monitor_suites(session_mock): - session_mock.execute.return_value.first.return_value = None + session_mock.execute.return_value.mappings.return_value.first.return_value = None TestDefinition.get_for_project(uuid4()) @@ -32,7 +32,7 @@ def test_get_for_project_excludes_monitor_suites(session_mock): def test_get_for_project_excludes_monitor_suites_with_project_codes(session_mock): - session_mock.execute.return_value.first.return_value = None + session_mock.execute.return_value.mappings.return_value.first.return_value = None TestDefinition.get_for_project(uuid4(), project_codes=["demo"]) From 4b63c022b3ee7b3973395f2dc4e3509948eef034 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Fri, 1 May 2026 01:43:17 -0400 Subject: [PATCH 108/123] =?UTF-8?q?refactor(mcp):=20TG-1030=20reviewer=20f?= =?UTF-8?q?eedback=20=E2=80=94=20transition=20self-loop,=20resolve=5Ftest?= =?UTF-8?q?=5Fsuite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - request_cancel idempotency now via a CANCEL_REQUESTED -> CANCEL_REQUESTED self-loop in _VALID_TRANSITIONS. Cleaner than the short-circuit branch: _transition() handles the no-op naturally and the rule lives in the map. - New resolve_test_suite() helper in mcp/tools/common.py mirroring the resolve_table_group() pattern from TG-1028. Submit tools (run_tests, run_profiling, generate_tests) drop their inline parse-uuid + perm-check blocks in favor of the resolvers. - Cancel tools share a private _resolve_job_execution() helper that combines the parse-uuid + job_key filter + perm-scoped lookup + collapsed-error pattern. Tests for "not found", "wrong job_key", "forbidden project", and source='system' now collapse to one not_found_or_inaccessible case at the resolver boundary. Co-Authored-By: Claude Opus 4.7 (1M context) --- testgen/common/models/job_execution.py | 8 +- testgen/mcp/tools/common.py | 15 ++ testgen/mcp/tools/execution.py | 70 ++++------ .../unit/common/models/test_job_execution.py | 4 +- tests/unit/mcp/test_tools_execution.py | 129 ++++-------------- 5 files changed, 76 insertions(+), 150 deletions(-) diff --git a/testgen/common/models/job_execution.py b/testgen/common/models/job_execution.py index b1bedc37..49aa67b9 100644 --- a/testgen/common/models/job_execution.py +++ b/testgen/common/models/job_execution.py @@ -26,7 +26,8 @@ class JobStatus(StrEnum): JobStatus.PENDING: frozenset({JobStatus.CLAIMED, JobStatus.CANCEL_REQUESTED}), JobStatus.CLAIMED: frozenset({JobStatus.RUNNING, JobStatus.ERROR, JobStatus.CANCEL_REQUESTED}), JobStatus.RUNNING: frozenset({JobStatus.COMPLETED, JobStatus.ERROR, JobStatus.CANCEL_REQUESTED}), - JobStatus.CANCEL_REQUESTED: frozenset({JobStatus.CANCELED}), + # CANCEL_REQUESTED self-loop makes request_cancel() idempotent + JobStatus.CANCEL_REQUESTED: frozenset({JobStatus.CANCELED, JobStatus.CANCEL_REQUESTED}), } @@ -180,10 +181,7 @@ def mark_canceled(self) -> bool: return self._transition(JobStatus.CANCELED, completed_at=datetime.now(UTC)) def request_cancel(self) -> bool: - # Idempotent: a re-request on an already-cancel-requested job succeeds - # without re-issuing the transition, so the caller doesn't have to - # special-case "cancel pressed twice." - return self.status == JobStatus.CANCEL_REQUESTED.value or self._transition(JobStatus.CANCEL_REQUESTED) + return self._transition(JobStatus.CANCEL_REQUESTED) def mark_completed(self) -> bool: return self._transition(JobStatus.COMPLETED, completed_at=datetime.now(UTC)) diff --git a/testgen/mcp/tools/common.py b/testgen/mcp/tools/common.py index d64869fc..35a4f64f 100644 --- a/testgen/mcp/tools/common.py +++ b/testgen/mcp/tools/common.py @@ -5,6 +5,7 @@ from testgen.common.models.table_group import TableGroup from testgen.common.models.test_definition import TestType from testgen.common.models.test_result import TestResultStatus +from testgen.common.models.test_suite import TestSuite from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError from testgen.mcp.permissions import get_project_permissions @@ -80,3 +81,17 @@ def resolve_table_group(table_group_id: str) -> TableGroup: if tg is None: raise MCPResourceNotAccessible("Table group", table_group_id) return tg + + +def resolve_test_suite(test_suite_id: str) -> TestSuite: + """Resolve a regular (non-monitor) test suite ID, collapsing missing-or-inaccessible into one error path.""" + suite_uuid = parse_uuid(test_suite_id, "test_suite_id") + perms = get_project_permissions() + suite = TestSuite.get( + suite_uuid, + TestSuite.is_monitor.isnot(True), + TestSuite.project_code.in_(perms.allowed_codes), + ) + if suite is None: + raise MCPResourceNotAccessible("Test suite", test_suite_id) + return suite diff --git a/testgen/mcp/tools/execution.py b/testgen/mcp/tools/execution.py index 1a906892..a7a2c933 100644 --- a/testgen/mcp/tools/execution.py +++ b/testgen/mcp/tools/execution.py @@ -5,11 +5,9 @@ from testgen.api.schemas import JobKey, JobSource from testgen.common.models import get_current_session, with_database_session from testgen.common.models.job_execution import JobExecution -from testgen.common.models.table_group import TableGroup -from testgen.common.models.test_suite import TestSuite from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission -from testgen.mcp.tools.common import parse_uuid +from testgen.mcp.tools.common import parse_uuid, resolve_table_group, resolve_test_suite from testgen.mcp.tools.markdown import MdDoc @@ -22,13 +20,7 @@ def run_tests(test_suite_id: str) -> str: Args: test_suite_id: UUID of the test suite to run, e.g. from ``list_test_suites``. """ - suite_uuid = parse_uuid(test_suite_id, "test_suite_id") - - suite = TestSuite.get_regular(suite_uuid) - perms = get_project_permissions() - if suite is None or not perms.has_access(suite.project_code): - raise MCPResourceNotAccessible("Test suite", test_suite_id) - + suite = resolve_test_suite(test_suite_id) job = JobExecution.submit( job_key=JobKey.run_tests, kwargs={"test_suite_id": str(suite.id)}, @@ -47,13 +39,7 @@ def run_profiling(table_group_id: str) -> str: Args: table_group_id: UUID of the table group to profile, e.g. from ``get_data_inventory``. """ - group_uuid = parse_uuid(table_group_id, "table_group_id") - - table_group = TableGroup.get(group_uuid) - perms = get_project_permissions() - if table_group is None or not perms.has_access(table_group.project_code): - raise MCPResourceNotAccessible("Table group", table_group_id) - + table_group = resolve_table_group(table_group_id) job = JobExecution.submit( job_key=JobKey.run_profile, kwargs={"table_group_id": str(table_group.id)}, @@ -75,13 +61,7 @@ def generate_tests(test_suite_id: str) -> str: Args: test_suite_id: UUID of the test suite to generate tests for, e.g. from ``list_test_suites``. """ - suite_uuid = parse_uuid(test_suite_id, "test_suite_id") - - suite = TestSuite.get_regular(suite_uuid) - perms = get_project_permissions() - if suite is None or not perms.has_access(suite.project_code): - raise MCPResourceNotAccessible("Test suite", test_suite_id) - + suite = resolve_test_suite(test_suite_id) job = JobExecution.submit( job_key=JobKey.run_test_generation, kwargs={"test_suite_id": str(suite.id), "generation_set": "Standard"}, @@ -106,7 +86,8 @@ def cancel_test_run(job_execution_id: str) -> str: Args: job_execution_id: UUID of a test run, e.g. from ``get_recent_test_runs``. """ - return _cancel_job(job_execution_id, JobKey.run_tests, "Test run", "get_recent_test_runs") + job = _resolve_job_execution(job_execution_id, JobKey.run_tests, "Test run") + return _render_cancel(job, "Test run", "get_recent_test_runs") @with_database_session @@ -117,7 +98,27 @@ def cancel_profiling_run(job_execution_id: str) -> str: Args: job_execution_id: UUID of a profiling run, e.g. from ``list_profiling_summaries``. """ - return _cancel_job(job_execution_id, JobKey.run_profile, "Profiling run", "list_profiling_summaries") + job = _resolve_job_execution(job_execution_id, JobKey.run_profile, "Profiling run") + return _render_cancel(job, "Profiling run", "list_profiling_summaries") + + +def _resolve_job_execution(job_execution_id: str, expected_job_key: JobKey, kind: str) -> JobExecution: + """Resolve a user-submitted job by ID + expected job_key, collapsing missing-or-inaccessible + into one error path. Filters out source='system' jobs (internal rollups, never user-cancelable). + """ + job_uuid = parse_uuid(job_execution_id, "job_execution_id") + perms = get_project_permissions() + job = get_current_session().scalars( + select(JobExecution).where( + JobExecution.id == job_uuid, + JobExecution.job_key == expected_job_key, + JobExecution.source != "system", + JobExecution.project_code.in_(perms.allowed_codes), + ) + ).first() + if job is None: + raise MCPResourceNotAccessible(kind, job_execution_id) + return job def _render_submission( @@ -137,26 +138,11 @@ def _render_submission( return doc.render() -def _cancel_job(job_execution_id: str, expected_job_key: JobKey, kind: str, poll_tool: str) -> str: - job_uuid = parse_uuid(job_execution_id, "job_execution_id") - - job = get_current_session().scalars( - select(JobExecution).where( - JobExecution.id == job_uuid, - JobExecution.job_key == expected_job_key, - JobExecution.source != "system", - ) - ).first() - - perms = get_project_permissions() - if job is None or not perms.has_access(job.project_code): - raise MCPResourceNotAccessible(kind, job_execution_id) - +def _render_cancel(job: JobExecution, kind: str, poll_tool: str) -> str: if not job.request_cancel(): raise MCPUserError( f"Cannot cancel — current status is `{job.status}`. Only queued or running jobs can be canceled." ) - doc = MdDoc() doc.heading(1, f"{kind} cancellation requested") doc.field("Job ID", job.id, code=True) diff --git a/tests/unit/common/models/test_job_execution.py b/tests/unit/common/models/test_job_execution.py index cf54eef1..2ff28c18 100644 --- a/tests/unit/common/models/test_job_execution.py +++ b/tests/unit/common/models/test_job_execution.py @@ -170,12 +170,12 @@ def test_request_cancel_pending_to_cancel_requested(mock_session): def test_request_cancel_idempotent_when_already_requested(mock_session): - """A re-request on a job already in cancel_requested returns True without hitting the DB.""" + """A re-request on a job already in cancel_requested succeeds via the CANCEL_REQUESTED self-loop.""" job = JobExecution(id=uuid4(), status="cancel_requested") + mock_session.execute.return_value.scalar_one_or_none.return_value = _returning_row(job, status="cancel_requested") assert job.request_cancel() is True assert job.status == "cancel_requested" - mock_session.execute.assert_not_called() def test_request_cancel_terminal_state_returns_false(mock_session): diff --git a/tests/unit/mcp/test_tools_execution.py b/tests/unit/mcp/test_tools_execution.py index f0c4cd39..8ed455f3 100644 --- a/tests/unit/mcp/test_tools_execution.py +++ b/tests/unit/mcp/test_tools_execution.py @@ -32,8 +32,8 @@ def _mock_job(job_id=None, project_code="demo", status="pending", request_cancel return job -def _patch_cancel_lookup(job): - """Patch the SQLAlchemy lookup chain inside _cancel_job to return ``job``.""" +def _patch_job_lookup(job): + """Patch the SQLAlchemy lookup chain inside _resolve_job_execution to return ``job``.""" session = MagicMock() session.scalars.return_value.first.return_value = job return patch("testgen.mcp.tools.execution.get_current_session", return_value=session) @@ -43,11 +43,11 @@ def _patch_cancel_lookup(job): @patch("testgen.mcp.tools.execution.JobExecution") -@patch("testgen.mcp.tools.execution.TestSuite") +@patch("testgen.mcp.tools.common.TestSuite") def test_run_tests_submits_job(mock_suite_cls, mock_job_exec, db_session_mock): suite_id = uuid4() suite = _mock_test_suite(suite_id=suite_id) - mock_suite_cls.get_regular.return_value = suite + mock_suite_cls.get.return_value = suite submitted = MagicMock(id=uuid4()) mock_job_exec.submit.return_value = submitted @@ -76,11 +76,11 @@ def test_run_tests_invalid_uuid(db_session_mock): @patch("testgen.mcp.tools.execution.JobExecution") -@patch("testgen.mcp.tools.execution.TestSuite") -def test_run_tests_suite_not_found(mock_suite_cls, mock_job_exec, db_session_mock): - """Both a missing suite and a monitor suite resolve to None via get_regular — - same unified error in both cases.""" - mock_suite_cls.get_regular.return_value = None +@patch("testgen.mcp.tools.common.TestSuite") +def test_run_tests_suite_not_found_or_inaccessible(mock_suite_cls, mock_job_exec, db_session_mock): + """Unknown UUID, monitor suite, and forbidden project all collapse to the same SQL-side + miss inside resolve_test_suite.""" + mock_suite_cls.get.return_value = None from testgen.mcp.tools.execution import run_tests @@ -89,28 +89,11 @@ def test_run_tests_suite_not_found(mock_suite_cls, mock_job_exec, db_session_moc mock_job_exec.submit.assert_not_called() -@patch("testgen.mcp.tools.execution.JobExecution") -@patch("testgen.mcp.tools.execution.TestSuite") -@patch("testgen.mcp.permissions._compute_project_permissions") -def test_run_tests_forbidden_project_unified_error( - mock_compute, mock_suite_cls, mock_job_exec, db_session_mock -): - """Suite exists but user lacks edit on its project — same 'not found or not accessible'.""" - mock_compute.return_value = ProjectPermissions(memberships={"other": "role_a"}, permission="edit") - mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="forbidden") - - from testgen.mcp.tools.execution import run_tests - - with pytest.raises(MCPResourceNotAccessible, match="not found or not accessible"): - run_tests(str(uuid4())) - mock_job_exec.submit.assert_not_called() - - # --- run_profiling ---------------------------------------------------------- @patch("testgen.mcp.tools.execution.JobExecution") -@patch("testgen.mcp.tools.execution.TableGroup") +@patch("testgen.mcp.tools.common.TableGroup") def test_run_profiling_submits_job(mock_tg_cls, mock_job_exec, db_session_mock): group_id = uuid4() tg = _mock_table_group(group_id=group_id) @@ -142,8 +125,8 @@ def test_run_profiling_invalid_uuid(db_session_mock): @patch("testgen.mcp.tools.execution.JobExecution") -@patch("testgen.mcp.tools.execution.TableGroup") -def test_run_profiling_table_group_not_found(mock_tg_cls, mock_job_exec, db_session_mock): +@patch("testgen.mcp.tools.common.TableGroup") +def test_run_profiling_table_group_not_found_or_inaccessible(mock_tg_cls, mock_job_exec, db_session_mock): mock_tg_cls.get.return_value = None from testgen.mcp.tools.execution import run_profiling @@ -153,31 +136,15 @@ def test_run_profiling_table_group_not_found(mock_tg_cls, mock_job_exec, db_sess mock_job_exec.submit.assert_not_called() -@patch("testgen.mcp.tools.execution.JobExecution") -@patch("testgen.mcp.tools.execution.TableGroup") -@patch("testgen.mcp.permissions._compute_project_permissions") -def test_run_profiling_forbidden_project_unified_error( - mock_compute, mock_tg_cls, mock_job_exec, db_session_mock -): - mock_compute.return_value = ProjectPermissions(memberships={"other": "role_a"}, permission="edit") - mock_tg_cls.get.return_value = _mock_table_group(project_code="forbidden") - - from testgen.mcp.tools.execution import run_profiling - - with pytest.raises(MCPResourceNotAccessible, match="not found or not accessible"): - run_profiling(str(uuid4())) - mock_job_exec.submit.assert_not_called() - - # --- generate_tests --------------------------------------------------------- @patch("testgen.mcp.tools.execution.JobExecution") -@patch("testgen.mcp.tools.execution.TestSuite") +@patch("testgen.mcp.tools.common.TestSuite") def test_generate_tests_submits_job(mock_suite_cls, mock_job_exec, db_session_mock): suite_id = uuid4() suite = _mock_test_suite(suite_id=suite_id) - mock_suite_cls.get_regular.return_value = suite + mock_suite_cls.get.return_value = suite submitted = MagicMock(id=uuid4()) mock_job_exec.submit.return_value = submitted @@ -205,9 +172,9 @@ def test_generate_tests_invalid_uuid(db_session_mock): @patch("testgen.mcp.tools.execution.JobExecution") -@patch("testgen.mcp.tools.execution.TestSuite") -def test_generate_tests_suite_not_found(mock_suite_cls, mock_job_exec, db_session_mock): - mock_suite_cls.get_regular.return_value = None +@patch("testgen.mcp.tools.common.TestSuite") +def test_generate_tests_suite_not_found_or_inaccessible(mock_suite_cls, mock_job_exec, db_session_mock): + mock_suite_cls.get.return_value = None from testgen.mcp.tools.execution import generate_tests @@ -216,22 +183,6 @@ def test_generate_tests_suite_not_found(mock_suite_cls, mock_job_exec, db_sessio mock_job_exec.submit.assert_not_called() -@patch("testgen.mcp.tools.execution.JobExecution") -@patch("testgen.mcp.tools.execution.TestSuite") -@patch("testgen.mcp.permissions._compute_project_permissions") -def test_generate_tests_forbidden_project_unified_error( - mock_compute, mock_suite_cls, mock_job_exec, db_session_mock -): - mock_compute.return_value = ProjectPermissions(memberships={"other": "role_a"}, permission="edit") - mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="forbidden") - - from testgen.mcp.tools.execution import generate_tests - - with pytest.raises(MCPResourceNotAccessible, match="not found or not accessible"): - generate_tests(str(uuid4())) - mock_job_exec.submit.assert_not_called() - - # --- decorator-level denial (no edit on any project) ----------------------- @@ -250,7 +201,7 @@ def test_decorator_denies_when_user_has_no_edit_on_any_project( mock_compute, tool_name, args, db_session_mock ): """@mcp_permission('edit') raises MCPPermissionDenied — distinct from the - per-tool MCPResourceNotAccessible — when the user has no edit on any project.""" + resolver-level MCPResourceNotAccessible — when the user has no edit on any project.""" mock_compute.return_value = ProjectPermissions(memberships={"some_project": "role_c"}, permission="edit") from testgen.mcp.tools import execution @@ -270,33 +221,22 @@ def test_cancel_test_run_invalid_uuid(db_session_mock): cancel_test_run("not-a-uuid") -def test_cancel_test_run_not_found(db_session_mock): +def test_cancel_test_run_not_found_or_inaccessible(db_session_mock): + """Unknown UUID, wrong job_key (e.g. profiling run UUID), forbidden project, and + source='system' all collapse to the same SQL-side miss inside _resolve_job_execution.""" from testgen.mcp.tools.execution import cancel_test_run - with _patch_cancel_lookup(None): + with _patch_job_lookup(None): with pytest.raises(MCPResourceNotAccessible, match="Test run .* not found or not accessible"): cancel_test_run(str(uuid4())) -@patch("testgen.mcp.permissions._compute_project_permissions") -def test_cancel_test_run_forbidden_project_unified_error(mock_compute, db_session_mock): - mock_compute.return_value = ProjectPermissions(memberships={"other": "role_a"}, permission="edit") - job = _mock_job(project_code="forbidden") - - from testgen.mcp.tools.execution import cancel_test_run - - with _patch_cancel_lookup(job): - with pytest.raises(MCPResourceNotAccessible, match="not found or not accessible"): - cancel_test_run(str(uuid4())) - job.request_cancel.assert_not_called() - - def test_cancel_test_run_terminal_status(db_session_mock): job = _mock_job(status="completed", request_cancel_returns=False) from testgen.mcp.tools.execution import cancel_test_run - with _patch_cancel_lookup(job): + with _patch_job_lookup(job): with pytest.raises(MCPUserError, match=r"Cannot cancel.*current status is `completed`"): cancel_test_run(str(uuid4())) @@ -313,7 +253,7 @@ def fake_request_cancel(): from testgen.mcp.tools.execution import cancel_test_run - with _patch_cancel_lookup(job): + with _patch_job_lookup(job): result = cancel_test_run(str(job_id)) assert "Test run cancellation requested" in result @@ -356,33 +296,20 @@ def test_cancel_profiling_run_invalid_uuid(db_session_mock): cancel_profiling_run("not-a-uuid") -def test_cancel_profiling_run_not_found(db_session_mock): +def test_cancel_profiling_run_not_found_or_inaccessible(db_session_mock): from testgen.mcp.tools.execution import cancel_profiling_run - with _patch_cancel_lookup(None): + with _patch_job_lookup(None): with pytest.raises(MCPResourceNotAccessible, match="Profiling run .* not found or not accessible"): cancel_profiling_run(str(uuid4())) -@patch("testgen.mcp.permissions._compute_project_permissions") -def test_cancel_profiling_run_forbidden_project_unified_error(mock_compute, db_session_mock): - mock_compute.return_value = ProjectPermissions(memberships={"other": "role_a"}, permission="edit") - job = _mock_job(project_code="forbidden") - - from testgen.mcp.tools.execution import cancel_profiling_run - - with _patch_cancel_lookup(job): - with pytest.raises(MCPResourceNotAccessible, match="not found or not accessible"): - cancel_profiling_run(str(uuid4())) - job.request_cancel.assert_not_called() - - def test_cancel_profiling_run_terminal_status(db_session_mock): job = _mock_job(status="error", request_cancel_returns=False) from testgen.mcp.tools.execution import cancel_profiling_run - with _patch_cancel_lookup(job): + with _patch_job_lookup(job): with pytest.raises(MCPUserError, match=r"Cannot cancel.*current status is `error`"): cancel_profiling_run(str(uuid4())) @@ -399,7 +326,7 @@ def fake_request_cancel(): from testgen.mcp.tools.execution import cancel_profiling_run - with _patch_cancel_lookup(job): + with _patch_job_lookup(job): result = cancel_profiling_run(str(job_id)) assert "Profiling run cancellation requested" in result From 23b7c7caaed1b53f18c824561aa9ef2b89f83948 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Fri, 1 May 2026 14:35:54 -0400 Subject: [PATCH 109/123] fix(monitors): exclude dropped columns from freshness fingerprint generation --- .../bigquery/gen_query_tests/gen_Freshness_Trend.sql | 8 ++++++++ .../databricks/gen_query_tests/gen_Freshness_Trend.sql | 8 ++++++++ .../flavors/mssql/gen_query_tests/gen_Freshness_Trend.sql | 8 ++++++++ .../oracle/gen_query_tests/gen_Freshness_Trend.sql | 8 ++++++++ .../sap_hana/gen_query_tests/gen_Freshness_Trend.sql | 8 ++++++++ testgen/template/gen_query_tests/gen_Freshness_Trend.sql | 8 ++++++++ 6 files changed, 48 insertions(+) diff --git a/testgen/template/flavors/bigquery/gen_query_tests/gen_Freshness_Trend.sql b/testgen/template/flavors/bigquery/gen_query_tests/gen_Freshness_Trend.sql index 231cad88..ed6c227c 100644 --- a/testgen/template/flavors/bigquery/gen_query_tests/gen_Freshness_Trend.sql +++ b/testgen/template/flavors/bigquery/gen_query_tests/gen_Freshness_Trend.sql @@ -20,6 +20,14 @@ latest_results AS ( -- Ignore dropped tables AND dtc.drop_date IS NULL ) + INNER JOIN data_column_chars dcc ON ( + dcc.table_groups_id = p.table_groups_id + AND dcc.schema_name = p.schema_name + AND dcc.table_name = p.table_name + AND dcc.column_name = p.column_name + -- Ignore dropped columns + AND dcc.drop_date IS NULL + ) WHERE p.table_groups_id = :TABLE_GROUPS_ID ::UUID ), -- IDs - TOP 2 diff --git a/testgen/template/flavors/databricks/gen_query_tests/gen_Freshness_Trend.sql b/testgen/template/flavors/databricks/gen_query_tests/gen_Freshness_Trend.sql index 7aaba268..aa9d2a87 100644 --- a/testgen/template/flavors/databricks/gen_query_tests/gen_Freshness_Trend.sql +++ b/testgen/template/flavors/databricks/gen_query_tests/gen_Freshness_Trend.sql @@ -20,6 +20,14 @@ latest_results AS ( -- Ignore dropped tables AND dtc.drop_date IS NULL ) + INNER JOIN data_column_chars dcc ON ( + dcc.table_groups_id = p.table_groups_id + AND dcc.schema_name = p.schema_name + AND dcc.table_name = p.table_name + AND dcc.column_name = p.column_name + -- Ignore dropped columns + AND dcc.drop_date IS NULL + ) WHERE p.table_groups_id = :TABLE_GROUPS_ID ::UUID ), -- IDs - TOP 2 diff --git a/testgen/template/flavors/mssql/gen_query_tests/gen_Freshness_Trend.sql b/testgen/template/flavors/mssql/gen_query_tests/gen_Freshness_Trend.sql index aa18dac0..a14dc9a4 100644 --- a/testgen/template/flavors/mssql/gen_query_tests/gen_Freshness_Trend.sql +++ b/testgen/template/flavors/mssql/gen_query_tests/gen_Freshness_Trend.sql @@ -20,6 +20,14 @@ latest_results AS ( -- Ignore dropped tables AND dtc.drop_date IS NULL ) + INNER JOIN data_column_chars dcc ON ( + dcc.table_groups_id = p.table_groups_id + AND dcc.schema_name = p.schema_name + AND dcc.table_name = p.table_name + AND dcc.column_name = p.column_name + -- Ignore dropped columns + AND dcc.drop_date IS NULL + ) WHERE p.table_groups_id = :TABLE_GROUPS_ID ::UUID ), -- IDs - TOP 2 diff --git a/testgen/template/flavors/oracle/gen_query_tests/gen_Freshness_Trend.sql b/testgen/template/flavors/oracle/gen_query_tests/gen_Freshness_Trend.sql index d22e79d6..05724f8f 100644 --- a/testgen/template/flavors/oracle/gen_query_tests/gen_Freshness_Trend.sql +++ b/testgen/template/flavors/oracle/gen_query_tests/gen_Freshness_Trend.sql @@ -20,6 +20,14 @@ latest_results AS ( -- Ignore dropped tables AND dtc.drop_date IS NULL ) + INNER JOIN data_column_chars dcc ON ( + dcc.table_groups_id = p.table_groups_id + AND dcc.schema_name = p.schema_name + AND dcc.table_name = p.table_name + AND dcc.column_name = p.column_name + -- Ignore dropped columns + AND dcc.drop_date IS NULL + ) WHERE p.table_groups_id = :TABLE_GROUPS_ID ::UUID ), -- IDs - TOP 2 diff --git a/testgen/template/flavors/sap_hana/gen_query_tests/gen_Freshness_Trend.sql b/testgen/template/flavors/sap_hana/gen_query_tests/gen_Freshness_Trend.sql index ae947a22..06f09372 100644 --- a/testgen/template/flavors/sap_hana/gen_query_tests/gen_Freshness_Trend.sql +++ b/testgen/template/flavors/sap_hana/gen_query_tests/gen_Freshness_Trend.sql @@ -20,6 +20,14 @@ latest_results AS ( -- Ignore dropped tables AND dtc.drop_date IS NULL ) + INNER JOIN data_column_chars dcc ON ( + dcc.table_groups_id = p.table_groups_id + AND dcc.schema_name = p.schema_name + AND dcc.table_name = p.table_name + AND dcc.column_name = p.column_name + -- Ignore dropped columns + AND dcc.drop_date IS NULL + ) WHERE p.table_groups_id = :TABLE_GROUPS_ID ::UUID ), -- IDs - TOP 2 diff --git a/testgen/template/gen_query_tests/gen_Freshness_Trend.sql b/testgen/template/gen_query_tests/gen_Freshness_Trend.sql index 19c75fd6..cc83e820 100644 --- a/testgen/template/gen_query_tests/gen_Freshness_Trend.sql +++ b/testgen/template/gen_query_tests/gen_Freshness_Trend.sql @@ -20,6 +20,14 @@ latest_results AS ( -- Ignore dropped tables AND dtc.drop_date IS NULL ) + INNER JOIN data_column_chars dcc ON ( + dcc.table_groups_id = p.table_groups_id + AND dcc.schema_name = p.schema_name + AND dcc.table_name = p.table_name + AND dcc.column_name = p.column_name + -- Ignore dropped columns + AND dcc.drop_date IS NULL + ) WHERE p.table_groups_id = :TABLE_GROUPS_ID ::UUID ), -- IDs - TOP 2 From e82c35c79689920d0c03514a38b3002be8faa308 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Fri, 1 May 2026 14:54:39 -0400 Subject: [PATCH 110/123] fix(monitors): regenerate monitors that fail validation --- .../commands/queries/execute_tests_query.py | 1 + testgen/commands/run_test_execution.py | 22 +++++++++---- testgen/commands/run_test_validation.py | 33 +++++++++++++------ .../execution/get_active_test_definitions.sql | 1 + 4 files changed, 40 insertions(+), 17 deletions(-) diff --git a/testgen/commands/queries/execute_tests_query.py b/testgen/commands/queries/execute_tests_query.py index 4902cf98..e81d95a9 100644 --- a/testgen/commands/queries/execute_tests_query.py +++ b/testgen/commands/queries/execute_tests_query.py @@ -56,6 +56,7 @@ class TestExecutionDef(InputParameters): schema_name: str table_name: str column_name: str + lock_refresh: str skip_errors: int history_calculation: str custom_query: str diff --git a/testgen/commands/run_test_execution.py b/testgen/commands/run_test_execution.py index e1c3bb85..568a463d 100644 --- a/testgen/commands/run_test_execution.py +++ b/testgen/commands/run_test_execution.py @@ -186,14 +186,22 @@ def _sync_monitor_definitions(sql_generator: TestExecutionSQL) -> None: table_names = [row["table_name"] for row in missing_monitors] run_monitor_generation(test_suite_id, ["Freshness_Trend"], mode="insert", table_names=table_names) - # Regenerate monitors that errored in previous run + # Regenerate monitors that errored in previous run or fail validation + regen_tables_by_type: dict[str, set[str]] = defaultdict(set) + errored_monitors = fetch_dict_from_db(*sql_generator.get_errored_autogen_monitors()) - if errored_monitors: - errored_by_type: dict[str, list[str]] = defaultdict(list) - for row in errored_monitors: - errored_by_type[row["test_type"]].append(row["table_name"]) - for test_type, table_names in errored_by_type.items(): - run_monitor_generation(test_suite_id, [test_type], mode="upsert", table_names=table_names) + for row in errored_monitors: + regen_tables_by_type[row["test_type"]].add(row["table_name"]) + + active_defs = [TestExecutionDef(**item) for item in fetch_dict_from_db(*sql_generator.get_active_test_definitions())] + if active_defs: + run_test_validation(sql_generator, active_defs, defer_persistence=True) + for td in active_defs: + if td.errors and td.test_type in ("Freshness_Trend", "Volume_Trend") and td.lock_refresh != "Y": + regen_tables_by_type[td.test_type].add(td.table_name) + + for test_type, table_names in regen_tables_by_type.items(): + run_monitor_generation(test_suite_id, [test_type], mode="upsert", table_names=list(table_names)) def _run_tests( diff --git a/testgen/commands/run_test_validation.py b/testgen/commands/run_test_validation.py index cdb961be..db247676 100644 --- a/testgen/commands/run_test_validation.py +++ b/testgen/commands/run_test_validation.py @@ -107,7 +107,19 @@ def check_identifiers( return errors -def run_test_validation(sql_generator: TestExecutionSQL, test_defs: list[TestExecutionDef]) -> list[TestExecutionDef]: +def run_test_validation( + sql_generator: TestExecutionSQL, + test_defs: list[TestExecutionDef], + defer_persistence: bool = False, +) -> list[TestExecutionDef]: + """Validate test definitions against the current target schema. + + Populates ``td.errors`` on test_defs that reference missing tables or columns. + By default, also writes Error results and deactivates the failing tests; pass + ``defer_persistence=True`` to skip those side effects (e.g. when the caller + will attempt a regeneration first and let a later validation pass persist + whatever is still broken). + """ quote = sql_generator.flavor_service.quote_character identifiers_to_check, target_schemas, collection_errors = collect_test_identifiers(test_defs, quote) @@ -141,15 +153,16 @@ def run_test_validation(sql_generator: TestExecutionSQL, test_defs: list[TestExe # Skip "Deactivated" prefix since it's already there from collection_errors or we add it test_defs_by_id[test_id].errors.extend(error_list[1:] if test_defs_by_id[test_id].errors else error_list) - error_results = sql_generator.get_test_errors(test_defs_by_id.values()) - if error_results: - LOG.warning(f"Tests in test suite failed validation: {len(error_results)}") - LOG.info("Writing test validation errors to test results") - write_to_app_db(error_results, sql_generator.result_columns, sql_generator.test_results_table) + if not defer_persistence: + error_results = sql_generator.get_test_errors(test_defs_by_id.values()) + if error_results: + LOG.warning(f"Tests in test suite failed validation: {len(error_results)}") + LOG.info("Writing test validation errors to test results") + write_to_app_db(error_results, sql_generator.result_columns, sql_generator.test_results_table) - LOG.info("Disabling tests in test suite that failed validation") - execute_db_queries([sql_generator.disable_invalid_test_definitions()]) - else: - LOG.info("No tests in test suite failed validation") + LOG.info("Disabling tests in test suite that failed validation") + execute_db_queries([sql_generator.disable_invalid_test_definitions()]) + else: + LOG.info("No tests in test suite failed validation") return [td for td in test_defs if not td.errors] diff --git a/testgen/template/execution/get_active_test_definitions.sql b/testgen/template/execution/get_active_test_definitions.sql index c8701130..e4321463 100644 --- a/testgen/template/execution/get_active_test_definitions.sql +++ b/testgen/template/execution/get_active_test_definitions.sql @@ -3,6 +3,7 @@ SELECT td.id, schema_name, table_name, column_name, + lock_refresh, skip_errors, baseline_ct, baseline_unique_ct, From 7d7334ec8f44dc418c7ce1550a2d8c15b6cfa364 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Fri, 1 May 2026 14:55:27 -0400 Subject: [PATCH 111/123] fix: errors in test results and edit table monitor dialog --- testgen/common/models/test_definition.py | 3 ++- testgen/ui/queries/test_result_queries.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/testgen/common/models/test_definition.py b/testgen/common/models/test_definition.py index 9470685c..8740203b 100644 --- a/testgen/common/models/test_definition.py +++ b/testgen/common/models/test_definition.py @@ -193,8 +193,9 @@ class TestType(ParamFieldsMixin, Entity): # Unmapped columns: generation_template, result_visualization, result_visualization_params _summary_columns = ( - *[key for key in TestTypeSummary.__annotations__.keys() if key != "default_test_description"], + *[key for key in TestTypeSummary.__annotations__.keys() if key not in ("default_test_description", "default_impact_dimension")], test_description.label("default_test_description"), + impact_dimension.label("default_impact_dimension"), ) @classmethod diff --git a/testgen/ui/queries/test_result_queries.py b/testgen/ui/queries/test_result_queries.py index c93f9126..82ebc23a 100644 --- a/testgen/ui/queries/test_result_queries.py +++ b/testgen/ui/queries/test_result_queries.py @@ -88,7 +88,7 @@ def get_test_results( tt.dq_dimension, r.impact_dimension, tt.test_scope, r.schema_name, r.column_names, r.test_time::DATE as test_date, r.test_type, tt.id as test_type_id, tt.test_name_short, tt.test_name_long, r.test_description, tt.measure_uom, tt.measure_uom_description, - c.test_operator, r.threshold_value::NUMERIC(16, 5), r.result_measure::NUMERIC(16, 5), r.result_status, + c.test_operator, r.threshold_value::NUMERIC(16, 5), r.result_measure, r.result_status, CASE WHEN r.result_code = 0 THEN r.disposition ELSE 'Passed' From ce9b4df75e1f38336d6fcca8e74dd5edb3edb630 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Fri, 1 May 2026 15:23:36 -0400 Subject: [PATCH 112/123] test: fix failing unit tests --- tests/unit/commands/queries/test_execute_tests_query.py | 1 + tests/unit/commands/test_run_test_validation.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/unit/commands/queries/test_execute_tests_query.py b/tests/unit/commands/queries/test_execute_tests_query.py index fe51fccc..71fb66dd 100644 --- a/tests/unit/commands/queries/test_execute_tests_query.py +++ b/tests/unit/commands/queries/test_execute_tests_query.py @@ -21,6 +21,7 @@ def _make_td(**overrides) -> TestExecutionDef: "schema_name": "public", "table_name": "orders", "column_name": "amount", + "lock_refresh": "N", "skip_errors": 0, "history_calculation": "NONE", "custom_query": "", diff --git a/tests/unit/commands/test_run_test_validation.py b/tests/unit/commands/test_run_test_validation.py index 6336a86c..c5f218a8 100644 --- a/tests/unit/commands/test_run_test_validation.py +++ b/tests/unit/commands/test_run_test_validation.py @@ -16,6 +16,7 @@ def _make_td(**overrides) -> TestExecutionDef: "schema_name": "public", "table_name": "orders", "column_name": "amount", + "lock_refresh": "N", "skip_errors": 0, "history_calculation": "NONE", "custom_query": "", From e188d8bc439e6b26abb8a0628b499572ef5dfe3b Mon Sep 17 00:00:00 2001 From: Ricardo Boni Date: Fri, 1 May 2026 19:18:05 -0400 Subject: [PATCH 113/123] feat(mcp): track inventory tool calls in Mixpanel (TG-1062) Send a single mcp-get-data-inventory event whenever the get_data_inventory MCP tool is invoked. Username is sourced from a new field on ProjectPermissions, populated by the @mcp_permission decorator. Adds TG_MIXPANEL_URL env var so MIXPANEL_URL can be redirected to a local echo for development without touching the production project. --- testgen/mcp/permissions.py | 2 ++ testgen/mcp/tools/discovery.py | 2 ++ testgen/settings.py | 2 +- tests/unit/mcp/test_permissions.py | 13 ++++++++--- tests/unit/mcp/test_tools_discovery.py | 5 ++++ tests/unit/mcp/test_tools_execution.py | 2 +- tests/unit/mcp/test_tools_profiling.py | 1 + tests/unit/mcp/test_tools_source_data.py | 2 ++ tests/unit/mcp/test_tools_test_results.py | 28 +++++++++++++++++++---- tests/unit/mcp/test_tools_test_runs.py | 2 ++ 10 files changed, 50 insertions(+), 9 deletions(-) diff --git a/testgen/mcp/permissions.py b/testgen/mcp/permissions.py index 4848954f..dce78000 100644 --- a/testgen/mcp/permissions.py +++ b/testgen/mcp/permissions.py @@ -23,6 +23,7 @@ class ProjectPermissions: memberships: dict[str, str] # {project_code: role} permission: str + username: str def codes_allowed_to(self, permission: str) -> list[str]: """Project codes where the user's role includes the given permission.""" @@ -92,6 +93,7 @@ def _compute_project_permissions(user: User, permission: str) -> ProjectPermissi return ProjectPermissions( memberships={m.project_code: m.role for m in memberships_list}, permission=permission, + username=user.username, ) diff --git a/testgen/mcp/tools/discovery.py b/testgen/mcp/tools/discovery.py index ba5b07ef..17942a28 100644 --- a/testgen/mcp/tools/discovery.py +++ b/testgen/mcp/tools/discovery.py @@ -1,3 +1,4 @@ +from testgen.common.mixpanel_service import MixpanelService from testgen.common.models import with_database_session from testgen.common.models.data_table import DataTable from testgen.common.models.project import Project @@ -20,6 +21,7 @@ def get_data_inventory() -> str: from testgen.mcp.services.inventory_service import get_inventory perms = get_project_permissions() + MixpanelService().send_event("mcp-get-data-inventory", username=perms.username) return get_inventory( project_codes=perms.allowed_codes, view_project_codes=perms.codes_allowed_to("view"), diff --git a/testgen/settings.py b/testgen/settings.py index e8699f25..26510a4e 100644 --- a/testgen/settings.py +++ b/testgen/settings.py @@ -495,7 +495,7 @@ def _ssl_files_present() -> bool: """ -MIXPANEL_URL: str = "https://api.mixpanel.com" +MIXPANEL_URL: str = getenv("TG_MIXPANEL_URL", "https://api.mixpanel.com") MIXPANEL_TIMEOUT: int = 3 MIXPANEL_TOKEN: str = "973680ddf8c2b512e6f6d1f2959149eb" """ diff --git a/tests/unit/mcp/test_permissions.py b/tests/unit/mcp/test_permissions.py index b63dedfe..4b058295 100644 --- a/tests/unit/mcp/test_permissions.py +++ b/tests/unit/mcp/test_permissions.py @@ -117,6 +117,7 @@ def test_codes_allowed_to_filters_by_role(): perms = ProjectPermissions( memberships={"proj_a": "role_a", "proj_b": "role_c"}, permission="catalog", + username="test_user", ) # "view" includes role_a but not role_c result = perms.codes_allowed_to("view") @@ -127,6 +128,7 @@ def test_codes_allowed_to_all_matching(): perms = ProjectPermissions( memberships={"proj_a": "role_a", "proj_b": "role_b"}, permission="catalog", + username="test_user", ) # "catalog" includes all roles result = perms.codes_allowed_to("catalog") @@ -137,6 +139,7 @@ def test_codes_allowed_to_none_matching(): perms = ProjectPermissions( memberships={"proj_a": "role_c"}, permission="catalog", + username="test_user", ) # "view" excludes role_c result = perms.codes_allowed_to("view") @@ -150,6 +153,7 @@ def test_allowed_codes_uses_decorator_permission(): perms = ProjectPermissions( memberships={"proj_a": "role_a", "proj_b": "role_c"}, permission="view", + username="test_user", ) # "view" includes role_a but not role_c assert perms.allowed_codes == ["proj_a"] @@ -159,7 +163,7 @@ def test_allowed_codes_uses_decorator_permission(): def test_verify_access_allowed_passes(): - perms = ProjectPermissions(memberships={"proj_a": "role_a"}, permission="view") + perms = ProjectPermissions(memberships={"proj_a": "role_a"}, permission="view", username="test_user") perms.verify_access("proj_a", not_found="not found") @@ -167,6 +171,7 @@ def test_verify_access_membership_but_wrong_role_raises(): perms = ProjectPermissions( memberships={"proj_a": "role_a", "proj_b": "role_c"}, permission="view", + username="test_user", ) with pytest.raises(MCPPermissionDenied, match="necessary permission"): perms.verify_access("proj_b", not_found="not found") @@ -176,6 +181,7 @@ def test_verify_access_no_membership_raises_not_found(): perms = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) with pytest.raises(MCPPermissionDenied, match="not found"): perms.verify_access("secret", not_found="not found") @@ -185,6 +191,7 @@ def test_verify_access_accepts_typed_not_found_exception(): perms = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) with pytest.raises(MCPResourceNotAccessible, match="Project `secret` not found or not accessible"): perms.verify_access("secret", not_found=MCPResourceNotAccessible("Project", "secret")) @@ -194,7 +201,7 @@ def test_verify_access_accepts_typed_not_found_exception(): def test_has_access(): - perms = ProjectPermissions(memberships={"proj_a": "role_a"}, permission="view") + perms = ProjectPermissions(memberships={"proj_a": "role_a"}, permission="view", username="test_user") assert perms.has_access("proj_a") is True assert perms.has_access("proj_b") is False @@ -208,7 +215,7 @@ def test_get_project_permissions_raises_without_decorator(): def test_get_project_permissions_returns_set_value(): - perms = ProjectPermissions(memberships={}, permission="view") + perms = ProjectPermissions(memberships={}, permission="view", username="test_user") token = _mcp_project_permissions.set(perms) try: assert get_project_permissions() is perms diff --git a/tests/unit/mcp/test_tools_discovery.py b/tests/unit/mcp/test_tools_discovery.py index 4ed20201..40146b21 100644 --- a/tests/unit/mcp/test_tools_discovery.py +++ b/tests/unit/mcp/test_tools_discovery.py @@ -27,6 +27,7 @@ def test_get_data_inventory_passes_project_codes_for_scoped_user( mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_c"}, permission="catalog", + username="test_user", ) mock_get_inventory.return_value = "# Data Inventory" @@ -46,6 +47,7 @@ def test_get_data_inventory_view_codes_for_scoped_user( mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_c", "proj_b": "role_a"}, permission="catalog", + username="test_user", ) mock_get_inventory.return_value = "# Data Inventory" @@ -95,6 +97,7 @@ def test_list_projects_filters_for_scoped_user(mock_compute, mock_project, db_se mock_compute.return_value = ProjectPermissions( memberships={"demo": "role_a"}, permission="catalog", + username="test_user", ) proj1 = MagicMock() @@ -173,6 +176,7 @@ def test_list_test_suites_raises_not_found_for_inaccessible_project( mock_compute.return_value = ProjectPermissions( memberships={"other_project": "role_a"}, permission="view", + username="test_user", ) from testgen.mcp.tools.discovery import list_test_suites @@ -188,6 +192,7 @@ def test_list_test_suites_raises_denial_for_insufficient_permission( mock_compute.return_value = ProjectPermissions( memberships={"other_project": "role_a", "secret_project": "role_c"}, permission="view", + username="test_user", ) from testgen.mcp.tools.discovery import list_test_suites diff --git a/tests/unit/mcp/test_tools_execution.py b/tests/unit/mcp/test_tools_execution.py index 8ed455f3..79f7e99b 100644 --- a/tests/unit/mcp/test_tools_execution.py +++ b/tests/unit/mcp/test_tools_execution.py @@ -202,7 +202,7 @@ def test_decorator_denies_when_user_has_no_edit_on_any_project( ): """@mcp_permission('edit') raises MCPPermissionDenied — distinct from the resolver-level MCPResourceNotAccessible — when the user has no edit on any project.""" - mock_compute.return_value = ProjectPermissions(memberships={"some_project": "role_c"}, permission="edit") + mock_compute.return_value = ProjectPermissions(memberships={"some_project": "role_c"}, permission="edit", username="test_user") from testgen.mcp.tools import execution diff --git a/tests/unit/mcp/test_tools_profiling.py b/tests/unit/mcp/test_tools_profiling.py index 5627d562..e9773075 100644 --- a/tests/unit/mcp/test_tools_profiling.py +++ b/tests/unit/mcp/test_tools_profiling.py @@ -464,6 +464,7 @@ def test_list_profiling_summaries_neither_arg_rejected(db_session_mock): def test_list_profiling_summaries_rejects_inaccessible_project(mock_compute, db_session_mock): mock_compute.return_value = ProjectPermissions( memberships={"demo": "role_a"}, permission="catalog", + username="test_user", ) from testgen.mcp.tools.profiling import list_profiling_summaries diff --git a/tests/unit/mcp/test_tools_source_data.py b/tests/unit/mcp/test_tools_source_data.py index 07dfb3d7..0a888b46 100644 --- a/tests/unit/mcp/test_tools_source_data.py +++ b/tests/unit/mcp/test_tools_source_data.py @@ -115,6 +115,7 @@ def test_get_source_data_query_passes_project_codes(mock_compute, mock_td, mock_ mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) context = _make_context() mock_td.get_source_data_context.return_value = context @@ -266,6 +267,7 @@ def test_get_source_data_passes_project_codes(mock_compute, mock_td, mock_fetch, mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) context = _make_context(project_code="proj_a") mock_td.get_source_data_context.return_value = context diff --git a/tests/unit/mcp/test_tools_test_results.py b/tests/unit/mcp/test_tools_test_results.py index 78b1562b..cadcb86c 100644 --- a/tests/unit/mcp/test_tools_test_results.py +++ b/tests/unit/mcp/test_tools_test_results.py @@ -173,7 +173,7 @@ def test_list_test_results_run_in_monitor_suite_rejected(mock_test_run_cls, mock def test_list_test_results_run_in_forbidden_project( mock_compute, mock_test_run_cls, mock_suite_cls, db_session_mock ): - mock_compute.return_value = ProjectPermissions(memberships={"proj_a": "role_a"}, permission="view") + mock_compute.return_value = ProjectPermissions(memberships={"proj_a": "role_a"}, permission="view", username="test_user") mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="forbidden_project") @@ -193,6 +193,7 @@ def test_list_test_results_passes_project_codes( mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="proj_a") @@ -265,7 +266,7 @@ def test_list_test_results_by_suite_id_monitor_or_missing(mock_suite_cls, db_ses @patch("testgen.mcp.tools.test_results.TestSuite") @patch("testgen.mcp.permissions._compute_project_permissions") def test_list_test_results_by_suite_id_inaccessible_project(mock_compute, mock_suite_cls, db_session_mock): - mock_compute.return_value = ProjectPermissions(memberships={"proj_a": "role_a"}, permission="view") + mock_compute.return_value = ProjectPermissions(memberships={"proj_a": "role_a"}, permission="view", username="test_user") mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="forbidden_project") from testgen.mcp.tools.test_results import list_test_results @@ -437,7 +438,7 @@ def test_get_failure_summary_run_not_found(mock_test_run_cls, db_session_mock): def test_get_failure_summary_run_in_forbidden_project( mock_compute, mock_test_run_cls, mock_suite_cls, db_session_mock, ): - mock_compute.return_value = ProjectPermissions(memberships={"proj_a": "role_a"}, permission="view") + mock_compute.return_value = ProjectPermissions(memberships={"proj_a": "role_a"}, permission="view", username="test_user") mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="forbidden_project") @@ -470,6 +471,7 @@ def test_get_failure_summary_passes_project_codes( mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) mock_test_run_cls.get_by_id_or_job.return_value = _mock_test_run() mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="proj_a") @@ -549,6 +551,7 @@ def test_get_test_result_history_passes_project_codes( mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) mock_result.select_history.return_value = [] @@ -577,6 +580,7 @@ def test_get_failure_summary_rejects_project_code_alone(mock_compute, db_session mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) from testgen.mcp.tools.test_results import get_failure_summary @@ -591,6 +595,7 @@ def test_get_failure_summary_rejects_cross_suite_table_or_column_grouping(mock_c mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) from testgen.mcp.tools.test_results import get_failure_summary @@ -605,6 +610,7 @@ def test_get_failure_summary_cross_run_by_project(mock_compute, mock_result, db_ mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) mock_result.select_failures.return_value = [] @@ -627,6 +633,7 @@ def test_get_failure_summary_cross_run_by_project_and_suite( mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="proj_a") mock_result.select_failures.return_value = [] @@ -647,6 +654,7 @@ def test_get_failure_summary_rejects_inaccessible_project(mock_compute, db_sessi mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) from testgen.mcp.tools.test_results import get_failure_summary @@ -659,7 +667,7 @@ def test_get_failure_summary_rejects_inaccessible_project(mock_compute, db_sessi @patch("testgen.mcp.permissions._compute_project_permissions") def test_get_failure_summary_rejects_inaccessible_test_suite(mock_compute, mock_suite_cls, db_session_mock): """test_suite_id branch validates suite access — same contract as list_test_results.""" - mock_compute.return_value = ProjectPermissions(memberships={"proj_a": "role_a"}, permission="view") + mock_compute.return_value = ProjectPermissions(memberships={"proj_a": "role_a"}, permission="view", username="test_user") mock_suite_cls.get_regular.return_value = _mock_test_suite(project_code="forbidden_project") from testgen.mcp.tools.test_results import get_failure_summary @@ -711,6 +719,7 @@ def test_search_test_results_happy_path(mock_compute, mock_search_results, db_se mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) mock_search_results.return_value = ([_mock_search_row()], 1) @@ -734,6 +743,7 @@ def test_search_test_results_empty(mock_compute, mock_search_results, db_session mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) mock_search_results.return_value = ([], 0) @@ -749,6 +759,7 @@ def test_search_test_results_rejects_unknown_project(mock_compute, db_session_mo mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) from testgen.mcp.tools.test_results import search_test_results @@ -763,6 +774,7 @@ def test_search_test_results_paginates(mock_compute, mock_search_results, db_ses mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) # total > limit → footer expected rows = [_mock_search_row() for _ in range(2)] @@ -786,6 +798,7 @@ def test_get_failure_trend_happy_path(mock_compute, mock_failure_trend, db_sessi mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) b1 = MagicMock(failed_ct=3, warning_ct=1, total_ct=10) b1.bucket = date(2026, 4, 1) @@ -810,6 +823,7 @@ def test_get_failure_trend_empty(mock_compute, mock_failure_trend, db_session_mo mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) mock_failure_trend.return_value = [] @@ -834,6 +848,7 @@ def test_get_failure_trend_exclude_today_shifts_end_date(mock_compute, mock_fail mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) mock_failure_trend.return_value = [] @@ -883,6 +898,7 @@ def test_get_test_run_diff_happy_path( mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) suite_id = uuid4() run_a = MagicMock(id=uuid4(), test_suite_id=suite_id) @@ -927,6 +943,7 @@ def test_get_test_run_diff_run_not_found( mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) suite_id = uuid4() mock_test_run_cls.get_by_id_or_job.side_effect = [None, MagicMock(id=uuid4(), test_suite_id=suite_id)] @@ -949,6 +966,7 @@ def test_get_test_run_diff_rejects_inaccessible_project( mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) suite_id = uuid4() run = MagicMock(id=uuid4(), test_suite_id=suite_id) @@ -972,6 +990,7 @@ def test_get_test_run_diff_rejects_different_suites( mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) suite_id_a = uuid4() suite_id_b = uuid4() @@ -1007,6 +1026,7 @@ def test_get_test_run_diff_rejects_monitor_suite( mock_compute.return_value = ProjectPermissions( memberships={"proj_a": "role_a"}, permission="view", + username="test_user", ) suite_id = uuid4() run = MagicMock(id=uuid4(), test_suite_id=suite_id) diff --git a/tests/unit/mcp/test_tools_test_runs.py b/tests/unit/mcp/test_tools_test_runs.py index a03b5604..c914dd25 100644 --- a/tests/unit/mcp/test_tools_test_runs.py +++ b/tests/unit/mcp/test_tools_test_runs.py @@ -163,6 +163,7 @@ def test_get_recent_test_runs_raises_not_found_for_inaccessible_project( mock_compute.return_value = ProjectPermissions( memberships={"other_project": "role_a"}, permission="view", + username="test_user", ) from testgen.mcp.tools.test_runs import get_recent_test_runs @@ -178,6 +179,7 @@ def test_get_recent_test_runs_raises_denial_for_insufficient_permission( mock_compute.return_value = ProjectPermissions( memberships={"other_project": "role_a", "secret_project": "role_c"}, permission="view", + username="test_user", ) from testgen.mcp.tools.test_runs import get_recent_test_runs From 3b0ea29073cc74dc97c64141eba5620c507e8eb6 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Sun, 3 May 2026 02:33:06 -0400 Subject: [PATCH 114/123] fix(run dialogs): remove CLI command - delete unused duplicate files --- .../js/pages/generate_tests_dialog.js | 182 ------------------ .../frontend/js/pages/run_tests_dialog.js | 164 ---------------- .../js/components/generate_tests_dialog.js | 11 -- .../js/components/run_profiling_dialog.js | 20 +- .../static/js/components/run_tests_dialog.js | 18 +- 5 files changed, 4 insertions(+), 391 deletions(-) delete mode 100644 testgen/ui/components/frontend/js/pages/generate_tests_dialog.js delete mode 100644 testgen/ui/components/frontend/js/pages/run_tests_dialog.js diff --git a/testgen/ui/components/frontend/js/pages/generate_tests_dialog.js b/testgen/ui/components/frontend/js/pages/generate_tests_dialog.js deleted file mode 100644 index 510611d6..00000000 --- a/testgen/ui/components/frontend/js/pages/generate_tests_dialog.js +++ /dev/null @@ -1,182 +0,0 @@ -/** - * @typedef RefreshWarning - * @type {object} - * @property {number} test_ct - * @property {number} unlocked_test_ct - * @property {number} unlocked_edits_ct - * - * @typedef Result - * @type {object} - * @property {boolean} success - * @property {string} message - * - * @typedef Properties - * @type {object} - * @property {string} test_suite_id - * @property {string} test_suite_name - * @property {string[]} generation_sets - * @property {string?} default_generation_set - * @property {RefreshWarning?} refresh_warning - * @property {string?} lock_result - * @property {Result?} result - */ -import van from '/app/static/js/van.min.js'; -import { Button } from '/app/static/js/components/button.js'; -import { Dialog } from '/app/static/js/components/dialog.js'; -import { Alert } from '/app/static/js/components/alert.js'; -import { Code } from '/app/static/js/components/code.js'; -import { ExpanderToggle } from '/app/static/js/components/expander_toggle.js'; -import { Select } from '/app/static/js/components/select.js'; -import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; - -const { div, span, strong } = van.tags; - -const GenerateTestsDialog = (/** @type Properties */ props) => { - const { emit } = props; - loadStylesheet('generate-tests-dialog', stylesheet); - - const dialogProp = getValue(props.dialog); - const dialogOpen = van.state(dialogProp?.open === true); - - const testSuiteId = getValue(props.test_suite_id); - const testSuiteName = getValue(props.test_suite_name); - const generationSets = getValue(props.generation_sets) ?? []; - const defaultSet = getValue(props.default_generation_set) ?? (generationSets[0] ?? ''); - const selectedSet = van.state(defaultSet); - - const showCLI = van.state(false); - - const content = div( - { class: 'flex-column fx-gap-3 generate-tests--wrapper' }, - generationSets.length > 0 - ? Select({ - label: 'Generation Set', - value: selectedSet, - allowNull: false, - options: generationSets.map(s => ({ value: s, label: s })), - onChange: (value) => { selectedSet.val = value; }, - portalClass: 'generate-tests--select', - }) - : '', - () => { - const warning = getValue(props.refresh_warning); - if (!warning || !warning.test_ct) return ''; - let message = ''; - if (warning.unlocked_edits_ct > 0) { - message = 'Manual changes have been made to auto-generated tests in this test suite that have not been locked. '; - } else if (warning.unlocked_test_ct > 0) { - message = 'Auto-generated tests are present in this test suite that have not been locked. '; - } - return div( - { class: 'flex-column fx-gap-2' }, - Alert( - { type: 'warn' }, - div(message), - div({ class: 'mt-1' }, `Generating tests now will overwrite unlocked tests subject to auto-generation based on the latest profiling.`), - div({ class: 'mt-1 text-caption' }, `Auto-generated Tests: ${warning.test_ct}, Unlocked: ${warning.unlocked_test_ct}, Edited Unlocked: ${warning.unlocked_edits_ct}`), - ), - warning.unlocked_edits_ct > 0 - ? div( - () => { - const lockResult = getValue(props.lock_result); - return lockResult - ? Alert({ type: 'success' }, span(lockResult)) - : Button({ - type: 'stroked', - label: 'Lock Edited Tests', - width: 'auto', - onclick: () => emit('LockEditedTests', {}), - }); - }, - ) - : '', - ); - }, - div( - span('Execute test generation for the test suite '), - strong({}, testSuiteName), - span('?'), - ), - ExpanderToggle({ - expandLabel: 'Show CLI command', - collapseLabel: 'Collapse', - onExpand: () => showCLI.val = true, - onCollapse: () => showCLI.val = false, - }), - () => Code({ class: showCLI.val ? '' : 'hidden' }, `testgen run-test-generation --test-suite-id ${testSuiteId} --generation-set '${selectedSet.val}'`), - () => { - const result = getValue(props.result) ?? {}; - return result.message - ? Alert({ type: result.success ? 'success' : 'error' }, span(result.message)) - : ''; - }, - () => !getValue(props.result) - ? div( - { class: 'flex-row fx-justify-content-flex-end mt-3' }, - Button({ - label: 'Generate Tests', - type: 'stroked', - color: 'primary', - width: 'auto', - style: 'width: auto;', - onclick: () => emit('GenerateTestsConfirmed', { - payload: { - test_suite_id: testSuiteId, - generation_set: selectedSet.val, - }, - }), - }), - ) - : '', - ); - - if (dialogProp) { - const dialogTitle = van.derive(() => getValue(props.dialog)?.title ?? 'Generate Tests'); - return Dialog( - { - title: dialogTitle, - open: dialogOpen, - onClose: () => { dialogOpen.val = false; emit('CloseClicked', {}); }, - width: '36rem', - }, - content, - ); - } - return content; -}; - -const stylesheet = new CSSStyleSheet(); -stylesheet.replace(` -.generate-tests--wrapper { - min-height: 120px; -} - -.generate-tests--select { - max-height: 200px !important; -} -`); - -export { GenerateTestsDialog }; - -export default (component) => { - const { data, setStateValue, setTriggerValue, parentElement } = component; - - let componentState = parentElement.state; - if (componentState === undefined) { - componentState = {}; - for (const [key, value] of Object.entries(data)) { - componentState[key] = van.state(value); - } - parentElement.state = componentState; - componentState.emit = createEmitter(setTriggerValue); - van.add(parentElement, GenerateTestsDialog(componentState)); - } else { - for (const [key, value] of Object.entries(data)) { - if (!isEqual(componentState[key].val, value)) { - componentState[key].val = value; - } - } - } - - return () => { parentElement.state = null; }; -}; diff --git a/testgen/ui/components/frontend/js/pages/run_tests_dialog.js b/testgen/ui/components/frontend/js/pages/run_tests_dialog.js deleted file mode 100644 index 04ee1523..00000000 --- a/testgen/ui/components/frontend/js/pages/run_tests_dialog.js +++ /dev/null @@ -1,164 +0,0 @@ -/** - * @typedef TestSuiteOption - * @type {object} - * @property {string} value - * @property {string} label - * - * @typedef Result - * @type {object} - * @property {boolean} success - * @property {string} message - * @property {boolean?} show_link - * - * @typedef Properties - * @type {object} - * @property {string} project_code - * @property {TestSuiteOption[]} test_suites - * @property {string?} default_test_suite_id - * @property {Result?} result - */ -import van from '/app/static/js/van.min.js'; -import { Button } from '/app/static/js/components/button.js'; -import { Dialog } from '/app/static/js/components/dialog.js'; -import { Alert } from '/app/static/js/components/alert.js'; -import { Code } from '/app/static/js/components/code.js'; -import { ExpanderToggle } from '/app/static/js/components/expander_toggle.js'; -import { Icon } from '/app/static/js/components/icon.js'; -import { Select } from '/app/static/js/components/select.js'; -import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js'; - -const { div, span, strong } = van.tags; - -const RunTestsDialog = (/** @type Properties */ props) => { - const { emit } = props; - loadStylesheet('run-tests-dialog', stylesheet); - - const dialogProp = getValue(props.dialog); - const dialogOpen = van.state(dialogProp?.open === true); - - const testSuites = getValue(props.test_suites) ?? []; - const defaultId = getValue(props.default_test_suite_id); - const selectedId = van.state(defaultId ?? (testSuites.length === 1 ? testSuites[0].value : null)); - const selectedTestSuite = van.derive(() => testSuites.find(ts => ts.value === selectedId.val) ?? null); - - const showCLI = van.state(false); - - const content = div( - { class: 'flex-column fx-gap-3 run-tests--wrapper' }, - testSuites.length !== 1 - ? Select({ - label: 'Test Suite', - value: selectedId, - options: testSuites, - onChange: (value) => { selectedId.val = value; }, - portalClass: 'run-tests--select', - }) - : () => span('Run tests for the test suite ', strong({}, selectedTestSuite.val?.label ?? ''), '?'), - () => selectedTestSuite.val - ? div( - ExpanderToggle({ - expandLabel: 'Show CLI command', - collapseLabel: 'Collapse', - onExpand: () => showCLI.val = true, - onCollapse: () => showCLI.val = false, - }), - Code({ class: () => showCLI.val ? '' : 'hidden' }, `testgen run-tests --test-suite-id ${selectedTestSuite.val.value}`), - ) - : div({ style: 'margin: auto;' }, 'Select a test suite to run.'), - () => { - const result = getValue(props.result) ?? {}; - return result.message - ? Alert({ type: result.success ? 'success' : 'error' }, span(result.message)) - : ''; - }, - () => !getValue(props.result) - ? div( - { class: 'flex-row fx-justify-space-between mt-3' }, - div( - { class: 'flex-row fx-gap-1' }, - Icon({ size: 16 }, 'info'), - span({ class: 'text-caption' }, ' Test execution will be performed in a background process.'), - ), - Button({ - label: 'Run Tests', - type: 'stroked', - color: 'primary', - width: 'auto', - style: 'width: auto;', - disabled: van.derive(() => !selectedTestSuite.val), - onclick: () => emit('RunTestsConfirmed', { - payload: { - test_suite_id: selectedTestSuite.val?.value, - test_suite_name: selectedTestSuite.val?.label, - }, - }), - }), - ) - : '', - () => getValue(props.result)?.show_link - ? Button({ - type: 'stroked', - color: 'primary', - label: 'Go to Test Runs', - style: 'width: auto; margin-left: auto; margin-top: 12px;', - icon: 'chevron_right', - onclick: () => emit('GoToTestRunsClicked', { - payload: { - project_code: getValue(props.project_code), - test_suite_id: selectedTestSuite.val?.value, - }, - }), - }) - : '', - ); - - if (dialogProp) { - const dialogTitle = van.derive(() => getValue(props.dialog)?.title ?? 'Run Tests'); - return Dialog( - { - title: dialogTitle, - open: dialogOpen, - onClose: () => { dialogOpen.val = false; emit('CloseClicked', {}); }, - width: '32rem', - }, - content, - ); - } - return content; -}; - -const stylesheet = new CSSStyleSheet(); -stylesheet.replace(` -.run-tests--wrapper { - min-height: 120px; -} - -.run-tests--select { - max-height: 200px !important; -} -`); - -export { RunTestsDialog }; - -export default (component) => { - const { data, setStateValue, setTriggerValue, parentElement } = component; - - let componentState = parentElement.state; - if (componentState === undefined) { - componentState = {}; - for (const [key, value] of Object.entries(data)) { - componentState[key] = van.state(value); - } - parentElement.state = componentState; - componentState.emit = createEmitter(setTriggerValue); - van.add(parentElement, RunTestsDialog(componentState)); - } else { - for (const [key, value] of Object.entries(data)) { - if (!isEqual(componentState[key].val, value)) { - componentState[key].val = value; - } - } - } - - return () => { parentElement.state = null; }; -}; diff --git a/testgen/ui/static/js/components/generate_tests_dialog.js b/testgen/ui/static/js/components/generate_tests_dialog.js index 54fab2d1..789d7160 100644 --- a/testgen/ui/static/js/components/generate_tests_dialog.js +++ b/testgen/ui/static/js/components/generate_tests_dialog.js @@ -25,8 +25,6 @@ import van from '/app/static/js/van.min.js'; import { Button } from '/app/static/js/components/button.js'; import { Dialog } from '/app/static/js/components/dialog.js'; import { Alert } from '/app/static/js/components/alert.js'; -import { Code } from '/app/static/js/components/code.js'; -import { ExpanderToggle } from '/app/static/js/components/expander_toggle.js'; import { Select } from '/app/static/js/components/select.js'; import { getValue, loadStylesheet } from '/app/static/js/utils.js'; @@ -56,8 +54,6 @@ const GenerateTestsDialog = (/** @type Properties */ props) => { const defaultSet = getValue(props.default_generation_set) ?? (generationSets[0] ?? ''); const selectedSet = van.state(defaultSet); - const showCLI = van.state(false); - const content = div( { class: 'flex-column fx-gap-3 generate-tests--wrapper' }, generationSets.length > 0 @@ -109,13 +105,6 @@ const GenerateTestsDialog = (/** @type Properties */ props) => { strong({}, testSuiteName), span('?'), ), - ExpanderToggle({ - expandLabel: 'Show CLI command', - collapseLabel: 'Collapse', - onExpand: () => showCLI.val = true, - onCollapse: () => showCLI.val = false, - }), - () => Code({ class: showCLI.val ? '' : 'hidden' }, `testgen run-test-generation --test-suite-id ${testSuiteId} --generation-set '${selectedSet.val}'`), () => { const result = getValue(props.result) ?? {}; return result.message diff --git a/testgen/ui/static/js/components/run_profiling_dialog.js b/testgen/ui/static/js/components/run_profiling_dialog.js index d1b03f5a..cc7237d7 100644 --- a/testgen/ui/static/js/components/run_profiling_dialog.js +++ b/testgen/ui/static/js/components/run_profiling_dialog.js @@ -18,10 +18,8 @@ import van from '/app/static/js/van.min.js'; import { Alert } from '/app/static/js/components/alert.js'; import { Dialog } from '/app/static/js/components/dialog.js'; -import { ExpanderToggle } from '/app/static/js/components/expander_toggle.js'; import { Icon } from '/app/static/js/components/icon.js'; import { getValue, loadStylesheet } from '/app/static/js/utils.js'; -import { Code } from '/app/static/js/components/code.js'; import { Button } from '/app/static/js/components/button.js'; import { Select } from '/app/static/js/components/select.js'; import { TableGroupStats } from '/app/static/js/components/table_group_stats.js'; @@ -55,7 +53,6 @@ const RunProfilingDialog = (props) => { const allowSelection = getValue(props.allow_selection); const selectedId = van.state(getValue(props.selected_id)); const selectedTableGroup = van.derive(() => tableGroups.find(({ id }) => id === selectedId.val)); - const showCLICommand = van.state(false); const content = div( { id: wrapperId }, @@ -74,20 +71,7 @@ const RunProfilingDialog = (props) => { '?', ), () => selectedTableGroup.val - ? div( - TableGroupStats({ class: 'mt-1 mb-3' }, selectedTableGroup.val), - ExpanderToggle({ - default: showCLICommand, - collapseLabel: 'Collapse', - expandLabel: 'Show CLI command', - onCollapse: () => showCLICommand.val = false, - onExpand: () => showCLICommand.val = true, - }), - div( - { style: () => showCLICommand.val ? '' : 'display: none' }, - Code({}, `testgen run-profile --table-group-id ${selectedTableGroup.val.id}`), - ), - ) + ? TableGroupStats({ class: 'mt-1 mb-3' }, selectedTableGroup.val) : div({ style: 'margin: auto;' }, 'Select a table group to profile.'), () => { const result = getValue(props.result) ?? {}; @@ -144,7 +128,7 @@ const RunProfilingDialog = (props) => { const stylesheet = new CSSStyleSheet(); stylesheet.replace(` .run-profiling--allow-selection { - min-height: 225px; + min-height: 190px; } .run-profiling--select { diff --git a/testgen/ui/static/js/components/run_tests_dialog.js b/testgen/ui/static/js/components/run_tests_dialog.js index 3adb565c..40a3f095 100644 --- a/testgen/ui/static/js/components/run_tests_dialog.js +++ b/testgen/ui/static/js/components/run_tests_dialog.js @@ -53,8 +53,6 @@ const RunTestsDialog = (/** @type Properties */ props) => { const selectedId = van.state(defaultId ?? (testSuites.length === 1 ? testSuites[0].value : null)); const selectedTestSuite = van.derive(() => testSuites.find(ts => ts.value === selectedId.val) ?? null); - const showCLI = van.state(false); - const content = div( { class: 'flex-column fx-gap-3 run-tests--wrapper' }, testSuites.length !== 1 @@ -67,19 +65,7 @@ const RunTestsDialog = (/** @type Properties */ props) => { }) : () => span('Run tests for the test suite ', strong({}, selectedTestSuite.val?.label ?? ''), '?'), () => selectedTestSuite.val - ? div( - ExpanderToggle({ - default: showCLI, - expandLabel: 'Show CLI command', - collapseLabel: 'Collapse', - onExpand: () => showCLI.val = true, - onCollapse: () => showCLI.val = false, - }), - div( - { style: () => showCLI.val ? '' : 'display: none' }, - Code({}, `testgen run-tests --test-suite-id ${selectedTestSuite.val.value}`), - ), - ) + ? '' : div({ style: 'margin: auto;' }, 'Select a test suite to run.'), () => { const result = getValue(props.result) ?? {}; @@ -146,7 +132,7 @@ const RunTestsDialog = (/** @type Properties */ props) => { const stylesheet = new CSSStyleSheet(); stylesheet.replace(` .run-tests--wrapper { - min-height: 120px; + min-height: 85px; } .run-tests--select { From fb3f6b72a9f4ce7e6dbe8c5c30fd4b48ef659d8b Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Sun, 3 May 2026 02:34:36 -0400 Subject: [PATCH 115/123] fix(demo): trigger test failures on dimension table for weighted scoring --- .../template/quick_start/add_cat_tests.sql | 8 +++++ .../quick_start/update_target_data_iter3.sql | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/testgen/template/quick_start/add_cat_tests.sql b/testgen/template/quick_start/add_cat_tests.sql index 7dc931cf..59c2be0d 100644 --- a/testgen/template/quick_start/add_cat_tests.sql +++ b/testgen/template/quick_start/add_cat_tests.sql @@ -10,3 +10,11 @@ VALUES ('0ea85e17-acbe-47fe-8394-9970725ad37d', '2024-06-07 02:45:27.102847', : 'f_ebike_sales', 'SUM(total_amount)', 0, '0', 'sale_date <= (DATE_TRUNC(''month'', CURRENT_DATE) - (interval ''3 month'' - interval ''{ITERATION_NUMBER} month'') - interval ''1 day'')', 'product_id, sale_date_year, sale_date_month', null, 'tmp_f_ebike_sales_last_month', 'SUM(total_amount)', null, 'product_id, sale_date_year, sale_date_month', null, 'Y', null, 'WARN', 'N'); + +-- Demo: mark customer_id and credit_card as CDEs so the iter3 dimension-table issues +-- surface in the CDE-filtered breakdown. The profiling auto-flagger doesn't mark ID columns. +UPDATE data_column_chars + SET critical_data_element = TRUE + WHERE table_groups_id = '0ea85e17-acbe-47fe-8394-9970725ad37d' + AND table_name = 'd_ebike_customers' + AND column_name IN ('customer_id', 'credit_card'); diff --git a/testgen/template/quick_start/update_target_data_iter3.sql b/testgen/template/quick_start/update_target_data_iter3.sql index d1dece90..887c9c58 100644 --- a/testgen/template/quick_start/update_target_data_iter3.sql +++ b/testgen/template/quick_start/update_target_data_iter3.sql @@ -5,3 +5,37 @@ SET total_amount = (sale_price + 100) * quantity_sold, adjusted_total_amount = (sale_price + 100) * quantity_sold - discount_amount, sale_price = sale_price + 100 WHERE product_id = 30027; + +-- Demo data quality issues on the customer dimension table (entity, table weight 10) +-- to showcase weighted DQ scoring. None of these columns are touched by the monitor demo. + +-- ~25% of customers with mangled postal codes — triggers Valid_US_Zip. +-- Combined weight: 10 (entity) x 1.5 (Zip) x 2.0 (PII Address) = 30 per row. +UPDATE d_ebike_customers +SET postal_code = SUBSTRING(postal_code, 1, 4) || 'X' +WHERE customer_id % 4 = 0; + +-- ~33% of customers with invalid income_level — triggers LOV_Match (baseline LOV: HIGH/LOW/MEDIUM). +-- Combined weight: 10 x 1.5 (Code) x 2.0 (PII Demographic) = 30 per row. +UPDATE d_ebike_customers +SET income_level = 'PREMIUM' +WHERE customer_id % 3 = 0; + +-- ~33% of customers with non-numeric credit_card — triggers Pattern_Match. +-- Combined weight: 10 x 2.0 (ID-Secondary) x 1.0 = 20 per row. credit_card is also marked CDE. +UPDATE d_ebike_customers +SET credit_card = 'PENDING-VERIFY' +WHERE customer_id % 3 = 1; + +-- ~5% of customers reassigned to share customer_id 100001 — triggers Unique, Unique_Pct, Dupe_Rows. +-- UPDATE rather than INSERT keeps the row count stable so Volume_Trend baselines aren't disturbed. +-- Combined weight: 10 x 3.0 (ID-Unique) x 1.0 = 30 per row. customer_id is also marked CDE. +-- This must run last because the other plays filter on customer_id. +UPDATE d_ebike_customers +SET customer_id = 100001 +WHERE customer_id IN ( + SELECT customer_id FROM d_ebike_customers + WHERE customer_id != 100001 + ORDER BY customer_id + OFFSET 100 LIMIT 25 +); From 0d32f883275f752a176f4130f1025bc78d99388b Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Sun, 3 May 2026 02:36:14 -0400 Subject: [PATCH 116/123] docs: enable mcp by default - add build script for reference doc --- deploy/build_mcp_docs.py | 151 ++++++++++++++++++++++++++ invocations/dev.py | 11 +- testgen/api/oauth/metadata.py | 4 +- testgen/mcp/server.py | 28 +++-- testgen/mcp/tools/__init__.py | 17 +++ testgen/mcp/tools/discovery.py | 3 + testgen/mcp/tools/execution.py | 3 + testgen/mcp/tools/profiling.py | 3 + testgen/mcp/tools/reference.py | 3 + testgen/mcp/tools/source_data.py | 3 + testgen/mcp/tools/test_definitions.py | 3 + testgen/mcp/tools/test_results.py | 3 + testgen/mcp/tools/test_runs.py | 3 + testgen/server/__init__.py | 8 +- testgen/settings.py | 4 +- 15 files changed, 231 insertions(+), 16 deletions(-) create mode 100644 deploy/build_mcp_docs.py diff --git a/deploy/build_mcp_docs.py b/deploy/build_mcp_docs.py new file mode 100644 index 00000000..a420c259 --- /dev/null +++ b/deploy/build_mcp_docs.py @@ -0,0 +1,151 @@ +"""Export the TestGen MCP server as a Markdown reference page. + +Usage: + python deploy/build_mcp_docs.py [--output PATH] + +Introspects the FastMCP instance built by ``build_mcp_server()`` and emits +a single Markdown page listing prompts, tools, and resources. Tools are +grouped by the ``_DOC_GROUP`` constant defined on each tool module — when +adding a new tool module, declare ``_DOC_GROUP = "..."`` so the new tools +land under the right heading automatically. +""" + +import argparse +import re +import sys +import textwrap +from pathlib import Path +from typing import Any + +from testgen.mcp.server import build_mcp_server +from testgen.mcp.tools import DocGroup + +_DEFAULT_OUTPUT = Path("docs/mcp/supported-tools.md") +_ARGS_HEADER_RE = re.compile(r"^\s*Args:\s*$", re.MULTILINE) + +# Order in which tool groups appear on the page. Each entry is a ``DocGroup`` +# member; tools whose module declares a ``_DOC_GROUP`` not in this list are +# appended after these in the order they are first seen. +_GROUP_ORDER: list[DocGroup] = [ + DocGroup.DISCOVER, + DocGroup.INVESTIGATE, + DocGroup.BROWSE_PROFILING, + DocGroup.TRIGGER, +] +_FALLBACK_GROUP = "Other tools" + + +def _short_description(docstring: str) -> str: + """Return the first prose paragraph of a docstring, stripped of Args/Returns sections.""" + if not docstring: + return "" + text = textwrap.dedent(docstring).strip() + match = _ARGS_HEADER_RE.search(text) + if match: + text = text[: match.start()].rstrip() + first_paragraph = text.split("\n\n", 1)[0] + return " ".join(line.strip() for line in first_paragraph.splitlines()) + + +def _entry_name(item: Any) -> str: + """Display name for a tool, resource, or prompt.""" + return str(getattr(item, "uri", None) or item.name) + + +def _render_entry(item: Any) -> str: + description = _short_description(item.description or "") + return f"- **`{_entry_name(item)}`** — {description}" + + +def _group_for_tool(tool: Any) -> str: + """Resolve a tool's display group via its module's ``_DOC_GROUP`` constant.""" + module = sys.modules.get(tool.fn.__module__) + group = getattr(module, "_DOC_GROUP", None) + return str(group) if group is not None else _FALLBACK_GROUP + + +def _group_tools(tools: list[Any]) -> list[tuple[str, list[Any]]]: + """Bucket tools by their module's ``_DOC_GROUP``, ordered by ``_GROUP_ORDER``.""" + buckets: dict[str, list[Any]] = {} + for tool in tools: + buckets.setdefault(_group_for_tool(tool), []).append(tool) + + ordered: list[tuple[str, list[Any]]] = [] + for group in _GROUP_ORDER: + title = str(group) + if title in buckets: + ordered.append((title, sorted(buckets.pop(title), key=lambda t: t.name))) + for title, bucket in buckets.items(): + ordered.append((title, sorted(bucket, key=lambda t: t.name))) + return ordered + + +def _build_markdown(mcp: Any) -> str: + tools = mcp._tool_manager.list_tools() + resources = sorted(mcp._resource_manager.list_resources(), key=lambda r: str(r.uri)) + prompts = sorted(mcp._prompt_manager.list_prompts(), key=lambda p: p.name) + grouped_tools = _group_tools(list(tools)) + + parts: list[str] = [ + "# Supported Tools", + "", + "The TestGen MCP server exposes the prompts, tools, and resources listed below.", + "", + "For setup instructions, see [Set up the MCP Server](setup.md).", + "For example questions to ask an assistant, see [MCP Server](index.md#what-you-can-ask).", + "", + "## Prompts", + "", + ( + "Prompts are pre-built workflows you can invoke directly through your AI client — typically " + "as a slash command (for example, `/testgen:table_health` in Claude Code) or " + "from a quick-action menu. They orchestrate several tool calls behind the scenes for common " + "investigations. Exact UX varies by client." + ), + "", + ] + parts.extend(_render_entry(prompt) for prompt in prompts) + parts.append("") + + parts.extend(["## Tools", "", "Tools are operations the assistant calls during a conversation, picked based on what you ask.", ""]) + for heading, bucket in grouped_tools: + parts.append(f"### {heading}") + parts.append("") + parts.extend(_render_entry(tool) for tool in bucket) + parts.append("") + + parts.extend( + [ + "## Resources", + "", + "Resources are static reference documents that AI clients can fetch by URI.", + "", + ] + ) + parts.extend(_render_entry(resource) for resource in resources) + + return "\n".join(parts).rstrip() + "\n" + + +def main() -> None: + parser = argparse.ArgumentParser(description="Export the TestGen MCP server as a Markdown reference.") + parser.add_argument( + "--output", + type=Path, + default=_DEFAULT_OUTPUT, + help=f"Output Markdown file path (default: {_DEFAULT_OUTPUT}, relative to cwd)", + ) + args = parser.parse_args() + + mcp = build_mcp_server(api_base_url="https://testgen.example.com") + markdown = _build_markdown(mcp) + + output: Path = args.output + output.parent.mkdir(parents=True, exist_ok=True) + frontmatter = "---\nsearch:\n boost: 0.5\n---\n" + output.write_text(frontmatter + markdown, encoding="utf-8") + print(f"Exported MCP supported tools -> {output}") + + +if __name__ == "__main__": + main() diff --git a/invocations/dev.py b/invocations/dev.py index 37011fec..9b6e79f0 100644 --- a/invocations/dev.py +++ b/invocations/dev.py @@ -1,4 +1,4 @@ -__all__ = ["build_api_docs", "build_public_image", "clean", "install", "lint"] +__all__ = ["build_api_docs", "build_mcp_docs", "build_public_image", "clean", "install", "lint"] import re from os.path import exists, join @@ -83,6 +83,15 @@ def build_api_docs(ctx: Context, version: str = "", output: str = "") -> None: ctx.run(f"python deploy/build_api_docs.py {' '.join(args)}") +@task(name="build-mcp-docs", pre=(install,)) +def build_mcp_docs(ctx: Context, output: str = "") -> None: + """Exports the MCP supported-tools page from the FastMCP server.""" + args = [] + if output: + args.append(f"--output {output}") + ctx.run(f"python deploy/build_mcp_docs.py {' '.join(args)}") + + @task( pre=(required_tools, prep_dk_builer), iterable=["label"], diff --git a/testgen/api/oauth/metadata.py b/testgen/api/oauth/metadata.py index 31efefbd..55d6fb28 100644 --- a/testgen/api/oauth/metadata.py +++ b/testgen/api/oauth/metadata.py @@ -1,4 +1,4 @@ -"""RFC 8414 — OAuth 2.0 Authorization Server Metadata.""" +"""RFC 8414 — OAuth 2.1 Authorization Server Metadata.""" from fastapi import APIRouter from fastapi.responses import JSONResponse @@ -10,7 +10,7 @@ @router.get("/.well-known/oauth-authorization-server") def authorization_server_metadata(): - """Return OAuth 2.0 Authorization Server Metadata per RFC 8414. + """Return OAuth 2.1 Authorization Server Metadata per RFC 8414. MCP clients use this for server discovery. """ diff --git a/testgen/mcp/server.py b/testgen/mcp/server.py index 19e3a539..4477916b 100644 --- a/testgen/mcp/server.py +++ b/testgen/mcp/server.py @@ -72,15 +72,11 @@ def _configure_mcp_logging() -> None: logging.getLogger(name).parent = testgen_logger -def build_mcp_app( +def build_mcp_server( api_base_url: str, server_url: str | None = None, -) -> tuple[Starlette, StreamableHTTPSessionManager]: - """Create the MCP Starlette app with tools, resources, and prompts registered. - - Returns the Starlette app and its session manager. The caller must run - ``session_manager.run()`` as an async context manager (e.g. in the host - app's lifespan) to initialize the task group before requests arrive. +) -> FastMCP: + """Create the FastMCP server with tools, resources, and prompts registered. Args: api_base_url: OAuth issuer URL (the API server). @@ -178,5 +174,23 @@ def safe_prompt(fn): safe_prompt(compare_runs) safe_prompt(profiling_overview) + return mcp + + +def build_mcp_app( + api_base_url: str, + server_url: str | None = None, +) -> tuple[Starlette, StreamableHTTPSessionManager]: + """Create the MCP Starlette app with tools, resources, and prompts registered. + + Returns the Starlette app and its session manager. The caller must run + ``session_manager.run()`` as an async context manager (e.g. in the host + app's lifespan) to initialize the task group before requests arrive. + + Args: + api_base_url: OAuth issuer URL (the API server). + server_url: MCP resource server URL. Defaults to ``{api_base_url}/mcp``. + """ + mcp = build_mcp_server(api_base_url, server_url) app = mcp.streamable_http_app() return app, mcp.session_manager diff --git a/testgen/mcp/tools/__init__.py b/testgen/mcp/tools/__init__.py index e69de29b..08a8bc37 100644 --- a/testgen/mcp/tools/__init__.py +++ b/testgen/mcp/tools/__init__.py @@ -0,0 +1,17 @@ +"""MCP tool implementations. + +Each tool module declares ``_DOC_GROUP = DocGroup.`` to control where +the tool appears on the supported-tools doc page. The ``deploy/build_mcp_docs.py`` +script reads these values to organize the page. +""" + +from enum import StrEnum + + +class DocGroup(StrEnum): + """User-facing groupings for tools on the supported-tools doc page.""" + + DISCOVER = "Discover what TestGen knows about" + INVESTIGATE = "Investigate quality issues" + BROWSE_PROFILING = "Browse profiling results" + TRIGGER = "Trigger profiling, tests, and test generation" diff --git a/testgen/mcp/tools/discovery.py b/testgen/mcp/tools/discovery.py index 17942a28..db7dc0a3 100644 --- a/testgen/mcp/tools/discovery.py +++ b/testgen/mcp/tools/discovery.py @@ -5,9 +5,12 @@ from testgen.common.models.test_run import TestRun from testgen.common.models.test_suite import TestSuite from testgen.mcp.permissions import get_project_permissions, mcp_permission +from testgen.mcp.tools import DocGroup from testgen.mcp.tools.common import resolve_table_group, validate_limit, validate_page from testgen.mcp.tools.markdown import MdDoc +_DOC_GROUP = DocGroup.DISCOVER + @with_database_session @mcp_permission("catalog") diff --git a/testgen/mcp/tools/execution.py b/testgen/mcp/tools/execution.py index a7a2c933..7d571d6d 100644 --- a/testgen/mcp/tools/execution.py +++ b/testgen/mcp/tools/execution.py @@ -7,7 +7,10 @@ from testgen.common.models.job_execution import JobExecution from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission +from testgen.mcp.tools import DocGroup from testgen.mcp.tools.common import parse_uuid, resolve_table_group, resolve_test_suite + +_DOC_GROUP = DocGroup.TRIGGER from testgen.mcp.tools.markdown import MdDoc diff --git a/testgen/mcp/tools/profiling.py b/testgen/mcp/tools/profiling.py index abaa7cfa..de878768 100644 --- a/testgen/mcp/tools/profiling.py +++ b/testgen/mcp/tools/profiling.py @@ -7,6 +7,7 @@ from testgen.common.models.table_group import TableGroup, TableGroupSummary from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission +from testgen.mcp.tools import DocGroup from testgen.mcp.tools.common import ( format_page_footer, format_page_info, @@ -16,6 +17,8 @@ from testgen.mcp.tools.markdown import MdDoc from testgen.utils import friendly_score +_DOC_GROUP = DocGroup.BROWSE_PROFILING + @with_database_session @mcp_permission("catalog") diff --git a/testgen/mcp/tools/reference.py b/testgen/mcp/tools/reference.py index 114cfe45..ad2e3262 100644 --- a/testgen/mcp/tools/reference.py +++ b/testgen/mcp/tools/reference.py @@ -1,7 +1,10 @@ from testgen.common.models import with_database_session from testgen.common.models.test_definition import TestType +from testgen.mcp.tools import DocGroup from testgen.mcp.tools.markdown import MdDoc +_DOC_GROUP = DocGroup.DISCOVER + @with_database_session def get_test_type(test_type: str) -> str: diff --git a/testgen/mcp/tools/source_data.py b/testgen/mcp/tools/source_data.py index fa003190..78449a21 100644 --- a/testgen/mcp/tools/source_data.py +++ b/testgen/mcp/tools/source_data.py @@ -9,9 +9,12 @@ ) from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission +from testgen.mcp.tools import DocGroup from testgen.mcp.tools.common import parse_uuid, validate_limit from testgen.mcp.tools.markdown import MdDoc +_DOC_GROUP = DocGroup.INVESTIGATE + def _resolve_context(test_definition_id: str, reference_date: str | None) -> dict: """Look up the test definition context and validate permissions.""" diff --git a/testgen/mcp/tools/test_definitions.py b/testgen/mcp/tools/test_definitions.py index 7863c331..2f25c102 100644 --- a/testgen/mcp/tools/test_definitions.py +++ b/testgen/mcp/tools/test_definitions.py @@ -3,6 +3,7 @@ from testgen.common.models.test_result import TestResult from testgen.mcp.exceptions import MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission +from testgen.mcp.tools import DocGroup from testgen.mcp.tools.common import ( format_page_footer, format_page_info, @@ -13,6 +14,8 @@ ) from testgen.mcp.tools.markdown import MdDoc +_DOC_GROUP = DocGroup.DISCOVER + _VALID_SCOPES = {"column", "table", "referential", "custom"} _VALID_IMPACT_DIMENSIONS = {"Reliability", "Conformance", "Regularity", "Usability"} _VALID_DQ_DIMENSIONS = {"Accuracy", "Completeness", "Consistency", "Recency", "Timeliness", "Uniqueness", "Validity"} diff --git a/testgen/mcp/tools/test_results.py b/testgen/mcp/tools/test_results.py index 53dc812f..1a098383 100644 --- a/testgen/mcp/tools/test_results.py +++ b/testgen/mcp/tools/test_results.py @@ -7,6 +7,7 @@ from testgen.common.models.test_suite import TestSuite from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission +from testgen.mcp.tools import DocGroup from testgen.mcp.tools.common import ( format_page_footer, format_page_info, @@ -19,6 +20,8 @@ ) from testgen.mcp.tools.markdown import MdDoc +_DOC_GROUP = DocGroup.INVESTIGATE + _DEFAULT_SEARCH_STATUSES = [TestResultStatus.Failed, TestResultStatus.Warning] diff --git a/testgen/mcp/tools/test_runs.py b/testgen/mcp/tools/test_runs.py index d70d2cb4..fcdb4571 100644 --- a/testgen/mcp/tools/test_runs.py +++ b/testgen/mcp/tools/test_runs.py @@ -2,9 +2,12 @@ from testgen.common.models.test_run import TestRun from testgen.common.models.test_suite import TestSuite from testgen.mcp.permissions import get_project_permissions, mcp_permission +from testgen.mcp.tools import DocGroup from testgen.mcp.tools.common import validate_limit from testgen.mcp.tools.markdown import MdDoc +_DOC_GROUP = DocGroup.INVESTIGATE + @with_database_session @mcp_permission("view") diff --git a/testgen/server/__init__.py b/testgen/server/__init__.py index f55fca29..120a7789 100644 --- a/testgen/server/__init__.py +++ b/testgen/server/__init__.py @@ -89,17 +89,17 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: tags_metadata = [ {"name": "Jobs", "description": "Submit, poll, cancel, and list job executions (profiling, tests, generation)."}, {"name": "Test Definitions", "description": "Export and import test definitions across environments."}, - {"name": "OAuth", "description": "OAuth 2.0 authorization code flow and token management."}, + {"name": "OAuth", "description": "OAuth 2.1 authorization code flow and token management."}, {"name": "API", "description": "Health and version information."}, ] app = FastAPI( - title=f"{version_data.edition} API" if version_data else "DataOps TestGen API", - summary="REST API for DataOps TestGen.", + title=f"{version_data.edition} API" if version_data else "TestGen API", + summary="REST API for DataOps Data Quality TestGen.", description=( "Automate profiling, test execution, and test generation jobs. " "Export and import test definitions for promotion across environments.\n\n" - "**Authentication**: OAuth 2.0 authorization code flow. " + "**Authentication**: OAuth 2.1 authorization code flow. " "See `GET /.well-known/oauth-authorization-server` for discovery." ), version=version or version_data.current or "dev", diff --git a/testgen/settings.py b/testgen/settings.py index 26510a4e..c8b4913a 100644 --- a/testgen/settings.py +++ b/testgen/settings.py @@ -560,12 +560,12 @@ def _ssl_files_present() -> bool: Email: SMTP password """ -MCP_ENABLED: bool = getenv("TG_MCP_ENABLED", "no").lower() in ("yes", "true") +MCP_ENABLED: bool = getenv("TG_MCP_ENABLED", "yes").lower() in ("yes", "true") """ Enable the MCP server when running `testgen run-app all`. from env variable: `TG_MCP_ENABLED` -defaults to: `Yes` +defaults to: `yes` """ API_PORT: int = int(os.getenv("TG_API_PORT", "8530")) From 5c631636b64fc07cb77175da37e61990d04766fb Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Mon, 4 May 2026 00:47:18 -0400 Subject: [PATCH 117/123] fix: improve standalone version --- testgen/__main__.py | 14 +++++++++++++- testgen/settings.py | 8 ++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/testgen/__main__.py b/testgen/__main__.py index e3e0ced7..1708fbd7 100644 --- a/testgen/__main__.py +++ b/testgen/__main__.py @@ -94,6 +94,7 @@ def invoke(self, ctx: Context): raise except Exception: LOG.exception("There was an unexpected error") + sys.exit(1) def format_epilog(self, _ctx: Context, formatter: click.HelpFormatter) -> None: # Schema revision is a DB round-trip; defer until `--help` is actually @@ -551,6 +552,14 @@ def generate_secret(length: int = 12) -> str: "TG_TARGET_DB_TRUST_SERVER_CERTIFICATE=yes", "TG_EXPORT_TO_OBSERVABILITY_VERIFY_SSL=no", ] + + # Persist caller-supplied runtime overrides (ports, TLS) so they apply to + # subsequent `testgen run-app` invocations. + persisted_env_vars = ("TG_UI_PORT", "TG_API_PORT", "SSL_CERT_FILE", "SSL_KEY_FILE") + persisted_lines = [f"{name}={os.environ[name]}" for name in persisted_env_vars if os.environ.get(name)] + if persisted_lines: + config_lines.extend(["", "# Runtime overrides from installer", *persisted_lines]) + config_path.write_text("\n".join(config_lines) + "\n") click.echo(f"Config written to {config_path}") @@ -860,7 +869,9 @@ def init_ui(): child_env = {**os.environ, "TG_JOB_SOURCE": "UI", STANDALONE_URI_ENV_VAR: server_uri} process= subprocess.Popen( - [ # noqa: S607 + [ + sys.executable, + "-m", "streamlit", "run", app_file, @@ -868,6 +879,7 @@ def init_ui(): "--client.showErrorDetails=none", "--client.toolbarMode=minimal", "--server.enableStaticServing=true", + f"--server.port={settings.UI_PORT}", f"--server.sslCertFile={settings.SSL_CERT_FILE}" if use_ssl else "", f"--server.sslKeyFile={settings.SSL_KEY_FILE}" if use_ssl else "", "--", diff --git a/testgen/settings.py b/testgen/settings.py index c8b4913a..0c69f600 100644 --- a/testgen/settings.py +++ b/testgen/settings.py @@ -568,6 +568,14 @@ def _ssl_files_present() -> bool: defaults to: `yes` """ +UI_PORT: int = int(os.getenv("TG_UI_PORT", "8501")) +""" +Port for the UI server. + +from env variable: `TG_UI_PORT` +defaults to: `8501` +""" + API_PORT: int = int(os.getenv("TG_API_PORT", "8530")) """ Port for the API server. From 8ba2b38d2fda5fc818e2aeac2be7b995019c0eb3 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Mon, 4 May 2026 10:56:20 -0400 Subject: [PATCH 118/123] fix: address review feedback --- deploy/build_mcp_docs.py | 2 +- testgen/mcp/tools/__init__.py | 17 ----------------- testgen/mcp/tools/common.py | 15 +++++++++++++++ testgen/mcp/tools/discovery.py | 3 +-- testgen/mcp/tools/execution.py | 3 +-- testgen/mcp/tools/profiling.py | 2 +- testgen/mcp/tools/reference.py | 2 +- testgen/mcp/tools/source_data.py | 3 +-- testgen/mcp/tools/test_definitions.py | 2 +- testgen/mcp/tools/test_results.py | 2 +- testgen/mcp/tools/test_runs.py | 3 +-- 11 files changed, 24 insertions(+), 30 deletions(-) diff --git a/deploy/build_mcp_docs.py b/deploy/build_mcp_docs.py index a420c259..2040820d 100644 --- a/deploy/build_mcp_docs.py +++ b/deploy/build_mcp_docs.py @@ -18,7 +18,7 @@ from typing import Any from testgen.mcp.server import build_mcp_server -from testgen.mcp.tools import DocGroup +from testgen.mcp.tools.common import DocGroup _DEFAULT_OUTPUT = Path("docs/mcp/supported-tools.md") _ARGS_HEADER_RE = re.compile(r"^\s*Args:\s*$", re.MULTILINE) diff --git a/testgen/mcp/tools/__init__.py b/testgen/mcp/tools/__init__.py index 08a8bc37..e69de29b 100644 --- a/testgen/mcp/tools/__init__.py +++ b/testgen/mcp/tools/__init__.py @@ -1,17 +0,0 @@ -"""MCP tool implementations. - -Each tool module declares ``_DOC_GROUP = DocGroup.`` to control where -the tool appears on the supported-tools doc page. The ``deploy/build_mcp_docs.py`` -script reads these values to organize the page. -""" - -from enum import StrEnum - - -class DocGroup(StrEnum): - """User-facing groupings for tools on the supported-tools doc page.""" - - DISCOVER = "Discover what TestGen knows about" - INVESTIGATE = "Investigate quality issues" - BROWSE_PROFILING = "Browse profiling results" - TRIGGER = "Trigger profiling, tests, and test generation" diff --git a/testgen/mcp/tools/common.py b/testgen/mcp/tools/common.py index 35a4f64f..2a5966e2 100644 --- a/testgen/mcp/tools/common.py +++ b/testgen/mcp/tools/common.py @@ -1,4 +1,5 @@ from datetime import date +from enum import StrEnum from uuid import UUID from testgen.common.date_service import parse_since @@ -10,6 +11,20 @@ from testgen.mcp.permissions import get_project_permissions +class DocGroup(StrEnum): + """User-facing groupings for tools on the supported-tools doc page. + + Each tool module declares ``_DOC_GROUP = DocGroup.``; the + ``deploy/build_mcp_docs.py`` script reads these values to organize + the page. + """ + + DISCOVER = "Discover what TestGen knows about" + INVESTIGATE = "Investigate quality issues" + BROWSE_PROFILING = "Browse profiling results" + TRIGGER = "Trigger profiling, tests, and test generation" + + def parse_uuid(value: str, label: str = "ID") -> UUID: try: return UUID(value) diff --git a/testgen/mcp/tools/discovery.py b/testgen/mcp/tools/discovery.py index db7dc0a3..05b7ab5b 100644 --- a/testgen/mcp/tools/discovery.py +++ b/testgen/mcp/tools/discovery.py @@ -5,8 +5,7 @@ from testgen.common.models.test_run import TestRun from testgen.common.models.test_suite import TestSuite from testgen.mcp.permissions import get_project_permissions, mcp_permission -from testgen.mcp.tools import DocGroup -from testgen.mcp.tools.common import resolve_table_group, validate_limit, validate_page +from testgen.mcp.tools.common import DocGroup, resolve_table_group, validate_limit, validate_page from testgen.mcp.tools.markdown import MdDoc _DOC_GROUP = DocGroup.DISCOVER diff --git a/testgen/mcp/tools/execution.py b/testgen/mcp/tools/execution.py index 7d571d6d..b7313535 100644 --- a/testgen/mcp/tools/execution.py +++ b/testgen/mcp/tools/execution.py @@ -7,8 +7,7 @@ from testgen.common.models.job_execution import JobExecution from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission -from testgen.mcp.tools import DocGroup -from testgen.mcp.tools.common import parse_uuid, resolve_table_group, resolve_test_suite +from testgen.mcp.tools.common import DocGroup, parse_uuid, resolve_table_group, resolve_test_suite _DOC_GROUP = DocGroup.TRIGGER from testgen.mcp.tools.markdown import MdDoc diff --git a/testgen/mcp/tools/profiling.py b/testgen/mcp/tools/profiling.py index de878768..9d293425 100644 --- a/testgen/mcp/tools/profiling.py +++ b/testgen/mcp/tools/profiling.py @@ -7,8 +7,8 @@ from testgen.common.models.table_group import TableGroup, TableGroupSummary from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission -from testgen.mcp.tools import DocGroup from testgen.mcp.tools.common import ( + DocGroup, format_page_footer, format_page_info, parse_uuid, diff --git a/testgen/mcp/tools/reference.py b/testgen/mcp/tools/reference.py index ad2e3262..98a088fe 100644 --- a/testgen/mcp/tools/reference.py +++ b/testgen/mcp/tools/reference.py @@ -1,6 +1,6 @@ from testgen.common.models import with_database_session from testgen.common.models.test_definition import TestType -from testgen.mcp.tools import DocGroup +from testgen.mcp.tools.common import DocGroup from testgen.mcp.tools.markdown import MdDoc _DOC_GROUP = DocGroup.DISCOVER diff --git a/testgen/mcp/tools/source_data.py b/testgen/mcp/tools/source_data.py index 78449a21..1e75b78c 100644 --- a/testgen/mcp/tools/source_data.py +++ b/testgen/mcp/tools/source_data.py @@ -9,8 +9,7 @@ ) from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission -from testgen.mcp.tools import DocGroup -from testgen.mcp.tools.common import parse_uuid, validate_limit +from testgen.mcp.tools.common import DocGroup, parse_uuid, validate_limit from testgen.mcp.tools.markdown import MdDoc _DOC_GROUP = DocGroup.INVESTIGATE diff --git a/testgen/mcp/tools/test_definitions.py b/testgen/mcp/tools/test_definitions.py index 2f25c102..c969cb23 100644 --- a/testgen/mcp/tools/test_definitions.py +++ b/testgen/mcp/tools/test_definitions.py @@ -3,8 +3,8 @@ from testgen.common.models.test_result import TestResult from testgen.mcp.exceptions import MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission -from testgen.mcp.tools import DocGroup from testgen.mcp.tools.common import ( + DocGroup, format_page_footer, format_page_info, parse_uuid, diff --git a/testgen/mcp/tools/test_results.py b/testgen/mcp/tools/test_results.py index 1a098383..ec708a3a 100644 --- a/testgen/mcp/tools/test_results.py +++ b/testgen/mcp/tools/test_results.py @@ -7,8 +7,8 @@ from testgen.common.models.test_suite import TestSuite from testgen.mcp.exceptions import MCPResourceNotAccessible, MCPUserError from testgen.mcp.permissions import get_project_permissions, mcp_permission -from testgen.mcp.tools import DocGroup from testgen.mcp.tools.common import ( + DocGroup, format_page_footer, format_page_info, parse_result_status, diff --git a/testgen/mcp/tools/test_runs.py b/testgen/mcp/tools/test_runs.py index fcdb4571..68f9ce7b 100644 --- a/testgen/mcp/tools/test_runs.py +++ b/testgen/mcp/tools/test_runs.py @@ -2,8 +2,7 @@ from testgen.common.models.test_run import TestRun from testgen.common.models.test_suite import TestSuite from testgen.mcp.permissions import get_project_permissions, mcp_permission -from testgen.mcp.tools import DocGroup -from testgen.mcp.tools.common import validate_limit +from testgen.mcp.tools.common import DocGroup, validate_limit from testgen.mcp.tools.markdown import MdDoc _DOC_GROUP = DocGroup.INVESTIGATE From a6a1f4b5c7fc2d2d398c020e002f2601e0a859c2 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Mon, 4 May 2026 12:09:29 -0400 Subject: [PATCH 119/123] fix: subprocess not started correctly in standalone on some systems --- testgen/__main__.py | 11 ++++++++++- .../ui/components/frontend/js/pages/connections.js | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/testgen/__main__.py b/testgen/__main__.py index 1708fbd7..4f83c36d 100644 --- a/testgen/__main__.py +++ b/testgen/__main__.py @@ -2,6 +2,7 @@ import importlib import logging import os +import pathlib import platform import secrets import signal @@ -573,6 +574,14 @@ def generate_secret(length: int = 12) -> str: from testgen.ui.scripts.patch_streamlit import patch as patch_streamlit patch_streamlit(dev=True) + # Seed Streamlit's first-run credentials file so `run-app` doesn't block + # on the interactive email prompt. We don't care about the value — just + # that the file exists so Streamlit skips the prompt. + streamlit_creds = pathlib.Path.home() / ".streamlit" / "credentials.toml" + if not streamlit_creds.exists(): + streamlit_creds.parent.mkdir(parents=True, exist_ok=True) + streamlit_creds.write_text('[general]\nemail = ""\n') + # Start embedded PostgreSQL (standalone mode is now active via config) start_standalone_postgres() @@ -917,7 +926,7 @@ def run_app(module): case "all": children = [ - subprocess.Popen([sys.executable, sys.argv[0], "run-app", m], start_new_session=True) + subprocess.Popen([sys.executable, "-m", "testgen", "run-app", m], start_new_session=True) for m in APP_MODULES ] diff --git a/testgen/ui/components/frontend/js/pages/connections.js b/testgen/ui/components/frontend/js/pages/connections.js index b48c7a1e..09cb2950 100644 --- a/testgen/ui/components/frontend/js/pages/connections.js +++ b/testgen/ui/components/frontend/js/pages/connections.js @@ -66,7 +66,7 @@ const Connections = (props) => { label: 'Setup Table Groups', width: 'auto', disabled: !getValue(props.permissions).is_admin, - tooltip: 'You do not have permissions to perform this action. Contact your administrator.', + tooltip: () => !getValue(props.permissions).is_admin ? 'You do not have permissions to perform this action. Contact your administrator.' : '', onclick: () => emit('SetupTableGroupClicked', {}), }), ), From 29b8fe9bd4b57dc7bba37369d87233883e2fe288 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Mon, 4 May 2026 12:48:34 -0400 Subject: [PATCH 120/123] fix(runs): jobs not deleted when parent entity deleted --- testgen/common/models/profiling_run.py | 6 ++++++ testgen/common/models/table_group.py | 6 ++++++ testgen/common/models/test_run.py | 6 ++++++ testgen/common/models/test_suite.py | 6 ++++++ 4 files changed, 24 insertions(+) diff --git a/testgen/common/models/profiling_run.py b/testgen/common/models/profiling_run.py index 18485527..65a24bc6 100644 --- a/testgen/common/models/profiling_run.py +++ b/testgen/common/models/profiling_run.py @@ -289,6 +289,12 @@ def cascade_delete(cls, ids: list[str]) -> None: DELETE FROM profile_results WHERE profile_run_id IN :profiling_run_ids; + + DELETE FROM job_executions + WHERE id IN ( + SELECT job_execution_id FROM profiling_runs + WHERE id IN :profiling_run_ids AND job_execution_id IS NOT NULL + ); """ db_session = get_current_session() db_session.execute(text(query), {"profiling_run_ids": tuple(ids)}) diff --git a/testgen/common/models/table_group.py b/testgen/common/models/table_group.py index bf1b3bdc..117e8983 100644 --- a/testgen/common/models/table_group.py +++ b/testgen/common/models/table_group.py @@ -418,6 +418,12 @@ def cascade_delete(cls, ids: list[str]) -> None: USING table_groups tg WHERE tg.id = pr.table_groups_id AND tg.id IN :table_group_ids; + DELETE FROM job_executions + WHERE id IN ( + SELECT pr.job_execution_id FROM profiling_runs pr + WHERE pr.table_groups_id IN :table_group_ids AND pr.job_execution_id IS NOT NULL + ); + DELETE FROM profiling_runs pr USING table_groups tg WHERE tg.id = pr.table_groups_id AND tg.id IN :table_group_ids; diff --git a/testgen/common/models/test_run.py b/testgen/common/models/test_run.py index 97b13d2f..7653f355 100644 --- a/testgen/common/models/test_run.py +++ b/testgen/common/models/test_run.py @@ -382,6 +382,12 @@ def cascade_delete(cls, ids: list[str]) -> None: query = """ DELETE FROM test_results WHERE test_run_id IN :test_run_ids; + + DELETE FROM job_executions + WHERE id IN ( + SELECT job_execution_id FROM test_runs + WHERE id IN :test_run_ids AND job_execution_id IS NOT NULL + ); """ db_session = get_current_session() db_session.execute(text(query), {"test_run_ids": tuple(ids)}) diff --git a/testgen/common/models/test_suite.py b/testgen/common/models/test_suite.py index 6ef777c5..bd396eb1 100644 --- a/testgen/common/models/test_suite.py +++ b/testgen/common/models/test_suite.py @@ -230,6 +230,12 @@ def is_in_use(cls, ids: list[str]) -> bool: @classmethod def cascade_delete(cls, ids: list[str]) -> None: query = """ + DELETE FROM job_executions + WHERE id IN ( + SELECT job_execution_id FROM test_runs + WHERE test_suite_id IN :test_suite_ids AND job_execution_id IS NOT NULL + ); + DELETE FROM test_runs WHERE test_suite_id IN :test_suite_ids; From 642e6913b17234606a99a965367631748f084881 Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Mon, 4 May 2026 12:48:54 -0400 Subject: [PATCH 121/123] fix: pass termination signal correctly in Windows --- testgen/__main__.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/testgen/__main__.py b/testgen/__main__.py index 4f83c36d..deffaeec 100644 --- a/testgen/__main__.py +++ b/testgen/__main__.py @@ -78,6 +78,16 @@ VERSION_DATA = version_service.get_version() CHILDREN_POLL_INTERVAL = 10 + +def _forward_signal_to_child(child: subprocess.Popen, signum: int) -> None: + # On POSIX, forward the signal verbatim. On Windows, subprocess.send_signal + # rejects everything except SIGTERM / CTRL_C_EVENT / CTRL_BREAK_EVENT, so + # fall back to terminate() — equivalent to TerminateProcess(). + if sys.platform == "win32": + child.terminate() + else: + child.send_signal(signum) + @dataclass class Configuration: verbose: bool = field(default=False) @@ -898,7 +908,7 @@ def init_ui(): ) def term_ui(signum, _): LOG.info(f"Sending termination signal {signum} to Testgen UI") - process.send_signal(signum) + _forward_signal_to_child(process, signum) signal.signal(signal.SIGINT, term_ui) signal.signal(signal.SIGTERM, term_ui) status_code = process.wait() @@ -932,7 +942,7 @@ def run_app(module): def term_children(signum, _): for child in children: - child.send_signal(signum) + _forward_signal_to_child(child, signum) signal.signal(signal.SIGINT, term_children) signal.signal(signal.SIGTERM, term_children) From c320b9eb00ccde5abd3280dd670cfaa962d3a49a Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Mon, 4 May 2026 13:07:30 -0400 Subject: [PATCH 122/123] fix(standalone): quick-start not working on windows --- testgen/commands/run_launch_db_config.py | 9 ++++++--- testgen/commands/run_quick_start.py | 9 ++++++--- testgen/common/standalone_postgres.py | 18 ++++++++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/testgen/commands/run_launch_db_config.py b/testgen/commands/run_launch_db_config.py index ba0a08b4..11b2257f 100644 --- a/testgen/commands/run_launch_db_config.py +++ b/testgen/commands/run_launch_db_config.py @@ -9,7 +9,7 @@ from testgen.common.models import with_database_session from testgen.common.read_file import get_template_files from testgen.common.read_yaml_metadata_records import import_metadata_records_from_yaml -from testgen.common.standalone_postgres import get_home_dir, is_standalone_mode +from testgen.common.standalone_postgres import get_target_host_port, is_standalone_mode LOG = logging.getLogger("testgen") @@ -24,10 +24,13 @@ def _get_params_mapping() -> dict: ui_user_encrypted_password = encrypt_ui_password(settings.PASSWORD) project_host = settings.PROJECT_DATABASE_HOST + project_port = settings.PROJECT_DATABASE_PORT project_user = settings.PROJECT_DATABASE_USER project_password = settings.PROJECT_DATABASE_PASSWORD if is_standalone_mode(): - project_host = str(get_home_dir() / "pgdata") + project_host, server_port = get_target_host_port() + if server_port: + project_port = server_port project_user = "postgres" project_password = "" @@ -43,7 +46,7 @@ def _get_params_mapping() -> dict: "PROJECT_NAME": settings.PROJECT_NAME, "PROJECT_DB": settings.PROJECT_DATABASE_NAME, "PROJECT_USER": project_user, - "PROJECT_PORT": settings.PROJECT_DATABASE_PORT, + "PROJECT_PORT": project_port, "PROJECT_HOST": project_host, "PROJECT_PW_ENCRYPTED": EncryptText(project_password), "PROJECT_HTTP_PATH": "", diff --git a/testgen/commands/run_quick_start.py b/testgen/commands/run_quick_start.py index 5d0aed18..e7a9a84d 100644 --- a/testgen/commands/run_quick_start.py +++ b/testgen/commands/run_quick_start.py @@ -27,7 +27,7 @@ from testgen.common.models.table_group import TableGroup from testgen.common.notifications.base import smtp_configured from testgen.common.read_file import read_template_sql_file -from testgen.common.standalone_postgres import get_home_dir, is_standalone_mode +from testgen.common.standalone_postgres import get_target_host_port, is_standalone_mode LOG = logging.getLogger("testgen") random.seed(42) @@ -144,10 +144,13 @@ def _prepare_connection_to_target_database(params_mapping): def _get_settings_params_mapping() -> dict: host = settings.PROJECT_DATABASE_HOST + port = settings.PROJECT_DATABASE_PORT admin_user = settings.DATABASE_ADMIN_USER admin_password = settings.DATABASE_ADMIN_PASSWORD if is_standalone_mode(): - host = str(get_home_dir() / "pgdata") + host, server_port = get_target_host_port() + if server_port: + port = server_port admin_user = "postgres" admin_password = "" @@ -159,7 +162,7 @@ def _get_settings_params_mapping() -> dict: "PROJECT_SCHEMA": settings.PROJECT_DATABASE_SCHEMA, "PROJECT_KEY": settings.PROJECT_KEY, "PROJECT_DB_HOST": host, - "PROJECT_DB_PORT": settings.PROJECT_DATABASE_PORT, + "PROJECT_DB_PORT": port, "SQL_FLAVOR": settings.PROJECT_SQL_FLAVOR, } diff --git a/testgen/common/standalone_postgres.py b/testgen/common/standalone_postgres.py index af7c7c27..d272eb94 100644 --- a/testgen/common/standalone_postgres.py +++ b/testgen/common/standalone_postgres.py @@ -33,6 +33,24 @@ def is_standalone_mode() -> bool: return settings.getenv(STANDALONE_MODE_ENV_VAR, "no").lower() in ("yes", "true", "1") +def get_target_host_port() -> tuple[str, str | None]: + """Return ``(host, port)`` for connecting to the embedded server's *target* DB. + + On Linux/macOS pgserver listens on a Unix socket; we return the data dir + path as the host so the PostgreSQL flavor's ``host.startswith("/")`` socket + detection kicks in (port is unused for sockets). On Windows pgserver uses + TCP, so we return the live ``hostname:port`` parsed from the pgserver URI — + otherwise the Windows path ends up shoved into the URL parser as a hostname + and trips on the drive-letter colon. + """ + server_uri = get_server_uri() or os.environ.get(STANDALONE_URI_ENV_VAR) + if server_uri: + parsed = urlparse(server_uri) + if parsed.hostname: + return parsed.hostname, str(parsed.port) if parsed.port else None + return str(get_home_dir() / "pgdata"), None + + def start_server(data_dir: Path | None = None) -> None: """Start the embedded PostgreSQL server. From 11d5926d6219c5bc4042e046767690b6895105ed Mon Sep 17 00:00:00 2001 From: Aarthy Adityan Date: Mon, 4 May 2026 18:04:14 -0400 Subject: [PATCH 123/123] release: 5.9.5 -> 5.32.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b10d5b09..3cae9aed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "dataops-testgen" -version = "5.9.5" +version = "5.32.2" description = "DataKitchen's Data Quality DataOps TestGen" authors = [ { "name" = "DataKitchen, Inc.", "email" = "info@datakitchen.io" },