Add Task Archive feature.

This commit is contained in:
2025-07-06 08:49:09 +02:00
parent 8f63817194
commit 397175f38e
5 changed files with 489 additions and 4 deletions

88
app.py
View File

@@ -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/<int:task_id>/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/<int:task_id>/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)

View File

@@ -75,6 +75,10 @@ def run_all_migrations(db_path=None):
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():
# Handle company migration and admin user setup
@@ -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)

View File

@@ -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)

View File

@@ -40,6 +40,7 @@
<option value="IN_PROGRESS">In Progress</option>
<option value="ON_HOLD">On Hold</option>
<option value="COMPLETED">Completed</option>
<option value="ARCHIVED">Archived</option>
</select>
</div>
</div>

View File

@@ -22,6 +22,7 @@
<div class="management-actions task-actions">
<button id="add-task-btn" class="btn btn-primary">+ Add Task</button>
<button id="refresh-tasks" class="btn btn-secondary">🔄 Refresh</button>
<button id="toggle-archived" class="btn btn-outline" title="Show/Hide Archived Tasks">📦 Show Archived</button>
</div>
</div>
</div>
@@ -44,6 +45,10 @@
<div class="stat-number" id="overdue-tasks">0</div>
<div class="stat-label">Overdue</div>
</div>
<div class="stat-card" id="archived-stat-card" style="display: none;">
<div class="stat-number" id="archived-tasks">0</div>
<div class="stat-label">Archived</div>
</div>
</div>
<!-- Task Board -->
@@ -87,6 +92,16 @@
<!-- Task cards will be populated here -->
</div>
</div>
<div class="task-column archived-column" data-status="ARCHIVED" style="display: none;">
<div class="column-header">
<h3>📦 Archived</h3>
<span class="task-count">0</span>
</div>
<div class="column-content" id="column-ARCHIVED">
<!-- Archived task cards will be populated here -->
</div>
</div>
</div>
<!-- Loading and Error States -->
@@ -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 = '';
@@ -906,6 +1004,10 @@ class UnifiedTaskManager {
this.loadTasks();
});
document.getElementById('toggle-archived').addEventListener('click', () => {
this.toggleArchivedView();
});
// Date validation
document.getElementById('task-due-date').addEventListener('blur', () => {
const input = document.getElementById('task-due-date');
@@ -1287,7 +1389,8 @@ class UnifiedTaskManager {
'NOT_STARTED': [],
'IN_PROGRESS': [],
'ON_HOLD': [],
'COMPLETED': []
'COMPLETED': [],
'ARCHIVED': []
};
filteredTasks.forEach(task => {
@@ -1404,7 +1507,23 @@ 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 = `
<div class="task-actions">
<button class="archive-btn" onclick="taskManager.archiveTask(${task.id}); event.stopPropagation();" title="Archive Task">📦</button>
</div>
`;
} else if (task.status === 'ARCHIVED') {
actionButtons = `
<div class="task-actions">
<button class="restore-btn" onclick="taskManager.restoreTask(${task.id}); event.stopPropagation();" title="Restore Task">↩️</button>
</div>
`;
}
card.innerHTML = `
<div class="task-card-header">
@@ -1419,6 +1538,9 @@ class UnifiedTaskManager {
<span class="task-assignee">${task.assigned_to_name || 'Unassigned'}</span>
${dueDate ? `<span class="task-due-date ${isOverdue ? 'overdue' : ''}">${formatUserDate(task.due_date)}</span>` : ''}
</div>
${task.status === 'COMPLETED' ? `<div class="task-completed-date">Completed: ${formatUserDate(task.completed_date)}</div>` : ''}
${task.status === 'ARCHIVED' ? `<div class="task-archived-date">Archived: ${formatUserDate(task.archived_date)}</div>` : ''}
${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) {