From d8b9477dc7fce8c1f0c45072b2fb1bda5f9657da Mon Sep 17 00:00:00 2001 From: Liam Keegan Date: Fri, 25 Oct 2024 08:58:54 +0200 Subject: [PATCH] Add admin buttons to delete and resubmit samples - add DELETE `admin/samples/` API endpoint - deletes the sample and all associated input files and results - add delete button to admin interface with modal confirmation dialog - resolves #39 - add POST `admin/resubmit-sample/` API endpoint - deletes any existing results for the sample, then sets its status to QUEUED - add resubmit button to admin interface with modal confirmation dialog - resolves #40 --- backend/src/predicTCR_server/app.py | 31 +++++++ backend/src/predicTCR_server/model.py | 8 +- backend/tests/helpers/flask_test_utils.py | 5 +- backend/tests/test_app.py | 28 ++++++ frontend/src/components/SamplesTable.vue | 102 ++++++++++++++++++++++ frontend/src/views/AdminView.vue | 6 +- runner/docker-compose.yml | 1 - 7 files changed, 173 insertions(+), 8 deletions(-) diff --git a/backend/src/predicTCR_server/app.py b/backend/src/predicTCR_server/app.py index 4ad30fc..047ef6b 100644 --- a/backend/src/predicTCR_server/app.py +++ b/backend/src/predicTCR_server/app.py @@ -3,6 +3,7 @@ import os import secrets import datetime +import shutil import flask from flask import Flask from flask import jsonify @@ -249,6 +250,36 @@ def admin_all_samples(): return jsonify(message="Admin account required"), 400 return jsonify(get_samples()) + @app.route("/api/admin/resubmit-sample/", methods=["POST"]) + @jwt_required() + def admin_resubmit_sample(sample_id: int): + if not current_user.is_admin: + return jsonify(message="Admin account required"), 400 + sample = db.session.get(Sample, sample_id) + if sample is None: + return jsonify(message="Sample not found"), 404 + sample.result_file_path().unlink(missing_ok=True) + sample.has_results_zip = False + sample.status = Status.QUEUED + db.session.commit() + return jsonify(message="Sample added to the queue") + + @app.route("/api/admin/samples/", methods=["DELETE"]) + @jwt_required() + def admin_delete_sample(sample_id: int): + if not current_user.is_admin: + return jsonify(message="Admin account required"), 400 + sample = db.session.get(Sample, sample_id) + if sample is None: + return jsonify(message="Sample not found"), 404 + try: + shutil.rmtree(sample.base_path()) + except Exception as e: + logger.error(e) + db.session.delete(sample) + db.session.commit() + return jsonify(message="Sample deleted") + @app.route("/api/admin/user", methods=["POST"]) @jwt_required() def admin_update_user(): diff --git a/backend/src/predicTCR_server/model.py b/backend/src/predicTCR_server/model.py index 0147aa5..612f7d0 100644 --- a/backend/src/predicTCR_server/model.py +++ b/backend/src/predicTCR_server/model.py @@ -85,18 +85,18 @@ class Sample(db.Model): status: Mapped[Status] = mapped_column(Enum(Status), nullable=False) has_results_zip: Mapped[bool] = mapped_column(Boolean, nullable=False) - def _base_path(self) -> pathlib.Path: + def base_path(self) -> pathlib.Path: data_path = flask.current_app.config["PREDICTCR_DATA_PATH"] return pathlib.Path(f"{data_path}/{self.id}") def input_h5_file_path(self) -> pathlib.Path: - return self._base_path() / "input.h5" + return self.base_path() / "input.h5" def input_csv_file_path(self) -> pathlib.Path: - return self._base_path() / "input.csv" + return self.base_path() / "input.csv" def result_file_path(self) -> pathlib.Path: - return self._base_path() / "result.zip" + return self.base_path() / "result.zip" @dataclass diff --git a/backend/tests/helpers/flask_test_utils.py b/backend/tests/helpers/flask_test_utils.py index 3a6d512..e82bd21 100644 --- a/backend/tests/helpers/flask_test_utils.py +++ b/backend/tests/helpers/flask_test_utils.py @@ -32,7 +32,7 @@ def add_test_users(app): def add_test_samples(app, data_path: pathlib.Path): with app.app_context(): - for sample_id, name in zip( + for sample_id, name, status in zip( [1, 2, 3, 4], [ "s1", @@ -40,6 +40,7 @@ def add_test_samples(app, data_path: pathlib.Path): "s3", "s4", ], + [Status.QUEUED, Status.RUNNING, Status.COMPLETED, Status.FAILED], ): ref_dir = data_path / f"{sample_id}" ref_dir.mkdir(parents=True, exist_ok=True) @@ -54,7 +55,7 @@ def add_test_samples(app, data_path: pathlib.Path): source=f"source{sample_id}", timestamp=sample_id, timestamp_results=0, - status=Status.QUEUED, + status=status, has_results_zip=False, ) db.session.add(new_sample) diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py index ac23d73..39d2676 100644 --- a/backend/tests/test_app.py +++ b/backend/tests/test_app.py @@ -286,6 +286,34 @@ def test_admin_samples_valid(client): assert len(response.json) == 4 +def test_admin_delete_samples_valid_admin_user(client): + headers = _get_auth_headers(client, "admin@abc.xy", "admin") + assert len(client.get("/api/admin/samples", headers=headers).json) == 4 + response = client.delete("/api/admin/samples/1", headers=headers) + assert response.status_code == 200 + assert len(client.get("/api/admin/samples", headers=headers).json) == 3 + response = client.delete("/api/admin/samples/1", headers=headers) + assert response.status_code == 404 + response = client.delete("/api/admin/samples/2", headers=headers) + assert response.status_code == 200 + assert len(client.get("/api/admin/samples", headers=headers).json) == 2 + + +@pytest.mark.parametrize( + "index,sample_id,status", + [(0, 4, "failed"), (1, 3, "completed"), (2, 2, "running"), (3, 1, "queued")], +) +def test_admin_resubmit_samples_valid_admin_user(client, index, sample_id, status): + headers = _get_auth_headers(client, "admin@abc.xy", "admin") + sample_before = client.get("/api/admin/samples", headers=headers).json[index] + assert sample_before["status"] == status + response = client.post(f"/api/admin/resubmit-sample/{sample_id}", headers=headers) + assert response.status_code == 200 + sample_after = client.get("/api/admin/samples", headers=headers).json[index] + assert sample_after["status"] == "queued" + assert sample_after["has_results_zip"] is False + + def test_admin_runner_token_invalid(client): # no auth header response = client.get("/api/admin/runner_token") diff --git a/frontend/src/components/SamplesTable.vue b/frontend/src/components/SamplesTable.vue index 629f0d6..50f6840 100644 --- a/frontend/src/components/SamplesTable.vue +++ b/frontend/src/components/SamplesTable.vue @@ -2,6 +2,8 @@ // @ts-ignore import { FwbA, + FwbButton, + FwbModal, FwbTable, FwbTableBody, FwbTableCell, @@ -10,16 +12,59 @@ import { FwbTableRow, } from "flowbite-vue"; import { + apiClient, download_input_csv_file, download_input_h5_file, download_result, + logout, } from "@/utils/api-client"; import type { Sample } from "@/utils/types"; +import { ref } from "vue"; defineProps<{ samples: Sample[]; admin: boolean; }>(); + +const emit = defineEmits(["samplesModified"]); + +const current_sample_id = ref(null as number | null); +const show_delete_modal = ref(false); +const show_resubmit_modal = ref(false); +function close_modals() { + show_resubmit_modal.value = false; + show_delete_modal.value = false; +} + +function resubmit_current_sample() { + close_modals(); + apiClient + .post(`admin/resubmit-sample/${current_sample_id.value}`) + .then(() => { + emit("samplesModified"); + }) + .catch((error) => { + if (error.response.status > 400) { + logout(); + } + console.log(error); + }); +} + +function delete_current_sample() { + close_modals(); + apiClient + .delete(`admin/samples/${current_sample_id.value}`) + .then(() => { + emit("samplesModified"); + }) + .catch((error) => { + if (error.response.status > 400) { + logout(); + } + console.log(error); + }); +} + + Resubmit + Delete + + + + + + + + + + + + + diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 57455e2..6d1ecab 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -71,7 +71,11 @@ get_samples(); - + diff --git a/runner/docker-compose.yml b/runner/docker-compose.yml index 1d3f060..9d0205f 100644 --- a/runner/docker-compose.yml +++ b/runner/docker-compose.yml @@ -17,4 +17,3 @@ services: networks: predictcr-network: name: predictcr - external: true