From 397175f38e7cb643d42b510227a61c1c54bef974 Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Sun, 6 Jul 2025 08:49:09 +0200 Subject: [PATCH] Add Task Archive feature. --- app.py | 88 +++++++++++ migrate_db.py | 203 +++++++++++++++++++++++++ models.py | 2 + templates/task_modal.html | 1 + templates/unified_task_management.html | 199 +++++++++++++++++++++++- 5 files changed, 489 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index f4b3572..2f97bf7 100644 --- a/app.py +++ b/app.py @@ -69,6 +69,15 @@ def run_migrations(): with app.app_context(): db.create_all() init_system_settings() + + # Run PostgreSQL-specific migrations + try: + from migrate_db import migrate_postgresql_schema + migrate_postgresql_schema() + except ImportError: + print("PostgreSQL migration function not available") + except Exception as e: + print(f"Warning: PostgreSQL migration failed: {e}") print("PostgreSQL setup completed successfully!") else: print("Using SQLite - running SQLite migrations...") @@ -3996,6 +4005,13 @@ def update_task_status(task_id): # Clear completion date if moving away from completed task.completed_date = None + # Set archived date if status is ARCHIVED + if task_status == TaskStatus.ARCHIVED: + task.archived_date = datetime.now().date() + elif old_status == TaskStatus.ARCHIVED: + # Clear archived date if moving away from archived + task.archived_date = None + db.session.commit() return jsonify({ @@ -4199,6 +4215,78 @@ def remove_task_dependency(task_id, dependency_task_id): return jsonify({'success': False, 'message': str(e)}) +# Task Archive/Restore APIs +@app.route('/api/tasks//archive', methods=['POST']) +@role_required(Role.TEAM_MEMBER) +@company_required +def archive_task(task_id): + """Archive a completed task""" + try: + # Get the task and verify ownership through project + task = Task.query.join(Project).filter( + Task.id == task_id, + Project.company_id == g.user.company_id + ).first() + if not task: + return jsonify({'success': False, 'message': 'Task not found'}) + + # Only allow archiving completed tasks + if task.status != TaskStatus.COMPLETED: + return jsonify({'success': False, 'message': 'Only completed tasks can be archived'}) + + # Archive the task + task.status = TaskStatus.ARCHIVED + task.archived_date = datetime.now().date() + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Task archived successfully', + 'archived_date': task.archived_date.isoformat() + }) + + except Exception as e: + db.session.rollback() + logger.error(f"Error archiving task: {str(e)}") + return jsonify({'success': False, 'message': str(e)}) + + +@app.route('/api/tasks//restore', methods=['POST']) +@role_required(Role.TEAM_MEMBER) +@company_required +def restore_task(task_id): + """Restore an archived task to completed status""" + try: + # Get the task and verify ownership through project + task = Task.query.join(Project).filter( + Task.id == task_id, + Project.company_id == g.user.company_id + ).first() + if not task: + return jsonify({'success': False, 'message': 'Task not found'}) + + # Only allow restoring archived tasks + if task.status != TaskStatus.ARCHIVED: + return jsonify({'success': False, 'message': 'Only archived tasks can be restored'}) + + # Restore the task to completed status + task.status = TaskStatus.COMPLETED + task.archived_date = None + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Task restored successfully' + }) + + except Exception as e: + db.session.rollback() + logger.error(f"Error restoring task: {str(e)}") + return jsonify({'success': False, 'message': str(e)}) + + # Sprint Management APIs @app.route('/api/sprints') @role_required(Role.TEAM_MEMBER) diff --git a/migrate_db.py b/migrate_db.py index c846492..1b53d4c 100644 --- a/migrate_db.py +++ b/migrate_db.py @@ -74,6 +74,10 @@ def run_all_migrations(db_path=None): migrate_task_system(db_path) migrate_system_events(db_path) migrate_dashboard_system(db_path) + + # Run PostgreSQL-specific migrations if applicable + if FLASK_AVAILABLE: + migrate_postgresql_schema() if FLASK_AVAILABLE: with app.app_context(): @@ -604,24 +608,61 @@ def migrate_task_system(db_path): cursor.execute(""" CREATE TABLE task ( id INTEGER PRIMARY KEY AUTOINCREMENT, + task_number VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(200) NOT NULL, description TEXT, status VARCHAR(50) DEFAULT 'Not Started', priority VARCHAR(50) DEFAULT 'Medium', estimated_hours FLOAT, project_id INTEGER NOT NULL, + sprint_id INTEGER, assigned_to_id INTEGER, start_date DATE, due_date DATE, completed_date DATE, + archived_date DATE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_by_id INTEGER NOT NULL, FOREIGN KEY (project_id) REFERENCES project (id), + FOREIGN KEY (sprint_id) REFERENCES sprint (id), FOREIGN KEY (assigned_to_id) REFERENCES user (id), FOREIGN KEY (created_by_id) REFERENCES user (id) ) """) + else: + # Add missing columns to existing task table + cursor.execute("PRAGMA table_info(task)") + task_columns = [column[1] for column in cursor.fetchall()] + + task_migrations = [ + ('task_number', "ALTER TABLE task ADD COLUMN task_number VARCHAR(20)"), + ('sprint_id', "ALTER TABLE task ADD COLUMN sprint_id INTEGER"), + ('archived_date', "ALTER TABLE task ADD COLUMN archived_date DATE") + ] + + for column_name, sql_command in task_migrations: + if column_name not in task_columns: + print(f"Adding {column_name} column to task table...") + cursor.execute(sql_command) + + # Add unique constraint for task_number if it was just added + if 'task_number' not in task_columns: + print("Adding unique constraint for task_number...") + # For SQLite, we need to recreate the table to add unique constraint + cursor.execute("CREATE UNIQUE INDEX idx_task_number ON task(task_number)") + + # Generate task numbers for existing tasks that don't have them + print("Generating task numbers for existing tasks...") + cursor.execute("SELECT id FROM task WHERE task_number IS NULL ORDER BY id") + tasks_without_numbers = cursor.fetchall() + + for i, (task_id,) in enumerate(tasks_without_numbers, 1): + task_number = f"TSK-{i:03d}" + cursor.execute("UPDATE task SET task_number = ? WHERE id = ?", (task_number, task_id)) + + if tasks_without_numbers: + print(f"Generated {len(tasks_without_numbers)} task numbers") # Check if sub_task table exists cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='sub_task'") @@ -649,6 +690,49 @@ def migrate_task_system(db_path): ) """) + # Check if task_dependency table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='task_dependency'") + if not cursor.fetchone(): + print("Creating task_dependency table...") + cursor.execute(""" + CREATE TABLE task_dependency ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + blocked_task_id INTEGER NOT NULL, + blocking_task_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (blocked_task_id) REFERENCES task (id), + FOREIGN KEY (blocking_task_id) REFERENCES task (id), + UNIQUE(blocked_task_id, blocking_task_id), + CHECK (blocked_task_id != blocking_task_id) + ) + """) + + # Check if sprint table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='sprint'") + if not cursor.fetchone(): + print("Creating sprint table...") + cursor.execute(""" + CREATE TABLE sprint ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(200) NOT NULL, + description TEXT, + status VARCHAR(50) DEFAULT 'PLANNING', + goal TEXT, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + capacity_hours INTEGER, + project_id INTEGER, + company_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by_id INTEGER NOT NULL, + FOREIGN KEY (project_id) REFERENCES project (id), + FOREIGN KEY (company_id) REFERENCES company (id), + FOREIGN KEY (created_by_id) REFERENCES user (id), + UNIQUE(company_id, name) + ) + """) + # Add category_id to project table if it doesn't exist cursor.execute("PRAGMA table_info(project)") project_columns = [column[1] for column in cursor.fetchall()] @@ -884,6 +968,120 @@ def create_all_tables(cursor): print("All tables created") +def migrate_postgresql_schema(): + """Migrate PostgreSQL schema for archive functionality.""" + if not FLASK_AVAILABLE: + print("Skipping PostgreSQL migration - Flask not available") + return + + try: + import psycopg2 + from sqlalchemy import text + + with app.app_context(): + # Check if we're using PostgreSQL + database_url = app.config['SQLALCHEMY_DATABASE_URI'] + if not ('postgresql://' in database_url or 'postgres://' in database_url): + print("Not using PostgreSQL - skipping PostgreSQL migration") + return + + print("Running PostgreSQL schema migrations...") + + # Check if archived_date column exists + result = db.session.execute(text(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'task' AND column_name = 'archived_date' + """)) + + if not result.fetchone(): + print("Adding archived_date column to task table...") + db.session.execute(text("ALTER TABLE task ADD COLUMN archived_date DATE")) + db.session.commit() + + # Check if ARCHIVED status exists in enum + result = db.session.execute(text(""" + SELECT enumlabel + FROM pg_enum + WHERE enumtypid = (SELECT oid FROM pg_type WHERE typname = 'taskstatus') + AND enumlabel = 'ARCHIVED' + """)) + + if not result.fetchone(): + print("Adding ARCHIVED status to TaskStatus enum...") + db.session.execute(text("ALTER TYPE taskstatus ADD VALUE 'ARCHIVED'")) + db.session.commit() + + # Check if task_number column exists + result = db.session.execute(text(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'task' AND column_name = 'task_number' + """)) + + if not result.fetchone(): + print("Adding task_number column to task table...") + db.session.execute(text("ALTER TABLE task ADD COLUMN task_number VARCHAR(20) UNIQUE")) + + # Generate task numbers for existing tasks + print("Generating task numbers for existing tasks...") + result = db.session.execute(text("SELECT id FROM task WHERE task_number IS NULL ORDER BY id")) + tasks_without_numbers = result.fetchall() + + for i, (task_id,) in enumerate(tasks_without_numbers, 1): + task_number = f"TSK-{i:03d}" + db.session.execute(text("UPDATE task SET task_number = :task_number WHERE id = :task_id"), + {"task_number": task_number, "task_id": task_id}) + + db.session.commit() + if tasks_without_numbers: + print(f"Generated {len(tasks_without_numbers)} task numbers") + + # Check if sprint_id column exists + result = db.session.execute(text(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'task' AND column_name = 'sprint_id' + """)) + + if not result.fetchone(): + print("Adding sprint_id column to task table...") + db.session.execute(text("ALTER TABLE task ADD COLUMN sprint_id INTEGER")) + db.session.execute(text("ALTER TABLE task ADD CONSTRAINT fk_task_sprint FOREIGN KEY (sprint_id) REFERENCES sprint (id)")) + db.session.commit() + + # Check if task_dependency table exists + result = db.session.execute(text(""" + SELECT table_name + FROM information_schema.tables + WHERE table_name = 'task_dependency' + """)) + + if not result.fetchone(): + print("Creating task_dependency table...") + db.session.execute(text(""" + CREATE TABLE task_dependency ( + id SERIAL PRIMARY KEY, + blocked_task_id INTEGER NOT NULL, + blocking_task_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (blocked_task_id) REFERENCES task (id), + FOREIGN KEY (blocking_task_id) REFERENCES task (id), + UNIQUE(blocked_task_id, blocking_task_id), + CHECK (blocked_task_id <> blocking_task_id) + ) + """)) + db.session.commit() + + print("PostgreSQL schema migration completed successfully!") + + except Exception as e: + print(f"Error during PostgreSQL migration: {e}") + if FLASK_AVAILABLE: + db.session.rollback() + raise + + def migrate_dashboard_system(db_file=None): """Migrate to add Dashboard widget system.""" db_path = get_db_path(db_file) @@ -1045,6 +1243,8 @@ def main(): help='Run only system events migration') parser.add_argument('--dashboard', '--dash', action='store_true', help='Run only dashboard system migration') + parser.add_argument('--postgresql', '--pg', action='store_true', + help='Run only PostgreSQL schema migration') args = parser.parse_args() @@ -1081,6 +1281,9 @@ def main(): elif args.dashboard: migrate_dashboard_system(db_path) + elif args.postgresql: + migrate_postgresql_schema() + else: # Default: run all migrations run_all_migrations(db_path) diff --git a/models.py b/models.py index 237a8a1..19f64bb 100644 --- a/models.py +++ b/models.py @@ -421,6 +421,7 @@ class TaskStatus(enum.Enum): IN_PROGRESS = "In Progress" ON_HOLD = "On Hold" COMPLETED = "Completed" + ARCHIVED = "Archived" CANCELLED = "Cancelled" # Task priority enumeration @@ -455,6 +456,7 @@ class Task(db.Model): start_date = db.Column(db.Date, nullable=True) due_date = db.Column(db.Date, nullable=True) completed_date = db.Column(db.Date, nullable=True) + archived_date = db.Column(db.Date, nullable=True) # Metadata created_at = db.Column(db.DateTime, default=datetime.now) diff --git a/templates/task_modal.html b/templates/task_modal.html index 1343eac..33f6055 100644 --- a/templates/task_modal.html +++ b/templates/task_modal.html @@ -40,6 +40,7 @@ + diff --git a/templates/unified_task_management.html b/templates/unified_task_management.html index 4689076..96845cc 100644 --- a/templates/unified_task_management.html +++ b/templates/unified_task_management.html @@ -22,6 +22,7 @@
+
@@ -44,6 +45,10 @@
0
Overdue
+ @@ -87,6 +92,16 @@ + + @@ -283,6 +298,88 @@ font-size: 0.8rem; } +/* Archived Column Styles */ +.archived-column { + background: #f1f3f4; + border: 2px dashed #9aa0a6; + opacity: 0.8; +} + +.archived-column .column-header { + border-bottom-color: #9aa0a6; +} + +.archived-column .task-count { + background: #9aa0a6; +} + +/* Archived Task Card Styles */ +.archived-column .task-card { + opacity: 0.7; + border-left: 4px solid #9aa0a6; +} + +/* Button Styles */ +.btn.btn-outline { + background: transparent; + border: 1px solid #6c757d; + color: #6c757d; +} + +.btn.btn-outline:hover { + background: #6c757d; + color: white; +} + +.btn.btn-outline.active { + background: #6c757d; + color: white; +} + +/* Task Action Buttons */ +.task-actions { + margin-top: 0.5rem; + display: flex; + justify-content: flex-end; + gap: 0.25rem; +} + +.archive-btn, .restore-btn { + background: none; + border: 1px solid #ccc; + border-radius: 4px; + padding: 0.25rem 0.5rem; + cursor: pointer; + font-size: 0.85rem; + transition: all 0.2s; +} + +.archive-btn:hover { + background: #f8f9fa; + border-color: #6c757d; +} + +.restore-btn:hover { + background: #e3f2fd; + border-color: #007bff; +} + +/* Task Date Displays */ +.task-completed-date, .task-archived-date { + font-size: 0.75rem; + color: #6c757d; + margin-top: 0.25rem; + font-style: italic; +} + +.task-completed-date { + color: #28a745; +} + +.task-archived-date { + color: #9aa0a6; +} + .column-content { min-height: 400px; padding: 0.5rem 0; @@ -879,6 +976,7 @@ class UnifiedTaskManager { }; this.currentTask = null; this.sortableInstances = []; + this.showArchived = false; this.currentUserId = {{ g.user.id|tojson }}; this.smartSearch = new SmartTaskSearch(); this.searchQuery = ''; @@ -905,6 +1003,10 @@ class UnifiedTaskManager { document.getElementById('refresh-tasks').addEventListener('click', () => { this.loadTasks(); }); + + document.getElementById('toggle-archived').addEventListener('click', () => { + this.toggleArchivedView(); + }); // Date validation document.getElementById('task-due-date').addEventListener('blur', () => { @@ -1287,7 +1389,8 @@ class UnifiedTaskManager { 'NOT_STARTED': [], 'IN_PROGRESS': [], 'ON_HOLD': [], - 'COMPLETED': [] + 'COMPLETED': [], + 'ARCHIVED': [] }; filteredTasks.forEach(task => { @@ -1404,8 +1507,24 @@ class UnifiedTaskManager { card.addEventListener('click', () => this.openTaskModal(task)); const dueDate = task.due_date ? new Date(task.due_date) : null; - const isOverdue = dueDate && dueDate < new Date() && task.status !== 'COMPLETED'; - + const isOverdue = dueDate && dueDate < new Date() && task.status !== 'COMPLETED' && task.status !== 'ARCHIVED'; + + // Add archive/restore buttons for completed and archived tasks + let actionButtons = ''; + if (task.status === 'COMPLETED') { + actionButtons = ` +
+ +
+ `; + } else if (task.status === 'ARCHIVED') { + actionButtons = ` +
+ +
+ `; + } + card.innerHTML = `
@@ -1419,6 +1538,9 @@ class UnifiedTaskManager { ${task.assigned_to_name || 'Unassigned'} ${dueDate ? `${formatUserDate(task.due_date)}` : ''}
+ ${task.status === 'COMPLETED' ? `
Completed: ${formatUserDate(task.completed_date)}
` : ''} + ${task.status === 'ARCHIVED' ? `
Archived: ${formatUserDate(task.archived_date)}
` : ''} + ${actionButtons} `; return card; @@ -1432,15 +1554,84 @@ class UnifiedTaskManager { const total = this.tasks.length; const completed = this.tasks.filter(t => t.status === 'COMPLETED').length; const inProgress = this.tasks.filter(t => t.status === 'IN_PROGRESS').length; + const archived = this.tasks.filter(t => t.status === 'ARCHIVED').length; const overdue = this.tasks.filter(t => { const dueDate = t.due_date ? new Date(t.due_date) : null; - return dueDate && dueDate < new Date() && t.status !== 'COMPLETED'; + return dueDate && dueDate < new Date() && t.status !== 'COMPLETED' && t.status !== 'ARCHIVED'; }).length; document.getElementById('total-tasks').textContent = total; document.getElementById('completed-tasks').textContent = completed; document.getElementById('in-progress-tasks').textContent = inProgress; document.getElementById('overdue-tasks').textContent = overdue; + document.getElementById('archived-tasks').textContent = archived; + } + + toggleArchivedView() { + this.showArchived = !this.showArchived; + const toggleBtn = document.getElementById('toggle-archived'); + const archivedColumn = document.querySelector('.archived-column'); + const archivedStatCard = document.getElementById('archived-stat-card'); + + if (this.showArchived) { + toggleBtn.textContent = 'đŸ“Ļ Hide Archived'; + toggleBtn.classList.add('active'); + archivedColumn.style.display = 'block'; + archivedStatCard.style.display = 'block'; + } else { + toggleBtn.textContent = 'đŸ“Ļ Show Archived'; + toggleBtn.classList.remove('active'); + archivedColumn.style.display = 'none'; + archivedStatCard.style.display = 'none'; + } + + this.renderTasks(); + } + + async archiveTask(taskId) { + if (confirm('Are you sure you want to archive this task? It will be moved to the archived section.')) { + try { + const response = await fetch(`/api/tasks/${taskId}/archive`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + } + }); + + const data = await response.json(); + if (data.success) { + await this.loadTasks(); + } else { + alert('Failed to archive task: ' + data.message); + } + } catch (error) { + console.error('Error archiving task:', error); + alert('Failed to archive task: ' + error.message); + } + } + } + + async restoreTask(taskId) { + if (confirm('Are you sure you want to restore this task? It will be moved back to completed status.')) { + try { + const response = await fetch(`/api/tasks/${taskId}/restore`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + } + }); + + const data = await response.json(); + if (data.success) { + await this.loadTasks(); + } else { + alert('Failed to restore task: ' + data.message); + } + } catch (error) { + console.error('Error restoring task:', error); + alert('Failed to restore task: ' + error.message); + } + } } async handleTaskMove(evt) {