Add a customizable dashboard feature.

This commit is contained in:
2025-07-03 14:36:19 +02:00
committed by Jens Luedicke
parent 4a4aa05645
commit 336d998a8a
5 changed files with 2805 additions and 318 deletions

663
app.py
View File

@@ -1,5 +1,5 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent, KanbanBoard, KanbanColumn, KanbanCard 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, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate
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
@@ -879,62 +879,11 @@ def verify_email(token):
return redirect(url_for('login')) return redirect(url_for('login'))
@app.route('/dashboard') @app.route('/dashboard')
@role_required(Role.TEAM_LEADER) @role_required(Role.TEAM_MEMBER)
@company_required
def dashboard(): def dashboard():
# Get dashboard data based on user role """User dashboard with configurable widgets."""
dashboard_data = {} return render_template('dashboard.html', title='Dashboard')
if g.user.role == Role.ADMIN and g.user.company_id:
# Admin sees everything within their company
dashboard_data.update({
'total_users': User.query.filter_by(company_id=g.user.company_id).count(),
'total_teams': Team.query.filter_by(company_id=g.user.company_id).count(),
'blocked_users': User.query.filter_by(company_id=g.user.company_id, is_blocked=True).count(),
'unverified_users': User.query.filter_by(company_id=g.user.company_id, is_verified=False).count(),
'recent_registrations': User.query.filter_by(company_id=g.user.company_id).order_by(User.id.desc()).limit(5).all()
})
if g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN]:
# Team leaders and supervisors see team-related data
if g.user.team_id or g.user.role == Role.ADMIN:
if g.user.role == Role.ADMIN and g.user.company_id:
# Admin can see all teams in their company
teams = Team.query.filter_by(company_id=g.user.company_id).all()
team_members = User.query.filter(
User.team_id.isnot(None),
User.company_id == g.user.company_id
).all()
else:
# Team leaders/supervisors see their own team
teams = [Team.query.get(g.user.team_id)] if g.user.team_id else []
team_members = User.query.filter_by(
team_id=g.user.team_id,
company_id=g.user.company_id
).all() if g.user.team_id else []
dashboard_data.update({
'teams': teams,
'team_members': team_members,
'team_member_count': len(team_members)
})
# Get recent time entries for the user's oversight
if g.user.role == Role.ADMIN:
# Admin sees all recent entries
recent_entries = TimeEntry.query.order_by(TimeEntry.arrival_time.desc()).limit(10).all()
elif g.user.team_id:
# Team leaders see their team's entries
team_user_ids = [user.id for user in User.query.filter_by(team_id=g.user.team_id).all()]
recent_entries = TimeEntry.query.filter(TimeEntry.user_id.in_(team_user_ids)).order_by(TimeEntry.arrival_time.desc()).limit(10).all()
else:
recent_entries = []
dashboard_data['recent_entries'] = recent_entries
return render_template('dashboard.html', title='Dashboard', **dashboard_data)
# Redirect old admin dashboard URL to new dashboard # Redirect old admin dashboard URL to new dashboard
@@ -4467,6 +4416,606 @@ def delete_kanban_column(column_id):
db.session.rollback() db.session.rollback()
return jsonify({'success': False, 'message': str(e)}) return jsonify({'success': False, 'message': str(e)})
# Dashboard API Endpoints
@app.route('/api/dashboard')
@role_required(Role.TEAM_MEMBER)
@company_required
def get_dashboard():
"""Get user's dashboard configuration and widgets."""
try:
# Get or create user dashboard
dashboard = UserDashboard.query.filter_by(user_id=g.user.id).first()
if not dashboard:
dashboard = UserDashboard(user_id=g.user.id)
db.session.add(dashboard)
db.session.commit()
logger.info(f"Created new dashboard {dashboard.id} for user {g.user.id}")
else:
logger.info(f"Using existing dashboard {dashboard.id} for user {g.user.id}")
# Get user's widgets
widgets = DashboardWidget.query.filter_by(dashboard_id=dashboard.id).order_by(DashboardWidget.grid_y, DashboardWidget.grid_x).all()
logger.info(f"Found {len(widgets)} widgets for dashboard {dashboard.id}")
# Convert to JSON format
widget_data = []
for widget in widgets:
# Convert grid size to simple size names
if widget.grid_width == 1 and widget.grid_height == 1:
size = 'small'
elif widget.grid_width == 2 and widget.grid_height == 1:
size = 'medium'
elif widget.grid_width == 2 and widget.grid_height == 2:
size = 'large'
elif widget.grid_width == 3 and widget.grid_height == 1:
size = 'wide'
else:
size = 'small'
# Parse config JSON
try:
import json
config = json.loads(widget.config) if widget.config else {}
except (json.JSONDecodeError, TypeError):
config = {}
widget_data.append({
'id': widget.id,
'type': widget.widget_type.value,
'title': widget.title,
'size': size,
'grid_x': widget.grid_x,
'grid_y': widget.grid_y,
'grid_width': widget.grid_width,
'grid_height': widget.grid_height,
'config': config
})
return jsonify({
'success': True,
'dashboard': {
'id': dashboard.id,
'layout_config': dashboard.layout_config,
'grid_columns': dashboard.grid_columns,
'theme': dashboard.theme
},
'widgets': widget_data
})
except Exception as e:
logger.error(f"Error loading dashboard: {e}")
return jsonify({'success': False, 'error': str(e)})
@app.route('/api/dashboard/widgets', methods=['POST'])
@role_required(Role.TEAM_MEMBER)
@company_required
def create_or_update_widget():
"""Create or update a dashboard widget."""
try:
data = request.get_json()
# Get or create user dashboard
dashboard = UserDashboard.query.filter_by(user_id=g.user.id).first()
if not dashboard:
dashboard = UserDashboard(user_id=g.user.id)
db.session.add(dashboard)
db.session.flush() # Get the ID
logger.info(f"Created new dashboard {dashboard.id} for user {g.user.id} in widget creation")
else:
logger.info(f"Using existing dashboard {dashboard.id} for user {g.user.id} in widget creation")
# Check if updating existing widget
widget_id = data.get('widget_id')
if widget_id:
widget = DashboardWidget.query.filter_by(
id=widget_id,
dashboard_id=dashboard.id
).first()
if not widget:
return jsonify({'success': False, 'error': 'Widget not found'})
else:
# Create new widget
widget = DashboardWidget(dashboard_id=dashboard.id)
# Find next available position
max_y = db.session.query(func.max(DashboardWidget.grid_y)).filter_by(
dashboard_id=dashboard.id
).scalar() or 0
widget.grid_y = max_y + 1
widget.grid_x = 0
# Update widget properties
widget.widget_type = WidgetType(data['type'])
widget.title = data['title']
# Convert size to grid dimensions
size = data.get('size', 'small')
if size == 'small':
widget.grid_width = 1
widget.grid_height = 1
elif size == 'medium':
widget.grid_width = 2
widget.grid_height = 1
elif size == 'large':
widget.grid_width = 2
widget.grid_height = 2
elif size == 'wide':
widget.grid_width = 3
widget.grid_height = 1
# Build config from form data
config = {}
for key, value in data.items():
if key not in ['type', 'title', 'size', 'widget_id']:
config[key] = value
# Store config as JSON string
if config:
import json
widget.config = json.dumps(config)
else:
widget.config = None
if not widget_id:
db.session.add(widget)
logger.info(f"Creating new widget: {widget.widget_type.value} for dashboard {dashboard.id}")
else:
logger.info(f"Updating existing widget {widget_id}")
db.session.commit()
logger.info(f"Widget saved successfully with ID: {widget.id}")
# Verify the widget was actually saved
saved_widget = DashboardWidget.query.filter_by(id=widget.id).first()
if saved_widget:
logger.info(f"Verification: Widget {widget.id} exists in database with dashboard_id: {saved_widget.dashboard_id}")
else:
logger.error(f"Verification failed: Widget {widget.id} not found in database")
# Convert grid size back to simple size name for response
if widget.grid_width == 1 and widget.grid_height == 1:
size_name = 'small'
elif widget.grid_width == 2 and widget.grid_height == 1:
size_name = 'medium'
elif widget.grid_width == 2 and widget.grid_height == 2:
size_name = 'large'
elif widget.grid_width == 3 and widget.grid_height == 1:
size_name = 'wide'
else:
size_name = 'small'
# Parse config for response
try:
import json
config_dict = json.loads(widget.config) if widget.config else {}
except (json.JSONDecodeError, TypeError):
config_dict = {}
return jsonify({
'success': True,
'message': 'Widget saved successfully',
'widget': {
'id': widget.id,
'type': widget.widget_type.value,
'title': widget.title,
'size': size_name,
'grid_x': widget.grid_x,
'grid_y': widget.grid_y,
'grid_width': widget.grid_width,
'grid_height': widget.grid_height,
'config': config_dict
}
})
except Exception as e:
db.session.rollback()
logger.error(f"Error saving widget: {e}")
return jsonify({'success': False, 'error': str(e)})
@app.route('/api/dashboard/widgets/<int:widget_id>', methods=['DELETE'])
@role_required(Role.TEAM_MEMBER)
@company_required
def delete_widget(widget_id):
"""Delete a dashboard widget."""
try:
# Get user dashboard
dashboard = UserDashboard.query.filter_by(user_id=g.user.id).first()
if not dashboard:
return jsonify({'success': False, 'error': 'Dashboard not found'})
# Find and delete widget
widget = DashboardWidget.query.filter_by(
id=widget_id,
dashboard_id=dashboard.id
).first()
if not widget:
return jsonify({'success': False, 'error': 'Widget not found'})
# No need to update positions for grid-based layout
db.session.delete(widget)
db.session.commit()
return jsonify({'success': True, 'message': 'Widget deleted successfully'})
except Exception as e:
db.session.rollback()
logger.error(f"Error deleting widget: {e}")
return jsonify({'success': False, 'error': str(e)})
@app.route('/api/dashboard/positions', methods=['POST'])
@role_required(Role.TEAM_MEMBER)
@company_required
def update_widget_positions():
"""Update widget grid positions after drag and drop."""
try:
data = request.get_json()
positions = data.get('positions', [])
# Get user dashboard
dashboard = UserDashboard.query.filter_by(user_id=g.user.id).first()
if not dashboard:
return jsonify({'success': False, 'error': 'Dashboard not found'})
# Update grid positions
for pos_data in positions:
widget = DashboardWidget.query.filter_by(
id=pos_data['id'],
dashboard_id=dashboard.id
).first()
if widget:
# For now, just assign sequential grid positions
# In a more advanced implementation, we'd calculate actual grid coordinates
widget.grid_x = pos_data.get('grid_x', 0)
widget.grid_y = pos_data.get('grid_y', pos_data.get('position', 0))
db.session.commit()
return jsonify({'success': True, 'message': 'Positions updated successfully'})
except Exception as e:
db.session.rollback()
logger.error(f"Error updating positions: {e}")
return jsonify({'success': False, 'error': str(e)})
# Widget data endpoints
@app.route('/api/dashboard/widgets/<int:widget_id>/data')
@role_required(Role.TEAM_MEMBER)
@company_required
def get_widget_data(widget_id):
"""Get data for a specific widget."""
try:
# Get user dashboard
dashboard = UserDashboard.query.filter_by(user_id=g.user.id).first()
if not dashboard:
return jsonify({'success': False, 'error': 'Dashboard not found'})
# Find widget
widget = DashboardWidget.query.filter_by(
id=widget_id,
dashboard_id=dashboard.id
).first()
if not widget:
return jsonify({'success': False, 'error': 'Widget not found'})
# Get widget-specific data based on type
widget_data = {}
if widget.widget_type == WidgetType.DAILY_SUMMARY:
from datetime import datetime, timedelta
config = widget.config or {}
period = config.get('summary_period', 'daily')
# Calculate time summaries
now = datetime.now()
# Today's summary
start_of_today = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_entries = TimeEntry.query.filter(
TimeEntry.user_id == g.user.id,
TimeEntry.arrival_time >= start_of_today,
TimeEntry.departure_time.isnot(None)
).all()
today_seconds = sum(entry.duration or 0 for entry in today_entries)
# This week's summary
start_of_week = start_of_today - timedelta(days=start_of_today.weekday())
week_entries = TimeEntry.query.filter(
TimeEntry.user_id == g.user.id,
TimeEntry.arrival_time >= start_of_week,
TimeEntry.departure_time.isnot(None)
).all()
week_seconds = sum(entry.duration or 0 for entry in week_entries)
# This month's summary
start_of_month = start_of_today.replace(day=1)
month_entries = TimeEntry.query.filter(
TimeEntry.user_id == g.user.id,
TimeEntry.arrival_time >= start_of_month,
TimeEntry.departure_time.isnot(None)
).all()
month_seconds = sum(entry.duration or 0 for entry in month_entries)
widget_data.update({
'today': f"{today_seconds // 3600}h {(today_seconds % 3600) // 60}m",
'week': f"{week_seconds // 3600}h {(week_seconds % 3600) // 60}m",
'month': f"{month_seconds // 3600}h {(month_seconds % 3600) // 60}m",
'entries_today': len(today_entries),
'entries_week': len(week_entries),
'entries_month': len(month_entries)
})
elif widget.widget_type == WidgetType.ACTIVE_PROJECTS:
config = widget.config or {}
project_filter = config.get('project_filter', 'all')
max_projects = int(config.get('max_projects', 5))
# Get user's projects
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
projects = Project.query.filter_by(
company_id=g.user.company_id,
is_active=True
).limit(max_projects).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)
).limit(max_projects).all()
else:
projects = []
widget_data['projects'] = [{
'id': p.id,
'name': p.name,
'code': p.code,
'description': p.description
} for p in projects]
elif widget.widget_type == WidgetType.ASSIGNED_TASKS:
config = widget.config or {}
task_filter = config.get('task_filter', 'assigned')
task_status = config.get('task_status', 'active')
# Get user's tasks based on filter
if task_filter == 'assigned':
tasks = Task.query.filter_by(assigned_to_id=g.user.id)
elif task_filter == 'created':
tasks = Task.query.filter_by(created_by_id=g.user.id)
else:
# Get tasks from user's projects
if g.user.team_id:
project_ids = [p.id for p in Project.query.filter(
Project.company_id == g.user.company_id,
db.or_(Project.team_id == g.user.team_id, Project.team_id == None)
).all()]
tasks = Task.query.filter(Task.project_id.in_(project_ids))
else:
tasks = Task.query.join(Project).filter(Project.company_id == g.user.company_id)
# Filter by status if specified
if task_status != 'all':
if task_status == 'active':
tasks = tasks.filter(Task.status.in_([TaskStatus.PENDING, TaskStatus.IN_PROGRESS]))
elif task_status == 'pending':
tasks = tasks.filter_by(status=TaskStatus.PENDING)
elif task_status == 'completed':
tasks = tasks.filter_by(status=TaskStatus.COMPLETED)
tasks = tasks.limit(10).all()
widget_data['tasks'] = [{
'id': t.id,
'name': t.name,
'description': t.description,
'status': t.status.value if t.status else 'Pending',
'priority': t.priority.value if t.priority else 'Medium',
'project_name': t.project.name if t.project else 'No Project'
} for t in tasks]
elif widget.widget_type == WidgetType.WEEKLY_CHART:
from datetime import datetime, timedelta
# Get weekly data for chart
now = datetime.now()
start_of_week = now - timedelta(days=now.weekday())
weekly_data = []
for i in range(7):
day = start_of_week + timedelta(days=i)
day_start = day.replace(hour=0, minute=0, second=0, microsecond=0)
day_end = day_start + timedelta(days=1)
day_entries = TimeEntry.query.filter(
TimeEntry.user_id == g.user.id,
TimeEntry.arrival_time >= day_start,
TimeEntry.arrival_time < day_end,
TimeEntry.departure_time.isnot(None)
).all()
total_seconds = sum(entry.duration or 0 for entry in day_entries)
weekly_data.append({
'day': day.strftime('%A'),
'date': day.strftime('%Y-%m-%d'),
'hours': round(total_seconds / 3600, 2),
'entries': len(day_entries)
})
widget_data['weekly_data'] = weekly_data
elif widget.widget_type == WidgetType.TASK_PRIORITY:
# Get tasks by priority
if g.user.team_id:
project_ids = [p.id for p in Project.query.filter(
Project.company_id == g.user.company_id,
db.or_(Project.team_id == g.user.team_id, Project.team_id == None)
).all()]
tasks = Task.query.filter(
Task.project_id.in_(project_ids),
Task.assigned_to_id == g.user.id
).order_by(Task.priority.desc(), Task.created_at.desc()).limit(10).all()
else:
tasks = Task.query.filter_by(assigned_to_id=g.user.id).order_by(
Task.priority.desc(), Task.created_at.desc()
).limit(10).all()
widget_data['priority_tasks'] = [{
'id': t.id,
'name': t.name,
'description': t.description,
'priority': t.priority.value if t.priority else 'Medium',
'status': t.status.value if t.status else 'Pending',
'project_name': t.project.name if t.project else 'No Project'
} for t in tasks]
elif widget.widget_type == WidgetType.KANBAN_SUMMARY:
# Get kanban data summary
if g.user.team_id:
project_ids = [p.id for p in Project.query.filter(
Project.company_id == g.user.company_id,
db.or_(Project.team_id == g.user.team_id, Project.team_id == None)
).all()]
boards = KanbanBoard.query.filter(
KanbanBoard.project_id.in_(project_ids),
KanbanBoard.is_active == True
).limit(3).all()
else:
boards = []
board_summaries = []
for board in boards:
columns = KanbanColumn.query.filter_by(board_id=board.id).order_by(KanbanColumn.position).all()
total_cards = sum(len([c for c in col.cards if c.is_active]) for col in columns)
board_summaries.append({
'id': board.id,
'name': board.name,
'project_name': board.project.name,
'total_cards': total_cards,
'columns': len(columns)
})
widget_data['kanban_boards'] = board_summaries
elif widget.widget_type == WidgetType.PROJECT_PROGRESS:
# Get project progress data
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
projects = Project.query.filter_by(
company_id=g.user.company_id,
is_active=True
).limit(5).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)
).limit(5).all()
else:
projects = []
project_progress = []
for project in projects:
total_tasks = Task.query.filter_by(project_id=project.id).count()
completed_tasks = Task.query.filter_by(
project_id=project.id,
status=TaskStatus.COMPLETED
).count()
progress = (completed_tasks / total_tasks * 100) if total_tasks > 0 else 0
project_progress.append({
'id': project.id,
'name': project.name,
'code': project.code,
'progress': round(progress, 1),
'completed_tasks': completed_tasks,
'total_tasks': total_tasks
})
widget_data['project_progress'] = project_progress
elif widget.widget_type == WidgetType.PRODUCTIVITY_METRICS:
from datetime import datetime, timedelta
# Calculate productivity metrics
now = datetime.now()
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = today - timedelta(days=7)
# This week vs last week comparison
this_week_entries = TimeEntry.query.filter(
TimeEntry.user_id == g.user.id,
TimeEntry.arrival_time >= week_ago,
TimeEntry.departure_time.isnot(None)
).all()
last_week_entries = TimeEntry.query.filter(
TimeEntry.user_id == g.user.id,
TimeEntry.arrival_time >= week_ago - timedelta(days=7),
TimeEntry.arrival_time < week_ago,
TimeEntry.departure_time.isnot(None)
).all()
this_week_hours = sum(entry.duration or 0 for entry in this_week_entries) / 3600
last_week_hours = sum(entry.duration or 0 for entry in last_week_entries) / 3600
productivity_change = ((this_week_hours - last_week_hours) / last_week_hours * 100) if last_week_hours > 0 else 0
widget_data.update({
'this_week_hours': round(this_week_hours, 1),
'last_week_hours': round(last_week_hours, 1),
'productivity_change': round(productivity_change, 1),
'avg_daily_hours': round(this_week_hours / 7, 1),
'total_entries': len(this_week_entries)
})
return jsonify({
'success': True,
'data': widget_data
})
except Exception as e:
logger.error(f"Error getting widget data: {e}")
return jsonify({'success': False, 'error': str(e)})
@app.route('/api/current-timer-status')
@role_required(Role.TEAM_MEMBER)
@company_required
def get_current_timer_status():
"""Get current timer status for dashboard widgets."""
try:
# Get the user's current active time entry
active_entry = TimeEntry.query.filter_by(
user_id=g.user.id,
departure_time=None
).first()
if active_entry:
# Calculate current duration
now = datetime.now()
elapsed_seconds = int((now - active_entry.arrival_time).total_seconds())
return jsonify({
'success': True,
'isActive': True,
'startTime': active_entry.arrival_time.isoformat(),
'currentDuration': elapsed_seconds,
'entryId': active_entry.id
})
else:
return jsonify({
'success': True,
'isActive': False,
'message': 'No active timer'
})
except Exception as e:
logger.error(f"Error getting timer status: {e}")
return jsonify({'success': False, 'error': 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=True, host='0.0.0.0', port=port)

View File

@@ -16,7 +16,8 @@ try:
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, KanbanBoard, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent, KanbanBoard,
KanbanColumn, KanbanCard) KanbanColumn, KanbanCard, WidgetType, UserDashboard, DashboardWidget,
WidgetTemplate)
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
FLASK_AVAILABLE = True FLASK_AVAILABLE = True
except ImportError: except ImportError:
@@ -74,6 +75,7 @@ def run_all_migrations(db_path=None):
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) migrate_kanban_system(db_path)
migrate_dashboard_system(db_path)
if FLASK_AVAILABLE: if FLASK_AVAILABLE:
with app.app_context(): with app.app_context():
@@ -985,6 +987,149 @@ def migrate_kanban_system(db_file=None):
conn.close() conn.close()
def migrate_dashboard_system(db_file=None):
"""Migrate to add Dashboard widget system."""
db_path = get_db_path(db_file)
print(f"Migrating Dashboard 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 user_dashboard table already exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='user_dashboard'")
if cursor.fetchone():
print("Dashboard tables already exist. Skipping migration.")
return True
print("Creating Dashboard system tables...")
# Create user_dashboard table
cursor.execute("""
CREATE TABLE user_dashboard (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name VARCHAR(100) DEFAULT 'My Dashboard',
is_default BOOLEAN DEFAULT 1,
layout_config TEXT,
grid_columns INTEGER DEFAULT 6,
theme VARCHAR(20) DEFAULT 'light',
auto_refresh INTEGER DEFAULT 300,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user (id)
)
""")
# Create dashboard_widget table
cursor.execute("""
CREATE TABLE dashboard_widget (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dashboard_id INTEGER NOT NULL,
widget_type VARCHAR(50) NOT NULL,
grid_x INTEGER NOT NULL DEFAULT 0,
grid_y INTEGER NOT NULL DEFAULT 0,
grid_width INTEGER NOT NULL DEFAULT 1,
grid_height INTEGER NOT NULL DEFAULT 1,
title VARCHAR(100),
config TEXT,
refresh_interval INTEGER DEFAULT 60,
is_visible BOOLEAN DEFAULT 1,
is_minimized BOOLEAN DEFAULT 0,
z_index INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (dashboard_id) REFERENCES user_dashboard (id)
)
""")
# Create widget_template table
cursor.execute("""
CREATE TABLE widget_template (
id INTEGER PRIMARY KEY AUTOINCREMENT,
widget_type VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
icon VARCHAR(50),
default_width INTEGER DEFAULT 1,
default_height INTEGER DEFAULT 1,
default_config TEXT,
required_role VARCHAR(50) DEFAULT 'Team Member',
is_active BOOLEAN DEFAULT 1,
category VARCHAR(50) DEFAULT 'General',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Create indexes for better performance
cursor.execute("CREATE INDEX idx_user_dashboard_user ON user_dashboard(user_id)")
cursor.execute("CREATE INDEX idx_user_dashboard_default ON user_dashboard(user_id, is_default)")
cursor.execute("CREATE INDEX idx_dashboard_widget_dashboard ON dashboard_widget(dashboard_id)")
cursor.execute("CREATE INDEX idx_dashboard_widget_type ON dashboard_widget(widget_type)")
cursor.execute("CREATE INDEX idx_widget_template_type ON widget_template(widget_type)")
cursor.execute("CREATE INDEX idx_widget_template_category ON widget_template(category)")
# Insert default widget templates
default_templates = [
# Time Tracking Widgets
('current_timer', 'Current Timer', 'Shows active time tracking session', '⏲️', 2, 1, '{}', 'Team Member', 'Time'),
('daily_summary', 'Daily Summary', 'Today\'s time tracking summary', '📊', 2, 1, '{}', 'Team Member', 'Time'),
('weekly_chart', 'Weekly Chart', 'Weekly time distribution chart', '📈', 3, 2, '{}', 'Team Member', 'Time'),
('break_reminder', 'Break Reminder', 'Reminds when breaks are due', '', 1, 1, '{}', 'Team Member', 'Time'),
# Project Management Widgets
('active_projects', 'Active Projects', 'List of current active projects', '📁', 2, 2, '{}', 'Team Member', 'Projects'),
('project_progress', 'Project Progress', 'Visual progress of projects', '🎯', 2, 1, '{}', 'Team Member', 'Projects'),
('project_activity', 'Recent Activity', 'Recent project activities', '🔄', 2, 1, '{}', 'Team Member', 'Projects'),
('project_deadlines', 'Upcoming Deadlines', 'Projects with approaching deadlines', '⚠️', 2, 1, '{}', 'Team Member', 'Projects'),
# Task Management Widgets
('assigned_tasks', 'My Tasks', 'Tasks assigned to me', '', 2, 2, '{}', 'Team Member', 'Tasks'),
('task_priority', 'Priority Matrix', 'Tasks organized by priority', '🔥', 2, 2, '{}', 'Team Member', 'Tasks'),
('kanban_summary', 'Kanban Overview', 'Summary of Kanban boards', '📋', 3, 1, '{}', 'Team Member', 'Tasks'),
('task_trends', 'Task Trends', 'Task completion trends', '📉', 2, 1, '{}', 'Team Member', 'Tasks'),
# Analytics Widgets
('productivity_metrics', 'Productivity', 'Personal productivity metrics', '', 1, 1, '{}', 'Team Member', 'Analytics'),
('time_distribution', 'Time Distribution', 'How time is distributed', '🥧', 2, 2, '{}', 'Team Member', 'Analytics'),
('goal_progress', 'Goals', 'Progress towards goals', '🎯', 1, 1, '{}', 'Team Member', 'Analytics'),
('performance_comparison', 'Performance', 'Performance comparison over time', '📊', 2, 1, '{}', 'Team Member', 'Analytics'),
# Team Widgets (Role-based)
('team_overview', 'Team Overview', 'Overview of team performance', '👥', 3, 2, '{}', 'Team Leader', 'Team'),
('resource_allocation', 'Resources', 'Team resource allocation', '📊', 2, 2, '{}', 'Administrator', 'Team'),
('team_performance', 'Team Performance', 'Team performance metrics', '📈', 3, 1, '{}', 'Supervisor', 'Team'),
('company_metrics', 'Company Metrics', 'Company-wide metrics', '🏢', 3, 2, '{}', 'System Administrator', 'Team'),
# Quick Action Widgets
('quick_timer', 'Quick Timer', 'Quick time tracking controls', '▶️', 1, 1, '{}', 'Team Member', 'Actions'),
('favorite_projects', 'Favorites', 'Quick access to favorite projects', '', 1, 2, '{}', 'Team Member', 'Actions'),
('recent_actions', 'Recent Actions', 'Recently performed actions', '🕒', 2, 1, '{}', 'Team Member', 'Actions'),
('shortcuts_panel', 'Shortcuts', 'Quick action shortcuts', '🚀', 1, 1, '{}', 'Team Member', 'Actions'),
]
cursor.executemany("""
INSERT INTO widget_template
(widget_type, name, description, icon, default_width, default_height, default_config, required_role, category)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", default_templates)
conn.commit()
print("Dashboard system migration completed successfully!")
return True
except Exception as e:
print(f"Error during Dashboard 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."""
@@ -1004,6 +1149,8 @@ def main():
help='Run only system events migration') help='Run only system events migration')
parser.add_argument('--kanban', '-k', action='store_true', parser.add_argument('--kanban', '-k', action='store_true',
help='Run only Kanban system migration') help='Run only Kanban system migration')
parser.add_argument('--dashboard', '--dash', action='store_true',
help='Run only dashboard system migration')
args = parser.parse_args() args = parser.parse_args()
@@ -1039,6 +1186,9 @@ def main():
elif args.kanban: elif args.kanban:
migrate_kanban_system(db_path) migrate_kanban_system(db_path)
elif args.dashboard:
migrate_dashboard_system(db_path)
else: else:
# Default: run all migrations # Default: run all migrations
run_all_migrations(db_path) run_all_migrations(db_path)

