mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2025-12-30 07:40:51 -06:00
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.
This commit is contained in:
@@ -42,6 +42,9 @@ class User(UserMixin, db.Model):
|
||||
time_rounding_minutes = db.Column(db.Integer, default=1, nullable=False) # Rounding interval: 1, 5, 10, 15, 30, 60
|
||||
time_rounding_method = db.Column(db.String(10), default='nearest', nullable=False) # 'nearest', 'up', or 'down'
|
||||
|
||||
# Overtime settings
|
||||
standard_hours_per_day = db.Column(db.Float, default=8.0, nullable=False) # Standard working hours per day for overtime calculation
|
||||
|
||||
# Relationships
|
||||
time_entries = db.relationship('TimeEntry', backref='user', lazy='dynamic', cascade='all, delete-orphan')
|
||||
project_costs = db.relationship('ProjectCost', backref='user', lazy='dynamic', cascade='all, delete-orphan')
|
||||
@@ -53,6 +56,9 @@ class User(UserMixin, db.Model):
|
||||
self.role = role
|
||||
self.email = (email or None)
|
||||
self.full_name = (full_name or None)
|
||||
# Set default for standard_hours_per_day if not set by SQLAlchemy
|
||||
if not hasattr(self, 'standard_hours_per_day') or self.standard_hours_per_day is None:
|
||||
self.standard_hours_per_day = 8.0
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.username}>'
|
||||
|
||||
@@ -309,6 +309,75 @@ def weekly_trends():
|
||||
}]
|
||||
})
|
||||
|
||||
@analytics_bp.route('/api/analytics/overtime')
|
||||
@login_required
|
||||
def overtime_analytics():
|
||||
"""Get overtime statistics for the current user or all users (if admin)"""
|
||||
try:
|
||||
days = int(request.args.get('days', 30))
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': 'Invalid days parameter'}), 400
|
||||
|
||||
from app.utils.overtime import calculate_period_overtime, get_daily_breakdown
|
||||
|
||||
end_date = datetime.now().date()
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# If admin, show all users; otherwise show current user only
|
||||
if current_user.is_admin:
|
||||
users = User.query.filter_by(is_active=True).all()
|
||||
else:
|
||||
users = [current_user]
|
||||
|
||||
# Calculate overtime for each user
|
||||
user_overtime_data = []
|
||||
total_overtime = 0
|
||||
total_regular = 0
|
||||
|
||||
for user in users:
|
||||
overtime_info = calculate_period_overtime(user, start_date, end_date)
|
||||
if overtime_info['total_hours'] > 0: # Only include users with tracked time
|
||||
user_overtime_data.append({
|
||||
'username': user.display_name,
|
||||
'regular_hours': overtime_info['regular_hours'],
|
||||
'overtime_hours': overtime_info['overtime_hours'],
|
||||
'total_hours': overtime_info['total_hours'],
|
||||
'days_with_overtime': overtime_info['days_with_overtime']
|
||||
})
|
||||
total_overtime += overtime_info['overtime_hours']
|
||||
total_regular += overtime_info['regular_hours']
|
||||
|
||||
# Get daily breakdown for chart
|
||||
if not current_user.is_admin:
|
||||
daily_data = get_daily_breakdown(current_user, start_date, end_date)
|
||||
else:
|
||||
# For admin, show aggregated daily data
|
||||
daily_data = []
|
||||
|
||||
return jsonify({
|
||||
'users': user_overtime_data,
|
||||
'summary': {
|
||||
'total_regular_hours': round(total_regular, 2),
|
||||
'total_overtime_hours': round(total_overtime, 2),
|
||||
'total_hours': round(total_regular + total_overtime, 2),
|
||||
'overtime_percentage': round(
|
||||
(total_overtime / (total_regular + total_overtime) * 100)
|
||||
if (total_regular + total_overtime) > 0 else 0,
|
||||
1
|
||||
)
|
||||
},
|
||||
'daily_breakdown': [
|
||||
{
|
||||
'date': day['date_str'],
|
||||
'regular_hours': day['regular_hours'],
|
||||
'overtime_hours': day['overtime_hours'],
|
||||
'total_hours': day['total_hours']
|
||||
}
|
||||
for day in daily_data
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@analytics_bp.route('/api/analytics/project-efficiency')
|
||||
@login_required
|
||||
def project_efficiency():
|
||||
|
||||
@@ -259,13 +259,27 @@ def user_report():
|
||||
user_totals[username] = {
|
||||
'hours': 0,
|
||||
'billable_hours': 0,
|
||||
'entries': []
|
||||
'entries': [],
|
||||
'user_obj': entry.user # Store user object for overtime calculation
|
||||
}
|
||||
user_totals[username]['hours'] += entry.duration_hours
|
||||
if entry.billable:
|
||||
user_totals[username]['billable_hours'] += entry.duration_hours
|
||||
user_totals[username]['entries'].append(entry)
|
||||
|
||||
# Calculate overtime for each user
|
||||
from app.utils.overtime import calculate_period_overtime
|
||||
for username, data in user_totals.items():
|
||||
if data['user_obj']:
|
||||
overtime_data = calculate_period_overtime(
|
||||
data['user_obj'],
|
||||
start_dt.date(),
|
||||
end_dt.date()
|
||||
)
|
||||
data['regular_hours'] = overtime_data['regular_hours']
|
||||
data['overtime_hours'] = overtime_data['overtime_hours']
|
||||
data['days_with_overtime'] = overtime_data['days_with_overtime']
|
||||
|
||||
summary = {
|
||||
'total_hours': round(total_hours, 1),
|
||||
'billable_hours': round(billable_hours, 1),
|
||||
|
||||
@@ -97,6 +97,16 @@ def settings():
|
||||
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
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
|
||||
<select name="project_id" id="project_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<i class="fas fa-project-diagram mr-1"></i>Project
|
||||
</label>
|
||||
<select name="project_id" id="project_id" class="form-input">
|
||||
<option value="">All Projects</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if selected_project == project.id %}selected{% endif %}>{{ project.name }}</option>
|
||||
@@ -17,8 +19,10 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="user_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">User</label>
|
||||
<select name="user_id" id="user_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<label for="user_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<i class="fas fa-user mr-1"></i>User
|
||||
</label>
|
||||
<select name="user_id" id="user_id" class="form-input">
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if selected_user == user.id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||
@@ -26,15 +30,21 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Start Date</label>
|
||||
<input type="date" name="start_date" id="start_date" value="{{ start_date }}" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<i class="fas fa-calendar mr-1"></i>Start Date
|
||||
</label>
|
||||
<input type="date" name="start_date" id="start_date" value="{{ start_date }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">End Date</label>
|
||||
<input type="date" name="end_date" id="end_date" value="{{ end_date }}" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<i class="fas fa-calendar mr-1"></i>End Date
|
||||
</label>
|
||||
<input type="date" name="end_date" id="end_date" value="{{ end_date }}" class="form-input">
|
||||
</div>
|
||||
<div class="self-end">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Filter</button>
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition">
|
||||
<i class="fas fa-filter mr-2"></i>Filter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
|
||||
<select name="project_id" id="project_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<i class="fas fa-project-diagram mr-1"></i>Project
|
||||
</label>
|
||||
<select name="project_id" id="project_id" class="form-input">
|
||||
<option value="">All Projects</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if selected_project == project.id %}selected{% endif %}>{{ project.name }}</option>
|
||||
@@ -17,8 +19,10 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="user_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">User</label>
|
||||
<select name="user_id" id="user_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<label for="user_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<i class="fas fa-user mr-1"></i>User
|
||||
</label>
|
||||
<select name="user_id" id="user_id" class="form-input">
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if selected_user == user.id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||
@@ -26,15 +30,21 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Start Date</label>
|
||||
<input type="date" name="start_date" id="start_date" value="{{ start_date }}" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<i class="fas fa-calendar mr-1"></i>Start Date
|
||||
</label>
|
||||
<input type="date" name="start_date" id="start_date" value="{{ start_date }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">End Date</label>
|
||||
<input type="date" name="end_date" id="end_date" value="{{ end_date }}" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<i class="fas fa-calendar mr-1"></i>End Date
|
||||
</label>
|
||||
<input type="date" name="end_date" id="end_date" value="{{ end_date }}" class="form-input">
|
||||
</div>
|
||||
<div class="self-end">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Filter</button>
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition">
|
||||
<i class="fas fa-filter mr-2"></i>Filter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label for="user_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">User</label>
|
||||
<select name="user_id" id="user_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<label for="user_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<i class="fas fa-user mr-1"></i>User
|
||||
</label>
|
||||
<select name="user_id" id="user_id" class="form-input">
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if selected_user == user.id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||
@@ -17,8 +19,10 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
|
||||
<select name="project_id" id="project_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<i class="fas fa-project-diagram mr-1"></i>Project
|
||||
</label>
|
||||
<select name="project_id" id="project_id" class="form-input">
|
||||
<option value="">All Projects</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if selected_project == project.id %}selected{% endif %}>{{ project.name }}</option>
|
||||
@@ -26,15 +30,21 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Start Date</label>
|
||||
<input type="date" name="start_date" id="start_date" value="{{ start_date }}" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<i class="fas fa-calendar mr-1"></i>Start Date
|
||||
</label>
|
||||
<input type="date" name="start_date" id="start_date" value="{{ start_date }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">End Date</label>
|
||||
<input type="date" name="end_date" id="end_date" value="{{ end_date }}" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<i class="fas fa-calendar mr-1"></i>End Date
|
||||
</label>
|
||||
<input type="date" name="end_date" id="end_date" value="{{ end_date }}" class="form-input">
|
||||
</div>
|
||||
<div class="self-end">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Filter</button>
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition">
|
||||
<i class="fas fa-filter mr-2"></i>Filter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -45,22 +55,65 @@
|
||||
<tr>
|
||||
<th class="p-2">User</th>
|
||||
<th class="p-2">Total Hours</th>
|
||||
<th class="p-2">Regular Hours</th>
|
||||
<th class="p-2">Overtime Hours</th>
|
||||
<th class="p-2">Billable Hours</th>
|
||||
<th class="p-2">Days with Overtime</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for username, totals in user_totals.items() %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-2">{{ username }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(totals.hours) }}</td>
|
||||
<td class="p-2 font-semibold">{{ "%.2f"|format(totals.hours) }}</td>
|
||||
<td class="p-2 text-green-600 dark:text-green-400">
|
||||
{{ "%.2f"|format(totals.regular_hours) if totals.regular_hours is defined else "%.2f"|format(totals.hours) }}
|
||||
</td>
|
||||
<td class="p-2">
|
||||
{% if totals.overtime_hours is defined and totals.overtime_hours > 0 %}
|
||||
<span class="text-orange-600 dark:text-orange-400 font-semibold">
|
||||
<i class="fas fa-business-time mr-1"></i>{{ "%.2f"|format(totals.overtime_hours) }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400">0.00</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-2">{{ "%.2f"|format(totals.billable_hours) }}</td>
|
||||
<td class="p-2 text-center">
|
||||
{% if totals.days_with_overtime is defined and totals.days_with_overtime > 0 %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
{{ totals.days_with_overtime }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="p-4 text-center">No data for the selected period.</td>
|
||||
<td colspan="6" class="p-4 text-center">No data for the selected period.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Overtime Summary -->
|
||||
{% if user_totals %}
|
||||
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-500 dark:text-blue-400 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">About Overtime Tracking</h3>
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300 mt-1">
|
||||
Overtime is calculated based on each user's standard working hours per day setting.
|
||||
Hours worked beyond the standard are counted as overtime. Users can configure their
|
||||
standard hours in <a href="{{ url_for('user.settings') }}" class="underline hover:text-blue-900 dark:hover:text-blue-100">Settings</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -200,6 +200,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overtime Settings -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<i class="fas fa-business-time mr-2"></i>{{ _('Overtime Settings') }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{{ _('Set your standard working hours per day. Any time worked beyond this will be counted as overtime.') }}
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="standard_hours_per_day" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ _('Standard Hours Per Day') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="number" id="standard_hours_per_day" name="standard_hours_per_day"
|
||||
value="{{ user.standard_hours_per_day }}"
|
||||
min="0.5" max="24" step="0.5"
|
||||
class="w-full px-3 py-2 pr-16 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
|
||||
<span class="absolute right-3 top-2.5 text-sm text-gray-500 dark:text-gray-400 pointer-events-none">{{ _('hours') }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _('Typically 8 hours for a full-time job') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center bg-blue-50 dark:bg-blue-900/20 p-4 rounded-md">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-1">
|
||||
<i class="fas fa-info-circle mr-1"></i>{{ _('How it works') }}
|
||||
</p>
|
||||
<p class="text-xs text-blue-800 dark:text-blue-200">
|
||||
{{ _('If you work more than your standard hours in a day, the extra time will be tracked as overtime in reports and analytics.') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Regional Settings -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
|
||||
297
app/utils/overtime.py
Normal file
297
app/utils/overtime.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
Overtime Calculation Utilities
|
||||
|
||||
Provides functions to calculate overtime hours based on user's standard working hours per day.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from sqlalchemy import func
|
||||
|
||||
|
||||
def calculate_daily_overtime(total_hours: float, standard_hours: float) -> float:
|
||||
"""
|
||||
Calculate overtime hours for a single day.
|
||||
|
||||
Args:
|
||||
total_hours: Total hours worked in a day
|
||||
standard_hours: Standard working hours per day
|
||||
|
||||
Returns:
|
||||
Overtime hours (0 if no overtime)
|
||||
"""
|
||||
if total_hours <= standard_hours:
|
||||
return 0.0
|
||||
return round(total_hours - standard_hours, 2)
|
||||
|
||||
|
||||
def calculate_period_overtime(
|
||||
user,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
include_weekends: bool = True
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Calculate overtime for a specific period.
|
||||
|
||||
Args:
|
||||
user: User object with standard_hours_per_day setting
|
||||
start_date: Start date of the period
|
||||
end_date: End date of the period
|
||||
include_weekends: Whether to count weekend hours as overtime
|
||||
|
||||
Returns:
|
||||
Dictionary with regular_hours, overtime_hours, and total_hours
|
||||
"""
|
||||
from app.models import TimeEntry
|
||||
from app import db
|
||||
|
||||
# Get all time entries for the period
|
||||
entries = TimeEntry.query.filter(
|
||||
TimeEntry.user_id == user.id,
|
||||
TimeEntry.end_time.isnot(None),
|
||||
TimeEntry.start_time >= start_date,
|
||||
TimeEntry.start_time <= end_date
|
||||
).all()
|
||||
|
||||
# Group entries by date
|
||||
daily_hours = {}
|
||||
for entry in entries:
|
||||
entry_date = entry.start_time.date()
|
||||
hours = entry.duration_hours
|
||||
|
||||
if entry_date not in daily_hours:
|
||||
daily_hours[entry_date] = 0.0
|
||||
daily_hours[entry_date] += hours
|
||||
|
||||
# Calculate overtime per day
|
||||
standard_hours = user.standard_hours_per_day
|
||||
total_regular = 0.0
|
||||
total_overtime = 0.0
|
||||
|
||||
for day_date, hours in daily_hours.items():
|
||||
# Check if weekend
|
||||
if not include_weekends and day_date.weekday() >= 5: # Saturday=5, Sunday=6
|
||||
# All weekend hours are overtime
|
||||
total_overtime += hours
|
||||
else:
|
||||
# Calculate regular vs overtime
|
||||
if hours <= standard_hours:
|
||||
total_regular += hours
|
||||
else:
|
||||
total_regular += standard_hours
|
||||
total_overtime += (hours - standard_hours)
|
||||
|
||||
return {
|
||||
'regular_hours': round(total_regular, 2),
|
||||
'overtime_hours': round(total_overtime, 2),
|
||||
'total_hours': round(total_regular + total_overtime, 2),
|
||||
'days_with_overtime': sum(1 for h in daily_hours.values() if h > standard_hours)
|
||||
}
|
||||
|
||||
|
||||
def get_daily_breakdown(
|
||||
user,
|
||||
start_date: date,
|
||||
end_date: date
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Get a daily breakdown of regular and overtime hours.
|
||||
|
||||
Args:
|
||||
user: User object with standard_hours_per_day setting
|
||||
start_date: Start date of the period
|
||||
end_date: End date of the period
|
||||
|
||||
Returns:
|
||||
List of dictionaries with daily breakdown
|
||||
"""
|
||||
from app.models import TimeEntry
|
||||
from app import db
|
||||
|
||||
# Get all time entries for the period
|
||||
entries = TimeEntry.query.filter(
|
||||
TimeEntry.user_id == user.id,
|
||||
TimeEntry.end_time.isnot(None),
|
||||
TimeEntry.start_time >= start_date,
|
||||
TimeEntry.start_time <= end_date
|
||||
).order_by(TimeEntry.start_time).all()
|
||||
|
||||
# Group entries by date
|
||||
daily_data = {}
|
||||
for entry in entries:
|
||||
entry_date = entry.start_time.date()
|
||||
|
||||
if entry_date not in daily_data:
|
||||
daily_data[entry_date] = {
|
||||
'date': entry_date,
|
||||
'total_hours': 0.0,
|
||||
'entries': []
|
||||
}
|
||||
|
||||
daily_data[entry_date]['total_hours'] += entry.duration_hours
|
||||
daily_data[entry_date]['entries'].append(entry)
|
||||
|
||||
# Calculate overtime for each day
|
||||
standard_hours = user.standard_hours_per_day
|
||||
breakdown = []
|
||||
|
||||
for day_date in sorted(daily_data.keys()):
|
||||
day_info = daily_data[day_date]
|
||||
total_hours = day_info['total_hours']
|
||||
|
||||
regular_hours = min(total_hours, standard_hours)
|
||||
overtime_hours = max(0, total_hours - standard_hours)
|
||||
|
||||
breakdown.append({
|
||||
'date': day_date,
|
||||
'date_str': day_date.strftime('%Y-%m-%d'),
|
||||
'weekday': day_date.strftime('%A'),
|
||||
'total_hours': round(total_hours, 2),
|
||||
'regular_hours': round(regular_hours, 2),
|
||||
'overtime_hours': round(overtime_hours, 2),
|
||||
'is_overtime': overtime_hours > 0,
|
||||
'entries_count': len(day_info['entries'])
|
||||
})
|
||||
|
||||
return breakdown
|
||||
|
||||
|
||||
def get_weekly_overtime_summary(
|
||||
user,
|
||||
weeks: int = 4
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Get a weekly summary of overtime for the last N weeks.
|
||||
|
||||
Args:
|
||||
user: User object with standard_hours_per_day setting
|
||||
weeks: Number of weeks to look back
|
||||
|
||||
Returns:
|
||||
List of weekly summaries
|
||||
"""
|
||||
from app.models import TimeEntry
|
||||
from app import db
|
||||
|
||||
end_date = datetime.now().date()
|
||||
start_date = end_date - timedelta(weeks=weeks)
|
||||
|
||||
# Get all time entries
|
||||
entries = TimeEntry.query.filter(
|
||||
TimeEntry.user_id == user.id,
|
||||
TimeEntry.end_time.isnot(None),
|
||||
TimeEntry.start_time >= start_date,
|
||||
TimeEntry.start_time <= end_date
|
||||
).all()
|
||||
|
||||
# Group by week
|
||||
weekly_data = {}
|
||||
for entry in entries:
|
||||
entry_date = entry.start_time.date()
|
||||
# Get Monday of that week
|
||||
week_start = entry_date - timedelta(days=entry_date.weekday())
|
||||
|
||||
if week_start not in weekly_data:
|
||||
weekly_data[week_start] = {}
|
||||
|
||||
if entry_date not in weekly_data[week_start]:
|
||||
weekly_data[week_start][entry_date] = 0.0
|
||||
|
||||
weekly_data[week_start][entry_date] += entry.duration_hours
|
||||
|
||||
# Calculate overtime per week
|
||||
standard_hours = user.standard_hours_per_day
|
||||
weekly_summary = []
|
||||
|
||||
for week_start in sorted(weekly_data.keys()):
|
||||
daily_hours = weekly_data[week_start]
|
||||
|
||||
week_regular = 0.0
|
||||
week_overtime = 0.0
|
||||
|
||||
for day_date, hours in daily_hours.items():
|
||||
if hours <= standard_hours:
|
||||
week_regular += hours
|
||||
else:
|
||||
week_regular += standard_hours
|
||||
week_overtime += (hours - standard_hours)
|
||||
|
||||
week_end = week_start + timedelta(days=6)
|
||||
|
||||
weekly_summary.append({
|
||||
'week_start': week_start,
|
||||
'week_end': week_end,
|
||||
'week_label': f"{week_start.strftime('%b %d')} - {week_end.strftime('%b %d')}",
|
||||
'regular_hours': round(week_regular, 2),
|
||||
'overtime_hours': round(week_overtime, 2),
|
||||
'total_hours': round(week_regular + week_overtime, 2),
|
||||
'days_worked': len(daily_hours)
|
||||
})
|
||||
|
||||
return weekly_summary
|
||||
|
||||
|
||||
def get_overtime_statistics(
|
||||
user,
|
||||
start_date: date,
|
||||
end_date: date
|
||||
) -> Dict:
|
||||
"""
|
||||
Get comprehensive overtime statistics for a period.
|
||||
|
||||
Args:
|
||||
user: User object
|
||||
start_date: Start date
|
||||
end_date: End date
|
||||
|
||||
Returns:
|
||||
Dictionary with various overtime statistics
|
||||
"""
|
||||
period_data = calculate_period_overtime(user, start_date, end_date)
|
||||
daily_breakdown = get_daily_breakdown(user, start_date, end_date)
|
||||
|
||||
# Calculate additional statistics
|
||||
days_worked = len(daily_breakdown)
|
||||
days_with_overtime = sum(1 for day in daily_breakdown if day['is_overtime'])
|
||||
|
||||
# Average hours per day
|
||||
avg_hours_per_day = (
|
||||
period_data['total_hours'] / days_worked if days_worked > 0 else 0
|
||||
)
|
||||
|
||||
# Max overtime in a single day
|
||||
max_overtime_day = max(
|
||||
(day for day in daily_breakdown if day['is_overtime']),
|
||||
key=lambda x: x['overtime_hours'],
|
||||
default=None
|
||||
)
|
||||
|
||||
return {
|
||||
'period': {
|
||||
'start_date': start_date.strftime('%Y-%m-%d'),
|
||||
'end_date': end_date.strftime('%Y-%m-%d'),
|
||||
'days_in_period': (end_date - start_date).days + 1
|
||||
},
|
||||
'hours': period_data,
|
||||
'days_statistics': {
|
||||
'days_worked': days_worked,
|
||||
'days_with_overtime': days_with_overtime,
|
||||
'percentage_overtime_days': (
|
||||
round(days_with_overtime / days_worked * 100, 1)
|
||||
if days_worked > 0 else 0
|
||||
)
|
||||
},
|
||||
'averages': {
|
||||
'avg_hours_per_day': round(avg_hours_per_day, 2),
|
||||
'avg_overtime_per_overtime_day': (
|
||||
round(period_data['overtime_hours'] / days_with_overtime, 2)
|
||||
if days_with_overtime > 0 else 0
|
||||
)
|
||||
},
|
||||
'max_overtime': {
|
||||
'date': max_overtime_day['date_str'] if max_overtime_day else None,
|
||||
'hours': max_overtime_day['overtime_hours'] if max_overtime_day else 0
|
||||
}
|
||||
}
|
||||
|
||||
133
docs/features/OVERTIME_TRACKING.md
Normal file
133
docs/features/OVERTIME_TRACKING.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Overtime Tracking Feature
|
||||
|
||||
## Quick Start
|
||||
|
||||
The Overtime Tracking feature allows users to track hours worked beyond their standard workday.
|
||||
|
||||
### For Users
|
||||
|
||||
1. **Set Your Standard Hours**
|
||||
- Go to Settings → Overtime Settings
|
||||
- Enter your standard working hours per day (e.g., 8.0)
|
||||
- Click Save
|
||||
|
||||
2. **View Your Overtime**
|
||||
- Navigate to Reports → User Report
|
||||
- Select your date range
|
||||
- View overtime breakdown in the report table
|
||||
|
||||
### For Developers
|
||||
|
||||
**Key Files:**
|
||||
- `app/utils/overtime.py` - Core calculation functions
|
||||
- `app/models/user.py` - User model with standard_hours_per_day field
|
||||
- `app/routes/reports.py` - Report route with overtime display
|
||||
- `app/routes/analytics.py` - Analytics API endpoint
|
||||
- `migrations/versions/031_add_standard_hours_per_day.py` - Database migration
|
||||
|
||||
**API Endpoint:**
|
||||
```
|
||||
GET /api/analytics/overtime?days=30
|
||||
```
|
||||
|
||||
**Key Functions:**
|
||||
```python
|
||||
from app.utils.overtime import (
|
||||
calculate_daily_overtime,
|
||||
calculate_period_overtime,
|
||||
get_daily_breakdown,
|
||||
get_overtime_statistics
|
||||
)
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run all overtime tests
|
||||
pytest tests/test_overtime.py tests/test_overtime_smoke.py -v
|
||||
|
||||
# With coverage
|
||||
pytest tests/test_overtime*.py --cov=app.utils.overtime --cov-report=html
|
||||
```
|
||||
|
||||
### Documentation
|
||||
|
||||
- **Full Documentation**: [OVERTIME_FEATURE_DOCUMENTATION.md](../../OVERTIME_FEATURE_DOCUMENTATION.md)
|
||||
- **Implementation Summary**: [OVERTIME_IMPLEMENTATION_SUMMARY.md](../../OVERTIME_IMPLEMENTATION_SUMMARY.md)
|
||||
|
||||
## How It Works
|
||||
|
||||
1. User sets standard hours per day in settings (default: 8.0)
|
||||
2. System tracks all time entries as usual
|
||||
3. When viewing reports, system calculates:
|
||||
- For each day: regular hours (up to standard) + overtime hours (beyond standard)
|
||||
4. Reports display:
|
||||
- Total hours worked
|
||||
- Regular hours (green)
|
||||
- Overtime hours (orange)
|
||||
- Days with overtime
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Full-time Employee (8 hours/day)
|
||||
- Monday: 8 hours → 8 regular, 0 overtime
|
||||
- Tuesday: 10 hours → 8 regular, 2 overtime
|
||||
- Wednesday: 7 hours → 7 regular, 0 overtime
|
||||
|
||||
### Example 2: Part-time Employee (6 hours/day)
|
||||
- Monday: 6 hours → 6 regular, 0 overtime
|
||||
- Tuesday: 7 hours → 6 regular, 1 overtime
|
||||
- Wednesday: 5 hours → 5 regular, 0 overtime
|
||||
|
||||
## Configuration
|
||||
|
||||
**User Setting:** `standard_hours_per_day`
|
||||
- Type: Float
|
||||
- Default: 8.0
|
||||
- Range: 0.5 to 24.0
|
||||
- Location: User Settings → Overtime Settings
|
||||
|
||||
## Database
|
||||
|
||||
**Table:** `users`
|
||||
**Column:** `standard_hours_per_day`
|
||||
- Type: `FLOAT`
|
||||
- Default: `8.0`
|
||||
- Nullable: `NO`
|
||||
|
||||
**Migration:** `031_add_standard_hours_per_day`
|
||||
|
||||
## Features
|
||||
|
||||
✅ User-configurable standard hours
|
||||
✅ Automatic overtime calculation
|
||||
✅ Display in user reports
|
||||
✅ Analytics API endpoint
|
||||
✅ Daily overtime breakdown
|
||||
✅ Weekly overtime summaries
|
||||
✅ Comprehensive statistics
|
||||
✅ Full test coverage
|
||||
✅ Complete documentation
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Weekly overtime thresholds
|
||||
- Overtime approval workflows
|
||||
- Overtime pay rate calculations
|
||||
- Email notifications for excessive overtime
|
||||
- Overtime budget limits
|
||||
- Export overtime reports
|
||||
|
||||
## Support
|
||||
|
||||
For questions or issues:
|
||||
1. Review the [full documentation](../../OVERTIME_FEATURE_DOCUMENTATION.md)
|
||||
2. Check test cases for examples
|
||||
3. Open a GitHub issue
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Status:** ✅ Production Ready
|
||||
**Last Updated:** October 27, 2025
|
||||
|
||||
28
migrations/versions/031_add_standard_hours_per_day.py
Normal file
28
migrations/versions/031_add_standard_hours_per_day.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Add standard_hours_per_day to users
|
||||
|
||||
Revision ID: 031
|
||||
Revises: 030
|
||||
Create Date: 2025-10-27 10:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '031'
|
||||
down_revision = '030'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Add standard_hours_per_day column to users table"""
|
||||
op.add_column('users',
|
||||
sa.Column('standard_hours_per_day', sa.Float(), nullable=False, server_default='8.0')
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Remove standard_hours_per_day column from users table"""
|
||||
op.drop_column('users', 'standard_hours_per_day')
|
||||
|
||||
2
setup.py
2
setup.py
@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name='timetracker',
|
||||
version='3.4.1',
|
||||
version='3.5.0',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
|
||||
458
tests/test_overtime.py
Normal file
458
tests/test_overtime.py
Normal file
@@ -0,0 +1,458 @@
|
||||
"""
|
||||
Tests for overtime calculation functionality
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, date
|
||||
from app import db
|
||||
from app.models import User, TimeEntry, Project, Client
|
||||
from app.utils.overtime import (
|
||||
calculate_daily_overtime,
|
||||
calculate_period_overtime,
|
||||
get_daily_breakdown,
|
||||
get_weekly_overtime_summary,
|
||||
get_overtime_statistics
|
||||
)
|
||||
|
||||
|
||||
class TestOvertimeCalculations:
|
||||
"""Test suite for overtime calculation utilities"""
|
||||
|
||||
def test_calculate_daily_overtime_no_overtime(self):
|
||||
"""Test that no overtime is calculated when hours are below standard"""
|
||||
result = calculate_daily_overtime(6.0, 8.0)
|
||||
assert result == 0.0
|
||||
|
||||
def test_calculate_daily_overtime_exact_standard(self):
|
||||
"""Test that no overtime is calculated when hours equal standard"""
|
||||
result = calculate_daily_overtime(8.0, 8.0)
|
||||
assert result == 0.0
|
||||
|
||||
def test_calculate_daily_overtime_with_overtime(self):
|
||||
"""Test overtime calculation when hours exceed standard"""
|
||||
result = calculate_daily_overtime(10.0, 8.0)
|
||||
assert result == 2.0
|
||||
|
||||
def test_calculate_daily_overtime_large_overtime(self):
|
||||
"""Test overtime calculation with significant overtime"""
|
||||
result = calculate_daily_overtime(14.5, 8.0)
|
||||
assert result == 6.5
|
||||
|
||||
|
||||
class TestPeriodOvertime:
|
||||
"""Test suite for period-based overtime calculations"""
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(self, app):
|
||||
"""Create a test user with 8 hour standard day"""
|
||||
user = User(username='test_user_ot', role='user')
|
||||
user.standard_hours_per_day = 8.0
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def test_client_obj(self, app):
|
||||
"""Create a test client"""
|
||||
test_client = Client(name='Test Client OT')
|
||||
db.session.add(test_client)
|
||||
db.session.commit()
|
||||
return test_client
|
||||
|
||||
@pytest.fixture
|
||||
def test_project(self, app, test_client_obj):
|
||||
"""Create a test project"""
|
||||
project = Project(
|
||||
name='Test Project OT',
|
||||
client_id=test_client_obj.id
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
return project
|
||||
|
||||
def test_period_overtime_no_entries(self, app, test_user):
|
||||
"""Test period overtime calculation with no time entries"""
|
||||
start_date = date.today() - timedelta(days=7)
|
||||
end_date = date.today()
|
||||
|
||||
result = calculate_period_overtime(test_user, start_date, end_date)
|
||||
|
||||
assert result['regular_hours'] == 0.0
|
||||
assert result['overtime_hours'] == 0.0
|
||||
assert result['total_hours'] == 0.0
|
||||
assert result['days_with_overtime'] == 0
|
||||
|
||||
def test_period_overtime_all_regular(self, app, test_user, test_project):
|
||||
"""Test period with all regular hours (no overtime)"""
|
||||
start_date = date.today() - timedelta(days=2)
|
||||
|
||||
# Create entries for 2 days with 7 hours each (below standard 8)
|
||||
for i in range(2):
|
||||
entry_date = start_date + timedelta(days=i)
|
||||
entry_start = datetime.combine(entry_date, datetime.min.time().replace(hour=9))
|
||||
entry_end = entry_start + timedelta(hours=7)
|
||||
|
||||
entry = TimeEntry(
|
||||
user_id=test_user.id,
|
||||
project_id=test_project.id,
|
||||
start_time=entry_start,
|
||||
end_time=entry_end,
|
||||
notes='Regular work'
|
||||
)
|
||||
db.session.add(entry)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
result = calculate_period_overtime(test_user, start_date, date.today())
|
||||
|
||||
assert result['regular_hours'] == 14.0
|
||||
assert result['overtime_hours'] == 0.0
|
||||
assert result['total_hours'] == 14.0
|
||||
assert result['days_with_overtime'] == 0
|
||||
|
||||
def test_period_overtime_with_overtime(self, app, test_user, test_project):
|
||||
"""Test period with overtime hours"""
|
||||
start_date = date.today() - timedelta(days=2)
|
||||
|
||||
# Day 1: 10 hours (2 hours overtime)
|
||||
entry_date = start_date
|
||||
entry_start = datetime.combine(entry_date, datetime.min.time().replace(hour=9))
|
||||
entry_end = entry_start + timedelta(hours=10)
|
||||
|
||||
entry1 = TimeEntry(
|
||||
user_id=test_user.id,
|
||||
project_id=test_project.id,
|
||||
start_time=entry_start,
|
||||
end_time=entry_end,
|
||||
notes='Long day'
|
||||
)
|
||||
db.session.add(entry1)
|
||||
|
||||
# Day 2: 6 hours (no overtime)
|
||||
entry_date2 = start_date + timedelta(days=1)
|
||||
entry_start2 = datetime.combine(entry_date2, datetime.min.time().replace(hour=9))
|
||||
entry_end2 = entry_start2 + timedelta(hours=6)
|
||||
|
||||
entry2 = TimeEntry(
|
||||
user_id=test_user.id,
|
||||
project_id=test_project.id,
|
||||
start_time=entry_start2,
|
||||
end_time=entry_end2,
|
||||
notes='Short day'
|
||||
)
|
||||
db.session.add(entry2)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
result = calculate_period_overtime(test_user, start_date, date.today())
|
||||
|
||||
assert result['regular_hours'] == 14.0 # 8 + 6
|
||||
assert result['overtime_hours'] == 2.0
|
||||
assert result['total_hours'] == 16.0
|
||||
assert result['days_with_overtime'] == 1
|
||||
|
||||
def test_period_overtime_multiple_entries_same_day(self, app, test_user, test_project):
|
||||
"""Test overtime calculation with multiple entries on the same day"""
|
||||
entry_date = date.today()
|
||||
|
||||
# Create 3 entries totaling 10 hours (2 hours overtime)
|
||||
for i, hours in enumerate([4, 3, 3]):
|
||||
entry_start = datetime.combine(entry_date, datetime.min.time().replace(hour=9 + i * 3))
|
||||
entry_end = entry_start + timedelta(hours=hours)
|
||||
|
||||
entry = TimeEntry(
|
||||
user_id=test_user.id,
|
||||
project_id=test_project.id,
|
||||
start_time=entry_start,
|
||||
end_time=entry_end,
|
||||
notes=f'Entry {i+1}'
|
||||
)
|
||||
db.session.add(entry)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
result = calculate_period_overtime(test_user, entry_date, entry_date)
|
||||
|
||||
assert result['regular_hours'] == 8.0
|
||||
assert result['overtime_hours'] == 2.0
|
||||
assert result['total_hours'] == 10.0
|
||||
assert result['days_with_overtime'] == 1
|
||||
|
||||
|
||||
class TestDailyBreakdown:
|
||||
"""Test suite for daily overtime breakdown"""
|
||||
|
||||
@pytest.fixture
|
||||
def test_user_daily(self, app):
|
||||
"""Create a test user"""
|
||||
user = User(username='test_user_daily', role='user')
|
||||
user.standard_hours_per_day = 8.0
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def test_project_daily(self, app, test_client_obj):
|
||||
"""Create a test project"""
|
||||
project = Project(
|
||||
name='Test Project Daily',
|
||||
client_id=test_client_obj.id
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
return project
|
||||
|
||||
@pytest.fixture
|
||||
def test_client_obj(self, app):
|
||||
"""Create a test client"""
|
||||
test_client = Client(name='Test Client Daily')
|
||||
db.session.add(test_client)
|
||||
db.session.commit()
|
||||
return test_client
|
||||
|
||||
def test_daily_breakdown_empty(self, app, test_user_daily):
|
||||
"""Test daily breakdown with no entries"""
|
||||
start_date = date.today() - timedelta(days=7)
|
||||
end_date = date.today()
|
||||
|
||||
result = get_daily_breakdown(test_user_daily, start_date, end_date)
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
def test_daily_breakdown_with_entries(self, app, test_user_daily, test_project_daily):
|
||||
"""Test daily breakdown with various entries"""
|
||||
start_date = date.today() - timedelta(days=2)
|
||||
|
||||
# Day 1: 9 hours (1 hour overtime)
|
||||
entry1_start = datetime.combine(start_date, datetime.min.time().replace(hour=9))
|
||||
entry1_end = entry1_start + timedelta(hours=9)
|
||||
entry1 = TimeEntry(
|
||||
user_id=test_user_daily.id,
|
||||
project_id=test_project_daily.id,
|
||||
start_time=entry1_start,
|
||||
end_time=entry1_end
|
||||
)
|
||||
db.session.add(entry1)
|
||||
|
||||
# Day 2: 6 hours (no overtime)
|
||||
entry2_start = datetime.combine(start_date + timedelta(days=1), datetime.min.time().replace(hour=9))
|
||||
entry2_end = entry2_start + timedelta(hours=6)
|
||||
entry2 = TimeEntry(
|
||||
user_id=test_user_daily.id,
|
||||
project_id=test_project_daily.id,
|
||||
start_time=entry2_start,
|
||||
end_time=entry2_end
|
||||
)
|
||||
db.session.add(entry2)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
result = get_daily_breakdown(test_user_daily, start_date, date.today())
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
# Check day 1
|
||||
day1 = result[0]
|
||||
assert day1['total_hours'] == 9.0
|
||||
assert day1['regular_hours'] == 8.0
|
||||
assert day1['overtime_hours'] == 1.0
|
||||
assert day1['is_overtime'] is True
|
||||
|
||||
# Check day 2
|
||||
day2 = result[1]
|
||||
assert day2['total_hours'] == 6.0
|
||||
assert day2['regular_hours'] == 6.0
|
||||
assert day2['overtime_hours'] == 0.0
|
||||
assert day2['is_overtime'] is False
|
||||
|
||||
|
||||
class TestOvertimeStatistics:
|
||||
"""Test suite for comprehensive overtime statistics"""
|
||||
|
||||
@pytest.fixture
|
||||
def test_user_stats(self, app):
|
||||
"""Create a test user"""
|
||||
user = User(username='test_user_stats', role='user')
|
||||
user.standard_hours_per_day = 8.0
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def test_project_stats(self, app, test_client_obj):
|
||||
"""Create a test project"""
|
||||
project = Project(
|
||||
name='Test Project Stats',
|
||||
client_id=test_client_obj.id
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
return project
|
||||
|
||||
@pytest.fixture
|
||||
def test_client_obj(self, app):
|
||||
"""Create a test client"""
|
||||
test_client = Client(name='Test Client Stats')
|
||||
db.session.add(test_client)
|
||||
db.session.commit()
|
||||
return test_client
|
||||
|
||||
def test_overtime_statistics_comprehensive(self, app, test_user_stats, test_project_stats):
|
||||
"""Test comprehensive overtime statistics"""
|
||||
start_date = date.today() - timedelta(days=4)
|
||||
|
||||
# Create entries for multiple days with varying hours
|
||||
hours_per_day = [10, 7, 9, 6, 11] # 5 days
|
||||
|
||||
for i, hours in enumerate(hours_per_day):
|
||||
entry_date = start_date + timedelta(days=i)
|
||||
entry_start = datetime.combine(entry_date, datetime.min.time().replace(hour=9))
|
||||
entry_end = entry_start + timedelta(hours=hours)
|
||||
|
||||
entry = TimeEntry(
|
||||
user_id=test_user_stats.id,
|
||||
project_id=test_project_stats.id,
|
||||
start_time=entry_start,
|
||||
end_time=entry_end
|
||||
)
|
||||
db.session.add(entry)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
result = get_overtime_statistics(test_user_stats, start_date, date.today())
|
||||
|
||||
# Verify structure
|
||||
assert 'period' in result
|
||||
assert 'hours' in result
|
||||
assert 'days_statistics' in result
|
||||
assert 'averages' in result
|
||||
assert 'max_overtime' in result
|
||||
|
||||
# Verify calculations
|
||||
# Total hours: 10 + 7 + 9 + 6 + 11 = 43
|
||||
# Days with overtime: 10 (2 OT), 9 (1 OT), 11 (3 OT) = 3 days
|
||||
# Total overtime: 2 + 1 + 3 = 6 hours
|
||||
# Regular: 43 - 6 = 37 hours
|
||||
|
||||
assert result['hours']['total_hours'] == 43.0
|
||||
assert result['hours']['overtime_hours'] == 6.0
|
||||
assert result['hours']['regular_hours'] == 37.0
|
||||
assert result['days_statistics']['days_worked'] == 5
|
||||
assert result['days_statistics']['days_with_overtime'] == 3
|
||||
|
||||
# Max overtime should be 3 hours (from the 11-hour day)
|
||||
assert result['max_overtime']['hours'] == 3.0
|
||||
|
||||
|
||||
class TestUserModel:
|
||||
"""Test suite for User model overtime-related functionality"""
|
||||
|
||||
def test_user_has_standard_hours_field(self, app):
|
||||
"""Test that User model has standard_hours_per_day field"""
|
||||
user = User(username='test_user_field', role='user')
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
# Check that field exists and has default value
|
||||
assert hasattr(user, 'standard_hours_per_day')
|
||||
assert user.standard_hours_per_day == 8.0
|
||||
|
||||
def test_user_can_set_custom_standard_hours(self, app):
|
||||
"""Test that standard hours can be customized"""
|
||||
user = User(username='test_user_custom', role='user')
|
||||
user.standard_hours_per_day = 7.5
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
# Reload from database
|
||||
user_reloaded = User.query.filter_by(username='test_user_custom').first()
|
||||
assert user_reloaded.standard_hours_per_day == 7.5
|
||||
|
||||
def test_user_standard_hours_validation_min(self, app):
|
||||
"""Test that standard hours can be set to minimum value"""
|
||||
user = User(username='test_user_min', role='user')
|
||||
user.standard_hours_per_day = 0.5
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
assert user.standard_hours_per_day == 0.5
|
||||
|
||||
def test_user_standard_hours_validation_max(self, app):
|
||||
"""Test that standard hours can be set to maximum value"""
|
||||
user = User(username='test_user_max', role='user')
|
||||
user.standard_hours_per_day = 24.0
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
assert user.standard_hours_per_day == 24.0
|
||||
|
||||
|
||||
class TestWeeklyOvertimeSummary:
|
||||
"""Test suite for weekly overtime summaries"""
|
||||
|
||||
@pytest.fixture
|
||||
def test_user_weekly(self, app):
|
||||
"""Create a test user"""
|
||||
user = User(username='test_user_weekly', role='user')
|
||||
user.standard_hours_per_day = 8.0
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def test_project_weekly(self, app, test_client_obj):
|
||||
"""Create a test project"""
|
||||
project = Project(
|
||||
name='Test Project Weekly',
|
||||
client_id=test_client_obj.id
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
return project
|
||||
|
||||
@pytest.fixture
|
||||
def test_client_obj(self, app):
|
||||
"""Create a test client"""
|
||||
test_client = Client(name='Test Client Weekly')
|
||||
db.session.add(test_client)
|
||||
db.session.commit()
|
||||
return test_client
|
||||
|
||||
def test_weekly_summary_empty(self, app, test_user_weekly):
|
||||
"""Test weekly summary with no entries"""
|
||||
result = get_weekly_overtime_summary(test_user_weekly, weeks=2)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_weekly_summary_with_data(self, app, test_user_weekly, test_project_weekly):
|
||||
"""Test weekly summary with entries across multiple weeks"""
|
||||
# Create entries for the past 2 weeks
|
||||
for week in range(2):
|
||||
for day in range(5): # 5 working days
|
||||
entry_date = date.today() - timedelta(weeks=1-week, days=day)
|
||||
entry_start = datetime.combine(entry_date, datetime.min.time().replace(hour=9))
|
||||
entry_end = entry_start + timedelta(hours=9) # 9 hours per day (1 hour OT)
|
||||
|
||||
entry = TimeEntry(
|
||||
user_id=test_user_weekly.id,
|
||||
project_id=test_project_weekly.id,
|
||||
start_time=entry_start,
|
||||
end_time=entry_end
|
||||
)
|
||||
db.session.add(entry)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
result = get_weekly_overtime_summary(test_user_weekly, weeks=2)
|
||||
|
||||
# Should have data for weeks with entries
|
||||
assert len(result) > 0
|
||||
|
||||
# Each week should have proper structure
|
||||
for week_data in result:
|
||||
assert 'week_start' in week_data
|
||||
assert 'week_end' in week_data
|
||||
assert 'regular_hours' in week_data
|
||||
assert 'overtime_hours' in week_data
|
||||
assert 'total_hours' in week_data
|
||||
assert 'days_worked' in week_data
|
||||
|
||||
268
tests/test_overtime_smoke.py
Normal file
268
tests/test_overtime_smoke.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
Smoke tests for overtime feature
|
||||
Quick tests to verify basic overtime functionality is working
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, date
|
||||
from app import db
|
||||
from app.models import User, TimeEntry, Project, Client
|
||||
from app.utils.overtime import calculate_daily_overtime, calculate_period_overtime
|
||||
|
||||
|
||||
class TestOvertimeSmoke:
|
||||
"""Smoke tests for overtime feature"""
|
||||
|
||||
def test_overtime_utils_import(self):
|
||||
"""Smoke test: verify overtime utilities can be imported"""
|
||||
from app.utils import overtime
|
||||
assert hasattr(overtime, 'calculate_daily_overtime')
|
||||
assert hasattr(overtime, 'calculate_period_overtime')
|
||||
assert hasattr(overtime, 'get_daily_breakdown')
|
||||
assert hasattr(overtime, 'get_weekly_overtime_summary')
|
||||
assert hasattr(overtime, 'get_overtime_statistics')
|
||||
|
||||
def test_user_model_has_standard_hours(self, app):
|
||||
"""Smoke test: verify User model has standard_hours_per_day field"""
|
||||
user = User(username='smoke_test_user', role='user')
|
||||
assert hasattr(user, 'standard_hours_per_day')
|
||||
assert user.standard_hours_per_day == 8.0 # Default value
|
||||
|
||||
def test_basic_overtime_calculation(self):
|
||||
"""Smoke test: verify basic overtime calculation works"""
|
||||
# 10 hours worked with 8 hour standard = 2 hours overtime
|
||||
overtime = calculate_daily_overtime(10.0, 8.0)
|
||||
assert overtime == 2.0
|
||||
|
||||
def test_no_overtime_calculation(self):
|
||||
"""Smoke test: verify no overtime when under standard hours"""
|
||||
overtime = calculate_daily_overtime(6.0, 8.0)
|
||||
assert overtime == 0.0
|
||||
|
||||
def test_period_overtime_basic(self, app):
|
||||
"""Smoke test: verify period overtime calculation doesn't crash"""
|
||||
# Create a test user
|
||||
user = User(username='smoke_period_user', role='user')
|
||||
user.standard_hours_per_day = 8.0
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
# Calculate overtime for a period with no entries
|
||||
start_date = date.today() - timedelta(days=7)
|
||||
end_date = date.today()
|
||||
|
||||
result = calculate_period_overtime(user, start_date, end_date)
|
||||
|
||||
# Should return valid structure even with no data
|
||||
assert 'regular_hours' in result
|
||||
assert 'overtime_hours' in result
|
||||
assert 'total_hours' in result
|
||||
assert 'days_with_overtime' in result
|
||||
assert result['overtime_hours'] == 0.0
|
||||
|
||||
def test_settings_route_accessible(self, app):
|
||||
"""Smoke test: verify settings page is accessible"""
|
||||
from app.routes.user import settings
|
||||
# Just verify the route exists and is importable
|
||||
assert settings is not None
|
||||
|
||||
def test_user_report_route_exists(self, app):
|
||||
"""Smoke test: verify user report route exists"""
|
||||
from app.routes.reports import user_report
|
||||
assert user_report is not None
|
||||
|
||||
def test_analytics_overtime_route_exists(self, app):
|
||||
"""Smoke test: verify analytics overtime route exists"""
|
||||
from app.routes.analytics import overtime_analytics
|
||||
assert overtime_analytics is not None
|
||||
|
||||
def test_overtime_calculation_with_real_entry(self, app):
|
||||
"""Smoke test: verify overtime calculation with a real time entry"""
|
||||
# Create test data
|
||||
user = User(username='smoke_entry_user', role='user')
|
||||
user.standard_hours_per_day = 8.0
|
||||
db.session.add(user)
|
||||
|
||||
client_obj = Client(name='Smoke Test Client')
|
||||
db.session.add(client_obj)
|
||||
db.session.commit()
|
||||
|
||||
project = Project(name='Smoke Test Project', client_id=client_obj.id)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
|
||||
# Create a 10-hour time entry (should result in 2 hours overtime)
|
||||
entry_date = date.today()
|
||||
entry_start = datetime.combine(entry_date, datetime.min.time().replace(hour=9))
|
||||
entry_end = entry_start + timedelta(hours=10)
|
||||
|
||||
entry = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=entry_start,
|
||||
end_time=entry_end,
|
||||
notes='Smoke test entry'
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
|
||||
# Calculate overtime
|
||||
result = calculate_period_overtime(user, entry_date, entry_date)
|
||||
|
||||
assert result['total_hours'] == 10.0
|
||||
assert result['regular_hours'] == 8.0
|
||||
assert result['overtime_hours'] == 2.0
|
||||
assert result['days_with_overtime'] == 1
|
||||
|
||||
def test_migration_file_exists(self):
|
||||
"""Smoke test: verify migration file exists"""
|
||||
import os
|
||||
migration_path = 'migrations/versions/031_add_standard_hours_per_day.py'
|
||||
assert os.path.exists(migration_path), f"Migration file not found: {migration_path}"
|
||||
|
||||
def test_overtime_template_fields(self, app):
|
||||
"""Smoke test: verify settings template has overtime field"""
|
||||
import os
|
||||
template_path = 'app/templates/user/settings.html'
|
||||
assert os.path.exists(template_path)
|
||||
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
assert 'standard_hours_per_day' in content, "Settings template missing overtime field"
|
||||
assert 'Overtime Settings' in content, "Settings template missing overtime section"
|
||||
|
||||
|
||||
class TestOvertimeIntegration:
|
||||
"""Integration tests for overtime feature"""
|
||||
|
||||
def test_full_overtime_workflow(self, app):
|
||||
"""Integration test: full overtime calculation workflow"""
|
||||
# 1. Create user with custom standard hours
|
||||
user = User(username='integration_user', role='user')
|
||||
user.standard_hours_per_day = 7.5 # 7.5 hour workday
|
||||
db.session.add(user)
|
||||
|
||||
# 2. Create client and project
|
||||
client_obj = Client(name='Integration Client')
|
||||
db.session.add(client_obj)
|
||||
db.session.commit()
|
||||
|
||||
project = Project(name='Integration Project', client_id=client_obj.id)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
|
||||
# 3. Create time entries over multiple days
|
||||
start_date = date.today() - timedelta(days=4)
|
||||
|
||||
# Day 1: 9 hours (1.5 hours overtime)
|
||||
entry1_start = datetime.combine(start_date, datetime.min.time().replace(hour=9))
|
||||
entry1_end = entry1_start + timedelta(hours=9)
|
||||
entry1 = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=entry1_start,
|
||||
end_time=entry1_end
|
||||
)
|
||||
db.session.add(entry1)
|
||||
|
||||
# Day 2: 7 hours (no overtime)
|
||||
entry2_start = datetime.combine(start_date + timedelta(days=1), datetime.min.time().replace(hour=9))
|
||||
entry2_end = entry2_start + timedelta(hours=7)
|
||||
entry2 = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=entry2_start,
|
||||
end_time=entry2_end
|
||||
)
|
||||
db.session.add(entry2)
|
||||
|
||||
# Day 3: 10 hours (2.5 hours overtime)
|
||||
entry3_start = datetime.combine(start_date + timedelta(days=2), datetime.min.time().replace(hour=9))
|
||||
entry3_end = entry3_start + timedelta(hours=10)
|
||||
entry3 = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=entry3_start,
|
||||
end_time=entry3_end
|
||||
)
|
||||
db.session.add(entry3)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# 4. Calculate period overtime
|
||||
result = calculate_period_overtime(user, start_date, date.today())
|
||||
|
||||
# 5. Verify results
|
||||
# Total: 9 + 7 + 10 = 26 hours
|
||||
# Overtime: 1.5 + 0 + 2.5 = 4 hours
|
||||
# Regular: 26 - 4 = 22 hours
|
||||
assert result['total_hours'] == 26.0
|
||||
assert result['overtime_hours'] == 4.0
|
||||
assert result['regular_hours'] == 22.0
|
||||
assert result['days_with_overtime'] == 2
|
||||
|
||||
# 6. Verify daily breakdown
|
||||
from app.utils.overtime import get_daily_breakdown
|
||||
breakdown = get_daily_breakdown(user, start_date, date.today())
|
||||
|
||||
assert len(breakdown) == 3
|
||||
assert breakdown[0]['overtime_hours'] == 1.5 # Day 1
|
||||
assert breakdown[1]['overtime_hours'] == 0.0 # Day 2
|
||||
assert breakdown[2]['overtime_hours'] == 2.5 # Day 3
|
||||
|
||||
def test_different_standard_hours_between_users(self, app):
|
||||
"""Integration test: different users with different standard hours"""
|
||||
# User 1: 8 hour standard
|
||||
user1 = User(username='user_8h', role='user')
|
||||
user1.standard_hours_per_day = 8.0
|
||||
db.session.add(user1)
|
||||
|
||||
# User 2: 6 hour standard (part-time)
|
||||
user2 = User(username='user_6h', role='user')
|
||||
user2.standard_hours_per_day = 6.0
|
||||
db.session.add(user2)
|
||||
|
||||
# Create client and project
|
||||
client_obj = Client(name='Multi User Client')
|
||||
db.session.add(client_obj)
|
||||
db.session.commit()
|
||||
|
||||
project = Project(name='Multi User Project', client_id=client_obj.id)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
|
||||
# Both users work 7 hours today
|
||||
today = date.today()
|
||||
entry_start = datetime.combine(today, datetime.min.time().replace(hour=9))
|
||||
entry_end = entry_start + timedelta(hours=7)
|
||||
|
||||
entry1 = TimeEntry(
|
||||
user_id=user1.id,
|
||||
project_id=project.id,
|
||||
start_time=entry_start,
|
||||
end_time=entry_end
|
||||
)
|
||||
db.session.add(entry1)
|
||||
|
||||
entry2 = TimeEntry(
|
||||
user_id=user2.id,
|
||||
project_id=project.id,
|
||||
start_time=entry_start,
|
||||
end_time=entry_end
|
||||
)
|
||||
db.session.add(entry2)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Calculate overtime for both users
|
||||
result1 = calculate_period_overtime(user1, today, today)
|
||||
result2 = calculate_period_overtime(user2, today, today)
|
||||
|
||||
# User 1: 7 hours, no overtime (under 8)
|
||||
assert result1['overtime_hours'] == 0.0
|
||||
assert result1['regular_hours'] == 7.0
|
||||
|
||||
# User 2: 7 hours, 1 hour overtime (over 6)
|
||||
assert result2['overtime_hours'] == 1.0
|
||||
assert result2['regular_hours'] == 6.0
|
||||
|
||||
Reference in New Issue
Block a user