mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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>
112 lines
4.3 KiB
Python
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'
|