Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions backend/api/query/adapters/patient.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ def _ensure_position_join(query: Select[Any], ctx: dict[str, Any]) -> tuple[Sele
ln = aliased(models.LocationNode)
ctx["position_node"] = ln
query = query.outerjoin(ln, models.Patient.position_id == ln.id)
ctx["needs_distinct"] = True
return query, ln


Expand Down Expand Up @@ -172,6 +171,15 @@ def apply_patient_filter_clause(
if c is not None:
query = query.where(c)
return query
if key in LOCATION_SORT_KEY_KINDS:
query, lineage_nodes = _ensure_position_lineage_joins(query, ctx)
expr = _location_title_for_kind(
lineage_nodes, LOCATION_SORT_KEY_KINDS[key]
)
c = apply_ops_to_column(expr, op, val)
if c is not None:
query = query.where(c)
return query
if key == "position":
if op in (QueryOperator.EQ, QueryOperator.IN) and val:
has_uuid = (val.uuid_value is not None and val.uuid_value != "") or (
Expand Down Expand Up @@ -248,7 +256,6 @@ def apply_patient_sorts(
query, _pa, col = join_property_value(
query, models.Patient, prop_id, ft, "patient"
)
ctx["needs_distinct"] = True
if desc_order:
order_parts.append(col.desc().nulls_last())
else:
Expand Down Expand Up @@ -303,12 +310,12 @@ def apply_patient_sorts(
elif key in LOCATION_SORT_KEY_KINDS:
query, lineage_nodes = _ensure_position_lineage_joins(query, ctx)
t = _location_title_for_kind(lineage_nodes, LOCATION_SORT_KEY_KINDS[key])
order_parts.append(t.desc() if desc_order else t.asc())
order_parts.append(
t.desc().nulls_last() if desc_order else t.asc().nulls_first()
)

if not order_parts:
return query.order_by(models.Patient.id.asc())
if ctx.get("needs_distinct"):
return query.order_by(models.Patient.id.asc(), *order_parts)
return query.order_by(*order_parts, models.Patient.id.asc())


Expand Down Expand Up @@ -497,7 +504,7 @@ def build_patient_queryable_fields_static() -> list[QueryableField]:
label=label,
kind=QueryableFieldKind.SCALAR,
value_type=QueryableValueType.STRING,
allowed_operators=[],
allowed_operators=str_ops,
sortable=True,
sort_directions=sort_directions_for(True),
searchable=False,
Expand Down
4 changes: 0 additions & 4 deletions backend/api/query/adapters/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ def _ensure_team_join(query: Select[Any], ctx: dict[str, Any]) -> tuple[Select[A
ln = aliased(models.LocationNode)
ctx["assignee_team"] = ln
query = query.outerjoin(ln, models.Task.assignee_team_id == ln.id)
ctx["needs_distinct"] = True
return query, ln


Expand Down Expand Up @@ -322,7 +321,6 @@ def apply_task_sorts(
query, _pa, col = join_property_value(
query, models.Task, prop_id, ft, "task"
)
ctx["needs_distinct"] = True
if desc_order:
order_parts.append(col.desc().nulls_last())
else:
Expand Down Expand Up @@ -392,8 +390,6 @@ def apply_task_sorts(

if not order_parts:
return query.order_by(models.Task.id.asc())
if ctx.get("needs_distinct"):
return query.order_by(models.Task.id.asc(), *order_parts)
return query.order_by(*order_parts, models.Task.id.asc())


Expand Down
14 changes: 14 additions & 0 deletions backend/api/query/dedupe_select.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from typing import Any

from sqlalchemy import Select, select


def dedupe_orm_select_by_root_id(stmt: Select[Any], root_model: type) -> Select[Any]:
opts = getattr(stmt, "_with_options", None) or ()
stmt_flat = stmt.order_by(None).limit(None).offset(None)
pk = root_model.id
ids_sq = stmt_flat.with_only_columns(pk).distinct().scalar_subquery()
out = select(root_model).where(root_model.id.in_(ids_sq))
for opt in opts:
out = out.options(opt)
return out
8 changes: 5 additions & 3 deletions backend/api/query/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from api.context import Info
from api.inputs import PaginationInput
from api.query.inputs import QueryFilterClauseInput, QuerySearchInput, QuerySortClauseInput
from api.query.dedupe_select import dedupe_orm_select_by_root_id
from api.query.property_sql import load_property_field_types
from api.query.registry import get_entity_handler

Expand Down Expand Up @@ -65,12 +66,13 @@ async def apply_unified_query(
if text:
stmt = handler["apply_search"](stmt, search, ctx)

if ctx.get("needs_distinct"):
stmt = dedupe_orm_select_by_root_id(stmt, handler["root_model"])
ctx["needs_distinct"] = False

if not for_count:
stmt = handler["apply_sorts"](stmt, sorts, ctx, property_field_types)

if ctx.get("needs_distinct"):
stmt = stmt.distinct(handler["root_model"].id)

if (
not for_count
and pagination is not None
Expand Down
18 changes: 11 additions & 7 deletions backend/api/resolvers/patient.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from api.services.property import PropertyService
from api.types.patient import PatientType
from api.errors import raise_forbidden
from api.query.dedupe_select import dedupe_orm_select_by_root_id
from api.query.patient_location_scope import (
apply_patient_subtree_filter_from_cte,
build_location_descendants_cte,
Expand Down Expand Up @@ -87,7 +88,10 @@ async def _build_patients_base_query(
)

if filter_cte is not None:
query = apply_patient_subtree_filter_from_cte(query, filter_cte).distinct()
query = dedupe_orm_select_by_root_id(
apply_patient_subtree_filter_from_cte(query, filter_cte),
models.Patient,
)

return query, accessible_location_ids

Expand Down Expand Up @@ -220,7 +224,7 @@ async def recent_patients(
root_cte = root_cte.union_all(root_children)
patient_locations_root = aliased(models.patient_locations)
patient_teams_root = aliased(models.patient_teams)
query = (
query = dedupe_orm_select_by_root_id(
query.outerjoin(
patient_locations_root,
models.Patient.id == patient_locations_root.c.patient_id,
Expand All @@ -241,8 +245,8 @@ async def recent_patients(
)
| (patient_locations_root.c.location_id.in_(select(root_cte.c.id)))
| (patient_teams_root.c.location_id.in_(select(root_cte.c.id)))
)
.distinct()
),
models.Patient,
)

return query
Expand Down Expand Up @@ -301,7 +305,7 @@ async def recentPatientsTotal(
root_cte = root_cte.union_all(root_children)
patient_locations_root = aliased(models.patient_locations)
patient_teams_root = aliased(models.patient_teams)
query = (
query = dedupe_orm_select_by_root_id(
query.outerjoin(
patient_locations_root,
models.Patient.id == patient_locations_root.c.patient_id,
Expand All @@ -322,8 +326,8 @@ async def recentPatientsTotal(
)
| (patient_locations_root.c.location_id.in_(select(root_cte.c.id)))
| (patient_teams_root.c.location_id.in_(select(root_cte.c.id)))
)
.distinct()
),
models.Patient,
)

return await count_unified_query(
Expand Down
9 changes: 7 additions & 2 deletions backend/api/services/authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ def filter_patients_by_access(
patient_locations = aliased(models.patient_locations)
patient_teams = aliased(models.patient_teams)

return (
expanded = (
query.outerjoin(
patient_locations,
models.Patient.id == patient_locations.c.patient_id,
Expand All @@ -199,5 +199,10 @@ def filter_patients_by_access(
| (patient_locations.c.location_id.in_(select(cte.c.id)))
| (patient_teams.c.location_id.in_(select(cte.c.id)))
)
.distinct()
)
opts = getattr(expanded, "_with_options", None) or ()
ids_sq = expanded.with_only_columns(models.Patient.id).distinct().scalar_subquery()
out = select(models.Patient).where(models.Patient.id.in_(ids_sq))
for opt in opts:
out = out.options(opt)
return out
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@

from typing import Sequence, Union

from alembic import op

revision: str = "merge_saved_views_task_assignees"
down_revision: Union[str, Sequence[str], None] = (
"add_saved_views_table",
Expand Down
46 changes: 40 additions & 6 deletions web/components/tables/PatientList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { columnFiltersToQueryFilterClauses, sortingStateToQuerySortClauses } fro
import { LIST_PAGE_SIZE } from '@/utils/listPaging'
import { useAccumulatedPagination } from '@/hooks/useAccumulatedPagination'
import { PatientCardView } from '@/components/patients/PatientCardView'
import { queryableFieldsToFilterListItems, queryableFieldsToSortingListItems } from '@/utils/queryableFilterList'
import { queryableFieldsToFilterListItems, queryableFieldsToSortingListItems, type QueryableChoiceTagLabelResolver } from '@/utils/queryableFilterList'
import { getPropertyFilterFn as getPropertyDatatype } from '@/utils/propertyFilterMapping'
import { UserSelectFilterPopUp } from './UserSelectFilterPopUp'
import { ExpandableTextBlock } from '@/components/common/ExpandableTextBlock'
Expand All @@ -38,6 +38,7 @@ import {
import { getParsedDocument } from '@/data/hooks/queryHelpers'
import { replaceSavedViewInMySavedViewsCache } from '@/utils/savedViewsCache'
import { useDeferredColumnOrderChange } from '@/hooks/useDeferredColumnOrderChange'
import { useStableSerializedList } from '@/hooks/useStableSerializedList'
import { columnIdsFromColumnDefs, sanitizeColumnOrderForKnownColumns } from '@/utils/columnOrder'
import {
hasActiveLocationFilter,
Expand Down Expand Up @@ -114,6 +115,22 @@ export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initi
const { refreshingPatientIds } = useRefreshingEntityIds()
const { data: propertyDefinitionsData } = usePropertyDefinitions()
const { data: queryableFieldsData } = useQueryableFields('Patient')
const queryableFieldsStable = useStableSerializedList(
queryableFieldsData?.queryableFields,
(f) => ({
key: f.key,
label: f.label,
filterable: f.filterable,
sortable: f.sortable,
sortDirections: f.sortDirections,
propertyDefinitionId: f.propertyDefinitionId,
kind: f.kind,
valueType: f.valueType,
choice: f.choice
? { keys: f.choice.optionKeys, labels: f.choice.optionLabels }
: null,
})
)
const effectiveRootLocationIds = rootLocationIds ?? selectedRootLocationIds
const [isPanelOpen, setIsPanelOpen] = useState(false)
const [selectedPatient, setSelectedPatient] = useState<PatientViewModel | undefined>(undefined)
Expand Down Expand Up @@ -634,14 +651,31 @@ export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initi
'tasks': translation('tasks'),
'updated': translation('updated'),
'updateDate': translation('updated'),
'description': translation('description'),
}
return translatedByKey[key] ?? field.label
}, [translation])

const resolvePatientChoiceTagLabel = useCallback<QueryableChoiceTagLabelResolver>((field, optionKey, backendLabel) => {
if (field.propertyDefinitionId) return backendLabel
if (field.key === 'state') return translation('patientState', { state: optionKey })
if (field.key === 'sex') {
if (optionKey === Sex.Male) return translation('male')
if (optionKey === Sex.Female) return translation('female')
return translation('diverse')
}
return backendLabel
}, [translation])

const availableFilters: FilterListItem[] = useMemo(() => {
const raw = queryableFieldsData?.queryableFields
const raw = queryableFieldsStable
if (raw?.length) {
return queryableFieldsToFilterListItems(raw, propertyFieldTypeByDefId, resolvePatientQueryableLabel)
return queryableFieldsToFilterListItems(
raw,
propertyFieldTypeByDefId,
resolvePatientQueryableLabel,
resolvePatientChoiceTagLabel
)
}
return [
{
Expand Down Expand Up @@ -698,15 +732,15 @@ export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initi
}
}) ?? [],
]
}, [queryableFieldsData?.queryableFields, propertyFieldTypeByDefId, resolvePatientQueryableLabel, translation, allPatientStates, propertyDefinitionsData?.propertyDefinitions])
}, [queryableFieldsStable, propertyFieldTypeByDefId, resolvePatientQueryableLabel, resolvePatientChoiceTagLabel, translation, allPatientStates, propertyDefinitionsData?.propertyDefinitions])

const availableSortItems = useMemo(() => {
const raw = queryableFieldsData?.queryableFields
const raw = queryableFieldsStable
if (raw?.length) {
return queryableFieldsToSortingListItems(raw, resolvePatientQueryableLabel)
}
return availableFilters.map(({ id, label, dataType }) => ({ id, label, dataType }))
}, [queryableFieldsData?.queryableFields, availableFilters, resolvePatientQueryableLabel])
}, [queryableFieldsStable, availableFilters, resolvePatientQueryableLabel])

const knownColumnIdsOrdered = useMemo(
() => columnIdsFromColumnDefs(columns),
Expand Down
43 changes: 37 additions & 6 deletions web/components/tables/TaskList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ import { UserInfoPopup } from '@/components/UserInfoPopup'
import type { ColumnDef, ColumnFiltersState, ColumnOrderState, PaginationState, SortingState, TableState, VisibilityState } from '@tanstack/table-core'
import type { Dispatch, SetStateAction } from 'react'
import { useDeferredColumnOrderChange } from '@/hooks/useDeferredColumnOrderChange'
import { useStableSerializedList } from '@/hooks/useStableSerializedList'
import { columnIdsFromColumnDefs, sanitizeColumnOrderForKnownColumns } from '@/utils/columnOrder'
import { DueDateUtils } from '@/utils/dueDate'
import { PriorityUtils } from '@/utils/priority'
import { getPropertyColumnsForEntity } from '@/utils/propertyColumn'
import { useColumnVisibilityWithPropertyDefaults } from '@/hooks/usePropertyColumnVisibility'
import { queryableFieldsToFilterListItems, queryableFieldsToSortingListItems } from '@/utils/queryableFilterList'
import { queryableFieldsToFilterListItems, queryableFieldsToSortingListItems, type QueryableChoiceTagLabelResolver } from '@/utils/queryableFilterList'
import { LIST_PAGE_SIZE } from '@/utils/listPaging'
import { TaskCardView } from '@/components/tasks/TaskCardView'
import { RefreshingTaskIdsContext, TaskRowRefreshingGate } from '@/components/tables/TaskRowRefreshingGate'
Expand Down Expand Up @@ -153,6 +154,22 @@ export const TaskList = forwardRef<TaskListRef, TaskListProps>(({ tasks: initial
const translation = useTasksTranslation()
const { data: propertyDefinitionsData } = usePropertyDefinitions()
const { data: queryableFieldsData } = useQueryableFields('Task')
const queryableFieldsStable = useStableSerializedList(
queryableFieldsData?.queryableFields,
(f) => ({
key: f.key,
label: f.label,
filterable: f.filterable,
sortable: f.sortable,
sortDirections: f.sortDirections,
propertyDefinitionId: f.propertyDefinitionId,
kind: f.kind,
valueType: f.valueType,
choice: f.choice
? { keys: f.choice.optionKeys, labels: f.choice.optionLabels }
: null,
})
)

const [clientVisibleCount, setClientVisibleCount] = useState(LIST_PAGE_SIZE)
const [listLayout, setListLayout] = useState<'table' | 'card'>(() => (
Expand Down Expand Up @@ -443,21 +460,35 @@ export const TaskList = forwardRef<TaskListRef, TaskListProps>(({ tasks: initial
priority: translation('priorityLabel'),
patient: translation('patient'),
assignee: translation('assignedTo'),
assigneeTeam: translation('assigneeTeam'),
updated: translation('updated'),
updateDate: translation('updated'),
position: translation('location'),
state: translation('status'),
firstname: translation('firstName'),
lastname: translation('lastName'),
birthdate: translation('birthdate'),
estimatedTime: translation('estimatedTime'),
creationDate: translation('creationDate'),
}
return translatedByKey[field.key] ?? field.label
}, [translation])

const resolveTaskChoiceTagLabel = useCallback<QueryableChoiceTagLabelResolver>((field, optionKey, backendLabel) => {
if (field.propertyDefinitionId) return backendLabel
if (field.key === 'priority') return translation('priority', { priority: optionKey })
return backendLabel
}, [translation])

const availableFilters: FilterListItem[] = useMemo(() => {
const raw = queryableFieldsData?.queryableFields
const raw = queryableFieldsStable
if (raw?.length) {
return queryableFieldsToFilterListItems(raw, propertyFieldTypeByDefId, resolveTaskQueryableLabel)
return queryableFieldsToFilterListItems(
raw,
propertyFieldTypeByDefId,
resolveTaskQueryableLabel,
resolveTaskChoiceTagLabel
)
}
return [
{ id: 'title', label: translation('title'), dataType: 'text', tags: [] },
Expand All @@ -478,15 +509,15 @@ export const TaskList = forwardRef<TaskListRef, TaskListProps>(({ tasks: initial
tags: def.options.map((opt, idx) => ({ label: opt, tag: `${def.id}-opt-${idx}` })),
})) ?? [],
]
}, [queryableFieldsData?.queryableFields, propertyFieldTypeByDefId, resolveTaskQueryableLabel, translation, propertyDefinitionsData?.propertyDefinitions])
}, [queryableFieldsStable, propertyFieldTypeByDefId, resolveTaskQueryableLabel, resolveTaskChoiceTagLabel, translation, propertyDefinitionsData?.propertyDefinitions])

const availableSortItems = useMemo(() => {
const raw = queryableFieldsData?.queryableFields
const raw = queryableFieldsStable
if (raw?.length) {
return queryableFieldsToSortingListItems(raw, resolveTaskQueryableLabel)
}
return availableFilters.map(({ id, label, dataType }) => ({ id, label, dataType }))
}, [queryableFieldsData?.queryableFields, availableFilters, resolveTaskQueryableLabel])
}, [queryableFieldsStable, availableFilters, resolveTaskQueryableLabel])

const columns = useMemo<ColumnDef<TaskViewModel>[]>(() => {
const cols: ColumnDef<TaskViewModel>[] = [
Expand Down
Loading
Loading