Files
TimeTracker/tests/test_time_rounding_models.py
T
Dries Peeters 48ec29e096 feat: Add per-user time rounding preferences
Implement comprehensive time rounding preferences that allow each user to
configure how their time entries are rounded when stopping timers.

Features:
- Per-user rounding settings (independent from global config)
- Multiple rounding intervals: 1, 5, 10, 15, 30, 60 minutes
- Three rounding methods: nearest, up (ceiling), down (floor)
- Enable/disable toggle for flexible time tracking
- Real-time preview showing rounding examples
- Backward compatible with existing global rounding settings

Database Changes:
- Add migration 027 with three new user columns:
  * time_rounding_enabled (Boolean, default: true)
  * time_rounding_minutes (Integer, default: 1)
  * time_rounding_method (String, default: 'nearest')

Implementation:
- Update User model with rounding preference fields
- Modify TimeEntry.calculate_duration() to use per-user rounding
- Create app/utils/time_rounding.py with core rounding logic
- Update user settings route and template with rounding UI
- Add comprehensive unit, model, and smoke tests (50+ test cases)

UI/UX:
- Add "Time Rounding Preferences" section to user settings page
- Interactive controls with live example visualization
- Descriptive help text and method explanations
- Fix navigation: Settings link now correctly points to user.settings
- Fix CSRF token in settings form

Documentation:
- Add comprehensive user guide (docs/TIME_ROUNDING_PREFERENCES.md)
- Include API documentation and usage examples
- Provide troubleshooting guide and best practices
- Add deployment instructions for migration

Testing:
- Unit tests for rounding logic (tests/test_time_rounding.py)
- Model integration tests (tests/test_time_rounding_models.py)
- End-to-end smoke tests (tests/test_time_rounding_smoke.py)

Fixes:
- Correct settings navigation link in user dropdown menu
- Fix CSRF token format in user settings template

This feature enables flexible billing practices, supports different client
requirements, and maintains exact time tracking when needed.
2025-10-24 09:36:03 +02:00

351 lines
12 KiB
Python

"""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