From 3d6aead0729d1d4613d3bd27955bfce6f7e96df3 Mon Sep 17 00:00:00 2001 From: Roland Date: Sun, 14 Jun 2026 14:20:10 -0600 Subject: [PATCH] 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/.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 / (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) --- alembic/env.py | 1 - .../b2e4f6a8c1d3_drop_inventory_tables.py | 74 ++ app/src/App.js | 2 +- app/src/api.js | 147 +-- app/src/components/Calculators.js | 2 + app/src/components/Inventory.js | 1070 ----------------- app/src/components/Theme.tsx | 27 +- .../calculators/FoodStorageCalculator.js | 222 ++++ .../calculators/FoodStorageCalculator.test.js | 60 + .../calculators/RationCalculator.js | 204 ++++ .../calculators/RationCalculator.test.js | 73 ++ app/src/components/inventory/CatalogEditor.js | 108 ++ app/src/components/inventory/FieldCell.js | 90 ++ .../components/inventory/FieldSchemaEditor.js | 113 ++ app/src/components/inventory/Inventory.cy.js | 50 + .../components/inventory/InventoryRoute.js | 177 +++ .../components/inventory/InventorySummary.js | 131 ++ .../inventory/InventorySummary.test.js | 39 + .../components/inventory/InventoryTable.js | 286 +++++ .../inventory/InventoryTable.test.js | 143 +++ app/src/components/inventory/units.js | 141 +++ app/src/components/inventory/units.test.js | 69 ++ app/src/hooks/customHooks.js | 121 +- modules/inventory/__init__.py | 31 +- modules/inventory/api.py | 143 +-- modules/inventory/catalog.py | 129 ++ modules/inventory/catalog_defaults.yaml | 89 ++ modules/inventory/common.py | 452 +++---- modules/inventory/conftest.py | 64 +- modules/inventory/defaults.py | 112 ++ modules/inventory/errors.py | 20 +- modules/inventory/inventory.py | 206 ---- modules/inventory/migrate.py | 151 +++ modules/inventory/models.py | 61 - modules/inventory/schema.py | 53 +- modules/inventory/test/test_api.py | 159 +-- modules/inventory/test/test_catalog.py | 84 ++ .../inventory/test/test_inventories_config.py | 219 ---- modules/inventory/test/test_inventory.py | 424 ++----- modules/inventory/test/test_migration.py | 96 ++ .../inventory/test/test_multifile_config.py | 42 + requirements.txt | 1 - wrolpi/common.py | 296 ++++- wrolpi/contexts.py | 13 +- wrolpi/test/test_common.py | 7 +- wrolpi/test/test_config_backup_import.py | 107 +- 46 files changed, 3612 insertions(+), 2697 deletions(-) create mode 100644 alembic/versions/b2e4f6a8c1d3_drop_inventory_tables.py delete mode 100644 app/src/components/Inventory.js create mode 100644 app/src/components/calculators/FoodStorageCalculator.js create mode 100644 app/src/components/calculators/FoodStorageCalculator.test.js create mode 100644 app/src/components/calculators/RationCalculator.js create mode 100644 app/src/components/calculators/RationCalculator.test.js create mode 100644 app/src/components/inventory/CatalogEditor.js create mode 100644 app/src/components/inventory/FieldCell.js create mode 100644 app/src/components/inventory/FieldSchemaEditor.js create mode 100644 app/src/components/inventory/Inventory.cy.js create mode 100644 app/src/components/inventory/InventoryRoute.js create mode 100644 app/src/components/inventory/InventorySummary.js create mode 100644 app/src/components/inventory/InventorySummary.test.js create mode 100644 app/src/components/inventory/InventoryTable.js create mode 100644 app/src/components/inventory/InventoryTable.test.js create mode 100644 app/src/components/inventory/units.js create mode 100644 app/src/components/inventory/units.test.js create mode 100644 modules/inventory/catalog.py create mode 100644 modules/inventory/catalog_defaults.yaml create mode 100644 modules/inventory/defaults.py delete mode 100644 modules/inventory/inventory.py create mode 100644 modules/inventory/migrate.py delete mode 100644 modules/inventory/models.py create mode 100644 modules/inventory/test/test_catalog.py delete mode 100644 modules/inventory/test/test_inventories_config.py create mode 100644 modules/inventory/test/test_migration.py create mode 100644 modules/inventory/test/test_multifile_config.py diff --git a/alembic/env.py b/alembic/env.py index 21060167..c00e553c 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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 diff --git a/alembic/versions/b2e4f6a8c1d3_drop_inventory_tables.py b/alembic/versions/b2e4f6a8c1d3_drop_inventory_tables.py new file mode 100644 index 00000000..d000a1e1 --- /dev/null +++ b/alembic/versions/b2e4f6a8c1d3_drop_inventory_tables.py @@ -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/.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') diff --git a/app/src/App.js b/app/src/App.js index 4b1e0b9c..d82b6980 100644 --- a/app/src/App.js +++ b/app/src/App.js @@ -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"; diff --git a/app/src/api.js b/app/src/api.js index 01a3445e..4ccf8598 100644 --- a/app/src/api.js +++ b/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; } diff --git a/app/src/components/Calculators.js b/app/src/components/Calculators.js index 2c0665bf..3a33e63a 100644 --- a/app/src/components/Calculators.js +++ b/app/src/components/Calculators.js @@ -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: }, {key: 'drive', icon: 'cogs', button: 'Drive Ratio', contents: }, {key: 'water', icon: 'tint', button: 'Water Storage', contents: }, + {key: 'ration', icon: 'food', button: 'Food Storage', contents: }, {key: 'antenna', icon: 'signal', button: 'Antenna', contents: }, {key: 'temperature', icon: 'thermometer', button: 'Temperature', contents: }, {key: 'qrCode', icon: 'qrcode', button: 'QR Code', contents: }, diff --git a/app/src/components/Inventory.js b/app/src/components/Inventory.js deleted file mode 100644 index 08c0caab..00000000 --- a/app/src/components/Inventory.js +++ /dev/null @@ -1,1070 +0,0 @@ -import React, {useContext, useState} from 'react'; -import { - deleteInventory, - deleteItems, - getBrands, - getCategories, - getInventories, - getItems, - saveInventory, - saveItem, - updateInventory, - updateItem -} from "../api"; -import { - Checkbox, - Dropdown, - Form, - Grid, - Portal, - TableBody, - TableCell, - TableHeader, - TableHeaderCell, - TableRow -} from "semantic-ui-react"; -import {Route, Routes} from "react-router"; -import {arraysEqual, enumerate, PageContainer, replaceNullValues, TabLinks, useTitle} from './Common'; -import _ from 'lodash'; -import {ThemeContext} from "../contexts/contexts"; -import {useInventory} from "../hooks/customHooks"; -import {Button, Form as ThemedForm, Header, Segment, Table} from "./Theme"; - -function InventorySummary() { - const {t} = useContext(ThemeContext); - const [inventoryId, setInventoryId] = useState(); - const {byCategory, bySubcategory, byName} = useInventory(inventoryId); - - const setInventory = (inventory) => { - setInventoryId(inventory.id); - } - - const categoryTable = () => { - function row([key, i]) { - return - {i.category} - {i.total_size} - {i.unit} - - } - - if (!_.isEmpty(byCategory)) { - return - - - Category - Total Size - Unit - - - - {byCategory.map(row)} - -
; - } - return

