mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-04 10:40:23 -06:00
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:
@@ -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():
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
227
docs/BULK_TASK_OPERATIONS.md
Normal file
227
docs/BULK_TASK_OPERATIONS.md
Normal 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
|
||||
|
||||
@@ -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}
|
||||
|
||||
565
tests/test_bulk_task_operations.py
Normal file
565
tests/test_bulk_task_operations.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user