Conversation
There was a problem hiding this comment.
Pull request overview
Adds ISO 8601 duration validation for Timing value and window fields, and updates the UI/tests to reflect the new duration format and validation behavior.
Changes:
- Added Pydantic validators for ISO 8601 duration parsing on
value,window_lower, andwindow_upper. - Updated the timings UI to guide users with ISO 8601 patterns and added client-side “all-or-none” window validation.
- Expanded router tests to cover acceptance/rejection of duration formats for API/UI endpoints.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_timings.py | Updates expectations to use ISO 8601 durations (e.g., P5D, -P1D). |
| tests/test_routers_timings.py | Adds API/UI tests for rejecting invalid duration strings and accepting valid ones. |
| src/soa_builder/web/templates/timings.html | Adds input patterns/titles for ISO durations, rearranges fields, and adds client-side window group validation. |
| src/soa_builder/web/templates/instances.html | Renames “Default Condition” labels to “Next Instance (default condition)”. |
| src/soa_builder/web/schemas.py | Adds ISO 8601 duration regex + field validators to Timing create/update schemas. |
| src/soa_builder/web/routers/timings.py | Converts UI create/update validation errors into HTTP 400 with summarized messages. |
| class TimingCreate(BaseModel): | ||
| name: str | ||
| label: Optional[str] = None | ||
| description: Optional[str] = None | ||
| type: Optional[str] = None | ||
| value: Optional[str] = None | ||
| value_label: Optional[str] = None | ||
| relative_to_from: Optional[str] = None | ||
| relative_from_schedule_instance: Optional[str] = None | ||
| relative_to_schedule_instance: Optional[str] = None | ||
| window_label: Optional[str] = None | ||
| window_upper: Optional[str] = None | ||
| window_lower: Optional[str] = None | ||
| member_of_timeline: Optional[str] = None |
There was a problem hiding this comment.
The PR description mentions “all-or-nothing validation of Timing window values”, but backend validation currently only checks ISO formatting per-field. As a result, API callers can submit any subset of window_lower/window_upper/window_label and it will pass schema validation (the UI JS can be bypassed). Add a backend validator (e.g., a model-level validator) to enforce that either all three window fields are provided (non-empty after stripping) or none are provided, for both TimingCreate and TimingUpdate.
| @field_validator("value", "window_lower", "window_upper", mode="before") | ||
| @classmethod | ||
| def check_iso8601_duration(cls, v: Optional[str]) -> Optional[str]: | ||
| return _validate_iso8601_duration(v) |
There was a problem hiding this comment.
The PR description mentions “all-or-nothing validation of Timing window values”, but backend validation currently only checks ISO formatting per-field. As a result, API callers can submit any subset of window_lower/window_upper/window_label and it will pass schema validation (the UI JS can be bypassed). Add a backend validator (e.g., a model-level validator) to enforce that either all three window fields are provided (non-empty after stripping) or none are provided, for both TimingCreate and TimingUpdate.
| # ISO 8601 duration pattern supporting both standard (-P2D) and USDM (P-2D) conventions | ||
| _ISO8601_DURATION_RE = re.compile( | ||
| r"^-?P-?(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$" | ||
| ) |
There was a problem hiding this comment.
The pattern ^-?P-? allows a double-negative prefix like -P-2D, which is likely unintended (it’s neither standard ISO -P2D nor the USDM-style P-2D). Consider changing the regex to allow exactly one sign position (either before P or immediately after P), but not both.
| @@ -178,4 +178,32 @@ <h2>Timings for SoA {{ soa_id }}</h2> | |||
| <tr><td colspan="12" style="padding:6px;color:#777;">No timings yet.</td></tr> | |||
There was a problem hiding this comment.
The table now has more than 12 columns (additional/reordered headers were added), but the “No timings yet.” row still uses colspan="12", which will render misaligned. Update the colspan to match the current number of <th> columns.
| <tr><td colspan="12" style="padding:6px;color:#777;">No timings yet.</td></tr> | |
| <tr><td colspan="16" style="padding:6px;color:#777;">No timings yet.</td></tr> |
| <div style="display:flex;flex-direction:column;gap:2px;"> | ||
| <label><strong>Value</strong></label> | ||
| <input name="value" placeholder="Value (optional)" /> | ||
| <input name="value" placeholder="e.g. P7D (optional)" pattern="-?P-?(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(\d+H)?(\d+M)?(\d+S)?)?" title="ISO 8601 duration, e.g. P1D, P2W, PT8H, -P2D" /> |
There was a problem hiding this comment.
The ISO 8601 duration pattern="..." is duplicated in multiple inputs (create + edit rows, value + window fields). This increases the chance of UI/backend drift (and currently mirrors the backend regex that also accepts -P-2D). Consider centralizing the pattern (e.g., Jinja variable/macro include) so changes happen in one place.
| {% endfor %} | ||
| </select> | ||
| </td> | ||
| <td style="padding: 4px;"><input name="value" value="{{ t.value or '' }}" placeholder="e.g. P7D" pattern="-?P-?(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(\d+H)?(\d+M)?(\d+S)?)?" title="ISO 8601 duration, e.g. P1D, P2W, PT8H, -P2D" /></td> |
There was a problem hiding this comment.
The ISO 8601 duration pattern="..." is duplicated in multiple inputs (create + edit rows, value + window fields). This increases the chance of UI/backend drift (and currently mirrors the backend regex that also accepts -P-2D). Consider centralizing the pattern (e.g., Jinja variable/macro include) so changes happen in one place.
| <td style="padding: 4px;"><input name="window_lower" value="{{ t.window_lower or '' }}" placeholder="e.g. -P2D" pattern="-?P-?(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(\d+H)?(\d+M)?(\d+S)?)?" title="ISO 8601 duration, e.g. P1D, -P2D, PT8H" /></td> | ||
| <td style="padding: 4px;"><input name="window_upper" value="{{ t.window_upper or '' }}" placeholder="e.g. P3D" pattern="-?P-?(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(\d+H)?(\d+M)?(\d+S)?)?" title="ISO 8601 duration, e.g. P1D, P3D, PT8H" /></td> |
There was a problem hiding this comment.
The ISO 8601 duration pattern="..." is duplicated in multiple inputs (create + edit rows, value + window fields). This increases the chance of UI/backend drift (and currently mirrors the backend regex that also accepts -P-2D). Consider centralizing the pattern (e.g., Jinja variable/macro include) so changes happen in one place.
| member_of_timeline=member_of_timeline, | ||
| ) | ||
| except ValidationError as exc: | ||
| msgs = "; ".join(e["msg"] for e in exc.errors()) |
There was a problem hiding this comment.
The UI error message concatenates only e["msg"] and drops which field failed (loc). If multiple fields fail validation, users won’t know which input to fix. Consider including the field path (e.g., derived from e["loc"]) in the message, or returning structured error details that the template can render next to the offending field(s).
| msgs = "; ".join(e["msg"] for e in exc.errors()) | |
| # Include field path (loc) in error messages so users know which input failed | |
| error_messages = [] | |
| for e in exc.errors(): | |
| loc = e.get("loc") | |
| if loc: | |
| # Pydantic typically includes "body" as the first element; skip it for UI friendliness | |
| if isinstance(loc, (list, tuple)): | |
| loc_parts = [str(part) for part in loc if part != "body"] | |
| field_path = ".".join(loc_parts) if loc_parts else "" | |
| else: | |
| field_path = str(loc) | |
| if field_path: | |
| error_messages.append(f"{field_path}: {e.get('msg')}") | |
| else: | |
| error_messages.append(e.get("msg")) | |
| else: | |
| error_messages.append(e.get("msg")) | |
| msgs = "; ".join(m for m in error_messages if m) |
| def test_window_all_or_none_accepts_all_three(): | ||
| """Test that providing all three window fields is accepted.""" |
There was a problem hiding this comment.
The “all-or-none” window behavior is only tested for the two accepted cases (all three present / none present). Add negative tests asserting rejection when only 1 or 2 of the 3 window fields are provided (for both JSON API and UI form endpoints), especially if backend validation is added to enforce this consistently.
| def test_window_all_or_none_accepts_none(): | ||
| """Test that providing no window fields is accepted.""" |
There was a problem hiding this comment.
The “all-or-none” window behavior is only tested for the two accepted cases (all three present / none present). Add negative tests asserting rejection when only 1 or 2 of the 3 window fields are provided (for both JSON API and UI form endpoints), especially if backend validation is added to enforce this consistently.
ISO validation of Timing window
all-or-nothing validation of Timing window values
ISO validation of Timing Value