Merge pull request #188 from DRYTRIX/Feat-Bulk-Task-Operations

feat: Add bulk task operations and CSV export across all entities
This commit is contained in:
Dries Peeters
2025-10-30 10:07:13 +01:00
committed by GitHub
9 changed files with 1429 additions and 58 deletions

View File

@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, Response
from flask_babel import gettext as _
from flask_login import login_required, current_user
import app as app_module
@@ -8,6 +8,8 @@ from datetime import datetime
from decimal import Decimal
from app.utils.db import safe_commit
from app.utils.permissions import admin_or_permission_required
import csv
import io
clients_bp = Blueprint('clients', __name__)
@@ -431,6 +433,82 @@ def bulk_status_change():
return redirect(url_for('clients.list_clients'))
@clients_bp.route('/clients/export')
@login_required
def export_clients():
"""Export clients to CSV"""
status = request.args.get('status', 'active')
search = request.args.get('search', '').strip()
query = Client.query
if status == 'active':
query = query.filter_by(status='active')
elif status == 'inactive':
query = query.filter_by(status='inactive')
if search:
like = f"%{search}%"
query = query.filter(
db.or_(
Client.name.ilike(like),
Client.description.ilike(like),
Client.contact_person.ilike(like),
Client.email.ilike(like)
)
)
clients = query.order_by(Client.name).all()
# Create CSV in memory
output = io.StringIO()
writer = csv.writer(output)
# Write header
writer.writerow([
'ID',
'Name',
'Description',
'Contact Person',
'Email',
'Phone',
'Address',
'Default Hourly Rate',
'Status',
'Active Projects',
'Total Projects',
'Created At',
'Updated At'
])
# Write client data
for client in clients:
writer.writerow([
client.id,
client.name,
client.description or '',
client.contact_person or '',
client.email or '',
client.phone or '',
client.address or '',
client.default_hourly_rate or '',
client.status,
client.active_projects,
client.total_projects,
client.created_at.strftime('%Y-%m-%d %H:%M:%S') if client.created_at else '',
client.updated_at.strftime('%Y-%m-%d %H:%M:%S') if client.updated_at else ''
])
# Create response
output.seek(0)
return Response(
output.getvalue(),
mimetype='text/csv',
headers={
'Content-Disposition': f'attachment; filename=clients_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
}
)
@clients_bp.route('/api/clients')
@login_required
def api_clients():

View File

@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, make_response
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, make_response, Response
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db, log_event, track_event
@@ -7,6 +7,8 @@ from datetime import datetime
from decimal import Decimal
from app.utils.db import safe_commit
from app.utils.permissions import admin_or_permission_required, permission_required
import csv
import io
from app.utils.posthog_funnels import (
track_onboarding_first_project,
track_project_setup_started,
@@ -87,6 +89,100 @@ def list_projects():
favorites_only=favorites_only
)
@projects_bp.route('/projects/export')
@login_required
def export_projects():
"""Export projects to CSV"""
status = request.args.get('status', 'active')
client_name = request.args.get('client', '').strip()
search = request.args.get('search', '').strip()
favorites_only = request.args.get('favorites', '').lower() == 'true'
query = Project.query
# Filter by favorites if requested
if favorites_only:
query = query.join(
UserFavoriteProject,
db.and_(
UserFavoriteProject.project_id == Project.id,
UserFavoriteProject.user_id == current_user.id
)
)
# Filter by status
if status == 'active':
query = query.filter(Project.status == 'active')
elif status == 'archived':
query = query.filter(Project.status == 'archived')
elif status == 'inactive':
query = query.filter(Project.status == 'inactive')
if client_name:
query = query.join(Client).filter(Client.name == client_name)
if search:
like = f"%{search}%"
query = query.filter(
db.or_(
Project.name.ilike(like),
Project.description.ilike(like)
)
)
projects = query.order_by(Project.name).all()
# Create CSV in memory
output = io.StringIO()
writer = csv.writer(output)
# Write header
writer.writerow([
'ID',
'Name',
'Code',
'Client',
'Description',
'Status',
'Billable',
'Hourly Rate',
'Budget Amount',
'Budget Threshold %',
'Estimated Hours',
'Billing Reference',
'Created At',
'Updated At'
])
# Write project data
for project in projects:
writer.writerow([
project.id,
project.name,
project.code or '',
project.client if project.client else '',
project.description or '',
project.status,
'Yes' if project.billable else 'No',
project.hourly_rate or '',
project.budget_amount or '',
project.budget_threshold_percent or '',
project.estimated_hours or '',
project.billing_ref or '',
project.created_at.strftime('%Y-%m-%d %H:%M:%S') if project.created_at else '',
project.updated_at.strftime('%Y-%m-%d %H:%M:%S') if hasattr(project, 'updated_at') and project.updated_at else ''
])
# Create response
output.seek(0)
return Response(
output.getvalue(),
mimetype='text/csv',
headers={
'Content-Disposition': f'attachment; filename=projects_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
}
)
@projects_bp.route('/projects/create', methods=['GET', 'POST'])
@login_required
@admin_or_permission_required('create_projects')

View File

