Rebuild Inventory as a config-only system with food catalog and calculators
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

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>
This commit is contained in:
Roland 2026-06-14 14:20:10 -06:00
parent ae5f64424d
commit 3d6aead072
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
46 changed files with 3612 additions and 2697 deletions

View file

@ -26,7 +26,6 @@ from wrolpi.collections.models import Collection, CollectionItem # noqa: F401
from modules.videos.models import Video, Channel # noqa: F401
from modules.archive.models import Archive # noqa: F401
from modules.zim.models import Zim, TagZimEntry, ZimSubscription # noqa: F401
from modules.inventory.models import Item, Inventory # noqa: F401
target_metadata = Base.metadata

View file

@ -0,0 +1,74 @@
"""Drop inventory tables (inventory is now config-only).
The inventory system no longer uses the database; each inventory is a YAML file at config/inventory/<slug>.yaml.
Legacy data is migrated to those files on startup (see modules/inventory/migrate.py) before these tables are dropped.
Revision ID: b2e4f6a8c1d3
Revises: a7b1c2d3e4f5
Create Date: 2026-06-13 00:00:00.000000
"""
import os
from alembic import op
from sqlalchemy.orm import Session
revision = 'b2e4f6a8c1d3'
down_revision = 'a7b1c2d3e4f5'
branch_labels = None
depends_on = None
DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
def upgrade():
bind = op.get_bind()
session = Session(bind=bind)
# `item` first due to its foreign key to `inventory`.
session.execute('DROP TABLE IF EXISTS public.item')
session.execute('DROP TABLE IF EXISTS public.inventory')
session.execute('DROP TABLE IF EXISTS public.inventories_version')
def downgrade():
bind = op.get_bind()
session = Session(bind=bind)
session.execute('''
CREATE TABLE inventories_version (
version SERIAL PRIMARY KEY NOT NULL
)
''')
session.execute('''
CREATE TABLE public.inventory (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
viewed_at TIMESTAMP WITHOUT TIME ZONE,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITHOUT TIME ZONE
)''')
session.execute('''
CREATE TABLE public.item (
id SERIAL PRIMARY KEY,
inventory_id INTEGER REFERENCES inventory(id) ON DELETE CASCADE,
brand TEXT,
name TEXT,
count DECIMAL,
item_size DECIMAL,
unit TEXT,
serving INTEGER,
category TEXT,
subcategory TEXT,
expiration_date DATE,
purchase_date DATE,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITHOUT TIME ZONE
)''')
if not DOCKERIZED:
session.execute('ALTER TABLE public.inventories_version OWNER TO wrolpi')
session.execute('ALTER TABLE public.inventory OWNER TO wrolpi')
session.execute('ALTER TABLE public.item OWNER TO wrolpi')

View file

@ -7,7 +7,7 @@ import AdminRoute from "./components/admin/AdminRoute";
import {Container} from "semantic-ui-react";
import 'semantic-ui-offline/semantic.min.css';
import {MoreRoute} from "./components/Apps";
import {InventoryRoute} from "./components/Inventory";
import {InventoryRoute} from "./components/inventory/InventoryRoute";
import {ArchiveRoute} from "./components/Archive";
import {FilesRoute} from "./components/Files";
import {QueryProvider, StatusProvider} from "./hooks/customHooks";

View file

