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
55 changes: 55 additions & 0 deletions app/dependencies/filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from copy import deepcopy
from typing import Any, get_args, get_origin

from fastapi import Depends, Query, params
from fastapi.exceptions import RequestValidationError
from fastapi_filter.base.filter import BaseFilterModel
from pydantic import ValidationError, create_model


def _list_to_query_fields(filter_model: type[BaseFilterModel]):
fields = {}
for name, f in filter_model.model_fields.items():
field_info = deepcopy(f)
annotation = f.annotation

if (
annotation is list
or get_origin(annotation) is list
or any(get_origin(a) is list for a in get_args(annotation))
) and type(field_info.default) is not params.Query:
field_info.default = Query(default=field_info.default)

fields[name] = (f.annotation, field_info)

return fields


def FilterDepends(filter_model: type[BaseFilterModel], *, by_alias: bool = False, **_) -> Any:
"""Use a hack to treat lists as query parameters.

What we do is loop through the fields of a filter and assign any `list` field a default value of
`Query` so that FastAPI knows it should be treated a query parameter and not body.

When we apply the filter, we build the original filter to properly validate the data (i.e. can
the string be parsed and formatted as a list of <type>?)
"""
fields = _list_to_query_fields(filter_model)
GeneratedFilter = create_model(filter_model.__class__.__name__, **fields) # noqa: N806

class FilterWrapper(GeneratedFilter): # type: ignore[misc,valid-type]
def __new__(cls, *args, **kwargs):
try:
instance = GeneratedFilter(*args, **kwargs)
data = instance.model_dump(
exclude_unset=True, exclude_defaults=True, by_alias=by_alias
)
if original_filter := getattr(filter_model.Constants, "original_filter", None):
prefix = f"{filter_model.Constants.prefix}__"
stripped = {k.removeprefix(prefix): v for k, v in data.items()}
return original_filter(**stripped)
return filter_model(**data)
except ValidationError as e:
raise RequestValidationError(e.errors()) from e

return Depends(FilterWrapper)
3 changes: 2 additions & 1 deletion app/filters/activity.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from datetime import datetime
from typing import Annotated

from fastapi_filter import FilterDepends, with_prefix
from fastapi_filter import with_prefix

