Squashed commit of the following:

commit 1eeea9f83ad9230a5c1f7a75662770eaab0df837
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 21:15:41 2025 +0200

    Disable resuming of old time entries.

commit 3e3ec2f01cb7943622b819a19179388078ae1315
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 20:59:19 2025 +0200

    Refactor db migrations.

commit 15a51a569da36c6b7c9e01ab17b6fdbdee6ad994
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 19:58:04 2025 +0200

    Apply new style for Time Tracking view.

commit 77e5278b303e060d2b03853b06277f8aa567ae68
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 18:06:04 2025 +0200

    Allow direct registrations as a Company.

commit 188a8772757cbef374243d3a5f29e4440ddecabe
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 18:04:45 2025 +0200

    Add email invitation feature.

commit d9ebaa02aa01b518960a20dccdd5a327d82f30c6
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 17:12:32 2025 +0200

    Apply common style for Company, User, Team management pages.

commit 81149caf4d8fc6317e2ab1b4f022b32fc5aa6d22
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 16:44:32 2025 +0200

    Move export functions to own module.

commit 1a26e19338e73f8849c671471dd15cc3c1b1fe82
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 15:51:15 2025 +0200

    Split up models.py.

commit 61f1ccd10f721b0ff4dc1eccf30c7a1ee13f204d
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 12:05:28 2025 +0200

    Move utility function into own modules.

commit 84b341ed35e2c5387819a8b9f9d41eca900ae79f
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 11:44:24 2025 +0200

    Refactor auth functions use.

commit 923e311e3da5b26d85845c2832b73b7b17c48adb
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 11:35:52 2025 +0200

    Refactor route nameing and fix bugs along the way.

commit f0a5c4419c340e62a2615c60b2a9de28204d2995
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 10:34:33 2025 +0200

    Fix URL endpoints in announcement template.

commit b74d74542a1c8dc350749e4788a9464d067a88b5
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 09:25:53 2025 +0200

    Move announcements to own module.

commit 9563a28021ac46c82c04fe4649b394dbf96f92c7
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 09:16:30 2025 +0200

    Combine Company view and edit templates.

commit 6687c373e681d54e4deab6b2582fed5cea9aadf6
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 08:17:42 2025 +0200

    Move Users, Company and System Administration to own modules.

commit 8b7894a2e3eb84bb059f546648b6b9536fea724e
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 07:40:57 2025 +0200

    Move Teams and Projects to own modules.

commit d11bf059d99839ecf1f5d7020b8c8c8a2454c00b
Author: Jens Luedicke <jens@luedicke.me>
Date:   Mon Jul 7 07:09:33 2025 +0200

    Move Tasks and Sprints to own modules.
This commit is contained in:
2025-07-07 21:16:36 +02:00
parent 4214e88d18
commit 9a79778ad6
116 changed files with 21063 additions and 5653 deletions

511
routes/system_admin.py Normal file
View File

