Server overview 500s as soon as a backup exists: a span handed to timesince #83

Closed
opened 2026-09-07 13:06:51 +00:00 by tiagoagueda · 0 comments
Owner

Observation

Reported from the live instance: Server settings → Overview returns 500 as soon as there
is a backup on disk.

ERROR django.request: Internal Server Error: /server/overview/
  File "django/templatetags/i18n.py", line 156, in render
  File "django/template/defaultfilters.py", line 820, in timesince_filter
  File "django/utils/timesince.py", line 62, in timesince
    d = datetime.datetime(d.year, d.month, d.day)
AttributeError: 'datetime.timedelta' object has no attribute 'year'

What is wrong

_newest_backup in core/server_views.py returns the age as a span:

"age": timezone.now() - timezone.datetime.fromtimestamp(newest.stat().st_mtime, ...)

and server/overview.html hands that span to timesince, which wants a moment:

{% blocktranslate with when=newest_backup.age|timesince trimmed %}{{ when }} ago{% endblocktranslate %}

timesince immediately reads d.year, and a timedelta has no year.

The line below it, {% if newest_backup.age.days >= 7 %}, is correct — .days is a
question about a span. Both readings of "age" are in the same six lines of template, and
only one of them matches what the view returns.

Why nothing caught it

No test has ever rendered this page with a backup on disk. With an empty backup
directory the template takes the {% else %} branch and prints "none yet", never touching
timesince — so the unit tests passed, tests/test_page_coverage.py was satisfied that the
page was visited, and the accessibility suite ran axe over it happily. Every one of them
looked at the empty state.

The first real archive to exist anywhere was one taken by hand before an upgrade. The page
had been broken for as long as the feature had existed, and was reachable by anybody who had
ever run manage.py backup — which is to say, anybody following the documentation.

The fix

Return both, since the page needs both and they are not interchangeable:

made_at = timezone.datetime.fromtimestamp(newest.stat().st_mtime, tz=...)
return {"path": newest, "made_at": made_at, "age": timezone.now() - made_at}

made_at|timesince for the words, age.days for the week. Three tests come with it: a
backup on disk renders, one older than a week says so, and an empty directory still says
"none yet" — the last so the branch that used to be the only one tested stays tested.

Worth thinking about beyond this bug

The empty state is the easy state, and it is the one a fixture gives you for free. Every
page whose interesting branch needs a file, a backup or a plugin on disk is a page whose
interesting branch may never have been rendered. tests/test_page_coverage.py proves a page
was visited; it cannot prove it was visited with anything in it.

Classification

Bug. Present in 0.2.0 and in every release before it.

## Observation Reported from the live instance: **Server settings → Overview returns 500** as soon as there is a backup on disk. ``` ERROR django.request: Internal Server Error: /server/overview/ File "django/templatetags/i18n.py", line 156, in render File "django/template/defaultfilters.py", line 820, in timesince_filter File "django/utils/timesince.py", line 62, in timesince d = datetime.datetime(d.year, d.month, d.day) AttributeError: 'datetime.timedelta' object has no attribute 'year' ``` ## What is wrong `_newest_backup` in `core/server_views.py` returns the age as a **span**: ```python "age": timezone.now() - timezone.datetime.fromtimestamp(newest.stat().st_mtime, ...) ``` and `server/overview.html` hands that span to `timesince`, which wants a **moment**: ```django {% blocktranslate with when=newest_backup.age|timesince trimmed %}{{ when }} ago{% endblocktranslate %} ``` `timesince` immediately reads `d.year`, and a `timedelta` has no year. The line below it, `{% if newest_backup.age.days >= 7 %}`, is correct — `.days` is a question about a span. Both readings of "age" are in the same six lines of template, and only one of them matches what the view returns. ## Why nothing caught it **No test has ever rendered this page with a backup on disk.** With an empty backup directory the template takes the `{% else %}` branch and prints "none yet", never touching `timesince` — so the unit tests passed, `tests/test_page_coverage.py` was satisfied that the page was visited, and the accessibility suite ran axe over it happily. Every one of them looked at the empty state. The first real archive to exist anywhere was one taken by hand before an upgrade. The page had been broken for as long as the feature had existed, and was reachable by anybody who had ever run `manage.py backup` — which is to say, anybody following the documentation. ## The fix Return both, since the page needs both and they are not interchangeable: ```python made_at = timezone.datetime.fromtimestamp(newest.stat().st_mtime, tz=...) return {"path": newest, "made_at": made_at, "age": timezone.now() - made_at} ``` `made_at|timesince` for the words, `age.days` for the week. Three tests come with it: a backup on disk renders, one older than a week says so, and an empty directory still says "none yet" — the last so the branch that used to be the only one tested stays tested. ## Worth thinking about beyond this bug The empty state is the easy state, and it is the one a fixture gives you for free. Every page whose interesting branch needs a file, a backup or a plugin on disk is a page whose interesting branch may never have been rendered. `tests/test_page_coverage.py` proves a page was *visited*; it cannot prove it was visited with anything in it. ## Classification Bug. Present in 0.2.0 and in every release before it.
tiagoagueda added this to the 0.3.0 milestone 2026-09-07 13:06:51 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Postulo/postulo#83
No description provided.