mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
Rebuild Inventory as a config-only system with food catalog and calculators
Replace the DB-backed inventory module with a config-only design and build out food-storage tooling around it. Inventory (config-only, no database) - Each inventory is a YAML file at config/inventory/<slug>.yaml, loaded once into memory and the single source of truth. New reusable MultiFileConfig (wrolpi/common.py) manages a directory of per-entity files. - Inventory types (food/fuel/tool) seed a customizable field schema (text/number/quantity/date/select/location/calories). Unit math/aggregation moved to the frontend (mathjs); Pint dropped from the backend. - Minimal API: GET / (all inventories full), POST /, PUT /<slug> (whole- inventory save with version-based optimistic concurrency; 404 for missing), DELETE. Legacy inventories.yaml / old tables migrate to per-inventory files (pound->lb), and an alembic migration drops the tables. Frontend - New app/src/components/inventory/ package: spreadsheet-style table with Tab/Enter keyboard entry, sortable columns, expired-row highlighting, customizable field editor, and pooled cross-inventory location autocomplete. - A single useInventories store loads everything once and persists whole inventories optimistically; list/items/summary/locations derive client-side. - Summary groups items (default by Category), totals size x count, shows a calories column, and embeds a ration estimate (how long the stock lasts). Food catalog - Shared, editable catalog (config/inventory/catalog.yaml) seeded from catalog_defaults.yaml (~60 generic items, total calories per package) with merge-keep-edits + tombstones. GET/PUT /api/inventory/catalog, a management modal, and entry-row pre-fill of category/size/calories from a name match. Calculators - Generic Food Storage planner (like the Water calculator): household + editable per-person/year amounts -> total to store over a chosen duration, with women/children weighted by DGA calorie shares and an imperial/metric toggle. Also fixes ConfigFile.save() to accept nested file_names. Backend pytest and frontend jest suites updated/added throughout; alembic check clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ae5f64424d
commit
3d6aead072
46 changed files with 3612 additions and 2697 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
74
alembic/versions/b2e4f6a8c1d3_drop_inventory_tables.py
Normal file
74
alembic/versions/b2e4f6a8c1d3_drop_inventory_tables.py
Normal 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')
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
147
app/src/api.js
147
app/src/api.js
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}/>
|
||||
|
|
|
|||
222
app/src/components/calculators/FoodStorageCalculator.js
Normal file
222
app/src/components/calculators/FoodStorageCalculator.js
Normal 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>;
|
||||
}
|
||||
60
app/src/components/calculators/FoodStorageCalculator.test.js
Normal file
60
app/src/components/calculators/FoodStorageCalculator.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
204
app/src/components/calculators/RationCalculator.js
Normal file
204
app/src/components/calculators/RationCalculator.js
Normal 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.
|
||||
73
app/src/components/calculators/RationCalculator.test.js
Normal file
73
app/src/components/calculators/RationCalculator.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
108
app/src/components/inventory/CatalogEditor.js
Normal file
108
app/src/components/inventory/CatalogEditor.js
Normal 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>;
|
||||
}
|
||||
90
app/src/components/inventory/FieldCell.js
Normal file
90
app/src/components/inventory/FieldCell.js
Normal 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};
|
||||
113
app/src/components/inventory/FieldSchemaEditor.js
Normal file
113
app/src/components/inventory/FieldSchemaEditor.js
Normal 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>;
|
||||
}
|
||||
50
app/src/components/inventory/Inventory.cy.js
Normal file
50
app/src/components/inventory/Inventory.cy.js
Normal 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');
|
||||
});
|
||||
});
|
||||
177
app/src/components/inventory/InventoryRoute.js
Normal file
177
app/src/components/inventory/InventoryRoute.js
Normal 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>;
|
||||
}
|
||||
131
app/src/components/inventory/InventorySummary.js
Normal file
131
app/src/components/inventory/InventorySummary.js
Normal 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>}
|
||||
</>;
|
||||
}
|
||||
39
app/src/components/inventory/InventorySummary.test.js
Normal file
39
app/src/components/inventory/InventorySummary.test.js
Normal 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
|
||||
});
|
||||
});
|
||||
286
app/src/components/inventory/InventoryTable.js
Normal file
286
app/src/components/inventory/InventoryTable.js
Normal 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);
|
||||
}
|
||||
143
app/src/components/inventory/InventoryTable.test.js
Normal file
143
app/src/components/inventory/InventoryTable.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
141
app/src/components/inventory/units.js
Normal file
141
app/src/components/inventory/units.js
Normal 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(' + ') : '—';
|
||||
}
|
||||
69
app/src/components/inventory/units.test.js
Normal file
69
app/src/components/inventory/units.test.js
Normal 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('—');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
129
modules/inventory/catalog.py
Normal file
129
modules/inventory/catalog.py
Normal 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)
|
||||
89
modules/inventory/catalog_defaults.yaml
Normal file
89
modules/inventory/catalog_defaults.yaml
Normal 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: ''}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 _
|
||||
|
|
|
|||
112
modules/inventory/defaults.py
Normal file
112
modules/inventory/defaults.py
Normal 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'
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
151
modules/inventory/migrate.py
Normal file
151
modules/inventory/migrate.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
84
modules/inventory/test/test_catalog.py
Normal file
84
modules/inventory/test/test_catalog.py
Normal 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
|
||||
|
|
@ -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'
|
||||
|
|
@ -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')]
|
||||
|
|
|
|||
96
modules/inventory/test/test_migration.py
Normal file
96
modules/inventory/test/test_migration.py
Normal 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)
|
||||
42
modules/inventory/test/test_multifile_config.py
Normal file
42
modules/inventory/test/test_multifile_config.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
296
wrolpi/common.py
296
wrolpi/common.py
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue