mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-24 15:20:52 -05:00
b4486a627f
- Webhook models: remove duplicate index definitions so db.create_all() no longer raises 'index already exists' (columns already have index=True) - ImportService: fix circular import by late-importing ClientService, ProjectService, TimeTrackingService in __init__ - reports: fix F823 by renaming unpack variable _ to _entry_count to avoid shadowing gettext _ in export_task_excel() - Code quality: add .flake8 with extend-ignore so flake8 CI passes; simplify pyproject.toml isort config (drop unsupported options) - Format: run black and isort on app/ - tests: restore minimal app fixture in test_import_export_models
1553 lines
60 KiB
Python
1553 lines
60 KiB
Python
import csv
|
|
import io
|
|
import re
|
|
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
|
|
from flask import (
|
|
Blueprint,
|
|
Response,
|
|
current_app,
|
|
flash,
|
|
jsonify,
|
|
make_response,
|
|
redirect,
|
|
render_template,
|
|
request,
|
|
url_for,
|
|
)
|
|
from flask_babel import gettext as _
|
|
from flask_login import current_user, login_required
|
|
|
|
import app as app_module
|
|
from app import db
|
|
from app.models import Activity, KanbanColumn, Project, Task, TaskActivity, TimeEntry, User
|
|
from app.utils.db import safe_commit
|
|
from app.utils.pagination import get_pagination_params
|
|
from app.utils.timezone import convert_app_datetime_to_user, now_in_app_timezone
|
|
|
|
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 (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_ids = parse_ids("project_id")
|
|
assigned_to_ids = parse_ids("assigned_to")
|
|
search = request.args.get("search", "").strip()
|
|
tags = request.args.get("tags", "").strip()
|
|
overdue_param = request.args.get("overdue", "").strip().lower()
|
|
overdue = overdue_param in ["1", "true", "on", "yes"]
|
|
|
|
# Check if this is an AJAX request first (before loading filter data)
|
|
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
|
|
|
|
# Use service layer to get tasks (prevents N+1 queries)
|
|
task_service = TaskService()
|
|
|
|
# Optimize permission check - check is_admin first (no DB query needed)
|
|
has_view_all_tasks = current_user.is_admin
|
|
if not has_view_all_tasks:
|
|
# Only check permission if not admin (roles are already loaded via lazy="joined")
|
|
has_view_all_tasks = current_user.has_permission("view_all_tasks")
|
|
|
|
result = task_service.list_tasks(
|
|
status=status if status else None,
|
|
priority=priority if priority else None,
|
|
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,
|
|
tags=tags if tags else None,
|
|
overdue=overdue,
|
|
user_id=current_user.id,
|
|
is_admin=current_user.is_admin,
|
|
has_view_all_tasks=has_view_all_tasks,
|
|
page=page,
|
|
per_page=per_page,
|
|
)
|
|
|
|
# Check if this is an AJAX request
|
|
if is_ajax:
|
|
# Return only the tasks list HTML for AJAX requests
|
|
response = make_response(
|
|
render_template(
|
|
"tasks/_tasks_list.html",
|
|
tasks=result["tasks"],
|
|
pagination=result["pagination"],
|
|
status=status,
|
|
priority=priority,
|
|
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,
|
|
tags=tags,
|
|
overdue=overdue,
|
|
)
|
|
)
|
|
response.headers["Content-Type"] = "text/html; charset=utf-8"
|
|
return response
|
|
|
|
# Get filter options - only load for full page loads, not AJAX requests
|
|
# These are used for filter dropdowns in the template
|
|
# Use reasonable limits to avoid loading too many records
|
|
projects = Project.query.filter_by(status="active").order_by(Project.name).limit(500).all()
|
|
users = User.query.filter_by(is_active=True).order_by(User.username).limit(200).all()
|
|
|
|
# Kanban columns are already loaded in TaskService, but we need them for the template
|
|
# This is a lightweight query, so it's acceptable
|
|
kanban_columns = KanbanColumn.get_active_columns(project_id=None) if KanbanColumn else []
|
|
|
|
# Pre-calculate task counts by status for summary cards (avoid template iteration)
|
|
task_counts = {"todo": 0, "in_progress": 0, "review": 0, "done": 0}
|
|
for task in result["tasks"]:
|
|
if task.status in task_counts:
|
|
task_counts[task.status] += 1
|
|
|
|
# Prevent browser caching of kanban board
|
|
response = render_template(
|
|
"tasks/list.html",
|
|
tasks=result["tasks"],
|
|
pagination=result["pagination"],
|
|
projects=projects,
|
|
users=users,
|
|
kanban_columns=kanban_columns,
|
|
status=status,
|
|
priority=priority,
|
|
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,
|
|
tags=tags,
|
|
overdue=overdue,
|
|
task_counts=task_counts,
|
|
)
|
|
resp = make_response(response)
|
|
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
|
|
resp.headers["Pragma"] = "no-cache"
|
|
resp.headers["Expires"] = "0"
|
|
return resp
|
|
|
|
|
|
@tasks_bp.route("/tasks/create", methods=["GET", "POST"])
|
|
@login_required
|
|
def create_task():
|
|
"""Create a new task"""
|
|
if request.method == "POST":
|
|
project_id = request.form.get("project_id", type=int)
|
|
name = request.form.get("name", "").strip()
|
|
description = request.form.get("description", "").strip()
|
|
priority = request.form.get("priority", "medium")
|
|
estimated_hours_str = request.form.get("estimated_hours", "").strip()
|
|
due_date_str = request.form.get("due_date", "").strip()
|
|
assigned_to = request.form.get("assigned_to", type=int)
|
|
|
|
# Validate and sanitize input
|
|
from app.utils.validation import sanitize_input, validate_string
|
|
|
|
# Validate required fields
|
|
if not project_id or not name:
|
|
flash(_("Project and task name are required"), "error")
|
|
projects = Project.query.filter_by(status="active").order_by(Project.name).all()
|
|
users = User.query.order_by(User.username).all()
|
|
return render_template("tasks/create.html", projects=projects, users=users)
|
|
|
|
# Sanitize and validate name
|
|
try:
|
|
name = validate_string(sanitize_input(name), min_length=1, max_length=200)
|
|
except Exception as e:
|
|
flash(_("Invalid task name: %(error)s", error=str(e)), "error")
|
|
projects = Project.query.filter_by(status="active").order_by(Project.name).all()
|
|
users = User.query.order_by(User.username).all()
|
|
return render_template("tasks/create.html", projects=projects, users=users)
|
|
|
|
# Validate project exists
|
|
project = Project.query.get(project_id)
|
|
if not project:
|
|
flash(_("Selected project does not exist"), "error")
|
|
projects = Project.query.filter_by(status="active").order_by(Project.name).all()
|
|
users = User.query.order_by(User.username).all()
|
|
return render_template("tasks/create.html", projects=projects, users=users)
|
|
|
|
# Validate priority
|
|
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:
|
|
try:
|
|
estimated_hours = float(estimated_hours_str)
|
|
if estimated_hours < 0:
|
|
estimated_hours = None
|
|
except (ValueError, TypeError):
|
|
estimated_hours = None
|
|
|
|
# Parse due date
|
|
due_date = None
|
|
if due_date_str:
|
|
try:
|
|
due_date = datetime.strptime(due_date_str, "%Y-%m-%d").date()
|
|
except ValueError:
|
|
flash(_("Invalid due date format"), "error")
|
|
projects = Project.query.filter_by(status="active").order_by(Project.name).all()
|
|
users = User.query.order_by(User.username).all()
|
|
return render_template("tasks/create.html", projects=projects, users=users)
|
|
|
|
# Gantt color (hex e.g. #3b82f6)
|
|
color_val = request.form.get("color", "").strip()
|
|
if color_val and not re.match(r"^#[0-9A-Fa-f]{6}$", color_val):
|
|
color_val = None
|
|
if color_val == "":
|
|
color_val = None
|
|
|
|
# Tags (comma-separated, max 500 chars)
|
|
tags_val = request.form.get("tags", "").strip()[:500] or None
|
|
|
|
# Use service layer to create task
|
|
from app.services import TaskService
|
|
|
|
task_service = TaskService()
|
|
|
|
result = task_service.create_task(
|
|
name=name,
|
|
project_id=project_id,
|
|
description=description,
|
|
assignee_id=assigned_to,
|
|
priority=priority,
|
|
due_date=due_date,
|
|
estimated_hours=estimated_hours,
|
|
created_by=current_user.id,
|
|
color=color_val,
|
|
status=status,
|
|
tags=tags_val,
|
|
)
|
|
|
|
if not result["success"]:
|
|
flash(_(result["message"]), "error")
|
|
projects = Project.query.filter_by(status="active").order_by(Project.name).all()
|
|
users = User.query.order_by(User.username).all()
|
|
return render_template("tasks/create.html", projects=projects, users=users)
|
|
|
|
task = result["task"]
|
|
|
|
# Log task creation
|
|
app_module.log_event(
|
|
"task.created", user_id=current_user.id, task_id=task.id, project_id=project_id, priority=priority
|
|
)
|
|
app_module.track_event(
|
|
current_user.id, "task.created", {"task_id": task.id, "project_id": project_id, "priority": priority}
|
|
)
|
|
|
|
# Log activity
|
|
Activity.log(
|
|
user_id=current_user.id,
|
|
action="created",
|
|
entity_type="task",
|
|
entity_id=task.id,
|
|
entity_name=task.name,
|
|
description=f'Created task "{task.name}" in project "{project.name}"',
|
|
extra_data={"project_id": project_id, "priority": priority},
|
|
ip_address=request.remote_addr,
|
|
user_agent=request.headers.get("User-Agent"),
|
|
)
|
|
|
|
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
|
|
projects = Project.query.filter_by(status="active").order_by(Project.name).all()
|
|
users = User.query.order_by(User.username).all()
|
|
|
|
return render_template("tasks/create.html", projects=projects, users=users)
|
|
|
|
|
|
@tasks_bp.route("/tasks/<int:task_id>")
|
|
@login_required
|
|
def view_task(task_id):
|
|
"""View task details - REFACTORED to use service layer with eager loading"""
|
|
from sqlalchemy.orm import joinedload
|
|
|
|
from app.models import Comment
|
|
from app.services import TaskService
|
|
|
|
task_service = TaskService()
|
|
|
|
# Get task with all relations using eager loading (prevents N+1 queries)
|
|
task = task_service.get_task_with_details(
|
|
task_id=task_id, include_time_entries=False, include_comments=False, include_activities=False
|
|
)
|
|
|
|
if not task:
|
|
flash(_("Task not found"), "error")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
# Check if user has access to this task
|
|
if not current_user.is_admin and task.assigned_to != current_user.id and task.created_by != current_user.id:
|
|
flash(_("You do not have access to this task"), "error")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
# Get time entries with pagination (limit to 100 most recent to avoid loading too many)
|
|
# Eagerly load user relationship to prevent N+1 queries
|
|
time_entries = (
|
|
task.time_entries.options(joinedload(TimeEntry.user))
|
|
.order_by(TimeEntry.start_time.desc(), TimeEntry.id.desc())
|
|
.limit(100)
|
|
.all()
|
|
)
|
|
|
|
# Recent activity entries (activities is a dynamic relationship, so query it)
|
|
# Eagerly load user relationship to prevent N+1 queries
|
|
activities = (
|
|
task.activities.options(joinedload(TaskActivity.user))
|
|
.order_by(TaskActivity.created_at.desc(), TaskActivity.id.desc())
|
|
.limit(20)
|
|
.all()
|
|
)
|
|
|
|
# Get comments for this task with eager loading of authors and replies to prevent N+1 queries
|
|
# Load all comments (including replies) with their authors to avoid lazy loading issues
|
|
# Use selectinload for replies to load them in a separate query, preventing circular loading
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
# Load all comments for this task with eager loading
|
|
all_comments = (
|
|
Comment.query.filter_by(task_id=task_id)
|
|
.options(
|
|
joinedload(Comment.author), # Eagerly load author for all comments
|
|
# Load replies with their authors - selectinload loads all direct replies in one query
|
|
# This prevents N+1 queries when accessing comment.replies in the template
|
|
selectinload(Comment.replies).joinedload(Comment.author),
|
|
# Note: Comment.attachments is a dynamic relationship (lazy="dynamic")
|
|
# and cannot be eager loaded with selectinload/joinedload
|
|
)
|
|
.order_by(Comment.created_at.asc())
|
|
.all()
|
|
)
|
|
|
|
# Filter to only top-level comments (no parent_id) for the template
|
|
# The replies relationship is now eagerly loaded for direct replies
|
|
# Nested replies beyond the first level will be loaded lazily, but the template depth limit prevents issues
|
|
comments = [c for c in all_comments if c.parent_id is None]
|
|
|
|
return render_template(
|
|
"tasks/view.html", task=task, time_entries=time_entries, activities=activities, comments=comments
|
|
)
|
|
|
|
|
|
@tasks_bp.route("/tasks/<int:task_id>/edit", methods=["GET", "POST"])
|
|
@login_required
|
|
def edit_task(task_id):
|
|
"""Edit task details"""
|
|
task = Task.query.get_or_404(task_id)
|
|
|
|
# Check if user can edit this task
|
|
if not current_user.is_admin and task.created_by != current_user.id:
|
|
flash(_("You can only edit tasks you created"), "error")
|
|
return redirect(url_for("tasks.view_task", task_id=task.id))
|
|
|
|
if request.method == "POST":
|
|
# Preload context for potential validation errors
|
|
projects = Project.query.filter_by(status="active").order_by(Project.name).all()
|
|
users = User.query.order_by(User.username).all()
|
|
project_id = request.form.get("project_id", type=int)
|
|
name = request.form.get("name", "").strip()
|
|
description = request.form.get("description", "").strip()
|
|
priority = request.form.get("priority", "medium")
|
|
estimated_hours_str = request.form.get("estimated_hours", "").strip()
|
|
due_date_str = request.form.get("due_date", "").strip()
|
|
assigned_to = request.form.get("assigned_to", type=int)
|
|
|
|
# Validate and sanitize input
|
|
from app.utils.validation import sanitize_input, validate_string
|
|
|
|
# Validate required fields
|
|
if not name:
|
|
flash(_("Task name is required"), "error")
|
|
return render_template("tasks/edit.html", task=task, projects=projects, users=users)
|
|
|
|
# Sanitize and validate name
|
|
try:
|
|
name = validate_string(sanitize_input(name), min_length=1, max_length=200)
|
|
except Exception as e:
|
|
flash(_("Invalid task name: %(error)s", error=str(e)), "error")
|
|
return render_template("tasks/edit.html", task=task, projects=projects, users=users)
|
|
|
|
# Sanitize description
|
|
if description:
|
|
try:
|
|
description = sanitize_input(description, max_length=5000)
|
|
except Exception:
|
|
description = sanitize_input(description[:5000] if len(description) > 5000 else description)
|
|
|
|
# Validate project selection
|
|
if not project_id:
|
|
flash(_("Project is required"), "error")
|
|
return render_template("tasks/edit.html", task=task, projects=projects, users=users)
|
|
new_project = Project.query.filter_by(id=project_id, status="active").first()
|
|
if not new_project:
|
|
flash(_("Selected project does not exist or is inactive"), "error")
|
|
return render_template("tasks/edit.html", task=task, projects=projects, users=users)
|
|
|
|
# Validate priority
|
|
if priority not in ["low", "medium", "high", "urgent"]:
|
|
priority = task.priority or "medium"
|
|
|
|
# Parse estimated hours
|
|
estimated_hours = None
|
|
if estimated_hours_str:
|
|
try:
|
|
estimated_hours = float(estimated_hours_str)
|
|
if estimated_hours < 0:
|
|
estimated_hours = None
|
|
except (ValueError, TypeError):
|
|
estimated_hours = None
|
|
|
|
# Parse due date
|
|
due_date = None
|
|
if due_date_str:
|
|
try:
|
|
due_date = datetime.strptime(due_date_str, "%Y-%m-%d").date()
|
|
except ValueError:
|
|
flash(_("Invalid due date format"), "error")
|
|
return render_template("tasks/edit.html", task=task, projects=projects, users=users)
|
|
|
|
# Update task
|
|
# Handle project change first so any early returns (status flows) still persist it
|
|
if project_id != task.project_id:
|
|
old_project_id = task.project_id
|
|
task.project_id = project_id
|
|
# Keep related time entries consistent with the task's project
|
|
try:
|
|
TimeEntry.query.filter_by(task_id=task.id).update(
|
|
{"project_id": project_id}, synchronize_session="fetch"
|
|
)
|
|
db.session.add(
|
|
TaskActivity(
|
|
task_id=task.id,
|
|
user_id=current_user.id,
|
|
event="project_change",
|
|
details=f"Project changed from {old_project_id} to {project_id}",
|
|
)
|
|
)
|
|
except Exception as e:
|
|
# If anything goes wrong here, fall back to just changing the task
|
|
current_app.logger.warning(f"Failed to log task project change activity: {e}")
|
|
|
|
task.name = name
|
|
task.description = description
|
|
task.priority = priority
|
|
task.estimated_hours = estimated_hours
|
|
task.due_date = due_date
|
|
task.assigned_to = assigned_to
|
|
# Tags (comma-separated, max 500 chars)
|
|
tags_val = request.form.get("tags", "").strip()[:500]
|
|
task.tags = tags_val or None
|
|
# Gantt color (hex e.g. #3b82f6)
|
|
color_val = request.form.get("color", "").strip()
|
|
if color_val and re.match(r"^#[0-9A-Fa-f]{6}$", color_val):
|
|
task.color = color_val
|
|
elif color_val == "":
|
|
task.color = None
|
|
# Handle status update (including reopening from done)
|
|
selected_status = request.form.get("status", "").strip()
|
|
valid_statuses = KanbanColumn.get_valid_status_keys(project_id=task.project_id)
|
|
if selected_status and selected_status in valid_statuses and selected_status != task.status:
|
|
try:
|
|
previous_status = task.status
|
|
if selected_status == "in_progress":
|
|
# If reopening from done, preserve started_at
|
|
if task.status == "done":
|
|
task.completed_at = None
|
|
task.status = "in_progress"
|
|
if not task.started_at:
|
|
task.started_at = now_in_app_timezone()
|
|
task.updated_at = now_in_app_timezone()
|
|
db.session.add(
|
|
TaskActivity(
|
|
task_id=task.id,
|
|
user_id=current_user.id,
|
|
event="reopen",
|
|
details="Task reopened to In Progress",
|
|
)
|
|
)
|
|
if not safe_commit("edit_task_reopen_in_progress", {"task_id": task.id}):
|
|
flash(
|
|
_("Could not update status due to a database error. Please check server logs."), "error"
|
|
)
|
|
return render_template("tasks/edit.html", task=task, projects=projects, users=users)
|
|
else:
|
|
task.start_task()
|
|
db.session.add(
|
|
TaskActivity(
|
|
task_id=task.id,
|
|
user_id=current_user.id,
|
|
event="start",
|
|
details=f"Task moved from {previous_status} to In Progress",
|
|
)
|
|
)
|
|
safe_commit("log_task_start_from_edit", {"task_id": task.id})
|
|
elif selected_status == "done":
|
|
task.complete_task()
|
|
db.session.add(
|
|
TaskActivity(
|
|
task_id=task.id, user_id=current_user.id, event="complete", details="Task completed"
|
|
)
|
|
)
|
|
safe_commit("log_task_complete_from_edit", {"task_id": task.id})
|
|
elif selected_status == "cancelled":
|
|
task.cancel_task()
|
|
db.session.add(
|
|
TaskActivity(task_id=task.id, user_id=current_user.id, event="cancel", details="Task cancelled")
|
|
)
|
|
safe_commit("log_task_cancel_from_edit", {"task_id": task.id})
|
|
else:
|
|
# Reopen or move to non-special states
|
|
# Clear completed_at if reopening from done
|
|
if task.status == "done" and selected_status in ["todo", "review"]:
|
|
task.completed_at = None
|
|
task.status = selected_status
|
|
task.updated_at = now_in_app_timezone()
|
|
event_name = (
|
|
"reopen"
|
|
if previous_status == "done" and selected_status in ["todo", "review"]
|
|
else (
|
|
"pause"
|
|
if selected_status == "todo"
|
|
else ("review" if selected_status == "review" else "status_change")
|
|
)
|
|
)
|
|
db.session.add(
|
|
TaskActivity(
|
|
task_id=task.id,
|
|
user_id=current_user.id,
|
|
event=event_name,
|
|
details=f"Task moved from {previous_status} to {selected_status}",
|
|
)
|
|
)
|
|
if not safe_commit("edit_task_status_change", {"task_id": task.id, "status": selected_status}):
|
|
flash("Could not update status due to a database error. Please check server logs.", "error")
|
|
return render_template("tasks/edit.html", task=task, projects=projects, users=users)
|
|
except ValueError as e:
|
|
flash(str(e), "error")
|
|
return render_template("tasks/edit.html", task=task, projects=projects, users=users)
|
|
|
|
# Always update the updated_at timestamp to local time after edits
|
|
task.updated_at = now_in_app_timezone()
|
|
|
|
if not safe_commit("edit_task", {"task_id": task.id}):
|
|
flash(_("Could not update task due to a database error. Please check server logs."), "error")
|
|
return render_template("tasks/edit.html", task=task, projects=projects, users=users)
|
|
|
|
# Log task update
|
|
app_module.log_event("task.updated", user_id=current_user.id, task_id=task.id, project_id=task.project_id)
|
|
app_module.track_event(current_user.id, "task.updated", {"task_id": task.id, "project_id": task.project_id})
|
|
|
|
# Log activity
|
|
Activity.log(
|
|
user_id=current_user.id,
|
|
action="updated",
|
|
entity_type="task",
|
|
entity_id=task.id,
|
|
entity_name=task.name,
|
|
description=f'Updated task "{task.name}"',
|
|
ip_address=request.remote_addr,
|
|
user_agent=request.headers.get("User-Agent"),
|
|
)
|
|
|
|
flash(f'Task "{name}" updated successfully', "success")
|
|
return redirect(url_for("tasks.view_task", task_id=task.id))
|
|
|
|
# Get available projects and users for form
|
|
projects = Project.query.filter_by(status="active").order_by(Project.name).all()
|
|
users = User.query.order_by(User.username).all()
|
|
|
|
return render_template("tasks/edit.html", task=task, projects=projects, users=users)
|
|
|
|
|
|
@tasks_bp.route("/tasks/<int:task_id>/status", methods=["POST"])
|
|
@login_required
|
|
def update_task_status(task_id):
|
|
"""Update task status"""
|
|
task = Task.query.get_or_404(task_id)
|
|
new_status = request.form.get("status", "").strip()
|
|
|
|
# Check if user can update this task
|
|
if not current_user.is_admin and task.assigned_to != current_user.id and task.created_by != current_user.id:
|
|
flash(_("You do not have permission to update this task"), "error")
|
|
return redirect(url_for("tasks.view_task", task_id=task.id))
|
|
|
|
# Validate status against configured kanban columns for this task's project
|
|
valid_statuses = KanbanColumn.get_valid_status_keys(project_id=task.project_id)
|
|
if new_status not in valid_statuses:
|
|
flash(_("Invalid status"), "error")
|
|
return redirect(url_for("tasks.view_task", task_id=task.id))
|
|
|
|
# Update status
|
|
try:
|
|
if new_status == "in_progress":
|
|
# If reopening from done, bypass start_task restriction
|
|
if task.status == "done":
|
|
task.completed_at = None
|
|
task.status = "in_progress"
|
|
# Preserve existing started_at if present, otherwise set now
|
|
if not task.started_at:
|
|
task.started_at = now_in_app_timezone()
|
|
task.updated_at = now_in_app_timezone()
|
|
db.session.add(
|
|
TaskActivity(
|
|
task_id=task.id, user_id=current_user.id, event="reopen", details="Task reopened to In Progress"
|
|
)
|
|
)
|
|
if not safe_commit("update_task_status_reopen_in_progress", {"task_id": task.id, "status": new_status}):
|
|
flash("Could not update status due to a database error. Please check server logs.", "error")
|
|
return redirect(url_for("tasks.view_task", task_id=task.id))
|
|
else:
|
|
previous_status = task.status
|
|
task.start_task()
|
|
db.session.add(
|
|
TaskActivity(
|
|
task_id=task.id,
|
|
user_id=current_user.id,
|
|
event="start",
|
|
details=f"Task moved from {previous_status} to In Progress",
|
|
)
|
|
)
|
|
safe_commit("log_task_start", {"task_id": task.id})
|
|
elif new_status == "done":
|
|
task.complete_task()
|
|
db.session.add(
|
|
TaskActivity(task_id=task.id, user_id=current_user.id, event="complete", details="Task completed")
|
|
)
|
|
safe_commit("log_task_complete", {"task_id": task.id})
|
|
elif new_status == "cancelled":
|
|
task.cancel_task()
|
|
db.session.add(
|
|
TaskActivity(task_id=task.id, user_id=current_user.id, event="cancel", details="Task cancelled")
|
|
)
|
|
safe_commit("log_task_cancel", {"task_id": task.id})
|
|
else:
|
|
# For other transitions, handle reopening from done and local timestamps
|
|
if task.status == "done" and new_status in ["todo", "review"]:
|
|
task.completed_at = None
|
|
previous_status = task.status
|
|
task.status = new_status
|
|
task.updated_at = now_in_app_timezone()
|
|
# Log pause or review or generic change
|
|
if previous_status == "done" and new_status in ["todo", "review"]:
|
|
event_name = "reopen"
|
|
else:
|
|
event_map = {
|
|
"todo": "pause",
|
|
"review": "review",
|
|
}
|
|
event_name = event_map.get(new_status, "status_change")
|
|
db.session.add(
|
|
TaskActivity(
|
|
task_id=task.id,
|
|
user_id=current_user.id,
|
|
event=event_name,
|
|
details=f"Task moved from {previous_status} to {new_status}",
|
|
)
|
|
)
|
|
if not safe_commit("update_task_status", {"task_id": task.id, "status": new_status}):
|
|
flash("Could not update status due to a database error. Please check server logs.", "error")
|
|
return redirect(url_for("tasks.view_task", task_id=task.id))
|
|
|
|
# Log task status change
|
|
app_module.log_event(
|
|
"task.status_changed",
|
|
user_id=current_user.id,
|
|
task_id=task.id,
|
|
old_status=previous_status,
|
|
new_status=new_status,
|
|
)
|
|
app_module.track_event(
|
|
current_user.id,
|
|
"task.status_changed",
|
|
{"task_id": task.id, "old_status": previous_status, "new_status": new_status},
|
|
)
|
|
|
|
flash(f"Task status updated to {task.status_display}", "success")
|
|
except ValueError as e:
|
|
flash(str(e), "error")
|
|
|
|
return redirect(url_for("tasks.view_task", task_id=task.id))
|
|
|
|
|
|
@tasks_bp.route("/tasks/<int:task_id>/priority", methods=["POST"])
|
|
@login_required
|
|
def update_task_priority(task_id):
|
|
"""Update task priority"""
|
|
task = Task.query.get_or_404(task_id)
|
|
new_priority = request.form.get("priority", "").strip()
|
|
|
|
# Check if user can update this task
|
|
if not current_user.is_admin and task.created_by != current_user.id:
|
|
flash(_("You can only update tasks you created"), "error")
|
|
return redirect(url_for("tasks.view_task", task_id=task.id))
|
|
|
|
try:
|
|
task.update_priority(new_priority)
|
|
flash(f"Task priority updated to {task.priority_display}", "success")
|
|
except ValueError as e:
|
|
flash(str(e), "error")
|
|
|
|
return redirect(url_for("tasks.view_task", task_id=task.id))
|
|
|
|
|
|
@tasks_bp.route("/tasks/<int:task_id>/assign", methods=["POST"])
|
|
@login_required
|
|
def assign_task(task_id):
|
|
"""Assign task to a user"""
|
|
task = Task.query.get_or_404(task_id)
|
|
user_id = request.form.get("user_id", type=int)
|
|
|
|
# Check if user can assign this task
|
|
if not current_user.is_admin and task.created_by != current_user.id:
|
|
flash(_("You can only assign tasks you created"), "error")
|
|
return redirect(url_for("tasks.view_task", task_id=task.id))
|
|
|
|
if user_id:
|
|
user = User.query.get(user_id)
|
|
if not user:
|
|
flash(_("Selected user does not exist"), "error")
|
|
return redirect(url_for("tasks.view_task", task_id=task.id))
|
|
|
|
task.reassign(user_id)
|
|
if user_id:
|
|
flash(f"Task assigned to {user.username}", "success")
|
|
else:
|
|
flash(_("Task unassigned"), "success")
|
|
|
|
return redirect(url_for("tasks.view_task", task_id=task.id))
|
|
|
|
|
|
@tasks_bp.route("/tasks/<int:task_id>/delete", methods=["POST"])
|
|
@login_required
|
|
def delete_task(task_id):
|
|
"""Delete a task"""
|
|
task = Task.query.get_or_404(task_id)
|
|
|
|
# Check if user can delete this task
|
|
if not current_user.is_admin and task.created_by != current_user.id:
|
|
flash(_("You can only delete tasks you created"), "error")
|
|
return redirect(url_for("tasks.view_task", task_id=task.id))
|
|
|
|
# Check if task has time entries
|
|
if task.time_entries.count() > 0:
|
|
flash(_("Cannot delete task with existing time entries"), "error")
|
|
return redirect(url_for("tasks.view_task", task_id=task.id))
|
|
|
|
task_name = task.name
|
|
task_id_for_log = task.id
|
|
project_id_for_log = task.project_id
|
|
|
|
# Log activity before deletion
|
|
Activity.log(
|
|
user_id=current_user.id,
|
|
action="deleted",
|
|
entity_type="task",
|
|
entity_id=task_id_for_log,
|
|
entity_name=task_name,
|
|
description=f'Deleted task "{task_name}"',
|
|
ip_address=request.remote_addr,
|
|
user_agent=request.headers.get("User-Agent"),
|
|
)
|
|
|
|
db.session.delete(task)
|
|
if not safe_commit("delete_task", {"task_id": task_id_for_log}):
|
|
flash(_("Could not delete task due to a database error. Please check server logs."), "error")
|
|
return redirect(url_for("tasks.view_task", task_id=task_id_for_log))
|
|
|
|
# Log task deletion
|
|
app_module.log_event(
|
|
"task.deleted", user_id=current_user.id, task_id=task_id_for_log, project_id=project_id_for_log
|
|
)
|
|
app_module.track_event(
|
|
current_user.id, "task.deleted", {"task_id": task_id_for_log, "project_id": project_id_for_log}
|
|
)
|
|
|
|
flash(f'Task "{task_name}" deleted successfully', "success")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
|
|
@tasks_bp.route("/tasks/bulk-delete", methods=["POST"])
|
|
@login_required
|
|
def bulk_delete_tasks():
|
|
"""Delete multiple tasks at once"""
|
|
task_ids = request.form.getlist("task_ids[]")
|
|
|
|
if not task_ids:
|
|
flash(_("No tasks selected for deletion"), "warning")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
deleted_count = 0
|
|
skipped_count = 0
|
|
errors = []
|
|
|
|
# Use eager loading to prevent N+1 queries when checking time entries
|
|
from sqlalchemy.orm import joinedload
|
|
|
|
for task_id_str in task_ids:
|
|
try:
|
|
task_id = int(task_id_str)
|
|
# Use get_or_404 for better error handling, but catch 404 for bulk operations
|
|
try:
|
|
task = Task.query.options(joinedload(Task.project)).get(task_id)
|
|
except Exception:
|
|
task = None
|
|
|
|
if not task:
|
|
continue
|
|
|
|
# Check permissions
|
|
if not current_user.is_admin and task.created_by != current_user.id:
|
|
skipped_count += 1
|
|
errors.append(f"'{task.name}': No permission")
|
|
continue
|
|
|
|
# Check for time entries - use exists() for better performance
|
|
from sqlalchemy import exists
|
|
|
|
from app.models import TimeEntry
|
|
|
|
has_time_entries = db.session.query(exists().where(TimeEntry.task_id == task_id)).scalar()
|
|
if has_time_entries:
|
|
skipped_count += 1
|
|
errors.append(f"'{task.name}': Has time entries")
|
|
continue
|
|
|
|
# Delete the task
|
|
task_id_for_log = task.id
|
|
project_id_for_log = task.project_id
|
|
task_name = task.name
|
|
|
|
db.session.delete(task)
|
|
deleted_count += 1
|
|
|
|
# Log the deletion
|
|
app_module.log_event(
|
|
"task.deleted", user_id=current_user.id, task_id=task_id_for_log, project_id=project_id_for_log
|
|
)
|
|
app_module.track_event(
|
|
current_user.id, "task.deleted", {"task_id": task_id_for_log, "project_id": project_id_for_log}
|
|
)
|
|
|
|
except Exception as e:
|
|
skipped_count += 1
|
|
errors.append(f"ID {task_id_str}: {str(e)}")
|
|
|
|
# Commit all deletions
|
|
if deleted_count > 0:
|
|
if not safe_commit("bulk_delete_tasks", {"count": deleted_count}):
|
|
flash(_("Could not delete tasks due to a database error. Please check server logs."), "error")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
# Show appropriate messages
|
|
if deleted_count > 0:
|
|
flash(f'Successfully deleted {deleted_count} task{"s" if deleted_count != 1 else ""}', "success")
|
|
|
|
if skipped_count > 0:
|
|
flash(f'Skipped {skipped_count} task{"s" if skipped_count != 1 else ""}: {"; ".join(errors[:3])}', "warning")
|
|
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
|
|
@tasks_bp.route("/tasks/bulk-status", methods=["POST"])
|
|
@login_required
|
|
def bulk_update_status():
|
|
"""Update status for multiple tasks at once"""
|
|
task_ids = request.form.getlist("task_ids[]")
|
|
new_status = request.form.get("status", "").strip()
|
|
|
|
if not task_ids:
|
|
flash(_("No tasks selected"), "warning")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
if not new_status:
|
|
flash(_("Invalid status value"), "error")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
updated_count = 0
|
|
skipped_count = 0
|
|
|
|
for task_id_str in task_ids:
|
|
try:
|
|
task_id = int(task_id_str)
|
|
task = Task.query.get(task_id)
|
|
|
|
if not task:
|
|
continue
|
|
|
|
# Validate status against configured kanban columns for this task's project
|
|
valid_statuses = set(
|
|
KanbanColumn.get_valid_status_keys(project_id=task.project_id)
|
|
if KanbanColumn
|
|
else ["todo", "in_progress", "review", "done", "cancelled"]
|
|
)
|
|
if new_status not in valid_statuses:
|
|
skipped_count += 1
|
|
continue
|
|
|
|
if not task:
|
|
continue
|
|
|
|
# Check permissions
|
|
if not current_user.is_admin and task.created_by != current_user.id:
|
|
skipped_count += 1
|
|
continue
|
|
|
|
# Handle reopening from done if needed
|
|
if task.status == "done" and new_status in ["todo", "review", "in_progress"]:
|
|
task.completed_at = None
|
|
task.status = new_status
|
|
task.updated_at = now_in_app_timezone()
|
|
updated_count += 1
|
|
|
|
except Exception:
|
|
skipped_count += 1
|
|
|
|
if updated_count > 0:
|
|
if not safe_commit("bulk_update_task_status", {"count": updated_count, "status": new_status}):
|
|
flash(_("Could not update tasks due to a database error"), "error")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
flash(
|
|
f'Successfully updated {updated_count} task{"s" if updated_count != 1 else ""} to {new_status}', "success"
|
|
)
|
|
|
|
if skipped_count > 0:
|
|
flash(f'Skipped {skipped_count} task{"s" if skipped_count != 1 else ""} (no permission)', "warning")
|
|
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
|
|
@tasks_bp.route("/tasks/bulk-update-due-date", methods=["POST"])
|
|
@login_required
|
|
def bulk_update_due_date():
|
|
"""Update due date for multiple tasks at once (e.g. from overdue page). Accepts JSON or form."""
|
|
if request.is_json:
|
|
data = request.get_json(silent=True) or {}
|
|
task_ids = [str(x) for x in data.get("task_ids") or []]
|
|
due_date_str = (data.get("due_date") or "").strip()
|
|
else:
|
|
task_ids = request.form.getlist("task_ids[]")
|
|
due_date_str = (request.form.get("due_date") or "").strip()
|
|
|
|
if not task_ids:
|
|
if request.is_json:
|
|
return jsonify({"success": False, "message": _("No tasks selected")}), 400
|
|
flash(_("No tasks selected"), "warning")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
if not due_date_str:
|
|
if request.is_json:
|
|
return jsonify({"success": False, "message": _("Due date is required (YYYY-MM-DD)")}), 400
|
|
flash(_("Due date is required"), "error")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
try:
|
|
from datetime import datetime as dt
|
|
|
|
due_date = dt.strptime(due_date_str, "%Y-%m-%d").date()
|
|
except ValueError:
|
|
if request.is_json:
|
|
return jsonify({"success": False, "message": _("Invalid date format. Use YYYY-MM-DD")}), 400
|
|
flash(_("Invalid date format. Use YYYY-MM-DD"), "error")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
updated_count = 0
|
|
skipped_count = 0
|
|
|
|
for task_id_str in task_ids:
|
|
try:
|
|
task_id = int(task_id_str)
|
|
task = Task.query.get(task_id)
|
|
if not task:
|
|
continue
|
|
if not current_user.is_admin and task.created_by != current_user.id:
|
|
skipped_count += 1
|
|
continue
|
|
task.update_due_date(due_date)
|
|
updated_count += 1
|
|
except Exception:
|
|
skipped_count += 1
|
|
|
|
if updated_count > 0:
|
|
if not safe_commit("bulk_update_task_due_date", {"count": updated_count, "due_date": due_date_str}):
|
|
if request.is_json:
|
|
return jsonify({"success": False, "message": _("Database error")}), 500
|
|
flash(_("Could not update tasks due to a database error"), "error")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
if request.is_json:
|
|
return jsonify(
|
|
{
|
|
"success": True,
|
|
"updated": updated_count,
|
|
"skipped": skipped_count,
|
|
"message": (
|
|
_("Updated %(count)s task(s)", count=updated_count) if updated_count else _("No tasks updated")
|
|
),
|
|
}
|
|
)
|
|
if updated_count > 0:
|
|
flash(
|
|
_("Successfully updated %(count)s task(s) due date to %(date)s", count=updated_count, date=due_date_str),
|
|
"success",
|
|
)
|
|
if skipped_count > 0:
|
|
flash(_("Skipped %(count)s task(s) (no permission)", count=skipped_count), "warning")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
|
|
@tasks_bp.route("/tasks/bulk-priority", methods=["POST"])
|
|
@login_required
|
|
def bulk_update_priority():
|
|
"""Update priority for multiple tasks at once"""
|
|
if request.is_json:
|
|
data = request.get_json(silent=True) or {}
|
|
task_ids = [str(x) for x in data.get("task_ids") or []]
|
|
new_priority = (data.get("priority") or "").strip()
|
|
else:
|
|
task_ids = request.form.getlist("task_ids[]")
|
|
new_priority = (request.form.get("priority") or "").strip()
|
|
|
|
if not task_ids:
|
|
if request.is_json:
|
|
return jsonify({"success": False, "message": _("No tasks selected")}), 400
|
|
flash(_("No tasks selected"), "warning")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
if not new_priority or new_priority not in ["low", "medium", "high", "urgent"]:
|
|
if request.is_json:
|
|
return jsonify({"success": False, "message": _("Invalid priority value")}), 400
|
|
flash(_("Invalid priority value"), "error")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
updated_count = 0
|
|
skipped_count = 0
|
|
|
|
for task_id_str in task_ids:
|
|
try:
|
|
task_id = int(task_id_str)
|
|
task = Task.query.get(task_id)
|
|
|
|
if not task:
|
|
continue
|
|
|
|
# Check permissions
|
|
if not current_user.is_admin and task.created_by != current_user.id:
|
|
skipped_count += 1
|
|
continue
|
|
|
|
task.priority = new_priority
|
|
task.updated_at = now_in_app_timezone()
|
|
updated_count += 1
|
|
|
|
except Exception:
|
|
skipped_count += 1
|
|
|
|
if updated_count > 0:
|
|
if not safe_commit("bulk_update_task_priority", {"count": updated_count, "priority": new_priority}):
|
|
if request.is_json:
|
|
return jsonify({"success": False, "message": _("Database error")}), 500
|
|
flash(_("Could not update tasks due to a database error"), "error")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
if request.is_json:
|
|
return jsonify(
|
|
{
|
|
"success": True,
|
|
"updated": updated_count,
|
|
"skipped": skipped_count,
|
|
"message": (
|
|
_("Updated %(count)s task(s)", count=updated_count) if updated_count else _("No tasks updated")
|
|
),
|
|
}
|
|
)
|
|
if updated_count > 0:
|
|
flash(
|
|
f'Successfully updated {updated_count} task{"s" if updated_count != 1 else ""} to {new_priority} priority',
|
|
"success",
|
|
)
|
|
if skipped_count > 0:
|
|
flash(f'Skipped {skipped_count} task{"s" if skipped_count != 1 else ""} (no permission)', "warning")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
|
|
@tasks_bp.route("/tasks/bulk-assign", methods=["POST"])
|
|
@login_required
|
|
def bulk_assign_tasks():
|
|
"""Assign multiple tasks to a user"""
|
|
task_ids = request.form.getlist("task_ids[]")
|
|
assigned_to = request.form.get("assigned_to", type=int)
|
|
|
|
if not task_ids:
|
|
flash(_("No tasks selected"), "warning")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
if not assigned_to:
|
|
flash(_("No user selected for assignment"), "error")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
# Verify user exists
|
|
user = User.query.get(assigned_to)
|
|
if not user:
|
|
flash(_("Invalid user selected"), "error")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
updated_count = 0
|
|
skipped_count = 0
|
|
|
|
for task_id_str in task_ids:
|
|
try:
|
|
task_id = int(task_id_str)
|
|
task = Task.query.get(task_id)
|
|
|
|
if not task:
|
|
continue
|
|
|
|
# Check permissions
|
|
if not current_user.is_admin and task.created_by != current_user.id:
|
|
skipped_count += 1
|
|
continue
|
|
|
|
task.assigned_to = assigned_to
|
|
updated_count += 1
|
|
|
|
except Exception:
|
|
skipped_count += 1
|
|
|
|
if updated_count > 0:
|
|
if not safe_commit("bulk_assign_tasks", {"count": updated_count, "assigned_to": assigned_to}):
|
|
flash(_("Could not assign tasks due to a database error"), "error")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
flash(
|
|
f'Successfully assigned {updated_count} task{"s" if updated_count != 1 else ""} to {user.display_name}',
|
|
"success",
|
|
)
|
|
|
|
if skipped_count > 0:
|
|
flash(f'Skipped {skipped_count} task{"s" if skipped_count != 1 else ""} (no permission)', "warning")
|
|
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
|
|
@tasks_bp.route("/tasks/bulk-move-project", methods=["POST"])
|
|
@login_required
|
|
def bulk_move_project():
|
|
"""Move multiple tasks to a different project"""
|
|
task_ids = request.form.getlist("task_ids[]")
|
|
new_project_id = request.form.get("project_id", type=int)
|
|
|
|
if not task_ids:
|
|
flash(_("No tasks selected"), "warning")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
if not new_project_id:
|
|
flash(_("No project selected"), "error")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
# Verify project exists and is active
|
|
new_project = Project.query.filter_by(id=new_project_id, status="active").first()
|
|
if not new_project:
|
|
flash(_("Invalid project selected"), "error")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
updated_count = 0
|
|
skipped_count = 0
|
|
|
|
for task_id_str in task_ids:
|
|
try:
|
|
task_id = int(task_id_str)
|
|
task = Task.query.get(task_id)
|
|
|
|
if not task:
|
|
continue
|
|
|
|
# Check permissions
|
|
if not current_user.is_admin and task.created_by != current_user.id:
|
|
skipped_count += 1
|
|
continue
|
|
|
|
# Update task project
|
|
old_project_id = task.project_id
|
|
task.project_id = new_project_id
|
|
|
|
# Batch-update related time entries to match the new project
|
|
TimeEntry.query.filter_by(task_id=task.id).update(
|
|
{"project_id": new_project_id}, synchronize_session="fetch"
|
|
)
|
|
|
|
# Log activity
|
|
db.session.add(
|
|
TaskActivity(
|
|
task_id=task.id,
|
|
user_id=current_user.id,
|
|
event="project_change",
|
|
details=f"Project changed from {old_project_id} to {new_project_id}",
|
|
)
|
|
)
|
|
|
|
updated_count += 1
|
|
|
|
except Exception:
|
|
skipped_count += 1
|
|
|
|
if updated_count > 0:
|
|
if not safe_commit("bulk_move_project", {"count": updated_count, "project_id": new_project_id}):
|
|
flash(_("Could not move tasks due to a database error"), "error")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
flash(
|
|
f'Successfully moved {updated_count} task{"s" if updated_count != 1 else ""} to {new_project.name}',
|
|
"success",
|
|
)
|
|
|
|
if skipped_count > 0:
|
|
flash(f'Skipped {skipped_count} task{"s" if skipped_count != 1 else ""} (no permission)', "warning")
|
|
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
|
|
@tasks_bp.route("/tasks/export")
|
|
@login_required
|
|
def export_tasks():
|
|
"""Export tasks to CSV (supports same filters as list view, including multi-select)"""
|
|
|
|
# Parse filter parameters - same logic as list_tasks (multi-select + backward compat)
|
|
def parse_ids(param_name):
|
|
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 []
|
|
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_ids = parse_ids("project_id")
|
|
assigned_to_ids = parse_ids("assigned_to")
|
|
search = request.args.get("search", "").strip()
|
|
tags = request.args.get("tags", "").strip()
|
|
overdue_param = request.args.get("overdue", "").strip().lower()
|
|
overdue = overdue_param in ["1", "true", "on", "yes"]
|
|
|
|
query = Task.query
|
|
|
|
# Apply filters (same as list_tasks)
|
|
if status:
|
|
query = query.filter_by(status=status)
|
|
|
|
if priority:
|
|
query = query.filter_by(priority=priority)
|
|
|
|
if project_ids:
|
|
query = query.filter(Task.project_id.in_(project_ids))
|
|
|
|
if assigned_to_ids:
|
|
query = query.filter(Task.assigned_to.in_(assigned_to_ids))
|
|
|
|
if search:
|
|
like = f"%{search}%"
|
|
query = query.filter(db.or_(Task.name.ilike(like), Task.description.ilike(like)))
|
|
|
|
if tags:
|
|
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
|
if tag_list:
|
|
tag_conditions = [Task.tags.ilike(f"%{tag}%") for tag in tag_list]
|
|
query = query.filter(db.or_(*tag_conditions))
|
|
|
|
# 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 - users without view_all_tasks permission only see their tasks
|
|
has_view_all_tasks = current_user.is_admin or current_user.has_permission("view_all_tasks")
|
|
if not has_view_all_tasks:
|
|
query = query.filter(db.or_(Task.assigned_to == current_user.id, Task.created_by == current_user.id))
|
|
|
|
tasks = query.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).all()
|
|
|
|
# Create CSV in memory
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
|
|
# Write header
|
|
writer.writerow(
|
|
[
|
|
"ID",
|
|
"Name",
|
|
"Description",
|
|
"Project",
|
|
"Status",
|
|
"Priority",
|
|
"Tags",
|
|
"Assigned To",
|
|
"Created By",
|
|
"Due Date",
|
|
"Estimated Hours",
|
|
"Created At",
|
|
"Updated At",
|
|
]
|
|
)
|
|
|
|
# Write task data
|
|
for task in tasks:
|
|
writer.writerow(
|
|
[
|
|
task.id,
|
|
task.name,
|
|
task.description or "",
|
|
task.project.name if task.project else "",
|
|
task.status,
|
|
task.priority,
|
|
task.tags or "",
|
|
task.assigned_user.display_name if task.assigned_user else "",
|
|
task.creator.display_name if task.creator else "",
|
|
task.due_date.strftime("%Y-%m-%d") if task.due_date else "",
|
|
task.estimated_hours or "",
|
|
(
|
|
convert_app_datetime_to_user(task.created_at, user=current_user).strftime("%Y-%m-%d %H:%M:%S")
|
|
if task.created_at
|
|
else ""
|
|
),
|
|
(
|
|
convert_app_datetime_to_user(task.updated_at, user=current_user).strftime("%Y-%m-%d %H:%M:%S")
|
|
if task.updated_at
|
|
else ""
|
|
),
|
|
]
|
|
)
|
|
|
|
# Create response
|
|
output.seek(0)
|
|
return Response(
|
|
output.getvalue(),
|
|
mimetype="text/csv",
|
|
headers={
|
|
"Content-Disposition": f'attachment; filename=tasks_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
|
|
},
|
|
)
|
|
|
|
|
|
@tasks_bp.route("/tasks/my-tasks")
|
|
@login_required
|
|
def my_tasks():
|
|
"""Show current user's tasks with filters and pagination"""
|
|
page = request.args.get("page", 1, type=int)
|
|
status = request.args.get("status", "")
|
|
priority = request.args.get("priority", "")
|
|
project_id = request.args.get("project_id", type=int)
|
|
search = request.args.get("search", "").strip()
|
|
tags = request.args.get("tags", "").strip()
|
|
task_type = request.args.get("task_type", "") # '', 'assigned', 'created'
|
|
overdue_param = request.args.get("overdue", "").strip().lower()
|
|
overdue = overdue_param in ["1", "true", "on", "yes"]
|
|
|
|
query = Task.query
|
|
|
|
# Restrict to current user's tasks depending on task_type filter
|
|
if task_type == "assigned":
|
|
query = query.filter(Task.assigned_to == current_user.id)
|
|
elif task_type == "created":
|
|
query = query.filter(Task.created_by == current_user.id)
|
|
else:
|
|
query = query.filter(db.or_(Task.assigned_to == current_user.id, Task.created_by == current_user.id))
|
|
|
|
# Apply filters
|
|
if status:
|
|
query = query.filter_by(status=status)
|
|
|
|
if priority:
|
|
query = query.filter_by(priority=priority)
|
|
|
|
if project_id:
|
|
query = query.filter_by(project_id=project_id)
|
|
|
|
if search:
|
|
like = f"%{search}%"
|
|
query = query.filter(db.or_(Task.name.ilike(like), Task.description.ilike(like)))
|
|
|
|
if tags:
|
|
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
|
if tag_list:
|
|
tag_conditions = [Task.tags.ilike(f"%{tag}%") for tag in tag_list]
|
|
query = query.filter(db.or_(*tag_conditions))
|
|
|
|
# Overdue filter (uses application's local date)
|
|
if overdue:
|
|
today_local = now_in_app_timezone().date()
|
|
query = query.filter(Task.due_date < today_local, Task.status.in_(["todo", "in_progress", "review"]))
|
|
|
|
tasks = query.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).paginate(
|
|
page=page, per_page=20, error_out=False
|
|
)
|
|
|
|
# Provide projects for filter dropdown
|
|
projects = Project.query.filter_by(status="active").order_by(Project.name).all()
|
|
# Force fresh kanban columns from database (no cache)
|
|
db.session.expire_all()
|
|
kanban_columns = KanbanColumn.get_active_columns() if KanbanColumn else []
|
|
|
|
# Precompute task counts by status for summary cards (avoid template selectattr iteration)
|
|
task_counts = {"todo": 0, "in_progress": 0, "review": 0, "done": 0}
|
|
for task in tasks.items:
|
|
if task.status in task_counts:
|
|
task_counts[task.status] += 1
|
|
|
|
# Prevent browser caching of kanban board
|
|
response = render_template(
|
|
"tasks/my_tasks.html",
|
|
tasks=tasks.items,
|
|
pagination=tasks,
|
|
projects=projects,
|
|
kanban_columns=kanban_columns,
|
|
status=status,
|
|
priority=priority,
|
|
project_id=project_id,
|
|
search=search,
|
|
tags=tags,
|
|
task_type=task_type,
|
|
overdue=overdue,
|
|
task_counts=task_counts,
|
|
)
|
|
resp = make_response(response)
|
|
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
|
|
resp.headers["Pragma"] = "no-cache"
|
|
resp.headers["Expires"] = "0"
|
|
return resp
|
|
|
|
|
|
@tasks_bp.route("/tasks/overdue")
|
|
@login_required
|
|
def overdue_tasks():
|
|
"""Show all overdue tasks"""
|
|
if not current_user.is_admin:
|
|
flash(_("Only administrators can view all overdue tasks"), "error")
|
|
return redirect(url_for("tasks.list_tasks"))
|
|
|
|
tasks = Task.get_overdue_tasks()
|
|
kanban_columns = KanbanColumn.get_active_columns() if KanbanColumn else []
|
|
|
|
return render_template("tasks/overdue.html", tasks=tasks, kanban_columns=kanban_columns)
|
|
|
|
|
|
@tasks_bp.route("/api/tasks/<int:task_id>")
|
|
@login_required
|
|
def api_task(task_id):
|
|
"""API endpoint to get task details"""
|
|
task = Task.query.get_or_404(task_id)
|
|
|
|
# Check if user has access to this task
|
|
if not current_user.is_admin and task.assigned_to != current_user.id and task.created_by != current_user.id:
|
|
return jsonify({"error": "Access denied"}), 403
|
|
|
|
return jsonify(task.to_dict())
|
|
|
|
|
|
@tasks_bp.route("/api/tasks/<int:task_id>/status", methods=["PUT"])
|
|
@login_required
|
|
def api_update_status(task_id):
|
|
"""API endpoint to update task status"""
|
|
task = Task.query.get_or_404(task_id)
|
|
data = request.get_json()
|
|
new_status = data.get("status", "").strip()
|
|
|
|
# Check if user can update this task
|
|
if not current_user.is_admin and task.assigned_to != current_user.id and task.created_by != current_user.id:
|
|
return jsonify({"error": "Access denied"}), 403
|
|
|
|
# Validate status against configured kanban columns for this task's project
|
|
valid_statuses = KanbanColumn.get_valid_status_keys(project_id=task.project_id)
|
|
if new_status not in valid_statuses:
|
|
return jsonify({"error": "Invalid status"}), 400
|
|
|
|
# Update status
|
|
try:
|
|
if new_status == "in_progress":
|
|
if task.status == "done":
|
|
task.completed_at = None
|
|
task.status = "in_progress"
|
|
if not task.started_at:
|
|
task.started_at = now_in_app_timezone()
|
|
task.updated_at = now_in_app_timezone()
|
|
if not safe_commit(
|
|
"api_update_task_status_reopen_in_progress", {"task_id": task.id, "status": new_status}
|
|
):
|
|
return jsonify({"error": "Database error while updating status"}), 500
|
|
else:
|
|
task.start_task()
|
|
elif new_status == "done":
|
|
task.complete_task()
|
|
elif new_status == "cancelled":
|
|
task.cancel_task()
|
|
else:
|
|
if task.status == "done" and new_status in ["todo", "review"]:
|
|
task.completed_at = None
|
|
task.status = new_status
|
|
task.updated_at = now_in_app_timezone()
|
|
if not safe_commit("api_update_task_status", {"task_id": task.id, "status": new_status}):
|
|
return jsonify({"error": "Database error while updating status"}), 500
|
|
|
|
return jsonify({"success": True, "task": task.to_dict()})
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 400
|