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 14:20:10 -06:00
|
|
|
"""Config-only inventory backend.
|
2021-11-01 16:42:52 -06:00
|
|
|
|
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 14:20:10 -06:00
|
|
|
Each inventory is a single YAML file at ``config/inventory/<slug>.yaml`` and is the source-of-truth (no database).
|
|
|
|
|
All files are loaded into one shared, cross-worker dict keyed by slug. Unit math/aggregation happens in the
|
|
|
|
|
frontend (mathjs), so this module treats item field values as opaque strings/numbers.
|
|
|
|
|
"""
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import List, Optional
|
2021-11-01 16:42:52 -06:00
|
|
|
|
2026-06-19 16:13:56 -06:00
|
|
|
from wrolpi.common import logger, MultiFileConfig, read_config_data
|
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 14:20:10 -06:00
|
|
|
from wrolpi.errors import ValidationError
|
|
|
|
|
from .defaults import (DEFAULT_INVENTORIES, FIELD_TYPES, default_fields, slugify)
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
MY_DIR: Path = Path(__file__).parent
|
|
|
|
|
|
|
|
|
|
logger = logger.getChild(__name__)
|
|
|
|
|
|
|
|
|
|
|
2024-09-28 17:28:33 -06:00
|
|
|
@dataclass
|
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 14:20:10 -06:00
|
|
|
class InventoryConfigValidator:
|
2024-09-28 17:28:33 -06:00
|
|
|
version: int = None
|
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 14:20:10 -06:00
|
|
|
slug: str = None
|
|
|
|
|
name: str = None
|
|
|
|
|
type: str = None
|
|
|
|
|
created_at: str = None
|
|
|
|
|
viewed_at: str = None
|
|
|
|
|
fields: list = field(default_factory=list)
|
|
|
|
|
items: list = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_fields(fields_: list):
|
|
|
|
|
"""Validate a field schema list. Raises ValidationError on bad input."""
|
|
|
|
|
if not isinstance(fields_, list):
|
|
|
|
|
raise ValidationError('fields must be a list')
|
|
|
|
|
seen = set()
|
|
|
|
|
for f in fields_:
|
|
|
|
|
if not isinstance(f, dict) or not f.get('key') or not f.get('type'):
|
|
|
|
|
raise ValidationError(f'Invalid field definition: {f}')
|
|
|
|
|
if f['type'] not in FIELD_TYPES:
|
|
|
|
|
raise ValidationError(f'Unknown field type: {f["type"]}')
|
|
|
|
|
if f['key'] in seen:
|
|
|
|
|
raise ValidationError(f'Duplicate field key: {f["key"]}')
|
|
|
|
|
seen.add(f['key'])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _allowed_item_keys(fields_: list) -> set:
|
|
|
|
|
"""Keys an item may contain, derived from the field schema (quantity fields add a companion `<key>_unit`)."""
|
|
|
|
|
keys = set()
|
|
|
|
|
for f in fields_:
|
|
|
|
|
keys.add(f['key'])
|
|
|
|
|
if f.get('type') == 'quantity':
|
|
|
|
|
keys.add(f'{f["key"]}_unit')
|
|
|
|
|
return keys
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_items(items: list, fields_: list) -> list:
|
2026-07-30 17:59:46 -04:00
|
|
|
"""Filter each item to the schema's allowed keys, strip whitespace from string values, and assign stable ids (fresh id for any missing/duplicate)."""
|
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 14:20:10 -06:00
|
|
|
if not isinstance(items, list):
|
|
|
|
|
raise ValidationError('items must be a list')
|
|
|
|
|
allowed = _allowed_item_keys(fields_)
|
|
|
|
|
counter = max((i.get('id') for i in items if isinstance(i.get('id'), int)), default=0)
|
|
|
|
|
used = set()
|
|
|
|
|
normalized = []
|
|
|
|
|
for raw in items:
|
2026-07-30 17:59:46 -04:00
|
|
|
clean = {k: (v.strip() if isinstance(v, str) else v) for k, v in raw.items() if k in allowed}
|
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 14:20:10 -06:00
|
|
|
item_id = raw.get('id')
|
|
|
|
|
if not isinstance(item_id, int) or item_id in used:
|
|
|
|
|
counter += 1
|
|
|
|
|
item_id = counter
|
|
|
|
|
used.add(item_id)
|
|
|
|
|
clean['id'] = item_id
|
|
|
|
|
normalized.append(clean)
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Slugs reserved in the config/inventory/ directory that are NOT inventories. The shared food catalog lives at
|
|
|
|
|
# config/inventory/catalog.yaml, so it must be excluded from inventory discovery (and from new-inventory slugs).
|
|
|
|
|
RESERVED_SLUGS = {'catalog'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class InventoriesConfig(MultiFileConfig):
|
|
|
|
|
subdirectory = 'inventory'
|
|
|
|
|
name = 'inventory'
|
|
|
|
|
validator = InventoryConfigValidator
|
|
|
|
|
default_entity = dict(version=0, slug=None, name=None, type='food', created_at=None, viewed_at=None,
|
|
|
|
|
fields=[], items=[])
|
|
|
|
|
|
|
|
|
|
def discover(self) -> List[str]:
|
|
|
|
|
# Exclude reserved files (e.g. catalog.yaml) that share the directory but are not inventories.
|
|
|
|
|
return [slug for slug in super().discover() if slug not in RESERVED_SLUGS]
|
|
|
|
|
|
|
|
|
|
def all_inventories(self) -> List[dict]:
|
|
|
|
|
"""Return every inventory in full (fields + items), sorted by name.
|
|
|
|
|
|
|
|
|
|
The frontend loads this once and derives everything (selected items, summary, locations, ration data)
|
|
|
|
|
client-side, then persists whole inventories via `save_inventory` — so there are no granular item/field
|
|
|
|
|
endpoints.
|
|
|
|
|
"""
|
|
|
|
|
return sorted(self.all(), key=lambda i: (i.get('name') or '').lower())
|
|
|
|
|
|
|
|
|
|
def get_inventory(self, slug: str) -> Optional[dict]:
|
|
|
|
|
return self.get(slug)
|
|
|
|
|
|
|
|
|
|
def create_inventory(self, name: str, inventory_type: str) -> dict:
|
|
|
|
|
from wrolpi.dates import now
|
|
|
|
|
name = (name or '').strip()
|
|
|
|
|
if not name:
|
|
|
|
|
raise ValidationError('Inventory name is required')
|
|
|
|
|
slug = slugify(name)
|
|
|
|
|
# Slug is a stable identifier; dedupe it so two same-named inventories don't collide on disk, and never
|
|
|
|
|
# let a user-created inventory claim a reserved slug (e.g. naming an inventory "Catalog").
|
|
|
|
|
taken = lambda s: s in self._configs or s in RESERVED_SLUGS
|
|
|
|
|
if taken(slug):
|
|
|
|
|
suffix = 2
|
|
|
|
|
while taken(f'{slug}-{suffix}'):
|
|
|
|
|
suffix += 1
|
|
|
|
|
slug = f'{slug}-{suffix}'
|
|
|
|
|
created = now().isoformat()
|
|
|
|
|
self.create(slug, dict(
|
|
|
|
|
slug=slug,
|
|
|
|
|
name=name,
|
|
|
|
|
type=inventory_type,
|
|
|
|
|
created_at=created,
|
|
|
|
|
viewed_at=created,
|
|
|
|
|
fields=default_fields(inventory_type),
|
|
|
|
|
items=[],
|
|
|
|
|
))
|
|
|
|
|
return self.get(slug)
|
|
|
|
|
|
|
|
|
|
def delete_inventory(self, slug: str):
|
|
|
|
|
if slug not in self._configs:
|
|
|
|
|
raise ValidationError(f'No inventory: {slug}')
|
|
|
|
|
self.delete(slug)
|
|
|
|
|
|
|
|
|
|
def save_inventory(self, slug: str, data: dict, expected_version: int = None) -> dict:
|
|
|
|
|
"""Replace an inventory's editable parts (name / fields / items) in one write.
|
|
|
|
|
|
|
|
|
|
`slug` is a stable id and is never changed by a save (only the display `name` changes). Item ids are
|
|
|
|
|
normalized server-side (any item missing/duplicating an id gets a fresh one). When `expected_version` is
|
|
|
|
|
given and does not match the stored version, an `InventoryConflict` is raised so a stale client (e.g. a
|
|
|
|
|
second browser tab) cannot silently clobber a newer save.
|
|
|
|
|
"""
|
|
|
|
|
from wrolpi.dates import now
|
|
|
|
|
from .errors import InventoryConflict
|
|
|
|
|
|
|
|
|
|
if slug not in self._configs:
|
|
|
|
|
raise ValidationError(f'No inventory: {slug}')
|
|
|
|
|
|
|
|
|
|
# Hold the save lock for the whole read-check-mutate-persist cycle so two concurrent saves of the same
|
|
|
|
|
# inventory can't both pass the version check and clobber each other (lost write).
|
|
|
|
|
with self.save_lock():
|
|
|
|
|
current = dict(self._configs[slug])
|
|
|
|
|
stored_version = current.get('version')
|
|
|
|
|
if expected_version is not None and stored_version is not None and stored_version != expected_version:
|
|
|
|
|
raise InventoryConflict(
|
|
|
|
|
f'Inventory {slug} changed since it was loaded (stored {stored_version} != {expected_version})')
|
|
|
|
|
|
|
|
|
|
if 'name' in data:
|
|
|
|
|
name = (data['name'] or '').strip()
|
|
|
|
|
if not name:
|
|
|
|
|
raise ValidationError('Inventory name is required')
|
|
|
|
|
current['name'] = name
|
|
|
|
|
if data.get('type'):
|
|
|
|
|
current['type'] = data['type']
|
|
|
|
|
if 'fields' in data:
|
|
|
|
|
fields_ = data['fields']
|
|
|
|
|
_validate_fields(fields_)
|
|
|
|
|
for index, f in enumerate(fields_):
|
|
|
|
|
f['order'] = index
|
|
|
|
|
current['fields'] = fields_
|
|
|
|
|
if 'items' in data:
|
|
|
|
|
current['items'] = _normalize_items(data['items'], current.get('fields') or [])
|
|
|
|
|
|
|
|
|
|
current['viewed_at'] = now().isoformat()
|
|
|
|
|
self._configs[slug] = current
|
|
|
|
|
# Persist synchronously (lock already held) so the version bump is immediate and returned to the client.
|
|
|
|
|
self._persist_slug(slug)
|
|
|
|
|
return self.get(slug)
|
|
|
|
|
|
2026-06-19 16:13:56 -06:00
|
|
|
def _load_backup(self, slug: str, backup_date: str) -> dict:
|
|
|
|
|
"""Read and validate a dated backup file for an inventory."""
|
|
|
|
|
file = self._get_backup_file(slug, backup_date)
|
|
|
|
|
if not file.is_file():
|
|
|
|
|
raise ValidationError(f'No backup for {slug} on {backup_date}')
|
|
|
|
|
data = read_config_data(file)
|
|
|
|
|
if not self.is_valid(data):
|
|
|
|
|
raise ValidationError(f'Backup is not a valid inventory config: {file}')
|
|
|
|
|
return data
|
|
|
|
|
|
|
|
|
|
def preview_restore(self, slug: str, backup_date: str, mode: str) -> dict:
|
|
|
|
|
"""Describe what restoring a backup would change, without applying it.
|
|
|
|
|
|
|
|
|
|
`overwrite` replaces the whole inventory (items present only in the backup are added, items present only in
|
|
|
|
|
the current inventory are removed, and the field schema may change). `merge` only adds backup items whose
|
|
|
|
|
id isn't already present (current fields and items are kept).
|
|
|
|
|
"""
|
|
|
|
|
if mode not in ('merge', 'overwrite'):
|
|
|
|
|
raise ValidationError('mode must be "merge" or "overwrite"')
|
|
|
|
|
if slug not in self._configs:
|
|
|
|
|
raise ValidationError(f'No inventory: {slug}')
|
|
|
|
|
backup = self._load_backup(slug, backup_date)
|
|
|
|
|
current = self.get(slug)
|
|
|
|
|
current_items = current.get('items') or []
|
|
|
|
|
backup_items = backup.get('items') or []
|
|
|
|
|
current_ids = {i.get('id') for i in current_items}
|
|
|
|
|
backup_ids = {i.get('id') for i in backup_items}
|
|
|
|
|
|
|
|
|
|
add = [i for i in backup_items if i.get('id') not in current_ids]
|
|
|
|
|
if mode == 'overwrite':
|
|
|
|
|
remove = [i for i in current_items if i.get('id') not in backup_ids]
|
|
|
|
|
unchanged = sum(1 for i in current_items if i.get('id') in backup_ids)
|
|
|
|
|
fields_change = (current.get('fields') or []) != (backup.get('fields') or [])
|
|
|
|
|
else:
|
|
|
|
|
remove = []
|
|
|
|
|
unchanged = len(current_items)
|
|
|
|
|
fields_change = False
|
|
|
|
|
return dict(
|
|
|
|
|
mode=mode,
|
|
|
|
|
add=add,
|
|
|
|
|
remove=remove,
|
|
|
|
|
unchanged=unchanged,
|
|
|
|
|
fields_change=fields_change,
|
|
|
|
|
current_count=len(current_items),
|
|
|
|
|
backup_count=len(backup_items),
|
|
|
|
|
backup_name=backup.get('name'),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def apply_restore(self, slug: str, backup_date: str, mode: str) -> dict:
|
|
|
|
|
"""Restore an inventory from a dated backup (see `preview_restore` for `merge`/`overwrite` semantics).
|
|
|
|
|
|
|
|
|
|
The pre-restore state is backed up by `_persist_slug`, so a restore can itself be undone."""
|
|
|
|
|
from wrolpi.dates import now
|
|
|
|
|
if mode not in ('merge', 'overwrite'):
|
|
|
|
|
raise ValidationError('mode must be "merge" or "overwrite"')
|
|
|
|
|
if slug not in self._configs:
|
|
|
|
|
raise ValidationError(f'No inventory: {slug}')
|
|
|
|
|
backup = self._load_backup(slug, backup_date)
|
|
|
|
|
|
|
|
|
|
with self.save_lock():
|
|
|
|
|
current = dict(self._configs[slug])
|
|
|
|
|
if mode == 'overwrite':
|
|
|
|
|
# Replace the editable parts with the backup; `slug`/`version` stay (version bumps on persist).
|
|
|
|
|
current['name'] = backup.get('name') or current.get('name')
|
|
|
|
|
current['type'] = backup.get('type') or current.get('type')
|
|
|
|
|
current['fields'] = backup.get('fields') or []
|
|
|
|
|
# Validate the field dicts (a corrupt backup field without a 'key' would otherwise raise a 500
|
|
|
|
|
# inside _normalize_items); raises a 400 ValidationError instead.
|
|
|
|
|
_validate_fields(current['fields'])
|
|
|
|
|
current['items'] = _normalize_items(backup.get('items') or [], current['fields'])
|
|
|
|
|
else:
|
|
|
|
|
current_items = current.get('items') or []
|
|
|
|
|
current_ids = {i.get('id') for i in current_items}
|
|
|
|
|
add = [i for i in (backup.get('items') or []) if i.get('id') not in current_ids]
|
|
|
|
|
current['items'] = _normalize_items(current_items + add, current.get('fields') or [])
|
|
|
|
|
current['viewed_at'] = now().isoformat()
|
|
|
|
|
self._configs[slug] = current
|
|
|
|
|
self._persist_slug(slug)
|
|
|
|
|
return self.get(slug)
|
|
|
|
|
|
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 14:20:10 -06:00
|
|
|
def seed_defaults(self):
|
|
|
|
|
"""Create the default example inventories if the directory is empty."""
|
|
|
|
|
if self._configs:
|
2025-12-06 14:47:52 -07:00
|
|
|
return
|
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 14:20:10 -06:00
|
|
|
for default in DEFAULT_INVENTORIES:
|
|
|
|
|
try:
|
Add a recommended Food Storage example, a Ration tab, and a supply planner
Build on the rebuilt config-only Inventory with three related additions.
Recommended example inventory:
- A fresh install now seeds the "Food Storage" example with the recommended
one-adult / one-year (~3,000 kcal/day) emergency food supply (60 items,
~1.12M kcal), instead of an empty inventory.
- The counts are layered on top of the shipped food catalog (single source of
per-package nutrition) via RECOMMENDED_ONE_YEAR_FOOD_COUNTS, joined at seed
time by recommended_food_storage_items().
- Genericized the two remaining brand names in the catalog defaults
(Velveeta -> Processed Cheese, Spam -> Canned Lunch Meat) so both the catalog
and the seeded inventory stay brand-free.
Ration tab (split out of Summary):
- Moved the Ration Estimate (People / Daily Calories / estimate table) out of
the increasingly long Summary tab into its own "Ration" tab, shown only for
inventories that have a calories field.
Supply planner ("Plan More Supply"):
- A slider picks a target duration longer than the current estimate; the whole
inventory is scaled up proportionally (every item by the same factor, mix
kept balanced) into a sortable shopping list of what to buy.
- Any split package rounds UP to a whole one (ceil), matching the "1.6 buckets
-> buy 1 more" rule.
- The shopping list is exportable as CSV (reusing toCSV) and PDF (reusing the
print-to-PDF path, scoped via body.printing-shopping so only the list prints
-- no inventory or summary).
Search filter:
- Moved the items search out of the top toolbar (which implied it filtered
every tab) into the Items tab, so it clearly narrows Items only;
Summary/Ration/Export/print always use the full inventory.
Aggregation/role helpers (findNameKey, planSupplyPurchase) live in summarize.js,
the single source of truth shared by Summary, export, and the ration views.
All new pure functions and UI flows are covered by tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:39:58 -06:00
|
|
|
inventory = self.create_inventory(default['name'], default['type'])
|
|
|
|
|
# Optionally populate the example with seed items (e.g. the recommended one-year food supply).
|
|
|
|
|
items_factory = default.get('items_factory')
|
|
|
|
|
items = items_factory() if items_factory else None
|
|
|
|
|
if items:
|
|
|
|
|
self.save_inventory(inventory['slug'], dict(items=items))
|
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 14:20:10 -06:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f'Failed to seed default inventory {default}: {e}')
|
2026-03-09 16:11:44 -06:00
|
|
|
|
2022-02-16 21:57:27 -07:00
|
|
|
|
2023-04-18 22:03:45 -06:00
|
|
|
INVENTORIES_CONFIG: InventoriesConfig = InventoriesConfig()
|
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 14:20:10 -06:00
|
|
|
TEST_INVENTORIES_CONFIG: Optional[InventoriesConfig] = None
|
2022-02-16 21:57:27 -07:00
|
|
|
|
|
|
|
|
|
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 14:20:10 -06:00
|
|
|
def get_inventory_configs() -> InventoriesConfig:
|
|
|
|
|
if isinstance(TEST_INVENTORIES_CONFIG, InventoriesConfig):
|
2022-02-16 21:57:27 -07:00
|
|
|
return TEST_INVENTORIES_CONFIG
|
|
|
|
|
return INVENTORIES_CONFIG
|
|
|
|
|
|
|
|
|
|
|
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 14:20:10 -06:00
|
|
|
# Backwards-compatible alias used by some call sites.
|
|
|
|
|
get_inventories_config = get_inventory_configs
|
|
|
|
|
|
|
|
|
|
|
2022-02-16 21:57:27 -07:00
|
|
|
def set_test_inventories_config(enabled: bool):
|
|
|
|
|
global TEST_INVENTORIES_CONFIG
|
|
|
|
|
if enabled:
|
|
|
|
|
TEST_INVENTORIES_CONFIG = InventoriesConfig()
|
|
|
|
|
else:
|
|
|
|
|
TEST_INVENTORIES_CONFIG = None
|
|
|
|
|
|
|
|
|
|
|
2024-09-26 09:13:29 -06:00
|
|
|
def import_inventories_config():
|
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 14:20:10 -06:00
|
|
|
"""Migrate any legacy inventories.yaml, load per-inventory files, seed defaults, and load the food catalog."""
|
|
|
|
|
from .migrate import migrate_legacy_inventory
|
|
|
|
|
config = get_inventory_configs()
|
|
|
|
|
try:
|
|
|
|
|
migrate_legacy_inventory(config)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error('Failed to migrate legacy inventories', exc_info=e)
|
|
|
|
|
config.import_all()
|
|
|
|
|
config.seed_defaults()
|
|
|
|
|
|
|
|
|
|
# Load the shared food catalog and merge in any new shipped defaults.
|
|
|
|
|
try:
|
|
|
|
|
from .catalog import import_catalog_config
|
|
|
|
|
import_catalog_config()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error('Failed to import food catalog', exc_info=e)
|