Skip to content
Merged
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
88 changes: 49 additions & 39 deletions src/soa_builder/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel

from ..normalization import normalize_soa
from .initialize_database import _connect, _init_db
Expand Down Expand Up @@ -78,7 +77,20 @@
from .routers.arms import delete_arm

# Avoid binding visit helpers directly to allow fresh reloads in tests
from .schemas import ArmCreate, SOACreate, SOAMetadataUpdate, VisitCreate
from .schemas import (
ArmCreate,
SOACreate,
SOAMetadataUpdate,
VisitCreate,
VisitUpdate,
ConceptsUpdate,
# FreezeCreate,
CellCreate,
# BulkActivities,
# MatrixVisit,
# MatrixActivity,
MatrixImport,
)
from .utils import (
get_next_code_uid as _get_next_code_uid,
get_next_concept_uid as _get_next_concept_uid,
Expand Down Expand Up @@ -1047,51 +1059,23 @@ def _rollback_preview(soa_id: int, freeze_id: int) -> dict:
}


# ------ Schemas -----#
class ConceptsUpdate(BaseModel):
concept_codes: List[str]


class FreezeCreate(BaseModel):
version_label: Optional[str] = None


class CellCreate(BaseModel):
visit_id: int
activity_id: int
status: str


class BulkActivities(BaseModel):
names: List[str]


class MatrixVisit(BaseModel):
name: str
label: Optional[str] = None


class MatrixActivity(BaseModel):
name: str
statuses: List[str]


class MatrixImport(BaseModel):
visits: List[MatrixVisit]
activities: List[MatrixActivity]
reset: bool = True


def _fetch_matrix(soa_id: int):
conn = _connect()
cur = conn.cursor()
# Epochs not part of matrix axes currently; retrieved separately where needed.
cur.execute(
"SELECT id,name,label,order_index,epoch_id FROM visit WHERE soa_id=? ORDER BY order_index",
"SELECT id,name,label,order_index,epoch_id,description FROM visit WHERE soa_id=? ORDER BY order_index",
(soa_id,),
)
visits = [
dict(id=r[0], name=r[1], label=r[2], order_index=r[3], epoch_id=r[4])
dict(
id=r[0],
name=r[1],
label=r[2],
order_index=r[3],
epoch_id=r[4],
description=r[5],
)
for r in cur.fetchall()
]
# Activities: include optional label/description if schema supports them
Expand Down Expand Up @@ -5755,6 +5739,32 @@ def ui_set_visit_epoch(
)


@app.post("/ui/soa/{soa_id}/update_visit", response_class=HTMLResponse)
def ui_update_visit(
request: Request,
soa_id: int,
visit_id: int = Form(...),
name: Optional[str] = Form(None),
label: Optional[str] = Form(None),
description: Optional[str] = Form(None),
):
"""Form handler to update a Visit's mutable fields (name/label/description)."""
# Build payload with provided fields; blanks should clear values
payload = VisitUpdate(
name=name,
Comment on lines +5747 to +5754
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

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

The function accepts a name parameter but the inline form in edit.html doesn't provide a name input field, so name will always be None. This creates an inconsistency where the API could unintentionally clear the visit name. Consider removing the name parameter from this handler since it's not used in the UI.

Suggested change
name: Optional[str] = Form(None),
label: Optional[str] = Form(None),
description: Optional[str] = Form(None),
):
"""Form handler to update a Visit's mutable fields (name/label/description)."""
# Build payload with provided fields; blanks should clear values
payload = VisitUpdate(
name=name,
label: Optional[str] = Form(None),
description: Optional[str] = Form(None),
):
"""Form handler to update a Visit's mutable fields (label/description)."""
# Build payload with provided fields; blanks should clear values
payload = VisitUpdate(

Copilot uses AI. Check for mistakes.
label=label,
description=description,
)
try:
visits_router.update_visit(soa_id, visit_id, payload)
except Exception:
# Let redirect proceed; detailed errors will appear in API logs
pass
Comment on lines +5760 to +5762
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

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

The bare except Exception silently swallows all errors without providing any feedback to the user. Consider logging the error or displaying a user-friendly error message before redirecting.

Copilot uses AI. Check for mistakes.
Comment on lines +5760 to +5762
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

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

Catching all exceptions with a bare except Exception and silently passing provides no user feedback when updates fail. Consider logging the exception or providing user-facing error feedback instead of silently redirecting.

Suggested change
except Exception:
# Let redirect proceed; detailed errors will appear in API logs
pass
except Exception as exc:
# Log the exception but still redirect to the edit page
logging.exception(
"Failed to update visit %s for SOA %s via UI handler: %s",
visit_id,
soa_id,
exc,
)

