Skip to content

Commit 0b42fb0

Browse files
authored
Update staging (#245)
* feat: readable labels (#232) * feat: readable labels Readable labels. Splitting dq-readable-checks branch into two, one for labels, another one for future modals. * fix: add search for assertionLabel Upgrading the dq search with the assertionLabel to work with old checks names (internal) and new description * fix: UI arrow position fix Checks table overview arrow position fix * feat: import values from nocodb Import dq label values from nocoDB * fix: TECH-9140 file validation (#239) * fix: TECH-9140 file validation TECH-9140 file type validation * fix: add xls format add xls format * fix: only uploaders filter list (#244) Created a endpoint for retrieving the user list for the filter modal to only show the users with uploads
1 parent d8506ed commit 0b42fb0

9 files changed

Lines changed: 289 additions & 70 deletions

File tree

api/data_ingestion/routers/upload.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,20 @@
4242
from data_ingestion.permissions.permissions import IsPrivileged
4343
from data_ingestion.schemas.core import PagedResponseSchema
4444
from data_ingestion.schemas.upload import (
45+
DataQualityCheckLabel,
4546
FileUpload as FileUploadSchema,
4647
FileUploadRequest,
4748
UnstructuredFileUploadRequest,
4849
ValidateFuzzyRequest,
4950
)
5051
from data_ingestion.utils.data_quality import get_metadata_path
5152
from data_ingestion.utils.fuzzy_matching import run_fuzzy_matching
53+
from data_ingestion.utils.nocodb import (
54+
get_nocodb_table_id_from_name,
55+
get_nocodb_table_rows,
56+
)
57+
58+
DQ_CHECK_LABELS_TABLE_NAME = "SchoolGeolocationMasterDQChecksTest"
5259

5360
router = APIRouter(
5461
prefix="/api/upload",
@@ -57,6 +64,81 @@
5764
)
5865

5966

67+
def _parse_bool(value) -> bool:
68+
if isinstance(value, bool):
69+
return value
70+
if value is None:
71+
return True
72+
return str(value).strip().lower() in {"true", "1", "yes", "y"}
73+
74+
75+
def _parse_sort_order(value) -> int | None:
76+
if value in (None, ""):
77+
return None
78+
try:
79+
return int(value)
80+
except (TypeError, ValueError):
81+
return None
82+
83+
84+
def _parse_dq_table_column_name(value: str) -> tuple[str, str]:
85+
key = value.removeprefix("dq_")
86+
assertion, _, column_key = key.partition("-")
87+
return assertion, column_key
88+
89+
90+
def _normalize_dq_check_label(row: dict) -> DataQualityCheckLabel | None:
91+
dq_table_column_name = row.get("DQ Table Column Name") or ""
92+
parsed_assertion, parsed_column_key = _parse_dq_table_column_name(
93+
dq_table_column_name
94+
)
95+
assertion = row.get("Assertion") or parsed_assertion
96+
97+
if not assertion:
98+
return None
99+
100+
active = _parse_bool(row.get("Active"))
101+
if not active:
102+
return None
103+
104+
ui_error_description = (
105+
row.get("UI Error Description")
106+
or row.get("Human Readable Name")
107+
or assertion.replace("_", " ")
108+
)
109+
110+
return DataQualityCheckLabel(
111+
assertion=assertion,
112+
column_key=row.get("Column Key") or parsed_column_key,
113+
ui_error_description=ui_error_description,
114+
dq_table_column_name=dq_table_column_name or None,
115+
dq_check_category=row.get("DQ Check Category"),
116+
column_checked=row.get("Column Checked"),
117+
human_readable_name=row.get("Human Readable Name"),
118+
active=active,
119+
sort_order=_parse_sort_order(row.get("Sort Order")),
120+
)
121+
122+
123+
@router.get("/data_quality_check_labels", response_model=list[DataQualityCheckLabel])
124+
async def list_data_quality_check_labels():
125+
table_id = get_nocodb_table_id_from_name(DQ_CHECK_LABELS_TABLE_NAME)
126+
rows = get_nocodb_table_rows(table_id)
127+
labels = [
128+
label for row in rows if (label := _normalize_dq_check_label(row)) is not None
129+
]
130+
131+
return sorted(
132+
labels,
133+
key=lambda label: (
134+
label.sort_order is None,
135+
label.sort_order or 0,
136+
label.assertion,
137+
label.column_key,
138+
),
139+
)
140+
141+
60142
@router.get("/basic_check/{dataset}")
61143
async def list_basic_checks(
62144
dataset: str = "geolocation",

api/data_ingestion/routers/users.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from data_ingestion.internal.auth import azure_scheme
1212
from data_ingestion.internal.groups import GroupsApi
1313
from data_ingestion.internal.users import UsersApi
14-
from data_ingestion.models import Role, User
14+
from data_ingestion.models import FileUpload, Role, User
1515
from data_ingestion.permissions.permissions import IsPrivileged
1616
from data_ingestion.schemas.group import ModifyUserAccessRequest
1717
from data_ingestion.schemas.invitation import (
@@ -48,6 +48,20 @@ async def list_users(db: AsyncSession = Depends(get_db)):
4848
)
4949

5050

51+
@router.get(
52+
"/uploaders",
53+
response_model=list[DatabaseUser],
54+
dependencies=[Security(IsPrivileged())],
55+
)
56+
async def list_uploaders(db: AsyncSession = Depends(get_db)):
57+
return await db.scalars(
58+
select(User)
59+
.join(FileUpload, FileUpload.uploader_id == User.id)
60+
.distinct()
61+
.order_by(User.given_name, User.surname, User.email)
62+
)
63+
64+
5165
@router.post("", response_model=DatabaseUser, dependencies=[Security(IsPrivileged())])
5266
async def create_user(
5367
body: DatabaseUserCreateRequest,

api/data_ingestion/schemas/upload.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,15 @@ class UnstructuredFileUploadRequest:
6767
class ValidateFuzzyRequest:
6868
file: UploadFile = Form(...)
6969
column_to_schema_mapping: str = Form(...)
70+
71+
72+
class DataQualityCheckLabel(BaseModel):
73+
assertion: str
74+
column_key: str = ""
75+
ui_error_description: str
76+
dq_table_column_name: str | None = None
77+
dq_check_category: str | None = None
78+
column_checked: str | None = None
79+
human_readable_name: str | None = None
80+
active: bool = True
81+
sort_order: int | None = None

ui/src/api/routers/uploads.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { PagedResponse } from "@/types/api.ts";
44
import {
55
BasicChecks,
66
DataQualityCheck,
7+
DataQualityCheckLabel,
78
FuzzyValidationParams,
89
FuzzyValidationResponse,
910
UploadParams,
@@ -19,6 +20,11 @@ export default function routes(axi: AxiosInstance) {
1920
): Promise<AxiosResponse<DataQualityCheck>> => {
2021
return axi.get(`upload/data_quality_check/${upload_id}`);
2122
},
23+
list_data_quality_check_labels: (): Promise<
24+
AxiosResponse<DataQualityCheckLabel[]>
25+
> => {
26+
return axi.get("upload/data_quality_check_labels");
27+
},
2228
list_uploads: (params?: {
2329
page?: number;
2430
page_size?: number;

ui/src/api/routers/users.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export default function routes(axi: AxiosInstance) {
1111
list: (): Promise<AxiosResponse<DatabaseUserWithRoles[]>> => {
1212
return axi.get("/users");
1313
},
14+
listUploaders: (): Promise<AxiosResponse<DatabaseUser[]>> => {
15+
return axi.get("/users/uploaders");
16+
},
1417
get: (id: string): Promise<AxiosResponse<DatabaseUserWithRoles>> => {
1518
return axi.get(`/users/${id}`);
1619
},

ui/src/components/check-file-uploads/ColumnChecks.tsx

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,35 @@ import {
1313
TableHeader,
1414
TableRow,
1515
} from "@carbon/react";
16+
import { useQuery } from "@tanstack/react-query";
1617

18+
import { api } from "@/api";
1719
import { cn } from "@/lib/utils.ts";
18-
import { Check } from "@/types/upload";
20+
import { Check, DataQualityCheckLabel } from "@/types/upload";
1921
import { commaNumber } from "@/utils/number.ts";
2022

23+
const getLabelKey = (assertion: string, columnKey = "") =>
24+
`${assertion}-${columnKey}`;
25+
26+
const buildAssertionLabelMap = (labels: DataQualityCheckLabel[]) =>
27+
labels.reduce<Record<string, string>>((acc, label) => {
28+
acc[getLabelKey(label.assertion, label.column_key)] =
29+
label.ui_error_description;
30+
return acc;
31+
}, {});
32+
33+
export const formatAssertion = (
34+
assertion: string,
35+
columnKey: string,
36+
assertionLabels: Record<string, string>,
37+
) => {
38+
return (
39+
assertionLabels[getLabelKey(assertion, columnKey)] ??
40+
assertionLabels[getLabelKey(assertion)] ??
41+
assertion.replace(/_/g, " ")
42+
);
43+
};
44+
2145
interface ExtendedDataTableHeader extends DataTableHeader {
2246
sortable?: boolean;
2347
}
@@ -33,6 +57,16 @@ const DataQualityChecks = ({ data }: DataQualityChecksProps) => {
3357
direction: "ascending" | "descending";
3458
}>({ key: "", direction: "ascending" });
3559

60+
const { data: dataQualityCheckLabelsQuery } = useQuery({
61+
queryKey: ["data_quality_check_labels"],
62+
queryFn: api.uploads.list_data_quality_check_labels,
63+
});
64+
65+
const assertionLabels = useMemo(
66+
() => buildAssertionLabelMap(dataQualityCheckLabelsQuery?.data ?? []),
67+
[dataQualityCheckLabelsQuery],
68+
);
69+
3670
const handleUpSort = (key: string) => {
3771
setSortConfig({ key, direction: "ascending" });
3872
};
@@ -44,9 +78,19 @@ const DataQualityChecks = ({ data }: DataQualityChecksProps) => {
4478
const filteredAndSortedRows = useMemo(() => {
4579
const result = data.filter(check => {
4680
const searchString = searchTerm.toLowerCase();
81+
const columnKey = check.column === "" ? "NO_COLUMN" : check.column;
82+
const columnDisplay =
83+
columnKey === "NO_COLUMN" ? "Entire row" : columnKey;
84+
const assertionLabel = formatAssertion(
85+
check.assertion,
86+
check.column,
87+
assertionLabels,
88+
);
4789
return (
4890
check.column.toLowerCase().includes(searchString) ||
49-
check.assertion.toLowerCase().includes(searchString)
91+
check.assertion.toLowerCase().includes(searchString) ||
92+
columnDisplay.toLowerCase().includes(searchString) ||
93+
assertionLabel.toLowerCase().includes(searchString)
5094
);
5195
});
5296

@@ -78,13 +122,13 @@ const DataQualityChecks = ({ data }: DataQualityChecksProps) => {
78122
}
79123

80124
return result;
81-
}, [data, searchTerm, sortConfig]);
125+
}, [assertionLabels, data, searchTerm, sortConfig]);
82126

83127
const renderSortControls = (key: string) => {
84128
const isActive = sortConfig.key === key;
85129

86130
return (
87-
<div className="absolute right-1 top-1/2 flex -translate-y-1/2 flex-col">
131+
<div className="ml-2 flex shrink-0 flex-col">
88132
<ChevronUp
89133
className={cn(
90134
"cursor-pointer transition-colors duration-150",
@@ -112,18 +156,19 @@ const DataQualityChecks = ({ data }: DataQualityChecksProps) => {
112156
const rows = filteredAndSortedRows.map(check => {
113157
const {
114158
assertion,
115-
column = "NO_COLUMN",
159+
column = "",
116160
count_failed,
117161
count_passed,
118162
percent_passed,
119163
} = check;
120164

121165
const columnKey = column === "" ? "NO_COLUMN" : column;
166+
const columnDisplay = columnKey === "NO_COLUMN" ? "Entire row" : columnKey;
122167

123168
return {
124169
id: `${assertion}-${columnKey}`,
125-
column: columnKey,
126-
assertion,
170+
column: columnDisplay,
171+
assertion: formatAssertion(assertion, column, assertionLabels),
127172
result_with_errors: (
128173
<div className="flex items-center">
129174
{count_failed > 0 ? (
@@ -152,12 +197,12 @@ const DataQualityChecks = ({ data }: DataQualityChecksProps) => {
152197
const dqResultHeaders: ExtendedDataTableHeader[] = [
153198
{
154199
key: "column",
155-
header: "Column(s)",
200+
header: "Column",
156201
sortable: false,
157202
},
158203
{
159204
key: "assertion",
160-
header: "Validation Rule",
205+
header: "Check Description",
161206
sortable: false,
162207
},
163208
{
@@ -185,7 +230,7 @@ const DataQualityChecks = ({ data }: DataQualityChecksProps) => {
185230
<div className="rounded-lg border bg-white shadow-sm">
186231
<div className="border-b px-4 py-3">
187232
<h3 className="text-lg font-semibold text-gray-800">
188-
Overview of all fields sorted by type
233+
Overview of all checks sorted by type
189234
</h3>
190235
</div>
191236

@@ -199,9 +244,9 @@ const DataQualityChecks = ({ data }: DataQualityChecksProps) => {
199244
<TableHeader
200245
key={header.key}
201246
isSortable={false}
202-
className={cn("relative bg-blue-50 text-gray-700")}
247+
className={cn("bg-blue-50 text-gray-700")}
203248
>
204-
<div className="flex w-full items-center justify-between">
249+
<div className="flex w-full items-center justify-between gap-2">
205250
<span>{header.header}</span>
206251
{(header as ExtendedDataTableHeader).sortable &&
207252
renderSortControls(header.key)}

ui/src/components/upload/FilterModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ function FilterModal({
5959
}, [open]);
6060

6161
const { data: usersData } = useQuery({
62-
queryKey: ["users"],
63-
queryFn: api.users.list,
62+
queryKey: ["uploaders"],
63+
queryFn: api.users.listUploaders,
6464
enabled: isPrivileged,
6565
});
6666

0 commit comments

Comments
 (0)