Startup and restore now check only the saved_views structure (table, columns, types, sizes, a JSON object state) and never the value rules, so a row saved under older rules no longer stops the database from opening. Reads normalize each view: missing keys take their defaults, unknown keys are dropped, invalid values default and the name is made displayable. POST and PATCH still validate strictly. Only request validation (SavedViewInvalid) maps to 400; database and internal errors are a logged generic 500. A saved view no longer replaces the plain Images or Library return state, a toolbar change while sources load keeps a saved Source and Folder, a state over 4 KB is refused in the browser with its own toast, the delete confirmation skips its failure toast after the page is gone, the Edit dialog reuses the shared field helper, and quick star toggles coalesce into one Favorites count read.
116 lines
4.4 KiB
Python
116 lines
4.4 KiB
Python
"""Saved views (D16): list, create, rename and delete named toolbar states.
|
|
|
|
The page, the name and the whitelisted per-page state are validated here
|
|
(through ``database/saved_views.py``) before anything is written. Only that
|
|
request validation (``SavedViewInvalid``) is a 400 carrying its message; any
|
|
other failure, including a ``ValueError`` raised by the database layer, is a
|
|
logged 500 with a generic message.
|
|
"""
|
|
|
|
from functools import wraps
|
|
from typing import Callable
|
|
|
|
from aiohttp import web
|
|
|
|
try:
|
|
from ...database.saved_views import SavedViewInvalid, encode_state, validate_name
|
|
except ImportError:
|
|
from database.saved_views import SavedViewInvalid, encode_state, validate_name
|
|
|
|
_CREATE_FIELDS = frozenset({"page", "name", "state"})
|
|
_RENAME_FIELDS = frozenset({"name"})
|
|
_MAX_ID = 2**63 - 1
|
|
|
|
|
|
class SavedViewNotFound(LookupError):
|
|
pass
|
|
|
|
|
|
def saved_view_response(handler: Callable) -> Callable:
|
|
"""``{success:false,error}`` with 400, 404 or 500 on every failure."""
|
|
|
|
@wraps(handler)
|
|
async def wrapped(self, request: web.Request) -> web.Response:
|
|
try:
|
|
return await handler(self, request)
|
|
except SavedViewNotFound:
|
|
return web.json_response(
|
|
{"success": False, "error": "Saved view not found"}, status=404
|
|
)
|
|
except SavedViewInvalid as exc:
|
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
|
except Exception:
|
|
self.logger.exception("Saved view operation failed")
|
|
return web.json_response(
|
|
{"success": False, "error": "Saved view operation failed"}, status=500
|
|
)
|
|
|
|
return wrapped
|
|
|
|
|
|
async def _body(request: web.Request, fields: frozenset) -> dict:
|
|
"""A JSON object whose keys are exactly ``fields``."""
|
|
try:
|
|
body = await request.json()
|
|
except (ValueError, UnicodeDecodeError): # JSONDecodeError is a ValueError
|
|
raise SavedViewInvalid("Expected a JSON object") from None
|
|
if not isinstance(body, dict):
|
|
raise SavedViewInvalid("Expected a JSON object")
|
|
if set(body) != fields:
|
|
raise SavedViewInvalid(
|
|
"Request must contain exactly " + ", ".join(sorted(fields))
|
|
)
|
|
return body
|
|
|
|
|
|
def _view_id(request: web.Request) -> int:
|
|
raw = request.match_info["view_id"]
|
|
if not raw.isascii() or not raw.isdigit():
|
|
raise SavedViewInvalid("Invalid saved view ID")
|
|
value = int(raw)
|
|
if not 1 <= value <= _MAX_ID:
|
|
raise SavedViewInvalid("Invalid saved view ID")
|
|
return value
|
|
|
|
|
|
class SavedViewRoutesMixin:
|
|
def _register_saved_view_routes(self, routes: web.RouteTableDef) -> None:
|
|
routes.get("/prompt_manager/saved-views")(self.list_saved_views)
|
|
routes.post("/prompt_manager/saved-views")(self.create_saved_view)
|
|
routes.patch("/prompt_manager/saved-views/{view_id}")(self.rename_saved_view)
|
|
routes.delete("/prompt_manager/saved-views/{view_id}")(self.delete_saved_view)
|
|
|
|
@saved_view_response
|
|
async def list_saved_views(self, request: web.Request) -> web.Response:
|
|
views = await self._run_in_executor(self.db.list_saved_views)
|
|
return web.json_response({"success": True, "views": views})
|
|
|
|
@saved_view_response
|
|
async def create_saved_view(self, request: web.Request) -> web.Response:
|
|
body = await _body(request, _CREATE_FIELDS)
|
|
encode_state(body["page"], body["state"])
|
|
validate_name(body["name"])
|
|
view = await self._run_in_executor(
|
|
self.db.create_saved_view, body["page"], body["name"], body["state"]
|
|
)
|
|
return web.json_response({"success": True, "view": view}, status=201)
|
|
|
|
@saved_view_response
|
|
async def rename_saved_view(self, request: web.Request) -> web.Response:
|
|
view_id = _view_id(request)
|
|
body = await _body(request, _RENAME_FIELDS)
|
|
validate_name(body["name"])
|
|
view = await self._run_in_executor(
|
|
self.db.rename_saved_view, view_id, body["name"]
|
|
)
|
|
if view is None:
|
|
raise SavedViewNotFound
|
|
return web.json_response({"success": True, "view": view})
|
|
|
|
@saved_view_response
|
|
async def delete_saved_view(self, request: web.Request) -> web.Response:
|
|
view_id = _view_id(request)
|
|
if not await self._run_in_executor(self.db.delete_saved_view, view_id):
|
|
raise SavedViewNotFound
|
|
return web.json_response({"success": True, "id": view_id})
|