@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, make_response
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, make_response, Response
from flask_babel import gettext as _
from flask_login import login_required, current_user
import app as app_module
@@ -8,6 +8,8 @@ from datetime import datetime, date
from decimal import Decimal
from app.utils.db import safe_commit
from app.utils.timezone import now_in_app_timezone
import csv
import io
tasks_bp = Blueprint('tasks', __name__)
@@ -772,6 +774,181 @@ def bulk_assign_tasks():
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
# Update related time entries to match the new project
for entry in task.time_entries.all():
entry.project_id = new_project_id
# 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"""
# Get the same filters as the list view
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)
search = request.args.get('search', '').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_id:
query = query.filter_by(project_id=project_id)
if assigned_to:
query = query.filter_by(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'])
)
# Show user's tasks first, then others
if not current_user.is_admin:
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',
'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.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 '',
task.created_at.strftime('%Y-%m-%d %H:%M:%S') if task.created_at else '',
task.updated_at.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():

View File

@@ -32,24 +32,29 @@
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible">
<div class="flex justify-between items-center mb-4">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ clients|length }} client{{ 's' if clients|length != 1 else '' }} found
</h3>
{% if current_user.is_admin %}
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'clientsBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="clientsBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg">
<li><a class="block px-4 py-2 text-sm" href="#" onclick="return showBulkStatusChange('active')"><i class="fas fa-check-circle mr-2 text-green-600"></i>Mark as Active</a></li>
<li><a class="block px-4 py-2 text-sm" href="#" onclick="return showBulkStatusChange('inactive')"><i class="fas fa-pause-circle mr-2 text-amber-500"></i>Mark as Inactive</a></li>
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
<li><a class="block px-4 py-2 text-sm text-rose-600" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
</ul>
<div class="flex items-center gap-2">
<a href="{{ url_for('clients.export_clients', status=status, search=search) }}" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center" title="Export to CSV">
<i class="fas fa-download mr-1"></i> Export
</a>
{% if current_user.is_admin %}
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'clientsBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="clientsBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 z-50 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg">
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('active')"><i class="fas fa-check-circle mr-2 text-green-600"></i>Mark as Active</a></li>
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('inactive')"><i class="fas fa-pause-circle mr-2 text-amber-500"></i>Mark as Inactive</a></li>
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
</ul>
</div>
{% endif %}
</div>
{% endif %}
</div>
<table class="w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">

View File

@@ -52,15 +52,15 @@
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible">
<div class="flex justify-between items-center mb-4">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ projects|length }} project{{ 's' if projects|length != 1 else '' }} found
</h3>
<div class="flex items-center gap-2">
<button type="button" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" title="Export to CSV">
<a href="{{ url_for('projects.export_projects', status=status, client=request.args.get('client', ''), search=request.args.get('search', ''), favorites=request.args.get('favorites', '')) }}" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center" title="Export to CSV">
<i class="fas fa-download mr-1"></i> Export
</button>
</a>
{% if current_user.is_admin %}
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'projectsBulkMenu')" disabled>

View File

