Skip to content

Commit

Permalink
Implement upload of results
Browse files Browse the repository at this point in the history
- runner creates three folders where the script runs, and uploads their contents as zipfiles:
  - `/user_results`: provided to user
  - `/trusted_user_results`: provided to user if they have "full results" enabled by an admin
  - `/admin_results`: only visible to admins
- admin results are always uploaded, included for failed runs (for debugging)
- user results are only provided if the run was successful
- add `PREDICTCR_RUNNER_SCRIPT_DIR` env var to docker compose to specify where script & model can be found
- add /admin/result endpoint to download admin results zipfile
- switch from micromamba to miniconda docker image
  - reticulate doesn't seem to support micromamba, at least within docker
- re-enable runner docker image CI
- update runner README.md
- resolves #6
- resolves #43

Also refactor admin views using tabs
  • Loading branch information
lkeegan committed Oct 28, 2024
1 parent 4838031 commit 844151e
Show file tree
Hide file tree
Showing 15 changed files with 287 additions and 144 deletions.
40 changes: 20 additions & 20 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,23 +110,23 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: docker compose build
# - uses: docker/login-action@v3
# with:
# registry: ghcr.io
# username: ${{ github.actor }}
# password: ${{ secrets.GITHUB_TOKEN }}
# if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# - run: |
# echo $PREDICTCR_DOCKER_IMAGE_TAG
# docker compose build
# docker compose push
# if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# env:
# PREDICTCR_DOCKER_IMAGE_TAG: ${{ github.sha }}
# - run: |
# echo $PREDICTCR_DOCKER_IMAGE_TAG
# docker compose build
# docker compose push
# if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# env:
# PREDICTCR_DOCKER_IMAGE_TAG: "latest"
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
- run: |
echo $PREDICTCR_DOCKER_IMAGE_TAG
docker compose build
docker compose push
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
PREDICTCR_DOCKER_IMAGE_TAG: ${{ github.sha }}
- run: |
echo $PREDICTCR_DOCKER_IMAGE_TAG
docker compose build
docker compose push
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
PREDICTCR_DOCKER_IMAGE_TAG: "latest"
47 changes: 41 additions & 6 deletions backend/src/predicTCR_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,10 @@ def result():
if not user_sample.has_results_zip:
logger.info(f" -> sample {sample_id} found but no results available")
return jsonify(message="No results available"), 400
requested_file = user_sample.result_file_path()
if current_user.full_results:
requested_file = user_sample.trusted_user_result_file_path()
else:
requested_file = user_sample.user_result_file_path()
if not requested_file.is_file():
logger.info(f" -> file {requested_file} not found")
return jsonify(message="Results file not found"), 400
Expand Down Expand Up @@ -243,6 +246,26 @@ def add_sample():
return jsonify(sample=new_sample)
return jsonify(message=error_message), 400

@app.route("/api/admin/result", methods=["POST"])
@jwt_required()
def admin_result():
if not current_user.is_admin:
return jsonify(message="Admin account required"), 400
sample_id = request.json.get("sample_id", None)
logger.info(
f"User {current_user.email} requesting admin results for sample {sample_id}"
)
user_sample = db.session.get(Sample, sample_id)
if user_sample is None:
logger.info(f" -> sample {sample_id} not found")
return jsonify(message="Sample not found"), 400
requested_file = user_sample.admin_result_file_path()
if not requested_file.is_file():
logger.info(f" -> file {requested_file} not found")
return jsonify(message="Results file not found"), 400
logger.info(f"Returning file {requested_file}")
return flask.send_file(requested_file, as_attachment=True)

@app.route("/api/admin/samples", methods=["GET"])
@jwt_required()
def admin_all_samples():
Expand All @@ -258,7 +281,9 @@ def admin_resubmit_sample(sample_id: int):
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.user_result_file_path().unlink(missing_ok=True)
sample.trusted_user_result_file_path().unlink(missing_ok=True)
sample.admin_result_file_path().unlink(missing_ok=True)
sample.has_results_zip = False
sample.status = Status.QUEUED
db.session.commit()
Expand Down Expand Up @@ -381,10 +406,14 @@ def runner_result():
logger.info(" -> missing success key")
return jsonify(message="Missing key: success=True/False"), 400
success = success.lower() == "true"
zipfile = request.files.to_dict().get("file", None)
if success is True and zipfile is None:
user_zipfile = request.files.to_dict().get("user_results", None)
trusted_user_zipfile = request.files.to_dict().get("trusted_user_results", None)
admin_zipfile = request.files.to_dict().get("admin_results", None)
if success is True and (user_zipfile is None or admin_zipfile is None):
logger.info(" -> missing zipfile")
return jsonify(message="Result has success=True but no file"), 400
return jsonify(
message="Result has success=True but a result zipfile is missing"
), 400
runner_hostname = form_as_dict.get("runner_hostname", "")
logger.info(
f"Job '{job_id}' uploaded result for '{sample_id}' from runner {current_user.email} / {runner_hostname}"
Expand All @@ -393,7 +422,13 @@ def runner_result():
if error_message != "":
logger.info(f" -> error message: {error_message}")
message, code = process_result(
int(job_id), int(sample_id), success, error_message, zipfile
int(job_id),
int(sample_id),
success,
error_message,
user_zipfile,
trusted_user_zipfile,
admin_zipfile,
)
return jsonify(message=message), code

Expand Down
44 changes: 28 additions & 16 deletions backend/src/predicTCR_server/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,14 @@ def input_h5_file_path(self) -> pathlib.Path:
def input_csv_file_path(self) -> pathlib.Path:
return self.base_path() / "input.csv"

def result_file_path(self) -> pathlib.Path:
return self.base_path() / "result.zip"
def user_result_file_path(self) -> pathlib.Path:
return self.base_path() / "user_results.zip"

def trusted_user_result_file_path(self) -> pathlib.Path:
return self.base_path() / "trusted_user_results.zip"

def admin_result_file_path(self) -> pathlib.Path:
return self.base_path() / "admin_results.zip"


@dataclass
Expand Down Expand Up @@ -191,37 +197,43 @@ def process_result(
sample_id: int,
success: bool,
error_message: str,
result_zip_file: FileStorage | None,
user_result_zip_file: FileStorage | None,
trusted_user_result_zip_file: FileStorage | None,
admin_result_zip_file: FileStorage | None,
) -> tuple[str, int]:
sample = db.session.get(Sample, sample_id)
if sample is None:
logger.warning(f" --> Unknown sample id {sample_id}")
return f"Unknown sample id {sample_id}", 400
sample.base_path().mkdir(parents=True, exist_ok=True)
job = db.session.get(Job, job_id)
if job is None:
logger.warning(f" --> Unknown job id {job_id}")
return f"Unknown job id {job_id}", 400
job.timestamp_end = timestamp_now()
if success is False:
sample.has_results_zip = False
sample.status = Status.FAILED
if success:
job.status = Status.COMPLETED
else:
job.status = Status.FAILED
job.error_message = error_message
db.session.commit()
return "Result processed", 200
db.session.commit()
if sample.has_results_zip:
logger.warning(f" --> Sample {sample_id} already has results")
job.status = Status.COMPLETED
db.session.commit()
return f"Sample {sample_id} already has results", 400
if result_zip_file is None:
logger.warning(" --> No zipfile")
return "Zip file missing", 400
sample.result_file_path().parent.mkdir(parents=True, exist_ok=True)
result_zip_file.save(sample.result_file_path())
if admin_result_zip_file is not None:
admin_result_zip_file.save(sample.admin_result_file_path())
if success is False:
sample.has_results_zip = False
sample.status = Status.FAILED
db.session.commit()
return "Result processed", 200
if user_result_zip_file is None or trusted_user_result_zip_file is None:
logger.warning(" --> Missing user result zipfile")
return "User result zip file missing", 400
user_result_zip_file.save(sample.user_result_file_path())
trusted_user_result_zip_file.save(sample.trusted_user_result_file_path())
sample.has_results_zip = True
sample.status = Status.COMPLETED
job.status = Status.COMPLETED
db.session.commit()
return "Result processed", 200

Expand Down
4 changes: 3 additions & 1 deletion backend/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,9 @@ def _upload_result(client, result_zipfile: pathlib.Path, job_id: int, sample_id:
"job_id": job_id,
"sample_id": sample_id,
"success": True,
"file": (io.BytesIO(f.read()), result_zipfile.name),
"user_results": (io.BytesIO(f.read()), result_zipfile.name),
"trusted_user_results": (io.BytesIO(f.read()), result_zipfile.name),
"admin_results": (io.BytesIO(f.read()), result_zipfile.name),
},
headers=headers,
)
Expand Down
16 changes: 14 additions & 2 deletions frontend/src/components/SamplesTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
download_input_csv_file,
download_input_h5_file,
download_result,
download_admin_result,
logout,
} from "@/utils/api-client";
import type { Sample } from "@/utils/types";
Expand Down Expand Up @@ -82,7 +83,11 @@ function delete_current_sample() {
<fwb-table-head-cell v-if="admin">Actions</fwb-table-head-cell>
</fwb-table-head>
<fwb-table-body>
<fwb-table-row v-for="sample in samples" :key="sample.id">
<fwb-table-row
v-for="sample in samples"
:key="sample.id"
:class="sample.status === 'failed' ? '!bg-red-200' : '!bg-slate-50'"
>
<fwb-table-cell v-if="admin">{{ sample["id"] }}</fwb-table-cell>
<fwb-table-cell>{{
new Date(sample["timestamp"] * 1000).toLocaleDateString("de-DE")
Expand All @@ -108,7 +113,14 @@ function delete_current_sample() {
</fwb-a>
</fwb-table-cell>
<fwb-table-cell>
<template v-if="sample.has_results_zip">
<template v-if="admin">
<fwb-a
href=""
@click.prevent="download_admin_result(sample.id, sample.name)"
>zip</fwb-a
>
</template>
<template v-else-if="sample.has_results_zip">
<fwb-a
href=""
@click.prevent="download_result(sample.id, sample.name)"
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/utils/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ export function download_result(sample_id: number, sample_name: string) {
);
}

export function download_admin_result(sample_id: number, sample_name: string) {
download_file_from_endpoint(
"admin/result",
{ sample_id: sample_id },
`${sample_name}_admin.zip`,
);
}

export function logout() {
const user = useUserStore();
user.user = null;
Expand Down
85 changes: 51 additions & 34 deletions frontend/src/views/AdminView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import UsersTable from "@/components/UsersTable.vue";
import ListComponent from "@/components/ListComponent.vue";
import JobsTable from "@/components/JobsTable.vue";
import ListItem from "@/components/ListItem.vue";
import { FwbButton } from "flowbite-vue";
import { FwbButton, FwbTab, FwbTabs } from "flowbite-vue";
import { ref } from "vue";
import type { Sample } from "@/utils/types";
import { apiClient, logout } from "@/utils/api-client";
Expand All @@ -26,6 +26,7 @@ function generate_api_token() {
});
}
const activeTab = ref("samples");
const samples = ref([] as Sample[]);
function get_samples() {
Expand All @@ -48,39 +49,55 @@ get_samples();
<template>
<main>
<div class="p-4">
<ListComponent>
<ListItem title="Settings">
<SettingsTable />
</ListItem>
<ListItem title="Generate runner API Token">
<p>
Here you can generate a new runner user with an API token for
authentication. Note the token should be kept secret! It is valid
for 6 months, then you will need to generate a new one:
</p>
<p>
<fwb-button @click="generate_api_token">
Generate API Token and copy to clipboard
</fwb-button>
</p>
</ListItem>
<ListItem title="Runners">
<UsersTable :is_runner="true"></UsersTable>
</ListItem>
<ListItem title="Users">
<UsersTable :is_runner="false"></UsersTable>
</ListItem>
<ListItem title="Samples">
<SamplesTable
:samples="samples"
:admin="true"
@samples-modified="get_samples"
></SamplesTable>
</ListItem>
<ListItem title="Runner Jobs">
<JobsTable />
</ListItem>
</ListComponent>
<fwb-tabs v-model="activeTab" variant="underline" class="p-5">
<fwb-tab name="samples" title="Samples">
<ListComponent>
<ListItem title="Samples">
<SamplesTable
:samples="samples"
:admin="true"
@samples-modified="get_samples"
></SamplesTable>
</ListItem>
</ListComponent>
</fwb-tab>
<fwb-tab name="users" title="Users">
<ListComponent>
<ListItem title="Users">
<UsersTable :is_runner="false"></UsersTable>
</ListItem>
</ListComponent>
</fwb-tab>
<fwb-tab name="runners" title="Runners">
<ListComponent>
<ListItem title="Generate runner API Token">
<p>
Here you can generate a new runner user with an API token for
authentication. Note the token should be kept secret! It is
valid for 6 months, then you will need to generate a new one:
</p>
<p>
<fwb-button @click="generate_api_token">
Generate API Token and copy to clipboard
</fwb-button>
</p>
</ListItem>
<ListItem title="Runners">
<UsersTable :is_runner="true"></UsersTable>
</ListItem>
<ListItem title="Runner Jobs">
<JobsTable />
</ListItem>
</ListComponent>
</fwb-tab>
<fwb-tab name="settings" title="Settings">
<ListComponent>
<ListItem title="Settings">
<SettingsTable />
</ListItem>
</ListComponent>
</fwb-tab>
</fwb-tabs>
</div>
</main>
</template>
Loading

0 comments on commit 844151e

Please sign in to comment.