Skip to content

Commit 0fb9eef

Browse files
author
ci bot
committed
Merge branch 'feat/TG-1134-external-url-custom-metadata' into 'enterprise'
TG-1134: External URL and custom metadata on test definitions See merge request dkinternal/testgen/dataops-testgen!576
2 parents e208c6a + 48fc2c3 commit 0fb9eef

20 files changed

Lines changed: 630 additions & 184 deletions

testgen/common/models/test_definition.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from collections.abc import Iterable
23
from dataclasses import dataclass
34
from datetime import UTC, datetime
@@ -135,6 +136,28 @@ def _is_blank(value: object) -> bool:
135136
return value is None or value == ""
136137

137138

139+
CUSTOM_METADATA_MAX_KEYS = 50
140+
CUSTOM_METADATA_MAX_BYTES = 10_240
141+
142+
143+
def validate_custom_metadata(value: object) -> str | None:
144+
"""Return an error message if ``value`` is not a valid ``custom_metadata`` payload, else ``None``.
145+
146+
``custom_metadata`` must be a JSON object (key-value pairs), bounded in key count and serialized
147+
size. Shared by every write path — ``TestDefinition.validate`` (UI/CLI/MCP) and the
148+
``TestDefinitionExport`` import schema — so the rule has a single definition.
149+
"""
150+
if value is None:
151+
return None
152+
if not isinstance(value, dict):
153+
return "must be a JSON object of key-value pairs"
154+
if len(value) > CUSTOM_METADATA_MAX_KEYS:
155+
return f"must have at most {CUSTOM_METADATA_MAX_KEYS} keys"
156+
if len(json.dumps(value)) > CUSTOM_METADATA_MAX_BYTES:
157+
return f"must be at most {CUSTOM_METADATA_MAX_BYTES} bytes when serialized as JSON"
158+
return None
159+
160+
138161
class ParamFieldsMixin:
139162
"""Parsed access to default_parm_columns/prompts/help metadata.
140163
@@ -224,6 +247,8 @@ class TestDefinitionSummary(TestTypeSummary):
224247
prediction: dict[str, dict[str, float]] | None
225248
flagged: bool
226249
impact_dimension: str | None
250+
external_url: str | None
251+
custom_metadata: dict | None
227252

228253
@property
229254
def display_name(self) -> str:
@@ -397,6 +422,8 @@ class TestDefinition(Entity):
397422
flagged: bool = Column(Boolean, default=False, nullable=False)
398423
external_id: UUID | None = Column(postgresql.UUID(as_uuid=True))
399424
impact_dimension: str | None = Column(String, nullable=True)
425+
external_url: str | None = Column(NullIfEmptyString)
426+
custom_metadata: dict | None = Column(postgresql.JSONB)
400427

401428
_default_order_by = (
402429
asc(func.lower(schema_name)),
@@ -555,14 +582,16 @@ def select_page(
555582
# Fields editable on every test type regardless of param_columns.
556583
EDITABLE_BASE_FIELDS: ClassVar[frozenset[str]] = frozenset({
557584
"test_active", "severity", "lock_refresh", "flagged", "test_description",
585+
"external_url", "custom_metadata",
558586
})
559587

560588
def editable_fields(self, test_type: TestType) -> set[str]:
561589
"""Fields a caller may set or change on this test definition under the given test type."""
562590
fields = self.EDITABLE_BASE_FIELDS | test_type.param_columns
563-
# column_name is meaningful for column-scoped tests (the column under test) and
564-
# custom-scoped tests (a "Test Focus" label). Other scopes don't use it.
565-
if test_type.test_scope in ("column", "custom"):
591+
# column_name is meaningful for column-scoped tests (the column under test),
592+
# custom-scoped tests (a "Test Focus" label), and referential tests (the aggregate
593+
# expression or categorical column list under test). Table-scoped tests don't use it.
594+
if test_type.test_scope in ("column", "custom", "referential"):
566595
fields = fields | {"column_name"}
567596
# impact_dimension is overridable only for user-defined-semantic scopes
568597
# (custom-scope = user-authored SQL; referential-scope = comparison-based tests).
@@ -588,9 +617,10 @@ def validate(self, test_type: TestType) -> None:
588617
f"(got `{self.severity}`)"
589618
)
590619

591-
# column_name applies to column-scoped tests (the column under test) and
592-
# custom-scoped tests (a "Test Focus" label). Other scopes don't use it.
593-
if test_type.test_scope not in ("column", "custom") and not _is_blank(self.column_name):
620+
# column_name applies to column-scoped tests (the column under test),
621+
# custom-scoped tests (a "Test Focus" label), and referential tests (the aggregate
622+
# expression or categorical column list under test). Table-scoped tests don't use it.
623+
if test_type.test_scope not in ("column", "custom", "referential") and not _is_blank(self.column_name):
594624
errors["column_name"] = (
595625
f"test type `{test_type.test_type}` has scope `{test_type.test_scope}`; "
596626
f"column_name does not apply to this scope"
@@ -601,6 +631,10 @@ def validate(self, test_type: TestType) -> None:
601631
f"test type `{test_type.test_type}` does not accept a custom query"
602632
)
603633

634+
metadata_error = validate_custom_metadata(self.custom_metadata)
635+
if metadata_error:
636+
errors["custom_metadata"] = metadata_error
637+
604638
for required in _required_fields_for(test_type):
605639
if _is_blank(getattr(self, required, None)):
606640
errors[required] = f"required for test type `{test_type.test_type}`"

testgen/common/test_definition_export_import_service.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from testgen.common.models import get_current_session
1515
from testgen.common.models.data_table import DataTable
1616
from testgen.common.models.table_group import TableGroup
17-
from testgen.common.models.test_definition import TestDefinition, TestType
17+
from testgen.common.models.test_definition import TestDefinition, TestType, validate_custom_metadata
1818
from testgen.common.models.test_suite import TestSuite
1919

2020
EXPORT_FORMAT_VERSION = 1
@@ -129,11 +129,26 @@ class TestDefinitionExport(BaseModel):
129129
history_calculation_upper: str | None = None
130130
history_lookback: int = 0
131131

132+
# URL / metadata
133+
external_url: str | None = None
134+
custom_metadata: dict[str, Any] | None = None
135+
132136
@field_validator("skip_errors", "window_days", "history_lookback", mode="before")
133137
@classmethod
134138
def _coerce_none_to_zero(cls, v: int | None) -> int:
135139
return v if v is not None else 0
136140

141+
@field_validator("custom_metadata")
142+
@classmethod
143+
def _check_custom_metadata(cls, v: dict[str, Any] | None) -> dict[str, Any] | None:
144+
# The import path does not run TestDefinition.validate(), so this is the only enforcement
145+
# of the custom_metadata shape/size bound on import. Shares validate_custom_metadata with
146+
# the model so the rule has a single definition.
147+
error = validate_custom_metadata(v)
148+
if error:
149+
raise ValueError(f"custom_metadata {error}")
150+
return v
151+
137152

138153
class ExportSource(BaseModel):
139154
project_code: str

testgen/mcp/tools/test_definitions.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from datetime import UTC, datetime
23
from enum import StrEnum
34
from typing import NoReturn
@@ -242,6 +243,14 @@ def _append_td_summary(doc: MdDoc, td: TestDefinitionSummary) -> None:
242243
elif td.last_auto_gen_date:
243244
doc.field("Last Updated", f"{td.last_auto_gen_date} (auto-generated)")
244245

246+
# URL and metadata
247+
if td.external_url:
248+
doc.field("External URL", td.external_url)
249+
if td.custom_metadata:
250+
doc.heading(2, "Custom Metadata")
251+
for key, value in td.custom_metadata.items():
252+
doc.field(MdDoc.escape(key), value)
253+
245254
# Parameters (editable fields from test type metadata)
246255
_append_parameters_section(doc, td)
247256

@@ -506,6 +515,25 @@ def _raise_validation_errors(err: InvalidTestDefinitionFields, header: str) -> N
506515
raise MCPUserError(f"{header}\n\n{bullets}") from err
507516

508517

518+
def _coerce_custom_metadata(fields: dict) -> None:
519+
"""Accept ``custom_metadata`` as a JSON string for convenience, parsing it to an object in place.
520+
521+
A blank string becomes ``None`` (clears the field). Non-object JSON is left for model
522+
validation to reject with a consistent message.
523+
"""
524+
value = fields.get("custom_metadata")
525+
if not isinstance(value, str):
526+
return
527+
stripped = value.strip()
528+
if not stripped:
529+
fields["custom_metadata"] = None
530+
return
531+
try:
532+
fields["custom_metadata"] = json.loads(stripped)
533+
except json.JSONDecodeError as e:
534+
raise MCPUserError("`custom_metadata` must be a JSON object of key-value pairs.") from e
535+
536+
509537
@with_database_session
510538
@mcp_permission("edit")
511539
def create_test(
@@ -547,6 +575,7 @@ def create_test(
547575
)
548576

549577
fields = fields or {}
578+
_coerce_custom_metadata(fields)
550579
accepted = td.editable_fields(tt)
551580
rejected = sorted(set(fields) - accepted)
552581
if rejected:
@@ -591,6 +620,7 @@ def update_test(test_definition_id: str, fields: dict) -> str:
591620
if not fields:
592621
raise MCPUserError("No fields supplied to update.")
593622

623+
_coerce_custom_metadata(fields)
594624
accepted = td.editable_fields(tt)
595625
rejected = sorted(set(fields) - accepted)
596626
if rejected:

testgen/mcp/tools/test_results.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from testgen.common.enums import JobStatus
77
from testgen.common.models import get_current_session, with_database_session
88
from testgen.common.models.job_execution import JobExecution
9-
from testgen.common.models.test_definition import TestType
9+
from testgen.common.models.test_definition import TestDefinition, TestType
1010
from testgen.common.models.test_result import BucketInterval, TestResult, TestResultStatus
1111
from testgen.common.models.test_run import TestRun, TestRunSummary
1212
from testgen.common.models.test_suite import TestSuite
@@ -130,6 +130,12 @@ def list_test_results(
130130

131131
type_names = {tt.test_type: tt.test_name_short for tt in TestType.select_where(TestType.active == "Y")}
132132

133+
td_ids = {r.test_definition_id for r in results if r.test_definition_id}
134+
source_by_td = {
135+
td.id: (td.external_url, td.custom_metadata)
136+
for td in (TestDefinition.select_where(TestDefinition.id.in_(td_ids)) if td_ids else [])
137+
}
138+
133139
doc = MdDoc()
134140
doc.heading(1, f"Test Results for run `{run_id_label}`")
135141
if resolved_via_suite:
@@ -153,6 +159,13 @@ def list_test_results(
153159
doc.field("Threshold", r.threshold_value)
154160
if r.message:
155161
doc.field("Message", r.message)
162+
external_url, custom_metadata = source_by_td.get(r.test_definition_id, (None, None))
163+
if external_url:
164+
doc.field("External URL", external_url)
165+
if custom_metadata:
166+
doc.heading(3, "Custom Metadata")
167+
for key, value in custom_metadata.items():
168+
doc.field(MdDoc.escape(key), value)
156169

157170
return doc.render()
158171

testgen/template/dbsetup/030_initialize_new_schema_structure.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,8 @@ CREATE TABLE test_definitions (
241241
flagged BOOLEAN DEFAULT FALSE NOT NULL,
242242
external_id UUID,
243243
impact_dimension VARCHAR(20),
244+
external_url VARCHAR,
245+
custom_metadata JSONB,
244246
CONSTRAINT test_definitions_test_suites_test_suite_id_fk
245247
FOREIGN KEY (test_suite_id) REFERENCES test_suites
246248
);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
SET SEARCH_PATH TO {SCHEMA_NAME};
2+
3+
ALTER TABLE test_definitions
4+
ADD COLUMN IF NOT EXISTS external_url VARCHAR,
5+
ADD COLUMN IF NOT EXISTS custom_metadata JSONB;

testgen/ui/components/frontend/js/pages/test_definition_summary.js

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,25 @@
1717
* @property {string} export_to_observability
1818
* @property {string?} last_manual_update
1919
* @property {string?} usage_notes
20+
* @property {string?} external_url
21+
* @property {object?} custom_metadata
2022
* @property {Array<TestDefinitionAttribute>} attributes
21-
*
23+
*
2224
* @typedef Properties
2325
* @type {object}
2426
* @property {TestDefinition} test_definition
2527
*/
2628
import van from '/app/static/js/van.min.js';
27-
import { createEmitter, getValue, isEqual, loadStylesheet } from '/app/static/js/utils.js';
29+
import { createEmitter, getValue, isEqual, isHttpUrl, loadStylesheet } from '/app/static/js/utils.js';
2830
import { Alert } from '/app/static/js/components/alert.js';
2931
import { Attribute } from '/app/static/js/components/attribute.js';
32+
import { Link } from '/app/static/js/components/link.js';
3033

3134
const { div, strong } = van.tags;
3235

36+
const metadataDisplayValue = (value) =>
37+
(value !== null && typeof value === 'object') ? JSON.stringify(value) : value;
38+
3339
/**
3440
* @param {Properties} props
3541
* @returns
@@ -112,6 +118,37 @@ const TestDefinitionSummary = (props) => {
112118
),
113119
),
114120
),
121+
testDefinition.external_url
122+
? Attribute({
123+
label: 'External URL',
124+
value: isHttpUrl(testDefinition.external_url)
125+
? Link({
126+
href: testDefinition.external_url.trim(),
127+
label: testDefinition.external_url.trim(),
128+
open_new: true,
129+
right_icon: 'open_in_new',
130+
right_icon_size: 16,
131+
})
132+
: testDefinition.external_url,
133+
class: 'mt-4 external-url-attribute',
134+
})
135+
: '',
136+
testDefinition.custom_metadata && Object.keys(testDefinition.custom_metadata).length
137+
? div(
138+
{ class: 'flex-column fx-gap-3 mt-4' },
139+
strong({}, 'Custom Metadata'),
140+
div(
141+
{ class: 'flex-row fx-flex-wrap fx-gap-4 test-definition-attributes' },
142+
Object.entries(testDefinition.custom_metadata).map(([key, value]) =>
143+
Attribute({
144+
label: key,
145+
value: metadataDisplayValue(value),
146+
class: 'fx-flex',
147+
})
148+
),
149+
),
150+
)
151+
: '',
115152
testDefinition.usage_notes
116153
? Alert(
117154
{ type: 'info', class: 'mt-4' },
@@ -132,6 +169,20 @@ stylesheet.replace(`
132169
.test-definition-attributes > div .attribute-value {
133170
font-size: 16px;
134171
}
172+
.external-url-attribute .attribute-value {
173+
overflow-wrap: anywhere;
174+
}
175+
.external-url-attribute .tg-link {
176+
max-width: 100%;
177+
}
178+
.external-url-attribute .tg-link--wrapper {
179+
flex-wrap: wrap;
180+
}
181+
.external-url-attribute .tg-link--text {
182+
overflow-wrap: anywhere;
183+
word-break: break-all;
184+
min-width: 0;
185+
}
135186
`);
136187

137188
export { TestDefinitionSummary };

0 commit comments

Comments
 (0)