diff --git a/.vscode/settings.json b/.vscode/settings.json index cba5b25c4a20..486181d1ffa4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,8 +22,12 @@ "files.insertFinalNewline": true, "python.testing.pytestEnabled": true, "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.tabSize": 4, "editor.codeActionsOnSave": { - "source.organizeImports": "never" + "source.organizeImports": "never", + "source.fixAll.ruff": "explicit", + "source.organizeImports.ruff": "explicit" } }, "editor.defaultFormatter": "esbenp.prettier-vscode", diff --git a/requirements-base.in b/requirements-base.in index 0f2141f40702..a0644d54eefa 100644 --- a/requirements-base.in +++ b/requirements-base.in @@ -15,6 +15,8 @@ duo-client email-validator emails fastapi==0.115.12 +fastapi-filter +advanced-alchemy google-api-python-client google-auth-oauthlib h11 @@ -54,7 +56,7 @@ slowapi spacy==3.8.5 sqlalchemy-filters sqlalchemy-utils -sqlalchemy==2.0.8 +sqlalchemy statsmodels tabulate tenacity diff --git a/requirements-base.txt b/requirements-base.txt index e5593e7f9262..f18f8ed8ea0d 100644 --- a/requirements-base.txt +++ b/requirements-base.txt @@ -6,6 +6,8 @@ # --index-url https://pypi.netflix.net/simple +advanced-alchemy==1.4.4 + # via -r requirements-base.in aiocache==0.12.3 # via -r requirements-base.in aiofiles==24.1.0 @@ -17,7 +19,9 @@ aiohttp==3.11.12 aiosignal==1.3.2 # via aiohttp alembic==1.14.1 - # via -r requirements-base.in + # via + # -r requirements-base.in + # advanced-alchemy annotated-types==0.7.0 # via pydantic anyio==4.8.0 @@ -148,6 +152,8 @@ googleapis-common-protos==1.67.0 # via google-api-core graphql-core==3.2.6 # via hypothesis-graphql +greenlet==3.2.3 + # via advanced-alchemy h11==0.14.0 # via # -r requirements-base.in @@ -457,9 +463,10 @@ spacy-legacy==3.0.12 # via spacy spacy-loggers==1.0.5 # via spacy -sqlalchemy==2.0.8 +sqlalchemy==2.0.41 # via # -r requirements-base.in + # advanced-alchemy # alembic # sqlalchemy-filters # sqlalchemy-utils @@ -509,6 +516,7 @@ typer==0.15.1 typing-extensions==4.13.2 # via # -r requirements-base.in + # advanced-alchemy # alembic # anyio # fastapi diff --git a/src/dispatch/case/filters.py b/src/dispatch/case/filters.py new file mode 100644 index 000000000000..7663f2792992 --- /dev/null +++ b/src/dispatch/case/filters.py @@ -0,0 +1,269 @@ +"""FastAPI-Filter implementation for Case filtering.""" + +from __future__ import annotations + +from datetime import datetime + +from fastapi_filter.contrib.sqlalchemy import Filter +from pydantic import Field +from sqlalchemy.orm import Query +from sqlalchemy.orm.query import Query + +from dispatch.case.enums import CaseResolutionReason, CaseStatus +from dispatch.case.models import Case +from dispatch.enums import Visibility + + +class CaseFilter(Filter): + """FastAPI-Filter implementation for Case filtering.""" + + # Basic field filters + id__in: list[int] | None = Field(None, description="Filter by case IDs") + name__ilike: str | None = Field(None, description="Search by case name (case-insensitive)") + title__ilike: str | None = Field(None, description="Search by case title (case-insensitive)") + description__ilike: str | None = Field( + None, description="Search by case description (case-insensitive)" + ) + resolution__ilike: str | None = Field( + None, description="Search by resolution text (case-insensitive)" + ) + + # Enum filters + resolution_reason__in: list[CaseResolutionReason] | None = Field( + None, description="Filter by resolution reasons" + ) + status__in: list[CaseStatus] | None = Field(None, description="Filter by case statuses") + visibility__in: list[Visibility] | None = Field(None, description="Filter by visibility levels") + + # Location and team filters + participants_team__ilike: str | None = Field(None, description="Search by participants team") + participants_location__ilike: str | None = Field( + None, description="Search by participants location" + ) + + # Boolean filters + dedicated_channel: bool | None = Field(None, description="Filter by dedicated channel status") + event: bool | None = Field(None, description="Filter by event status") + + # Date range filters for reported_at + reported_at__gte: datetime | None = Field( + None, description="Cases reported on or after this date" + ) + reported_at__lte: datetime | None = Field( + None, description="Cases reported on or before this date" + ) + reported_at__gt: datetime | None = Field(None, description="Cases reported after this date") + reported_at__lt: datetime | None = Field(None, description="Cases reported before this date") + + # Date range filters for triage_at + triage_at__gte: datetime | None = Field(None, description="Cases triaged on or after this date") + triage_at__lte: datetime | None = Field( + None, description="Cases triaged on or before this date" + ) + triage_at__gt: datetime | None = Field(None, description="Cases triaged after this date") + triage_at__lt: datetime | None = Field(None, description="Cases triaged before this date") + + # Date range filters for escalated_at + escalated_at__gte: datetime | None = Field( + None, description="Cases escalated on or after this date" + ) + escalated_at__lte: datetime | None = Field( + None, description="Cases escalated on or before this date" + ) + escalated_at__gt: datetime | None = Field(None, description="Cases escalated after this date") + escalated_at__lt: datetime | None = Field(None, description="Cases escalated before this date") + + # Date range filters for closed_at + closed_at__gte: datetime | None = Field(None, description="Cases closed on or after this date") + closed_at__lte: datetime | None = Field(None, description="Cases closed on or before this date") + closed_at__gt: datetime | None = Field(None, description="Cases closed after this date") + closed_at__lt: datetime | None = Field(None, description="Cases closed before this date") + + # Date range filters for created_at + created_at__gte: datetime | None = Field( + None, description="Cases created on or after this date" + ) + created_at__lte: datetime | None = Field( + None, description="Cases created on or before this date" + ) + created_at__gt: datetime | None = Field(None, description="Cases created after this date") + created_at__lt: datetime | None = Field(None, description="Cases created before this date") + + # Date range filters for updated_at + updated_at__gte: datetime | None = Field( + None, description="Cases updated on or after this date" + ) + updated_at__lte: datetime | None = Field( + None, description="Cases updated on or before this date" + ) + updated_at__gt: datetime | None = Field(None, description="Cases updated after this date") + updated_at__lt: datetime | None = Field(None, description="Cases updated before this date") + + # Relationship filters + case_type_id__in: list[int] | None = Field(None, description="Filter by case type IDs") + case_priority_id__in: list[int] | None = Field(None, description="Filter by case priority IDs") + case_severity_id__in: list[int] | None = Field(None, description="Filter by case severity IDs") + + # Participant relationship filters + assignee_id__in: list[int] | None = Field( + None, description="Filter by assignee participant IDs" + ) + reporter_id__in: list[int] | None = Field( + None, description="Filter by reporter participant IDs" + ) + + # Tag relationship filters + tag_id__in: list[int] | None = Field(None, description="Filter by tag IDs") + tag_type_id__in: list[int] | None = Field(None, description="Filter by tag type IDs") + + # Project filter + project_id__in: list[int] | None = Field(None, description="Filter by project IDs") + + # Full-text search + search: str | None = Field(None, description="Full-text search across multiple fields") + + class Constants(Filter.Constants): + model: type = Case + search_model_fields: list[str] = ["name", "title", "description"] + + def filter(self, query: Query[Case]) -> Query[Case]: + """Enhanced filter method with custom email filtering and permission handling.""" + from sqlalchemy.orm import aliased + + # Apply standard FastAPI-Filter filtering first + query = super().filter(query) + + # Custom email filtering logic (moved from views) + if hasattr(self, "_assignee_emails") and self._assignee_emails: + from dispatch.individual.models import IndividualContact + from dispatch.participant.models import Participant + + assignee_participant = aliased(Participant) + assignee_individual = aliased(IndividualContact) + + query = ( + query.join(assignee_participant, Case.assignee_id == assignee_participant.id) + .join( + assignee_individual, + assignee_participant.individual_contact_id == assignee_individual.id, + ) + .filter(assignee_individual.email.in_(self._assignee_emails)) + ) + + if hasattr(self, "_reporter_emails") and self._reporter_emails: + from dispatch.individual.models import IndividualContact + from dispatch.participant.models import Participant + + reporter_participant = aliased(Participant) + reporter_individual = aliased(IndividualContact) + + query = ( + query.join(reporter_participant, Case.reporter_id == reporter_participant.id) + .join( + reporter_individual, + reporter_participant.individual_contact_id == reporter_individual.id, + ) + .filter(reporter_individual.email.in_(self._reporter_emails)) + ) + + return query + + def set_email_filters( + self, assignee_emails: list[str] | None = None, reporter_emails: list[str] | None = None + ): + """Set email filters for participant filtering.""" + if assignee_emails: + self._assignee_emails = assignee_emails + if reporter_emails: + self._reporter_emails = reporter_emails + + +# Alternative approach using separate filters for different concerns +class CaseBasicFilter(Filter): + """Basic field filters for cases.""" + + id__in: list[int] | None = None + name__icontains: str | None = None + title__icontains: str | None = None + description__icontains: str | None = None + resolution__icontains: str | None = None + dedicated_channel: bool | None = None + event: bool | None = None + + class Constants(Filter.Constants): + model: type = Case + + +class CaseStatusFilter(Filter): + """Status and enum-based filters for cases.""" + + resolution_reason__in: list[CaseResolutionReason] | None = None + status__in: list[CaseStatus] | None = None + visibility__in: list[Visibility] | None = None + + class Constants(Filter.Constants): + model: type = Case + + +class CaseDateFilter(Filter): + """Date range filters for cases.""" + + # Reported date filters + reported_at__gte: datetime | None = None + reported_at__lte: datetime | None = None + reported_at__gt: datetime | None = None + reported_at__lt: datetime | None = None + + # Triage date filters + triage_at__gte: datetime | None = None + triage_at__lte: datetime | None = None + triage_at__gt: datetime | None = None + triage_at__lt: datetime | None = None + + # Escalation date filters + escalated_at__gte: datetime | None = None + escalated_at__lte: datetime | None = None + escalated_at__gt: datetime | None = None + escalated_at__lt: datetime | None = None + + # Closure date filters + closed_at__gte: datetime | None = None + closed_at__lte: datetime | None = None + closed_at__gt: datetime | None = None + closed_at__lt: datetime | None = None + + # Creation date filters + created_at__gte: datetime | None = None + created_at__lte: datetime | None = None + created_at__gt: datetime | None = None + created_at__lt: datetime | None = None + + # Update date filters + updated_at__gte: datetime | None = None + updated_at__lte: datetime | None = None + updated_at__gt: datetime | None = None + updated_at__lt: datetime | None = None + + class Constants(Filter.Constants): + model: type = Case + + +class CaseRelationshipFilter(Filter): + """Relationship-based filters for cases.""" + + # Core relationships + case_type_id__in: list[int] | None = None + case_priority_id__in: list[int] | None = None + case_severity_id__in: list[int] | None = None + project_id__in: list[int] | None = None + + # Participant relationships + assignee_id__in: list[int] | None = None + reporter_id__in: list[int] | None = None + + # Tag relationships + tag_id__in: list[int] | None = None + tag_type_id__in: list[int] | None = None + + class Constants(Filter.Constants): + model: type = Case diff --git a/src/dispatch/case/views.py b/src/dispatch/case/views.py index 8ac2205a7273..2439e0c56da0 100644 --- a/src/dispatch/case/views.py +++ b/src/dispatch/case/views.py @@ -3,7 +3,7 @@ from datetime import datetime from typing import Annotated -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status from sqlalchemy.exc import IntegrityError from starlette.requests import Request @@ -17,9 +17,11 @@ ) from dispatch.auth.service import CurrentUser from dispatch.case.enums import CaseStatus -from dispatch.common.utils.views import create_pydantic_include from dispatch.database.core import DbSession from dispatch.database.service import CommonParameters, search_filter_sort_paginate +from dispatch.case.filters import CaseFilter +from fastapi_filter import FilterDepends +from fastapi import Query from dispatch.event import flows as event_flows from dispatch.event.models import EventCreateMinimal, EventUpdate from dispatch.incident import service as incident_service @@ -46,8 +48,6 @@ from .models import ( Case, CaseCreate, - CaseExpandedPagination, - CasePagination, CasePaginationMinimalWithExtras, CaseRead, CaseUpdate, @@ -123,28 +123,83 @@ def get_case_participants( @router.get("", summary="Retrieves a list of cases.") def get_cases( - common: CommonParameters, + request: Request, + db_session: DbSession, + current_user: CurrentUser, + case_filter: CaseFilter = FilterDepends(CaseFilter), + limit: int = Query(25, ge=1, le=100, description="Number of items per page"), + offset: int = Query(0, ge=0, description="Number of items to skip"), include: list[str] = Query([], alias="include[]"), expand: bool = Query(default=False), + # Custom email filtering parameters + assignee_email__in: list[str] | None = None, + reporter_email__in: list[str] | None = None, ): - """Retrieves all cases.""" - pagination = search_filter_sort_paginate(model="Case", **common) + """Retrieves all cases using FastAPI-Filter.""" + import json + from urllib.parse import parse_qs + + # Handle array bracket notation from frontend (e.g., status__in[]=Escalated) + query_params = dict(request.query_params) + + # Process array bracket parameters + for key in list(query_params.keys()): + if key.endswith('[]'): + # Remove the brackets from the key + clean_key = key[:-2] + # Get all values for this parameter (FastAPI automatically converts to list) + if hasattr(request.query_params, 'getlist'): + values = request.query_params.getlist(key) + else: + # Fallback: parse the raw query string + raw_params = parse_qs(str(request.url.query)) + values = raw_params.get(key, []) + + # Set the clean parameter on the filter if it exists + if hasattr(case_filter, clean_key): + setattr(case_filter, clean_key, values) + + # Set email filters on the case filter instance + case_filter.set_email_filters( + assignee_emails=assignee_email__in, + reporter_emails=reporter_email__in + ) + + # Apply the filter to the query (includes email filtering) + query = case_filter.filter(db_session.query(Case)) + + # Get total count before pagination + total_count = query.count() + + # Apply pagination + cases = query.offset(offset).limit(limit).all() + + # Build response using existing models + from dispatch.case.models import CasePagination, CaseExpandedPagination + + pagination_data = { + "items": cases, + "total": total_count, + "page": offset // limit + 1 if limit > 0 else 1, + "itemsPerPage": limit, + } if expand: - return json.loads(CaseExpandedPagination(**pagination).json()) + return json.loads(CaseExpandedPagination(**pagination_data).model_dump_json()) if include: + from dispatch.common.utils.views import create_pydantic_include # only allow two levels for now include_sets = create_pydantic_include(include) - include_fields = { "items": {"__all__": include_sets}, "itemsPerPage": ..., "page": ..., "total": ..., } - return json.loads(CaseExpandedPagination(**pagination).json(include=include_fields)) - return json.loads(CasePagination(**pagination).json()) + return json.loads(CaseExpandedPagination(**pagination_data).model_dump_json(include=include_fields)) + + return json.loads(CasePagination(**pagination_data).model_dump_json()) @router.get("/minimal", summary="Retrieves a list of cases with minimal data.") diff --git a/src/dispatch/static/dispatch/src/case/store.js b/src/dispatch/static/dispatch/src/case/store.js index 5aabed8339c5..887edef6ddae 100644 --- a/src/dispatch/static/dispatch/src/case/store.js +++ b/src/dispatch/static/dispatch/src/case/store.js @@ -1,7 +1,7 @@ import { getField, updateField } from "vuex-map-fields" import { debounce } from "lodash" -import SearchUtils from "@/search/utils" +import { createFastAPIFilterParameters } from "@/search/fastapi-filter" import CaseApi from "@/case/api" import ProjectApi from "@/project/api" import PluginApi from "@/plugin/api" @@ -151,7 +151,7 @@ const actions = { AuthApi.getUserRole().then((response) => { commit("SET_CURRENT_USER_ROLE", response.data) }) - let params = SearchUtils.createParametersFromTableOptions({ ...state.table.options }, "Case") + let params = createFastAPIFilterParameters({ ...state.table.options }, "Case") return CaseApi.getAll(params) .then((response) => { commit("SET_TABLE_LOADING", false) diff --git a/src/dispatch/static/dispatch/src/search/fastapi-filter.ts b/src/dispatch/static/dispatch/src/search/fastapi-filter.ts new file mode 100644 index 000000000000..7cd302ad9733 --- /dev/null +++ b/src/dispatch/static/dispatch/src/search/fastapi-filter.ts @@ -0,0 +1,297 @@ +/** + * FastAPI Filter Utilities + * + * Modern TypeScript implementation for converting frontend filter options + * to FastAPI-Filter compatible query parameters. + */ + +// Type definitions +export interface DateRange { + start: string | null + end: string | null +} + +export interface EntityWithId { + id: number + name?: string +} + +export interface IndividualContact { + email: string +} + +export interface Participant { + email?: string + individual?: IndividualContact +} + +export interface FilterValue { + [key: string]: unknown +} + +export interface TableOptions { + filters: Record + sortBy?: string[] + descending?: boolean[] + page?: number + itemsPerPage?: number + [key: string]: unknown +} + +export interface FastAPIFilterParams { + [key: string]: string | number | boolean | (string | number)[] +} + +export interface SortExpression { + sortBy: string[] + descending: boolean[] +} + +/** + * Specialized filter parameter builders for different entity types + */ +class FilterParamBuilder { + private params: FastAPIFilterParams = {} + + /** + * Add date range filters (e.g., reported_at__gte, reported_at__lte) + */ + addDateRange(key: string, dateRange: DateRange): this { + if (dateRange.start) { + this.params[`${key}__gte`] = dateRange.start + if (dateRange.end) { + this.params[`${key}__lte`] = dateRange.end + } + } + return this + } + + /** + * Add participant email filters (assignee, reporter) + */ + addParticipantEmails(key: string, participants: Participant[]): this { + const emails = participants + .map((p) => p.email || p.individual?.email) + .filter((email): email is string => Boolean(email)) + + if (emails.length > 0) { + const paramKey = key === "assignee" ? "assignee_email__in" : "reporter_email__in" + this.params[paramKey] = emails + } + return this + } + + /** + * Add entity ID filters (case_type_id__in, project_id__in, etc.) + */ + addEntityIds(key: string, entities: EntityWithId[], paramName?: string): this { + const ids = entities + .map((entity) => entity.id) + .filter((id): id is number => typeof id === "number") + + if (ids.length > 0) { + const param = paramName || `${key}_id__in` + this.params[param] = ids + } + return this + } + + /** + * Add simple array filter (status__in, etc.) + */ + addArrayFilter(key: string, values: unknown[]): this { + const validValues = values.filter((v) => v != null) + if (validValues.length > 0) { + this.params[`${key}__in`] = validValues as (string | number)[] + } + return this + } + + /** + * Add simple key-value parameter + */ + addSimpleParam(key: string, value: unknown): this { + if (value != null) { + this.params[key] = value as string | number | boolean + } + return this + } + + /** + * Get the built parameters + */ + build(): FastAPIFilterParams { + return { ...this.params } + } +} + +/** + * Maps filter keys to their specialized handlers + */ +const FILTER_HANDLERS = { + // Participant filters + assignee: (builder: FilterParamBuilder, key: string, value: unknown[]) => { + builder.addParticipantEmails(key, value as Participant[]) + }, + participant: (builder: FilterParamBuilder, key: string, value: unknown[]) => { + builder.addParticipantEmails(key, value as Participant[]) + }, + + // Entity ID filters + case_type: (builder: FilterParamBuilder, key: string, value: unknown[]) => { + builder.addEntityIds(key, value as EntityWithId[]) + }, + case_priority: (builder: FilterParamBuilder, key: string, value: unknown[]) => { + builder.addEntityIds(key, value as EntityWithId[]) + }, + case_severity: (builder: FilterParamBuilder, key: string, value: unknown[]) => { + builder.addEntityIds(key, value as EntityWithId[]) + }, + project: (builder: FilterParamBuilder, key: string, value: unknown[]) => { + builder.addEntityIds(key, value as EntityWithId[], "project_id__in") + }, + tag: (builder: FilterParamBuilder, key: string, value: unknown[]) => { + builder.addEntityIds(key, value as EntityWithId[], "tag_id__in") + }, + tag_type: (builder: FilterParamBuilder, key: string, value: unknown[]) => { + builder.addEntityIds(key, value as EntityWithId[], "tag_type_id__in") + }, + + // Simple array filters + status: (builder: FilterParamBuilder, key: string, value: unknown[]) => { + builder.addArrayFilter(key, value) + }, +} as const + +/** + * Check if a value is a date range object + */ +function isDateRange(value: unknown): value is DateRange { + return typeof value === "object" && value !== null && "start" in value +} + +/** + * Convert frontend filter options to FastAPI-Filter parameters + */ +export function createFastAPIFilterParams( + filters: Record +): FastAPIFilterParams { + const builder = new FilterParamBuilder() + + Object.entries(filters).forEach(([key, value]) => { + // Handle date ranges + if (isDateRange(value)) { + builder.addDateRange(key, value) + return + } + + // Handle arrays + if (Array.isArray(value) && value.length > 0) { + const validValues = value.filter((v) => v != null) + + if (validValues.length > 0) { + const handler = FILTER_HANDLERS[key as keyof typeof FILTER_HANDLERS] + if (handler) { + handler(builder, key, validValues) + } else { + // Default: treat as array filter + builder.addArrayFilter(key, validValues) + } + } + return + } + + // Handle single values + if (value != null && !isDateRange(value)) { + builder.addSimpleParam(key, value) + } + }) + + return builder.build() +} + +/** + * Create sorting expression from sortBy and descending arrays + */ +export function createSortExpression(sortBy?: string[], descending?: boolean[]): SortExpression { + return { + sortBy: sortBy || [], + descending: descending || [], + } +} + +/** + * Convert complete table options to FastAPI-Filter compatible parameters + */ +export function createFastAPIFilterParameters(options: TableOptions): Record { + const { sortBy, descending } = createSortExpression(options.sortBy, options.descending) + const filterParams = createFastAPIFilterParams(options.filters) + + // Create clean options without the processed fields + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + const { filters, sortBy: _sortBy, descending: _descending, ...cleanOptions } = options + + // Add sorting parameters + const sortParams: Record = {} + if (sortBy.length > 0) { + const firstSort = sortBy[0] + const isDescending = descending[0] || false + sortParams.order_by = isDescending ? `-${firstSort}` : firstSort + } + + return { + ...cleanOptions, + ...filterParams, + ...sortParams, + } +} + +/** + * Type-safe parameter validation + */ +export function validateFilterParams(params: FastAPIFilterParams): string[] { + const errors: string[] = [] + + Object.entries(params).forEach(([key, value]) => { + if (key.includes("__in") && !Array.isArray(value)) { + errors.push(`Parameter '${key}' should be an array but got ${typeof value}`) + } + + if (key.includes("__gte") || key.includes("__lte")) { + if (typeof value !== "string" && !(value instanceof Date)) { + errors.push(`Date parameter '${key}' should be a string or Date but got ${typeof value}`) + } + } + }) + + return errors +} + +/** + * Debug utility to log filter transformation + */ +export function debugFilterTransformation( + originalFilters: Record, + fastApiParams: FastAPIFilterParams +): void { + if (process.env.NODE_ENV === "development") { + console.group("🔍 Filter Transformation Debug") + console.log("Original filters:", originalFilters) + console.log("FastAPI parameters:", fastApiParams) + + const validationErrors = validateFilterParams(fastApiParams) + if (validationErrors.length > 0) { + console.warn("⚠️ Validation warnings:", validationErrors) + } + + console.groupEnd() + } +} + +export default { + createFastAPIFilterParameters, + createFastAPIFilterParams, + createSortExpression, + validateFilterParams, + debugFilterTransformation, +} diff --git a/src/dispatch/static/dispatch/src/search/utils.js b/src/dispatch/static/dispatch/src/search/utils.js index 993c43e6fcba..217b691bdec9 100644 --- a/src/dispatch/static/dispatch/src/search/utils.js +++ b/src/dispatch/static/dispatch/src/search/utils.js @@ -12,6 +12,92 @@ const toPascalCase = (str) => .replace(new RegExp(/\w/), (s) => s.toUpperCase()) export default { + createFastAPIFilterParameters(options, model) { + let [sortBy, descending] = this.createSortExpression(options.sortBy, options.descending) + let params = this.createFastAPIFilterParams(options.filters) + + // Remove filters from options to avoid duplication + let cleanOptions = { ...options } + delete cleanOptions.filters + delete cleanOptions.sortBy + delete cleanOptions.descending + + // Add sorting parameters + if (sortBy && sortBy.length > 0) { + params.order_by = descending[0] ? `-${sortBy[0]}` : sortBy[0] + } + + return { + ...cleanOptions, + ...params + } + }, + + createFastAPIFilterParams(filters) { + let params = {} + + forEach(filters, function (value, key) { + // Handle date ranges + if (has(value, "start") && value.start) { + params[`${key}__gte`] = value.start + if (value.end) { + params[`${key}__lte`] = value.end + } + } + // Handle arrays of objects (like case types, priorities, etc.) + else if (Array.isArray(value) && value.length > 0) { + // Filter out null/undefined values + let validValues = value.filter(v => v !== null && v !== undefined) + + if (validValues.length > 0) { + if (key === "assignee" || key === "participant") { + // Handle participant/assignee specially - map to email + let emails = validValues.map(v => v.email || (v.individual && v.individual.email)).filter(Boolean) + if (emails.length > 0) { + params[`${key === "assignee" ? "assignee_email" : "reporter_email"}__in`] = emails + } + } else if (key === "case_type" || key === "case_priority" || key === "case_severity") { + // Map object arrays to ID arrays + let ids = validValues.map(v => v.id).filter(Boolean) + if (ids.length > 0) { + params[`${key}_id__in`] = ids + } + } else if (key === "project") { + // Map project objects to ID arrays + let ids = validValues.map(v => v.id).filter(Boolean) + if (ids.length > 0) { + params["project_id__in"] = ids + } + } else if (key === "tag") { + // Map tag objects to ID arrays + let ids = validValues.map(v => v.id).filter(Boolean) + if (ids.length > 0) { + params["tag_id__in"] = ids + } + } else if (key === "tag_type") { + // Map tag type objects to ID arrays + let ids = validValues.map(v => v.id).filter(Boolean) + if (ids.length > 0) { + params["tag_type_id__in"] = ids + } + } else if (key === "status") { + // Status is already an array of strings + params["status__in"] = validValues + } else { + // Default case - assume it's an array of values + params[`${key}__in`] = validValues + } + } + } + // Handle single values + else if (value !== null && value !== undefined && !Array.isArray(value) && !has(value, "start")) { + params[key] = value + } + }) + + return params + }, + mapQueryParamsToTableOptions(options, queryParams) { forEach(queryParams, function (values, key) { if (Array.isArray(values)) {