No items have been added to this inventory.

; - } - - const subcategoryTable = () => { - function row([key, i]) { - return - {i.category} - {i.subcategory} - {i.total_size} - {i.unit} - - } - - if (!_.isEmpty(bySubcategory)) { - return - - - Category - Subcategory - Total Size - Unit - - - - {bySubcategory.map(row)} - -
; - } - return

No items have been added to this inventory.

; - } - - const nameTable = () => { - - function row([key, i]) { - return - {i.brand} - {i.name} - {i.total_size} - {i.unit} - - } - - if (!_.isEmpty(byName)) { - - return - - - Brand - Product Name - Total Size - Unit - - - - {byName.map(row)} - -
; - } - return

No items have been added to this inventory.

; - } - - return <> - -
Categorized
- {categoryTable()} - -
Categorized by Subcategory
- {subcategoryTable()} - -
By Name
- {nameTable()} - -} - -class InventoryList extends React.Component { - - constructor(props) { - super(props); - this.state = { - checkboxes: [], - editItems: {}, - } - } - - handleCheckbox = (checkbox, item) => { - // Add or remove a checkbox ID from the checkboxes array. - let checkboxes = this.state.checkboxes; - let checked = checkbox.current.state.checked; - - // Add the item id to the list of checked checkboxes. - let value = checkbox.current.props.value; - if (!checked) { - checkboxes = checkboxes.concat([value]); - } else { - const index = checkboxes.indexOf(value); - if (index > -1) { - checkboxes.splice(index, 1); - } - } - - // Create or remove the editItem. This is a copy of the item which can then be modified and saved. - let editItems = this.state.editItems; - if (!checked) { - let editItem = {} - Object.assign(editItem, item); - replaceNullValues(editItem); - editItems[item.id] = editItem; - } else { - delete editItems[item.id]; - } - - this.setState({checkboxes: checkboxes, editItems: editItems}); - } - - handleRemove = async (e) => { - e.preventDefault(); - await deleteItems(this.state.checkboxes); - await this.props.fetchItems(); - } - - handleInputChange = (e, {name, value}, itemId) => { - let editItems = this.state.editItems; - editItems[itemId][name] = value; - this.setState({editItems}); - } - - handleSave = async (e) => { - e.preventDefault(); - let keys = Object.keys(this.state.editItems); - for (let i = 0; i < keys.length; i++) { - let itemId = keys[i]; - let item = this.state.editItems[itemId]; - await updateItem(itemId, item); - } - this.setState({editItems: {}, checkboxes: []}, this.props.fetchItems); - } - - row = (item) => { - let ref = React.createRef(); - let editable = this.state.checkboxes.indexOf(item.id) >= 0; - - let checkboxCell = - this.handleCheckbox(ref, item)} - /> - ; - - if (!editable) { - // Show the user the non-editable version, until they check the checkbox. - return - {checkboxCell} - - {item.brand} - - - {item.name} - - - {item.item_size} - - - {item.unit} - - - {item.count} - - { - item.subcategory} - - - {item.category} - - - {item.expiration_date} - - - } else { - // Insert the modified table row so the user can edit this item. - let editItem = this.state.editItems[item.id]; - return - {checkboxCell} - - this.handleInputChange(i, j, item.id)} - /> - - - this.handleInputChange(i, j, item.id)} - /> - - - this.handleInputChange(i, j, item.id)} - /> - - - this.handleInputChange(i, j, item.id)} - /> - - - this.handleInputChange(i, j, item.id)} - /> - - this.handleInputChange(i, j, item.id)} - subcategory={editItem.subcategory} - category={editItem.category} - /> - - this.handleInputChange(i, j, item.id)} - /> - - - } - } - - handleSort = (columns) => { - this.props.handleSort(columns); - } - - render() { - if (_.isEmpty(this.props.items)) { - return - {({t}) => ( -

Add some items using the form above!

- )} -
- } - - const {sortColumns, sortDirection} = this.props; - - return <> - - - - this.handleSort(['id'])} - > - Edit - - this.handleSort(['brand', 'name'])} - > - Brand - - this.handleSort(['name', 'brand'])} - > - Name - - this.handleSort(['size'])} - > - Size - - this.handleSort(['unit'])} - > - Unit - - this.handleSort(['count'])} - > - Count - - this.handleSort(['subcategory', 'category'])} - > - Subcategory - - this.handleSort(['category', 'subcategory'])} - > - Category - - this.handleSort(['expiration_date'])} - > - Expiration Date - - - - - {this.props.items.map((i) => this.row(i))} - -
- - - - } -} - -class InventoryPortal extends React.Component { - - constructor(props) { - super(props); - this.state = { - open: false, - inventory: null, - name: '', - }; - } - - componentDidUpdate(prevProps, prevState, snapshot) { - if (this.props !== prevProps) { - this.setState({ - inventory: this.props.inventory, - name: this.props.inventory ? this.props.inventory.name : '', - }); - } - } - - handleClose = () => this.setState({open: false}); - handleOpen = () => this.setState({open: true}); - - handleInputChange = (e, {name, value}) => { - this.setState({[name]: value}); - } - - handleSubmit = async (e) => { - e.preventDefault(); - let inventory = { - id: this.state.inventory ? this.state.inventory.id : null, - name: this.state.name || (this.state.inventory ? this.state.inventory.name : ''), - }; - await this.props.handleSubmit(inventory, this.handleClose); - } - - handleDelete = async (e) => { - e.preventDefault(); - this.props.handleDelete(this.state.inventory, this.handleClose); - } - - render() { - return - {({s}) => ( - <> - - { - this.props.deleteButton && - - } - - - - - - )} - - } -} - -class EditInventory extends React.Component { - - constructor(props) { - super(props); - this.state = { - open: false, - inventory: props.inventory, - } - } - - componentDidUpdate(prevProps, prevState, snapshot) { - if (prevProps !== this.props) { - this.setState({inventory: this.props.inventory}); - } - } - - handleSubmit = async (inventory, closeCallback) => { - let inventoryId = inventory.id; - let response = await updateInventory(inventoryId, inventory); - if (response.ok) { - await this.props.success(); - closeCallback(); - } - } - - handleDelete = async (inventory, closeCallback) => { - let response = await deleteInventory(inventory.id); - - if (response.ok) { - await this.props.success(); - closeCallback(); - } - } - - render() { - return - } -} - -class NewInventory extends React.Component { - - handleSubmit = async (inventory, closeCallback) => { - delete inventory.id; - let response = await saveInventory(inventory); - - if (response.ok) { - await this.props.success(); - closeCallback(); - } - } - - render() { - return - } - -} - -class InventorySelector extends React.Component { - constructor(props) { - super(props); - this.state = { - options: [], - inventories: [], - selected: null, - newInventoryPortal: false, - editInventoryPortal: false, - name: '', - } - } - - async componentDidMount() { - await this.fetchInventories(); - } - - async fetchInventories() { - try { - let inventories = await getInventories(); - let options = []; - for (let i = 0; i < inventories.length; i++) { - let inventory = inventories[i]; - options = options.concat([{ - key: inventory.id, - text: inventory.name, - value: inventory.id, - id: inventory.id, - }]) - } - let selected = this.state.selected || inventories[0]; - this.setState({inventories, options, selected}, - () => this.props.setInventory(selected)); - } catch (e) { - console.error(e); - } - } - - setInventory = (e, {value}) => { - let selected = null; - for (let i = 0; i < this.state.inventories.length; i++) { - if (value === this.state.inventories[i].id) { - selected = this.state.inventories[i]; - } - } - this.setState({selected: selected}, - () => this.props.setInventory(this.state.selected)); - } - - render() { - return <> -
Inventory
- - - - - - this.fetchInventories()}/> - this.fetchInventories()} - /> - - - - } -} - -class SuggestionInput extends React.Component { - - constructor(props) { - super(props); - this.state = { - value: '', - } - } - - componentDidMount() { - this.setState({value: this.props.value !== undefined ? this.props.value : ''}) - } - - componentDidUpdate(prevProps, prevState, snapshot) { - if (prevProps !== this.props && this.props.value !== undefined) { - this.setState({value: this.props.value}); - } - } - - buildOptions() { - return this.props.options.map((i) => ) - } - - handleInputChange = (e, option) => { - e.preventDefault(); - let [key, value,] = option.value.split(','); - if (!isNaN(key)) { - // User chose from the suggestions. - this.setState({value: value}); - this.props.handleInputChange(e, {name: option.name, key: key, value: value}) - } else { - // User typed in something new. - value = key === undefined ? '' : key; - this.setState({value: value}); - this.props.handleInputChange(e, {name: option.name, value: value}); - } - } - - clearInput() { - this.setState({value: ''}); - } - - render() { - return - {({s}) => ( - <> - - - - {this.buildOptions()} - - - )} - - } -} - -class CategoryInputs extends React.Component { - - constructor(props) { - super(props); - this.state = { - categories: [], - subcategory: '', - category: '', - } - } - - componentDidMount = async () => { - await this.fetchCategories(); - this.setState({ - subcategory: this.props.subcategory !== undefined ? this.props.subcategory : '', - category: this.props.category !== undefined ? this.props.category : '', - }); - } - - fetchCategories = async () => { - try { - let categories = await getCategories(); - categories = enumerate(categories); - let newCategories = []; - for (let i = 0; i < categories.length; i++) { - let [key, sc] = categories[i]; - let [subcategory, category] = sc; - newCategories = newCategories.concat([[key, subcategory, `${subcategory}/${category}`]]); - } - this.setState({categories: newCategories}); - } catch (e) { - console.error(e); - } - } - - clearInputs = () => { - this.subcategoryRef.current.clearInput(); - - this.setState({ - subcategory: '', - category: '', - }); - } - - handleInputChange = (e, {name, value, key}) => { - this.setState({[name]: value}); - if (key) { - // User chose from suggestions. Set the associated category. - let [subcategory, category] = this.state.categories[key][2].split('/'); - this.setState({subcategory: subcategory, category: category}); - this.props.handleInputChange(e, {'name': 'subcategory', 'value': subcategory}); - this.props.handleInputChange(e, {'name': 'category', 'value': category}); - } else { - this.props.handleInputChange(e, {name, value}); - } - } - - render() { - this.subcategoryRef = React.createRef(); - - return - {({s}) => ( - - - - - - - - - - )} - - } -} - -class TableCategoryInputs extends CategoryInputs { - render() { - return <> - - - - - - - - } -} - -class BrandInput extends React.Component { - constructor(props) { - super(props); - this.state = { - brand: '', - options: [], - } - } - - async componentDidMount() { - await this.fetchBrands(); - this.setState({brand: this.props.value !== undefined ? this.props.value : ''}); - } - - fetchBrands = async () => { - try { - let brands = await getBrands(); - brands = enumerate(brands); - let newBrands = []; - for (let i = 0; i < brands.length; i++) { - let [key, brand] = brands[i]; - newBrands = newBrands.concat([[key, brand, brand]]); - } - this.setState({options: newBrands}); - } catch (e) { - console.error(e); - } - } - - handleInputChange = (e, {name, value}) => { - this.setState({[name]: value}); - this.props.handleInputChange(e, {name, value}); - } - - clearInput() { - this.setState({brand: ''}); - } - - render() { - return - } -} - -class InventoryAddList extends React.Component { - - constructor(props) { - super(props); - this.state = { - inventory: null, - categories: [], - sort: {columns: null, direction: 'ascending'}, - items: [], - brand: '', - name: '', - item_size: '', - unit: '', - count: '', - category: '', - subcategory: '', - expiration_date: '', - } - } - - componentDidMount = async () => { - await this.fetchItems(); - } - - fetchItems = async () => { - if (this.state.inventory) { - this.setState({items: []}); - let response = await getItems(this.state.inventory.id); - let items = response.items; - - this.setState({items: items}); - } - } - - handleInputChange = (e, {name, value}) => { - this.setState({[name]: value}); - } - - clearInputs = () => { - this.setState({ - brand: '', - name: '', - item_size: '', - unit: '', - count: '', - category: '', - subcategory: '', - expiration_date: '', - }); - this.categoriesRef.current.clearInputs(); - this.brandRef.current.clearInput(); - } - - handleSubmit = async (e) => { - e.preventDefault(); - let item = { - brand: this.state.brand, - name: this.state.name, - item_size: this.state.item_size, - unit: this.state.unit, - count: this.state.count, - category: this.state.category, - subcategory: this.state.subcategory, - expiration_date: this.state.expiration_date, - }; - let response = await saveItem(this.state.inventory.id, item); - if (response.ok) { - await this.clearInputs(); - await this.fetchItems(); - await this.fetchCategories(); - await this.fetchBrands(); - } - } - - setInventory = async (inventory) => { - this.setState({inventory}, this.fetchItems); - } - - fetchCategories = async () => { - await this.categoriesRef.current.fetchCategories(); - } - - fetchBrands = async () => { - await this.brandRef.current.fetchBrands(); - } - - handleSort = (newColumns) => { - let sorted = _.sortBy(this.state.items, newColumns); - let {columns, direction} = this.state.sort; - if (arraysEqual(columns, newColumns)) { - direction = direction === 'ascending' ? 'descending' : 'ascending'; - } - if (direction === 'descending') { - sorted = sorted.reverse(); - } - this.setState({items: sorted, sort: {columns: newColumns, direction: direction}}); - } - - render() { - this.categoriesRef = React.createRef(); - this.brandRef = React.createRef(); - return - {({s}) => ( - <> - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + ))} + + + {CATEGORY_META.map(meta => {rateInput(meta)})} + + + + + + Total Stored Calories + {fmt(total, 'kcal')} + + + Daily Demand + {fmt(daily, 'kcal/day')} + + + Food Lasts + {days ? formatDuration(days) : '—'} + + +
+ ; +} + +// 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. diff --git a/app/src/components/calculators/RationCalculator.test.js b/app/src/components/calculators/RationCalculator.test.js new file mode 100644 index 00000000..2bd1985d --- /dev/null +++ b/app/src/components/calculators/RationCalculator.test.js @@ -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(); + }); +}); diff --git a/app/src/components/inventory/CatalogEditor.js b/app/src/components/inventory/CatalogEditor.js new file mode 100644 index 00000000..221b2ce8 --- /dev/null +++ b/app/src/components/inventory/CatalogEditor.js @@ -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 + Food Catalog + +

+ Known items used to autocomplete and pre-fill the entry form. kcal is the total + calories for one package of the given size. +

+ + + + + {COLUMNS.map(c => {c.label})} + + + + + {draft.map((entry, index) => { + const aria = (col) => `${entry.name || 'new item'} ${col}`; + return + + update(index, 'name', e.target.value)}/> + + + update(index, 'category', e.target.value)}/> + + + update(index, 'subcategory', e.target.value)}/> + + + update(index, 'item_size', e.target.value)}/> + + + update(index, 'calories', e.target.value)}/> + + + + + ; + })} + +
+
+ + + + +
; +} diff --git a/app/src/components/inventory/FieldCell.js b/app/src/components/inventory/FieldCell.js new file mode 100644 index 00000000..dd249df7 --- /dev/null +++ b/app/src/components/inventory/FieldCell.js @@ -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 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 ; + } + + if (field.type === 'number' || field.type === 'calories') { + // `calories` is a number (kcal per unit) that the Summary's ration estimate detects by type. + return ; + } + + if (field.type === 'quantity') { + const unitOptions = ALL_UNITS.map(u => ({key: u, value: u, text: u})); + return onChange(e.target.value)} + onKeyDown={handleKeyDown} + aria-label={field.label} + label={; + } + + // text / fallback (supports a datalist via listId, e.g. catalog name suggestions on the Name field). + return ; +} + +// The list of selectable unit groups, re-exported for the field editor. +export {UNIT_GROUPS}; diff --git a/app/src/components/inventory/FieldSchemaEditor.js b/app/src/components/inventory/FieldSchemaEditor.js new file mode 100644 index 00000000..2c9860a0 --- /dev/null +++ b/app/src/components/inventory/FieldSchemaEditor.js @@ -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 //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 + Customize Fields + + {/* 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. */} + + + + Order + Label + Type + Unit / Options + + + + + {draft.map((f, index) => + + + + + + update(index, {label: e.target.value})}/> + + + update(index, {unit: data.value})}/>} + {f.type === 'select' && + update(index, { + options: e.target.value.split(',').map(s => s.trim()).filter(Boolean) + })}/>} + + + + + )} + +
+ +
+ + + + +
; +} diff --git a/app/src/components/inventory/Inventory.cy.js b/app/src/components/inventory/Inventory.cy.js new file mode 100644 index 00000000..ed3407d8 --- /dev/null +++ b/app/src/components/inventory/Inventory.cy.js @@ -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(, {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(, {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(, {initialEntries: ['/inventory']}); + cy.wait('@getInventories'); + + cy.contains('Summary').click(); + cy.contains('Group by').should('be.visible'); + }); +}); diff --git a/app/src/components/inventory/InventoryRoute.js b/app/src/components/inventory/InventoryRoute.js new file mode 100644 index 00000000..833e6cbc --- /dev/null +++ b/app/src/components/inventory/InventoryRoute.js @@ -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 + New Inventory + + setName(e.target.value)} + placeholder='Food Storage' style={{marginBottom: '1em'}}/> +
+ Type:{' '} + setRenameValue(e.target.value)}/> + + + + + + + + setConfirmDelete(false)} + onConfirm={doDelete} + /> + ; +} diff --git a/app/src/components/inventory/InventorySummary.js b/app/src/components/inventory/InventorySummary.js new file mode 100644 index 00000000..227ca00a --- /dev/null +++ b/app/src/components/inventory/InventorySummary.js @@ -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

