Files
TimeTracker/app/__init__.py
Dries Peeters 69f9f1140d feat(i18n): add translations, locale switcher, and user language preference
- Integrate Flask-Babel and i18n utilities; initialize in app factory
- Add `preferred_language` to `User` with Alembic migration (011_add_user_preferred_language)
- Add `babel.cfg` and `scripts/extract_translations.py`
- Add `translations/` for en, de, fr, it, nl, fi
- Update templates to use `_()` and add language picker in navbar/profile
- Respect locale in routes and context processors; persist user preference
- Update requirements and Docker/Docker entrypoint for Babel/gettext support
- Minor copy and style adjustments across pages

Migration: run `alembic upgrade head`
2025-09-11 23:08:41 +02:00

429 lines
17 KiB
Python

import os
import logging
from datetime import timedelta
from flask import Flask, request, session
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_socketio import SocketIO
from dotenv import load_dotenv
from flask_babel import Babel, _
import re
from jinja2 import ChoiceLoader, FileSystemLoader
from werkzeug.middleware.proxy_fix import ProxyFix
# Load environment variables
load_dotenv()
# Initialize extensions
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
socketio = SocketIO()
babel = Babel()
def create_app(config=None):
"""Application factory pattern"""
app = Flask(__name__)
# Make app aware of reverse proxy (scheme/host) for correct URL generation & cookies
# Trust a single proxy by default; adjust via env if needed
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
# Configuration
app.config.from_object('app.config.Config')
if config:
app.config.update(config)
# Add top-level templates directory in addition to app/templates
extra_templates_path = os.path.abspath(
os.path.join(app.root_path, '..', 'templates')
)
app.jinja_loader = ChoiceLoader([
app.jinja_loader,
FileSystemLoader(extra_templates_path)
])
# Prefer Postgres if POSTGRES_* envs are present but URL points to SQLite
current_url = app.config.get('SQLALCHEMY_DATABASE_URI', '')
if (
not app.config.get('TESTING')
and isinstance(current_url, str)
and current_url.startswith('sqlite')
and (
os.getenv('POSTGRES_DB')
or os.getenv('POSTGRES_USER')
or os.getenv('POSTGRES_PASSWORD')
)
):
pg_user = os.getenv('POSTGRES_USER', 'timetracker')
pg_pass = os.getenv('POSTGRES_PASSWORD', 'timetracker')
pg_db = os.getenv('POSTGRES_DB', 'timetracker')
pg_host = os.getenv('POSTGRES_HOST', 'db')
app.config['SQLALCHEMY_DATABASE_URI'] = (
f'postgresql+psycopg2://{pg_user}:{pg_pass}@{pg_host}:5432/{pg_db}'
)
# Initialize extensions
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
socketio.init_app(app, cors_allowed_origins="*")
# Ensure translations exist and configure absolute translation directories before Babel init
try:
translations_dirs = (app.config.get('BABEL_TRANSLATION_DIRECTORIES') or 'translations').split(',')
base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
abs_dirs = []
for d in translations_dirs:
d = d.strip()
if not d:
continue
abs_dirs.append(d if os.path.isabs(d) else os.path.abspath(os.path.join(base_path, d)))
if abs_dirs:
app.config['BABEL_TRANSLATION_DIRECTORIES'] = os.pathsep.join(abs_dirs)
# Best-effort compile with Babel CLI if available, else Python fallback
try:
import subprocess
subprocess.run(['pybabel', 'compile', '-d', abs_dirs[0]], check=False)
except Exception:
pass
from app.utils.i18n import ensure_translations_compiled
for d in abs_dirs:
ensure_translations_compiled(d)
except Exception:
pass
# Internationalization: locale selector compatible with Flask-Babel v4+
def _select_locale():
try:
# 1) User preference from DB
from flask_login import current_user
if current_user and getattr(current_user, 'is_authenticated', False):
pref = getattr(current_user, 'preferred_language', None)
if pref:
return pref
# 2) Session override (set-language route)
if 'preferred_language' in session:
return session.get('preferred_language')
# 3) Best match with Accept-Language
supported = list(app.config.get('LANGUAGES', {}).keys()) or ['en']
return request.accept_languages.best_match(supported) or app.config.get('BABEL_DEFAULT_LOCALE', 'en')
except Exception:
return app.config.get('BABEL_DEFAULT_LOCALE', 'en')
babel.init_app(
app,
default_locale=app.config.get('BABEL_DEFAULT_LOCALE', 'en'),
default_timezone=app.config.get('TZ', 'Europe/Rome'),
locale_selector=_select_locale,
)
# Ensure gettext helpers available in Jinja
try:
from flask_babel import gettext as _gettext, ngettext as _ngettext
app.jinja_env.globals.update(_=_gettext, ngettext=_ngettext)
except Exception:
pass
# Log effective database URL (mask password)
db_url = app.config.get('SQLALCHEMY_DATABASE_URI', '')
try:
masked_db_url = re.sub(r"//([^:]+):[^@]+@", r"//\\1:***@", db_url)
except Exception:
masked_db_url = db_url
app.logger.info(f"Using database URL: {masked_db_url}")
# Configure login manager
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
login_manager.login_message_category = 'info'
# Internationalization selector handled via babel.init_app(locale_selector=...)
# Register user loader
@login_manager.user_loader
def load_user(user_id):
"""Load user for Flask-Login"""
from app.models import User
return User.query.get(int(user_id))
# Request logging for /login to trace POSTs reaching the app
@app.before_request
def log_login_requests():
try:
if request.path == '/login':
app.logger.info("%s %s from %s UA=%s", request.method, request.path, request.headers.get('X-Forwarded-For') or request.remote_addr, request.headers.get('User-Agent'))
except Exception:
pass
# Log all write operations and their outcomes
@app.after_request
def log_write_requests(response):
try:
if request.method in ('POST', 'PUT', 'PATCH', 'DELETE'):
app.logger.info(
"%s %s -> %s from %s",
request.method,
request.path,
response.status_code,
request.headers.get('X-Forwarded-For') or request.remote_addr,
)
except Exception:
pass
return response
# Configure session
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(
seconds=int(os.getenv('PERMANENT_SESSION_LIFETIME', 86400))
)
# Setup logging
setup_logging(app)
# Register blueprints
from app.routes.auth import auth_bp
from app.routes.main import main_bp
from app.routes.projects import projects_bp
from app.routes.timer import timer_bp
from app.routes.reports import reports_bp
from app.routes.admin import admin_bp
from app.routes.api import api_bp
from app.routes.analytics import analytics_bp
from app.routes.tasks import tasks_bp
from app.routes.invoices import invoices_bp
from app.routes.clients import clients_bp
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
app.register_blueprint(projects_bp)
app.register_blueprint(timer_bp)
app.register_blueprint(reports_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(api_bp)
app.register_blueprint(analytics_bp)
app.register_blueprint(tasks_bp)
app.register_blueprint(invoices_bp)
app.register_blueprint(clients_bp)
# Initialize phone home function if enabled
if app.config.get('LICENSE_SERVER_ENABLED', True):
try:
from app.utils.license_server import init_license_client, start_license_client, get_license_client
# Check if client is already running
existing_client = get_license_client()
if existing_client and existing_client.running:
app.logger.info("Phone home function already running, skipping initialization")
else:
license_client = init_license_client(
app_identifier=app.config.get('LICENSE_SERVER_APP_ID', 'timetracker'),
app_version=app.config.get('LICENSE_SERVER_APP_VERSION', '1.0.0')
)
if start_license_client():
app.logger.info("Phone home function started successfully")
else:
app.logger.warning("Failed to start phone home function")
except Exception as e:
app.logger.warning(f"Could not initialize phone home function: {e}")
# Register cleanup function for graceful shutdown
@app.teardown_appcontext
def cleanup_license_client(exception=None):
"""Cleanup phone home function on app context teardown"""
try:
from app.utils.license_server import get_license_client, stop_license_client
client = get_license_client()
if client and client.running:
app.logger.info("Stopping phone home function during app teardown")
stop_license_client()
except Exception as e:
app.logger.warning(f"Error during license client cleanup: {e}")
# Register error handlers
from app.utils.error_handlers import register_error_handlers
register_error_handlers(app)
# Register context processors
from app.utils.context_processors import register_context_processors
register_context_processors(app)
# (translations compiled and directories set before Babel init)
# Register template filters
from app.utils.template_filters import register_template_filters
register_template_filters(app)
# Register CLI commands
from app.utils.cli import register_cli_commands
register_cli_commands(app)
# Promote configured admin usernames automatically on each request (idempotent)
@app.before_request
def _promote_admin_users_on_request():
try:
from flask_login import current_user
if not current_user or not getattr(current_user, 'is_authenticated', False):
return
admin_usernames = [u.strip().lower() for u in app.config.get('ADMIN_USERNAMES', ['admin'])]
if current_user.username and current_user.username.lower() in admin_usernames and current_user.role != 'admin':
current_user.role = 'admin'
db.session.commit()
except Exception:
# Non-fatal; avoid breaking requests if this fails
try:
db.session.rollback()
except Exception:
pass
# Initialize database on first request
def initialize_database():
try:
# Import models to ensure they are registered
from app.models import User, Project, TimeEntry, Task, Settings, TaskActivity
# Create database tables
db.create_all()
# Check and migrate Task Management tables if needed
migrate_task_management_tables()
# Create default admin user if it doesn't exist
admin_username = app.config.get('ADMIN_USERNAMES', ['admin'])[0]
if not User.query.filter_by(username=admin_username).first():
admin_user = User(
username=admin_username,
role='admin'
)
admin_user.is_active = True
db.session.add(admin_user)
db.session.commit()
print(f"Created default admin user: {admin_username}")
print("Database initialized successfully")
except Exception as e:
print(f"Error initializing database: {e}")
# Don't raise the exception, just log it
# Store the initialization function for later use
app.initialize_database = initialize_database
return app
def setup_logging(app):
"""Setup application logging"""
log_level = os.getenv('LOG_LEVEL', 'INFO')
# Default to a file in the project logs directory if not provided
default_log_path = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'logs', 'timetracker.log'))
log_file = os.getenv('LOG_FILE', default_log_path)
# Prepare handlers
handlers = [logging.StreamHandler()]
# Add file handler (default or specified)
try:
# Ensure log directory exists
log_dir = os.path.dirname(log_file)
if log_dir and not os.path.exists(log_dir):
os.makedirs(log_dir, exist_ok=True)
# Create file handler
file_handler = logging.FileHandler(log_file)
handlers.append(file_handler)
except (PermissionError, OSError) as e:
print(f"Warning: Could not create log file '{log_file}': {e}")
print("Logging to console only")
# Don't add file handler, just use console logging
# Configure Flask app logger directly (works well under gunicorn)
for handler in handlers:
handler.setLevel(getattr(logging, log_level.upper()))
handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'))
# Clear existing handlers to avoid duplicate logs
app.logger.handlers.clear()
app.logger.propagate = False
app.logger.setLevel(getattr(logging, log_level.upper()))
for handler in handlers:
app.logger.addHandler(handler)
# Also configure root logger so modules using logging.getLogger() are captured
root_logger = logging.getLogger()
root_logger.setLevel(getattr(logging, log_level.upper()))
# Avoid duplicating handlers if already attached
root_logger.handlers = []
for handler in handlers:
root_logger.addHandler(handler)
# Suppress noisy logs in production
if not app.debug:
logging.getLogger('werkzeug').setLevel(logging.ERROR)
def migrate_task_management_tables():
"""Check and migrate Task Management tables if they don't exist"""
try:
from sqlalchemy import inspect, text
# Check if tasks table exists
inspector = inspect(db.engine)
existing_tables = inspector.get_table_names()
if 'tasks' not in existing_tables:
print("Task Management: Creating tasks table...")
# Create the tasks table
db.create_all()
print("✓ Tasks table created successfully")
else:
print("Task Management: Tasks table already exists")
# Check if task_id column exists in time_entries table
if 'time_entries' in existing_tables:
time_entries_columns = [col['name'] for col in inspector.get_columns('time_entries')]
if 'task_id' not in time_entries_columns:
print("Task Management: Adding task_id column to time_entries table...")
try:
# Add task_id column to time_entries table
db.engine.execute(text("ALTER TABLE time_entries ADD COLUMN task_id INTEGER REFERENCES tasks(id)"))
print("✓ task_id column added to time_entries table")
except Exception as e:
print(f"⚠ Warning: Could not add task_id column: {e}")
print(" You may need to manually add this column or recreate the database")
else:
print("Task Management: task_id column already exists in time_entries table")
print("Task Management migration check completed")
except Exception as e:
print(f"⚠ Warning: Task Management migration check failed: {e}")
print(" The application will continue, but Task Management features may not work properly")
def init_database(app):
"""Initialize database tables and create default admin user"""
with app.app_context():
try:
# Import models to ensure they are registered
from app.models import User, Project, TimeEntry, Task, Settings, TaskActivity
# Create database tables
db.create_all()
# Check and migrate Task Management tables if needed
migrate_task_management_tables()
# Create default admin user if it doesn't exist
admin_username = app.config.get('ADMIN_USERNAMES', ['admin'])[0]
if not User.query.filter_by(username=admin_username).first():
admin_user = User(
username=admin_username,
role='admin'
)
admin_user.is_active = True
db.session.add(admin_user)
db.session.commit()
print(f"Created default admin user: {admin_username}")
print("Database initialized successfully")
except Exception as e:
print(f"Error initializing database: {e}")
raise