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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 1.3.0 (2026-07-22)
- feat: validate ES uploads with bronze `dataio` converters and Pandera (#273)
- feat: apply bronze `config.toml` `grade_map` before ES course Pandera (#273)
- feat: add PATCH endpoint to archive models (`/{inst_id}/models/{model_name}/archive`) (#274)
- feat: expose `archived` on `/models` endpoint responses (#275)

## 1.2.0 (2026-07-15)
- feat: list institution bronze volume CSVs via `/input/bronze-datasets` (#206)
- feat: import a bronze volume dataset into GCS via `/input/upload-from-volume-to-gcs-bucket` (#206)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "src"
version = "1.2.0"
version = "1.3.0"
description = "School-agnostic lib for implementing Edvise workflows."
readme = "README.md"
requires-python = ">=3.12,<3.13"
Expand Down
1 change: 1 addition & 0 deletions src/webapp/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,7 @@ class ModelTable(Base):
deleted: Mapped[bool] = mapped_column(nullable=True)
# If true, the model has been approved and is ready for use.
valid: Mapped[bool] = mapped_column(nullable=True)
archived: Mapped[int] = mapped_column(Integer, default=0)
# The time the deletion request was set.
deleted_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), nullable=True
Expand Down
73 changes: 73 additions & 0 deletions src/webapp/databricks.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@
re.IGNORECASE,
)

# Only these basenames may be read from bronze_volume/training_inputs/ (path traversal safe).
ALLOWED_BRONZE_TRAINING_INPUTS_FILES = frozenset({"dataio.py", "config.toml"})


def _create_databricks_workspace_client(operation: str) -> WorkspaceClient:
"""
Expand Down Expand Up @@ -640,6 +643,76 @@ def download_bronze_volume_file(self, inst_name: str, file_name: str) -> Any:
raise ValueError("Databricks download returned no contents.")
return stream

def download_bronze_training_inputs_file(
self, inst_name: str, relative_path: str = "dataio.py"
) -> Any:
"""
Download a file from ``bronze_volume/training_inputs/`` for an institution.

Only allowlisted basenames (currently ``dataio.py`` and ``config.toml``) are
permitted; nested paths, ``..``, and absolute paths are rejected.

Args:
inst_name: Institution display name (passed through ``databricksify_inst_name``).
relative_path: Basename under ``training_inputs/`` (default ``dataio.py``).

Returns:
Byte stream of file contents (same shape as ``download_bronze_volume_file``).

Raises:
ValueError: If path is unsafe, Databricks is not configured, or download fails.
"""
if not relative_path or not isinstance(relative_path, str):
raise ValueError("relative_path is required.")
if relative_path != os.path.basename(relative_path):
raise ValueError(
"relative_path must be a basename only (no directories or separators)."
)
if relative_path in (".", "..") or ".." in relative_path:
raise ValueError("relative_path must not contain path traversal.")
if relative_path not in ALLOWED_BRONZE_TRAINING_INPUTS_FILES:
raise ValueError(
f"relative_path must be one of {sorted(ALLOWED_BRONZE_TRAINING_INPUTS_FILES)}."
)
if not databricks_vars.get("DATABRICKS_HOST_URL") or not databricks_vars.get(
"CATALOG_NAME"
):
raise ValueError("Databricks integration not configured.")
if not gcs_vars.get("GCP_SERVICE_ACCOUNT_EMAIL"):
raise ValueError("GCP service account email not configured.")

try:
w = WorkspaceClient(
host=databricks_vars["DATABRICKS_HOST_URL"],
google_service_account=gcs_vars["GCP_SERVICE_ACCOUNT_EMAIL"],
)
except Exception as e:
LOGGER.exception(
"Failed to create Databricks WorkspaceClient with host: %s and service account: %s",
databricks_vars.get("DATABRICKS_HOST_URL"),
gcs_vars.get("GCP_SERVICE_ACCOUNT_EMAIL"),
)
raise ValueError(f"Workspace client creation failed: {e}") from e

db_inst_name = databricksify_inst_name(inst_name)
volume_path = (
f"/Volumes/{databricks_vars['CATALOG_NAME']}/"
f"{db_inst_name}_bronze/bronze_volume/training_inputs/{relative_path}"
)

try:
response = w.files.download(volume_path)
except Exception as e:
LOGGER.exception("Failed to download from %s", volume_path)
raise ValueError(
f"Failed to download bronze training_inputs file: {e}"
) from e

stream = getattr(response, "contents", None)
if stream is None:
raise ValueError("Databricks download returned no contents.")
return stream

# Note that for each unique PIPELINE, we'll need a new function, this is by nature of how unique pipelines
# may have unique parameters and would have a unique name (i.e. the name field specified in w.jobs.list()). But any run of a given pipeline (even across institutions) can use the same function.
# E.g. there is one PDP inference pipeline, so one PDP inference function here.
Expand Down
65 changes: 65 additions & 0 deletions src/webapp/databricks_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,71 @@ def test_build_shared_inference_job_parameters_shape() -> None:
)


def test_download_bronze_training_inputs_rejects_path_traversal(
ctrl: DatabricksControl,
) -> None:
with pytest.raises(ValueError, match="basename"):
ctrl.download_bronze_training_inputs_file("School", "../dataio.py")
with pytest.raises(ValueError, match="basename"):
ctrl.download_bronze_training_inputs_file("School", "foo/dataio.py")
with pytest.raises(ValueError, match="one of"):
ctrl.download_bronze_training_inputs_file("School", "secrets.py")


def test_download_bronze_training_inputs_file_builds_training_inputs_path(
monkeypatch: pytest.MonkeyPatch, ctrl: DatabricksControl
) -> None:
import src.webapp.databricks as db_mod

monkeypatch.setitem(
db_mod.databricks_vars, "DATABRICKS_HOST_URL", "https://example.databricks.com"
)
monkeypatch.setitem(db_mod.databricks_vars, "CATALOG_NAME", "dev_catalog")
monkeypatch.setitem(db_mod.gcs_vars, "GCP_SERVICE_ACCOUNT_EMAIL", "sa@example.com")

mock_stream = MagicMock()
mock_response = MagicMock()
mock_response.contents = mock_stream
workspace = MagicMock()
workspace.files.download.return_value = mock_response

with mock.patch.object(db_mod, "WorkspaceClient", return_value=workspace):
result = ctrl.download_bronze_training_inputs_file("Edvise School", "dataio.py")

assert result is mock_stream
workspace.files.download.assert_called_once_with(
"/Volumes/dev_catalog/edvise_school_bronze/bronze_volume/training_inputs/dataio.py"
)


def test_download_bronze_training_inputs_allows_config_toml(
monkeypatch: pytest.MonkeyPatch, ctrl: DatabricksControl
) -> None:
import src.webapp.databricks as db_mod

monkeypatch.setitem(
db_mod.databricks_vars, "DATABRICKS_HOST_URL", "https://example.databricks.com"
)
monkeypatch.setitem(db_mod.databricks_vars, "CATALOG_NAME", "dev_catalog")
monkeypatch.setitem(db_mod.gcs_vars, "GCP_SERVICE_ACCOUNT_EMAIL", "sa@example.com")

mock_stream = MagicMock()
mock_response = MagicMock()
mock_response.contents = mock_stream
workspace = MagicMock()
workspace.files.download.return_value = mock_response

with mock.patch.object(db_mod, "WorkspaceClient", return_value=workspace):
result = ctrl.download_bronze_training_inputs_file(
"Edvise School", "config.toml"
)

assert result is mock_stream
workspace.files.download.assert_called_once_with(
"/Volumes/dev_catalog/edvise_school_bronze/bronze_volume/training_inputs/config.toml"
)


def test_run_validated_gcs_to_bronze_sync_calls_run_now_with_bundle_params(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand Down
4 changes: 2 additions & 2 deletions src/webapp/gcsutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,8 +396,8 @@ def validate_file(
file_name: Blob name under unvalidated/.
allowed_schemas: List of schema/model names allowed.
institution_id: Validation namespace: "edvise", "pdp", or "legacy".
institution_identifier: Optional institution ID (e.g. UUID). Used by
Edvise upload validation for caller context.
institution_identifier: For ES, institution name used to fetch bronze
``training_inputs/dataio.py`` converters. Unused for PDP/Legacy.

Returns:
List of inferred schema names (e.g. ["STUDENT"]).
Expand Down
6 changes: 5 additions & 1 deletion src/webapp/routers/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -1613,6 +1613,7 @@ def _run_validation_and_upsert_file_record(
current_user: BaseUser,
storage_control: StorageControl,
sess: Session,
institution_name: Optional[str] = None,
) -> Dict[str, Any]:
"""Run storage validate_file, then upsert file record and return response dict."""
try:
Expand All @@ -1621,7 +1622,9 @@ def _run_validation_and_upsert_file_record(
file_name,
allowed_schemas,
institution_id=schema_namespace,
institution_identifier=inst_id if schema_namespace == "edvise" else None,
institution_identifier=(
institution_name if schema_namespace == "edvise" else None
),
)
except HardValidationError as e:
logging.debug("Inferred Schemas FAILED (hard) %s", e)
Expand Down Expand Up @@ -1781,6 +1784,7 @@ def validation_helper(
current_user,
storage_control,
sess,
institution_name=inst.name,
)
if batch_id is not None:
_load_batch_for_bronze_sync(sess, inst_id, batch_id)
Expand Down
11 changes: 6 additions & 5 deletions src/webapp/routers/data_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1464,7 +1464,7 @@ def test_validate_file_with_edvise_schema(edvise_client: TestClient) -> None:
assert MOCK_STORAGE.validate_file.called
call_kwargs = MOCK_STORAGE.validate_file.call_args.kwargs
assert call_kwargs.get("institution_id") == "edvise"
assert call_kwargs.get("institution_identifier") == uuid_to_str(EDVISE_INST_UUID)
assert call_kwargs.get("institution_identifier") == "edvise_school"

# Non-PDP bronze sync runs only when batch_id is provided at validation time.
MOCK_DATABRICKS.run_validated_gcs_to_bronze_sync.assert_not_called()
Expand Down Expand Up @@ -1854,9 +1854,10 @@ def test_validate_file_with_edvise_does_not_require_active_schema_doc(

assert response.status_code == 200
assert MOCK_STORAGE.validate_file.call_args.kwargs.get("institution_id") == "edvise"
assert MOCK_STORAGE.validate_file.call_args.kwargs.get(
"institution_identifier"
) == uuid_to_str(EDVISE_INST_UUID)
assert (
MOCK_STORAGE.validate_file.call_args.kwargs.get("institution_identifier")
== "edvise_school"
)


def test_validation_helper_pdp_and_edvise_mutual_exclusivity(
Expand Down Expand Up @@ -1995,7 +1996,7 @@ def capture_schema(*args, **kwargs):

assert captured_arg_count == 3
assert captured_institution_id == "edvise"
assert captured_institution_identifier == uuid_to_str(EDVISE_INST_UUID)
assert captured_institution_identifier == "edvise_school"

# Reset mock
MOCK_STORAGE.validate_file.side_effect = None
Expand Down
45 changes: 45 additions & 0 deletions src/webapp/routers/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ class ModelInfo(BaseModel):
created_by: str | None = None
valid: bool = True
deleted: bool | None = None
archived: bool = False


def _model_version_as_str(version: Any) -> str | None:
Expand Down Expand Up @@ -297,6 +298,7 @@ def read_inst_models(
"created_by": uuid_to_str(elem[0].created_by),
"deleted": elem[0].deleted,
"valid": elem[0].valid,
"archived": bool(elem[0].archived),
}
)
return res
Expand Down Expand Up @@ -373,6 +375,7 @@ def create_model(
"created_by": uuid_to_str(query_result[0][0].created_by),
"deleted": query_result[0][0].deleted,
"valid": query_result[0][0].valid,
"archived": bool(query_result[0][0].archived),
}


Expand Down Expand Up @@ -420,6 +423,7 @@ def read_inst_model(
"created_by": uuid_to_str(query_result[0][0].created_by),
"deleted": query_result[0][0].deleted,
"valid": query_result[0][0].valid,
"archived": bool(query_result[0][0].archived),
}


Expand Down Expand Up @@ -466,6 +470,47 @@ def delete_model(
}


@router.patch("/{inst_id}/models/{model_name}/archive")
def archive_model(
inst_id: str,
model_name: str,
current_user: Annotated[BaseUser, Depends(get_current_active_user)],
sql_session: Annotated[Session, Depends(get_session)],
) -> Any:
"""Archive a model by setting ``archived`` from 0 to 1."""
transformed_model_name = str(decode_url_piece(model_name)).strip()
has_access_to_inst_or_err(inst_id, current_user)

local_session.set(sql_session)
sess = local_session.get()

model = sess.execute(
select(ModelTable).where(
ModelTable.name == transformed_model_name,
ModelTable.inst_id == str_to_uuid(inst_id),
)
).scalar_one_or_none()
if model is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Model not found."
)
if model.archived:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Model is already archived.",
)

model.archived = 1
sess.commit()

return {
"inst_id": inst_id,
"model_name": transformed_model_name,
"archived": 1,
"status": "Model archived",
}


@router.get("/{inst_id}/models/{model_name}/runs", response_model=list[RunInfo])
def read_inst_model_outputs(
inst_id: str,
Expand Down
37 changes: 37 additions & 0 deletions src/webapp/routers/models_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def same_model_orderless(a_elem: ModelInfo, b_elem: ModelInfo) -> bool:
or a_elem.m_id != b_elem.m_id
or a_elem.valid != b_elem.valid
or a_elem.deleted != b_elem.deleted
or a_elem.archived != b_elem.archived
):
return False
return True
Expand Down Expand Up @@ -247,6 +248,7 @@ def test_read_inst_models(client: TestClient) -> None:
inst_id="1d7c75c33eda42949c6675ea8af97b55",
deleted=None,
valid=True,
archived=False,
),
)

Expand Down Expand Up @@ -279,10 +281,45 @@ def test_read_inst_model(client: TestClient) -> None:
m_id="e4862c62829440d8ab4c9c298f02f619",
name="sample_model_for_school_1",
valid=True,
archived=False,
)
assert same_model_orderless(response_model, expected_model)


def test_archive_model(client: TestClient, session: sqlalchemy.orm.Session) -> None:
"""Test PATCH /institutions/{inst_id}/models/{model_name}/archive."""
base = "/institutions/" + uuid_to_str(USER_VALID_INST_UUID) + "/models/"
assert client.patch(base + "missing_model/archive").status_code == 404

response = client.patch(base + "sample_model_for_school_1/archive")
assert response.status_code == 200
assert response.json() == {
"inst_id": uuid_to_str(USER_VALID_INST_UUID),
"model_name": "sample_model_for_school_1",
"archived": 1,
"status": "Model archived",
}
model_row = session.get(ModelTable, SAMPLE_UUID)
assert model_row is not None
assert model_row.archived == 1
assert client.patch(base + "sample_model_for_school_1/archive").status_code == 409

# Confirm the archived state is reflected on the model read endpoints.
get_response = client.get(base + "sample_model_for_school_1")
assert get_response.status_code == 200
assert get_response.json()["archived"] is True

list_response = client.get(
"/institutions/" + uuid_to_str(USER_VALID_INST_UUID) + "/models"
)
assert list_response.status_code == 200
assert next(
m["archived"]
for m in list_response.json()
if m["name"] == "sample_model_for_school_1"
)


def test_read_inst_model_outputs(client: TestClient) -> None:
"""Test GET /institutions/345/models/10/output."""
MOCK_STORAGE.list_blobs_in_folder.return_value = []
Expand Down
Loading
Loading