Copilot uses AI. Check for mistakes.
return HTMLResponse(
f"<script>window.location='/ui/soa/{int(soa_id)}/edit';</script>"
)


@app.post("/ui/soa/{soa_id}/delete_activity", response_class=HTMLResponse)
def ui_delete_activity(request: Request, soa_id: int, activity_id: int = Form(...)):
"""Form handler to delete an Activity"""
Expand Down
31 changes: 31 additions & 0 deletions src/soa_builder/web/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,34 @@ class SOAMetadataUpdate(BaseModel):
study_id: Optional[str] = None
study_label: Optional[str] = None
study_description: Optional[str] = None


# moved from app.py
class ConceptsUpdate(BaseModel):
concept_codes: List[str]


class FreezeCreate(BaseModel):
version_label: Optional[str] = None


class CellCreate(BaseModel):
visit_id: int
activity_id: int
status: str


class MatrixVisit(BaseModel):
name: str
label: Optional[str] = None


class MatrixActivity(BaseModel):
name: str
statuses: List[str]


class MatrixImport(BaseModel):
visits: List[MatrixVisit]
activities: List[MatrixActivity]
reset: bool = True
26 changes: 18 additions & 8 deletions src/soa_builder/web/templates/edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ <h2>Editing SoA {{ soa_id }}</h2>
<summary>Visits ({{ visits|length }}) <span class="hint">(drag to reorder)</span></summary>
<ul id="visits-list" class="drag-list">
{% for v in visits %}
<li class="drag-item" draggable="true" data-id="{{ v.id }}"><span class="ord">{{ v.order_index }}</span>. {{ v.name }} ({{ v.label }})
<li class="drag-item" draggable="true" data-id="{{ v.id }}"><span class="ord">{{ v.order_index }}</span>. {{ v.name }}
{% if epochs %}
<form method="post" action="/ui/soa/{{ soa_id }}/set_visit_epoch" style="display:inline;margin-left:6px;">
<input type="hidden" name="visit_id" value="{{ v.id }}" />
Expand All @@ -98,18 +98,27 @@ <h2>Editing SoA {{ soa_id }}</h2>
</select>
</form>
{% endif %}
<form method="post" action="/ui/soa/{{ soa_id }}/delete_visit" style="display:inline;">
<input type="hidden" name="visit_id" value="{{ v.id }}" />
<button style="background:#c62828;color:#fff;border:none;padding:2px 6px;border-radius:3px;cursor:pointer;">
Delete
</button>
</form>
<span class="visit-actions">
<form method="post" action="/ui/soa/{{ soa_id }}/update_visit" style="display:inline;font-size:0.6em;">
<input type="hidden" name="visit_id" value="{{ v.id }}" />
<input name="label" value="{{ v.label or '' }}" placeholder="Label (opt)" style="width:100px;" />
<input name="description" value="{{ v.description or '' }}" placeholder="Description (optional)" style="width:160px;" />
<button style="background:#607d8b;color:#fff;border:none;padding:2px 6px;border-radius:3px;cursor:pointer;">Save</button>
</form>
<form method="post" action="/ui/soa/{{ soa_id }}/delete_visit" style="display:inline;">
<input type="hidden" name="visit_id" value="{{ v.id }}" />
<button style="background:#c62828;color:#fff;border:none;padding:2px 6px;border-radius:3px;cursor:pointer;">
Delete
</button>
</form>
</span>
</li>
{% endfor %}
</ul>
<form method="post" action="/ui/soa/{{ soa_id }}/add_visit" style="margin-top:6px;">
<input name="name" placeholder="Visit Name" required />
<input name="label" placeholder="Label (optional)" />
<input name="description" placeholder="Description (optional)" />
{% if epochs %}
<select name="epoch_id" style="font-size:0.7em;">
<option value="">Epoch (optional)</option>
Expand Down Expand Up @@ -535,7 +544,8 @@ <h3>Matrix</h3>
.drag-item { padding:3px 4px; margin:2px 0; background:#fff; border:1px solid #e0e0e0; border-radius:3px; cursor:grab; display:flex; align-items:center; gap:4px; }
.drag-item.dragging { opacity:0.5; }
.drag-item.over { border-color:#1976d2; background:#e3f2fd; }
.drag-item form { margin-left:auto; }
.drag-item form { margin-left:0; }
.visit-actions { display:inline-flex; align-items:center; gap:8px; margin-left:auto; }
.hint { font-weight:400; font-size:0.7em; color:#666; }
</style>
<script>
Expand Down