Files
TimeTracker/app/utils/search.py
T
Dries Peeters ef9b66f5e7 fix(api): align client search, OpenAPI version, and Client construction
Global search referenced Client.company, which is not a column on Client, so client matches failed at runtime. Legacy and v1 search, plus search_clients(), now filter on name, email, description, and contact_person; result descriptions use the same fields. Legacy /api/search returns count: 0 for queries shorter than two characters so responses stay consistent.

OpenAPI info.version is taken from get_version_from_setup(), with a config fallback when the resolved version is unknown. get_version_from_setup() also honors TIMETRACKER_VERSION and APP_VERSION for CI and container builds.

Client.__init__ accepts custom_fields. ClientService no longer passes status= into Client(), which the initializer does not support.

Tests add HTTP route contract checks and OpenAPI version alignment, fix subcontractor search fixtures (Client/Task construction and v1 client fixture naming), and update related API integration tests.
2026-04-15 12:57:01 +02:00

154 lines
4.2 KiB
Python

"""
Search utilities for full-text search across the application.
"""
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, or_
from app.models import Client, Comment, Invoice, Project, Task, TimeEntry
def search_projects(query: str, user_id: Optional[int] = None, status: Optional[str] = None) -> List[Project]:
"""
Search projects by name and description.
Args:
query: Search query
user_id: Optional user ID filter
status: Optional status filter
Returns:
List of matching projects
"""
search_term = f"%{query}%"
search_query = Project.query.filter(or_(Project.name.ilike(search_term), Project.description.ilike(search_term)))
if status:
search_query = search_query.filter_by(status=status)
return search_query.order_by(Project.name).all()
def search_time_entries(query: str, user_id: Optional[int] = None, project_id: Optional[int] = None) -> List[TimeEntry]:
"""
Search time entries by notes and tags.
Args:
query: Search query
user_id: Optional user ID filter
project_id: Optional project ID filter
Returns:
List of matching time entries
"""
search_term = f"%{query}%"
search_query = TimeEntry.query.filter(or_(TimeEntry.notes.ilike(search_term), TimeEntry.tags.ilike(search_term)))
if user_id:
search_query = search_query.filter_by(user_id=user_id)
if project_id:
search_query = search_query.filter_by(project_id=project_id)
return search_query.order_by(TimeEntry.start_time.desc()).all()
def search_tasks(query: str, project_id: Optional[int] = None, status: Optional[str] = None) -> List[Task]:
"""
Search tasks by name and description.
Args:
query: Search query
project_id: Optional project ID filter
status: Optional status filter
Returns:
List of matching tasks
"""
search_term = f"%{query}%"
search_query = Task.query.filter(or_(Task.name.ilike(search_term), Task.description.ilike(search_term)))
if project_id:
search_query = search_query.filter_by(project_id=project_id)
if status:
search_query = search_query.filter_by(status=status)
return search_query.order_by(Task.priority.desc(), Task.created_at.desc()).all()
def search_invoices(query: str, status: Optional[str] = None) -> List[Invoice]:
"""
Search invoices by number and client name.
Args:
query: Search query
status: Optional status filter
Returns:
List of matching invoices
"""
search_term = f"%{query}%"
search_query = Invoice.query.filter(
or_(Invoice.invoice_number.ilike(search_term), Invoice.client_name.ilike(search_term))
)
if status:
search_query = search_query.filter_by(status=status)
return search_query.order_by(Invoice.created_at.desc()).all()
def search_clients(query: str) -> List[Client]:
"""
Search clients by name, email, and company.
Args:
query: Search query
Returns:
List of matching clients
"""
search_term = f"%{query}%"
return (
Client.query.filter(
or_(
Client.name.ilike(search_term),
Client.email.ilike(search_term),
Client.description.ilike(search_term),
Client.contact_person.ilike(search_term),
)
)
.order_by(Client.name)
.all()
)
def global_search(query: str, user_id: Optional[int] = None, limit_per_type: int = 10) -> Dict[str, List[Any]]:
"""
Perform a global search across all entities.
Args:
query: Search query
user_id: Optional user ID filter
limit_per_type: Maximum results per entity type
Returns:
dict with search results by entity type
"""
results = {
"projects": search_projects(query, user_id=user_id)[:limit_per_type],
"time_entries": search_time_entries(query, user_id=user_id)[:limit_per_type],
"tasks": search_tasks(query)[:limit_per_type],
"invoices": search_invoices(query)[:limit_per_type],
"clients": search_clients(query)[:limit_per_type],
}
return results