From 5693cd1c1ecf17be334675ac679fddfb5f60d27e Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:23:33 -0500 Subject: [PATCH 1/6] Ordered timing fields in logical way; added hover help text --- src/soa_builder/web/templates/timings.html | 59 +++++++++++----------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/src/soa_builder/web/templates/timings.html b/src/soa_builder/web/templates/timings.html index c4c015b..483d7ea 100644 --- a/src/soa_builder/web/templates/timings.html +++ b/src/soa_builder/web/templates/timings.html @@ -1,6 +1,6 @@ {% extends 'base.html' %} {% block content %} -

Timings for SoA {{ soa_id }}

+

Timings for SoA {{ soa_id }}

@@ -23,11 +23,11 @@

Timings for SoA {{ soa_id }}

- - + + {% for name, instance_uid in (instance_options or {}).items() %} + {% endfor %}
@@ -40,17 +40,17 @@

Timings for SoA {{ soa_id }}

- - - {% for sv in relative_to_from_options or [] %} + {% for sv in timing_type_options or [] %} {% endfor %}
- - {% for name, instance_uid in (instance_options or {}).items() %} @@ -58,11 +58,11 @@

Timings for SoA {{ soa_id }}

- - + + {% for sv in relative_to_from_options or [] %} + {% endfor %}
@@ -97,12 +97,12 @@

Timings for SoA {{ soa_id }}

Name Label Description - Type + Rel From Instance Value Value Label - Rel To/From - Rel From Instance + Type Rel To Instance + Rel To/From Window Label Window Upper Window Lower @@ -117,6 +117,16 @@

Timings for SoA {{ soa_id }}

+ + + + + - - - - - +
- + - - {% for sv in relative_to_from_options or [] %} - - {% endfor %} - -
-
- - + +
- - + +
@@ -87,6 +78,15 @@

+ + +

@@ -102,11 +102,11 @@

Value Label Type Rel To Instance - Rel To/From - Window Label - Window Upper Window Lower + Window Upper + Window Label Member of Timeline + Rel To/From Save Delete @@ -135,15 +135,6 @@

- - - - + + + + {% for sv in relative_to_from_options or [] %} + + {% endfor %} + + From ef1a7ca620787588586c621b0505bb0782de3d52 Mon Sep 17 00:00:00 2001 From: Darren <3921919+pendingintent@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:42:13 -0500 Subject: [PATCH 4/6] Added ISO validation rules for windows, value as well as all-or-nothing test for window values --- src/soa_builder/web/routers/timings.py | 70 ++++---- src/soa_builder/web/schemas.py | 32 +++- src/soa_builder/web/templates/timings.html | 40 ++++- tests/test_routers_timings.py | 180 +++++++++++++++++++++ tests/test_timings.py | 12 +- 5 files changed, 291 insertions(+), 43 deletions(-) diff --git a/src/soa_builder/web/routers/timings.py b/src/soa_builder/web/routers/timings.py index b30370b..ef02813 100644 --- a/src/soa_builder/web/routers/timings.py +++ b/src/soa_builder/web/routers/timings.py @@ -3,6 +3,8 @@ import os from typing import Optional +from pydantic import ValidationError + from fastapi import APIRouter, HTTPException, Request, Form from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates @@ -291,21 +293,25 @@ def ui_create_timing( conn_c2.close() except Exception: rtf_code_uid = None - payload = TimingCreate( - name=name, - label=label, - description=description, - type=code_uid, - value=value, - value_label=value_label, - relative_to_from=rtf_code_uid, - relative_from_schedule_instance=relative_from_schedule_instance, - relative_to_schedule_instance=relative_to_schedule_instance, - window_label=window_label, - window_upper=window_upper, - window_lower=window_lower, - member_of_timeline=member_of_timeline, - ) + try: + payload = TimingCreate( + name=name, + label=label, + description=description, + type=code_uid, + value=value, + value_label=value_label, + relative_to_from=rtf_code_uid, + relative_from_schedule_instance=relative_from_schedule_instance, + relative_to_schedule_instance=relative_to_schedule_instance, + window_label=window_label, + window_upper=window_upper, + window_lower=window_lower, + member_of_timeline=member_of_timeline, + ) + except ValidationError as exc: + msgs = "; ".join(e["msg"] for e in exc.errors()) + raise HTTPException(400, f"Validation error: {msgs}") create_timing(soa_id, payload) return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/timings", status_code=303) @@ -623,21 +629,25 @@ def ui_update_timing( # On any error, fall back to previous behavior (leave fields unchanged) mapped_type = None if type_submission_value not in ("",) else "" mapped_rtf = None if relative_to_from_submission_value not in ("",) else "" - payload = TimingUpdate( - name=name, - label=label, - description=description, - type=mapped_type, - value=value, - value_label=value_label, - relative_to_from=mapped_rtf, - relative_from_schedule_instance=relative_from_schedule_instance, - relative_to_schedule_instance=relative_to_schedule_instance, - window_label=window_label, - window_upper=window_upper, - window_lower=window_lower, - member_of_timeline=member_of_timeline, - ) + try: + payload = TimingUpdate( + name=name, + label=label, + description=description, + type=mapped_type, + value=value, + value_label=value_label, + relative_to_from=mapped_rtf, + relative_from_schedule_instance=relative_from_schedule_instance, + relative_to_schedule_instance=relative_to_schedule_instance, + window_label=window_label, + window_upper=window_upper, + window_lower=window_lower, + member_of_timeline=member_of_timeline, + ) + except ValidationError as exc: + msgs = "; ".join(e["msg"] for e in exc.errors()) + raise HTTPException(400, f"Validation error: {msgs}") update_timing(soa_id, timing_id, payload) return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/timings", status_code=303) diff --git a/src/soa_builder/web/schemas.py b/src/soa_builder/web/schemas.py index 67695bf..b7892f5 100644 --- a/src/soa_builder/web/schemas.py +++ b/src/soa_builder/web/schemas.py @@ -1,6 +1,26 @@ +import re from typing import List, Optional -from pydantic import BaseModel +from pydantic import BaseModel, field_validator + +# 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)?)?$" +) + + +def _validate_iso8601_duration(v: Optional[str]) -> Optional[str]: + if v is None: + return v + v = v.strip() + if not v: + return None + m = _ISO8601_DURATION_RE.match(v) + if not m or not any(m.group(i) is not None for i in range(1, 8)): + raise ValueError( + f"'{v}' is not a valid ISO 8601 duration (e.g. P1D, P2W, PT8H, -P2D)" + ) + return v class InstanceUpdate(BaseModel): @@ -42,6 +62,11 @@ class TimingCreate(BaseModel): window_lower: Optional[str] = None member_of_timeline: Optional[str] = None + @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) + class TimingUpdate(BaseModel): name: str @@ -58,6 +83,11 @@ class TimingUpdate(BaseModel): window_lower: Optional[str] = None member_of_timeline: Optional[str] = None + @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) + class ScheduleTimelineCreate(BaseModel): name: str diff --git a/src/soa_builder/web/templates/timings.html b/src/soa_builder/web/templates/timings.html index 378c04c..6b909d4 100644 --- a/src/soa_builder/web/templates/timings.html +++ b/src/soa_builder/web/templates/timings.html @@ -33,7 +33,7 @@

- +
@@ -59,11 +59,11 @@

- +

- +
@@ -125,7 +125,7 @@

+ - + +