from app.dependencies.filter import FilterDepends
from app.filters.common import (
CreationFilterMixin,
CreatorFilterMixin,
Expand Down
28 changes: 28 additions & 0 deletions app/filters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from sqlalchemy.orm import DeclarativeBase

from app.db.model import Identifiable
from app.logger import L

Aliases = dict[type[Identifiable], type[Identifiable] | dict[str, type[Identifiable]]]

Expand All @@ -18,6 +19,33 @@ class CustomFilter[T: DeclarativeBase](Filter):
class Constants(Filter.Constants):
ordering_model_fields: list[str]

@field_validator("*", mode="before")
@classmethod
def split_str(cls, value, field): # pyright: ignore reportIncompatibleMethodOverride
"""Prevent splitting field logic from parent class."""
# backwards compatibility by splitting only comma separated single list elements that do not
# have space directly after the comma. e.g "a,b,c" will be split but not 'a, b, c'.
if (
field.field_name is not None # noqa: PLR0916
and (
field.field_name == cls.Constants.ordering_field_name
or field.field_name.endswith("__in")
or field.field_name.endswith("__not_in")
)
and value
and len(value) == 1
and isinstance(value[0], str)
and "," in value[0]
and ", " not in value[0]
):
msg = (
"Deprecated comma separated single-string IN query used instead of native list. "
f"Filter: field.config['title'] Field name: {field.field_name} Value: {value}"
)
L.warning(msg)
return value[0].split(",")
return value

@field_validator("order_by", check_fields=False)
@classmethod
def restrict_sortable_fields(cls, value: list[str]):
Expand Down
3 changes: 1 addition & 2 deletions app/filters/brain_atlas.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
from typing import Annotated

from fastapi_filter import FilterDepends

from app.db.model import BrainAtlas, BrainAtlasRegion
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import IdFilterMixin, NameFilterMixin, SpeciesFilterMixin

Expand Down
2 changes: 1 addition & 1 deletion app/filters/brain_region.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
from typing import Annotated

import sqlalchemy as sa
from fastapi_filter import FilterDepends
from sqlalchemy.orm import aliased

from app.db.model import BrainRegion, BrainRegionHierarchy
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import NameFilterMixin

Expand Down
3 changes: 1 addition & 2 deletions app/filters/brain_region_hierarchy.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
from typing import Annotated

from fastapi_filter import FilterDepends

from app.db.model import BrainRegionHierarchy
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import IdFilterMixin, NameFilterMixin

Expand Down
3 changes: 1 addition & 2 deletions app/filters/cell_composition.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
from typing import Annotated

from fastapi_filter import FilterDepends

from app.db.model import CellComposition
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import (
EntityFilterMixin,
Expand Down
3 changes: 1 addition & 2 deletions app/filters/circuit.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
from datetime import datetime
from typing import Annotated

from fastapi_filter import FilterDepends

from app.db.model import Circuit
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import (
BrainRegionFilterMixin,
Expand Down
9 changes: 7 additions & 2 deletions app/filters/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from datetime import datetime
from typing import Annotated

from fastapi_filter import FilterDepends, with_prefix
from fastapi_filter import with_prefix

from app.db.model import (
Agent,
Expand All @@ -15,12 +15,17 @@
Strain,
Subject,
)
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter


class IdFilterMixin:
id: uuid.UUID | None = None
id__in: list[uuid.UUID] | None = None

# id__in needs to be a str for backwards compatibility when instead of a native list a comma
# separated string is provided, e.g. 'id1,id2' . With list[UUID] backwards compatibility would
# fail because of validation of the field which would be expected to be a UUID.
id__in: list[str] | None = None


class NameFilterMixin:
Expand Down
3 changes: 1 addition & 2 deletions app/filters/density.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
from typing import Annotated

from fastapi_filter import FilterDepends

from app.db.model import (
ExperimentalBoutonDensity,
ExperimentalNeuronDensity,
ExperimentalSynapsesPerConnection,
)
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import (
BrainRegionFilter,
Expand Down
3 changes: 1 addition & 2 deletions app/filters/electrical_cell_recording.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
from typing import Annotated

from fastapi_filter import FilterDepends

from app.db.model import ElectricalCellRecording
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import (
BrainRegionFilterMixin,
Expand Down
3 changes: 2 additions & 1 deletion app/filters/emodel.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from typing import Annotated

from fastapi_filter import FilterDepends, with_prefix
from fastapi_filter import with_prefix

from app.db.model import EModel
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import (
BrainRegionFilterMixin,
Expand Down
3 changes: 1 addition & 2 deletions app/filters/entity.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
from typing import Annotated

from fastapi_filter import FilterDepends

from app.db.model import Entity
from app.db.types import EntityType
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter


Expand Down
3 changes: 1 addition & 2 deletions app/filters/ion_channel_model.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
from typing import Annotated

from fastapi_filter import FilterDepends

from app.db.model import IonChannelModel
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import (
BrainRegionFilterMixin,
Expand Down
3 changes: 2 additions & 1 deletion app/filters/measurement_annotation.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import uuid
from typing import Annotated

from fastapi_filter import FilterDepends, with_prefix
from fastapi_filter import with_prefix

from app.db.model import MeasurementAnnotation, MeasurementItem, MeasurementKind
from app.db.types import MeasurementStatistic, MeasurementUnit, StructuralDomain
from app.db.utils import MeasurableEntityType
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import CreationFilterMixin

Expand Down
3 changes: 2 additions & 1 deletion app/filters/memodel.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from typing import Annotated

from fastapi_filter import FilterDepends, with_prefix
from fastapi_filter import with_prefix

from app.db.model import MEModel, ValidationStatus
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import (
BrainRegionFilterMixin,
Expand Down
3 changes: 1 addition & 2 deletions app/filters/memodel_calibration_result.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import uuid
from typing import Annotated

from fastapi_filter import FilterDepends

from app.db.model import MEModelCalibrationResult
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import EntityFilterMixin

Expand Down
3 changes: 2 additions & 1 deletion app/filters/morphology.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from typing import Annotated

from fastapi_filter import FilterDepends, with_prefix
from fastapi_filter import with_prefix

from app.db.model import ReconstructionMorphology
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import (
BrainRegionFilterMixin,
Expand Down
3 changes: 1 addition & 2 deletions app/filters/person.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import uuid
from typing import Annotated

from fastapi_filter import FilterDepends

from app.db.model import Person
from app.dependencies.filter import FilterDepends
from app.filters.common import AgentFilter, CreatorFilterMixin


Expand Down
3 changes: 2 additions & 1 deletion app/filters/simulation.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import uuid
from typing import Annotated

from fastapi_filter import FilterDepends, with_prefix
from fastapi_filter import with_prefix

from app.db.model import Simulation
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import (
ContributionFilterMixin,
Expand Down
3 changes: 1 addition & 2 deletions app/filters/simulation_campaign.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
from typing import Annotated

from fastapi_filter import FilterDepends

from app.db.model import SimulationCampaign
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import EntityFilterMixin, NameFilterMixin
from app.filters.simulation import NestedSimulationFilter, NestedSimulationFilterDep
Expand Down
3 changes: 1 addition & 2 deletions app/filters/simulation_execution.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
from typing import Annotated

from fastapi_filter import FilterDepends

from app.db.model import SimulationExecution
from app.dependencies.filter import FilterDepends
from app.filters.activity import ActivityFilterMixin
from app.filters.base import CustomFilter

Expand Down
3 changes: 1 addition & 2 deletions app/filters/simulation_generation.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
from typing import Annotated

from fastapi_filter import FilterDepends

from app.db.model import SimulationGeneration
from app.dependencies.filter import FilterDepends
from app.filters.activity import ActivityFilterMixin
from app.filters.base import CustomFilter

Expand Down
3 changes: 2 additions & 1 deletion app/filters/simulation_result.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from typing import Annotated

from fastapi_filter import FilterDepends, with_prefix
from fastapi_filter import with_prefix

from app.db.model import SimulationResult
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import (
ContributionFilterMixin,
Expand Down
3 changes: 1 addition & 2 deletions app/filters/single_neuron_simulation.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
from typing import Annotated

from fastapi_filter import FilterDepends

from app.db.model import SingleNeuronSimulation
from app.db.types import SingleNeuronSimulationStatus
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import (
BrainRegionFilterMixin,
Expand Down
3 changes: 2 additions & 1 deletion app/filters/single_neuron_synaptome.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from typing import Annotated

from fastapi_filter import FilterDepends, with_prefix
from fastapi_filter import with_prefix

from app.db.model import SingleNeuronSynaptome
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import (
BrainRegionFilterMixin,
Expand Down
3 changes: 1 addition & 2 deletions app/filters/single_neuron_synaptome_simulation.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
from typing import Annotated

from fastapi_filter import FilterDepends

from app.db.model import SingleNeuronSynaptomeSimulation
from app.db.types import SingleNeuronSimulationStatus
from app.dependencies.filter import FilterDepends
from app.filters.base import CustomFilter
from app.filters.common import (
BrainRegionFilterMixin,
Expand Down
Loading