@@ -0,0 +1,511 @@
"""
System Administrator routes
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, g, current_app
from models import (db, Company, User, Role, Team, Project, TimeEntry, SystemSettings,
SystemEvent, BrandingSettings, Task, SubTask, TaskDependency, Sprint,
Comment, UserPreferences, UserDashboard, WorkConfig, CompanySettings,
CompanyWorkConfig, ProjectCategory)
from routes.auth import system_admin_required
from flask import session
from sqlalchemy import func
from datetime import datetime, timedelta
import logging
import os
logger = logging.getLogger(__name__)
system_admin_bp = Blueprint('system_admin', __name__, url_prefix='/system-admin')
@system_admin_bp.route('/dashboard')
@system_admin_required
def system_admin_dashboard():
"""System Administrator Dashboard - view all data across companies"""
# Global statistics
total_companies = Company.query.count()
total_users = User.query.count()
total_teams = Team.query.count()
total_projects = Project.query.count()
total_time_entries = TimeEntry.query.count()
# System admin count
system_admins = User.query.filter_by(role=Role.SYSTEM_ADMIN).count()
regular_admins = User.query.filter_by(role=Role.ADMIN).count()
# Recent activity (last 7 days)
week_ago = datetime.now() - timedelta(days=7)
recent_users = User.query.filter(User.created_at >= week_ago).count()
recent_companies = Company.query.filter(Company.created_at >= week_ago).count()
recent_time_entries = TimeEntry.query.filter(TimeEntry.arrival_time >= week_ago).count()
# Top companies by user count
top_companies = db.session.query(
Company.name,
Company.id,
db.func.count(User.id).label('user_count')
).join(User).group_by(Company.id).order_by(db.func.count(User.id).desc()).limit(5).all()
# Recent companies
recent_companies_list = Company.query.order_by(Company.created_at.desc()).limit(5).all()
# System health checks
orphaned_users = User.query.filter_by(company_id=None).count()
orphaned_time_entries = TimeEntry.query.filter_by(user_id=None).count()
blocked_users = User.query.filter_by(is_blocked=True).count()
return render_template('system_admin_dashboard.html',
title='System Administrator Dashboard',
total_companies=total_companies,
total_users=total_users,
total_teams=total_teams,
total_projects=total_projects,
total_time_entries=total_time_entries,
system_admins=system_admins,
regular_admins=regular_admins,
recent_users=recent_users,
recent_companies=recent_companies,
recent_time_entries=recent_time_entries,
top_companies=top_companies,
recent_companies_list=recent_companies_list,
orphaned_users=orphaned_users,
orphaned_time_entries=orphaned_time_entries,
blocked_users=blocked_users)
@system_admin_bp.route('/companies')
@system_admin_required
def system_admin_companies():
"""System admin view of all companies"""
# Get filter parameters
search_query = request.args.get('search', '')
# Base query
query = Company.query
# Apply search filter
if search_query:
query = query.filter(
db.or_(
Company.name.ilike(f'%{search_query}%'),
Company.slug.ilike(f'%{search_query}%')
)
)
# Get all companies
companies = query.order_by(Company.created_at.desc()).all()
# Create a paginated response object
from flask_sqlalchemy import Pagination
page = request.args.get('page', 1, type=int)
per_page = 20
# Paginate companies
companies_paginated = query.paginate(page=page, per_page=per_page, error_out=False)
# Calculate statistics for each company
company_stats = {}
for company in companies_paginated.items:
company_stats[company.id] = {
'user_count': User.query.filter_by(company_id=company.id).count(),
'admin_count': User.query.filter_by(company_id=company.id, role=Role.ADMIN).count(),
'team_count': Team.query.filter_by(company_id=company.id).count(),
'project_count': Project.query.filter_by(company_id=company.id).count(),
'active_projects': Project.query.filter_by(company_id=company.id, is_active=True).count(),
}
return render_template('system_admin_companies.html',
title='System Admin - Companies',
companies=companies_paginated,
company_stats=company_stats,
search_query=search_query)
@system_admin_bp.route('/companies/<int:company_id>')
@system_admin_required
def system_admin_company_detail(company_id):
"""System admin detailed view of a specific company"""
company = Company.query.get_or_404(company_id)
# Get recent time entries count
week_ago = datetime.now() - timedelta(days=7)
recent_time_entries = TimeEntry.query.join(User).filter(
User.company_id == company.id,
TimeEntry.arrival_time >= week_ago
).count()
# Get role distribution
role_counts = {}
for role in Role:
count = User.query.filter_by(company_id=company.id, role=role).count()
if count > 0:
role_counts[role.value] = count
# Get users list
users = User.query.filter_by(company_id=company.id).order_by(User.created_at.desc()).all()
# Get teams list
teams = Team.query.filter_by(company_id=company.id).all()
# Get projects list
projects = Project.query.filter_by(company_id=company.id).order_by(Project.created_at.desc()).all()
return render_template('system_admin_company_detail.html',
title=f'Company Details - {company.name}',
company=company,
users=users,
teams=teams,
projects=projects,
recent_time_entries=recent_time_entries,
role_counts=role_counts)
@system_admin_bp.route('/companies/<int:company_id>/delete', methods=['POST'])
@system_admin_required
def delete_company(company_id):
"""System Admin: Delete a company and all its data"""
company = Company.query.get_or_404(company_id)
company_name = company.name
try:
# Delete all related data in the correct order to avoid foreign key constraints
# Delete comments (must be before tasks)
Comment.query.filter(Comment.task_id.in_(
db.session.query(Task.id).join(Project).filter(Project.company_id == company_id)
)).delete(synchronize_session=False)
# Delete subtasks
SubTask.query.filter(SubTask.task_id.in_(
db.session.query(Task.id).join(Project).filter(Project.company_id == company_id)
)).delete(synchronize_session=False)
# Delete task dependencies
TaskDependency.query.filter(
TaskDependency.blocked_task_id.in_(
db.session.query(Task.id).join(Project).filter(Project.company_id == company_id)
) | TaskDependency.blocking_task_id.in_(
db.session.query(Task.id).join(Project).filter(Project.company_id == company_id)
)
).delete(synchronize_session=False)
# Delete tasks
Task.query.filter(Task.project_id.in_(
db.session.query(Project.id).filter(Project.company_id == company_id)
)).delete(synchronize_session=False)
# Delete sprints
Sprint.query.filter(Sprint.project_id.in_(
db.session.query(Project.id).filter(Project.company_id == company_id)
)).delete(synchronize_session=False)
# Delete time entries
TimeEntry.query.filter(TimeEntry.user_id.in_(
db.session.query(User.id).filter(User.company_id == company_id)
)).delete(synchronize_session=False)
# Delete projects
Project.query.filter_by(company_id=company_id).delete()
# Delete teams
Team.query.filter_by(company_id=company_id).delete()
# Delete user preferences, dashboards, and work configs
UserPreferences.query.filter(UserPreferences.user_id.in_(
db.session.query(User.id).filter(User.company_id == company_id)
)).delete(synchronize_session=False)
UserDashboard.query.filter(UserDashboard.user_id.in_(
db.session.query(User.id).filter(User.company_id == company_id)
)).delete(synchronize_session=False)
WorkConfig.query.filter(WorkConfig.user_id.in_(
db.session.query(User.id).filter(User.company_id == company_id)
)).delete(synchronize_session=False)
# Delete users
User.query.filter_by(company_id=company_id).delete()
# Delete company settings and work config
CompanySettings.query.filter_by(company_id=company_id).delete()
CompanyWorkConfig.query.filter_by(company_id=company_id).delete()
# Delete project categories
ProjectCategory.query.filter_by(company_id=company_id).delete()
# Finally, delete the company
db.session.delete(company)
db.session.commit()
flash(f'Company "{company_name}" and all its data have been permanently deleted.', 'success')
logger.info(f"System admin {g.user.username} deleted company {company_name} (ID: {company_id})")
except Exception as e:
db.session.rollback()
logger.error(f"Error deleting company {company_id}: {str(e)}")
flash(f'Error deleting company: {str(e)}', 'error')
return redirect(url_for('system_admin.system_admin_company_detail', company_id=company_id))
return redirect(url_for('system_admin.system_admin_companies'))
@system_admin_bp.route('/time-entries')
@system_admin_required
def system_admin_time_entries():
"""System Admin: View time entries across all companies"""
page = request.args.get('page', 1, type=int)
company_filter = request.args.get('company', '')
per_page = 50
# Build query properly with explicit joins
query = db.session.query(
TimeEntry,
User.username,
Company.name.label('company_name'),
Project.name.label('project_name')
).join(
User, TimeEntry.user_id == User.id
).join(
Company, User.company_id == Company.id
).outerjoin(
Project, TimeEntry.project_id == Project.id
)
# Apply company filter
if company_filter:
query = query.filter(Company.id == company_filter)
# Order by arrival time (newest first)
query = query.order_by(TimeEntry.arrival_time.desc())
# Paginate
entries = query.paginate(page=page, per_page=per_page, error_out=False)
# Get companies for filter dropdown
companies = Company.query.order_by(Company.name).all()
# Get today's date for the template
today = datetime.now().date()
return render_template('system_admin_time_entries.html',
title='System Admin - Time Entries',
entries=entries,
companies=companies,
current_company=company_filter,
today=today)
@system_admin_bp.route('/settings', methods=['GET', 'POST'])
@system_admin_required
def system_admin_settings():
"""System Admin: Global system settings"""
if request.method == 'POST':
# Update system settings
registration_enabled = request.form.get('registration_enabled') == 'on'
email_verification = request.form.get('email_verification_required') == 'on'
tracking_script_enabled = request.form.get('tracking_script_enabled') == 'on'
tracking_script_code = request.form.get('tracking_script_code', '')
# Update or create settings
reg_setting = SystemSettings.query.filter_by(key='registration_enabled').first()
if reg_setting:
reg_setting.value = 'true' if registration_enabled else 'false'
else:
reg_setting = SystemSettings(
key='registration_enabled',
value='true' if registration_enabled else 'false',
description='Controls whether new user registration is allowed'
)
db.session.add(reg_setting)
email_setting = SystemSettings.query.filter_by(key='email_verification_required').first()
if email_setting:
email_setting.value = 'true' if email_verification else 'false'
else:
email_setting = SystemSettings(
key='email_verification_required',
value='true' if email_verification else 'false',
description='Controls whether email verification is required for new accounts'
)
db.session.add(email_setting)
tracking_enabled_setting = SystemSettings.query.filter_by(key='tracking_script_enabled').first()
if tracking_enabled_setting:
tracking_enabled_setting.value = 'true' if tracking_script_enabled else 'false'
else:
tracking_enabled_setting = SystemSettings(
key='tracking_script_enabled',
value='true' if tracking_script_enabled else 'false',
description='Controls whether custom tracking script is enabled'
)
db.session.add(tracking_enabled_setting)
tracking_code_setting = SystemSettings.query.filter_by(key='tracking_script_code').first()
if tracking_code_setting:
tracking_code_setting.value = tracking_script_code
else:
tracking_code_setting = SystemSettings(
key='tracking_script_code',
value=tracking_script_code,
description='Custom tracking script code (HTML/JavaScript)'
)
db.session.add(tracking_code_setting)
db.session.commit()
flash('System settings updated successfully.', 'success')
return redirect(url_for('system_admin.system_admin_settings'))
# Get current settings
settings = {}
all_settings = SystemSettings.query.all()
for setting in all_settings:
if setting.key == 'registration_enabled':
settings['registration_enabled'] = setting.value == 'true'
elif setting.key == 'email_verification_required':
settings['email_verification_required'] = setting.value == 'true'
elif setting.key == 'tracking_script_enabled':
settings['tracking_script_enabled'] = setting.value == 'true'
elif setting.key == 'tracking_script_code':
settings['tracking_script_code'] = setting.value
# System statistics
total_companies = Company.query.count()
total_users = User.query.count()
total_system_admins = User.query.filter_by(role=Role.SYSTEM_ADMIN).count()
return render_template('system_admin_settings.html',
title='System Administrator Settings',
settings=settings,
total_companies=total_companies,
total_users=total_users,
total_system_admins=total_system_admins)
@system_admin_bp.route('/health')
@system_admin_required
def system_admin_health():
"""System Admin: System health check and event log"""
# Get system health summary
health_summary = SystemEvent.get_system_health_summary()
# Get recent events (last 7 days)
recent_events = SystemEvent.get_recent_events(days=7, limit=100)
# Get events by severity for quick stats
errors = SystemEvent.get_events_by_severity('error', days=7, limit=20)
warnings = SystemEvent.get_events_by_severity('warning', days=7, limit=20)
# System metrics
now = datetime.now()
# Database connection test
db_healthy = True
db_error = None
try:
db.session.execute('SELECT 1')
except Exception as e:
db_healthy = False
db_error = str(e)
SystemEvent.log_event(
'database_check_failed',
f'Database health check failed: {str(e)}',
'system',
'error'
)
# Application uptime (approximate based on first event)
first_event = SystemEvent.query.order_by(SystemEvent.timestamp.asc()).first()
uptime_start = first_event.timestamp if first_event else now
uptime_duration = now - uptime_start
# Recent activity stats
today = now.date()
today_events = SystemEvent.query.filter(
func.date(SystemEvent.timestamp) == today
).count()
# Log the health check
SystemEvent.log_event(
'system_health_check',
f'System health check performed by {session.get("username", "unknown")}',
'system',
'info',
user_id=session.get('user_id'),
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
return render_template('system_admin_health.html',
title='System Health Check',
health_summary=health_summary,
recent_events=recent_events,
errors=errors,
warnings=warnings,
db_healthy=db_healthy,
db_error=db_error,
uptime_duration=uptime_duration,
today_events=today_events)
@system_admin_bp.route('/branding', methods=['GET', 'POST'])
@system_admin_required
def branding():
"""System Admin: Branding settings"""
if request.method == 'POST':
branding = BrandingSettings.get_current()
# Handle form data
branding.app_name = request.form.get('app_name', g.branding.app_name).strip()
branding.logo_alt_text = request.form.get('logo_alt_text', '').strip()
branding.primary_color = request.form.get('primary_color', '#007bff').strip()
# Handle imprint settings
branding.imprint_enabled = 'imprint_enabled' in request.form
branding.imprint_title = request.form.get('imprint_title', 'Imprint').strip()
branding.imprint_content = request.form.get('imprint_content', '').strip()
branding.updated_by_id = g.user.id
# Handle logo upload
if 'logo_file' in request.files:
logo_file = request.files['logo_file']
if logo_file and logo_file.filename:
# Create uploads directory if it doesn't exist
upload_dir = os.path.join(current_app.static_folder, 'uploads', 'branding')
os.makedirs(upload_dir, exist_ok=True)
# Save the file with a timestamp to avoid conflicts
import time
filename = f"logo_{int(time.time())}_{logo_file.filename}"
logo_path = os.path.join(upload_dir, filename)
logo_file.save(logo_path)
branding.logo_filename = filename
# Handle favicon upload
if 'favicon_file' in request.files:
favicon_file = request.files['favicon_file']
if favicon_file and favicon_file.filename:
# Create uploads directory if it doesn't exist
upload_dir = os.path.join(current_app.static_folder, 'uploads', 'branding')
os.makedirs(upload_dir, exist_ok=True)
# Save the file with a timestamp to avoid conflicts
import time
filename = f"favicon_{int(time.time())}_{favicon_file.filename}"
favicon_path = os.path.join(upload_dir, filename)
favicon_file.save(favicon_path)
branding.favicon_filename = filename
db.session.commit()
flash('Branding settings updated successfully.', 'success')
return redirect(url_for('system_admin.branding'))
# Get current branding settings
branding = BrandingSettings.get_current()
return render_template('system_admin_branding.html',
title='System Administrator - Branding Settings',
branding=branding)