@@ -90,20 +90,24 @@
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible">
<div class="flex justify-between items-center mb-4">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ tasks|length }} task{{ 's' if tasks|length != 1 else '' }} found
</h3>
<div class="flex items-center gap-2">
<button type="button" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" title="Export to CSV">
<a href="{{ url_for('tasks.export_tasks', status=status, priority=priority, project_id=project_id, assigned_to=assigned_to, search=search, overdue=overdue) }}" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center" title="Export to CSV">
<i class="fas fa-download mr-1"></i> Export
</button>
</a>
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'tasksBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="tasksBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-48 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg">
<ul id="tasksBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusDialog()"><i class="fas fa-tasks mr-2"></i>Change Status</a></li>
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkAssignDialog()"><i class="fas fa-user mr-2"></i>Assign To</a></li>
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkProjectDialog()"><i class="fas fa-folder mr-2"></i>Move to Project</a></li>
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
</ul>
</div>
@@ -187,20 +191,121 @@
</table>
</div>
<!-- Bulk Delete Form (hidden) -->
<!-- Bulk Operations Forms (hidden) -->
<form id="confirmBulkDelete-form" method="POST" action="{{ url_for('tasks.bulk_delete_tasks') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
<form id="bulkStatusForm" method="POST" action="{{ url_for('tasks.bulk_update_status') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="status" id="bulkStatusValue">
</form>
<form id="bulkAssignForm" method="POST" action="{{ url_for('tasks.bulk_assign_tasks') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="assigned_to" id="bulkAssignValue">
</form>
<form id="bulkProjectForm" method="POST" action="{{ url_for('tasks.bulk_move_project') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="project_id" id="bulkProjectValue">
</form>
<!-- Bulk Delete Confirmation Dialog -->
{{ confirm_dialog(
'confirmBulkDelete',
'Delete Selected Tasks',
'Are you sure you want to delete the selected tasks? This action cannot be undone. Tasks with existing time entries will be skipped.',
'Delete',
'Cancel',
'danger'
) }}
<div id="confirmBulkDelete" class="fixed inset-0 z-50 hidden overflow-y-auto" role="dialog" aria-modal="true">
<div class="flex items-center justify-center min-h-screen px-4">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75" onclick="closeBulkDeleteDialog()"></div>
<div class="relative bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full p-6 zoom-in">
<div class="flex items-start mb-4">
<div class="flex-shrink-0">
<div class="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-red-600 dark:text-red-400 text-xl"></i>
</div>
</div>
<div class="ml-4 flex-1">
<h3 class="text-lg font-semibold text-text-light dark:text-text-dark mb-2">Delete Selected Tasks</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">Are you sure you want to delete the selected tasks? This action cannot be undone. Tasks with existing time entries will be skipped.</p>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button type="button" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" onclick="closeBulkDeleteDialog()">
Cancel
</button>
<button type="button" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors" onclick="submitBulkDelete()">
Delete
</button>
</div>
</div>
</div>
</div>
<!-- Bulk Status Change Dialog -->
<div id="bulkStatusDialog" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">Change Status for Selected Tasks</h3>
<label for="bulkStatusSelect" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Select Status</label>
<select id="bulkStatusSelect" class="form-input w-full mb-4">
<option value="">-- Select Status --</option>
{% if kanban_columns %}
{% for column in kanban_columns %}
<option value="{{ column.status_key }}">{{ column.name }}</option>
{% endfor %}
{% else %}
<option value="todo">To Do</option>
<option value="in_progress">In Progress</option>
<option value="review">Review</option>
<option value="done">Done</option>
<option value="cancelled">Cancelled</option>
{% endif %}
</select>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeBulkStatusDialog()" class="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600">Cancel</button>
<button type="button" onclick="submitBulkStatus()" class="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary/90">Update Status</button>
</div>
</div>
</div>
</div>
<!-- Bulk Assign Dialog -->
<div id="bulkAssignDialog" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">Assign Selected Tasks</h3>
<label for="bulkAssignSelect" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Select User</label>
<select id="bulkAssignSelect" class="form-input w-full mb-4">
<option value="">-- Select User --</option>
{% for user in users %}
<option value="{{ user.id }}">{{ user.display_name }}</option>
{% endfor %}
</select>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeBulkAssignDialog()" class="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600">Cancel</button>
<button type="button" onclick="submitBulkAssign()" class="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary/90">Assign Tasks</button>
</div>
</div>
</div>
</div>
<!-- Bulk Move to Project Dialog -->
<div id="bulkProjectDialog" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">Move Selected Tasks to Project</h3>
<label for="bulkProjectSelect" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Select Project</label>
<select id="bulkProjectSelect" class="form-input w-full mb-4">
<option value="">-- Select Project --</option>
{% for project in projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% endfor %}
</select>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeBulkProjectDialog()" class="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600">Cancel</button>
<button type="button" onclick="submitBulkProject()" class="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary/90">Move Tasks</button>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -249,6 +354,138 @@ function showBulkDeleteConfirm() {
return false;
}
function closeBulkDeleteDialog() {
document.getElementById('confirmBulkDelete').classList.add('hidden');
}
function submitBulkDelete() {
const form = document.getElementById('confirmBulkDelete-form');
// Clear existing hidden inputs
form.querySelectorAll('input[name="task_ids[]"]').forEach(input => input.remove());
// Add selected task IDs to form
const checkboxes = document.querySelectorAll('.task-checkbox:checked');
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'task_ids[]';
input.value = cb.value;
form.appendChild(input);
});
// Submit the form
form.submit();
}
// Bulk status change functions
function showBulkStatusDialog() {
document.getElementById('bulkStatusDialog').classList.remove('hidden');
return false;
}
function closeBulkStatusDialog() {
document.getElementById('bulkStatusDialog').classList.add('hidden');
}
function submitBulkStatus() {
const status = document.getElementById('bulkStatusSelect').value;
if (!status) {
alert('Please select a status');
return;
}
const form = document.getElementById('bulkStatusForm');
document.getElementById('bulkStatusValue').value = status;
// Clear existing hidden inputs
form.querySelectorAll('input[name="task_ids[]"]').forEach(input => input.remove());
// Add selected task IDs to form
const checkboxes = document.querySelectorAll('.task-checkbox:checked');
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'task_ids[]';
input.value = cb.value;
form.appendChild(input);
});
form.submit();
}
// Bulk assign functions
function showBulkAssignDialog() {
document.getElementById('bulkAssignDialog').classList.remove('hidden');
return false;
}
function closeBulkAssignDialog() {
document.getElementById('bulkAssignDialog').classList.add('hidden');
}
function submitBulkAssign() {
const assignedTo = document.getElementById('bulkAssignSelect').value;
if (!assignedTo) {
alert('Please select a user');
return;
}
const form = document.getElementById('bulkAssignForm');
document.getElementById('bulkAssignValue').value = assignedTo;
// Clear existing hidden inputs
form.querySelectorAll('input[name="task_ids[]"]').forEach(input => input.remove());
// Add selected task IDs to form
const checkboxes = document.querySelectorAll('.task-checkbox:checked');
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'task_ids[]';
input.value = cb.value;
form.appendChild(input);
});
form.submit();
}
// Bulk move to project functions
function showBulkProjectDialog() {
document.getElementById('bulkProjectDialog').classList.remove('hidden');
return false;
}
function closeBulkProjectDialog() {
document.getElementById('bulkProjectDialog').classList.add('hidden');
}
function submitBulkProject() {
const projectId = document.getElementById('bulkProjectSelect').value;
if (!projectId) {
alert('Please select a project');
return;
}
const form = document.getElementById('bulkProjectForm');
document.getElementById('bulkProjectValue').value = projectId;
// Clear existing hidden inputs
form.querySelectorAll('input[name="task_ids[]"]').forEach(input => input.remove());
// Add selected task IDs to form
const checkboxes = document.querySelectorAll('.task-checkbox:checked');
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'task_ids[]';
input.value = cb.value;
form.appendChild(input);
});
form.submit();
}
// Delete task confirmation (single)
function confirmDeleteTask(taskId, taskName, hasTimeEntries) {
if (hasTimeEntries) {
@@ -330,32 +567,6 @@ document.addEventListener('DOMContentLoaded', function() {
toggleButton.title = '{{ _('Hide Filters') }}';
}
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
// Handle bulk delete confirmation
const form = document.getElementById('confirmBulkDelete-form');
if (form) {
form.addEventListener('submit', function(e) {
// Prevent default to add task IDs first
e.preventDefault();
const checkboxes = document.querySelectorAll('.task-checkbox:checked');
// Clear existing hidden inputs
form.querySelectorAll('input[name="task_ids[]"]').forEach(input => input.remove());
// Add selected task IDs to form
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'task_ids[]';
input.value = cb.value;
form.appendChild(input);
});
// Now submit the form
form.submit();
});
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,227 @@
# Bulk Task Operations
This document describes the bulk task operations feature that allows users to perform actions on multiple tasks simultaneously.
## Overview
The bulk task operations feature provides an efficient way to manage multiple tasks at once, reducing the time and effort required for common administrative tasks. This feature is available on the main task list page.
## Features
### 1. Multi-Select Checkboxes
- Each task in the list has a checkbox for selection
- A "Select All" checkbox in the header selects/deselects all visible tasks
- Selected count is displayed in the bulk actions menu
- Visual feedback shows which tasks are selected
### 2. Bulk Status Change
Change the status of multiple tasks simultaneously.
**How to use:**
1. Select one or more tasks using checkboxes
2. Click "Bulk Actions" button
3. Select "Change Status"
4. Choose the desired status from the dropdown
5. Click "Update Status"
**Supported statuses:**
- To Do
- In Progress
- Review
- Done
- Cancelled
**Behavior:**
- Updates all selected tasks to the chosen status
- When reopening completed tasks, automatically clears the `completed_at` timestamp
- Respects permission checks (users can only update tasks they created)
- Provides feedback on success and any skipped tasks
### 3. Bulk Assignment
Assign multiple tasks to a user at once.
**How to use:**
1. Select one or more tasks using checkboxes
2. Click "Bulk Actions" button
3. Select "Assign To"
4. Choose the user from the dropdown
5. Click "Assign Tasks"
**Behavior:**
- Assigns all selected tasks to the chosen user
- Users can only assign tasks they created (unless they're admin)
- Provides feedback on success and any skipped tasks
### 4. Bulk Move to Project
Move multiple tasks to a different project.
**How to use:**
1. Select one or more tasks using checkboxes
2. Click "Bulk Actions" button
3. Select "Move to Project"
4. Choose the target project from the dropdown
5. Click "Move Tasks"
**Behavior:**
- Moves all selected tasks to the target project
- Automatically updates related time entries to match the new project
- Logs task activity for the project change
- Users can only move tasks they created (unless they're admin)
- Only active projects are shown in the dropdown
### 5. Bulk Delete
Delete multiple tasks at once (with confirmation).
**How to use:**
1. Select one or more tasks using checkboxes
2. Click "Bulk Actions" button
3. Select "Delete"
4. Confirm the deletion in the dialog
5. Click "Delete" to proceed
**Behavior:**
- Requires confirmation before deletion
- Tasks with existing time entries are automatically skipped (not deleted)
- Users can only delete tasks they created (unless they're admin)
- Provides feedback on success and any skipped tasks
- Deletion is permanent and cannot be undone
## Permissions
Bulk operations respect the following permission rules:
- **Regular Users**: Can only perform bulk operations on tasks they created
- **Admin Users**: Can perform bulk operations on any tasks
- **Permission Violations**: Tasks that the user doesn't have permission to modify are automatically skipped with a warning message
## User Interface
### Bulk Actions Button
Located in the task list toolbar, the button shows:
- Number of selected tasks
- Disabled state when no tasks are selected
- Dropdown menu with all available bulk operations
### Dialog Boxes
Each bulk operation (except delete) has a dedicated dialog with:
- Clear title explaining the action
- Dropdown for selecting the target (status, user, or project)
- Cancel button to abort the operation
- Submit button to perform the action
### Confirmation Dialog
The bulk delete operation shows a confirmation dialog with:
- Warning about permanent deletion
- Note about tasks with time entries being skipped
- Cancel and Delete buttons
## Technical Details
### Routes
All bulk operation routes are POST endpoints:
```
POST /tasks/bulk-delete - Delete multiple tasks
POST /tasks/bulk-status - Change status for multiple tasks
POST /tasks/bulk-assign - Assign multiple tasks to a user
POST /tasks/bulk-move-project - Move multiple tasks to a project
```
### Request Format
All routes expect the following POST data:
```
task_ids[]: Array of task IDs (e.g., ['1', '2', '3'])
status: Target status (for bulk-status)
assigned_to: User ID (for bulk-assign)
project_id: Project ID (for bulk-move-project)
csrf_token: CSRF protection token
```
### Response Behavior
- **Success**: Redirects to task list with success flash message
- **Partial Success**: Redirects with success message and warning about skipped tasks
- **Error**: Redirects with error flash message
- **No Selection**: Returns warning about no tasks selected
### Database Operations
- All bulk operations are performed in a single database transaction
- Changes are committed only after all validations pass
- Failed operations result in a rollback
- Activity logging for audit trail (where applicable)
## Best Practices
1. **Review Selection**: Always review selected tasks before performing bulk operations
2. **Start Small**: Test with a small number of tasks first
3. **Check Permissions**: Ensure you have permission to modify the selected tasks
4. **Time Entries**: Remember that tasks with time entries cannot be deleted
5. **Backup Data**: For critical operations, ensure you have recent backups
## Error Handling
The feature includes comprehensive error handling:
- **No Tasks Selected**: Friendly warning message
- **Invalid Input**: Validation errors with specific messages
- **Permission Denied**: Tasks are skipped with warning
- **Database Errors**: Safe rollback with error message
- **Network Issues**: Standard browser error handling
## Testing
Comprehensive tests are available in `tests/test_bulk_task_operations.py`:
- Unit tests for each operation
- Integration tests with real data
- Permission checking tests
- Error handling tests
- Smoke tests for route availability
To run the tests:
```bash
pytest tests/test_bulk_task_operations.py -v
```
## Future Enhancements
Potential improvements for future versions:
1. **Bulk Priority Change**: Change priority for multiple tasks
2. **Bulk Due Date Update**: Set due dates for multiple tasks
3. **Export Selected**: Export only selected tasks
4. **Undo Operation**: Ability to undo recent bulk operations
5. **Keyboard Shortcuts**: Quick access via keyboard shortcuts
6. **Advanced Selection**: Select by filters (e.g., all overdue tasks)
## Troubleshooting
### Tasks Not Being Updated
- Check that you have permission to modify the tasks
- Verify that the tasks exist and haven't been deleted
- Look for error messages in the flash notifications
### Bulk Delete Skipping Tasks
- Tasks with time entries cannot be deleted
- Delete time entries first, then retry
- Alternatively, use task archiving instead
### Selection Not Working
- Clear browser cache and reload
- Check JavaScript console for errors
- Ensure JavaScript is enabled in your browser
## Support
For issues or questions about bulk task operations:
1. Check this documentation first
2. Review the test suite for examples
3. Check the application logs for errors
4. Contact your system administrator

View File

@@ -102,3 +102,15 @@
{"asctime": "2025-10-29 08:57:21,949", "levelname": "INFO", "name": "timetracker", "message": "task.updated", "taskName": null, "request_id": "b91eb6e3-4229-4e57-a38c-a50a0d8d4fc8", "event": "task.updated", "user_id": 1, "task_id": 1, "project_id": 2}
{"asctime": "2025-10-29 08:57:25,166", "levelname": "INFO", "name": "timetracker", "message": "timer.duplicated", "taskName": null, "request_id": "bb3a4bdf-773c-4a92-85bb-fd93b838e50c", "event": "timer.duplicated", "user_id": 1, "time_entry_id": 1, "project_id": 1, "task_id": 1}
{"asctime": "2025-10-29 08:57:26,120", "levelname": "INFO", "name": "timetracker", "message": "timer.duplicated", "taskName": null, "request_id": "e1f0e4ad-5de0-40cc-9630-20fc944ed3b7", "event": "timer.duplicated", "user_id": 1, "time_entry_id": 1, "project_id": 1, "task_id": null}
{"asctime": "2025-10-30 09:33:51,285", "levelname": "INFO", "name": "timetracker", "message": "task.deleted", "taskName": null, "request_id": "984166af-7388-44a3-93a4-c8c18d8daad5", "event": "task.deleted", "user_id": 1, "task_id": 1, "project_id": 1}
{"asctime": "2025-10-30 09:33:51,306", "levelname": "INFO", "name": "timetracker", "message": "task.deleted", "taskName": null, "request_id": "984166af-7388-44a3-93a4-c8c18d8daad5", "event": "task.deleted", "user_id": 1, "task_id": 2, "project_id": 1}
{"asctime": "2025-10-30 09:33:51,317", "levelname": "INFO", "name": "timetracker", "message": "task.deleted", "taskName": null, "request_id": "984166af-7388-44a3-93a4-c8c18d8daad5", "event": "task.deleted", "user_id": 1, "task_id": 3, "project_id": 1}
{"asctime": "2025-10-30 09:34:44,871", "levelname": "INFO", "name": "timetracker", "message": "task.deleted", "taskName": null, "request_id": "48672a3c-55a2-4875-a6f1-4d465bfc9a33", "event": "task.deleted", "user_id": 1, "task_id": 1, "project_id": 1}
{"asctime": "2025-10-30 09:34:44,892", "levelname": "INFO", "name": "timetracker", "message": "task.deleted", "taskName": null, "request_id": "48672a3c-55a2-4875-a6f1-4d465bfc9a33", "event": "task.deleted", "user_id": 1, "task_id": 2, "project_id": 1}
{"asctime": "2025-10-30 09:34:44,892", "levelname": "INFO", "name": "timetracker", "message": "task.deleted", "taskName": null, "request_id": "48672a3c-55a2-4875-a6f1-4d465bfc9a33", "event": "task.deleted", "user_id": 1, "task_id": 3, "project_id": 1}
{"asctime": "2025-10-30 09:43:59,793", "levelname": "INFO", "name": "timetracker", "message": "task.deleted", "taskName": null, "request_id": "f6bf169b-cc3b-497d-acaa-334ff0c15cee", "event": "task.deleted", "user_id": 1, "task_id": 1, "project_id": 1}
{"asctime": "2025-10-30 09:43:59,817", "levelname": "INFO", "name": "timetracker", "message": "task.deleted", "taskName": null, "request_id": "f6bf169b-cc3b-497d-acaa-334ff0c15cee", "event": "task.deleted", "user_id": 1, "task_id": 2, "project_id": 1}
{"asctime": "2025-10-30 09:43:59,821", "levelname": "INFO", "name": "timetracker", "message": "task.deleted", "taskName": null, "request_id": "f6bf169b-cc3b-497d-acaa-334ff0c15cee", "event": "task.deleted", "user_id": 1, "task_id": 3, "project_id": 1}
{"asctime": "2025-10-30 09:45:46,439", "levelname": "INFO", "name": "timetracker", "message": "task.deleted", "taskName": null, "request_id": "87a0ad0f-3147-49be-850a-048e28fe9887", "event": "task.deleted", "user_id": 1, "task_id": 1, "project_id": 1}
{"asctime": "2025-10-30 09:45:46,455", "levelname": "INFO", "name": "timetracker", "message": "task.deleted", "taskName": null, "request_id": "87a0ad0f-3147-49be-850a-048e28fe9887", "event": "task.deleted", "user_id": 1, "task_id": 2, "project_id": 1}
{"asctime": "2025-10-30 09:45:46,461", "levelname": "INFO", "name": "timetracker", "message": "task.deleted", "taskName": null, "request_id": "87a0ad0f-3147-49be-850a-048e28fe9887", "event": "task.deleted", "user_id": 1, "task_id": 3, "project_id": 1}

View File

@@ -0,0 +1,565 @@
"""
Test suite for bulk task operations.
Tests bulk delete, bulk status change, bulk assignment, and bulk move to project.
"""
import pytest
from flask import url_for
from app.models import Task, Project, User, TaskActivity
from app import db
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def tasks_for_bulk(app, user, admin_user, project):
"""Create multiple tasks for bulk operations testing."""
with app.app_context():
tasks = []
for i in range(5):
task = Task(
project_id=project.id,
name=f'Bulk Test Task {i+1}',
description=f'Task {i+1} for bulk operations',
priority='medium',
status='todo',
created_by=user.id
)
db.session.add(task)
tasks.append(task)
db.session.commit()
# Refresh to get IDs
for task in tasks:
db.session.refresh(task)
return tasks
@pytest.fixture
def second_project(app):
"""Create a second project for move operations testing."""
with app.app_context():
from app.models import Client as ClientModel
# Create or get a client for the second project
project_client = ClientModel.query.first()
if not project_client:
project_client = ClientModel(name='Test Client 2', email='client2@example.com', created_by=1)
db.session.add(project_client)
db.session.commit()
db.session.refresh(project_client)
project = Project(
name='Second Project',
client_id=project_client.id,
billable=True,
status='active',
created_by=1
)
db.session.add(project)
db.session.commit()
db.session.refresh(project)
return project
# ============================================================================
# Bulk Delete Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.routes
def test_bulk_delete_no_tasks_selected(authenticated_client):
"""Test bulk delete with no tasks selected."""
response = authenticated_client.post('/tasks/bulk-delete', data={
'task_ids[]': []
}, follow_redirects=True)
assert response.status_code == 200
assert b'No tasks selected' in response.data or b'No tasks' in response.data
@pytest.mark.integration
@pytest.mark.routes
def test_bulk_delete_multiple_tasks(authenticated_client, app, tasks_for_bulk):
"""Test bulk deleting multiple tasks."""
with app.app_context():
task_ids = [str(task.id) for task in tasks_for_bulk[:3]]
response = authenticated_client.post('/tasks/bulk-delete', data={
'task_ids[]': task_ids
}, follow_redirects=True)
assert response.status_code == 200
assert b'Successfully deleted' in response.data or b'deleted' in response.data
# Verify tasks are deleted
for task_id in task_ids:
task = Task.query.get(int(task_id))
assert task is None
@pytest.mark.integration
@pytest.mark.routes
def test_bulk_delete_with_time_entries_skips_task(authenticated_client, app, user, project):
"""Test that bulk delete skips tasks with time entries."""
with app.app_context():
# Create task with time entry
task = Task(
project_id=project.id,
name='Task with Time Entry',
created_by=user.id
)
db.session.add(task)
db.session.commit()
db.session.refresh(task)
from app.models import TimeEntry
from datetime import datetime
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
task_id=task.id,
start_time=datetime.utcnow(),
end_time=datetime.utcnow(),
duration_seconds=3600
)
db.session.add(entry)
db.session.commit()
response = authenticated_client.post('/tasks/bulk-delete', data={
'task_ids[]': [str(task.id)]
}, follow_redirects=True)
assert response.status_code == 200
assert b'Skipped' in response.data or b'time entries' in response.data
# Verify task still exists
task = Task.query.get(task.id)
assert task is not None
@pytest.mark.integration
@pytest.mark.routes
def test_bulk_delete_permission_check(client, app, admin_user, user, project):
"""Test that non-admin users can only delete their own tasks."""
with app.app_context():
# Create task owned by admin
admin_task = Task(
project_id=project.id,
name='Admin Task',
created_by=admin_user.id
)
db.session.add(admin_task)
db.session.commit()
db.session.refresh(admin_task)
# Try to delete as regular user
with client.session_transaction() as sess:
sess['_user_id'] = str(user.id)
response = client.post('/tasks/bulk-delete', data={
'task_ids[]': [str(admin_task.id)]
}, follow_redirects=True)
assert response.status_code == 200
# Verify task still exists (skipped due to no permission)
task = Task.query.get(admin_task.id)
assert task is not None
# ============================================================================
# Bulk Status Change Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.routes
def test_bulk_status_no_tasks_selected(authenticated_client):
"""Test bulk status change with no tasks selected."""
response = authenticated_client.post('/tasks/bulk-status', data={
'task_ids[]': [],
'status': 'in_progress'
}, follow_redirects=True)
assert response.status_code == 200
assert b'No tasks selected' in response.data or b'No tasks' in response.data
@pytest.mark.integration
@pytest.mark.routes
def test_bulk_status_change_multiple_tasks(authenticated_client, app, tasks_for_bulk):
"""Test changing status for multiple tasks."""
with app.app_context():
task_ids = [str(task.id) for task in tasks_for_bulk[:3]]
response = authenticated_client.post('/tasks/bulk-status', data={
'task_ids[]': task_ids,
'status': 'in_progress'
}, follow_redirects=True)
assert response.status_code == 200
assert b'Successfully updated' in response.data or b'updated' in response.data
# Verify status is changed
for task_id in task_ids:
task = Task.query.get(int(task_id))
assert task is not None
assert task.status == 'in_progress'
@pytest.mark.integration
@pytest.mark.routes
def test_bulk_status_invalid_status(authenticated_client, app, tasks_for_bulk):
"""Test bulk status change with invalid status."""
with app.app_context():
task_ids = [str(task.id) for task in tasks_for_bulk[:2]]
response = authenticated_client.post('/tasks/bulk-status', data={
'task_ids[]': task_ids,
'status': 'invalid_status'
}, follow_redirects=True)
assert response.status_code == 200
assert b'Invalid status' in response.data or b'error' in response.data.lower()
@pytest.mark.integration
@pytest.mark.routes
def test_bulk_status_reopen_from_done(authenticated_client, app, tasks_for_bulk):
"""Test bulk status change to reopen completed tasks."""
with app.app_context():
# Mark tasks as done first
for task in tasks_for_bulk[:2]:
task.status = 'done'
from datetime import datetime
task.completed_at = datetime.utcnow()
db.session.commit()
task_ids = [str(task.id) for task in tasks_for_bulk[:2]]
response = authenticated_client.post('/tasks/bulk-status', data={
'task_ids[]': task_ids,
'status': 'in_progress'
}, follow_redirects=True)
assert response.status_code == 200
# Verify completed_at is cleared
for task_id in task_ids:
task = Task.query.get(int(task_id))
assert task.status == 'in_progress'
assert task.completed_at is None
# ============================================================================
# Bulk Assignment Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.routes
def test_bulk_assign_no_tasks_selected(authenticated_client, user):
"""Test bulk assignment with no tasks selected."""
response = authenticated_client.post('/tasks/bulk-assign', data={
'task_ids[]': [],
'assigned_to': user.id
}, follow_redirects=True)
assert response.status_code == 200
assert b'No tasks selected' in response.data or b'No tasks' in response.data
@pytest.mark.integration
@pytest.mark.routes
def test_bulk_assign_multiple_tasks(authenticated_client, app, tasks_for_bulk, admin_user):
"""Test assigning multiple tasks to a user."""
with app.app_context():
task_ids = [str(task.id) for task in tasks_for_bulk[:3]]
response = authenticated_client.post('/tasks/bulk-assign', data={
'task_ids[]': task_ids,
'assigned_to': admin_user.id
}, follow_redirects=True)
assert response.status_code == 200
assert b'Successfully assigned' in response.data or b'assigned' in response.data
# Verify assignment
for task_id in task_ids:
task = Task.query.get(int(task_id))
assert task is not None
assert task.assigned_to == admin_user.id
@pytest.mark.integration
@pytest.mark.routes
def test_bulk_assign_no_user_selected(authenticated_client, app, tasks_for_bulk):
"""Test bulk assignment without selecting a user."""
with app.app_context():
task_ids = [str(task.id) for task in tasks_for_bulk[:2]]
response = authenticated_client.post('/tasks/bulk-assign', data={
'task_ids[]': task_ids
}, follow_redirects=True)
assert response.status_code == 200
assert b'No user selected' in response.data or b'error' in response.data.lower()
@pytest.mark.integration
@pytest.mark.routes
def test_bulk_assign_invalid_user(authenticated_client, app, tasks_for_bulk):
"""Test bulk assignment with invalid user ID."""
with app.app_context():
task_ids = [str(task.id) for task in tasks_for_bulk[:2]]
response = authenticated_client.post('/tasks/bulk-assign', data={
'task_ids[]': task_ids,
'assigned_to': 99999 # Non-existent user ID
}, follow_redirects=True)
assert response.status_code == 200
assert b'Invalid user' in response.data or b'error' in response.data.lower()
# ============================================================================
# Bulk Move to Project Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.routes
def test_bulk_move_project_no_tasks_selected(authenticated_client, project):
"""Test bulk move to project with no tasks selected."""
response = authenticated_client.post('/tasks/bulk-move-project', data={
'task_ids[]': [],
'project_id': project.id
}, follow_redirects=True)
assert response.status_code == 200
assert b'No tasks selected' in response.data or b'No tasks' in response.data
@pytest.mark.integration
@pytest.mark.routes
def test_bulk_move_project_multiple_tasks(authenticated_client, app, tasks_for_bulk, second_project):
"""Test moving multiple tasks to a different project."""
with app.app_context():
task_ids = [str(task.id) for task in tasks_for_bulk[:3]]
original_project_id = tasks_for_bulk[0].project_id
response = authenticated_client.post('/tasks/bulk-move-project', data={
'task_ids[]': task_ids,
'project_id': second_project.id
}, follow_redirects=True)
assert response.status_code == 200
assert b'Successfully moved' in response.data or b'moved' in response.data
# Verify project change
for task_id in task_ids:
task = Task.query.get(int(task_id))
assert task is not None
assert task.project_id == second_project.id
assert task.project_id != original_project_id
@pytest.mark.integration
@pytest.mark.routes
def test_bulk_move_project_updates_time_entries(authenticated_client, app, user, project, second_project):
"""Test that bulk move to project updates related time entries."""
with app.app_context():
# Create task with time entry
task = Task(
project_id=project.id,
name='Task with Time Entry',
created_by=user.id
)
db.session.add(task)
db.session.commit()
db.session.refresh(task)
from app.models import TimeEntry
from datetime import datetime
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
task_id=task.id,
start_time=datetime.utcnow(),
end_time=datetime.utcnow(),
duration_seconds=3600
)
db.session.add(entry)
db.session.commit()
db.session.refresh(entry)
response = authenticated_client.post('/tasks/bulk-move-project', data={
'task_ids[]': [str(task.id)],
'project_id': second_project.id
}, follow_redirects=True)
assert response.status_code == 200
# Verify time entry project is updated
entry = TimeEntry.query.get(entry.id)
assert entry.project_id == second_project.id
@pytest.mark.integration
@pytest.mark.routes
def test_bulk_move_project_no_project_selected(authenticated_client, app, tasks_for_bulk):
"""Test bulk move to project without selecting a project."""
with app.app_context():
task_ids = [str(task.id) for task in tasks_for_bulk[:2]]
response = authenticated_client.post('/tasks/bulk-move-project', data={
'task_ids[]': task_ids
}, follow_redirects=True)
assert response.status_code == 200
assert b'No project selected' in response.data or b'error' in response.data.lower()
@pytest.mark.integration
@pytest.mark.routes
def test_bulk_move_project_invalid_project(authenticated_client, app, tasks_for_bulk):
"""Test bulk move to project with invalid project ID."""
with app.app_context():
task_ids = [str(task.id) for task in tasks_for_bulk[:2]]
response = authenticated_client.post('/tasks/bulk-move-project', data={
'task_ids[]': task_ids,
'project_id': 99999 # Non-existent project ID
}, follow_redirects=True)
assert response.status_code == 200
assert b'Invalid project' in response.data or b'error' in response.data.lower()
@pytest.mark.integration
@pytest.mark.routes
def test_bulk_move_project_logs_activity(authenticated_client, app, tasks_for_bulk, second_project):
"""Test that bulk move to project logs task activity."""
with app.app_context():
task_ids = [str(task.id) for task in tasks_for_bulk[:2]]
response = authenticated_client.post('/tasks/bulk-move-project', data={
'task_ids[]': task_ids,
'project_id': second_project.id
}, follow_redirects=True)
assert response.status_code == 200
# Verify activity is logged
for task_id in task_ids:
task = Task.query.get(int(task_id))
activities = task.activities.filter_by(event='project_change').all()
assert len(activities) > 0
# ============================================================================
# Smoke Tests
# ============================================================================
@pytest.mark.smoke
@pytest.mark.routes
def test_bulk_operations_routes_exist(authenticated_client):
"""Smoke test to verify bulk operations routes exist."""
# Test bulk delete route
response = authenticated_client.post('/tasks/bulk-delete', data={
'task_ids[]': []
}, follow_redirects=True)
assert response.status_code == 200
# Test bulk status route
response = authenticated_client.post('/tasks/bulk-status', data={
'task_ids[]': [],
'status': 'todo'
}, follow_redirects=True)
assert response.status_code == 200
# Test bulk assign route
response = authenticated_client.post('/tasks/bulk-assign', data={
'task_ids[]': []
}, follow_redirects=True)
assert response.status_code == 200
# Test bulk move project route
response = authenticated_client.post('/tasks/bulk-move-project', data={
'task_ids[]': []
}, follow_redirects=True)
assert response.status_code == 200
@pytest.mark.smoke
@pytest.mark.routes
def test_task_list_has_checkboxes(authenticated_client):
"""Smoke test to verify task list page has checkboxes for bulk operations."""
response = authenticated_client.get('/tasks')
assert response.status_code == 200
assert b'task-checkbox' in response.data or b'checkbox' in response.data
assert b'selectAll' in response.data or b'select' in response.data.lower()
# ============================================================================
# CSV Export Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.routes
def test_export_tasks_csv(authenticated_client, app, tasks_for_bulk):
"""Test exporting tasks to CSV."""
with app.app_context():
response = authenticated_client.get('/tasks/export')
assert response.status_code == 200
assert response.mimetype == 'text/csv'
assert 'attachment' in response.headers.get('Content-Disposition', '')
# Check CSV content
csv_data = response.data.decode('utf-8')
assert 'ID' in csv_data
assert 'Name' in csv_data
assert 'Project' in csv_data
assert 'Status' in csv_data
# Check that task data is in CSV
assert tasks_for_bulk[0].name in csv_data
@pytest.mark.integration
@pytest.mark.routes
def test_export_tasks_with_filters(authenticated_client, app, tasks_for_bulk):
"""Test exporting tasks with filters applied."""
with app.app_context():
# Update one task to a different status
tasks_for_bulk[0].status = 'in_progress'
db.session.commit()
# Export with status filter
response = authenticated_client.get('/tasks/export?status=in_progress')
assert response.status_code == 200
csv_data = response.data.decode('utf-8')
# Verify CSV structure
lines = csv_data.split('\n')
assert 'ID,Name,Description,Project,Status' in lines[0]
# Check if filter worked - if no data, at least header should be there
# The actual data presence depends on permission model
assert len(lines) >= 1 # At least header
@pytest.mark.smoke
@pytest.mark.routes
def test_export_button_exists(authenticated_client):
"""Smoke test to verify export button exists on task list."""
response = authenticated_client.get('/tasks')
assert response.status_code == 200
assert b'Export' in response.data or b'export' in response.data
assert b'/tasks/export' in response.data