feat: add event backdating with collapsible datetime picker

Add ability to specify custom date/time when recording events,
enabling historical data entry. Forms show "Now - Set custom date"
with a collapsible datetime picker that converts to milliseconds.

- Add event_datetime_field() component in templates/actions.py
- Add datetime picker to all event forms (cohort, hatch, outcome,
  tag, attrs, move, feed)
- Add _parse_ts_utc() helper to parse form timestamp or use current
- Add tests for backdating functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-01 10:40:01 +00:00
parent 82def73188
commit abf78ec98a
7 changed files with 328 additions and 65 deletions

View File

@@ -1552,3 +1552,134 @@ class TestStatusCorrectValidation:
)
assert resp.status_code == 403
# =============================================================================
# Backdating Tests
# =============================================================================
class TestParseTsUtcHelper:
"""Tests for the _parse_ts_utc helper function."""
def test_parse_ts_utc_returns_current_for_none(self):
"""Returns current time when value is None."""
import time
from animaltrack.web.routes.actions import _parse_ts_utc
before = int(time.time() * 1000)
result = _parse_ts_utc(None)
after = int(time.time() * 1000)
assert before <= result <= after
def test_parse_ts_utc_returns_current_for_zero(self):
"""Returns current time when value is '0'."""
import time
from animaltrack.web.routes.actions import _parse_ts_utc
before = int(time.time() * 1000)
result = _parse_ts_utc("0")
after = int(time.time() * 1000)
assert before <= result <= after
def test_parse_ts_utc_returns_current_for_empty(self):
"""Returns current time when value is empty string."""
import time
from animaltrack.web.routes.actions import _parse_ts_utc
before = int(time.time() * 1000)
result = _parse_ts_utc("")
after = int(time.time() * 1000)
assert before <= result <= after
def test_parse_ts_utc_returns_provided_value(self):
"""Returns the provided timestamp when valid."""
from animaltrack.web.routes.actions import _parse_ts_utc
# Use a past timestamp
past_ts = 1700000000000 # Some time in 2023
result = _parse_ts_utc(str(past_ts))
assert result == past_ts
def test_parse_ts_utc_returns_current_for_invalid(self):
"""Returns current time when value is invalid."""
import time
from animaltrack.web.routes.actions import _parse_ts_utc
before = int(time.time() * 1000)
result = _parse_ts_utc("not-a-number")
after = int(time.time() * 1000)
assert before <= result <= after
class TestBackdatingCohort:
"""Tests for backdating cohort creation."""
def test_cohort_uses_provided_timestamp(self, client, seeded_db, location_strip1_id):
"""POST uses provided ts_utc for backdating."""
# Use a past timestamp (Feb 13, 2025 in ms)
backdated_ts = 1739404800000
resp = client.post(
"/actions/animal-cohort",
data={
"species": "duck",
"location_id": location_strip1_id,
"count": "2",
"life_stage": "adult",
"sex": "female",
"origin": "purchased",
"ts_utc": str(backdated_ts),
"nonce": "test-backdate-cohort-1",
},
)
assert resp.status_code == 200
# Verify the event was created with the backdated timestamp
event_row = seeded_db.execute(
"SELECT ts_utc FROM events WHERE type = 'AnimalCohortCreated' ORDER BY id DESC LIMIT 1"
).fetchone()
assert event_row is not None
assert event_row[0] == backdated_ts
def test_cohort_uses_current_time_when_ts_utc_zero(self, client, seeded_db, location_strip1_id):
"""POST uses current time when ts_utc is 0."""
import time
before = int(time.time() * 1000)
resp = client.post(
"/actions/animal-cohort",
data={
"species": "duck",
"location_id": location_strip1_id,
"count": "2",
"life_stage": "adult",
"sex": "female",
"origin": "purchased",
"ts_utc": "0",
"nonce": "test-backdate-cohort-2",
},
)
after = int(time.time() * 1000)
assert resp.status_code == 200
# Verify the event was created with a current timestamp
event_row = seeded_db.execute(
"SELECT ts_utc FROM events WHERE type = 'AnimalCohortCreated' ORDER BY id DESC LIMIT 1"
).fetchone()
assert event_row is not None
assert before <= event_row[0] <= after