diff --git a/optuna_dashboard/_app.py b/optuna_dashboard/_app.py index 831e6093..3caec909 100644 --- a/optuna_dashboard/_app.py +++ b/optuna_dashboard/_app.py @@ -38,6 +38,7 @@ from ._intermediate_values import has_intermediate_values from ._search_space import get_search_space from ._serializer import serialize_study_detail from ._serializer import serialize_study_summary +from . import _note as note if typing.TYPE_CHECKING: @@ -281,6 +282,25 @@ def create_app(storage: BaseStorage) -> Bottle: ], } + @app.post("/api/studies//note") + @handle_json_api_exception + def save_note(study_id: int) -> BottleViewReturn: + response.content_type = "application/json" + + system_attrs = storage.get_study_system_attrs(study_id) + req_note_ver = request.json.get("version", None) + req_note_body = request.json.get("body", None) + if req_note_ver is None or req_note_body is None: + response.status = 400 # Bad request + return {"reason": "Invalid request."} + if not note.version_is_incremented(system_attrs, req_note_ver): + response.status = 400 # Bad request + return {"reason": "The text you are editing has changed. Please copy your edits and refresh the page."} + + note.save_note(storage, study_id, req_note_ver, req_note_body) + response.status = 204 # No content + return {} + @app.get("/static/") def send_static(filename: str) -> BottleViewReturn: return static_file(filename, root=STATIC_DIR) diff --git a/optuna_dashboard/_note.py b/optuna_dashboard/_note.py new file mode 100644 index 00000000..8d7fa55b --- /dev/null +++ b/optuna_dashboard/_note.py @@ -0,0 +1,72 @@ +from typing import Dict, Any +from typing import TypedDict +import math + +from optuna.storages import BaseStorage + +SYSTEM_ATTR_MAX_LENGTH = 2045 +NOTE_VER_KEY = "dashboard:note_ver" +NOTE_STR_KEY_PREFIX = "dashboard:note_str:" + +NoteType = TypedDict( + "NoteType", + { + "version": int, + "body": str, + }, +) + + +def get_note_from_system_attrs(system_attrs: Dict[str, Any]) -> NoteType: + if NOTE_VER_KEY not in system_attrs: + return { + "version": 0, + "body": "", + } + note_ver = int(system_attrs[NOTE_VER_KEY]) + note_attrs: Dict[str, str] = { + key: value + for key, value in system_attrs.items() + if key.startswith(NOTE_STR_KEY_PREFIX) + } + return { + "version": note_ver, + "body": concat_body(note_attrs) + } + + +def version_is_incremented(system_attrs: Dict[str, Any], req_note_ver: int) -> bool: + db_note_ver = system_attrs.get(NOTE_VER_KEY, 0) + return req_note_ver == db_note_ver + 1 + + +def save_note(storage: BaseStorage, study_id: int, ver: int, body: str) -> None: + storage.set_study_system_attr(study_id, NOTE_VER_KEY, ver) + + attrs = split_body(body) + for k, v in attrs.items(): + storage.set_study_system_attr(study_id, k, v) + + # Clear previous messages + all_note_attrs: Dict[str, str] = { + key: value + for key, value in storage.get_study_system_attrs(study_id).items() + if key.startswith(NOTE_STR_KEY_PREFIX) + } + if len(all_note_attrs) > len(attrs): + for i in range(len(attrs), len(all_note_attrs)): + storage.set_study_system_attr(study_id, f"{NOTE_STR_KEY_PREFIX}{i}", "") + + +def split_body(note_str: str) -> Dict[str, str]: + note_len = len(note_str) + attrs = {} + for i in range(math.ceil(note_len / SYSTEM_ATTR_MAX_LENGTH)): + start = i * SYSTEM_ATTR_MAX_LENGTH + end = min((i + 1) * SYSTEM_ATTR_MAX_LENGTH, note_len) + attrs[f"{NOTE_STR_KEY_PREFIX}{i}"] = note_str[start:end] + return attrs + + +def concat_body(note_attrs: Dict[str, str]) -> str: + return "".join(note_attrs[f"{NOTE_STR_KEY_PREFIX}{i}"] for i in range(len(note_attrs))) diff --git a/optuna_dashboard/_serializer.py b/optuna_dashboard/_serializer.py index fc155214..d33ec75f 100644 --- a/optuna_dashboard/_serializer.py +++ b/optuna_dashboard/_serializer.py @@ -10,6 +10,8 @@ from optuna.distributions import BaseDistribution from optuna.study import StudySummary from optuna.trial import FrozenTrial +from . import _note as note + try: from typing import TypedDict @@ -111,6 +113,7 @@ def serialize_study_detail( serialized["intersection_search_space"] = serialize_search_space(intersection) serialized["union_search_space"] = serialize_search_space(union) serialized["has_intermediate_values"] = has_intermediate_values + serialized["note"] = note.get_note_from_system_attrs(summary.system_attrs) return serialized diff --git a/optuna_dashboard/static/components/Note.tsx b/optuna_dashboard/static/components/Note.tsx new file mode 100644 index 00000000..2b2a7f3a --- /dev/null +++ b/optuna_dashboard/static/components/Note.tsx @@ -0,0 +1,18 @@ +import {Card, CardContent, useTheme, TextField} from "@mui/material"; +import React, {FC} from "react"; + + +export const Note: FC<{studyId: number}> = ({studyId}) => { + const theme = useTheme() + return ( + + + + + + ) +} \ No newline at end of file diff --git a/python_tests/test_note.py b/python_tests/test_note.py new file mode 100644 index 00000000..f69de768 --- /dev/null +++ b/python_tests/test_note.py @@ -0,0 +1,19 @@ +from unittest import TestCase +from unittest.mock import patch + +from optuna_dashboard import _note as note + + +class NoteTestCase(TestCase): + @patch("optuna_dashboard._note.SYSTEM_ATTR_MAX_LENGTH", 5) + def test_split_and_concat_note_body(self) -> None: + for dummy_body_str, attr_len in [ + ("012", 1), + ("01234", 1), + ("012345", 2), + ]: + with self.subTest(f"with_{dummy_body_str}_{attr_len}"): + attrs = note.split_body(dummy_body_str) + assert len(attrs) == attr_len + actual = note.concat_body(attrs) + assert dummy_body_str == actual