diff --git a/src/webapp/database.py b/src/webapp/database.py index c6ac37a8..bf72f683 100644 --- a/src/webapp/database.py +++ b/src/webapp/database.py @@ -222,6 +222,10 @@ class InstTable(Base): legacy_id: Mapped[str | None] = mapped_column( String(VAR_CHAR_LENGTH), nullable=True ) + # Only populated for GenAI onboarding schools (loose uploads; mapping pipeline). + genai_id: Mapped[str | None] = mapped_column( + String(VAR_CHAR_LENGTH), nullable=True, unique=True + ) created_at: Mapped[datetime.datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), diff --git a/src/webapp/routers/data.py b/src/webapp/routers/data.py index 88092ee2..ca5de476 100644 --- a/src/webapp/routers/data.py +++ b/src/webapp/routers/data.py @@ -1264,7 +1264,7 @@ def _infer_allowed_schemas_from_filename(file_name: str, inst: Any) -> List[str] Args: file_name: Name of the file (used for keyword inference). - inst: Institution row (must have legacy_id attr for legacy fallback). + inst: Institution row (legacy_id or genai_id enables arbitrary-name UNKNOWN fallback). Returns: Sorted list of allowed schema names (e.g. ["COURSE"], ["STUDENT"], ["UNKNOWN"]). @@ -1291,7 +1291,7 @@ def _infer_allowed_schemas_from_filename(file_name: str, inst: Any) -> List[str] if has_semester: inferred_from_name.add("SEMESTER") if not inferred_from_name: - if getattr(inst, "legacy_id", None): + if getattr(inst, "legacy_id", None) or getattr(inst, "genai_id", None): return ["UNKNOWN"] raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -1408,15 +1408,16 @@ def _resolve_schema_namespace_and_extension( base_schema_id: Any, file_name: str, ) -> Tuple[str, Optional[Dict[str, Any]]]: - """Resolve schema_namespace and updated_inst_schema by institution type (edvise/pdp/legacy).""" + """Resolve schema_namespace and updated_inst_schema by institution type (edvise/pdp/legacy/genai).""" pdp_id = getattr(inst, "pdp_id", None) edvise_id = getattr(inst, "edvise_id", None) legacy_id = getattr(inst, "legacy_id", None) - if not has_at_most_one_school_type(pdp_id, edvise_id, legacy_id): + genai_id = getattr(inst, "genai_id", None) + if not has_at_most_one_school_type(pdp_id, edvise_id, legacy_id, genai_id): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Institution configuration error: cannot have more than one of " - "pdp_id, edvise_id, or legacy_id set", + "pdp_id, edvise_id, legacy_id, or genai_id set", ) if edvise_id: return _resolve_edvise_schema(sess, now) @@ -1424,11 +1425,13 @@ def _resolve_schema_namespace_and_extension( return _resolve_pdp_schema(sess, now) if legacy_id: return ("legacy", None) + if genai_id: + return ("legacy", None) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=( "Institution configuration error: institution has no pdp_id, edvise_id, " - "or legacy_id; cannot resolve validation schema." + "legacy_id, or genai_id; cannot resolve validation schema." ), ) diff --git a/src/webapp/routers/data_test.py b/src/webapp/routers/data_test.py index 7add80bd..33cdd498 100644 --- a/src/webapp/routers/data_test.py +++ b/src/webapp/routers/data_test.py @@ -1376,7 +1376,7 @@ def test_validation_helper_pdp_and_edvise_mutual_exclusivity( assert response.status_code == 500 assert ( - "cannot have more than one of pdp_id, edvise_id, or legacy_id set" + "cannot have more than one of pdp_id, edvise_id, legacy_id, or genai_id set" in response.json()["detail"] ) @@ -1388,14 +1388,15 @@ def test_validation_helper_pdp_and_edvise_mutual_exclusivity( def test_validation_helper_rejects_institution_without_school_type( edvise_client: TestClient, edvise_session: sqlalchemy.orm.Session ) -> None: - """Upload validation requires pdp_id, edvise_id, or legacy_id on the institution.""" + """Upload validation requires a school-type id on the institution.""" inst = edvise_session.execute( select(InstTable).where(InstTable.id == EDVISE_INST_UUID) ).scalar_one() - saved = (inst.edvise_id, inst.pdp_id, inst.legacy_id) + saved = (inst.edvise_id, inst.pdp_id, inst.legacy_id, inst.genai_id) inst.edvise_id = None # type: ignore inst.pdp_id = None # type: ignore inst.legacy_id = None # type: ignore + inst.genai_id = None # type: ignore edvise_session.commit() from .data import STATE @@ -1408,9 +1409,9 @@ def test_validation_helper_rejects_institution_without_school_type( + "/input/validate-upload/test_student_file.csv", ) assert response.status_code == 500 - assert "no pdp_id, edvise_id, or legacy_id" in response.json()["detail"] + assert "no pdp_id, edvise_id, legacy_id, or genai_id" in response.json()["detail"] - inst.edvise_id, inst.pdp_id, inst.legacy_id = saved + inst.edvise_id, inst.pdp_id, inst.legacy_id, inst.genai_id = saved edvise_session.commit() diff --git a/src/webapp/routers/institutions.py b/src/webapp/routers/institutions.py index 9e9e87e6..85f85013 100644 --- a/src/webapp/routers/institutions.py +++ b/src/webapp/routers/institutions.py @@ -22,6 +22,7 @@ PDP_SCHEMA_GROUP, EDVISE_SCHEMA_GROUP, LEGACY_SCHEMA_GROUP, + GENAI_SCHEMA_GROUP, UsState, get_external_bucket_name, ) @@ -42,12 +43,12 @@ # PATCH/POST: every institution must resolve to exactly one school type (IDs on the row). _EXACTLY_ONE_SCHOOL_TYPE_DETAIL = ( "Institution must be exactly one of PDP (set pdp_id), " - "Edvise Schema (ES) (set edvise_id or is_edvise), or Legacy " - "(set legacy_id or is_legacy)." + "Edvise Schema (ES) (set edvise_id or is_edvise), Legacy " + "(set legacy_id or is_legacy), or GenAI (set genai_id or is_genai)." ) _MUTUALLY_EXCLUSIVE_SCHOOL_TYPE_DETAIL = ( - "An institution cannot be more than one of PDP, Edvise Schema (ES), or Legacy. " - "Please choose one schema type." + "An institution cannot be more than one of PDP, Edvise Schema (ES), Legacy, " + "or GenAI. Please choose one schema type." ) @@ -74,6 +75,9 @@ class InstitutionCreationRequest(BaseModel): # Legacy schools: upload data in any format. When True and legacy_id is omitted, legacy_id is auto-assigned. is_legacy: bool | None = None legacy_id: str | None = None + # GenAI onboarding: loose uploads; mapped outputs validated against ES separately. + is_genai: bool | None = None + genai_id: str | None = None retention_days: int | None = None @@ -89,6 +93,7 @@ class Institution(BaseModel): pdp_id: str | None = None edvise_id: str | None = None legacy_id: str | None = None + genai_id: str | None = None @router.get("/institutions", response_model=list[Institution]) @@ -118,6 +123,7 @@ def read_all_inst( "pdp_id": None if elem[0].pdp_id is None else elem[0].pdp_id, "edvise_id": None if elem[0].edvise_id is None else elem[0].edvise_id, "legacy_id": None if elem[0].legacy_id is None else elem[0].legacy_id, + "genai_id": None if elem[0].genai_id is None else elem[0].genai_id, } ) return res @@ -128,21 +134,24 @@ def _request_has_more_than_one_school_type( pdp_id: Optional[str], edvise_id: Optional[str], legacy_id: Optional[str], + genai_id: Optional[str], ) -> bool: - """Return True if the request indicates more than one of PDP, Edvise Schema (ES), or Legacy.""" + """Return True if the request indicates more than one school type.""" pdp_set = bool(pdp_id) edvise_set = bool(req.is_edvise) or bool(edvise_id) legacy_set = bool(req.is_legacy) or bool(legacy_id) - return (pdp_set + edvise_set + legacy_set) > 1 + genai_set = bool(req.is_genai) or bool(genai_id) + return (pdp_set + edvise_set + legacy_set + genai_set) > 1 -def _compute_edvise_legacy_ids_for_create( +def _compute_edvise_legacy_genai_ids_for_create( sess: Session, req: InstitutionCreationRequest, edvise_id: Optional[str], legacy_id: Optional[str], -) -> Tuple[Optional[str], Optional[str]]: - """Auto-assign edvise_id or legacy_id when type is set but no id provided. Returns (edvise_id, legacy_id).""" + genai_id: Optional[str], +) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """Auto-assign edvise_id, legacy_id, or genai_id when flags are set but id omitted.""" if req.is_edvise and not edvise_id: count = ( sess.execute( @@ -163,18 +172,33 @@ def _compute_edvise_legacy_ids_for_create( or 0 ) legacy_id = f"legacy_{count + 1}" - return (edvise_id, legacy_id) + if req.is_genai and not genai_id: + count = ( + sess.execute( + select(func.count()) + .select_from(InstTable) + .where(InstTable.genai_id.isnot(None)) + ).scalar() + or 0 + ) + genai_id = f"genai_{count + 1}" + return (edvise_id, legacy_id, genai_id) def _raise_if_existing_row_invalid_for_duplicate_post(existing: InstTable) -> None: """Reject idempotent POST when the stored row violates school-type invariants.""" - ep, ee, el = existing.pdp_id, existing.edvise_id, existing.legacy_id - if not has_at_most_one_school_type(ep, ee, el): + ep, ee, el, eg = ( + existing.pdp_id, + existing.edvise_id, + existing.legacy_id, + existing.genai_id, + ) + if not has_at_most_one_school_type(ep, ee, el, eg): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=_MUTUALLY_EXCLUSIVE_SCHOOL_TYPE_DETAIL, ) - if sum(bool(x) for x in (ep, ee, el)) != 1: + if sum(bool(x) for x in (ep, ee, el, eg)) != 1: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=( @@ -196,21 +220,22 @@ def _raise_if_institution_name_patch_disallowed( def _normalize_patch_school_id_strings(update_data: dict) -> None: - for key in ("pdp_id", "edvise_id", "legacy_id"): + for key in ("pdp_id", "edvise_id", "legacy_id", "genai_id"): if key in update_data: update_data[key] = (update_data[key] or "").strip() or None -def _resolve_merged_school_type_triple_for_patch( +def _resolve_merged_school_type_ids_for_patch( existing_inst: InstTable, update_data: dict, sess: Session, -) -> Tuple[Optional[str], Optional[str], Optional[str], bool]: +) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str], bool]: """Merge PATCH school-type fields, auto-assign ids, enforce exactly one type.""" - old_type_triple = ( + old_type_ids = ( existing_inst.pdp_id, existing_inst.edvise_id, existing_inst.legacy_id, + existing_inst.genai_id, ) _raise_if_institution_name_patch_disallowed(update_data, existing_inst.name) _normalize_patch_school_id_strings(update_data) @@ -227,32 +252,52 @@ def _resolve_merged_school_type_triple_for_patch( if "legacy_id" in update_data else existing_inst.legacy_id ) + final_genai_id = ( + update_data["genai_id"] if "genai_id" in update_data else existing_inst.genai_id + ) if _patch_indicates_more_than_one_school_type( - update_data, final_pdp_id, final_edvise_id, final_legacy_id + update_data, final_pdp_id, final_edvise_id, final_legacy_id, final_genai_id ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=_MUTUALLY_EXCLUSIVE_SCHOOL_TYPE_DETAIL, ) - final_edvise_id, final_legacy_id = _compute_edvise_legacy_ids_for_patch( - sess, update_data, final_edvise_id, final_legacy_id + final_edvise_id, final_legacy_id, final_genai_id = ( + _compute_edvise_legacy_genai_ids_for_patch( + sess, update_data, final_edvise_id, final_legacy_id, final_genai_id + ) ) - if not has_at_most_one_school_type(final_pdp_id, final_edvise_id, final_legacy_id): + if not has_at_most_one_school_type( + final_pdp_id, final_edvise_id, final_legacy_id, final_genai_id + ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=_MUTUALLY_EXCLUSIVE_SCHOOL_TYPE_DETAIL, ) - if sum(bool(x) for x in (final_pdp_id, final_edvise_id, final_legacy_id)) != 1: + if ( + sum( + bool(x) + for x in (final_pdp_id, final_edvise_id, final_legacy_id, final_genai_id) + ) + != 1 + ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=_EXACTLY_ONE_SCHOOL_TYPE_DETAIL, ) - school_type_changed = old_type_triple != ( + school_type_changed = old_type_ids != ( final_pdp_id, final_edvise_id, final_legacy_id, + final_genai_id, + ) + return ( + final_pdp_id, + final_edvise_id, + final_legacy_id, + final_genai_id, + school_type_changed, ) - return final_pdp_id, final_edvise_id, final_legacy_id, school_type_changed def _require_single_institution_row_by_uuid(sess: Session, inst_id: str) -> InstTable: @@ -275,8 +320,9 @@ def _persist_institution_patch_row_fields( final_pdp_id: Optional[str], final_edvise_id: Optional[str], final_legacy_id: Optional[str], + final_genai_id: Optional[str], ) -> None: - """Apply non-schema PATCH fields and the resolved school-type triple to the ORM row.""" + """Apply non-schema PATCH fields and the resolved school-type ids to the ORM row.""" if "state" in update_data: existing_inst.state = update_data["state"] if "allowed_emails" in update_data: @@ -286,6 +332,7 @@ def _persist_institution_patch_row_fields( existing_inst.pdp_id = final_pdp_id existing_inst.edvise_id = final_edvise_id existing_inst.legacy_id = final_legacy_id + existing_inst.genai_id = final_genai_id def _apply_institution_schema_updates_from_patch( @@ -295,6 +342,7 @@ def _apply_institution_schema_updates_from_patch( final_pdp_id: Optional[str], final_edvise_id: Optional[str], final_legacy_id: Optional[str], + final_genai_id: Optional[str], ) -> None: if school_type_changed: extra_allowed = ( @@ -305,6 +353,7 @@ def _apply_institution_schema_updates_from_patch( final_pdp_id, final_edvise_id, final_legacy_id, + final_genai_id, ) elif "allowed_schemas" in update_data: existing_inst.schemas = update_data["allowed_schemas"] @@ -315,23 +364,27 @@ def _patch_indicates_more_than_one_school_type( pdp_id: Optional[str], edvise_id: Optional[str], legacy_id: Optional[str], + genai_id: Optional[str], ) -> bool: - """True if merged IDs plus explicit is_edvise/is_legacy flags imply more than one school type.""" + """True if merged IDs plus type flags imply more than one school type.""" pdp_set = bool(pdp_id) edvise_flag = update_data["is_edvise"] if "is_edvise" in update_data else False legacy_flag = update_data["is_legacy"] if "is_legacy" in update_data else False + genai_flag = update_data["is_genai"] if "is_genai" in update_data else False edvise_set = bool(edvise_id) or bool(edvise_flag) legacy_set = bool(legacy_id) or bool(legacy_flag) - return (pdp_set + edvise_set + legacy_set) > 1 + genai_set = bool(genai_id) or bool(genai_flag) + return (pdp_set + edvise_set + legacy_set + genai_set) > 1 -def _compute_edvise_legacy_ids_for_patch( +def _compute_edvise_legacy_genai_ids_for_patch( sess: Session, update_data: dict, edvise_id: Optional[str], legacy_id: Optional[str], -) -> Tuple[Optional[str], Optional[str]]: - """Auto-assign edvise_id/legacy_id when PATCH sets is_edvise/is_legacy True and id is still empty.""" + genai_id: Optional[str], +) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """Auto-assign ids when PATCH sets is_edvise/is_legacy/is_genai True and id is still empty.""" if update_data.get("is_edvise") and not edvise_id: count = ( sess.execute( @@ -352,7 +405,17 @@ def _compute_edvise_legacy_ids_for_patch( or 0 ) legacy_id = f"legacy_{count + 1}" - return (edvise_id, legacy_id) + if update_data.get("is_genai") and not genai_id: + count = ( + sess.execute( + select(func.count()) + .select_from(InstTable) + .where(InstTable.genai_id.isnot(None)) + ).scalar() + or 0 + ) + genai_id = f"genai_{count + 1}" + return (edvise_id, legacy_id, genai_id) def _build_requested_schemas( @@ -360,10 +423,11 @@ def _build_requested_schemas( pdp_id: Optional[str], edvise_id: Optional[str], legacy_id: Optional[str], + genai_id: Optional[str], ) -> list: """Merge optional explicit allowed_schemas with the schema group for the school type. - Callers must ensure exactly one of pdp_id, edvise_id, or legacy_id is set; + Callers must ensure exactly one of pdp_id, edvise_id, legacy_id, or genai_id is set; the merged groups are always non-empty. """ requested_schemas = list(allowed_schemas) if allowed_schemas else [] @@ -373,6 +437,8 @@ def _build_requested_schemas( requested_schemas += list(EDVISE_SCHEMA_GROUP) if legacy_id: requested_schemas += list(LEGACY_SCHEMA_GROUP) + if genai_id: + requested_schemas += list(GENAI_SCHEMA_GROUP) return list(set(requested_schemas)) @@ -381,18 +447,22 @@ def _build_requested_schemas_for_create( pdp_id: Optional[str], edvise_id: Optional[str], legacy_id: Optional[str], + genai_id: Optional[str], ) -> list: """Build the requested_schemas list from req and school-type IDs (same rules as PATCH).""" - return _build_requested_schemas(req.allowed_schemas, pdp_id, edvise_id, legacy_id) + return _build_requested_schemas( + req.allowed_schemas, pdp_id, edvise_id, legacy_id, genai_id + ) def _validate_and_prepare_create_institution( req: InstitutionCreationRequest, current_user: BaseUser, sql_session: Session, -) -> Tuple[Session, Optional[str], Optional[str], Optional[str]]: +) -> Tuple[Session, Optional[str], Optional[str], Optional[str], Optional[str]]: """ - Validate request and compute normalized IDs. Returns (sess, pdp_id, edvise_id, legacy_id). + Validate request and compute normalized IDs. + Returns (sess, pdp_id, edvise_id, legacy_id, genai_id). Raises HTTPException on validation failure. """ if not current_user.is_datakinder(): @@ -413,29 +483,32 @@ def _validate_and_prepare_create_institution( pdp_id = (req.pdp_id or "").strip() or None edvise_id = (req.edvise_id or "").strip() or None legacy_id = (req.legacy_id or "").strip() or None + genai_id = (req.genai_id or "").strip() or None - if _request_has_more_than_one_school_type(req, pdp_id, edvise_id, legacy_id): + if _request_has_more_than_one_school_type( + req, pdp_id, edvise_id, legacy_id, genai_id + ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=_MUTUALLY_EXCLUSIVE_SCHOOL_TYPE_DETAIL, ) local_session.set(sql_session) sess = local_session.get() - edvise_id, legacy_id = _compute_edvise_legacy_ids_for_create( - sess, req, edvise_id, legacy_id + edvise_id, legacy_id, genai_id = _compute_edvise_legacy_genai_ids_for_create( + sess, req, edvise_id, legacy_id, genai_id ) - if not has_at_most_one_school_type(pdp_id, edvise_id, legacy_id): + if not has_at_most_one_school_type(pdp_id, edvise_id, legacy_id, genai_id): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=_MUTUALLY_EXCLUSIVE_SCHOOL_TYPE_DETAIL, ) - school_type_count = sum(bool(x) for x in (pdp_id, edvise_id, legacy_id)) + school_type_count = sum(bool(x) for x in (pdp_id, edvise_id, legacy_id, genai_id)) if school_type_count != 1: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=_EXACTLY_ONE_SCHOOL_TYPE_DETAIL, ) - return (sess, pdp_id, edvise_id, legacy_id) + return (sess, pdp_id, edvise_id, legacy_id, genai_id) def _institution_row_to_response(row: Any) -> Dict[str, Any]: @@ -447,6 +520,7 @@ def _institution_row_to_response(row: Any) -> Dict[str, Any]: "pdp_id": row.pdp_id, "edvise_id": row.edvise_id, "legacy_id": row.legacy_id, + "genai_id": row.genai_id, "retention_days": row.retention_days, } @@ -457,6 +531,7 @@ def _create_institution_record_and_infrastructure( pdp_id: Optional[str], edvise_id: Optional[str], legacy_id: Optional[str], + genai_id: Optional[str], requested_schemas: list, current_user: BaseUser, storage_control: StorageControl, @@ -473,6 +548,7 @@ def _create_institution_record_and_infrastructure( pdp_id=pdp_id, edvise_id=edvise_id, legacy_id=legacy_id, + genai_id=genai_id, schemas=requested_schemas, allowed_emails=req.allowed_emails, state=req.state, @@ -522,8 +598,8 @@ def create_institution( databricks_control: Annotated[DatabricksControl, Depends(DatabricksControl)], ) -> Any: """Create a new institution. Only available to Datakinders.""" - sess, pdp_id, edvise_id, legacy_id = _validate_and_prepare_create_institution( - req, current_user, sql_session + sess, pdp_id, edvise_id, legacy_id, genai_id = ( + _validate_and_prepare_create_institution(req, current_user, sql_session) ) query_result = sess.execute( select(InstTable).where( @@ -532,7 +608,7 @@ def create_institution( ).all() if len(query_result) == 0: requested_schemas = _build_requested_schemas_for_create( - req, pdp_id, edvise_id, legacy_id + req, pdp_id, edvise_id, legacy_id, genai_id ) row = _create_institution_record_and_infrastructure( sess, @@ -540,6 +616,7 @@ def create_institution( pdp_id, edvise_id, legacy_id, + genai_id, requested_schemas, current_user, storage_control, @@ -566,12 +643,12 @@ def update_inst( ) -> Any: """Modifies an existing institution. - The row must keep exactly one of pdp_id, edvise_id, or legacy_id (same as POST). - ``is_edvise`` / ``is_legacy`` in the body trigger the same auto-id assignment as + The row must keep exactly one of pdp_id, edvise_id, legacy_id, or genai_id (same as POST). + ``is_edvise`` / ``is_legacy`` / ``is_genai`` in the body trigger the same auto-id assignment as POST when the corresponding id is still empty after merging with the existing row. - ``schemas`` is recomputed (like POST) only when the school-type triple actually - changes; ``allowed_schemas`` alone still replaces ``schemas`` when no type change. + ``schemas`` is recomputed (like POST) only when the school-type ids actually + change; ``allowed_schemas`` alone still replaces ``schemas`` when no type change. """ if not current_user.is_datakinder(): raise HTTPException( @@ -586,14 +663,16 @@ def update_inst( final_pdp_id, final_edvise_id, final_legacy_id, + final_genai_id, school_type_changed, - ) = _resolve_merged_school_type_triple_for_patch(existing_inst, update_data, sess) + ) = _resolve_merged_school_type_ids_for_patch(existing_inst, update_data, sess) _persist_institution_patch_row_fields( existing_inst, update_data, final_pdp_id, final_edvise_id, final_legacy_id, + final_genai_id, ) _apply_institution_schema_updates_from_patch( existing_inst, @@ -602,6 +681,7 @@ def update_inst( final_pdp_id, final_edvise_id, final_legacy_id, + final_genai_id, ) sess.commit() refreshed = _require_single_institution_row_by_uuid(sess, inst_id) @@ -705,6 +785,7 @@ def read_inst_name( "pdp_id": query_result[0][0].pdp_id, "edvise_id": query_result[0][0].edvise_id, "legacy_id": query_result[0][0].legacy_id, + "genai_id": query_result[0][0].genai_id, } @@ -741,6 +822,7 @@ def read_inst_pdp_id( "pdp_id": query_result[0][0].pdp_id, "edvise_id": query_result[0][0].edvise_id, "legacy_id": query_result[0][0].legacy_id, + "genai_id": query_result[0][0].genai_id, } @@ -779,4 +861,5 @@ def read_inst_id( "pdp_id": query_result[0][0].pdp_id, "edvise_id": query_result[0][0].edvise_id, "legacy_id": query_result[0][0].legacy_id, + "genai_id": query_result[0][0].genai_id, } diff --git a/src/webapp/routers/institutions_test.py b/src/webapp/routers/institutions_test.py index d0280629..ad6c9325 100644 --- a/src/webapp/routers/institutions_test.py +++ b/src/webapp/routers/institutions_test.py @@ -170,11 +170,12 @@ def test_read_all_inst_datakinder(datakinder_client: TestClient) -> None: response = datakinder_client.get("/institutions") assert response.status_code == 200 data = response.json() - # Verify all institutions have edvise_id, pdp_id, and legacy_id fields + # Verify all institutions have school-type id fields for inst in data: assert "edvise_id" in inst assert "pdp_id" in inst assert "legacy_id" in inst + assert "genai_id" in inst # Verify specific expected values assert len(data) == 4 # UUID_1, UUID_2, UUID_3, USER_VALID_INST_UUID school_1 = next(i for i in data if i["name"] == "school_1") @@ -190,6 +191,7 @@ def test_read_all_inst_datakinder(datakinder_client: TestClient) -> None: "pdp_id": "456", "edvise_id": None, "legacy_id": None, + "genai_id": None, "retention_days": None, "state": "GA", }, @@ -199,6 +201,7 @@ def test_read_all_inst_datakinder(datakinder_client: TestClient) -> None: "pdp_id": None, "edvise_id": None, "legacy_id": None, + "genai_id": None, "retention_days": None, "state": None, }, @@ -208,6 +211,7 @@ def test_read_all_inst_datakinder(datakinder_client: TestClient) -> None: "pdp_id": "12345", "edvise_id": None, "legacy_id": None, + "genai_id": None, "retention_days": None, "state": "NY", }, @@ -217,6 +221,7 @@ def test_read_all_inst_datakinder(datakinder_client: TestClient) -> None: "pdp_id": None, "edvise_id": "edvise456", "legacy_id": None, + "genai_id": None, "retention_days": None, "state": "CA", }, @@ -359,7 +364,7 @@ def test_create_inst(datakinder_client: TestClient) -> None: def test_create_inst_rejects_no_school_type(datakinder_client: TestClient) -> None: - """POST /institutions requires exactly one of PDP, Edvise Schema (ES), or Legacy.""" + """POST /institutions requires exactly one school type (PDP, ES, Legacy, or GenAI).""" os.environ["ENV"] = "DEV" response = datakinder_client.post( "/institutions", @@ -404,6 +409,7 @@ def test_create_inst_duplicate_name_state_ok_when_existing_row_is_valid( assert data["name"] == "school_1" assert data["pdp_id"] == "456" assert data["edvise_id"] is None + assert data["genai_id"] is None def test_create_inst_rejects_is_pdp_without_pdp_id( @@ -426,7 +432,7 @@ def test_create_inst_rejects_duplicate_when_existing_row_has_conflicting_ids( """POST (name, state) match must 400 if stored row violates mutual exclusivity.""" inst = session.get(InstTable, UUID_1) assert inst is not None - saved = (inst.pdp_id, inst.edvise_id, inst.legacy_id) + saved = (inst.pdp_id, inst.edvise_id, inst.legacy_id, inst.genai_id) try: inst.edvise_id = "corrupt_edvise" session.commit() @@ -442,7 +448,7 @@ def test_create_inst_rejects_duplicate_when_existing_row_has_conflicting_ids( assert response.status_code == 400 assert "more than one" in response.json()["detail"].lower() finally: - inst.pdp_id, inst.edvise_id, inst.legacy_id = saved + inst.pdp_id, inst.edvise_id, inst.legacy_id, inst.genai_id = saved session.commit() @@ -462,6 +468,22 @@ def test_update_inst_patch_is_edvise_on_pdp_institution_returns_400( assert "more than one" in response.json()["detail"].lower() +def test_update_inst_patch_both_is_edvise_and_is_genai_returns_400( + datakinder_client: TestClient, +) -> None: + """PATCH cannot indicate both Edvise and GenAI in one request.""" + MOCK_STORAGE.create_bucket.return_value = None + MOCK_STORAGE.create_folders.return_value = None + MOCK_DATABRICKS.setup_new_inst.return_value = None + + response = datakinder_client.patch( + "/institutions/" + uuid_to_str(UUID_2), + json={"is_edvise": True, "is_genai": True}, + ) + assert response.status_code == 400 + assert "more than one" in response.json()["detail"].lower() + + def test_update_inst_patch_both_is_edvise_and_is_legacy_returns_400( datakinder_client: TestClient, ) -> None: @@ -692,6 +714,7 @@ def test_create_inst_auto_assign_edvise_id( assert data["edvise_id"] is not None and data["edvise_id"].startswith("edvise_") assert data["pdp_id"] is None assert data["legacy_id"] is None + assert data["genai_id"] is None def test_create_inst_auto_assign_legacy_id( @@ -715,6 +738,7 @@ def test_create_inst_auto_assign_legacy_id( assert data["legacy_id"] == "legacy_1" assert data["pdp_id"] is None assert data["edvise_id"] is None + assert data["genai_id"] is None def test_create_inst_reject_both_edvise_and_legacy( @@ -731,6 +755,20 @@ def test_create_inst_reject_both_edvise_and_legacy( assert "cannot be more than one" in response.json()["detail"] +def test_create_inst_reject_both_is_edvise_and_is_genai( + datakinder_client: TestClient, +) -> None: + """POST cannot set is_edvise and is_genai together.""" + request_data = { + "name": "edvise_genai_conflict", + "is_edvise": True, + "is_genai": True, + } + response = datakinder_client.post("/institutions", json=request_data) + assert response.status_code == 400 + assert "cannot be more than one" in response.json()["detail"] + + def test_create_inst_with_legacy_id_explicit(datakinder_client: TestClient) -> None: """Test POST /institutions with explicit legacy_id (no auto-assign).""" os.environ["ENV"] = "DEV" @@ -749,6 +787,53 @@ def test_create_inst_with_legacy_id_explicit(datakinder_client: TestClient) -> N assert data["legacy_id"] == "custom_legacy_123" assert data["pdp_id"] is None assert data["edvise_id"] is None + assert data["genai_id"] is None + + +def test_create_inst_auto_assign_genai_id(datakinder_client: TestClient) -> None: + """POST is_genai=True with no genai_id auto-assigns genai_id.""" + os.environ["ENV"] = "DEV" + MOCK_STORAGE.create_bucket.return_value = None + MOCK_STORAGE.create_folders.return_value = None + MOCK_DATABRICKS.setup_new_inst.return_value = None + + response = datakinder_client.post( + "/institutions", + json={ + "name": "auto_genai_test", + "is_genai": True, + "genai_id": None, + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["genai_id"] == "genai_1" + assert data["pdp_id"] is None + assert data["edvise_id"] is None + assert data["legacy_id"] is None + + +def test_create_inst_with_genai_id_explicit(datakinder_client: TestClient) -> None: + """POST with explicit genai_id.""" + os.environ["ENV"] = "DEV" + MOCK_STORAGE.create_bucket.return_value = None + MOCK_STORAGE.create_folders.return_value = None + MOCK_DATABRICKS.setup_new_inst.return_value = None + + response = datakinder_client.post( + "/institutions", + json={ + "name": "explicit_genai_school", + "state": "WA", + "genai_id": "custom_genai_99", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["genai_id"] == "custom_genai_99" + assert data["pdp_id"] is None + assert data["edvise_id"] is None + assert data["legacy_id"] is None def test_create_inst_storage_bucket_fails(datakinder_client: TestClient) -> None: @@ -826,6 +911,25 @@ def test_update_inst_add_legacy_id(datakinder_client: TestClient) -> None: assert data["legacy_id"] == "legacy_abc" assert data["pdp_id"] is None assert data["edvise_id"] is None + assert data["genai_id"] is None + + +def test_update_inst_add_genai_id(datakinder_client: TestClient) -> None: + """Test PATCH /institutions - add genai_id when institution had no type yet.""" + MOCK_STORAGE.create_bucket.return_value = None + MOCK_STORAGE.create_folders.return_value = None + MOCK_DATABRICKS.setup_new_inst.return_value = None + + response = datakinder_client.patch( + "/institutions/" + uuid_to_str(UUID_2), + json={"genai_id": "genai_abc"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["genai_id"] == "genai_abc" + assert data["pdp_id"] is None + assert data["edvise_id"] is None + assert data["legacy_id"] is None def test_update_inst_switch_pdp_to_edvise(datakinder_client: TestClient) -> None: @@ -955,6 +1059,8 @@ def test_read_inst_by_id_includes_edvise_id(client: TestClient) -> None: data = response.json() assert "edvise_id" in data assert data["edvise_id"] is None # This institution doesn't have Edvise Schema (ES) + assert "genai_id" in data + assert data["genai_id"] is None def test_read_inst_by_name_includes_edvise_id(client: TestClient) -> None: @@ -1094,6 +1200,26 @@ def test_update_inst_is_edvise_auto_assigns_id(datakinder_client: TestClient) -> assert data["edvise_id"].startswith("edvise_") assert data["pdp_id"] is None assert data["legacy_id"] is None + assert data["genai_id"] is None + + +def test_update_inst_is_genai_auto_assigns_id(datakinder_client: TestClient) -> None: + """PATCH is_genai True assigns genai_id when row had no type (same as POST).""" + MOCK_STORAGE.create_bucket.return_value = None + MOCK_STORAGE.create_folders.return_value = None + MOCK_DATABRICKS.setup_new_inst.return_value = None + + response = datakinder_client.patch( + "/institutions/" + uuid_to_str(UUID_2), + json={"is_genai": True}, + ) + assert response.status_code == 200 + data = response.json() + assert data["genai_id"] is not None + assert data["genai_id"].startswith("genai_") + assert data["pdp_id"] is None + assert data["edvise_id"] is None + assert data["legacy_id"] is None def test_update_inst_same_pdp_id_preserves_schemas( diff --git a/src/webapp/test_helper.py b/src/webapp/test_helper.py index f9c4d570..7943e4e7 100644 --- a/src/webapp/test_helper.py +++ b/src/webapp/test_helper.py @@ -80,6 +80,7 @@ "pdp_id": None, "edvise_id": None, "legacy_id": None, + "genai_id": None, "retention_days": 0, } @@ -90,6 +91,7 @@ "pdp_id": "12345", "edvise_id": None, "legacy_id": None, + "genai_id": None, "retention_days": None, } diff --git a/src/webapp/utilities.py b/src/webapp/utilities.py index 76dcff74..7d67bf54 100644 --- a/src/webapp/utilities.py +++ b/src/webapp/utilities.py @@ -156,27 +156,33 @@ class SchemaType(StrEnum): SchemaType.UNKNOWN, } +GENAI_SCHEMA_GROUP: Final = { + SchemaType.UNKNOWN, +} + def has_at_most_one_school_type( pdp_id: str | None, edvise_id: str | None, legacy_id: str | None, + genai_id: str | None = None, ) -> bool: """ - Return True if at most one of pdp_id, edvise_id, or legacy_id is set. + Return True if at most one of pdp_id, edvise_id, legacy_id, or genai_id is set. Used to enforce mutual exclusivity: at most one of PDP, Edvise Schema (ES), - or Legacy may be set (create requires exactly one). + Legacy, or GenAI may be set (create requires exactly one). Args: pdp_id: PDP institution identifier, or None. edvise_id: Edvise Schema (ES) institution identifier, or None. legacy_id: Legacy institution identifier, or None. + genai_id: GenAI institution identifier, or None. Returns: - True if zero or one of the three IDs is set; False if two or more are set. + True if zero or one of the four IDs is set; False if two or more are set. """ - return sum(bool(x) for x in (pdp_id, edvise_id, legacy_id)) <= 1 + return sum(bool(x) for x in (pdp_id, edvise_id, legacy_id, genai_id)) <= 1 class BaseUser(BaseModel): diff --git a/src/webapp/utilities_test.py b/src/webapp/utilities_test.py index 29617c9d..971b1a4d 100644 --- a/src/webapp/utilities_test.py +++ b/src/webapp/utilities_test.py @@ -29,15 +29,17 @@ def test_base_user_class_functions(): def test_has_at_most_one_school_type() -> None: - """Test mutual exclusivity helper: at most one of pdp_id, edvise_id, legacy_id may be set.""" - assert has_at_most_one_school_type(None, None, None) is True - assert has_at_most_one_school_type("pdp1", None, None) is True - assert has_at_most_one_school_type(None, "edvise1", None) is True - assert has_at_most_one_school_type(None, None, "legacy1") is True - assert has_at_most_one_school_type("pdp1", "edvise1", None) is False - assert has_at_most_one_school_type("pdp1", None, "legacy1") is False - assert has_at_most_one_school_type(None, "edvise1", "legacy1") is False - assert has_at_most_one_school_type("pdp1", "edvise1", "legacy1") is False + """Test mutual exclusivity helper: at most one school-type id may be set.""" + assert has_at_most_one_school_type(None, None, None, None) is True + assert has_at_most_one_school_type("pdp1", None, None, None) is True + assert has_at_most_one_school_type(None, "edvise1", None, None) is True + assert has_at_most_one_school_type(None, None, "legacy1", None) is True + assert has_at_most_one_school_type(None, None, None, "genai1") is True + assert has_at_most_one_school_type("pdp1", "edvise1", None, None) is False + assert has_at_most_one_school_type("pdp1", None, "legacy1", None) is False + assert has_at_most_one_school_type(None, "edvise1", "legacy1", None) is False + assert has_at_most_one_school_type("pdp1", "edvise1", "legacy1", None) is False + assert has_at_most_one_school_type(None, None, "legacy1", "genai1") is False def test_has_access_to_inst_or_err():