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:
@@ -55,6 +55,26 @@ from animaltrack.web.templates.actions import (
|
|||||||
tag_end_form,
|
tag_end_form,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts_utc(form_value: str | None) -> int:
|
||||||
|
"""Parse ts_utc from form, defaulting to current time if empty or zero.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
form_value: The ts_utc value from form data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Timestamp in milliseconds. Returns current time if form_value is
|
||||||
|
None, empty, or "0".
|
||||||
|
"""
|
||||||
|
if not form_value or form_value == "0":
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
try:
|
||||||
|
ts = int(form_value)
|
||||||
|
return ts if ts > 0 else int(time.time() * 1000)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
# APIRouter for multi-file route organization
|
# APIRouter for multi-file route organization
|
||||||
ar = APIRouter()
|
ar = APIRouter()
|
||||||
|
|
||||||
@@ -153,8 +173,8 @@ async def animal_cohort(request: Request):
|
|||||||
auth = request.scope.get("auth")
|
auth = request.scope.get("auth")
|
||||||
actor = auth.username if auth else "unknown"
|
actor = auth.username if auth else "unknown"
|
||||||
|
|
||||||
# Create cohort
|
# Create cohort - use form ts_utc if provided for backdating
|
||||||
ts_utc = int(time.time() * 1000)
|
ts_utc = _parse_ts_utc(form.get("ts_utc"))
|
||||||
service = _create_animal_service(db)
|
service = _create_animal_service(db)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -289,8 +309,8 @@ async def hatch_recorded(request: Request):
|
|||||||
auth = request.scope.get("auth")
|
auth = request.scope.get("auth")
|
||||||
actor = auth.username if auth else "unknown"
|
actor = auth.username if auth else "unknown"
|
||||||
|
|
||||||
# Record hatch
|
# Record hatch - use form ts_utc if provided for backdating
|
||||||
ts_utc = int(time.time() * 1000)
|
ts_utc = _parse_ts_utc(form.get("ts_utc"))
|
||||||
service = _create_animal_service(db)
|
service = _create_animal_service(db)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -433,8 +453,8 @@ async def animal_promote(request: Request):
|
|||||||
auth = request.scope.get("auth")
|
auth = request.scope.get("auth")
|
||||||
actor = auth.username if auth else "unknown"
|
actor = auth.username if auth else "unknown"
|
||||||
|
|
||||||
# Promote animal
|
# Promote animal - use form ts_utc if provided for backdating
|
||||||
ts_utc = int(time.time() * 1000)
|
ts_utc = _parse_ts_utc(form.get("ts_utc"))
|
||||||
service = _create_animal_service(db)
|
service = _create_animal_service(db)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -530,14 +550,8 @@ async def animal_tag_add(request: Request):
|
|||||||
confirmed = form.get("confirmed", "") == "true"
|
confirmed = form.get("confirmed", "") == "true"
|
||||||
nonce = form.get("nonce")
|
nonce = form.get("nonce")
|
||||||
|
|
||||||
# Get timestamp - use provided or current
|
# Get timestamp - use provided or current (supports backdating)
|
||||||
ts_utc_str = form.get("ts_utc", "0")
|
ts_utc = _parse_ts_utc(form.get("ts_utc"))
|
||||||
try:
|
|
||||||
ts_utc = int(ts_utc_str)
|
|
||||||
if ts_utc == 0:
|
|
||||||
ts_utc = int(time.time() * 1000)
|
|
||||||
except ValueError:
|
|
||||||
ts_utc = int(time.time() * 1000)
|
|
||||||
|
|
||||||
# resolved_ids can be multiple values
|
# resolved_ids can be multiple values
|
||||||
resolved_ids = form.getlist("resolved_ids")
|
resolved_ids = form.getlist("resolved_ids")
|
||||||
@@ -761,14 +775,8 @@ async def animal_tag_end(request: Request):
|
|||||||
confirmed = form.get("confirmed", "") == "true"
|
confirmed = form.get("confirmed", "") == "true"
|
||||||
nonce = form.get("nonce")
|
nonce = form.get("nonce")
|
||||||
|
|
||||||
# Get timestamp - use provided or current
|
# Get timestamp - use provided or current (supports backdating)
|
||||||
ts_utc_str = form.get("ts_utc", "0")
|
ts_utc = _parse_ts_utc(form.get("ts_utc"))
|
||||||
try:
|
|
||||||
ts_utc = int(ts_utc_str)
|
|
||||||
if ts_utc == 0:
|
|
||||||
ts_utc = int(time.time() * 1000)
|
|
||||||
except ValueError:
|
|
||||||
ts_utc = int(time.time() * 1000)
|
|
||||||
|
|
||||||
# resolved_ids can be multiple values
|
# resolved_ids can be multiple values
|
||||||
resolved_ids = form.getlist("resolved_ids")
|
resolved_ids = form.getlist("resolved_ids")
|
||||||
@@ -966,14 +974,8 @@ async def animal_attrs(request: Request):
|
|||||||
confirmed = form.get("confirmed", "") == "true"
|
confirmed = form.get("confirmed", "") == "true"
|
||||||
nonce = form.get("nonce")
|
nonce = form.get("nonce")
|
||||||
|
|
||||||
# Get timestamp - use provided or current
|
# Get timestamp - use provided or current (supports backdating)
|
||||||
ts_utc_str = form.get("ts_utc", "0")
|
ts_utc = _parse_ts_utc(form.get("ts_utc"))
|
||||||
try:
|
|
||||||
ts_utc = int(ts_utc_str)
|
|
||||||
if ts_utc == 0:
|
|
||||||
ts_utc = int(time.time() * 1000)
|
|
||||||
except ValueError:
|
|
||||||
ts_utc = int(time.time() * 1000)
|
|
||||||
|
|
||||||
# resolved_ids can be multiple values
|
# resolved_ids can be multiple values
|
||||||
resolved_ids = form.getlist("resolved_ids")
|
resolved_ids = form.getlist("resolved_ids")
|
||||||
@@ -1203,14 +1205,8 @@ async def animal_outcome(request: Request):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Get timestamp - use provided or current
|
# Get timestamp - use provided or current (supports backdating)
|
||||||
ts_utc_str = form.get("ts_utc", "0")
|
ts_utc = _parse_ts_utc(form.get("ts_utc"))
|
||||||
try:
|
|
||||||
ts_utc = int(ts_utc_str)
|
|
||||||
if ts_utc == 0:
|
|
||||||
ts_utc = int(time.time() * 1000)
|
|
||||||
except ValueError:
|
|
||||||
ts_utc = int(time.time() * 1000)
|
|
||||||
|
|
||||||
# resolved_ids can be multiple values
|
# resolved_ids can be multiple values
|
||||||
resolved_ids = form.getlist("resolved_ids")
|
resolved_ids = form.getlist("resolved_ids")
|
||||||
@@ -1452,14 +1448,8 @@ async def animal_status_correct(req: Request):
|
|||||||
confirmed = form.get("confirmed", "") == "true"
|
confirmed = form.get("confirmed", "") == "true"
|
||||||
nonce = form.get("nonce")
|
nonce = form.get("nonce")
|
||||||
|
|
||||||
# Get timestamp - use provided or current
|
# Get timestamp - use provided or current (supports backdating)
|
||||||
ts_utc_str = form.get("ts_utc", "0")
|
ts_utc = _parse_ts_utc(form.get("ts_utc"))
|
||||||
try:
|
|
||||||
ts_utc = int(ts_utc_str)
|
|
||||||
if ts_utc == 0:
|
|
||||||
ts_utc = int(time.time() * 1000)
|
|
||||||
except ValueError:
|
|
||||||
ts_utc = int(time.time() * 1000)
|
|
||||||
|
|
||||||
# resolved_ids can be multiple values
|
# resolved_ids can be multiple values
|
||||||
resolved_ids = form.getlist("resolved_ids")
|
resolved_ids = form.getlist("resolved_ids")
|
||||||
|
|||||||
@@ -24,6 +24,26 @@ from animaltrack.services.feed import FeedService, ValidationError
|
|||||||
from animaltrack.web.templates import page
|
from animaltrack.web.templates import page
|
||||||
from animaltrack.web.templates.feed import feed_page
|
from animaltrack.web.templates.feed import feed_page
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts_utc(form_value: str | None) -> int:
|
||||||
|
"""Parse ts_utc from form, defaulting to current time if empty or zero.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
form_value: The ts_utc value from form data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Timestamp in milliseconds. Returns current time if form_value is
|
||||||
|
None, empty, or "0".
|
||||||
|
"""
|
||||||
|
if not form_value or form_value == "0":
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
try:
|
||||||
|
ts = int(form_value)
|
||||||
|
return ts if ts > 0 else int(time.time() * 1000)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
# APIRouter for multi-file route organization
|
# APIRouter for multi-file route organization
|
||||||
ar = APIRouter()
|
ar = APIRouter()
|
||||||
|
|
||||||
@@ -152,8 +172,8 @@ async def feed_given(request: Request):
|
|||||||
selected_feed_type_code=feed_type_code,
|
selected_feed_type_code=feed_type_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get current timestamp
|
# Get timestamp - use provided or current (supports backdating)
|
||||||
ts_utc = int(time.time() * 1000)
|
ts_utc = _parse_ts_utc(form.get("ts_utc"))
|
||||||
|
|
||||||
# Create feed service
|
# Create feed service
|
||||||
event_store = EventStore(db)
|
event_store = EventStore(db)
|
||||||
@@ -324,8 +344,8 @@ async def feed_purchased(request: Request):
|
|||||||
"Price cannot be negative",
|
"Price cannot be negative",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get current timestamp
|
# Get timestamp - use provided or current (supports backdating)
|
||||||
ts_utc = int(time.time() * 1000)
|
ts_utc = _parse_ts_utc(form.get("ts_utc"))
|
||||||
|
|
||||||
# Create feed service
|
# Create feed service
|
||||||
event_store = EventStore(db)
|
event_store = EventStore(db)
|
||||||
|
|||||||
@@ -24,6 +24,26 @@ from animaltrack.services.animal import AnimalService, ValidationError
|
|||||||
from animaltrack.web.templates import page
|
from animaltrack.web.templates import page
|
||||||
from animaltrack.web.templates.move import diff_panel, move_form
|
from animaltrack.web.templates.move import diff_panel, move_form
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts_utc(form_value: str | None) -> int:
|
||||||
|
"""Parse ts_utc from form, defaulting to current time if empty or zero.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
form_value: The ts_utc value from form data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Timestamp in milliseconds. Returns current time if form_value is
|
||||||
|
None, empty, or "0".
|
||||||
|
"""
|
||||||
|
if not form_value or form_value == "0":
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
try:
|
||||||
|
ts = int(form_value)
|
||||||
|
return ts if ts > 0 else int(time.time() * 1000)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
# APIRouter for multi-file route organization
|
# APIRouter for multi-file route organization
|
||||||
ar = APIRouter()
|
ar = APIRouter()
|
||||||
|
|
||||||
@@ -122,14 +142,8 @@ async def animal_move(request: Request):
|
|||||||
confirmed = form.get("confirmed", "") == "true"
|
confirmed = form.get("confirmed", "") == "true"
|
||||||
nonce = form.get("nonce")
|
nonce = form.get("nonce")
|
||||||
|
|
||||||
# Get timestamp - use provided or current
|
# Get timestamp - use provided or current (supports backdating)
|
||||||
ts_utc_str = form.get("ts_utc", "0")
|
ts_utc = _parse_ts_utc(form.get("ts_utc"))
|
||||||
try:
|
|
||||||
ts_utc = int(ts_utc_str)
|
|
||||||
if ts_utc == 0:
|
|
||||||
ts_utc = int(time.time() * 1000)
|
|
||||||
except ValueError:
|
|
||||||
ts_utc = int(time.time() * 1000)
|
|
||||||
|
|
||||||
# resolved_ids can be multiple values
|
# resolved_ids can be multiple values
|
||||||
resolved_ids = form.getlist("resolved_ids")
|
resolved_ids = form.getlist("resolved_ids")
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fasthtml.common import H2, H3, Div, Form, Hidden, Option, P, Span
|
from fasthtml.common import H2, H3, Div, Form, Hidden, Input, Option, P, Script, Span
|
||||||
from monsterui.all import (
|
from monsterui.all import (
|
||||||
Alert,
|
Alert,
|
||||||
AlertT,
|
AlertT,
|
||||||
Button,
|
Button,
|
||||||
ButtonT,
|
ButtonT,
|
||||||
|
FormLabel,
|
||||||
LabelInput,
|
LabelInput,
|
||||||
LabelSelect,
|
LabelSelect,
|
||||||
LabelTextArea,
|
LabelTextArea,
|
||||||
@@ -20,6 +21,97 @@ from animaltrack.models.animals import Animal
|
|||||||
from animaltrack.models.reference import Location, Species
|
from animaltrack.models.reference import Location, Species
|
||||||
from animaltrack.selection.validation import SelectionDiff
|
from animaltrack.selection.validation import SelectionDiff
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Event Datetime Picker Component
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def event_datetime_field(field_id: str = "event_datetime") -> Div:
|
||||||
|
"""Create a collapsible datetime picker for backdating events.
|
||||||
|
|
||||||
|
When collapsed, shows "Now" and the event will use the current time.
|
||||||
|
When expanded, allows user to pick a specific date/time which is
|
||||||
|
converted to milliseconds and stored in the hidden ts_utc field.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
field_id: Unique ID prefix for the datetime field elements.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Div containing the datetime picker with toggle functionality.
|
||||||
|
"""
|
||||||
|
toggle_id = f"{field_id}_toggle"
|
||||||
|
picker_id = f"{field_id}_picker"
|
||||||
|
input_id = f"{field_id}_input"
|
||||||
|
|
||||||
|
# JavaScript for toggle and conversion
|
||||||
|
script = f"""
|
||||||
|
(function() {{
|
||||||
|
var toggle = document.getElementById('{toggle_id}');
|
||||||
|
var picker = document.getElementById('{picker_id}');
|
||||||
|
var input = document.getElementById('{input_id}');
|
||||||
|
var tsField = document.querySelector('input[name="ts_utc"]');
|
||||||
|
|
||||||
|
if (!toggle || !picker || !input) return;
|
||||||
|
|
||||||
|
toggle.addEventListener('click', function(e) {{
|
||||||
|
e.preventDefault();
|
||||||
|
if (picker.style.display === 'none') {{
|
||||||
|
picker.style.display = 'block';
|
||||||
|
toggle.textContent = 'Use current time';
|
||||||
|
}} else {{
|
||||||
|
picker.style.display = 'none';
|
||||||
|
toggle.textContent = 'Set custom date';
|
||||||
|
input.value = '';
|
||||||
|
if (tsField) tsField.value = '0';
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
|
||||||
|
input.addEventListener('change', function() {{
|
||||||
|
if (tsField && input.value) {{
|
||||||
|
var date = new Date(input.value);
|
||||||
|
tsField.value = date.getTime().toString();
|
||||||
|
}} else if (tsField) {{
|
||||||
|
tsField.value = '0';
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
FormLabel("Event Time"),
|
||||||
|
Div(
|
||||||
|
P(
|
||||||
|
Span("Now", cls="text-stone-400"),
|
||||||
|
" - ",
|
||||||
|
Span(
|
||||||
|
"Set custom date",
|
||||||
|
id=toggle_id,
|
||||||
|
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline",
|
||||||
|
),
|
||||||
|
cls="text-sm",
|
||||||
|
),
|
||||||
|
Div(
|
||||||
|
Input(
|
||||||
|
id=input_id,
|
||||||
|
name=f"{field_id}_value",
|
||||||
|
type="datetime-local",
|
||||||
|
cls="uk-input w-full mt-2",
|
||||||
|
),
|
||||||
|
P(
|
||||||
|
"Select date/time for this event (leave empty for current time)",
|
||||||
|
cls="text-xs text-stone-500 mt-1",
|
||||||
|
),
|
||||||
|
id=picker_id,
|
||||||
|
style="display: none;",
|
||||||
|
),
|
||||||
|
cls="mt-1",
|
||||||
|
),
|
||||||
|
Hidden(name="ts_utc", value="0"),
|
||||||
|
Script(script),
|
||||||
|
cls="space-y-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Cohort Creation Form
|
# Cohort Creation Form
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -160,6 +252,8 @@ def cohort_form(
|
|||||||
id="origin",
|
id="origin",
|
||||||
name="origin",
|
name="origin",
|
||||||
),
|
),
|
||||||
|
# Event datetime picker (for backdating)
|
||||||
|
event_datetime_field("cohort_datetime"),
|
||||||
# Hidden nonce for idempotency
|
# Hidden nonce for idempotency
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
@@ -276,6 +370,8 @@ def hatch_form(
|
|||||||
cls="text-xs text-stone-400 mt-1",
|
cls="text-xs text-stone-400 mt-1",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
# Event datetime picker (for backdating)
|
||||||
|
event_datetime_field("hatch_datetime"),
|
||||||
# Hidden nonce for idempotency
|
# Hidden nonce for idempotency
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
@@ -477,10 +573,11 @@ def tag_add_form(
|
|||||||
name="tag",
|
name="tag",
|
||||||
placeholder="Enter tag name",
|
placeholder="Enter tag name",
|
||||||
),
|
),
|
||||||
|
# Event datetime picker (for backdating)
|
||||||
|
event_datetime_field("tag_add_datetime"),
|
||||||
# Hidden fields for selection context
|
# Hidden fields for selection context
|
||||||
*resolved_id_fields,
|
*resolved_id_fields,
|
||||||
Hidden(name="roster_hash", value=roster_hash),
|
Hidden(name="roster_hash", value=roster_hash),
|
||||||
Hidden(name="ts_utc", value=str(ts_utc or 0)),
|
|
||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
@@ -665,10 +762,11 @@ def tag_end_form(
|
|||||||
P("No active tags on selected animals", cls="text-sm text-stone-400"),
|
P("No active tags on selected animals", cls="text-sm text-stone-400"),
|
||||||
cls="p-3 bg-slate-800 rounded-md",
|
cls="p-3 bg-slate-800 rounded-md",
|
||||||
),
|
),
|
||||||
|
# Event datetime picker (for backdating)
|
||||||
|
event_datetime_field("tag_end_datetime"),
|
||||||
# Hidden fields for selection context
|
# Hidden fields for selection context
|
||||||
*resolved_id_fields,
|
*resolved_id_fields,
|
||||||
Hidden(name="roster_hash", value=roster_hash),
|
Hidden(name="roster_hash", value=roster_hash),
|
||||||
Hidden(name="ts_utc", value=str(ts_utc or 0)),
|
|
||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
@@ -877,10 +975,11 @@ def attrs_form(
|
|||||||
id="repro_status",
|
id="repro_status",
|
||||||
name="repro_status",
|
name="repro_status",
|
||||||
),
|
),
|
||||||
|
# Event datetime picker (for backdating)
|
||||||
|
event_datetime_field("attrs_datetime"),
|
||||||
# Hidden fields for selection context
|
# Hidden fields for selection context
|
||||||
*resolved_id_fields,
|
*resolved_id_fields,
|
||||||
Hidden(name="roster_hash", value=roster_hash),
|
Hidden(name="roster_hash", value=roster_hash),
|
||||||
Hidden(name="ts_utc", value=str(ts_utc or 0)),
|
|
||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
@@ -1144,10 +1243,11 @@ def outcome_form(
|
|||||||
rows=2,
|
rows=2,
|
||||||
placeholder="Any additional notes...",
|
placeholder="Any additional notes...",
|
||||||
),
|
),
|
||||||
|
# Event datetime picker (for backdating)
|
||||||
|
event_datetime_field("outcome_datetime"),
|
||||||
# Hidden fields for selection context
|
# Hidden fields for selection context
|
||||||
*resolved_id_fields,
|
*resolved_id_fields,
|
||||||
Hidden(name="roster_hash", value=roster_hash),
|
Hidden(name="roster_hash", value=roster_hash),
|
||||||
Hidden(name="ts_utc", value=str(ts_utc or 0)),
|
|
||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
@@ -1364,10 +1464,11 @@ def status_correct_form(
|
|||||||
rows=2,
|
rows=2,
|
||||||
placeholder="Any additional notes...",
|
placeholder="Any additional notes...",
|
||||||
),
|
),
|
||||||
|
# Event datetime picker (for backdating)
|
||||||
|
event_datetime_field("status_correct_datetime"),
|
||||||
# Hidden fields for selection context
|
# Hidden fields for selection context
|
||||||
*resolved_id_fields,
|
*resolved_id_fields,
|
||||||
Hidden(name="roster_hash", value=roster_hash),
|
Hidden(name="roster_hash", value=roster_hash),
|
||||||
Hidden(name="ts_utc", value=str(ts_utc or 0)),
|
|
||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from monsterui.all import (
|
|||||||
from ulid import ULID
|
from ulid import ULID
|
||||||
|
|
||||||
from animaltrack.models.reference import FeedType, Location
|
from animaltrack.models.reference import FeedType, Location
|
||||||
|
from animaltrack.web.templates.actions import event_datetime_field
|
||||||
|
|
||||||
|
|
||||||
def feed_page(
|
def feed_page(
|
||||||
@@ -202,6 +203,8 @@ def give_feed_form(
|
|||||||
name="notes",
|
name="notes",
|
||||||
placeholder="Optional notes",
|
placeholder="Optional notes",
|
||||||
),
|
),
|
||||||
|
# Event datetime picker (for backdating)
|
||||||
|
event_datetime_field("feed_given_datetime"),
|
||||||
# Hidden nonce
|
# Hidden nonce
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
@@ -298,6 +301,8 @@ def purchase_feed_form(
|
|||||||
name="notes",
|
name="notes",
|
||||||
placeholder="Optional notes",
|
placeholder="Optional notes",
|
||||||
),
|
),
|
||||||
|
# Event datetime picker (for backdating)
|
||||||
|
event_datetime_field("feed_purchase_datetime"),
|
||||||
# Hidden nonce
|
# Hidden nonce
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from ulid import ULID
|
|||||||
|
|
||||||
from animaltrack.models.reference import Location
|
from animaltrack.models.reference import Location
|
||||||
from animaltrack.selection.validation import SelectionDiff
|
from animaltrack.selection.validation import SelectionDiff
|
||||||
|
from animaltrack.web.templates.actions import event_datetime_field
|
||||||
|
|
||||||
|
|
||||||
def move_form(
|
def move_form(
|
||||||
@@ -102,11 +103,12 @@ def move_form(
|
|||||||
id="to_location_id",
|
id="to_location_id",
|
||||||
name="to_location_id",
|
name="to_location_id",
|
||||||
),
|
),
|
||||||
|
# Event datetime picker (for backdating)
|
||||||
|
event_datetime_field("move_datetime"),
|
||||||
# Hidden fields for selection context
|
# Hidden fields for selection context
|
||||||
*resolved_id_fields,
|
*resolved_id_fields,
|
||||||
Hidden(name="roster_hash", value=roster_hash),
|
Hidden(name="roster_hash", value=roster_hash),
|
||||||
Hidden(name="from_location_id", value=from_location_id or ""),
|
Hidden(name="from_location_id", value=from_location_id or ""),
|
||||||
Hidden(name="ts_utc", value=str(ts_utc or 0)),
|
|
||||||
Hidden(name="resolver_version", value="v1"),
|
Hidden(name="resolver_version", value="v1"),
|
||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
|
|||||||
@@ -1552,3 +1552,134 @@ class TestStatusCorrectValidation:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert resp.status_code == 403
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user