From 4a4aa0564588979f30ce75db33a42c93c17bcf49 Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Thu, 3 Jul 2025 13:10:30 +0200 Subject: [PATCH] Add Kanban Boards for Projects. --- app.py | 766 ++++++++++++++++-- migrate_db.py | 251 ++++-- models.py | 347 +++++--- templates/admin_projects.html | 1 + templates/kanban_overview.html | 517 ++++++++++++ templates/layout.html | 1 + templates/project_kanban.html | 1377 ++++++++++++++++++++++++++++++++ 7 files changed, 3020 insertions(+), 240 deletions(-) create mode 100644 templates/kanban_overview.html create mode 100644 templates/project_kanban.html diff --git a/app.py b/app.py index 0b5bde5..445867c 100644 --- a/app.py +++ b/app.py @@ -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//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/', 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//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/', 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/', 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/', 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//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/', 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) \ No newline at end of file diff --git a/migrate_db.py b/migrate_db.py index 4c83313..07e8f16 100644 --- a/migrate_db.py +++ b/migrate_db.py @@ -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) diff --git a/models.py b/models.py index 6736d25..086a8ab 100644 --- a/models.py +++ b/models.py @@ -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'' - + 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'' @@ -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'' - + 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'' @@ -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'' @@ -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'' - + @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'' @@ -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'' @@ -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'' - + @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'' - + @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'' - + 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'' - + @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' - } \ No newline at end of file + } + +# 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'' + +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'' + + @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'' + + 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) \ No newline at end of file diff --git a/templates/admin_projects.html b/templates/admin_projects.html index eecef2e..5734256 100644 --- a/templates/admin_projects.html +++ b/templates/admin_projects.html @@ -93,6 +93,7 @@ Edit Tasks + Kanban {% if g.user.role == Role.ADMIN and project.time_entries|length == 0 %}
diff --git a/templates/kanban_overview.html b/templates/kanban_overview.html new file mode 100644 index 0000000..3673be6 --- /dev/null +++ b/templates/kanban_overview.html @@ -0,0 +1,517 @@ +{% extends "layout.html" %} + +{% block content %} +
+
+

Kanban Board Overview

+

Manage your tasks visually across all projects

+
+ + {% if project_boards %} +
+ {% for project, boards in project_boards.items() %} +
+
+
+

+ {{ project.code }} + {{ project.name }} +

+ {% if project.category %} + + {{ project.category.icon or '📁' }} {{ project.category.name }} + + {% endif %} + {% if project.description %} +

{{ project.description }}

+ {% endif %} +
+ +
+ +
+

Kanban Boards ({{ boards|length }})

+
+ {% for board in boards %} +
+
+
+ {{ board.name }} + {% if board.is_default %} + Default + {% endif %} +
+ {% if board.description %} +
{{ board.description }}
+ {% endif %} +
+
+ {{ board.columns|length }} columns + + {% 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 + +
+
+ {% endfor %} +
+
+ + +
+ {% 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 %} +
{{ default_board.name }} Preview
+
+ {% for column in default_board.columns %} + {% if loop.index <= 4 %} +
+
+ {{ column.name }} + {{ column.cards|selectattr('is_active')|list|length }} +
+
+ {% set active_cards = column.cards|selectattr('is_active')|list %} + {% for card in active_cards %} + {% if loop.index <= 3 %} +
+
+ {% if card.title|length > 30 %} + {{ card.title|truncate(30, True) }} + {% else %} + {{ card.title }} + {% endif %} +
+ {% if card.assigned_to %} +
{{ card.assigned_to.username }}
+ {% endif %} +
+ {% endif %} + {% endfor %} + {% if active_cards|length > 3 %} +
+{{ active_cards|length - 3 }} more
+ {% endif %} +
+
+ {% endif %} + {% endfor %} + {% if default_board.columns|length > 4 %} +
+ +{{ default_board.columns|length - 4 }} more columns +
+ {% endif %} +
+ {% endif %} +
+
+ {% endfor %} +
+ + +
+
+

{{ project_boards.keys()|list|length }}

+

Projects with Kanban

+
+
+ {% set total_boards = 0 %} + {% for project, boards in project_boards.items() %} + {% set total_boards = total_boards + boards|length %} + {% endfor %} +

{{ total_boards }}

+

Total Boards

+
+
+ {% 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 %} +

{{ total_cards }}

+

Total Cards

+
+
+ + {% else %} + +
+
+
📋
+

No Kanban Boards Yet

+

Start organizing your projects with visual Kanban boards.

+
+

Getting Started:

+
    +
  1. Go to a project from Project Management
  2. +
  3. Click the "Kanban" button
  4. +
  5. Create your first board and start organizing tasks
  6. +
+
+ {% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %} + Go to Projects + {% endif %} +
+
+ {% endif %} +
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/layout.html b/templates/layout.html index 6cd3925..124130b 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -41,6 +41,7 @@
    {% if g.user %}
  • 🏠Home
  • +
  • 📋Kanban Board
  • 📊Analytics
  • diff --git a/templates/project_kanban.html b/templates/project_kanban.html new file mode 100644 index 0000000..9cbf480 --- /dev/null +++ b/templates/project_kanban.html @@ -0,0 +1,1377 @@ +{% extends "layout.html" %} + +{% block content %} +
    +
    +
    +

    Kanban Board: {{ project.code }} - {{ project.name }}

    +

    {{ project.description or 'No description available' }}

    + {% if project.category %} + + {{ project.category.icon or '📁' }} {{ project.category.name }} + + {% endif %} +
    +
    + {% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %} + + + {% endif %} + Back to Projects + Task View +
    +
    + + + {% if boards %} +
    + + +
    + {% endif %} + + + + + + {% if not boards %} +
    +
    +

    No Kanban Boards Created Yet

    +

    Create your first Kanban board to start organizing tasks visually.

    + {% if g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator', 'System Administrator'] %} + + {% endif %} +
    +
    + {% endif %} +
    + + + + + + + + + + + + + + + +{% endblock %} \ No newline at end of file