diff --git a/goosebit/api/v1/devices/requests.py b/goosebit/api/v1/devices/requests.py index f2342f69..55a282a1 100644 --- a/goosebit/api/v1/devices/requests.py +++ b/goosebit/api/v1/devices/requests.py @@ -5,12 +5,3 @@ class DevicesDeleteRequest(BaseModel): devices: list[str] - - -class DevicesPatchRequest(BaseModel): - devices: list[str] - software: str | None = None - name: str | None = None - pinned: bool | None = None - feed: str | None = None - force_update: bool | None = None diff --git a/goosebit/api/v1/devices/routes.py b/goosebit/api/v1/devices/routes.py index 8e7eca95..073c0214 100644 --- a/goosebit/api/v1/devices/routes.py +++ b/goosebit/api/v1/devices/routes.py @@ -5,11 +5,11 @@ from goosebit.api.responses import StatusResponse from goosebit.auth import validate_user_permissions -from goosebit.db.models import Device, Software, UpdateModeEnum -from goosebit.updater.manager import delete_devices, get_update_manager +from goosebit.db.models import Device +from goosebit.updater.manager import delete_devices from . import device -from .requests import DevicesDeleteRequest, DevicesPatchRequest +from .requests import DevicesDeleteRequest from .responses import DevicesResponse router = APIRouter(prefix="/devices", tags=["devices"]) @@ -23,32 +23,6 @@ async def devices_get(_: Request) -> DevicesResponse: return await DevicesResponse.convert(await Device.all().prefetch_related("assigned_software", "hardware")) -@router.patch( - "", - dependencies=[Security(validate_user_permissions, scopes=["device.write"])], -) -async def devices_patch(_: Request, config: DevicesPatchRequest) -> StatusResponse: - for uuid in config.devices: - updater = await get_update_manager(uuid) - if config.software is not None: - if config.software == "rollout": - await updater.update_update(UpdateModeEnum.ROLLOUT, None) - elif config.software == "latest": - await updater.update_update(UpdateModeEnum.LATEST, None) - else: - software = await Software.get_or_none(id=config.software) - await updater.update_update(UpdateModeEnum.ASSIGNED, software) - if config.pinned is not None: - await updater.update_update(UpdateModeEnum.PINNED, None) - if config.name is not None: - await updater.update_name(config.name) - if config.feed is not None: - await updater.update_feed(config.feed) - if config.force_update is not None: - await updater.update_force_update(config.force_update) - return StatusResponse(success=True) - - @router.delete( "", dependencies=[Security(validate_user_permissions, scopes=["device.delete"])], diff --git a/goosebit/api/v1/software/requests.py b/goosebit/api/v1/software/requests.py index 8ce35c27..3cd08dbd 100644 --- a/goosebit/api/v1/software/requests.py +++ b/goosebit/api/v1/software/requests.py @@ -1,5 +1,5 @@ -from pydantic import RootModel +from pydantic import BaseModel -class SoftwareDeleteRequest(RootModel[list[int]]): - pass +class SoftwareDeleteRequest(BaseModel): + software_ids: list[int] diff --git a/goosebit/api/v1/software/routes.py b/goosebit/api/v1/software/routes.py index 1b4d7983..6cfc35b9 100644 --- a/goosebit/api/v1/software/routes.py +++ b/goosebit/api/v1/software/routes.py @@ -1,9 +1,12 @@ -from fastapi import APIRouter, HTTPException, Security +import aiofiles +from fastapi import APIRouter, File, Form, HTTPException, Security, UploadFile from fastapi.requests import Request from goosebit.api.responses import StatusResponse from goosebit.auth import validate_user_permissions from goosebit.db.models import Rollout, Software +from goosebit.settings import config +from goosebit.updates import create_software_update from .requests import SoftwareDeleteRequest from .responses import SoftwareResponse @@ -23,10 +26,10 @@ async def software_get(_: Request) -> SoftwareResponse: "", dependencies=[Security(validate_user_permissions, scopes=["software.delete"])], ) -async def software_delete(_: Request, config: SoftwareDeleteRequest) -> StatusResponse: +async def software_delete(_: Request, delete_req: SoftwareDeleteRequest) -> StatusResponse: success = False - for f_id in config.root: - software = await Software.get_or_none(id=f_id) + for software_id in delete_req.software_ids: + software = await Software.get_or_none(id=software_id) if software is None: continue @@ -43,3 +46,27 @@ async def software_delete(_: Request, config: SoftwareDeleteRequest) -> StatusRe await software.delete() success = True return StatusResponse(success=success) + + +@router.post( + "", + dependencies=[Security(validate_user_permissions, scopes=["software.write"])], +) +async def post_update(_: Request, file: UploadFile = File(None), url: str = Form(None)): + if url is not None: + # remote file + software = await Software.get_or_none(uri=url) + if software is not None: + rollout_count = await Rollout.filter(software=software).count() + if rollout_count == 0: + await software.delete() + else: + raise HTTPException(409, "Software with same URL already exists and is referenced by rollout") + + await create_software_update(url, None) + else: + # local file + software_file = config.artifacts_dir.joinpath(file.filename) + + async with aiofiles.open(software_file, mode="ab") as f: + await f.write(await file.read()) diff --git a/goosebit/ui/bff/devices/requests.py b/goosebit/ui/bff/devices/requests.py new file mode 100644 index 00000000..51d53e9e --- /dev/null +++ b/goosebit/ui/bff/devices/requests.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class DevicesPatchRequest(BaseModel): + devices: list[str] + software: str | None = None + name: str | None = None + pinned: bool | None = None + feed: str | None = None + force_update: bool | None = None diff --git a/goosebit/ui/bff/devices/routes.py b/goosebit/ui/bff/devices/routes.py index dbff36b2..456f3d62 100644 --- a/goosebit/ui/bff/devices/routes.py +++ b/goosebit/ui/bff/devices/routes.py @@ -4,9 +4,13 @@ from fastapi.requests import Request from tortoise.expressions import Q +from goosebit.api.responses import StatusResponse +from goosebit.api.v1.devices import routes from goosebit.auth import validate_user_permissions -from goosebit.db.models import Device, UpdateModeEnum, UpdateStateEnum +from goosebit.db.models import Device, Software, UpdateModeEnum, UpdateStateEnum +from goosebit.updater.manager import get_update_manager +from .requests import DevicesPatchRequest from .responses import BFFDeviceResponse router = APIRouter(prefix="/devices") @@ -31,3 +35,38 @@ def search_filter(search_value): total_records = await Device.all().count() return await BFFDeviceResponse.convert(request, query, search_filter, total_records) + + +@router.patch( + "", + dependencies=[Security(validate_user_permissions, scopes=["device.write"])], +) +async def devices_patch(_: Request, config: DevicesPatchRequest) -> StatusResponse: + for uuid in config.devices: + updater = await get_update_manager(uuid) + if config.software is not None: + if config.software == "rollout": + await updater.update_update(UpdateModeEnum.ROLLOUT, None) + elif config.software == "latest": + await updater.update_update(UpdateModeEnum.LATEST, None) + else: + software = await Software.get_or_none(id=config.software) + await updater.update_update(UpdateModeEnum.ASSIGNED, software) + if config.pinned is not None: + await updater.update_update(UpdateModeEnum.PINNED, None) + if config.name is not None: + await updater.update_name(config.name) + if config.feed is not None: + await updater.update_feed(config.feed) + if config.force_update is not None: + await updater.update_force_update(config.force_update) + return StatusResponse(success=True) + + +router.add_api_route( + "", + routes.devices_delete, + methods=["DELETE"], + dependencies=[Security(validate_user_permissions, scopes=["device.delete"])], + name="bff_devices_delete", +) diff --git a/goosebit/ui/bff/download/__init__.py b/goosebit/ui/bff/download/__init__.py new file mode 100644 index 00000000..6096e388 --- /dev/null +++ b/goosebit/ui/bff/download/__init__.py @@ -0,0 +1 @@ +from .routes import router # noqa : F401 diff --git a/goosebit/ui/bff/download/routes.py b/goosebit/ui/bff/download/routes.py new file mode 100644 index 00000000..3cebcc61 --- /dev/null +++ b/goosebit/ui/bff/download/routes.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, HTTPException +from fastapi.requests import Request +from fastapi.responses import FileResponse, RedirectResponse + +from goosebit.db.models import Software + +router = APIRouter(prefix="/download", tags=["download"]) + + +@router.get("/{file_id}") +async def download_file(_: Request, file_id: int): + software = await Software.get_or_none(id=file_id) + if software is None: + raise HTTPException(404) + if software.local: + return FileResponse( + software.path, + media_type="application/octet-stream", + filename=software.path.name, + ) + else: + return RedirectResponse(url=software.uri) diff --git a/goosebit/ui/bff/rollouts/routes.py b/goosebit/ui/bff/rollouts/routes.py index a8b08474..beb95f91 100644 --- a/goosebit/ui/bff/rollouts/routes.py +++ b/goosebit/ui/bff/rollouts/routes.py @@ -2,6 +2,7 @@ from fastapi.requests import Request from tortoise.expressions import Q +from goosebit.api.v1.rollouts import routes from goosebit.auth import validate_user_permissions from goosebit.db.models import Rollout @@ -22,3 +23,30 @@ def search_filter(search_value): total_records = await Rollout.all().count() return await BFFRolloutsResponse.convert(request, query, search_filter, total_records) + + +router.add_api_route( + "", + routes.rollouts_put, + methods=["POST"], + dependencies=[Security(validate_user_permissions, scopes=["rollout.write"])], + name="bff_rollouts_post", +) + + +router.add_api_route( + "", + routes.rollouts_patch, + methods=["PATCH"], + dependencies=[Security(validate_user_permissions, scopes=["rollout.write"])], + name="bff_rollouts_patch", +) + + +router.add_api_route( + "", + routes.rollouts_delete, + methods=["DELETE"], + dependencies=[Security(validate_user_permissions, scopes=["rollout.delete"])], + name="bff_rollouts_delete", +) diff --git a/goosebit/ui/bff/routes.py b/goosebit/ui/bff/routes.py index 8825d52b..fa29e9ec 100644 --- a/goosebit/ui/bff/routes.py +++ b/goosebit/ui/bff/routes.py @@ -2,9 +2,10 @@ from fastapi import APIRouter -from . import devices, rollouts, software +from . import devices, download, rollouts, software router = APIRouter(prefix="/bff", tags=["bff"]) router.include_router(devices.router) router.include_router(software.router) router.include_router(rollouts.router) +router.include_router(download.router) diff --git a/goosebit/ui/bff/software/routes.py b/goosebit/ui/bff/software/routes.py index 9aac7292..c120cef2 100644 --- a/goosebit/ui/bff/software/routes.py +++ b/goosebit/ui/bff/software/routes.py @@ -1,12 +1,17 @@ from __future__ import annotations -from fastapi import APIRouter, Security +import aiofiles +from fastapi import APIRouter, Form, HTTPException, Security, UploadFile from fastapi.requests import Request from tortoise.expressions import Q +from goosebit.api.v1.software import routes from goosebit.auth import validate_user_permissions -from goosebit.db.models import Software -from goosebit.ui.bff.software.responses import BFFSoftwareResponse +from goosebit.db.models import Rollout, Software +from goosebit.settings import config +from goosebit.updates import create_software_update + +from .responses import BFFSoftwareResponse router = APIRouter(prefix="/software") @@ -23,3 +28,56 @@ def search_filter(search_value): total_records = await Software.all().count() return await BFFSoftwareResponse.convert(request, query, search_filter, total_records) + + +router.add_api_route( + "", + routes.software_delete, + methods=["DELETE"], + dependencies=[Security(validate_user_permissions, scopes=["software.delete"])], + name="bff_software_delete", +) + + +@router.post( + "", + dependencies=[Security(validate_user_permissions, scopes=["software.write"])], +) +async def post_update( + request: Request, + url: str = Form(default=None), + chunk: UploadFile = Form(default=None), + init: bool = Form(default=None), + done: bool = Form(default=None), + filename: str = Form(default=None), +): + if url is not None: + # remote file + software = await Software.get_or_none(uri=url) + if software is not None: + rollout_count = await Rollout.filter(software=software).count() + if rollout_count == 0: + await software.delete() + else: + raise HTTPException(409, "Software with same URL already exists and is referenced by rollout") + + await create_software_update(url, None) + else: + # local file + file = config.artifacts_dir.joinpath(filename) + config.artifacts_dir.mkdir(parents=True, exist_ok=True) + + temp_file = file.with_suffix(".tmp") + if init: + temp_file.unlink(missing_ok=True) + + contents = await chunk.read() + + async with aiofiles.open(temp_file, mode="ab") as f: + await f.write(contents) + + if done: + try: + await create_software_update(file.absolute().as_uri(), temp_file) + finally: + temp_file.unlink(missing_ok=True) diff --git a/goosebit/ui/routes.py b/goosebit/ui/routes.py index 1a652dc4..4d256fdf 100644 --- a/goosebit/ui/routes.py +++ b/goosebit/ui/routes.py @@ -1,14 +1,10 @@ -import aiofiles -from fastapi import APIRouter, Depends, Form, HTTPException, Security, UploadFile +from fastapi import APIRouter, Depends, Security from fastapi.requests import Request from fastapi.responses import RedirectResponse from fastapi.security import OAuth2PasswordBearer from goosebit.auth import redirect_if_unauthenticated, validate_user_permissions -from goosebit.db.models import Rollout, Software -from goosebit.settings import config from goosebit.ui.nav import nav -from goosebit.updates import create_software_update from . import bff from .templates import templates @@ -51,52 +47,6 @@ async def software_ui(request: Request): return templates.TemplateResponse(request, "software.html.jinja", context={"title": "Software"}) -@router.post( - "/upload/local", - dependencies=[Security(validate_user_permissions, scopes=["software.write"])], -) -async def upload_update_local( - request: Request, - chunk: UploadFile = Form(...), - init: bool = Form(...), - done: bool = Form(...), - filename: str = Form(...), -): - file = config.artifacts_dir.joinpath(filename) - config.artifacts_dir.mkdir(parents=True, exist_ok=True) - - temp_file = file.with_suffix(".tmp") - if init: - temp_file.unlink(missing_ok=True) - - contents = await chunk.read() - - async with aiofiles.open(temp_file, mode="ab") as f: - await f.write(contents) - - if done: - try: - await create_software_update(file.absolute().as_uri(), temp_file) - finally: - temp_file.unlink(missing_ok=True) - - -@router.post( - "/upload/remote", - dependencies=[Security(validate_user_permissions, scopes=["software.write"])], -) -async def upload_update_remote(request: Request, url: str = Form(...)): - software = await Software.get_or_none(uri=url) - if software is not None: - rollout_count = await Rollout.filter(software=software).count() - if rollout_count == 0: - await software.delete() - else: - raise HTTPException(409, "Software with same URL already exists and is referenced by rollout") - - await create_software_update(url, None) - - @router.get( "/rollouts", dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])], diff --git a/goosebit/ui/static/js/devices.js b/goosebit/ui/static/js/devices.js index ffc3f53e..09de81b7 100644 --- a/goosebit/ui/static/js/devices.js +++ b/goosebit/ui/static/js/devices.js @@ -255,7 +255,7 @@ async function updateDeviceConfig() { const software = document.getElementById("selected-sw").value; try { - await patch_request("/api/v1/devices", { devices, name, feed, software }); + await patch_request("/ui/bff/devices", { devices, name, feed, software }); } catch (error) { console.error("Update device config failed:", error); } @@ -265,7 +265,7 @@ async function updateDeviceConfig() { async function forceUpdateDevices(devices) { try { - await patch_request("/api/v1/devices", { devices, force_update: true }); + await patch_request("/ui/bff/devices", { devices, force_update: true }); } catch (error) { console.error("Update force update state failed:", error); } @@ -275,7 +275,7 @@ async function forceUpdateDevices(devices) { async function deleteDevices(devices) { try { - await delete_request("/api/v1/devices", { devices }); + await delete_request("/ui/bff/devices", { devices }); } catch (error) { console.error("Delete device failed:", error); } @@ -285,7 +285,7 @@ async function deleteDevices(devices) { async function pinDevices(devices) { try { - await patch_request("/api/v1/devices", { devices, pinned: true }); + await patch_request("/ui/bff/devices", { devices, pinned: true }); } catch (error) { console.error("Error:", error); } diff --git a/goosebit/ui/static/js/rollouts.js b/goosebit/ui/static/js/rollouts.js index 499abbdd..a61d3ffd 100644 --- a/goosebit/ui/static/js/rollouts.js +++ b/goosebit/ui/static/js/rollouts.js @@ -178,7 +178,7 @@ async function createRollout() { const software_id = document.getElementById("selected-sw").value; try { - await post("/api/v1/rollouts", { name, feed, software_id }); + await post_request("/ui/bff/rollouts", { name, feed, software_id }); } catch (error) { console.error("Rollout creation failed:", error); } @@ -192,7 +192,7 @@ function updateRolloutList() { async function deleteRollouts(ids) { try { - await delete_request("/api/v1/rollouts", { ids }); + await delete_request("/ui/bff/rollouts", { ids }); } catch (error) { console.error("Rollouts deletion failed:", error); } @@ -203,7 +203,7 @@ async function deleteRollouts(ids) { async function pauseRollouts(ids, paused) { try { - await patch_request("/api/v1/rollouts", { ids, paused }); + await patch_request("/ui/bff/rollouts", { ids, paused }); } catch (error) { console.error(`Rollouts ${paused ? "pausing" : "unpausing"} failed:`, error); } diff --git a/goosebit/ui/static/js/software.js b/goosebit/ui/static/js/software.js index 131bf29b..dfa7b8b1 100644 --- a/goosebit/ui/static/js/software.js +++ b/goosebit/ui/static/js/software.js @@ -42,7 +42,7 @@ async function sendFileChunks(file) { formData.append("done", "false"); } - const response = await fetch("/ui/upload/local", { + const response = await fetch("/ui/bff/software", { method: "POST", body: formData, }); @@ -86,7 +86,7 @@ async function sendFileUrl(url) { const formData = new FormData(); formData.append("url", url); - const response = await fetch("/ui/upload/remote", { + const response = await fetch("/ui/bff/software", { method: "POST", body: formData, }); @@ -264,9 +264,9 @@ function updateBtnState() { } } -async function deleteSoftware(files) { +async function deleteSoftware(software_ids) { try { - await delete_request("/api/v1/software", files); + await delete_request("/ui/bff/software", { software_ids }); updateSoftwareList(); } catch (error) { console.error("Deleting software list failed:", error); @@ -274,5 +274,5 @@ async function deleteSoftware(files) { } function downloadSoftware(file) { - window.location.href = `/api/v1/download/${file}`; + window.location.href = `/ui/bff/download/${file}`; } diff --git a/goosebit/ui/static/js/util.js b/goosebit/ui/static/js/util.js index 239e9f17..6c1a00e6 100644 --- a/goosebit/ui/static/js/util.js +++ b/goosebit/ui/static/js/util.js @@ -22,12 +22,12 @@ function secondsToRecentDate(t) { async function updateSoftwareSelection(addSpecialMode = false) { try { - const response = await fetch("/api/v1/software"); + const response = await fetch("/ui/bff/software"); if (!response.ok) { console.error("Retrieving software list failed."); return; } - const data = (await response.json()).software; + const data = (await response.json()).data; const selectElem = document.getElementById("selected-sw"); if (addSpecialMode) { @@ -55,8 +55,7 @@ async function updateSoftwareSelection(addSpecialMode = false) { } } -// TODO: rename this function -async function post(url, object) { +async function post_request(url, object) { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/tests/updater/controller/v1/test_routes.py b/tests/updater/controller/v1/test_routes.py index c02be843..7f4c585d 100644 --- a/tests/updater/controller/v1/test_routes.py +++ b/tests/updater/controller/v1/test_routes.py @@ -8,7 +8,7 @@ async def _api_device_update(async_client, device, update_attribute, update_value): response = await async_client.patch( - f"/api/v1/devices", + f"/ui/bff/devices", json={"devices": [f"{device.uuid}"], update_attribute: update_value}, ) assert response.status_code == 200