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

766
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 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 (
format_duration, prepare_export_data, prepare_team_hours_export_data,
format_table_data, format_graph_data, format_team_data
@@ -60,10 +60,10 @@ def run_migrations():
# Check if we're using PostgreSQL or SQLite
database_url = app.config['SQLALCHEMY_DATABASE_URI']
print(f"DEBUG: Database URL: {database_url}")
is_postgresql = 'postgresql://' in database_url or 'postgres://' in database_url
print(f"DEBUG: Is PostgreSQL: {is_postgresql}")
if is_postgresql:
print("Using PostgreSQL - skipping SQLite migrations, ensuring tables exist...")
with app.app_context():
@@ -171,16 +171,16 @@ def inject_globals():
active_announcements = []
if g.user:
active_announcements = Announcement.get_active_announcements_for_user(g.user)
# Get tracking script settings
tracking_script_enabled = False
tracking_script_code = ''
try:
tracking_enabled_setting = SystemSettings.query.filter_by(key='tracking_script_enabled').first()
if tracking_enabled_setting:
tracking_script_enabled = tracking_enabled_setting.value == 'true'
tracking_code_setting = SystemSettings.query.filter_by(key='tracking_script_code').first()
if tracking_code_setting:
tracking_script_code = tracking_code_setting.value
@@ -505,7 +505,7 @@ def login():
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
flash('Invalid username or password', 'error')
return render_template('login.html', title='Login')
@@ -526,7 +526,7 @@ def logout():
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
session.clear()
flash('You have been logged out.', 'info')
return redirect(url_for('login'))
@@ -1258,7 +1258,7 @@ def verify_2fa():
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
flash('Invalid verification code. Please try again.', 'error')
return render_template('verify_2fa.html', title='Two-Factor Authentication')
@@ -1448,7 +1448,7 @@ def delete_entry(entry_id):
def update_entry(entry_id):
entry = TimeEntry.query.filter_by(id=entry_id, user_id=session['user_id']).first_or_404()
data = request.json
if not data:
return jsonify({'success': False, 'message': 'No JSON data provided'}), 400
@@ -1465,7 +1465,7 @@ def update_entry(entry_id):
# Accept only ISO 8601 format
departure_time_str = data['departure_time']
entry.departure_time = datetime.fromisoformat(departure_time_str.replace('Z', '+00:00'))
# Recalculate duration if both times are present
if entry.arrival_time and entry.departure_time:
# Calculate work duration considering breaks
@@ -2117,23 +2117,23 @@ def system_admin_settings():
total_system_admins=total_system_admins)
@app.route('/system-admin/health')
@system_admin_required
@system_admin_required
def system_admin_health():
"""System Admin: System health check and event log"""
# Get system health summary
health_summary = SystemEvent.get_system_health_summary()
# Get recent events (last 7 days)
recent_events = SystemEvent.get_recent_events(days=7, limit=100)
# Get events by severity for quick stats
errors = SystemEvent.get_events_by_severity('error', days=7, limit=20)
warnings = SystemEvent.get_events_by_severity('warning', days=7, limit=20)
# System metrics
from datetime import datetime, timedelta
now = datetime.now()
# Database connection test
db_healthy = True
db_error = None
@@ -2148,18 +2148,18 @@ def system_admin_health():
'system',
'error'
)
# Application uptime (approximate based on first event)
first_event = SystemEvent.query.order_by(SystemEvent.timestamp.asc()).first()
uptime_start = first_event.timestamp if first_event else now
uptime_duration = now - uptime_start
# Recent activity stats
today = now.date()
today_events = SystemEvent.query.filter(
func.date(SystemEvent.timestamp) == today
).count()
# Log the health check
SystemEvent.log_event(
'system_health_check',
@@ -2170,7 +2170,7 @@ def system_admin_health():
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
return render_template('system_admin_health.html',
title='System Health Check',
health_summary=health_summary,
@@ -2188,10 +2188,10 @@ def system_admin_announcements():
"""System Admin: Manage announcements"""
page = request.args.get('page', 1, type=int)
per_page = 20
announcements = Announcement.query.order_by(Announcement.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False)
return render_template('system_admin_announcements.html',
title='System Admin - Announcements',
announcements=announcements)
@@ -2206,43 +2206,43 @@ def system_admin_announcement_new():
announcement_type = request.form.get('announcement_type', 'info')
is_urgent = request.form.get('is_urgent') == 'on'
is_active = request.form.get('is_active') == 'on'
# Handle date fields
start_date = request.form.get('start_date')
end_date = request.form.get('end_date')
start_datetime = None
end_datetime = None
if start_date:
try:
start_datetime = datetime.strptime(start_date, '%Y-%m-%dT%H:%M')
except ValueError:
pass
if end_date:
try:
end_datetime = datetime.strptime(end_date, '%Y-%m-%dT%H:%M')
except ValueError:
pass
# Handle targeting
target_all_users = request.form.get('target_all_users') == 'on'
target_roles = None
target_companies = None
if not target_all_users:
selected_roles = request.form.getlist('target_roles')
selected_companies = request.form.getlist('target_companies')
if selected_roles:
import json
target_roles = json.dumps(selected_roles)
if selected_companies:
import json
target_companies = json.dumps([int(c) for c in selected_companies])
announcement = Announcement(
title=title,
content=content,
@@ -2256,17 +2256,17 @@ def system_admin_announcement_new():
target_companies=target_companies,
created_by_id=g.user.id
)
db.session.add(announcement)
db.session.commit()
flash('Announcement created successfully.', 'success')
return redirect(url_for('system_admin_announcements'))
# Get roles and companies for targeting options
roles = [role.value for role in Role]
companies = Company.query.order_by(Company.name).all()
return render_template('system_admin_announcement_form.html',
title='Create Announcement',
announcement=None,
@@ -2278,18 +2278,18 @@ def system_admin_announcement_new():
def system_admin_announcement_edit(id):
"""System Admin: Edit announcement"""
announcement = Announcement.query.get_or_404(id)
if request.method == 'POST':
announcement.title = request.form.get('title')
announcement.content = request.form.get('content')
announcement.announcement_type = request.form.get('announcement_type', 'info')
announcement.is_urgent = request.form.get('is_urgent') == 'on'
announcement.is_active = request.form.get('is_active') == 'on'
# Handle date fields
start_date = request.form.get('start_date')
end_date = request.form.get('end_date')
if start_date:
try:
announcement.start_date = datetime.strptime(start_date, '%Y-%m-%dT%H:%M')
@@ -2297,7 +2297,7 @@ def system_admin_announcement_edit(id):
announcement.start_date = None
else:
announcement.start_date = None
if end_date:
try:
announcement.end_date = datetime.strptime(end_date, '%Y-%m-%dT%H:%M')
@@ -2305,20 +2305,20 @@ def system_admin_announcement_edit(id):
announcement.end_date = None
else:
announcement.end_date = None
# Handle targeting
announcement.target_all_users = request.form.get('target_all_users') == 'on'
if not announcement.target_all_users:
selected_roles = request.form.getlist('target_roles')
selected_companies = request.form.getlist('target_companies')
if selected_roles:
import json
announcement.target_roles = json.dumps(selected_roles)
else:
announcement.target_roles = None
if selected_companies:
import json
announcement.target_companies = json.dumps([int(c) for c in selected_companies])
@@ -2327,18 +2327,18 @@ def system_admin_announcement_edit(id):
else:
announcement.target_roles = None
announcement.target_companies = None
announcement.updated_at = datetime.now()
db.session.commit()
flash('Announcement updated successfully.', 'success')
return redirect(url_for('system_admin_announcements'))
# Get roles and companies for targeting options
roles = [role.value for role in Role]
companies = Company.query.order_by(Company.name).all()
return render_template('system_admin_announcement_form.html',
title='Edit Announcement',
announcement=announcement,
@@ -2350,10 +2350,10 @@ def system_admin_announcement_edit(id):
def system_admin_announcement_delete(id):
"""System Admin: Delete announcement"""
announcement = Announcement.query.get_or_404(id)
db.session.delete(announcement)
db.session.commit()
flash('Announcement deleted successfully.', 'success')
return redirect(url_for('system_admin_announcements'))
@@ -3392,6 +3392,74 @@ def manage_project_tasks(project_id):
tasks=tasks,
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
@app.route('/api/tasks', methods=['POST'])
@role_required(Role.TEAM_MEMBER)
@@ -3801,6 +3869,604 @@ def delete_category(category_id):
db.session.rollback()
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__':
port = int(os.environ.get('PORT', 5000))
app.run(debug=False, host='0.0.0.0', port=port)

View File

@@ -13,9 +13,10 @@ from datetime import datetime
# Try to import from Flask app context if available
try:
from app import app, db
from models import (User, TimeEntry, WorkConfig, SystemSettings, Team, Role, Project,
Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType,
ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent)
from models import (User, TimeEntry, WorkConfig, SystemSettings, Team, Role, Project,
Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType,
ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent, KanbanBoard,
KanbanColumn, KanbanCard)
from werkzeug.security import generate_password_hash
FLASK_AVAILABLE = True
except ImportError:
@@ -23,14 +24,14 @@ except ImportError:
FLASK_AVAILABLE = False
# Define Role and AccountType enums for standalone mode
import enum
class Role(enum.Enum):
TEAM_MEMBER = "Team Member"
TEAM_LEADER = "Team Leader"
SUPERVISOR = "Supervisor"
ADMIN = "Administrator"
SYSTEM_ADMIN = "System Administrator"
class AccountType(enum.Enum):
COMPANY_USER = "Company User"
FREELANCER = "Freelancer"
@@ -40,11 +41,11 @@ def get_db_path(db_file=None):
"""Determine database path based on environment or provided file."""
if db_file:
return db_file
# Check for Docker environment
if os.path.exists('/data'):
return '/data/timetrack.db'
return 'timetrack.db'
@@ -52,7 +53,7 @@ def run_all_migrations(db_path=None):
"""Run all database migrations in sequence."""
db_path = get_db_path(db_path)
print(f"Running migrations on database: {db_path}")
# Check if database exists
if not os.path.exists(db_path):
print("Database doesn't exist. Creating new database.")
@@ -63,21 +64,22 @@ def run_all_migrations(db_path=None):
else:
create_new_database(db_path)
return
print("Running database migrations...")
# Run migrations in sequence
run_basic_migrations(db_path)
migrate_to_company_model(db_path)
migrate_work_config_data(db_path)
migrate_task_system(db_path)
migrate_system_events(db_path)
migrate_kanban_system(db_path)
if FLASK_AVAILABLE:
with app.app_context():
# Handle company migration and admin user setup
migrate_data()
print("Database migrations completed successfully!")
@@ -85,7 +87,7 @@ def run_basic_migrations(db_path):
"""Run basic table structure migrations."""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Check if time_entry table exists first
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='time_entry'")
@@ -141,7 +143,7 @@ def run_basic_migrations(db_path):
else:
cursor.execute("PRAGMA table_info(work_config)")
work_config_columns = [column[1] for column in cursor.fetchall()]
work_config_migrations = [
('additional_break_minutes', "ALTER TABLE work_config ADD COLUMN additional_break_minutes INTEGER DEFAULT 15"),
('additional_break_threshold_hours', "ALTER TABLE work_config ADD COLUMN additional_break_threshold_hours FLOAT DEFAULT 9.0"),
@@ -186,7 +188,7 @@ def run_basic_migrations(db_path):
create_missing_tables(cursor)
conn.commit()
except Exception as e:
print(f"Error during basic migrations: {e}")
conn.rollback()
@@ -197,7 +199,7 @@ def run_basic_migrations(db_path):
def create_missing_tables(cursor):
"""Create missing tables."""
# Team table
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='team'")
if not cursor.fetchone():
@@ -228,7 +230,7 @@ def create_missing_tables(cursor):
)
""")
# Project table
# Project table
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='project'")
if not cursor.fetchone():
print("Creating project table...")
@@ -272,7 +274,7 @@ def create_missing_tables(cursor):
UNIQUE(name)
)
""")
# Announcement table
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='announcement'")
if not cursor.fetchone():
@@ -340,13 +342,13 @@ def migrate_to_company_model(db_path):
def add_company_id_to_tables(cursor):
"""Add company_id columns to tables that need multi-tenancy."""
tables_needing_company = ['project', 'team']
for table_name in tables_needing_company:
cursor.execute(f"PRAGMA table_info({table_name})")
columns = [column[1] for column in cursor.fetchall()]
if 'company_id' not in columns:
print(f"Adding company_id column to {table_name}...")
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN company_id INTEGER")
@@ -354,7 +356,7 @@ def add_company_id_to_tables(cursor):
def migrate_user_roles(cursor):
"""Handle user role enum migration with constraint updates."""
cursor.execute("PRAGMA table_info(user)")
user_columns = cursor.fetchall()
@@ -387,26 +389,26 @@ def migrate_user_roles(cursor):
print(f"Updated {updated_count} users from role '{old_role}' to '{new_role}'")
# Set any NULL or invalid roles to defaults
cursor.execute("UPDATE user SET role = ? WHERE role IS NULL OR role NOT IN (?, ?, ?, ?, ?)",
(Role.TEAM_MEMBER.value, Role.TEAM_MEMBER.value, Role.TEAM_LEADER.value,
cursor.execute("UPDATE user SET role = ? WHERE role IS NULL OR role NOT IN (?, ?, ?, ?, ?)",
(Role.TEAM_MEMBER.value, Role.TEAM_MEMBER.value, Role.TEAM_LEADER.value,
Role.SUPERVISOR.value, Role.ADMIN.value, Role.SYSTEM_ADMIN.value))
null_roles = cursor.rowcount
if null_roles > 0:
print(f"Set {null_roles} NULL/invalid roles to 'Team Member'")
# Ensure all users have a company_id before creating NOT NULL constraint
print("Checking for users without company_id...")
cursor.execute("SELECT COUNT(*) FROM user WHERE company_id IS NULL")
null_company_count = cursor.fetchone()[0]
print(f"Found {null_company_count} users without company_id")
if null_company_count > 0:
print(f"Assigning {null_company_count} users to default company...")
# Get or create a default company
cursor.execute("SELECT id FROM company ORDER BY id LIMIT 1")
company_result = cursor.fetchone()
if company_result:
default_company_id = company_result[0]
print(f"Using existing company ID {default_company_id} as default")
@@ -419,12 +421,12 @@ def migrate_user_roles(cursor):
""", ("Default Company", "default-company", "Auto-created default company for migration"))
default_company_id = cursor.lastrowid
print(f"Created default company with ID {default_company_id}")
# Assign all users without company_id to the default company
cursor.execute("UPDATE user SET company_id = ? WHERE company_id IS NULL", (default_company_id,))
updated_users = cursor.rowcount
print(f"Assigned {updated_users} users to default company")
# Verify the fix
cursor.execute("SELECT COUNT(*) FROM user WHERE company_id IS NULL")
remaining_null = cursor.fetchone()[0]
@@ -463,27 +465,27 @@ def migrate_user_roles(cursor):
cursor.execute("SELECT id FROM company ORDER BY id LIMIT 1")
company_result = cursor.fetchone()
default_company_id = company_result[0] if company_result else 1
# Copy all data from old table to new table with validation
cursor.execute("""
INSERT INTO user_new
SELECT id, username, email, password_hash, created_at,
INSERT INTO user_new
SELECT id, username, email, password_hash, created_at,
COALESCE(company_id, ?) as company_id,
is_verified, verification_token, token_expiry, is_blocked,
CASE
CASE
WHEN role IN (?, ?, ?, ?, ?) THEN role
ELSE ?
END as role,
team_id,
CASE
CASE
WHEN account_type IN (?, ?) THEN account_type
ELSE ?
END as account_type,
business_name, two_factor_enabled, two_factor_secret
FROM user
""", (default_company_id, Role.TEAM_MEMBER.value, Role.TEAM_LEADER.value, Role.SUPERVISOR.value,
""", (default_company_id, Role.TEAM_MEMBER.value, Role.TEAM_LEADER.value, Role.SUPERVISOR.value,
Role.ADMIN.value, Role.SYSTEM_ADMIN.value, Role.TEAM_MEMBER.value,
AccountType.COMPANY_USER.value, AccountType.FREELANCER.value,
AccountType.COMPANY_USER.value, AccountType.FREELANCER.value,
AccountType.COMPANY_USER.value))
# Drop the old table and rename the new one
@@ -517,7 +519,7 @@ def migrate_work_config_data(db_path):
if not FLASK_AVAILABLE:
print("Skipping work config data migration - Flask not available")
return
with app.app_context():
try:
# Create CompanyWorkConfig for all companies that don't have one
@@ -526,10 +528,10 @@ def migrate_work_config_data(db_path):
existing_config = CompanyWorkConfig.query.filter_by(company_id=company.id).first()
if not existing_config:
print(f"Creating CompanyWorkConfig for {company.name}")
# Use Germany defaults (existing system default)
preset = CompanyWorkConfig.get_regional_preset(WorkRegion.GERMANY)
company_config = CompanyWorkConfig(
company_id=company.id,
work_hours_per_day=preset['work_hours_per_day'],
@@ -541,7 +543,7 @@ def migrate_work_config_data(db_path):
region_name=preset['region_name']
)
db.session.add(company_config)
# Migrate existing WorkConfig user preferences to UserPreferences
old_configs = WorkConfig.query.filter(WorkConfig.user_id.isnot(None)).all()
for old_config in old_configs:
@@ -550,7 +552,7 @@ def migrate_work_config_data(db_path):
existing_prefs = UserPreferences.query.filter_by(user_id=user.id).first()
if not existing_prefs:
print(f"Migrating preferences for user {user.username}")
user_prefs = UserPreferences(
user_id=user.id,
time_format_24h=getattr(old_config, 'time_format_24h', True),
@@ -559,10 +561,10 @@ def migrate_work_config_data(db_path):
round_to_nearest=getattr(old_config, 'round_to_nearest', True)
)
db.session.add(user_prefs)
db.session.commit()
print("Work config data migration completed successfully")
except Exception as e:
print(f"Error during work config migration: {e}")
db.session.rollback()
@@ -706,7 +708,7 @@ def migrate_system_events(db_path):
FOREIGN KEY (company_id) REFERENCES company (id)
)
""")
# Add an initial system event if Flask is available
if FLASK_AVAILABLE:
# We'll add the initial event after the table is created
@@ -732,7 +734,7 @@ def migrate_data():
if not FLASK_AVAILABLE:
print("Skipping data migration - Flask not available")
return
try:
# Update existing users with null/invalid data
users = User.query.all()
@@ -741,7 +743,7 @@ def migrate_data():
user.role = Role.TEAM_MEMBER
if user.two_factor_enabled is None:
user.two_factor_enabled = False
# Check if any system admin users exist
system_admin_count = User.query.filter_by(role=Role.SYSTEM_ADMIN).count()
if system_admin_count == 0:
@@ -749,10 +751,10 @@ def migrate_data():
print(f"To promote a user: UPDATE user SET role = '{Role.SYSTEM_ADMIN.value}' WHERE username = 'your_username';")
else:
print(f"Found {system_admin_count} system administrator(s)")
db.session.commit()
print("Data migration completed successfully")
except Exception as e:
print(f"Error during data migration: {e}")
db.session.rollback()
@@ -763,7 +765,7 @@ def init_system_settings():
if not FLASK_AVAILABLE:
print("Skipping system settings initialization - Flask not available")
return
# Check if registration_enabled setting exists
reg_setting = SystemSettings.query.filter_by(key='registration_enabled').first()
if not reg_setting:
@@ -776,7 +778,7 @@ def init_system_settings():
db.session.add(reg_setting)
db.session.commit()
print("Registration setting initialized to enabled")
# Check if email_verification_required setting exists
email_verification_setting = SystemSettings.query.filter_by(key='email_verification_required').first()
if not email_verification_setting:
@@ -789,7 +791,7 @@ def init_system_settings():
db.session.add(email_verification_setting)
db.session.commit()
print("Email verification setting initialized to enabled")
# Check if tracking_script_enabled setting exists
tracking_script_setting = SystemSettings.query.filter_by(key='tracking_script_enabled').first()
if not tracking_script_setting:
@@ -802,7 +804,7 @@ def init_system_settings():
db.session.add(tracking_script_setting)
db.session.commit()
print("Tracking script setting initialized to disabled")
# Check if tracking_script_code setting exists
tracking_script_code_setting = SystemSettings.query.filter_by(key='tracking_script_code').first()
if not tracking_script_code_setting:
@@ -820,10 +822,10 @@ def init_system_settings():
def create_new_database(db_path):
"""Create a new database with all tables."""
print(f"Creating new database at {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
create_all_tables(cursor)
conn.commit()
@@ -840,7 +842,7 @@ def create_all_tables(cursor):
"""Create all tables from scratch."""
# This would contain all CREATE TABLE statements
# For brevity, showing key tables only
cursor.execute("""
CREATE TABLE company (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -854,7 +856,7 @@ def create_all_tables(cursor):
UNIQUE(name)
)
""")
cursor.execute("""
CREATE TABLE user (
id INTEGER PRIMARY KEY,
@@ -877,18 +879,120 @@ def create_all_tables(cursor):
FOREIGN KEY (team_id) REFERENCES team (id)
)
""")
# Add other table creation statements as needed
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():
"""Main function with command line interface."""
parser = argparse.ArgumentParser(description='TimeTrack Database Migration Tool')
parser.add_argument('--db-file', '-d', help='Path to SQLite database file')
parser.add_argument('--create-new', '-c', action='store_true',
parser.add_argument('--create-new', '-c', action='store_true',
help='Create a new database (will overwrite existing)')
parser.add_argument('--migrate-all', '-m', action='store_true',
parser.add_argument('--migrate-all', '-m', action='store_true',
help='Run all migrations (default action)')
parser.add_argument('--task-system', '-t', action='store_true',
help='Run only task system migration')
@@ -898,16 +1002,18 @@ def main():
help='Run only basic table migrations')
parser.add_argument('--system-events', '-s', action='store_true',
help='Run only system events migration')
parser.add_argument('--kanban', '-k', action='store_true',
help='Run only Kanban system migration')
args = parser.parse_args()
db_path = get_db_path(args.db_file)
print(f"TimeTrack Database Migration Tool")
print(f"Database: {db_path}")
print(f"Flask available: {FLASK_AVAILABLE}")
print("-" * 50)
try:
if args.create_new:
if os.path.exists(db_path):
@@ -917,25 +1023,28 @@ def main():
return
os.remove(db_path)
create_new_database(db_path)
elif args.task_system:
migrate_task_system(db_path)
elif args.company_model:
migrate_to_company_model(db_path)
elif args.basic:
run_basic_migrations(db_path)
elif args.system_events:
migrate_system_events(db_path)
elif args.kanban:
migrate_kanban_system(db_path)
else:
# Default: run all migrations
run_all_migrations(db_path)
print("\nMigration completed successfully!")
except Exception as e:
print(f"\nError during migration: {e}")
sys.exit(1)

347
models.py
View File

@@ -26,22 +26,22 @@ class Company(db.Model):
slug = db.Column(db.String(50), unique=True, nullable=False) # URL-friendly identifier
description = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.now)
# Freelancer support
is_personal = db.Column(db.Boolean, default=False) # True for auto-created freelancer companies
# Company settings
is_active = db.Column(db.Boolean, default=True)
max_users = db.Column(db.Integer, default=100) # Optional user limit
# Relationships
users = db.relationship('User', backref='company', lazy=True)
teams = db.relationship('Team', backref='company', lazy=True)
teams = db.relationship('Team', backref='company', lazy=True)
projects = db.relationship('Project', backref='company', lazy=True)
def __repr__(self):
return f'<Company {self.name}>'
def generate_slug(self):
"""Generate URL-friendly slug from company name"""
import re
@@ -55,16 +55,16 @@ class Team(db.Model):
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.String(255))
created_at = db.Column(db.DateTime, default=datetime.now)
# Company association for multi-tenancy
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Relationship with users (one team has many users)
users = db.relationship('User', backref='team', lazy=True)
# Unique constraint per company
__table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_team_name_per_company'),)
def __repr__(self):
return f'<Team {self.name}>'
@@ -76,52 +76,52 @@ class Project(db.Model):
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
# Company association for multi-tenancy
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Foreign key to user who created the project (Admin/Supervisor)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Optional team assignment - if set, only team members can log time to this project
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True)
# Project categorization
category_id = db.Column(db.Integer, db.ForeignKey('project_category.id'), nullable=True)
# Project dates
start_date = db.Column(db.Date, nullable=True)
end_date = db.Column(db.Date, nullable=True)
# Relationships
created_by = db.relationship('User', foreign_keys=[created_by_id], backref='created_projects')
team = db.relationship('Team', backref='projects')
time_entries = db.relationship('TimeEntry', backref='project', lazy=True)
category = db.relationship('ProjectCategory', back_populates='projects')
# Unique constraint per company
__table_args__ = (db.UniqueConstraint('company_id', 'code', name='uq_project_code_per_company'),)
def __repr__(self):
return f'<Project {self.code}: {self.name}>'
def is_user_allowed(self, user):
"""Check if a user is allowed to log time to this project"""
if not self.is_active:
return False
# Must be in same company
if self.company_id != user.company_id:
return False
# Admins and Supervisors can log time to any project in their company
if user.role in [Role.ADMIN, Role.SUPERVISOR]:
return True
# If project is team-specific, only team members can log time
if self.team_id:
return user.team_id == self.team_id
# If no team restriction, any user in the company can log time
return True
@@ -132,52 +132,52 @@ class User(db.Model):
email = db.Column(db.String(120), nullable=False)
password_hash = db.Column(db.String(128))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Company association for multi-tenancy
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Email verification fields
is_verified = db.Column(db.Boolean, default=False)
verification_token = db.Column(db.String(100), unique=True, nullable=True)
token_expiry = db.Column(db.DateTime, nullable=True)
# New field for blocking users
is_blocked = db.Column(db.Boolean, default=False)
# New fields for role and team
role = db.Column(db.Enum(Role, values_callable=lambda obj: [e.value for e in obj]), default=Role.TEAM_MEMBER)
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True)
# Freelancer support
account_type = db.Column(db.Enum(AccountType, values_callable=lambda obj: [e.value for e in obj]), default=AccountType.COMPANY_USER)
business_name = db.Column(db.String(100), nullable=True) # Optional business name for freelancers
# Unique constraints per company
__table_args__ = (
db.UniqueConstraint('company_id', 'username', name='uq_user_username_per_company'),
db.UniqueConstraint('company_id', 'email', name='uq_user_email_per_company'),
)
# Two-Factor Authentication fields
two_factor_enabled = db.Column(db.Boolean, default=False)
two_factor_secret = db.Column(db.String(32), nullable=True) # Base32 encoded secret
# Relationships
time_entries = db.relationship('TimeEntry', backref='user', lazy=True)
work_config = db.relationship('WorkConfig', backref='user', lazy=True, uselist=False)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def generate_verification_token(self):
"""Generate a verification token that expires in 24 hours"""
self.verification_token = secrets.token_urlsafe(32)
self.token_expiry = datetime.utcnow() + timedelta(hours=24)
return self.verification_token
def verify_token(self, token):
"""Verify the token and mark user as verified if valid"""
if token == self.verification_token and self.token_expiry > datetime.utcnow():
@@ -186,13 +186,13 @@ class User(db.Model):
self.token_expiry = None
return True
return False
def generate_2fa_secret(self):
"""Generate a new 2FA secret"""
import pyotp
self.two_factor_secret = pyotp.random_base32()
return self.two_factor_secret
def get_2fa_uri(self):
"""Get the provisioning URI for QR code generation"""
if not self.two_factor_secret:
@@ -203,7 +203,7 @@ class User(db.Model):
name=self.email,
issuer_name="TimeTrack"
)
def verify_2fa_token(self, token, allow_setup=False):
"""Verify a 2FA token"""
if not self.two_factor_secret:
@@ -214,7 +214,7 @@ class User(db.Model):
import pyotp
totp = pyotp.TOTP(self.two_factor_secret)
return totp.verify(token, valid_window=1) # Allow 1 window tolerance
def __repr__(self):
return f'<User {self.username}>'
@@ -224,7 +224,7 @@ class SystemSettings(db.Model):
value = db.Column(db.String(255), nullable=False)
description = db.Column(db.String(255))
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f'<SystemSettings {self.key}={self.value}>'
@@ -237,14 +237,14 @@ class TimeEntry(db.Model):
pause_start_time = db.Column(db.DateTime, nullable=True)
total_break_duration = db.Column(db.Integer, default=0) # Total break duration in seconds
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
# Project association - nullable for backward compatibility
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True)
# Task/SubTask associations - nullable for backward compatibility
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True)
subtask_id = db.Column(db.Integer, db.ForeignKey('sub_task.id'), nullable=True)
# Optional notes/description for the time entry
notes = db.Column(db.Text, nullable=True)
@@ -259,15 +259,15 @@ class WorkConfig(db.Model):
break_threshold_hours = db.Column(db.Float, default=6.0) # Work hours that trigger mandatory break
additional_break_minutes = db.Column(db.Integer, default=15) # Default 15 minutes for additional break
additional_break_threshold_hours = db.Column(db.Float, default=9.0) # Work hours that trigger additional break
# Time rounding settings
time_rounding_minutes = db.Column(db.Integer, default=0) # 0 = no rounding, 15 = 15 min, 30 = 30 min
round_to_nearest = db.Column(db.Boolean, default=True) # True = round to nearest, False = round up
# Date/time format settings
time_format_24h = db.Column(db.Boolean, default=True) # True = 24h, False = 12h (AM/PM)
date_format = db.Column(db.String(20), default='ISO') # ISO, US, EU, etc.
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
@@ -288,33 +288,33 @@ class WorkRegion(enum.Enum):
class CompanyWorkConfig(db.Model):
id = db.Column(db.Integer, primary_key=True)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Work policy settings (legal requirements)
work_hours_per_day = db.Column(db.Float, default=8.0) # Standard work hours per day
mandatory_break_minutes = db.Column(db.Integer, default=30) # Required break duration
break_threshold_hours = db.Column(db.Float, default=6.0) # Hours that trigger mandatory break
additional_break_minutes = db.Column(db.Integer, default=15) # Additional break duration
additional_break_threshold_hours = db.Column(db.Float, default=9.0) # Hours that trigger additional break
# Regional compliance
region = db.Column(db.Enum(WorkRegion), default=WorkRegion.GERMANY)
region_name = db.Column(db.String(50), default='Germany')
# 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=True)
# Relationships
company = db.relationship('Company', backref='work_config')
created_by = db.relationship('User', foreign_keys=[created_by_id])
# Unique constraint - one config per company
__table_args__ = (db.UniqueConstraint('company_id', name='uq_company_work_config'),)
def __repr__(self):
return f'<CompanyWorkConfig {self.company.name}: {self.region.value}, {self.work_hours_per_day}h/day>'
@classmethod
def get_regional_preset(cls, region):
"""Get regional preset configuration."""
@@ -366,25 +366,25 @@ class CompanyWorkConfig(db.Model):
class UserPreferences(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Display format preferences
time_format_24h = db.Column(db.Boolean, default=True) # True = 24h, False = 12h (AM/PM)
date_format = db.Column(db.String(20), default='ISO') # ISO, US, EU, etc.
# Time rounding preferences
time_rounding_minutes = db.Column(db.Integer, default=0) # 0 = no rounding, 15 = 15 min, 30 = 30 min
round_to_nearest = db.Column(db.Boolean, default=True) # True = round to nearest, False = round up
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
# Relationships
user = db.relationship('User', backref=db.backref('preferences', uselist=False))
# Unique constraint - one preferences per user
__table_args__ = (db.UniqueConstraint('user_id', name='uq_user_preferences'),)
def __repr__(self):
return f'<UserPreferences {self.user.username}: {self.date_format}, {"24h" if self.time_format_24h else "12h"}>'
@@ -395,23 +395,23 @@ class ProjectCategory(db.Model):
description = db.Column(db.Text, nullable=True)
color = db.Column(db.String(7), default='#007bff') # Hex color for UI
icon = db.Column(db.String(50), nullable=True) # Icon name/emoji
# Company association for multi-tenancy
company_id = db.Column(db.Integer, db.ForeignKey('company.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)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
company = db.relationship('Company', backref='project_categories')
created_by = db.relationship('User', foreign_keys=[created_by_id])
projects = db.relationship('Project', back_populates='category', lazy=True)
# Unique constraint per company
__table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_category_name_per_company'),)
def __repr__(self):
return f'<ProjectCategory {self.name}>'
@@ -435,52 +435,52 @@ class Task(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
# Task properties
status = db.Column(db.Enum(TaskStatus), default=TaskStatus.NOT_STARTED)
priority = db.Column(db.Enum(TaskPriority), default=TaskPriority.MEDIUM)
estimated_hours = db.Column(db.Float, nullable=True) # Estimated time to complete
# Project association
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
# Task assignment
assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
# Task dates
start_date = db.Column(db.Date, nullable=True)
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
project = db.relationship('Project', backref='tasks')
assigned_to = db.relationship('User', foreign_keys=[assigned_to_id], backref='assigned_tasks')
created_by = db.relationship('User', foreign_keys=[created_by_id])
subtasks = db.relationship('SubTask', backref='parent_task', lazy=True, cascade='all, delete-orphan')
time_entries = db.relationship('TimeEntry', backref='task', lazy=True)
def __repr__(self):
return f'<Task {self.name} ({self.status.value})>'
@property
def progress_percentage(self):
"""Calculate task progress based on subtasks completion"""
if not self.subtasks:
return 100 if self.status == TaskStatus.COMPLETED else 0
completed_subtasks = sum(1 for subtask in self.subtasks if subtask.status == TaskStatus.COMPLETED)
return int((completed_subtasks / len(self.subtasks)) * 100)
@property
def total_time_logged(self):
"""Calculate total time logged to this task (in seconds)"""
return sum(entry.duration or 0 for entry in self.time_entries if entry.duration)
def can_user_access(self, user):
"""Check if a user can access this task"""
return self.project.is_user_allowed(user)
@@ -490,41 +490,41 @@ class SubTask(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
# SubTask properties
status = db.Column(db.Enum(TaskStatus), default=TaskStatus.NOT_STARTED)
priority = db.Column(db.Enum(TaskPriority), default=TaskPriority.MEDIUM)
estimated_hours = db.Column(db.Float, nullable=True)
# Parent task association
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
# Assignment
assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
# Dates
start_date = db.Column(db.Date, nullable=True)
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
assigned_to = db.relationship('User', foreign_keys=[assigned_to_id], backref='assigned_subtasks')
created_by = db.relationship('User', foreign_keys=[created_by_id])
time_entries = db.relationship('TimeEntry', backref='subtask', lazy=True)
def __repr__(self):
return f'<SubTask {self.name} ({self.status.value})>'
@property
def total_time_logged(self):
"""Calculate total time logged to this subtask (in seconds)"""
return sum(entry.duration or 0 for entry in self.time_entries if entry.duration)
def can_user_access(self, user):
"""Check if a user can access this subtask"""
return self.parent_task.can_user_access(user)
@@ -534,58 +534,58 @@ class Announcement(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text, nullable=False)
# Announcement properties
is_active = db.Column(db.Boolean, default=True)
is_urgent = db.Column(db.Boolean, default=False) # For urgent announcements with different styling
announcement_type = db.Column(db.String(20), default='info') # info, warning, success, danger
# Scheduling
start_date = db.Column(db.DateTime, nullable=True) # When to start showing
end_date = db.Column(db.DateTime, nullable=True) # When to stop showing
# Targeting
target_all_users = db.Column(db.Boolean, default=True)
target_roles = db.Column(db.Text, nullable=True) # JSON string of roles if not all users
target_companies = db.Column(db.Text, nullable=True) # JSON string of company IDs if not all companies
# 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
created_by = db.relationship('User', foreign_keys=[created_by_id])
def __repr__(self):
return f'<Announcement {self.title}>'
def is_visible_now(self):
"""Check if announcement should be visible at current time"""
if not self.is_active:
return False
now = datetime.now()
# Check start date
if self.start_date and now < self.start_date:
return False
# Check end date
if self.end_date and now > self.end_date:
return False
return True
def is_visible_to_user(self, user):
"""Check if announcement should be visible to specific user"""
if not self.is_visible_now():
return False
# If targeting all users, show to everyone
if self.target_all_users:
return True
# Check role targeting
if self.target_roles:
import json
@@ -595,7 +595,7 @@ class Announcement(db.Model):
return False
except (json.JSONDecodeError, AttributeError):
pass
# Check company targeting
if self.target_companies:
import json
@@ -605,9 +605,9 @@ class Announcement(db.Model):
return False
except (json.JSONDecodeError, AttributeError):
pass
return True
@staticmethod
def get_active_announcements_for_user(user):
"""Get all active announcements visible to a specific user"""
@@ -622,27 +622,27 @@ class SystemEvent(db.Model):
description = db.Column(db.Text, nullable=False)
severity = db.Column(db.String(20), default='info') # 'info', 'warning', 'error', 'critical'
timestamp = db.Column(db.DateTime, default=datetime.now, nullable=False)
# Optional associations
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=True)
# Additional metadata (JSON string)
event_metadata = db.Column(db.Text, nullable=True) # Store additional event data as JSON
# IP address and user agent for security tracking
ip_address = db.Column(db.String(45), nullable=True) # IPv6 compatible
user_agent = db.Column(db.Text, nullable=True)
# Relationships
user = db.relationship('User', backref='system_events')
company = db.relationship('Company', backref='system_events')
def __repr__(self):
return f'<SystemEvent {self.event_type}: {self.description[:50]}>'
@staticmethod
def log_event(event_type, description, event_category='system', severity='info',
def log_event(event_type, description, event_category='system', severity='info',
user_id=None, company_id=None, event_metadata=None, ip_address=None, user_agent=None):
"""Helper method to log system events"""
event = SystemEvent(
@@ -664,7 +664,7 @@ class SystemEvent(db.Model):
# Log to application logger if DB logging fails
import logging
logging.error(f"Failed to log system event: {e}")
@staticmethod
def get_recent_events(days=7, limit=100):
"""Get recent system events from the last N days"""
@@ -673,7 +673,7 @@ class SystemEvent(db.Model):
return SystemEvent.query.filter(
SystemEvent.timestamp >= since
).order_by(SystemEvent.timestamp.desc()).limit(limit).all()
@staticmethod
def get_events_by_severity(severity, days=7, limit=50):
"""Get events by severity level"""
@@ -683,42 +683,151 @@ class SystemEvent(db.Model):
SystemEvent.timestamp >= since,
SystemEvent.severity == severity
).order_by(SystemEvent.timestamp.desc()).limit(limit).all()
@staticmethod
def get_system_health_summary():
"""Get a summary of system health based on recent events"""
from datetime import datetime, timedelta
from sqlalchemy import func
now = datetime.now()
last_24h = now - timedelta(hours=24)
last_week = now - timedelta(days=7)
# Count events by severity in last 24h
recent_errors = SystemEvent.query.filter(
SystemEvent.timestamp >= last_24h,
SystemEvent.severity.in_(['error', 'critical'])
).count()
recent_warnings = SystemEvent.query.filter(
SystemEvent.timestamp >= last_24h,
SystemEvent.severity == 'warning'
).count()
# Count total events in last week
weekly_events = SystemEvent.query.filter(
SystemEvent.timestamp >= last_week
).count()
# Get most recent error
last_error = SystemEvent.query.filter(
SystemEvent.severity.in_(['error', 'critical'])
).order_by(SystemEvent.timestamp.desc()).first()
return {
'errors_24h': recent_errors,
'warnings_24h': recent_warnings,
'total_events_week': weekly_events,
'last_error': last_error,
'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">
<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('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 %}
<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?')">

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>
{% 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('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>
<!-- Role-based menu items -->

File diff suppressed because it is too large Load Diff