Files
TimeTracker/app/services/task_service.py
Dries Peeters 1e777e590f Fix task view endpoint HTTP 500 errors
- Fix incorrect relationship name: Comment.user -> Comment.author
  The Comment model uses 'author' relationship, not 'user'

- Fix eager loading of dynamic relationships
  Remove invalid eager loading attempts for Task.activities and
  Task.time_entries, which are dynamic relationships (lazy='dynamic')
  and cannot be eager loaded with joinedload()

- Query dynamic relationships correctly
  Update task view route to properly query time_entries and activities
  using their dynamic relationship query objects, with proper eager
  loading of nested relationships (TimeEntry.user, TaskActivity.user)
  to prevent N+1 queries

Fixes issue where task detail view returned HTTP 500 error after
creating a new task.
2025-11-28 15:26:08 +01:00

299 lines
9.0 KiB
Python

"""
Service for task business logic.
"""
from typing import Optional, Dict, Any, List
from app import db
from app.repositories import TaskRepository, ProjectRepository
from app.models import Task
from app.constants import TaskStatus
from app.utils.db import safe_commit
from app.utils.event_bus import emit_event
from app.constants import WebhookEvent
class TaskService:
"""
Service for task business logic operations.
This service handles all task-related business logic including:
- Creating and updating tasks
- Listing tasks with filtering and pagination
- Getting task details with related data
- Task assignment and status management
All methods use the repository pattern for data access and include
eager loading to prevent N+1 query problems.
Example:
service = TaskService()
result = service.create_task(
name="New Task",
project_id=1,
created_by=user_id
)
if result['success']:
task = result['task']
"""
def __init__(self):
"""
Initialize TaskService with required repositories.
"""
self.task_repo = TaskRepository()
self.project_repo = ProjectRepository()
def create_task(
self,
name: str,
project_id: int,
created_by: int,
description: Optional[str] = None,
assignee_id: Optional[int] = None,
priority: str = 'medium',
due_date: Optional[Any] = None,
estimated_hours: Optional[float] = None
) -> Dict[str, Any]:
"""
Create a new task.
Args:
name: Task name
project_id: Project ID
description: Task description
assignee_id: User ID of assignee
priority: Task priority (low, medium, high)
due_date: Due date
estimated_hours: Estimated hours
created_by: User ID of creator
Returns:
dict with 'success', 'message', and 'task' keys
"""
# Validate project
project = self.project_repo.get_by_id(project_id)
if not project:
return {
'success': False,
'message': 'Invalid project',
'error': 'invalid_project'
}
# Create task
task = self.task_repo.create(
name=name,
project_id=project_id,
description=description,
assigned_to=assignee_id,
priority=priority,
due_date=due_date,
estimated_hours=estimated_hours,
status=TaskStatus.TODO.value,
created_by=created_by
)
if not safe_commit('create_task', {'project_id': project_id, 'created_by': created_by}):
return {
'success': False,
'message': 'Could not create task due to a database error',
'error': 'database_error'
}
# Emit domain event
emit_event(WebhookEvent.TASK_CREATED.value, {
'task_id': task.id,
'project_id': project_id,
'created_by': created_by
})
return {
'success': True,
'message': 'Task created successfully',
'task': task
}
def get_task_with_details(
self,
task_id: int,
include_time_entries: bool = True,
include_comments: bool = True,
include_activities: bool = True
) -> Optional[Task]:
"""
Get task with all related data using eager loading to prevent N+1 queries.
Args:
task_id: The task ID
include_time_entries: Whether to include time entries
include_comments: Whether to include comments
include_activities: Whether to include activities
Returns:
Task with eagerly loaded relations, or None if not found
"""
from sqlalchemy.orm import joinedload
from app.models import TimeEntry, Comment, TaskActivity
query = self.task_repo.query().filter_by(id=task_id)
# Eagerly load project and assignee
query = query.options(
joinedload(Task.project),
joinedload(Task.assigned_user),
joinedload(Task.creator)
)
# Conditionally load relations
# Note: time_entries is a dynamic relationship (lazy='dynamic') and cannot be eager loaded
# Time entries must be queried separately using task.time_entries.order_by(...).all()
if include_comments:
query = query.options(joinedload(Task.comments).joinedload(Comment.author))
# Note: activities is a dynamic relationship (lazy='dynamic') and cannot be eager loaded
# Activities must be queried separately using task.activities.order_by(...).all()
return query.first()
def update_task(
self,
task_id: int,
user_id: int,
**kwargs
) -> Dict[str, Any]:
"""
Update a task.
Returns:
dict with 'success', 'message', and 'task' keys
"""
task = self.task_repo.get_by_id(task_id)
if not task:
return {
'success': False,
'message': 'Task not found',
'error': 'not_found'
}
# Update fields
self.task_repo.update(task, **kwargs)
if not safe_commit('update_task', {'task_id': task_id, 'user_id': user_id}):
return {
'success': False,
'message': 'Could not update task due to a database error',
'error': 'database_error'
}
return {
'success': True,
'message': 'Task updated successfully',
'task': task
}
def get_project_tasks(
self,
project_id: int,
status: Optional[str] = None
) -> List[Task]:
"""Get tasks for a project"""
return self.task_repo.get_by_project(
project_id=project_id,
status=status,
include_relations=True
)
def list_tasks(
self,
status: Optional[str] = None,
priority: Optional[str] = None,
project_id: Optional[int] = None,
assigned_to: Optional[int] = None,
search: Optional[str] = None,
overdue: bool = False,
user_id: Optional[int] = None,
is_admin: bool = False,
page: int = 1,
per_page: int = 20
) -> Dict[str, Any]:
"""
List tasks with filtering and pagination.
Uses eager loading to prevent N+1 queries.
Returns:
dict with 'tasks', 'pagination', and 'total' keys
"""
from sqlalchemy.orm import joinedload
from app.utils.timezone import now_in_app_timezone
query = self.task_repo.query()
# Eagerly load relations to prevent N+1
query = query.options(
joinedload(Task.project),
joinedload(Task.assigned_user),
joinedload(Task.creator)
)
# Apply filters
if status:
query = query.filter(Task.status == status)
if priority:
query = query.filter(Task.priority == priority)
if project_id:
query = query.filter(Task.project_id == project_id)
if assigned_to:
query = query.filter(Task.assigned_to == assigned_to)
if search:
like = f"%{search}%"
query = query.filter(
db.or_(
Task.name.ilike(like),
Task.description.ilike(like)
)
)
# Overdue filter
if overdue:
today_local = now_in_app_timezone().date()
query = query.filter(
Task.due_date < today_local,
Task.status.in_(['todo', 'in_progress', 'review'])
)
# Permission filter - non-admins only see their tasks
if not is_admin and user_id:
query = query.filter(
db.or_(
Task.assigned_to == user_id,
Task.created_by == user_id
)
)
# Order by priority, due date, created date
query = query.order_by(
Task.priority.desc(),
Task.due_date.asc(),
Task.created_at.asc()
)
# Determine pagination
has_filters = bool(status or priority or project_id or assigned_to or search or overdue)
if not has_filters:
per_page = 10000 # Show all if no filters
# Paginate
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
return {
'tasks': pagination.items,
'pagination': pagination,
'total': pagination.total
}