162
models.py
View File

@@ -831,3 +831,165 @@ class KanbanCard(db.Model):
"""Check if a user can access this card""" """Check if a user can access this card"""
# Check board's project permissions # Check board's project permissions
return self.column.board.project.is_user_allowed(user) return self.column.board.project.is_user_allowed(user)
# Dashboard Widget System
class WidgetType(enum.Enum):
# Time Tracking Widgets
CURRENT_TIMER = "current_timer"
DAILY_SUMMARY = "daily_summary"
WEEKLY_CHART = "weekly_chart"
BREAK_REMINDER = "break_reminder"
# Project Management Widgets
ACTIVE_PROJECTS = "active_projects"
PROJECT_PROGRESS = "project_progress"
PROJECT_ACTIVITY = "project_activity"
PROJECT_DEADLINES = "project_deadlines"
# Task Management Widgets
ASSIGNED_TASKS = "assigned_tasks"
TASK_PRIORITY = "task_priority"
KANBAN_SUMMARY = "kanban_summary"
TASK_TRENDS = "task_trends"
# Analytics Widgets
PRODUCTIVITY_METRICS = "productivity_metrics"
TIME_DISTRIBUTION = "time_distribution"
GOAL_PROGRESS = "goal_progress"
PERFORMANCE_COMPARISON = "performance_comparison"
# Team Widgets (Role-based)
TEAM_OVERVIEW = "team_overview"
RESOURCE_ALLOCATION = "resource_allocation"
TEAM_PERFORMANCE = "team_performance"
COMPANY_METRICS = "company_metrics"
# Quick Action Widgets
QUICK_TIMER = "quick_timer"
FAVORITE_PROJECTS = "favorite_projects"
RECENT_ACTIONS = "recent_actions"
SHORTCUTS_PANEL = "shortcuts_panel"
class WidgetSize(enum.Enum):
SMALL = "1x1" # 1 grid unit
MEDIUM = "2x1" # 2 grid units wide, 1 high
LARGE = "2x2" # 2x2 grid units
WIDE = "3x1" # 3 grid units wide, 1 high
TALL = "1x2" # 1 grid unit wide, 2 high
EXTRA_LARGE = "3x2" # 3x2 grid units
class UserDashboard(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
name = db.Column(db.String(100), default='My Dashboard')
is_default = db.Column(db.Boolean, default=True)
layout_config = db.Column(db.Text) # JSON string for grid layout configuration
# Dashboard settings
grid_columns = db.Column(db.Integer, default=6) # Number of grid columns
theme = db.Column(db.String(20), default='light') # light, dark, auto
auto_refresh = db.Column(db.Integer, default=300) # Auto-refresh interval in seconds
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
user = db.relationship('User', backref='dashboards')
widgets = db.relationship('DashboardWidget', backref='dashboard', lazy=True, cascade='all, delete-orphan')
# Unique constraint - one default dashboard per user
__table_args__ = (db.Index('idx_user_default_dashboard', 'user_id', 'is_default'),)
def __repr__(self):
return f'<UserDashboard {self.name} (User: {self.user.username})>'
class DashboardWidget(db.Model):
id = db.Column(db.Integer, primary_key=True)
dashboard_id = db.Column(db.Integer, db.ForeignKey('user_dashboard.id'), nullable=False)
widget_type = db.Column(db.Enum(WidgetType), nullable=False)
# Grid position and size
grid_x = db.Column(db.Integer, nullable=False, default=0) # X position in grid
grid_y = db.Column(db.Integer, nullable=False, default=0) # Y position in grid
grid_width = db.Column(db.Integer, nullable=False, default=1) # Width in grid units
grid_height = db.Column(db.Integer, nullable=False, default=1) # Height in grid units
# Widget configuration
title = db.Column(db.String(100)) # Custom widget title
config = db.Column(db.Text) # JSON string for widget-specific configuration
refresh_interval = db.Column(db.Integer, default=60) # Refresh interval in seconds
# Widget state
is_visible = db.Column(db.Boolean, default=True)
is_minimized = db.Column(db.Boolean, default=False)
z_index = db.Column(db.Integer, default=1) # Stacking order
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
def __repr__(self):
return f'<DashboardWidget {self.widget_type.value} ({self.grid_width}x{self.grid_height})>'
@property
def config_dict(self):
"""Parse widget configuration JSON"""
if self.config:
import json
try:
return json.loads(self.config)
except:
return {}
return {}
@config_dict.setter
def config_dict(self, value):
"""Set widget configuration as JSON"""
import json
self.config = json.dumps(value) if value else None
class WidgetTemplate(db.Model):
"""Pre-defined widget templates for easy dashboard setup"""
id = db.Column(db.Integer, primary_key=True)
widget_type = db.Column(db.Enum(WidgetType), nullable=False)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text)
icon = db.Column(db.String(50)) # Icon name or emoji
# Default configuration
default_width = db.Column(db.Integer, default=1)
default_height = db.Column(db.Integer, default=1)
default_config = db.Column(db.Text) # JSON string for default widget configuration
# Access control
required_role = db.Column(db.Enum(Role), default=Role.TEAM_MEMBER)
is_active = db.Column(db.Boolean, default=True)
# Categories for organization
category = db.Column(db.String(50), default='General') # Time, Projects, Tasks, Analytics, Team, Actions
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
def __repr__(self):
return f'<WidgetTemplate {self.name} ({self.widget_type.value})>'
def can_user_access(self, user):
"""Check if user has required role to use this widget"""
if not self.is_active:
return False
# Define role hierarchy
role_hierarchy = {
Role.TEAM_MEMBER: 1,
Role.TEAM_LEADER: 2,
Role.SUPERVISOR: 3,
Role.ADMIN: 4,
Role.SYSTEM_ADMIN: 5
}
user_level = role_hierarchy.get(user.role, 0)
required_level = role_hierarchy.get(self.required_role, 0)
return user_level >= required_level

File diff suppressed because it is too large Load Diff

View File

@@ -41,6 +41,7 @@
<ul> <ul>
{% if g.user %} {% if g.user %}
<li><a href="{{ url_for('home') }}" data-tooltip="Home"><i class="nav-icon">🏠</i><span class="nav-text">Home</span></a></li> <li><a href="{{ url_for('home') }}" data-tooltip="Home"><i class="nav-icon">🏠</i><span class="nav-text">Home</span></a></li>
<li><a href="{{ url_for('dashboard') }}" data-tooltip="Dashboard"><i class="nav-icon">📊</i><span class="nav-text">Dashboard</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('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>