Fix task edit crashing after due_date/due_time save (regression from reminders)

The web edit/create/quick-add views and the sync endpoint assigned
due_date/due_time/reminder_at straight from request data as raw strings.
Django tolerates this at save() (types get coerced deep in the SQL
layer), but the in-memory instance keeps the raw strings afterward.
Task.reschedule_reminders(), added for per-task reminders and run from
save() on that same instance, was the first code to actually operate on
those fields before a page reload and crashed with a TypeError - which
the service worker's 5xx-treated-as-offline fallback then masked as a
misleading "you're offline" screen instead of a real error.

Fixed by parsing with Django's parse_date/parse_time/parse_datetime at
the point these values are read from the request/payload in
tasks/views.py and sync/views.py, rather than trusting raw strings.

5 new regression tests exercise the actual views/endpoint with raw
date/time strings and assert proper types land on the model.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Keith Smith
2026-09-05 10:00:45 -06:00
co-authored by Claude Sonnet 5
parent 5359bf243a
commit a27fcc4c69
4 changed files with 117 additions and 12 deletions
+44
View File
@@ -133,3 +133,47 @@ class IsOverdueTests(TestCase):
mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 15, 23, 0, 0))
task = self.make_task(due_date=date(2026, 1, 1), due_time=dt_time(9, 0, 0), status='completed')
self.assertFalse(task.is_overdue)
class WebFormDueDateTypeTests(TestCase):
"""
The web views assign due_date/due_time straight from request.POST (raw
strings), relying on Django to coerce them at the DB layer - which
leaves the in-memory instance holding strings after save(). Anything
that touches those fields on that same instance afterward (e.g.
Task.reschedule_reminders(), called from save() itself) must not choke
on that. Regression test for a real production crash on task edit.
"""
def setUp(self):
self.user = User.objects.create_user(
username='webformuser', email='webformuser@example.com', password='testpass123',
)
self.client.force_login(self.user)
def test_editing_due_date_and_time_does_not_crash(self):
task = Task.objects.create(user=self.user, title='Task')
response = self.client.post(f'/tasks/{task.id}/', {
'title': 'Task', 'status': 'pending', 'priority': 'medium',
'due_date': '2026-09-10', 'due_time': '17:00', 'recurrence': 'none',
})
self.assertEqual(response.status_code, 302)
task.refresh_from_db()
self.assertEqual(task.due_date, date(2026, 9, 10))
self.assertEqual(task.due_time, dt_time(17, 0))
def test_creating_task_with_due_date_does_not_crash(self):
response = self.client.post('/tasks/new/', {
'title': 'New task', 'status': 'pending', 'priority': 'medium',
'due_date': '2026-09-10', 'due_time': '17:00', 'recurrence': 'none',
})
self.assertEqual(response.status_code, 302)
task = Task.objects.get(title='New task')
self.assertEqual(task.due_date, date(2026, 9, 10))
self.assertEqual(task.due_time, dt_time(17, 0))
def test_quick_add_task_with_due_date_does_not_crash(self):
response = self.client.post('/tasks/quick-add/', {'title': 'Quick task', 'due_date': '2026-09-10'})
self.assertEqual(response.status_code, 302)
task = Task.objects.get(title='Quick task')
self.assertEqual(task.due_date, date(2026, 9, 10))
+6 -5
View File
@@ -7,6 +7,7 @@ from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.shortcuts import render, redirect, get_object_or_404
from django.utils import timezone
from django.utils.dateparse import parse_date, parse_time
from django.utils.http import url_has_allowed_host_and_scheme
from django.views import View
from django.views.decorators.http import require_POST
@@ -466,10 +467,10 @@ class TaskDetailView(View):
task.priority = priority
due_date = request.POST.get('due_date')
task.due_date = due_date if due_date else None
task.due_date = parse_date(due_date) if due_date else None
due_time = request.POST.get('due_time')
task.due_time = due_time if due_time else None
task.due_time = parse_time(due_time) if due_time else None
# Validate recurrence against allowed choices
recurrence = request.POST.get('recurrence', 'none')
@@ -540,8 +541,8 @@ class TaskCreateView(View):
description=request.POST.get('description', ''),
status=status,
priority=priority,
due_date=request.POST.get('due_date') or None,
due_time=request.POST.get('due_time') or None,
due_date=parse_date(request.POST.get('due_date')) if request.POST.get('due_date') else None,
due_time=parse_time(request.POST.get('due_time')) if request.POST.get('due_time') else None,
recurrence=recurrence,
recurrence_rule=request.POST.get('recurrence_rule', '') if recurrence == 'custom' else '',
)
@@ -559,7 +560,7 @@ def task_quick_add(request):
task = Task.objects.create(
user=request.user,
title=request.POST.get('title'),
due_date=request.POST.get('due_date') or None,
due_date=parse_date(request.POST.get('due_date')) if request.POST.get('due_date') else None,
)
# Handle optional tag
tag_id = request.POST.get('tag')