wrolpi/modules/inventory/defaults.py
Roland 3d6aead072
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled
Rebuild Inventory as a config-only system with food catalog and calculators
Replace the DB-backed inventory module with a config-only design and build out
food-storage tooling around it.

Inventory (config-only, no database)
- Each inventory is a YAML file at config/inventory/<slug>.yaml, loaded once
  into memory and the single source of truth. New reusable MultiFileConfig
  (wrolpi/common.py) manages a directory of per-entity files.
- Inventory types (food/fuel/tool) seed a customizable field schema
  (text/number/quantity/date/select/location/calories). Unit math/aggregation
  moved to the frontend (mathjs); Pint dropped from the backend.
- Minimal API: GET / (all inventories full), POST /, PUT /<slug> (whole-
  inventory save with version-based optimistic concurrency; 404 for missing),
  DELETE. Legacy inventories.yaml / old tables migrate to per-inventory files
  (pound->lb), and an alembic migration drops the tables.

Frontend
- New app/src/components/inventory/ package: spreadsheet-style table with
  Tab/Enter keyboard entry, sortable columns, expired-row highlighting,
  customizable field editor, and pooled cross-inventory location autocomplete.
- A single useInventories store loads everything once and persists whole
  inventories optimistically; list/items/summary/locations derive client-side.
- Summary groups items (default by Category), totals size x count, shows a
  calories column, and embeds a ration estimate (how long the stock lasts).

Food catalog
- Shared, editable catalog (config/inventory/catalog.yaml) seeded from
  catalog_defaults.yaml (~60 generic items, total calories per package) with
  merge-keep-edits + tombstones. GET/PUT /api/inventory/catalog, a management
  modal, and entry-row pre-fill of category/size/calories from a name match.

Calculators
- Generic Food Storage planner (like the Water calculator): household +
  editable per-person/year amounts -> total to store over a chosen duration,
  with women/children weighted by DGA calorie shares and an imperial/metric
  toggle.

Also fixes ConfigFile.save() to accept nested file_names. Backend pytest and
frontend jest suites updated/added throughout; alembic check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:14:54 -06:00

112 lines
4.3 KiB
Python

"""Default field schemas and category lists for the inventory module.
An inventory's ``type`` only seeds its initial ``fields`` — afterwards the user owns the schema and may
add/remove/reorder/retype fields freely. Quantity-field default units use mathjs-compatible names (e.g. ``lb``,
not ``pound``); all unit math happens in the frontend via mathjs.
"""
import re
# Supported field types. The backend treats every value as an opaque string/number; only the frontend interprets
# `quantity` (magnitude + `<key>_unit`), `date`, `select` (enum `options`), `location`, and `calories` (a number of
# kcal-per-unit that the Summary's ration estimate detects by type).
FIELD_TYPES = ('text', 'number', 'quantity', 'date', 'select', 'location', 'calories')
def _field(key, label, type_, order, **kwargs):
d = dict(key=key, label=label, type=type_, order=order)
d.update({k: v for k, v in kwargs.items() if v is not None})
return d
# Food storage categories (subcategory, category). Used to seed the `category` select field's options and for
# category suggestions in the UI.
DEFAULT_FOOD_CATEGORIES = [
('salt', 'cooking ingredients'),
('dry milk', 'dairy'),
('evaporated milk', 'dairy'),
('freeze dried', 'dairy'),
('shortening', 'fats'),
('vegetable oil', 'fats'),
('canned', 'fruits'),
('dehydrated', 'fruits'),
('freeze-dried', 'fruits'),
('corn meal', 'grains'),
('flour', 'grains'),
('oats', 'grains'),
('pasta', 'grains'),
('rice', 'grains'),
('wheat', 'grains'),
('dry beans', 'legumes'),
('lentils', 'legumes'),
('canned', 'meats'),
('dried', 'meats'),
('freeze dried', 'meals'),
('brown sugar', 'sugars'),
('corn syrup', 'sugars'),
('honey', 'sugars'),
('juice mix', 'sugars'),
('white sugar', 'sugars'),
('canned', 'vegetables'),
('dehydrated', 'vegetables'),
('freeze dried', 'vegetables'),
('water', 'water'),
('bottled', 'water'),
('barrel', 'water'),
]
_FOOD_CATEGORY_OPTIONS = sorted({category for _, category in DEFAULT_FOOD_CATEGORIES})
DEFAULT_FIELD_SETS = {
'food': [
_field('brand', 'Brand', 'text', 0),
_field('name', 'Name', 'text', 1, required=True),
_field('category', 'Category', 'select', 2, options=_FOOD_CATEGORY_OPTIONS),
_field('subcategory', 'Subcategory', 'text', 3),
_field('item_size', 'Size', 'quantity', 4, unit='lb'),
_field('count', 'Count', 'number', 5),
_field('calories', 'kcal per unit', 'calories', 6),
_field('expiration_date', 'Expires', 'date', 7),
],
'fuel': [
_field('name', 'Name', 'text', 0, required=True),
_field('fuel_type', 'Fuel Type', 'select', 1,
options=['gasoline', 'diesel', 'propane', 'kerosene', 'oil']),
_field('container', 'Container', 'text', 2),
_field('item_size', 'Size', 'quantity', 3, unit='gallon'),
_field('count', 'Count', 'number', 4),
_field('purchase_date', 'Purchased', 'date', 5),
_field('location', 'Location', 'location', 6),
],
'tool': [
_field('name', 'Name', 'text', 0, required=True),
_field('brand', 'Brand', 'text', 1),
_field('category', 'Category', 'select', 2,
options=['hand', 'power', 'garden', 'automotive', 'measuring', 'safety']),
_field('count', 'Count', 'number', 3),
_field('condition', 'Condition', 'select', 4, options=['new', 'good', 'worn', 'broken']),
_field('location', 'Location', 'location', 5),
_field('notes', 'Notes', 'text', 6),
],
}
# The first/default type used when a type is not specified (e.g. legacy migration).
DEFAULT_TYPE = 'food'
INVENTORY_TYPES = tuple(DEFAULT_FIELD_SETS.keys())
# A brand-new install seeds one example inventory of each kind so the feature is discoverable.
DEFAULT_INVENTORIES = [
dict(name='Food Storage', type='food'),
]
def default_fields(inventory_type: str) -> list:
"""Return a fresh copy of the default field set for the given type (falls back to food)."""
from copy import deepcopy
return deepcopy(DEFAULT_FIELD_SETS.get(inventory_type, DEFAULT_FIELD_SETS[DEFAULT_TYPE]))
def slugify(name: str) -> str:
"""Convert an inventory name to a filesystem-safe slug used as its config file stem."""
slug = re.sub(r'[^a-z0-9]+', '-', (name or '').strip().lower()).strip('-')
return slug or 'inventory'