No items to summarize yet.

; + } + + const fieldOptions = (fs) => fs.map(f => ({key: f.key, value: f.key, text: f.label})); + + return <> +
Summary
+
+ + Group by:{' '} + setQuantityKey(data.value)}/> + } +
+ + + + toggleSort('name')} + style={{cursor: 'pointer'}}> + {groupFields.find(f => f.key === groupKey)?.label || 'Group'} + + toggleSort('count')} + style={{cursor: 'pointer'}}>Items + {quantityKey && toggleSort('total')} + style={{cursor: 'pointer'}}>Total} + {caloriesKey && toggleSort('calories')} + style={{cursor: 'pointer'}}>Calories} + + + + {rows.map(row => + {row.name} + {row.count} + {quantityKey && {row.total}} + {caloriesKey && + {row.calories > 0 ? `${row.calories.toLocaleString()} kcal` : '—'}} + )} + +
+ + {caloriesKey && + +
Ration Estimate
+ +
} + ; +} diff --git a/app/src/components/inventory/InventorySummary.test.js b/app/src/components/inventory/InventorySummary.test.js new file mode 100644 index 00000000..162aeeaf --- /dev/null +++ b/app/src/components/inventory/InventorySummary.test.js @@ -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(); + expect(groupOrder()).toEqual(['beans', 'grains']); + }); + + test('sorting by Items uses the numeric count', () => { + render(); + 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(); + fireEvent.click(screen.getByText('Total')); // descending + expect(groupOrder()).toEqual(['beans', 'grains']); // beans 100 lb > grains 30 lb + }); +}); diff --git a/app/src/components/inventory/InventoryTable.js b/app/src/components/inventory/InventoryTable.js new file mode 100644 index 00000000..3d25179f --- /dev/null +++ b/app/src/components/inventory/InventoryTable.js @@ -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 && + + {(locations || []).map(l => } + {/* Food-catalog name suggestions; selecting one pre-fills the matching fields. */} + {hasNameField && (catalog || []).length > 0 && + + {catalog.map(e => } + {selected.size > 0 && + } + + + + + {sortedFields.map(f => toggleSort(f.key)} + style={{cursor: 'pointer'}}> + {f.label} + )} + + + + {displayItems.map(item => { + const editing = edits[item.id]; + const expired = isExpired(item); + return + + toggleSelected(item.id)}/> + {expired && } + + {sortedFields.map(f => + {editing + ? setEditValue(item.id, f.key, v)} + onUnitChange={v => setEditValue(item.id, `${f.key}_unit`, v)} + onEnter={() => saveEdit(item.id)} + listId={listIdFor(f)} + /> + : startEdit(item)} style={{cursor: 'pointer'}}> + {formatValue(item, f)} + } + )} + ; + })} + + + {/* The persistent new-item entry row. */} + + + + + {sortedFields.map((f, index) => + setDraftValue(f.key, v)} + onUnitChange={v => setDraftValue(`${f.key}_unit`, v)} + onEnter={submitDraft} + inputRef={index === 0 ? firstInputRef : undefined} + listId={listIdFor(f)} + /> + )} + + +
+ ; +} + +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); +} diff --git a/app/src/components/inventory/InventoryTable.test.js b/app/src/components/inventory/InventoryTable.test.js new file mode 100644 index 00000000..e611d275 --- /dev/null +++ b/app/src/components/inventory/InventoryTable.test.js @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + 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(); + 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(); + // 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(); + 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( + ); + + 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); + }); +}); diff --git a/app/src/components/inventory/units.js b/app/src/components/inventory/units.js new file mode 100644 index 00000000..357412aa --- /dev/null +++ b/app/src/components/inventory/units.js @@ -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(' + ') : '—'; +} diff --git a/app/src/components/inventory/units.test.js b/app/src/components/inventory/units.test.js new file mode 100644 index 00000000..8ff7f9a6 --- /dev/null +++ b/app/src/components/inventory/units.test.js @@ -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('—'); + }); +}); diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index 76be6078..6bf4a6b0 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -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(); diff --git a/modules/inventory/__init__.py b/modules/inventory/__init__.py index c0d70cf0..d1f9ec16 100644 --- a/modules/inventory/__init__.py +++ b/modules/inventory/__init__.py @@ -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'] diff --git a/modules/inventory/api.py b/modules/inventory/api.py index 3fee5773..61fd7fc2 100644 --- a/modules/inventory/api.py +++ b/modules/inventory/api.py @@ -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 /). 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('/') -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('/') -@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 / 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_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('/') +@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('/') @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('//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('//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/') -@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/', name='item_delete_many') -@inventory_bp.delete('/item/', 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) diff --git a/modules/inventory/catalog.py b/modules/inventory/catalog.py new file mode 100644 index 00000000..fdd65a9d --- /dev/null +++ b/modules/inventory/catalog.py @@ -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) diff --git a/modules/inventory/catalog_defaults.yaml b/modules/inventory/catalog_defaults.yaml new file mode 100644 index 00000000..3a4a0075 --- /dev/null +++ b/modules/inventory/catalog_defaults.yaml @@ -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: ''} diff --git a/modules/inventory/common.py b/modules/inventory/common.py index 2a26236e..bb5d0b7f 100644 --- a/modules/inventory/common.py +++ b/modules/inventory/common.py @@ -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/.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 `_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) diff --git a/modules/inventory/conftest.py b/modules/inventory/conftest.py index ad9ca198..1c3ce82a 100644 --- a/modules/inventory/conftest.py +++ b/modules/inventory/conftest.py @@ -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 _ diff --git a/modules/inventory/defaults.py b/modules/inventory/defaults.py new file mode 100644 index 00000000..5ece650f --- /dev/null +++ b/modules/inventory/defaults.py @@ -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 + `_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' diff --git a/modules/inventory/errors.py b/modules/inventory/errors.py index 5da50e71..f41cc7c8 100644 --- a/modules/inventory/errors.py +++ b/modules/inventory/errors.py @@ -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 diff --git a/modules/inventory/inventory.py b/modules/inventory/inventory.py deleted file mode 100644 index 239f929a..00000000 --- a/modules/inventory/inventory.py +++ /dev/null @@ -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') diff --git a/modules/inventory/migrate.py b/modules/inventory/migrate.py new file mode 100644 index 00000000..20a21a56 --- /dev/null +++ b/modules/inventory/migrate.py @@ -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 diff --git a/modules/inventory/models.py b/modules/inventory/models.py deleted file mode 100644 index 6f43f546..00000000 --- a/modules/inventory/models.py +++ /dev/null @@ -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'' - - -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'' - - 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() diff --git a/modules/inventory/schema.py b/modules/inventory/schema.py index 63de69fb..ce4d12b6 100644 --- a/modules/inventory/schema.py +++ b/modules/inventory/schema.py @@ -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' diff --git a/modules/inventory/test/test_api.py b/modules/inventory/test/test_api.py index db4ea120..54026e9c 100644 --- a/modules/inventory/test/test_api.py +++ b/modules/inventory/test/test_api.py @@ -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 diff --git a/modules/inventory/test/test_catalog.py b/modules/inventory/test/test_catalog.py new file mode 100644 index 00000000..2087c59e --- /dev/null +++ b/modules/inventory/test/test_catalog.py @@ -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 / route. + request, response = await async_client.get('/api/inventory/catalog') + assert len(response.json['catalog']) == 1 diff --git a/modules/inventory/test/test_inventories_config.py b/modules/inventory/test/test_inventories_config.py deleted file mode 100644 index edaab00b..00000000 --- a/modules/inventory/test/test_inventories_config.py +++ /dev/null @@ -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' diff --git a/modules/inventory/test/test_inventory.py b/modules/inventory/test/test_inventory.py index c0efbafa..e4c17510 100644 --- a/modules/inventory/test/test_inventory.py +++ b/modules/inventory/test/test_inventory.py @@ -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')] diff --git a/modules/inventory/test/test_migration.py b/modules/inventory/test/test_migration.py new file mode 100644 index 00000000..f9ef0c3f --- /dev/null +++ b/modules/inventory/test/test_migration.py @@ -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) diff --git a/modules/inventory/test/test_multifile_config.py b/modules/inventory/test/test_multifile_config.py new file mode 100644 index 00000000..715096db --- /dev/null +++ b/modules/inventory/test/test_multifile_config.py @@ -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 diff --git a/requirements.txt b/requirements.txt index a01c0895..55f0326d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/wrolpi/common.py b/wrolpi/common.py index 28a781eb..3be6e6f4 100644 --- a/wrolpi/common.py +++ b/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/.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_`` 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 diff --git a/wrolpi/contexts.py b/wrolpi/contexts.py index 14659a14..e21dfeab 100644 --- a/wrolpi/contexts.py +++ b/wrolpi/contexts.py @@ -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: diff --git a/wrolpi/test/test_common.py b/wrolpi/test/test_common.py index b4705608..6530eb64 100644 --- a/wrolpi/test/test_common.py +++ b/wrolpi/test/test_common.py @@ -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): diff --git a/wrolpi/test/test_config_backup_import.py b/wrolpi/test/test_config_backup_import.py index 8f2cbfed..8352155c 100644 --- a/wrolpi/test/test_config_backup_import.py +++ b/wrolpi/test/test_config_backup_import.py @@ -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: