Files
TimeTracker/app/services/project_template_service.py
T
Dries Peeters 66ca9589bb Fix project template tasks creation and editing
- Fix tasks not being created when projects are created from templates
  * Remove invalid status parameter from task creation (TaskService doesn't accept it)
  * Fix estimated_hours conversion from string to float/None
  * Add proper error handling for task creation failures
  * Refresh template from database before reading tasks

- Fix missing tasks section in template edit form
  * Add tasks section to edit template form with full CRUD functionality
  * Display existing tasks when editing templates
  * Allow adding, editing, and removing tasks in templates

- Fix tasks not being saved when creating/editing templates
  * Fix JavaScript form handling to properly collect tasks from form
  * Create tasks input field on page load and update on submit
  * Add capture phase event listener to ensure tasks are set before form submission
  * Ensure tasks are always saved as a list (never None)

- Fix [object Object] error in toast notifications
  * Add safeguards to ensure flash messages are always strings
  * Add object-to-string conversion in toast notification system

- Improve error handling and validation
  * Add validation to ensure tasks is always a list
  * Improve JSON parsing with better error handling
  * Add safeguards for flash message handling
2025-12-29 20:33:56 +01:00

316 lines
12 KiB
Python

"""
Service for project template business logic.
"""
from typing import Optional, List, Dict, Any
from app import db
from app.models import ProjectTemplate, Project, Task, User
from app.utils.db import safe_commit
from app.utils.event_bus import emit_event
from app.constants import WebhookEvent
class ProjectTemplateService:
"""
Service for project template operations.
"""
def create_template(
self,
name: str,
created_by: int,
description: Optional[str] = None,
config: Optional[Dict[str, Any]] = None,
tasks: Optional[List[Dict[str, Any]]] = None,
category: Optional[str] = None,
tags: Optional[List[str]] = None,
is_public: bool = False,
) -> Dict[str, Any]:
"""
Create a new project template.
Returns:
dict with 'success', 'message', and 'template' keys
"""
try:
# Ensure tasks is a list
tasks_list = tasks if tasks and isinstance(tasks, list) else []
template = ProjectTemplate(
name=name,
description=description,
config=config or {},
tasks=tasks_list,
category=category,
tags=tags or [],
is_public=is_public,
created_by=created_by,
)
db.session.add(template)
if not safe_commit("create_template", {"name": name}):
return {"success": False, "message": "Could not create template due to a database error."}
emit_event(
WebhookEvent.PROJECT_TEMPLATE_CREATED,
{"template_id": template.id, "template_name": template.name, "created_by": created_by},
)
return {"success": True, "message": "Template created successfully.", "template": template}
except Exception as e:
db.session.rollback()
return {"success": False, "message": f"Error creating template: {str(e)}"}
def create_project_from_template(
self,
template_id: int,
client_id: int,
created_by: int,
name: Optional[str] = None,
override_config: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Create a project from a template.
Returns:
dict with 'success', 'message', and 'project' keys
"""
try:
template = ProjectTemplate.query.get(template_id)
if not template:
return {"success": False, "message": "Template not found."}
# Refresh template from database to ensure we have latest tasks data
db.session.refresh(template)
# Merge template config with overrides
config = template.config.copy() if template.config else {}
if override_config:
config.update(override_config)
# Use provided name or template name
project_name = name or template.name
# Create project
from app.services.project_service import ProjectService
project_service = ProjectService()
result = project_service.create_project(
name=project_name,
client_id=client_id,
description=config.get("description", template.description),
billable=config.get("billable", True),
hourly_rate=config.get("hourly_rate"),
created_by=created_by,
)
if not result["success"]:
return result
project = result["project"]
# Apply additional config
if "billing_ref" in config:
project.billing_ref = config["billing_ref"]
if "code" in config:
project.code = config["code"]
if "estimated_hours" in config:
project.estimated_hours = config["estimated_hours"]
if "budget_amount" in config:
project.budget_amount = config["budget_amount"]
if "budget_threshold_percent" in config:
project.budget_threshold_percent = config["budget_threshold_percent"]
# Create tasks from template
# Check if tasks exist and is a non-empty list
tasks_to_create = template.tasks if template.tasks and isinstance(template.tasks, list) and len(template.tasks) > 0 else []
if tasks_to_create:
import logging
logger = logging.getLogger(__name__)
logger.info(f"Creating {len(tasks_to_create)} tasks from template {template_id} for project {project.id}")
from app.services.task_service import TaskService
task_service = TaskService()
created_count = 0
failed_count = 0
for task_config in tasks_to_create:
# Ensure task_config is a dictionary
if not isinstance(task_config, dict):
logger.warning(f"Invalid task config (not a dict): {task_config}")
failed_count += 1
continue
# Get task name - required field
task_name = task_config.get("name", "").strip()
if not task_name:
logger.warning(f"Skipping task with empty name: {task_config}")
failed_count += 1
continue
# Convert estimated_hours to float if it's a string or number
estimated_hours = task_config.get("estimated_hours")
if estimated_hours:
try:
estimated_hours = float(estimated_hours)
except (ValueError, TypeError):
estimated_hours = None
else:
estimated_hours = None
result = task_service.create_task(
name=task_name,
project_id=project.id,
description=task_config.get("description"),
priority=task_config.get("priority", "medium"),
estimated_hours=estimated_hours,
created_by=created_by,
)
# Log if task creation failed but don't stop the process
if not result.get("success"):
logger.warning(f"Failed to create task '{task_name}' from template: {result.get('message', 'Unknown error')}")
failed_count += 1
else:
created_count += 1
logger.info(f"Successfully created task '{task_name}' (ID: {result.get('task', {}).id if result.get('task') else 'unknown'})")
logger.info(f"Task creation summary: {created_count} created, {failed_count} failed out of {len(tasks_to_create)} tasks")
else:
import logging
logger = logging.getLogger(__name__)
logger.info(f"No tasks to create from template {template_id} (tasks: {template.tasks})")
# Update template usage
template.usage_count += 1
from app.utils.timezone import now_in_app_timezone
template.last_used_at = now_in_app_timezone()
db.session.commit()
return {"success": True, "message": "Project created from template successfully.", "project": project}
except Exception as e:
db.session.rollback()
return {"success": False, "message": f"Error creating project from template: {str(e)}"}
def get_template(self, template_id: int) -> Optional[ProjectTemplate]:
"""Get a template by ID"""
return ProjectTemplate.query.get(template_id)
def list_templates(
self,
user_id: Optional[int] = None,
category: Optional[str] = None,
is_public: Optional[bool] = None,
page: int = 1,
per_page: int = 20,
) -> Any: # Returns pagination object from query.paginate()
"""
List templates with filtering and pagination.
Returns:
Pagination object with templates
"""
query = ProjectTemplate.query
# Filter by user (own templates or public)
if user_id:
query = query.filter(db.or_(ProjectTemplate.created_by == user_id, ProjectTemplate.is_public == True))
elif is_public is not None:
query = query.filter(ProjectTemplate.is_public == is_public)
# Filter by category
if category:
query = query.filter(ProjectTemplate.category == category)
# Order by usage count and name
query = query.order_by(ProjectTemplate.usage_count.desc(), ProjectTemplate.name.asc())
return query.paginate(page=page, per_page=per_page, error_out=False)
def update_template(self, template_id: int, user_id: int, **kwargs) -> Dict[str, Any]:
"""
Update a template.
Returns:
dict with 'success' and 'message' keys
"""
try:
template = ProjectTemplate.query.get(template_id)
if not template:
return {"success": False, "message": "Template not found."}
# Check permissions
if template.created_by != user_id:
return {"success": False, "message": "You do not have permission to edit this template."}
# Update fields
if "name" in kwargs:
template.name = kwargs["name"]
if "description" in kwargs:
template.description = kwargs["description"]
if "config" in kwargs:
template.config = kwargs["config"]
if "tasks" in kwargs:
# Ensure tasks is always a list
tasks = kwargs["tasks"]
if tasks is None:
tasks = []
elif not isinstance(tasks, list):
import logging
logging.getLogger(__name__).warning(f"Tasks is not a list: {type(tasks)}, value: {tasks}")
tasks = []
template.tasks = tasks
if "category" in kwargs:
template.category = kwargs["category"]
if "tags" in kwargs:
template.tags = kwargs["tags"]
if "is_public" in kwargs:
template.is_public = kwargs["is_public"]
if not safe_commit("update_template", {"template_id": template_id}):
return {"success": False, "message": "Could not update template due to a database error."}
emit_event(
WebhookEvent.PROJECT_TEMPLATE_UPDATED, {"template_id": template.id, "template_name": template.name}
)
return {"success": True, "message": "Template updated successfully.", "template": template}
except Exception as e:
db.session.rollback()
return {"success": False, "message": f"Error updating template: {str(e)}"}
def delete_template(self, template_id: int, user_id: int) -> Dict[str, Any]:
"""
Delete a template.
Returns:
dict with 'success' and 'message' keys
"""
try:
template = ProjectTemplate.query.get(template_id)
if not template:
return {"success": False, "message": "Template not found."}
# Check permissions
if template.created_by != user_id:
return {"success": False, "message": "You do not have permission to delete this template."}
template_name = template.name
db.session.delete(template)
if not safe_commit("delete_template", {"template_id": template_id}):
return {"success": False, "message": "Could not delete template due to a database error."}
emit_event(
WebhookEvent.PROJECT_TEMPLATE_DELETED, {"template_id": template_id, "template_name": template_name}
)
return {"success": True, "message": "Template deleted successfully."}
except Exception as e:
db.session.rollback()
return {"success": False, "message": f"Error deleting template: {str(e)}"}