Files
TimeTracker/app/routes/user.py
Dries Peeters c93a37f126 feat: add overtime tracking support with configurable working hours
Implement comprehensive overtime tracking feature that allows users to
set their standard working hours per day and automatically calculates
overtime for hours worked beyond that threshold.

Core Features:
- Add standard_hours_per_day field to User model (default: 8.0 hours)
- Create Alembic migration (031_add_standard_hours_per_day.py)
- Implement overtime calculation utilities (app/utils/overtime.py)
  * calculate_daily_overtime: per-day overtime calculation
  * calculate_period_overtime: multi-day overtime aggregation
  * get_daily_breakdown: detailed day-by-day analysis
  * get_weekly_overtime_summary: weekly overtime statistics
  * get_overtime_statistics: comprehensive overtime metrics

User Interface:
- Add "Overtime Settings" section to user settings page
- Display overtime data in user reports (regular vs overtime hours)
- Show "Days with Overtime" badge in reports
- Add overtime analytics API endpoint (/api/analytics/overtime)
- Improve input field styling with cleaner appearance (no spinners)

Reports Enhancement:
- Standardize form input styling across all report pages
- Replace inline Tailwind classes with consistent form-input class
- Add FontAwesome icons to form labels for better UX
- Improve button hover states and transitions

Testing:
- Add comprehensive unit tests (tests/test_overtime.py)
- Add smoke tests for quick validation (tests/test_overtime_smoke.py)
- Test coverage for models, utilities, and various overtime scenarios

Documentation:
- OVERTIME_FEATURE_DOCUMENTATION.md: complete feature guide
- OVERTIME_IMPLEMENTATION_SUMMARY.md: technical implementation details
- docs/features/OVERTIME_TRACKING.md: quick start guide

This change enables organizations to track employee overtime accurately
based on individual working hour configurations, providing better
insights into work patterns and resource allocation.
2025-10-27 08:44:04 +01:00

216 lines
8.2 KiB
Python

