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.
511 lines
20 KiB
Python
511 lines
20 KiB
Python
"""
|
|
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) |