diff --git a/backend/src/predicTCR_server/app.py b/backend/src/predicTCR_server/app.py index 643a96b..07df9d4 100644 --- a/backend/src/predicTCR_server/app.py +++ b/backend/src/predicTCR_server/app.py @@ -17,6 +17,7 @@ db, Sample, User, + Settings, add_new_user, add_new_runner_user, reset_user_password, @@ -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(): @@ -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(): @@ -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 diff --git a/backend/src/predicTCR_server/model.py b/backend/src/predicTCR_server/model.py index 3991792..827d44c 100644 --- a/backend/src/predicTCR_server/model.py +++ b/backend/src/predicTCR_server/model.py @@ -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, @@ -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) @@ -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" } @@ -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, @@ -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 @@ -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, diff --git a/backend/src/predicTCR_server/settings.py b/backend/src/predicTCR_server/settings.py index 93a3a30..67780ca 100644 --- a/backend/src/predicTCR_server/settings.py +++ b/backend/src/predicTCR_server/settings.py @@ -2,5 +2,3 @@ predicTCR_url = "predictcr.lkeegan.dev" -predicTCR_submission_interval_minutes = 0 -predicTCR_submission_quota = 1000 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d9a7273..c78695f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from predicTCR_server import create_app import shutil diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py index 1f10a49..dfddbfd 100644 --- a/backend/tests/test_app.py +++ b/backend/tests/test_app.py @@ -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, "admin@abc.xy", "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 diff --git a/frontend/src/components/SettingsTable.vue b/frontend/src/components/SettingsTable.vue new file mode 100644 index 0000000..94667e7 --- /dev/null +++ b/frontend/src/components/SettingsTable.vue @@ -0,0 +1,85 @@ + + + diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts index ccc797a..6dab111 100644 --- a/frontend/src/utils/types.ts +++ b/frontend/src/utils/types.ts @@ -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; +}; diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 747367f..8ae35c4 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -1,5 +1,6 @@