mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-04-28 08:21:13 -05:00
54ec5fe4b2
Implements comprehensive bulk operations and export functionality for tasks, clients, and projects with consistent UI/UX across all three entities. Features Added: - Bulk task operations (delete, status change, assignment, move to project) - Multi-select checkboxes with "select all" functionality - CSV export for tasks, clients, and projects - Export respects current filters and permissions - Modal dialogs for bulk operation confirmation Bug Fixes: - Fixed bulk delete not working due to dialog submission issue - Fixed dropdown menus being cut off in short tables (z-index and overflow) - Fixed projects export attempting to access .name on string property Technical Details: - Backend: Added 5 new routes (tasks bulk ops, 3 export routes) - Frontend: Updated task/client/project list templates with consistent UI - Tests: Added 23 comprehensive tests for bulk operations - Changed table overflow from overflow-x-auto to overflow-visible - Added z-50 to all dropdown menus for proper layering Routes Added: - POST /tasks/bulk-delete - POST /tasks/bulk-status - POST /tasks/bulk-assign - POST /tasks/bulk-move-project - GET /tasks/export - GET /clients/export - GET /projects/export Files Changed: - app/routes/tasks.py (+103 lines) - app/routes/clients.py (+73 lines) - app/routes/projects.py (+95 lines) - app/templates/tasks/list.html (major refactor) - app/templates/clients/list.html (+export, overflow fix) - app/templates/projects/list.html (+export fix, overflow fix) - tests/test_bulk_task_operations.py (NEW, 23 tests) - docs/BULK_TASK_OPERATIONS.md (NEW) - BULK_TASK_OPERATIONS_IMPLEMENTATION.md (NEW) - BUGFIXES_BULK_OPERATIONS.md (NEW) - BUGFIXES_CONSISTENCY_AND_EXPORT.md (NEW) Breaking Changes: None Migration Required: None
566 lines
20 KiB
Python
566 lines
20 KiB
Python
"""
|
|
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
|
|
|