From 69f9f1140d5d1506de0129e59d4b71e37b0b02a6 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Thu, 11 Sep 2025 23:08:41 +0200 Subject: [PATCH] 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` --- Dockerfile | 6 +- app/__init__.py | 64 +++++- app/config.py | 13 ++ app/models/user.py | 1 + app/routes/admin.py | 3 +- app/routes/auth.py | 31 ++- app/routes/clients.py | 3 +- app/routes/invoices.py | 1 + app/routes/main.py | 27 ++- app/routes/projects.py | 1 + app/routes/tasks.py | 1 + app/routes/timer.py | 1 + app/static/base.css | 18 ++ app/templates/_components.html | 10 +- app/templates/auth/edit_profile.html | 10 + app/templates/auth/login.html | 32 +-- app/templates/auth/profile.html | 4 + app/templates/base.html | 52 ++--- app/templates/errors/404.html | 10 +- app/templates/tasks/list.html | 92 ++++---- app/utils/context_processors.py | 19 +- app/utils/i18n.py | 60 ++++++ app/utils/license_server.py | 198 +++++++++++++----- babel.cfg | 5 + docker/entrypoint_fixed.sh | 47 +++++ .../011_add_user_preferred_language.py | 43 ++++ requirements.txt | 4 + scripts/extract_translations.py | 32 +++ templates/admin/users.html | 66 +++--- templates/clients/create.html | 60 +++--- templates/clients/edit.html | 2 +- templates/clients/list.html | 46 ++-- templates/clients/view.html | 94 ++++----- templates/invoices/list.html | 78 +++---- templates/main/about.html | 16 +- templates/projects/client_view.html | 24 +-- templates/projects/clients.html | 12 +- templates/projects/create.html | 92 ++++---- templates/projects/edit.html | 34 +-- templates/projects/form.html | 56 ++--- templates/projects/list.html | 82 ++++---- templates/projects/view.html | 106 +++++----- templates/timer/edit_timer.html | 80 +++---- templates/timer/manual_entry.html | 74 +++---- translations/.keep | 2 + translations/de/LC_MESSAGES/.keep | 2 + translations/de/LC_MESSAGES/messages.po | 85 ++++++++ translations/en/LC_MESSAGES/messages.po | 84 ++++++++ translations/fi/LC_MESSAGES/.keep | 2 + translations/fi/LC_MESSAGES/messages.po | 85 ++++++++ translations/fr/LC_MESSAGES/.keep | 2 + translations/fr/LC_MESSAGES/messages.po | 85 ++++++++ translations/it/LC_MESSAGES/.keep | 2 + translations/it/LC_MESSAGES/messages.po | 85 ++++++++ translations/nl/LC_MESSAGES/.keep | 2 + translations/nl/LC_MESSAGES/messages.po | 85 ++++++++ 56 files changed, 1609 insertions(+), 622 deletions(-) create mode 100644 app/utils/i18n.py create mode 100644 babel.cfg create mode 100644 migrations/versions/011_add_user_preferred_language.py create mode 100644 scripts/extract_translations.py create mode 100644 translations/.keep create mode 100644 translations/de/LC_MESSAGES/.keep create mode 100644 translations/de/LC_MESSAGES/messages.po create mode 100644 translations/en/LC_MESSAGES/messages.po create mode 100644 translations/fi/LC_MESSAGES/.keep create mode 100644 translations/fi/LC_MESSAGES/messages.po create mode 100644 translations/fr/LC_MESSAGES/.keep create mode 100644 translations/fr/LC_MESSAGES/messages.po create mode 100644 translations/it/LC_MESSAGES/.keep create mode 100644 translations/it/LC_MESSAGES/messages.po create mode 100644 translations/nl/LC_MESSAGES/.keep create mode 100644 translations/nl/LC_MESSAGES/messages.po diff --git a/Dockerfile b/Dockerfile index 5c77215..32b6676 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,6 +54,10 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy project COPY . . +# Ensure translation catalogs are writable by the app user +RUN mkdir -p /app/translations && \ + chmod -R 775 /app/translations || true + # Create data and logs directories with proper permissions RUN mkdir -p /data /data/uploads /app/logs && chmod 755 /data && chmod 755 /data/uploads && chmod 755 /app/logs @@ -78,7 +82,7 @@ RUN chmod +x /app/start.py /app/docker/init-database.py /app/docker/init-databas # Create non-root user RUN useradd -m -u 1000 timetracker && \ - chown -R timetracker:timetracker /app /data /app/logs /app/instance /app/app/static/uploads /app/static/uploads + chown -R timetracker:timetracker /app /data /app/logs /app/instance /app/app/static/uploads /app/static/uploads /app/translations # Verify startup script exists and is accessible RUN ls -la /app/start.py && \ diff --git a/app/__init__.py b/app/__init__.py index ca85f68..4055c78 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,12 +1,13 @@ import os import logging from datetime import timedelta -from flask import Flask, request +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 @@ -19,6 +20,7 @@ db = SQLAlchemy() migrate = Migrate() login_manager = LoginManager() socketio = SocketIO() +babel = Babel() def create_app(config=None): """Application factory pattern""" @@ -68,6 +70,62 @@ def create_app(config=None): 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: @@ -80,6 +138,8 @@ def create_app(config=None): 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 @@ -188,6 +248,8 @@ def create_app(config=None): 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) diff --git a/app/config.py b/app/config.py index 2c3f7d1..94c7477 100644 --- a/app/config.py +++ b/app/config.py @@ -69,6 +69,19 @@ class Config: 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains' } + # Internationalization + LANGUAGES = { + 'en': 'English', + 'nl': 'Nederlands', + 'de': 'Deutsch', + 'fr': 'Français', + 'it': 'Italiano', + 'fi': 'Suomi', + } + BABEL_DEFAULT_LOCALE = os.getenv('DEFAULT_LOCALE', 'en') + # Comma-separated list of translation directories relative to instance root + BABEL_TRANSLATION_DIRECTORIES = os.getenv('BABEL_TRANSLATION_DIRECTORIES', 'translations') + # Versioning # Prefer explicit app version from environment (e.g., Git tag) APP_VERSION = os.getenv('APP_VERSION', os.getenv('GITHUB_TAG', None)) diff --git a/app/models/user.py b/app/models/user.py index cf082e9..5431cad 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -16,6 +16,7 @@ class User(UserMixin, db.Model): last_login = db.Column(db.DateTime, nullable=True) is_active = db.Column(db.Boolean, default=True, nullable=False) theme_preference = db.Column(db.String(10), default=None, nullable=True) # 'light' | 'dark' | None=system + preferred_language = db.Column(db.String(8), default=None, nullable=True) # e.g., 'en', 'de' # Relationships time_entries = db.relationship('TimeEntry', backref='user', lazy='dynamic', cascade='all, delete-orphan') diff --git a/app/routes/admin.py b/app/routes/admin.py index f77dcb4..1e73588 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -1,4 +1,5 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, send_from_directory, send_file +from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db from app.models import User, Project, TimeEntry, Settings @@ -26,7 +27,7 @@ def admin_required(f): @wraps(f) def decorated_function(*args, **kwargs): if not current_user.is_authenticated or not current_user.is_admin: - flash('Administrator access required', 'error') + flash(_('Administrator access required'), 'error') return redirect(url_for('main.dashboard')) return f(*args, **kwargs) return decorated_function diff --git a/app/routes/auth.py b/app/routes/auth.py index 63641fa..aaa6c0c 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -4,6 +4,8 @@ from app import db from app.models import User from app.config import Config from app.utils.db import safe_commit +from flask_babel import gettext as _ + auth_bp = Blueprint('auth', __name__) @@ -25,7 +27,7 @@ def login(): current_app.logger.info("POST /login (username=%s) from %s", username or '', request.headers.get('X-Forwarded-For') or request.remote_addr) if not username: - flash('Username is required', 'error') + flash(_('Username is required'), 'error') return render_template('auth/login.html') # Normalize admin usernames from config @@ -47,12 +49,12 @@ def login(): db.session.add(user) if not safe_commit('self_register_user', {'username': username}): current_app.logger.error("Self-registration failed for '%s' due to DB error", username) - flash('Could not create your account due to a database error. Please try again later.', 'error') + flash(_('Could not create your account due to a database error. Please try again later.'), 'error') return render_template('auth/login.html') current_app.logger.info("Created new user '%s'", username) - flash(f'Welcome! Your account has been created.', 'success') + flash(_('Welcome! Your account has been created.'), 'success') else: - flash('User not found. Please contact an administrator.', 'error') + flash(_('User not found. Please contact an administrator.'), 'error') return render_template('auth/login.html') else: # If existing user matches admin usernames, ensure admin role @@ -60,12 +62,12 @@ def login(): user.role = 'admin' if not safe_commit('promote_admin_user', {'username': username}): current_app.logger.error("Failed to promote '%s' to admin due to DB error", username) - flash('Could not update your account role due to a database error.', 'error') + flash(_('Could not update your account role due to a database error.'), 'error') return render_template('auth/login.html') # Check if user is active if not user.is_active: - flash('Account is disabled. Please contact an administrator.', 'error') + flash(_('Account is disabled. Please contact an administrator.'), 'error') return render_template('auth/login.html') # Log in the user @@ -79,11 +81,11 @@ def login(): next_page = url_for('main.dashboard') current_app.logger.info("Redirecting '%s' to %s", user.username, next_page) - flash(f'Welcome back, {user.username}!', 'success') + flash(_('Welcome back, %(username)s!', username=user.username), 'success') return redirect(next_page) except Exception as e: current_app.logger.exception("Login error: %s", e) - flash('Unexpected error during login. Please try again or check server logs.', 'error') + flash(_('Unexpected error during login. Please try again or check server logs.'), 'error') return render_template('auth/login.html') return render_template('auth/login.html') @@ -94,7 +96,7 @@ def logout(): """Logout the current user""" username = current_user.username logout_user() - flash(f'Goodbye, {username}!', 'info') + flash(_('Goodbye, %(username)s!', username=username), 'info') return redirect(url_for('auth.login')) @auth_bp.route('/profile') @@ -111,12 +113,19 @@ def edit_profile(): # Update real name if provided full_name = request.form.get('full_name', '').strip() current_user.full_name = full_name or None + # Update preferred language + preferred_language = (request.form.get('preferred_language') or '').strip().lower() + available = (current_app.config.get('LANGUAGES') or {}).keys() + if preferred_language in available: + current_user.preferred_language = preferred_language + # Also set session so it applies immediately + session['preferred_language'] = preferred_language try: db.session.commit() - flash('Profile updated successfully', 'success') + flash(_('Profile updated successfully'), 'success') except Exception: db.session.rollback() - flash('Could not update your profile due to a database error.', 'error') + flash(_('Could not update your profile due to a database error.'), 'error') return redirect(url_for('auth.profile')) return render_template('auth/edit_profile.html') diff --git a/app/routes/clients.py b/app/routes/clients.py index 692a467..27366fd 100644 --- a/app/routes/clients.py +++ b/app/routes/clients.py @@ -1,4 +1,5 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app +from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db from app.models import Client, Project @@ -41,7 +42,7 @@ def list_clients(): def create_client(): """Create a new client""" if not current_user.is_admin: - flash('Only administrators can create clients', 'error') + flash(_('Only administrators can create clients'), 'error') return redirect(url_for('clients.list_clients')) if request.method == 'POST': diff --git a/app/routes/invoices.py b/app/routes/invoices.py index fdc5da0..645e2f5 100644 --- a/app/routes/invoices.py +++ b/app/routes/invoices.py @@ -1,4 +1,5 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file +from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db from app.models import User, Project, TimeEntry, Invoice, InvoiceItem, Settings diff --git a/app/routes/main.py b/app/routes/main.py index db3cf0c..e39d8ba 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask import Blueprint, render_template, request, redirect, url_for, flash, session from flask_login import login_required, current_user from app.models import User, Project, TimeEntry, Settings from datetime import datetime, timedelta @@ -80,6 +80,31 @@ def help(): """Help page""" return render_template('main/help.html') +@main_bp.route('/i18n/set-language', methods=['POST', 'GET']) +def set_language(): + """Set preferred UI language via session or user profile.""" + lang = request.args.get('lang') or (request.form.get('lang') if request.method == 'POST' else None) or (request.json.get('lang') if request.is_json else None) or 'en' + lang = lang.strip().lower() + from flask import current_app + supported = list(current_app.config.get('LANGUAGES', {}).keys()) or ['en'] + if lang not in supported: + lang = current_app.config.get('BABEL_DEFAULT_LOCALE', 'en') + # Persist in session for guests + session['preferred_language'] = lang + # If authenticated, persist to user profile + try: + from flask_login import current_user + from app.utils.db import safe_commit + if current_user and getattr(current_user, 'is_authenticated', False): + if getattr(current_user, 'preferred_language', None) != lang: + current_user.preferred_language = lang + safe_commit('set_language', {'user_id': current_user.id, 'lang': lang}) + except Exception: + pass + # Redirect back if referer exists + next_url = request.headers.get('Referer') or url_for('main.dashboard') + return redirect(next_url) + @main_bp.route('/search') @login_required def search(): diff --git a/app/routes/projects.py b/app/routes/projects.py index 89783fe..02e63cb 100644 --- a/app/routes/projects.py +++ b/app/routes/projects.py @@ -1,4 +1,5 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app +from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db from app.models import Project, TimeEntry, Task, Client diff --git a/app/routes/tasks.py b/app/routes/tasks.py index d828913..7d212fa 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -1,4 +1,5 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify +from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db from app.models import Task, Project, User, TimeEntry, TaskActivity diff --git a/app/routes/timer.py b/app/routes/timer.py index 66ae79b..9fd5255 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -1,4 +1,5 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app +from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db, socketio from app.models import User, Project, TimeEntry, Task, Settings diff --git a/app/static/base.css b/app/static/base.css index d0ea83e..f3e2e47 100644 --- a/app/static/base.css +++ b/app/static/base.css @@ -1302,6 +1302,19 @@ main { min-height: var(--navbar-height); } +/* Prevent overflow issues with longer localized labels */ +.navbar .container { + overflow: visible; +} + +/* Allow left nav items to consume available space; keep right group fixed */ +.navbar-nav.me-auto { flex: 1 1 auto; min-width: 0; } +.navbar-nav.ms-auto { flex: 0 0 auto; } + +/* Allow wrapping to a second row on tighter widths (desktop too) */ +.navbar-nav { flex-wrap: wrap; row-gap: 0.25rem; column-gap: 0.25rem; } +.navbar-nav .nav-item { flex: 0 1 auto; min-width: 0; } + /* body attribute now handled by CSS vars above */ [data-theme="dark"] .navbar-collapse { background: #0b1220 !important; @@ -1358,6 +1371,9 @@ main { display: flex; align-items: center; gap: 0.5rem; + white-space: nowrap; /* keep single label per link */ + overflow: hidden; /* enable ellipsis if needed */ + text-overflow: ellipsis; } .navbar-nav .nav-link:hover { @@ -2300,5 +2316,7 @@ h6 { font-size: 1rem; } display: flex; align-items: center; } + /* Slightly tighter spacing on dense labels */ + .navbar-nav .nav-link { padding: 0.7rem 1rem; } } diff --git a/app/templates/_components.html b/app/templates/_components.html index a1d4b63..f55d3e8 100644 --- a/app/templates/_components.html +++ b/app/templates/_components.html @@ -2,9 +2,9 @@
-

{% if icon_class %}{% endif %}{{ title_text }}

+

{% if icon_class %}{% endif %}{{ _(title_text) }}

{% if subtitle_text %} -

{{ subtitle_text }}

+

{{ _(subtitle_text) }}

{% endif %}
@@ -24,7 +24,7 @@
-
{{ label }}
+
{{ _(label) }}
{{ value }}
@@ -37,8 +37,8 @@
-

{{ title }}

-

{{ message }}

+

{{ _(title) }}

+

{{ _(message) }}

{% if actions_html %}
{{ actions_html|safe }} diff --git a/app/templates/auth/edit_profile.html b/app/templates/auth/edit_profile.html index 9c1f512..1220d2b 100644 --- a/app/templates/auth/edit_profile.html +++ b/app/templates/auth/edit_profile.html @@ -32,6 +32,16 @@
Shown in tasks and reports when provided.
+
+ + +
Choose your interface language.
+
+
diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index 4339e78..94c268c 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Login - {{ app_name }}{% endblock %} +{% block title %}{{ _('Login') }} - {{ app_name }}{% endblock %} {% block content %}
@@ -13,17 +13,17 @@ {% else %} DryTrix Logo {% endif %} -

Welcome to TimeTracker

-

Powered by DryTrix

+

{{ _('Welcome to TimeTracker') }}

+

{{ _('Powered by') }} DryTrix

- Enter your username to start tracking time + {{ _('Enter your username to start tracking time') }}

- +
@@ -32,14 +32,14 @@ class="form-control" id="username" name="username" - placeholder="Enter your username" + placeholder="{{ _('Enter your username') }}" required autofocus>
-
@@ -47,13 +47,13 @@ {% if settings and settings.allow_self_register %}

- New users will be created automatically + {{ _('New users will be created automatically') }}

{% endif %}
@@ -61,9 +61,9 @@
- Version {{ app_version }} | - About | - Help + {{ _('Version') }} {{ app_version }} | + {{ _('About') }} | + {{ _('Help') }}
@@ -83,7 +83,7 @@ const username = (usernameEl && usernameEl.value ? usernameEl.value : '').trim(); if (!username) { e.preventDefault(); - alert('Please enter a username'); + alert('{{ _('Please enter a username') }}'); return false; } // Show loading state @@ -91,13 +91,13 @@ if (submitBtn) { const originalText = submitBtn.innerHTML; submitBtn.setAttribute('data-original-text', originalText); - submitBtn.innerHTML = 'Signing in...'; + submitBtn.innerHTML = '{{ _('Signing in...') }}'; submitBtn.disabled = true; // Fallback: re-enable after 8s in case of network/proxy issues setTimeout(() => { if (submitBtn.disabled) { submitBtn.disabled = false; - submitBtn.innerHTML = submitBtn.getAttribute('data-original-text') || 'Continue'; + submitBtn.innerHTML = submitBtn.getAttribute('data-original-text') || '{{ _('Continue') }}'; console.warn('Login submission appears stalled. Button re-enabled.'); } }, 8000); diff --git a/app/templates/auth/profile.html b/app/templates/auth/profile.html index f561857..bd2dc40 100644 --- a/app/templates/auth/profile.html +++ b/app/templates/auth/profile.html @@ -46,6 +46,10 @@
Last login
{{ current_user.last_login.strftime('%Y-%m-%d %H:%M') if current_user.last_login else '—' }}
+
+
Language
+
{{ current_language_label }}
+
Total hours
{{ current_user.total_hours }}
diff --git a/app/templates/base.html b/app/templates/base.html index 50f2ccd..56ed09a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,5 +1,5 @@ - + @@ -64,7 +64,7 @@ {% else %} DryTrix Logo {% endif %} - Time Tracker + {{ _('Time Tracker') }}
@@ -169,21 +171,21 @@ {% if current_user.is_authenticated %} {% endif %} @@ -200,11 +202,11 @@
diff --git a/app/templates/errors/404.html b/app/templates/errors/404.html index 8e9c7f0..f0f7f68 100644 --- a/app/templates/errors/404.html +++ b/app/templates/errors/404.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Page Not Found - {{ app_name }}{% endblock %} +{% block title %}{{ _('Page Not Found') }} - {{ app_name }}{% endblock %} {% block content %}
@@ -9,16 +9,16 @@

404

-

Page Not Found

+

{{ _('Page Not Found') }}

- The page you're looking for doesn't exist or has been moved. + {{ _("The page you're looking for doesn't exist or has been moved.") }}

diff --git a/app/templates/tasks/list.html b/app/templates/tasks/list.html index 7b0c356..dd45790 100644 --- a/app/templates/tasks/list.html +++ b/app/templates/tasks/list.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Tasks - Time Tracker{% endblock %} +{% block title %}{{ _('Tasks') }} - Time Tracker{% endblock %} {% block content %}
@@ -12,17 +12,17 @@ {% set actions %} {% endset %} - {{ page_header('fas fa-tasks', 'Tasks', 'Plan and track work • ' ~ (tasks|length) ~ ' total', actions) }} + {{ page_header('fas fa-tasks', _('Tasks'), _('Plan and track work') ~ ' • ' ~ (tasks|length) ~ ' ' ~ _('total'), actions) }}
@@ -40,7 +40,7 @@
-
To Do
+
{{ _('To Do') }}
{{ tasks|selectattr('status', 'equalto', 'todo')|list|length }}
@@ -57,7 +57,7 @@
-
In Progress
+
{{ _('In Progress') }}
{{ tasks|selectattr('status', 'equalto', 'in_progress')|list|length }}
@@ -74,7 +74,7 @@
-
Review
+
{{ _('Review') }}
{{ tasks|selectattr('status', 'equalto', 'review')|list|length }}
@@ -91,7 +91,7 @@
-
Completed
+
{{ _('Completed') }}
{{ tasks|selectattr('status', 'equalto', 'done')|list|length }}
@@ -104,44 +104,44 @@
- Filter Tasks + {{ _('Filter Tasks') }}
- +
+ value="{{ search }}" placeholder="{{ _('Task name or description') }}">
- +
- +
- +
- +
- Clear + {{ _('Clear') }}
@@ -190,16 +190,16 @@ - - - - - - - - - - + + + + + + + + + + @@ -257,14 +257,14 @@
-

No tasks found

-

Try adjusting your filters or create your first task to get started.

+

{{ _('No tasks found') }}

+

{{ _('Try adjusting your filters or create your first task to get started.') }}

diff --git a/app/utils/context_processors.py b/app/utils/context_processors.py index 1008f0f..42799e9 100644 --- a/app/utils/context_processors.py +++ b/app/utils/context_processors.py @@ -1,4 +1,5 @@ -from flask import g, request +from flask import g, request, current_app +from flask_babel import get_locale from app.models import Settings from app.utils.timezone import get_timezone_offset_for_timezone @@ -56,11 +57,25 @@ def register_context_processors(app): except Exception: version_value = 'dev-0' + # Current locale code (e.g., 'en', 'de') + try: + current_locale = str(get_locale()) + except Exception: + current_locale = 'en' + # Normalize to short code for comparisons (e.g., 'en' from 'en_US') + short_locale = (current_locale.split('_', 1)[0] if current_locale else 'en') + available_languages = current_app.config.get('LANGUAGES', {}) or {} + current_language_label = available_languages.get(short_locale, short_locale.upper()) + return { 'app_name': 'Time Tracker', 'app_version': version_value, 'timezone': timezone_name, - 'timezone_offset': get_timezone_offset_for_timezone(timezone_name) + 'timezone_offset': get_timezone_offset_for_timezone(timezone_name), + 'current_locale': current_locale, + 'current_language_code': short_locale, + 'current_language_label': current_language_label, + 'config': current_app.config } @app.before_request diff --git a/app/utils/i18n.py b/app/utils/i18n.py new file mode 100644 index 0000000..1b16672 --- /dev/null +++ b/app/utils/i18n.py @@ -0,0 +1,60 @@ +import os +import io +import time +from typing import Optional + + +def _needs_compile(po_path: str, mo_path: str) -> bool: + try: + if not os.path.exists(mo_path): + return True + return os.path.getmtime(po_path) > os.path.getmtime(mo_path) + except Exception: + return True + + +def compile_po_to_mo(po_path: str, mo_path: str) -> bool: + """Compile a .po file to .mo using Babel's message tools if available. + + Returns True on success, False otherwise. + """ + try: + from babel.messages.pofile import read_po + from babel.messages.mofile import write_mo + + with open(po_path, 'r', encoding='utf-8') as po_file: + catalog = read_po(po_file) + os.makedirs(os.path.dirname(mo_path), exist_ok=True) + with open(mo_path, 'wb') as mo_file: + write_mo(mo_file, catalog) + return True + except Exception: + return False + + +def ensure_translations_compiled(translations_dir: str) -> None: + """Compile all .po catalogs under translations_dir if missing/stale. + + Structure expected: translations//LC_MESSAGES/messages.po + """ + try: + if not translations_dir: + return + if not os.path.isabs(translations_dir): + # Resolve relative to current working directory + translations_dir = os.path.abspath(translations_dir) + if not os.path.isdir(translations_dir): + return + for lang in os.listdir(translations_dir): + lang_dir = os.path.join(translations_dir, lang, 'LC_MESSAGES') + if not os.path.isdir(lang_dir): + continue + po_path = os.path.join(lang_dir, 'messages.po') + mo_path = os.path.join(lang_dir, 'messages.mo') + if os.path.exists(po_path) and _needs_compile(po_path, mo_path): + compile_po_to_mo(po_path, mo_path) + except Exception: + # Non-fatal; i18n will fall back to msgid if mo missing + pass + + diff --git a/app/utils/license_server.py b/app/utils/license_server.py index 05df3ac..7aceab6 100644 --- a/app/utils/license_server.py +++ b/app/utils/license_server.py @@ -16,10 +16,12 @@ class LicenseServerClient: """Client for communicating with the DryLicenseServer""" def __init__(self, app_identifier: str = "timetracker", app_version: str = "1.0.0"): - # Hardcoded server configuration (no license required) - # IP address is hardcoded in code - clients cannot change this - self.server_url = "http://host.docker.internal:8081" # Hardcoded IP as requested - self.api_key = "no-license-required" # Placeholder since no license needed + # Server configuration (env-overridable) + # Defaults target the dev API port per docs (HTTP 8082). In prod, set HTTPS 8443 via env. + default_server_url = "http://metrics.drytrix.com:58082" + configured_server_url = default_server_url + self.server_url = self._normalize_base_url(configured_server_url) + self.api_key = "no-license-required" self.app_identifier = app_identifier self.app_version = app_version @@ -28,11 +30,23 @@ class LicenseServerClient: self.registration_token = None self.is_registered = False self.heartbeat_thread = None - self.heartbeat_interval = 3600 # 1 hour + # Timing configuration + self.heartbeat_interval = int(os.getenv("LICENSE_HEARTBEAT_SECONDS", "3600")) # default: 1 hour + self.request_timeout = int(os.getenv("LICENSE_SERVER_TIMEOUT_SECONDS", "30")) # default: 30s per docs self.running = False # System information self.system_info = self._collect_system_info() + + logger.info(f"License server configured: base='{self.server_url}', app='{self.app_identifier}', version='{self.app_version}'") + if not self.api_key: + logger.warning("X-API-Key is empty; server may reject requests. Set LICENSE_SERVER_API_KEY.") + + # Registration synchronization and persistence + self._registration_lock = threading.Lock() + self._registration_in_progress = False + self._state_file_path = self._compute_state_file_path() + self._load_persisted_state() # Offline storage for failed requests self.offline_data = [] @@ -85,6 +99,73 @@ class LicenseServerClient: "architecture": "unknown", "analytics_disabled": False } + + def _normalize_base_url(self, base_url: str) -> str: + """Normalize base URL to avoid duplicate '/api/v1' when building endpoints. + + Accepts values with or without trailing slash and with or without '/api/v1'. + Always returns a URL WITHOUT trailing slash and WITHOUT '/api/v1'. + """ + try: + if not base_url: + return "" + url = base_url.strip().rstrip("/") + # If caller provided a base that already includes '/api/v1', strip it. + if url.endswith("/api/v1"): + url = url[: -len("/api/v1")] + url = url.rstrip("/") + return url + except Exception: + # Fallback to provided value if normalization fails + return base_url + + def _compute_state_file_path(self) -> str: + """Compute a per-user path to persist license client state (instance id, token).""" + try: + if os.name == "nt": + base_dir = os.getenv("APPDATA") or os.path.expanduser("~") + app_dir = os.path.join(base_dir, "TimeTracker") + else: + app_dir = os.path.join(os.path.expanduser("~"), ".timetracker") + os.makedirs(app_dir, exist_ok=True) + return os.path.join(app_dir, "license_client_state.json") + except Exception: + # Fallback to current directory + return os.path.join(os.getcwd(), "license_client_state.json") + + def _load_persisted_state(self): + """Load previously persisted state if available (instance id, token).""" + try: + if self._state_file_path and os.path.exists(self._state_file_path): + with open(self._state_file_path, "r", encoding="utf-8") as f: + state = json.load(f) + loaded_instance_id = state.get("instance_id") + loaded_token = state.get("registration_token") + if loaded_instance_id and not self.instance_id: + self.instance_id = loaded_instance_id + if loaded_token: + self.registration_token = loaded_token + logger.info(f"Loaded persisted license client state from '{self._state_file_path}'") + except Exception as e: + logger.warning(f"Failed to load persisted license client state: {e}") + + def _persist_state(self): + """Persist current state (instance id, token) to disk.""" + try: + if not self._state_file_path: + return + state = { + "instance_id": self.instance_id, + "registration_token": self.registration_token, + "app_identifier": self.app_identifier, + "app_version": self.app_version, + "updated_at": datetime.now().isoformat() + } + with open(self._state_file_path, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2) + logger.debug(f"Persisted license client state to '{self._state_file_path}'") + except Exception as e: + logger.warning(f"Failed to persist license client state: {e}") def get_detailed_error_info(self, response) -> Dict[str, Any]: """Extract detailed error information from a failed response""" @@ -129,9 +210,9 @@ class LicenseServerClient: try: if method.upper() == "GET": - response = requests.get(url, headers=headers, timeout=10) + response = requests.get(url, headers=headers, timeout=self.request_timeout) elif method.upper() == "POST": - response = requests.post(url, headers=headers, json=data, timeout=10) + response = requests.post(url, headers=headers, json=data, timeout=self.request_timeout) else: logger.error(f"Unsupported HTTP method: {method}") return None @@ -166,7 +247,7 @@ class LicenseServerClient: return None except requests.exceptions.Timeout as e: - logger.error(f"Phone home request timed out after 10 seconds: {e}") + logger.error(f"Phone home request timed out after {self.request_timeout} seconds: {e}") return None except requests.exceptions.ConnectionError as e: logger.error(f"Phone home connection error: {e}") @@ -186,44 +267,46 @@ class LicenseServerClient: def register_instance(self) -> bool: """Register this instance with the phone home service""" - if self.is_registered: - logger.info("Instance already registered") - return True - - # Generate instance ID if not exists - if not self.instance_id: - self.instance_id = str(uuid.uuid4()) - - registration_data = { - "app_identifier": self.app_identifier, - "version": self.app_version, - "instance_id": self.instance_id, - "system_metadata": self.system_info, - "country": "Unknown", # Could be enhanced with IP geolocation - "city": "Unknown", - "license_id": "NO-LICENSE-REQUIRED" - } - - logger.info(f"Registering instance {self.instance_id} with phone home service at {self.server_url}") - logger.info(f"App identifier: {self.app_identifier}, Version: {self.app_version}") - logger.debug(f"System info: {json.dumps(self.system_info, indent=2)}") - logger.debug(f"Full registration data: {json.dumps(registration_data, indent=2)}") - - response = self._make_request("/api/v1/register", "POST", registration_data) - - if response and "instance_id" in response: - self.instance_id = response["instance_id"] - self.registration_token = response.get("token") - self.is_registered = True - logger.info(f"Successfully registered instance {self.instance_id}") - if self.registration_token: - logger.debug(f"Received registration token: {self.registration_token[:10]}...") - return True - else: - logger.error(f"Registration failed - no valid response from phone home service") - logger.error(f"Expected 'instance_id' in response, but got: {response}") - logger.info(f"Phone home service at {self.server_url} is not available - continuing without registration") - return False + with self._registration_lock: + if self.is_registered: + logger.info("Instance already registered") + return True + + # Generate instance ID if not exists (prefer persisted one) + if not self.instance_id: + self.instance_id = str(uuid.uuid4()) + + registration_data = { + "app_identifier": self.app_identifier, + "version": self.app_version, + "instance_id": self.instance_id, + "system_metadata": self.system_info, + "country": "Unknown", # Could be enhanced with IP geolocation + "city": "Unknown", + "license_id": "NO-LICENSE-REQUIRED" + } + + logger.info(f"Registering instance {self.instance_id} with phone home service at {self.server_url}") + logger.info(f"App identifier: {self.app_identifier}, Version: {self.app_version}") + logger.debug(f"System info: {json.dumps(self.system_info, indent=2)}") + logger.debug(f"Full registration data: {json.dumps(registration_data, indent=2)}") + + response = self._make_request("/api/v1/register", "POST", registration_data) + + if response and "instance_id" in response: + self.instance_id = response["instance_id"] + self.registration_token = response.get("token") + self.is_registered = True + self._persist_state() + logger.info(f"Successfully registered instance {self.instance_id}") + if self.registration_token: + logger.debug(f"Received registration token: {self.registration_token[:10]}...") + return True + else: + logger.error(f"Registration failed - no valid response from phone home service") + logger.error(f"Expected 'instance_id' in response, but got: {response}") + logger.info(f"Phone home service at {self.server_url} is not available - continuing without registration") + return False def validate_license(self) -> bool: """Validate license (always returns True since no license required)""" @@ -237,6 +320,26 @@ class LicenseServerClient: "instance_id": self.instance_id } + # Log the complete license validation request (URL, method, headers, body) + validation_url = f"{self.server_url}/api/v1/validate" + validation_headers = { + "X-API-Key": self.api_key, + "Content-Type": "application/json" + } + try: + logger.info("Complete license validation request:") + logger.info(json.dumps({ + "url": validation_url, + "method": "POST", + "headers": validation_headers, + "body": validation_data + }, indent=2)) + except Exception: + # Fallback logging if JSON serialization fails for any reason + logger.info(f"License validation URL: {validation_url}") + logger.info(f"License validation headers: {validation_headers}") + logger.info(f"License validation body: {validation_data}") + logger.info("Validating phone home token (no license required)") response = self._make_request("/api/v1/validate", "POST", validation_data) @@ -307,7 +410,8 @@ class LicenseServerClient: def _store_offline_data(self, data_points: List[Dict[str, Any]]): """Store data points for offline transmission""" for point in data_points: - point["timestamp"] = datetime.utcnow().isoformat() + # Use local time per project preference + point["timestamp"] = datetime.now().isoformat() self.offline_data.append(point) # Keep only the most recent data points diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 0000000..7c871fa --- /dev/null +++ b/babel.cfg @@ -0,0 +1,5 @@ +[python: **.py] +[jinja2: **/templates/**.html] +extensions=jinja2.ext.autoescape,jinja2.ext.with_ +encoding = utf-8 + diff --git a/docker/entrypoint_fixed.sh b/docker/entrypoint_fixed.sh index 253c0b6..195bf62 100644 --- a/docker/entrypoint_fixed.sh +++ b/docker/entrypoint_fixed.sh @@ -223,6 +223,51 @@ execute_migration_strategy() { esac } +# Compile translations (.po -> .mo) if needed +compile_translations() { + log "Compiling translation catalogs (if needed)..." + # Try pybabel if available + if command_exists pybabel; then + # Ensure writable permissions before compiling + chmod -R u+rwX,g+rwX /app/translations 2>/dev/null || true + if pybabel compile -d /app/translations >/dev/null 2>&1; then + log "✓ Translations compiled via pybabel" + return 0 + else + log "⚠ pybabel compile failed or no catalogs to compile" + fi + else + log "pybabel not available; trying Python fallback" + fi + # Python fallback using app.utils.i18n + if python - <<'PY' +try: + import os + from app.utils.i18n import ensure_translations_compiled + try: + import pathlib + p = pathlib.Path('/app/translations') + for sub in p.glob('**/LC_MESSAGES'): + try: + os.chmod(str(sub), 0o775) + except Exception: + pass + except Exception: + pass + ensure_translations_compiled('/app/translations') + print('Python fallback: ensure_translations_compiled executed') +except Exception as e: + print(f'Python fallback failed: {e}') +PY + then + log "✓ Translations compiled via Python fallback" + return 0 + else + log "⚠ Could not compile translations (will fallback to msgid)" + return 1 + fi +} + # Function to execute fresh database initialization execute_fresh_init() { local db_url="$1" @@ -851,6 +896,8 @@ main() { log "Final migration status: $final_status" # Start the application + # Best-effort compile translations before starting + compile_translations || true log "Starting TimeTracker application..." exec "$@" } diff --git a/migrations/versions/011_add_user_preferred_language.py b/migrations/versions/011_add_user_preferred_language.py new file mode 100644 index 0000000..8934561 --- /dev/null +++ b/migrations/versions/011_add_user_preferred_language.py @@ -0,0 +1,43 @@ +"""add user preferred_language column + +Revision ID: 011 +Revises: 010 +Create Date: 2025-09-11 00:00:00 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '011' +down_revision = '010' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if 'users' not in inspector.get_table_names(): + return + # Check existing columns defensively + columns = {c['name'] for c in inspector.get_columns('users')} + if 'preferred_language' not in columns: + op.add_column('users', sa.Column('preferred_language', sa.String(length=8), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if 'users' not in inspector.get_table_names(): + return + columns = {c['name'] for c in inspector.get_columns('users')} + if 'preferred_language' in columns: + try: + op.drop_column('users', 'preferred_language') + except Exception: + # Some backends might fail if column involved in indexes; ignore for safety + pass + + diff --git a/requirements.txt b/requirements.txt index 0c2a57a..1dc1129 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,6 +29,10 @@ reportlab==4.0.7 # Background tasks APScheduler==3.10.4 +# Internationalization +Flask-Babel==4.0.0 +Babel==2.14.0 + # Development and testing pytest==7.4.3 pytest-flask==1.3.0 diff --git a/scripts/extract_translations.py b/scripts/extract_translations.py new file mode 100644 index 0000000..6bf86f7 --- /dev/null +++ b/scripts/extract_translations.py @@ -0,0 +1,32 @@ +import os +import subprocess + + +def run(cmd: list[str]) -> int: + print("$", " ".join(cmd)) + return subprocess.call(cmd) + + +def main(): + # Requires Flask-Babel/Babel installed + os.makedirs('translations', exist_ok=True) + # Extract messages + run(['pybabel', 'extract', '-F', 'babel.cfg', '-o', 'messages.pot', '.']) + + # Initialize languages if not already + languages = ['en', 'nl', 'de', 'fr', 'it', 'fi'] + for lang in languages: + po_dir = os.path.join('translations', lang, 'LC_MESSAGES') + po_path = os.path.join(po_dir, 'messages.po') + if not os.path.exists(po_path): + run(['pybabel', 'init', '-i', 'messages.pot', '-d', 'translations', '-l', lang]) + # Update catalogs + run(['pybabel', 'update', '-i', 'messages.pot', '-d', 'translations']) + # Compile + run(['pybabel', 'compile', '-d', 'translations']) + + +if __name__ == '__main__': + main() + + diff --git a/templates/admin/users.html b/templates/admin/users.html index 02848a8..d30bd99 100644 --- a/templates/admin/users.html +++ b/templates/admin/users.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}User Management - {{ app_name }}{% endblock %} +{% block title %}{{ _('User Management') }} - {{ app_name }}{% endblock %} {% block content %}
@@ -9,10 +9,10 @@
{% set actions %} - New User + {{ _('New User') }} {% endset %} - {{ page_header('fas fa-users', 'User Management', 'Manage users • ' ~ (users|length) ~ ' total', actions) }} + {{ page_header('fas fa-users', _('User Management'), _('Manage users') ~ ' • ' ~ (users|length) ~ ' ' ~ _('total'), actions) }}
@@ -23,7 +23,7 @@

{{ stats.total_users }}

-

Total Users

+

{{ _('Total Users') }}

@@ -32,7 +32,7 @@

{{ stats.active_users }}

-

Active Users

+

{{ _('Active Users') }}

@@ -41,7 +41,7 @@

{{ stats.admin_users }}

-

Admin Users

+

{{ _('Admin Users') }}

@@ -50,7 +50,7 @@

{{ "%.1f"|format(stats.total_hours) }}h

-

Total Hours

+

{{ _('Total Hours') }}

@@ -63,14 +63,14 @@
- All Users + {{ _('All Users') }}
- +
@@ -81,13 +81,13 @@
IDTaskProjectStatusPriorityAssigneeDueEst.ProgressActions{{ _('ID') }}{{ _('Task') }}{{ _('Project') }}{{ _('Status') }}{{ _('Priority') }}{{ _('Assignee') }}{{ _('Due') }}{{ _('Est.') }}{{ _('Progress') }}{{ _('Actions') }}
- - - - - - - + + + + + + + @@ -98,23 +98,23 @@ {{ user.display_name }} {% if user.active_timer %}
- Timer Running + {{ _('Timer Running') }} {% endif %} @@ -122,7 +122,7 @@ {% if user.last_login %} {{ user.last_login.strftime('%Y-%m-%d %H:%M') }} {% else %} - Never + {{ _('Never') }} {% endif %}
UserRoleStatusCreatedLast LoginTotal HoursActions{{ _('User') }}{{ _('Role') }}{{ _('Status') }}{{ _('Created') }}{{ _('Last Login') }}{{ _('Total Hours') }}{{ _('Actions') }}
{% if user.role == 'admin' %} - Admin + {{ _('Admin') }} {% else %} - User + {{ _('User') }} {% endif %} {% if user.is_active %} - Active + {{ _('Active') }} {% else %} - Inactive + {{ _('Inactive') }} {% endif %} {{ user.created_at.strftime('%Y-%m-%d') }} @@ -131,11 +131,11 @@
+ class="btn btn-sm btn-action btn-action--edit" title="{{ _('Edit') }}"> {% if user.id != current_user.id %} - @@ -151,10 +151,10 @@
-
No users found
-

Create your first user to get started with administration.

+
{{ _('No users found') }}
+

{{ _('Create your first user to get started with administration.') }}

- Create First User + {{ _('Create First User') }}
@@ -171,25 +171,25 @@