@@ -47,6 +47,23 @@ class TableGroupStats(EntityMinimal):
4747 data_point_ct : int
4848
4949
50+ @dataclass
51+ class TableGroupListItem (EntityMinimal ):
52+ id : UUID
53+ table_groups_name : str
54+ table_group_schema : str
55+ project_code : str
56+ connection_name : str | None
57+ table_count : int
58+ column_count : int | None
59+ row_count : int | None
60+ last_profiled_date : datetime | None
61+ last_tested_date : datetime | None
62+ profiling_score : float | None
63+ testing_score : float | None
64+ quality_score : float | None
65+
66+
5067@dataclass
5168class TableGroupSummary (EntityMinimal ):
5269 id : UUID
@@ -149,6 +166,20 @@ class TableGroup(Entity):
149166 dq_score_testing ,
150167 )
151168
169+ @property
170+ def quality_score (self ) -> float :
171+ """Overall quality score per ``utils.score``: profiling * testing when both
172+ exist; the non-null one when only one ran; ``0.0`` when neither has run.
173+
174+ Mirrors what Project Dashboard, data_catalog, and inventory_service display.
175+ Render through ``utils.friendly_score`` to get the user-facing percentage form
176+ (``"95.0"`` rather than ``"0.95"``). The list-side equivalent is computed in
177+ SQL inside ``_list_with_activity`` — keep both in sync if the formula changes.
178+ """
179+ from testgen .utils import score
180+
181+ return score (self .dq_score_profiling , self .dq_score_testing )
182+
152183 @classmethod
153184 def get_minimal (cls , id_ : str | UUID ) -> TableGroupMinimal | None :
154185 result = cls ._get_columns (id_ , cls ._minimal_columns )
@@ -385,6 +416,103 @@ def select_summary(
385416 total = items [0 ].total_count if items else 0
386417 return items , total
387418
419+ @classmethod
420+ def list_for_project (
421+ cls , project_code : str , * , page : int = 1 , limit : int = 20
422+ ) -> tuple [list [TableGroupListItem ], int ]:
423+ """Config-focused paginated listing for a project, with table count and activity timestamps."""
424+ return cls ._list_with_activity (
425+ scope_sql = "groups.project_code = :project_code" ,
426+ scope_params = {"project_code" : project_code },
427+ page = page ,
428+ limit = limit ,
429+ )
430+
431+ @classmethod
432+ def list_for_connection (
433+ cls , connection_id : int , * , page : int = 1 , limit : int = 20
434+ ) -> tuple [list [TableGroupListItem ], int ]:
435+ """Config-focused paginated listing for a connection, with table count and activity timestamps."""
436+ return cls ._list_with_activity (
437+ scope_sql = "groups.connection_id = :connection_id" ,
438+ scope_params = {"connection_id" : connection_id },
439+ page = page ,
440+ limit = limit ,
441+ )
442+
443+ @classmethod
444+ def _list_with_activity (
445+ cls ,
446+ * ,
447+ scope_sql : str ,
448+ scope_params : dict ,
449+ page : int ,
450+ limit : int ,
451+ ) -> tuple [list [TableGroupListItem ], int ]:
452+ session = get_current_session ()
453+
454+ # Separate COUNT(*) query — keeps `total` correct on out-of-range pages where
455+ # the page rows are empty (a window function over zero rows would return 0).
456+ total_query = f"SELECT COUNT(*) FROM table_groups AS groups WHERE { scope_sql } ;"
457+ total = session .execute (text (total_query ), scope_params ).scalar () or 0
458+ if total == 0 :
459+ return [], 0
460+
461+ params : dict = {** scope_params , "limit" : limit , "offset" : (page - 1 ) * limit }
462+ rows_query = f"""
463+ WITH stats AS (
464+ SELECT table_groups_id,
465+ COUNT(*) AS table_count,
466+ SUM(column_ct) AS column_count,
467+ SUM(COALESCE(record_ct, approx_record_ct)) AS row_count
468+ FROM data_table_chars
469+ GROUP BY table_groups_id
470+ ),
471+ latest_profile AS (
472+ SELECT pr.table_groups_id, MAX(je.started_at) AS started_at
473+ FROM profiling_runs pr
474+ LEFT JOIN job_executions je ON je.id = pr.job_execution_id
475+ GROUP BY pr.table_groups_id
476+ ),
477+ latest_test AS (
478+ SELECT ts.table_groups_id, MAX(tr.test_starttime) AS test_starttime
479+ FROM test_runs tr
480+ JOIN test_suites ts ON ts.id = tr.test_suite_id
481+ WHERE ts.is_monitor IS NOT TRUE
482+ GROUP BY ts.table_groups_id
483+ )
484+ SELECT
485+ groups.id,
486+ groups.table_groups_name,
487+ groups.table_group_schema,
488+ groups.project_code,
489+ connections.connection_name,
490+ COALESCE(stats.table_count, 0) AS table_count,
491+ stats.column_count,
492+ stats.row_count,
493+ latest_profile.started_at AS last_profiled_date,
494+ latest_test.test_starttime AS last_tested_date,
495+ groups.dq_score_profiling AS profiling_score,
496+ groups.dq_score_testing AS testing_score,
497+ -- Mirrors utils.score: product when both exist, else the non-null one, else NULL.
498+ CASE
499+ WHEN groups.dq_score_profiling IS NOT NULL AND groups.dq_score_testing IS NOT NULL
500+ THEN groups.dq_score_profiling * groups.dq_score_testing
501+ ELSE COALESCE(groups.dq_score_profiling, groups.dq_score_testing)
502+ END AS quality_score
503+ FROM table_groups AS groups
504+ LEFT JOIN connections ON connections.connection_id = groups.connection_id
505+ LEFT JOIN stats ON stats.table_groups_id = groups.id
506+ LEFT JOIN latest_profile ON latest_profile.table_groups_id = groups.id
507+ LEFT JOIN latest_test ON latest_test.table_groups_id = groups.id
508+ WHERE { scope_sql }
509+ ORDER BY LOWER(groups.table_groups_name)
510+ LIMIT :limit OFFSET :offset;
511+ """
512+ rows = session .execute (text (rows_query ), params ).mappings ().all ()
513+ items = [TableGroupListItem (** row ) for row in rows ]
514+ return items , total
515+
388516 @classmethod
389517 def is_in_use (cls , ids : list [str ]) -> bool :
390518 test_suites = TestSuite .select_minimal_where (TestSuite .table_groups_id .in_ (ids ))
0 commit comments