diff --git a/app/routes/kanban.py b/app/routes/kanban.py index 768248e..93cd242 100644 --- a/app/routes/kanban.py +++ b/app/routes/kanban.py @@ -14,21 +14,43 @@ kanban_bp = Blueprint("kanban", __name__) @login_required @module_enabled("kanban") def board(): - """Kanban board page with optional project and user filters""" - project_id = request.args.get("project_id", type=int) - user_id = request.args.get("user_id", type=int) + """Kanban board page with optional project and user filters (supports multi-select)""" + # Parse filter parameters - support both single ID (backward compatibility) and multi-select + def parse_ids(param_name): + """Parse comma-separated IDs or single ID into a list of integers""" + # Try multi-select parameter first (e.g., project_ids) + multi_param = request.args.get(param_name + 's', '').strip() + if multi_param: + try: + return [int(x.strip()) for x in multi_param.split(',') if x.strip()] + except (ValueError, AttributeError): + return [] + # Fall back to single parameter for backward compatibility (e.g., project_id) + single_param = request.args.get(param_name, type=int) + if single_param: + return [single_param] + return [] + + project_ids = parse_ids('project_id') + user_ids = parse_ids('user_id') + + # Build query with filters query = Task.query - if project_id: - query = query.filter_by(project_id=project_id) - if user_id: - query = query.filter_by(assigned_to=user_id) + if project_ids: + query = query.filter(Task.project_id.in_(project_ids)) + if user_ids: + query = query.filter(Task.assigned_to.in_(user_ids)) + # Order tasks for stable rendering tasks = query.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).all() - # Fresh columns - use project-specific columns if project_id is provided + + # Fresh columns - use project-specific columns if single project is selected db.session.expire_all() if KanbanColumn: + # Only use project-specific columns if exactly one project is selected + single_project_id = project_ids[0] if len(project_ids) == 1 else None # Try to get project-specific columns first - columns = KanbanColumn.get_active_columns(project_id=project_id) + columns = KanbanColumn.get_active_columns(project_id=single_project_id) # If no project-specific columns exist, fall back to global columns if not columns: columns = KanbanColumn.get_active_columns(project_id=None) @@ -38,15 +60,26 @@ def board(): columns = KanbanColumn.get_active_columns(project_id=None) else: columns = [] + # Provide projects for filter dropdown from app.models import Project, User projects = Project.query.filter_by(status="active").order_by(Project.name).all() # Provide users for filter dropdown (active users only) users = User.query.filter_by(is_active=True).order_by(User.full_name, User.username).all() + # No-cache response = render_template( - "kanban/board.html", tasks=tasks, kanban_columns=columns, projects=projects, users=users, project_id=project_id, user_id=user_id + "kanban/board.html", + tasks=tasks, + kanban_columns=columns, + projects=projects, + users=users, + project_ids=project_ids, + user_ids=user_ids, + # Keep old single params for backward compatibility in templates + project_id=project_ids[0] if len(project_ids) == 1 else None, + user_id=user_ids[0] if len(user_ids) == 1 else None ) resp = make_response(response) resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" diff --git a/app/routes/tasks.py b/app/routes/tasks.py index fb12474..5a79533 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -16,20 +16,48 @@ import io tasks_bp = Blueprint("tasks", __name__) +ALLOWED_NEXT_PREFIXES = ("/kanban", "/gantt", "/tasks", "/projects") + + +def _is_safe_next_url(next_url): + """Validate next URL to avoid open redirects. Allow relative paths with allowed prefixes.""" + if not next_url or not isinstance(next_url, str): + return False + next_url = next_url.strip() + if not next_url.startswith("/") or next_url.startswith("//"): + return False + return any(next_url == p or next_url.startswith(p + "?") or next_url.startswith(p + "#") for p in ALLOWED_NEXT_PREFIXES) + @tasks_bp.route("/tasks") @login_required def list_tasks(): - """List all tasks with filtering options - REFACTORED to use service layer with eager loading""" + """List all tasks with filtering options - REFACTORED to use service layer with eager loading (supports multi-select)""" from app.services import TaskService # Get pagination parameters from request (respects per_page query param, defaults to DEFAULT_PAGE_SIZE) page, per_page = get_pagination_params() + # Parse filter parameters - support both single ID (backward compatibility) and multi-select + def parse_ids(param_name): + """Parse comma-separated IDs or single ID into a list of integers""" + # Try multi-select parameter first (e.g., project_ids) + multi_param = request.args.get(param_name + 's', '').strip() + if multi_param: + try: + return [int(x.strip()) for x in multi_param.split(',') if x.strip()] + except (ValueError, AttributeError): + return [] + # Fall back to single parameter for backward compatibility (e.g., project_id) + single_param = request.args.get(param_name, type=int) + if single_param: + return [single_param] + return [] + status = request.args.get("status", "") priority = request.args.get("priority", "") - project_id = request.args.get("project_id", type=int) - assigned_to = request.args.get("assigned_to", type=int) + project_ids = parse_ids('project_id') + assigned_to_ids = parse_ids('assigned_to') search = request.args.get("search", "").strip() overdue_param = request.args.get("overdue", "").strip().lower() overdue = overdue_param in ["1", "true", "on", "yes"] @@ -49,8 +77,8 @@ def list_tasks(): result = task_service.list_tasks( status=status if status else None, priority=priority if priority else None, - project_id=project_id, - assigned_to=assigned_to, + project_ids=project_ids if project_ids else None, + assigned_to_ids=assigned_to_ids if assigned_to_ids else None, search=search if search else None, overdue=overdue, user_id=current_user.id, @@ -69,8 +97,11 @@ def list_tasks(): pagination=result["pagination"], status=status, priority=priority, - project_id=project_id, - assigned_to=assigned_to, + project_ids=project_ids, + assigned_to_ids=assigned_to_ids, + # Keep old single params for backward compatibility + project_id=project_ids[0] if len(project_ids) == 1 else None, + assigned_to=assigned_to_ids[0] if len(assigned_to_ids) == 1 else None, search=search, overdue=overdue, )) @@ -108,8 +139,11 @@ def list_tasks(): kanban_columns=kanban_columns, status=status, priority=priority, - project_id=project_id, - assigned_to=assigned_to, + project_ids=project_ids, + assigned_to_ids=assigned_to_ids, + # Keep old single params for backward compatibility + project_id=project_ids[0] if len(project_ids) == 1 else None, + assigned_to=assigned_to_ids[0] if len(assigned_to_ids) == 1 else None, search=search, overdue=overdue, task_counts=task_counts, @@ -165,6 +199,11 @@ def create_task(): if priority not in ["low", "medium", "high", "urgent"]: priority = "medium" + # Validate initial status + status = request.form.get("status", "todo").strip() + if status not in ("todo", "in_progress", "review", "done", "cancelled"): + status = "todo" + # Parse estimated hours estimated_hours = None if estimated_hours_str: @@ -208,6 +247,7 @@ def create_task(): estimated_hours=estimated_hours, created_by=current_user.id, color=color_val, + status=status, ) if not result["success"]: @@ -240,6 +280,9 @@ def create_task(): ) flash(f'Task "{name}" created successfully', "success") + next_url = request.form.get("next") or request.args.get("next") + if next_url and _is_safe_next_url(next_url): + return redirect(next_url) return redirect(url_for("tasks.view_task", task_id=task.id)) # Get available projects and users for form diff --git a/app/services/task_service.py b/app/services/task_service.py index d3f6f9a..4543c6f 100644 --- a/app/services/task_service.py +++ b/app/services/task_service.py @@ -43,6 +43,8 @@ class TaskService: self.task_repo = TaskRepository() self.project_repo = ProjectRepository() + VALID_STATUSES = ("todo", "in_progress", "review", "done", "cancelled") + def create_task( self, name: str, @@ -54,6 +56,7 @@ class TaskService: due_date: Optional[Any] = None, estimated_hours: Optional[float] = None, color: Optional[str] = None, + status: Optional[str] = None, ) -> Dict[str, Any]: """ Create a new task. @@ -68,6 +71,7 @@ class TaskService: estimated_hours: Estimated hours created_by: User ID of creator color: Optional Gantt chart bar color (hex e.g. #3b82f6) + status: Optional initial status (todo, in_progress, review, done, cancelled) Returns: dict with 'success', 'message', and 'task' keys @@ -77,6 +81,12 @@ class TaskService: if not project: return {"success": False, "message": "Invalid project", "error": "invalid_project"} + task_status = ( + status + if status and status in self.VALID_STATUSES + else TaskStatus.TODO.value + ) + # Create task task = self.task_repo.create( name=name, @@ -86,7 +96,7 @@ class TaskService: priority=priority, due_date=due_date, estimated_hours=estimated_hours, - status=TaskStatus.TODO.value, + status=task_status, created_by=created_by, ) if color: @@ -186,6 +196,8 @@ class TaskService: has_view_all_tasks: bool = False, page: int = 1, per_page: int = 20, + project_ids: Optional[list] = None, + assigned_to_ids: Optional[list] = None, ) -> Dict[str, Any]: """ List tasks with filtering and pagination. @@ -225,10 +237,15 @@ class TaskService: if priority: query = query.filter(Task.priority == priority) - if project_id: + # Support both single ID (backward compatibility) and multi-select + if project_ids: + query = query.filter(Task.project_id.in_(project_ids)) + elif project_id: query = query.filter(Task.project_id == project_id) - if assigned_to: + if assigned_to_ids: + query = query.filter(Task.assigned_to.in_(assigned_to_ids)) + elif assigned_to: query = query.filter(Task.assigned_to == assigned_to) if search: @@ -269,9 +286,14 @@ class TaskService: count_query = count_query.filter(Task.status == status) if priority: count_query = count_query.filter(Task.priority == priority) - if project_id: + # Support both single ID and multi-select + if project_ids: + count_query = count_query.filter(Task.project_id.in_(project_ids)) + elif project_id: count_query = count_query.filter(Task.project_id == project_id) - if assigned_to: + if assigned_to_ids: + count_query = count_query.filter(Task.assigned_to.in_(assigned_to_ids)) + elif assigned_to: count_query = count_query.filter(Task.assigned_to == assigned_to) if search: like = f"%{search}%" @@ -363,4 +385,8 @@ class TaskService: total_time = (time.time() - start_time) * 1000 logger.info(f"[TaskService.list_tasks] Total time: {total_time:.2f}ms (tasks: {len(tasks) if tasks else 0}, page: {page}, per_page: {per_page})") + return {"tasks": tasks, "pagination": pagination, "total": pagination.total} +me) * 1000 + logger.info(f"[TaskService.list_tasks] Total time: {total_time:.2f}ms (tasks: {len(tasks) if tasks else 0}, page: {page}, per_page: {per_page})") + return {"tasks": tasks, "pagination": pagination, "total": pagination.total} diff --git a/app/templates/components/multi_select.html b/app/templates/components/multi_select.html new file mode 100644 index 0000000..ddd6f9a --- /dev/null +++ b/app/templates/components/multi_select.html @@ -0,0 +1,266 @@ +{# ============================================ + MULTI-SELECT COMPONENT + Reusable multi-select dropdown with checkboxes + ============================================ #} + +{% macro multi_select( + field_name, + label, + items, + selected_ids=[], + item_id_attr='id', + item_label_attr='name', + placeholder='All', + show_search=True, + form_id=None +) %} +{# + Parameters: + - field_name: Name of the hidden input field (e.g., 'project_ids') + - label: Label text for the dropdown + - items: List of items to display (e.g., projects, users) + - selected_ids: List of currently selected IDs + - item_id_attr: Attribute name for item ID (default: 'id') + - item_label_attr: Attribute name for item label (default: 'name') + - placeholder: Text to show when nothing is selected (default: 'All') + - show_search: Whether to show search box (default: True) + - form_id: Optional form ID for auto-submit +#} +