Add Kanban Boards for Projects.
This commit is contained in:
766
app.py
766
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/<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)
|
||||
251
migrate_db.py
251
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)
|
||||
|
||||
347
models.py
347
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'<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)
|
||||
@@ -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?')">
|
||||
|
||||
517
templates/kanban_overview.html
Normal file
517
templates/kanban_overview.html
Normal 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 %}
|
||||
@@ -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 -->
|
||||
|
||||
1377
templates/project_kanban.html
Normal file
1377
templates/project_kanban.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user