mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 03:27:43 +09:00
328 more routes used a JSON body as a dict straight from
request.get_json(), so a body of null, [] or a bare string crashed them
into a 500. Bare reads become json_object(); the
'get_json(silent=True) or {}' reads of action routes become
json_object(optional=True), which still treats a missing body as {}.
Routes that take arrays or pass the body through untouched are left
alone. The full backend suite shows no new failures.
Also carries three files' bare section citations (apps, backups,
notifications) and the extended citation guard, which needed them.
225 lines
8.0 KiB
Python
225 lines
8.0 KiB
Python
"""Dashboard boards API — /api/v1/dashboards.
|
|
|
|
Per-user dashboard tabs and their widget layout. Everything here is scoped to
|
|
the calling user: a board that belongs to somebody else is indistinguishable
|
|
from a board that does not exist, so cross-user access returns 404 rather than
|
|
403 — a 403 would confirm the id is real.
|
|
|
|
``GET /`` seeds the shipped default boards the first time a user asks for
|
|
theirs, so a fresh account lands on a populated dashboard it can then edit,
|
|
rearrange or delete like anything else.
|
|
|
|
The widget payload is validated defensively before it is stored: the panel
|
|
hands this JSON straight back to the grid renderer, so a malformed instance
|
|
would be a broken dashboard the user cannot fix without an API call.
|
|
"""
|
|
import logging
|
|
|
|
from flask import Blueprint, jsonify
|
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
|
|
|
from app.services import dashboard_service
|
|
from app.services.dashboard_service import GRID_COLS, MAX_WIDGETS_PER_BOARD
|
|
from app.api._request import json_object
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
dashboards_bp = Blueprint('dashboards', __name__)
|
|
|
|
# Defensive ceilings on the geometry. The grid is 12 columns wide and rows are
|
|
# 88px; nothing legitimate is thousands of rows tall.
|
|
MAX_ROW = 1000
|
|
MAX_SPAN = 100
|
|
MAX_ID_LEN = 64
|
|
MAX_NAME_LEN = 120
|
|
|
|
|
|
def _validate_widgets(raw):
|
|
"""Normalise a widget-instance list. Returns ``(widgets, error)``.
|
|
|
|
Each instance must be an object carrying a string ``i`` unique within the
|
|
board, a string ``type``, and integer ``x``/``y``/``w``/``h`` that fit the
|
|
12-column grid. ``cfg`` is free-form but must be an object; it is stored
|
|
verbatim because only the widget renderer knows what it means.
|
|
"""
|
|
if not isinstance(raw, list):
|
|
return None, 'widgets must be a list'
|
|
if len(raw) > MAX_WIDGETS_PER_BOARD:
|
|
return None, f'A board holds at most {MAX_WIDGETS_PER_BOARD} widgets'
|
|
|
|
seen = set()
|
|
out = []
|
|
for index, item in enumerate(raw):
|
|
if not isinstance(item, dict):
|
|
return None, f'widget {index} must be an object'
|
|
|
|
wid = item.get('i')
|
|
if not isinstance(wid, str) or not wid.strip():
|
|
return None, f'widget {index} needs a string id "i"'
|
|
if len(wid) > MAX_ID_LEN:
|
|
return None, f'widget {index} id is too long'
|
|
if wid in seen:
|
|
return None, f'duplicate widget id "{wid}"'
|
|
seen.add(wid)
|
|
|
|
wtype = item.get('type')
|
|
if not isinstance(wtype, str) or not wtype.strip():
|
|
return None, f'widget "{wid}" needs a string type'
|
|
if len(wtype) > MAX_ID_LEN:
|
|
return None, f'widget "{wid}" type is too long'
|
|
|
|
geometry = {}
|
|
for field in ('x', 'y', 'w', 'h'):
|
|
value = item.get(field)
|
|
# bool is a subclass of int in Python; True is not a coordinate.
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
return None, f'widget "{wid}" needs an integer {field}'
|
|
geometry[field] = value
|
|
|
|
if geometry['x'] < 0 or geometry['y'] < 0:
|
|
return None, f'widget "{wid}" cannot have negative coordinates'
|
|
if geometry['w'] < 1 or geometry['h'] < 1:
|
|
return None, f'widget "{wid}" must span at least one cell'
|
|
if geometry['x'] + geometry['w'] > GRID_COLS:
|
|
return None, f'widget "{wid}" does not fit the {GRID_COLS}-column grid'
|
|
if geometry['y'] > MAX_ROW or geometry['h'] > MAX_SPAN:
|
|
return None, f'widget "{wid}" is out of bounds'
|
|
|
|
cfg = item.get('cfg')
|
|
if cfg is None:
|
|
cfg = {}
|
|
if not isinstance(cfg, dict):
|
|
return None, f'widget "{wid}" cfg must be an object'
|
|
|
|
out.append({
|
|
'i': wid,
|
|
'type': wtype,
|
|
'x': geometry['x'],
|
|
'y': geometry['y'],
|
|
'w': geometry['w'],
|
|
'h': geometry['h'],
|
|
'cfg': cfg,
|
|
})
|
|
|
|
return out, None
|
|
|
|
|
|
def _validate_name(value):
|
|
if not isinstance(value, str) or not value.strip():
|
|
return None, 'name is required'
|
|
name = value.strip()
|
|
if len(name) > MAX_NAME_LEN:
|
|
return None, f'name must be {MAX_NAME_LEN} characters or fewer'
|
|
return name, None
|
|
|
|
|
|
def _validate_icon(value):
|
|
if not isinstance(value, str) or not value.strip():
|
|
return None, 'icon must be a string'
|
|
icon = value.strip()
|
|
if len(icon) > MAX_ID_LEN:
|
|
return None, f'icon must be {MAX_ID_LEN} characters or fewer'
|
|
return icon, None
|
|
|
|
|
|
@dashboards_bp.route('', methods=['GET'])
|
|
@dashboards_bp.route('/', methods=['GET'])
|
|
@jwt_required()
|
|
def list_boards():
|
|
"""This user's boards, seeding the shipped defaults on the first call."""
|
|
user_id = get_jwt_identity()
|
|
boards = dashboard_service.get_boards_for_user(user_id)
|
|
return jsonify({'boards': [b.to_dict() for b in boards]})
|
|
|
|
|
|
@dashboards_bp.route('', methods=['POST'])
|
|
@dashboards_bp.route('/', methods=['POST'])
|
|
@jwt_required()
|
|
def create_board():
|
|
"""Add a board for this user. ``widgets`` is optional — a new tab usually
|
|
starts empty and gets filled from the widget library."""
|
|
user_id = get_jwt_identity()
|
|
body = json_object(optional=True)
|
|
|
|
name, err = _validate_name(body.get('name'))
|
|
if err:
|
|
return jsonify({'error': err}), 400
|
|
|
|
icon = 'grid'
|
|
if body.get('icon') is not None:
|
|
icon, err = _validate_icon(body.get('icon'))
|
|
if err:
|
|
return jsonify({'error': err}), 400
|
|
|
|
widgets = []
|
|
if body.get('widgets') is not None:
|
|
widgets, err = _validate_widgets(body.get('widgets'))
|
|
if err:
|
|
return jsonify({'error': err}), 400
|
|
|
|
board = dashboard_service.create_board(user_id, name, icon=icon, widgets=widgets)
|
|
return jsonify(board.to_dict()), 201
|
|
|
|
|
|
@dashboards_bp.route('/<int:board_id>', methods=['PUT'])
|
|
@jwt_required()
|
|
def update_board(board_id):
|
|
"""Rename / re-icon / reorder a board, or store a new widget layout."""
|
|
user_id = get_jwt_identity()
|
|
board = dashboard_service.get_board_for_user(user_id, board_id)
|
|
if not board:
|
|
return jsonify({'error': 'Board not found'}), 404
|
|
|
|
body = json_object(optional=True)
|
|
name = icon = position = widgets = None
|
|
|
|
if body.get('name') is not None:
|
|
name, err = _validate_name(body.get('name'))
|
|
if err:
|
|
return jsonify({'error': err}), 400
|
|
|
|
if body.get('icon') is not None:
|
|
icon, err = _validate_icon(body.get('icon'))
|
|
if err:
|
|
return jsonify({'error': err}), 400
|
|
|
|
if body.get('position') is not None:
|
|
position = body.get('position')
|
|
if isinstance(position, bool) or not isinstance(position, int) or position < 0:
|
|
return jsonify({'error': 'position must be a non-negative integer'}), 400
|
|
|
|
if body.get('widgets') is not None:
|
|
widgets, err = _validate_widgets(body.get('widgets'))
|
|
if err:
|
|
return jsonify({'error': err}), 400
|
|
|
|
board = dashboard_service.update_board(
|
|
board, name=name, icon=icon, position=position, widgets=widgets)
|
|
return jsonify(board.to_dict())
|
|
|
|
|
|
@dashboards_bp.route('/<int:board_id>', methods=['DELETE'])
|
|
@jwt_required()
|
|
def delete_board(board_id):
|
|
user_id = get_jwt_identity()
|
|
board = dashboard_service.get_board_for_user(user_id, board_id)
|
|
if not board:
|
|
return jsonify({'error': 'Board not found'}), 404
|
|
dashboard_service.delete_board(board)
|
|
return jsonify({'success': True}), 200
|
|
|
|
|
|
@dashboards_bp.route('/<int:board_id>/reset', methods=['POST'])
|
|
@jwt_required()
|
|
def reset_board(board_id):
|
|
"""Restore a shipped default board. Boards the user created have no default
|
|
to fall back to, so those are refused rather than silently emptied."""
|
|
user_id = get_jwt_identity()
|
|
board = dashboard_service.get_board_for_user(user_id, board_id)
|
|
if not board:
|
|
return jsonify({'error': 'Board not found'}), 404
|
|
restored = dashboard_service.reset_board(board)
|
|
if not restored:
|
|
return jsonify({'error': 'This board has no shipped default to reset to'}), 400
|
|
return jsonify(restored.to_dict())
|