mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-03 02:26:43 +09:00
The dashboard's layout lived in localStorage as `{id, visible}` — a visibility
toggle wearing a layout's clothes. It carried no geometry, so widget width was
baked into SCSS (`.dash-kpi { grid-column: span 3 }`) and "make the chart wider"
was a code change. And it stayed on one machine.
`DashboardBoard` gives a user real boards: named, ordered, each holding a JSON
array of placed widgets with x/y/w/h and per-instance config. Three defaults
ship — overview, infrastructure, apps & deploys — seeded on a user's first GET
and restorable per board.
Validation is deliberate about the boring cases: geometry must fit the 12-column
grid, ids must be unique within a board, bools are rejected as coordinates
(`bool` subclasses `int`), and a rejected PUT leaves the stored board untouched
rather than half-applied. A board belonging to another user is a 404, not a 403
— there is no reason to confirm it exists.
The shipped defaults are checked by rebuilding their occupancy map cell by cell,
so editing DEFAULT_BOARDS into an overlap fails the suite instead of shipping a
broken first-run layout.
31 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
224 lines
8.0 KiB
Python
224 lines
8.0 KiB
Python
"""Dashboard boards API (plan 62) — /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, request
|
|
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
|
|
|
|
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 = request.get_json(silent=True) or {}
|
|
|
|
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 = request.get_json(silent=True) or {}
|
|
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())
|