|
1 | 1 | from datetime import date, datetime, timezone |
2 | 2 | from enum import Enum |
3 | | -from typing import List, Optional |
| 3 | +from typing import List, Optional, Literal |
4 | 4 |
|
5 | | -from pydantic import BaseModel, Field |
| 5 | +from pydantic import BaseModel, Field, model_validator |
6 | 6 |
|
7 | 7 | from .firefly_models import ( |
8 | 8 | AccountRead, |
@@ -282,19 +282,120 @@ class GetTransactionsRequest(BaseModel): |
282 | 282 |
|
283 | 283 |
|
284 | 284 | class SearchTransactionsRequest(BaseModel): |
285 | | - query: str = Field( |
286 | | - ..., |
287 | | - description='Text to search for in transaction descriptions, account names, and other transaction fields', |
| 285 | + query: str | None = Field( |
| 286 | + None, |
| 287 | + description='Free-text search or raw Firefly III query string. Can be combined with structured filters below.', |
288 | 288 | ) |
289 | | - page: Optional[int] = Field( |
| 289 | + |
| 290 | + # Transaction type and amount filters |
| 291 | + type: Literal['withdrawal', 'deposit', 'transfer'] | None = Field( |
| 292 | + None, |
| 293 | + description='Transaction type to filter by', |
| 294 | + examples=['withdrawal', 'deposit', 'transfer'], |
| 295 | + ) |
| 296 | + amount_equals: float | None = Field( |
| 297 | + None, |
| 298 | + description='Exact amount to match', |
| 299 | + examples=[123.45], |
| 300 | + ) |
| 301 | + amount_more: float | None = Field( |
| 302 | + None, |
| 303 | + description='Minimum amount (inclusive)', |
| 304 | + examples=[100.00], |
| 305 | + ) |
| 306 | + amount_less: float | None = Field( |
| 307 | + None, |
| 308 | + description='Maximum amount (inclusive)', |
| 309 | + examples=[50.00], |
| 310 | + ) |
| 311 | + |
| 312 | + # Date filters |
| 313 | + date_on: date | None = Field( |
| 314 | + None, |
| 315 | + description='Exact date match in YYYY-MM-DD format', |
| 316 | + examples=['2024-01-15'], |
| 317 | + ) |
| 318 | + date_after: date | None = Field( |
| 319 | + None, |
| 320 | + description='From date (inclusive) in YYYY-MM-DD format', |
| 321 | + examples=['2024-01-01'], |
| 322 | + ) |
| 323 | + date_before: date | None = Field( |
| 324 | + None, |
| 325 | + description='Until date (inclusive) in YYYY-MM-DD format', |
| 326 | + examples=['2024-12-31'], |
| 327 | + ) |
| 328 | + |
| 329 | + # Content filters |
| 330 | + description_contains: str | None = Field( |
| 331 | + None, |
| 332 | + description='Text to search for in transaction descriptions', |
| 333 | + examples=['groceries', 'coffee'], |
| 334 | + ) |
| 335 | + |
| 336 | + # Metadata filters |
| 337 | + category: str | None = Field( |
| 338 | + None, |
| 339 | + description='Category name to filter by (exact match)', |
| 340 | + examples=['Food', 'Transportation'], |
| 341 | + ) |
| 342 | + budget: str | None = Field( |
| 343 | + None, |
| 344 | + description='Budget name to filter by (exact match)', |
| 345 | + examples=['Groceries', 'Dining Out'], |
| 346 | + ) |
| 347 | + |
| 348 | + # Account filters |
| 349 | + account_contains: str | None = Field( |
| 350 | + None, |
| 351 | + description='Text to search for in any account name (source or destination)', |
| 352 | + examples=['checking', 'savings'], |
| 353 | + ) |
| 354 | + account_id: str | None = Field( |
| 355 | + None, |
| 356 | + description='Account ID to filter by (matches source or destination account)', |
| 357 | + examples=['123'], |
| 358 | + ) |
| 359 | + |
| 360 | + # Pagination |
| 361 | + page: int | None = Field( |
290 | 362 | 1, |
291 | 363 | description='Page number to retrieve (1-based). Use for browsing large result sets.', |
292 | 364 | ge=1, |
293 | 365 | ) |
294 | | - limit: Optional[int] = Field( |
| 366 | + limit: int | None = Field( |
295 | 367 | 50, description='Maximum number of transactions to return per page (1-500)', ge=1, le=500 |
296 | 368 | ) |
297 | 369 |
|
| 370 | + @model_validator(mode='after') |
| 371 | + def validate_search_criteria(self): |
| 372 | + """Ensure at least one search criterion is provided.""" |
| 373 | + search_fields = [ |
| 374 | + self.query, |
| 375 | + self.type, |
| 376 | + self.amount_equals, |
| 377 | + self.amount_more, |
| 378 | + self.amount_less, |
| 379 | + self.date_on, |
| 380 | + self.date_after, |
| 381 | + self.date_before, |
| 382 | + self.description_contains, |
| 383 | + self.category, |
| 384 | + self.budget, |
| 385 | + self.account_contains, |
| 386 | + self.account_id, |
| 387 | + ] |
| 388 | + # Consider a field provided if: (a) it's not None and not a string, or |
| 389 | + # (b) it's a string and not empty/whitespace-only |
| 390 | + has_criteria = any( |
| 391 | + (field is not None and not isinstance(field, str)) |
| 392 | + or (isinstance(field, str) and field.strip() != '') |
| 393 | + for field in search_fields |
| 394 | + ) |
| 395 | + if not has_criteria: |
| 396 | + raise ValueError('At least one search criterion must be provided') |
| 397 | + return self |
| 398 | + |
298 | 399 |
|
299 | 400 | class DeleteTransactionRequest(BaseModel): |
300 | 401 | id: str = Field(..., description='Unique identifier of the transaction to permanently remove') |
|
0 commit comments