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 flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file
|
||||||
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent
|
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent, KanbanBoard, KanbanColumn, KanbanCard
|
||||||
from data_formatting import (
|
from data_formatting import (
|
||||||
format_duration, prepare_export_data, prepare_team_hours_export_data,
|
format_duration, prepare_export_data, prepare_team_hours_export_data,
|
||||||
format_table_data, format_graph_data, format_team_data
|
format_table_data, format_graph_data, format_team_data
|
||||||
@@ -60,10 +60,10 @@ def run_migrations():
|
|||||||
# Check if we're using PostgreSQL or SQLite
|
# Check if we're using PostgreSQL or SQLite
|
||||||
database_url = app.config['SQLALCHEMY_DATABASE_URI']
|
database_url = app.config['SQLALCHEMY_DATABASE_URI']
|
||||||
print(f"DEBUG: Database URL: {database_url}")
|
print(f"DEBUG: Database URL: {database_url}")
|
||||||
|
|
||||||
is_postgresql = 'postgresql://' in database_url or 'postgres://' in database_url
|
is_postgresql = 'postgresql://' in database_url or 'postgres://' in database_url
|
||||||
print(f"DEBUG: Is PostgreSQL: {is_postgresql}")
|
print(f"DEBUG: Is PostgreSQL: {is_postgresql}")
|
||||||
|
|
||||||
if is_postgresql:
|
if is_postgresql:
|
||||||
print("Using PostgreSQL - skipping SQLite migrations, ensuring tables exist...")
|
print("Using PostgreSQL - skipping SQLite migrations, ensuring tables exist...")
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
@@ -171,16 +171,16 @@ def inject_globals():
|
|||||||
active_announcements = []
|
active_announcements = []
|
||||||
if g.user:
|
if g.user:
|
||||||
active_announcements = Announcement.get_active_announcements_for_user(g.user)
|
active_announcements = Announcement.get_active_announcements_for_user(g.user)
|
||||||
|
|
||||||
# Get tracking script settings
|
# Get tracking script settings
|
||||||
tracking_script_enabled = False
|
tracking_script_enabled = False
|
||||||
tracking_script_code = ''
|
tracking_script_code = ''
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tracking_enabled_setting = SystemSettings.query.filter_by(key='tracking_script_enabled').first()
|
tracking_enabled_setting = SystemSettings.query.filter_by(key='tracking_script_enabled').first()
|
||||||
if tracking_enabled_setting:
|
if tracking_enabled_setting:
|
||||||
tracking_script_enabled = tracking_enabled_setting.value == 'true'
|
tracking_script_enabled = tracking_enabled_setting.value == 'true'
|
||||||
|
|
||||||
tracking_code_setting = SystemSettings.query.filter_by(key='tracking_script_code').first()
|
tracking_code_setting = SystemSettings.query.filter_by(key='tracking_script_code').first()
|
||||||
if tracking_code_setting:
|
if tracking_code_setting:
|
||||||
tracking_script_code = tracking_code_setting.value
|
tracking_script_code = tracking_code_setting.value
|
||||||
@@ -505,7 +505,7 @@ def login():
|
|||||||
ip_address=request.remote_addr,
|
ip_address=request.remote_addr,
|
||||||
user_agent=request.headers.get('User-Agent')
|
user_agent=request.headers.get('User-Agent')
|
||||||
)
|
)
|
||||||
|
|
||||||
flash('Invalid username or password', 'error')
|
flash('Invalid username or password', 'error')
|
||||||
|
|
||||||
return render_template('login.html', title='Login')
|
return render_template('login.html', title='Login')
|
||||||
@@ -526,7 +526,7 @@ def logout():
|
|||||||
ip_address=request.remote_addr,
|
ip_address=request.remote_addr,
|
||||||
user_agent=request.headers.get('User-Agent')
|
user_agent=request.headers.get('User-Agent')
|
||||||
)
|
)
|
||||||
|
|
||||||
session.clear()
|
session.clear()
|
||||||
flash('You have been logged out.', 'info')
|
flash('You have been logged out.', 'info')
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
@@ -1258,7 +1258,7 @@ def verify_2fa():
|
|||||||
ip_address=request.remote_addr,
|
ip_address=request.remote_addr,
|
||||||
user_agent=request.headers.get('User-Agent')
|
user_agent=request.headers.get('User-Agent')
|
||||||
)
|
)
|
||||||
|
|
||||||
flash('Invalid verification code. Please try again.', 'error')
|
flash('Invalid verification code. Please try again.', 'error')
|
||||||
|
|
||||||
return render_template('verify_2fa.html', title='Two-Factor Authentication')
|
return render_template('verify_2fa.html', title='Two-Factor Authentication')
|
||||||
@@ -1448,7 +1448,7 @@ def delete_entry(entry_id):
|
|||||||
def update_entry(entry_id):
|
def update_entry(entry_id):
|
||||||
entry = TimeEntry.query.filter_by(id=entry_id, user_id=session['user_id']).first_or_404()
|
entry = TimeEntry.query.filter_by(id=entry_id, user_id=session['user_id']).first_or_404()
|
||||||
data = request.json
|
data = request.json
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({'success': False, 'message': 'No JSON data provided'}), 400
|
return jsonify({'success': False, 'message': 'No JSON data provided'}), 400
|
||||||
|
|
||||||
@@ -1465,7 +1465,7 @@ def update_entry(entry_id):
|
|||||||
# Accept only ISO 8601 format
|
# Accept only ISO 8601 format
|
||||||
departure_time_str = data['departure_time']
|
departure_time_str = data['departure_time']
|
||||||
entry.departure_time = datetime.fromisoformat(departure_time_str.replace('Z', '+00:00'))
|
entry.departure_time = datetime.fromisoformat(departure_time_str.replace('Z', '+00:00'))
|
||||||
|
|
||||||
# Recalculate duration if both times are present
|
# Recalculate duration if both times are present
|
||||||
if entry.arrival_time and entry.departure_time:
|
if entry.arrival_time and entry.departure_time:
|
||||||
# Calculate work duration considering breaks
|
# Calculate work duration considering breaks
|
||||||
@@ -2117,23 +2117,23 @@ def system_admin_settings():
|
|||||||
total_system_admins=total_system_admins)
|
total_system_admins=total_system_admins)
|
||||||
|
|
||||||
@app.route('/system-admin/health')
|
@app.route('/system-admin/health')
|
||||||
@system_admin_required
|
@system_admin_required
|
||||||
def system_admin_health():
|
def system_admin_health():
|
||||||
"""System Admin: System health check and event log"""
|
"""System Admin: System health check and event log"""
|
||||||
# Get system health summary
|
# Get system health summary
|
||||||
health_summary = SystemEvent.get_system_health_summary()
|
health_summary = SystemEvent.get_system_health_summary()
|
||||||
|
|
||||||
# Get recent events (last 7 days)
|
# Get recent events (last 7 days)
|
||||||
recent_events = SystemEvent.get_recent_events(days=7, limit=100)
|
recent_events = SystemEvent.get_recent_events(days=7, limit=100)
|
||||||
|
|
||||||
# Get events by severity for quick stats
|
# Get events by severity for quick stats
|
||||||
errors = SystemEvent.get_events_by_severity('error', days=7, limit=20)
|
errors = SystemEvent.get_events_by_severity('error', days=7, limit=20)
|
||||||
warnings = SystemEvent.get_events_by_severity('warning', days=7, limit=20)
|
warnings = SystemEvent.get_events_by_severity('warning', days=7, limit=20)
|
||||||
|
|
||||||
# System metrics
|
# System metrics
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
|
||||||
# Database connection test
|
# Database connection test
|
||||||
db_healthy = True
|
db_healthy = True
|
||||||
db_error = None
|
db_error = None
|
||||||
@@ -2148,18 +2148,18 @@ def system_admin_health():
|
|||||||
'system',
|
'system',
|
||||||
'error'
|
'error'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Application uptime (approximate based on first event)
|
# Application uptime (approximate based on first event)
|
||||||
first_event = SystemEvent.query.order_by(SystemEvent.timestamp.asc()).first()
|
first_event = SystemEvent.query.order_by(SystemEvent.timestamp.asc()).first()
|
||||||
uptime_start = first_event.timestamp if first_event else now
|
uptime_start = first_event.timestamp if first_event else now
|
||||||
uptime_duration = now - uptime_start
|
uptime_duration = now - uptime_start
|
||||||
|
|
||||||
# Recent activity stats
|
# Recent activity stats
|
||||||
today = now.date()
|
today = now.date()
|
||||||
today_events = SystemEvent.query.filter(
|
today_events = SystemEvent.query.filter(
|
||||||
func.date(SystemEvent.timestamp) == today
|
func.date(SystemEvent.timestamp) == today
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
# Log the health check
|
# Log the health check
|
||||||
SystemEvent.log_event(
|
SystemEvent.log_event(
|
||||||
'system_health_check',
|
'system_health_check',
|
||||||
@@ -2170,7 +2170,7 @@ def system_admin_health():
|
|||||||
ip_address=request.remote_addr,
|
ip_address=request.remote_addr,
|
||||||
user_agent=request.headers.get('User-Agent')
|
user_agent=request.headers.get('User-Agent')
|
||||||
)
|
)
|
||||||
|
|
||||||
return render_template('system_admin_health.html',
|
return render_template('system_admin_health.html',
|
||||||
title='System Health Check',
|
title='System Health Check',
|
||||||
health_summary=health_summary,
|
health_summary=health_summary,
|
||||||
@@ -2188,10 +2188,10 @@ def system_admin_announcements():
|
|||||||
"""System Admin: Manage announcements"""
|
"""System Admin: Manage announcements"""
|
||||||
page = request.args.get('page', 1, type=int)
|
page = request.args.get('page', 1, type=int)
|
||||||
per_page = 20
|
per_page = 20
|
||||||
|
|
||||||
announcements = Announcement.query.order_by(Announcement.created_at.desc()).paginate(
|
announcements = Announcement.query.order_by(Announcement.created_at.desc()).paginate(
|
||||||
page=page, per_page=per_page, error_out=False)
|
page=page, per_page=per_page, error_out=False)
|
||||||
|
|
||||||
return render_template('system_admin_announcements.html',
|
return render_template('system_admin_announcements.html',
|
||||||
title='System Admin - Announcements',
|
title='System Admin - Announcements',
|
||||||
announcements=announcements)
|
announcements=announcements)
|
||||||
@@ -2206,43 +2206,43 @@ def system_admin_announcement_new():
|
|||||||
announcement_type = request.form.get('announcement_type', 'info')
|
announcement_type = request.form.get('announcement_type', 'info')
|
||||||
is_urgent = request.form.get('is_urgent') == 'on'
|
is_urgent = request.form.get('is_urgent') == 'on'
|
||||||
is_active = request.form.get('is_active') == 'on'
|
is_active = request.form.get('is_active') == 'on'
|
||||||
|
|
||||||
# Handle date fields
|
# Handle date fields
|
||||||
start_date = request.form.get('start_date')
|
start_date = request.form.get('start_date')
|
||||||
end_date = request.form.get('end_date')
|
end_date = request.form.get('end_date')
|
||||||
|
|
||||||
start_datetime = None
|
start_datetime = None
|
||||||
end_datetime = None
|
end_datetime = None
|
||||||
|
|
||||||
if start_date:
|
if start_date:
|
||||||
try:
|
try:
|
||||||
start_datetime = datetime.strptime(start_date, '%Y-%m-%dT%H:%M')
|
start_datetime = datetime.strptime(start_date, '%Y-%m-%dT%H:%M')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if end_date:
|
if end_date:
|
||||||
try:
|
try:
|
||||||
end_datetime = datetime.strptime(end_date, '%Y-%m-%dT%H:%M')
|
end_datetime = datetime.strptime(end_date, '%Y-%m-%dT%H:%M')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Handle targeting
|
# Handle targeting
|
||||||
target_all_users = request.form.get('target_all_users') == 'on'
|
target_all_users = request.form.get('target_all_users') == 'on'
|
||||||
target_roles = None
|
target_roles = None
|
||||||
target_companies = None
|
target_companies = None
|
||||||
|
|
||||||
if not target_all_users:
|
if not target_all_users:
|
||||||
selected_roles = request.form.getlist('target_roles')
|
selected_roles = request.form.getlist('target_roles')
|
||||||
selected_companies = request.form.getlist('target_companies')
|
selected_companies = request.form.getlist('target_companies')
|
||||||
|
|
||||||
if selected_roles:
|
if selected_roles:
|
||||||
import json
|
import json
|
||||||
target_roles = json.dumps(selected_roles)
|
target_roles = json.dumps(selected_roles)
|
||||||
|
|
||||||
if selected_companies:
|
if selected_companies:
|
||||||
import json
|
import json
|
||||||
target_companies = json.dumps([int(c) for c in selected_companies])
|
target_companies = json.dumps([int(c) for c in selected_companies])
|
||||||
|
|
||||||
announcement = Announcement(
|
announcement = Announcement(
|
||||||
title=title,
|
title=title,
|
||||||
content=content,
|
content=content,
|
||||||
@@ -2256,17 +2256,17 @@ def system_admin_announcement_new():
|
|||||||
target_companies=target_companies,
|
target_companies=target_companies,
|
||||||
created_by_id=g.user.id
|
created_by_id=g.user.id
|
||||||
)
|
)
|
||||||
|
|
||||||
db.session.add(announcement)
|
db.session.add(announcement)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
flash('Announcement created successfully.', 'success')
|
flash('Announcement created successfully.', 'success')
|
||||||
return redirect(url_for('system_admin_announcements'))
|
return redirect(url_for('system_admin_announcements'))
|
||||||
|
|
||||||
# Get roles and companies for targeting options
|
# Get roles and companies for targeting options
|
||||||
roles = [role.value for role in Role]
|
roles = [role.value for role in Role]
|
||||||
companies = Company.query.order_by(Company.name).all()
|
companies = Company.query.order_by(Company.name).all()
|
||||||
|
|
||||||
return render_template('system_admin_announcement_form.html',
|
return render_template('system_admin_announcement_form.html',
|
||||||
title='Create Announcement',
|
title='Create Announcement',
|
||||||
announcement=None,
|
announcement=None,
|
||||||
@@ -2278,18 +2278,18 @@ def system_admin_announcement_new():
|
|||||||
def system_admin_announcement_edit(id):
|
def system_admin_announcement_edit(id):
|
||||||
"""System Admin: Edit announcement"""
|
"""System Admin: Edit announcement"""
|
||||||
announcement = Announcement.query.get_or_404(id)
|
announcement = Announcement.query.get_or_404(id)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
announcement.title = request.form.get('title')
|
announcement.title = request.form.get('title')
|
||||||
announcement.content = request.form.get('content')
|
announcement.content = request.form.get('content')
|
||||||
announcement.announcement_type = request.form.get('announcement_type', 'info')
|
announcement.announcement_type = request.form.get('announcement_type', 'info')
|
||||||
announcement.is_urgent = request.form.get('is_urgent') == 'on'
|
announcement.is_urgent = request.form.get('is_urgent') == 'on'
|
||||||
announcement.is_active = request.form.get('is_active') == 'on'
|
announcement.is_active = request.form.get('is_active') == 'on'
|
||||||
|
|
||||||
# Handle date fields
|
# Handle date fields
|
||||||
start_date = request.form.get('start_date')
|
start_date = request.form.get('start_date')
|
||||||
end_date = request.form.get('end_date')
|
end_date = request.form.get('end_date')
|
||||||
|
|
||||||
if start_date:
|
if start_date:
|
||||||
try:
|
try:
|
||||||
announcement.start_date = datetime.strptime(start_date, '%Y-%m-%dT%H:%M')
|
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
|
announcement.start_date = None
|
||||||
else:
|
else:
|
||||||
announcement.start_date = None
|
announcement.start_date = None
|
||||||
|
|
||||||
if end_date:
|
if end_date:
|
||||||
try:
|
try:
|
||||||
announcement.end_date = datetime.strptime(end_date, '%Y-%m-%dT%H:%M')
|
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
|
announcement.end_date = None
|
||||||
else:
|
else:
|
||||||
announcement.end_date = None
|
announcement.end_date = None
|
||||||
|
|
||||||
# Handle targeting
|
# Handle targeting
|
||||||
announcement.target_all_users = request.form.get('target_all_users') == 'on'
|
announcement.target_all_users = request.form.get('target_all_users') == 'on'
|
||||||
|
|
||||||
if not announcement.target_all_users:
|
if not announcement.target_all_users:
|
||||||
selected_roles = request.form.getlist('target_roles')
|
selected_roles = request.form.getlist('target_roles')
|
||||||
selected_companies = request.form.getlist('target_companies')
|
selected_companies = request.form.getlist('target_companies')
|
||||||
|
|
||||||
if selected_roles:
|
if selected_roles:
|
||||||
import json
|
import json
|
||||||
announcement.target_roles = json.dumps(selected_roles)
|
announcement.target_roles = json.dumps(selected_roles)
|
||||||
else:
|
else:
|
||||||
announcement.target_roles = None
|
announcement.target_roles = None
|
||||||
|
|
||||||
if selected_companies:
|
if selected_companies:
|
||||||
import json
|
import json
|
||||||
announcement.target_companies = json.dumps([int(c) for c in selected_companies])
|
announcement.target_companies = json.dumps([int(c) for c in selected_companies])
|
||||||
@@ -2327,18 +2327,18 @@ def system_admin_announcement_edit(id):
|
|||||||
else:
|
else:
|
||||||
announcement.target_roles = None
|
announcement.target_roles = None
|
||||||
announcement.target_companies = None
|
announcement.target_companies = None
|
||||||
|
|
||||||
announcement.updated_at = datetime.now()
|
announcement.updated_at = datetime.now()
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
flash('Announcement updated successfully.', 'success')
|
flash('Announcement updated successfully.', 'success')
|
||||||
return redirect(url_for('system_admin_announcements'))
|
return redirect(url_for('system_admin_announcements'))
|
||||||
|
|
||||||
# Get roles and companies for targeting options
|
# Get roles and companies for targeting options
|
||||||
roles = [role.value for role in Role]
|
roles = [role.value for role in Role]
|
||||||
companies = Company.query.order_by(Company.name).all()
|
companies = Company.query.order_by(Company.name).all()
|
||||||
|
|
||||||
return render_template('system_admin_announcement_form.html',
|
return render_template('system_admin_announcement_form.html',
|
||||||
title='Edit Announcement',
|
title='Edit Announcement',
|
||||||
announcement=announcement,
|
announcement=announcement,
|
||||||
@@ -2350,10 +2350,10 @@ def system_admin_announcement_edit(id):
|
|||||||
def system_admin_announcement_delete(id):
|
def system_admin_announcement_delete(id):
|
||||||
"""System Admin: Delete announcement"""
|
"""System Admin: Delete announcement"""
|
||||||
announcement = Announcement.query.get_or_404(id)
|
announcement = Announcement.query.get_or_404(id)
|
||||||
|
|
||||||
db.session.delete(announcement)
|
db.session.delete(announcement)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
flash('Announcement deleted successfully.', 'success')
|
flash('Announcement deleted successfully.', 'success')
|
||||||
return redirect(url_for('system_admin_announcements'))
|
return redirect(url_for('system_admin_announcements'))
|
||||||
|
|
||||||
@@ -3392,6 +3392,74 @@ def manage_project_tasks(project_id):
|
|||||||
tasks=tasks,
|
tasks=tasks,
|
||||||
team_members=team_members)
|
team_members=team_members)
|
||||||
|
|
||||||
|
@app.route('/admin/projects/<int:project_id>/kanban')
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def project_kanban(project_id):
|
||||||
|
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first_or_404()
|
||||||
|
|
||||||
|
# Check if user has access to this project
|
||||||
|
if not project.is_user_allowed(g.user):
|
||||||
|
flash('You do not have access to this project.', 'error')
|
||||||
|
return redirect(url_for('admin_projects'))
|
||||||
|
|
||||||
|
# Get all Kanban boards for this project
|
||||||
|
boards = KanbanBoard.query.filter_by(project_id=project_id, is_active=True).order_by(KanbanBoard.created_at.desc()).all()
|
||||||
|
|
||||||
|
# Get team members for assignment dropdown
|
||||||
|
if project.team_id:
|
||||||
|
team_members = User.query.filter_by(team_id=project.team_id, company_id=g.user.company_id).all()
|
||||||
|
else:
|
||||||
|
team_members = User.query.filter_by(company_id=g.user.company_id).all()
|
||||||
|
|
||||||
|
# Get tasks for task assignment dropdown
|
||||||
|
tasks = Task.query.filter_by(project_id=project_id).order_by(Task.name).all()
|
||||||
|
|
||||||
|
return render_template('project_kanban.html',
|
||||||
|
title=f'Kanban - {project.name}',
|
||||||
|
project=project,
|
||||||
|
boards=boards,
|
||||||
|
team_members=team_members,
|
||||||
|
tasks=tasks)
|
||||||
|
|
||||||
|
@app.route('/kanban')
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def kanban_overview():
|
||||||
|
# Get all projects the user has access to
|
||||||
|
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
|
||||||
|
# Admins and Supervisors can see all company projects
|
||||||
|
projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).order_by(Project.name).all()
|
||||||
|
elif g.user.team_id:
|
||||||
|
# Team members see team projects + unassigned projects
|
||||||
|
projects = Project.query.filter(
|
||||||
|
Project.company_id == g.user.company_id,
|
||||||
|
Project.is_active == True,
|
||||||
|
db.or_(Project.team_id == g.user.team_id, Project.team_id == None)
|
||||||
|
).order_by(Project.name).all()
|
||||||
|
else:
|
||||||
|
# Unassigned users see only unassigned projects
|
||||||
|
projects = Project.query.filter_by(
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
team_id=None,
|
||||||
|
is_active=True
|
||||||
|
).order_by(Project.name).all()
|
||||||
|
|
||||||
|
# Get Kanban boards for each project
|
||||||
|
project_boards = {}
|
||||||
|
for project in projects:
|
||||||
|
boards = KanbanBoard.query.filter_by(
|
||||||
|
project_id=project.id,
|
||||||
|
is_active=True
|
||||||
|
).order_by(KanbanBoard.created_at.desc()).all()
|
||||||
|
|
||||||
|
if boards: # Only include projects that have Kanban boards
|
||||||
|
project_boards[project] = boards
|
||||||
|
|
||||||
|
return render_template('kanban_overview.html',
|
||||||
|
title='Kanban Overview',
|
||||||
|
project_boards=project_boards)
|
||||||
|
|
||||||
# Task API Routes
|
# Task API Routes
|
||||||
@app.route('/api/tasks', methods=['POST'])
|
@app.route('/api/tasks', methods=['POST'])
|
||||||
@role_required(Role.TEAM_MEMBER)
|
@role_required(Role.TEAM_MEMBER)
|
||||||
@@ -3801,6 +3869,604 @@ def delete_category(category_id):
|
|||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return jsonify({'success': False, 'message': str(e)})
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
# Kanban API Routes
|
||||||
|
@app.route('/api/kanban/stats')
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def get_kanban_stats():
|
||||||
|
try:
|
||||||
|
# Get all projects the user has access to
|
||||||
|
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
|
||||||
|
projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all()
|
||||||
|
elif g.user.team_id:
|
||||||
|
projects = Project.query.filter(
|
||||||
|
Project.company_id == g.user.company_id,
|
||||||
|
Project.is_active == True,
|
||||||
|
db.or_(Project.team_id == g.user.team_id, Project.team_id == None)
|
||||||
|
).all()
|
||||||
|
else:
|
||||||
|
projects = Project.query.filter_by(
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
team_id=None,
|
||||||
|
is_active=True
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Count boards and cards
|
||||||
|
total_boards = 0
|
||||||
|
total_cards = 0
|
||||||
|
projects_with_boards = 0
|
||||||
|
|
||||||
|
for project in projects:
|
||||||
|
boards = KanbanBoard.query.filter_by(project_id=project.id, is_active=True).all()
|
||||||
|
if boards:
|
||||||
|
projects_with_boards += 1
|
||||||
|
total_boards += len(boards)
|
||||||
|
for board in boards:
|
||||||
|
for column in board.columns:
|
||||||
|
total_cards += len([card for card in column.cards if card.is_active])
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'stats': {
|
||||||
|
'projects_with_boards': projects_with_boards,
|
||||||
|
'total_boards': total_boards,
|
||||||
|
'total_cards': total_cards
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/boards', methods=['GET'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def get_kanban_boards():
|
||||||
|
try:
|
||||||
|
project_id = request.args.get('project_id')
|
||||||
|
|
||||||
|
# Verify project access
|
||||||
|
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first()
|
||||||
|
if not project or not project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Project not found or access denied'})
|
||||||
|
|
||||||
|
boards = KanbanBoard.query.filter_by(project_id=project_id, is_active=True).all()
|
||||||
|
|
||||||
|
boards_data = []
|
||||||
|
for board in boards:
|
||||||
|
boards_data.append({
|
||||||
|
'id': board.id,
|
||||||
|
'name': board.name,
|
||||||
|
'description': board.description,
|
||||||
|
'is_default': board.is_default,
|
||||||
|
'created_at': board.created_at.isoformat(),
|
||||||
|
'column_count': len(board.columns)
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'boards': boards_data})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/boards', methods=['POST'])
|
||||||
|
@role_required(Role.TEAM_LEADER)
|
||||||
|
@company_required
|
||||||
|
def create_kanban_board():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
project_id = int(data.get('project_id'))
|
||||||
|
|
||||||
|
# Verify project access
|
||||||
|
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first()
|
||||||
|
if not project or not project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Project not found or access denied'})
|
||||||
|
|
||||||
|
name = data.get('name')
|
||||||
|
if not name:
|
||||||
|
return jsonify({'success': False, 'message': 'Board name is required'})
|
||||||
|
|
||||||
|
# Check if board name already exists in project
|
||||||
|
existing = KanbanBoard.query.filter_by(project_id=project_id, name=name).first()
|
||||||
|
if existing:
|
||||||
|
return jsonify({'success': False, 'message': 'Board name already exists in this project'})
|
||||||
|
|
||||||
|
# Create board
|
||||||
|
board = KanbanBoard(
|
||||||
|
name=name,
|
||||||
|
description=data.get('description', ''),
|
||||||
|
project_id=project_id,
|
||||||
|
is_default=data.get('is_default') in ['true', 'on', True],
|
||||||
|
created_by_id=g.user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(board)
|
||||||
|
db.session.flush() # Get board ID for columns
|
||||||
|
|
||||||
|
# Create default columns
|
||||||
|
default_columns = [
|
||||||
|
{'name': 'To Do', 'position': 1, 'color': '#6c757d'},
|
||||||
|
{'name': 'In Progress', 'position': 2, 'color': '#007bff'},
|
||||||
|
{'name': 'Done', 'position': 3, 'color': '#28a745'}
|
||||||
|
]
|
||||||
|
|
||||||
|
for col_data in default_columns:
|
||||||
|
column = KanbanColumn(
|
||||||
|
name=col_data['name'],
|
||||||
|
position=col_data['position'],
|
||||||
|
color=col_data['color'],
|
||||||
|
board_id=board.id
|
||||||
|
)
|
||||||
|
db.session.add(column)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Board created successfully', 'board_id': board.id})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/boards/<int:board_id>', methods=['GET'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def get_kanban_board(board_id):
|
||||||
|
try:
|
||||||
|
board = KanbanBoard.query.join(Project).filter(
|
||||||
|
KanbanBoard.id == board_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not board or not board.project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Board not found or access denied'})
|
||||||
|
|
||||||
|
columns_data = []
|
||||||
|
for column in board.columns:
|
||||||
|
if not column.is_active:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cards_data = []
|
||||||
|
for card in column.cards:
|
||||||
|
if not card.is_active:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cards_data.append({
|
||||||
|
'id': card.id,
|
||||||
|
'title': card.title,
|
||||||
|
'description': card.description,
|
||||||
|
'position': card.position,
|
||||||
|
'color': card.color,
|
||||||
|
'assigned_to': {
|
||||||
|
'id': card.assigned_to.id,
|
||||||
|
'username': card.assigned_to.username
|
||||||
|
} if card.assigned_to else None,
|
||||||
|
'task_id': card.task_id,
|
||||||
|
'task_name': card.task.name if card.task else None,
|
||||||
|
'due_date': card.due_date.isoformat() if card.due_date else None,
|
||||||
|
'created_at': card.created_at.isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
columns_data.append({
|
||||||
|
'id': column.id,
|
||||||
|
'name': column.name,
|
||||||
|
'description': column.description,
|
||||||
|
'position': column.position,
|
||||||
|
'color': column.color,
|
||||||
|
'wip_limit': column.wip_limit,
|
||||||
|
'card_count': column.card_count,
|
||||||
|
'is_over_wip_limit': column.is_over_wip_limit,
|
||||||
|
'cards': cards_data
|
||||||
|
})
|
||||||
|
|
||||||
|
board_data = {
|
||||||
|
'id': board.id,
|
||||||
|
'name': board.name,
|
||||||
|
'description': board.description,
|
||||||
|
'project': {
|
||||||
|
'id': board.project.id,
|
||||||
|
'name': board.project.name,
|
||||||
|
'code': board.project.code
|
||||||
|
},
|
||||||
|
'columns': columns_data
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'board': board_data})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/cards', methods=['POST'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def create_kanban_card():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
column_id = data.get('column_id')
|
||||||
|
|
||||||
|
# Verify column access
|
||||||
|
column = KanbanColumn.query.join(KanbanBoard).join(Project).filter(
|
||||||
|
KanbanColumn.id == column_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not column or not column.board.project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Column not found or access denied'})
|
||||||
|
|
||||||
|
title = data.get('title')
|
||||||
|
if not title:
|
||||||
|
return jsonify({'success': False, 'message': 'Card title is required'})
|
||||||
|
|
||||||
|
# Calculate position (add to end of column)
|
||||||
|
max_position = db.session.query(func.max(KanbanCard.position)).filter_by(
|
||||||
|
column_id=column_id, is_active=True
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# Parse due date
|
||||||
|
due_date = None
|
||||||
|
if data.get('due_date'):
|
||||||
|
due_date = datetime.strptime(data.get('due_date'), '%Y-%m-%d').date()
|
||||||
|
|
||||||
|
# Create card
|
||||||
|
card = KanbanCard(
|
||||||
|
title=title,
|
||||||
|
description=data.get('description', ''),
|
||||||
|
position=max_position + 1,
|
||||||
|
color=data.get('color'),
|
||||||
|
column_id=column_id,
|
||||||
|
task_id=int(data.get('task_id')) if data.get('task_id') else None,
|
||||||
|
assigned_to_id=int(data.get('assigned_to_id')) if data.get('assigned_to_id') else None,
|
||||||
|
due_date=due_date,
|
||||||
|
created_by_id=g.user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(card)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Card created successfully', 'card_id': card.id})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/cards/<int:card_id>/move', methods=['PUT'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def move_kanban_card(card_id):
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
new_column_id = data.get('column_id')
|
||||||
|
new_position = data.get('position')
|
||||||
|
|
||||||
|
# Verify card access
|
||||||
|
card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).join(Project).filter(
|
||||||
|
KanbanCard.id == card_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not card or not card.can_user_access(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Card not found or access denied'})
|
||||||
|
|
||||||
|
# Verify new column access
|
||||||
|
new_column = KanbanColumn.query.join(KanbanBoard).join(Project).filter(
|
||||||
|
KanbanColumn.id == new_column_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not new_column or not new_column.board.project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Target column not found or access denied'})
|
||||||
|
|
||||||
|
old_column_id = card.column_id
|
||||||
|
old_position = card.position
|
||||||
|
|
||||||
|
# Update positions in old column (if moving to different column)
|
||||||
|
if old_column_id != new_column_id:
|
||||||
|
# Shift cards down in old column
|
||||||
|
cards_to_shift = KanbanCard.query.filter(
|
||||||
|
KanbanCard.column_id == old_column_id,
|
||||||
|
KanbanCard.position > old_position,
|
||||||
|
KanbanCard.is_active == True
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for c in cards_to_shift:
|
||||||
|
c.position -= 1
|
||||||
|
|
||||||
|
# Update positions in new column
|
||||||
|
cards_to_shift = KanbanCard.query.filter(
|
||||||
|
KanbanCard.column_id == new_column_id,
|
||||||
|
KanbanCard.position >= new_position,
|
||||||
|
KanbanCard.is_active == True,
|
||||||
|
KanbanCard.id != card_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for c in cards_to_shift:
|
||||||
|
c.position += 1
|
||||||
|
|
||||||
|
# Update card
|
||||||
|
card.column_id = new_column_id
|
||||||
|
card.position = new_position
|
||||||
|
card.updated_at = datetime.now()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Card moved successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/cards/<int:card_id>', methods=['PUT'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def update_kanban_card(card_id):
|
||||||
|
try:
|
||||||
|
card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).join(Project).filter(
|
||||||
|
KanbanCard.id == card_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not card or not card.can_user_access(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Card not found or access denied'})
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
# Update card fields
|
||||||
|
if 'title' in data:
|
||||||
|
card.title = data['title']
|
||||||
|
if 'description' in data:
|
||||||
|
card.description = data['description']
|
||||||
|
if 'color' in data:
|
||||||
|
card.color = data['color']
|
||||||
|
if 'assigned_to_id' in data:
|
||||||
|
card.assigned_to_id = int(data['assigned_to_id']) if data['assigned_to_id'] else None
|
||||||
|
if 'task_id' in data:
|
||||||
|
card.task_id = int(data['task_id']) if data['task_id'] else None
|
||||||
|
if 'due_date' in data:
|
||||||
|
card.due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date() if data['due_date'] else None
|
||||||
|
|
||||||
|
card.updated_at = datetime.now()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Card updated successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/cards/<int:card_id>', methods=['DELETE'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def delete_kanban_card(card_id):
|
||||||
|
try:
|
||||||
|
card = KanbanCard.query.join(KanbanColumn).join(KanbanBoard).join(Project).filter(
|
||||||
|
KanbanCard.id == card_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not card or not card.can_user_access(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Card not found or access denied'})
|
||||||
|
|
||||||
|
column_id = card.column_id
|
||||||
|
position = card.position
|
||||||
|
|
||||||
|
# Soft delete
|
||||||
|
card.is_active = False
|
||||||
|
card.updated_at = datetime.now()
|
||||||
|
|
||||||
|
# Shift remaining cards up
|
||||||
|
cards_to_shift = KanbanCard.query.filter(
|
||||||
|
KanbanCard.column_id == column_id,
|
||||||
|
KanbanCard.position > position,
|
||||||
|
KanbanCard.is_active == True
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for c in cards_to_shift:
|
||||||
|
c.position -= 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Card deleted successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
# Kanban Column Management API
|
||||||
|
@app.route('/api/kanban/columns', methods=['POST'])
|
||||||
|
@role_required(Role.TEAM_LEADER)
|
||||||
|
@company_required
|
||||||
|
def create_kanban_column():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
board_id = int(data.get('board_id'))
|
||||||
|
|
||||||
|
# Verify board access
|
||||||
|
board = KanbanBoard.query.join(Project).filter(
|
||||||
|
KanbanBoard.id == board_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not board or not board.project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Board not found or access denied'})
|
||||||
|
|
||||||
|
name = data.get('name')
|
||||||
|
if not name:
|
||||||
|
return jsonify({'success': False, 'message': 'Column name is required'})
|
||||||
|
|
||||||
|
# Check if column name already exists in board
|
||||||
|
existing = KanbanColumn.query.filter_by(board_id=board_id, name=name).first()
|
||||||
|
if existing:
|
||||||
|
return jsonify({'success': False, 'message': 'Column name already exists in this board'})
|
||||||
|
|
||||||
|
# Calculate position (add to end)
|
||||||
|
max_position = db.session.query(func.max(KanbanColumn.position)).filter_by(
|
||||||
|
board_id=board_id, is_active=True
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# Create column
|
||||||
|
column = KanbanColumn(
|
||||||
|
name=name,
|
||||||
|
description=data.get('description', ''),
|
||||||
|
position=max_position + 1,
|
||||||
|
color=data.get('color', '#6c757d'),
|
||||||
|
wip_limit=int(data.get('wip_limit')) if data.get('wip_limit') else None,
|
||||||
|
board_id=board_id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(column)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Column created successfully', 'column_id': column.id})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/columns/<int:column_id>', methods=['PUT'])
|
||||||
|
@role_required(Role.TEAM_LEADER)
|
||||||
|
@company_required
|
||||||
|
def update_kanban_column(column_id):
|
||||||
|
try:
|
||||||
|
column = KanbanColumn.query.join(KanbanBoard).join(Project).filter(
|
||||||
|
KanbanColumn.id == column_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not column or not column.board.project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Column not found or access denied'})
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
# Check for name conflicts (excluding current column)
|
||||||
|
if 'name' in data:
|
||||||
|
existing = KanbanColumn.query.filter(
|
||||||
|
KanbanColumn.board_id == column.board_id,
|
||||||
|
KanbanColumn.name == data['name'],
|
||||||
|
KanbanColumn.id != column_id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
return jsonify({'success': False, 'message': 'Column name already exists in this board'})
|
||||||
|
|
||||||
|
# Update column fields
|
||||||
|
if 'name' in data:
|
||||||
|
column.name = data['name']
|
||||||
|
if 'description' in data:
|
||||||
|
column.description = data['description']
|
||||||
|
if 'color' in data:
|
||||||
|
column.color = data['color']
|
||||||
|
if 'wip_limit' in data:
|
||||||
|
column.wip_limit = int(data['wip_limit']) if data['wip_limit'] else None
|
||||||
|
|
||||||
|
column.updated_at = datetime.now()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Column updated successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/columns/<int:column_id>/move', methods=['PUT'])
|
||||||
|
@role_required(Role.TEAM_LEADER)
|
||||||
|
@company_required
|
||||||
|
def move_kanban_column(column_id):
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
new_position = int(data.get('position'))
|
||||||
|
|
||||||
|
# Verify column access
|
||||||
|
column = KanbanColumn.query.join(KanbanBoard).join(Project).filter(
|
||||||
|
KanbanColumn.id == column_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not column or not column.board.project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Column not found or access denied'})
|
||||||
|
|
||||||
|
old_position = column.position
|
||||||
|
board_id = column.board_id
|
||||||
|
|
||||||
|
if old_position == new_position:
|
||||||
|
return jsonify({'success': True, 'message': 'Column position unchanged'})
|
||||||
|
|
||||||
|
# Update positions of other columns
|
||||||
|
if old_position < new_position:
|
||||||
|
# Moving right: shift columns left
|
||||||
|
columns_to_shift = KanbanColumn.query.filter(
|
||||||
|
KanbanColumn.board_id == board_id,
|
||||||
|
KanbanColumn.position > old_position,
|
||||||
|
KanbanColumn.position <= new_position,
|
||||||
|
KanbanColumn.is_active == True,
|
||||||
|
KanbanColumn.id != column_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for c in columns_to_shift:
|
||||||
|
c.position -= 1
|
||||||
|
else:
|
||||||
|
# Moving left: shift columns right
|
||||||
|
columns_to_shift = KanbanColumn.query.filter(
|
||||||
|
KanbanColumn.board_id == board_id,
|
||||||
|
KanbanColumn.position >= new_position,
|
||||||
|
KanbanColumn.position < old_position,
|
||||||
|
KanbanColumn.is_active == True,
|
||||||
|
KanbanColumn.id != column_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for c in columns_to_shift:
|
||||||
|
c.position += 1
|
||||||
|
|
||||||
|
# Update the moved column
|
||||||
|
column.position = new_position
|
||||||
|
column.updated_at = datetime.now()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Column moved successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/kanban/columns/<int:column_id>', methods=['DELETE'])
|
||||||
|
@role_required(Role.TEAM_LEADER)
|
||||||
|
@company_required
|
||||||
|
def delete_kanban_column(column_id):
|
||||||
|
try:
|
||||||
|
column = KanbanColumn.query.join(KanbanBoard).join(Project).filter(
|
||||||
|
KanbanColumn.id == column_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not column or not column.board.project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Column not found or access denied'})
|
||||||
|
|
||||||
|
# Check if column has active cards
|
||||||
|
active_cards = KanbanCard.query.filter_by(column_id=column_id, is_active=True).count()
|
||||||
|
if active_cards > 0:
|
||||||
|
return jsonify({'success': False, 'message': f'Cannot delete column with {active_cards} active cards. Move or delete cards first.'})
|
||||||
|
|
||||||
|
board_id = column.board_id
|
||||||
|
position = column.position
|
||||||
|
|
||||||
|
# Soft delete the column
|
||||||
|
column.is_active = False
|
||||||
|
column.updated_at = datetime.now()
|
||||||
|
|
||||||
|
# Shift remaining columns left
|
||||||
|
columns_to_shift = KanbanColumn.query.filter(
|
||||||
|
KanbanColumn.board_id == board_id,
|
||||||
|
KanbanColumn.position > position,
|
||||||
|
KanbanColumn.is_active == True
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for c in columns_to_shift:
|
||||||
|
c.position -= 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Column deleted successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
port = int(os.environ.get('PORT', 5000))
|
port = int(os.environ.get('PORT', 5000))
|
||||||
app.run(debug=False, host='0.0.0.0', port=port)
|
app.run(debug=False, host='0.0.0.0', port=port)
|
||||||
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 to import from Flask app context if available
|
||||||
try:
|
try:
|
||||||
from app import app, db
|
from app import app, db
|
||||||
from models import (User, TimeEntry, WorkConfig, SystemSettings, Team, Role, Project,
|
from models import (User, TimeEntry, WorkConfig, SystemSettings, Team, Role, Project,
|
||||||
Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType,
|
Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType,
|
||||||
ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent)
|
ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent, KanbanBoard,
|
||||||
|
KanbanColumn, KanbanCard)
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
FLASK_AVAILABLE = True
|
FLASK_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -23,14 +24,14 @@ except ImportError:
|
|||||||
FLASK_AVAILABLE = False
|
FLASK_AVAILABLE = False
|
||||||
# Define Role and AccountType enums for standalone mode
|
# Define Role and AccountType enums for standalone mode
|
||||||
import enum
|
import enum
|
||||||
|
|
||||||
class Role(enum.Enum):
|
class Role(enum.Enum):
|
||||||
TEAM_MEMBER = "Team Member"
|
TEAM_MEMBER = "Team Member"
|
||||||
TEAM_LEADER = "Team Leader"
|
TEAM_LEADER = "Team Leader"
|
||||||
SUPERVISOR = "Supervisor"
|
SUPERVISOR = "Supervisor"
|
||||||
ADMIN = "Administrator"
|
ADMIN = "Administrator"
|
||||||
SYSTEM_ADMIN = "System Administrator"
|
SYSTEM_ADMIN = "System Administrator"
|
||||||
|
|
||||||
class AccountType(enum.Enum):
|
class AccountType(enum.Enum):
|
||||||
COMPANY_USER = "Company User"
|
COMPANY_USER = "Company User"
|
||||||
FREELANCER = "Freelancer"
|
FREELANCER = "Freelancer"
|
||||||
@@ -40,11 +41,11 @@ def get_db_path(db_file=None):
|
|||||||
"""Determine database path based on environment or provided file."""
|
"""Determine database path based on environment or provided file."""
|
||||||
if db_file:
|
if db_file:
|
||||||
return db_file
|
return db_file
|
||||||
|
|
||||||
# Check for Docker environment
|
# Check for Docker environment
|
||||||
if os.path.exists('/data'):
|
if os.path.exists('/data'):
|
||||||
return '/data/timetrack.db'
|
return '/data/timetrack.db'
|
||||||
|
|
||||||
return 'timetrack.db'
|
return 'timetrack.db'
|
||||||
|
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ def run_all_migrations(db_path=None):
|
|||||||
"""Run all database migrations in sequence."""
|
"""Run all database migrations in sequence."""
|
||||||
db_path = get_db_path(db_path)
|
db_path = get_db_path(db_path)
|
||||||
print(f"Running migrations on database: {db_path}")
|
print(f"Running migrations on database: {db_path}")
|
||||||
|
|
||||||
# Check if database exists
|
# Check if database exists
|
||||||
if not os.path.exists(db_path):
|
if not os.path.exists(db_path):
|
||||||
print("Database doesn't exist. Creating new database.")
|
print("Database doesn't exist. Creating new database.")
|
||||||
@@ -63,21 +64,22 @@ def run_all_migrations(db_path=None):
|
|||||||
else:
|
else:
|
||||||
create_new_database(db_path)
|
create_new_database(db_path)
|
||||||
return
|
return
|
||||||
|
|
||||||
print("Running database migrations...")
|
print("Running database migrations...")
|
||||||
|
|
||||||
# Run migrations in sequence
|
# Run migrations in sequence
|
||||||
run_basic_migrations(db_path)
|
run_basic_migrations(db_path)
|
||||||
migrate_to_company_model(db_path)
|
migrate_to_company_model(db_path)
|
||||||
migrate_work_config_data(db_path)
|
migrate_work_config_data(db_path)
|
||||||
migrate_task_system(db_path)
|
migrate_task_system(db_path)
|
||||||
migrate_system_events(db_path)
|
migrate_system_events(db_path)
|
||||||
|
migrate_kanban_system(db_path)
|
||||||
|
|
||||||
if FLASK_AVAILABLE:
|
if FLASK_AVAILABLE:
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
# Handle company migration and admin user setup
|
# Handle company migration and admin user setup
|
||||||
migrate_data()
|
migrate_data()
|
||||||
|
|
||||||
print("Database migrations completed successfully!")
|
print("Database migrations completed successfully!")
|
||||||
|
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ def run_basic_migrations(db_path):
|
|||||||
"""Run basic table structure migrations."""
|
"""Run basic table structure migrations."""
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if time_entry table exists first
|
# Check if time_entry table exists first
|
||||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='time_entry'")
|
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:
|
else:
|
||||||
cursor.execute("PRAGMA table_info(work_config)")
|
cursor.execute("PRAGMA table_info(work_config)")
|
||||||
work_config_columns = [column[1] for column in cursor.fetchall()]
|
work_config_columns = [column[1] for column in cursor.fetchall()]
|
||||||
|
|
||||||
work_config_migrations = [
|
work_config_migrations = [
|
||||||
('additional_break_minutes', "ALTER TABLE work_config ADD COLUMN additional_break_minutes INTEGER DEFAULT 15"),
|
('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"),
|
('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)
|
create_missing_tables(cursor)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error during basic migrations: {e}")
|
print(f"Error during basic migrations: {e}")
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
@@ -197,7 +199,7 @@ def run_basic_migrations(db_path):
|
|||||||
|
|
||||||
def create_missing_tables(cursor):
|
def create_missing_tables(cursor):
|
||||||
"""Create missing tables."""
|
"""Create missing tables."""
|
||||||
|
|
||||||
# Team table
|
# Team table
|
||||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='team'")
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='team'")
|
||||||
if not cursor.fetchone():
|
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'")
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='project'")
|
||||||
if not cursor.fetchone():
|
if not cursor.fetchone():
|
||||||
print("Creating project table...")
|
print("Creating project table...")
|
||||||
@@ -272,7 +274,7 @@ def create_missing_tables(cursor):
|
|||||||
UNIQUE(name)
|
UNIQUE(name)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# Announcement table
|
# Announcement table
|
||||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='announcement'")
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='announcement'")
|
||||||
if not cursor.fetchone():
|
if not cursor.fetchone():
|
||||||
@@ -340,13 +342,13 @@ def migrate_to_company_model(db_path):
|
|||||||
|
|
||||||
def add_company_id_to_tables(cursor):
|
def add_company_id_to_tables(cursor):
|
||||||
"""Add company_id columns to tables that need multi-tenancy."""
|
"""Add company_id columns to tables that need multi-tenancy."""
|
||||||
|
|
||||||
tables_needing_company = ['project', 'team']
|
tables_needing_company = ['project', 'team']
|
||||||
|
|
||||||
for table_name in tables_needing_company:
|
for table_name in tables_needing_company:
|
||||||
cursor.execute(f"PRAGMA table_info({table_name})")
|
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||||
columns = [column[1] for column in cursor.fetchall()]
|
columns = [column[1] for column in cursor.fetchall()]
|
||||||
|
|
||||||
if 'company_id' not in columns:
|
if 'company_id' not in columns:
|
||||||
print(f"Adding company_id column to {table_name}...")
|
print(f"Adding company_id column to {table_name}...")
|
||||||
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN company_id INTEGER")
|
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):
|
def migrate_user_roles(cursor):
|
||||||
"""Handle user role enum migration with constraint updates."""
|
"""Handle user role enum migration with constraint updates."""
|
||||||
|
|
||||||
cursor.execute("PRAGMA table_info(user)")
|
cursor.execute("PRAGMA table_info(user)")
|
||||||
user_columns = cursor.fetchall()
|
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}'")
|
print(f"Updated {updated_count} users from role '{old_role}' to '{new_role}'")
|
||||||
|
|
||||||
# Set any NULL or invalid roles to defaults
|
# Set any NULL or invalid roles to defaults
|
||||||
cursor.execute("UPDATE user SET role = ? WHERE role IS NULL OR role NOT IN (?, ?, ?, ?, ?)",
|
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.TEAM_MEMBER.value, Role.TEAM_MEMBER.value, Role.TEAM_LEADER.value,
|
||||||
Role.SUPERVISOR.value, Role.ADMIN.value, Role.SYSTEM_ADMIN.value))
|
Role.SUPERVISOR.value, Role.ADMIN.value, Role.SYSTEM_ADMIN.value))
|
||||||
null_roles = cursor.rowcount
|
null_roles = cursor.rowcount
|
||||||
if null_roles > 0:
|
if null_roles > 0:
|
||||||
print(f"Set {null_roles} NULL/invalid roles to 'Team Member'")
|
print(f"Set {null_roles} NULL/invalid roles to 'Team Member'")
|
||||||
|
|
||||||
# Ensure all users have a company_id before creating NOT NULL constraint
|
# Ensure all users have a company_id before creating NOT NULL constraint
|
||||||
print("Checking for users without company_id...")
|
print("Checking for users without company_id...")
|
||||||
cursor.execute("SELECT COUNT(*) FROM user WHERE company_id IS NULL")
|
cursor.execute("SELECT COUNT(*) FROM user WHERE company_id IS NULL")
|
||||||
null_company_count = cursor.fetchone()[0]
|
null_company_count = cursor.fetchone()[0]
|
||||||
print(f"Found {null_company_count} users without company_id")
|
print(f"Found {null_company_count} users without company_id")
|
||||||
|
|
||||||
if null_company_count > 0:
|
if null_company_count > 0:
|
||||||
print(f"Assigning {null_company_count} users to default company...")
|
print(f"Assigning {null_company_count} users to default company...")
|
||||||
|
|
||||||
# Get or create a default company
|
# Get or create a default company
|
||||||
cursor.execute("SELECT id FROM company ORDER BY id LIMIT 1")
|
cursor.execute("SELECT id FROM company ORDER BY id LIMIT 1")
|
||||||
company_result = cursor.fetchone()
|
company_result = cursor.fetchone()
|
||||||
|
|
||||||
if company_result:
|
if company_result:
|
||||||
default_company_id = company_result[0]
|
default_company_id = company_result[0]
|
||||||
print(f"Using existing company ID {default_company_id} as default")
|
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", "default-company", "Auto-created default company for migration"))
|
||||||
default_company_id = cursor.lastrowid
|
default_company_id = cursor.lastrowid
|
||||||
print(f"Created default company with ID {default_company_id}")
|
print(f"Created default company with ID {default_company_id}")
|
||||||
|
|
||||||
# Assign all users without company_id to the default company
|
# 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,))
|
cursor.execute("UPDATE user SET company_id = ? WHERE company_id IS NULL", (default_company_id,))
|
||||||
updated_users = cursor.rowcount
|
updated_users = cursor.rowcount
|
||||||
print(f"Assigned {updated_users} users to default company")
|
print(f"Assigned {updated_users} users to default company")
|
||||||
|
|
||||||
# Verify the fix
|
# Verify the fix
|
||||||
cursor.execute("SELECT COUNT(*) FROM user WHERE company_id IS NULL")
|
cursor.execute("SELECT COUNT(*) FROM user WHERE company_id IS NULL")
|
||||||
remaining_null = cursor.fetchone()[0]
|
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")
|
cursor.execute("SELECT id FROM company ORDER BY id LIMIT 1")
|
||||||
company_result = cursor.fetchone()
|
company_result = cursor.fetchone()
|
||||||
default_company_id = company_result[0] if company_result else 1
|
default_company_id = company_result[0] if company_result else 1
|
||||||
|
|
||||||
# Copy all data from old table to new table with validation
|
# Copy all data from old table to new table with validation
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO user_new
|
INSERT INTO user_new
|
||||||
SELECT id, username, email, password_hash, created_at,
|
SELECT id, username, email, password_hash, created_at,
|
||||||
COALESCE(company_id, ?) as company_id,
|
COALESCE(company_id, ?) as company_id,
|
||||||
is_verified, verification_token, token_expiry, is_blocked,
|
is_verified, verification_token, token_expiry, is_blocked,
|
||||||
CASE
|
CASE
|
||||||
WHEN role IN (?, ?, ?, ?, ?) THEN role
|
WHEN role IN (?, ?, ?, ?, ?) THEN role
|
||||||
ELSE ?
|
ELSE ?
|
||||||
END as role,
|
END as role,
|
||||||
team_id,
|
team_id,
|
||||||
CASE
|
CASE
|
||||||
WHEN account_type IN (?, ?) THEN account_type
|
WHEN account_type IN (?, ?) THEN account_type
|
||||||
ELSE ?
|
ELSE ?
|
||||||
END as account_type,
|
END as account_type,
|
||||||
business_name, two_factor_enabled, two_factor_secret
|
business_name, two_factor_enabled, two_factor_secret
|
||||||
FROM user
|
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,
|
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))
|
AccountType.COMPANY_USER.value))
|
||||||
|
|
||||||
# Drop the old table and rename the new one
|
# Drop the old table and rename the new one
|
||||||
@@ -517,7 +519,7 @@ def migrate_work_config_data(db_path):
|
|||||||
if not FLASK_AVAILABLE:
|
if not FLASK_AVAILABLE:
|
||||||
print("Skipping work config data migration - Flask not available")
|
print("Skipping work config data migration - Flask not available")
|
||||||
return
|
return
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
try:
|
try:
|
||||||
# Create CompanyWorkConfig for all companies that don't have one
|
# 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()
|
existing_config = CompanyWorkConfig.query.filter_by(company_id=company.id).first()
|
||||||
if not existing_config:
|
if not existing_config:
|
||||||
print(f"Creating CompanyWorkConfig for {company.name}")
|
print(f"Creating CompanyWorkConfig for {company.name}")
|
||||||
|
|
||||||
# Use Germany defaults (existing system default)
|
# Use Germany defaults (existing system default)
|
||||||
preset = CompanyWorkConfig.get_regional_preset(WorkRegion.GERMANY)
|
preset = CompanyWorkConfig.get_regional_preset(WorkRegion.GERMANY)
|
||||||
|
|
||||||
company_config = CompanyWorkConfig(
|
company_config = CompanyWorkConfig(
|
||||||
company_id=company.id,
|
company_id=company.id,
|
||||||
work_hours_per_day=preset['work_hours_per_day'],
|
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']
|
region_name=preset['region_name']
|
||||||
)
|
)
|
||||||
db.session.add(company_config)
|
db.session.add(company_config)
|
||||||
|
|
||||||
# Migrate existing WorkConfig user preferences to UserPreferences
|
# Migrate existing WorkConfig user preferences to UserPreferences
|
||||||
old_configs = WorkConfig.query.filter(WorkConfig.user_id.isnot(None)).all()
|
old_configs = WorkConfig.query.filter(WorkConfig.user_id.isnot(None)).all()
|
||||||
for old_config in old_configs:
|
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()
|
existing_prefs = UserPreferences.query.filter_by(user_id=user.id).first()
|
||||||
if not existing_prefs:
|
if not existing_prefs:
|
||||||
print(f"Migrating preferences for user {user.username}")
|
print(f"Migrating preferences for user {user.username}")
|
||||||
|
|
||||||
user_prefs = UserPreferences(
|
user_prefs = UserPreferences(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
time_format_24h=getattr(old_config, 'time_format_24h', True),
|
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)
|
round_to_nearest=getattr(old_config, 'round_to_nearest', True)
|
||||||
)
|
)
|
||||||
db.session.add(user_prefs)
|
db.session.add(user_prefs)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
print("Work config data migration completed successfully")
|
print("Work config data migration completed successfully")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error during work config migration: {e}")
|
print(f"Error during work config migration: {e}")
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
@@ -706,7 +708,7 @@ def migrate_system_events(db_path):
|
|||||||
FOREIGN KEY (company_id) REFERENCES company (id)
|
FOREIGN KEY (company_id) REFERENCES company (id)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# Add an initial system event if Flask is available
|
# Add an initial system event if Flask is available
|
||||||
if FLASK_AVAILABLE:
|
if FLASK_AVAILABLE:
|
||||||
# We'll add the initial event after the table is created
|
# We'll add the initial event after the table is created
|
||||||
@@ -732,7 +734,7 @@ def migrate_data():
|
|||||||
if not FLASK_AVAILABLE:
|
if not FLASK_AVAILABLE:
|
||||||
print("Skipping data migration - Flask not available")
|
print("Skipping data migration - Flask not available")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Update existing users with null/invalid data
|
# Update existing users with null/invalid data
|
||||||
users = User.query.all()
|
users = User.query.all()
|
||||||
@@ -741,7 +743,7 @@ def migrate_data():
|
|||||||
user.role = Role.TEAM_MEMBER
|
user.role = Role.TEAM_MEMBER
|
||||||
if user.two_factor_enabled is None:
|
if user.two_factor_enabled is None:
|
||||||
user.two_factor_enabled = False
|
user.two_factor_enabled = False
|
||||||
|
|
||||||
# Check if any system admin users exist
|
# Check if any system admin users exist
|
||||||
system_admin_count = User.query.filter_by(role=Role.SYSTEM_ADMIN).count()
|
system_admin_count = User.query.filter_by(role=Role.SYSTEM_ADMIN).count()
|
||||||
if system_admin_count == 0:
|
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';")
|
print(f"To promote a user: UPDATE user SET role = '{Role.SYSTEM_ADMIN.value}' WHERE username = 'your_username';")
|
||||||
else:
|
else:
|
||||||
print(f"Found {system_admin_count} system administrator(s)")
|
print(f"Found {system_admin_count} system administrator(s)")
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
print("Data migration completed successfully")
|
print("Data migration completed successfully")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error during data migration: {e}")
|
print(f"Error during data migration: {e}")
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
@@ -763,7 +765,7 @@ def init_system_settings():
|
|||||||
if not FLASK_AVAILABLE:
|
if not FLASK_AVAILABLE:
|
||||||
print("Skipping system settings initialization - Flask not available")
|
print("Skipping system settings initialization - Flask not available")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if registration_enabled setting exists
|
# Check if registration_enabled setting exists
|
||||||
reg_setting = SystemSettings.query.filter_by(key='registration_enabled').first()
|
reg_setting = SystemSettings.query.filter_by(key='registration_enabled').first()
|
||||||
if not reg_setting:
|
if not reg_setting:
|
||||||
@@ -776,7 +778,7 @@ def init_system_settings():
|
|||||||
db.session.add(reg_setting)
|
db.session.add(reg_setting)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
print("Registration setting initialized to enabled")
|
print("Registration setting initialized to enabled")
|
||||||
|
|
||||||
# Check if email_verification_required setting exists
|
# Check if email_verification_required setting exists
|
||||||
email_verification_setting = SystemSettings.query.filter_by(key='email_verification_required').first()
|
email_verification_setting = SystemSettings.query.filter_by(key='email_verification_required').first()
|
||||||
if not email_verification_setting:
|
if not email_verification_setting:
|
||||||
@@ -789,7 +791,7 @@ def init_system_settings():
|
|||||||
db.session.add(email_verification_setting)
|
db.session.add(email_verification_setting)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
print("Email verification setting initialized to enabled")
|
print("Email verification setting initialized to enabled")
|
||||||
|
|
||||||
# Check if tracking_script_enabled setting exists
|
# Check if tracking_script_enabled setting exists
|
||||||
tracking_script_setting = SystemSettings.query.filter_by(key='tracking_script_enabled').first()
|
tracking_script_setting = SystemSettings.query.filter_by(key='tracking_script_enabled').first()
|
||||||
if not tracking_script_setting:
|
if not tracking_script_setting:
|
||||||
@@ -802,7 +804,7 @@ def init_system_settings():
|
|||||||
db.session.add(tracking_script_setting)
|
db.session.add(tracking_script_setting)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
print("Tracking script setting initialized to disabled")
|
print("Tracking script setting initialized to disabled")
|
||||||
|
|
||||||
# Check if tracking_script_code setting exists
|
# Check if tracking_script_code setting exists
|
||||||
tracking_script_code_setting = SystemSettings.query.filter_by(key='tracking_script_code').first()
|
tracking_script_code_setting = SystemSettings.query.filter_by(key='tracking_script_code').first()
|
||||||
if not tracking_script_code_setting:
|
if not tracking_script_code_setting:
|
||||||
@@ -820,10 +822,10 @@ def init_system_settings():
|
|||||||
def create_new_database(db_path):
|
def create_new_database(db_path):
|
||||||
"""Create a new database with all tables."""
|
"""Create a new database with all tables."""
|
||||||
print(f"Creating new database at {db_path}")
|
print(f"Creating new database at {db_path}")
|
||||||
|
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
create_all_tables(cursor)
|
create_all_tables(cursor)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -840,7 +842,7 @@ def create_all_tables(cursor):
|
|||||||
"""Create all tables from scratch."""
|
"""Create all tables from scratch."""
|
||||||
# This would contain all CREATE TABLE statements
|
# This would contain all CREATE TABLE statements
|
||||||
# For brevity, showing key tables only
|
# For brevity, showing key tables only
|
||||||
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE TABLE company (
|
CREATE TABLE company (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -854,7 +856,7 @@ def create_all_tables(cursor):
|
|||||||
UNIQUE(name)
|
UNIQUE(name)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE TABLE user (
|
CREATE TABLE user (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
@@ -877,18 +879,120 @@ def create_all_tables(cursor):
|
|||||||
FOREIGN KEY (team_id) REFERENCES team (id)
|
FOREIGN KEY (team_id) REFERENCES team (id)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# Add other table creation statements as needed
|
# Add other table creation statements as needed
|
||||||
print("All tables created")
|
print("All tables created")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_kanban_system(db_file=None):
|
||||||
|
"""Migrate to add Kanban board system."""
|
||||||
|
db_path = get_db_path(db_file)
|
||||||
|
|
||||||
|
print(f"Migrating Kanban system in {db_path}...")
|
||||||
|
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
print(f"Database file {db_path} does not exist. Run basic migration first.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if kanban_board table already exists
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='kanban_board'")
|
||||||
|
if cursor.fetchone():
|
||||||
|
print("Kanban tables already exist. Skipping migration.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
print("Creating Kanban board tables...")
|
||||||
|
|
||||||
|
# Create kanban_board table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE kanban_board (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
project_id INTEGER NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
is_default BOOLEAN DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by_id INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (project_id) REFERENCES project (id),
|
||||||
|
FOREIGN KEY (created_by_id) REFERENCES user (id),
|
||||||
|
UNIQUE(project_id, name)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create kanban_column table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE kanban_column (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
color VARCHAR(7) DEFAULT '#6c757d',
|
||||||
|
wip_limit INTEGER,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
board_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (board_id) REFERENCES kanban_board (id),
|
||||||
|
UNIQUE(board_id, name)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create kanban_card table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE kanban_card (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
color VARCHAR(7),
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
column_id INTEGER NOT NULL,
|
||||||
|
task_id INTEGER,
|
||||||
|
assigned_to_id INTEGER,
|
||||||
|
due_date DATE,
|
||||||
|
completed_date DATE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by_id INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (column_id) REFERENCES kanban_column (id),
|
||||||
|
FOREIGN KEY (task_id) REFERENCES task (id),
|
||||||
|
FOREIGN KEY (assigned_to_id) REFERENCES user (id),
|
||||||
|
FOREIGN KEY (created_by_id) REFERENCES user (id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create indexes for better performance
|
||||||
|
cursor.execute("CREATE INDEX idx_kanban_board_project ON kanban_board(project_id)")
|
||||||
|
cursor.execute("CREATE INDEX idx_kanban_column_board ON kanban_column(board_id)")
|
||||||
|
cursor.execute("CREATE INDEX idx_kanban_card_column ON kanban_card(column_id)")
|
||||||
|
cursor.execute("CREATE INDEX idx_kanban_card_task ON kanban_card(task_id)")
|
||||||
|
cursor.execute("CREATE INDEX idx_kanban_card_assigned ON kanban_card(assigned_to_id)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("Kanban system migration completed successfully!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during Kanban system migration: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Main function with command line interface."""
|
"""Main function with command line interface."""
|
||||||
parser = argparse.ArgumentParser(description='TimeTrack Database Migration Tool')
|
parser = argparse.ArgumentParser(description='TimeTrack Database Migration Tool')
|
||||||
parser.add_argument('--db-file', '-d', help='Path to SQLite database file')
|
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)')
|
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)')
|
help='Run all migrations (default action)')
|
||||||
parser.add_argument('--task-system', '-t', action='store_true',
|
parser.add_argument('--task-system', '-t', action='store_true',
|
||||||
help='Run only task system migration')
|
help='Run only task system migration')
|
||||||
@@ -898,16 +1002,18 @@ def main():
|
|||||||
help='Run only basic table migrations')
|
help='Run only basic table migrations')
|
||||||
parser.add_argument('--system-events', '-s', action='store_true',
|
parser.add_argument('--system-events', '-s', action='store_true',
|
||||||
help='Run only system events migration')
|
help='Run only system events migration')
|
||||||
|
parser.add_argument('--kanban', '-k', action='store_true',
|
||||||
|
help='Run only Kanban system migration')
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
db_path = get_db_path(args.db_file)
|
db_path = get_db_path(args.db_file)
|
||||||
|
|
||||||
print(f"TimeTrack Database Migration Tool")
|
print(f"TimeTrack Database Migration Tool")
|
||||||
print(f"Database: {db_path}")
|
print(f"Database: {db_path}")
|
||||||
print(f"Flask available: {FLASK_AVAILABLE}")
|
print(f"Flask available: {FLASK_AVAILABLE}")
|
||||||
print("-" * 50)
|
print("-" * 50)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if args.create_new:
|
if args.create_new:
|
||||||
if os.path.exists(db_path):
|
if os.path.exists(db_path):
|
||||||
@@ -917,25 +1023,28 @@ def main():
|
|||||||
return
|
return
|
||||||
os.remove(db_path)
|
os.remove(db_path)
|
||||||
create_new_database(db_path)
|
create_new_database(db_path)
|
||||||
|
|
||||||
elif args.task_system:
|
elif args.task_system:
|
||||||
migrate_task_system(db_path)
|
migrate_task_system(db_path)
|
||||||
|
|
||||||
elif args.company_model:
|
elif args.company_model:
|
||||||
migrate_to_company_model(db_path)
|
migrate_to_company_model(db_path)
|
||||||
|
|
||||||
elif args.basic:
|
elif args.basic:
|
||||||
run_basic_migrations(db_path)
|
run_basic_migrations(db_path)
|
||||||
|
|
||||||
elif args.system_events:
|
elif args.system_events:
|
||||||
migrate_system_events(db_path)
|
migrate_system_events(db_path)
|
||||||
|
|
||||||
|
elif args.kanban:
|
||||||
|
migrate_kanban_system(db_path)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Default: run all migrations
|
# Default: run all migrations
|
||||||
run_all_migrations(db_path)
|
run_all_migrations(db_path)
|
||||||
|
|
||||||
print("\nMigration completed successfully!")
|
print("\nMigration completed successfully!")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\nError during migration: {e}")
|
print(f"\nError during migration: {e}")
|
||||||
sys.exit(1)
|
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
|
slug = db.Column(db.String(50), unique=True, nullable=False) # URL-friendly identifier
|
||||||
description = db.Column(db.Text)
|
description = db.Column(db.Text)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
|
||||||
# Freelancer support
|
# Freelancer support
|
||||||
is_personal = db.Column(db.Boolean, default=False) # True for auto-created freelancer companies
|
is_personal = db.Column(db.Boolean, default=False) # True for auto-created freelancer companies
|
||||||
|
|
||||||
# Company settings
|
# Company settings
|
||||||
is_active = db.Column(db.Boolean, default=True)
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
max_users = db.Column(db.Integer, default=100) # Optional user limit
|
max_users = db.Column(db.Integer, default=100) # Optional user limit
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
users = db.relationship('User', backref='company', lazy=True)
|
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)
|
projects = db.relationship('Project', backref='company', lazy=True)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Company {self.name}>'
|
return f'<Company {self.name}>'
|
||||||
|
|
||||||
def generate_slug(self):
|
def generate_slug(self):
|
||||||
"""Generate URL-friendly slug from company name"""
|
"""Generate URL-friendly slug from company name"""
|
||||||
import re
|
import re
|
||||||
@@ -55,16 +55,16 @@ class Team(db.Model):
|
|||||||
name = db.Column(db.String(100), nullable=False)
|
name = db.Column(db.String(100), nullable=False)
|
||||||
description = db.Column(db.String(255))
|
description = db.Column(db.String(255))
|
||||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
|
||||||
# Company association for multi-tenancy
|
# Company association for multi-tenancy
|
||||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||||
|
|
||||||
# Relationship with users (one team has many users)
|
# Relationship with users (one team has many users)
|
||||||
users = db.relationship('User', backref='team', lazy=True)
|
users = db.relationship('User', backref='team', lazy=True)
|
||||||
|
|
||||||
# Unique constraint per company
|
# Unique constraint per company
|
||||||
__table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_team_name_per_company'),)
|
__table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_team_name_per_company'),)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Team {self.name}>'
|
return f'<Team {self.name}>'
|
||||||
|
|
||||||
@@ -76,52 +76,52 @@ class Project(db.Model):
|
|||||||
is_active = db.Column(db.Boolean, default=True)
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
# Company association for multi-tenancy
|
# Company association for multi-tenancy
|
||||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||||
|
|
||||||
# Foreign key to user who created the project (Admin/Supervisor)
|
# Foreign key to user who created the project (Admin/Supervisor)
|
||||||
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
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
|
# 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)
|
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True)
|
||||||
|
|
||||||
# Project categorization
|
# Project categorization
|
||||||
category_id = db.Column(db.Integer, db.ForeignKey('project_category.id'), nullable=True)
|
category_id = db.Column(db.Integer, db.ForeignKey('project_category.id'), nullable=True)
|
||||||
|
|
||||||
# Project dates
|
# Project dates
|
||||||
start_date = db.Column(db.Date, nullable=True)
|
start_date = db.Column(db.Date, nullable=True)
|
||||||
end_date = db.Column(db.Date, nullable=True)
|
end_date = db.Column(db.Date, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
created_by = db.relationship('User', foreign_keys=[created_by_id], backref='created_projects')
|
created_by = db.relationship('User', foreign_keys=[created_by_id], backref='created_projects')
|
||||||
team = db.relationship('Team', backref='projects')
|
team = db.relationship('Team', backref='projects')
|
||||||
time_entries = db.relationship('TimeEntry', backref='project', lazy=True)
|
time_entries = db.relationship('TimeEntry', backref='project', lazy=True)
|
||||||
category = db.relationship('ProjectCategory', back_populates='projects')
|
category = db.relationship('ProjectCategory', back_populates='projects')
|
||||||
|
|
||||||
# Unique constraint per company
|
# Unique constraint per company
|
||||||
__table_args__ = (db.UniqueConstraint('company_id', 'code', name='uq_project_code_per_company'),)
|
__table_args__ = (db.UniqueConstraint('company_id', 'code', name='uq_project_code_per_company'),)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Project {self.code}: {self.name}>'
|
return f'<Project {self.code}: {self.name}>'
|
||||||
|
|
||||||
def is_user_allowed(self, user):
|
def is_user_allowed(self, user):
|
||||||
"""Check if a user is allowed to log time to this project"""
|
"""Check if a user is allowed to log time to this project"""
|
||||||
if not self.is_active:
|
if not self.is_active:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Must be in same company
|
# Must be in same company
|
||||||
if self.company_id != user.company_id:
|
if self.company_id != user.company_id:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Admins and Supervisors can log time to any project in their company
|
# Admins and Supervisors can log time to any project in their company
|
||||||
if user.role in [Role.ADMIN, Role.SUPERVISOR]:
|
if user.role in [Role.ADMIN, Role.SUPERVISOR]:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# If project is team-specific, only team members can log time
|
# If project is team-specific, only team members can log time
|
||||||
if self.team_id:
|
if self.team_id:
|
||||||
return user.team_id == self.team_id
|
return user.team_id == self.team_id
|
||||||
|
|
||||||
# If no team restriction, any user in the company can log time
|
# If no team restriction, any user in the company can log time
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -132,52 +132,52 @@ class User(db.Model):
|
|||||||
email = db.Column(db.String(120), nullable=False)
|
email = db.Column(db.String(120), nullable=False)
|
||||||
password_hash = db.Column(db.String(128))
|
password_hash = db.Column(db.String(128))
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
# Company association for multi-tenancy
|
# Company association for multi-tenancy
|
||||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||||
|
|
||||||
# Email verification fields
|
# Email verification fields
|
||||||
is_verified = db.Column(db.Boolean, default=False)
|
is_verified = db.Column(db.Boolean, default=False)
|
||||||
verification_token = db.Column(db.String(100), unique=True, nullable=True)
|
verification_token = db.Column(db.String(100), unique=True, nullable=True)
|
||||||
token_expiry = db.Column(db.DateTime, nullable=True)
|
token_expiry = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
# New field for blocking users
|
# New field for blocking users
|
||||||
is_blocked = db.Column(db.Boolean, default=False)
|
is_blocked = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
# New fields for role and team
|
# 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)
|
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)
|
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True)
|
||||||
|
|
||||||
# Freelancer support
|
# Freelancer support
|
||||||
account_type = db.Column(db.Enum(AccountType, values_callable=lambda obj: [e.value for e in obj]), default=AccountType.COMPANY_USER)
|
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
|
business_name = db.Column(db.String(100), nullable=True) # Optional business name for freelancers
|
||||||
|
|
||||||
# Unique constraints per company
|
# Unique constraints per company
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
db.UniqueConstraint('company_id', 'username', name='uq_user_username_per_company'),
|
db.UniqueConstraint('company_id', 'username', name='uq_user_username_per_company'),
|
||||||
db.UniqueConstraint('company_id', 'email', name='uq_user_email_per_company'),
|
db.UniqueConstraint('company_id', 'email', name='uq_user_email_per_company'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Two-Factor Authentication fields
|
# Two-Factor Authentication fields
|
||||||
two_factor_enabled = db.Column(db.Boolean, default=False)
|
two_factor_enabled = db.Column(db.Boolean, default=False)
|
||||||
two_factor_secret = db.Column(db.String(32), nullable=True) # Base32 encoded secret
|
two_factor_secret = db.Column(db.String(32), nullable=True) # Base32 encoded secret
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
time_entries = db.relationship('TimeEntry', backref='user', lazy=True)
|
time_entries = db.relationship('TimeEntry', backref='user', lazy=True)
|
||||||
work_config = db.relationship('WorkConfig', backref='user', lazy=True, uselist=False)
|
work_config = db.relationship('WorkConfig', backref='user', lazy=True, uselist=False)
|
||||||
|
|
||||||
def set_password(self, password):
|
def set_password(self, password):
|
||||||
self.password_hash = generate_password_hash(password)
|
self.password_hash = generate_password_hash(password)
|
||||||
|
|
||||||
def check_password(self, password):
|
def check_password(self, password):
|
||||||
return check_password_hash(self.password_hash, password)
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
def generate_verification_token(self):
|
def generate_verification_token(self):
|
||||||
"""Generate a verification token that expires in 24 hours"""
|
"""Generate a verification token that expires in 24 hours"""
|
||||||
self.verification_token = secrets.token_urlsafe(32)
|
self.verification_token = secrets.token_urlsafe(32)
|
||||||
self.token_expiry = datetime.utcnow() + timedelta(hours=24)
|
self.token_expiry = datetime.utcnow() + timedelta(hours=24)
|
||||||
return self.verification_token
|
return self.verification_token
|
||||||
|
|
||||||
def verify_token(self, token):
|
def verify_token(self, token):
|
||||||
"""Verify the token and mark user as verified if valid"""
|
"""Verify the token and mark user as verified if valid"""
|
||||||
if token == self.verification_token and self.token_expiry > datetime.utcnow():
|
if token == self.verification_token and self.token_expiry > datetime.utcnow():
|
||||||
@@ -186,13 +186,13 @@ class User(db.Model):
|
|||||||
self.token_expiry = None
|
self.token_expiry = None
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def generate_2fa_secret(self):
|
def generate_2fa_secret(self):
|
||||||
"""Generate a new 2FA secret"""
|
"""Generate a new 2FA secret"""
|
||||||
import pyotp
|
import pyotp
|
||||||
self.two_factor_secret = pyotp.random_base32()
|
self.two_factor_secret = pyotp.random_base32()
|
||||||
return self.two_factor_secret
|
return self.two_factor_secret
|
||||||
|
|
||||||
def get_2fa_uri(self):
|
def get_2fa_uri(self):
|
||||||
"""Get the provisioning URI for QR code generation"""
|
"""Get the provisioning URI for QR code generation"""
|
||||||
if not self.two_factor_secret:
|
if not self.two_factor_secret:
|
||||||
@@ -203,7 +203,7 @@ class User(db.Model):
|
|||||||
name=self.email,
|
name=self.email,
|
||||||
issuer_name="TimeTrack"
|
issuer_name="TimeTrack"
|
||||||
)
|
)
|
||||||
|
|
||||||
def verify_2fa_token(self, token, allow_setup=False):
|
def verify_2fa_token(self, token, allow_setup=False):
|
||||||
"""Verify a 2FA token"""
|
"""Verify a 2FA token"""
|
||||||
if not self.two_factor_secret:
|
if not self.two_factor_secret:
|
||||||
@@ -214,7 +214,7 @@ class User(db.Model):
|
|||||||
import pyotp
|
import pyotp
|
||||||
totp = pyotp.TOTP(self.two_factor_secret)
|
totp = pyotp.TOTP(self.two_factor_secret)
|
||||||
return totp.verify(token, valid_window=1) # Allow 1 window tolerance
|
return totp.verify(token, valid_window=1) # Allow 1 window tolerance
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<User {self.username}>'
|
return f'<User {self.username}>'
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ class SystemSettings(db.Model):
|
|||||||
value = db.Column(db.String(255), nullable=False)
|
value = db.Column(db.String(255), nullable=False)
|
||||||
description = db.Column(db.String(255))
|
description = db.Column(db.String(255))
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<SystemSettings {self.key}={self.value}>'
|
return f'<SystemSettings {self.key}={self.value}>'
|
||||||
|
|
||||||
@@ -237,14 +237,14 @@ class TimeEntry(db.Model):
|
|||||||
pause_start_time = db.Column(db.DateTime, nullable=True)
|
pause_start_time = db.Column(db.DateTime, nullable=True)
|
||||||
total_break_duration = db.Column(db.Integer, default=0) # Total break duration in seconds
|
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)
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||||
|
|
||||||
# Project association - nullable for backward compatibility
|
# Project association - nullable for backward compatibility
|
||||||
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True)
|
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True)
|
||||||
|
|
||||||
# Task/SubTask associations - nullable for backward compatibility
|
# Task/SubTask associations - nullable for backward compatibility
|
||||||
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True)
|
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)
|
subtask_id = db.Column(db.Integer, db.ForeignKey('sub_task.id'), nullable=True)
|
||||||
|
|
||||||
# Optional notes/description for the time entry
|
# Optional notes/description for the time entry
|
||||||
notes = db.Column(db.Text, nullable=True)
|
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
|
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_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
|
additional_break_threshold_hours = db.Column(db.Float, default=9.0) # Work hours that trigger additional break
|
||||||
|
|
||||||
# Time rounding settings
|
# Time rounding settings
|
||||||
time_rounding_minutes = db.Column(db.Integer, default=0) # 0 = no rounding, 15 = 15 min, 30 = 30 min
|
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
|
round_to_nearest = db.Column(db.Boolean, default=True) # True = round to nearest, False = round up
|
||||||
|
|
||||||
# Date/time format settings
|
# Date/time format settings
|
||||||
time_format_24h = db.Column(db.Boolean, default=True) # True = 24h, False = 12h (AM/PM)
|
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.
|
date_format = db.Column(db.String(20), default='ISO') # ISO, US, EU, etc.
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=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)
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||||
@@ -288,33 +288,33 @@ class WorkRegion(enum.Enum):
|
|||||||
class CompanyWorkConfig(db.Model):
|
class CompanyWorkConfig(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||||
|
|
||||||
# Work policy settings (legal requirements)
|
# Work policy settings (legal requirements)
|
||||||
work_hours_per_day = db.Column(db.Float, default=8.0) # Standard work hours per day
|
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
|
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
|
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_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
|
additional_break_threshold_hours = db.Column(db.Float, default=9.0) # Hours that trigger additional break
|
||||||
|
|
||||||
# Regional compliance
|
# Regional compliance
|
||||||
region = db.Column(db.Enum(WorkRegion), default=WorkRegion.GERMANY)
|
region = db.Column(db.Enum(WorkRegion), default=WorkRegion.GERMANY)
|
||||||
region_name = db.Column(db.String(50), default='Germany')
|
region_name = db.Column(db.String(50), default='Germany')
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=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)
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
company = db.relationship('Company', backref='work_config')
|
company = db.relationship('Company', backref='work_config')
|
||||||
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||||
|
|
||||||
# Unique constraint - one config per company
|
# Unique constraint - one config per company
|
||||||
__table_args__ = (db.UniqueConstraint('company_id', name='uq_company_work_config'),)
|
__table_args__ = (db.UniqueConstraint('company_id', name='uq_company_work_config'),)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<CompanyWorkConfig {self.company.name}: {self.region.value}, {self.work_hours_per_day}h/day>'
|
return f'<CompanyWorkConfig {self.company.name}: {self.region.value}, {self.work_hours_per_day}h/day>'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_regional_preset(cls, region):
|
def get_regional_preset(cls, region):
|
||||||
"""Get regional preset configuration."""
|
"""Get regional preset configuration."""
|
||||||
@@ -366,25 +366,25 @@ class CompanyWorkConfig(db.Model):
|
|||||||
class UserPreferences(db.Model):
|
class UserPreferences(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
# Display format preferences
|
# Display format preferences
|
||||||
time_format_24h = db.Column(db.Boolean, default=True) # True = 24h, False = 12h (AM/PM)
|
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.
|
date_format = db.Column(db.String(20), default='ISO') # ISO, US, EU, etc.
|
||||||
|
|
||||||
# Time rounding preferences
|
# Time rounding preferences
|
||||||
time_rounding_minutes = db.Column(db.Integer, default=0) # 0 = no rounding, 15 = 15 min, 30 = 30 min
|
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
|
round_to_nearest = db.Column(db.Boolean, default=True) # True = round to nearest, False = round up
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=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))
|
user = db.relationship('User', backref=db.backref('preferences', uselist=False))
|
||||||
|
|
||||||
# Unique constraint - one preferences per user
|
# Unique constraint - one preferences per user
|
||||||
__table_args__ = (db.UniqueConstraint('user_id', name='uq_user_preferences'),)
|
__table_args__ = (db.UniqueConstraint('user_id', name='uq_user_preferences'),)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<UserPreferences {self.user.username}: {self.date_format}, {"24h" if self.time_format_24h else "12h"}>'
|
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)
|
description = db.Column(db.Text, nullable=True)
|
||||||
color = db.Column(db.String(7), default='#007bff') # Hex color for UI
|
color = db.Column(db.String(7), default='#007bff') # Hex color for UI
|
||||||
icon = db.Column(db.String(50), nullable=True) # Icon name/emoji
|
icon = db.Column(db.String(50), nullable=True) # Icon name/emoji
|
||||||
|
|
||||||
# Company association for multi-tenancy
|
# Company association for multi-tenancy
|
||||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=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)
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
company = db.relationship('Company', backref='project_categories')
|
company = db.relationship('Company', backref='project_categories')
|
||||||
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||||
projects = db.relationship('Project', back_populates='category', lazy=True)
|
projects = db.relationship('Project', back_populates='category', lazy=True)
|
||||||
|
|
||||||
# Unique constraint per company
|
# Unique constraint per company
|
||||||
__table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_category_name_per_company'),)
|
__table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_category_name_per_company'),)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<ProjectCategory {self.name}>'
|
return f'<ProjectCategory {self.name}>'
|
||||||
|
|
||||||
@@ -435,52 +435,52 @@ class Task(db.Model):
|
|||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
name = db.Column(db.String(200), nullable=False)
|
name = db.Column(db.String(200), nullable=False)
|
||||||
description = db.Column(db.Text, nullable=True)
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
# Task properties
|
# Task properties
|
||||||
status = db.Column(db.Enum(TaskStatus), default=TaskStatus.NOT_STARTED)
|
status = db.Column(db.Enum(TaskStatus), default=TaskStatus.NOT_STARTED)
|
||||||
priority = db.Column(db.Enum(TaskPriority), default=TaskPriority.MEDIUM)
|
priority = db.Column(db.Enum(TaskPriority), default=TaskPriority.MEDIUM)
|
||||||
estimated_hours = db.Column(db.Float, nullable=True) # Estimated time to complete
|
estimated_hours = db.Column(db.Float, nullable=True) # Estimated time to complete
|
||||||
|
|
||||||
# Project association
|
# Project association
|
||||||
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
|
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
|
||||||
|
|
||||||
# Task assignment
|
# Task assignment
|
||||||
assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||||
|
|
||||||
# Task dates
|
# Task dates
|
||||||
start_date = db.Column(db.Date, nullable=True)
|
start_date = db.Column(db.Date, nullable=True)
|
||||||
due_date = db.Column(db.Date, nullable=True)
|
due_date = db.Column(db.Date, nullable=True)
|
||||||
completed_date = db.Column(db.Date, nullable=True)
|
completed_date = db.Column(db.Date, nullable=True)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=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)
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
project = db.relationship('Project', backref='tasks')
|
project = db.relationship('Project', backref='tasks')
|
||||||
assigned_to = db.relationship('User', foreign_keys=[assigned_to_id], backref='assigned_tasks')
|
assigned_to = db.relationship('User', foreign_keys=[assigned_to_id], backref='assigned_tasks')
|
||||||
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||||
subtasks = db.relationship('SubTask', backref='parent_task', lazy=True, cascade='all, delete-orphan')
|
subtasks = db.relationship('SubTask', backref='parent_task', lazy=True, cascade='all, delete-orphan')
|
||||||
time_entries = db.relationship('TimeEntry', backref='task', lazy=True)
|
time_entries = db.relationship('TimeEntry', backref='task', lazy=True)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Task {self.name} ({self.status.value})>'
|
return f'<Task {self.name} ({self.status.value})>'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def progress_percentage(self):
|
def progress_percentage(self):
|
||||||
"""Calculate task progress based on subtasks completion"""
|
"""Calculate task progress based on subtasks completion"""
|
||||||
if not self.subtasks:
|
if not self.subtasks:
|
||||||
return 100 if self.status == TaskStatus.COMPLETED else 0
|
return 100 if self.status == TaskStatus.COMPLETED else 0
|
||||||
|
|
||||||
completed_subtasks = sum(1 for subtask in self.subtasks if subtask.status == TaskStatus.COMPLETED)
|
completed_subtasks = sum(1 for subtask in self.subtasks if subtask.status == TaskStatus.COMPLETED)
|
||||||
return int((completed_subtasks / len(self.subtasks)) * 100)
|
return int((completed_subtasks / len(self.subtasks)) * 100)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_time_logged(self):
|
def total_time_logged(self):
|
||||||
"""Calculate total time logged to this task (in seconds)"""
|
"""Calculate total time logged to this task (in seconds)"""
|
||||||
return sum(entry.duration or 0 for entry in self.time_entries if entry.duration)
|
return sum(entry.duration or 0 for entry in self.time_entries if entry.duration)
|
||||||
|
|
||||||
def can_user_access(self, user):
|
def can_user_access(self, user):
|
||||||
"""Check if a user can access this task"""
|
"""Check if a user can access this task"""
|
||||||
return self.project.is_user_allowed(user)
|
return self.project.is_user_allowed(user)
|
||||||
@@ -490,41 +490,41 @@ class SubTask(db.Model):
|
|||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
name = db.Column(db.String(200), nullable=False)
|
name = db.Column(db.String(200), nullable=False)
|
||||||
description = db.Column(db.Text, nullable=True)
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
# SubTask properties
|
# SubTask properties
|
||||||
status = db.Column(db.Enum(TaskStatus), default=TaskStatus.NOT_STARTED)
|
status = db.Column(db.Enum(TaskStatus), default=TaskStatus.NOT_STARTED)
|
||||||
priority = db.Column(db.Enum(TaskPriority), default=TaskPriority.MEDIUM)
|
priority = db.Column(db.Enum(TaskPriority), default=TaskPriority.MEDIUM)
|
||||||
estimated_hours = db.Column(db.Float, nullable=True)
|
estimated_hours = db.Column(db.Float, nullable=True)
|
||||||
|
|
||||||
# Parent task association
|
# Parent task association
|
||||||
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
|
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
|
||||||
|
|
||||||
# Assignment
|
# Assignment
|
||||||
assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||||
|
|
||||||
# Dates
|
# Dates
|
||||||
start_date = db.Column(db.Date, nullable=True)
|
start_date = db.Column(db.Date, nullable=True)
|
||||||
due_date = db.Column(db.Date, nullable=True)
|
due_date = db.Column(db.Date, nullable=True)
|
||||||
completed_date = db.Column(db.Date, nullable=True)
|
completed_date = db.Column(db.Date, nullable=True)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=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)
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
assigned_to = db.relationship('User', foreign_keys=[assigned_to_id], backref='assigned_subtasks')
|
assigned_to = db.relationship('User', foreign_keys=[assigned_to_id], backref='assigned_subtasks')
|
||||||
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||||
time_entries = db.relationship('TimeEntry', backref='subtask', lazy=True)
|
time_entries = db.relationship('TimeEntry', backref='subtask', lazy=True)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<SubTask {self.name} ({self.status.value})>'
|
return f'<SubTask {self.name} ({self.status.value})>'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_time_logged(self):
|
def total_time_logged(self):
|
||||||
"""Calculate total time logged to this subtask (in seconds)"""
|
"""Calculate total time logged to this subtask (in seconds)"""
|
||||||
return sum(entry.duration or 0 for entry in self.time_entries if entry.duration)
|
return sum(entry.duration or 0 for entry in self.time_entries if entry.duration)
|
||||||
|
|
||||||
def can_user_access(self, user):
|
def can_user_access(self, user):
|
||||||
"""Check if a user can access this subtask"""
|
"""Check if a user can access this subtask"""
|
||||||
return self.parent_task.can_user_access(user)
|
return self.parent_task.can_user_access(user)
|
||||||
@@ -534,58 +534,58 @@ class Announcement(db.Model):
|
|||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
title = db.Column(db.String(200), nullable=False)
|
title = db.Column(db.String(200), nullable=False)
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False)
|
||||||
|
|
||||||
# Announcement properties
|
# Announcement properties
|
||||||
is_active = db.Column(db.Boolean, default=True)
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
is_urgent = db.Column(db.Boolean, default=False) # For urgent announcements with different styling
|
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
|
announcement_type = db.Column(db.String(20), default='info') # info, warning, success, danger
|
||||||
|
|
||||||
# Scheduling
|
# Scheduling
|
||||||
start_date = db.Column(db.DateTime, nullable=True) # When to start showing
|
start_date = db.Column(db.DateTime, nullable=True) # When to start showing
|
||||||
end_date = db.Column(db.DateTime, nullable=True) # When to stop showing
|
end_date = db.Column(db.DateTime, nullable=True) # When to stop showing
|
||||||
|
|
||||||
# Targeting
|
# Targeting
|
||||||
target_all_users = db.Column(db.Boolean, default=True)
|
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_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
|
target_companies = db.Column(db.Text, nullable=True) # JSON string of company IDs if not all companies
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=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)
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Announcement {self.title}>'
|
return f'<Announcement {self.title}>'
|
||||||
|
|
||||||
def is_visible_now(self):
|
def is_visible_now(self):
|
||||||
"""Check if announcement should be visible at current time"""
|
"""Check if announcement should be visible at current time"""
|
||||||
if not self.is_active:
|
if not self.is_active:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
|
||||||
# Check start date
|
# Check start date
|
||||||
if self.start_date and now < self.start_date:
|
if self.start_date and now < self.start_date:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check end date
|
# Check end date
|
||||||
if self.end_date and now > self.end_date:
|
if self.end_date and now > self.end_date:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def is_visible_to_user(self, user):
|
def is_visible_to_user(self, user):
|
||||||
"""Check if announcement should be visible to specific user"""
|
"""Check if announcement should be visible to specific user"""
|
||||||
if not self.is_visible_now():
|
if not self.is_visible_now():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# If targeting all users, show to everyone
|
# If targeting all users, show to everyone
|
||||||
if self.target_all_users:
|
if self.target_all_users:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Check role targeting
|
# Check role targeting
|
||||||
if self.target_roles:
|
if self.target_roles:
|
||||||
import json
|
import json
|
||||||
@@ -595,7 +595,7 @@ class Announcement(db.Model):
|
|||||||
return False
|
return False
|
||||||
except (json.JSONDecodeError, AttributeError):
|
except (json.JSONDecodeError, AttributeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Check company targeting
|
# Check company targeting
|
||||||
if self.target_companies:
|
if self.target_companies:
|
||||||
import json
|
import json
|
||||||
@@ -605,9 +605,9 @@ class Announcement(db.Model):
|
|||||||
return False
|
return False
|
||||||
except (json.JSONDecodeError, AttributeError):
|
except (json.JSONDecodeError, AttributeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_active_announcements_for_user(user):
|
def get_active_announcements_for_user(user):
|
||||||
"""Get all active announcements visible to a specific 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)
|
description = db.Column(db.Text, nullable=False)
|
||||||
severity = db.Column(db.String(20), default='info') # 'info', 'warning', 'error', 'critical'
|
severity = db.Column(db.String(20), default='info') # 'info', 'warning', 'error', 'critical'
|
||||||
timestamp = db.Column(db.DateTime, default=datetime.now, nullable=False)
|
timestamp = db.Column(db.DateTime, default=datetime.now, nullable=False)
|
||||||
|
|
||||||
# Optional associations
|
# Optional associations
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=True)
|
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=True)
|
||||||
|
|
||||||
# Additional metadata (JSON string)
|
# Additional metadata (JSON string)
|
||||||
event_metadata = db.Column(db.Text, nullable=True) # Store additional event data as JSON
|
event_metadata = db.Column(db.Text, nullable=True) # Store additional event data as JSON
|
||||||
|
|
||||||
# IP address and user agent for security tracking
|
# IP address and user agent for security tracking
|
||||||
ip_address = db.Column(db.String(45), nullable=True) # IPv6 compatible
|
ip_address = db.Column(db.String(45), nullable=True) # IPv6 compatible
|
||||||
user_agent = db.Column(db.Text, nullable=True)
|
user_agent = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
user = db.relationship('User', backref='system_events')
|
user = db.relationship('User', backref='system_events')
|
||||||
company = db.relationship('Company', backref='system_events')
|
company = db.relationship('Company', backref='system_events')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<SystemEvent {self.event_type}: {self.description[:50]}>'
|
return f'<SystemEvent {self.event_type}: {self.description[:50]}>'
|
||||||
|
|
||||||
@staticmethod
|
@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):
|
user_id=None, company_id=None, event_metadata=None, ip_address=None, user_agent=None):
|
||||||
"""Helper method to log system events"""
|
"""Helper method to log system events"""
|
||||||
event = SystemEvent(
|
event = SystemEvent(
|
||||||
@@ -664,7 +664,7 @@ class SystemEvent(db.Model):
|
|||||||
# Log to application logger if DB logging fails
|
# Log to application logger if DB logging fails
|
||||||
import logging
|
import logging
|
||||||
logging.error(f"Failed to log system event: {e}")
|
logging.error(f"Failed to log system event: {e}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_recent_events(days=7, limit=100):
|
def get_recent_events(days=7, limit=100):
|
||||||
"""Get recent system events from the last N days"""
|
"""Get recent system events from the last N days"""
|
||||||
@@ -673,7 +673,7 @@ class SystemEvent(db.Model):
|
|||||||
return SystemEvent.query.filter(
|
return SystemEvent.query.filter(
|
||||||
SystemEvent.timestamp >= since
|
SystemEvent.timestamp >= since
|
||||||
).order_by(SystemEvent.timestamp.desc()).limit(limit).all()
|
).order_by(SystemEvent.timestamp.desc()).limit(limit).all()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_events_by_severity(severity, days=7, limit=50):
|
def get_events_by_severity(severity, days=7, limit=50):
|
||||||
"""Get events by severity level"""
|
"""Get events by severity level"""
|
||||||
@@ -683,42 +683,151 @@ class SystemEvent(db.Model):
|
|||||||
SystemEvent.timestamp >= since,
|
SystemEvent.timestamp >= since,
|
||||||
SystemEvent.severity == severity
|
SystemEvent.severity == severity
|
||||||
).order_by(SystemEvent.timestamp.desc()).limit(limit).all()
|
).order_by(SystemEvent.timestamp.desc()).limit(limit).all()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_system_health_summary():
|
def get_system_health_summary():
|
||||||
"""Get a summary of system health based on recent events"""
|
"""Get a summary of system health based on recent events"""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
last_24h = now - timedelta(hours=24)
|
last_24h = now - timedelta(hours=24)
|
||||||
last_week = now - timedelta(days=7)
|
last_week = now - timedelta(days=7)
|
||||||
|
|
||||||
# Count events by severity in last 24h
|
# Count events by severity in last 24h
|
||||||
recent_errors = SystemEvent.query.filter(
|
recent_errors = SystemEvent.query.filter(
|
||||||
SystemEvent.timestamp >= last_24h,
|
SystemEvent.timestamp >= last_24h,
|
||||||
SystemEvent.severity.in_(['error', 'critical'])
|
SystemEvent.severity.in_(['error', 'critical'])
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
recent_warnings = SystemEvent.query.filter(
|
recent_warnings = SystemEvent.query.filter(
|
||||||
SystemEvent.timestamp >= last_24h,
|
SystemEvent.timestamp >= last_24h,
|
||||||
SystemEvent.severity == 'warning'
|
SystemEvent.severity == 'warning'
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
# Count total events in last week
|
# Count total events in last week
|
||||||
weekly_events = SystemEvent.query.filter(
|
weekly_events = SystemEvent.query.filter(
|
||||||
SystemEvent.timestamp >= last_week
|
SystemEvent.timestamp >= last_week
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
# Get most recent error
|
# Get most recent error
|
||||||
last_error = SystemEvent.query.filter(
|
last_error = SystemEvent.query.filter(
|
||||||
SystemEvent.severity.in_(['error', 'critical'])
|
SystemEvent.severity.in_(['error', 'critical'])
|
||||||
).order_by(SystemEvent.timestamp.desc()).first()
|
).order_by(SystemEvent.timestamp.desc()).first()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'errors_24h': recent_errors,
|
'errors_24h': recent_errors,
|
||||||
'warnings_24h': recent_warnings,
|
'warnings_24h': recent_warnings,
|
||||||
'total_events_week': weekly_events,
|
'total_events_week': weekly_events,
|
||||||
'last_error': last_error,
|
'last_error': last_error,
|
||||||
'health_status': 'healthy' if recent_errors == 0 else 'issues' if recent_errors < 5 else 'critical'
|
'health_status': 'healthy' if recent_errors == 0 else 'issues' if recent_errors < 5 else 'critical'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Kanban Board models
|
||||||
|
class KanbanBoard(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Project association
|
||||||
|
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
|
||||||
|
|
||||||
|
# Board settings
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
is_default = db.Column(db.Boolean, default=False) # Default board for project
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
project = db.relationship('Project', backref='kanban_boards')
|
||||||
|
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||||
|
columns = db.relationship('KanbanColumn', backref='board', lazy=True, cascade='all, delete-orphan', order_by='KanbanColumn.position')
|
||||||
|
|
||||||
|
# Unique constraint per project
|
||||||
|
__table_args__ = (db.UniqueConstraint('project_id', 'name', name='uq_kanban_board_name_per_project'),)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<KanbanBoard {self.name}>'
|
||||||
|
|
||||||
|
class KanbanColumn(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Column settings
|
||||||
|
position = db.Column(db.Integer, nullable=False) # Order in board
|
||||||
|
color = db.Column(db.String(7), default='#6c757d') # Hex color
|
||||||
|
wip_limit = db.Column(db.Integer, nullable=True) # Work in progress limit
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
|
||||||
|
# Board association
|
||||||
|
board_id = db.Column(db.Integer, db.ForeignKey('kanban_board.id'), nullable=False)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
cards = db.relationship('KanbanCard', backref='column', lazy=True, cascade='all, delete-orphan', order_by='KanbanCard.position')
|
||||||
|
|
||||||
|
# Unique constraint per board
|
||||||
|
__table_args__ = (db.UniqueConstraint('board_id', 'name', name='uq_kanban_column_name_per_board'),)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<KanbanColumn {self.name}>'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def card_count(self):
|
||||||
|
"""Get number of cards in this column"""
|
||||||
|
return len([card for card in self.cards if card.is_active])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_over_wip_limit(self):
|
||||||
|
"""Check if column is over WIP limit"""
|
||||||
|
if not self.wip_limit:
|
||||||
|
return False
|
||||||
|
return self.card_count > self.wip_limit
|
||||||
|
|
||||||
|
class KanbanCard(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(200), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Card settings
|
||||||
|
position = db.Column(db.Integer, nullable=False) # Order in column
|
||||||
|
color = db.Column(db.String(7), nullable=True) # Optional custom color
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
|
||||||
|
# Column association
|
||||||
|
column_id = db.Column(db.Integer, db.ForeignKey('kanban_column.id'), nullable=False)
|
||||||
|
|
||||||
|
# Optional task association
|
||||||
|
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True)
|
||||||
|
|
||||||
|
# Card assignment
|
||||||
|
assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||||
|
|
||||||
|
# Card dates
|
||||||
|
due_date = db.Column(db.Date, nullable=True)
|
||||||
|
completed_date = db.Column(db.Date, nullable=True)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
task = db.relationship('Task', backref='kanban_cards')
|
||||||
|
assigned_to = db.relationship('User', foreign_keys=[assigned_to_id], backref='assigned_kanban_cards')
|
||||||
|
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<KanbanCard {self.title}>'
|
||||||
|
|
||||||
|
def can_user_access(self, user):
|
||||||
|
"""Check if a user can access this card"""
|
||||||
|
# Check board's project permissions
|
||||||
|
return self.column.board.project.is_user_allowed(user)
|
||||||
@@ -93,6 +93,7 @@
|
|||||||
<td class="actions">
|
<td class="actions">
|
||||||
<a href="{{ url_for('edit_project', project_id=project.id) }}" class="btn btn-sm btn-primary">Edit</a>
|
<a href="{{ url_for('edit_project', project_id=project.id) }}" class="btn btn-sm btn-primary">Edit</a>
|
||||||
<a href="{{ url_for('manage_project_tasks', project_id=project.id) }}" class="btn btn-sm btn-info">Tasks</a>
|
<a href="{{ url_for('manage_project_tasks', project_id=project.id) }}" class="btn btn-sm btn-info">Tasks</a>
|
||||||
|
<a href="{{ url_for('project_kanban', project_id=project.id) }}" class="btn btn-sm btn-success">Kanban</a>
|
||||||
{% if g.user.role == Role.ADMIN and project.time_entries|length == 0 %}
|
{% if g.user.role == Role.ADMIN and project.time_entries|length == 0 %}
|
||||||
<form method="POST" action="{{ url_for('delete_project', project_id=project.id) }}" style="display: inline;"
|
<form method="POST" action="{{ url_for('delete_project', project_id=project.id) }}" style="display: inline;"
|
||||||
onsubmit="return confirm('Are you sure you want to delete this project?')">
|
onsubmit="return confirm('Are you sure you want to delete this project?')">
|
||||||
|
|||||||
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>
|
<ul>
|
||||||
{% if g.user %}
|
{% if g.user %}
|
||||||
<li><a href="{{ url_for('home') }}" data-tooltip="Home"><i class="nav-icon">🏠</i><span class="nav-text">Home</span></a></li>
|
<li><a href="{{ url_for('home') }}" data-tooltip="Home"><i class="nav-icon">🏠</i><span class="nav-text">Home</span></a></li>
|
||||||
|
<li><a href="{{ url_for('kanban_overview') }}" data-tooltip="Kanban Board"><i class="nav-icon">📋</i><span class="nav-text">Kanban Board</span></a></li>
|
||||||
<li><a href="{{ url_for('analytics') }}" data-tooltip="Time Analytics"><i class="nav-icon">📊</i><span class="nav-text">Analytics</span></a></li>
|
<li><a href="{{ url_for('analytics') }}" data-tooltip="Time Analytics"><i class="nav-icon">📊</i><span class="nav-text">Analytics</span></a></li>
|
||||||
|
|
||||||
<!-- Role-based menu items -->
|
<!-- Role-based menu items -->
|
||||||
|
|||||||
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