Add Kanban Boards for Projects.

This commit is contained in:
2025-07-03 13:10:30 +02:00
committed by Jens Luedicke
parent edb8fd6673
commit 4a4aa05645
7 changed files with 3020 additions and 240 deletions

668
app.py
View File

@@ -1,5 +1,5 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent, KanbanBoard, KanbanColumn, KanbanCard
from data_formatting import ( from data_formatting import (
format_duration, prepare_export_data, prepare_team_hours_export_data, format_duration, prepare_export_data, prepare_team_hours_export_data,
format_table_data, format_graph_data, format_team_data format_table_data, format_graph_data, format_team_data
@@ -3392,6 +3392,74 @@ def manage_project_tasks(project_id):
tasks=tasks, tasks=tasks,
team_members=team_members) team_members=team_members)
@app.route('/admin/projects/<int:project_id>/kanban')
@role_required(Role.TEAM_MEMBER)
@company_required
def project_kanban(project_id):
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first_or_404()
# Check if user has access to this project
if not project.is_user_allowed(g.user):
flash('You do not have access to this project.', 'error')
return redirect(url_for('admin_projects'))
# Get all Kanban boards for this project
boards = KanbanBoard.query.filter_by(project_id=project_id, is_active=True).order_by(KanbanBoard.created_at.desc()).all()
# Get team members for assignment dropdown
if project.team_id:
team_members = User.query.filter_by(team_id=project.team_id, company_id=g.user.company_id).all()
else:
team_members = User.query.filter_by(company_id=g.user.company_id).all()
# Get tasks for task assignment dropdown
tasks = Task.query.filter_by(project_id=project_id).order_by(Task.name).all()
return render_template('project_kanban.html',
title=f'Kanban - {project.name}',
project=project,
boards=boards,
team_members=team_members,
tasks=tasks)
@app.route('/kanban')
@role_required(Role.TEAM_MEMBER)
@company_required
def kanban_overview():
# Get all projects the user has access to
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
# Admins and Supervisors can see all company projects
projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).order_by(Project.name).all()
elif g.user.team_id:
# Team members see team projects + unassigned projects
projects = Project.query.filter(
Project.company_id == g.user.company_id,
Project.is_active == True,
db.or_(Project.team_id == g.user.team_id, Project.team_id == None)
).order_by(Project.name).all()
else:
# Unassigned users see only unassigned projects
projects = Project.query.filter_by(
company_id=g.user.company_id,
team_id=None,
is_active=True
).order_by(Project.name).all()
# Get Kanban boards for each project
project_boards = {}
for project in projects:
boards = KanbanBoard.query.filter_by(
project_id=project.id,
is_active=True
).order_by(KanbanBoard.created_at.desc()).all()
if boards: # Only include projects that have Kanban boards
project_boards[project] = boards
return render_template('kanban_overview.html',
title='Kanban Overview',
project_boards=project_boards)
# Task API Routes # Task API Routes
@app.route('/api/tasks', methods=['POST']) @app.route('/api/tasks', methods=['POST'])
@role_required(Role.TEAM_MEMBER) @role_required(Role.TEAM_MEMBER)
@@ -3801,6 +3869,604 @@ def delete_category(category_id):
db.session.rollback() db.session.rollback()
return jsonify({'success': False, 'message': str(e)}) return jsonify({'success': False, 'message': str(e)})
# Kanban API Routes
@app.route('/api/kanban/stats')
@role_required(Role.TEAM_MEMBER)
@company_required
def get_kanban_stats():
try:
# Get all projects the user has access to
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all()
elif g.user.team_id:
projects = Project.query.filter(
Project.company_id == g.user.company_id,
Project.is_active == True,
db.or_(Project.team_id == g.user.team_id, Project.team_id == None)
).all()
else:
projects = Project.query.filter_by(
company_id=g.user.company_id,
team_id=None,
is_active=True
).all()
# Count boards and cards
total_boards = 0
total_cards = 0
projects_with_boards = 0
for project in projects:
boards = KanbanBoard.query.filter_by(project_id=project.id, is_active=True).all()
if boards:
projects_with_boards += 1
total_boards += len(boards)
for board in boards:
for column in board.columns:
total_cards += len([card for card in column.cards if card.is_active])
return jsonify({
'success': True,
'stats': {
'projects_with_boards': projects_with_boards,
'total_boards': total_boards,
'total_cards': total_cards
}
})
except Exception as e:
return jsonify({'success': False, 'message': str(e)})
@app.route('/api/kanban/boards', methods=['GET'])
@role_required(Role.TEAM_MEMBER)
@company_required
def get_kanban_boards():
try:
project_id = request.args.get('project_id')
# Verify project access
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first()
if not project or not project.is_user_allowed(g.user):
return jsonify({'success': False, 'message': 'Project not found or access denied'})
boards = KanbanBoard.query.filter_by(project_id=project_id, is_active=True).all()
boards_data = []
for board in boards:
boards_data.append({
'id': board.id,
'name': board.name,
'description': board.description,
'is_default': board.is_default,
'created_at': board.created_at.isoformat(),
'column_count': len(board.columns)
})
return jsonify({'success': True, 'boards': boards_data})
except Exception as e:
return jsonify({'success': False, 'message': str(e)})
@app.route('/api/kanban/boards', methods=['POST'])
@role_required(Role.TEAM_LEADER)
@company_required
def create_kanban_board():
try:
data = request.get_json()
project_id = int(data.get('project_id'))
# Verify project access
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first()
if not project or not project.is_user_allowed(g.user):
return jsonify({'success': False, 'message': 'Project not found or access denied'})
name = data.get('name')
if not name:
return jsonify({'success': False, 'message': 'Board name is required'})
# Check if board name already exists in project
existing = KanbanBoard.query.filter_by(project_id=project_id, name=name).first()
if existing:
return jsonify({'success': False, 'message': 'Board name already exists in this project'})
# Create board
board = KanbanBoard(
name=name,
description=data.get('description', ''),
project_id=project_id,
is_default=data.get('is_default') in ['true', 'on', True],
created_by_id=g.user.id
)
db.session.add(board)
db.session.flush() # Get board ID for columns
# Create default columns
default_columns = [
{'name': 'To Do', 'position': 1, 'color': '#6c757d'},
{'name': 'In Progress', 'position': 2, 'color': '#007bff'},
{'name': 'Done', 'position': 3, 'color': '#28a745'}
]
for col_data in default_columns:
column = KanbanColumn(
name=col_data['name'],
position=col_data['position'],
color=col_data['color'],
board_id=board.id
)
db.session.add(column)
db.session.commit()
return jsonify({'success': True, 'message': 'Board created successfully', 'board_id': board.id})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)})
@app.route('/api/kanban/boards/<int:board_id>', methods=['GET'])
@role_required(Role.TEAM_MEMBER)
@company_required
def get_kanban_board(board_id):
try:
board = KanbanBoard.query.join(Project).filter(
KanbanBoard.id == board_id,
Project.company_id == g.user.company_id
).first()
if not board or not board.project.is_user_allowed(g.user):
return jsonify({'success': False, 'message': 'Board not found or access denied'})
columns_data = []
for column in board.columns:
if not column.is_active:
continue
cards_data = []
for card in column.cards:
if not card.is_active:
continue
cards_data.append({
'id': card.id,
'title': card.title,
'description': card.description,
'position': card.position,
'color': card.color,
'assigned_to': {
'id': card.assigned_to.id,
'username': card.assigned_to.username
} if card.assigned_to else None,
'task_id': card.task_id,
'task_name': card.task.name if card.task else None,
'due_date': card.due_date.isoformat() if card.due_date else None,
'created_at': card.created_at.isoformat()
})
columns_data.append({
'id': column.id,
'name': column.name,
'description': column.description,
'position': column.position,
'color': column.color,
'wip_limit': column.wip_limit,
'card_count': column.card_count,
'is_over_wip_limit': column.is_over_wip_limit,
'cards': cards_data
})
board_data = {
'id': board.id,
'name': board.name,
'description': board.description,
'project': {
'id': board.project.id,
'name': board.project.name,
'code': board.project.code
},
'columns': columns_data
}
return jsonify({'success': True, 'board': board_data})
except Exception as e:
return jsonify({'success': False, 'message': str(e)})
@app.route('/api/kanban/cards', methods=['POST'])
@role_required(Role.TEAM_MEMBER)
@company_required
def create_kanban_card():
try:
data = request.get_json()
column_id = data.get('column_id')
# Verify column access
column = KanbanColumn.query.join(KanbanBoard).join(Project).filter(
KanbanColumn.id == column_id,
Project.company_id == g.user.company_id
).first()
if not column or not column.board.project.is_user_allowed(g.user):
return jsonify({'success': False, 'message': 'Column not found or access denied'})
title = data.get('title')
if not title:
return jsonify({'success': False, 'message': 'Card title is required'})
# Calculate position (add to end of column)
max_position = db.session.query(func.max(KanbanCard.position)).filter_by(
column_id=column_id, is_active=True
).scalar() or 0
# Parse due date
due_date = None
if data.get('due_date'):
due_date = datetime.strptime(data.get('due_date'), '%Y-%m-%d').date()
# Create card
card = KanbanCard(
title=title,
description=data.get('description', ''),
position=max_position + 1,
color=data.get('color'),
column_id=column_id,
task_id=int(data.get('task_id')) if data.get('task_id') else None,
assigned_to_id=int(data.get('assigned_to_id')) if data.get('assigned_to_id') else None,
due_date=due_date,
created_by_id=g.user.id
)
db.session.add(card)
db.session.commit()
return jsonify({'success': True, 'message': 'Card created successfully', 'card_id': card.id})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)})
@app.route('/api/kanban/cards/<int:card_id>/move', methods=['PUT'])
@role_required(Role.TEAM_MEMBER)
@company_required
def move_kanban_card(card_id):
try:
data = request.get_json()
new_column_id = data.get('column_id')
new_position = data.get('position')
# Verify card access
card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).join(Project).filter(
KanbanCard.id == card_id,
Project.company_id == g.user.company_id
).first()
if not card or not card.can_user_access(g.user):
return jsonify({'success': False, 'message': 'Card not found or access denied'})
# Verify new column access
new_column = KanbanColumn.query.join(KanbanBoard).join(Project).filter(
KanbanColumn.id == new_column_id,
Project.company_id == g.user.company_id
).first()
if not new_column or not new_column.board.project.is_user_allowed(g.user):
return jsonify({'success': False, 'message': 'Target column not found or access denied'})
old_column_id = card.column_id
old_position = card.position
# Update positions in old column (if moving to different column)
if old_column_id != new_column_id:
# Shift cards down in old column
cards_to_shift = KanbanCard.query.filter(
KanbanCard.column_id == old_column_id,
KanbanCard.position > old_position,
KanbanCard.is_active == True
).all()
for c in cards_to_shift:
c.position -= 1
# Update positions in new column
cards_to_shift = KanbanCard.query.filter(
KanbanCard.column_id == new_column_id,
KanbanCard.position >= new_position,
KanbanCard.is_active == True,
KanbanCard.id != card_id
).all()
for c in cards_to_shift:
c.position += 1
# Update card
card.column_id = new_column_id
card.position = new_position
card.updated_at = datetime.now()
db.session.commit()
return jsonify({'success': True, 'message': 'Card moved successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)})
@app.route('/api/kanban/cards/<int:card_id>', methods=['PUT'])
@role_required(Role.TEAM_MEMBER)
@company_required
def update_kanban_card(card_id):
try:
card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).join(Project).filter(
KanbanCard.id == card_id,
Project.company_id == g.user.company_id
).first()
if not card or not card.can_user_access(g.user):
return jsonify({'success': False, 'message': 'Card not found or access denied'})
data = request.get_json()
# Update card fields
if 'title' in data:
card.title = data['title']
if 'description' in data:
card.description = data['description']
if 'color' in data:
card.color = data['color']
if 'assigned_to_id' in data:
card.assigned_to_id = int(data['assigned_to_id']) if data['assigned_to_id'] else None
if 'task_id' in data:
card.task_id = int(data['task_id']) if data['task_id'] else None
if 'due_date' in data:
card.due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date() if data['due_date'] else None
card.updated_at = datetime.now()
db.session.commit()
return jsonify({'success': True, 'message': 'Card updated successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)})
@app.route('/api/kanban/cards/<int:card_id>', methods=['DELETE'])
@role_required(Role.TEAM_MEMBER)
@company_required
def delete_kanban_card(card_id):
try:
card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).join(Project).filter(
KanbanCard.id == card_id,
Project.company_id == g.user.company_id
).first()
if not card or not card.can_user_access(g.user):
return jsonify({'success': False, 'message': 'Card not found or access denied'})
column_id = card.column_id
position = card.position
# Soft delete
card.is_active = False
card.updated_at = datetime.now()
# Shift remaining cards up
cards_to_shift = KanbanCard.query.filter(
KanbanCard.column_id == column_id,
KanbanCard.position > position,
KanbanCard.is_active == True
).all()
for c in cards_to_shift:
c.position -= 1
db.session.commit()
return jsonify({'success': True, 'message': 'Card deleted successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)})
# Kanban Column Management API
@app.route('/api/kanban/columns', methods=['POST'])
@role_required(Role.TEAM_LEADER)
@company_required
def create_kanban_column():
try:
data = request.get_json()
board_id = int(data.get('board_id'))
# Verify board access
board = KanbanBoard.query.join(Project).filter(
KanbanBoard.id == board_id,
Project.company_id == g.user.company_id
).first()
if not board or not board.project.is_user_allowed(g.user):
return jsonify({'success': False, 'message': 'Board not found or access denied'})
name = data.get('name')
if not name:
return jsonify({'success': False, 'message': 'Column name is required'})
# Check if column name already exists in board
existing = KanbanColumn.query.filter_by(board_id=board_id, name=name).first()
if existing:
return jsonify({'success': False, 'message': 'Column name already exists in this board'})
# Calculate position (add to end)
max_position = db.session.query(func.max(KanbanColumn.position)).filter_by(
board_id=board_id, is_active=True
).scalar() or 0
# Create column
column = KanbanColumn(
name=name,
description=data.get('description', ''),
position=max_position + 1,
color=data.get('color', '#6c757d'),
wip_limit=int(data.get('wip_limit')) if data.get('wip_limit') else None,
board_id=board_id
)
db.session.add(column)
db.session.commit()
return jsonify({'success': True, 'message': 'Column created successfully', 'column_id': column.id})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)})
@app.route('/api/kanban/columns/<int:column_id>', methods=['PUT'])
@role_required(Role.TEAM_LEADER)
@company_required
def update_kanban_column(column_id):
try:
column = KanbanColumn.query.join(KanbanBoard).join(Project).filter(
KanbanColumn.id == column_id,
Project.company_id == g.user.company_id
).first()
if not column or not column.board.project.is_user_allowed(g.user):
return jsonify({'success': False, 'message': 'Column not found or access denied'})
data = request.get_json()
# Check for name conflicts (excluding current column)
if 'name' in data:
existing = KanbanColumn.query.filter(
KanbanColumn.board_id == column.board_id,
KanbanColumn.name == data['name'],
KanbanColumn.id != column_id
).first()
if existing:
return jsonify({'success': False, 'message': 'Column name already exists in this board'})
# Update column fields
if 'name' in data:
column.name = data['name']
if 'description' in data:
column.description = data['description']
if 'color' in data:
column.color = data['color']
if 'wip_limit' in data:
column.wip_limit = int(data['wip_limit']) if data['wip_limit'] else None
column.updated_at = datetime.now()
db.session.commit()
return jsonify({'success': True, 'message': 'Column updated successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)})
@app.route('/api/kanban/columns/<int:column_id>/move', methods=['PUT'])
@role_required(Role.TEAM_LEADER)
@company_required
def move_kanban_column(column_id):
try:
data = request.get_json()
new_position = int(data.get('position'))
# Verify column access
column = KanbanColumn.query.join(KanbanBoard).join(Project).filter(
KanbanColumn.id == column_id,
Project.company_id == g.user.company_id
).first()
if not column or not column.board.project.is_user_allowed(g.user):
return jsonify({'success': False, 'message': 'Column not found or access denied'})
old_position = column.position
board_id = column.board_id
if old_position == new_position:
return jsonify({'success': True, 'message': 'Column position unchanged'})
# Update positions of other columns
if old_position < new_position:
# Moving right: shift columns left
columns_to_shift = KanbanColumn.query.filter(
KanbanColumn.board_id == board_id,
KanbanColumn.position > old_position,
KanbanColumn.position <= new_position,
KanbanColumn.is_active == True,
KanbanColumn.id != column_id
).all()
for c in columns_to_shift:
c.position -= 1
else:
# Moving left: shift columns right
columns_to_shift = KanbanColumn.query.filter(
KanbanColumn.board_id == board_id,
KanbanColumn.position >= new_position,
KanbanColumn.position < old_position,
KanbanColumn.is_active == True,
KanbanColumn.id != column_id
).all()
for c in columns_to_shift:
c.position += 1
# Update the moved column
column.position = new_position
column.updated_at = datetime.now()
db.session.commit()
return jsonify({'success': True, 'message': 'Column moved successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)})
@app.route('/api/kanban/columns/<int:column_id>', methods=['DELETE'])
@role_required(Role.TEAM_LEADER)
@company_required
def delete_kanban_column(column_id):
try:
column = KanbanColumn.query.join(KanbanBoard).join(Project).filter(
KanbanColumn.id == column_id,
Project.company_id == g.user.company_id
).first()
if not column or not column.board.project.is_user_allowed(g.user):
return jsonify({'success': False, 'message': 'Column not found or access denied'})
# Check if column has active cards
active_cards = KanbanCard.query.filter_by(column_id=column_id, is_active=True).count()
if active_cards > 0:
return jsonify({'success': False, 'message': f'Cannot delete column with {active_cards} active cards. Move or delete cards first.'})
board_id = column.board_id
position = column.position
# Soft delete the column
column.is_active = False
column.updated_at = datetime.now()
# Shift remaining columns left
columns_to_shift = KanbanColumn.query.filter(
KanbanColumn.board_id == board_id,
KanbanColumn.position > position,
KanbanColumn.is_active == True
).all()
for c in columns_to_shift:
c.position -= 1
db.session.commit()
return jsonify({'success': True, 'message': 'Column deleted successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)})
if __name__ == '__main__': if __name__ == '__main__':
port = int(os.environ.get('PORT', 5000)) port = int(os.environ.get('PORT', 5000))
app.run(debug=False, host='0.0.0.0', port=port) app.run(debug=False, host='0.0.0.0', port=port)

View File

@@ -15,7 +15,8 @@ try:
from app import app, db from app import app, db
from models import (User, TimeEntry, WorkConfig, SystemSettings, Team, Role, Project, from models import (User, TimeEntry, WorkConfig, SystemSettings, Team, Role, Project,
Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType,
ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent) ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent, KanbanBoard,
KanbanColumn, KanbanCard)
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
FLASK_AVAILABLE = True FLASK_AVAILABLE = True
except ImportError: except ImportError:
@@ -72,6 +73,7 @@ def run_all_migrations(db_path=None):
migrate_work_config_data(db_path) migrate_work_config_data(db_path)
migrate_task_system(db_path) migrate_task_system(db_path)
migrate_system_events(db_path) migrate_system_events(db_path)
migrate_kanban_system(db_path)
if FLASK_AVAILABLE: if FLASK_AVAILABLE:
with app.app_context(): with app.app_context():
@@ -882,6 +884,108 @@ def create_all_tables(cursor):
print("All tables created") print("All tables created")
def migrate_kanban_system(db_file=None):
"""Migrate to add Kanban board system."""
db_path = get_db_path(db_file)
print(f"Migrating Kanban system in {db_path}...")
if not os.path.exists(db_path):
print(f"Database file {db_path} does not exist. Run basic migration first.")
return False
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Check if kanban_board table already exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='kanban_board'")
if cursor.fetchone():
print("Kanban tables already exist. Skipping migration.")
return True
print("Creating Kanban board tables...")
# Create kanban_board table
cursor.execute("""
CREATE TABLE kanban_board (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
description TEXT,
project_id INTEGER NOT NULL,
is_active BOOLEAN DEFAULT 1,
is_default BOOLEAN DEFAULT 0,
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 (created_by_id) REFERENCES user (id),
UNIQUE(project_id, name)
)
""")
# Create kanban_column table
cursor.execute("""
CREATE TABLE kanban_column (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
description TEXT,
position INTEGER NOT NULL,
color VARCHAR(7) DEFAULT '#6c757d',
wip_limit INTEGER,
is_active BOOLEAN DEFAULT 1,
board_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (board_id) REFERENCES kanban_board (id),
UNIQUE(board_id, name)
)
""")
# Create kanban_card table
cursor.execute("""
CREATE TABLE kanban_card (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title VARCHAR(200) NOT NULL,
description TEXT,
position INTEGER NOT NULL,
color VARCHAR(7),
is_active BOOLEAN DEFAULT 1,
column_id INTEGER NOT NULL,
task_id INTEGER,
assigned_to_id INTEGER,
due_date DATE,
completed_date DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL,
FOREIGN KEY (column_id) REFERENCES kanban_column (id),
FOREIGN KEY (task_id) REFERENCES task (id),
FOREIGN KEY (assigned_to_id) REFERENCES user (id),
FOREIGN KEY (created_by_id) REFERENCES user (id)
)
""")
# Create indexes for better performance
cursor.execute("CREATE INDEX idx_kanban_board_project ON kanban_board(project_id)")
cursor.execute("CREATE INDEX idx_kanban_column_board ON kanban_column(board_id)")
cursor.execute("CREATE INDEX idx_kanban_card_column ON kanban_card(column_id)")
cursor.execute("CREATE INDEX idx_kanban_card_task ON kanban_card(task_id)")
cursor.execute("CREATE INDEX idx_kanban_card_assigned ON kanban_card(assigned_to_id)")
conn.commit()
print("Kanban system migration completed successfully!")
return True
except Exception as e:
print(f"Error during Kanban system migration: {e}")
conn.rollback()
raise
finally:
conn.close()
def main(): def main():
"""Main function with command line interface.""" """Main function with command line interface."""
parser = argparse.ArgumentParser(description='TimeTrack Database Migration Tool') parser = argparse.ArgumentParser(description='TimeTrack Database Migration Tool')
@@ -898,6 +1002,8 @@ def main():
help='Run only basic table migrations') help='Run only basic table migrations')
parser.add_argument('--system-events', '-s', action='store_true', parser.add_argument('--system-events', '-s', action='store_true',
help='Run only system events migration') help='Run only system events migration')
parser.add_argument('--kanban', '-k', action='store_true',
help='Run only Kanban system migration')
args = parser.parse_args() args = parser.parse_args()
@@ -930,6 +1036,9 @@ def main():
elif args.system_events: elif args.system_events:
migrate_system_events(db_path) migrate_system_events(db_path)
elif args.kanban:
migrate_kanban_system(db_path)
else: else:
# Default: run all migrations # Default: run all migrations
run_all_migrations(db_path) run_all_migrations(db_path)

109
models.py
View File

@@ -722,3 +722,112 @@ class SystemEvent(db.Model):
'last_error': last_error, 'last_error': last_error,
'health_status': 'healthy' if recent_errors == 0 else 'issues' if recent_errors < 5 else 'critical' 'health_status': 'healthy' if recent_errors == 0 else 'issues' if recent_errors < 5 else 'critical'
} }
# Kanban Board models
class KanbanBoard(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text, nullable=True)
# Project association
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
# Board settings
is_active = db.Column(db.Boolean, default=True)
is_default = db.Column(db.Boolean, default=False) # Default board for project
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
project = db.relationship('Project', backref='kanban_boards')
created_by = db.relationship('User', foreign_keys=[created_by_id])
columns = db.relationship('KanbanColumn', backref='board', lazy=True, cascade='all, delete-orphan', order_by='KanbanColumn.position')
# Unique constraint per project
__table_args__ = (db.UniqueConstraint('project_id', 'name', name='uq_kanban_board_name_per_project'),)
def __repr__(self):
return f'<KanbanBoard {self.name}>'
class KanbanColumn(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text, nullable=True)
# Column settings
position = db.Column(db.Integer, nullable=False) # Order in board
color = db.Column(db.String(7), default='#6c757d') # Hex color
wip_limit = db.Column(db.Integer, nullable=True) # Work in progress limit
is_active = db.Column(db.Boolean, default=True)
# Board association
board_id = db.Column(db.Integer, db.ForeignKey('kanban_board.id'), nullable=False)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
cards = db.relationship('KanbanCard', backref='column', lazy=True, cascade='all, delete-orphan', order_by='KanbanCard.position')
# Unique constraint per board
__table_args__ = (db.UniqueConstraint('board_id', 'name', name='uq_kanban_column_name_per_board'),)
def __repr__(self):
return f'<KanbanColumn {self.name}>'
@property
def card_count(self):
"""Get number of cards in this column"""
return len([card for card in self.cards if card.is_active])
@property
def is_over_wip_limit(self):
"""Check if column is over WIP limit"""
if not self.wip_limit:
return False
return self.card_count > self.wip_limit
class KanbanCard(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
# Card settings
position = db.Column(db.Integer, nullable=False) # Order in column
color = db.Column(db.String(7), nullable=True) # Optional custom color
is_active = db.Column(db.Boolean, default=True)
# Column association
column_id = db.Column(db.Integer, db.ForeignKey('kanban_column.id'), nullable=False)
# Optional task association
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True)
# Card assignment
assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
# Card dates
due_date = db.Column(db.Date, nullable=True)
completed_date = db.Column(db.Date, nullable=True)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
task = db.relationship('Task', backref='kanban_cards')
assigned_to = db.relationship('User', foreign_keys=[assigned_to_id], backref='assigned_kanban_cards')
created_by = db.relationship('User', foreign_keys=[created_by_id])
def __repr__(self):
return f'<KanbanCard {self.title}>'
def can_user_access(self, user):
"""Check if a user can access this card"""
# Check board's project permissions
return self.column.board.project.is_user_allowed(user)

View File

@@ -93,6 +93,7 @@
<td class="actions"> <td class="actions">
<a href="{{ url_for('edit_project', project_id=project.id) }}" class="btn btn-sm btn-primary">Edit</a> <a href="{{ url_for('edit_project', project_id=project.id) }}" class="btn btn-sm btn-primary">Edit</a>
<a href="{{ url_for('manage_project_tasks', project_id=project.id) }}" class="btn btn-sm btn-info">Tasks</a> <a href="{{ url_for('manage_project_tasks', project_id=project.id) }}" class="btn btn-sm btn-info">Tasks</a>
<a href="{{ url_for('project_kanban', project_id=project.id) }}" class="btn btn-sm btn-success">Kanban</a>
{% if g.user.role == Role.ADMIN and project.time_entries|length == 0 %} {% if g.user.role == Role.ADMIN and project.time_entries|length == 0 %}
<form method="POST" action="{{ url_for('delete_project', project_id=project.id) }}" style="display: inline;" <form method="POST" action="{{ url_for('delete_project', project_id=project.id) }}" style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete this project?')"> onsubmit="return confirm('Are you sure you want to delete this project?')">

View File

@@ -0,0 +1,517 @@
{% extends "layout.html" %}
{% block content %}
<div class="kanban-overview-container">
<div class="overview-header">
<h2>Kanban Board Overview</h2>
<p class="overview-description">Manage your tasks visually across all projects</p>
</div>
{% if project_boards %}
<div class="projects-grid">
{% for project, boards in project_boards.items() %}
<div class="project-card">
<div class="project-header">
<div class="project-info">
<h3 class="project-name">
<span class="project-code">{{ project.code }}</span>
{{ project.name }}
</h3>
{% if project.category %}
<span class="category-badge" style="background-color: {{ project.category.color }}20; color: {{ project.category.color }};">
{{ project.category.icon or '📁' }} {{ project.category.name }}
</span>
{% endif %}
{% if project.description %}
<p class="project-description">{{ project.description }}</p>
{% endif %}
</div>
<div class="project-actions">
<a href="{{ url_for('project_kanban', project_id=project.id) }}" class="btn btn-primary">Open Kanban</a>
<a href="{{ url_for('manage_project_tasks', project_id=project.id) }}" class="btn btn-secondary">Task List</a>
</div>
</div>
<div class="boards-section">
<h4>Kanban Boards ({{ boards|length }})</h4>
<div class="boards-list">
{% for board in boards %}
<div class="board-item" onclick="openBoard({{ project.id }}, {{ board.id }})">
<div class="board-info">
<div class="board-name">
{{ board.name }}
{% if board.is_default %}
<span class="default-badge">Default</span>
{% endif %}
</div>
{% if board.description %}
<div class="board-description">{{ board.description }}</div>
{% endif %}
</div>
<div class="board-stats">
<span class="column-count">{{ board.columns|length }} columns</span>
<span class="card-count">
{% set board_cards = 0 %}
{% for column in board.columns %}
{% set board_cards = board_cards + column.cards|selectattr('is_active')|list|length %}
{% endfor %}
{{ board_cards }} cards
</span>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Quick Board Preview -->
<div class="board-preview">
{% set default_board = boards|selectattr('is_default')|first %}
{% if not default_board %}
{% set default_board = boards|first %}
{% endif %}
{% if default_board and default_board.columns %}
<h5>{{ default_board.name }} Preview</h5>
<div class="preview-columns">
{% for column in default_board.columns %}
{% if loop.index <= 4 %}
<div class="preview-column" style="border-top: 3px solid {{ column.color }};">
<div class="preview-column-header">
<span class="preview-column-name">{{ column.name }}</span>
<span class="preview-card-count">{{ column.cards|selectattr('is_active')|list|length }}</span>
</div>
<div class="preview-cards">
{% set active_cards = column.cards|selectattr('is_active')|list %}
{% for card in active_cards %}
{% if loop.index <= 3 %}
<div class="preview-card" {% if card.color %}style="background-color: {{ card.color }};"{% endif %}>
<div class="preview-card-title">
{% if card.title|length > 30 %}
{{ card.title|truncate(30, True) }}
{% else %}
{{ card.title }}
{% endif %}
</div>
{% if card.assigned_to %}
<div class="preview-card-assignee">{{ card.assigned_to.username }}</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
{% if active_cards|length > 3 %}
<div class="preview-more">+{{ active_cards|length - 3 }} more</div>
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
{% if default_board.columns|length > 4 %}
<div class="preview-more-columns">
+{{ default_board.columns|length - 4 }} more columns
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- Quick Stats -->
<div class="quick-stats">
<div class="stat-card">
<h3>{{ project_boards.keys()|list|length }}</h3>
<p>Projects with Kanban</p>
</div>
<div class="stat-card">
{% set total_boards = 0 %}
{% for project, boards in project_boards.items() %}
{% set total_boards = total_boards + boards|length %}
{% endfor %}
<h3>{{ total_boards }}</h3>
<p>Total Boards</p>
</div>
<div class="stat-card">
{% set total_cards = 0 %}
{% for project, boards in project_boards.items() %}
{% for board in boards %}
{% for column in board.columns %}
{% set total_cards = total_cards + column.cards|selectattr('is_active')|list|length %}
{% endfor %}
{% endfor %}
{% endfor %}
<h3>{{ total_cards }}</h3>
<p>Total Cards</p>
</div>
</div>
{% else %}
<!-- No Kanban Boards -->
<div class="no-kanban">
<div class="no-kanban-content">
<div class="no-kanban-icon">📋</div>
<h3>No Kanban Boards Yet</h3>
<p>Start organizing your projects with visual Kanban boards.</p>
<div class="getting-started">
<h4>Getting Started:</h4>
<ol>
<li>Go to a project from <a href="{{ url_for('admin_projects') }}">Project Management</a></li>
<li>Click the <strong>"Kanban"</strong> button</li>
<li>Create your first board and start organizing tasks</li>
</ol>
</div>
{% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %}
<a href="{{ url_for('admin_projects') }}" class="btn btn-primary">Go to Projects</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
<style>
.kanban-overview-container {
padding: 1rem;
}
.overview-header {
margin-bottom: 2rem;
text-align: center;
}
.overview-header h2 {
margin: 0 0 0.5rem 0;
color: #333;
}
.overview-description {
color: #666;
margin: 0;
}
.projects-grid {
display: grid;
gap: 2rem;
margin-bottom: 2rem;
}
.project-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: box-shadow 0.2s ease;
}
.project-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.project-header {
padding: 1.5rem;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.project-info {
flex: 1;
}
.project-name {
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.project-code {
background: #007bff;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
}
.project-description {
color: #666;
margin: 0.5rem 0 0 0;
font-size: 0.9rem;
}
.project-actions {
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
.boards-section {
padding: 1.5rem;
border-bottom: 1px solid #dee2e6;
}
.boards-section h4 {
margin: 0 0 1rem 0;
color: #333;
}
.boards-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.board-item {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.board-item:hover {
background: #e7f3ff;
border-color: #007bff;
transform: translateY(-1px);
}
.board-info {
flex: 1;
}
.board-name {
font-weight: 600;
margin-bottom: 0.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.default-badge {
background: #28a745;
color: white;
padding: 0.15rem 0.4rem;
border-radius: 12px;
font-size: 0.7rem;
font-weight: 500;
}
.board-description {
color: #666;
font-size: 0.9rem;
}
.board-stats {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.8rem;
color: #666;
text-align: right;
}
.board-preview {
padding: 1.5rem;
}
.board-preview h5 {
margin: 0 0 1rem 0;
color: #333;
}
.preview-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.preview-column {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
overflow: hidden;
}
.preview-column-header {
padding: 0.75rem;
background: white;
border-bottom: 1px solid #dee2e6;
display: flex;
justify-content: space-between;
align-items: center;
}
.preview-column-name {
font-weight: 600;
font-size: 0.9rem;
}
.preview-card-count {
background: #e9ecef;
padding: 0.2rem 0.4rem;
border-radius: 8px;
font-size: 0.7rem;
font-weight: 500;
}
.preview-cards {
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.preview-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 0.5rem;
font-size: 0.8rem;
}
.preview-card-title {
font-weight: 500;
margin-bottom: 0.25rem;
}
.preview-card-assignee {
color: #666;
font-size: 0.7rem;
}
.preview-more {
color: #666;
font-size: 0.8rem;
text-align: center;
padding: 0.25rem;
}
.preview-more-columns {
display: flex;
align-items: center;
justify-content: center;
color: #666;
font-size: 0.9rem;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 1rem;
}
.quick-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #dee2e6;
text-align: center;
}
.stat-card h3 {
margin: 0 0 0.5rem 0;
font-size: 2rem;
color: #007bff;
}
.stat-card p {
margin: 0;
color: #666;
font-weight: 500;
}
.no-kanban {
text-align: center;
padding: 4rem 2rem;
}
.no-kanban-content {
max-width: 500px;
margin: 0 auto;
}
.no-kanban-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.no-kanban h3 {
color: #666;
margin-bottom: 1rem;
}
.no-kanban p {
color: #999;
margin-bottom: 2rem;
}
.getting-started {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
text-align: left;
}
.getting-started h4 {
margin: 0 0 1rem 0;
color: #333;
}
.getting-started ol {
margin: 0;
padding-left: 1.5rem;
}
.getting-started li {
margin-bottom: 0.5rem;
color: #666;
}
.category-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
@media (max-width: 768px) {
.project-header {
flex-direction: column;
gap: 1rem;
}
.project-actions {
width: 100%;
justify-content: flex-start;
}
.preview-columns {
grid-template-columns: 1fr;
}
.quick-stats {
grid-template-columns: 1fr;
}
}
</style>
<script>
function openBoard(projectId, boardId) {
window.location.href = `/admin/projects/${projectId}/kanban?board=${boardId}`;
}
</script>
{% endblock %}

View File

@@ -41,6 +41,7 @@
<ul> <ul>
{% if g.user %} {% if g.user %}
<li><a href="{{ url_for('home') }}" data-tooltip="Home"><i class="nav-icon">🏠</i><span class="nav-text">Home</span></a></li> <li><a href="{{ url_for('home') }}" data-tooltip="Home"><i class="nav-icon">🏠</i><span class="nav-text">Home</span></a></li>
<li><a href="{{ url_for('kanban_overview') }}" data-tooltip="Kanban Board"><i class="nav-icon">📋</i><span class="nav-text">Kanban Board</span></a></li>
<li><a href="{{ url_for('analytics') }}" data-tooltip="Time Analytics"><i class="nav-icon">📊</i><span class="nav-text">Analytics</span></a></li> <li><a href="{{ url_for('analytics') }}" data-tooltip="Time Analytics"><i class="nav-icon">📊</i><span class="nav-text">Analytics</span></a></li>
<!-- Role-based menu items --> <!-- Role-based menu items -->

File diff suppressed because it is too large Load Diff