"""User profile and settings routes"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from app import db
from app.models import User, Activity
from app.utils.db import safe_commit
from flask_babel import gettext as _
import pytz
user_bp = Blueprint('user', __name__)
@user_bp.route('/profile')
@login_required
def profile():
"""User profile page"""
# Get user statistics
total_hours = current_user.total_hours
active_timer = current_user.active_timer
recent_entries = current_user.get_recent_entries(limit=10)
# Get recent activities
recent_activities = Activity.get_recent(user_id=current_user.id, limit=20)
return render_template('user/profile.html',
user=current_user,
total_hours=total_hours,
active_timer=active_timer,
recent_entries=recent_entries,
recent_activities=recent_activities)
@user_bp.route('/settings', methods=['GET', 'POST'])
@login_required
def settings():
"""User settings and preferences page"""
if request.method == 'POST':
try:
# Notification preferences
current_user.email_notifications = 'email_notifications' in request.form
current_user.notification_overdue_invoices = 'notification_overdue_invoices' in request.form
current_user.notification_task_assigned = 'notification_task_assigned' in request.form
current_user.notification_task_comments = 'notification_task_comments' in request.form
current_user.notification_weekly_summary = 'notification_weekly_summary' in request.form
# Profile information
full_name = request.form.get('full_name', '').strip()
if full_name:
current_user.full_name = full_name
email = request.form.get('email', '').strip()
if email:
current_user.email = email
# Display preferences
theme_preference = request.form.get('theme_preference')
if theme_preference in ['light', 'dark', None, '']:
current_user.theme_preference = theme_preference if theme_preference else None
# Regional settings
timezone = request.form.get('timezone')
if timezone:
try:
# Validate timezone
pytz.timezone(timezone)
current_user.timezone = timezone
except pytz.exceptions.UnknownTimeZoneError:
flash(_('Invalid timezone selected'), 'error')
return redirect(url_for('user.settings'))
date_format = request.form.get('date_format')
if date_format:
current_user.date_format = date_format
time_format = request.form.get('time_format')
if time_format in ['12h', '24h']:
current_user.time_format = time_format
week_start_day = request.form.get('week_start_day', type=int)
if week_start_day is not None and 0 <= week_start_day <= 6:
current_user.week_start_day = week_start_day
# Language preference
preferred_language = request.form.get('preferred_language')
if preferred_language:
current_user.preferred_language = preferred_language
# Time rounding preferences
current_user.time_rounding_enabled = 'time_rounding_enabled' in request.form
time_rounding_minutes = request.form.get('time_rounding_minutes', type=int)
if time_rounding_minutes and time_rounding_minutes in [1, 5, 10, 15, 30, 60]:
current_user.time_rounding_minutes = time_rounding_minutes
time_rounding_method = request.form.get('time_rounding_method')
if time_rounding_method in ['nearest', 'up', 'down']:
current_user.time_rounding_method = time_rounding_method
# Overtime settings
standard_hours_per_day = request.form.get('standard_hours_per_day', type=float)
if standard_hours_per_day is not None:
# Validate range (0.5 to 24 hours)
if 0.5 <= standard_hours_per_day <= 24:
current_user.standard_hours_per_day = standard_hours_per_day
else:
flash(_('Standard hours per day must be between 0.5 and 24'), 'error')
return redirect(url_for('user.settings'))
# Save changes
if safe_commit(db.session):
# Log activity
Activity.log(
user_id=current_user.id,
action='updated',
entity_type='user',
entity_id=current_user.id,
entity_name=current_user.username,
description='Updated user settings'
)
flash(_('Settings saved successfully'), 'success')
else:
flash(_('Error saving settings'), 'error')
except Exception as e:
flash(_('Error saving settings: %(error)s', error=str(e)), 'error')
db.session.rollback()
return redirect(url_for('user.settings'))
# Get all available timezones
timezones = sorted(pytz.common_timezones)
# Get available languages from config
from flask import current_app
languages = current_app.config.get('LANGUAGES', {
'en': 'English',
'nl': 'Nederlands',
'de': 'Deutsch',
'fr': 'Français',
'it': 'Italiano',
'fi': 'Suomi'
})
# Get time rounding options
from app.utils.time_rounding import get_available_rounding_intervals, get_available_rounding_methods
rounding_intervals = get_available_rounding_intervals()
rounding_methods = get_available_rounding_methods()
return render_template('user/settings.html',
user=current_user,
timezones=timezones,
languages=languages,
rounding_intervals=rounding_intervals,
rounding_methods=rounding_methods)
@user_bp.route('/api/preferences', methods=['PATCH'])
@login_required
def update_preferences():
"""API endpoint to update user preferences (for AJAX calls)"""
try:
data = request.get_json()
if 'theme_preference' in data:
theme = data['theme_preference']
if theme in ['light', 'dark', 'system', None, '']:
current_user.theme_preference = theme if theme and theme != 'system' else None
if 'email_notifications' in data:
current_user.email_notifications = bool(data['email_notifications'])
if 'timezone' in data:
try:
pytz.timezone(data['timezone'])
current_user.timezone = data['timezone']
except pytz.exceptions.UnknownTimeZoneError:
return jsonify({'error': 'Invalid timezone'}), 400
db.session.commit()
return jsonify({
'success': True,
'message': _('Preferences updated')
})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@user_bp.route('/api/theme', methods=['POST'])
@login_required
def set_theme():
"""Quick API endpoint to set theme (for theme switcher)"""
try:
data = request.get_json()
theme = data.get('theme')
if theme in ['light', 'dark', None, '']:
current_user.theme_preference = theme if theme else None
db.session.commit()
return jsonify({
'success': True,
'theme': current_user.theme_preference or 'system'
})
return jsonify({'error': 'Invalid theme'}), 400
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500