mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-23 06:49:13 +00:00
refactor(web): one error-response path for api_v3 (#624)
* refactor(web): answer unhandled api_v3 errors from one blueprint handler
Fifty-three api_v3 routes ended in a copy of the same catch-all: log the
traceback, return {status, "An error occurred; see logs for details",
details: describe_exception(e)} with a 500. They are replaced by one
errorhandler on the api_v3 blueprint that returns exactly that body.
It lives on the blueprint rather than falling through to app.py's global
handler because the two answers differ: the global one adds
error_code: UNKNOWN_ERROR, and api_client.js sends a body with an
error_code to the error modal and one without to a plain toast. A
blueprint handler also gives tests that mount api_v3 on a bare Flask app
the same answer the real app gives.
Only handlers that were byte-for-byte that shape were removed (matched on
the AST, and each rewritten function re-parsed and compared). Handlers
with their own message, extra keys, operation-history records or cleanup
stay, as does execute_plugin_action's step-1 handler, which sits inside
an `except subprocess.TimeoutExpired` arm that would otherwise turn a
plugin's timeout into a 408.
HTTPExceptions raised inside a route go back as themselves in the global
handler's 4xx shape. Where a removed catch-all used to swallow one (only
delete_plugin_asset's non-silent get_json() is reachable), a malformed
request now gets its 415/400 instead of a 500.
Most of the diff is re-indentation from unwrapping the try blocks;
`git diff -w` shows the real change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): plugin action errors name the real failure, not UnboundLocalError
execute_plugin_action bound a local `logger` in its JSON-parsing arm,
which made `logger` local to the whole function. Every other
`logger.error` in it then raised UnboundLocalError, so a failing OAuth
step-1 script was reported as "UnboundLocalError: cannot access local
variable 'logger'" -- from the step-1 handler, and before the previous
commit from the route's outer catch-all too. Use the module logger.
Found by comparing every api_v3 route's forced-failure response before
and after the catch-all consolidation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(web): drop the error category and exception-name code guessing
WebInterfaceError derived an ErrorCategory from every error code and put
it in each structured error body as `error_category`. Nothing reads it:
not the web UI (static/ and templates/), not the tests beyond the ones
pinning the mapping itself, and not any plugin in ledmatrix-plugins. The
enum, the inference table and the JSON key go.
from_exception() could also guess an error code from the exception's
class name ("Config" -> CONFIG_LOAD_FAILED, and so on). Every caller
passes a code, so the guess never ran; error_code is now required.
suggested_fixes stays: the error dialog in static/v3/js/utils/
error_handler.js lists them.
The REST reference loses error_category and says what an unanticipated
exception in an /api/v3 route answers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(web): one call for the from_exception error responses
Nine plugin routes built a structured error by hand:
from src.web_interface.errors import WebInterfaceError
error = WebInterfaceError.from_exception(e, ErrorCode.X)
return error_response(error.error_code, error.message,
details=error.details, context=error.context,
status_code=500)
That is now exception_error_response(e, ErrorCode.X) in api_helpers, so
error_response() is the only structured-error entry point the routes
use. The three operation-history routes never passed the context, and
with_context=False keeps their bodies exactly as they were; a test
compares the helper against the hand-written pair for both forms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): one api_v3 error-response path
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,14 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
`a/b` or `..` resolve to nothing everywhere. Installs where each directory is
|
||||
named for its manifest id, the installer's layout, behave as before.
|
||||
|
||||
- `/api/v3` routes answer an exception they don't handle themselves from one
|
||||
blueprint error handler, with the same `{status, message, details}` body the
|
||||
53 removed per-route catch-alls returned. `ErrorCategory` and the
|
||||
`error_category` key are removed from `src.web_interface.errors` (nothing read
|
||||
them); `exception_error_response()` replaces the `from_exception` +
|
||||
`error_response` pairs. A failing plugin action script's error now names the
|
||||
real failure instead of `UnboundLocalError`.
|
||||
|
||||
- `FontManager.get_font()` returns a BDF font at its native size when asked for
|
||||
a size the file doesn't contain (5x7.bdf at 8 or 10px, say). It used to
|
||||
return PIL's default font, a different typeface, so a plugin that relied on
|
||||
|
||||
@@ -2111,13 +2111,18 @@ Errors use one of two shapes. Most endpoints answer:
|
||||
}
|
||||
```
|
||||
|
||||
Endpoints built on the structured error helper add a code and category:
|
||||
An exception no route anticipated gets this shape too, with a 500, the
|
||||
message `An error occurred; see logs for details`, and `details` naming the
|
||||
exception type and text (credentials redacted). The api_v3 blueprint's
|
||||
error handler produces it, so it is the same for every `/api/v3` route.
|
||||
|
||||
Endpoints built on the structured error helper add a code, and usually
|
||||
suggested fixes (the web UI's error dialog lists them):
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"error_code": "CONFIG_SAVE_FAILED",
|
||||
"error_category": "configuration",
|
||||
"message": "Error description",
|
||||
"details": "optional",
|
||||
"context": { },
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any, Optional, Dict, Tuple
|
||||
from flask import jsonify, request
|
||||
|
||||
from src.web_interface.error_handler import create_error_response, create_success_response
|
||||
from src.web_interface.errors import ErrorCode
|
||||
from src.web_interface.errors import ErrorCode, WebInterfaceError
|
||||
|
||||
|
||||
def success_response(
|
||||
@@ -75,6 +75,40 @@ def error_response(
|
||||
)
|
||||
|
||||
|
||||
def exception_error_response(
|
||||
exc: Exception,
|
||||
error_code: ErrorCode,
|
||||
*,
|
||||
with_context: bool = True,
|
||||
status_code: int = 500
|
||||
):
|
||||
"""
|
||||
error_response() for a caught exception, built by WebInterfaceError.
|
||||
|
||||
The message is the code's fixed, user-facing one -- never the exception
|
||||
text. `details` comes from the exception's own `context` dict when it has
|
||||
one, and `context` records the exception type. with_context=False leaves
|
||||
the context out, as the operation-history routes always have.
|
||||
|
||||
Args:
|
||||
exc: The exception being reported
|
||||
error_code: Error code
|
||||
with_context: Whether to include the context (exception type)
|
||||
status_code: HTTP status code
|
||||
|
||||
Returns:
|
||||
Flask jsonify response with status code
|
||||
"""
|
||||
error = WebInterfaceError.from_exception(exc, error_code)
|
||||
return error_response(
|
||||
error.error_code,
|
||||
error.message,
|
||||
details=error.details,
|
||||
context=error.context if with_context else None,
|
||||
status_code=status_code
|
||||
)
|
||||
|
||||
|
||||
def validate_request_json(required_fields: list, data: Optional[Dict] = None) -> Tuple[Optional[Dict], Optional[Any]]:
|
||||
"""
|
||||
Validate request JSON has required fields.
|
||||
|
||||
@@ -7,9 +7,7 @@ Provides helpers for consistent error responses across API endpoints.
|
||||
from typing import Any, Optional
|
||||
from flask import jsonify
|
||||
|
||||
from src.web_interface.errors import (
|
||||
WebInterfaceError, ErrorCode, ErrorCategory
|
||||
)
|
||||
from src.web_interface.errors import WebInterfaceError, ErrorCode
|
||||
from src.logging_config import get_logger
|
||||
from src.redaction import redact_credentials
|
||||
|
||||
@@ -72,6 +70,39 @@ def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
||||
return text
|
||||
|
||||
|
||||
# What a failure nothing anticipated says. The detail beside it carries the
|
||||
# actual diagnosis; this sentence only points at where the traceback went.
|
||||
UNHANDLED_ERROR_MESSAGE = 'An error occurred; see logs for details'
|
||||
|
||||
|
||||
def unhandled_exception_payload(exc: BaseException) -> dict:
|
||||
"""JSON body for an exception no route handled: status, message, details.
|
||||
|
||||
Deliberately no `error_code`. The plugin API client (api_client.js) passes
|
||||
a body that has one straight to the rich error modal, and wraps one that
|
||||
has none as a plain API_ERROR toast; the api_v3 routes answered this shape
|
||||
from their own catch-alls for years, so the UI is built around it.
|
||||
"""
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': UNHANDLED_ERROR_MESSAGE,
|
||||
'details': describe_exception(exc),
|
||||
}
|
||||
|
||||
|
||||
def http_exception_payload(error) -> dict:
|
||||
"""JSON body for a werkzeug HTTPException (405, 400, 415, 413...).
|
||||
|
||||
Same shape web_interface/app.py's global handler returns, so a 4xx raised
|
||||
inside an api_v3 route reads the same as one raised anywhere else.
|
||||
"""
|
||||
return {
|
||||
'status': 'error',
|
||||
'error_code': (error.name or 'HTTP_ERROR').upper().replace(' ', '_'),
|
||||
'message': error.description,
|
||||
}
|
||||
|
||||
|
||||
def create_error_response(
|
||||
error_code: ErrorCode,
|
||||
message: str,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Structured error handling for web interface.
|
||||
|
||||
Provides error codes, categories, and consistent error response formatting.
|
||||
Provides error codes and consistent error response formatting.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
@@ -9,17 +9,6 @@ from typing import Dict, Any, Optional, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class ErrorCategory(Enum):
|
||||
"""Error categories for classification."""
|
||||
CONFIGURATION = "configuration"
|
||||
PLUGIN = "plugin"
|
||||
VALIDATION = "validation"
|
||||
NETWORK = "network"
|
||||
PERMISSION = "permission"
|
||||
SYSTEM = "system"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class ErrorCode(Enum):
|
||||
"""Error codes for specific error types."""
|
||||
# Configuration errors
|
||||
@@ -63,12 +52,11 @@ class WebInterfaceError:
|
||||
"""
|
||||
Structured error for web interface responses.
|
||||
|
||||
Provides consistent error format with error codes, categories,
|
||||
messages, and context.
|
||||
Provides consistent error format with error codes, messages, and
|
||||
context.
|
||||
"""
|
||||
error_code: ErrorCode
|
||||
message: str
|
||||
category: ErrorCategory
|
||||
details: Optional[str] = None
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
suggested_fixes: Optional[List[str]] = None
|
||||
@@ -78,7 +66,6 @@ class WebInterfaceError:
|
||||
self,
|
||||
error_code: ErrorCode,
|
||||
message: str,
|
||||
category: Optional[ErrorCategory] = None,
|
||||
details: Optional[str] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
suggested_fixes: Optional[List[str]] = None,
|
||||
@@ -86,7 +73,6 @@ class WebInterfaceError:
|
||||
):
|
||||
self.error_code = error_code
|
||||
self.message = message
|
||||
self.category = category or self._infer_category(error_code)
|
||||
self.details = details
|
||||
self.context = context or {}
|
||||
# `is None`, not truthiness: an explicit [] means "this caller has
|
||||
@@ -96,25 +82,6 @@ class WebInterfaceError:
|
||||
else self._get_default_suggestions(error_code))
|
||||
self.original_error = original_error
|
||||
|
||||
def _infer_category(self, error_code: ErrorCode) -> ErrorCategory:
|
||||
"""Infer error category from error code."""
|
||||
code_str = error_code.value
|
||||
|
||||
if code_str.startswith("CONFIG_"):
|
||||
return ErrorCategory.CONFIGURATION
|
||||
elif code_str.startswith("PLUGIN_"):
|
||||
return ErrorCategory.PLUGIN
|
||||
elif code_str.startswith("VALIDATION_") or code_str.startswith("SCHEMA_") or code_str == "INVALID_INPUT":
|
||||
return ErrorCategory.VALIDATION
|
||||
elif code_str.startswith("NETWORK_") or code_str == "API_ERROR" or code_str == "TIMEOUT":
|
||||
return ErrorCategory.NETWORK
|
||||
elif code_str.startswith("PERMISSION_") or code_str == "FILE_PERMISSION_ERROR":
|
||||
return ErrorCategory.PERMISSION
|
||||
elif code_str.startswith("SYSTEM_") or code_str == "SERVICE_UNAVAILABLE":
|
||||
return ErrorCategory.SYSTEM
|
||||
else:
|
||||
return ErrorCategory.UNKNOWN
|
||||
|
||||
def _get_default_suggestions(self, error_code: ErrorCode) -> List[str]:
|
||||
"""Get default suggested fixes for error code."""
|
||||
suggestions_map = {
|
||||
@@ -178,7 +145,6 @@ class WebInterfaceError:
|
||||
result = {
|
||||
"status": "error",
|
||||
"error_code": self.error_code.value,
|
||||
"error_category": self.category.value,
|
||||
"message": self.message,
|
||||
}
|
||||
|
||||
@@ -197,7 +163,7 @@ class WebInterfaceError:
|
||||
def from_exception(
|
||||
cls,
|
||||
exception: Exception,
|
||||
error_code: Optional[ErrorCode] = None,
|
||||
error_code: ErrorCode,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
) -> 'WebInterfaceError':
|
||||
"""
|
||||
@@ -205,13 +171,9 @@ class WebInterfaceError:
|
||||
|
||||
Args:
|
||||
exception: Exception to convert
|
||||
error_code: Optional specific error code
|
||||
error_code: The error code to report
|
||||
context: Optional additional context
|
||||
"""
|
||||
# Infer error code from exception type if not provided
|
||||
if not error_code:
|
||||
error_code = cls._infer_error_code(exception)
|
||||
|
||||
# Build context
|
||||
error_context = context or {}
|
||||
error_context['exception_type'] = type(exception).__name__
|
||||
@@ -252,26 +214,6 @@ class WebInterfaceError:
|
||||
}
|
||||
return messages.get(error_code, "An unexpected error occurred")
|
||||
|
||||
@classmethod
|
||||
def _infer_error_code(cls, exception: Exception) -> ErrorCode:
|
||||
"""Infer error code from exception type."""
|
||||
exception_name = type(exception).__name__
|
||||
|
||||
if "Config" in exception_name:
|
||||
return ErrorCode.CONFIG_LOAD_FAILED
|
||||
elif "Plugin" in exception_name:
|
||||
return ErrorCode.PLUGIN_LOAD_FAILED
|
||||
elif "Permission" in exception_name or "Access" in exception_name:
|
||||
return ErrorCode.PERMISSION_DENIED
|
||||
elif "Validation" in exception_name or "Schema" in exception_name:
|
||||
return ErrorCode.VALIDATION_ERROR
|
||||
elif "Network" in exception_name or "Connection" in exception_name:
|
||||
return ErrorCode.NETWORK_ERROR
|
||||
elif "Timeout" in exception_name:
|
||||
return ErrorCode.TIMEOUT
|
||||
else:
|
||||
return ErrorCode.UNKNOWN_ERROR
|
||||
|
||||
@classmethod
|
||||
def _get_exception_details(cls, exception: Exception) -> Optional[str]:
|
||||
"""Get additional details from exception."""
|
||||
|
||||
@@ -230,6 +230,52 @@ class TestHandlersCarryDetail:
|
||||
"handlers returning the generic message without %s: %r"
|
||||
% ("both a traceback log and the detail", offenders))
|
||||
|
||||
def test_no_api_v3_route_copies_the_blueprint_handler(self):
|
||||
"""The generic catch-all lives once, on the blueprint.
|
||||
|
||||
Fifty-three routes carried their own copy of it -- log with exc_info,
|
||||
return {status, "An error occurred; see logs for details",
|
||||
describe_exception(e)}, 500 -- until they were folded into
|
||||
`_api_v3_unhandled_exception`. A new copy changes nothing a caller
|
||||
sees, so nothing else would notice it; this does. A handler that says
|
||||
something *different* (its own message, extra keys, cleanup) is fine.
|
||||
"""
|
||||
import ast
|
||||
import pathlib
|
||||
|
||||
generic = "An error occurred; see logs for details"
|
||||
copies = []
|
||||
for path in sorted(pathlib.Path("web_interface/blueprints/api_v3").glob("*.py")):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for fn in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]:
|
||||
for h in ast.walk(fn):
|
||||
if not (isinstance(h, ast.ExceptHandler)
|
||||
and isinstance(h.type, ast.Name)
|
||||
and h.type.id == "Exception"):
|
||||
continue
|
||||
for r in [n for n in h.body if isinstance(n, ast.Return)]:
|
||||
v = r.value
|
||||
if not (isinstance(v, ast.Tuple) and len(v.elts) == 2
|
||||
and isinstance(v.elts[0], ast.Call)
|
||||
and getattr(v.elts[0].func, "id", None) == "jsonify"
|
||||
and v.elts[0].args
|
||||
and isinstance(v.elts[0].args[0], ast.Dict)):
|
||||
continue
|
||||
d = v.elts[0].args[0]
|
||||
keys = {k.value for k in d.keys if isinstance(k, ast.Constant)}
|
||||
message = [val.value for k, val in zip(d.keys, d.values)
|
||||
if isinstance(k, ast.Constant) and k.value == "message"
|
||||
and isinstance(val, ast.Constant)]
|
||||
if keys == {"status", "message", "details"} and message == [generic]:
|
||||
copies.append((path.name, fn.name))
|
||||
|
||||
# One is not a copy: execute_plugin_action's step-1 handler sits
|
||||
# inside the route's `except subprocess.TimeoutExpired` arm, which
|
||||
# would turn a plugin's own timeout into a 408 if this let it through.
|
||||
assert copies == [("plugins.py", "execute_plugin_action")], (
|
||||
"these handlers duplicate the api_v3 blueprint's errorhandler; "
|
||||
"delete them and let the exception propagate: %r" % copies)
|
||||
|
||||
def test_client_errors_keep_their_own_status(self):
|
||||
"""A 405 must not be reported as a server-side UNKNOWN_ERROR.
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""An exception no api_v3 route catches is answered once, by the blueprint.
|
||||
|
||||
Fifty-three routes used to end in a copy of the same catch-all:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(..., exc_info=True)
|
||||
return jsonify({'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)}), 500
|
||||
|
||||
They were removed in favour of one errorhandler on the api_v3 blueprint. These
|
||||
tests pin that the answer did not change: the same status, exactly the same
|
||||
keys and values, credentials still redacted, and the traceback still logged.
|
||||
|
||||
The exact-equality matters. web_interface/app.py's global handler answers with
|
||||
an extra `error_code: UNKNOWN_ERROR`, and the plugin API client routes a body
|
||||
that carries an error_code to a different UI path (api_client.js) -- so
|
||||
"falls through to the global handler" would not have been the same answer.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import UnsupportedMediaType
|
||||
|
||||
from src.web_interface.error_handler import describe_exception
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
|
||||
# A credential in three of the forms describe_exception redacts.
|
||||
FORCED = RuntimeError(
|
||||
"forced failure token=SECRET123 at https://u:pw1@example.com/x?api_key=K1")
|
||||
|
||||
# What every removed catch-all returned, written out rather than imported so
|
||||
# a change to the shared payload cannot also change the expectation.
|
||||
EXPECTED = {
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(FORCED),
|
||||
}
|
||||
|
||||
MANAGERS = ("config_manager", "plugin_manager", "plugin_store_manager",
|
||||
"saved_repositories_manager", "schema_manager", "operation_queue",
|
||||
"plugin_state_manager", "operation_history", "cache_manager")
|
||||
|
||||
|
||||
class Boom:
|
||||
"""A manager that fails on any use -- attribute, truthiness, call."""
|
||||
|
||||
def _raise(self, *args, **kwargs):
|
||||
raise FORCED
|
||||
|
||||
__getattr__ = _raise
|
||||
__bool__ = _raise
|
||||
__call__ = _raise
|
||||
__iter__ = _raise
|
||||
__len__ = _raise
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def exploding_managers(monkeypatch):
|
||||
for name in MANAGERS:
|
||||
monkeypatch.setattr(api_v3, name, Boom(), raising=False)
|
||||
# The WiFi routes build their own manager rather than using one above.
|
||||
import src.wifi_manager
|
||||
monkeypatch.setattr(src.wifi_manager, "WiFiManager", Boom())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(exploding_managers):
|
||||
"""The blueprint alone, on an app with no error handlers of its own."""
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
return app.test_client()
|
||||
|
||||
|
||||
# A sample of routes whose catch-all was removed, across every module that
|
||||
# lost one. Each reaches a manager (or WiFiManager) inside what used to be
|
||||
# the try block.
|
||||
REMOVED_CATCH_ALLS = [
|
||||
("GET", "/api/v3/config/main", None),
|
||||
("GET", "/api/v3/config/secrets", None),
|
||||
("GET", "/api/v3/display/modes", None),
|
||||
("POST", "/api/v3/display/on-demand/stop", {}),
|
||||
("GET", "/api/v3/cache/list", None),
|
||||
("GET", "/api/v3/plugins/installed", None),
|
||||
("GET", "/api/v3/plugins/health", None),
|
||||
("GET", "/api/v3/plugins/metrics/some-plugin", None),
|
||||
("GET", "/api/v3/plugins/schema?plugin_id=some-plugin", None),
|
||||
("GET", "/api/v3/plugins/store/list", None),
|
||||
("GET", "/api/v3/plugins/saved-repositories", None),
|
||||
("POST", "/api/v3/plugins/install", {"plugin_id": "some-plugin"}),
|
||||
("POST", "/api/v3/plugins/config/reset?plugin_id=some-plugin", {}),
|
||||
("GET", "/api/v3/plugins/limits/some-plugin", None),
|
||||
("GET", "/api/v3/wifi/status", None),
|
||||
("POST", "/api/v3/wifi/disconnect", {}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method,url,body", REMOVED_CATCH_ALLS,
|
||||
ids=[f"{m} {u}" for m, u, _ in REMOVED_CATCH_ALLS])
|
||||
def test_the_answer_is_what_the_catch_all_returned(client, caplog, method, url, body):
|
||||
with caplog.at_level(logging.ERROR, logger="web_interface.blueprints.api_v3"):
|
||||
resp = client.open(url, method=method, json=body)
|
||||
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json() == EXPECTED
|
||||
|
||||
# The promise in the message: the traceback is in the log.
|
||||
records = [r for r in caplog.records
|
||||
if r.name == "web_interface.blueprints.api_v3"
|
||||
and r.levelno >= logging.ERROR and r.exc_info]
|
||||
assert records, "the unhandled exception was not logged with its traceback"
|
||||
assert records[-1].exc_info[1] is FORCED
|
||||
|
||||
|
||||
def test_credentials_are_redacted_from_the_detail(client):
|
||||
body = client.get("/api/v3/plugins/installed").get_json()
|
||||
for secret in ("SECRET123", "pw1", "K1"):
|
||||
assert secret not in body["details"]
|
||||
assert "<redacted>" in body["details"]
|
||||
assert body["details"].startswith("RuntimeError: forced failure")
|
||||
|
||||
|
||||
def test_a_client_error_keeps_its_own_status(client):
|
||||
"""HTTPExceptions subclass Exception; a 415 must not become a 500."""
|
||||
resp = client.post("/api/v3/plugins/assets/delete", data="not json",
|
||||
content_type="text/plain")
|
||||
assert resp.status_code == 415
|
||||
body = resp.get_json()
|
||||
assert body == {
|
||||
'status': 'error',
|
||||
'error_code': 'UNSUPPORTED_MEDIA_TYPE',
|
||||
'message': body['message'],
|
||||
}
|
||||
assert "Content-Type" in body['message']
|
||||
|
||||
|
||||
class TestInTheRealApp:
|
||||
"""Mounted in web_interface/app.py, beside its global handlers."""
|
||||
|
||||
@pytest.fixture
|
||||
def web_app(self):
|
||||
import web_interface.app as web_app
|
||||
return web_app
|
||||
|
||||
def test_the_blueprint_handler_answers_not_the_global_one(
|
||||
self, web_app, exploding_managers):
|
||||
resp = web_app.app.test_client().get("/api/v3/plugins/installed")
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json() == EXPECTED
|
||||
|
||||
def test_client_errors_read_the_same_as_the_global_handler(
|
||||
self, web_app, exploding_managers):
|
||||
"""The blueprint's 4xx shape must not drift from app.py's."""
|
||||
resp = web_app.app.test_client().post(
|
||||
"/api/v3/plugins/assets/delete", data="not json",
|
||||
content_type="text/plain")
|
||||
with web_app.app.test_request_context():
|
||||
global_resp, global_status = web_app.handle_exception(
|
||||
UnsupportedMediaType(description=resp.get_json()['message']))
|
||||
assert resp.status_code == global_status == 415
|
||||
assert resp.get_json() == global_resp.get_json()
|
||||
|
||||
def test_global_handler_shape(self, web_app):
|
||||
"""Everything outside api_v3 still gets app.py's answer."""
|
||||
with web_app.app.test_request_context("/somewhere"):
|
||||
resp, status = web_app.handle_exception(FORCED)
|
||||
body = resp.get_json()
|
||||
assert status == 500
|
||||
assert body == {
|
||||
'status': 'error',
|
||||
'error_code': 'UNKNOWN_ERROR',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(FORCED),
|
||||
}
|
||||
assert "SECRET123" not in body["details"]
|
||||
|
||||
|
||||
class TestPluginActionStep1:
|
||||
"""execute_plugin_action's OAuth step-1 handler reports the script's error.
|
||||
|
||||
The route bound a local `logger` in its JSON-parsing arm, which made
|
||||
`logger` local to the whole function; every other `logger.error` in it
|
||||
then raised UnboundLocalError. The step-1 handler therefore answered
|
||||
"UnboundLocalError: cannot access local variable 'logger'" instead of
|
||||
whatever the plugin's auth script actually raised.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_dir(self, tmp_path):
|
||||
import json
|
||||
d = tmp_path / "demo-plugin"
|
||||
d.mkdir()
|
||||
(d / "manifest.json").write_text(json.dumps({
|
||||
"id": "demo-plugin",
|
||||
"web_ui_actions": [{"id": "auth", "type": "script",
|
||||
"script": "auth.py", "oauth_flow": True}],
|
||||
}), encoding="utf-8")
|
||||
(d / "auth.py").write_text(
|
||||
"def get_auth_url():\n"
|
||||
" raise RuntimeError('the auth script failed')\n",
|
||||
encoding="utf-8")
|
||||
return d
|
||||
|
||||
def test_the_script_error_reaches_the_response(self, plugin_dir, monkeypatch):
|
||||
from unittest.mock import MagicMock
|
||||
manager = MagicMock()
|
||||
manager.get_plugin_directory.return_value = str(plugin_dir)
|
||||
monkeypatch.setattr(api_v3, "plugin_manager", manager, raising=False)
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
|
||||
resp = app.test_client().post(
|
||||
"/api/v3/plugins/action",
|
||||
json={"plugin_id": "demo-plugin", "action_id": "auth"})
|
||||
|
||||
assert resp.status_code == 500
|
||||
body = resp.get_json()
|
||||
assert body["details"] == "RuntimeError: the auth script failed"
|
||||
assert body["message"] == 'An error occurred; see logs for details'
|
||||
@@ -15,7 +15,7 @@ every api_v3 endpoint actually calls.
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from src.web_interface.api_helpers import success_response
|
||||
from src.web_interface.api_helpers import exception_error_response, success_response
|
||||
from src.web_interface.error_handler import (
|
||||
create_error_response,
|
||||
create_success_response,
|
||||
@@ -64,6 +64,59 @@ class TestCreateErrorResponse:
|
||||
assert response.get_json()["suggested_fixes"] == ["Try again"]
|
||||
|
||||
|
||||
class TestExceptionErrorResponse:
|
||||
"""The one-call form of from_exception() + error_response().
|
||||
|
||||
Nine plugin routes spelled the pair out by hand; these pin that the helper
|
||||
answers exactly what that spelling did, so folding them changed nothing a
|
||||
client sees.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _by_hand(exc, code, with_context):
|
||||
from src.web_interface.api_helpers import error_response
|
||||
error = WebInterfaceError.from_exception(exc, code)
|
||||
if with_context:
|
||||
return error_response(error.error_code, error.message,
|
||||
details=error.details, context=error.context,
|
||||
status_code=500)
|
||||
return error_response(error.error_code, error.message,
|
||||
details=error.details, status_code=500)
|
||||
|
||||
@pytest.mark.parametrize("with_context", [True, False])
|
||||
@pytest.mark.parametrize("code", [ErrorCode.SYSTEM_ERROR,
|
||||
ErrorCode.CONFIG_SAVE_FAILED,
|
||||
ErrorCode.PLUGIN_UPDATE_FAILED])
|
||||
def test_same_answer_as_the_hand_written_pair(self, app, code, with_context):
|
||||
exc = ValueError("token=SECRET boom")
|
||||
exc.context = {"config_path": "/etc/x.json"}
|
||||
with app.test_request_context():
|
||||
got, got_status = exception_error_response(
|
||||
exc, code, with_context=with_context)
|
||||
want, want_status = self._by_hand(exc, code, with_context)
|
||||
assert got_status == want_status == 500
|
||||
assert got.get_json() == want.get_json()
|
||||
|
||||
def test_shape(self, app):
|
||||
with app.test_request_context():
|
||||
response, status = exception_error_response(
|
||||
RuntimeError("token=SECRET"), ErrorCode.SYSTEM_ERROR)
|
||||
assert status == 500
|
||||
assert response.get_json() == {
|
||||
"status": "error",
|
||||
"error_code": "SYSTEM_ERROR",
|
||||
"message": "A system error occurred",
|
||||
"context": {"exception_type": "RuntimeError"},
|
||||
"suggested_fixes": ["Review error details and try again"],
|
||||
}
|
||||
|
||||
def test_without_context(self, app):
|
||||
with app.test_request_context():
|
||||
response, _ = exception_error_response(
|
||||
RuntimeError("x"), ErrorCode.SYSTEM_ERROR, with_context=False)
|
||||
assert "context" not in response.get_json()
|
||||
|
||||
|
||||
class TestCreateSuccessResponse:
|
||||
def test_bare_success(self):
|
||||
assert create_success_response() == {"status": "success"}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Tests for src/web_interface/errors.py — the structured error type behind
|
||||
every API error response (category inference, default suggestions, the
|
||||
JSON shape, and exception conversion).
|
||||
every API error response (default suggestions, the JSON shape, and
|
||||
exception conversion).
|
||||
|
||||
Pure logic; no Flask context needed.
|
||||
|
||||
@@ -11,39 +11,7 @@ caller passing [] to mean "no suggestions" silently got the default list.
|
||||
|
||||
import pytest
|
||||
|
||||
from src.web_interface.errors import ErrorCategory, ErrorCode, WebInterfaceError
|
||||
|
||||
|
||||
class TestCategoryInference:
|
||||
@pytest.mark.parametrize("code,expected", [
|
||||
(ErrorCode.CONFIG_SAVE_FAILED, ErrorCategory.CONFIGURATION),
|
||||
(ErrorCode.CONFIG_ROLLBACK_FAILED, ErrorCategory.CONFIGURATION),
|
||||
(ErrorCode.PLUGIN_NOT_FOUND, ErrorCategory.PLUGIN),
|
||||
(ErrorCode.PLUGIN_OPERATION_CONFLICT, ErrorCategory.PLUGIN),
|
||||
(ErrorCode.VALIDATION_ERROR, ErrorCategory.VALIDATION),
|
||||
(ErrorCode.SCHEMA_VALIDATION_FAILED, ErrorCategory.VALIDATION),
|
||||
(ErrorCode.INVALID_INPUT, ErrorCategory.VALIDATION),
|
||||
(ErrorCode.NETWORK_ERROR, ErrorCategory.NETWORK),
|
||||
(ErrorCode.API_ERROR, ErrorCategory.NETWORK),
|
||||
(ErrorCode.TIMEOUT, ErrorCategory.NETWORK),
|
||||
(ErrorCode.PERMISSION_DENIED, ErrorCategory.PERMISSION),
|
||||
(ErrorCode.FILE_PERMISSION_ERROR, ErrorCategory.PERMISSION),
|
||||
(ErrorCode.SYSTEM_ERROR, ErrorCategory.SYSTEM),
|
||||
(ErrorCode.SERVICE_UNAVAILABLE, ErrorCategory.SYSTEM),
|
||||
(ErrorCode.UNKNOWN_ERROR, ErrorCategory.UNKNOWN),
|
||||
])
|
||||
def test_every_code_prefix_maps_to_its_category(self, code, expected):
|
||||
assert WebInterfaceError(code, "msg").category is expected
|
||||
|
||||
def test_explicit_category_overrides_inference(self):
|
||||
error = WebInterfaceError(
|
||||
ErrorCode.CONFIG_SAVE_FAILED, "msg", category=ErrorCategory.SYSTEM)
|
||||
assert error.category is ErrorCategory.SYSTEM
|
||||
|
||||
def test_every_error_code_gets_a_category(self):
|
||||
# No code may fall through uncategorized as the enum grows.
|
||||
for code in ErrorCode:
|
||||
assert isinstance(WebInterfaceError(code, "msg").category, ErrorCategory)
|
||||
from src.web_interface.errors import ErrorCode, WebInterfaceError
|
||||
|
||||
|
||||
class TestDefaultSuggestions:
|
||||
@@ -79,8 +47,9 @@ class TestToDict:
|
||||
result = WebInterfaceError(ErrorCode.SYSTEM_ERROR, "boom").to_dict()
|
||||
assert result["status"] == "error"
|
||||
assert result["error_code"] == "SYSTEM_ERROR"
|
||||
assert result["error_category"] == "system"
|
||||
assert result["message"] == "boom"
|
||||
# No error_category: nothing in the UI, tests or plugins ever read it.
|
||||
assert set(result) == {"status", "error_code", "message", "suggested_fixes"}
|
||||
|
||||
def test_details_included_when_set(self):
|
||||
result = WebInterfaceError(
|
||||
@@ -116,23 +85,7 @@ class TestToDict:
|
||||
|
||||
|
||||
class TestFromException:
|
||||
@pytest.mark.parametrize("exc_name,expected", [
|
||||
("ConfigError", ErrorCode.CONFIG_LOAD_FAILED),
|
||||
("PluginError", ErrorCode.PLUGIN_LOAD_FAILED),
|
||||
("PermissionError", ErrorCode.PERMISSION_DENIED),
|
||||
("AccessDenied", ErrorCode.PERMISSION_DENIED),
|
||||
("ValidationError", ErrorCode.VALIDATION_ERROR),
|
||||
("SchemaError", ErrorCode.VALIDATION_ERROR),
|
||||
("NetworkError", ErrorCode.NETWORK_ERROR),
|
||||
("ConnectionError", ErrorCode.NETWORK_ERROR),
|
||||
("TimeoutError", ErrorCode.TIMEOUT),
|
||||
("SomethingElse", ErrorCode.UNKNOWN_ERROR),
|
||||
])
|
||||
def test_code_inferred_from_exception_class_name(self, exc_name, expected):
|
||||
exc = type(exc_name, (Exception,), {})("boom")
|
||||
assert WebInterfaceError.from_exception(exc).error_code is expected
|
||||
|
||||
def test_explicit_code_skips_inference(self):
|
||||
def test_the_given_code_is_reported(self):
|
||||
error = WebInterfaceError.from_exception(
|
||||
ValueError("boom"), error_code=ErrorCode.PLUGIN_NOT_FOUND)
|
||||
assert error.error_code is ErrorCode.PLUGIN_NOT_FOUND
|
||||
@@ -140,28 +93,28 @@ class TestFromException:
|
||||
def test_message_is_the_safe_one_not_the_exception_text(self):
|
||||
# The raw exception text is not echoed into `message`; that field is
|
||||
# a fixed, user-facing string per code.
|
||||
error = WebInterfaceError.from_exception(ValueError("secret-ish detail"))
|
||||
error = WebInterfaceError.from_exception(ValueError("secret-ish detail"), ErrorCode.UNKNOWN_ERROR)
|
||||
assert error.message == "An unexpected error occurred"
|
||||
assert "secret-ish" not in error.message
|
||||
|
||||
def test_exception_type_recorded_in_context(self):
|
||||
error = WebInterfaceError.from_exception(ValueError("boom"))
|
||||
error = WebInterfaceError.from_exception(ValueError("boom"), ErrorCode.UNKNOWN_ERROR)
|
||||
assert error.context["exception_type"] == "ValueError"
|
||||
|
||||
def test_caller_context_is_preserved_alongside_type(self):
|
||||
error = WebInterfaceError.from_exception(
|
||||
ValueError("boom"), context={"plugin_id": "clock"})
|
||||
ValueError("boom"), ErrorCode.UNKNOWN_ERROR, context={"plugin_id": "clock"})
|
||||
assert error.context["plugin_id"] == "clock"
|
||||
assert error.context["exception_type"] == "ValueError"
|
||||
|
||||
def test_caller_supplied_exception_type_is_overwritten(self):
|
||||
error = WebInterfaceError.from_exception(
|
||||
ValueError("boom"), context={"exception_type": "Fake"})
|
||||
ValueError("boom"), ErrorCode.UNKNOWN_ERROR, context={"exception_type": "Fake"})
|
||||
assert error.context["exception_type"] == "ValueError"
|
||||
|
||||
def test_original_error_retained(self):
|
||||
exc = ValueError("boom")
|
||||
assert WebInterfaceError.from_exception(exc).original_error is exc
|
||||
assert WebInterfaceError.from_exception(exc, ErrorCode.UNKNOWN_ERROR).original_error is exc
|
||||
|
||||
def test_every_code_has_a_safe_message(self):
|
||||
for code in ErrorCode:
|
||||
@@ -205,4 +158,4 @@ class TestExceptionDetails:
|
||||
def test_details_flow_into_from_exception(self):
|
||||
exc = ValueError("boom")
|
||||
exc.context = {"config_path": "/etc/x.json"}
|
||||
assert "config_path" in WebInterfaceError.from_exception(exc).details
|
||||
assert "config_path" in WebInterfaceError.from_exception(exc, ErrorCode.UNKNOWN_ERROR).details
|
||||
|
||||
@@ -35,13 +35,16 @@ from typing import Dict, Any, Optional, Tuple, Type
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
logger = logging.getLogger(__name__)
|
||||
# Import new infrastructure
|
||||
from src.web_interface.api_helpers import success_response, error_response, validate_request_json
|
||||
from src.web_interface.api_helpers import (success_response, error_response,
|
||||
exception_error_response, validate_request_json)
|
||||
from src.web_interface.errors import ErrorCode
|
||||
from src.web_interface.secret_helpers import (find_secret_fields, mask_all_secret_values,
|
||||
merge_secrets, remove_empty_secrets,
|
||||
separate_secrets,
|
||||
strip_masked_values)
|
||||
from src.web_interface.error_handler import describe_exception, redact_text
|
||||
from src.web_interface.error_handler import (describe_exception, http_exception_payload,
|
||||
redact_text, unhandled_exception_payload)
|
||||
from werkzeug.exceptions import HTTPException
|
||||
from src.plugin_system.operation_types import OperationType
|
||||
from src.web_interface.validators import (
|
||||
validate_file_upload
|
||||
@@ -114,6 +117,43 @@ SYSTEM_FONTS = frozenset([
|
||||
'clr6x12', 'helvr12', 'texgyre-27'
|
||||
])
|
||||
api_v3 = Blueprint('api_v3', __name__)
|
||||
|
||||
|
||||
@api_v3.errorhandler(Exception)
|
||||
def _api_v3_unhandled_exception(error):
|
||||
"""The answer for any exception an api_v3 route does not handle itself.
|
||||
|
||||
Fifty-odd routes used to end in the same four lines -- log the traceback,
|
||||
return {status, message: "An error occurred; see logs for details",
|
||||
details: describe_exception(e)} with a 500. This is those four lines, once.
|
||||
A route still catches for itself when its failure needs something else: a
|
||||
specific message, extra keys, an operation-history record, or cleanup.
|
||||
|
||||
It is registered on the blueprint, not left to web_interface/app.py's
|
||||
global handler, because the two answers differ: the global one adds
|
||||
`error_code: UNKNOWN_ERROR`, and the plugin API client treats a body with
|
||||
an error_code differently from one without (see api_client.js). Tests that
|
||||
mount this blueprint on a bare Flask app get the same answer as the real
|
||||
app does, too.
|
||||
|
||||
`details` is describe_exception(), which redacts credentials and caps the
|
||||
length. CodeQL reads returning it as stack-trace exposure; it is the
|
||||
project's deliberate trade-off, because a device whose storage is failing
|
||||
otherwise answers "see logs for details" from the log viewer too
|
||||
(test_web_error_detail.py).
|
||||
|
||||
Werkzeug's HTTPExceptions subclass Exception, so a 400/405/413/415 raised
|
||||
inside a route lands here as well; it goes back as itself, in the global
|
||||
handler's shape. A 404 or explicit 500 never arrives: Flask prefers the
|
||||
app's code-specific handlers over a blueprint's class-based one.
|
||||
"""
|
||||
if isinstance(error, HTTPException):
|
||||
return jsonify(http_exception_payload(error)), error.code or 500
|
||||
logger.error("Unhandled exception in %s", request.endpoint or request.path,
|
||||
exc_info=error)
|
||||
return jsonify(unhandled_exception_payload(error)), 500
|
||||
|
||||
|
||||
def _get_plugin_version(plugin_id: str) -> str:
|
||||
"""Read the installed version from a plugin's manifest.json.
|
||||
|
||||
|
||||
@@ -47,15 +47,11 @@ def _day_setting(data, day, flat_key, nested_key):
|
||||
@api_v3.route('/config/main', methods=['GET'])
|
||||
def get_main_config():
|
||||
"""Get main configuration, with credentials redacted."""
|
||||
try:
|
||||
if not api_v3.config_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500
|
||||
|
||||
config = api_v3.config_manager.load_config()
|
||||
return jsonify({'status': 'success', 'data': _redact_credentials(config)})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/config/schedule', methods=['GET'])
|
||||
def get_schedule_config():
|
||||
"""Get current schedule configuration"""
|
||||
@@ -1165,7 +1161,6 @@ def save_main_config():
|
||||
@api_v3.route('/config/secrets', methods=['GET'])
|
||||
def get_secrets_config():
|
||||
"""Get secrets configuration"""
|
||||
try:
|
||||
if not api_v3.config_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500
|
||||
|
||||
@@ -1176,9 +1171,6 @@ def get_secrets_config():
|
||||
# alone so a client can still tell "set" from "not set".
|
||||
return jsonify({'status': 'success',
|
||||
'data': mask_all_secret_values(config)})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/config/raw/main', methods=['POST'])
|
||||
def save_raw_main_config():
|
||||
"""Save raw main configuration JSON"""
|
||||
|
||||
@@ -6,7 +6,7 @@ endpoint names are unchanged by living here.
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
_ensure_display_service_running,
|
||||
_get_display_service_status, _stop_display_service, api_v3,
|
||||
describe_exception, jsonify, logger, os, request, uuid,
|
||||
jsonify, logger, os, request, uuid,
|
||||
)
|
||||
import web_interface.blueprints.api_v3 as _pkg
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
@@ -31,7 +31,6 @@ def _cache_manager():
|
||||
@api_v3.route('/display/current', methods=['GET'])
|
||||
def get_display_current():
|
||||
"""Get current display state"""
|
||||
try:
|
||||
import base64
|
||||
from PIL import Image
|
||||
import io
|
||||
@@ -67,9 +66,6 @@ def get_display_current():
|
||||
'image': image_data # Base64 encoded image data or None if unavailable
|
||||
}
|
||||
return jsonify({'status': 'success', 'data': display_data})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/display/modes', methods=['GET'])
|
||||
def get_display_modes():
|
||||
"""Every display mode that can be requested on-demand, with its plugin.
|
||||
@@ -92,7 +88,6 @@ def get_display_modes():
|
||||
for the duration -- so they are reported with enabled: false
|
||||
rather than omitted.
|
||||
"""
|
||||
try:
|
||||
if not api_v3.plugin_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
||||
|
||||
@@ -137,19 +132,9 @@ def get_display_modes():
|
||||
})
|
||||
|
||||
return jsonify({'status': 'success', 'data': {'modes': modes}})
|
||||
except Exception as exc:
|
||||
# describe_exception, not a bare message: test_web_error_detail.py
|
||||
# enforces that every handler here returns it, because a device whose
|
||||
# storage is failing otherwise answers "see logs for details" from the
|
||||
# log viewer too. It redacts credentials out of the exception text.
|
||||
# CodeQL flags this as stack-trace exposure across all ~75 handlers;
|
||||
# it is the project's deliberate, reviewed trade-off.
|
||||
logger.error('Error in get_display_modes', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||
@api_v3.route('/display/on-demand/status', methods=['GET'])
|
||||
def get_on_demand_status():
|
||||
"""Return the current on-demand display state."""
|
||||
try:
|
||||
cache = _cache_manager()
|
||||
# memory_ttl=0: the display service writes this key, so only the file
|
||||
# is current. This process's memory tier would keep serving the first
|
||||
@@ -170,13 +155,9 @@ def get_on_demand_status():
|
||||
'service': service_status
|
||||
}
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error('Error in get_on_demand_status', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||
@api_v3.route('/display/on-demand/start', methods=['POST'])
|
||||
def start_on_demand_display():
|
||||
"""Request the display controller to run a specific plugin on-demand."""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
plugin_id = data.get('plugin_id')
|
||||
mode = data.get('mode')
|
||||
@@ -278,13 +259,9 @@ def start_on_demand_display():
|
||||
'service': service_result
|
||||
}
|
||||
return jsonify({'status': 'success', 'data': response_data})
|
||||
except Exception as exc:
|
||||
logger.error('Error in start_on_demand_display', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||
@api_v3.route('/display/on-demand/stop', methods=['POST'])
|
||||
def stop_on_demand_display():
|
||||
"""Request the display controller to stop on-demand mode."""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
stop_service = data.get('stop_service', False)
|
||||
|
||||
@@ -313,9 +290,6 @@ def stop_on_demand_display():
|
||||
'service': service_result
|
||||
}
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error('Error in stop_on_demand_display', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||
@api_v3.route('/display/current-status', methods=['GET'])
|
||||
def get_current_display_status():
|
||||
"""Return the display mode/plugin currently intended to be shown.
|
||||
@@ -325,7 +299,6 @@ def get_current_display_status():
|
||||
System Logs page) can show what's on screen without querying the display
|
||||
process directly.
|
||||
"""
|
||||
try:
|
||||
cache = _cache_manager()
|
||||
# memory_ttl=0: written by the display service; see get_on_demand_status.
|
||||
state = cache.get('display_current_state', max_age=120, memory_ttl=0)
|
||||
@@ -336,6 +309,3 @@ def get_current_display_status():
|
||||
'last_updated': None,
|
||||
}
|
||||
return jsonify({'status': 'success', 'data': state})
|
||||
except Exception as e:
|
||||
logger.error('Error in get_current_display_status', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@@ -4,7 +4,7 @@ Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||
endpoint names are unchanged by living here.
|
||||
"""
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
PROJECT_ROOT, Path, Response, SYSTEM_FONTS, api_v3, describe_exception,
|
||||
PROJECT_ROOT, Path, Response, SYSTEM_FONTS, api_v3,
|
||||
jsonify, logger, os, re, request, validate_file_upload,
|
||||
)
|
||||
|
||||
@@ -52,7 +52,6 @@ def _catalog_response(catalog):
|
||||
@api_v3.route('/fonts/catalog', methods=['GET'])
|
||||
def get_fonts_catalog():
|
||||
"""Get fonts catalog"""
|
||||
try:
|
||||
# Check cache first (5 minute TTL)
|
||||
try:
|
||||
from web_interface.cache import get_cached, set_cached
|
||||
@@ -157,15 +156,9 @@ def get_fonts_catalog():
|
||||
logger.error("[FontCatalog] Failed to cache fonts_catalog", exc_info=True)
|
||||
|
||||
return _catalog_response(catalog)
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/fonts/tokens', methods=['GET'])
|
||||
def get_font_tokens():
|
||||
"""Get font size tokens"""
|
||||
try:
|
||||
# This would integrate with the actual font system
|
||||
# For now, return sample tokens
|
||||
tokens = {
|
||||
@@ -177,13 +170,9 @@ def get_font_tokens():
|
||||
'xxl': 16
|
||||
}
|
||||
return jsonify({'status': 'success', 'data': {'tokens': tokens}})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/fonts/upload', methods=['POST'])
|
||||
def upload_font():
|
||||
"""Upload font file"""
|
||||
try:
|
||||
if 'font_file' not in request.files:
|
||||
return jsonify({'status': 'error', 'message': 'No font file provided'}), 400
|
||||
|
||||
@@ -256,13 +245,9 @@ def upload_font():
|
||||
'filename': safe_filename,
|
||||
'path': f'assets/fonts/{safe_filename}'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/fonts/preview', methods=['GET'])
|
||||
def get_font_preview() -> tuple[Response, int] | Response:
|
||||
"""Generate a preview image of text rendered with a specific font"""
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import io
|
||||
import base64
|
||||
@@ -399,13 +384,9 @@ def get_font_preview() -> tuple[Response, int] | Response:
|
||||
'height': img_height
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/fonts/<font_family>', methods=['DELETE'])
|
||||
def delete_font(font_family: str) -> tuple[Response, int] | Response:
|
||||
"""Delete a user-uploaded font file"""
|
||||
try:
|
||||
# Security: Validate font_family to prevent path traversal
|
||||
# Reject if it contains path separators or ..
|
||||
if '..' in font_family or '/' in font_family or '\\' in font_family:
|
||||
@@ -485,6 +466,3 @@ def delete_font(font_family: str) -> tuple[Response, int] | Response:
|
||||
'status': 'success',
|
||||
'message': f'Font {deleted_filename} deleted successfully'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@@ -186,13 +186,6 @@ def get_logs():
|
||||
'status': 'error',
|
||||
'message': 'Timeout while fetching logs'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)
|
||||
}), 500
|
||||
# Multi-Display Sync Endpoints
|
||||
@api_v3.route('/sync/status', methods=['GET'])
|
||||
def get_sync_status():
|
||||
@@ -231,7 +224,6 @@ def get_sync_status():
|
||||
@api_v3.route('/cache/list', methods=['GET'])
|
||||
def list_cache_files():
|
||||
"""List all cache files with metadata"""
|
||||
try:
|
||||
if not api_v3.cache_manager:
|
||||
# Initialize cache manager if not already initialized
|
||||
from src.cache_manager import CacheManager
|
||||
@@ -248,13 +240,9 @@ def list_cache_files():
|
||||
'total_files': len(cache_files)
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in list_cache_files', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/cache/delete', methods=['POST'])
|
||||
def delete_cache_file():
|
||||
"""Delete a specific cache file by key"""
|
||||
try:
|
||||
if not api_v3.cache_manager:
|
||||
# Initialize cache manager if not already initialized
|
||||
from src.cache_manager import CacheManager
|
||||
@@ -279,9 +267,6 @@ def delete_cache_file():
|
||||
'status': 'success',
|
||||
'message': f'Cache file for key "{cache_key}" deleted successfully'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in delete_cache_file', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
def _errors_cache():
|
||||
"""The shared cache the display service publishes its errors to."""
|
||||
if not api_v3.cache_manager:
|
||||
|
||||
@@ -14,6 +14,7 @@ from web_interface.blueprints.api_v3 import (
|
||||
_set_missing_booleans_to_false,
|
||||
_set_nested_value, _starlark_virtual_plugins, _toggle_starlark_app,
|
||||
api_v3, datetime, deep_merge, describe_exception, error_response,
|
||||
exception_error_response,
|
||||
find_secret_fields, hashlib, json, jsonify, logger, logging,
|
||||
merge_secrets, os, redact_text, remove_empty_secrets, request,
|
||||
separate_secrets, shutil, stat, subprocess, success_response,
|
||||
@@ -32,7 +33,6 @@ import web_interface.blueprints.api_v3 as _pkg
|
||||
@api_v3.route('/plugins/installed', methods=['GET'])
|
||||
def get_installed_plugins():
|
||||
"""Get installed plugins"""
|
||||
try:
|
||||
if not api_v3.plugin_manager or not api_v3.plugin_store_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin managers not initialized'}), 500
|
||||
|
||||
@@ -174,13 +174,9 @@ def get_installed_plugins():
|
||||
plugins.extend(_starlark_virtual_plugins())
|
||||
|
||||
return jsonify({'status': 'success', 'data': {'plugins': plugins}})
|
||||
except Exception as e:
|
||||
logger.error('Error in get_installed_plugins', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/health', methods=['GET'])
|
||||
def get_plugin_health():
|
||||
"""Get health metrics for all plugins"""
|
||||
try:
|
||||
if not api_v3.plugin_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
||||
|
||||
@@ -214,13 +210,9 @@ def get_plugin_health():
|
||||
'status': 'success',
|
||||
'data': health_summaries
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in get_plugin_health', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/health/<plugin_id>', methods=['GET'])
|
||||
def get_plugin_health_single(plugin_id):
|
||||
"""Get health metrics for a specific plugin"""
|
||||
try:
|
||||
if not api_v3.plugin_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
||||
|
||||
@@ -238,13 +230,9 @@ def get_plugin_health_single(plugin_id):
|
||||
'status': 'success',
|
||||
'data': health_summary
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in get_plugin_health_single', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/health/<plugin_id>/reset', methods=['POST'])
|
||||
def reset_plugin_health(plugin_id):
|
||||
"""Reset health state for a plugin (manual recovery)"""
|
||||
try:
|
||||
if not api_v3.plugin_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
||||
|
||||
@@ -262,13 +250,9 @@ def reset_plugin_health(plugin_id):
|
||||
'status': 'success',
|
||||
'message': f'Health state reset for plugin {plugin_id}'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in reset_plugin_health', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/metrics', methods=['GET'])
|
||||
def get_plugin_metrics():
|
||||
"""Get resource metrics for all plugins"""
|
||||
try:
|
||||
if not api_v3.plugin_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
||||
|
||||
@@ -301,13 +285,9 @@ def get_plugin_metrics():
|
||||
'status': 'success',
|
||||
'data': metrics_summaries
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in get_plugin_metrics', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/metrics/<plugin_id>', methods=['GET'])
|
||||
def get_plugin_metrics_single(plugin_id):
|
||||
"""Get resource metrics for a specific plugin"""
|
||||
try:
|
||||
if not api_v3.plugin_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
||||
|
||||
@@ -325,13 +305,9 @@ def get_plugin_metrics_single(plugin_id):
|
||||
'status': 'success',
|
||||
'data': metrics_summary
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in get_plugin_metrics_single', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/metrics/<plugin_id>/reset', methods=['POST'])
|
||||
def reset_plugin_metrics(plugin_id):
|
||||
"""Reset metrics for a plugin"""
|
||||
try:
|
||||
if not api_v3.plugin_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
||||
|
||||
@@ -349,13 +325,9 @@ def reset_plugin_metrics(plugin_id):
|
||||
'status': 'success',
|
||||
'message': f'Metrics reset for plugin {plugin_id}'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in reset_plugin_metrics', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/limits/<plugin_id>', methods=['GET', 'POST'])
|
||||
def manage_plugin_limits(plugin_id):
|
||||
"""Get or set resource limits for a plugin"""
|
||||
try:
|
||||
if not api_v3.plugin_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
||||
|
||||
@@ -403,9 +375,6 @@ def manage_plugin_limits(plugin_id):
|
||||
'status': 'success',
|
||||
'message': f'Resource limits updated for plugin {plugin_id}'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in manage_plugin_limits', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/toggle', methods=['POST'])
|
||||
def toggle_plugin():
|
||||
"""Toggle plugin enabled/disabled"""
|
||||
@@ -546,14 +515,7 @@ def get_operation_status(operation_id):
|
||||
|
||||
return success_response(data=operation.to_dict())
|
||||
except Exception as e:
|
||||
from src.web_interface.errors import WebInterfaceError
|
||||
error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR)
|
||||
return error_response(
|
||||
error.error_code,
|
||||
error.message,
|
||||
details=error.details,
|
||||
status_code=500
|
||||
)
|
||||
return exception_error_response(e, ErrorCode.SYSTEM_ERROR, with_context=False)
|
||||
@api_v3.route('/plugins/operation/history', methods=['GET'])
|
||||
def get_operation_history() -> Response:
|
||||
"""Get operation history from the audit log."""
|
||||
@@ -578,9 +540,7 @@ def get_operation_history() -> Response:
|
||||
operation_type=operation_type
|
||||
)
|
||||
except (AttributeError, RuntimeError) as e:
|
||||
from src.web_interface.errors import WebInterfaceError
|
||||
error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR)
|
||||
return error_response(error.error_code, error.message, details=error.details, status_code=500)
|
||||
return exception_error_response(e, ErrorCode.SYSTEM_ERROR, with_context=False)
|
||||
|
||||
return success_response(data=[record.to_dict() for record in history])
|
||||
@api_v3.route('/plugins/operation/history', methods=['DELETE'])
|
||||
@@ -596,9 +556,7 @@ def clear_operation_history() -> Response:
|
||||
try:
|
||||
api_v3.operation_history.clear_history()
|
||||
except (OSError, RuntimeError) as e:
|
||||
from src.web_interface.errors import WebInterfaceError
|
||||
error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR)
|
||||
return error_response(error.error_code, error.message, details=error.details, status_code=500)
|
||||
return exception_error_response(e, ErrorCode.SYSTEM_ERROR, with_context=False)
|
||||
|
||||
return success_response(message='Operation history cleared')
|
||||
@api_v3.route('/plugins/state', methods=['GET'])
|
||||
@@ -633,15 +591,7 @@ def get_plugin_state():
|
||||
for plugin_id, state in all_states.items()
|
||||
})
|
||||
except Exception as e:
|
||||
from src.web_interface.errors import WebInterfaceError
|
||||
error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR)
|
||||
return error_response(
|
||||
error.error_code,
|
||||
error.message,
|
||||
details=error.details,
|
||||
context=error.context,
|
||||
status_code=500
|
||||
)
|
||||
return exception_error_response(e, ErrorCode.SYSTEM_ERROR)
|
||||
@api_v3.route('/plugins/state/reconcile', methods=['POST'])
|
||||
def reconcile_plugin_state():
|
||||
"""Reconcile plugin state across all sources"""
|
||||
@@ -705,15 +655,7 @@ def reconcile_plugin_state():
|
||||
message=result.message
|
||||
)
|
||||
except Exception as e:
|
||||
from src.web_interface.errors import WebInterfaceError
|
||||
error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR)
|
||||
return error_response(
|
||||
error.error_code,
|
||||
error.message,
|
||||
details=error.details,
|
||||
context=error.context,
|
||||
status_code=500
|
||||
)
|
||||
return exception_error_response(e, ErrorCode.SYSTEM_ERROR)
|
||||
def _drop_stale_reconciliation_findings(unresolved):
|
||||
"""Re-check a stored reconciliation verdict against current state.
|
||||
|
||||
@@ -915,15 +857,7 @@ def get_plugin_config():
|
||||
|
||||
return success_response(data=plugin_config)
|
||||
except Exception as e:
|
||||
from src.web_interface.errors import WebInterfaceError
|
||||
error = WebInterfaceError.from_exception(e, ErrorCode.CONFIG_LOAD_FAILED)
|
||||
return error_response(
|
||||
error.error_code,
|
||||
error.message,
|
||||
details=error.details,
|
||||
context=error.context,
|
||||
status_code=500
|
||||
)
|
||||
return exception_error_response(e, ErrorCode.CONFIG_LOAD_FAILED)
|
||||
@api_v3.route('/plugins/update', methods=['POST'])
|
||||
def update_plugin():
|
||||
"""Update plugin"""
|
||||
@@ -1168,8 +1102,6 @@ def update_plugin():
|
||||
except Exception as e:
|
||||
logger.error("Unhandled exception in update endpoint", exc_info=True)
|
||||
|
||||
from src.web_interface.errors import WebInterfaceError
|
||||
error = WebInterfaceError.from_exception(e, ErrorCode.PLUGIN_UPDATE_FAILED)
|
||||
if api_v3.operation_history:
|
||||
api_v3.operation_history.record_operation(
|
||||
"update",
|
||||
@@ -1177,13 +1109,7 @@ def update_plugin():
|
||||
status="failed",
|
||||
error=str(e)
|
||||
)
|
||||
return error_response(
|
||||
error.error_code,
|
||||
error.message,
|
||||
details=error.details,
|
||||
context=error.context,
|
||||
status_code=500
|
||||
)
|
||||
return exception_error_response(e, ErrorCode.PLUGIN_UPDATE_FAILED)
|
||||
@api_v3.route('/plugins/uninstall', methods=['POST'])
|
||||
def uninstall_plugin():
|
||||
"""Uninstall plugin"""
|
||||
@@ -1266,8 +1192,6 @@ def uninstall_plugin():
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
from src.web_interface.errors import WebInterfaceError
|
||||
error = WebInterfaceError.from_exception(e, ErrorCode.PLUGIN_UNINSTALL_FAILED)
|
||||
if api_v3.operation_history:
|
||||
api_v3.operation_history.record_operation(
|
||||
"uninstall",
|
||||
@@ -1275,17 +1199,10 @@ def uninstall_plugin():
|
||||
status="failed",
|
||||
error=str(e)
|
||||
)
|
||||
return error_response(
|
||||
error.error_code,
|
||||
error.message,
|
||||
details=error.details,
|
||||
context=error.context,
|
||||
status_code=500
|
||||
)
|
||||
return exception_error_response(e, ErrorCode.PLUGIN_UNINSTALL_FAILED)
|
||||
@api_v3.route('/plugins/install', methods=['POST'])
|
||||
def install_plugin():
|
||||
"""Install plugin from store"""
|
||||
try:
|
||||
if not api_v3.plugin_store_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
|
||||
|
||||
@@ -1423,13 +1340,9 @@ def install_plugin():
|
||||
status_code=500
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error in install_plugin', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/install-from-url', methods=['POST'])
|
||||
def install_plugin_from_url():
|
||||
"""Install plugin from custom GitHub URL"""
|
||||
try:
|
||||
if not api_v3.plugin_store_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
|
||||
|
||||
@@ -1482,13 +1395,9 @@ def install_plugin_from_url():
|
||||
'message': result.get('error', 'Failed to install plugin from URL')
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error in install_plugin_from_url', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/registry-from-url', methods=['POST'])
|
||||
def get_registry_from_url():
|
||||
"""Get plugin list from a registry-style monorepo URL"""
|
||||
try:
|
||||
if not api_v3.plugin_store_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
|
||||
|
||||
@@ -1519,25 +1428,17 @@ def get_registry_from_url():
|
||||
'message': 'Failed to fetch registry from URL or URL does not contain a valid registry'
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error in get_registry_from_url', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/saved-repositories', methods=['GET'])
|
||||
def get_saved_repositories():
|
||||
"""Get all saved repositories"""
|
||||
try:
|
||||
if not api_v3.saved_repositories_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500
|
||||
|
||||
repositories = api_v3.saved_repositories_manager.get_all()
|
||||
return jsonify({'status': 'success', 'data': {'repositories': repositories}})
|
||||
except Exception as e:
|
||||
logger.error('Error in get_saved_repositories', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/saved-repositories', methods=['POST'])
|
||||
def add_saved_repository():
|
||||
"""Add a repository to saved list"""
|
||||
try:
|
||||
if not api_v3.saved_repositories_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500
|
||||
|
||||
@@ -1566,13 +1467,9 @@ def add_saved_repository():
|
||||
'status': 'error',
|
||||
'message': 'Repository already exists or failed to save'
|
||||
}), 400
|
||||
except Exception as e:
|
||||
logger.error('Error in add_saved_repository', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/saved-repositories', methods=['DELETE'])
|
||||
def remove_saved_repository():
|
||||
"""Remove a repository from saved list"""
|
||||
try:
|
||||
if not api_v3.saved_repositories_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500
|
||||
|
||||
@@ -1595,13 +1492,9 @@ def remove_saved_repository():
|
||||
'status': 'error',
|
||||
'message': 'Repository not found'
|
||||
}), 404
|
||||
except Exception as e:
|
||||
logger.error('Error in remove_saved_repository', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/store/list', methods=['GET'])
|
||||
def list_plugin_store():
|
||||
"""Search plugin store"""
|
||||
try:
|
||||
if not api_v3.plugin_store_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
|
||||
|
||||
@@ -1649,13 +1542,9 @@ def list_plugin_store():
|
||||
})
|
||||
|
||||
return jsonify({'status': 'success', 'data': {'plugins': formatted_plugins}})
|
||||
except Exception as e:
|
||||
logger.error('Error in list_plugin_store', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/store/github-status', methods=['GET'])
|
||||
def get_github_auth_status():
|
||||
"""Check if GitHub authentication is configured and validate token"""
|
||||
try:
|
||||
if not api_v3.plugin_store_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
|
||||
|
||||
@@ -1699,13 +1588,9 @@ def get_github_auth_status():
|
||||
'error': error_message
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in get_github_auth_status', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/store/refresh', methods=['POST'])
|
||||
def refresh_plugin_store():
|
||||
"""Refresh plugin store repository"""
|
||||
try:
|
||||
if not api_v3.plugin_store_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
|
||||
|
||||
@@ -1725,9 +1610,6 @@ def refresh_plugin_store():
|
||||
'message': message,
|
||||
'plugin_count': plugin_count
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in refresh_plugin_store', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/config', methods=['POST'])
|
||||
def save_plugin_config():
|
||||
"""Save plugin configuration, separating secrets from regular config"""
|
||||
@@ -2308,8 +2190,6 @@ def save_plugin_config():
|
||||
|
||||
return success_response(message=message)
|
||||
except Exception as e:
|
||||
from src.web_interface.errors import WebInterfaceError
|
||||
error = WebInterfaceError.from_exception(e, ErrorCode.CONFIG_SAVE_FAILED)
|
||||
if api_v3.operation_history:
|
||||
api_v3.operation_history.record_operation(
|
||||
"configure",
|
||||
@@ -2317,13 +2197,7 @@ def save_plugin_config():
|
||||
status="failed",
|
||||
error=str(e)
|
||||
)
|
||||
return error_response(
|
||||
error.error_code,
|
||||
error.message,
|
||||
details=error.details,
|
||||
context=error.context,
|
||||
status_code=500
|
||||
)
|
||||
return exception_error_response(e, ErrorCode.CONFIG_SAVE_FAILED)
|
||||
def _merge_onto_stored_plugin_config(plugin_id, submitted_config, current_config=None):
|
||||
"""A JSON plugin-config body merged onto the plugin's stored section.
|
||||
|
||||
@@ -2740,7 +2614,6 @@ def _prepare_plugin_config_for_save(plugin_id, plugin_config, schema, schema_mgr
|
||||
@api_v3.route('/plugins/schema', methods=['GET'])
|
||||
def get_plugin_schema():
|
||||
"""Get plugin configuration schema"""
|
||||
try:
|
||||
plugin_id = request.args.get('plugin_id')
|
||||
if not plugin_id:
|
||||
return jsonify({'status': 'error', 'message': 'plugin_id required'}), 400
|
||||
@@ -2778,13 +2651,9 @@ def get_plugin_schema():
|
||||
}
|
||||
|
||||
return jsonify({'status': 'success', 'data': {'schema': default_schema}})
|
||||
except Exception as e:
|
||||
logger.error('Error in get_plugin_schema', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/config/reset', methods=['POST'])
|
||||
def reset_plugin_config():
|
||||
"""Reset plugin configuration to schema defaults"""
|
||||
try:
|
||||
if not api_v3.config_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500
|
||||
|
||||
@@ -2857,9 +2726,6 @@ def reset_plugin_config():
|
||||
'message': f'Plugin {plugin_id} configuration reset to defaults',
|
||||
'data': {'config': defaults}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in reset_plugin_config', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/action', methods=['POST'])
|
||||
def execute_plugin_action():
|
||||
"""Execute a plugin-defined action (e.g., authentication)"""
|
||||
@@ -2868,8 +2734,10 @@ def execute_plugin_action():
|
||||
try:
|
||||
data = request.get_json(force=True) or {}
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
# The module logger, not a local one: binding `logger` anywhere in
|
||||
# this function made every other `logger.error` here raise
|
||||
# UnboundLocalError, so the step-1 handler below reported that
|
||||
# instead of the plugin script's real failure.
|
||||
logger.error(f"Error parsing JSON in execute_plugin_action: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -3172,9 +3040,6 @@ sys.exit(proc.returncode)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'status': 'error', 'message': 'Action timed out'}), 408
|
||||
except Exception as e:
|
||||
logger.error('Error in execute_plugin_action', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
def _plugin_uploads_dir(plugin_id):
|
||||
"""assets/plugins/<plugin_id>/uploads for a request-supplied id, or None.
|
||||
|
||||
@@ -3187,7 +3052,6 @@ def _plugin_uploads_dir(plugin_id):
|
||||
@api_v3.route('/plugins/assets/upload', methods=['POST'])
|
||||
def upload_plugin_asset():
|
||||
"""Upload asset files for a plugin"""
|
||||
try:
|
||||
plugin_id = request.form.get('plugin_id')
|
||||
if not plugin_id:
|
||||
return jsonify({'status': 'error', 'message': 'plugin_id is required'}), 400
|
||||
@@ -3332,9 +3196,6 @@ def upload_plugin_asset():
|
||||
'total_files': len(metadata)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/<plugin_id>/static/<path:file_path>', methods=['GET'])
|
||||
def serve_plugin_static(plugin_id, file_path):
|
||||
"""Serve static files from plugin directory.
|
||||
@@ -3353,7 +3214,6 @@ def serve_plugin_static(plugin_id, file_path):
|
||||
``plugin-repos/foo-evil/x``, whose string does start with
|
||||
``plugin-repos/foo``.
|
||||
"""
|
||||
try:
|
||||
safe_plugin_id = safe_path_component(plugin_id)
|
||||
if not safe_plugin_id:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400
|
||||
@@ -3399,13 +3259,9 @@ def serve_plugin_static(plugin_id, file_path):
|
||||
|
||||
return Response(content, mimetype=content_type)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/calendar/upload-credentials', methods=['POST'])
|
||||
def upload_calendar_credentials():
|
||||
"""Upload credentials.json file for calendar plugin"""
|
||||
try:
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'status': 'error', 'message': 'No file provided'}), 400
|
||||
|
||||
@@ -3476,9 +3332,6 @@ def upload_calendar_credentials():
|
||||
'path': str(credentials_path)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error in upload_calendar_credentials', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/calendar/authenticate', methods=['POST'])
|
||||
def authenticate_calendar():
|
||||
"""Google OAuth for the calendar plugin, in the two steps it requires.
|
||||
@@ -3492,7 +3345,6 @@ def authenticate_calendar():
|
||||
The script persists the PKCE verifier from step 1 for step 2 to reuse; the
|
||||
exchange fails with "Missing code verifier" otherwise.
|
||||
"""
|
||||
try:
|
||||
plugin_dir = _pkg._calendar_plugin_dir()
|
||||
if plugin_dir is None:
|
||||
return jsonify({
|
||||
@@ -3525,11 +3377,6 @@ def authenticate_calendar():
|
||||
return jsonify(safe), 400
|
||||
return jsonify(payload)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error in authenticate_calendar', exc_info=True)
|
||||
return jsonify({'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/calendar/list-calendars', methods=['GET'])
|
||||
def list_calendar_calendars():
|
||||
"""The calendars this account can see, for the config picker.
|
||||
@@ -3538,7 +3385,6 @@ def list_calendar_calendars():
|
||||
picker is used interactively and a subprocess per click is slower than the
|
||||
API call it would be wrapping.
|
||||
"""
|
||||
try:
|
||||
plugin_dir = _pkg._calendar_plugin_dir()
|
||||
if plugin_dir is None:
|
||||
return jsonify({
|
||||
@@ -3623,15 +3469,9 @@ def list_calendar_calendars():
|
||||
|
||||
return jsonify({'status': 'success', 'calendars': calendars})
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error in list_calendar_calendars', exc_info=True)
|
||||
return jsonify({'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/assets/delete', methods=['POST'])
|
||||
def delete_plugin_asset():
|
||||
"""Delete an asset file for a plugin"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
plugin_id = data.get('plugin_id')
|
||||
image_id = data.get('image_id')
|
||||
@@ -3677,13 +3517,9 @@ def delete_plugin_asset():
|
||||
|
||||
return jsonify({'status': 'success', 'message': 'Image deleted successfully'})
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/assets/list', methods=['GET'])
|
||||
def list_plugin_assets():
|
||||
"""List asset files for a plugin"""
|
||||
try:
|
||||
plugin_id = request.args.get('plugin_id')
|
||||
if not plugin_id:
|
||||
return jsonify({'status': 'error', 'message': 'plugin_id is required'}), 400
|
||||
@@ -3706,6 +3542,3 @@ def list_plugin_assets():
|
||||
|
||||
return jsonify({'status': 'success', 'data': {'assets': assets}})
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@@ -24,7 +24,6 @@ import web_interface.blueprints.api_v3 as _pkg
|
||||
@api_v3.route('/system/status', methods=['GET'])
|
||||
def get_system_status():
|
||||
"""Get system status"""
|
||||
try:
|
||||
# Check cache first (10 second TTL for system status)
|
||||
try:
|
||||
from web_interface.cache import get_cached, set_cached
|
||||
@@ -110,9 +109,6 @@ def get_system_status():
|
||||
pass # Cache write failed, but continue
|
||||
|
||||
return jsonify({'status': 'success', 'data': status})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/system/version', methods=['GET'])
|
||||
def get_system_version():
|
||||
"""Get LEDMatrix repository version"""
|
||||
|
||||
@@ -111,7 +111,6 @@ def _parse_bool_ish(value):
|
||||
@api_v3.route('/wifi/status', methods=['GET'])
|
||||
def get_wifi_status():
|
||||
"""Get current WiFi connection status"""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
@@ -132,13 +131,6 @@ def get_wifi_status():
|
||||
'last_connect_attempt': _last_connect_snapshot(),
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)
|
||||
}), 500
|
||||
@api_v3.route('/wifi/scan', methods=['GET'])
|
||||
def scan_wifi_networks():
|
||||
"""Scan for available WiFi networks
|
||||
@@ -219,7 +211,6 @@ def connect_wifi():
|
||||
background (see _last_connect_attempt); otherwise it waits for the result.
|
||||
"""
|
||||
global _last_connect_attempt
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
@@ -290,16 +281,9 @@ def connect_wifi():
|
||||
payload = _connect_result_payload(ssid, success, message)
|
||||
_record_connect_result(ssid, payload)
|
||||
return jsonify(payload), (200 if success else 400)
|
||||
except Exception as e:
|
||||
logger.error("Error connecting to WiFi", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||
}), 500
|
||||
@api_v3.route('/wifi/disconnect', methods=['POST'])
|
||||
def disconnect_wifi():
|
||||
"""Disconnect from the current WiFi network"""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
@@ -315,16 +299,9 @@ def disconnect_wifi():
|
||||
'status': 'error',
|
||||
'message': message or 'Failed to disconnect from network'
|
||||
}), 400
|
||||
except Exception as e:
|
||||
logger.error("Error disconnecting from WiFi", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||
}), 500
|
||||
@api_v3.route('/wifi/ap/enable', methods=['POST'])
|
||||
def enable_ap_mode():
|
||||
"""Enable access point mode"""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
@@ -342,17 +319,9 @@ def enable_ap_mode():
|
||||
'status': 'error',
|
||||
'message': message
|
||||
}), 400
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)
|
||||
}), 500
|
||||
@api_v3.route('/wifi/ap/disable', methods=['POST'])
|
||||
def disable_ap_mode():
|
||||
"""Disable access point mode"""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
@@ -368,17 +337,9 @@ def disable_ap_mode():
|
||||
'status': 'error',
|
||||
'message': message
|
||||
}), 400
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)
|
||||
}), 500
|
||||
@api_v3.route('/wifi/ap/auto-enable', methods=['GET'])
|
||||
def get_auto_enable_ap_mode():
|
||||
"""Get auto-enable AP mode setting"""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
@@ -390,17 +351,9 @@ def get_auto_enable_ap_mode():
|
||||
'auto_enable_ap_mode': auto_enable
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)
|
||||
}), 500
|
||||
@api_v3.route('/wifi/ap/auto-enable', methods=['POST'])
|
||||
def set_auto_enable_ap_mode():
|
||||
"""Set auto-enable AP mode setting"""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
@@ -428,17 +381,9 @@ def set_auto_enable_ap_mode():
|
||||
'auto_enable_ap_mode': auto_enable
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)
|
||||
}), 500
|
||||
@api_v3.route('/wifi/radio', methods=['GET'])
|
||||
def get_wifi_radio():
|
||||
"""Get current WiFi radio state (enabled/disabled) and wired-fallback status."""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
@@ -448,12 +393,6 @@ def get_wifi_radio():
|
||||
'status': 'success',
|
||||
'data': state
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Error getting WiFi radio state", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||
}), 500
|
||||
@api_v3.route('/wifi/radio', methods=['POST'])
|
||||
def set_wifi_radio():
|
||||
"""Turn the WiFi radio on or off.
|
||||
@@ -462,7 +401,6 @@ def set_wifi_radio():
|
||||
unless Ethernet is connected or force=True, to avoid locking the user out
|
||||
of this web interface.
|
||||
"""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -506,9 +444,3 @@ def set_wifi_radio():
|
||||
'message': message,
|
||||
'reason': reason
|
||||
}), 400
|
||||
except Exception as e:
|
||||
logger.error("Error setting WiFi radio state", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||
}), 500
|
||||
|
||||
Reference in New Issue
Block a user