feat: add complex arbitrary filtering on all objects (#140)
* feat: add complex arbitrary filtering on all objects * fix: safe numeric casting and application of comparators * fix: add way more tests, fix bugs with filter parsing * chore: pass model_class as arugment to apply_filter * fix: default to not caring about is_active in get_sessions_for_peer * fix: address coderabbit complaints (valid) * fix: throw filter errors when necessary, validate inputs and handle edge cases with more tests * fix: remove all type errors and most type warnings * fix: don't use db in tests that don't need it cheat: sprinkle in some pyright: ignore in filter.py * fix: allowlist for filtering -- no filtering by content, message id, or anything internal * fix: handle mixed types in metadata, add tests * chore: refine types * chore: rename fiter param everywhere
This commit is contained in:
parent
e1804b3ef1
commit
41bf5adc92
57
src/crud.py
57
src/crud.py
|
|
@ -17,6 +17,7 @@ from . import models, schemas
|
|||
from .exceptions import (
|
||||
ResourceNotFoundException,
|
||||
)
|
||||
from .utils.filter import apply_filter
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
|
@ -85,18 +86,17 @@ async def get_or_create_workspace(
|
|||
|
||||
|
||||
async def get_all_workspaces(
|
||||
filter: dict[str, Any] | None = None,
|
||||
filters: dict[str, Any] | None = None,
|
||||
) -> Select[tuple[models.Workspace]]:
|
||||
"""
|
||||
Get all workspaces.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
filter: Filter the workspaces by a dictionary of metadata
|
||||
filters: Filter the workspaces by a dictionary of metadata
|
||||
"""
|
||||
stmt = select(models.Workspace)
|
||||
if filter is not None:
|
||||
stmt = stmt.where(models.Workspace.h_metadata.contains(filter))
|
||||
stmt = apply_filter(stmt, models.Workspace, filters)
|
||||
stmt: Select[tuple[models.Workspace]] = stmt.order_by(models.Workspace.created_at)
|
||||
return stmt
|
||||
|
||||
|
|
@ -239,12 +239,11 @@ async def get_peer(
|
|||
|
||||
async def get_peers(
|
||||
workspace_name: str,
|
||||
filter: dict[str, str] | None = None,
|
||||
filters: dict[str, str] | None = None,
|
||||
) -> Select[tuple[models.Peer]]:
|
||||
stmt = select(models.Peer).where(models.Peer.workspace_name == workspace_name)
|
||||
|
||||
if filter is not None:
|
||||
stmt = stmt.where(models.Peer.h_metadata.contains(filter))
|
||||
stmt = apply_filter(stmt, models.Peer, filters)
|
||||
|
||||
stmt = stmt.order_by(models.Peer.created_at)
|
||||
|
||||
|
|
@ -291,8 +290,7 @@ async def update_peer(
|
|||
async def get_sessions_for_peer(
|
||||
workspace_name: str,
|
||||
peer_name: str,
|
||||
is_active: bool | None = None,
|
||||
filter: dict[str, Any] | None = None,
|
||||
filters: dict[str, Any] | None = None,
|
||||
) -> Select[tuple[models.Session]]:
|
||||
"""
|
||||
Get all sessions for a peer through the session_peers relationship.
|
||||
|
|
@ -300,8 +298,7 @@ async def get_sessions_for_peer(
|
|||
Args:
|
||||
workspace_name: Name of the workspace
|
||||
peer_name: Name of the peer
|
||||
is_active: Filter by active status (True/False/None for all)
|
||||
filter: Filter sessions by metadata
|
||||
filters: Filter sessions by metadata
|
||||
|
||||
Returns:
|
||||
SQLAlchemy Select statement
|
||||
|
|
@ -317,11 +314,7 @@ async def get_sessions_for_peer(
|
|||
.where(models.Session.workspace_name == workspace_name)
|
||||
)
|
||||
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(models.Session.is_active == is_active)
|
||||
|
||||
if filter is not None:
|
||||
stmt = stmt.where(models.Session.h_metadata.contains(filter))
|
||||
stmt = apply_filter(stmt, models.Session, filters)
|
||||
|
||||
stmt: Select[tuple[models.Session]] = stmt.order_by(models.Session.created_at)
|
||||
|
||||
|
|
@ -335,19 +328,14 @@ async def get_sessions_for_peer(
|
|||
|
||||
async def get_sessions(
|
||||
workspace_name: str,
|
||||
is_active: bool | None = None,
|
||||
filter: dict[str, Any] | None = None,
|
||||
filters: dict[str, Any] | None = None,
|
||||
) -> Select[tuple[models.Session]]:
|
||||
"""
|
||||
Get all sessions in a workspace.
|
||||
"""
|
||||
stmt = select(models.Session).where(models.Session.workspace_name == workspace_name)
|
||||
|
||||
if is_active:
|
||||
stmt = stmt.where(models.Session.is_active.is_(True))
|
||||
|
||||
if filter is not None:
|
||||
stmt = stmt.where(models.Session.h_metadata.contains(filter))
|
||||
stmt = apply_filter(stmt, models.Session, filters)
|
||||
|
||||
stmt = stmt.order_by(models.Session.created_at)
|
||||
|
||||
|
|
@ -1261,7 +1249,7 @@ async def get_messages(
|
|||
workspace_name: str,
|
||||
session_name: str,
|
||||
reverse: bool | None = False,
|
||||
filter: dict[str, Any] | None = None,
|
||||
filters: dict[str, Any] | None = None,
|
||||
token_limit: int | None = None,
|
||||
message_count_limit: int | None = None,
|
||||
) -> Select[tuple[models.Message]]:
|
||||
|
|
@ -1275,7 +1263,7 @@ async def get_messages(
|
|||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session
|
||||
reverse: Whether to reverse the order of messages
|
||||
filter: Filter to apply to the messages
|
||||
filters: Filter to apply to the messages
|
||||
token_limit: Maximum number of tokens to include in the messages
|
||||
message_count_limit: Maximum number of messages to include
|
||||
|
||||
|
|
@ -1288,13 +1276,10 @@ async def get_messages(
|
|||
models.Message.session_name == session_name,
|
||||
]
|
||||
|
||||
# Add metadata filter if provided
|
||||
if filter is not None:
|
||||
base_conditions.append(models.Message.h_metadata.contains(filter))
|
||||
|
||||
# Apply message count limit first (takes precedence over token limit)
|
||||
if message_count_limit is not None:
|
||||
stmt = select(models.Message).where(*base_conditions)
|
||||
stmt = apply_filter(stmt, models.Message, filters)
|
||||
# For message count limit, we want the most recent N messages
|
||||
# So we order by id desc to get most recent, then apply limit
|
||||
stmt = stmt.order_by(models.Message.id.desc()).limit(message_count_limit)
|
||||
|
|
@ -1304,7 +1289,6 @@ async def get_messages(
|
|||
stmt = stmt.order_by(models.Message.id.desc())
|
||||
else:
|
||||
stmt = stmt.order_by(models.Message.id.asc())
|
||||
|
||||
elif token_limit is not None:
|
||||
# Apply token limit logic
|
||||
# Create a subquery that calculates running sum of tokens for most recent messages
|
||||
|
|
@ -1325,16 +1309,17 @@ async def get_messages(
|
|||
.join(token_subquery, models.Message.id == token_subquery.c.id)
|
||||
.where(token_subquery.c.running_token_sum <= token_limit)
|
||||
)
|
||||
stmt = apply_filter(stmt, models.Message, filters)
|
||||
|
||||
# Apply final ordering based on reverse parameter
|
||||
if reverse:
|
||||
stmt = stmt.order_by(models.Message.id.desc())
|
||||
else:
|
||||
stmt = stmt.order_by(models.Message.id.asc())
|
||||
|
||||
else:
|
||||
# Default case - no limits applied
|
||||
stmt = select(models.Message).where(*base_conditions)
|
||||
stmt = apply_filter(stmt, models.Message, filters)
|
||||
if reverse:
|
||||
stmt = stmt.order_by(models.Message.id.desc())
|
||||
else:
|
||||
|
|
@ -1398,7 +1383,7 @@ async def get_messages_for_peer(
|
|||
workspace_name: str,
|
||||
peer_name: str,
|
||||
reverse: bool | None = False,
|
||||
filter: dict[str, Any] | None = None,
|
||||
filters: dict[str, Any] | None = None,
|
||||
) -> Select[tuple[models.Message]]:
|
||||
stmt = (
|
||||
select(models.Message)
|
||||
|
|
@ -1407,8 +1392,7 @@ async def get_messages_for_peer(
|
|||
.where(models.Message.session_name.is_(None))
|
||||
)
|
||||
|
||||
if filter is not None:
|
||||
stmt = stmt.where(models.Message.h_metadata.contains(filter))
|
||||
stmt = apply_filter(stmt, models.Message, filters)
|
||||
|
||||
if reverse:
|
||||
stmt = stmt.order_by(models.Message.id.desc())
|
||||
|
|
@ -1531,7 +1515,7 @@ async def query_documents(
|
|||
peer_name: str,
|
||||
collection_name: str,
|
||||
query: str,
|
||||
filter: dict[str, Any] | None = None,
|
||||
filters: dict[str, Any] | None = None,
|
||||
max_distance: float | None = None,
|
||||
top_k: int = 5,
|
||||
) -> Sequence[models.Document]:
|
||||
|
|
@ -1548,8 +1532,7 @@ async def query_documents(
|
|||
stmt = stmt.where(
|
||||
models.Document.embedding.cosine_distance(embedding_query) < max_distance
|
||||
)
|
||||
if filter is not None:
|
||||
stmt = stmt.where(models.Document.internal_metadata.contains(filter))
|
||||
stmt = apply_filter(stmt, models.Document, filters)
|
||||
stmt = stmt.limit(top_k).order_by(
|
||||
models.Document.embedding.cosine_distance(embedding_query)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -63,3 +63,11 @@ class DisabledException(HonchoException):
|
|||
|
||||
status_code = 405
|
||||
detail = "Feature is disabled"
|
||||
|
||||
|
||||
@final
|
||||
class FilterError(HonchoException):
|
||||
"""Exception raised when a filter is misconfigured or invalid."""
|
||||
|
||||
status_code = 422
|
||||
detail = "Invalid filter configuration"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from typing import Any
|
|||
|
||||
from fastapi import APIRouter, BackgroundTasks, Body, Depends, Path, Query
|
||||
from fastapi_pagination import Page
|
||||
from fastapi_pagination.ext.sqlalchemy import paginate
|
||||
from fastapi_pagination.ext.sqlalchemy import apaginate
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql import insert
|
||||
|
||||
|
|
@ -321,20 +321,20 @@ async def get_messages(
|
|||
):
|
||||
"""Get all messages for a session"""
|
||||
try:
|
||||
filter = None
|
||||
filters = None
|
||||
if options and hasattr(options, "filter"):
|
||||
filter = options.filter
|
||||
if filter == {}:
|
||||
filter = None
|
||||
filters = options.filter
|
||||
if filters == {}:
|
||||
filters = None
|
||||
|
||||
messages_query = await crud.get_messages(
|
||||
workspace_name=workspace_id,
|
||||
session_name=session_id,
|
||||
filter=filter,
|
||||
filters=filters,
|
||||
reverse=reverse,
|
||||
)
|
||||
|
||||
return await paginate(db, messages_query)
|
||||
return await apaginate(db, messages_query)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to get messages for session {session_id}: {str(e)}")
|
||||
raise ResourceNotFoundException("Session not found") from e
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from fastapi import (
|
|||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi_pagination import Page
|
||||
from fastapi_pagination.ext.sqlalchemy import paginate
|
||||
from fastapi_pagination.ext.sqlalchemy import apaginate
|
||||
from mirascope.llm import Stream
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -51,9 +51,9 @@ async def get_peers(
|
|||
if filter_param == {}:
|
||||
filter_param = None
|
||||
|
||||
return await paginate(
|
||||
return await apaginate(
|
||||
db,
|
||||
await crud.get_peers(workspace_name=workspace_id, filter=filter_param),
|
||||
await crud.get_peers(workspace_name=workspace_id, filters=filter_param),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -128,23 +128,18 @@ async def get_sessions_for_peer(
|
|||
):
|
||||
"""Get All Sessions for a Peer"""
|
||||
filter_param = None
|
||||
is_active = True
|
||||
|
||||
if options:
|
||||
if hasattr(options, "filter"):
|
||||
filter_param = options.filter
|
||||
if filter_param == {}:
|
||||
filter_param = None
|
||||
if hasattr(options, "is_active"):
|
||||
is_active = options.is_active
|
||||
if options and hasattr(options, "filter"):
|
||||
filter_param = options.filter
|
||||
if filter_param == {}:
|
||||
filter_param = None
|
||||
|
||||
return await paginate(
|
||||
return await apaginate(
|
||||
db,
|
||||
await crud.get_sessions_for_peer(
|
||||
workspace_name=workspace_id,
|
||||
peer_name=peer_id,
|
||||
is_active=is_active,
|
||||
filter=filter_param,
|
||||
filters=filter_param,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -274,20 +269,20 @@ async def get_messages_for_peer(
|
|||
):
|
||||
"""Get all messages for a peer"""
|
||||
try:
|
||||
filter = None
|
||||
filters = None
|
||||
if options and hasattr(options, "filter"):
|
||||
filter = options.filter
|
||||
if filter == {}:
|
||||
filter = None
|
||||
filters = options.filter
|
||||
if filters == {}:
|
||||
filters = None
|
||||
|
||||
messages_query = await crud.get_messages_for_peer(
|
||||
workspace_name=workspace_id,
|
||||
peer_name=peer_id,
|
||||
filter=filter,
|
||||
filters=filters,
|
||||
reverse=reverse,
|
||||
)
|
||||
|
||||
return await paginate(db, messages_query)
|
||||
return await apaginate(db, messages_query)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to get messages for peer {peer_id}: {str(e)}")
|
||||
raise ResourceNotFoundException("Peer not found") from e
|
||||
|
|
@ -337,4 +332,4 @@ async def search_peer(
|
|||
"""Search a Peer"""
|
||||
stmt = await crud.search(query, workspace_name=workspace_id, peer_name=peer_id)
|
||||
|
||||
return await paginate(db, stmt)
|
||||
return await apaginate(db, stmt)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import logging
|
|||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Response
|
||||
from fastapi_pagination import Page
|
||||
from fastapi_pagination.ext.sqlalchemy import paginate
|
||||
from fastapi_pagination.ext.sqlalchemy import apaginate
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import crud, schemas
|
||||
|
|
@ -84,23 +84,14 @@ async def get_sessions(
|
|||
):
|
||||
"""Get All Sessions in a Workspace"""
|
||||
filter_param = None
|
||||
is_active_param = False # Default from schema
|
||||
|
||||
if options:
|
||||
if hasattr(options, "filter") and options.filter:
|
||||
filter_param = options.filter
|
||||
if filter_param == {}: # Explicitly check for empty dict
|
||||
filter_param = None
|
||||
if hasattr(options, "is_active"): # Check if is_active is present
|
||||
is_active_param = options.is_active
|
||||
if options and hasattr(options, "filter") and options.filter:
|
||||
filter_param = options.filter
|
||||
if filter_param == {}: # Explicitly check for empty dict
|
||||
filter_param = None
|
||||
|
||||
return await paginate(
|
||||
db,
|
||||
await crud.get_sessions(
|
||||
workspace_name=workspace_id,
|
||||
is_active=is_active_param,
|
||||
filter=filter_param,
|
||||
),
|
||||
return await apaginate(
|
||||
db, await crud.get_sessions(workspace_name=workspace_id, filters=filter_param)
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -361,7 +352,7 @@ async def get_session_peers(
|
|||
peers_query = await crud.get_peers_from_session(
|
||||
workspace_name=workspace_id, session_name=session_id
|
||||
)
|
||||
return await paginate(db, peers_query)
|
||||
return await apaginate(db, peers_query)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to get peers from session {session_id}: {str(e)}")
|
||||
raise ResourceNotFoundException("Session not found") from e
|
||||
|
|
@ -468,4 +459,4 @@ async def search_session(
|
|||
query, workspace_name=workspace_id, session_name=session_id
|
||||
)
|
||||
|
||||
return await paginate(db, stmt)
|
||||
return await apaginate(db, stmt)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import logging
|
|||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from fastapi_pagination import Page
|
||||
from fastapi_pagination.ext.sqlalchemy import paginate
|
||||
from fastapi_pagination.ext.sqlalchemy import apaginate
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import crud, schemas
|
||||
|
|
@ -65,9 +65,9 @@ async def get_all_workspaces(
|
|||
if filter_param == {}:
|
||||
filter_param = None
|
||||
|
||||
return await paginate(
|
||||
return await apaginate(
|
||||
db,
|
||||
await crud.get_all_workspaces(filter=filter_param),
|
||||
await crud.get_all_workspaces(filters=filter_param),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -104,7 +104,7 @@ async def search_workspace(
|
|||
"""Search a Workspace"""
|
||||
stmt = await crud.search(query, workspace_name=workspace_id)
|
||||
|
||||
return await paginate(db, stmt)
|
||||
return await apaginate(db, stmt)
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
|
|||
|
|
@ -169,7 +169,6 @@ class SessionCreate(SessionBase):
|
|||
|
||||
class SessionGet(SessionBase):
|
||||
filter: dict[str, Any] | None = None
|
||||
is_active: bool = False
|
||||
|
||||
|
||||
class SessionUpdate(SessionBase):
|
||||
|
|
@ -248,6 +247,7 @@ class DialecticResponse(BaseModel):
|
|||
|
||||
class SessionCounts(BaseModel):
|
||||
"""Counts for a specific session in queue processing."""
|
||||
|
||||
completed: int
|
||||
in_progress: int
|
||||
pending: int
|
||||
|
|
@ -255,6 +255,7 @@ class SessionCounts(BaseModel):
|
|||
|
||||
class QueueCounts(BaseModel):
|
||||
"""Aggregated counts for queue processing status."""
|
||||
|
||||
total: int
|
||||
completed: int
|
||||
in_progress: int
|
||||
|
|
@ -264,6 +265,7 @@ class QueueCounts(BaseModel):
|
|||
|
||||
class QueueStatusRow(BaseModel):
|
||||
"""Represents a row from the queue status SQL query result."""
|
||||
|
||||
session_id: str | None
|
||||
total: int
|
||||
completed: int
|
||||
|
|
@ -277,6 +279,7 @@ class QueueStatusRow(BaseModel):
|
|||
|
||||
class PeerConfigResult(BaseModel):
|
||||
"""Result from querying peer configuration data."""
|
||||
|
||||
peer_name: str
|
||||
peer_configuration: dict[str, Any]
|
||||
session_peer_configuration: dict[str, Any]
|
||||
|
|
@ -284,11 +287,13 @@ class PeerConfigResult(BaseModel):
|
|||
|
||||
class SessionPeerData(BaseModel):
|
||||
"""Data for managing session peer relationships."""
|
||||
|
||||
peer_names: dict[str, SessionPeerConfig]
|
||||
|
||||
|
||||
class MessageBulkData(BaseModel):
|
||||
"""Data for bulk message operations."""
|
||||
|
||||
messages: list[MessageCreate]
|
||||
session_name: str
|
||||
workspace_name: str
|
||||
|
|
|
|||
|
|
@ -0,0 +1,568 @@
|
|||
import datetime
|
||||
from collections.abc import Callable
|
||||
from logging import getLogger
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from sqlalchemy import ColumnElement, Select, and_, case, cast, literal, not_, or_
|
||||
from sqlalchemy.types import Numeric
|
||||
|
||||
from ..exceptions import FilterError
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
# Type variable for SQLAlchemy model classes
|
||||
T = TypeVar("T")
|
||||
|
||||
# Module-level constants for comparison operators
|
||||
COMPARISON_OPERATORS = {
|
||||
"gte",
|
||||
"lte",
|
||||
"gt",
|
||||
"lt",
|
||||
"ne",
|
||||
"in",
|
||||
"contains",
|
||||
"icontains",
|
||||
}
|
||||
|
||||
NUMERIC_OPERATORS = {"gte", "lte", "gt", "lt", "ne"}
|
||||
|
||||
ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING = {
|
||||
"id": "name",
|
||||
"created_at": "created_at",
|
||||
"is_active": "is_active",
|
||||
"workspace_id": "workspace_name",
|
||||
"session_id": "session_name",
|
||||
"peer_id": "peer_name",
|
||||
"metadata": "h_metadata",
|
||||
}
|
||||
|
||||
ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_MESSAGES = {
|
||||
"workspace_id": "workspace_name",
|
||||
"session_id": "session_name",
|
||||
"peer_id": "peer_name",
|
||||
"token_count": "token_count",
|
||||
"created_at": "created_at",
|
||||
"metadata": "h_metadata",
|
||||
}
|
||||
|
||||
|
||||
def apply_filter(
|
||||
stmt: Select[tuple[T]], model_class: type[T], filters: dict[str, Any] | None = None
|
||||
) -> Select[tuple[T]]:
|
||||
"""
|
||||
Apply advanced filter to a SQL statement based on filter dictionary.
|
||||
|
||||
Supports logical operators (AND, OR, NOT), comparison operators
|
||||
(gte, lte, gt, lt, ne, contains, icontains, in), and wildcard character (*).
|
||||
|
||||
Note that the filter refers to column names from the user perspective:
|
||||
that means all `*_name` fields are actually `*_id` fields and `h_metadata`
|
||||
is actually `metadata`.
|
||||
|
||||
Examples:
|
||||
# Simple filters (backward compatible)
|
||||
{"peer_id": "alice", "metadata": {"type": "user"}}
|
||||
|
||||
# Logical operators
|
||||
{"AND": [{"peer_id": "alice"}, {"created_at": {"gte": "2024-01-01"}}]}
|
||||
{"OR": [{"peer_id": "alice"}, {"peer_id": "bob"}]}
|
||||
{"NOT": [{"peer_id": "alice"}]}
|
||||
|
||||
# Comparison operators
|
||||
{"created_at": {"gte": "2024-01-01", "lte": "2024-12-31"}}
|
||||
{"peer_id": {"in": ["alice", "bob"]}}
|
||||
|
||||
# Wildcards (matches everything for that field)
|
||||
{"peer_id": "*"}
|
||||
|
||||
Args:
|
||||
stmt: SQLAlchemy Select statement to modify
|
||||
model_class: SQLAlchemy model class for column access
|
||||
filters: Optional filter dictionary
|
||||
|
||||
Returns:
|
||||
Modified Select statement with filter applied if provided
|
||||
|
||||
Raises:
|
||||
FilterError: When the filter contains invalid configuration or values
|
||||
"""
|
||||
if filters is None:
|
||||
return stmt
|
||||
|
||||
conditions = _build_filter_conditions(filters, model_class)
|
||||
if conditions is not None:
|
||||
stmt = stmt.where(conditions)
|
||||
|
||||
return stmt
|
||||
|
||||
|
||||
def _build_filter_conditions(
|
||||
filter_dict: dict[str, Any], model_class: type[Any]
|
||||
) -> ColumnElement[bool] | None:
|
||||
"""
|
||||
Recursively build filter conditions from a filter dictionary.
|
||||
|
||||
Args:
|
||||
filter_dict: Filter dictionary that may contain logical operators
|
||||
model_class: SQLAlchemy model class for column access
|
||||
|
||||
Returns:
|
||||
SQLAlchemy condition object or None
|
||||
"""
|
||||
conditions: list[ColumnElement[bool]] = []
|
||||
|
||||
# Handle logical operators
|
||||
if "AND" in filter_dict:
|
||||
if not isinstance(filter_dict["AND"], list):
|
||||
raise FilterError(
|
||||
f"AND operator must contain a list, got {type(filter_dict['AND']).__name__}"
|
||||
)
|
||||
and_conditions: list[ColumnElement[bool]] = []
|
||||
for sub_filter in filter_dict["AND"]: # pyright: ignore
|
||||
sub_condition = _build_filter_conditions(sub_filter, model_class) # pyright: ignore
|
||||
if sub_condition is not None:
|
||||
and_conditions.append(sub_condition)
|
||||
if and_conditions:
|
||||
conditions.append(and_(*and_conditions))
|
||||
|
||||
if "OR" in filter_dict:
|
||||
if not isinstance(filter_dict["OR"], list):
|
||||
raise FilterError(
|
||||
f"OR operator must contain a list, got {type(filter_dict['OR']).__name__}"
|
||||
)
|
||||
or_conditions: list[ColumnElement[bool]] = []
|
||||
for sub_filter in filter_dict["OR"]: # pyright: ignore
|
||||
sub_condition = _build_filter_conditions(sub_filter, model_class) # pyright: ignore
|
||||
if sub_condition is not None:
|
||||
or_conditions.append(sub_condition)
|
||||
if or_conditions:
|
||||
conditions.append(or_(*or_conditions))
|
||||
|
||||
if "NOT" in filter_dict:
|
||||
if filter_dict["NOT"] is None:
|
||||
raise FilterError("NOT operator cannot be None")
|
||||
if not isinstance(filter_dict["NOT"], list):
|
||||
raise FilterError(
|
||||
f"NOT operator must contain a list, got {type(filter_dict['NOT']).__name__}"
|
||||
)
|
||||
not_conditions: list[ColumnElement[bool]] = []
|
||||
for sub_filter in filter_dict["NOT"]: # pyright: ignore
|
||||
sub_condition = _build_filter_conditions(sub_filter, model_class) # pyright: ignore
|
||||
if sub_condition is not None:
|
||||
not_conditions.append(
|
||||
not_(sub_condition)
|
||||
) # Apply NOT to each condition individually
|
||||
if not_conditions:
|
||||
conditions.append(and_(*not_conditions)) # Then AND them together
|
||||
|
||||
# Handle field-level conditions (skip logical operator keys)
|
||||
logical_keys = {"AND", "OR", "NOT"}
|
||||
for key, value in filter_dict.items():
|
||||
if key in logical_keys:
|
||||
continue
|
||||
|
||||
condition = _build_field_condition(key, value, model_class)
|
||||
if condition is not None:
|
||||
conditions.append(condition)
|
||||
|
||||
# Combine all conditions with AND
|
||||
if len(conditions) == 0:
|
||||
return None
|
||||
elif len(conditions) == 1:
|
||||
return conditions[0]
|
||||
else:
|
||||
return and_(*conditions)
|
||||
|
||||
|
||||
def _build_field_condition(
|
||||
key: str, value: Any, model_class: type[Any]
|
||||
) -> ColumnElement[bool] | None:
|
||||
"""
|
||||
Build a condition for a single field.
|
||||
|
||||
Args:
|
||||
key: Field name
|
||||
value: Field value or comparison dict
|
||||
model_class: SQLAlchemy model class
|
||||
|
||||
Returns:
|
||||
SQLAlchemy condition object or None
|
||||
"""
|
||||
if model_class.__name__ == "Message":
|
||||
column_name = ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_MESSAGES.get(key)
|
||||
else:
|
||||
column_name = ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING.get(key)
|
||||
|
||||
if column_name is None:
|
||||
raise FilterError(
|
||||
f"Column '{key}' is not allowed to be filtered on or does not exist on {model_class.__name__}"
|
||||
)
|
||||
|
||||
# Check if the column exists on the model
|
||||
if not hasattr(model_class, column_name):
|
||||
raise FilterError(f"Column '{key}' does not exist on {model_class.__name__}")
|
||||
|
||||
column = getattr(model_class, column_name)
|
||||
|
||||
# Handle wildcard - matches everything, so no condition needed
|
||||
if value == "*":
|
||||
return None
|
||||
|
||||
# Handle comparison operators vs regular values
|
||||
if isinstance(value, dict):
|
||||
# Check if this is a comparison operators dict by looking for known operators
|
||||
is_comparison_dict = any(op_key in COMPARISON_OPERATORS for op_key in value) # pyright: ignore
|
||||
|
||||
if is_comparison_dict:
|
||||
return _build_comparison_conditions(column, column_name, value) # pyright: ignore
|
||||
else:
|
||||
# This is a regular value that happens to be a dict
|
||||
# For JSONB fields (metadata, configuration), check if it contains nested comparison operators
|
||||
if column_name in ("h_metadata", "configuration"):
|
||||
return _build_nested_metadata_conditions(column, value) # pyright: ignore
|
||||
else:
|
||||
return column == value
|
||||
else:
|
||||
if column_name in ("h_metadata", "configuration"):
|
||||
return column.contains(value)
|
||||
else:
|
||||
return column == value
|
||||
|
||||
|
||||
def _safe_numeric_cast(
|
||||
column_accessor: ColumnElement[Any], op_value: Any
|
||||
) -> tuple[ColumnElement[Any], Any]:
|
||||
"""
|
||||
Safely cast JSONB column accessor to appropriate type for comparison.
|
||||
|
||||
Args:
|
||||
column_accessor: SQLAlchemy JSONB column accessor (.astext)
|
||||
op_value: The value to compare against
|
||||
|
||||
Returns:
|
||||
Tuple of (cast_column_accessor, cast_op_value) for typed comparison
|
||||
or (column_accessor, str_op_value) for string comparison
|
||||
"""
|
||||
try:
|
||||
if isinstance(op_value, bool):
|
||||
# For boolean values, compare with the string representation
|
||||
# PostgreSQL JSONB stores booleans as "true"/"false" strings when extracted with ->>
|
||||
return column_accessor, str(op_value).lower()
|
||||
|
||||
# For numeric values, use a safer cast that handles empty strings and invalid values
|
||||
# We use CASE WHEN to handle empty strings and non-numeric values gracefully
|
||||
safe_cast = case(
|
||||
(column_accessor == "", literal(None)), # Empty string -> NULL
|
||||
(column_accessor.is_(None), literal(None)), # NULL -> NULL
|
||||
else_=cast(column_accessor, Numeric()),
|
||||
)
|
||||
|
||||
if isinstance(op_value, int | float):
|
||||
return safe_cast, op_value
|
||||
else:
|
||||
# Try to parse as numeric (handles both strings and other types)
|
||||
try:
|
||||
# Try int first, then float
|
||||
parsed_value = int(op_value)
|
||||
return safe_cast, parsed_value
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
parsed_value = float(op_value)
|
||||
return safe_cast, parsed_value
|
||||
except (ValueError, TypeError):
|
||||
if isinstance(op_value, str):
|
||||
# If it's not numeric, treat as string comparison (e.g., dates, text)
|
||||
# This allows date strings like "2024-02-01" to be compared lexicographically
|
||||
return column_accessor, str(op_value)
|
||||
else:
|
||||
raise FilterError(
|
||||
f"Invalid value for numeric operator: {op_value}. Expected a number, got {type(op_value).__name__}"
|
||||
) from None
|
||||
except Exception as e:
|
||||
raise FilterError(
|
||||
f"Failed to process numeric cast for value '{op_value}': {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
def _build_comparison_condition(
|
||||
column: Any, field_name: str, operator: str, op_value: Any
|
||||
) -> ColumnElement[bool] | None:
|
||||
"""
|
||||
Build a single comparison condition for a JSONB field.
|
||||
|
||||
Args:
|
||||
column: SQLAlchemy JSONB column object
|
||||
field_name: Name of the field in the JSONB column
|
||||
operator: Comparison operator
|
||||
op_value: Value to compare against
|
||||
|
||||
Returns:
|
||||
SQLAlchemy condition object or None
|
||||
"""
|
||||
# Validate that the operator is supported
|
||||
if operator not in COMPARISON_OPERATORS:
|
||||
raise FilterError(f"Unsupported comparison operator: {operator}")
|
||||
|
||||
# Handle wildcard - matches everything, so no condition needed
|
||||
if op_value == "*":
|
||||
return None
|
||||
|
||||
field_accessor = column[field_name].astext
|
||||
|
||||
# Mapping of operators to their SQLAlchemy methods
|
||||
if operator in NUMERIC_OPERATORS:
|
||||
try:
|
||||
safe_accessor, safe_value = _safe_numeric_cast(field_accessor, op_value)
|
||||
operator_map: dict[str, Callable[[Any, Any], ColumnElement[bool]]] = {
|
||||
"gte": lambda a, v: a >= v,
|
||||
"lte": lambda a, v: a <= v,
|
||||
"gt": lambda a, v: a > v,
|
||||
"lt": lambda a, v: a < v,
|
||||
"ne": lambda a, v: a != v,
|
||||
}
|
||||
return operator_map[operator](safe_accessor, safe_value)
|
||||
except Exception as e:
|
||||
raise FilterError(
|
||||
f"Failed to build numeric comparison condition for operator '{operator}' with value '{op_value}': {str(e)}"
|
||||
) from e
|
||||
elif operator == "in":
|
||||
if hasattr(op_value, "__iter__") and not isinstance(op_value, str | bytes):
|
||||
# Handle wildcard in iterable - if present, matches everything, so no condition needed
|
||||
if "*" in op_value:
|
||||
return None
|
||||
return field_accessor.in_([str(v) for v in op_value])
|
||||
else:
|
||||
raise FilterError(
|
||||
f"Invalid value for 'in' operator: {op_value}. Expected an iterable (list, tuple, set), got {type(op_value).__name__}"
|
||||
)
|
||||
elif operator in ("contains", "icontains"):
|
||||
return field_accessor.ilike(f"%{op_value}%")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _build_nested_metadata_conditions(
|
||||
column: Any, metadata_dict: dict[str, Any]
|
||||
) -> ColumnElement[bool] | None:
|
||||
"""
|
||||
Build conditions for nested metadata fields with comparison operators.
|
||||
|
||||
Args:
|
||||
column: SQLAlchemy JSONB column object
|
||||
metadata_dict: Dictionary containing nested field conditions
|
||||
|
||||
Returns:
|
||||
Combined SQLAlchemy condition object or None
|
||||
"""
|
||||
conditions: list[ColumnElement[bool]] = []
|
||||
|
||||
for field_name, field_value in metadata_dict.items():
|
||||
if isinstance(field_value, dict) and any(
|
||||
op in COMPARISON_OPERATORS
|
||||
for op in field_value # pyright: ignore
|
||||
):
|
||||
# This field has comparison operators
|
||||
field_conditions: list[ColumnElement[bool]] = []
|
||||
for operator, op_value in field_value.items(): # pyright: ignore
|
||||
condition = _build_comparison_condition(
|
||||
column,
|
||||
field_name,
|
||||
operator, # pyright: ignore
|
||||
op_value,
|
||||
)
|
||||
if condition is not None:
|
||||
field_conditions.append(condition)
|
||||
|
||||
if field_conditions:
|
||||
conditions.append(
|
||||
field_conditions[0]
|
||||
if len(field_conditions) == 1
|
||||
else and_(*field_conditions)
|
||||
)
|
||||
else:
|
||||
# Handle wildcard - matches everything, so no condition needed
|
||||
if field_value == "*":
|
||||
continue
|
||||
# Regular field equality - use JSONB contains for nested object matching
|
||||
conditions.append(column.contains({field_name: field_value}))
|
||||
|
||||
# Combine all field conditions with AND
|
||||
return _combine_conditions_with_and(conditions)
|
||||
|
||||
|
||||
def _combine_conditions_with_and(
|
||||
conditions: list[ColumnElement[bool]],
|
||||
) -> ColumnElement[bool] | None:
|
||||
"""
|
||||
Combine a list of conditions with AND logic.
|
||||
|
||||
Args:
|
||||
conditions: List of SQLAlchemy condition objects
|
||||
|
||||
Returns:
|
||||
Combined condition object or None if no conditions
|
||||
"""
|
||||
if not conditions:
|
||||
return None
|
||||
elif len(conditions) == 1:
|
||||
return conditions[0]
|
||||
else:
|
||||
return and_(*conditions)
|
||||
|
||||
|
||||
def _build_comparison_conditions(
|
||||
column: Any, column_name: str, comparisons: dict[str, Any]
|
||||
) -> ColumnElement[bool] | None:
|
||||
"""
|
||||
Build comparison conditions for a single column.
|
||||
|
||||
Args:
|
||||
column: SQLAlchemy column object
|
||||
column_name: Name of the column
|
||||
comparisons: Dictionary of comparison operators and values
|
||||
|
||||
Returns:
|
||||
Combined SQLAlchemy condition object or None
|
||||
"""
|
||||
conditions: list[ColumnElement[bool]] = []
|
||||
|
||||
# Check if this is a datetime column
|
||||
is_datetime_column = hasattr(column.type, "python_type") and issubclass(
|
||||
column.type.python_type, datetime.datetime
|
||||
)
|
||||
|
||||
for operator, op_value in comparisons.items():
|
||||
# Validate that the operator is supported
|
||||
if operator not in COMPARISON_OPERATORS:
|
||||
raise FilterError(f"Unsupported comparison operator: {operator}")
|
||||
|
||||
# Handle wildcard - matches everything, so no condition needed
|
||||
if op_value == "*":
|
||||
continue
|
||||
|
||||
condition = None
|
||||
|
||||
# For datetime columns, cast string values to timestamp
|
||||
if is_datetime_column and isinstance(op_value, str):
|
||||
# Validate datetime string to prevent SQL injection
|
||||
validated_datetime = _validate_datetime_string(op_value)
|
||||
if validated_datetime is None:
|
||||
# Raise error if datetime validation fails
|
||||
raise FilterError(f"Invalid datetime value: {op_value}")
|
||||
|
||||
# Use the validated datetime object directly instead of string interpolation
|
||||
casted_value = validated_datetime
|
||||
else:
|
||||
# if the operator is a numeric operator, the value must cast to a number
|
||||
if operator in NUMERIC_OPERATORS:
|
||||
try:
|
||||
casted_value = float(op_value)
|
||||
except ValueError:
|
||||
raise FilterError(
|
||||
f"Invalid numeric value: {op_value}. Expected a number, got {type(op_value).__name__}"
|
||||
) from None
|
||||
else:
|
||||
casted_value = op_value
|
||||
|
||||
if operator == "gte":
|
||||
condition = column >= casted_value
|
||||
elif operator == "lte":
|
||||
condition = column <= casted_value
|
||||
elif operator == "gt":
|
||||
condition = column > casted_value
|
||||
elif operator == "lt":
|
||||
condition = column < casted_value
|
||||
elif operator == "ne":
|
||||
condition = column != casted_value
|
||||
elif operator == "in":
|
||||
if hasattr(op_value, "__iter__") and not isinstance(op_value, str | bytes):
|
||||
# Handle wildcard in iterable - if present, matches everything, so no condition needed
|
||||
if "*" in op_value:
|
||||
continue
|
||||
else:
|
||||
if is_datetime_column:
|
||||
# Validate and cast each datetime string value
|
||||
casted_values: list[str | datetime.datetime] = []
|
||||
for val in op_value:
|
||||
if isinstance(val, str):
|
||||
validated_datetime = _validate_datetime_string(val)
|
||||
if validated_datetime is None:
|
||||
raise FilterError(
|
||||
f"Invalid datetime value in list: {val}"
|
||||
)
|
||||
casted_values.append(validated_datetime)
|
||||
else:
|
||||
casted_values.append(val)
|
||||
if casted_values:
|
||||
condition = column.in_(casted_values)
|
||||
else:
|
||||
condition = column.in_(list(op_value))
|
||||
else:
|
||||
raise FilterError(
|
||||
f"Invalid value for 'in' operator: {op_value}. Expected an iterable (list, tuple, set), got {type(op_value).__name__}"
|
||||
)
|
||||
elif operator == "contains":
|
||||
if column_name == "h_metadata":
|
||||
# For JSONB columns, use JSONB contains
|
||||
condition = column.contains(op_value)
|
||||
else:
|
||||
# For text columns, use ILIKE
|
||||
condition = column.ilike(f"%{op_value}%")
|
||||
elif operator == "icontains":
|
||||
# Case-insensitive contains for text columns
|
||||
condition = column.ilike(f"%{op_value}%")
|
||||
|
||||
if condition is not None:
|
||||
conditions.append(condition)
|
||||
|
||||
# Combine all conditions for this field with AND
|
||||
if len(conditions) == 0:
|
||||
return None
|
||||
elif len(conditions) == 1:
|
||||
return conditions[0]
|
||||
else:
|
||||
return and_(*conditions)
|
||||
|
||||
|
||||
def _validate_datetime_string(value: str) -> datetime.datetime | None:
|
||||
"""
|
||||
Safely validate and parse a datetime string to prevent SQL injection.
|
||||
|
||||
This function attempts to parse the datetime string using multiple common formats
|
||||
to ensure it's a valid datetime before allowing it to be used in SQL queries.
|
||||
|
||||
Args:
|
||||
value: String value to validate as datetime
|
||||
|
||||
Returns:
|
||||
Parsed datetime object if valid, None if invalid
|
||||
"""
|
||||
# Strip whitespace
|
||||
value = value.strip()
|
||||
|
||||
# Try to parse with various common datetime formats
|
||||
datetime_formats = [
|
||||
"%Y-%m-%d %H:%M:%S", # 2024-01-01 12:00:00
|
||||
"%Y-%m-%d %H:%M:%S.%f", # 2024-01-01 12:00:00.123456
|
||||
"%Y-%m-%dT%H:%M:%S", # 2024-01-01T12:00:00 (ISO format)
|
||||
"%Y-%m-%dT%H:%M:%S.%f", # 2024-01-01T12:00:00.123456
|
||||
"%Y-%m-%dT%H:%M:%SZ", # 2024-01-01T12:00:00Z (UTC)
|
||||
"%Y-%m-%dT%H:%M:%S.%fZ", # 2024-01-01T12:00:00.123456Z
|
||||
"%Y-%m-%d", # 2024-01-01
|
||||
]
|
||||
|
||||
for fmt in datetime_formats:
|
||||
try:
|
||||
return datetime.datetime.strptime(value, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Try fromisoformat as a fallback (Python 3.7+)
|
||||
try:
|
||||
return datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Return None for invalid datetime - let the caller handle the error
|
||||
return None
|
||||
|
|
@ -375,12 +375,12 @@ async def test_get_filtered_messages(
|
|||
|
||||
response = client.post(
|
||||
f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list",
|
||||
json={"filter": {"key": "value2"}},
|
||||
json={"filter": {"metadata": {"key": "value2"}}},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert len(data["items"]) > 0
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["content"] == "Test message 2"
|
||||
assert data["items"][0]["peer_id"] == test_peer.name
|
||||
assert data["items"][0]["session_id"] == test_session.name
|
||||
|
|
@ -427,10 +427,10 @@ async def test_get_filtered_messages_with_complex_filter(
|
|||
db_session.add(test_message3)
|
||||
await db_session.commit()
|
||||
|
||||
# Filter by multiple criteria
|
||||
# Test old-style filter (backward compatibility)
|
||||
response = client.post(
|
||||
f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list",
|
||||
json={"filter": {"priority": "high", "category": "technical"}},
|
||||
json={"filter": {"metadata": {"priority": "high", "category": "technical"}}},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
|
@ -438,6 +438,40 @@ async def test_get_filtered_messages_with_complex_filter(
|
|||
# Should return messages 1 and 2 (both have high priority and technical category)
|
||||
assert len(data["items"]) >= 2
|
||||
|
||||
# Test new-style filter with AND operator
|
||||
response = client.post(
|
||||
f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list",
|
||||
json={
|
||||
"filter": {
|
||||
"AND": [
|
||||
{"metadata": {"priority": "high"}},
|
||||
{"metadata": {"category": "technical"}},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
# Test OR filter to get high priority OR question type
|
||||
response = client.post(
|
||||
f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list",
|
||||
json={
|
||||
"filter": {
|
||||
"OR": [
|
||||
{"metadata": {"priority": "high"}},
|
||||
{"metadata": {"type": "question"}},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert len(data["items"]) == 3 # All messages should match
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_message(
|
||||
|
|
|
|||
|
|
@ -110,17 +110,29 @@ def test_get_peers(client: TestClient, sample_data: tuple[Workspace, Peer]):
|
|||
assert "items" in data
|
||||
assert len(data["items"]) > 0
|
||||
|
||||
# Get peers with filter
|
||||
# Get peers with simple filter (backward compatibility)
|
||||
response = client.post(
|
||||
f"/v2/workspaces/{test_workspace.name}/peers/list",
|
||||
json={"filter": {"peer_key": "peer_value"}},
|
||||
json={"filter": {"metadata": {"peer_key": "peer_value"}}},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert len(data["items"]) >= 2
|
||||
assert len(data["items"]) == 2
|
||||
assert data["items"][0]["metadata"]["peer_key"] == "peer_value"
|
||||
|
||||
# Test new filter with NOT operator
|
||||
response = client.post(
|
||||
f"/v2/workspaces/{test_workspace.name}/peers/list",
|
||||
json={"filter": {"NOT": [{"metadata": {"peer_key": "peer_value2"}}]}},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
# Should find peers that don't have peer_key = "peer_value2"
|
||||
# This includes the 2 peers with "peer_value" + the sample peer with empty metadata
|
||||
assert len(data["items"]) == 3
|
||||
|
||||
|
||||
def test_get_peers_with_empty_filter(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
|
|
@ -275,33 +287,6 @@ def test_get_sessions_for_peer(client: TestClient, sample_data: tuple[Workspace,
|
|||
assert len(data["items"]) == 1
|
||||
|
||||
|
||||
def test_get_sessions_for_peer_with_is_active_filter(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
"""Test getting sessions for peer with is_active parameter"""
|
||||
test_workspace, test_peer = sample_data
|
||||
|
||||
# Create and then delete a session to have inactive session
|
||||
session_name = str(generate_nanoid())
|
||||
client.post(
|
||||
f"/v2/workspaces/{test_workspace.name}/sessions",
|
||||
json={"id": session_name, "peer_names": {test_peer.name: {}}},
|
||||
)
|
||||
client.delete(f"/v2/workspaces/{test_workspace.name}/sessions/{session_name}")
|
||||
|
||||
# Test getting inactive sessions
|
||||
response = client.post(
|
||||
f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions",
|
||||
json={"is_active": False},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
# Should find at least our deleted session
|
||||
inactive_sessions = [s for s in data["items"] if not s["is_active"]]
|
||||
assert len(inactive_sessions) > 0
|
||||
|
||||
|
||||
def test_get_sessions_for_peer_with_empty_filter(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ def test_get_peer_by_name_with_auth(
|
|||
# Use POST /list endpoint to get peers
|
||||
response = auth_client.post(
|
||||
f"/v2/workspaces/{test_workspace.name}/peers/list",
|
||||
json={"filter": {"name": test_peer.name}},
|
||||
json={"filter": {"id": test_peer.name}},
|
||||
)
|
||||
|
||||
# Admin JWT or JWT with matching workspace should be allowed
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ def test_create_session_with_too_many_peers(
|
|||
|
||||
session_response = client.post(
|
||||
f"/v2/workspaces/{test_workspace.name}/sessions/list",
|
||||
json={"filter": {"name": "test_session"}},
|
||||
json={"filter": {"id": "test_session"}},
|
||||
)
|
||||
assert session_response.status_code == 200
|
||||
assert len(session_response.json()["items"]) == 0
|
||||
|
|
@ -186,42 +186,16 @@ def test_get_sessions(client: TestClient, sample_data: tuple[Workspace, Peer]):
|
|||
assert data["workspace_id"] == test_workspace.name
|
||||
response = client.post(
|
||||
f"/v2/workspaces/{test_workspace.name}/sessions/list",
|
||||
json={"filter": {"test_key": "test_value"}},
|
||||
json={"filter": {"metadata": {"test_key": "test_value"}}},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert len(data["items"]) > 0
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["metadata"] == {"test_key": "test_value"}
|
||||
assert data["items"][0]["workspace_id"] == test_workspace.name
|
||||
|
||||
|
||||
def test_get_sessions_with_is_active_filter(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
"""Test session listing with is_active parameter"""
|
||||
test_workspace, test_peer = sample_data
|
||||
|
||||
# Create and then delete a session to have inactive session
|
||||
session_id = str(generate_nanoid())
|
||||
client.post(
|
||||
f"/v2/workspaces/{test_workspace.name}/sessions",
|
||||
json={"id": session_id, "peer_names": {test_peer.name: {}}},
|
||||
)
|
||||
client.delete(f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}")
|
||||
|
||||
# Test getting inactive sessions
|
||||
response = client.post(
|
||||
f"/v2/workspaces/{test_workspace.name}/sessions/list", json={"is_active": False}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
# Should find at least our deleted session
|
||||
inactive_sessions = [s for s in data["items"] if not s["is_active"]]
|
||||
assert len(inactive_sessions) > 0
|
||||
|
||||
|
||||
def test_get_sessions_with_empty_filter(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
|
|
@ -382,7 +356,7 @@ def test_delete_session(client: TestClient, sample_data: tuple[Workspace, Peer])
|
|||
# Check that session is marked as inactive
|
||||
response = client.post(
|
||||
f"/v2/workspaces/{test_workspace.name}/sessions/list",
|
||||
json={"is_active": False},
|
||||
json={"filter": {"is_active": False}},
|
||||
)
|
||||
data = response.json()
|
||||
# Find our session in the inactive sessions
|
||||
|
|
|
|||
|
|
@ -91,12 +91,12 @@ async def test_get_all_workspaces(client: TestClient):
|
|||
|
||||
response = client.post(
|
||||
"/v2/workspaces/list",
|
||||
json={"filter": {"test_key": "test_value"}},
|
||||
json={"filter": {"metadata": {"test_key": "test_value"}}},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert len(data["items"]) > 0
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["metadata"] == {"test_key": "test_value"}
|
||||
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue