Skip to content

Commit

Permalink
Add modifiable settings
Browse files Browse the repository at this point in the history
- add `Settings` model to database
  - have a single instance of this in the database
  - on startup create with default values if not already present in database
- add endpoints
  - get `/settings` returns the settings
    - used by frontend
  - post `/admin/settings` overwrites the settings with the supplied values
    - used by admin frontend
- allow admins to modify the settings in the frontend admin interface
  - lists are represented as semi-colon delimited strings
- resolves #13
- also add `global_quota` to Settings, resolves #22
  • Loading branch information
lkeegan committed Oct 21, 2024
1 parent c09d3c2 commit d6fb885
Show file tree
Hide file tree
Showing 9 changed files with 240 additions and 37 deletions.
33 changes: 33 additions & 0 deletions backend/src/predicTCR_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
db,
Sample,
User,
Settings,
add_new_user,
add_new_runner_user,
reset_user_password,
Expand Down Expand Up @@ -146,6 +147,11 @@ def change_password():
def samples():
return get_samples(current_user.email)

@app.route("/api/settings", methods=["GET"])
@jwt_required()
def get_settings():
return db.session.get(Settings, 1).as_dict()

@app.route("/api/input_h5_file", methods=["POST"])
@jwt_required()
def input_h5_file():
Expand Down Expand Up @@ -248,6 +254,21 @@ def admin_update_user():
message, code = update_user(request.json)
return jsonify(message=message), code

@app.route("/api/admin/settings", methods=["POST"])
@jwt_required()
def admin_update_settings():
if not current_user.is_admin:
return jsonify(message="Admin account required"), 400
settings = db.session.get(Settings, 1)
settings_as_dict = settings.as_dict()
for key, value in request.json.items():
if key in settings_as_dict:
setattr(settings, key, value)
else:
logger.info(f"Ignoring key {key}")
db.session.commit()
return jsonify(message="Settings updated")

@app.route("/api/admin/users", methods=["GET"])
@jwt_required()
def admin_users():
Expand Down Expand Up @@ -317,5 +338,17 @@ def runner_result():

with app.app_context():
db.create_all()
if db.session.get(Settings, 1) is None:
db.session.add(
Settings(
default_personal_submission_quota=10,
default_personal_submission_interval_mins=30,
global_quota=1000,
tumor_types="Lung;Breast;Other",
sources="TIL;PMBC;Other",
csv_required_columns="barcode;cdr3;chain",
)
)
db.session.commit()

return app
51 changes: 34 additions & 17 deletions backend/src/predicTCR_server/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,10 @@
import pathlib
from flask_sqlalchemy import SQLAlchemy
from werkzeug.datastructures import FileStorage
from sqlalchemy.inspection import inspect
from dataclasses import dataclass
from predicTCR_server.email import send_email
from predicTCR_server.settings import (
predicTCR_url,
predicTCR_submission_interval_minutes,
predicTCR_submission_quota,
)
from predicTCR_server.settings import predicTCR_url
from predicTCR_server.logger import get_logger
from predicTCR_server.utils import (
timestamp_now,
Expand All @@ -35,6 +32,26 @@ class Status(str, Enum):
FAILED = "failed"


@dataclass
class Settings(db.Model):
id: int = db.Column(db.Integer, primary_key=True)
default_personal_submission_quota: int = db.Column(db.Integer, nullable=False)
default_personal_submission_interval_mins: int = db.Column(
db.Integer, nullable=False
)
global_quota: int = db.Column(db.Integer, nullable=False)
tumor_types: str = db.Column(db.String, nullable=False)
sources: str = db.Column(db.String, nullable=False)
csv_required_columns: str = db.Column(db.String, nullable=False)

def as_dict(self):
return {
c: getattr(self, c)
for c in inspect(self).attrs.keys()
if c != "password_hash"
}


@dataclass
class Sample(db.Model):
id: int = db.Column(db.Integer, primary_key=True)
Expand Down Expand Up @@ -96,16 +113,9 @@ def check_password(self, password: str) -> bool:

def as_dict(self):
return {
"id": self.id,
"email": self.email,
"activated": self.activated,
"enabled": self.enabled,
"quota": self.quota,
"submission_interval_minutes": self.submission_interval_minutes,
"last_submission_timestamp": self.last_submission_timestamp,
"is_admin": self.is_admin,
"is_runner": self.is_runner,
"full_results": self.full_results,
c: getattr(self, c)
for c in inspect(self).attrs.keys()
if c != "password_hash"
}


Expand Down Expand Up @@ -238,8 +248,10 @@ def add_new_user(email: str, password: str, is_admin: bool) -> tuple[str, int]:
password_hash=ph.hash(password),
activated=False,
enabled=False,
quota=predicTCR_submission_quota,
submission_interval_minutes=predicTCR_submission_interval_minutes,
quota=db.session.get(Settings, 1).default_personal_submission_quota,
submission_interval_minutes=db.session.get(
Settings, 1
).default_personal_submission_interval_mins,
last_submission_timestamp=0,
is_admin=is_admin,
is_runner=False,
Expand Down Expand Up @@ -371,6 +383,9 @@ def get_user_if_allowed_to_submit(email: str) -> tuple[User | None, str]:
return None, f"Unknown email address {email}."
if user.quota <= 0:
return None, "You have reached your sample submission quota."
settings = db.session.get(Settings, 1)
if settings.global_quota <= 0:
return None, "The service has reached its sample submission quota."
mins_since_last_submission = (
timestamp_now() - user.last_submission_timestamp
) // 60
Expand Down Expand Up @@ -401,6 +416,8 @@ def add_new_sample(
return None, msg
user.last_submission_timestamp = timestamp_now()
user.quota -= 1
settings = db.session.get(Settings, 1)
settings.global_quota -= 1
new_sample = Sample(
email=email,
name=name,
Expand Down
2 changes: 0 additions & 2 deletions backend/src/predicTCR_server/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,3 @@


predicTCR_url = "predictcr.lkeegan.dev"
predicTCR_submission_interval_minutes = 0
predicTCR_submission_quota = 1000
1 change: 1 addition & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from __future__ import annotations

import pytest
from predicTCR_server import create_app
import shutil
Expand Down
33 changes: 33 additions & 0 deletions backend/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,39 @@ def test_samples_valid(client):
assert len(response.json) == 4


def test_get_settings_valid(client):
headers = _get_auth_headers(client)
response = client.get("/api/settings", headers=headers)
assert response.status_code == 200
assert response.json == {
"csv_required_columns": "barcode;cdr3;chain",
"default_personal_submission_interval_mins": 30,
"default_personal_submission_quota": 10,
"global_quota": 1000,
"id": 1,
"sources": "TIL;PMBC;Other",
"tumor_types": "Lung;Breast;Other",
}


def test_update_settings_valid(client):
headers = _get_auth_headers(client, "[email protected]", "admin")
new_settings = {
"csv_required_columns": "BB;CC;QQ",
"default_personal_submission_interval_mins": 60,
"default_personal_submission_quota": 7,
"global_quota": 999,
"id": 1,
"sources": "a;b;g",
"tumor_types": "1;2;6",
"invalid-key": "invalid",
}
response = client.post("/api/admin/settings", headers=headers, json=new_settings)
assert response.status_code == 200
new_settings.pop("invalid-key")
assert client.get("/api/settings", headers=headers).json == new_settings


@pytest.mark.parametrize("input_file_type", ["h5", "csv"])
def test_input_file_invalid(client, input_file_type: str):
# no auth header
Expand Down
85 changes: 85 additions & 0 deletions frontend/src/components/SettingsTable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { FwbButton, FwbInput, FwbRange } from "flowbite-vue";
import { ref } from "vue";
import { apiClient, logout } from "@/utils/api-client";
import type { Settings } from "@/utils/types";
const settings = ref(null as Settings | null);
function get_settings() {
apiClient
.get("/settings")
.then((response) => {
settings.value = response.data;
})
.catch((error) => {
if (error.response.status > 400) {
logout();
}
console.log(error);
});
}
get_settings();
function update_settings() {
apiClient
.post("admin/settings", settings.value)
.then(() => {
get_settings();
})
.catch((error) => {
if (error.response.status > 400) {
logout();
}
console.log(error);
});
}
</script>

<template>
<div class="flex flex-col m-2 p-2" v-if="settings">
<fwb-range
v-model="settings.default_personal_submission_quota"
:steps="1"
:min="0"
:max="99"
:label="`Default personal quota: ${settings.default_personal_submission_quota}`"
class="mb-2"
/>
<fwb-range
v-model="settings.default_personal_submission_interval_mins"
:steps="1"
:min="0"
:max="60"
:label="`Default interval between submissions: ${settings.default_personal_submission_interval_mins} minutes`"
class="mb-2"
/>
<fwb-range
v-model="settings.global_quota"
:steps="1"
:min="0"
:max="9999"
:label="`Remaining global quota: ${settings.global_quota}`"
class="mb-2"
/>
<fwb-input
v-model="settings.tumor_types"
class="mb-2"
label="Tumor types (separated by ;)"
></fwb-input>
<fwb-input
v-model="settings.sources"
class="mb-2"
label="Sources (separated by ;)"
></fwb-input>
<fwb-input
v-model="settings.csv_required_columns"
class="mb-2"
label="Required columns in CSV file (separated by ;)"
></fwb-input>
<fwb-button @click="update_settings" color="green">
Save settings</fwb-button
>
</div>
</template>
10 changes: 10 additions & 0 deletions frontend/src/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,13 @@ export type User = {
full_results: boolean;
submission_interval_minutes: number;
};

export type Settings = {
id: number;
default_personal_submission_quota: number;
default_personal_submission_interval_mins: number;
global_quota: number;
tumor_types: string;
sources: string;
csv_required_columns: string;
};
8 changes: 6 additions & 2 deletions frontend/src/views/AdminView.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import SamplesTable from "@/components/SamplesTable.vue";
import SettingsTable from "@/components/SettingsTable.vue";
import UsersTable from "@/components/UsersTable.vue";
import ListComponent from "@/components/ListComponent.vue";
import ListItem from "@/components/ListItem.vue";
Expand Down Expand Up @@ -46,8 +47,11 @@ get_samples();
<template>
<main>
<div class="p-4">
<ListComponent title="Generate runner API Token">
<ListItem>
<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
Expand Down
Loading

0 comments on commit d6fb885

Please sign in to comment.