From 8c82d5b4f860e0d4ba01a02ef79c81486d33b483 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 10 Jul 2026 23:46:26 +0200 Subject: [PATCH] Mark installed catalog modules as updates --- src/govoplan_admin/backend/api/v1/routes.py | 19 ++++++++++++------- src/govoplan_admin/backend/api/v1/schemas.py | 4 ++-- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/govoplan_admin/backend/api/v1/routes.py b/src/govoplan_admin/backend/api/v1/routes.py index a23735a..bae88e3 100644 --- a/src/govoplan_admin/backend/api/v1/routes.py +++ b/src/govoplan_admin/backend/api/v1/routes.py @@ -438,7 +438,7 @@ def _upsert_install_plan_item(session: Session, item: ModuleInstallPlanItem): return save_module_install_plan(session, [*retained, item]) -def _catalog_plan_item(module_id: str) -> tuple[ModuleInstallPlanItem, dict[str, object]]: +def _catalog_plan_item(module_id: str, available_module_ids: set[str]) -> tuple[ModuleInstallPlanItem, dict[str, object]]: result = validate_module_package_catalog() if not result.get("valid"): raise HTTPException( @@ -448,15 +448,17 @@ def _catalog_plan_item(module_id: str) -> tuple[ModuleInstallPlanItem, dict[str, for raw_item in result.get("modules", []): if not isinstance(raw_item, dict): continue - if raw_item.get("module_id") != module_id or raw_item.get("action") != "install": + if raw_item.get("module_id") != module_id or raw_item.get("action") not in {"install", "update"}: continue + raw_action = raw_item.get("action") + action = "update" if raw_action == "update" or module_id in available_module_ids else "install" license_decision = module_license_decision(_catalog_license_features(raw_item)) if not license_decision.get("allowed"): missing = license_decision.get("missing_features") missing_text = ", ".join(str(item) for item in missing) if isinstance(missing, list) else "required feature" raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=f"License does not allow installing {module_id}: {missing_text}.", + detail=f"License does not allow {action} for {module_id}: {missing_text}.", ) notes = raw_item.get("notes") if isinstance(raw_item.get("notes"), str) else None if license_decision.get("reason") and license_decision.get("missing_features"): @@ -464,7 +466,7 @@ def _catalog_plan_item(module_id: str) -> tuple[ModuleInstallPlanItem, dict[str, notes = f"{prefix}License warning: {license_decision['reason']}" return ModuleInstallPlanItem( module_id=str(raw_item["module_id"]), - action="install", + action=action, source="catalog", catalog=_catalog_plan_metadata(result), python_package=raw_item.get("python_package") if isinstance(raw_item.get("python_package"), str) else None, @@ -473,7 +475,7 @@ def _catalog_plan_item(module_id: str) -> tuple[ModuleInstallPlanItem, dict[str, webui_ref=raw_item.get("webui_ref") if isinstance(raw_item.get("webui_ref"), str) else None, notes=notes, ), result - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catalog install entry not found: {module_id}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catalog install/update entry not found: {module_id}") def _catalog_plan_metadata(validation: dict[str, object]) -> dict[str, object]: @@ -796,7 +798,9 @@ def plan_module_install_from_catalog( principal: ApiPrincipal = Depends(require_scope("system:settings:write")), ): try: - item, validation = _catalog_plan_item(module_id) + lifecycle = getattr(request.app.state, "govoplan_lifecycle", None) + available = dict(lifecycle.available_modules) if isinstance(lifecycle, ModuleLifecycleManager) else available_module_manifests(ignore_load_errors=True) + item, validation = _catalog_plan_item(module_id, set(available)) plan = _upsert_install_plan_item(session, item) record_module_package_catalog_acceptance(validation) except ModuleManagementError as exc: @@ -820,7 +824,8 @@ def plan_module_install_from_catalog( ), ) session.commit() - return _module_install_plan_response(session, request, notes=[f"Catalog install entry planned for {module_id}."]) + planned_action = "update" if item.action == "update" else "install" + return _module_install_plan_response(session, request, notes=[f"Catalog {planned_action} entry planned for {module_id}."]) @router.post("/system/modules/{module_id}/uninstall-plan", response_model=ModuleInstallPlanResponse) diff --git a/src/govoplan_admin/backend/api/v1/schemas.py b/src/govoplan_admin/backend/api/v1/schemas.py index 1665253..6fbb6f6 100644 --- a/src/govoplan_admin/backend/api/v1/schemas.py +++ b/src/govoplan_admin/backend/api/v1/schemas.py @@ -175,7 +175,7 @@ class ModuleInstallPlanItem(BaseModel): model_config = ConfigDict(extra="forbid") module_id: str = Field(min_length=1, max_length=120) - action: Literal["install", "uninstall"] + action: Literal["install", "update", "uninstall"] source: Literal["manual", "catalog"] = "manual" catalog: dict[str, Any] | None = None python_package: str | None = Field(default=None, max_length=200) @@ -327,7 +327,7 @@ class ModulePackageCatalogItem(BaseModel): name: str description: str | None = None version: str | None = None - action: Literal["install", "uninstall"] = "install" + action: Literal["install", "update", "uninstall"] = "install" python_package: str | None = None python_ref: str | None = None webui_package: str | None = None