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:
Chuck
2026-09-24 15:53:19 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ece416c4e5
commit 4e61d7248a
17 changed files with 2437 additions and 2418 deletions
+8
View File
@@ -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 `a/b` or `..` resolve to nothing everywhere. Installs where each directory is
named for its manifest id, the installer's layout, behave as before. 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 - `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 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 return PIL's default font, a different typeface, so a plugin that relied on
+7 -2
View File
@@ -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 ```json
{ {
"status": "error", "status": "error",
"error_code": "CONFIG_SAVE_FAILED", "error_code": "CONFIG_SAVE_FAILED",
"error_category": "configuration",
"message": "Error description", "message": "Error description",
"details": "optional", "details": "optional",
"context": { }, "context": { },
+35 -1
View File
@@ -9,7 +9,7 @@ from typing import Any, Optional, Dict, Tuple
from flask import jsonify, request from flask import jsonify, request
from src.web_interface.error_handler import create_error_response, create_success_response 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( 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]]: def validate_request_json(required_fields: list, data: Optional[Dict] = None) -> Tuple[Optional[Dict], Optional[Any]]:
""" """
Validate request JSON has required fields. Validate request JSON has required fields.
+34 -3
View File
@@ -7,9 +7,7 @@ Provides helpers for consistent error responses across API endpoints.
from typing import Any, Optional from typing import Any, Optional
from flask import jsonify from flask import jsonify
from src.web_interface.errors import ( from src.web_interface.errors import WebInterfaceError, ErrorCode
WebInterfaceError, ErrorCode, ErrorCategory
)
from src.logging_config import get_logger from src.logging_config import get_logger
from src.redaction import redact_credentials from src.redaction import redact_credentials
@@ -72,6 +70,39 @@ def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str:
return text 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( def create_error_response(
error_code: ErrorCode, error_code: ErrorCode,
message: str, message: str,
+5 -63
View File
@@ -1,7 +1,7 @@
""" """
Structured error handling for web interface. 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 from enum import Enum
@@ -9,17 +9,6 @@ from typing import Dict, Any, Optional, List
from dataclasses import dataclass 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): class ErrorCode(Enum):
"""Error codes for specific error types.""" """Error codes for specific error types."""
# Configuration errors # Configuration errors
@@ -63,12 +52,11 @@ class WebInterfaceError:
""" """
Structured error for web interface responses. Structured error for web interface responses.
Provides consistent error format with error codes, categories, Provides consistent error format with error codes, messages, and
messages, and context. context.
""" """
error_code: ErrorCode error_code: ErrorCode
message: str message: str
category: ErrorCategory
details: Optional[str] = None details: Optional[str] = None
context: Optional[Dict[str, Any]] = None context: Optional[Dict[str, Any]] = None
suggested_fixes: Optional[List[str]] = None suggested_fixes: Optional[List[str]] = None
@@ -78,7 +66,6 @@ class WebInterfaceError:
self, self,
error_code: ErrorCode, error_code: ErrorCode,
message: str, message: str,
category: Optional[ErrorCategory] = None,
details: Optional[str] = None, details: Optional[str] = None,
context: Optional[Dict[str, Any]] = None, context: Optional[Dict[str, Any]] = None,
suggested_fixes: Optional[List[str]] = None, suggested_fixes: Optional[List[str]] = None,
@@ -86,7 +73,6 @@ class WebInterfaceError:
): ):
self.error_code = error_code self.error_code = error_code
self.message = message self.message = message
self.category = category or self._infer_category(error_code)
self.details = details self.details = details
self.context = context or {} self.context = context or {}
# `is None`, not truthiness: an explicit [] means "this caller has # `is None`, not truthiness: an explicit [] means "this caller has
@@ -96,25 +82,6 @@ class WebInterfaceError:
else self._get_default_suggestions(error_code)) else self._get_default_suggestions(error_code))
self.original_error = original_error 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]: def _get_default_suggestions(self, error_code: ErrorCode) -> List[str]:
"""Get default suggested fixes for error code.""" """Get default suggested fixes for error code."""
suggestions_map = { suggestions_map = {
@@ -178,7 +145,6 @@ class WebInterfaceError:
result = { result = {
"status": "error", "status": "error",
"error_code": self.error_code.value, "error_code": self.error_code.value,
"error_category": self.category.value,
"message": self.message, "message": self.message,
} }
@@ -197,7 +163,7 @@ class WebInterfaceError:
def from_exception( def from_exception(
cls, cls,
exception: Exception, exception: Exception,
error_code: Optional[ErrorCode] = None, error_code: ErrorCode,
context: Optional[Dict[str, Any]] = None context: Optional[Dict[str, Any]] = None
) -> 'WebInterfaceError': ) -> 'WebInterfaceError':
""" """
@@ -205,13 +171,9 @@ class WebInterfaceError:
Args: Args:
exception: Exception to convert exception: Exception to convert
error_code: Optional specific error code error_code: The error code to report
context: Optional additional context 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 # Build context
error_context = context or {} error_context = context or {}
error_context['exception_type'] = type(exception).__name__ error_context['exception_type'] = type(exception).__name__
@@ -252,26 +214,6 @@ class WebInterfaceError:
} }
return messages.get(error_code, "An unexpected error occurred") 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 @classmethod
def _get_exception_details(cls, exception: Exception) -> Optional[str]: def _get_exception_details(cls, exception: Exception) -> Optional[str]:
"""Get additional details from exception.""" """Get additional details from exception."""
+46
View File
@@ -230,6 +230,52 @@ class TestHandlersCarryDetail:
"handlers returning the generic message without %s: %r" "handlers returning the generic message without %s: %r"
% ("both a traceback log and the detail", offenders)) % ("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): def test_client_errors_keep_their_own_status(self):
"""A 405 must not be reported as a server-side UNKNOWN_ERROR. """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'
+54 -1
View File
@@ -15,7 +15,7 @@ every api_v3 endpoint actually calls.
import pytest import pytest
from flask import Flask 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 ( from src.web_interface.error_handler import (
create_error_response, create_error_response,
create_success_response, create_success_response,
@@ -64,6 +64,59 @@ class TestCreateErrorResponse:
assert response.get_json()["suggested_fixes"] == ["Try again"] 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: class TestCreateSuccessResponse:
def test_bare_success(self): def test_bare_success(self):
assert create_success_response() == {"status": "success"} assert create_success_response() == {"status": "success"}
+12 -59
View File
@@ -1,7 +1,7 @@
""" """
Tests for src/web_interface/errors.py — the structured error type behind Tests for src/web_interface/errors.py — the structured error type behind
every API error response (category inference, default suggestions, the every API error response (default suggestions, the JSON shape, and
JSON shape, and exception conversion). exception conversion).
Pure logic; no Flask context needed. Pure logic; no Flask context needed.
@@ -11,39 +11,7 @@ caller passing [] to mean "no suggestions" silently got the default list.
import pytest import pytest
from src.web_interface.errors import ErrorCategory, ErrorCode, WebInterfaceError from src.web_interface.errors import 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)
class TestDefaultSuggestions: class TestDefaultSuggestions:
@@ -79,8 +47,9 @@ class TestToDict:
result = WebInterfaceError(ErrorCode.SYSTEM_ERROR, "boom").to_dict() result = WebInterfaceError(ErrorCode.SYSTEM_ERROR, "boom").to_dict()
assert result["status"] == "error" assert result["status"] == "error"
assert result["error_code"] == "SYSTEM_ERROR" assert result["error_code"] == "SYSTEM_ERROR"
assert result["error_category"] == "system"
assert result["message"] == "boom" 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): def test_details_included_when_set(self):
result = WebInterfaceError( result = WebInterfaceError(
@@ -116,23 +85,7 @@ class TestToDict:
class TestFromException: class TestFromException:
@pytest.mark.parametrize("exc_name,expected", [ def test_the_given_code_is_reported(self):
("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):
error = WebInterfaceError.from_exception( error = WebInterfaceError.from_exception(
ValueError("boom"), error_code=ErrorCode.PLUGIN_NOT_FOUND) ValueError("boom"), error_code=ErrorCode.PLUGIN_NOT_FOUND)
assert error.error_code is 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): def test_message_is_the_safe_one_not_the_exception_text(self):
# The raw exception text is not echoed into `message`; that field is # The raw exception text is not echoed into `message`; that field is
# a fixed, user-facing string per code. # 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 error.message == "An unexpected error occurred"
assert "secret-ish" not in error.message assert "secret-ish" not in error.message
def test_exception_type_recorded_in_context(self): 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" assert error.context["exception_type"] == "ValueError"
def test_caller_context_is_preserved_alongside_type(self): def test_caller_context_is_preserved_alongside_type(self):
error = WebInterfaceError.from_exception( 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["plugin_id"] == "clock"
assert error.context["exception_type"] == "ValueError" assert error.context["exception_type"] == "ValueError"
def test_caller_supplied_exception_type_is_overwritten(self): def test_caller_supplied_exception_type_is_overwritten(self):
error = WebInterfaceError.from_exception( 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" assert error.context["exception_type"] == "ValueError"
def test_original_error_retained(self): def test_original_error_retained(self):
exc = ValueError("boom") 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): def test_every_code_has_a_safe_message(self):
for code in ErrorCode: for code in ErrorCode:
@@ -205,4 +158,4 @@ class TestExceptionDetails:
def test_details_flow_into_from_exception(self): def test_details_flow_into_from_exception(self):
exc = ValueError("boom") exc = ValueError("boom")
exc.context = {"config_path": "/etc/x.json"} 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
+42 -2
View File
@@ -35,13 +35,16 @@ from typing import Dict, Any, Optional, Tuple, Type
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Import new infrastructure # 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.errors import ErrorCode
from src.web_interface.secret_helpers import (find_secret_fields, mask_all_secret_values, from src.web_interface.secret_helpers import (find_secret_fields, mask_all_secret_values,
merge_secrets, remove_empty_secrets, merge_secrets, remove_empty_secrets,
separate_secrets, separate_secrets,
strip_masked_values) 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.plugin_system.operation_types import OperationType
from src.web_interface.validators import ( from src.web_interface.validators import (
validate_file_upload validate_file_upload
@@ -114,6 +117,43 @@ SYSTEM_FONTS = frozenset([
'clr6x12', 'helvr12', 'texgyre-27' 'clr6x12', 'helvr12', 'texgyre-27'
]) ])
api_v3 = Blueprint('api_v3', __name__) 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: def _get_plugin_version(plugin_id: str) -> str:
"""Read the installed version from a plugin's manifest.json. """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']) @api_v3.route('/config/main', methods=['GET'])
def get_main_config(): def get_main_config():
"""Get main configuration, with credentials redacted.""" """Get main configuration, with credentials redacted."""
try:
if not api_v3.config_manager: if not api_v3.config_manager:
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500
config = api_v3.config_manager.load_config() config = api_v3.config_manager.load_config()
return jsonify({'status': 'success', 'data': _redact_credentials(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']) @api_v3.route('/config/schedule', methods=['GET'])
def get_schedule_config(): def get_schedule_config():
"""Get current schedule configuration""" """Get current schedule configuration"""
@@ -1165,7 +1161,6 @@ def save_main_config():
@api_v3.route('/config/secrets', methods=['GET']) @api_v3.route('/config/secrets', methods=['GET'])
def get_secrets_config(): def get_secrets_config():
"""Get secrets configuration""" """Get secrets configuration"""
try:
if not api_v3.config_manager: if not api_v3.config_manager:
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 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". # alone so a client can still tell "set" from "not set".
return jsonify({'status': 'success', return jsonify({'status': 'success',
'data': mask_all_secret_values(config)}) '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']) @api_v3.route('/config/raw/main', methods=['POST'])
def save_raw_main_config(): def save_raw_main_config():
"""Save raw main configuration JSON""" """Save raw main configuration JSON"""
+1 -31
View File
@@ -6,7 +6,7 @@ endpoint names are unchanged by living here.
from web_interface.blueprints.api_v3 import ( from web_interface.blueprints.api_v3 import (
_ensure_display_service_running, _ensure_display_service_running,
_get_display_service_status, _stop_display_service, api_v3, _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 import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these # 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']) @api_v3.route('/display/current', methods=['GET'])
def get_display_current(): def get_display_current():
"""Get current display state""" """Get current display state"""
try:
import base64 import base64
from PIL import Image from PIL import Image
import io import io
@@ -67,9 +66,6 @@ def get_display_current():
'image': image_data # Base64 encoded image data or None if unavailable 'image': image_data # Base64 encoded image data or None if unavailable
} }
return jsonify({'status': 'success', 'data': display_data}) 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']) @api_v3.route('/display/modes', methods=['GET'])
def get_display_modes(): def get_display_modes():
"""Every display mode that can be requested on-demand, with its plugin. """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 for the duration -- so they are reported with enabled: false
rather than omitted. rather than omitted.
""" """
try:
if not api_v3.plugin_manager: if not api_v3.plugin_manager:
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 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}}) 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']) @api_v3.route('/display/on-demand/status', methods=['GET'])
def get_on_demand_status(): def get_on_demand_status():
"""Return the current on-demand display state.""" """Return the current on-demand display state."""
try:
cache = _cache_manager() cache = _cache_manager()
# memory_ttl=0: the display service writes this key, so only the file # 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 # is current. This process's memory tier would keep serving the first
@@ -170,13 +155,9 @@ def get_on_demand_status():
'service': service_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']) @api_v3.route('/display/on-demand/start', methods=['POST'])
def start_on_demand_display(): def start_on_demand_display():
"""Request the display controller to run a specific plugin on-demand.""" """Request the display controller to run a specific plugin on-demand."""
try:
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
plugin_id = data.get('plugin_id') plugin_id = data.get('plugin_id')
mode = data.get('mode') mode = data.get('mode')
@@ -278,13 +259,9 @@ def start_on_demand_display():
'service': service_result 'service': service_result
} }
return jsonify({'status': 'success', 'data': response_data}) 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']) @api_v3.route('/display/on-demand/stop', methods=['POST'])
def stop_on_demand_display(): def stop_on_demand_display():
"""Request the display controller to stop on-demand mode.""" """Request the display controller to stop on-demand mode."""
try:
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
stop_service = data.get('stop_service', False) stop_service = data.get('stop_service', False)
@@ -313,9 +290,6 @@ def stop_on_demand_display():
'service': service_result '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']) @api_v3.route('/display/current-status', methods=['GET'])
def get_current_display_status(): def get_current_display_status():
"""Return the display mode/plugin currently intended to be shown. """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 System Logs page) can show what's on screen without querying the display
process directly. process directly.
""" """
try:
cache = _cache_manager() cache = _cache_manager()
# memory_ttl=0: written by the display service; see get_on_demand_status. # 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) state = cache.get('display_current_state', max_age=120, memory_ttl=0)
@@ -336,6 +309,3 @@ def get_current_display_status():
'last_updated': None, 'last_updated': None,
} }
return jsonify({'status': 'success', 'data': state}) 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
+1 -23
View File
@@ -4,7 +4,7 @@ Routes decorate the shared `api_v3` Blueprint from ._common, so their
endpoint names are unchanged by living here. endpoint names are unchanged by living here.
""" """
from web_interface.blueprints.api_v3 import ( 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, jsonify, logger, os, re, request, validate_file_upload,
) )
@@ -52,7 +52,6 @@ def _catalog_response(catalog):
@api_v3.route('/fonts/catalog', methods=['GET']) @api_v3.route('/fonts/catalog', methods=['GET'])
def get_fonts_catalog(): def get_fonts_catalog():
"""Get fonts catalog""" """Get fonts catalog"""
try:
# Check cache first (5 minute TTL) # Check cache first (5 minute TTL)
try: try:
from web_interface.cache import get_cached, set_cached 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) logger.error("[FontCatalog] Failed to cache fonts_catalog", exc_info=True)
return _catalog_response(catalog) 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']) @api_v3.route('/fonts/tokens', methods=['GET'])
def get_font_tokens(): def get_font_tokens():
"""Get font size tokens""" """Get font size tokens"""
try:
# This would integrate with the actual font system # This would integrate with the actual font system
# For now, return sample tokens # For now, return sample tokens
tokens = { tokens = {
@@ -177,13 +170,9 @@ def get_font_tokens():
'xxl': 16 'xxl': 16
} }
return jsonify({'status': 'success', 'data': {'tokens': tokens}}) 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']) @api_v3.route('/fonts/upload', methods=['POST'])
def upload_font(): def upload_font():
"""Upload font file""" """Upload font file"""
try:
if 'font_file' not in request.files: if 'font_file' not in request.files:
return jsonify({'status': 'error', 'message': 'No font file provided'}), 400 return jsonify({'status': 'error', 'message': 'No font file provided'}), 400
@@ -256,13 +245,9 @@ def upload_font():
'filename': safe_filename, 'filename': safe_filename,
'path': f'assets/fonts/{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']) @api_v3.route('/fonts/preview', methods=['GET'])
def get_font_preview() -> tuple[Response, int] | Response: def get_font_preview() -> tuple[Response, int] | Response:
"""Generate a preview image of text rendered with a specific font""" """Generate a preview image of text rendered with a specific font"""
try:
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
import io import io
import base64 import base64
@@ -399,13 +384,9 @@ def get_font_preview() -> tuple[Response, int] | Response:
'height': img_height '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']) @api_v3.route('/fonts/<font_family>', methods=['DELETE'])
def delete_font(font_family: str) -> tuple[Response, int] | Response: def delete_font(font_family: str) -> tuple[Response, int] | Response:
"""Delete a user-uploaded font file""" """Delete a user-uploaded font file"""
try:
# Security: Validate font_family to prevent path traversal # Security: Validate font_family to prevent path traversal
# Reject if it contains path separators or .. # Reject if it contains path separators or ..
if '..' in font_family or '/' in font_family or '\\' in font_family: 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', 'status': 'success',
'message': f'Font {deleted_filename} deleted successfully' '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
-15
View File
@@ -186,13 +186,6 @@ def get_logs():
'status': 'error', 'status': 'error',
'message': 'Timeout while fetching logs' 'message': 'Timeout while fetching logs'
}), 500 }), 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 # Multi-Display Sync Endpoints
@api_v3.route('/sync/status', methods=['GET']) @api_v3.route('/sync/status', methods=['GET'])
def get_sync_status(): def get_sync_status():
@@ -231,7 +224,6 @@ def get_sync_status():
@api_v3.route('/cache/list', methods=['GET']) @api_v3.route('/cache/list', methods=['GET'])
def list_cache_files(): def list_cache_files():
"""List all cache files with metadata""" """List all cache files with metadata"""
try:
if not api_v3.cache_manager: if not api_v3.cache_manager:
# Initialize cache manager if not already initialized # Initialize cache manager if not already initialized
from src.cache_manager import CacheManager from src.cache_manager import CacheManager
@@ -248,13 +240,9 @@ def list_cache_files():
'total_files': len(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']) @api_v3.route('/cache/delete', methods=['POST'])
def delete_cache_file(): def delete_cache_file():
"""Delete a specific cache file by key""" """Delete a specific cache file by key"""
try:
if not api_v3.cache_manager: if not api_v3.cache_manager:
# Initialize cache manager if not already initialized # Initialize cache manager if not already initialized
from src.cache_manager import CacheManager from src.cache_manager import CacheManager
@@ -279,9 +267,6 @@ def delete_cache_file():
'status': 'success', 'status': 'success',
'message': f'Cache file for key "{cache_key}" deleted successfully' '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(): def _errors_cache():
"""The shared cache the display service publishes its errors to.""" """The shared cache the display service publishes its errors to."""
if not api_v3.cache_manager: if not api_v3.cache_manager:
+14 -181
View File
@@ -14,6 +14,7 @@ from web_interface.blueprints.api_v3 import (
_set_missing_booleans_to_false, _set_missing_booleans_to_false,
_set_nested_value, _starlark_virtual_plugins, _toggle_starlark_app, _set_nested_value, _starlark_virtual_plugins, _toggle_starlark_app,
api_v3, datetime, deep_merge, describe_exception, error_response, api_v3, datetime, deep_merge, describe_exception, error_response,
exception_error_response,
find_secret_fields, hashlib, json, jsonify, logger, logging, find_secret_fields, hashlib, json, jsonify, logger, logging,
merge_secrets, os, redact_text, remove_empty_secrets, request, merge_secrets, os, redact_text, remove_empty_secrets, request,
separate_secrets, shutil, stat, subprocess, success_response, 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']) @api_v3.route('/plugins/installed', methods=['GET'])
def get_installed_plugins(): def get_installed_plugins():
"""Get installed plugins""" """Get installed plugins"""
try:
if not api_v3.plugin_manager or not api_v3.plugin_store_manager: if not api_v3.plugin_manager or not api_v3.plugin_store_manager:
return jsonify({'status': 'error', 'message': 'Plugin managers not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin managers not initialized'}), 500
@@ -174,13 +174,9 @@ def get_installed_plugins():
plugins.extend(_starlark_virtual_plugins()) plugins.extend(_starlark_virtual_plugins())
return jsonify({'status': 'success', 'data': {'plugins': 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']) @api_v3.route('/plugins/health', methods=['GET'])
def get_plugin_health(): def get_plugin_health():
"""Get health metrics for all plugins""" """Get health metrics for all plugins"""
try:
if not api_v3.plugin_manager: if not api_v3.plugin_manager:
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
@@ -214,13 +210,9 @@ def get_plugin_health():
'status': 'success', 'status': 'success',
'data': health_summaries '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']) @api_v3.route('/plugins/health/<plugin_id>', methods=['GET'])
def get_plugin_health_single(plugin_id): def get_plugin_health_single(plugin_id):
"""Get health metrics for a specific plugin""" """Get health metrics for a specific plugin"""
try:
if not api_v3.plugin_manager: if not api_v3.plugin_manager:
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
@@ -238,13 +230,9 @@ def get_plugin_health_single(plugin_id):
'status': 'success', 'status': 'success',
'data': health_summary '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']) @api_v3.route('/plugins/health/<plugin_id>/reset', methods=['POST'])
def reset_plugin_health(plugin_id): def reset_plugin_health(plugin_id):
"""Reset health state for a plugin (manual recovery)""" """Reset health state for a plugin (manual recovery)"""
try:
if not api_v3.plugin_manager: if not api_v3.plugin_manager:
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
@@ -262,13 +250,9 @@ def reset_plugin_health(plugin_id):
'status': 'success', 'status': 'success',
'message': f'Health state reset for plugin {plugin_id}' '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']) @api_v3.route('/plugins/metrics', methods=['GET'])
def get_plugin_metrics(): def get_plugin_metrics():
"""Get resource metrics for all plugins""" """Get resource metrics for all plugins"""
try:
if not api_v3.plugin_manager: if not api_v3.plugin_manager:
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
@@ -301,13 +285,9 @@ def get_plugin_metrics():
'status': 'success', 'status': 'success',
'data': metrics_summaries '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']) @api_v3.route('/plugins/metrics/<plugin_id>', methods=['GET'])
def get_plugin_metrics_single(plugin_id): def get_plugin_metrics_single(plugin_id):
"""Get resource metrics for a specific plugin""" """Get resource metrics for a specific plugin"""
try:
if not api_v3.plugin_manager: if not api_v3.plugin_manager:
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
@@ -325,13 +305,9 @@ def get_plugin_metrics_single(plugin_id):
'status': 'success', 'status': 'success',
'data': metrics_summary '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']) @api_v3.route('/plugins/metrics/<plugin_id>/reset', methods=['POST'])
def reset_plugin_metrics(plugin_id): def reset_plugin_metrics(plugin_id):
"""Reset metrics for a plugin""" """Reset metrics for a plugin"""
try:
if not api_v3.plugin_manager: if not api_v3.plugin_manager:
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
@@ -349,13 +325,9 @@ def reset_plugin_metrics(plugin_id):
'status': 'success', 'status': 'success',
'message': f'Metrics reset for plugin {plugin_id}' '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']) @api_v3.route('/plugins/limits/<plugin_id>', methods=['GET', 'POST'])
def manage_plugin_limits(plugin_id): def manage_plugin_limits(plugin_id):
"""Get or set resource limits for a plugin""" """Get or set resource limits for a plugin"""
try:
if not api_v3.plugin_manager: if not api_v3.plugin_manager:
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
@@ -403,9 +375,6 @@ def manage_plugin_limits(plugin_id):
'status': 'success', 'status': 'success',
'message': f'Resource limits updated for plugin {plugin_id}' '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']) @api_v3.route('/plugins/toggle', methods=['POST'])
def toggle_plugin(): def toggle_plugin():
"""Toggle plugin enabled/disabled""" """Toggle plugin enabled/disabled"""
@@ -546,14 +515,7 @@ def get_operation_status(operation_id):
return success_response(data=operation.to_dict()) return success_response(data=operation.to_dict())
except Exception as e: except Exception as e:
from src.web_interface.errors import WebInterfaceError return exception_error_response(e, ErrorCode.SYSTEM_ERROR, with_context=False)
error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR)
return error_response(
error.error_code,
error.message,
details=error.details,
status_code=500
)
@api_v3.route('/plugins/operation/history', methods=['GET']) @api_v3.route('/plugins/operation/history', methods=['GET'])
def get_operation_history() -> Response: def get_operation_history() -> Response:
"""Get operation history from the audit log.""" """Get operation history from the audit log."""
@@ -578,9 +540,7 @@ def get_operation_history() -> Response:
operation_type=operation_type operation_type=operation_type
) )
except (AttributeError, RuntimeError) as e: except (AttributeError, RuntimeError) as e:
from src.web_interface.errors import WebInterfaceError return exception_error_response(e, ErrorCode.SYSTEM_ERROR, with_context=False)
error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR)
return error_response(error.error_code, error.message, details=error.details, status_code=500)
return success_response(data=[record.to_dict() for record in history]) return success_response(data=[record.to_dict() for record in history])
@api_v3.route('/plugins/operation/history', methods=['DELETE']) @api_v3.route('/plugins/operation/history', methods=['DELETE'])
@@ -596,9 +556,7 @@ def clear_operation_history() -> Response:
try: try:
api_v3.operation_history.clear_history() api_v3.operation_history.clear_history()
except (OSError, RuntimeError) as e: except (OSError, RuntimeError) as e:
from src.web_interface.errors import WebInterfaceError return exception_error_response(e, ErrorCode.SYSTEM_ERROR, with_context=False)
error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR)
return error_response(error.error_code, error.message, details=error.details, status_code=500)
return success_response(message='Operation history cleared') return success_response(message='Operation history cleared')
@api_v3.route('/plugins/state', methods=['GET']) @api_v3.route('/plugins/state', methods=['GET'])
@@ -633,15 +591,7 @@ def get_plugin_state():
for plugin_id, state in all_states.items() for plugin_id, state in all_states.items()
}) })
except Exception as e: except Exception as e:
from src.web_interface.errors import WebInterfaceError return exception_error_response(e, ErrorCode.SYSTEM_ERROR)
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
)
@api_v3.route('/plugins/state/reconcile', methods=['POST']) @api_v3.route('/plugins/state/reconcile', methods=['POST'])
def reconcile_plugin_state(): def reconcile_plugin_state():
"""Reconcile plugin state across all sources""" """Reconcile plugin state across all sources"""
@@ -705,15 +655,7 @@ def reconcile_plugin_state():
message=result.message message=result.message
) )
except Exception as e: except Exception as e:
from src.web_interface.errors import WebInterfaceError return exception_error_response(e, ErrorCode.SYSTEM_ERROR)
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
)
def _drop_stale_reconciliation_findings(unresolved): def _drop_stale_reconciliation_findings(unresolved):
"""Re-check a stored reconciliation verdict against current state. """Re-check a stored reconciliation verdict against current state.
@@ -915,15 +857,7 @@ def get_plugin_config():
return success_response(data=plugin_config) return success_response(data=plugin_config)
except Exception as e: except Exception as e:
from src.web_interface.errors import WebInterfaceError return exception_error_response(e, ErrorCode.CONFIG_LOAD_FAILED)
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
)
@api_v3.route('/plugins/update', methods=['POST']) @api_v3.route('/plugins/update', methods=['POST'])
def update_plugin(): def update_plugin():
"""Update plugin""" """Update plugin"""
@@ -1168,8 +1102,6 @@ def update_plugin():
except Exception as e: except Exception as e:
logger.error("Unhandled exception in update endpoint", exc_info=True) 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: if api_v3.operation_history:
api_v3.operation_history.record_operation( api_v3.operation_history.record_operation(
"update", "update",
@@ -1177,13 +1109,7 @@ def update_plugin():
status="failed", status="failed",
error=str(e) error=str(e)
) )
return error_response( return exception_error_response(e, ErrorCode.PLUGIN_UPDATE_FAILED)
error.error_code,
error.message,
details=error.details,
context=error.context,
status_code=500
)
@api_v3.route('/plugins/uninstall', methods=['POST']) @api_v3.route('/plugins/uninstall', methods=['POST'])
def uninstall_plugin(): def uninstall_plugin():
"""Uninstall plugin""" """Uninstall plugin"""
@@ -1266,8 +1192,6 @@ def uninstall_plugin():
) )
except Exception as e: 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: if api_v3.operation_history:
api_v3.operation_history.record_operation( api_v3.operation_history.record_operation(
"uninstall", "uninstall",
@@ -1275,17 +1199,10 @@ def uninstall_plugin():
status="failed", status="failed",
error=str(e) error=str(e)
) )
return error_response( return exception_error_response(e, ErrorCode.PLUGIN_UNINSTALL_FAILED)
error.error_code,
error.message,
details=error.details,
context=error.context,
status_code=500
)
@api_v3.route('/plugins/install', methods=['POST']) @api_v3.route('/plugins/install', methods=['POST'])
def install_plugin(): def install_plugin():
"""Install plugin from store""" """Install plugin from store"""
try:
if not api_v3.plugin_store_manager: if not api_v3.plugin_store_manager:
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
@@ -1423,13 +1340,9 @@ def install_plugin():
status_code=500 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']) @api_v3.route('/plugins/install-from-url', methods=['POST'])
def install_plugin_from_url(): def install_plugin_from_url():
"""Install plugin from custom GitHub URL""" """Install plugin from custom GitHub URL"""
try:
if not api_v3.plugin_store_manager: if not api_v3.plugin_store_manager:
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 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') 'message': result.get('error', 'Failed to install plugin from URL')
}), 500 }), 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']) @api_v3.route('/plugins/registry-from-url', methods=['POST'])
def get_registry_from_url(): def get_registry_from_url():
"""Get plugin list from a registry-style monorepo URL""" """Get plugin list from a registry-style monorepo URL"""
try:
if not api_v3.plugin_store_manager: if not api_v3.plugin_store_manager:
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 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' 'message': 'Failed to fetch registry from URL or URL does not contain a valid registry'
}), 400 }), 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']) @api_v3.route('/plugins/saved-repositories', methods=['GET'])
def get_saved_repositories(): def get_saved_repositories():
"""Get all saved repositories""" """Get all saved repositories"""
try:
if not api_v3.saved_repositories_manager: if not api_v3.saved_repositories_manager:
return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500
repositories = api_v3.saved_repositories_manager.get_all() repositories = api_v3.saved_repositories_manager.get_all()
return jsonify({'status': 'success', 'data': {'repositories': repositories}}) 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']) @api_v3.route('/plugins/saved-repositories', methods=['POST'])
def add_saved_repository(): def add_saved_repository():
"""Add a repository to saved list""" """Add a repository to saved list"""
try:
if not api_v3.saved_repositories_manager: if not api_v3.saved_repositories_manager:
return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500
@@ -1566,13 +1467,9 @@ def add_saved_repository():
'status': 'error', 'status': 'error',
'message': 'Repository already exists or failed to save' 'message': 'Repository already exists or failed to save'
}), 400 }), 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']) @api_v3.route('/plugins/saved-repositories', methods=['DELETE'])
def remove_saved_repository(): def remove_saved_repository():
"""Remove a repository from saved list""" """Remove a repository from saved list"""
try:
if not api_v3.saved_repositories_manager: if not api_v3.saved_repositories_manager:
return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500
@@ -1595,13 +1492,9 @@ def remove_saved_repository():
'status': 'error', 'status': 'error',
'message': 'Repository not found' 'message': 'Repository not found'
}), 404 }), 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']) @api_v3.route('/plugins/store/list', methods=['GET'])
def list_plugin_store(): def list_plugin_store():
"""Search plugin store""" """Search plugin store"""
try:
if not api_v3.plugin_store_manager: if not api_v3.plugin_store_manager:
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 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}}) 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']) @api_v3.route('/plugins/store/github-status', methods=['GET'])
def get_github_auth_status(): def get_github_auth_status():
"""Check if GitHub authentication is configured and validate token""" """Check if GitHub authentication is configured and validate token"""
try:
if not api_v3.plugin_store_manager: if not api_v3.plugin_store_manager:
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
@@ -1699,13 +1588,9 @@ def get_github_auth_status():
'error': error_message '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']) @api_v3.route('/plugins/store/refresh', methods=['POST'])
def refresh_plugin_store(): def refresh_plugin_store():
"""Refresh plugin store repository""" """Refresh plugin store repository"""
try:
if not api_v3.plugin_store_manager: if not api_v3.plugin_store_manager:
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
@@ -1725,9 +1610,6 @@ def refresh_plugin_store():
'message': message, 'message': message,
'plugin_count': plugin_count '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']) @api_v3.route('/plugins/config', methods=['POST'])
def save_plugin_config(): def save_plugin_config():
"""Save plugin configuration, separating secrets from regular config""" """Save plugin configuration, separating secrets from regular config"""
@@ -2308,8 +2190,6 @@ def save_plugin_config():
return success_response(message=message) return success_response(message=message)
except Exception as e: 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: if api_v3.operation_history:
api_v3.operation_history.record_operation( api_v3.operation_history.record_operation(
"configure", "configure",
@@ -2317,13 +2197,7 @@ def save_plugin_config():
status="failed", status="failed",
error=str(e) error=str(e)
) )
return error_response( return exception_error_response(e, ErrorCode.CONFIG_SAVE_FAILED)
error.error_code,
error.message,
details=error.details,
context=error.context,
status_code=500
)
def _merge_onto_stored_plugin_config(plugin_id, submitted_config, current_config=None): 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. """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']) @api_v3.route('/plugins/schema', methods=['GET'])
def get_plugin_schema(): def get_plugin_schema():
"""Get plugin configuration schema""" """Get plugin configuration schema"""
try:
plugin_id = request.args.get('plugin_id') plugin_id = request.args.get('plugin_id')
if not plugin_id: if not plugin_id:
return jsonify({'status': 'error', 'message': 'plugin_id required'}), 400 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}}) 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']) @api_v3.route('/plugins/config/reset', methods=['POST'])
def reset_plugin_config(): def reset_plugin_config():
"""Reset plugin configuration to schema defaults""" """Reset plugin configuration to schema defaults"""
try:
if not api_v3.config_manager: if not api_v3.config_manager:
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 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', 'message': f'Plugin {plugin_id} configuration reset to defaults',
'data': {'config': 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']) @api_v3.route('/plugins/action', methods=['POST'])
def execute_plugin_action(): def execute_plugin_action():
"""Execute a plugin-defined action (e.g., authentication)""" """Execute a plugin-defined action (e.g., authentication)"""
@@ -2868,8 +2734,10 @@ def execute_plugin_action():
try: try:
data = request.get_json(force=True) or {} data = request.get_json(force=True) or {}
except Exception as e: except Exception as e:
import logging # The module logger, not a local one: binding `logger` anywhere in
logger = logging.getLogger(__name__) # 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}") logger.error(f"Error parsing JSON in execute_plugin_action: {e}")
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -3172,9 +3040,6 @@ sys.exit(proc.returncode)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return jsonify({'status': 'error', 'message': 'Action timed out'}), 408 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): def _plugin_uploads_dir(plugin_id):
"""assets/plugins/<plugin_id>/uploads for a request-supplied id, or None. """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']) @api_v3.route('/plugins/assets/upload', methods=['POST'])
def upload_plugin_asset(): def upload_plugin_asset():
"""Upload asset files for a plugin""" """Upload asset files for a plugin"""
try:
plugin_id = request.form.get('plugin_id') plugin_id = request.form.get('plugin_id')
if not plugin_id: if not plugin_id:
return jsonify({'status': 'error', 'message': 'plugin_id is required'}), 400 return jsonify({'status': 'error', 'message': 'plugin_id is required'}), 400
@@ -3332,9 +3196,6 @@ def upload_plugin_asset():
'total_files': len(metadata) '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']) @api_v3.route('/plugins/<plugin_id>/static/<path:file_path>', methods=['GET'])
def serve_plugin_static(plugin_id, file_path): def serve_plugin_static(plugin_id, file_path):
"""Serve static files from plugin directory. """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-evil/x``, whose string does start with
``plugin-repos/foo``. ``plugin-repos/foo``.
""" """
try:
safe_plugin_id = safe_path_component(plugin_id) safe_plugin_id = safe_path_component(plugin_id)
if not safe_plugin_id: if not safe_plugin_id:
return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400 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) 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']) @api_v3.route('/plugins/calendar/upload-credentials', methods=['POST'])
def upload_calendar_credentials(): def upload_calendar_credentials():
"""Upload credentials.json file for calendar plugin""" """Upload credentials.json file for calendar plugin"""
try:
if 'file' not in request.files: if 'file' not in request.files:
return jsonify({'status': 'error', 'message': 'No file provided'}), 400 return jsonify({'status': 'error', 'message': 'No file provided'}), 400
@@ -3476,9 +3332,6 @@ def upload_calendar_credentials():
'path': str(credentials_path) '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']) @api_v3.route('/plugins/calendar/authenticate', methods=['POST'])
def authenticate_calendar(): def authenticate_calendar():
"""Google OAuth for the calendar plugin, in the two steps it requires. """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 The script persists the PKCE verifier from step 1 for step 2 to reuse; the
exchange fails with "Missing code verifier" otherwise. exchange fails with "Missing code verifier" otherwise.
""" """
try:
plugin_dir = _pkg._calendar_plugin_dir() plugin_dir = _pkg._calendar_plugin_dir()
if plugin_dir is None: if plugin_dir is None:
return jsonify({ return jsonify({
@@ -3525,11 +3377,6 @@ def authenticate_calendar():
return jsonify(safe), 400 return jsonify(safe), 400
return jsonify(payload) 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']) @api_v3.route('/plugins/calendar/list-calendars', methods=['GET'])
def list_calendar_calendars(): def list_calendar_calendars():
"""The calendars this account can see, for the config picker. """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 picker is used interactively and a subprocess per click is slower than the
API call it would be wrapping. API call it would be wrapping.
""" """
try:
plugin_dir = _pkg._calendar_plugin_dir() plugin_dir = _pkg._calendar_plugin_dir()
if plugin_dir is None: if plugin_dir is None:
return jsonify({ return jsonify({
@@ -3623,15 +3469,9 @@ def list_calendar_calendars():
return jsonify({'status': 'success', 'calendars': 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']) @api_v3.route('/plugins/assets/delete', methods=['POST'])
def delete_plugin_asset(): def delete_plugin_asset():
"""Delete an asset file for a plugin""" """Delete an asset file for a plugin"""
try:
data = request.get_json() data = request.get_json()
plugin_id = data.get('plugin_id') plugin_id = data.get('plugin_id')
image_id = data.get('image_id') image_id = data.get('image_id')
@@ -3677,13 +3517,9 @@ def delete_plugin_asset():
return jsonify({'status': 'success', 'message': 'Image deleted successfully'}) 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']) @api_v3.route('/plugins/assets/list', methods=['GET'])
def list_plugin_assets(): def list_plugin_assets():
"""List asset files for a plugin""" """List asset files for a plugin"""
try:
plugin_id = request.args.get('plugin_id') plugin_id = request.args.get('plugin_id')
if not plugin_id: if not plugin_id:
return jsonify({'status': 'error', 'message': 'plugin_id is required'}), 400 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}}) 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']) @api_v3.route('/system/status', methods=['GET'])
def get_system_status(): def get_system_status():
"""Get system status""" """Get system status"""
try:
# Check cache first (10 second TTL for system status) # Check cache first (10 second TTL for system status)
try: try:
from web_interface.cache import get_cached, set_cached from web_interface.cache import get_cached, set_cached
@@ -110,9 +109,6 @@ def get_system_status():
pass # Cache write failed, but continue pass # Cache write failed, but continue
return jsonify({'status': 'success', 'data': status}) 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']) @api_v3.route('/system/version', methods=['GET'])
def get_system_version(): def get_system_version():
"""Get LEDMatrix repository version""" """Get LEDMatrix repository version"""
-68
View File
@@ -111,7 +111,6 @@ def _parse_bool_ish(value):
@api_v3.route('/wifi/status', methods=['GET']) @api_v3.route('/wifi/status', methods=['GET'])
def get_wifi_status(): def get_wifi_status():
"""Get current WiFi connection status""" """Get current WiFi connection status"""
try:
from src.wifi_manager import WiFiManager from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager() wifi_manager = WiFiManager()
@@ -132,13 +131,6 @@ def get_wifi_status():
'last_connect_attempt': _last_connect_snapshot(), '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']) @api_v3.route('/wifi/scan', methods=['GET'])
def scan_wifi_networks(): def scan_wifi_networks():
"""Scan for available 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. background (see _last_connect_attempt); otherwise it waits for the result.
""" """
global _last_connect_attempt global _last_connect_attempt
try:
from src.wifi_manager import WiFiManager from src.wifi_manager import WiFiManager
data = request.get_json(silent=True) data = request.get_json(silent=True)
@@ -290,16 +281,9 @@ def connect_wifi():
payload = _connect_result_payload(ssid, success, message) payload = _connect_result_payload(ssid, success, message)
_record_connect_result(ssid, payload) _record_connect_result(ssid, payload)
return jsonify(payload), (200 if success else 400) 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']) @api_v3.route('/wifi/disconnect', methods=['POST'])
def disconnect_wifi(): def disconnect_wifi():
"""Disconnect from the current WiFi network""" """Disconnect from the current WiFi network"""
try:
from src.wifi_manager import WiFiManager from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager() wifi_manager = WiFiManager()
@@ -315,16 +299,9 @@ def disconnect_wifi():
'status': 'error', 'status': 'error',
'message': message or 'Failed to disconnect from network' 'message': message or 'Failed to disconnect from network'
}), 400 }), 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']) @api_v3.route('/wifi/ap/enable', methods=['POST'])
def enable_ap_mode(): def enable_ap_mode():
"""Enable access point mode""" """Enable access point mode"""
try:
from src.wifi_manager import WiFiManager from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager() wifi_manager = WiFiManager()
@@ -342,17 +319,9 @@ def enable_ap_mode():
'status': 'error', 'status': 'error',
'message': message 'message': message
}), 400 }), 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']) @api_v3.route('/wifi/ap/disable', methods=['POST'])
def disable_ap_mode(): def disable_ap_mode():
"""Disable access point mode""" """Disable access point mode"""
try:
from src.wifi_manager import WiFiManager from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager() wifi_manager = WiFiManager()
@@ -368,17 +337,9 @@ def disable_ap_mode():
'status': 'error', 'status': 'error',
'message': message 'message': message
}), 400 }), 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']) @api_v3.route('/wifi/ap/auto-enable', methods=['GET'])
def get_auto_enable_ap_mode(): def get_auto_enable_ap_mode():
"""Get auto-enable AP mode setting""" """Get auto-enable AP mode setting"""
try:
from src.wifi_manager import WiFiManager from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager() wifi_manager = WiFiManager()
@@ -390,17 +351,9 @@ def get_auto_enable_ap_mode():
'auto_enable_ap_mode': auto_enable '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']) @api_v3.route('/wifi/ap/auto-enable', methods=['POST'])
def set_auto_enable_ap_mode(): def set_auto_enable_ap_mode():
"""Set auto-enable AP mode setting""" """Set auto-enable AP mode setting"""
try:
from src.wifi_manager import WiFiManager from src.wifi_manager import WiFiManager
data = request.get_json(silent=True) data = request.get_json(silent=True)
@@ -428,17 +381,9 @@ def set_auto_enable_ap_mode():
'auto_enable_ap_mode': auto_enable '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']) @api_v3.route('/wifi/radio', methods=['GET'])
def get_wifi_radio(): def get_wifi_radio():
"""Get current WiFi radio state (enabled/disabled) and wired-fallback status.""" """Get current WiFi radio state (enabled/disabled) and wired-fallback status."""
try:
from src.wifi_manager import WiFiManager from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager() wifi_manager = WiFiManager()
@@ -448,12 +393,6 @@ def get_wifi_radio():
'status': 'success', 'status': 'success',
'data': state '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']) @api_v3.route('/wifi/radio', methods=['POST'])
def set_wifi_radio(): def set_wifi_radio():
"""Turn the WiFi radio on or off. """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 unless Ethernet is connected or force=True, to avoid locking the user out
of this web interface. of this web interface.
""" """
try:
from src.wifi_manager import WiFiManager from src.wifi_manager import WiFiManager
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
@@ -506,9 +444,3 @@ def set_wifi_radio():
'message': message, 'message': message,
'reason': reason 'reason': reason
}), 400 }), 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