From 89141108d9bed0c736ad70808e1f0358dac56f31 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sat, 25 Oct 2025 06:53:13 +0200 Subject: [PATCH] Fix: Add missing timeago template filter causing 500 error The Time Entry Templates page (/templates) was throwing a 500 error when displaying templates with usage data. The templates referenced a 'timeago' Jinja2 filter that was never registered. Changes: - Added timeago filter to app/utils/template_filters.py - Converts datetime to human-readable relative time (e.g., "2 hours ago") - Handles None, naive/aware datetimes, and future dates gracefully - Provides appropriate granularity from seconds to years - Uses proper singular/plural forms - Added 11 comprehensive unit tests in tests/test_utils.py - Tests for None values, all time ranges, edge cases - Tests for naive datetimes and future dates - Tests for singular/plural formatting - Added smoke test in tests/test_time_entry_templates.py - Specifically tests templates with usage_count and last_used_at - Ensures the filter renders correctly in the template The filter is used in: - app/templates/time_entry_templates/list.html (line 96) - app/templates/time_entry_templates/edit.html (line 140) Fixes #151 --- app/utils/template_filters.py | 57 ++++++++++ tests/test_time_entry_templates.py | 26 +++++ tests/test_utils.py | 163 +++++++++++++++++++++++++++++ 3 files changed, 246 insertions(+) diff --git a/app/utils/template_filters.py b/app/utils/template_filters.py index c068440..b9acbf4 100644 --- a/app/utils/template_filters.py +++ b/app/utils/template_filters.py @@ -104,3 +104,60 @@ def register_template_filters(app): return f"{float(value):,.2f}" except Exception: return str(value) + + @app.template_filter('timeago') + def timeago_filter(dt): + """Convert a datetime to a 'time ago' string (e.g., '2 hours ago')""" + if dt is None: + return "" + + # Import here to avoid circular imports + from datetime import datetime, timezone + + # Ensure we're working with a timezone-aware datetime + if dt.tzinfo is None: + # Assume UTC if no timezone info + dt = dt.replace(tzinfo=timezone.utc) + + # Get current time in UTC + now = datetime.now(timezone.utc) + + # Calculate difference + diff = now - dt + + # Convert to seconds + seconds = diff.total_seconds() + + # Handle future dates + if seconds < 0: + return "just now" + + # Calculate time units + minutes = seconds / 60 + hours = minutes / 60 + days = hours / 24 + weeks = days / 7 + months = days / 30 + years = days / 365 + + # Return appropriate string + if seconds < 60: + return "just now" + elif minutes < 60: + m = int(minutes) + return f"{m} minute{'s' if m != 1 else ''} ago" + elif hours < 24: + h = int(hours) + return f"{h} hour{'s' if h != 1 else ''} ago" + elif days < 7: + d = int(days) + return f"{d} day{'s' if d != 1 else ''} ago" + elif weeks < 4: + w = int(weeks) + return f"{w} week{'s' if w != 1 else ''} ago" + elif months < 12: + mo = int(months) + return f"{mo} month{'s' if mo != 1 else ''} ago" + else: + y = int(years) + return f"{y} year{'s' if y != 1 else ''} ago" diff --git a/tests/test_time_entry_templates.py b/tests/test_time_entry_templates.py index c68162d..1ca6970 100644 --- a/tests/test_time_entry_templates.py +++ b/tests/test_time_entry_templates.py @@ -239,6 +239,32 @@ class TestTimeEntryTemplateRoutes: response = client.get('/templates', follow_redirects=False) assert response.status_code == 302 # Redirect to login + @pytest.mark.smoke + def test_list_templates_with_usage_data(self, authenticated_client, user, project): + """Test templates list page renders correctly with templates that have usage data""" + # Create a template with usage data (last_used_at set) + from datetime import datetime, timezone + from app.models import TimeEntryTemplate + from app import db + + template = TimeEntryTemplate( + user_id=user.id, + name='Used Template', + project_id=project.id, + default_duration_minutes=60, + usage_count=5, + last_used_at=datetime.now(timezone.utc) + ) + db.session.add(template) + db.session.commit() + + # Access the list page + response = authenticated_client.get('/templates') + assert response.status_code == 200 + assert b'Used Template' in response.data + # Verify that timeago filter is working (should show "just now" or similar) + assert b'ago' in response.data or b'just now' in response.data + def test_create_template_page_get(self, authenticated_client): """Test accessing create template page""" response = authenticated_client.get('/templates/create') diff --git a/tests/test_utils.py b/tests/test_utils.py index 6d23a98..09b5693 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -266,6 +266,169 @@ def test_format_money_filter_invalid(app): assert result == "not a number" +@pytest.mark.unit +@pytest.mark.utils +def test_timeago_filter_none(app): + """Test timeago filter with None.""" + register_template_filters(app) + with app.app_context(): + filter_func = app.jinja_env.filters.get('timeago') + result = filter_func(None) + assert result == "" + + +@pytest.mark.unit +@pytest.mark.utils +def test_timeago_filter_just_now(app): + """Test timeago filter with very recent datetime.""" + register_template_filters(app) + with app.app_context(): + filter_func = app.jinja_env.filters.get('timeago') + # Create datetime for 30 seconds ago + now = datetime.datetime.now(datetime.timezone.utc) + dt = now - datetime.timedelta(seconds=30) + result = filter_func(dt) + assert result == "just now" + + +@pytest.mark.unit +@pytest.mark.utils +def test_timeago_filter_minutes(app): + """Test timeago filter with minutes ago.""" + register_template_filters(app) + with app.app_context(): + filter_func = app.jinja_env.filters.get('timeago') + # Create datetime for 5 minutes ago + now = datetime.datetime.now(datetime.timezone.utc) + dt = now - datetime.timedelta(minutes=5) + result = filter_func(dt) + assert "minute" in result + assert "ago" in result + + +@pytest.mark.unit +@pytest.mark.utils +def test_timeago_filter_hours(app): + """Test timeago filter with hours ago.""" + register_template_filters(app) + with app.app_context(): + filter_func = app.jinja_env.filters.get('timeago') + # Create datetime for 3 hours ago + now = datetime.datetime.now(datetime.timezone.utc) + dt = now - datetime.timedelta(hours=3) + result = filter_func(dt) + assert "hour" in result + assert "ago" in result + + +@pytest.mark.unit +@pytest.mark.utils +def test_timeago_filter_days(app): + """Test timeago filter with days ago.""" + register_template_filters(app) + with app.app_context(): + filter_func = app.jinja_env.filters.get('timeago') + # Create datetime for 2 days ago + now = datetime.datetime.now(datetime.timezone.utc) + dt = now - datetime.timedelta(days=2) + result = filter_func(dt) + assert "day" in result + assert "ago" in result + + +@pytest.mark.unit +@pytest.mark.utils +def test_timeago_filter_weeks(app): + """Test timeago filter with weeks ago.""" + register_template_filters(app) + with app.app_context(): + filter_func = app.jinja_env.filters.get('timeago') + # Create datetime for 2 weeks ago + now = datetime.datetime.now(datetime.timezone.utc) + dt = now - datetime.timedelta(weeks=2) + result = filter_func(dt) + assert "week" in result + assert "ago" in result + + +@pytest.mark.unit +@pytest.mark.utils +def test_timeago_filter_months(app): + """Test timeago filter with months ago.""" + register_template_filters(app) + with app.app_context(): + filter_func = app.jinja_env.filters.get('timeago') + # Create datetime for 60 days ago (2 months) + now = datetime.datetime.now(datetime.timezone.utc) + dt = now - datetime.timedelta(days=60) + result = filter_func(dt) + assert "month" in result + assert "ago" in result + + +@pytest.mark.unit +@pytest.mark.utils +def test_timeago_filter_years(app): + """Test timeago filter with years ago.""" + register_template_filters(app) + with app.app_context(): + filter_func = app.jinja_env.filters.get('timeago') + # Create datetime for 400 days ago (over a year) + now = datetime.datetime.now(datetime.timezone.utc) + dt = now - datetime.timedelta(days=400) + result = filter_func(dt) + assert "year" in result + assert "ago" in result + + +@pytest.mark.unit +@pytest.mark.utils +def test_timeago_filter_future(app): + """Test timeago filter with future datetime.""" + register_template_filters(app) + with app.app_context(): + filter_func = app.jinja_env.filters.get('timeago') + # Create datetime in the future + now = datetime.datetime.now(datetime.timezone.utc) + dt = now + datetime.timedelta(hours=2) + result = filter_func(dt) + assert result == "just now" + + +@pytest.mark.unit +@pytest.mark.utils +def test_timeago_filter_naive_datetime(app): + """Test timeago filter with naive datetime (no timezone).""" + register_template_filters(app) + with app.app_context(): + filter_func = app.jinja_env.filters.get('timeago') + # Create naive datetime for 1 hour ago + dt = datetime.datetime.now() - datetime.timedelta(hours=1) + result = filter_func(dt) + # Should still work and convert to UTC + assert "ago" in result or result == "just now" + + +@pytest.mark.unit +@pytest.mark.utils +def test_timeago_filter_singular_plural(app): + """Test timeago filter uses correct singular/plural forms.""" + register_template_filters(app) + with app.app_context(): + filter_func = app.jinja_env.filters.get('timeago') + now = datetime.datetime.now(datetime.timezone.utc) + + # Test singular (1 minute) + dt = now - datetime.timedelta(minutes=1) + result = filter_func(dt) + assert "1 minute ago" in result + + # Test plural (2 minutes) + dt = now - datetime.timedelta(minutes=2) + result = filter_func(dt) + assert "2 minutes ago" in result + + # ============================================================================ # Context Processor Tests # ============================================================================