@ -583,140 +583,71 @@ export async function refreshChannel(channelId) {
}
return response;
}
export async function getCategories() {
const response = await apiGet(`${API_URI}/inventory/categories`);
if (response.ok) {
return (await response.json())['categories'];
} else {
const message = await getErrorMessage(response, 'Could not get categories');
toast({
type: 'error', title: 'Unexpected server response', description: message, time: 5000,
});
}
}
export async function getBrands() {
const response = await apiGet(`${API_URI}/inventory/brands`);
if (response.ok) {
return (await response.json())['brands'];
} else {
const message = await getErrorMessage(response, 'Could not get brands');
toast({
type: 'error', title: 'Unexpected server response', description: message, time: 5000,
});
}
}
// Inventory is config-only on the backend (no DB). The frontend loads every inventory once (full), derives
// list/items/summary/locations client-side, and persists whole inventories on change — so there are only four
// inventory calls.
export async function getInventories() {
// Returns every inventory in full (fields + items).
const response = await apiGet(`${API_URI}/inventory`);
if (response.ok) {
return (await response.json())['inventories'];
} else {
const message = await getErrorMessage(response, 'Could not get inventories');
toast({
type: 'error', title: 'Unexpected server response', description: message, time: 5000,
});
}
const message = await getErrorMessage(response, 'Could not get inventories');
toast({type: 'error', title: 'Unexpected server response', description: message, time: 5000});
return null;
}
export async function getInventory(inventoryId) {
const response = await apiGet(`${API_URI}/inventory/${inventoryId}`);
export async function createInventory({name, type}) {
const response = await apiPost(`${API_URI}/inventory`, {name, type});
if (response.ok) {
return await response.json();
} else {
const message = await getErrorMessage(response, 'Could not get inventory');
toast({
type: 'error', title: 'Unexpected server response', description: message, time: 5000,
});
return (await response.json())['inventory'];
}
const message = await getErrorMessage(response, 'Failed to create inventory');
toast({type: 'error', title: 'Unexpected server response', description: message, time: 5000});
return null;
}
export async function saveInventory(inventory) {
const response = await apiPost(`${API_URI}/inventory`, inventory);
if (!response.ok) {
const message = await getErrorMessage(response, 'Failed to save inventory');
toast({
type: 'error', title: 'Unexpected server response', description: message, time: 5000,
});
export async function saveInventory(slug, data) {
// Whole-inventory save (data = {name?, fields?, items?, version?}). Returns the saved inventory, or null on
// error/conflict so the caller can reconcile.
const response = await apiPut(`${API_URI}/inventory/${slug}`, data);
if (response.ok) {
return (await response.json())['inventory'];
}
return response
const message = await getErrorMessage(response, 'Failed to save inventory');
toast({type: 'error', title: 'Unexpected server response', description: message, time: 5000});
return null;
}
export async function updateInventory(inventoryId, inventory) {
delete inventory['id'];
const response = await apiPut(`${API_URI}/inventory/${inventoryId}`, inventory);
if (!response.ok) {
const message = await getErrorMessage(response, 'Failed to update inventory');
toast({
type: 'error', title: 'Unexpected server response', description: message, time: 5000,
});
}
return response
}
export async function deleteInventory(inventoryId) {
const response = await apiDelete(`${API_URI}/inventory/${inventoryId}`);
export async function deleteInventory(slug) {
const response = await apiDelete(`${API_URI}/inventory/${slug}`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Failed to delete inventory');
toast({
type: 'error', title: 'Unexpected server response', description: message, time: 5000,
});
toast({type: 'error', title: 'Unexpected server response', description: message, time: 5000});
}
return response
return response;
}
export async function getItems(inventoryId) {
const response = await apiGet(`${API_URI}/inventory/${inventoryId}/item`);
export async function getCatalog() {
// The shared food catalog (known items with total calories per package).
const response = await apiGet(`${API_URI}/inventory/catalog`);
if (response.ok) {
return await response.json();
} else {
const message = await getErrorMessage(response, 'Failed to get items');
toast({
type: 'error', title: 'Unexpected server response', description: message, time: 5000,
});
return (await response.json())['catalog'];
}
return response
const message = await getErrorMessage(response, 'Could not get the food catalog');
toast({type: 'error', title: 'Unexpected server response', description: message, time: 5000});
return null;
}
export async function saveItem(inventoryId, item) {
const response = await apiPost(`${API_URI}/inventory/${inventoryId}/item`, item);
export async function saveCatalog(items) {
const response = await apiPut(`${API_URI}/inventory/catalog`, {items});
if (response.ok) {
return await response.json();
} else {
const message = await getErrorMessage(response, 'Failed to save item');
toast({
type: 'error', title: 'Unexpected server response', description: message, time: 5000,
});
return (await response.json())['catalog'];
}
return response
}
export async function updateItem(itemId, item) {
item = emptyToNull(item);
const response = await apiPut(`${API_URI}/inventory/item/${itemId}`, item);
if (response.ok) {
return await response.json();
} else {
const message = await getErrorMessage(response, 'Failed to update item');
toast({
type: 'error', title: 'Unexpected server response', description: message, time: 5000,
});
}
return response
}
export async function deleteItems(itemIds) {
let i = itemIds.join(',');
const response = await apiDelete(`${API_URI}/inventory/item/${i}`);
if (response.ok) {
return await response.json();
} else {
const message = await getErrorMessage(response, 'Failed to delete items');
toast({
type: 'error', title: 'Unexpected server response', description: message, time: 5000,
});
}
return response
const message = await getErrorMessage(response, 'Failed to save the food catalog');
toast({type: 'error', title: 'Unexpected server response', description: message, time: 5000});
return null;
}

View file

@ -10,6 +10,7 @@ import {RatioCalculators} from "./calculators/RatioCalculator";
import {QRCodeCalculator} from "./calculators/QRCodeCalculator";
import {DriveCalculator} from "./calculators/DriveCalculator";
import {WaterCalculator} from "./calculators/WaterCalculator";
import {FoodStorageCalculator} from "./calculators/FoodStorageCalculator";
export const useCalculators = () => {
const [calc, setCalc] = useCalcQuery();
@ -19,6 +20,7 @@ export const useCalculators = () => {
{key: 'electrical', icon: 'lightning', button: 'Electrical', contents: <ElectricalCalculators/>},
{key: 'drive', icon: 'cogs', button: 'Drive Ratio', contents: <DriveCalculator/>},
{key: 'water', icon: 'tint', button: 'Water Storage', contents: <WaterCalculator/>},
{key: 'ration', icon: 'food', button: 'Food Storage', contents: <FoodStorageCalculator/>},
{key: 'antenna', icon: 'signal', button: 'Antenna', contents: <DipoleAntennaCalculator/>},
{key: 'temperature', icon: 'thermometer', button: 'Temperature', contents: <TemperatureCalculator/>},
{key: 'qrCode', icon: 'qrcode', button: 'QR Code', contents: <QRCodeCalculator/>},

File diff suppressed because it is too large Load diff

View file

@ -23,6 +23,9 @@ import {
ListItem as SListItem,
Loader as SLoader,
Menu as SMenu,
MenuItem as SMenuItem,
MenuHeader as SMenuHeader,
MenuMenu as SMenuMenu,
Modal as SModal,
ModalActions as SModalActions,
ModalContent as SModalContent,
@ -66,6 +69,8 @@ import {
ListDescriptionProps,
LoaderProps,
MenuProps,
MenuItemProps,
MenuMenuProps,
ModalProps,
ModalActionsProps,
ModalContentProps,
@ -312,11 +317,29 @@ export function Loader(props: LoaderProps) {
return <SLoader {...i} {...props}/>
}
export function Menu(props: MenuProps) {
// Menu is a compound component (Menu.Item / Menu.Header / Menu.Menu) so callers get the same API as Semantic's
// Menu while inheriting the theme. The parent menu is `inverted` in dark mode so item text stays readable.
const MenuBase: React.FC<MenuProps> = (props) => {
const {i} = useContext(ThemeContext);
return <SMenu {...i} {...props}/>
return <SMenu {...i} {...props}/>;
};
const MenuItem: React.FC<MenuItemProps> = (props) => <SMenuItem {...props}/>;
const MenuHeader: React.FC<MenuItemProps> = (props) => <SMenuHeader {...props}/>;
const MenuMenu: React.FC<MenuMenuProps> = (props) => <SMenuMenu {...props}/>;
interface MenuComponent extends React.FC<MenuProps> {
Item: React.FC<MenuItemProps>;
Header: React.FC<MenuItemProps>;
Menu: React.FC<MenuMenuProps>;
}
export const Menu: MenuComponent = Object.assign(MenuBase, {
Item: MenuItem,
Header: MenuHeader,
Menu: MenuMenu,
});
export function Placeholder(props: PlaceholderProps) {
const {i} = useContext(ThemeContext);
return <SPlaceholder {...i} {...props}/>

View file

@ -0,0 +1,222 @@
import React from "react";
import {GridColumn, Input, Label, TableBody, TableCell, TableFooter, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {Form, Header, Segment, Table} from "../Theme";
import {InfoPopup, roundDigits, Toggle, useLocalStorage} from "../Common";
import {ThemeContext} from "../../contexts/contexts";
// Generic food-storage planner: given a household, estimate how much of each food category to store for a chosen
// duration. Independent of any inventory (like the Water calculator). Defaults are a common one-year-per-adult
// food-storage plan (lbs per person per year); all amounts are editable.
// Each person's share of one adult man's ration, as a percent. Defaults are derived from the Dietary Guidelines
// for Americans 2020-2025 moderate-activity calorie needs (man ~2,600 / woman ~2,000 / child ~1,800 kcal/day),
// since food-storage quantities scale with energy needs. Editable.
export const DEFAULT_SHARES = {men: 100, women: 77, children: 69};
const countNum = (v) => {
const x = Number(v);
return x > 0 ? x : 0;
};
// Raw head count.
export function totalPeople(counts) {
return countNum(counts.men) + countNum(counts.women) + countNum(counts.children);
}
// Adult-man-equivalents: each person weighted by their ration share (%), so women and children need less.
export function adultEquivalents(counts, shares) {
const share = (v) => Math.max(0, Number(v) || 0) / 100;
return countNum(counts.men) * share(shares.men)
+ countNum(counts.women) * share(shares.women)
+ countNum(counts.children) * share(shares.children);
}
// Total amount of a category needed: per-person-per-year amount, scaled by people and the duration in months.
export function amountNeeded(perPersonPerYear, people, months) {
const a = Number(perPersonPerYear), m = Number(months);
if (!(a > 0) || !(people > 0) || !(m > 0)) {
return 0;
}
return a * people * (m / 12);
}
// Default per-person, per-year storage amounts (lbs), calibrated to a one-year supply for one adult man.
// Auxiliary foods and spices/condiments have no standard weight, so they default to 0 for the user to fill in.
export const FOOD_CATEGORIES = [
{key: 'grains', label: 'Grains', perYear: 300},
{key: 'legumes', label: 'Legumes', perYear: 60},
{key: 'fruits', label: 'Fruits', perYear: 185},
{key: 'vegetables', label: 'Vegetables', perYear: 185},
{key: 'powdered_milk', label: 'Powdered Milk', perYear: 16},
{key: 'cooking_oil', label: 'Cooking Oil', perYear: 25},
{key: 'meats', label: 'Meats / Meat Substitutes', perYear: 20},
{key: 'sugar', label: 'Sugar or Honey', perYear: 60},
{key: 'salt', label: 'Salt', perYear: 8},
{key: 'cooking_essentials', label: 'Cooking Essentials', perYear: 8},
{key: 'auxiliary', label: 'Auxiliary Foods', perYear: 0},
{key: 'condiments', label: 'Spices / Condiments', perYear: 0},
];
const DEFAULT_AMOUNTS = Object.fromEntries(FOOD_CATEGORIES.map(c => [c.key, String(c.perYear)]));
export const KG_PER_LB = 0.45359237;
// Convert all per-person amounts between lb and kg (used when the unit toggle flips), preserving the user's edits
// rather than resetting to defaults.
export function convertAmounts(amounts, toMetric) {
const factor = toMetric ? KG_PER_LB : 1 / KG_PER_LB;
const out = {};
for (const [key, value] of Object.entries(amounts)) {
out[key] = (value === '' || value == null) ? '' : String(roundDigits(Number(value) * factor, 2));
}
return out;
}
// Duration slider stops (months): monthly to a year, then to three years.
export const MONTH_STOPS = [1, 2, 3, 6, 9, 12, 18, 24, 36];
export function formatMonths(months) {
const m = Math.round(Number(months));
if (!(m > 0)) {
return '—';
}
if (m % 12 === 0) {
const y = m / 12;
return `${y} year${y === 1 ? '' : 's'}`;
}
return `${m} month${m === 1 ? '' : 's'}`;
}
const CATEGORY_INFO = 'Default amounts are a one-year food-storage supply for one adult man (pounds per person '
+ 'per year). Totals scale by each person\'s ration share (women and children need less — see the shares '
+ 'above), and by the duration. Edit any amount to match your own plan. Water is not included — use the Water '
+ 'Storage calculator for that.';
const SHARES_INFO = 'Each person\'s share of one adult man\'s ration. Defaults follow the Dietary Guidelines for '
+ 'Americans 2020-2025 moderate-activity calorie needs (man ~2,600, woman ~2,000, child ~1,800 kcal/day) — '
+ 'food storage scales with calories. Children vary widely by age, so adjust as needed.';
const PEOPLE_META = [
{key: 'men', label: 'Men', color: '#4477aa'},
{key: 'women', label: 'Women', color: '#aa3377'},
{key: 'children', label: 'Children', color: '#228833'},
];
function textColorFor(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6 ? '#000000' : '#ffffff';
}
function fmt(value, unit) {
return Number.isFinite(value) && value > 0 ? `${roundDigits(value, 1)} ${unit}` : '—';
}
export function FoodStorageCalculator() {
const [counts, setCounts] = useLocalStorage('food_storage_counts', {men: '1', women: '1', children: ''});
const [shares, setShares] = useLocalStorage('food_storage_shares', DEFAULT_SHARES);
const [monthsIndex, setMonthsIndex] = useLocalStorage('food_storage_months_index', MONTH_STOPS.indexOf(12));
const [amounts, setAmounts] = useLocalStorage('food_storage_amounts', DEFAULT_AMOUNTS);
const [metric, setMetric] = useLocalStorage('food_storage_metric', false);
const {t} = React.useContext(ThemeContext);
const unit = metric ? 'kg' : 'lb';
// Convert the editable amounts when switching units so the user keeps their numbers.
const toggleMetric = () => {
setAmounts(prev => convertAmounts(prev, !metric));
setMetric(!metric);
};
const equivalents = adultEquivalents(counts, shares);
const monthStop = Math.min(Math.max(0, monthsIndex), MONTH_STOPS.length - 1);
const months = MONTH_STOPS[monthStop];
const inputProps = {fluid: true, type: 'number', min: 0, onSelect: e => e.target.select(), autoComplete: 'off'};
const labelStyle = (color) =>
({backgroundColor: color, color: textColorFor(color), borderColor: color});
const countInput = ({key, label, color}) =>
<Input {...inputProps} step={1} name={`count-${key}`} labelPosition='left' value={counts[key]}
onChange={e => setCounts({...counts, [key]: e.target.value})}
label={<Label style={labelStyle(color)}>{label}</Label>}/>;
const shareInput = ({key, label, color}) =>
<Input {...inputProps} step={1} name={`share-${key}`} labelPosition='left' value={shares[key]}
onChange={e => setShares({...shares, [key]: e.target.value})}
label={<Label style={labelStyle(color)}>{label}</Label>}/>;
const rows = FOOD_CATEGORIES.map(c => {
const perYear = amounts[c.key] ?? '';
const total = amountNeeded(perYear, equivalents, months);
return {...c, perYear, total};
});
const grandTotal = rows.reduce((s, r) => s + r.total, 0);
return <Form>
<Header as='h1'>Food Storage</Header>
<p {...t}>Estimate how much food to store for your household over time.</p>
<div style={{marginBottom: '1em'}}>
<Toggle label={metric ? 'Metric (kg)' : 'Imperial (lb)'} checked={metric} onChange={toggleMetric}/>
</div>
<Header as='h3'>People</Header>
<Grid stackable columns={3}>
{PEOPLE_META.map(meta => <GridColumn key={meta.key}>{countInput(meta)}</GridColumn>)}
</Grid>
<Header as='h4' style={{marginTop: '1em', marginBottom: '0.5em'}}>
Ration share (% of an adult man) <InfoPopup content={SHARES_INFO}/>
</Header>
<Grid stackable columns={3}>
{PEOPLE_META.map(meta => <GridColumn key={meta.key}>{shareInput(meta)}</GridColumn>)}
</Grid>
<p {...t} style={{...t.style, marginTop: '0.5em', opacity: 0.8}}>
≈ {roundDigits(equivalents, 2)} adult-equivalent{equivalents === 1 ? '' : 's'}
</p>
<Header as='h3' style={{marginTop: '1em'}}>How long?</Header>
<div style={{padding: '0 0.5em'}}>
<input type='range' min={0} max={MONTH_STOPS.length - 1} step={1} value={monthStop}
aria-label='Duration' onChange={e => setMonthsIndex(Number(e.target.value))}
style={{width: '100%'}}/>
<Header as='h2' textAlign='center' style={{marginTop: '0.25em'}}>{formatMonths(months)}</Header>
</div>
<Header as='h3' style={{marginTop: '1em'}}>
Amounts ({unit} per person per year) <InfoPopup content={CATEGORY_INFO}/>
</Header>
<Segment>
<Table unstackable>
<TableHeader>
<TableRow>
<TableHeaderCell width={7}>Category</TableHeaderCell>
<TableHeaderCell width={5}>{unit === 'kg' ? 'Kg' : 'Lbs'} / person / year</TableHeaderCell>
<TableHeaderCell width={4}>Total needed</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{rows.map(r => <TableRow key={r.key}>
<TableCell>{r.label}</TableCell>
<TableCell>
<Input fluid type='number' min={0} value={r.perYear}
aria-label={`${r.label} per person per year`}
onChange={e => setAmounts({...amounts, [r.key]: e.target.value})}/>
</TableCell>
<TableCell>{fmt(r.total, unit)}</TableCell>
</TableRow>)}
</TableBody>
<TableFooter>
<TableRow>
<TableHeaderCell>Total</TableHeaderCell>
<TableHeaderCell/>
<TableHeaderCell>{fmt(grandTotal, unit)}</TableHeaderCell>
</TableRow>
</TableFooter>
</Table>
</Segment>
</Form>;
}

View file

@ -0,0 +1,60 @@
import {adultEquivalents, amountNeeded, convertAmounts, DEFAULT_SHARES, formatMonths, totalPeople} from "./FoodStorageCalculator";
describe('convertAmounts', () => {
test('converts lb to kg and back, preserving blanks', () => {
const lb = {grains: '300', aux: ''};
const kg = convertAmounts(lb, true);
expect(Number(kg.grains)).toBeCloseTo(136.08, 1); // 300 lb -> ~136 kg
expect(kg.aux).toBe('');
const back = convertAmounts(kg, false);
expect(Number(back.grains)).toBeCloseTo(300, 0);
});
});
describe('adultEquivalents', () => {
test('weights women and children below an adult man', () => {
// 1 man (100%) + 1 woman (77%) + 1 child (69%) = 2.46 adult-equivalents.
expect(adultEquivalents({men: '1', women: '1', children: '1'}, DEFAULT_SHARES)).toBeCloseTo(2.46, 5);
});
test('a household of adult men equals the head count', () => {
expect(adultEquivalents({men: '3', women: '', children: ''}, DEFAULT_SHARES)).toBe(3);
});
test('custom shares are respected', () => {
expect(adultEquivalents({men: '0', women: '2', children: '0'}, {men: 100, women: 50, children: 69}))
.toBeCloseTo(1, 5);
});
});
describe('totalPeople', () => {
test('sums men, women, and children', () => {
expect(totalPeople({men: '2', women: '1', children: '3'})).toBe(6);
});
test('ignores blank/negative counts', () => {
expect(totalPeople({men: '1', women: '', children: '-2'})).toBe(1);
});
});
describe('amountNeeded', () => {
test('scales per-person-per-year by people and duration (one man, one year)', () => {
expect(amountNeeded(300, 1, 12)).toBe(300); // grains, 1 man, 1 year
});
test('scales by household and partial year', () => {
expect(amountNeeded(300, 4, 6)).toBe(600); // 300 * 4 people * 0.5 year
});
test('zero for impossible inputs', () => {
expect(amountNeeded(0, 4, 12)).toBe(0);
expect(amountNeeded(300, 0, 12)).toBe(0);
expect(amountNeeded(300, 4, 0)).toBe(0);
});
});
describe('formatMonths', () => {
test('whole years read as years', () => {
expect(formatMonths(12)).toBe('1 year');
expect(formatMonths(24)).toBe('2 years');
});
test('otherwise months', () => {
expect(formatMonths(3)).toBe('3 months');
expect(formatMonths(1)).toBe('1 month');
});
});

View file

@ -0,0 +1,204 @@
import React from "react";
import {GridColumn, Input, Label, TableBody, TableCell, TableRow} from "semantic-ui-react";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {Form, Header, Table} from "../Theme";
import {InfoPopup, roundDigits, useLocalStorage} from "../Common";
import {formatDuration} from "./WaterCalculator";
// Pure calculation functions for the Ration (food storage) estimate.
//
// The model: an inventory holds food items, each with a per-unit calorie value and a count. Total stored calories
// divided by the household's daily calorie demand gives how many days the food lasts.
// Total calories stored across all items: sum of (calories * count) per item. A blank/zero count is treated as 1
// (a single unit), and a blank calorie value contributes nothing.
export function totalCalories(items, caloriesKey, countKey) {
if (!items || !caloriesKey) {
return 0;
}
return items.reduce((sum, item) => {
const calories = Number(item[caloriesKey]);
if (!(calories > 0)) {
return sum;
}
const rawCount = countKey ? Number(item[countKey]) : 1;
const count = rawCount > 0 ? rawCount : 1;
return sum + calories * count;
}, 0);
}
// Household daily calorie demand: sum of per-category counts times per-category rates.
export function dailyCalorieDemand(counts, rates) {
const term = (count, rate) => {
const c = Number(count), r = Number(rate);
return (c > 0 && r > 0) ? c * r : 0;
};
return term(counts.men, rates.men)
+ term(counts.women, rates.women)
+ term(counts.children, rates.children);
}
// Days the stored calories last at the given daily demand. Returns null for impossible inputs.
export function daysOfFood(total, dailyDemand) {
if (!(total > 0) || !(dailyDemand > 0)) {
return null;
}
return total / dailyDemand;
}
// The calories field is detected by its dedicated `calories` type (falling back to a field literally keyed
// "calories" for inventories created before the type existed).
export function findCaloriesKey(fields) {
const byType = (fields || []).find(f => f.type === 'calories');
if (byType) {
return byType.key;
}
const legacy = (fields || []).find(f => f.key === 'calories' && f.type === 'number');
return legacy ? legacy.key : null;
}
// The count multiplier: prefer a number field keyed "count", else the first number field, else none (treat as 1).
export function findCountKey(fields) {
const count = (fields || []).find(f => f.type === 'number' && f.key === 'count');
if (count) {
return count.key;
}
const firstNumber = (fields || []).find(f => f.type === 'number');
return firstNumber ? firstNumber.key : null;
}
// Per-person daily calorie needs, by activity level. Adult figures are representative working-age values
// (ages 19-60) from the Dietary Guidelines for Americans 2020-2025, Appendix 2, Table A2-2 ("Estimated Calorie
// Needs per Day, by Age, Sex, and Physical Activity Level"), corroborated by the Merck Manuals table. The single
// "children" figure is a mid-childhood approximation (the DGA child range is very wide, 1,000-3,200). "Survival"
// is a short-term emergency ration floor, below maintenance. All values are editable.
export const RATION_PRESETS = {
sedentary: {
label: 'Sedentary',
description: 'Everyday-living activity only',
rates: {men: 2400, women: 1800, children: 1600},
},
moderate: {
label: 'Moderately Active',
description: '~1.5–3 mi/day walking',
rates: {men: 2600, women: 2000, children: 1800},
},
active: {
label: 'Active',
description: '>3 mi/day walking + activity',
rates: {men: 3000, women: 2400, children: 2000},
},
survival: {
label: 'Survival',
description: 'Emergency ration floor (~1,200–1,500/adult)',
rates: {men: 1500, women: 1200, children: 1200},
},
};
const DEFAULT_PRESET = 'moderate';
const CATEGORY_META = [
{key: 'men', label: 'Men', color: '#4477aa'},
{key: 'women', label: 'Women', color: '#aa3377'},
{key: 'children', label: 'Children', color: '#228833'},
];
const DEMAND_INFO = 'Per-person daily calorie needs come from the Dietary Guidelines for Americans 2020-2025 '
+ '(Appendix 2), by sex and physical activity level. Adult values are representative for ages 19-60; the '
+ 'children figure is a mid-childhood approximation since needs vary widely with age (1,000-3,200 kcal/day). '
+ 'The Survival preset is a short-term emergency ration floor, below maintenance. All values are editable — '
+ 'adjust the household and activity level to see how long the stored food lasts.';
function textColorFor(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6 ? '#000000' : '#ffffff';
}
function fmt(value, unit) {
if (!Number.isFinite(value) || value <= 0) {
return '—';
}
return unit ? `${roundDigits(value, 0)} ${unit}` : `${roundDigits(value, 0)}`;
}
/**
* The household + preset controls and the days-of-food result. Reused by the Ration calculator page and by the
* inventory Summary (when the inventory has a `calories` field). Household/preset selections are persisted under
* shared localStorage keys, so adjusting them in one place carries to the other.
*/
export function RationEstimatePanel({items, caloriesKey, countKey}) {
const [preset, setPreset] = useLocalStorage('ration_preset', DEFAULT_PRESET);
const [counts, setCounts] = useLocalStorage('ration_counts', {men: '1', women: '1', children: ''});
const [rates, setRates] = React.useState({...RATION_PRESETS[DEFAULT_PRESET].rates});
React.useEffect(() => {
// Fall back to the default preset if a stale/unknown key is persisted in localStorage.
const p = RATION_PRESETS[preset] ?? RATION_PRESETS[DEFAULT_PRESET];
setRates({...p.rates});
}, [preset]);
const total = totalCalories(items, caloriesKey, countKey);
const daily = dailyCalorieDemand(counts, rates);
const days = daysOfFood(total, daily);
const inputProps = {fluid: true, type: 'number', onSelect: e => e.target.select(), autoComplete: 'off'};
const countInput = ({key, label, color}) =>
<Input {...inputProps} min={0} step={1} name={`count-${key}`} labelPosition='left' value={counts[key]}
onChange={e => setCounts({...counts, [key]: e.target.value})}
label={<Label style={{backgroundColor: color, color: textColorFor(color), borderColor: color}}>
{label}</Label>}/>;
const rateInput = ({key, label, color}) =>
<Input {...inputProps} min={0} step={50} name={`rate-${key}`} labelPosition='left' value={rates[key]}
onChange={e => setRates({...rates, [key]: e.target.value})}
label={<Label style={{backgroundColor: color, color: textColorFor(color), borderColor: color}}>
{label}</Label>}/>;
return <Form>
<Header as='h3'>People</Header>
<Grid stackable columns={3}>
{CATEGORY_META.map(meta => <GridColumn key={meta.key}>{countInput(meta)}</GridColumn>)}
</Grid>
<Header as='h3' style={{marginTop: '1em'}}>
Daily Calories (per person) <InfoPopup content={DEMAND_INFO}/>
</Header>
<div style={{marginBottom: '1em'}}>
{Object.entries(RATION_PRESETS).map(([key, p]) => (
<button key={key} type='button' onClick={() => setPreset(key)} style={{
marginRight: '0.5em', padding: '0.4em 0.8em',
border: preset === key ? '2px solid #2185d0' : '1px solid #ccc',
background: preset === key ? '#e8f4fc' : 'white', borderRadius: '4px', cursor: 'pointer',
}}>
<strong>{p.label}</strong> — {p.description}
</button>
))}
</div>
<Grid stackable columns={3}>
{CATEGORY_META.map(meta => <GridColumn key={meta.key}>{rateInput(meta)}</GridColumn>)}
</Grid>
<Table definition unstackable style={{marginTop: '1em'}}>
<TableBody>
<TableRow>
<TableCell width={6}>Total Stored Calories</TableCell>
<TableCell>{fmt(total, 'kcal')}</TableCell>
</TableRow>
<TableRow>
<TableCell>Daily Demand</TableCell>
<TableCell>{fmt(daily, 'kcal/day')}</TableCell>
</TableRow>
<TableRow>
<TableCell>Food Lasts</TableCell>
<TableCell>{days ? formatDuration(days) : '—'}</TableCell>
</TableRow>
</TableBody>
</Table>
</Form>;
}
// NOTE: the inventory-based "how long does my food last" estimate now lives only inside the inventory Summary
// (via RationEstimatePanel). The standalone Calculators page is the generic FoodStorageCalculator instead.

View file

@ -0,0 +1,73 @@
import {dailyCalorieDemand, daysOfFood, findCaloriesKey, findCountKey, totalCalories} from "./RationCalculator";
describe('findCaloriesKey', () => {
test('detects a calories-type field', () => {
expect(findCaloriesKey([{key: 'kcal', type: 'calories'}, {key: 'name', type: 'text'}])).toBe('kcal');
});
test('falls back to a legacy number field keyed "calories"', () => {
expect(findCaloriesKey([{key: 'calories', type: 'number'}])).toBe('calories');
});
test('returns null when no calories field exists', () => {
expect(findCaloriesKey([{key: 'name', type: 'text'}])).toBeNull();
});
});
describe('findCountKey', () => {
test('prefers a number field keyed "count"', () => {
expect(findCountKey([{key: 'qty', type: 'number'}, {key: 'count', type: 'number'}])).toBe('count');
});
test('falls back to the first number field', () => {
expect(findCountKey([{key: 'qty', type: 'number'}])).toBe('qty');
});
test('returns null when no number field exists', () => {
expect(findCountKey([{key: 'name', type: 'text'}])).toBeNull();
});
});
describe('totalCalories', () => {
const items = [
{calories: '1600', count: '4'}, // 6400
{calories: '2000', count: '2'}, // 4000
{calories: '', count: '5'}, // 0 (no calories)
{calories: '500'}, // 500 (count defaults to 1)
{calories: '300', count: '0'}, // 300 (zero count treated as 1)
];
test('sums calories times count', () => {
expect(totalCalories(items, 'calories', 'count')).toBe(6400 + 4000 + 500 + 300);
});
test('treats every item as one unit when no count field is chosen', () => {
expect(totalCalories(items, 'calories', null)).toBe(1600 + 2000 + 500 + 300);
});
test('returns 0 with no items or no calories key', () => {
expect(totalCalories(null, 'calories', 'count')).toBe(0);
expect(totalCalories(items, null, 'count')).toBe(0);
});
});
describe('dailyCalorieDemand', () => {
test('sums per-category counts times rates', () => {
const demand = dailyCalorieDemand({men: '2', women: '1', children: '3'},
{men: 2500, women: 2000, children: 1600});
expect(demand).toBe(2 * 2500 + 1 * 2000 + 3 * 1600);
});
test('ignores blank/zero counts', () => {
const demand = dailyCalorieDemand({men: '', women: '0', children: '1'},
{men: 2500, women: 2000, children: 1600});
expect(demand).toBe(1600);
});
});
describe('daysOfFood', () => {
test('divides total by daily demand', () => {
expect(daysOfFood(14000, 7000)).toBe(2);
});
test('null for impossible inputs', () => {
expect(daysOfFood(0, 2000)).toBeNull();
expect(daysOfFood(14000, 0)).toBeNull();
});
});

View file

@ -0,0 +1,108 @@
import React, {useState} from "react";
import {Input, Select, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Button, Header, Icon, Modal, Table} from "../Theme";
import {ALL_UNITS} from "./units";
const UNIT_OPTIONS = [{key: '', value: '', text: '—'}, ...ALL_UNITS.map(u => ({key: u, value: u, text: u}))];
const COLUMNS = [
{key: 'name', label: 'Name', width: 4},
{key: 'category', label: 'Category', width: 3},
{key: 'subcategory', label: 'Subcategory', width: 3},
{key: 'item_size', label: 'Size', width: 2},
{key: 'item_size_unit', label: 'Unit', width: 2},
{key: 'calories', label: 'kcal', width: 2},
];
function blankEntry() {
return {name: '', category: '', subcategory: '', item_size: '', item_size_unit: '', calories: ''};
}
/**
* Manage the shared food catalog: a scrollable, editable table (add/edit/delete) saved as a whole list. Each
* entry's `calories` is the total for one `item_size` package — the value pre-filled onto inventory items.
*/
export function CatalogEditor({catalog, open, onClose, onSave}) {
const [draft, setDraft] = useState([]);
React.useEffect(() => {
if (open) {
setDraft((catalog || []).map(e => ({...e})));
}
}, [open, catalog]);
const update = (index, key, value) =>
setDraft(prev => prev.map((e, i) => i === index ? {...e, [key]: value} : e));
const remove = (index) => setDraft(prev => prev.filter((_, i) => i !== index));
const add = () => setDraft(prev => [blankEntry(), ...prev]);
const save = async () => {
// Drop fully-empty rows.
const cleaned = draft.filter(e => (e.name || '').trim());
await onSave(cleaned);
onClose();
};
return <Modal open={open} onClose={onClose} closeIcon size='fullscreen'>
<Modal.Header>Food Catalog</Modal.Header>
<Modal.Content scrolling>
<p style={{opacity: 0.8}}>
Known items used to autocomplete and pre-fill the entry form. <strong>kcal</strong> is the total
calories for one package of the given size.
</p>
<Button onClick={add} style={{marginBottom: '0.5em'}}><Icon name='plus'/>Add Item</Button>
<Table celled unstackable compact>
<TableHeader>
<TableRow>
{COLUMNS.map(c => <TableHeaderCell key={c.key} width={c.width}>{c.label}</TableHeaderCell>)}
<TableHeaderCell collapsing/>
</TableRow>
</TableHeader>
<TableBody>
{draft.map((entry, index) => {
const aria = (col) => `${entry.name || 'new item'} ${col}`;
return <TableRow key={entry.id ?? `new-${index}`}>
<TableCell>
<Input fluid name='catalog-name' aria-label={aria('name')} value={entry.name || ''}
placeholder='Name' onChange={e => update(index, 'name', e.target.value)}/>
</TableCell>
<TableCell>
<Input fluid name='catalog-category' aria-label={aria('category')}
value={entry.category || ''}
onChange={e => update(index, 'category', e.target.value)}/>
</TableCell>
<TableCell>
<Input fluid name='catalog-subcategory' aria-label={aria('subcategory')}
value={entry.subcategory || ''}
onChange={e => update(index, 'subcategory', e.target.value)}/>
</TableCell>
<TableCell>
<Input fluid type='number' name='catalog-size' aria-label={aria('size')}
value={entry.item_size || ''}
onChange={e => update(index, 'item_size', e.target.value)}/>
</TableCell>
<TableCell>
<Select compact search name='catalog-unit' aria-label={aria('unit')} options={UNIT_OPTIONS}
value={entry.item_size_unit || ''}
onChange={(e, data) => update(index, 'item_size_unit', data.value)}/>
</TableCell>
<TableCell>
<Input fluid type='number' name='catalog-calories' aria-label={aria('calories')}
value={entry.calories || ''}
onChange={e => update(index, 'calories', e.target.value)}/>
</TableCell>
<TableCell collapsing>
<Button color='red' icon size='mini' aria-label='Remove'
onClick={() => remove(index)}><Icon name='trash'/></Button>
</TableCell>
</TableRow>;
})}
</TableBody>
</Table>
</Modal.Content>
<Modal.Actions>
<Button onClick={onClose}>Cancel</Button>
<Button primary onClick={save}>Save Catalog</Button>
</Modal.Actions>
</Modal>;
}

View file

@ -0,0 +1,90 @@
import React from "react";
import {Input, Select} from "semantic-ui-react";
import {ALL_UNITS, UNIT_GROUPS} from "./units";
// Renders the correct editor for a single field/value, wiring up keyboard navigation. The `inputRef` is attached
// to the primary <input> so the parent table can focus the first cell of a new row.
//
// Props:
// field - {key, label, type, unit?, options?}
// value - the current item value for field.key
// unitValue- the current unit (for quantity fields; stored under `${key}_unit`)
// onChange(value) - value changed
// onUnitChange(unit) - quantity unit changed
// onEnter() - Enter pressed (submit row)
// inputRef - ref attached to the primary input
export function FieldCell({field, value, unitValue, onChange, onUnitChange, onEnter, inputRef, listId}) {
const handleKeyDown = (e) => {
if (e.key === 'Enter' && onEnter) {
e.preventDefault();
onEnter();
}
};
const common = {
value: value ?? '',
onChange: (e, data) => onChange(data ? data.value : e.target.value),
onKeyDown: handleKeyDown,
'aria-label': field.label,
name: field.key,
};
if (field.type === 'select') {
const options = (field.options || []).map(o => ({key: o, value: o, text: o}));
// Allow clearing.
return <Select
fluid search clearable selection
name={field.key}
options={options}
placeholder={field.label}
value={value ?? ''}
onChange={(e, data) => onChange(data.value)}
onKeyDown={handleKeyDown}
aria-label={field.label}
/>;
}
if (field.type === 'date') {
return <Input {...common} type='date' fluid ref={inputRef}/>;
}
if (field.type === 'number' || field.type === 'calories') {
// `calories` is a number (kcal per unit) that the Summary's ration estimate detects by type.
return <Input {...common} type='number' fluid ref={inputRef}/>;
}
if (field.type === 'quantity') {
const unitOptions = ALL_UNITS.map(u => ({key: u, value: u, text: u}));
return <Input
type='number'
fluid
ref={inputRef}
name={field.key}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
aria-label={field.label}
label={<Select
compact
name={`${field.key}_unit`}
options={unitOptions}
value={unitValue ?? field.unit ?? ''}
onChange={(e, data) => onUnitChange(data.value)}
aria-label={`${field.label} unit`}
/>}
labelPosition='right'
/>;
}
if (field.type === 'location') {
// Native datalist autocomplete fed by all inventories' locations (basement, attic, ...). Using a datalist
// (rather than a Dropdown) keeps it a real text input, so the Tab/Enter row-entry flow still works.
return <Input {...common} fluid ref={inputRef} list={listId}/>;
}
// text / fallback (supports a datalist via listId, e.g. catalog name suggestions on the Name field).
return <Input {...common} fluid ref={inputRef} list={listId}/>;
}
// The list of selectable unit groups, re-exported for the field editor.
export {UNIT_GROUPS};

View file

@ -0,0 +1,113 @@
import React, {useState} from "react";
import {Input, Select, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Button, Icon, Modal, Table} from "../Theme";
import {ALL_UNITS} from "./units";
const FIELD_TYPE_OPTIONS = ['text', 'number', 'quantity', 'date', 'select', 'location', 'calories']
.map(t => ({key: t, value: t, text: t}));
const UNIT_OPTIONS = ALL_UNITS.map(u => ({key: u, value: u, text: u}));
function slugifyKey(label) {
return (label || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'field';
}
/**
* Modal editor for an inventory's field schema: add/remove/reorder/retype fields, set a quantity field's default
* unit, and a select field's options. Saves via PUT /<slug>/fields.
*/
export function FieldSchemaEditor({fields, open, onClose, onSave}) {
const [draft, setDraft] = useState(() => fields.map(f => ({...f})));
React.useEffect(() => {
if (open) {
setDraft(fields.map(f => ({...f})));
}
}, [open, fields]);
const update = (index, patch) => setDraft(prev => prev.map((f, i) => i === index ? {...f, ...patch} : f));
const remove = (index) => setDraft(prev => prev.filter((_, i) => i !== index));
const move = (index, delta) => setDraft(prev => {
const next = [...prev];
const target = index + delta;
if (target < 0 || target >= next.length) {
return prev;
}
[next[index], next[target]] = [next[target], next[index]];
return next;
});
const add = () => setDraft(prev => [...prev, {key: '', label: '', type: 'text'}]);
const save = async () => {
// Fill any missing keys from labels and normalize order.
const cleaned = draft
.filter(f => f.label || f.key)
.map((f, index) => ({
...f,
key: f.key || slugifyKey(f.label),
label: f.label || f.key,
order: index,
}));
await onSave(cleaned);
onClose();
};
return <Modal open={open} onClose={onClose} closeIcon size='large'>
<Modal.Header>Customize Fields</Modal.Header>
<Modal.Content scrolling>
{/* NOTE: do NOT use `fixed` here — semantic gives fixed-table cells `overflow:hidden`, which clips the
Type/Unit dropdown menus to the cell height. The `width` props below size the columns without it. */}
<Table celled unstackable>
<TableHeader>
<TableRow>
<TableHeaderCell width={2}>Order</TableHeaderCell>
<TableHeaderCell width={4}>Label</TableHeaderCell>
<TableHeaderCell width={4}>Type</TableHeaderCell>
<TableHeaderCell width={5}>Unit / Options</TableHeaderCell>
<TableHeaderCell width={1}/>
</TableRow>
</TableHeader>
<TableBody>
{draft.map((f, index) => <TableRow key={index}>
<TableCell collapsing>
<Button icon size='mini' onClick={() => move(index, -1)} disabled={index === 0}
aria-label='Move up'><Icon name='arrow up'/></Button>
<Button icon size='mini' onClick={() => move(index, 1)}
disabled={index === draft.length - 1}
aria-label='Move down'><Icon name='arrow down'/></Button>
</TableCell>
<TableCell>
<Input fluid value={f.label || ''} placeholder='Label'
onChange={e => update(index, {label: e.target.value})}/>
</TableCell>
<TableCell>
<Select fluid options={FIELD_TYPE_OPTIONS} value={f.type}
onChange={(e, data) => update(index, {type: data.value})}/>
</TableCell>
<TableCell>
{f.type === 'quantity' &&
<Select fluid search options={UNIT_OPTIONS} value={f.unit || ''}
placeholder='Default unit'
onChange={(e, data) => update(index, {unit: data.value})}/>}
{f.type === 'select' &&
<Input fluid placeholder='comma,separated,options'
value={(f.options || []).join(',')}
onChange={e => update(index, {
options: e.target.value.split(',').map(s => s.trim()).filter(Boolean)
})}/>}
</TableCell>
<TableCell collapsing>
<Button color='red' icon size='mini' onClick={() => remove(index)}
aria-label='Remove field'><Icon name='trash'/></Button>
</TableCell>
</TableRow>)}
</TableBody>
</Table>
<Button onClick={add}><Icon name='plus'/>Add Field</Button>
</Modal.Content>
<Modal.Actions>
<Button onClick={onClose}>Cancel</Button>
<Button primary onClick={save}>Save Fields</Button>
</Modal.Actions>
</Modal>;
}

View file

@ -0,0 +1,50 @@
import React from "react";
import {InventoryRoute} from "./InventoryRoute";
const FIELDS = [
{key: 'name', label: 'Name', type: 'text', order: 0},
{key: 'item_size', label: 'Size', type: 'quantity', unit: 'lb', order: 1},
{key: 'count', label: 'Count', type: 'number', order: 2},
];
const FOOD_STORAGE = {
slug: 'food-storage', name: 'Food Storage', type: 'food', version: 1, fields: FIELDS,
items: [{id: 1, name: 'Salt', item_size: '25', item_size_unit: 'lb', count: '8'}],
};
describe('Inventory Page', () => {
beforeEach(() => {
// The page loads every inventory in full from one endpoint.
cy.intercept('GET', '**/api/inventory', {statusCode: 200, body: {inventories: [FOOD_STORAGE]}})
.as('getInventories');
});
it('renders an inventory with its items', () => {
cy.mountWithTags(<InventoryRoute/>, {initialEntries: ['/inventory']});
cy.wait('@getInventories');
cy.contains('Food Storage').should('be.visible');
cy.contains('Salt').should('be.visible');
cy.get('input[name="name"]').should('exist'); // the spreadsheet add row
});
it('persists a new item with a whole-inventory PUT', () => {
cy.intercept('PUT', '**/api/inventory/food-storage', (req) => {
req.reply({statusCode: 200, body: {inventory: {...FOOD_STORAGE, version: 2, items: req.body.items}}});
}).as('saveInventory');
cy.mountWithTags(<InventoryRoute/>, {initialEntries: ['/inventory']});
cy.wait('@getInventories');
cy.get('input[name="name"]').last().type('Rice{enter}');
cy.wait('@saveInventory').its('request.body.items').should('have.length', 2);
});
it('switches to the summary tab', () => {
cy.mountWithTags(<InventoryRoute/>, {initialEntries: ['/inventory']});
cy.wait('@getInventories');
cy.contains('Summary').click();
cy.contains('Group by').should('be.visible');
});
});

View file

@ -0,0 +1,177 @@
import React, {useState} from "react";
import {Dropdown, Input, Select} from "semantic-ui-react";
import {Button, Confirm, Header, Icon, Loader, Menu, Modal, Segment} from "../Theme";
import {PageContainer, useTitle} from "../Common";
import {collectLocations, useCatalog, useInventories} from "../../hooks/customHooks";
import {InventoryTable} from "./InventoryTable";
import {InventorySummary} from "./InventorySummary";
import {FieldSchemaEditor} from "./FieldSchemaEditor";
import {CatalogEditor} from "./CatalogEditor";
const INVENTORY_TYPES = [
{key: 'food', value: 'food', text: 'Food Storage'},
{key: 'fuel', value: 'fuel', text: 'Fuel'},
{key: 'tool', value: 'tool', text: 'Tools'},
];
function NewInventoryModal({open, onClose, onCreate}) {
const [name, setName] = useState('');
const [type, setType] = useState('food');
const create = async () => {
const inventory = await onCreate(name, type);
if (inventory) {
setName('');
setType('food');
onClose();
}
};
return <Modal open={open} onClose={onClose} closeIcon>
<Modal.Header>New Inventory</Modal.Header>
<Modal.Content>
<Input fluid label='Name' value={name} onChange={e => setName(e.target.value)}
placeholder='Food Storage' style={{marginBottom: '1em'}}/>
<div>
Type:{' '}
<Select options={INVENTORY_TYPES} value={type} onChange={(e, data) => setType(data.value)}/>
<p style={{fontSize: '0.85em', opacity: 0.7, marginTop: '0.5em'}}>
The type seeds a starting set of fields — you can customize them afterward.
</p>
</div>
</Modal.Content>
<Modal.Actions>
<Button onClick={onClose}>Cancel</Button>
<Button primary onClick={create} disabled={!name.trim()}>Create</Button>
</Modal.Actions>
</Modal>;
}
export function InventoryRoute() {
useTitle('Inventory');
const {inventories, persistInventory, addInventory, removeInventory} = useInventories();
const {catalog, persistCatalog} = useCatalog();
const [slug, setSlug] = useState(null);
const [tab, setTab] = useState('items');
const [newOpen, setNewOpen] = useState(false);
const [catalogOpen, setCatalogOpen] = useState(false);
const [editFieldsOpen, setEditFieldsOpen] = useState(false);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState('');
const [confirmDelete, setConfirmDelete] = useState(false);
// Default to the first inventory once loaded.
React.useEffect(() => {
if (slug == null && inventories && inventories.length > 0) {
setSlug(inventories[0].slug);
}
}, [inventories, slug]);
const current = inventories?.find(i => i.slug === slug);
const fields = current ? current.fields : [];
const items = current ? current.items : [];
// Location suggestions are pooled across every inventory.
const locations = collectLocations(inventories);
const onCreate = async (name, type) => {
const inventory = await addInventory(name, type);
if (inventory) {
setSlug(inventory.slug);
}
return inventory;
};
const doRename = async () => {
await persistInventory(slug, {name: renameValue});
setRenaming(false);
};
const doDelete = async () => {
await removeInventory(slug);
setConfirmDelete(false);
setSlug(null);
};
if (inventories === null) {
return <PageContainer><Loader active inline='centered'/></PageContainer>;
}
const inventoryOptions = inventories.map(i => ({key: i.slug, value: i.slug, text: i.name}));
return <PageContainer>
<Header as='h1'>Inventory</Header>
<Segment>
<div style={{display: 'flex', gap: '0.5em', alignItems: 'center', flexWrap: 'wrap'}}>
<Dropdown
selection
placeholder='Select an inventory'
options={inventoryOptions}
value={slug || ''}
onChange={(e, data) => setSlug(data.value)}
style={{minWidth: '14em'}}
/>
<Button primary icon onClick={() => setNewOpen(true)} aria-label='New inventory'>
<Icon name='plus'/>
</Button>
<Button icon onClick={() => setCatalogOpen(true)} aria-label='Food catalog'>
<Icon name='book'/> Catalog
</Button>
{current && <>
<Button icon onClick={() => {
setRenameValue(current.name);
setRenaming(true);
}} aria-label='Rename inventory'><Icon name='edit'/></Button>
<Button icon onClick={() => setEditFieldsOpen(true)} aria-label='Customize fields'>
<Icon name='columns'/> Fields
</Button>
<Button color='red' icon onClick={() => setConfirmDelete(true)} aria-label='Delete inventory'>
<Icon name='trash'/>
</Button>
</>}
</div>
</Segment>
{current ? <>
<Menu pointing secondary>
<Menu.Item name='Items' active={tab === 'items'} onClick={() => setTab('items')}/>
<Menu.Item name='Summary' active={tab === 'summary'} onClick={() => setTab('summary')}/>
</Menu>
{tab === 'items'
? <InventoryTable slug={slug} fields={fields} items={items} locations={locations} catalog={catalog}
onChange={newItems => persistInventory(slug, {items: newItems})}/>
: <InventorySummary fields={fields} items={items}/>}
<FieldSchemaEditor fields={fields} open={editFieldsOpen}
onClose={() => setEditFieldsOpen(false)}
onSave={newFields => persistInventory(slug, {fields: newFields})}/>
</> : <p>Create an inventory to get started.</p>}
<NewInventoryModal open={newOpen} onClose={() => setNewOpen(false)} onCreate={onCreate}/>
<CatalogEditor catalog={catalog || []} open={catalogOpen} onClose={() => setCatalogOpen(false)}
onSave={persistCatalog}/>
<Modal open={renaming} onClose={() => setRenaming(false)} closeIcon size='tiny'>
<Modal.Header>Rename Inventory</Modal.Header>
<Modal.Content>
<Input fluid value={renameValue} onChange={e => setRenameValue(e.target.value)}/>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setRenaming(false)}>Cancel</Button>
<Button primary onClick={doRename} disabled={!renameValue.trim()}>Save</Button>
</Modal.Actions>
</Modal>
<Confirm
open={confirmDelete}
header='Delete Inventory'
content={`Delete "${current?.name}" and all its items? This cannot be undone.`}
confirmButton='Delete'
onCancel={() => setConfirmDelete(false)}
onConfirm={doDelete}
/>
</PageContainer>;
}

View file

@ -0,0 +1,131 @@
import React, {useMemo, useState} from "react";
import {Select, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Header, Segment, Table} from "../Theme";
import {formatTotals, sumQuantities} from "./units";
import {findCaloriesKey, findCountKey, RationEstimatePanel} from "../calculators/RationCalculator";
import {ThemeContext} from "../../contexts/contexts";
// Aggregate the inventory client-side: group items by a chosen text/select field and sum a chosen quantity field.
// When the inventory has a `calories` field, a ration estimate is shown below the summary table.
export function InventorySummary({fields, items}) {
const groupFields = fields.filter(f => ['text', 'select', 'location'].includes(f.type));
const quantityFields = fields.filter(f => f.type === 'quantity');
const caloriesKey = findCaloriesKey(fields);
const countKey = findCountKey(fields);
const {t} = React.useContext(ThemeContext);
// Default to grouping by Category (easier to see grains/dairy/etc.), falling back to the first group field.
const [groupKey, setGroupKey] = useState((groupFields.find(f => f.key === 'category') || groupFields[0])?.key);
const [quantityKey, setQuantityKey] = useState(quantityFields[0]?.key);
const [sort, setSort] = useState({key: 'name', dir: 'asc'});
const toggleSort = (key) => setSort(prev =>
prev.key === key
? {key, dir: prev.dir === 'asc' ? 'desc' : 'asc'}
: {key, dir: key === 'name' ? 'asc' : 'desc'});
const rows = useMemo(() => {
if (!groupKey) {
return [];
}
// A blank/zero count means a single unit (matches the ration estimate's convention).
const unitsOf = (item) => {
const c = countKey ? Number(item[countKey]) : 1;
return c > 0 ? c : 1;
};
const groups = {};
(items || []).forEach(item => {
const key = item[groupKey] || '(none)';
if (!groups[key]) {
groups[key] = {entries: [], count: 0, calories: 0};
}
const group = groups[key];
group.count += 1;
const units = unitsOf(item);
if (quantityKey) {
// Total size = per-container size × count (the count field), so the summary matches reality.
const size = Number(item[quantityKey]);
group.entries.push({
value: (size > 0 ? size : 0) * units,
unit: item[`${quantityKey}_unit`],
});
}
if (caloriesKey) {
const cal = Number(item[caloriesKey]);
if (cal > 0) {
group.calories += cal * units;
}
}
});
const mapped = Object.entries(groups).map(([name, g]) => {
const summed = sumQuantities(g.entries);
// "Total" is a multi-unit string; keep a numeric proxy (sum of magnitudes + unitless) for sorting.
const totalSort = (summed.totals || []).reduce((s, x) => s + x.magnitude, 0) + (summed.unitlessCount || 0);
return {name, count: g.count, total: formatTotals(summed), totalSort, calories: g.calories};
});
const compare = (a, b) => {
const r = sort.key === 'name' ? a.name.localeCompare(b.name)
: sort.key === 'count' ? a.count - b.count
: sort.key === 'total' ? a.totalSort - b.totalSort
: a.calories - b.calories;
return sort.dir === 'desc' ? -r : r;
};
return mapped.sort(compare);
}, [items, groupKey, quantityKey, countKey, caloriesKey, sort]);
const sortDir = (key) => sort.key === key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined;
if (!items || items.length === 0) {
return <p {...t}>No items to summarize yet.</p>;
}
const fieldOptions = (fs) => fs.map(f => ({key: f.key, value: f.key, text: f.label}));
return <>
<Header as='h3'>Summary</Header>
<div style={{marginBottom: '1em', display: 'flex', gap: '1em', flexWrap: 'wrap'}}>
<span {...t}>
Group by:{' '}
<Select options={fieldOptions(groupFields)} value={groupKey}
onChange={(e, data) => setGroupKey(data.value)}/>
</span>
{quantityFields.length > 0 &&
<span>
Sum:{' '}
<Select clearable options={fieldOptions(quantityFields)} value={quantityKey}
onChange={(e, data) => setQuantityKey(data.value)}/>
</span>}
</div>
<Table celled unstackable sortable>
<TableHeader>
<TableRow>
<TableHeaderCell sorted={sortDir('name')} onClick={() => toggleSort('name')}
style={{cursor: 'pointer'}}>
{groupFields.find(f => f.key === groupKey)?.label || 'Group'}
</TableHeaderCell>
<TableHeaderCell sorted={sortDir('count')} onClick={() => toggleSort('count')}
style={{cursor: 'pointer'}}>Items</TableHeaderCell>
{quantityKey && <TableHeaderCell sorted={sortDir('total')} onClick={() => toggleSort('total')}
style={{cursor: 'pointer'}}>Total</TableHeaderCell>}
{caloriesKey && <TableHeaderCell sorted={sortDir('calories')} onClick={() => toggleSort('calories')}
style={{cursor: 'pointer'}}>Calories</TableHeaderCell>}
</TableRow>
</TableHeader>
<TableBody>
{rows.map(row => <TableRow key={row.name}>
<TableCell>{row.name}</TableCell>
<TableCell>{row.count}</TableCell>
{quantityKey && <TableCell>{row.total}</TableCell>}
{caloriesKey &&
<TableCell>{row.calories > 0 ? `${row.calories.toLocaleString()} kcal` : '—'}</TableCell>}
</TableRow>)}
</TableBody>
</Table>
{caloriesKey &&
<Segment style={{marginTop: '2em'}}>
<Header as='h3'>Ration Estimate</Header>
<RationEstimatePanel items={items} caloriesKey={caloriesKey} countKey={countKey}/>
</Segment>}
</>;
}

View file

@ -0,0 +1,39 @@
import React from "react";
import {fireEvent, render, screen} from "@testing-library/react";
import {InventorySummary} from "./InventorySummary";
// Group by the first text field (category); two groups with different item counts and size totals.
const FIELDS = [
{key: 'category', label: 'Category', type: 'text', order: 0},
{key: 'item_size', label: 'Size', type: 'quantity', unit: 'lb', order: 1},
{key: 'count', label: 'Count', type: 'number', order: 2},
];
const ITEMS = [
{id: 1, category: 'grains', item_size: '10', item_size_unit: 'lb', count: '2'}, // 20 lb
{id: 2, category: 'grains', item_size: '5', item_size_unit: 'lb', count: '2'}, // 10 lb -> grains: 2 items, 30 lb
{id: 3, category: 'beans', item_size: '25', item_size_unit: 'lb', count: '4'}, // beans: 1 item, 100 lb
];
const groupOrder = () =>
[...document.querySelectorAll('tbody tr')].map(tr => tr.querySelector('td:first-child').textContent);
describe('InventorySummary sortable columns', () => {
test('defaults to group name ascending', () => {
render(<InventorySummary fields={FIELDS} items={ITEMS}/>);
expect(groupOrder()).toEqual(['beans', 'grains']);
});
test('sorting by Items uses the numeric count', () => {
render(<InventorySummary fields={FIELDS} items={ITEMS}/>);
fireEvent.click(screen.getByText('Items')); // first click -> descending
expect(groupOrder()).toEqual(['grains', 'beans']); // grains has 2 items, beans 1
fireEvent.click(screen.getByText('Items')); // toggle -> ascending
expect(groupOrder()).toEqual(['beans', 'grains']);
});
test('sorting by Total uses the summed magnitude, not the formatted string', () => {
render(<InventorySummary fields={FIELDS} items={ITEMS}/>);
fireEvent.click(screen.getByText('Total')); // descending
expect(groupOrder()).toEqual(['beans', 'grains']); // beans 100 lb > grains 30 lb
});
});

View file

@ -0,0 +1,286 @@
import React, {useMemo, useRef, useState} from "react";
import {Checkbox, TableBody, TableCell, TableFooter, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Button, Icon, Table} from "../Theme";
import {FieldCell} from "./FieldCell";
const LOCATION_LIST_ID = 'inventory-location-suggestions';
const CATALOG_LIST_ID = 'inventory-catalog-names';
const NUMERIC_TYPES = ['number', 'quantity', 'calories'];
// Fields copied from a catalog entry onto the new-item row when its name matches a catalog entry.
const CATALOG_KEYS = ['name', 'category', 'subcategory', 'item_size', 'item_size_unit', 'calories'];
// Build an empty item keyed by the field schema (quantity fields seed their default unit).
function emptyItem(fields) {
const item = {};
fields.forEach(f => {
item[f.key] = '';
if (f.type === 'quantity') {
item[`${f.key}_unit`] = f.unit || '';
}
});
return item;
}
function nextId(items) {
return items.reduce((max, i) => (typeof i.id === 'number' && i.id > max ? i.id : max), 0) + 1;
}
// Type-aware comparison for sorting: numeric for number/quantity/calories, chronological for date, locale string
// otherwise. Blank values sort last (ascending).
function compareByField(a, b, field) {
const av = a[field.key], bv = b[field.key];
const aBlank = av === '' || av == null;
const bBlank = bv === '' || bv == null;
if (aBlank || bBlank) {
return aBlank === bBlank ? 0 : (aBlank ? 1 : -1);
}
if (NUMERIC_TYPES.includes(field.type)) {
return (Number(av) || 0) - (Number(bv) || 0);
}
if (field.type === 'date') {
return (Date.parse(av) || 0) - (Date.parse(bv) || 0);
}
return String(av).localeCompare(String(bv));
}
/**
* Spreadsheet-style item table. All edits happen on the in-memory items; `onChange(newItems)` persists the whole
* inventory (the page holds every inventory and saves the changed one). A persistent "new item" row sits at the
* bottom: Tab moves between fields and Enter submits the item then refocuses the first field.
*/
export function InventoryTable({slug, fields, items, locations, catalog, onChange}) {
const [draft, setDraft] = useState(() => emptyItem(fields));
const [edits, setEdits] = useState({}); // itemId -> edited copy
const [selected, setSelected] = useState(new Set());
const [sort, setSort] = useState(null); // {key, dir: 'asc'|'desc'} or null for entry order
const firstInputRef = useRef(null);
const hasLocationField = fields.some(f => f.type === 'location');
const hasNameField = fields.some(f => f.key === 'name');
// Catalog lookup by lower-cased name, and the set of field keys this inventory actually has (so pre-fill only
// touches existing fields). Catalog integration only applies when the inventory has a `name` field.
const catalogByName = useMemo(() => {
const map = {};
(catalog || []).forEach(e => {
if (e.name) {
map[e.name.trim().toLowerCase()] = e;
}
});
return map;
}, [catalog]);
const fieldKeySet = useMemo(() => {
const keys = new Set();
fields.forEach(f => {
keys.add(f.key);
if (f.type === 'quantity') {
keys.add(`${f.key}_unit`);
}
});
return keys;
}, [fields]);
const listIdFor = (field) => {
if (field.type === 'location') {
return LOCATION_LIST_ID;
}
if (field.key === 'name' && (catalog || []).length) {
return CATALOG_LIST_ID;
}
return undefined;
};
// Re-seed local editing state when the inventory or its schema changes.
React.useEffect(() => {
setDraft(emptyItem(fields));
setEdits({});
setSelected(new Set());
setSort(null);
}, [slug, fields]);
// Expired = any date-type field strictly before the start of today.
const dateFields = useMemo(() => fields.filter(f => f.type === 'date'), [fields]);
const todayStart = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d.getTime();
}, []);
const isExpired = (item) => dateFields.some(f => {
const t = Date.parse(item[f.key]);
return !isNaN(t) && t < todayStart;
});
const toggleSort = (key) => setSort(prev =>
prev && prev.key === key
? {key, dir: prev.dir === 'asc' ? 'desc' : 'asc'}
: {key, dir: 'asc'});
// Rows are sorted for display only; the persistent entry row lives in the footer and is unaffected.
const displayItems = useMemo(() => {
const list = items || [];
if (!sort) {
return list;
}
const field = fields.find(f => f.key === sort.key);
if (!field) {
return list;
}
const sorted = [...list].sort((a, b) => compareByField(a, b, field));
return sort.dir === 'desc' ? sorted.reverse() : sorted;
}, [items, fields, sort]);
const focusFirst = () => {
const node = firstInputRef.current;
if (node && node.focus) {
node.focus();
} else if (node && node.inputRef && node.inputRef.current) {
node.inputRef.current.focus();
}
};
const setDraftValue = (key, value) => setDraft(prev => {
const next = {...prev, [key]: value};
// When the Name matches a catalog entry, pre-fill the matching fields (category, size, calories, ...).
if (key === 'name' && hasNameField) {
const entry = catalogByName[String(value).trim().toLowerCase()];
if (entry) {
CATALOG_KEYS.forEach(k => {
if (k !== 'name' && fieldKeySet.has(k) && entry[k] != null && entry[k] !== '') {
next[k] = entry[k];
}
});
}
}
return next;
});
const submitDraft = () => {
const hasValue = Object.entries(draft).some(([k, v]) => !k.endsWith('_unit') && v !== '' && v != null);
if (!hasValue) {
return;
}
onChange([...items, {...draft, id: nextId(items)}]);
setDraft(emptyItem(fields));
focusFirst();
};
const startEdit = (item) => setEdits(prev => ({...prev, [item.id]: {...item}}));
const setEditValue = (id, key, value) =>
setEdits(prev => ({...prev, [id]: {...prev[id], [key]: value}}));
const saveEdit = (id) => {
onChange(items.map(i => i.id === id ? edits[id] : i));
setEdits(prev => {
const next = {...prev};
delete next[id];
return next;
});
};
const toggleSelected = (id) => setSelected(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const removeSelected = () => {
if (selected.size === 0) {
return;
}
onChange(items.filter(i => !selected.has(i.id)));
setSelected(new Set());
};
const sortedFields = [...fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
return <>
{/* Shared location suggestions (pooled across all inventories), referenced by location-field inputs. */}
{hasLocationField &&
<datalist id={LOCATION_LIST_ID}>
{(locations || []).map(l => <option key={l} value={l}/>)}
</datalist>}
{/* Food-catalog name suggestions; selecting one pre-fills the matching fields. */}
{hasNameField && (catalog || []).length > 0 &&
<datalist id={CATALOG_LIST_ID}>
{catalog.map(e => <option key={e.id ?? e.name} value={e.name}/>)}
</datalist>}
{selected.size > 0 &&
<Button color='red' onClick={removeSelected} style={{marginBottom: '0.5em'}}>
Delete {selected.size} selected
</Button>}
<Table celled unstackable sortable>
<TableHeader>
<TableRow>
<TableHeaderCell collapsing/>
{sortedFields.map(f => <TableHeaderCell
key={f.key}
sorted={sort && sort.key === f.key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined}
onClick={() => toggleSort(f.key)}
style={{cursor: 'pointer'}}>
{f.label}
</TableHeaderCell>)}
</TableRow>
</TableHeader>
<TableBody>
{displayItems.map(item => {
const editing = edits[item.id];
const expired = isExpired(item);
return <TableRow key={item.id} negative={expired}>
<TableCell collapsing>
<Checkbox checked={selected.has(item.id)} onChange={() => toggleSelected(item.id)}/>
{expired && <Icon name='warning sign' color='red' title='Expired'
style={{marginLeft: '0.4em'}}/>}
</TableCell>
{sortedFields.map(f => <TableCell key={f.key}>
{editing
? <FieldCell
field={f}
value={editing[f.key]}
unitValue={editing[`${f.key}_unit`]}
onChange={v => setEditValue(item.id, f.key, v)}
onUnitChange={v => setEditValue(item.id, `${f.key}_unit`, v)}
onEnter={() => saveEdit(item.id)}
listId={listIdFor(f)}
/>
: <span onClick={() => startEdit(item)} style={{cursor: 'pointer'}}>
{formatValue(item, f)}
</span>}
</TableCell>)}
</TableRow>;
})}
</TableBody>
<TableFooter>
{/* The persistent new-item entry row. */}
<TableRow>
<TableCell collapsing>
<Button primary size='mini' onClick={submitDraft} aria-label='Add item'>+</Button>
</TableCell>
{sortedFields.map((f, index) => <TableCell key={f.key}>
<FieldCell
field={f}
value={draft[f.key]}
unitValue={draft[`${f.key}_unit`]}
onChange={v => setDraftValue(f.key, v)}
onUnitChange={v => setDraftValue(`${f.key}_unit`, v)}
onEnter={submitDraft}
inputRef={index === 0 ? firstInputRef : undefined}
listId={listIdFor(f)}
/>
</TableCell>)}
</TableRow>
</TableFooter>
</Table>
</>;
}
function formatValue(item, field) {
const value = item[field.key];
if (value === '' || value == null) {
return '';
}
if (field.type === 'quantity') {
const unit = item[`${field.key}_unit`] || field.unit || '';
return `${value} ${unit}`.trim();
}
return String(value);
}

View file

@ -0,0 +1,143 @@
import React from "react";
import {fireEvent, render, screen} from "@testing-library/react";
import {InventoryTable} from "./InventoryTable";
const FIELDS = [
{key: 'name', label: 'Name', type: 'text', order: 0},
{key: 'count', label: 'Count', type: 'number', order: 1},
];
describe('InventoryTable keyboard entry', () => {
test('pressing Enter in the draft row submits the new item via onChange', () => {
const onChange = jest.fn();
render(<InventoryTable slug='food-storage' fields={FIELDS} items={[]} locations={[]} onChange={onChange}/>);
const nameInput = screen.getAllByLabelText('Name')[0];
fireEvent.change(nameInput, {target: {value: 'Salt'}});
fireEvent.keyDown(nameInput, {key: 'Enter'});
expect(onChange).toHaveBeenCalledTimes(1);
const newItems = onChange.mock.calls[0][0];
expect(newItems).toHaveLength(1);
expect(newItems[0].name).toBe('Salt');
expect(typeof newItems[0].id).toBe('number');
});
test('does not submit an empty draft row', () => {
const onChange = jest.fn();
render(<InventoryTable slug='food-storage' fields={FIELDS} items={[]} locations={[]} onChange={onChange}/>);
const nameInput = screen.getAllByLabelText('Name')[0];
fireEvent.keyDown(nameInput, {key: 'Enter'});
expect(onChange).not.toHaveBeenCalled();
});
test('new item id is unique relative to existing items', () => {
const onChange = jest.fn();
const items = [{id: 7, name: 'Salt', count: '5'}];
render(<InventoryTable slug='food-storage' fields={FIELDS} items={items} locations={[]} onChange={onChange}/>);
const nameInput = screen.getAllByLabelText('Name').slice(-1)[0]; // the draft row's input
fireEvent.change(nameInput, {target: {value: 'Rice'}});
fireEvent.keyDown(nameInput, {key: 'Enter'});
const newItems = onChange.mock.calls[0][0];
expect(newItems).toHaveLength(2);
expect(newItems[1].id).toBe(8);
});
test('clicking an existing item cell starts inline edit', () => {
const items = [{id: 1, name: 'Salt', count: '5'}];
render(<InventoryTable slug='food-storage' fields={FIELDS} items={items} locations={[]} onChange={jest.fn()}/>);
fireEvent.click(screen.getByText('Salt'));
const editInputs = screen.getAllByLabelText('Name').map(i => i.value);
expect(editInputs).toContain('Salt');
});
});
describe('InventoryTable sorting', () => {
const items = [
{id: 1, name: 'Rice', count: '5'},
{id: 2, name: 'Beans', count: '20'},
{id: 3, name: 'Apples', count: '12'},
];
const bodyNames = () =>
[...document.querySelectorAll('tbody tr')].map(tr => tr.querySelector('td:nth-child(2)').textContent);
test('clicking a text header sorts ascending then descending', () => {
render(<InventoryTable slug='s' fields={FIELDS} items={items} locations={[]} onChange={jest.fn()}/>);
expect(bodyNames()).toEqual(['Rice', 'Beans', 'Apples']); // entry order
fireEvent.click(screen.getByText('Name'));
expect(bodyNames()).toEqual(['Apples', 'Beans', 'Rice']);
fireEvent.click(screen.getByText('Name'));
expect(bodyNames()).toEqual(['Rice', 'Beans', 'Apples']);
});
test('numeric column sorts numerically, not lexically', () => {
render(<InventoryTable slug='s' fields={FIELDS} items={items} locations={[]} onChange={jest.fn()}/>);
fireEvent.click(screen.getByText('Count'));
// 5 < 12 < 20 numerically (lexical would put '12','20','5').
expect(bodyNames()).toEqual(['Rice', 'Apples', 'Beans']);
});
});
describe('InventoryTable catalog pre-fill', () => {
const CAT_FIELDS = [
{key: 'name', label: 'Name', type: 'text', order: 0},
{key: 'category', label: 'Category', type: 'text', order: 1},
{key: 'item_size', label: 'Size', type: 'quantity', unit: 'lb', order: 2},
{key: 'calories', label: 'kcal', type: 'calories', order: 3},
];
const CATALOG = [
{id: 1, name: 'White Rice', category: 'grains', subcategory: 'rice',
item_size: '5', item_size_unit: 'lb', calories: '8040'},
];
test('selecting a catalog name pre-fills the matching fields', () => {
render(<InventoryTable slug='s' fields={CAT_FIELDS} items={[]} locations={[]} catalog={CATALOG}
onChange={jest.fn()}/>);
// The catalog datalist is present.
expect(document.querySelector('#inventory-catalog-names option[value="White Rice"]')).toBeTruthy();
fireEvent.change(screen.getByLabelText('Name'), {target: {value: 'White Rice'}});
expect(screen.getByLabelText('Category').value).toBe('grains');
expect(screen.getByLabelText('Size').value).toBe('5');
expect(screen.getByLabelText('kcal').value).toBe('8040');
});
test('a non-catalog name leaves other fields untouched', () => {
render(<InventoryTable slug='s' fields={CAT_FIELDS} items={[]} locations={[]} catalog={CATALOG}
onChange={jest.fn()}/>);
fireEvent.change(screen.getByLabelText('Name'), {target: {value: 'Homemade Jerky'}});
expect(screen.getByLabelText('Category').value).toBe('');
expect(screen.getByLabelText('kcal').value).toBe('');
});
});
describe('InventoryTable expired highlighting', () => {
const FIELDS_WITH_DATE = [
{key: 'name', label: 'Name', type: 'text', order: 0},
{key: 'expiration_date', label: 'Expires', type: 'date', order: 1},
];
test('rows past a date field are flagged negative with a warning icon', () => {
const items = [
{id: 1, name: 'Old', expiration_date: '2000-01-01'},
{id: 2, name: 'Fresh', expiration_date: '2999-01-01'},
];
const {container} = render(
<InventoryTable slug='s' fields={FIELDS_WITH_DATE} items={items} locations={[]} onChange={jest.fn()}/>);
const rowOf = (name) => screen.getByText(name).closest('tr');
expect(rowOf('Old').className).toContain('negative');
expect(rowOf('Fresh').className).not.toContain('negative');
// One expired row -> one warning icon.
expect(container.querySelectorAll('i.warning.sign.icon').length).toBe(1);
});
});

View file

@ -0,0 +1,141 @@
import {all, create} from "mathjs";
// A scoped mathjs instance so inventory unit handling never affects the global mathjs (used elsewhere). All
// inventory quantity math (conversion + aggregation) lives here — the backend stores quantities as opaque
// {magnitude, unit} strings and does no unit math.
const math = create(all);
// Units offered in the unit dropdown for `quantity` fields, grouped by physical dimension. Names are mathjs unit
// names (note: mathjs uses `lb`/`lbs`/`lbm`, NOT `pound`).
export const UNIT_GROUPS = [
{label: 'Mass', units: ['lb', 'oz', 'kg', 'g', 'mg', 'ton', 'tonne', 'grain']},
{label: 'Volume', units: ['gallon', 'quart', 'pint', 'cup', 'floz', 'liter', 'ml', 'tablespoon', 'teaspoon']},
{label: 'Length', units: ['inch', 'foot', 'yard', 'mile', 'meter', 'cm', 'mm', 'km']},
];
export const ALL_UNITS = UNIT_GROUPS.flatMap(g => g.units);
// Compaction ladders (ascending): a summed quantity is displayed in the largest unit on a compatible ladder where
// the magnitude is still >= 1 (mirrors the old backend `compact_unit`, e.g. 2000 lb -> 1 ton, 16 oz + 1 lb -> 2 lb).
const COMPACTION_LADDERS = [
['oz', 'lb', 'ton'],
];
// Parse a magnitude + unit into a mathjs Unit, or null if it can't be parsed (blank value, or a non-standard unit
// like "can"/"each" the user typed). Note: Number('') === 0, so blank strings are rejected explicitly.
function toUnit(value, unit) {
if (value === '' || value === null || value === undefined) {
return null;
}
if (typeof value === 'string' && value.trim() === '') {
return null;
}
const n = Number(value);
if (!Number.isFinite(n) || !unit) {
return null;
}
try {
return math.unit(n, unit);
} catch {
return null;
}
}
// Convert a {value, unit} to another unit. Returns a number, or null when incompatible/unparseable.
export function convert(value, fromUnit, toUnitName) {
const u = toUnit(value, fromUnit);
if (!u) {
return null;
}
try {
return u.toNumber(toUnitName);
} catch {
return null;
}
}
function compact(unitValue) {
// unitValue is a mathjs Unit. On a compatible ladder, pick the largest unit where the value is still >= 1.
for (const ladder of COMPACTION_LADDERS) {
let compatible = false;
try {
unitValue.toNumber(ladder[0]);
compatible = true;
} catch {
// Not this ladder's dimension.
}
if (!compatible) {
continue;
}
let chosen = ladder[0];
for (const u of ladder) {
if (unitValue.toNumber(u) >= 1) {
chosen = u;
} else {
break;
}
}
return math.unit(unitValue.toNumber(chosen), chosen);
}
return unitValue;
}
function roundNumber(n) {
return Math.round(n * 1e5) / 1e5;
}
/**
* Sum a list of {value, unit} entries, combining compatible units and compacting the result.
*
* Returns {totals: [{magnitude, unit}], unitlessCount}:
* - `totals` is one entry per distinct physical dimension (mass, volume, ...), each summed and compacted.
* - `unitlessCount` counts entries with no/blank/non-standard unit but a numeric value (e.g. "5 cans"),
* so the UI can still show a plain count.
*/
export function sumQuantities(entries) {
const buckets = []; // [{unit: mathjsUnit, sample: unitName}]
let unitlessTotal = 0;
let hasUnitless = false;
for (const {value, unit} of entries) {
const parsed = toUnit(value, unit);
if (!parsed) {
const n = Number(value);
if (Number.isFinite(n) && n !== 0) {
unitlessTotal += n;
hasUnitless = true;
}
continue;
}
// Find an existing bucket with a matching dimension.
const bucket = buckets.find(b => {
try {
return b.unit.equalBase(parsed);
} catch {
return false;
}
});
if (bucket) {
bucket.unit = math.add(bucket.unit, parsed);
} else {
buckets.push({unit: parsed});
}
}
const totals = buckets.map(b => {
const compacted = compact(b.unit);
const {value, unit} = compacted.toJSON();
return {magnitude: roundNumber(value), unit};
});
return {totals, unitlessCount: hasUnitless ? roundNumber(unitlessTotal) : null};
}
// Format the result of sumQuantities into a short human string, e.g. "1.5 lb + 2 gallon (3 unitless)".
export function formatTotals(summed) {
const parts = (summed.totals || []).map(t => `${t.magnitude} ${t.unit}`);
if (summed.unitlessCount) {
parts.push(`${summed.unitlessCount} unitless`);
}
return parts.length ? parts.join(' + ') : '—';
}

View file

@ -0,0 +1,69 @@
import {convert, formatTotals, sumQuantities} from "./units";
describe('convert', () => {
test('converts within mass', () => {
expect(convert(16, 'oz', 'lb')).toBeCloseTo(1, 5);
expect(convert(2000, 'lb', 'ton')).toBeCloseTo(1, 5);
});
test('converts within volume', () => {
expect(convert(1, 'gallon', 'quart')).toBeCloseTo(4, 5);
});
test('returns null for incompatible or unparseable units', () => {
expect(convert(1, 'lb', 'gallon')).toBeNull();
expect(convert(1, 'each', 'lb')).toBeNull();
expect(convert('', 'lb', 'oz')).toBeNull();
});
});
describe('sumQuantities', () => {
test('sums compatible units and compacts', () => {
const result = sumQuantities([
{value: '16', unit: 'oz'},
{value: '1', unit: 'lb'},
]);
expect(result.totals).toHaveLength(1);
expect(result.totals[0].unit).toBe('lb');
expect(result.totals[0].magnitude).toBeCloseTo(2, 5);
expect(result.unitlessCount).toBeNull();
});
test('compacts pounds to tons past the threshold', () => {
const result = sumQuantities([{value: '2000', unit: 'lb'}]);
expect(result.totals[0].unit).toBe('ton');
expect(result.totals[0].magnitude).toBeCloseTo(1, 5);
});
test('keeps incompatible dimensions in separate buckets', () => {
const result = sumQuantities([
{value: '1', unit: 'lb'},
{value: '1', unit: 'gallon'},
]);
expect(result.totals).toHaveLength(2);
const unitNames = result.totals.map(t => t.unit).sort();
expect(unitNames).toEqual(['gallon', 'lb']);
});
test('counts unitless / non-standard-unit entries separately', () => {
const result = sumQuantities([
{value: '5', unit: 'each'},
{value: '3', unit: ''},
{value: '1', unit: 'lb'},
]);
expect(result.unitlessCount).toBeCloseTo(8, 5);
expect(result.totals).toHaveLength(1);
expect(result.totals[0].unit).toBe('lb');
});
});
describe('formatTotals', () => {
test('joins totals and unitless count', () => {
const summed = {totals: [{magnitude: 1.5, unit: 'lb'}, {magnitude: 2, unit: 'gallon'}], unitlessCount: 3};
expect(formatTotals(summed)).toBe('1.5 lb + 2 gallon + 3 unitless');
});
test('em-dash when empty', () => {
expect(formatTotals({totals: [], unitlessCount: null})).toBe('—');
});
});

View file

@ -16,7 +16,12 @@ import {
getDomain,
getDownloads,
getFiles,
getInventory,
getInventories,
createInventory,
saveInventory,
deleteInventory,
getCatalog,
saveCatalog,
getOutdatedZims,
getSettings,
getStatistics,
@ -1471,33 +1476,107 @@ export const useArchiveStatistics = () => {
return {statistics, fetchStatistics}
}
export const useInventory = (inventoryId) => {
const [byCategory, setByCategory] = useState();
const [bySubcategory, setBySubcategory] = useState();
const [byName, setByName] = useState();
export const useInventories = () => {
// Single in-memory store: every inventory (full) is loaded once. The list, the selected inventory's items,
// the summary, location suggestions, and the ration calculator all derive from this — no per-action GETs.
// Mutations persist the whole changed inventory (optimistically) instead of calling granular endpoints.
const [inventories, setInventories] = useState(null);
const ref = useRef([]);
useEffect(() => {
ref.current = inventories || [];
}, [inventories]);
const fetchInventory = async () => {
if (!inventoryId) {
return
const fetchInventories = async () => {
const result = await getInventories();
if (Array.isArray(result)) {
setInventories(result);
}
try {
const inventory = await getInventory(inventoryId);
setByCategory(enumerate(inventory['by_category']));
setBySubcategory(enumerate(inventory['by_subcategory']));
setByName(enumerate(inventory['by_name']));
} catch (e) {
console.error(e);
}
}
};
useEffect(() => {
fetchInventory();
}, [inventoryId]);
fetchInventories();
}, []);
return {byCategory, bySubcategory, byName, fetchInventory}
// Persist a patch ({name?, fields?, items?}) to one inventory: update locally first for snappy UI, then save.
// On conflict/error the saved value is null, so we re-fetch to reconcile with the server.
const persistInventory = async (slug, patch) => {
const current = ref.current.find(i => i.slug === slug);
const version = current ? current.version : undefined;
setInventories(prev => (prev || []).map(i => i.slug === slug ? {...i, ...patch} : i));
const saved = await saveInventory(slug, {...patch, version});
if (saved) {
setInventories(prev => (prev || []).map(i => i.slug === slug ? saved : i));
} else {
await fetchInventories();
}
return saved;
};
const addInventory = async (name, type) => {
const inventory = await createInventory({name, type});
if (inventory) {
setInventories(prev => [...(prev || []), inventory]);
}
return inventory;
};
const removeInventory = async (slug) => {
const response = await deleteInventory(slug);
if (response.ok) {
setInventories(prev => (prev || []).filter(i => i.slug !== slug));
}
return response;
};
return {inventories, fetchInventories, persistInventory, addInventory, removeInventory}
}
// The shared food catalog: load once, persist the whole list on change (optimistic).
export const useCatalog = () => {
const [catalog, setCatalog] = useState(null);
const fetchCatalog = async () => {
const result = await getCatalog();
if (Array.isArray(result)) {
setCatalog(result);
}
};
useEffect(() => {
fetchCatalog();
}, []);
const persistCatalog = async (items) => {
setCatalog(items);
const saved = await saveCatalog(items);
if (Array.isArray(saved)) {
setCatalog(saved);
} else {
await fetchCatalog();
}
return saved;
};
return {catalog, fetchCatalog, persistCatalog};
};
// Pool location suggestions from every inventory's `location`-type fields (shared across inventories).
export const collectLocations = (inventories) => {
const locations = new Set();
(inventories || []).forEach(inv => {
const keys = (inv.fields || []).filter(f => f.type === 'location').map(f => f.key);
if (keys.length === 0) {
return;
}
(inv.items || []).forEach(item => keys.forEach(k => {
if (item[k]) {
locations.add(item[k]);
}
}));
});
return Array.from(locations).sort();
};
export const useStatistics = () => {
const [statistics, setStatistics] = useState();

View file

@ -1,31 +1,4 @@
from wrolpi.db import get_db_session
from .api import inventory_bp
from .inventory import logger, DEFAULT_CATEGORIES, DEFAULT_INVENTORIES
from .models import Item, Inventory
from .common import logger, get_inventory_configs, get_inventories_config
INVENTORY_INITIALIZED = False
def init_inventory(force=False):
"""
Initialize inventory categories, but only if none already exist. Initializes the inventories, but only if none
already exist.
"""
global INVENTORY_INITIALIZED
if INVENTORY_INITIALIZED and force is False:
return
logger.info('Initializing inventory')
with get_db_session(commit=True) as session:
if session.query(Item).count() == 0:
for subcategory, category in DEFAULT_CATEGORIES:
item = Item(subcategory=subcategory, category=category)
session.add(item)
if session.query(Inventory).count() == 0:
for name in DEFAULT_INVENTORIES:
inv = Inventory(name=name)
session.add(inv)
INVENTORY_INITIALIZED = True
__all__ = ['inventory_bp', 'logger', 'get_inventory_configs', 'get_inventories_config']

View file

@ -5,129 +5,68 @@ from sanic.request import Request
from sanic_ext import validate
from sanic_ext.extensions.openapi import openapi
from modules.inventory import common, inventory, schema
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
from wrolpi.common import run_after, recursive_map
from wrolpi.errors import ValidationError
NAME = 'inventory'
inventory_bp = Blueprint('Inventory', '/api/inventory')
@inventory_bp.get('/categories')
def get_categories(_: Request):
categories = inventory.get_categories()
return json_response(dict(categories=categories))
@inventory_bp.get('/brands')
def get_brands(_: Request):
brands = inventory.get_brands()
return json_response(dict(brands=brands))
# 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('/')
def get_inventories(request: Request):
inventories = inventory.get_inventories(request.ctx.session)
@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.get('/<inventory_id:int>')
def get_inventory(_: Request, inventory_id: int):
by_category = common.get_inventory_by_category(inventory_id)
by_subcategory = common.get_inventory_by_subcategory(inventory_id)
by_name = common.get_inventory_by_name(inventory_id)
return json_response(dict(by_category=by_category, by_subcategory=by_subcategory, by_name=by_name))
@inventory_bp.post('/')
@openapi.definition(
summary='Save a new inventory',
summary='Create a new inventory',
body=schema.InventoryPostRequest,
)
@validate(schema.InventoryPostRequest)
@run_after(common.save_inventories_config)
def post_inventory(_: Request, body: schema.InventoryPostRequest):
data = remove_whitespace(body.__dict__)
inventory.save_inventory(data)
return response.empty(HTTPStatus.CREATED)
inventory = get_inventory_configs().create_inventory(body.name, body.type or 'food')
return json_response(dict(inventory=inventory), HTTPStatus.CREATED)
@inventory_bp.put('/<inventory_id:int>')
@openapi.definition(
summary='Update an inventory',
body=schema.InventoryPutRequest,
)
@validate(schema.InventoryPutRequest)
@run_after(common.save_inventories_config)
def put_inventory(_: Request, inventory_id: int, body: schema.InventoryPutRequest):
data = remove_whitespace(body.__dict__)
inventory.update_inventory(inventory_id, data)
return response.empty()
# 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.delete('/<inventory_id:int>')
@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.')
@run_after(common.save_inventories_config)
def inventory_delete(_: Request, inventory_id: int):
inventory.delete_inventory(inventory_id)
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('/<inventory_id:int>/item')
@openapi.description('Get all items from an inventory.')
def items_get(_: Request, inventory_id: int):
items = inventory.get_items(inventory_id)
return json_response({'items': items})
@inventory_bp.post('/<inventory_id:int>/item')
@openapi.definition(
summary="Save an item into it's inventory.",
body=schema.ItemPostRequest,
)
@validate(schema.ItemPostRequest)
@run_after(common.save_inventories_config)
def post_item(_: Request, inventory_id: int, body: schema.ItemPostRequest):
data = remove_whitespace(body.__dict__)
inventory.save_item(inventory_id, data)
return response.empty()
@inventory_bp.put('/item/<item_id:int>')
@openapi.definition(
summary='Update an item.',
body=schema.ItemPutRequest,
)
@validate(schema.ItemPutRequest)
@run_after(common.save_inventories_config)
def put_item(_: Request, item_id: int, body: schema.ItemPutRequest):
data = remove_whitespace(body.__dict__)
inventory.update_item(item_id, data)
return response.empty()
@inventory_bp.delete('/item/<item_ids:[0-9,]+>', name='item_delete_many')
@inventory_bp.delete('/item/<item_ids:int>', name='item_delete_one')
@openapi.description('Delete items from an inventory.')
@run_after(common.save_inventories_config)
def item_delete(_: Request, item_ids: str):
try:
if isinstance(item_ids, int):
item_ids = [item_ids, ]
else:
item_ids = [int(i) for i in item_ids.split(',')]
except ValueError:
raise ValidationError('Could not parse item_ids')
inventory.delete_items(item_ids)
return response.empty()
def remove_whitespace(obj):
return recursive_map(obj, lambda i: i.strip() if hasattr(i, 'strip') else i)

View file

@ -0,0 +1,129 @@
"""Shared food catalog: a single config-only YAML of known food items used to autocomplete and pre-fill the
inventory entry form.
It ships with curated defaults (``catalog_defaults.yaml``) which are merged into the user's catalog on startup:
new shipped entries are added by their default id, but a default the user has edited or deleted is never
re-added (its id stays in ``merged_default_ids``, acting as a tombstone). `calories` is the total kcal for one
package of `item_size` (the basis the ration estimate uses).
"""
import pathlib
from copy import deepcopy
from dataclasses import dataclass, field
from typing import List
from wrolpi.common import logger, ConfigFile, read_config_data
logger = logger.getChild(__name__)
CATALOG_DEFAULTS_PATH = pathlib.Path(__file__).parent / 'catalog_defaults.yaml'
# The fields copied from a catalog entry onto an inventory item when pre-filling.
CATALOG_ENTRY_KEYS = ('name', 'category', 'subcategory', 'item_size', 'item_size_unit', 'calories')
@dataclass
class CatalogConfigValidator:
version: int = None
items: list = field(default_factory=list)
merged_default_ids: list = field(default_factory=list)
class CatalogConfig(ConfigFile):
file_name = 'inventory/catalog.yaml'
default_config = dict(version=0, items=[], merged_default_ids=[])
validator = CatalogConfigValidator
@property
def items(self) -> list:
return self._config.get('items') or []
@property
def merged_default_ids(self) -> list:
return self._config.get('merged_default_ids') or []
CATALOG_CONFIG: CatalogConfig = CatalogConfig()
TEST_CATALOG_CONFIG: CatalogConfig = None
def get_catalog_config() -> CatalogConfig:
if isinstance(TEST_CATALOG_CONFIG, CatalogConfig):
return TEST_CATALOG_CONFIG
return CATALOG_CONFIG
def set_test_catalog_config(enabled: bool):
global TEST_CATALOG_CONFIG
TEST_CATALOG_CONFIG = CatalogConfig() if enabled else None
def load_catalog_defaults() -> List[dict]:
"""Read the shipped default catalog entries from catalog_defaults.yaml."""
try:
data = read_config_data(CATALOG_DEFAULTS_PATH)
return data.get('items') or []
except Exception as e:
logger.error(f'Failed to read catalog defaults: {e}', exc_info=e)
return []
def _clean_entry(raw: dict) -> dict:
return {k: (raw.get(k) if raw.get(k) is not None else '') for k in CATALOG_ENTRY_KEYS}
def _next_id(items: list) -> int:
return max((i.get('id') for i in items if isinstance(i.get('id'), int)), default=0) + 1
def seed_catalog_defaults(config: CatalogConfig):
"""Merge shipped defaults into the catalog: add any default whose id has not been merged before, but never
re-add one the user edited/deleted (tracked by `merged_default_ids`)."""
defaults = load_catalog_defaults()
if not defaults:
return
merged = set(config.merged_default_ids)
items = [deepcopy(i) for i in config.items]
next_id = _next_id(items)
added = 0
for default in defaults:
default_id = default.get('id')
if default_id is None or default_id in merged:
continue
entry = _clean_entry(default)
entry['id'] = next_id
next_id += 1
items.append(entry)
merged.add(default_id)
added += 1
if added:
config.update({'items': items, 'merged_default_ids': sorted(merged)})
logger.info(f'Seeded {added} food catalog defaults')
def import_catalog_config():
"""Load the catalog from disk (if present), then merge in any new shipped defaults."""
config = get_catalog_config()
if config.get_file().is_file():
config.import_config()
config.successful_import = True
seed_catalog_defaults(config)
def save_catalog_items(items: list) -> list:
"""Replace the catalog's items (whole-list save). Each entry is filtered to the known keys and given a
stable id; `merged_default_ids` is preserved so deleted defaults stay tombstoned."""
config = get_catalog_config()
normalized = []
used = set()
counter = max((i.get('id') for i in (items or []) if isinstance(i.get('id'), int)), default=0)
for raw in (items or []):
entry = _clean_entry(raw)
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)
entry['id'] = item_id
normalized.append(entry)
config.update({'items': normalized})
return deepcopy(config.items)

View file

@ -0,0 +1,89 @@
# Default shared food catalog (shipped with WROLPi).
#
# Transcribed from a "One-Year Emergency Food Supply for One Adult" list.
# `calories` is the TOTAL calories for one package of the given `item_size` (the basis the Summary ration estimate
# uses: total = calories x count). A blank `calories` means the source list gave no calorie figure (mostly spices
# and condiments that contribute ~0 calories) — fill these in as desired; they are editable in the app.
#
# This is the seed for config/inventory/catalog.yaml (merge-keep-edits on update).
version: 0
items:
# --- Grains ---
- {id: 1, name: Long Grain White Rice, category: grains, subcategory: rice, item_size: '5', item_size_unit: lb, calories: '8040'}
- {id: 2, name: Whole Wheat Berries, category: grains, subcategory: wheat, item_size: '5', item_size_unit: lb, calories: '7500'}
- {id: 3, name: Corn Meal, category: grains, subcategory: corn meal, item_size: '5', item_size_unit: lb, calories: '8040'}
- {id: 4, name: Quick Oats, category: grains, subcategory: oats, item_size: '42', item_size_unit: oz, calories: '4500'}
- {id: 5, name: Quick Grits, category: grains, subcategory: grits, item_size: '5', item_size_unit: lb, calories: '7930'}
- {id: 6, name: Spaghetti Noodles, category: grains, subcategory: pasta, item_size: '16', item_size_unit: oz, calories: '1680'}
- {id: 7, name: Pancake & Waffle Mix, category: grains, subcategory: baking mix, item_size: '32', item_size_unit: oz, calories: '3200'}
- {id: 8, name: Brownie or Cake Mix, category: grains, subcategory: baking mix, item_size: '20', item_size_unit: oz, calories: '2200'}
# --- Dairy ---
- {id: 9, name: Nonfat Dry Milk, category: dairy, subcategory: dry milk, item_size: '64', item_size_unit: oz, calories: '6400'}
- {id: 10, name: Evaporated Milk, category: dairy, subcategory: evaporated milk, item_size: '12', item_size_unit: oz, calories: '480'}
- {id: 11, name: Velveeta Cheese, category: dairy, subcategory: cheese, item_size: '32', item_size_unit: oz, calories: '2560'}
# --- Fats ---
- {id: 12, name: Butter, category: fats, subcategory: butter, item_size: '1', item_size_unit: lb, calories: '3200'}
- {id: 13, name: Extra-Virgin Olive Oil, category: fats, subcategory: oil, item_size: '50.7', item_size_unit: oz, calories: '12000'}
- {id: 14, name: Shortening, category: fats, subcategory: shortening, item_size: '3', item_size_unit: lb, calories: '12000'}
- {id: 15, name: Peanut Butter, category: fats, subcategory: peanut butter, item_size: '18', item_size_unit: oz, calories: '3040'}
# --- Sugars ---
- {id: 16, name: White Granulated Sugar, category: sugars, subcategory: white sugar, item_size: '5', item_size_unit: lb, calories: '8500'}
- {id: 17, name: Brown Sugar, category: sugars, subcategory: brown sugar, item_size: '1', item_size_unit: lb, calories: '1700'}
- {id: 18, name: Confectioners Sugar, category: sugars, subcategory: powdered sugar, item_size: '1', item_size_unit: lb, calories: '1800'}
- {id: 19, name: Light Corn Syrup, category: sugars, subcategory: corn syrup, item_size: '16', item_size_unit: oz, calories: '1920'}
- {id: 20, name: Maple Syrup, category: sugars, subcategory: syrup, item_size: '12.5', item_size_unit: oz, calories: '1200'}
- {id: 21, name: Clover Honey, category: sugars, subcategory: honey, item_size: '16', item_size_unit: oz, calories: '1280'}
- {id: 22, name: Cocoa Syrup, category: sugars, subcategory: syrup, item_size: '16', item_size_unit: oz, calories: '1200'}
- {id: 23, name: Jelly or Preserves, category: sugars, subcategory: jelly, item_size: '16', item_size_unit: oz, calories: '1300'}
# --- Meats ---
- {id: 24, name: Tuna in Oil, category: meats, subcategory: canned, item_size: '5', item_size_unit: oz, calories: '187'}
- {id: 25, name: Canned Ham, category: meats, subcategory: canned, item_size: '16', item_size_unit: oz, calories: '800'}
- {id: 26, name: Spam, category: meats, subcategory: canned, item_size: '12', item_size_unit: oz, calories: '1020'}
- {id: 27, name: Vienna Sausage, category: meats, subcategory: canned, item_size: '5', item_size_unit: oz, calories: '375'}
- {id: 28, name: Roast Beef, category: meats, subcategory: canned, item_size: '12', item_size_unit: oz, calories: '375'}
# --- Meals (prepared / canned) ---
- {id: 29, name: Cream of Chicken Soup, category: meals, subcategory: canned, item_size: '10.75', item_size_unit: oz, calories: '300'}
- {id: 30, name: Canned Macaroni and Cheese, category: meals, subcategory: canned, item_size: '15', item_size_unit: oz, calories: '480'}
- {id: 31, name: Canned Pasta, category: meals, subcategory: canned, item_size: '15', item_size_unit: oz, calories: '500'}
- {id: 32, name: Chunky Soup, category: meals, subcategory: canned, item_size: '18.8', item_size_unit: oz, calories: '500'}
- {id: 33, name: Roast Beef Hash, category: meals, subcategory: canned, item_size: '15', item_size_unit: oz, calories: '780'}
- {id: 34, name: Beef Stew, category: meals, subcategory: canned, item_size: '24', item_size_unit: oz, calories: '630'}
- {id: 35, name: Chili with Beans, category: meals, subcategory: canned, item_size: '15', item_size_unit: oz, calories: '700'}
# --- Legumes ---
- {id: 36, name: Canned Beans, category: legumes, subcategory: canned, item_size: '15', item_size_unit: oz, calories: '370'}
# --- Vegetables ---
- {id: 37, name: Tomato Paste, category: vegetables, subcategory: canned, item_size: '6', item_size_unit: oz, calories: '125'}
- {id: 38, name: Spaghetti Sauce, category: vegetables, subcategory: sauce, item_size: '26.5', item_size_unit: oz, calories: '420'}
- {id: 39, name: Sliced Mushrooms, category: vegetables, subcategory: canned, item_size: '4', item_size_unit: oz, calories: '40'}
- {id: 40, name: Mixed Vegetables, category: vegetables, subcategory: canned, item_size: '15', item_size_unit: oz, calories: '140'}
- {id: 41, name: Instant Potatoes, category: vegetables, subcategory: potatoes, item_size: '32', item_size_unit: oz, calories: '3440'}
# --- Fruits ---
- {id: 42, name: Fruit Cocktail, category: fruits, subcategory: canned, item_size: '15', item_size_unit: oz, calories: '315'}
# --- Cooking ingredients (calories blank where the source list gave none) ---
- {id: 43, name: Cocoa Powder, category: cooking ingredients, subcategory: cocoa, item_size: '8', item_size_unit: oz, calories: '900'}
- {id: 44, name: Corn Starch, category: cooking ingredients, subcategory: thickener, item_size: '16', item_size_unit: oz, calories: '1680'}
- {id: 45, name: Baking Soda, category: cooking ingredients, subcategory: leavening, item_size: '16', item_size_unit: oz, calories: '0'}
- {id: 46, name: Pure Salt, category: cooking ingredients, subcategory: salt, item_size: '4', item_size_unit: lb, calories: '0'}
- {id: 47, name: Cream of Tartar, category: cooking ingredients, subcategory: leavening, item_size: '2.62', item_size_unit: oz, calories: ''}
- {id: 48, name: Active Dry Yeast, category: cooking ingredients, subcategory: leavening, item_size: '0.3125', item_size_unit: oz, calories: ''}
- {id: 49, name: Vanilla Extract, category: cooking ingredients, subcategory: flavoring, item_size: '2', item_size_unit: oz, calories: ''}
- {id: 50, name: Ground Black Pepper, category: cooking ingredients, subcategory: spice, item_size: '2.6', item_size_unit: oz, calories: ''}
- {id: 51, name: Seasoned Meat Tenderizer, category: cooking ingredients, subcategory: spice, item_size: '5.5', item_size_unit: oz, calories: ''}
- {id: 52, name: Onion Powder, category: cooking ingredients, subcategory: spice, item_size: '3.12', item_size_unit: oz, calories: ''}
- {id: 53, name: Oregano, category: cooking ingredients, subcategory: spice, item_size: '0.9', item_size_unit: oz, calories: ''}
- {id: 54, name: Garlic Powder, category: cooking ingredients, subcategory: spice, item_size: '2.5', item_size_unit: oz, calories: ''}
- {id: 55, name: Cinnamon, category: cooking ingredients, subcategory: spice, item_size: '2.37', item_size_unit: oz, calories: ''}
- {id: 56, name: Cayenne Red Pepper, category: cooking ingredients, subcategory: spice, item_size: '1.75', item_size_unit: oz, calories: ''}
- {id: 57, name: Lemon Juice, category: cooking ingredients, subcategory: acid, item_size: '15', item_size_unit: oz, calories: ''}
- {id: 58, name: Apple Cider Vinegar, category: cooking ingredients, subcategory: vinegar, item_size: '1', item_size_unit: gallon, calories: ''}
- {id: 59, name: Beef Bouillon Cubes, category: cooking ingredients, subcategory: bouillon, item_size: '', item_size_unit: '', calories: ''}
- {id: 60, name: Chicken Bouillon Cubes, category: cooking ingredients, subcategory: bouillon, item_size: '', item_size_unit: '', calories: ''}

View file

@ -1,286 +1,208 @@
import pathlib
from dataclasses import field, dataclass
from decimal import Decimal
from functools import partial
"""Config-only inventory backend.
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, Tuple
from typing import List, Optional
from pint import Quantity
from wrolpi.common import logger, Base, ConfigFile
from wrolpi.db import get_db_session
from wrolpi.events import Events
from .errors import NoInventories
from .inventory import get_items, get_inventories, get_unit_registry
from .models import Inventory, Item
from wrolpi.common import logger, MultiFileConfig
from wrolpi.errors import ValidationError
from .defaults import (DEFAULT_INVENTORIES, FIELD_TYPES, default_fields, slugify)
MY_DIR: Path = Path(__file__).parent
logger = logger.getChild(__name__)
def sum_by_key(items: List[Base], key: callable):
"""
Sum the total size of each item by the provided key function. Returns a dict containing the key, and the total_size
for that key. Combine total of like units. This means ounces and pounds will be in the same total.
"""
unit_registry = get_unit_registry()
summed = dict()
for item in items:
k = key(item)
item_size, count, unit = item.item_size, item.count, unit_registry(item.unit)
key_dim = (k, unit.dimensionality)
total_size = item_size * count * unit
try:
summed[key_dim] += total_size
except KeyError:
summed[key_dim] = total_size
summed = {k: compact_unit(v) for k, v in summed.items()}
return summed
def get_inventory_by_keys(keys: Tuple, inventory_id: int):
items = get_items(inventory_id)
summed = sum_by_key(items, lambda i: tuple(getattr(i, k) or '' for k in keys))
inventory = []
for key, quantity in sorted(summed.items(), key=lambda i: i[0][0]):
quantity = cleanup_quantity(quantity)
total_size, units = quantity.to_tuple()
unit = units[0][0]
d = dict(total_size=total_size, unit=unit)
d.update(dict(zip(keys, key[0])))
inventory.append(d)
return inventory
get_inventory_by_category = partial(get_inventory_by_keys, ('category',))
get_inventory_by_subcategory = partial(get_inventory_by_keys, ('category', 'subcategory'))
get_inventory_by_name = partial(get_inventory_by_keys, ('brand', 'name'))
INVENTORY_UNITS: dict = None
UNIT_PRECISION = 5
def initialize_inventory_units() -> dict:
global INVENTORY_UNITS
if INVENTORY_UNITS:
return INVENTORY_UNITS
unit_registry = get_unit_registry()
INVENTORY_UNITS = {
('ounce', 1): (16, unit_registry.pound),
('pound', 1): (2000, unit_registry.ton),
}
def compact_unit(quantity: Quantity) -> Quantity:
"""
Convert a Quantity to it's more readable format. Such as 2000 pounds to 1 ton.
"""
initialize_inventory_units()
number, (units,) = quantity.to_tuple()
next_unit = INVENTORY_UNITS.get(units)
if not next_unit:
# No units after this one, return as is.
return round(quantity, UNIT_PRECISION)
max_quantity, next_unit = next_unit
if number >= max_quantity:
# Number is too high for this unit, move to the next unit.
return compact_unit(quantity.to(next_unit))
# No units were necessary, return as is.
quantity = round(quantity, UNIT_PRECISION)
return quantity
def quantity_to_tuple(quantity: Quantity) -> Tuple[Decimal, Quantity]:
unit_registry = get_unit_registry()
decimal, (units,) = quantity.to_tuple()
unit, _ = units
quantity, unit = round(decimal, UNIT_PRECISION), unit_registry(unit)
return quantity, unit
def cleanup_quantity(quantity: Quantity) -> Quantity:
"""Remove trailing zeros from a Quantity."""
num, unit = quantity_to_tuple(quantity)
num = round(num, UNIT_PRECISION)
num = str(num).rstrip('0').rstrip('.')
return Decimal(num) * unit
@dataclass
class InventoriesConfigValidator:
class InventoryConfigValidator:
version: int = None
inventories: list[str] = field(default_factory=list)
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)
class InventoriesConfig(ConfigFile):
file_name = 'inventories.yaml'
default_config = dict(
inventories=[],
version=0,
)
validator = InventoriesConfigValidator
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'])
@property
def inventories(self) -> dict:
return self._config['inventories']
@inventories.setter
def inventories(self, value: dict):
self.update({'inventories': value})
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 dump_config(self, file: pathlib.Path = None, send_events=False, overwrite=False):
inventories = []
with get_db_session() as session:
for inventory in get_inventories(session):
inventories.append(inventory.dict())
if not inventories:
raise NoInventories('No Inventories are in the database!')
def _normalize_items(items: list, fields_: list) -> list:
"""Filter each item to the schema's allowed keys and assign stable ids (fresh id for any missing/duplicate)."""
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:
clean = {k: v for k, v in raw.items() if k in allowed}
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
self._config['inventories'] = inventories
super().dump_config(file, send_events, overwrite)
def import_config(self, file: pathlib.Path = None, send_events=False):
file = file or self.get_file()
# 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'}
# If config file doesn't exist, mark import as successful (nothing to import)
if not file.is_file():
logger.info('No inventories config file, skipping import')
self.successful_import = True
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)
def seed_defaults(self):
"""Create the default example inventories if the directory is empty."""
if self._configs:
return
super().import_config(file)
inventories_data = self.inventories
# Empty inventories list = never delete DB records
if not inventories_data:
logger.info(f'No inventories in config, preserving existing DB inventories')
self.successful_import = True
return
try:
with get_db_session(commit=True) as session:
db_inventories = session.query(Inventory).all()
db_inventories_by_name = {i.name: i for i in db_inventories}
config_inventories = [i['name'] for i in inventories_data]
for inventory_dict in inventories_data:
if inventory_dict['name'] not in db_inventories_by_name:
inventory = Inventory(
name=inventory_dict['name'],
created_at=inventory_dict['created_at'],
deleted_at=inventory_dict['deleted_at'],
)
session.add(inventory)
# Get the Inventory from the DB so we can use its ID.
inventory.flush()
db_inventories_by_name[inventory.name] = inventory
# Delete any inventories no longer in the config.
for name, inventory in db_inventories_by_name.items():
if name not in config_inventories:
session.delete(inventory)
for inventory_dict in inventories_data:
# Items are complex and do not have useful primary keys. Delete all items, create new items.
session.query(Item).filter(Item.inventory_id == inventory_dict['id']).delete()
inventory = db_inventories_by_name[inventory_dict['name']]
for item in inventory_dict['items']:
del item['inventory_id']
del item['id']
item = Item(inventory=inventory, inventory_id=inventory.id, **item)
session.add(item)
item.flush()
self.successful_import = True
except Exception as e:
self.successful_import = False
message = f'Failed to import {self.file_name}'
logger.error(message, exc_info=e)
if send_events:
Events.send_config_import_failed(message)
raise
def preview_backup_import(self, backup_date: str, mode: str) -> dict:
backup_file = self._get_backup_file(backup_date)
backup_data = self.read_config_file(backup_file)
current_data = self.read_config_file() if self.get_file().is_file() else dict(inventories=[], version=0)
backup_inventories = backup_data.get('inventories', [])
current_inventories = current_data.get('inventories', [])
current_names = {inv.get('name') for inv in current_inventories if inv.get('name')}
backup_names = {inv.get('name') for inv in backup_inventories if inv.get('name')}
add = []
remove = []
unchanged = 0
for inv in backup_inventories:
name = inv.get('name')
if name and name not in current_names:
add.append(dict(type='inventory', name=name))
elif name:
unchanged += 1
if mode == 'overwrite':
for name in sorted(current_names - backup_names):
remove.append(dict(type='inventory', name=name))
return dict(mode=mode, add=add, remove=remove, unchanged=unchanged)
def import_backup(self, backup_date: str, mode: str, send_events: bool = False):
self._preserve_current_config()
import shutil
backup_file = self._get_backup_file(backup_date)
config_file = self.get_file()
if mode == 'overwrite':
config_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(backup_file, config_file)
elif mode == 'merge':
backup_data = self.read_config_file(backup_file)
current_data = self.read_config_file() if config_file.is_file() else dict(inventories=[], version=0)
current_names = {inv.get('name') for inv in current_data.get('inventories', []) if inv.get('name')}
merged_inventories = list(current_data.get('inventories', []))
for inv in backup_data.get('inventories', []):
if inv.get('name') and inv['name'] not in current_names:
merged_inventories.append(inv)
merged_data = dict(
inventories=merged_inventories,
version=current_data.get('version', 0) + 1,
)
config_file.parent.mkdir(parents=True, exist_ok=True)
self.write_config_data(merged_data, config_file)
self.import_config(send_events=send_events)
for default in DEFAULT_INVENTORIES:
try:
self.create_inventory(default['name'], default['type'])
except Exception as e:
logger.warning(f'Failed to seed default inventory {default}: {e}')
INVENTORIES_CONFIG: InventoriesConfig = InventoriesConfig()
TEST_INVENTORIES_CONFIG: InventoriesConfig = None
TEST_INVENTORIES_CONFIG: Optional[InventoriesConfig] = None
def get_inventories_config():
global TEST_INVENTORIES_CONFIG
if isinstance(TEST_INVENTORIES_CONFIG, ConfigFile):
def get_inventory_configs() -> InventoriesConfig:
if isinstance(TEST_INVENTORIES_CONFIG, InventoriesConfig):
return TEST_INVENTORIES_CONFIG
global INVENTORIES_CONFIG
return INVENTORIES_CONFIG
# Backwards-compatible alias used by some call sites.
get_inventories_config = get_inventory_configs
def set_test_inventories_config(enabled: bool):
global TEST_INVENTORIES_CONFIG
if enabled:
@ -289,10 +211,20 @@ def set_test_inventories_config(enabled: bool):
TEST_INVENTORIES_CONFIG = None
def save_inventories_config():
"""Write all inventories and their respective items to a WROLPi Config file."""
get_inventories_config().background_dump.activate_switch()
def import_inventories_config():
get_inventories_config().import_config()
"""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)

View file

@ -1,26 +1,58 @@
import pytest
from modules.inventory import Inventory, Item, DEFAULT_CATEGORIES, DEFAULT_INVENTORIES
from modules.inventory import common as inventory_common
@pytest.fixture
def test_inventory(test_session):
inventory = Inventory(name='Test Inventory')
test_session.add(inventory)
test_session.commit()
return inventory
def test_inventory_configs(test_directory):
"""Use an isolated, config-only inventory store pointed at the test directory.
Ensures the shared context is attached so the config's save/switch paths work even in pure-unit tests that
do not start the app via `async_client`.
"""
from wrolpi.api_utils import api_app
from wrolpi.contexts import attach_shared_contexts
if not hasattr(api_app.shared_ctx, 'switches_lock'):
attach_shared_contexts(api_app)
(test_directory / 'config' / 'inventory').mkdir(parents=True, exist_ok=True)
inventory_common.set_test_inventories_config(True)
config = inventory_common.get_inventory_configs()
config.initialize()
try:
yield config
finally:
inventory_common.set_test_inventories_config(False)
@pytest.fixture
def init_test_inventory(test_session):
for subcategory, category in DEFAULT_CATEGORIES:
item = Item(subcategory=subcategory, category=category)
test_session.add(item)
def test_catalog_config(test_directory):
"""Use an isolated, config-only food catalog pointed at the test directory."""
from wrolpi.api_utils import api_app
from wrolpi.contexts import attach_shared_contexts
from modules.inventory import catalog as catalog_module
if not hasattr(api_app.shared_ctx, 'switches_lock'):
attach_shared_contexts(api_app)
for name in DEFAULT_INVENTORIES:
inv = Inventory(name=name)
test_session.add(inv)
(test_directory / 'config' / 'inventory').mkdir(parents=True, exist_ok=True)
catalog_module.set_test_catalog_config(True)
config = catalog_module.get_catalog_config()
config.initialize()
try:
yield config
finally:
catalog_module.set_test_catalog_config(False)
inventory = test_session.query(Inventory).filter_by(name='Food Storage').one()
test_session.commit()
yield inventory
@pytest.fixture
def food_inventory_factory(test_inventory_configs):
"""Create a food inventory (optionally with items) and return its slug."""
def _(name='Food Storage', inventory_type='food', items=None):
inventory = test_inventory_configs.create_inventory(name, inventory_type)
slug = inventory['slug']
if items:
test_inventory_configs.save_inventory(slug, dict(items=items))
return slug
return _

View file

@ -0,0 +1,112 @@
"""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'

View file

@ -3,13 +3,19 @@ from http import HTTPStatus
from wrolpi.errors import APIError
class NoInventories(APIError):
code = 'NO_INVENTORIES'
summary = 'No Inventories'
class UnknownInventory(APIError):
code = 'UNKNOWN_INVENTORY'
summary = 'No inventory with that slug exists'
status_code = HTTPStatus.NOT_FOUND
class InvalidFieldSchema(APIError):
code = 'INVALID_FIELD_SCHEMA'
summary = 'The inventory field schema is invalid'
status_code = HTTPStatus.BAD_REQUEST
class InventoriesVersionMismatch(APIError):
code = 'INVENTORIES_VERSION_MISMATCH'
summary = 'Inventories version in the DB does not match the inventories config'
status_code = HTTPStatus.BAD_REQUEST
class InventoryConflict(APIError):
code = 'INVENTORY_CONFLICT'
summary = 'The inventory was modified since it was loaded'
status_code = HTTPStatus.CONFLICT

View file

@ -1,206 +0,0 @@
from functools import partial
from operator import itemgetter
from typing import List, Tuple
import pint
import psycopg2
from sqlalchemy.orm import Session
from wrolpi.common import logger, Base
from wrolpi.db import get_db_session, get_db_curs
from wrolpi.errors import APIError
from .models import Inventory, Item
logger = logger.getChild(__name__)
__all__ = [
'DEFAULT_CATEGORIES',
'DEFAULT_INVENTORIES',
'delete_inventory',
'delete_items',
'get_brands',
'get_categories',
'get_inventories',
'get_items',
'get_unit_registry',
'logger',
'save_inventory',
'save_item',
'sort_categories',
'update_inventory',
'update_item',
]
UNIT_REGISTRY: pint.UnitRegistry = None
def get_unit_registry() -> pint.UnitRegistry:
global UNIT_REGISTRY
if UNIT_REGISTRY:
return UNIT_REGISTRY
UNIT_REGISTRY = pint.UnitRegistry()
return UNIT_REGISTRY
DEFAULT_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'),
]
DEFAULT_INVENTORIES = [
'Food Storage',
]
# Categories are sorted by category/sub-category.
sort_categories = partial(sorted, key=itemgetter(1, 0))
def get_inventories(session: Session) -> List[Inventory]:
inventories = session.query(Inventory).filter(
Inventory.deleted_at == None, # noqa
).order_by(
Inventory.viewed_at.desc(),
).all()
inventories = list(inventories)
return inventories
IGNORED_INVENTORY_KEYS = {'viewed_at', 'created_at', 'deleted_at'}
def _remove_conflicting_deleted_inventory(inventory: dict, session: Session):
"""
Remove any Inventories that share the inventory's name, but are marked as deleted.
"""
inventories = session.query(Inventory).filter(
Inventory.deleted_at != None, # noqa
Inventory.name == inventory['name'],
).all()
for i in inventories:
session.query(Item).filter_by(inventory_id=i.id).delete()
session.query(Inventory).filter_by(id=i.id).delete()
def save_inventory(inventory):
inventory = {k: v for k, v in inventory.items() if k not in IGNORED_INVENTORY_KEYS}
# Cleanup the whitespace.
inventory['name'] = inventory['name'].strip()
with get_db_session(commit=True) as session:
_remove_conflicting_deleted_inventory(inventory, session)
session.add(Inventory(**inventory))
def update_inventory(inventory_id: int, inventory: dict):
inventory = {k: v for k, v in inventory.items() if k not in IGNORED_INVENTORY_KEYS}
# Cleanup the whitespace.
inventory['name'] = inventory['name'].strip()
with get_db_session(commit=True) as session:
_remove_conflicting_deleted_inventory(inventory, session)
i = session.query(Inventory).filter_by(id=inventory_id).one()
for key, value in inventory.items():
setattr(i, key, value)
def delete_inventory(inventory_id: int):
with get_db_session(commit=True) as session:
inventory = session.query(Inventory).filter_by(id=inventory_id).one()
inventory.delete()
def get_categories() -> List[Tuple[str, str]]:
"""
Get all distinct sets of subcategory and category.
"""
categories = DEFAULT_CATEGORIES.copy()
with get_db_curs() as curs:
curs.execute("SELECT DISTINCT COALESCE(subcategory, ''), COALESCE(category, '') FROM item ORDER BY 1, 2")
try:
db_categories = curs.fetchall()
db_categories = [tuple(i) for i in db_categories]
categories = set(categories + db_categories)
except psycopg2.ProgrammingError:
# No categories in DB.
pass
return sort_categories(categories)
def get_brands() -> List[Tuple[int, str]]:
with get_db_curs() as curs:
curs.execute("SELECT DISTINCT brand FROM item WHERE brand IS NOT NULL AND brand != '' ORDER BY 1")
try:
brands = curs.fetchall()
except psycopg2.ProgrammingError:
# No brands
return []
return list(brands)
def get_items(inventory_id: int) -> List[Base]:
"""
Get all Items in an Inventory, except deleted items.
"""
with get_db_session() as session:
results = session.query(Item).filter(
Item.inventory_id == inventory_id,
Item.deleted_at == None, # noqa
).all()
return results
def save_item(inventory_id: int, item: dict):
with get_db_session(commit=True) as session:
item = Item(inventory_id=inventory_id, **item)
session.add(item)
def update_item(item_id: int, item: dict):
with get_db_session(commit=True) as session:
i = session.query(Item).filter_by(id=item_id).one()
del item['id']
del item['created_at']
for key, value in item.items():
setattr(i, key, value)
def delete_items(items_ids: List[int]):
with get_db_curs(commit=True) as curs:
curs.execute('UPDATE item SET deleted_at=current_timestamp WHERE id = ANY(%s) RETURNING id', (items_ids,))
deleted_ids = {i['id'] for i in curs.fetchall()}
if set(items_ids) != deleted_ids:
raise APIError('Could not delete the items')

View file

@ -0,0 +1,151 @@
"""One-time migration from the legacy DB-backed / single-file inventory system to per-inventory config files.
Runs on startup (idempotent): if any ``config/inventory/*.yaml`` files already exist, it does nothing. Otherwise it
converts the legacy ``config/inventories.yaml`` (or, as a fallback, the legacy ``inventory``/``item`` DB tables) into
per-inventory files typed ``food``, then backs up and renames the old file so it is not re-read.
"""
import shutil
from wrolpi.common import logger, read_config_data
from wrolpi.dates import now
from .defaults import default_fields, slugify
logger = logger.getChild(__name__)
# Legacy pint unit names that are not valid mathjs unit names. Unrecognized units are kept verbatim (the item
# simply won't aggregate in the frontend until the user picks a known unit).
LEGACY_UNIT_MAP = {
'pound': 'lb',
'pounds': 'lb',
'lbs': 'lb',
}
# Legacy item columns we carry over (mapped onto the default food field schema).
_LEGACY_ITEM_KEYS = ('brand', 'name', 'category', 'subcategory', 'count', 'expiration_date')
def normalize_unit(unit) -> str:
if not unit:
return unit
return LEGACY_UNIT_MAP.get(str(unit).strip().lower(), unit)
def _convert_item(legacy: dict, item_id: int) -> dict:
item = dict(id=item_id)
for key in _LEGACY_ITEM_KEYS:
value = legacy.get(key)
if value is not None and value != '':
item[key] = str(value) if not isinstance(value, str) else value
size = legacy.get('item_size')
if size is not None and size != '':
item['item_size'] = str(size)
unit = normalize_unit(legacy.get('unit'))
if unit:
item['item_size_unit'] = unit
return item
def _convert_inventory(legacy: dict, fallback_name: str) -> dict:
name = (legacy.get('name') or fallback_name or 'Inventory').strip()
created = legacy.get('created_at') or now().isoformat()
items = []
for legacy_item in legacy.get('items') or []:
# Skip soft-deleted legacy items.
if legacy_item.get('deleted_at'):
continue
items.append(_convert_item(legacy_item, len(items) + 1))
return dict(
name=name,
type='food',
created_at=str(created),
viewed_at=str(legacy.get('viewed_at') or created),
fields=default_fields('food'),
items=items,
)
def _legacy_inventories_from_db() -> list:
"""Read legacy inventories/items straight from the DB if the tables still exist (pre-drop installs)."""
from wrolpi.db import get_db_curs
inventories = []
try:
with get_db_curs() as curs:
curs.execute("SELECT to_regclass('public.inventory')")
if curs.fetchone()[0] is None:
return []
curs.execute('SELECT id, name, created_at, viewed_at FROM inventory WHERE deleted_at IS NULL')
rows = curs.fetchall()
for row in rows:
inv = dict(id=row[0], name=row[1], created_at=row[2], viewed_at=row[3], items=[])
curs.execute(
'SELECT brand, name, item_size, unit, count, category, subcategory, expiration_date '
'FROM item WHERE inventory_id = %s AND deleted_at IS NULL', (row[0],))
for item in curs.fetchall():
inv['items'].append(dict(
brand=item[0], name=item[1], item_size=item[2], unit=item[3], count=item[4],
category=item[5], subcategory=item[6], expiration_date=item[7],
))
inventories.append(inv)
except Exception as e:
logger.debug(f'No legacy inventory DB tables to migrate: {e}')
return []
return inventories
def migrate_legacy_inventory(config) -> bool:
"""Migrate legacy inventory data into per-inventory config files. Returns True if a migration happened."""
# Already migrated (per-inventory files exist).
if config.discover():
return False
legacy_file = config.get_directory().parent / 'inventories.yaml'
legacy_inventories = []
source = None
if legacy_file.is_file():
try:
data = read_config_data(legacy_file)
legacy_inventories = data.get('inventories') or []
source = 'file'
except Exception as e:
logger.error(f'Failed to read legacy inventories.yaml: {e}', exc_info=e)
if not legacy_inventories:
legacy_inventories = _legacy_inventories_from_db()
if legacy_inventories:
source = 'db'
if not legacy_inventories:
return False
logger.info(f'Migrating {len(legacy_inventories)} legacy inventories from {source}')
used_slugs = set()
for index, legacy in enumerate(legacy_inventories):
entity = _convert_inventory(legacy, fallback_name=f'Inventory {index + 1}')
# Dedupe against a stable base slug with an incrementing suffix (food, food-2, food-3, ...); reassigning
# `slug` in the loop would grow the string unboundedly and could collide with an existing config.
base_slug = slugify(entity['name'])
slug = base_slug
suffix = 2
while slug in used_slugs:
slug = f'{base_slug}-{suffix}'
suffix += 1
used_slugs.add(slug)
entity['slug'] = slug
try:
config.create(slug, entity)
except Exception as e:
logger.error(f'Failed to migrate inventory {entity["name"]}: {e}', exc_info=e)
# Back up and retire the legacy file so it is not migrated again.
if legacy_file.is_file():
try:
backup_dir = legacy_file.parent / 'backup'
backup_dir.mkdir(parents=True, exist_ok=True)
date_str = now().strftime('%Y%m%d')
shutil.copy(legacy_file, backup_dir / f'inventories-{date_str}.yaml')
legacy_file.rename(legacy_file.with_suffix('.yaml.migrated'))
except Exception as e:
logger.error(f'Failed to retire legacy inventories.yaml: {e}', exc_info=e)
return True

View file

@ -1,61 +0,0 @@
from sqlalchemy import Column, Integer, String, Date, ForeignKey, DECIMAL
from sqlalchemy.orm import relationship, Session
from sqlalchemy.orm.collections import InstrumentedList
from wrolpi.common import Base, ModelHelper
from wrolpi.dates import now, TZDateTime
class Item(Base, ModelHelper):
__tablename__ = 'item'
id = Column(Integer, primary_key=True)
brand = Column(String)
category = Column(String)
count = Column(DECIMAL)
created_at = Column(TZDateTime, default=now)
deleted_at = Column(TZDateTime)
expiration_date = Column(Date)
item_size = Column(DECIMAL)
name = Column(String)
purchase_date = Column(Date)
serving = Column(Integer)
subcategory = Column(String)
unit = Column(String)
inventory_id = Column(Integer, ForeignKey('inventory.id', ondelete='CASCADE'))
inventory = relationship('Inventory', primaryjoin="Item.inventory_id==Inventory.id", back_populates='items')
def __repr__(self):
return f'<Item id={self.id}, name={self.name}, brand={self.brand}, ' \
f'count={self.count}, item_size={self.item_size}, unit={self.unit}, inventory={self.inventory_id} >'
class Inventory(Base, ModelHelper):
__tablename__ = 'inventory'
id = Column(Integer, primary_key=True)
name = Column(String, unique=True, nullable=False)
viewed_at = Column(TZDateTime)
created_at = Column(TZDateTime, default=now)
deleted_at = Column(TZDateTime)
items: InstrumentedList = relationship('Item', foreign_keys='Item.inventory_id')
def __repr__(self):
return f'<Inventory id={self.id}, name={self.name!r}, deleted={self.deleted_at} >'
def dict(self):
d = super().dict()
d['items'] = [i.dict() for i in self.items]
return d
def delete(self):
"""
Delete all Items of this Inventory. Then delete this inventory.
"""
self.deleted_at = now()
@staticmethod
def find_by_name(session: Session, name: str) -> 'Inventory':
return session.query(Inventory).filter_by(name=name).one()

View file

@ -1,57 +1,8 @@
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import Union, Optional
@dataclass
class ItemPostRequest:
brand: str
name: str
item_size: Union[str, Decimal]
unit: str
count: Union[str, Decimal]
category: str
subcategory: str
expiration_date: Optional[str]
def __post_init__(self):
self.item_size = Decimal(self.item_size) if self.item_size else None
self.count = Decimal(self.count) if self.count else None
self.expiration_date = datetime.fromisoformat(self.expiration_date) if self.expiration_date else None
@dataclass
class ItemPutRequest:
id: int # Ignored in favor of URL
brand: Optional[str]
name: Optional[str]
item_size: Union[str, Decimal]
unit: Optional[str]
count: Union[str, Decimal]
serving: Union[str, Decimal, None]
category: Optional[str]
subcategory: Optional[str]
expiration_date: Optional[str]
purchase_date: Optional[float]
inventory_id: int
created_at: Optional[str]
deleted_at: Optional[str]
def __post_init__(self):
self.item_size = Decimal(self.item_size) if self.item_size else None
self.count = Decimal(self.count) if self.count else None
self.serving = Decimal(self.serving) if self.serving else None
self.expiration_date = datetime.fromisoformat(self.expiration_date) if self.expiration_date else None
self.created_at = datetime.fromisoformat(self.created_at) if self.created_at else None
self.deleted_at = datetime.fromisoformat(self.deleted_at) if self.deleted_at else None
from typing import Optional
@dataclass
class InventoryPostRequest:
name: str
@dataclass
class InventoryPutRequest:
name: str
type: Optional[str] = 'food'

View file

@ -3,109 +3,80 @@ from http import HTTPStatus
import pytest
from modules.inventory import Item
from modules.inventory.inventory import save_item
@pytest.mark.asyncio
async def test_delete_items(test_session, init_test_inventory, async_client):
"""
Multiple Items can be deleted in a single request.
"""
item = dict(
brand='Ocean',
name='Salt',
count='8.0',
unit='lbs',
item_size='25.0',
category='cooking ingredients',
subcategory='salt',
)
save_item(init_test_inventory.id, item)
save_item(init_test_inventory.id, item)
items = test_session.query(Item).filter(Item.brand != None).all()
request, response = await async_client.delete(f'/api/inventory/item/{items[0].id},{items[1].id}')
assert response.status_code == HTTPStatus.NO_CONTENT, response.status_code
# Refresh all items. Verify they were deleted.
[test_session.refresh(i) for i in items]
assert all([i.deleted_at for i in items])
@pytest.mark.asyncio
async def test_delete_item(test_session, init_test_inventory, async_client):
"""
An item can be deleted by itself.
"""
item = dict(
brand='Ocean',
name='Salt',
count='8.0',
unit='lbs',
item_size='25.0',
category='cooking ingredients',
subcategory='salt',
)
save_item(init_test_inventory.id, item)
item = test_session.query(Item).filter(Item.brand != None).one()
assert not item.deleted_at
request, response = await async_client.delete(f'/api/inventory/item/{item.id}')
assert response.status_code == HTTPStatus.NO_CONTENT, response.status_code
test_session.refresh(item)
assert item.deleted_at
@pytest.mark.asyncio
async def test_item_api(test_session, init_test_inventory, async_client):
"""An Item can be added to an Inventory."""
inventory_id = init_test_inventory.id
item = {'brand': 'Salty', 'name': 'Salt', 'item_size': '1', 'unit': 'lbs', 'count': '5',
'category': 'cooking ingredients', 'subcategory': 'salt', 'expiration_date': None}
request, response = await async_client.post(f'/api/inventory/{inventory_id}/item', content=json.dumps(item))
assert response.status_code == HTTPStatus.NO_CONTENT, response.status_code
# Get the Item we just created.
request, response = await async_client.get(f'/api/inventory/{inventory_id}/item')
assert response.status_code == HTTPStatus.OK
# assert item == response.json['items'][0]
item = response.json['items'][0]
expiration_dates = ('1969-12-31T20:25:45.123450', None)
for expiration_date in expiration_dates:
item['expiration_date'] = expiration_date
request, response = await async_client.put(f'/api/inventory/item/{item["id"]}', content=json.dumps(item))
assert response.status_code == HTTPStatus.NO_CONTENT, response.json
@pytest.mark.asyncio
async def test_inventory_api(test_session, async_client):
"""An Inventory can be created."""
inventory = dict(name='foo')
request, response = await async_client.post('/api/inventory', content=json.dumps(inventory))
async def test_inventory_lifecycle_api(test_inventory_configs, async_client):
"""Create, list (full), rename, and delete an inventory via the API."""
request, response = await async_client.post('/api/inventory',
content=json.dumps(dict(name='Food Storage', type='food')))
assert response.status_code == HTTPStatus.CREATED, response.status_code
inventory = response.json['inventory']
slug = inventory['slug']
assert slug == 'food-storage'
assert inventory['fields'], 'food inventory should be seeded with default fields'
# GET / returns every inventory in full (fields + items) in one request.
request, response = await async_client.get('/api/inventory')
assert response.status_code == HTTPStatus.OK
inventory = response.json['inventories'][0]
inventory_id = inventory['id']
assert response.json['inventories'][0]['name'] == 'Food Storage'
assert 'items' in response.json['inventories'][0]
request, response = await async_client.get(f'/api/inventory/{inventory_id}')
assert response.status_code == HTTPStatus.OK
# Rename via whole-inventory PUT; slug stays stable.
request, response = await async_client.put(f'/api/inventory/{slug}', content=json.dumps(dict(name='Pantry')))
assert response.status_code == HTTPStatus.OK, response.status_code
assert response.json['inventory']['name'] == 'Pantry'
assert response.json['inventory']['slug'] == slug
request, response = await async_client.get('/api/inventory/categories')
assert response.status_code == HTTPStatus.OK
request, response = await async_client.get('/api/inventory/brands')
assert response.status_code == HTTPStatus.OK
inventory = dict(name='new name')
request, response = await async_client.put(f'/api/inventory/{inventory_id}', content=json.dumps(inventory))
assert response.status_code == HTTPStatus.NO_CONTENT, response.status_code
request, response = await async_client.delete(f'/api/inventory/{inventory_id}')
request, response = await async_client.delete(f'/api/inventory/{slug}')
assert response.status_code == HTTPStatus.NO_CONTENT, response.status_code
request, response = await async_client.get('/api/inventory')
assert response.status_code == HTTPStatus.OK
assert len(response.json['inventories']) == 0
@pytest.mark.asyncio
async def test_save_items_and_fields_api(food_inventory_factory, async_client):
"""Items and fields are persisted with a single whole-inventory PUT."""
slug = food_inventory_factory()
items = [
{'brand': 'Salty', 'name': 'Salt', 'item_size': '1', 'item_size_unit': 'lb', 'count': '5'},
{'brand': 'Ricey', 'name': 'Rice', 'count': '4'},
]
request, response = await async_client.put(f'/api/inventory/{slug}', content=json.dumps(dict(items=items)))
assert response.status_code == HTTPStatus.OK, response.json
saved_items = response.json['inventory']['items']
assert {i['name'] for i in saved_items} == {'Salt', 'Rice'}
assert all(isinstance(i['id'], int) for i in saved_items)
# Replace the field schema in the same kind of request.
new_fields = [
{'key': 'name', 'label': 'Name', 'type': 'text'},
{'key': 'location', 'label': 'Location', 'type': 'location'},
]
request, response = await async_client.put(f'/api/inventory/{slug}', content=json.dumps(dict(fields=new_fields)))
assert response.status_code == HTTPStatus.OK, response.json
assert [f['key'] for f in response.json['inventory']['fields']] == ['name', 'location']
@pytest.mark.asyncio
async def test_put_version_conflict_api(food_inventory_factory, async_client):
"""A stale version is rejected with 409 instead of clobbering a newer save."""
slug = food_inventory_factory()
request, response = await async_client.get('/api/inventory')
inventory = next(i for i in response.json['inventories'] if i['slug'] == slug)
version = inventory['version']
request, response = await async_client.put(f'/api/inventory/{slug}',
content=json.dumps(dict(name='First', version=version)))
assert response.status_code == HTTPStatus.OK
request, response = await async_client.put(f'/api/inventory/{slug}',
content=json.dumps(dict(name='Stale', version=version)))
assert response.status_code == HTTPStatus.CONFLICT, response.status_code
@pytest.mark.asyncio
async def test_missing_inventory_404(test_inventory_configs, async_client):
request, response = await async_client.put('/api/inventory/does-not-exist', content=json.dumps(dict(name='x')))
assert response.status_code == HTTPStatus.NOT_FOUND, response.status_code

View file

@ -0,0 +1,84 @@
import json
from http import HTTPStatus
import pytest
from modules.inventory import catalog as catalog_module
def test_load_catalog_defaults():
"""The shipped catalog_defaults.yaml loads and is non-trivial."""
defaults = catalog_module.load_catalog_defaults()
assert len(defaults) > 30
rice = next(d for d in defaults if d['name'] == 'Long Grain White Rice')
assert rice['category'] == 'grains'
assert rice['calories'] == '8040'
def test_seed_catalog_defaults_and_tombstone(test_catalog_config):
config = test_catalog_config
assert config.items == []
# Seeding loads all shipped defaults.
catalog_module.seed_catalog_defaults(config)
seeded = len(config.items)
assert seeded == len(catalog_module.load_catalog_defaults())
assert sorted(config.merged_default_ids) == sorted(d['id'] for d in catalog_module.load_catalog_defaults())
# Seeding again is idempotent (nothing re-added).
catalog_module.seed_catalog_defaults(config)
assert len(config.items) == seeded
# A user-deleted default is NOT re-added on the next seed (tombstoned via merged_default_ids).
remaining = [i for i in config.items if i['name'] != 'Spam']
catalog_module.save_catalog_items(remaining)
catalog_module.seed_catalog_defaults(config)
assert not any(i['name'] == 'Spam' for i in config.items)
def test_catalog_file_is_not_loaded_as_an_inventory(test_inventory_configs, test_catalog_config):
"""catalog.yaml shares config/inventory/ but must never appear as an inventory."""
# Seed the catalog (writes config/inventory/catalog.yaml) and an inventory.
catalog_module.seed_catalog_defaults(test_catalog_config)
test_inventory_configs.create_inventory('Food Storage', 'food')
# Re-import the inventory directory; the catalog file must be excluded.
test_inventory_configs.import_all()
slugs = [i['slug'] for i in test_inventory_configs.all_inventories()]
assert 'catalog' not in slugs
assert slugs == ['food-storage']
def test_inventory_named_catalog_does_not_collide(test_inventory_configs):
"""A user inventory named 'Catalog' must not claim the reserved catalog slug."""
inv = test_inventory_configs.create_inventory('Catalog', 'food')
assert inv['slug'] != 'catalog'
def test_save_catalog_items_assigns_ids(test_catalog_config):
saved = catalog_module.save_catalog_items([
{'name': 'Test Beans', 'category': 'legumes', 'subcategory': 'dry beans',
'item_size': '25', 'item_size_unit': 'lb', 'calories': '39200'},
{'name': 'No Id Item', 'category': 'grains'},
])
assert [i['name'] for i in saved] == ['Test Beans', 'No Id Item']
assert all(isinstance(i['id'], int) for i in saved)
assert len({i['id'] for i in saved}) == 2
@pytest.mark.asyncio
async def test_catalog_api(test_catalog_config, async_client):
# Empty to start.
request, response = await async_client.get('/api/inventory/catalog')
assert response.status_code == HTTPStatus.OK
assert response.json['catalog'] == []
items = [{'name': 'White Rice', 'category': 'grains', 'subcategory': 'rice',
'item_size': '5', 'item_size_unit': 'lb', 'calories': '8040'}]
request, response = await async_client.put('/api/inventory/catalog', content=json.dumps(dict(items=items)))
assert response.status_code == HTTPStatus.OK, response.status_code
assert response.json['catalog'][0]['name'] == 'White Rice'
assert isinstance(response.json['catalog'][0]['id'], int)
# The static /catalog route is not shadowed by the dynamic /<slug> route.
request, response = await async_client.get('/api/inventory/catalog')
assert len(response.json['catalog']) == 1

View file

@ -1,219 +0,0 @@
"""
Tests for InventoriesConfig import/upgrade scenarios.
Tests cover:
1. Onboarding (new install) - no config file exists
2. Upgrade - config file exists with data
3. Edge cases - empty config, missing config with DB data
"""
import pytest
import yaml
from modules.inventory import Inventory
from modules.inventory.common import get_inventories_config
@pytest.mark.asyncio
class TestInventoriesConfigOnboarding:
"""Tests for new install scenarios where no config file exists."""
async def test_new_install_no_db_data(self, test_session, test_directory, async_client):
"""
New install with empty DB and no config file.
Should succeed with empty config.
"""
config = get_inventories_config()
config_path = config.get_file()
# Ensure no config file exists
if config_path.is_file():
config_path.unlink()
# Ensure DB has no inventories
assert test_session.query(Inventory).count() == 0
# Import should succeed
config.import_config()
assert config.successful_import is True
async def test_new_install_with_db_data(self, test_session, test_directory, async_client, test_inventory):
"""
Config file missing but DB has data.
Import should succeed (nothing to import), DB data preserved.
"""
config = get_inventories_config()
config_path = config.get_file()
# Ensure we have an inventory from the fixture
assert test_session.query(Inventory).count() == 1
# Remove config file to simulate missing config
if config_path.is_file():
config_path.unlink()
# Import should succeed (nothing to import)
config.import_config()
assert config.successful_import is True
# DB data should be preserved
inventories = test_session.query(Inventory).all()
assert len(inventories) == 1
assert inventories[0].name == 'Test Inventory'
@pytest.mark.asyncio
class TestInventoriesConfigUpgrade:
"""Tests for upgrade scenarios where config file exists."""
async def test_upgrade_config_exists_with_data(self, test_session, test_directory, async_client, test_inventory):
"""
Upgrade with existing config containing inventory data.
Should import and sync DB with config.
"""
config = get_inventories_config()
config_path = config.get_file()
# Dump to create config file
config.dump_config()
assert config_path.is_file()
# Now import should succeed and preserve the inventory
config.import_config()
assert config.successful_import is True
# Inventory should still exist
inventories = test_session.query(Inventory).all()
assert len(inventories) == 1
assert inventories[0].name == 'Test Inventory'
async def test_upgrade_config_exists_empty_list(self, test_session, test_directory, async_client, test_inventory):
"""
Config exists but has empty inventories list.
Should NOT delete existing DB inventories (never delete on empty).
"""
config = get_inventories_config()
config_path = config.get_file()
# Write empty config
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w') as f:
yaml.dump({'version': 0, 'inventories': []}, f)
# Reinitialize config to read the empty file
config.initialize()
# Import should succeed but NOT delete the inventory
config.import_config()
assert config.successful_import is True
# Inventory should still exist (never delete on empty config)
inventories = test_session.query(Inventory).all()
assert len(inventories) == 1
assert inventories[0].name == 'Test Inventory'
async def test_upgrade_config_missing_db_has_data(self, test_session, test_directory, async_client):
"""
Config file missing but DB has inventories.
Import should succeed (nothing to import), DB data preserved.
"""
config = get_inventories_config()
config_path = config.get_file()
# Create inventories in DB
for name in ['Inventory One', 'Inventory Two']:
inventory = Inventory(name=name)
test_session.add(inventory)
test_session.commit()
# Remove config file
if config_path.is_file():
config_path.unlink()
# Import should succeed (nothing to import)
config.import_config()
assert config.successful_import is True
# DB data should be preserved
inventories = test_session.query(Inventory).all()
assert len(inventories) == 2
inventory_names = {i.name for i in inventories}
assert 'Inventory One' in inventory_names
assert 'Inventory Two' in inventory_names
@pytest.mark.asyncio
class TestInventoriesConfigEdgeCases:
"""Tests for edge cases in config import."""
async def test_config_deletes_removed_items(self, test_session, test_directory, async_client):
"""
Config removes an inventory that exists in DB.
Should delete the inventory from DB.
"""
config = get_inventories_config()
config_path = config.get_file()
# Create two inventories
for name in ['Keep This', 'Delete This']:
inventory = Inventory(name=name)
test_session.add(inventory)
test_session.commit()
# Dump to config
config.dump_config()
# Modify config to remove one inventory
with open(config_path) as f:
config_data = yaml.safe_load(f)
config_data['inventories'] = [i for i in config_data['inventories'] if i['name'] == 'Keep This']
with open(config_path, 'w') as f:
yaml.dump(config_data, f)
# Reinitialize and import
config.initialize()
config.import_config()
assert config.successful_import is True
# Only one inventory should remain
inventories = test_session.query(Inventory).all()
assert len(inventories) == 1
assert inventories[0].name == 'Keep This'
async def test_config_adds_new_items(self, test_session, test_directory, async_client):
"""
Config has a new inventory that doesn't exist in DB.
Should create the inventory in DB.
"""
config = get_inventories_config()
config_path = config.get_file()
# Ensure no inventories in DB
assert test_session.query(Inventory).count() == 0
# Write config with a new inventory
config_data = {
'version': 0,
'inventories': [{
'name': 'New Inventory',
'id': 1,
'created_at': None,
'deleted_at': None,
'items': [],
}]
}
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w') as f:
yaml.dump(config_data, f)
# Reinitialize and import
config.initialize()
config.import_config()
assert config.successful_import is True
# Inventory should be created
inventories = test_session.query(Inventory).all()
assert len(inventories) == 1
assert inventories[0].name == 'New Inventory'

View file

@ -1,317 +1,133 @@
from decimal import Decimal
from itertools import zip_longest
from typing import List, Iterable
import pytest
import sqlalchemy
from pint import Quantity
from wrolpi.common import Base
from wrolpi.db import get_db_session
from wrolpi.test.common import PytestCase
from .. import init_inventory
from ..common import sum_by_key, get_inventory_by_category, get_inventory_by_subcategory, get_inventory_by_name, \
compact_unit, cleanup_quantity, save_inventories_config, import_inventories_config
from ..errors import NoInventories
from ..inventory import get_inventories, save_inventory, update_inventory, \
delete_inventory, get_categories, get_unit_registry
from ..models import Item, Inventory
UNIT_REGISTRY = get_unit_registry()
TEST_ITEMS_COLUMNS = (
'inventory_id',
'brand',
'name',
'item_size',
'unit',
'count',
'category',
'subcategory',
'deleted_at',
'serving',
'expiration_date',
'purchase_date',
)
TEST_ITEMS_TUPLES = [
(1, 'Wheaters', 'Red Wheat', 500, 'oz', 1, 'grains', 'wheat'),
(1, 'Wheaters', 'Red Wheat', 55, 'pounds', 2, 'grains', 'wheat'),
(1, 'Ricey', 'White Rice', 8, 'pounds', 1, 'grains', 'rice'),
(1, 'Chewy', 'Chicken Breast', 16, 'oz', 8, 'meats', 'canned'),
(1, 'Chewy', 'Beef', 16, 'oz', 12, 'meats', 'canned'),
(1, 'Vibrant', 'Peaches', 24, 'oz', 2, 'fruits', 'canned'),
(1, 'Vibrant', 'Pineapple', Decimal('22.3'), 'oz', 1, 'fruits', 'canned'),
# These are in the same category, but have conflicting unit types. (gallon vs pound)
(1, 'White', 'Vinegar', 1, 'gallon', 1, 'cooking ingredients', 'vinegar'),
(1, 'Salty', 'Salt', 1, 'pound', 1, 'cooking ingredients', 'salt'),
# This item is deleted, and should always be ignored.
(1, 'deleted', 'deleted', Decimal('1'), 'oz', 1, 'fruits', 'canned', '2020-01-01'),
]
TEST_ITEMS = [dict(zip(TEST_ITEMS_COLUMNS, i)) for i in TEST_ITEMS_TUPLES]
from modules.inventory import common as inventory_common
from modules.inventory.defaults import slugify, default_fields
from modules.inventory.errors import InventoryConflict
def extract_items(lst: List[Base], keys: Iterable[str]) -> List[Base]:
extracted = []
for i in lst:
extracted.append({j: getattr(i, j) for j in keys})
return extracted
def test_slugify():
assert slugify('Food Storage') == 'food-storage'
assert slugify(' Tools & Stuff!! ') == 'tools-stuff'
assert slugify('') == 'inventory'
class TestInventory(PytestCase):
@staticmethod
def prepare() -> None:
"""
This exists rather than setUp because the DB is wrapped.
"""
init_inventory(force=True)
with get_db_session(commit=True) as session:
for item in TEST_ITEMS:
item = Item(**item)
session.add(item)
def test_get_categories(self, test_session):
self.prepare()
categories = get_categories()
self.assertGreater(len(categories), 0)
def test_get_inventories(self, test_session):
self.prepare()
inventories = get_inventories(test_session)
dates = extract_items(inventories, {'created_at', 'deleted_at', 'viewed_at'})
self.assertItemsTruthyOrFalsey(dates, [{'created_at': True, 'deleted_at': False, 'viewed_at': False}])
self.assertEqual(inventories[0].id, 1)
self.assertEqual(inventories[0].name, 'Food Storage')
def test_inventory1(self, test_session):
self.prepare()
# Insert a new Inventory.
inventory = {
'name': 'New Inventory',
'viewed_at': 'asdf', # This should be ignored.
}
save_inventory(inventory)
with get_db_session() as session:
i1, i2 = session.query(Inventory).order_by(Inventory.name).all()
self.assertDictContains(i2, {'name': 'New Inventory', 'viewed_at': None})
# Inventories cannot share a name.
self.assertRaises(sqlalchemy.exc.IntegrityError, save_inventory, inventory)
# Insert a second inventory.
inventory['name'] = 'Super Inventory'
save_inventory(inventory)
# Cannot update the name to a name that is already used.
with get_db_session() as session:
i = session.query(Inventory).filter_by(name='Super Inventory').one()
inventory['name'] = 'New Inventory'
self.assertRaises(sqlalchemy.exc.IntegrityError, update_inventory, i.id, inventory)
# Add some items to "New Inventory"
with get_db_session(commit=True) as session:
before_item_count = session.query(Item).count()
session.add(Item(inventory_id=2, brand='a', name='b', item_size=45, unit='pounds', count=1))
session.add(Item(inventory_id=2, brand='a', name='b', item_size=45, unit='pounds', count=1))
session.add(Item(inventory_id=2, brand='a', name='b', item_size=45, unit='pounds', count=1))
session.add(Item(inventory_id=2, brand='a', name='b', item_size=45, unit='pounds', count=1))
# You can rename a inventory to a conflicting name, if the other inventory is marked as deleted.
with get_db_session(commit=True) as session:
delete_inventory(2)
# Check that the items from the deleted Inventory were not deleted, YET.
self.assertEqual(before_item_count + 4, session.query(Item).count())
i = session.query(Inventory).filter_by(name='Super Inventory').one()
inventory['name'] = 'New Inventory'
update_inventory(i.id, inventory)
# Check that the items from the deleted Inventory were really deleted.
self.assertEqual(before_item_count, session.query(Item).count())
def test_get_inventory_by_category(self, test_session):
self.prepare()
expected = [
dict(category='cooking ingredients', total_size=Decimal('1'), unit='gallon'),
dict(category='cooking ingredients', total_size=Decimal('1'), unit='pound'),
dict(category='fruits', total_size=Decimal('4.39375'), unit='pound'),
dict(category='grains', total_size=Decimal('149.25'), unit='pound'),
dict(category='meats', total_size=Decimal('20'), unit='pound'),
]
inventory = get_inventory_by_category(1)
for idx, (i, j) in enumerate(zip_longest(inventory, expected)):
self.assertEqual(i, j, f'category inventory {idx} is not equal')
def test_get_inventory_by_subcategory(self, test_session):
self.prepare()
expected = [
dict(category='cooking ingredients', subcategory='salt', total_size=Decimal('1'), unit='pound'),
dict(category='cooking ingredients', subcategory='vinegar', total_size=Decimal('1'), unit='gallon'),
dict(category='fruits', subcategory='canned', total_size=Decimal('4.39375'), unit='pound'),
dict(category='grains', subcategory='rice', total_size=Decimal('8'), unit='pound'),
dict(category='grains', subcategory='wheat', total_size=Decimal('141.25'), unit='pound'),
dict(category='meats', subcategory='canned', total_size=Decimal('20'), unit='pound'),
]
inventory = get_inventory_by_subcategory(1)
for idx, (i, j) in enumerate(zip_longest(inventory, expected)):
self.assertEqual(i, j, f'subcategory inventory {idx} is not equal')
def test_get_inventory_by_name(self, test_session):
self.prepare()
expected = [
dict(brand='Chewy', name='Beef', total_size=Decimal('12'), unit='pound'),
dict(brand='Chewy', name='Chicken Breast', total_size=Decimal('8'), unit='pound'),
dict(brand='Ricey', name='White Rice', total_size=Decimal('8'), unit='pound'),
dict(brand='Salty', name='Salt', total_size=Decimal('1'), unit='pound'),
dict(brand='Vibrant', name='Peaches', total_size=Decimal('3'), unit='pound'),
dict(brand='Vibrant', name='Pineapple', total_size=Decimal('1.39375'), unit='pound'),
dict(brand='Wheaters', name='Red Wheat', total_size=Decimal('141.25'), unit='pound'),
dict(brand='White', name='Vinegar', total_size=Decimal('1'), unit='gallon'),
]
inventory = get_inventory_by_name(1)
for idx, (i, j) in enumerate(zip_longest(inventory, expected)):
self.assertEqual(i, j, f'named inventory {idx} is not equal')
def test_default_fields():
food = default_fields('food')
assert [f['key'] for f in food][:2] == ['brand', 'name']
# Unknown type falls back to food.
assert default_fields('nonexistent')[0]['key'] == 'brand'
# The size field defaults to a mathjs-valid unit (lb, not pound).
size = next(f for f in food if f['key'] == 'item_size')
assert size['type'] == 'quantity'
assert size['unit'] == 'lb'
def quantity_to_string(quantity: Quantity) -> str:
quantity = cleanup_quantity(quantity)
num, (units,) = quantity.to_tuple()
(unit, _) = units
return f'{num} {unit}'
def test_create_and_list_inventory(test_inventory_configs):
config = test_inventory_configs
inventory = config.create_inventory('Food Storage', 'food')
assert inventory['slug'] == 'food-storage'
@pytest.mark.parametrize(
'quantity,expected',
[
(Decimal(5) * UNIT_REGISTRY.ounce, '5 ounce'),
(Decimal(16) * UNIT_REGISTRY.ounce, '1 pound'),
(Decimal(500) * UNIT_REGISTRY.pound, '500 pound'),
(Decimal(2000) * UNIT_REGISTRY.pound, '1 ton'),
(Decimal(128000) * UNIT_REGISTRY.ounce, '4 ton'),
]
)
def test_compact_unit(quantity, expected):
# Round the result, so we don't have to specify all those zeros for the test definition.
assert quantity_to_string(compact_unit(quantity)) == expected
pound, oz, gram, gallon = UNIT_REGISTRY('pound'), UNIT_REGISTRY('oz'), UNIT_REGISTRY('gram'), UNIT_REGISTRY('gallon')
pound, oz, gram, gallon = pound.units, oz.units, gram.units, gallon.units
mass = pound.dimensionality
length = gallon.dimensionality
@pytest.mark.parametrize(
'items,expected',
[
(
# No conversion is necessary.
[{'category': 'grains', 'count': Decimal('1'), 'item_size': Decimal('1'), 'unit': 'oz'}],
{(('grains',), oz.dimensionality): Decimal('1') * oz},
),
(
# The largest of the units is what is returned.
[
{'category': 'grains', 'count': Decimal('1'), 'item_size': Decimal('1'), 'unit': 'oz'},
{'category': 'grains', 'count': Decimal('1'), 'item_size': Decimal('1'), 'unit': 'lbs'},
],
{(('grains',), pound.dimensionality): Decimal('1.0625') * pound},
),
(
# Items are summed by category.
[
{'category': 'grains', 'count': Decimal('8'), 'item_size': Decimal('24'), 'unit': 'oz'},
{'category': 'grains', 'count': Decimal('2'), 'item_size': Decimal('45'), 'unit': 'lbs'},
{'category': 'fruits', 'count': Decimal('4'), 'item_size': Decimal('1'), 'unit': 'gram'},
],
{
(('grains',), pound.dimensionality): Decimal('102') * pound,
(('fruits',), gram.dimensionality): Decimal('4') * gram,
},
),
(
[
{'category': 'cooking ingredients', 'count': Decimal('1'), 'item_size': Decimal('1'),
'unit': 'gallon'},
{'category': 'cooking ingredients', 'count': Decimal('1'), 'item_size': Decimal('1'),
'unit': 'quart'},
{'category': 'cooking ingredients', 'count': Decimal('1'), 'item_size': Decimal('1'), 'unit': 'oz'},
],
{
(('cooking ingredients',), gallon.dimensionality): Decimal('1.25') * gallon,
(('cooking ingredients',), oz.dimensionality): Decimal('1') * oz,
},
),
]
)
def test_sum_by_key(items, expected):
items = [Item(**i) for i in items]
assert sum_by_key(items, lambda i: (i.category,)) == expected
def test_no_inventories(async_client, test_session, test_directory):
"""Can't save empty inventories."""
try:
save_inventories_config()
except NoInventories:
pass
@pytest.mark.asyncio
async def test_inventories_config(await_switches, test_session, test_directory, init_test_inventory):
for item in TEST_ITEMS:
item = Item(**item)
test_session.add(item)
test_session.commit()
# Save the Inventories/Items that were created above.
save_inventories_config()
await await_switches()
# Clear out the DB so the import will be tested
test_session.query(Item).delete()
test_session.query(Inventory).delete()
test_session.commit()
import_inventories_config()
inventories = get_inventories(test_session)
inventories = config.all_inventories()
assert len(inventories) == 1
# ID has increased because we did not reset the sequence when deleting from the table.
assert inventories[0].id == 2
assert inventories[0].name == 'Food Storage'
assert inventories[0]['name'] == 'Food Storage'
assert inventories[0]['type'] == 'food'
# all_inventories returns full inventories (fields + items), not summaries.
assert [f['key'] for f in inventories[0]['fields']] == [f['key'] for f in default_fields('food')]
assert inventories[0]['items'] == []
# All items in the DB match those in the test list, except for the "deleted" item.
non_deleted_items = [i for i in inventories[0].items if i.deleted_at is None]
assert len(non_deleted_items) == len(TEST_ITEMS) - 1
test_items = {(i['name'], i['brand'], i['count']) for i in TEST_ITEMS[:-1]}
db_items = {(i.name, i.brand, i.count) for i in non_deleted_items}
assert test_items == db_items
# An extra Inventory with items should be deleted from the DB.
inventory = Inventory(name='delete me')
test_session.add(inventory)
inventory.flush()
test_session.add(Item(inventory_id=inventory.id))
test_session.commit()
assert test_session.query(Inventory).count() == 2
assert {i for i, in test_session.query(Inventory.name)} == {'Food Storage', 'delete me'}
def test_create_inventory_unique_slug(test_inventory_configs):
config = test_inventory_configs
first = config.create_inventory('Tools', 'tool')
second = config.create_inventory('Tools', 'tool')
assert first['slug'] == 'tools'
assert second['slug'] == 'tools-2'
assert len(config.all_inventories()) == 2
import_inventories_config()
assert test_session.query(Inventory).count() == 1
assert test_session.query(Inventory).one().name == 'Food Storage'
def test_save_inventory_items(food_inventory_factory, test_inventory_configs):
config = test_inventory_configs
slug = food_inventory_factory()
# Whole-inventory save: add two items. Ids are assigned server-side.
saved = config.save_inventory(slug, dict(items=[
dict(brand='Ocean', name='Salt', item_size='25', item_size_unit='lb', count='8', bogus='nope'),
dict(name='Rice', count='4'),
]))
items = saved['items']
assert {i['name'] for i in items} == {'Salt', 'Rice'}
assert all(isinstance(i['id'], int) for i in items)
assert len({i['id'] for i in items}) == 2
# Unknown keys not in the field schema are dropped.
assert all('bogus' not in i for i in items)
# Saving again with one item removed persists the deletion.
keep = [i for i in items if i['name'] == 'Rice']
saved = config.save_inventory(slug, dict(items=keep))
assert [i['name'] for i in saved['items']] == ['Rice']
def test_save_inventory_fields(food_inventory_factory, test_inventory_configs):
config = test_inventory_configs
slug = food_inventory_factory()
new_fields = [
dict(key='name', label='Name', type='text'),
dict(key='location', label='Location', type='location'),
]
saved = config.save_inventory(slug, dict(fields=new_fields))
assert [f['key'] for f in saved['fields']] == ['name', 'location']
# Order is normalized to list order.
assert [f['order'] for f in saved['fields']] == [0, 1]
def test_save_inventory_rename_keeps_slug(food_inventory_factory, test_inventory_configs):
config = test_inventory_configs
slug = food_inventory_factory(name='Food Storage')
saved = config.save_inventory(slug, dict(name='Pantry'))
# Slug is a stable id; only the display name changes.
assert saved['slug'] == slug
assert saved['name'] == 'Pantry'
assert config.get_file(slug).is_file()
def test_save_inventory_version_conflict(food_inventory_factory, test_inventory_configs):
config = test_inventory_configs
slug = food_inventory_factory()
current = config.get_inventory(slug)
version = current['version']
# A save with the matching version succeeds and bumps the version.
saved = config.save_inventory(slug, dict(name='First'), expected_version=version)
assert saved['version'] == version + 1
# A stale client (still on the old version) is rejected rather than clobbering.
with pytest.raises(InventoryConflict):
config.save_inventory(slug, dict(name='Stale'), expected_version=version)
def test_delete_inventory(food_inventory_factory, test_inventory_configs):
config = test_inventory_configs
slug = food_inventory_factory()
assert config.get_file(slug).is_file()
config.delete_inventory(slug)
assert not config.get_file(slug).is_file()
assert config.all_inventories() == []
def test_config_round_trip(food_inventory_factory, test_inventory_configs):
"""Saving to per-inventory YAML and re-importing preserves fields and items."""
config = test_inventory_configs
slug = food_inventory_factory(name='Food Storage', items=[
dict(brand='Ocean', name='Salt', item_size='25', item_size_unit='lb', count='8'),
dict(brand='Ricey', name='Rice', item_size='10', item_size_unit='lb', count='4'),
])
assert config.get_file(slug).is_file()
# A fresh config instance re-reads the directory from disk.
fresh = inventory_common.InventoriesConfig()
fresh.initialize()
restored = fresh.get_inventory(slug)
assert restored is not None
assert restored['name'] == 'Food Storage'
assert {i['name'] for i in restored['items']} == {'Salt', 'Rice'}
assert [f['key'] for f in restored['fields']] == [f['key'] for f in default_fields('food')]

View file

@ -0,0 +1,96 @@
import pytest
import yaml
from modules.inventory import common as inventory_common
from modules.inventory.migrate import migrate_legacy_inventory, normalize_unit
def test_normalize_unit():
assert normalize_unit('pound') == 'lb'
assert normalize_unit('pounds') == 'lb'
assert normalize_unit('lbs') == 'lb'
# Already-valid mathjs units pass through unchanged.
assert normalize_unit('oz') == 'oz'
assert normalize_unit('gallon') == 'gallon'
assert normalize_unit(None) is None
def _write_legacy(config, inventories):
legacy_file = config.get_directory().parent / 'inventories.yaml'
legacy_file.parent.mkdir(parents=True, exist_ok=True)
with legacy_file.open('wt') as fh:
yaml.dump(dict(version=1, inventories=inventories), fh)
return legacy_file
def test_migrate_legacy_yaml(test_inventory_configs):
config = test_inventory_configs
legacy_file = _write_legacy(config, [
dict(name='Food Storage', created_at='2024-01-01', deleted_at=None, items=[
dict(id=1, inventory_id=1, brand='Ocean', name='Salt', item_size='25', unit='pound', count='8',
category='cooking ingredients', subcategory='salt', expiration_date='2030-01-01'),
dict(id=2, inventory_id=1, brand='deleted', name='Old', item_size='1', unit='oz', count='1',
deleted_at='2020-01-01'),
]),
])
assert migrate_legacy_inventory(config) is True
config.import_all()
inventories = config.all_inventories()
assert len(inventories) == 1
slug = inventories[0]['slug']
inventory = config.get_inventory(slug)
assert inventory['name'] == 'Food Storage'
assert inventory['type'] == 'food'
# The soft-deleted legacy item is dropped.
assert len(inventory['items']) == 1
item = inventory['items'][0]
assert item['name'] == 'Salt'
# Legacy pint unit 'pound' is normalized to mathjs 'lb'.
assert item['item_size_unit'] == 'lb'
# The legacy file is retired (renamed) and backed up so it is not migrated again.
assert not legacy_file.is_file()
assert legacy_file.with_suffix('.yaml.migrated').is_file()
def test_migrate_is_idempotent(test_inventory_configs):
config = test_inventory_configs
_write_legacy(config, [dict(name='Food', items=[])])
assert migrate_legacy_inventory(config) is True
config.import_all()
# Second run does nothing because per-inventory files now exist.
assert migrate_legacy_inventory(config) is False
config.import_all()
assert len(config.all_inventories()) == 1
def test_migrate_no_legacy_data(test_inventory_configs):
config = test_inventory_configs
assert migrate_legacy_inventory(config) is False
assert config.all_inventories() == []
def test_migrate_dedupes_same_named_inventories(test_inventory_configs):
"""Multiple inventories with the same name get distinct slugs (food, food-2, food-3) with no data loss."""
config = test_inventory_configs
_write_legacy(config, [
dict(name='Food', items=[]),
dict(name='Food', items=[]),
dict(name='Food', items=[]),
])
assert migrate_legacy_inventory(config) is True
config.import_all()
slugs = sorted(i['slug'] for i in config.all_inventories())
assert slugs == ['food', 'food-2', 'food-3']
assert len(config.all_inventories()) == 3 # none dropped
@pytest.mark.asyncio
async def test_import_seeds_defaults_when_empty(test_inventory_configs, async_client):
"""A fresh install with no legacy data seeds the default example inventory."""
config = test_inventory_configs
inventory_common.import_inventories_config()
inventories = config.all_inventories()
assert any(i['name'] == 'Food Storage' for i in inventories)

View file

@ -0,0 +1,42 @@
"""Tests for the reusable MultiFileConfig machinery, exercised through InventoriesConfig."""
from modules.inventory import common as inventory_common
from wrolpi.dates import now
def test_discover(food_inventory_factory, test_inventory_configs):
config = test_inventory_configs
food_inventory_factory(name='Food Storage')
food_inventory_factory(name='Tools', inventory_type='tool')
assert set(config.discover()) == {'food-storage', 'tools'}
def test_save_creates_dated_backup(food_inventory_factory, test_inventory_configs, test_directory):
config = test_inventory_configs
slug = food_inventory_factory(name='Food Storage')
# First save happened on create (no prior file → no backup). A second save backs up the existing file.
config.dump_all()
backup_dir = test_directory / 'config' / 'backup' / 'inventory'
date_str = now().strftime('%Y%m%d')
assert (backup_dir / f'{slug}-{date_str}.yaml').is_file()
def test_initialize_loads_from_disk(food_inventory_factory, test_inventory_configs):
config = test_inventory_configs
food_inventory_factory(name='Food Storage')
config.dump_all()
# A new instance pointed at the same directory loads the existing files on initialize().
fresh = inventory_common.InventoriesConfig()
fresh.initialize()
assert 'food-storage' in fresh.slugs
def test_invalid_file_is_skipped(test_inventory_configs):
config = test_inventory_configs
bad = config.get_directory() / 'broken.yaml'
bad.parent.mkdir(parents=True, exist_ok=True)
bad.write_text('this: [is, not, a, valid, inventory\n') # malformed YAML
# import_all must not raise; the bad file is skipped.
config.import_all()
assert 'broken' not in config.slugs

View file

@ -1,5 +1,4 @@
Pillow==12.2.0
Pint==0.25.3
PyYAML==6.0.3
SQLAlchemy[asyncio]==1.3.22
aiohttp==3.14.0

View file

@ -456,7 +456,7 @@ def get_media_directory() -> Path:
return MEDIA_DIRECTORY
DB_CONFIG_FILE_NAMES = {'tags.yaml', 'channels.yaml', 'domains.yaml', 'download_manager.yaml', 'inventories.yaml',
DB_CONFIG_FILE_NAMES = {'tags.yaml', 'channels.yaml', 'domains.yaml', 'download_manager.yaml',
'playlists.yaml'}
@ -622,7 +622,8 @@ class ConfigFile:
from wrolpi.events import Events
file = file or self.get_file()
if self.file_name not in file.name:
# Compare basenames so a nested file_name (e.g. 'inventory/catalog.yaml') is accepted.
if pathlib.Path(self.file_name).name != file.name:
raise RuntimeError(f'Refusing to save config to file which does not match {self.__class__.__name__}')
rel_path = get_relative_to_media_directory(file)
@ -646,9 +647,9 @@ class ConfigFile:
# Only one process can write to a config.
with api_app.shared_ctx.config_save_lock:
try:
# Config directory may not exist.
# Config directory may not exist (parents=True for nested file_names like inventory/catalog.yaml).
if not file.parent.is_dir():
file.parent.mkdir()
file.parent.mkdir(parents=True, exist_ok=True)
# Backup the existing config file.
if file.is_file():
@ -676,22 +677,7 @@ class ConfigFile:
raise e
def write_config_data(self, config: dict, config_file: pathlib.Path):
from decimal import Decimal
class _ConfigDumper(yaml.CSafeDumper):
"""CSafeDumper that converts Decimal to str. All other Python objects are rejected."""
pass
_ConfigDumper.add_representer(Decimal, lambda dumper, data: dumper.represent_str(str(data)))
with config_file.open('wt') as fh:
try:
yaml.dump(config, fh, Dumper=_ConfigDumper, width=self.width, sort_keys=True)
except yaml.representer.RepresenterError as e:
raise ValueError(f'Config contains a Python object that cannot be serialized: {e}') from e
# Wait for data to be written before releasing lock.
fh.flush()
os.fsync(fh.fileno())
write_config_data(config, config_file, width=self.width)
def get_file(self) -> Path:
if not self.file_name:
@ -792,6 +778,254 @@ class ConfigFile:
return self._config['version']
def write_config_data(config: dict, config_file: pathlib.Path, width: int = 90):
"""Write a config dict to a YAML file. Decimals are converted to str; all other Python objects are rejected.
Shared by `ConfigFile` and `MultiFileConfig` so both serialize configs identically (Decimal-safe, fsync'd)."""
class _ConfigDumper(yaml.CSafeDumper):
"""CSafeDumper that converts Decimal to str. All other Python objects are rejected."""
pass
_ConfigDumper.add_representer(Decimal, lambda dumper, data: dumper.represent_str(str(data)))
with config_file.open('wt') as fh:
try:
yaml.dump(config, fh, Dumper=_ConfigDumper, width=width, sort_keys=True)
except yaml.representer.RepresenterError as e:
raise ValueError(f'Config contains a Python object that cannot be serialized: {e}') from e
# Wait for data to be written before releasing lock.
fh.flush()
os.fsync(fh.fileno())
def read_config_data(file: pathlib.Path) -> dict:
"""Read a YAML config file into a dict. Mirrors `ConfigFile.read_config_file`, but is standalone so
`MultiFileConfig` can use it without a `ConfigFile` instance."""
with file.open('rt') as fh:
try:
config_data = yaml.load(fh, Loader=yaml.CSafeLoader)
except yaml.constructor.ConstructorError:
logger_.warning(f'Config file contains Python objects, using slow loader: {file}')
fh.seek(0)
config_data = yaml.load(fh, Loader=yaml.Loader)
if not isinstance(config_data, dict):
raise InvalidConfig(f'Config file is invalid: {file}')
return config_data
class MultiFileConfig:
"""Manages a *directory* of YAML config files, one file per entity (e.g. ``config/inventory/<slug>.yaml``).
Unlike `ConfigFile` (one file, mirrored to the database), a `MultiFileConfig` is the source-of-truth itself:
the YAML files are loaded into a single shared multiprocessing dict (keyed by slug) and served from memory.
There is no database.
Design notes / why this is NOT a `ConfigFile` subclass:
* `ConfigFile` assumes a single, statically-named file and registers per-`file_name` switch handlers at import.
A directory has a dynamic number of files whose names are not known at import time.
* `register_switch_handler` raises on duplicate names and `SWITCH_HANDLERS` is per-process, so dynamic
per-slug switch handlers cannot be created safely. Instead this class registers ONE directory-level
``background_save_<name>`` switch that dumps every entity to disk (cheap — entities are small, human-paced).
* In-memory state lives in one shared `manager.dict()` keyed by slug, so it is shared across Sanic workers
natively (top-level key reassignment propagates through the proxy).
Subclasses define: ``subdirectory`` (e.g. 'inventory'), ``name`` (switch/registry name), ``validator``
(a dataclass) and ``default_entity`` (default dict for a new entity, must include ``version``).
"""
subdirectory: str = None
name: str = None
validator: dataclass = None
default_entity: dict = None
glob_pattern: str = '*.yaml'
width: int = 90
def __init__(self):
# slug -> entity dict (the in-memory store; replaced by a shared dict in `initialize`).
self._configs: dict = dict()
from wrolpi.switches import register_switch_handler
@register_switch_handler(f'background_save_{self.name}')
def background_save(**context):
self.dump_all()
self.background_save = background_save
def __repr__(self):
return f'<{self.__class__.__name__} dir={self.get_directory()} count={len(self._configs)}>'
def get_directory(self) -> Path:
if not self.subdirectory:
raise NotImplementedError(f'You must define a subdirectory for this {self.__class__.__name__} config.')
if PYTEST:
return get_media_directory() / 'config' / self.subdirectory
return CONFIG_DIR / self.subdirectory
def get_file(self, slug: str) -> Path:
return self.get_directory() / f'{slug}.yaml'
def discover(self) -> List[str]:
"""Return the slugs of all entity files in the directory."""
directory = self.get_directory()
if not directory.is_dir():
return []
return sorted(f.stem for f in directory.glob(self.glob_pattern) if f.is_file())
def initialize(self, multiprocessing_dict: Optional[DictProxy] = None):
"""Assign the shared in-memory store, then import every entity file from disk."""
self._configs = multiprocessing_dict if multiprocessing_dict is not None else dict()
self._configs.clear()
self.import_all()
return self
def is_valid(self, data: dict) -> bool:
if not self.validator:
raise NotImplementedError(f'Cannot validate {self.name} without validator!')
allowed_fields = {i.name for i in fields(self.validator)}
try:
self.validator(**{k: v for k, v in data.items() if k in allowed_fields})
return True
except Exception as e:
logger_.debug(f'Failed to validate {self.name} config', exc_info=e)
return False
def import_all(self, send_events: bool = False):
"""Read every entity file in the directory into the in-memory store."""
for slug in self.discover():
file = self.get_file(slug)
try:
data = read_config_data(file)
if not self.is_valid(data):
logger_.error(f'Refusing to import invalid {self.name} config: {file}')
continue
self._configs[slug] = data
except Exception as e:
logger_.error(f'Failed to import {self.name} config: {file}', exc_info=e)
self.successful_import = True
@property
def successful_import(self) -> bool:
from wrolpi.api_utils import api_app
try:
return bool(api_app.shared_ctx.configs_imported.get(self.name))
except AttributeError:
# Shared context may not be attached (e.g. pure-unit tests); the flag is only informational here.
return False
@successful_import.setter
def successful_import(self, value: bool):
from wrolpi.api_utils import api_app
try:
api_app.shared_ctx.configs_imported[self.name] = value
except AttributeError:
pass
def get(self, slug: str) -> Optional[dict]:
"""Return a deepcopy of one entity's config, or None."""
data = self._configs.get(slug)
return deepcopy(data) if data is not None else None
def all(self) -> List[dict]:
return [deepcopy(v) for v in self._configs.values()]
@property
def slugs(self) -> List[str]:
return list(self._configs.keys())
def save_lock(self):
"""The cross-process save lock when the app context is attached; a no-op lock otherwise (pure-unit tests).
Hold this around a whole read-check-mutate-persist cycle (not just the file write) to avoid lost writes
under concurrent saves. It is NOT reentrant — call `_persist_slug` (not `_save_slug`) while holding it."""
from wrolpi.api_utils import api_app
return getattr(api_app.shared_ctx, 'config_save_lock', None) or contextlib.nullcontext()
def _persist_slug(self, slug: str):
"""Write one entity's in-memory config to its YAML file (version bump + daily backup).
The caller MUST already hold `save_lock()`."""
file = self.get_file(slug)
if PYTEST and not is_tempfile(file):
raise ValueError(f'Refusing to save config file while testing: {file}')
data = dict(self._configs[slug])
# Backup the existing file before overwriting.
if file.is_file():
backup_file = self._get_backup_filename(slug)
backup_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(file, backup_file)
data['version'] = (data.get('version') or 0) + 1
file.parent.mkdir(parents=True, exist_ok=True)
write_config_data(data, file, width=self.width)
# Persist the bumped version back to the shared store.
self._configs[slug] = data
def _save_slug(self, slug: str):
"""Acquire the save lock and write one entity to its file."""
with self.save_lock():
self._persist_slug(slug)
def _get_backup_filename(self, slug: str) -> Path:
date_str = now().strftime('%Y%m%d')
return get_media_directory() / 'config' / 'backup' / self.subdirectory / f'{slug}-{date_str}.yaml'
def dump_all(self):
"""Write every in-memory entity to disk."""
for slug in list(self._configs.keys()):
try:
self._save_slug(slug)
except Exception as e:
logger_.error(f'Failed to save {self.name} config: {slug}', exc_info=e)
def save_later(self, slug: str = None):
"""Schedule a background save of all entities (coalesces rapid edits)."""
self.background_save.activate_switch()
def create(self, slug: str, data: dict):
"""Add a new entity and persist it immediately."""
if slug in self._configs:
raise ValueError(f'{self.name} already exists: {slug}')
entity = deepcopy(self.default_entity)
entity.update(data)
entity['version'] = 0
if not self.is_valid(entity):
raise ValidationError(f'Invalid {self.name} config: {entity}')
self._configs[slug] = entity
self._save_slug(slug)
def replace(self, slug: str, data: dict):
"""Replace an entity's config wholesale and persist it (background)."""
if slug not in self._configs:
raise ValueError(f'No {self.name} exists: {slug}')
entity = dict(self._configs[slug])
entity.update(data)
if not self.is_valid(entity):
raise ValidationError(f'Invalid {self.name} config: {entity}')
self._configs[slug] = entity
self.save_later(slug)
def rename(self, old_slug: str, new_slug: str):
"""Rename an entity: write the new file, then remove the old one."""
if old_slug not in self._configs:
raise ValueError(f'No {self.name} exists: {old_slug}')
if new_slug in self._configs:
raise ValueError(f'{self.name} already exists: {new_slug}')
entity = dict(self._configs[old_slug])
entity['slug'] = new_slug
self._configs[new_slug] = entity
self._save_slug(new_slug)
self.delete(old_slug)
def delete(self, slug: str):
"""Remove an entity from memory and delete its file."""
self._configs.pop(slug, None)
file = self.get_file(slug)
if file.is_file():
if PYTEST and not is_tempfile(file):
raise ValueError(f'Refusing to delete config file while testing: {file}')
file.unlink()
@dataclass
class WROLPiConfigValidator:
archive_destination: str = None
@ -838,9 +1072,12 @@ def get_all_configs() -> Dict[str, ConfigFile]:
if channels_config := get_channels_config():
all_configs[channels_config.file_name] = channels_config
from modules.inventory.common import get_inventories_config
if inventories_config := get_inventories_config():
all_configs[inventories_config.file_name] = inventories_config
# NOTE: Inventory is no longer a single ConfigFile; it is a config-only MultiFileConfig (config/inventory/*.yaml)
# that is the source-of-truth itself (no DB sync), so it is not registered here. The shared food catalog,
# however, is a single ConfigFile and is registered.
from modules.inventory.catalog import get_catalog_config
if catalog_config := get_catalog_config():
all_configs[catalog_config.file_name] = catalog_config
from modules.videos.lib import get_videos_downloader_config
if videos_downloader_config := get_videos_downloader_config():
@ -890,7 +1127,6 @@ async def import_all_db_configs() -> dict[str, bool]:
from modules.videos.lib import import_channels_config
from modules.archive.lib import import_domains_config
from modules.inventory.common import import_inventories_config
from modules.inventory import init_inventory
results = {}
@ -930,10 +1166,9 @@ async def import_all_db_configs() -> dict[str, bool]:
logger.warning(f'Failed to import domains config: {e}')
results['domains'] = False
# Inventories
# Inventories (config-only, no DB). Migrates any legacy inventories.yaml, then loads per-inventory files.
try:
import_inventories_config()
init_inventory()
results['inventories'] = True
logger.debug('inventories config imported')
except Exception as e:
@ -2684,7 +2919,6 @@ def cached_multiprocessing_result(func: callable):
def create_empty_config_files() -> list[str]:
"""Creates config files only if they do not exist and will not conflict with the DB."""
from modules.inventory.common import get_inventories_config
from modules.videos.lib import get_channels_config, get_videos_downloader_config
from wrolpi.downloader import get_download_manager_config
from wrolpi.tags import get_tags_config
@ -2727,12 +2961,8 @@ def create_empty_config_files() -> list[str]:
get_archive_downloader_config().save(overwrite=True)
created.append(get_archive_downloader_config().get_file().name)
if not get_inventories_config().get_file().is_file():
from modules.inventory.models import Inventory
if session.query(Inventory).count() == 0:
# No Inventories will be deleted, create the empty Inventory config file.
get_inventories_config().save(overwrite=True)
created.append(get_inventories_config().get_file().name)
# NOTE: Inventory is config-only (no DB) and manages its own per-inventory files under config/inventory/;
# it is seeded by migrate_legacy_inventory()/import on startup, not here.
return created

View file

@ -26,6 +26,7 @@ def attach_shared_contexts(app: Sanic):
app.shared_ctx.wrolpi_config = manager.dict()
app.shared_ctx.tags_config = manager.dict()
app.shared_ctx.inventories_config = manager.dict()
app.shared_ctx.catalog_config = manager.dict()
app.shared_ctx.channels_config = manager.dict()
app.shared_ctx.download_manager_config = manager.dict()
app.shared_ctx.videos_downloader_config = manager.dict()
@ -98,6 +99,7 @@ def reset_shared_contexts(app: Sanic):
app.shared_ctx.wrolpi_config.clear()
app.shared_ctx.tags_config.clear()
app.shared_ctx.inventories_config.clear()
app.shared_ctx.catalog_config.clear()
app.shared_ctx.channels_config.clear()
app.shared_ctx.download_manager_config.clear()
app.shared_ctx.videos_downloader_config.clear()
@ -208,7 +210,7 @@ def reset_shared_contexts(app: Sanic):
def initialize_configs_contexts(app: Sanic):
"""Assign multiprocessing Dicts to their respective FileConfigs in this process."""
from modules.inventory.common import INVENTORIES_CONFIG
from modules.inventory.common import get_inventory_configs
from modules.videos.lib import CHANNELS_CONFIG
from modules.videos.lib import VIDEOS_DOWNLOADER_CONFIG
from wrolpi.common import WROLPI_CONFIG
@ -216,10 +218,17 @@ def initialize_configs_contexts(app: Sanic):
from wrolpi.downloader import DOWNLOAD_MANAGER_CONFIG
try: # noqa
INVENTORIES_CONFIG.initialize(app.shared_ctx.inventories_config)
# Inventory is config-only (no DB); the shared dict is keyed by inventory slug.
get_inventory_configs().initialize(app.shared_ctx.inventories_config)
except Exception as e:
logger.error(f'Failed to initialize in-memory inventories config: {e}')
try:
from modules.inventory.catalog import get_catalog_config
get_catalog_config().initialize(app.shared_ctx.catalog_config)
except Exception as e:
logger.error(f'Failed to initialize in-memory food catalog config: {e}')
try:
CHANNELS_CONFIG.initialize(app.shared_ctx.channels_config)
except Exception as e:

View file

@ -1125,14 +1125,13 @@ async def test_cached_multiprocessing_result(async_client):
@pytest.mark.asyncio
async def test_initialize_config_files(async_client, test_session, test_directory, test_wrolpi_config,
test_channels_config, test_tags_config, test_videos_downloader_config,
test_download_manager_config, simple_channel, test_inventory, tag_factory,
test_download_manager_config, simple_channel, tag_factory,
test_download_manager, test_downloader):
"""Config files should only be created if they are missing, and they will not overwrite existing entries in the DB."""
from modules.videos.lib import get_videos_downloader_config, get_channels_config
from modules.archive.lib import get_archive_downloader_config
from wrolpi.tags import get_tags_config
from wrolpi.downloader import get_download_manager_config
from modules.inventory.common import get_inventories_config
# Create some DB entries so the configs will not be imported.
tag1, tag2 = await tag_factory(), await tag_factory()
@ -1182,9 +1181,7 @@ async def test_initialize_config_files(async_client, test_session, test_director
'Download Manager config should have been created.'
assert get_download_manager_config().is_valid()
test_session.delete(test_inventory)
assert common.create_empty_config_files() == ['inventories.yaml'], 'Inventory config should have been created.'
assert get_inventories_config().is_valid()
# NOTE: Inventory is config-only (no DB) and is no longer created by create_empty_config_files().
def test_get_paths_in_parent_directory(test_directory, make_files_structure):

View file

@ -499,110 +499,9 @@ class TestDomainsBackupImport:
assert preview['unchanged'] == 1
class TestInventoriesBackupImport:
def test_preview_overwrite(self, test_directory):
from modules.inventory.common import get_inventories_config
config = get_inventories_config()
current = dict(
inventories=[
dict(name='Food', created_at='2026-01-01', deleted_at=None, items=[]),
dict(name='Tools', created_at='2026-01-01', deleted_at=None, items=[]),
],
version=1,
)
_write_yaml(config.get_file(), current)
backup = dict(
inventories=[dict(name='Food', created_at='2026-01-01', deleted_at=None, items=[])],
version=1,
)
_make_backup(get_media_directory(), 'inventories.yaml', '20260301', backup)
preview = config.preview_backup_import('20260301', 'overwrite')
assert preview['mode'] == 'overwrite'
assert len(preview['remove']) == 1
assert preview['remove'][0]['name'] == 'Tools'
assert preview['unchanged'] == 1
assert preview['add'] == []
@pytest.mark.asyncio
async def test_import_merge(self, test_directory, test_session, async_client):
from modules.inventory.common import get_inventories_config
config = get_inventories_config()
current = dict(
inventories=[dict(id=1, name='Food', created_at='2026-01-01', deleted_at=None, items=[])],
version=1,
)
_write_yaml(config.get_file(), current)
backup = dict(
inventories=[
dict(id=1, name='Food', created_at='2026-01-01', deleted_at=None, items=[]),
dict(id=2, name='Medical', created_at='2026-01-01', deleted_at=None, items=[]),
],
version=1,
)
_make_backup(get_media_directory(), 'inventories.yaml', '20260301', backup)
config.import_backup('20260301', 'merge')
merged = config.read_config_file()
names = [inv['name'] for inv in merged['inventories']]
assert 'Food' in names
assert 'Medical' in names
@pytest.mark.asyncio
async def test_import_overwrite(self, test_directory, test_session, async_client):
from modules.inventory.common import get_inventories_config
config = get_inventories_config()
current = dict(
inventories=[
dict(id=1, name='Food', created_at='2026-01-01', deleted_at=None, items=[]),
dict(id=2, name='Tools', created_at='2026-01-01', deleted_at=None, items=[]),
],
version=1,
)
_write_yaml(config.get_file(), current)
backup = dict(
inventories=[dict(id=1, name='Food', created_at='2026-01-01', deleted_at=None, items=[])],
version=1,
)
_make_backup(get_media_directory(), 'inventories.yaml', '20260301', backup)
config.import_backup('20260301', 'overwrite')
overwritten = config.read_config_file()
assert len(overwritten['inventories']) == 1
assert overwritten['inventories'][0]['name'] == 'Food'
def test_preview_merge(self, test_directory):
from modules.inventory.common import get_inventories_config
config = get_inventories_config()
current = dict(
inventories=[dict(name='Food', created_at='2026-01-01', deleted_at=None, items=[])],
version=1,
)
_write_yaml(config.get_file(), current)
backup = dict(
inventories=[
dict(name='Food', created_at='2026-01-01', deleted_at=None, items=[]),
dict(name='Medical', created_at='2026-01-01', deleted_at=None, items=[]),
],
version=1,
)
_make_backup(get_media_directory(), 'inventories.yaml', '20260301', backup)
preview = config.preview_backup_import('20260301', 'merge')
assert len(preview['add']) == 1
assert preview['add'][0]['name'] == 'Medical'
assert preview['unchanged'] == 1
# NOTE: Inventory no longer supports config backup/restore — it is a config-only MultiFileConfig
# (config/inventory/*.yaml) and was removed from DB_CONFIG_FILE_NAMES. Its previous backup-import tests
# (TestInventoriesBackupImport) were removed with that change.
class TestImportBackupPreservesCurrentConfig: