Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ jobs:

steps:
- uses: actions/checkout@v4
- name: Set up Python 3.10
- name: Set up Python 3.13
uses: actions/setup-python@v3
with:
python-version: "3.10"
python-version: "3.13"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
Expand Down
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ et_xmlfile==2.0.0
fhir.resources==8.2.0
fhir_core==1.1.5
idna==3.11
numpy==2.4.2
openpyxl==3.1.5
pandas==3.0.0
pydantic==2.12.5
Expand Down
70 changes: 40 additions & 30 deletions src/soa_builder/web/routers/timings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aggregating only e["msg"] drops the field context, making UI errors harder to act on (e.g., users can’t tell whether window_upper vs value failed). Include the error location (typically e["loc"]) in the message formatting so the response clearly identifies which field(s) are invalid.

Suggested change
msgs = "; ".join(e["msg"] for e in exc.errors())
msgs = "; ".join(
(
f"{'.'.join(str(p) for p in e.get('loc', []))}: {e.get('msg')}"
if e.get("loc")
else str(e.get("msg"))
)
for e in exc.errors()
)

Copilot uses AI. Check for mistakes.
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)

Expand Down Expand Up @@ -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)

Expand Down
70 changes: 69 additions & 1 deletion src/soa_builder/web/schemas.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,50 @@
import re
from typing import List, Optional

from pydantic import BaseModel
from pydantic import BaseModel, field_validator, model_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)?)?$"
)
Comment on lines +6 to +9
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This regex accepts durations where T is present but no time components are provided (e.g., P1DT), which is not a valid ISO 8601 duration. Consider adjusting the pattern so that if T is present, at least one of H/M/S must also be present.

Copilot uses AI. Check for mistakes.


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


def _validate_window_all_or_none(
window_lower: Optional[str],
window_upper: Optional[str],
window_label: Optional[str],
) -> None:
"""Enforce that window_lower, window_upper, and window_label are all provided or all absent."""

def _present(v: Optional[str]) -> bool:
return v is not None and v.strip() != ""

provided = [_present(window_lower), _present(window_upper), _present(window_label)]
if any(provided) and not all(provided):
missing = []
if not _present(window_lower):
missing.append("window_lower")
if not _present(window_upper):
missing.append("window_upper")
if not _present(window_label):
missing.append("window_label")
raise ValueError(
f"Window fields are all-or-nothing: missing {', '.join(missing)}"
)


class InstanceUpdate(BaseModel):
Expand Down Expand Up @@ -42,6 +86,18 @@ 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)

@model_validator(mode="after")
def check_window_all_or_none(self) -> "TimingCreate":
_validate_window_all_or_none(
self.window_lower, self.window_upper, self.window_label
)
return self


class TimingUpdate(BaseModel):
name: str
Expand All @@ -58,6 +114,18 @@ 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)

@model_validator(mode="after")
def check_window_all_or_none(self) -> "TimingUpdate":
_validate_window_all_or_none(
self.window_lower, self.window_upper, self.window_label
)
return self


class ScheduleTimelineCreate(BaseModel):
name: str
Expand Down
4 changes: 2 additions & 2 deletions src/soa_builder/web/templates/instances.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ <h2>Scheduled Activity Instances for SoA {{ soa_id }}</h2>
<input name="description" placeholder="Description (Optional)" />
</div>
<div style="display: flex;flex-direction: column;gap:2px;">
<label><strong>Default Condition ID</strong></label>
<label><strong>Next Instance (default condition)</strong></label>
<select name="default_condition_uid">
<option value="">-- Select Activity Instance --</option>
{% for name, instance_uid in (instance_options or {}).items() %}
Expand Down Expand Up @@ -78,7 +78,7 @@ <h2>Scheduled Activity Instances for SoA {{ soa_id }}</h2>
<th style="text-align:left;padding:4px;">Name</th>
<th style="text-align:left;padding:4px;">Label</th>
<th style="text-align:left;padding:4px;">Description</th>
<th style="text-align:left;padding:4px;">Default Condition</th>
<th style="text-align:left;padding:4px;">Next Instance (default condition)</th>
<th style="text-align:left;padding:4px;">Epoch</th>
<th style="text-align:left;padding:4px;">Timeline ID</th>
<th style="text-align:left;padding:4px;">Timeline Exit ID</th>
Expand Down
Loading
Loading