mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-08 04:30:20 -06:00
Merge pull request #142 from DRYTRIX/feat-Time-Rounding-Preferences
feat: Add per-user time rounding preferences
This commit is contained in:
@@ -135,15 +135,20 @@ class TimeEntry(db.Model):
|
||||
duration = self.end_time - self.start_time
|
||||
raw_seconds = int(duration.total_seconds())
|
||||
|
||||
# Apply rounding
|
||||
rounding_minutes = Config.ROUNDING_MINUTES
|
||||
if rounding_minutes > 1:
|
||||
# Round to nearest interval
|
||||
minutes = raw_seconds / 60
|
||||
rounded_minutes = round(minutes / rounding_minutes) * rounding_minutes
|
||||
self.duration_seconds = int(rounded_minutes * 60)
|
||||
# Apply per-user rounding if user preferences are set
|
||||
if self.user and hasattr(self.user, 'time_rounding_enabled'):
|
||||
from app.utils.time_rounding import apply_user_rounding
|
||||
self.duration_seconds = apply_user_rounding(raw_seconds, self.user)
|
||||
else:
|
||||
self.duration_seconds = raw_seconds
|
||||
# Fallback to global rounding setting for backward compatibility
|
||||
rounding_minutes = Config.ROUNDING_MINUTES
|
||||
if rounding_minutes > 1:
|
||||
# Round to nearest interval
|
||||
minutes = raw_seconds / 60
|
||||
rounded_minutes = round(minutes / rounding_minutes) * rounding_minutes
|
||||
self.duration_seconds = int(rounded_minutes * 60)
|
||||
else:
|
||||
self.duration_seconds = raw_seconds
|
||||
|
||||
def stop_timer(self, end_time=None):
|
||||
"""Stop an active timer"""
|
||||
|
||||
@@ -37,6 +37,11 @@ class User(UserMixin, db.Model):
|
||||
time_format = db.Column(db.String(10), default='24h', nullable=False) # '12h' or '24h'
|
||||
week_start_day = db.Column(db.Integer, default=1, nullable=False) # 0=Sunday, 1=Monday, etc.
|
||||
|
||||
# Time rounding preferences
|
||||
time_rounding_enabled = db.Column(db.Boolean, default=True, nullable=False) # Enable/disable time rounding
|
||||
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'
|
||||
|
||||
# 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')
|
||||
|
||||
@@ -86,6 +86,17 @@ def settings():
|
||||
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
|
||||
|
||||
# Save changes
|
||||
if safe_commit(db.session):
|
||||
# Log activity
|
||||
@@ -122,10 +133,17 @@ def settings():
|
||||
'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)
|
||||
languages=languages,
|
||||
rounding_intervals=rounding_intervals,
|
||||
rounding_methods=rounding_methods)
|
||||
|
||||
|
||||
@user_bp.route('/api/preferences', methods=['PATCH'])
|
||||
|
||||
@@ -290,7 +290,7 @@
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ current_user.email if current_user.is_authenticated else '' }}</div>
|
||||
</li>
|
||||
<li><a href="{{ url_for('auth.profile') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-user w-4"></i> {{ _('Profile') }}</a></li>
|
||||
<li><a href="{{ url_for('auth.edit_profile') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-cog w-4"></i> {{ _('Settings') }}</a></li>
|
||||
<li><a href="{{ url_for('user.settings') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-cog w-4"></i> {{ _('Settings') }}</a></li>
|
||||
<li class="border-t border-border-light dark:border-border-dark"><a href="{{ url_for('auth.logout') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-rose-600 dark:text-rose-400 hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-sign-out-alt w-4"></i> {{ _('Logout') }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
|
||||
<form method="POST" class="space-y-8">
|
||||
{{ csrf_token() if csrf_token }}
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<!-- Profile Information -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
@@ -138,6 +138,68 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time Rounding Preferences -->
|
||||
<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-clock mr-2"></i>{{ _('Time Rounding Preferences') }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{{ _('Configure how your time entries are rounded. This affects how durations are calculated when you stop timers.') }}
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="time_rounding_enabled" name="time_rounding_enabled"
|
||||
{% if user.time_rounding_enabled %}checked{% endif %}
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
onchange="toggleRoundingOptions()">
|
||||
<label for="time_rounding_enabled" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
|
||||
<span class="font-medium">{{ _('Enable Time Rounding') }}</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ _('Round time entries to configured intervals') }}</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="rounding-options" class="ml-8 space-y-4 border-l-2 border-gray-200 dark:border-gray-700 pl-4">
|
||||
<div>
|
||||
<label for="time_rounding_minutes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ _('Rounding Interval') }}
|
||||
</label>
|
||||
<select id="time_rounding_minutes" name="time_rounding_minutes"
|
||||
class="w-full px-3 py-2 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">
|
||||
{% for minutes, label in rounding_intervals %}
|
||||
<option value="{{ minutes }}" {% if user.time_rounding_minutes == minutes %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _('Time entries will be rounded to this interval') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="time_rounding_method" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ _('Rounding Method') }}
|
||||
</label>
|
||||
<select id="time_rounding_method" name="time_rounding_method"
|
||||
class="w-full px-3 py-2 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"
|
||||
onchange="updateRoundingMethodDescription()">
|
||||
{% for method, label, description in rounding_methods %}
|
||||
<option value="{{ method }}" data-description="{{ description }}" {% if user.time_rounding_method == method %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p id="rounding-method-description" class="text-xs text-gray-500 dark:text-gray-400 mt-1"></p>
|
||||
</div>
|
||||
|
||||
<!-- Example visualization -->
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-md">
|
||||
<p class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
<i class="fas fa-info-circle mr-1"></i>{{ _('Example') }}
|
||||
</p>
|
||||
<div id="rounding-example" class="text-xs text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
</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">
|
||||
@@ -236,6 +298,83 @@ document.getElementById('email_notifications').addEventListener('change', functi
|
||||
emailField.classList.remove('border-yellow-500');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle rounding options visibility
|
||||
function toggleRoundingOptions() {
|
||||
const enabled = document.getElementById('time_rounding_enabled').checked;
|
||||
const options = document.getElementById('rounding-options');
|
||||
|
||||
if (enabled) {
|
||||
options.style.opacity = '1';
|
||||
options.querySelectorAll('select').forEach(select => select.disabled = false);
|
||||
} else {
|
||||
options.style.opacity = '0.5';
|
||||
options.querySelectorAll('select').forEach(select => select.disabled = true);
|
||||
}
|
||||
updateRoundingExample();
|
||||
}
|
||||
|
||||
// Update rounding method description
|
||||
function updateRoundingMethodDescription() {
|
||||
const select = document.getElementById('time_rounding_method');
|
||||
const description = select.options[select.selectedIndex].getAttribute('data-description');
|
||||
document.getElementById('rounding-method-description').textContent = description;
|
||||
updateRoundingExample();
|
||||
}
|
||||
|
||||
// Update rounding example visualization
|
||||
function updateRoundingExample() {
|
||||
const enabled = document.getElementById('time_rounding_enabled').checked;
|
||||
const minutes = parseInt(document.getElementById('time_rounding_minutes').value);
|
||||
const method = document.getElementById('time_rounding_method').value;
|
||||
const exampleDiv = document.getElementById('rounding-example');
|
||||
|
||||
if (!enabled) {
|
||||
exampleDiv.innerHTML = '<p>{{ _("Time rounding is disabled. All times will be recorded exactly as tracked.") }}</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (minutes === 1) {
|
||||
exampleDiv.innerHTML = '<p>{{ _("No rounding - times will be recorded exactly as tracked.") }}</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate examples
|
||||
const testDuration = 62; // 62 minutes = 1h 2min
|
||||
let rounded;
|
||||
|
||||
if (method === 'up') {
|
||||
rounded = Math.ceil(testDuration / minutes) * minutes;
|
||||
} else if (method === 'down') {
|
||||
rounded = Math.floor(testDuration / minutes) * minutes;
|
||||
} else {
|
||||
rounded = Math.round(testDuration / minutes) * minutes;
|
||||
}
|
||||
|
||||
const formatTime = (mins) => {
|
||||
const hours = Math.floor(mins / 60);
|
||||
const remainingMins = mins % 60;
|
||||
if (hours > 0) {
|
||||
return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
|
||||
}
|
||||
return `${remainingMins}m`;
|
||||
};
|
||||
|
||||
exampleDiv.innerHTML = `
|
||||
<p><strong>{{ _("Actual time:") }}</strong> ${formatTime(testDuration)} → <strong>{{ _("Rounded:") }}</strong> ${formatTime(rounded)}</p>
|
||||
<p class="text-xs opacity-75">{{ _("With ") }}${minutes}{{ _(" minute intervals") }}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
toggleRoundingOptions();
|
||||
updateRoundingMethodDescription();
|
||||
|
||||
// Update example when settings change
|
||||
document.getElementById('time_rounding_enabled').addEventListener('change', updateRoundingExample);
|
||||
document.getElementById('time_rounding_minutes').addEventListener('change', updateRoundingExample);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
153
app/utils/time_rounding.py
Normal file
153
app/utils/time_rounding.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Time rounding utilities for per-user time entry rounding preferences"""
|
||||
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def round_time_duration(
|
||||
duration_seconds: int,
|
||||
rounding_minutes: int = 1,
|
||||
rounding_method: str = 'nearest'
|
||||
) -> int:
|
||||
"""
|
||||
Round a time duration in seconds based on the specified rounding settings.
|
||||
|
||||
Args:
|
||||
duration_seconds: The raw duration in seconds
|
||||
rounding_minutes: The rounding interval in minutes (e.g., 1, 5, 10, 15, 30, 60)
|
||||
rounding_method: The rounding method ('nearest', 'up', or 'down')
|
||||
|
||||
Returns:
|
||||
int: The rounded duration in seconds
|
||||
|
||||
Examples:
|
||||
>>> round_time_duration(3720, 15, 'nearest') # 62 minutes -> 60 minutes (1 hour)
|
||||
3600
|
||||
>>> round_time_duration(3720, 15, 'up') # 62 minutes -> 75 minutes (1.25 hours)
|
||||
4500
|
||||
>>> round_time_duration(3720, 15, 'down') # 62 minutes -> 60 minutes (1 hour)
|
||||
3600
|
||||
"""
|
||||
# If rounding is disabled (rounding_minutes = 1), return raw duration
|
||||
if rounding_minutes <= 1:
|
||||
return duration_seconds
|
||||
|
||||
# Validate rounding method
|
||||
if rounding_method not in ('nearest', 'up', 'down'):
|
||||
rounding_method = 'nearest'
|
||||
|
||||
# Convert to minutes for easier calculation
|
||||
duration_minutes = duration_seconds / 60.0
|
||||
|
||||
# Apply rounding based on method
|
||||
if rounding_method == 'up':
|
||||
rounded_minutes = math.ceil(duration_minutes / rounding_minutes) * rounding_minutes
|
||||
elif rounding_method == 'down':
|
||||
rounded_minutes = math.floor(duration_minutes / rounding_minutes) * rounding_minutes
|
||||
else: # 'nearest'
|
||||
rounded_minutes = round(duration_minutes / rounding_minutes) * rounding_minutes
|
||||
|
||||
# Convert back to seconds
|
||||
return int(rounded_minutes * 60)
|
||||
|
||||
|
||||
def get_user_rounding_settings(user) -> dict:
|
||||
"""
|
||||
Get the time rounding settings for a user.
|
||||
|
||||
Args:
|
||||
user: A User model instance
|
||||
|
||||
Returns:
|
||||
dict: Dictionary with 'enabled', 'minutes', and 'method' keys
|
||||
"""
|
||||
return {
|
||||
'enabled': getattr(user, 'time_rounding_enabled', True),
|
||||
'minutes': getattr(user, 'time_rounding_minutes', 1),
|
||||
'method': getattr(user, 'time_rounding_method', 'nearest')
|
||||
}
|
||||
|
||||
|
||||
def apply_user_rounding(duration_seconds: int, user) -> int:
|
||||
"""
|
||||
Apply a user's rounding preferences to a duration.
|
||||
|
||||
Args:
|
||||
duration_seconds: The raw duration in seconds
|
||||
user: A User model instance with rounding preferences
|
||||
|
||||
Returns:
|
||||
int: The rounded duration in seconds
|
||||
"""
|
||||
settings = get_user_rounding_settings(user)
|
||||
|
||||
# If rounding is disabled for this user, return raw duration
|
||||
if not settings['enabled']:
|
||||
return duration_seconds
|
||||
|
||||
return round_time_duration(
|
||||
duration_seconds,
|
||||
settings['minutes'],
|
||||
settings['method']
|
||||
)
|
||||
|
||||
|
||||
def format_rounding_interval(minutes: int) -> str:
|
||||
"""
|
||||
Format a rounding interval in minutes as a human-readable string.
|
||||
|
||||
Args:
|
||||
minutes: The rounding interval in minutes
|
||||
|
||||
Returns:
|
||||
str: A human-readable description
|
||||
|
||||
Examples:
|
||||
>>> format_rounding_interval(1)
|
||||
'No rounding (exact time)'
|
||||
>>> format_rounding_interval(15)
|
||||
'15 minutes'
|
||||
>>> format_rounding_interval(60)
|
||||
'1 hour'
|
||||
"""
|
||||
if minutes <= 1:
|
||||
return 'No rounding (exact time)'
|
||||
elif minutes == 60:
|
||||
return '1 hour'
|
||||
elif minutes >= 60:
|
||||
hours = minutes // 60
|
||||
return f'{hours} hour{"s" if hours > 1 else ""}'
|
||||
else:
|
||||
return f'{minutes} minute{"s" if minutes > 1 else ""}'
|
||||
|
||||
|
||||
def get_available_rounding_intervals() -> list:
|
||||
"""
|
||||
Get the list of available rounding intervals.
|
||||
|
||||
Returns:
|
||||
list: List of tuples (minutes, label)
|
||||
"""
|
||||
return [
|
||||
(1, 'No rounding (exact time)'),
|
||||
(5, '5 minutes'),
|
||||
(10, '10 minutes'),
|
||||
(15, '15 minutes'),
|
||||
(30, '30 minutes'),
|
||||
(60, '1 hour')
|
||||
]
|
||||
|
||||
|
||||
def get_available_rounding_methods() -> list:
|
||||
"""
|
||||
Get the list of available rounding methods.
|
||||
|
||||
Returns:
|
||||
list: List of tuples (method, label, description)
|
||||
"""
|
||||
return [
|
||||
('nearest', 'Round to nearest', 'Round to the nearest interval (standard rounding)'),
|
||||
('up', 'Always round up', 'Always round up to the next interval (ceiling)'),
|
||||
('down', 'Always round down', 'Always round down to the previous interval (floor)')
|
||||
]
|
||||
|
||||
105
apply_migration.py
Normal file
105
apply_migration.py
Normal file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple script to apply the time rounding preferences migration
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add the project root to the path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app import create_app, db
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
def check_columns_exist():
|
||||
"""Check if the time rounding columns already exist"""
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
inspector = inspect(db.engine)
|
||||
columns = [col['name'] for col in inspector.get_columns('users')]
|
||||
|
||||
has_enabled = 'time_rounding_enabled' in columns
|
||||
has_minutes = 'time_rounding_minutes' in columns
|
||||
has_method = 'time_rounding_method' in columns
|
||||
|
||||
return has_enabled, has_minutes, has_method
|
||||
|
||||
def apply_migration():
|
||||
"""Apply the migration to add time rounding columns"""
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
print("Applying time rounding preferences migration...")
|
||||
|
||||
# Check if columns already exist
|
||||
has_enabled, has_minutes, has_method = check_columns_exist()
|
||||
|
||||
if has_enabled and has_minutes and has_method:
|
||||
print("✓ Migration already applied! All columns exist.")
|
||||
return True
|
||||
|
||||
# Apply the migration
|
||||
try:
|
||||
if not has_enabled:
|
||||
print("Adding time_rounding_enabled column...")
|
||||
db.session.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN time_rounding_enabled BOOLEAN DEFAULT 1 NOT NULL"
|
||||
))
|
||||
|
||||
if not has_minutes:
|
||||
print("Adding time_rounding_minutes column...")
|
||||
db.session.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN time_rounding_minutes INTEGER DEFAULT 1 NOT NULL"
|
||||
))
|
||||
|
||||
if not has_method:
|
||||
print("Adding time_rounding_method column...")
|
||||
db.session.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN time_rounding_method VARCHAR(10) DEFAULT 'nearest' NOT NULL"
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
print("✓ Migration applied successfully!")
|
||||
|
||||
# Verify
|
||||
has_enabled, has_minutes, has_method = check_columns_exist()
|
||||
if has_enabled and has_minutes and has_method:
|
||||
print("✓ Verification passed! All columns exist.")
|
||||
return True
|
||||
else:
|
||||
print("✗ Verification failed! Some columns are missing.")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Migration failed: {e}")
|
||||
db.session.rollback()
|
||||
return False
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("=== Time Rounding Preferences Migration ===")
|
||||
print()
|
||||
|
||||
# Check current state
|
||||
try:
|
||||
has_enabled, has_minutes, has_method = check_columns_exist()
|
||||
print("Current database state:")
|
||||
print(f" - time_rounding_enabled: {'✓ exists' if has_enabled else '✗ missing'}")
|
||||
print(f" - time_rounding_minutes: {'✓ exists' if has_minutes else '✗ missing'}")
|
||||
print(f" - time_rounding_method: {'✓ exists' if has_method else '✗ missing'}")
|
||||
print()
|
||||
except Exception as e:
|
||||
print(f"✗ Could not check database state: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Apply migration if needed
|
||||
if has_enabled and has_minutes and has_method:
|
||||
print("All columns already exist. No migration needed.")
|
||||
else:
|
||||
success = apply_migration()
|
||||
if success:
|
||||
print("\n✓ Migration complete! You can now use the time rounding preferences feature.")
|
||||
print(" Please restart your application to load the changes.")
|
||||
else:
|
||||
print("\n✗ Migration failed. Please check the error messages above.")
|
||||
sys.exit(1)
|
||||
|
||||
345
docs/TIME_ROUNDING_PREFERENCES.md
Normal file
345
docs/TIME_ROUNDING_PREFERENCES.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# Time Rounding Preferences - Per-User Settings
|
||||
|
||||
## Overview
|
||||
|
||||
The Time Rounding Preferences feature allows each user to configure how their time entries are rounded when they stop timers. This provides flexibility for different billing practices and time tracking requirements while maintaining accurate time records.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Per-User Configuration**: Each user can set their own rounding preferences independently
|
||||
- **Multiple Rounding Intervals**: Support for 1, 5, 10, 15, 30, and 60-minute intervals
|
||||
- **Three Rounding Methods**:
|
||||
- **Nearest**: Round to the closest interval (standard rounding)
|
||||
- **Up**: Always round up to the next interval (ceiling)
|
||||
- **Down**: Always round down to the previous interval (floor)
|
||||
- **Enable/Disable Toggle**: Users can disable rounding to track exact time
|
||||
- **Real-time Preview**: Visual examples show how rounding will be applied
|
||||
|
||||
## User Guide
|
||||
|
||||
### Accessing Rounding Settings
|
||||
|
||||
1. Navigate to **Settings** from the user menu
|
||||
2. Scroll to the **Time Rounding Preferences** section
|
||||
3. Configure your preferences:
|
||||
- Toggle **Enable Time Rounding** on/off
|
||||
- Select your preferred **Rounding Interval**
|
||||
- Choose your **Rounding Method**
|
||||
4. Click **Save Settings** to apply changes
|
||||
|
||||
### Understanding Rounding Methods
|
||||
|
||||
#### Round to Nearest (Default)
|
||||
Standard mathematical rounding to the closest interval.
|
||||
|
||||
**Example** with 15-minute intervals:
|
||||
- 7 minutes → 0 minutes
|
||||
- 8 minutes → 15 minutes
|
||||
- 62 minutes → 60 minutes
|
||||
- 68 minutes → 75 minutes
|
||||
|
||||
#### Always Round Up
|
||||
Always rounds up to the next interval, ensuring you never under-bill.
|
||||
|
||||
**Example** with 15-minute intervals:
|
||||
- 1 minute → 15 minutes
|
||||
- 61 minutes → 75 minutes
|
||||
- 60 minutes → 60 minutes (exact match)
|
||||
|
||||
#### Always Round Down
|
||||
Always rounds down to the previous interval, ensuring conservative billing.
|
||||
|
||||
**Example** with 15-minute intervals:
|
||||
- 14 minutes → 0 minutes
|
||||
- 74 minutes → 60 minutes
|
||||
- 75 minutes → 75 minutes (exact match)
|
||||
|
||||
### Choosing the Right Settings
|
||||
|
||||
**For Freelancers/Contractors:**
|
||||
- Use **15-minute intervals** with **Round to Nearest** for balanced billing
|
||||
- Use **Round Up** if client agreements favor rounding up
|
||||
- Use **5 or 10 minutes** for more granular tracking
|
||||
|
||||
**For Internal Time Tracking:**
|
||||
- Use **No rounding (1 minute)** for exact time tracking
|
||||
- Use **15 or 30 minutes** for simplified reporting
|
||||
|
||||
**For Project-Based Billing:**
|
||||
- Use **30 or 60 minutes** for project-level granularity
|
||||
- Use **Round Down** for conservative estimates
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Database Schema
|
||||
|
||||
The following fields are added to the `users` table:
|
||||
|
||||
```sql
|
||||
time_rounding_enabled BOOLEAN DEFAULT 1 NOT NULL
|
||||
time_rounding_minutes INTEGER DEFAULT 1 NOT NULL
|
||||
time_rounding_method VARCHAR(10) DEFAULT 'nearest' NOT NULL
|
||||
```
|
||||
|
||||
### Default Values
|
||||
|
||||
For new and existing users:
|
||||
- **Enabled**: `True` (rounding is enabled by default)
|
||||
- **Minutes**: `1` (no rounding, exact time)
|
||||
- **Method**: `'nearest'` (standard rounding)
|
||||
|
||||
### How Rounding is Applied
|
||||
|
||||
1. **Timer Start**: When a user starts a timer, no rounding is applied
|
||||
2. **Timer Stop**: When a user stops a timer:
|
||||
- Calculate raw duration (end time - start time)
|
||||
- Apply user's rounding preferences
|
||||
- Store rounded duration in `duration_seconds` field
|
||||
3. **Manual Entries**: Rounding is applied when creating/editing manual entries
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
The feature is fully backward compatible:
|
||||
- If user preferences don't exist, the system falls back to the global `ROUNDING_MINUTES` config setting
|
||||
- Existing time entries are not retroactively rounded
|
||||
- Users without the new fields will use global rounding settings
|
||||
|
||||
## API Integration
|
||||
|
||||
### Get User Rounding Settings
|
||||
|
||||
```python
|
||||
from app.utils.time_rounding import get_user_rounding_settings
|
||||
|
||||
settings = get_user_rounding_settings(user)
|
||||
# Returns: {'enabled': True, 'minutes': 15, 'method': 'nearest'}
|
||||
```
|
||||
|
||||
### Apply Rounding to Duration
|
||||
|
||||
```python
|
||||
from app.utils.time_rounding import apply_user_rounding
|
||||
|
||||
raw_seconds = 3720 # 62 minutes
|
||||
rounded_seconds = apply_user_rounding(raw_seconds, user)
|
||||
# Returns: 3600 (60 minutes) with 15-min nearest rounding
|
||||
```
|
||||
|
||||
### Manual Rounding
|
||||
|
||||
```python
|
||||
from app.utils.time_rounding import round_time_duration
|
||||
|
||||
rounded = round_time_duration(
|
||||
duration_seconds=3720, # 62 minutes
|
||||
rounding_minutes=15,
|
||||
rounding_method='up'
|
||||
)
|
||||
# Returns: 4500 (75 minutes)
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Applying the Migration
|
||||
|
||||
Run the Alembic migration to add the new fields:
|
||||
|
||||
```bash
|
||||
# Using Alembic
|
||||
alembic upgrade head
|
||||
|
||||
# Or using the migration script
|
||||
python migrations/manage_migrations.py upgrade
|
||||
```
|
||||
|
||||
### Migration Details
|
||||
|
||||
- **Migration File**: `migrations/versions/027_add_user_time_rounding_preferences.py`
|
||||
- **Adds**: Three new columns to the `users` table
|
||||
- **Safe**: Non-destructive, adds columns with default values
|
||||
- **Rollback**: Supported via downgrade function
|
||||
|
||||
### Verifying Migration
|
||||
|
||||
```python
|
||||
from app.models import User
|
||||
from app import db
|
||||
|
||||
# Check if fields exist
|
||||
user = User.query.first()
|
||||
assert hasattr(user, 'time_rounding_enabled')
|
||||
assert hasattr(user, 'time_rounding_minutes')
|
||||
assert hasattr(user, 'time_rounding_method')
|
||||
|
||||
# Check default values
|
||||
assert user.time_rounding_enabled == True
|
||||
assert user.time_rounding_minutes == 1
|
||||
assert user.time_rounding_method == 'nearest'
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Available Rounding Intervals
|
||||
|
||||
The following intervals are supported:
|
||||
- `1` - No rounding (exact time)
|
||||
- `5` - 5 minutes
|
||||
- `10` - 10 minutes
|
||||
- `15` - 15 minutes
|
||||
- `30` - 30 minutes (half hour)
|
||||
- `60` - 60 minutes (1 hour)
|
||||
|
||||
### Available Rounding Methods
|
||||
|
||||
Three methods are supported:
|
||||
- `'nearest'` - Round to nearest interval
|
||||
- `'up'` - Always round up (ceiling)
|
||||
- `'down'` - Always round down (floor)
|
||||
|
||||
### Global Fallback Setting
|
||||
|
||||
If per-user rounding is not configured, the system uses the global setting:
|
||||
|
||||
```python
|
||||
# In app/config.py
|
||||
ROUNDING_MINUTES = int(os.environ.get('ROUNDING_MINUTES', 1))
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all time rounding tests
|
||||
pytest tests/test_time_rounding*.py -v
|
||||
|
||||
# Run specific test suites
|
||||
pytest tests/test_time_rounding.py -v # Unit tests
|
||||
pytest tests/test_time_rounding_models.py -v # Model integration tests
|
||||
pytest tests/test_time_rounding_smoke.py -v # Smoke tests
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
The feature includes:
|
||||
- **Unit Tests**: Core rounding logic (50+ test cases)
|
||||
- **Model Tests**: Database integration and TimeEntry model
|
||||
- **Smoke Tests**: End-to-end workflows and edge cases
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Freelancer with 15-Minute Billing
|
||||
|
||||
```python
|
||||
# User settings
|
||||
user.time_rounding_enabled = True
|
||||
user.time_rounding_minutes = 15
|
||||
user.time_rounding_method = 'nearest'
|
||||
|
||||
# Time entry: 62 minutes
|
||||
# Result: 60 minutes (rounded to nearest 15-min interval)
|
||||
```
|
||||
|
||||
### Example 2: Contractor with Round-Up Policy
|
||||
|
||||
```python
|
||||
# User settings
|
||||
user.time_rounding_enabled = True
|
||||
user.time_rounding_minutes = 15
|
||||
user.time_rounding_method = 'up'
|
||||
|
||||
# Time entry: 61 minutes
|
||||
# Result: 75 minutes (rounded up to next 15-min interval)
|
||||
```
|
||||
|
||||
### Example 3: Exact Time Tracking
|
||||
|
||||
```python
|
||||
# User settings
|
||||
user.time_rounding_enabled = False
|
||||
|
||||
# Time entry: 62 minutes 37 seconds
|
||||
# Result: 62 minutes 37 seconds (3757 seconds, exact)
|
||||
```
|
||||
|
||||
### Example 4: Conservative Billing
|
||||
|
||||
```python
|
||||
# User settings
|
||||
user.time_rounding_enabled = True
|
||||
user.time_rounding_minutes = 30
|
||||
user.time_rounding_method = 'down'
|
||||
|
||||
# Time entry: 62 minutes
|
||||
# Result: 60 minutes (rounded down to previous 30-min interval)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Rounding Not Applied
|
||||
|
||||
**Issue**: Time entries are not being rounded despite settings being enabled.
|
||||
|
||||
**Solutions**:
|
||||
1. Verify rounding is enabled: Check `user.time_rounding_enabled == True`
|
||||
2. Check rounding interval: Ensure `user.time_rounding_minutes > 1`
|
||||
3. Verify migration was applied: Check if columns exist in database
|
||||
4. Clear cache and restart application
|
||||
|
||||
### Unexpected Rounding Results
|
||||
|
||||
**Issue**: Durations are rounded differently than expected.
|
||||
|
||||
**Solutions**:
|
||||
1. Verify rounding method setting (nearest/up/down)
|
||||
2. Check the actual rounding interval (minutes value)
|
||||
3. Test with example calculations using the utility functions
|
||||
4. Review the rounding method documentation
|
||||
|
||||
### Migration Fails
|
||||
|
||||
**Issue**: Alembic migration fails to apply.
|
||||
|
||||
**Solutions**:
|
||||
1. Check database permissions
|
||||
2. Verify no conflicting migrations
|
||||
3. Run `alembic current` to check migration state
|
||||
4. Try manual column addition as fallback
|
||||
5. Check logs for specific error messages
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Choose Appropriate Intervals**: Match your rounding to billing agreements
|
||||
2. **Document Your Choice**: Note why you chose specific rounding settings
|
||||
3. **Test Before Production**: Verify rounding behavior with test entries
|
||||
4. **Communicate with Clients**: Ensure clients understand your rounding policy
|
||||
5. **Review Regularly**: Periodically review if rounding settings still make sense
|
||||
6. **Keep Records**: Document any changes to rounding preferences
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for future versions:
|
||||
- Project-specific rounding overrides
|
||||
- Time-of-day based rounding rules
|
||||
- Client-specific rounding preferences
|
||||
- Rounding reports and analytics
|
||||
- Bulk update of historical entries with new rounding
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check this documentation first
|
||||
2. Review test files for usage examples
|
||||
3. Check the codebase in `app/utils/time_rounding.py`
|
||||
4. Open an issue on the project repository
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 1.0 (2025-10-24)
|
||||
- Initial implementation of per-user time rounding preferences
|
||||
- Support for 6 rounding intervals (1, 5, 10, 15, 30, 60 minutes)
|
||||
- Support for 3 rounding methods (nearest, up, down)
|
||||
- UI integration in user settings page
|
||||
- Comprehensive test coverage
|
||||
- Full backward compatibility with global rounding settings
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Add user time rounding preferences
|
||||
|
||||
Revision ID: 027
|
||||
Revises: 026
|
||||
Create Date: 2025-10-24 00:00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '027'
|
||||
down_revision = '026'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Add time rounding preference fields to users table"""
|
||||
bind = op.get_bind()
|
||||
dialect_name = bind.dialect.name if bind else 'generic'
|
||||
|
||||
# Add time rounding preferences to users table
|
||||
try:
|
||||
# Enable/disable time rounding for this user
|
||||
op.add_column('users', sa.Column('time_rounding_enabled', sa.Boolean(), nullable=False, server_default='1'))
|
||||
print("✓ Added time_rounding_enabled column to users table")
|
||||
except Exception as e:
|
||||
print(f"⚠ Warning adding time_rounding_enabled column: {e}")
|
||||
|
||||
try:
|
||||
# Rounding interval in minutes (1, 5, 10, 15, 30, 60)
|
||||
# Default to 1 (no rounding, use exact time)
|
||||
op.add_column('users', sa.Column('time_rounding_minutes', sa.Integer(), nullable=False, server_default='1'))
|
||||
print("✓ Added time_rounding_minutes column to users table")
|
||||
except Exception as e:
|
||||
print(f"⚠ Warning adding time_rounding_minutes column: {e}")
|
||||
|
||||
try:
|
||||
# Rounding method: 'nearest', 'up', 'down'
|
||||
# 'nearest' = round to nearest interval
|
||||
# 'up' = always round up (ceil)
|
||||
# 'down' = always round down (floor)
|
||||
op.add_column('users', sa.Column('time_rounding_method', sa.String(10), nullable=False, server_default='nearest'))
|
||||
print("✓ Added time_rounding_method column to users table")
|
||||
except Exception as e:
|
||||
print(f"⚠ Warning adding time_rounding_method column: {e}")
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Remove time rounding preference fields from users table"""
|
||||
try:
|
||||
op.drop_column('users', 'time_rounding_method')
|
||||
print("✓ Dropped time_rounding_method column from users table")
|
||||
except Exception as e:
|
||||
print(f"⚠ Warning dropping time_rounding_method column: {e}")
|
||||
|
||||
try:
|
||||
op.drop_column('users', 'time_rounding_minutes')
|
||||
print("✓ Dropped time_rounding_minutes column from users table")
|
||||
except Exception as e:
|
||||
print(f"⚠ Warning dropping time_rounding_minutes column: {e}")
|
||||
|
||||
try:
|
||||
op.drop_column('users', 'time_rounding_enabled')
|
||||
print("✓ Dropped time_rounding_enabled column from users table")
|
||||
except Exception as e:
|
||||
print(f"⚠ Warning dropping time_rounding_enabled column: {e}")
|
||||
|
||||
197
tests/test_time_rounding.py
Normal file
197
tests/test_time_rounding.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""Unit tests for time rounding functionality"""
|
||||
|
||||
import pytest
|
||||
from app.utils.time_rounding import (
|
||||
round_time_duration,
|
||||
apply_user_rounding,
|
||||
format_rounding_interval,
|
||||
get_available_rounding_intervals,
|
||||
get_available_rounding_methods,
|
||||
get_user_rounding_settings
|
||||
)
|
||||
|
||||
|
||||
class TestRoundTimeDuration:
|
||||
"""Test the core time rounding function"""
|
||||
|
||||
def test_no_rounding_when_interval_is_one(self):
|
||||
"""Test that rounding_minutes=1 returns exact duration"""
|
||||
assert round_time_duration(3720, 1, 'nearest') == 3720
|
||||
assert round_time_duration(3722, 1, 'up') == 3722
|
||||
assert round_time_duration(3718, 1, 'down') == 3718
|
||||
|
||||
def test_round_to_nearest_5_minutes(self):
|
||||
"""Test rounding to nearest 5 minute interval"""
|
||||
# 62 minutes should round to 60 minutes (nearest 5-min interval)
|
||||
assert round_time_duration(3720, 5, 'nearest') == 3600
|
||||
# 63 minutes should round to 65 minutes
|
||||
assert round_time_duration(3780, 5, 'nearest') == 3900
|
||||
# 2 minutes should round to 0
|
||||
assert round_time_duration(120, 5, 'nearest') == 0
|
||||
# 3 minutes should round to 5
|
||||
assert round_time_duration(180, 5, 'nearest') == 300
|
||||
|
||||
def test_round_to_nearest_15_minutes(self):
|
||||
"""Test rounding to nearest 15 minute interval"""
|
||||
# 62 minutes should round to 60 minutes
|
||||
assert round_time_duration(3720, 15, 'nearest') == 3600
|
||||
# 68 minutes should round to 75 minutes
|
||||
assert round_time_duration(4080, 15, 'nearest') == 4500
|
||||
# 7 minutes should round to 0
|
||||
assert round_time_duration(420, 15, 'nearest') == 0
|
||||
# 8 minutes should round to 15
|
||||
assert round_time_duration(480, 15, 'nearest') == 900
|
||||
|
||||
def test_round_up(self):
|
||||
"""Test always rounding up (ceiling)"""
|
||||
# 62 minutes with 15-min interval rounds up to 75
|
||||
assert round_time_duration(3720, 15, 'up') == 4500
|
||||
# 60 minutes with 15-min interval stays 60 (exact match)
|
||||
assert round_time_duration(3600, 15, 'up') == 3600
|
||||
# 61 minutes with 15-min interval rounds up to 75
|
||||
assert round_time_duration(3660, 15, 'up') == 4500
|
||||
# 1 minute with 5-min interval rounds up to 5
|
||||
assert round_time_duration(60, 5, 'up') == 300
|
||||
|
||||
def test_round_down(self):
|
||||
"""Test always rounding down (floor)"""
|
||||
# 62 minutes with 15-min interval rounds down to 60
|
||||
assert round_time_duration(3720, 15, 'down') == 3600
|
||||
# 74 minutes with 15-min interval rounds down to 60
|
||||
assert round_time_duration(4440, 15, 'down') == 3600
|
||||
# 75 minutes with 15-min interval stays 75 (exact match)
|
||||
assert round_time_duration(4500, 15, 'down') == 4500
|
||||
|
||||
def test_round_to_hour(self):
|
||||
"""Test rounding to 1 hour intervals"""
|
||||
# 62 minutes rounds to 60 minutes (nearest hour)
|
||||
assert round_time_duration(3720, 60, 'nearest') == 3600
|
||||
# 90 minutes rounds to 120 minutes (nearest hour)
|
||||
assert round_time_duration(5400, 60, 'nearest') == 7200
|
||||
# 89 minutes rounds to 60 minutes (nearest hour)
|
||||
assert round_time_duration(5340, 60, 'nearest') == 3600
|
||||
|
||||
def test_invalid_rounding_method_defaults_to_nearest(self):
|
||||
"""Test that invalid rounding method falls back to 'nearest'"""
|
||||
result = round_time_duration(3720, 15, 'invalid')
|
||||
expected = round_time_duration(3720, 15, 'nearest')
|
||||
assert result == expected
|
||||
|
||||
def test_zero_duration(self):
|
||||
"""Test handling of zero duration"""
|
||||
assert round_time_duration(0, 15, 'nearest') == 0
|
||||
assert round_time_duration(0, 15, 'up') == 0
|
||||
assert round_time_duration(0, 15, 'down') == 0
|
||||
|
||||
def test_very_small_durations(self):
|
||||
"""Test rounding of very small durations"""
|
||||
# 30 seconds with 5-min rounding
|
||||
assert round_time_duration(30, 5, 'nearest') == 0
|
||||
assert round_time_duration(30, 5, 'up') == 300 # Rounds up to 5 minutes
|
||||
assert round_time_duration(30, 5, 'down') == 0
|
||||
|
||||
def test_very_large_durations(self):
|
||||
"""Test rounding of large durations"""
|
||||
# 8 hours 7 minutes (487 minutes) with 15-min rounding
|
||||
assert round_time_duration(29220, 15, 'nearest') == 29100 # 485 minutes
|
||||
# 8 hours 8 minutes (488 minutes) with 15-min rounding
|
||||
assert round_time_duration(29280, 15, 'nearest') == 29100 # 485 minutes
|
||||
|
||||
|
||||
class TestApplyUserRounding:
|
||||
"""Test applying user-specific rounding preferences"""
|
||||
|
||||
def test_with_rounding_disabled(self):
|
||||
"""Test that rounding is skipped when disabled for user"""
|
||||
class MockUser:
|
||||
time_rounding_enabled = False
|
||||
time_rounding_minutes = 15
|
||||
time_rounding_method = 'nearest'
|
||||
|
||||
user = MockUser()
|
||||
assert apply_user_rounding(3720, user) == 3720
|
||||
|
||||
def test_with_rounding_enabled(self):
|
||||
"""Test that rounding is applied when enabled"""
|
||||
class MockUser:
|
||||
time_rounding_enabled = True
|
||||
time_rounding_minutes = 15
|
||||
time_rounding_method = 'nearest'
|
||||
|
||||
user = MockUser()
|
||||
# 62 minutes should round to 60 with 15-min interval
|
||||
assert apply_user_rounding(3720, user) == 3600
|
||||
|
||||
def test_different_user_preferences(self):
|
||||
"""Test that different users can have different rounding settings"""
|
||||
class MockUser1:
|
||||
time_rounding_enabled = True
|
||||
time_rounding_minutes = 5
|
||||
time_rounding_method = 'up'
|
||||
|
||||
class MockUser2:
|
||||
time_rounding_enabled = True
|
||||
time_rounding_minutes = 15
|
||||
time_rounding_method = 'down'
|
||||
|
||||
duration = 3720 # 62 minutes
|
||||
|
||||
# User 1: 5-min up -> 65 minutes
|
||||
assert apply_user_rounding(duration, MockUser1()) == 3900
|
||||
|
||||
# User 2: 15-min down -> 60 minutes
|
||||
assert apply_user_rounding(duration, MockUser2()) == 3600
|
||||
|
||||
def test_get_user_rounding_settings(self):
|
||||
"""Test retrieving user rounding settings"""
|
||||
class MockUser:
|
||||
time_rounding_enabled = True
|
||||
time_rounding_minutes = 10
|
||||
time_rounding_method = 'up'
|
||||
|
||||
settings = get_user_rounding_settings(MockUser())
|
||||
assert settings['enabled'] is True
|
||||
assert settings['minutes'] == 10
|
||||
assert settings['method'] == 'up'
|
||||
|
||||
def test_get_user_rounding_settings_with_defaults(self):
|
||||
"""Test default values when attributes don't exist"""
|
||||
class MockUser:
|
||||
pass
|
||||
|
||||
settings = get_user_rounding_settings(MockUser())
|
||||
assert settings['enabled'] is True
|
||||
assert settings['minutes'] == 1
|
||||
assert settings['method'] == 'nearest'
|
||||
|
||||
|
||||
class TestFormattingFunctions:
|
||||
"""Test formatting and helper functions"""
|
||||
|
||||
def test_format_rounding_interval(self):
|
||||
"""Test formatting of rounding intervals"""
|
||||
assert format_rounding_interval(1) == 'No rounding (exact time)'
|
||||
assert format_rounding_interval(5) == '5 minutes'
|
||||
assert format_rounding_interval(15) == '15 minutes'
|
||||
assert format_rounding_interval(30) == '30 minutes'
|
||||
assert format_rounding_interval(60) == '1 hour'
|
||||
assert format_rounding_interval(120) == '2 hours'
|
||||
|
||||
def test_get_available_rounding_intervals(self):
|
||||
"""Test getting available rounding intervals"""
|
||||
intervals = get_available_rounding_intervals()
|
||||
assert len(intervals) == 6
|
||||
assert (1, 'No rounding (exact time)') in intervals
|
||||
assert (5, '5 minutes') in intervals
|
||||
assert (60, '1 hour') in intervals
|
||||
|
||||
def test_get_available_rounding_methods(self):
|
||||
"""Test getting available rounding methods"""
|
||||
methods = get_available_rounding_methods()
|
||||
assert len(methods) == 3
|
||||
|
||||
method_values = [m[0] for m in methods]
|
||||
assert 'nearest' in method_values
|
||||
assert 'up' in method_values
|
||||
assert 'down' in method_values
|
||||
|
||||
350
tests/test_time_rounding_models.py
Normal file
350
tests/test_time_rounding_models.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""Model tests for time rounding preferences integration"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from app import create_app, db
|
||||
from app.models import User, Project, TimeEntry
|
||||
from app.utils.time_rounding import apply_user_rounding
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create application for testing"""
|
||||
app = create_app()
|
||||
app.config['TESTING'] = True
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
|
||||
app.config['WTF_CSRF_ENABLED'] = False
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
yield app
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create test client"""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(app):
|
||||
"""Create a test user with default rounding preferences"""
|
||||
with app.app_context():
|
||||
user = User(username='testuser', role='user')
|
||||
user.time_rounding_enabled = True
|
||||
user.time_rounding_minutes = 15
|
||||
user.time_rounding_method = 'nearest'
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
# Return the user ID instead of the object
|
||||
user_id = user.id
|
||||
db.session.expunge_all()
|
||||
|
||||
# Re-query the user in a new session
|
||||
with app.app_context():
|
||||
return User.query.get(user_id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_project(app, test_user):
|
||||
"""Create a test project"""
|
||||
with app.app_context():
|
||||
user = User.query.get(test_user.id)
|
||||
project = Project(
|
||||
name='Test Project',
|
||||
client='Test Client',
|
||||
status='active',
|
||||
created_by_id=user.id
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
|
||||
project_id = project.id
|
||||
db.session.expunge_all()
|
||||
|
||||
with app.app_context():
|
||||
return Project.query.get(project_id)
|
||||
|
||||
|
||||
class TestUserRoundingPreferences:
|
||||
"""Test User model rounding preference fields"""
|
||||
|
||||
def test_user_has_rounding_fields(self, app, test_user):
|
||||
"""Test that user model has rounding preference fields"""
|
||||
with app.app_context():
|
||||
user = User.query.get(test_user.id)
|
||||
assert hasattr(user, 'time_rounding_enabled')
|
||||
assert hasattr(user, 'time_rounding_minutes')
|
||||
assert hasattr(user, 'time_rounding_method')
|
||||
|
||||
def test_user_default_rounding_values(self, app):
|
||||
"""Test default rounding values for new users"""
|
||||
with app.app_context():
|
||||
user = User(username='newuser', role='user')
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
# Defaults should be: enabled=True, minutes=1, method='nearest'
|
||||
assert user.time_rounding_enabled is True
|
||||
assert user.time_rounding_minutes == 1
|
||||
assert user.time_rounding_method == 'nearest'
|
||||
|
||||
def test_update_user_rounding_preferences(self, app, test_user):
|
||||
"""Test updating user rounding preferences"""
|
||||
with app.app_context():
|
||||
user = User.query.get(test_user.id)
|
||||
|
||||
# Update preferences
|
||||
user.time_rounding_enabled = False
|
||||
user.time_rounding_minutes = 30
|
||||
user.time_rounding_method = 'up'
|
||||
db.session.commit()
|
||||
|
||||
# Verify changes persisted
|
||||
user_id = user.id
|
||||
db.session.expunge_all()
|
||||
|
||||
user = User.query.get(user_id)
|
||||
assert user.time_rounding_enabled is False
|
||||
assert user.time_rounding_minutes == 30
|
||||
assert user.time_rounding_method == 'up'
|
||||
|
||||
def test_multiple_users_different_preferences(self, app):
|
||||
"""Test that different users can have different rounding preferences"""
|
||||
with app.app_context():
|
||||
user1 = User(username='user1', role='user')
|
||||
user1.time_rounding_enabled = True
|
||||
user1.time_rounding_minutes = 5
|
||||
user1.time_rounding_method = 'up'
|
||||
|
||||
user2 = User(username='user2', role='user')
|
||||
user2.time_rounding_enabled = False
|
||||
user2.time_rounding_minutes = 15
|
||||
user2.time_rounding_method = 'down'
|
||||
|
||||
db.session.add_all([user1, user2])
|
||||
db.session.commit()
|
||||
|
||||
# Verify each user has their own settings
|
||||
assert user1.time_rounding_minutes == 5
|
||||
assert user2.time_rounding_minutes == 15
|
||||
assert user1.time_rounding_method == 'up'
|
||||
assert user2.time_rounding_method == 'down'
|
||||
|
||||
|
||||
class TestTimeEntryRounding:
|
||||
"""Test time entry duration calculation with per-user rounding"""
|
||||
|
||||
def test_time_entry_uses_user_rounding(self, app, test_user, test_project):
|
||||
"""Test that time entry uses user's rounding preferences"""
|
||||
with app.app_context():
|
||||
user = User.query.get(test_user.id)
|
||||
project = Project.query.get(test_project.id)
|
||||
|
||||
# Create a time entry with 62 minutes duration
|
||||
start_time = datetime(2025, 1, 1, 10, 0, 0)
|
||||
end_time = start_time + timedelta(minutes=62)
|
||||
|
||||
entry = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
|
||||
db.session.add(entry)
|
||||
db.session.flush()
|
||||
|
||||
# User has 15-min nearest rounding, so 62 minutes should round to 60
|
||||
assert entry.duration_seconds == 3600 # 60 minutes
|
||||
|
||||
def test_time_entry_respects_disabled_rounding(self, app, test_user, test_project):
|
||||
"""Test that rounding is not applied when disabled"""
|
||||
with app.app_context():
|
||||
user = User.query.get(test_user.id)
|
||||
project = Project.query.get(test_project.id)
|
||||
|
||||
# Disable rounding for user
|
||||
user.time_rounding_enabled = False
|
||||
db.session.commit()
|
||||
|
||||
# Create a time entry with 62 minutes duration
|
||||
start_time = datetime(2025, 1, 1, 10, 0, 0)
|
||||
end_time = start_time + timedelta(minutes=62, seconds=30)
|
||||
|
||||
entry = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
|
||||
db.session.add(entry)
|
||||
db.session.flush()
|
||||
|
||||
# With rounding disabled, should be exact: 62.5 minutes = 3750 seconds
|
||||
assert entry.duration_seconds == 3750
|
||||
|
||||
def test_time_entry_round_up_method(self, app, test_user, test_project):
|
||||
"""Test time entry with 'up' rounding method"""
|
||||
with app.app_context():
|
||||
user = User.query.get(test_user.id)
|
||||
project = Project.query.get(test_project.id)
|
||||
|
||||
# Set to round up with 15-minute intervals
|
||||
user.time_rounding_method = 'up'
|
||||
db.session.commit()
|
||||
|
||||
# Create entry with 61 minutes (should round up to 75)
|
||||
start_time = datetime(2025, 1, 1, 10, 0, 0)
|
||||
end_time = start_time + timedelta(minutes=61)
|
||||
|
||||
entry = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
|
||||
db.session.add(entry)
|
||||
db.session.flush()
|
||||
|
||||
# 61 minutes rounds up to 75 minutes (next 15-min interval)
|
||||
assert entry.duration_seconds == 4500 # 75 minutes
|
||||
|
||||
def test_time_entry_round_down_method(self, app, test_user, test_project):
|
||||
"""Test time entry with 'down' rounding method"""
|
||||
with app.app_context():
|
||||
user = User.query.get(test_user.id)
|
||||
project = Project.query.get(test_project.id)
|
||||
|
||||
# Set to round down with 15-minute intervals
|
||||
user.time_rounding_method = 'down'
|
||||
db.session.commit()
|
||||
|
||||
# Create entry with 74 minutes (should round down to 60)
|
||||
start_time = datetime(2025, 1, 1, 10, 0, 0)
|
||||
end_time = start_time + timedelta(minutes=74)
|
||||
|
||||
entry = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
|
||||
db.session.add(entry)
|
||||
db.session.flush()
|
||||
|
||||
# 74 minutes rounds down to 60 minutes
|
||||
assert entry.duration_seconds == 3600 # 60 minutes
|
||||
|
||||
def test_time_entry_different_intervals(self, app, test_user, test_project):
|
||||
"""Test time entries with different rounding intervals"""
|
||||
with app.app_context():
|
||||
user = User.query.get(test_user.id)
|
||||
project = Project.query.get(test_project.id)
|
||||
|
||||
start_time = datetime(2025, 1, 1, 10, 0, 0)
|
||||
end_time = start_time + timedelta(minutes=62)
|
||||
|
||||
# Test 5-minute rounding
|
||||
user.time_rounding_minutes = 5
|
||||
db.session.commit()
|
||||
|
||||
entry1 = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
db.session.add(entry1)
|
||||
db.session.flush()
|
||||
|
||||
# 62 minutes rounds to 60 with 5-min intervals
|
||||
assert entry1.duration_seconds == 3600
|
||||
|
||||
# Test 30-minute rounding
|
||||
user.time_rounding_minutes = 30
|
||||
db.session.commit()
|
||||
|
||||
entry2 = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
db.session.add(entry2)
|
||||
db.session.flush()
|
||||
|
||||
# 62 minutes rounds to 60 with 30-min intervals
|
||||
assert entry2.duration_seconds == 3600
|
||||
|
||||
def test_stop_timer_applies_rounding(self, app, test_user, test_project):
|
||||
"""Test that stopping a timer applies user's rounding preferences"""
|
||||
with app.app_context():
|
||||
user = User.query.get(test_user.id)
|
||||
project = Project.query.get(test_project.id)
|
||||
|
||||
# Create an active timer
|
||||
start_time = datetime(2025, 1, 1, 10, 0, 0)
|
||||
entry = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=None
|
||||
)
|
||||
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
|
||||
# Stop the timer after 62 minutes
|
||||
end_time = start_time + timedelta(minutes=62)
|
||||
entry.stop_timer(end_time=end_time)
|
||||
|
||||
# Should be rounded to 60 minutes (user has 15-min nearest rounding)
|
||||
assert entry.duration_seconds == 3600
|
||||
|
||||
|
||||
class TestBackwardCompatibility:
|
||||
"""Test backward compatibility with global rounding settings"""
|
||||
|
||||
def test_fallback_to_global_rounding_without_user_preferences(self, app, test_project):
|
||||
"""Test that system falls back to global rounding if user prefs don't exist"""
|
||||
with app.app_context():
|
||||
# Create a user without rounding preferences (simulating old database)
|
||||
user = User(username='olduser', role='user')
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
|
||||
# Remove the new attributes to simulate old schema
|
||||
if hasattr(user, 'time_rounding_enabled'):
|
||||
delattr(user, 'time_rounding_enabled')
|
||||
if hasattr(user, 'time_rounding_minutes'):
|
||||
delattr(user, 'time_rounding_minutes')
|
||||
if hasattr(user, 'time_rounding_method'):
|
||||
delattr(user, 'time_rounding_method')
|
||||
|
||||
project = Project.query.get(test_project.id)
|
||||
|
||||
# Create a time entry - should fall back to global rounding
|
||||
start_time = datetime(2025, 1, 1, 10, 0, 0)
|
||||
end_time = start_time + timedelta(minutes=62)
|
||||
|
||||
entry = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
|
||||
db.session.add(entry)
|
||||
db.session.flush()
|
||||
|
||||
# Should use global rounding (Config.ROUNDING_MINUTES, default is 1)
|
||||
# With global rounding = 1, duration should be exact
|
||||
assert entry.duration_seconds == 3720 # 62 minutes exactly
|
||||
|
||||
405
tests/test_time_rounding_smoke.py
Normal file
405
tests/test_time_rounding_smoke.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""Smoke tests for time rounding preferences feature - end-to-end testing"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from app import create_app, db
|
||||
from app.models import User, Project, TimeEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create application for testing"""
|
||||
app = create_app()
|
||||
app.config['TESTING'] = True
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
|
||||
app.config['WTF_CSRF_ENABLED'] = False
|
||||
app.config['SERVER_NAME'] = 'localhost'
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
yield app
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create test client"""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_user(app, client):
|
||||
"""Create and authenticate a test user"""
|
||||
with app.app_context():
|
||||
user = User(username='smoketest', role='user', email='smoke@test.com')
|
||||
user.time_rounding_enabled = True
|
||||
user.time_rounding_minutes = 15
|
||||
user.time_rounding_method = 'nearest'
|
||||
db.session.add(user)
|
||||
|
||||
project = Project(
|
||||
name='Smoke Test Project',
|
||||
client='Smoke Test Client',
|
||||
status='active',
|
||||
created_by_id=1
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
|
||||
user_id = user.id
|
||||
project_id = project.id
|
||||
|
||||
# Simulate login
|
||||
with client.session_transaction() as sess:
|
||||
sess['user_id'] = user_id
|
||||
sess['_fresh'] = True
|
||||
|
||||
return {'user_id': user_id, 'project_id': project_id}
|
||||
|
||||
|
||||
class TestTimeRoundingFeatureSmokeTests:
|
||||
"""High-level smoke tests for the time rounding feature"""
|
||||
|
||||
def test_user_can_view_rounding_settings(self, app, client, authenticated_user):
|
||||
"""Test that user can access the settings page with rounding options"""
|
||||
with app.test_request_context():
|
||||
response = client.get('/settings')
|
||||
|
||||
# Should be able to access settings page
|
||||
assert response.status_code in [200, 302] # 302 if redirect to login
|
||||
|
||||
def test_user_can_update_rounding_preferences(self, app, client, authenticated_user):
|
||||
"""Test that user can update their rounding preferences"""
|
||||
with app.app_context():
|
||||
user = User.query.get(authenticated_user['user_id'])
|
||||
|
||||
# Change preferences
|
||||
user.time_rounding_enabled = False
|
||||
user.time_rounding_minutes = 30
|
||||
user.time_rounding_method = 'up'
|
||||
db.session.commit()
|
||||
|
||||
# Verify changes were saved
|
||||
db.session.expunge_all()
|
||||
user = User.query.get(authenticated_user['user_id'])
|
||||
|
||||
assert user.time_rounding_enabled is False
|
||||
assert user.time_rounding_minutes == 30
|
||||
assert user.time_rounding_method == 'up'
|
||||
|
||||
def test_time_entry_reflects_user_rounding_preferences(self, app, authenticated_user):
|
||||
"""Test that creating a time entry applies user's rounding preferences"""
|
||||
with app.app_context():
|
||||
user = User.query.get(authenticated_user['user_id'])
|
||||
project = Project.query.get(authenticated_user['project_id'])
|
||||
|
||||
# Create a time entry with 62 minutes
|
||||
start_time = datetime(2025, 1, 1, 10, 0, 0)
|
||||
end_time = start_time + timedelta(minutes=62)
|
||||
|
||||
entry = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
|
||||
# User has 15-min nearest rounding, so 62 -> 60 minutes
|
||||
assert entry.duration_seconds == 3600
|
||||
assert entry.duration_hours == 1.0
|
||||
|
||||
def test_different_users_have_independent_rounding(self, app):
|
||||
"""Test that different users can have different rounding settings"""
|
||||
with app.app_context():
|
||||
# Create two users with different preferences
|
||||
user1 = User(username='user1', role='user')
|
||||
user1.time_rounding_enabled = True
|
||||
user1.time_rounding_minutes = 5
|
||||
user1.time_rounding_method = 'nearest'
|
||||
|
||||
user2 = User(username='user2', role='user')
|
||||
user2.time_rounding_enabled = True
|
||||
user2.time_rounding_minutes = 30
|
||||
user2.time_rounding_method = 'up'
|
||||
|
||||
db.session.add_all([user1, user2])
|
||||
db.session.commit()
|
||||
|
||||
# Create a project
|
||||
project = Project(
|
||||
name='Test Project',
|
||||
client='Test Client',
|
||||
status='active',
|
||||
created_by_id=user1.id
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
|
||||
# Create identical time entries for both users
|
||||
start_time = datetime(2025, 1, 1, 10, 0, 0)
|
||||
end_time = start_time + timedelta(minutes=62)
|
||||
|
||||
entry1 = TimeEntry(
|
||||
user_id=user1.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
|
||||
entry2 = TimeEntry(
|
||||
user_id=user2.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
|
||||
db.session.add_all([entry1, entry2])
|
||||
db.session.commit()
|
||||
|
||||
# User1 (5-min nearest): 62 -> 60 minutes
|
||||
assert entry1.duration_seconds == 3600
|
||||
|
||||
# User2 (30-min up): 62 -> 90 minutes
|
||||
assert entry2.duration_seconds == 5400
|
||||
|
||||
def test_disabling_rounding_uses_exact_time(self, app, authenticated_user):
|
||||
"""Test that disabling rounding results in exact time tracking"""
|
||||
with app.app_context():
|
||||
user = User.query.get(authenticated_user['user_id'])
|
||||
project = Project.query.get(authenticated_user['project_id'])
|
||||
|
||||
# Disable rounding
|
||||
user.time_rounding_enabled = False
|
||||
db.session.commit()
|
||||
|
||||
# Create entry with odd duration
|
||||
start_time = datetime(2025, 1, 1, 10, 0, 0)
|
||||
end_time = start_time + timedelta(minutes=62, seconds=37)
|
||||
|
||||
entry = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
|
||||
# Should be exact: 62 minutes 37 seconds = 3757 seconds
|
||||
assert entry.duration_seconds == 3757
|
||||
|
||||
def test_rounding_with_various_intervals(self, app, authenticated_user):
|
||||
"""Test that all rounding intervals work correctly"""
|
||||
with app.app_context():
|
||||
user = User.query.get(authenticated_user['user_id'])
|
||||
project = Project.query.get(authenticated_user['project_id'])
|
||||
|
||||
# Test duration: 37 minutes
|
||||
start_time = datetime(2025, 1, 1, 10, 0, 0)
|
||||
end_time = start_time + timedelta(minutes=37)
|
||||
|
||||
test_cases = [
|
||||
(1, 2220), # No rounding: 37 minutes
|
||||
(5, 2100), # 5-min: 37 -> 35 minutes
|
||||
(10, 2400), # 10-min: 37 -> 40 minutes
|
||||
(15, 2700), # 15-min: 37 -> 45 minutes
|
||||
(30, 1800), # 30-min: 37 -> 30 minutes
|
||||
(60, 3600), # 60-min: 37 -> 60 minutes (1 hour)
|
||||
]
|
||||
|
||||
for interval, expected_seconds in test_cases:
|
||||
user.time_rounding_minutes = interval
|
||||
user.time_rounding_method = 'nearest'
|
||||
db.session.commit()
|
||||
|
||||
entry = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
|
||||
db.session.add(entry)
|
||||
db.session.flush()
|
||||
|
||||
assert entry.duration_seconds == expected_seconds, \
|
||||
f"Failed for {interval}-minute rounding: expected {expected_seconds}, got {entry.duration_seconds}"
|
||||
|
||||
db.session.rollback()
|
||||
|
||||
def test_rounding_methods_comparison(self, app, authenticated_user):
|
||||
"""Test that different rounding methods produce different results"""
|
||||
with app.app_context():
|
||||
user = User.query.get(authenticated_user['user_id'])
|
||||
project = Project.query.get(authenticated_user['project_id'])
|
||||
|
||||
# Test with 62 minutes and 15-min intervals
|
||||
start_time = datetime(2025, 1, 1, 10, 0, 0)
|
||||
end_time = start_time + timedelta(minutes=62)
|
||||
|
||||
user.time_rounding_minutes = 15
|
||||
|
||||
# Test 'nearest' method
|
||||
user.time_rounding_method = 'nearest'
|
||||
db.session.commit()
|
||||
|
||||
entry_nearest = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
db.session.add(entry_nearest)
|
||||
db.session.flush()
|
||||
|
||||
# 62 minutes nearest to 15-min interval -> 60 minutes
|
||||
assert entry_nearest.duration_seconds == 3600
|
||||
db.session.rollback()
|
||||
|
||||
# Test 'up' method
|
||||
user.time_rounding_method = 'up'
|
||||
db.session.commit()
|
||||
|
||||
entry_up = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
db.session.add(entry_up)
|
||||
db.session.flush()
|
||||
|
||||
# 62 minutes rounded up to 15-min interval -> 75 minutes
|
||||
assert entry_up.duration_seconds == 4500
|
||||
db.session.rollback()
|
||||
|
||||
# Test 'down' method
|
||||
user.time_rounding_method = 'down'
|
||||
db.session.commit()
|
||||
|
||||
entry_down = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
db.session.add(entry_down)
|
||||
db.session.flush()
|
||||
|
||||
# 62 minutes rounded down to 15-min interval -> 60 minutes
|
||||
assert entry_down.duration_seconds == 3600
|
||||
|
||||
def test_migration_compatibility(self, app):
|
||||
"""Test that the feature works after migration"""
|
||||
with app.app_context():
|
||||
# Verify that new users get the columns
|
||||
user = User(username='newuser', role='user')
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
# Check that all fields exist and have correct defaults
|
||||
assert hasattr(user, 'time_rounding_enabled')
|
||||
assert hasattr(user, 'time_rounding_minutes')
|
||||
assert hasattr(user, 'time_rounding_method')
|
||||
|
||||
assert user.time_rounding_enabled is True
|
||||
assert user.time_rounding_minutes == 1
|
||||
assert user.time_rounding_method == 'nearest'
|
||||
|
||||
def test_full_workflow(self, app, authenticated_user):
|
||||
"""Test complete workflow: set preferences -> create entry -> verify rounding"""
|
||||
with app.app_context():
|
||||
user = User.query.get(authenticated_user['user_id'])
|
||||
project = Project.query.get(authenticated_user['project_id'])
|
||||
|
||||
# Step 1: User sets their rounding preferences
|
||||
user.time_rounding_enabled = True
|
||||
user.time_rounding_minutes = 10
|
||||
user.time_rounding_method = 'up'
|
||||
db.session.commit()
|
||||
|
||||
# Step 2: User starts a timer
|
||||
start_time = datetime(2025, 1, 1, 9, 0, 0)
|
||||
timer = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=None # Active timer
|
||||
)
|
||||
db.session.add(timer)
|
||||
db.session.commit()
|
||||
|
||||
# Verify timer is active
|
||||
assert timer.is_active is True
|
||||
assert timer.duration_seconds is None
|
||||
|
||||
# Step 3: User stops the timer after 23 minutes
|
||||
end_time = start_time + timedelta(minutes=23)
|
||||
timer.stop_timer(end_time=end_time)
|
||||
|
||||
# Step 4: Verify the duration was rounded correctly
|
||||
# With 10-min 'up' rounding, 23 minutes should round up to 30 minutes
|
||||
assert timer.duration_seconds == 1800 # 30 minutes
|
||||
assert timer.is_active is False
|
||||
|
||||
# Step 5: Verify the entry is queryable with correct rounded duration
|
||||
db.session.expunge_all()
|
||||
saved_entry = TimeEntry.query.get(timer.id)
|
||||
assert saved_entry.duration_seconds == 1800
|
||||
assert saved_entry.duration_hours == 0.5
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and boundary conditions"""
|
||||
|
||||
def test_zero_duration_time_entry(self, app, authenticated_user):
|
||||
"""Test handling of zero-duration entries"""
|
||||
with app.app_context():
|
||||
user = User.query.get(authenticated_user['user_id'])
|
||||
project = Project.query.get(authenticated_user['project_id'])
|
||||
|
||||
# Create entry with same start and end time
|
||||
time = datetime(2025, 1, 1, 10, 0, 0)
|
||||
|
||||
entry = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=time,
|
||||
end_time=time
|
||||
)
|
||||
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
|
||||
# Zero duration should stay zero regardless of rounding
|
||||
assert entry.duration_seconds == 0
|
||||
|
||||
def test_very_long_duration(self, app, authenticated_user):
|
||||
"""Test rounding of very long time entries (multi-day)"""
|
||||
with app.app_context():
|
||||
user = User.query.get(authenticated_user['user_id'])
|
||||
project = Project.query.get(authenticated_user['project_id'])
|
||||
|
||||
# 8 hours 7 minutes
|
||||
start_time = datetime(2025, 1, 1, 9, 0, 0)
|
||||
end_time = start_time + timedelta(hours=8, minutes=7)
|
||||
|
||||
entry = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
|
||||
# User has 15-min nearest rounding
|
||||
# 487 minutes -> 485 minutes (rounded down to nearest 15)
|
||||
assert entry.duration_seconds == 29100 # 485 minutes
|
||||
|
||||
Reference in New Issue
Block a user