wrolpi/modules/inventory/api.py
Roland 6a46cfd17d
Add manual import + backup Restore/Merge/Overwrite for inventory configs
Inventories are a config-only MultiFileConfig (config/inventory/<slug>.yaml) and
were not reachable by the admin config Restore UI, so there was no way to re-import
a hand-edited/copied-in file or restore an inventory from its daily backup.

Backend:
- MultiFileConfig gains generic helpers: get_backup_dates(slug)/_get_backup_file
  (surface the daily config/backup/<subdir>/<slug>-DATE.yaml files) and reimport()
  (rebuild the in-memory store from disk; picks up hand-edits and copied-in files,
  drops files deleted on disk).  backup_date is constrained to YYYYMMDD so it cannot
  traverse out of the backup directory.
- InventoriesConfig.preview_restore/apply_restore: overwrite replaces the whole
  inventory; merge unions items by id (keeps current fields and existing items).
  The pre-restore state is backed up automatically, so a restore is undoable.
- API: POST /inventory/reimport, GET /inventory/<slug>/backups,
  POST /inventory/<slug>/restore/preview, POST /inventory/<slug>/restore.

Frontend:
- New InventoryImportModal (opened from a Restore button in the Inventory toolbar):
  Re-import from disk, plus a dated-backup table with per-row Merge/Overwrite that
  previews the change before applying.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:28:11 -06:00

117 lines
4.6 KiB
Python

from http import HTTPStatus
from sanic import response, Blueprint
from sanic.request import Request
from sanic_ext import validate
from sanic_ext.extensions.openapi import openapi
from modules.inventory import schema
from modules.inventory.common import get_inventory_configs
from modules.inventory.catalog import get_catalog_config, save_catalog_items
from modules.inventory.errors import UnknownInventory
from wrolpi.api_utils import json_response
NAME = 'inventory'
inventory_bp = Blueprint('Inventory', '/api/inventory')
# Inventory is config-only. The frontend loads every inventory once (GET /), derives everything (selected items,
# summary, location suggestions, ration data) client-side, and persists whole inventories (PUT /<slug>). There are
# deliberately no granular item/field/suggestion endpoints.
@inventory_bp.get('/')
@openapi.description('Get every inventory in full (fields and items).')
def get_inventories(_: Request):
inventories = get_inventory_configs().all_inventories()
return json_response(dict(inventories=inventories))
@inventory_bp.post('/')
@openapi.definition(
summary='Create a new inventory',
body=schema.InventoryPostRequest,
)
@validate(schema.InventoryPostRequest)
def post_inventory(_: Request, body: schema.InventoryPostRequest):
inventory = get_inventory_configs().create_inventory(body.name, body.type or 'food')
return json_response(dict(inventory=inventory), HTTPStatus.CREATED)
@inventory_bp.post('/reimport')
@openapi.description('Re-read every inventory config file from disk (picks up hand-edits and copied-in files).')
def post_inventory_reimport(_: Request):
inventories = get_inventory_configs().reimport()
inventories = sorted(inventories, key=lambda i: (i.get('name') or '').lower())
return json_response(dict(inventories=inventories))
# Shared food catalog (static routes, declared before the dynamic /<slug> routes).
@inventory_bp.get('/catalog')
@openapi.description('Get the shared food catalog entries.')
def get_catalog(_: Request):
return json_response(dict(catalog=get_catalog_config().items))
@inventory_bp.put('/catalog')
@openapi.description('Replace the shared food catalog (whole-list save).')
def put_catalog(request: Request):
items = (request.json or {}).get('items', [])
return json_response(dict(catalog=save_catalog_items(items)))
@inventory_bp.put('/<slug:str>')
@openapi.description('Replace an inventory (name / fields / items) in one request.')
def put_inventory(request: Request, slug: str):
if get_inventory_configs().get_inventory(slug) is None:
raise UnknownInventory(f'No inventory: {slug}')
body = request.json or {}
expected_version = body.get('version')
inventory = get_inventory_configs().save_inventory(slug, body, expected_version=expected_version)
return json_response(dict(inventory=inventory))
@inventory_bp.delete('/<slug:str>')
@openapi.description('Delete an inventory.')
def inventory_delete(_: Request, slug: str):
if get_inventory_configs().get_inventory(slug) is None:
raise UnknownInventory(f'No inventory: {slug}')
get_inventory_configs().delete_inventory(slug)
return response.empty()
@inventory_bp.get('/<slug:str>/backups')
@openapi.description('List the dated backups available for an inventory, newest first.')
def get_inventory_backups(_: Request, slug: str):
config = get_inventory_configs()
if config.get_inventory(slug) is None:
raise UnknownInventory(f'No inventory: {slug}')
return json_response(dict(dates=config.get_backup_dates(slug)))
@inventory_bp.post('/<slug:str>/restore/preview')
@openapi.definition(
summary='Preview restoring an inventory from a backup',
body=schema.InventoryRestoreRequest,
)
@validate(schema.InventoryRestoreRequest)
def post_inventory_restore_preview(_: Request, slug: str, body: schema.InventoryRestoreRequest):
config = get_inventory_configs()
if config.get_inventory(slug) is None:
raise UnknownInventory(f'No inventory: {slug}')
preview = config.preview_restore(slug, body.backup_date, body.mode)
return json_response(dict(preview=preview))
@inventory_bp.post('/<slug:str>/restore')
@openapi.definition(
summary='Restore an inventory from a backup (merge or overwrite)',
body=schema.InventoryRestoreRequest,
)
@validate(schema.InventoryRestoreRequest)
def post_inventory_restore(_: Request, slug: str, body: schema.InventoryRestoreRequest):
config = get_inventory_configs()
if config.get_inventory(slug) is None:
raise UnknownInventory(f'No inventory: {slug}')
inventory = config.apply_restore(slug, body.backup_date, body.mode)
return json_response(dict(inventory=inventory))