mirror of
https://github.com/thekiwismarthome/shopping-list-manager.git
synced 2026-06-30 21:46:30 +00:00
Compare commits
33 Commits
v2.0.4
...
1f86b6f485
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f86b6f485 | |||
| 2d02a68fe3 | |||
| c971b89779 | |||
| 42746c86b8 | |||
| 6a344914a2 | |||
| 8d9dd5bf6a | |||
| c48c96e133 | |||
| 1dea960449 | |||
| fd5b17ced8 | |||
| 0468893919 | |||
| 55d2f2d31d | |||
| d50bd39210 | |||
| 8652996b65 | |||
| d5c43fe3b5 | |||
| 380bba0408 | |||
| cf3ab90d74 | |||
| 4b7043d075 | |||
| e7306275e4 | |||
| 9430811cda | |||
| 2b11632253 | |||
| ae7717a8eb | |||
| 433c03035b | |||
| 8eb403ed8e | |||
| 03fb9a9a67 | |||
| ae133ae59b | |||
| 2a8b12a07e | |||
| a36933c4c6 | |||
| 402881c687 | |||
| d412764cba | |||
| 752f9e5622 | |||
| 86896ba4af | |||
| 36a8939ebc | |||
| 57b6d52ddf |
@@ -1,33 +1,98 @@
|
||||
## 1. Installation (HACS)
|
||||
# Shopping List Manager Integration for Home Assistant
|
||||
|
||||
### Recommended
|
||||
The backend integration that powers the Shopping List Manager. Provides persistent multi-list storage, a 500+ product catalog, real-time WebSocket events, and a full API for the Lovelace card — all running natively inside Home Assistant.
|
||||
|
||||
> **Pair with the [Shopping List Manager Card](https://github.com/thekiwismarthome/shopping-list-manager-card)** for the full UI experience.
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=thekiwismarthome&repository=shopping-list-manager&category=integration)
|
||||
|
||||
1. Click the button above.
|
||||
2. Confirm adding the repository to HACS.
|
||||
3. Install **Shopping List Manager** from **HACS → Integrations**.
|
||||
4. Restart Home Assistant.
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### 🛒 Multi-List Management
|
||||
- Create and manage multiple shopping lists
|
||||
- Private or shared lists with per-member access control
|
||||
- Active list state shared across all connected devices and users
|
||||
- List total price calculation
|
||||
|
||||
### 📦 Items
|
||||
- Add, update, check, and delete items with quantity and unit
|
||||
- Atomic quantity increment / decrement
|
||||
- Bulk check and clear checked items
|
||||
- Per-item pricing, notes, and category assignment
|
||||
|
||||
### 🔍 Product Catalog
|
||||
- **500+ products** (NZ-focused, extensible to AU, US, GB, CA)
|
||||
- Fuzzy search with alias matching
|
||||
- Recently-used product suggestions
|
||||
- Custom product creation
|
||||
- Allergen filtering and product substitute groups
|
||||
- Product images (WebP, 200×200px, optimised)
|
||||
|
||||
### 🗂️ Categories
|
||||
- 13 default categories — Produce, Dairy, Meat, Bakery, Pantry, Frozen, Beverages, Snacks, Household, Health, Pet, Baby, Other
|
||||
- Category colour coding and emoji icons
|
||||
- Per-list category ordering
|
||||
|
||||
### 💳 Loyalty Cards
|
||||
- Store loyalty and rewards card data
|
||||
- Private or shared card access per user
|
||||
|
||||
### 🔄 Real-Time Events
|
||||
- All changes fire events on the Home Assistant bus
|
||||
- Custom WebSocket subscription proxy so **non-admin users** receive live updates without requiring HA admin privileges
|
||||
|
||||
---
|
||||
|
||||
### Manual Repository URL
|
||||
## Requirements
|
||||
|
||||
https://github.com/thekiwismarthome/shopping-list-manager
|
||||
|
||||
Repository type: **Integration**
|
||||
| Component | Minimum Version |
|
||||
|---|---|
|
||||
| Home Assistant | 2024.1 |
|
||||
| HACS | 2.x |
|
||||
|
||||
---
|
||||
|
||||
## 2. Manual Installation (Optional)
|
||||
## Installation
|
||||
|
||||
1. Copy the folder:
|
||||
custom_components/shopping_list_manager
|
||||
### Via HACS (Recommended)
|
||||
|
||||
2. Paste it into:
|
||||
/config/custom_components/
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=thekiwismarthome&repository=shopping-list-manager&category=integration)
|
||||
|
||||
3. Restart Home Assistant.
|
||||
1. Click the button above
|
||||
2. Confirm adding the repository to HACS
|
||||
3. Install **Shopping List Manager** from **HACS → Integrations**
|
||||
4. Restart Home Assistant
|
||||
5. Go to **Settings → Devices & Services → Add Integration** and search for **Shopping List Manager**
|
||||
|
||||
### Manual Installation
|
||||
|
||||
1. Copy the `custom_components/shopping_list_manager/` folder into your HA `/config/custom_components/` directory
|
||||
2. Restart Home Assistant
|
||||
3. Go to **Settings → Devices & Services → Add Integration** and search for **Shopping List Manager**
|
||||
|
||||
---
|
||||
|
||||
## Lovelace Card
|
||||
|
||||
Install the companion card to get the full shopping UI:
|
||||
|
||||
## 3. Shopping List Card to go with this Integration
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=thekiwismarthome&repository=shopping-list-manager-card&category=plugin)
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation is available in the [Wiki](https://github.com/thekiwismarthome/shopping-list-manager/wiki).
|
||||
|
||||
## Support & Feedback
|
||||
|
||||
- [Open an Issue](https://github.com/thekiwismarthome/shopping-list-manager/issues)
|
||||
- [Home Assistant Community Forum](https://community.home-assistant.io)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE) for details.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Shopping List Manager integration for Home Assistant."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from pathlib import Path
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -17,6 +17,14 @@ _LOGGER = logging.getLogger(__name__)
|
||||
DATA_STORAGE = f"{DOMAIN}_storage"
|
||||
|
||||
|
||||
def _load_manifest_version(component_path: str) -> str:
|
||||
"""Load the integration version from manifest.json."""
|
||||
with open(os.path.join(component_path, "manifest.json"), encoding="utf-8") as manifest_file:
|
||||
manifest = json.load(manifest_file)
|
||||
|
||||
return manifest.get("version", "unknown")
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Shopping List Manager component from yaml (not used)."""
|
||||
# This integration doesn't support YAML configuration
|
||||
@@ -27,27 +35,31 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Shopping List Manager from a config entry."""
|
||||
_LOGGER.info("Setting up Shopping List Manager")
|
||||
|
||||
|
||||
# Get component path for loading data files
|
||||
component_path = os.path.dirname(__file__)
|
||||
config_path = hass.config.path()
|
||||
|
||||
|
||||
# Get country from options (or fall back to data, or default to NZ)
|
||||
country = entry.options.get("country") or entry.data.get("country", "NZ")
|
||||
_LOGGER.info("Using country: %s", country)
|
||||
|
||||
|
||||
# Initialize storage with country
|
||||
storage = ShoppingListStorage(hass, component_path, country)
|
||||
await storage.async_load()
|
||||
|
||||
# Initialize image handler
|
||||
image_handler = ImageHandler(hass, config_path)
|
||||
|
||||
|
||||
# Read installed version from manifest
|
||||
version = await hass.async_add_executor_job(_load_manifest_version, component_path)
|
||||
|
||||
# Store instances in hass.data
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][DATA_STORAGE] = storage
|
||||
hass.data[DOMAIN]["image_handler"] = image_handler
|
||||
hass.data[DOMAIN]["country"] = country
|
||||
hass.data[DOMAIN]["version"] = version
|
||||
|
||||
# Register update listener for options changes
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
@@ -171,6 +183,14 @@ async def _async_register_websocket_handlers(
|
||||
)
|
||||
|
||||
# Products handlers
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
handlers.websocket_download_product_image,
|
||||
)
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
handlers.websocket_search_by_barcode,
|
||||
)
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
handlers.websocket_search_products,
|
||||
@@ -192,17 +212,81 @@ async def _async_register_websocket_handlers(
|
||||
hass,
|
||||
handlers.websocket_update_product,
|
||||
)
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
handlers.websocket_delete_product,
|
||||
)
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
handlers.websocket_get_product_substitutes,
|
||||
)
|
||||
|
||||
# OpenFoodFacts proxy
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
handlers.websocket_off_fetch,
|
||||
)
|
||||
|
||||
# Categories handlers
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
handlers.websocket_get_categories,
|
||||
)
|
||||
|
||||
|
||||
# Integration settings handlers
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
handlers.websocket_get_integration_settings,
|
||||
)
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
handlers.websocket_set_country,
|
||||
)
|
||||
|
||||
# Backup / Restore handlers
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
handlers.websocket_export_data,
|
||||
)
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
handlers.websocket_import_data,
|
||||
)
|
||||
|
||||
# List members handler
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
handlers.websocket_update_list_members,
|
||||
)
|
||||
|
||||
# HA users handler
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
handlers.websocket_get_ha_users,
|
||||
)
|
||||
|
||||
# Loyalty card handlers
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
handlers.websocket_get_loyalty_cards,
|
||||
)
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
handlers.websocket_add_loyalty_card,
|
||||
)
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
handlers.websocket_update_loyalty_card,
|
||||
)
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
handlers.websocket_delete_loyalty_card,
|
||||
)
|
||||
websocket_api.async_register_command(
|
||||
hass,
|
||||
handlers.websocket_update_loyalty_card_members,
|
||||
)
|
||||
|
||||
_LOGGER.debug("WebSocket handlers registered")
|
||||
|
||||
|
||||
@@ -218,4 +302,4 @@ def get_storage(hass: HomeAssistant) -> ShoppingListStorage:
|
||||
|
||||
Helper function for WebSocket handlers to access storage.
|
||||
"""
|
||||
return hass.data[DOMAIN][DATA_STORAGE]
|
||||
return hass.data[DOMAIN][DATA_STORAGE]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -50,7 +50,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
|
||||
def __init__(self, config_entry):
|
||||
"""Initialize options flow."""
|
||||
self.config_entry = config_entry
|
||||
self._config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Manage the options."""
|
||||
@@ -59,9 +59,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
# Get current settings
|
||||
current_country = self.config_entry.options.get(
|
||||
current_country = self._config_entry.options.get(
|
||||
"country",
|
||||
self.config_entry.data.get("country", "NZ")
|
||||
self._config_entry.data.get("country", "NZ")
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -73,18 +73,19 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"US": "United States",
|
||||
"GB": "United Kingdom",
|
||||
"CA": "Canada",
|
||||
"BE": "Belgium (Dutch)",
|
||||
}),
|
||||
vol.Optional(
|
||||
"enable_price_tracking",
|
||||
default=self.config_entry.options.get("enable_price_tracking", True)
|
||||
default=self._config_entry.options.get("enable_price_tracking", True)
|
||||
): bool,
|
||||
vol.Optional(
|
||||
"enable_image_search",
|
||||
default=self.config_entry.options.get("enable_image_search", True)
|
||||
default=self._config_entry.options.get("enable_image_search", True)
|
||||
): bool,
|
||||
vol.Optional(
|
||||
"metric_units_only",
|
||||
default=self.config_entry.options.get("metric_units_only", True)
|
||||
default=self._config_entry.options.get("metric_units_only", True)
|
||||
): bool,
|
||||
}),
|
||||
description_placeholders={
|
||||
|
||||
@@ -9,6 +9,7 @@ STORAGE_KEY_LISTS = f"{DOMAIN}.lists"
|
||||
STORAGE_KEY_ITEMS = f"{DOMAIN}.items"
|
||||
STORAGE_KEY_PRODUCTS = f"{DOMAIN}.products"
|
||||
STORAGE_KEY_CATEGORIES = f"{DOMAIN}.categories"
|
||||
STORAGE_KEY_LOYALTY_CARDS = f"{DOMAIN}.loyalty_cards"
|
||||
|
||||
# WebSocket Commands - Lists
|
||||
WS_TYPE_LISTS_GET_ALL = f"{DOMAIN}/lists/get_all"
|
||||
@@ -16,6 +17,10 @@ WS_TYPE_LISTS_CREATE = f"{DOMAIN}/lists/create"
|
||||
WS_TYPE_LISTS_UPDATE = f"{DOMAIN}/lists/update"
|
||||
WS_TYPE_LISTS_DELETE = f"{DOMAIN}/lists/delete"
|
||||
WS_TYPE_LISTS_SET_ACTIVE = f"{DOMAIN}/lists/set_active"
|
||||
WS_TYPE_LISTS_UPDATE_MEMBERS = f"{DOMAIN}/lists/update_members"
|
||||
|
||||
# WebSocket Commands - Users
|
||||
WS_TYPE_USERS_GET_ALL = f"{DOMAIN}/users/get_all"
|
||||
|
||||
# WebSocket Commands - Items
|
||||
WS_TYPE_ITEMS_GET = f"{DOMAIN}/items/get"
|
||||
@@ -39,6 +44,13 @@ WS_TYPE_PRODUCTS_DELETE = f"{DOMAIN}/products/delete"
|
||||
WS_TYPE_CATEGORIES_GET_ALL = f"{DOMAIN}/categories/get_all"
|
||||
WS_TYPE_CATEGORIES_REORDER = f"{DOMAIN}/categories/reorder"
|
||||
|
||||
# WebSocket Commands - Loyalty Cards
|
||||
WS_TYPE_LOYALTY_GET_ALL = f"{DOMAIN}/loyalty/get_all"
|
||||
WS_TYPE_LOYALTY_ADD = f"{DOMAIN}/loyalty/add"
|
||||
WS_TYPE_LOYALTY_UPDATE = f"{DOMAIN}/loyalty/update"
|
||||
WS_TYPE_LOYALTY_DELETE = f"{DOMAIN}/loyalty/delete"
|
||||
WS_TYPE_LOYALTY_UPDATE_MEMBERS = f"{DOMAIN}/loyalty/update_members"
|
||||
|
||||
# WebSocket Commands - Subscriptions
|
||||
WS_TYPE_SUBSCRIBE = f"{DOMAIN}/subscribe"
|
||||
WS_TYPE_UNSUBSCRIBE = f"{DOMAIN}/unsubscribe"
|
||||
@@ -64,7 +76,10 @@ IMAGE_FORMAT = "webp"
|
||||
IMAGE_SIZE = 200 # 200x200px
|
||||
IMAGE_QUALITY = 85
|
||||
IMAGE_MAX_SIZE_KB = 15
|
||||
IMAGES_LOCAL_DIR = "www/shopping_list_manager/images"
|
||||
IMAGES_LOCAL_DIR = "www/images/shopping_list_manager"
|
||||
LEGACY_IMAGES_LOCAL_DIR = "www/shopping_list_manager/images"
|
||||
LOCAL_IMAGE_URL_PREFIX = "/local/images/shopping_list_manager/"
|
||||
LEGACY_IMAGE_URL_PREFIX = "/local/shopping_list_manager/images/"
|
||||
|
||||
# Placeholder image (inline SVG)
|
||||
PLACEHOLDER_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Crect width='200' height='200' fill='%23f0f0f0'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='Arial' font-size='16' fill='%23999'%3ENo Image%3C/text%3E%3C/svg%3E"
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"region": "BE",
|
||||
"language": "nl-BE",
|
||||
"categories": [
|
||||
{
|
||||
"id": "produce",
|
||||
"name": "Groenten & Fruit",
|
||||
"icon": "mdi:fruit-cherries",
|
||||
"color": "#4CAF50",
|
||||
"sort_order": 1,
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "dairy",
|
||||
"name": "Zuivel & Eieren",
|
||||
"icon": "mdi:cheese",
|
||||
"color": "#FFC107",
|
||||
"sort_order": 2,
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "meat",
|
||||
"name": "Vlees, Vis & Gevogelte",
|
||||
"icon": "mdi:food-steak",
|
||||
"color": "#F44336",
|
||||
"sort_order": 3,
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "bakery",
|
||||
"name": "Bakkerij",
|
||||
"icon": "mdi:bread-slice",
|
||||
"color": "#FF9800",
|
||||
"sort_order": 4,
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "frozen",
|
||||
"name": "Diepvries",
|
||||
"icon": "mdi:snowflake",
|
||||
"color": "#2196F3",
|
||||
"sort_order": 5,
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "pantry",
|
||||
"name": "Voorraadkast",
|
||||
"icon": "mdi:package-variant",
|
||||
"color": "#795548",
|
||||
"sort_order": 6,
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "beverages",
|
||||
"name": "Dranken",
|
||||
"icon": "mdi:cup",
|
||||
"color": "#00BCD4",
|
||||
"sort_order": 7,
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "snacks",
|
||||
"name": "Snacks & Koekjes",
|
||||
"icon": "mdi:food-apple",
|
||||
"color": "#E91E63",
|
||||
"sort_order": 8,
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "household",
|
||||
"name": "Huishouden",
|
||||
"icon": "mdi:spray-bottle",
|
||||
"color": "#9C27B0",
|
||||
"sort_order": 9,
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "health",
|
||||
"name": "Verzorging",
|
||||
"icon": "mdi:heart-pulse",
|
||||
"color": "#009688",
|
||||
"sort_order": 10,
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "pet",
|
||||
"name": "Dieren",
|
||||
"icon": "mdi:paw",
|
||||
"color": "#FF5722",
|
||||
"sort_order": 11,
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "baby",
|
||||
"name": "Baby",
|
||||
"icon": "mdi:baby-face",
|
||||
"color": "#F48FB1",
|
||||
"sort_order": 12,
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "other",
|
||||
"name": "Overig",
|
||||
"icon": "mdi:dots-horizontal",
|
||||
"color": "#9E9E9E",
|
||||
"sort_order": 99,
|
||||
"system": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,941 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"region": "BE",
|
||||
"language": "nl-BE",
|
||||
"currency": "EUR",
|
||||
"last_updated": "2026-06-14",
|
||||
"description": "Belgium Dutch grocery catalog with common Belgian supermarket products and local brands",
|
||||
"products": [
|
||||
{
|
||||
"id": "prod_be_halfvolle_melk",
|
||||
"name": "Halfvolle melk",
|
||||
"category_id": "dairy",
|
||||
"aliases": ["melk", "halfvolle melk", "halfvol"],
|
||||
"default_unit": "L",
|
||||
"default_quantity": 1,
|
||||
"price": 1.15,
|
||||
"brands": ["Boni", "Delhaize", "Carrefour"],
|
||||
"image_hint": "halfvolle_melk",
|
||||
"tags": ["basis"],
|
||||
"allergens": ["milk"],
|
||||
"substitution_group": "milk_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_volle_melk",
|
||||
"name": "Volle melk",
|
||||
"category_id": "dairy",
|
||||
"aliases": ["melk", "volle melk", "vollemelk"],
|
||||
"default_unit": "L",
|
||||
"default_quantity": 1,
|
||||
"price": 1.25,
|
||||
"brands": ["Boni", "Delhaize", "Carrefour"],
|
||||
"image_hint": "volle_melk",
|
||||
"tags": ["basis"],
|
||||
"allergens": ["milk"],
|
||||
"substitution_group": "milk_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_havermelk",
|
||||
"name": "Havermelk",
|
||||
"category_id": "dairy",
|
||||
"aliases": ["haverdrink", "havermelk", "plantaardige melk"],
|
||||
"default_unit": "L",
|
||||
"default_quantity": 1,
|
||||
"price": 2.29,
|
||||
"brands": ["Alpro", "Boni", "Oatly"],
|
||||
"image_hint": "havermelk",
|
||||
"tags": ["plantaardig"],
|
||||
"allergens": ["gluten"],
|
||||
"substitution_group": "milk_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_boter",
|
||||
"name": "Boter",
|
||||
"category_id": "dairy",
|
||||
"aliases": ["boter", "hoeveboter", "roomboter"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 250,
|
||||
"price": 2.85,
|
||||
"brands": ["Boni", "Balade", "Carlsbourg"],
|
||||
"image_hint": "boter",
|
||||
"tags": ["basis"],
|
||||
"allergens": ["milk"],
|
||||
"substitution_group": "butter_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_eieren",
|
||||
"name": "Eieren",
|
||||
"category_id": "dairy",
|
||||
"aliases": ["eieren", "eitjes", "vrije uitloop eieren"],
|
||||
"default_unit": "stuks",
|
||||
"default_quantity": 10,
|
||||
"price": 3.25,
|
||||
"brands": ["Boni", "Delhaize", "Carrefour"],
|
||||
"image_hint": "eieren",
|
||||
"tags": ["basis"],
|
||||
"allergens": ["egg"],
|
||||
"substitution_group": "eggs_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_jonge_kaas",
|
||||
"name": "Jonge kaas",
|
||||
"category_id": "dairy",
|
||||
"aliases": ["kaas", "jonge kaas", "sneetjes kaas"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 300,
|
||||
"price": 3.79,
|
||||
"brands": ["Boni", "Maredsous", "Passendale"],
|
||||
"image_hint": "jonge_kaas",
|
||||
"tags": ["broodbeleg"],
|
||||
"allergens": ["milk"],
|
||||
"substitution_group": "cheese_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_yoghurt_natuur",
|
||||
"name": "Yoghurt natuur",
|
||||
"category_id": "dairy",
|
||||
"aliases": ["yoghurt", "natuuryoghurt", "yoghurt natuur"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 500,
|
||||
"price": 1.89,
|
||||
"brands": ["Boni", "Danone", "Delhaize"],
|
||||
"image_hint": "yoghurt_natuur",
|
||||
"tags": [],
|
||||
"allergens": ["milk"],
|
||||
"substitution_group": "yoghurt_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_griekse_yoghurt",
|
||||
"name": "Griekse yoghurt",
|
||||
"category_id": "dairy",
|
||||
"aliases": ["griekse yoghurt", "yoghurt grieks"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 500,
|
||||
"price": 2.65,
|
||||
"brands": ["Boni", "Oikos", "Delhaize"],
|
||||
"image_hint": "griekse_yoghurt",
|
||||
"tags": [],
|
||||
"allergens": ["milk"],
|
||||
"substitution_group": "yoghurt_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_appels",
|
||||
"name": "Appels",
|
||||
"category_id": "produce",
|
||||
"aliases": ["appels", "jonagold", "elstar"],
|
||||
"default_unit": "kg",
|
||||
"default_quantity": 1,
|
||||
"price": 2.49,
|
||||
"brands": ["BelOrta", "Boni"],
|
||||
"image_hint": "appels",
|
||||
"tags": ["fruit"],
|
||||
"allergens": [],
|
||||
"substitution_group": "apple_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_bananen",
|
||||
"name": "Bananen",
|
||||
"category_id": "produce",
|
||||
"aliases": ["bananen", "banaan"],
|
||||
"default_unit": "kg",
|
||||
"default_quantity": 1,
|
||||
"price": 1.89,
|
||||
"brands": ["Chiquita", "Boni", "Delhaize"],
|
||||
"image_hint": "bananen",
|
||||
"tags": ["fruit"],
|
||||
"allergens": [],
|
||||
"substitution_group": "banana_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_peren",
|
||||
"name": "Peren",
|
||||
"category_id": "produce",
|
||||
"aliases": ["peren", "conference peer", "conference peren"],
|
||||
"default_unit": "kg",
|
||||
"default_quantity": 1,
|
||||
"price": 2.59,
|
||||
"brands": ["BelOrta", "Boni"],
|
||||
"image_hint": "peren",
|
||||
"tags": ["fruit"],
|
||||
"allergens": [],
|
||||
"substitution_group": "pear_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_aardbeien",
|
||||
"name": "Aardbeien",
|
||||
"category_id": "produce",
|
||||
"aliases": ["aardbeien", "belgische aardbeien"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 500,
|
||||
"price": 4.49,
|
||||
"brands": ["BelOrta", "Hoogstraten"],
|
||||
"image_hint": "aardbeien",
|
||||
"tags": ["fruit", "seizoen"],
|
||||
"allergens": [],
|
||||
"substitution_group": "berry_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_aardappelen",
|
||||
"name": "Aardappelen",
|
||||
"category_id": "produce",
|
||||
"aliases": ["aardappelen", "patatten", "vastkokende aardappelen"],
|
||||
"default_unit": "kg",
|
||||
"default_quantity": 2.5,
|
||||
"price": 3.25,
|
||||
"brands": ["Boni", "Delhaize", "Carrefour"],
|
||||
"image_hint": "aardappelen",
|
||||
"tags": ["groenten", "basis"],
|
||||
"allergens": [],
|
||||
"substitution_group": "potato_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_tomaten",
|
||||
"name": "Tomaten",
|
||||
"category_id": "produce",
|
||||
"aliases": ["tomaten", "trostomaten", "tomaat"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 500,
|
||||
"price": 2.49,
|
||||
"brands": ["BelOrta", "Boni", "Delhaize"],
|
||||
"image_hint": "tomaten",
|
||||
"tags": ["groenten"],
|
||||
"allergens": [],
|
||||
"substitution_group": "tomato_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_witloof",
|
||||
"name": "Witloof",
|
||||
"category_id": "produce",
|
||||
"aliases": ["witloof", "witlof", "grondwitloof"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 500,
|
||||
"price": 2.99,
|
||||
"brands": ["BelOrta", "Boni"],
|
||||
"image_hint": "witloof",
|
||||
"tags": ["groenten", "belgisch"],
|
||||
"allergens": [],
|
||||
"substitution_group": "chicory_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_preien",
|
||||
"name": "Prei",
|
||||
"category_id": "produce",
|
||||
"aliases": ["prei", "preien"],
|
||||
"default_unit": "stuks",
|
||||
"default_quantity": 3,
|
||||
"price": 1.99,
|
||||
"brands": ["Boni", "Delhaize"],
|
||||
"image_hint": "prei",
|
||||
"tags": ["groenten"],
|
||||
"allergens": [],
|
||||
"substitution_group": "leek_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_wortelen",
|
||||
"name": "Wortelen",
|
||||
"category_id": "produce",
|
||||
"aliases": ["wortelen", "peen", "wortels"],
|
||||
"default_unit": "kg",
|
||||
"default_quantity": 1,
|
||||
"price": 1.49,
|
||||
"brands": ["Boni", "Carrefour"],
|
||||
"image_hint": "wortelen",
|
||||
"tags": ["groenten"],
|
||||
"allergens": [],
|
||||
"substitution_group": "carrot_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_uien",
|
||||
"name": "Uien",
|
||||
"category_id": "produce",
|
||||
"aliases": ["uien", "ajuin", "gele uien"],
|
||||
"default_unit": "kg",
|
||||
"default_quantity": 1,
|
||||
"price": 1.39,
|
||||
"brands": ["Boni", "Delhaize"],
|
||||
"image_hint": "uien",
|
||||
"tags": ["groenten", "basis"],
|
||||
"allergens": [],
|
||||
"substitution_group": "onion_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_sla",
|
||||
"name": "Sla",
|
||||
"category_id": "produce",
|
||||
"aliases": ["sla", "kropsla", "salade"],
|
||||
"default_unit": "stuks",
|
||||
"default_quantity": 1,
|
||||
"price": 1.59,
|
||||
"brands": ["Boni", "Delhaize"],
|
||||
"image_hint": "sla",
|
||||
"tags": ["groenten"],
|
||||
"allergens": [],
|
||||
"substitution_group": "lettuce_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_brood_wit",
|
||||
"name": "Wit brood",
|
||||
"category_id": "bakery",
|
||||
"aliases": ["wit brood", "brood", "gesneden wit brood"],
|
||||
"default_unit": "stuks",
|
||||
"default_quantity": 1,
|
||||
"price": 2.39,
|
||||
"brands": ["Boni", "Delhaize", "Carrefour"],
|
||||
"image_hint": "wit_brood",
|
||||
"tags": ["basis"],
|
||||
"allergens": ["gluten"],
|
||||
"substitution_group": "bread_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_brood_grijs",
|
||||
"name": "Grijs brood",
|
||||
"category_id": "bakery",
|
||||
"aliases": ["grijs brood", "bruin brood", "tarwebrood"],
|
||||
"default_unit": "stuks",
|
||||
"default_quantity": 1,
|
||||
"price": 2.49,
|
||||
"brands": ["Boni", "Delhaize", "Carrefour"],
|
||||
"image_hint": "grijs_brood",
|
||||
"tags": ["basis"],
|
||||
"allergens": ["gluten"],
|
||||
"substitution_group": "bread_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_pistolets",
|
||||
"name": "Pistolets",
|
||||
"category_id": "bakery",
|
||||
"aliases": ["pistolets", "broodjes", "witte pistolets"],
|
||||
"default_unit": "stuks",
|
||||
"default_quantity": 6,
|
||||
"price": 2.19,
|
||||
"brands": ["Delhaize", "Carrefour", "Boni"],
|
||||
"image_hint": "pistolets",
|
||||
"tags": ["belgisch"],
|
||||
"allergens": ["gluten"],
|
||||
"substitution_group": "rolls_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_sandwiches",
|
||||
"name": "Sandwiches",
|
||||
"category_id": "bakery",
|
||||
"aliases": ["sandwiches", "melkbroodjes", "zachte broodjes"],
|
||||
"default_unit": "stuks",
|
||||
"default_quantity": 6,
|
||||
"price": 2.69,
|
||||
"brands": ["Boni", "Delhaize"],
|
||||
"image_hint": "sandwiches",
|
||||
"tags": ["belgisch"],
|
||||
"allergens": ["gluten", "milk", "egg"],
|
||||
"substitution_group": "rolls_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_luikse_wafels",
|
||||
"name": "Luikse wafels",
|
||||
"category_id": "bakery",
|
||||
"aliases": ["luikse wafels", "suikerwafels", "wafels"],
|
||||
"default_unit": "stuks",
|
||||
"default_quantity": 6,
|
||||
"price": 3.49,
|
||||
"brands": ["Lotus", "Boni", "Milcamps"],
|
||||
"image_hint": "luikse_wafels",
|
||||
"tags": ["belgisch"],
|
||||
"allergens": ["gluten", "milk", "egg"],
|
||||
"substitution_group": "waffle_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_kipfilet",
|
||||
"name": "Kipfilet",
|
||||
"category_id": "meat",
|
||||
"aliases": ["kipfilet", "kip", "kippenfilet"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 600,
|
||||
"price": 7.49,
|
||||
"brands": ["Boni", "Delhaize", "Carrefour"],
|
||||
"image_hint": "kipfilet",
|
||||
"tags": [],
|
||||
"allergens": [],
|
||||
"substitution_group": "chicken_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_gehakt",
|
||||
"name": "Gemengd gehakt",
|
||||
"category_id": "meat",
|
||||
"aliases": ["gehakt", "gemengd gehakt", "varkens runds gehakt"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 500,
|
||||
"price": 4.99,
|
||||
"brands": ["Boni", "Delhaize", "Carrefour"],
|
||||
"image_hint": "gehakt",
|
||||
"tags": [],
|
||||
"allergens": [],
|
||||
"substitution_group": "mince_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_spekblokjes",
|
||||
"name": "Spekblokjes",
|
||||
"category_id": "meat",
|
||||
"aliases": ["spekblokjes", "spekreepjes", "spek"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 200,
|
||||
"price": 2.99,
|
||||
"brands": ["Herta", "Boni", "Delhaize"],
|
||||
"image_hint": "spekblokjes",
|
||||
"tags": [],
|
||||
"allergens": [],
|
||||
"substitution_group": "bacon_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_hesp",
|
||||
"name": "Hesp",
|
||||
"category_id": "meat",
|
||||
"aliases": ["hesp", "ham", "gekookte hesp"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 150,
|
||||
"price": 2.89,
|
||||
"brands": ["Boni", "Aoste", "Delhaize"],
|
||||
"image_hint": "hesp",
|
||||
"tags": ["broodbeleg"],
|
||||
"allergens": [],
|
||||
"substitution_group": "ham_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_zalmfilet",
|
||||
"name": "Zalmfilet",
|
||||
"category_id": "meat",
|
||||
"aliases": ["zalm", "zalmfilet", "verse zalm"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 250,
|
||||
"price": 6.99,
|
||||
"brands": ["Delhaize", "Carrefour", "Boni"],
|
||||
"image_hint": "zalmfilet",
|
||||
"tags": ["vis"],
|
||||
"allergens": ["fish"],
|
||||
"substitution_group": "salmon_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_frietjes_dieprvries",
|
||||
"name": "Diepvriesfrieten",
|
||||
"category_id": "frozen",
|
||||
"aliases": ["diepvriesfrieten", "frieten", "frietjes", "frites"],
|
||||
"default_unit": "kg",
|
||||
"default_quantity": 1,
|
||||
"price": 2.39,
|
||||
"brands": ["Boni", "Lutosa", "McCain"],
|
||||
"image_hint": "diepvriesfrieten",
|
||||
"tags": ["belgisch"],
|
||||
"allergens": [],
|
||||
"substitution_group": "fries_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_groentenmix_dieprvries",
|
||||
"name": "Diepvriesgroenten",
|
||||
"category_id": "frozen",
|
||||
"aliases": ["diepvriesgroenten", "groentenmix", "diepvries groentemix"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 600,
|
||||
"price": 2.49,
|
||||
"brands": ["Boni", "Delhaize", "Carrefour"],
|
||||
"image_hint": "diepvriesgroenten",
|
||||
"tags": ["groenten"],
|
||||
"allergens": [],
|
||||
"substitution_group": "frozen_vegetables_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_vanille_ijs",
|
||||
"name": "Vanille-ijs",
|
||||
"category_id": "frozen",
|
||||
"aliases": ["vanille ijs", "vanille-ijs", "roomijs"],
|
||||
"default_unit": "L",
|
||||
"default_quantity": 1,
|
||||
"price": 3.49,
|
||||
"brands": ["Boni", "Ijsboerke", "Delhaize"],
|
||||
"image_hint": "vanille_ijs",
|
||||
"tags": [],
|
||||
"allergens": ["milk", "egg"],
|
||||
"substitution_group": "ice_cream_group",
|
||||
"priority_level": 2
|
||||
},
|
||||
{
|
||||
"id": "prod_be_pasta",
|
||||
"name": "Pasta",
|
||||
"category_id": "pantry",
|
||||
"aliases": ["pasta", "spaghetti", "penne"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 500,
|
||||
"price": 1.39,
|
||||
"brands": ["Boni", "Barilla", "Delhaize"],
|
||||
"image_hint": "pasta",
|
||||
"tags": ["basis"],
|
||||
"allergens": ["gluten"],
|
||||
"substitution_group": "pasta_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_rijst",
|
||||
"name": "Rijst",
|
||||
"category_id": "pantry",
|
||||
"aliases": ["rijst", "basmatirijst", "lange korrel rijst"],
|
||||
"default_unit": "kg",
|
||||
"default_quantity": 1,
|
||||
"price": 2.19,
|
||||
"brands": ["Boni", "Uncle Ben's", "Delhaize"],
|
||||
"image_hint": "rijst",
|
||||
"tags": ["basis"],
|
||||
"allergens": [],
|
||||
"substitution_group": "rice_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_bloem",
|
||||
"name": "Bloem",
|
||||
"category_id": "pantry",
|
||||
"aliases": ["bloem", "tarwebloem", "zelfrijzende bloem"],
|
||||
"default_unit": "kg",
|
||||
"default_quantity": 1,
|
||||
"price": 0.99,
|
||||
"brands": ["Boni", "Anco", "Delhaize"],
|
||||
"image_hint": "bloem",
|
||||
"tags": ["bakken", "basis"],
|
||||
"allergens": ["gluten"],
|
||||
"substitution_group": "flour_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_suiker",
|
||||
"name": "Suiker",
|
||||
"category_id": "pantry",
|
||||
"aliases": ["suiker", "kristalsuiker", "witte suiker"],
|
||||
"default_unit": "kg",
|
||||
"default_quantity": 1,
|
||||
"price": 1.29,
|
||||
"brands": ["Tienen-Tirlemont", "Boni", "Delhaize"],
|
||||
"image_hint": "suiker",
|
||||
"tags": ["bakken", "basis"],
|
||||
"allergens": [],
|
||||
"substitution_group": "sugar_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_koffie",
|
||||
"name": "Koffie",
|
||||
"category_id": "pantry",
|
||||
"aliases": ["koffie", "gemalen koffie", "koffiebonen"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 500,
|
||||
"price": 5.49,
|
||||
"brands": ["Douwe Egberts", "Rombouts", "Boni"],
|
||||
"image_hint": "koffie",
|
||||
"tags": ["ontbijt"],
|
||||
"allergens": [],
|
||||
"substitution_group": "coffee_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_choco",
|
||||
"name": "Choco",
|
||||
"category_id": "pantry",
|
||||
"aliases": ["choco", "chocopasta", "hazelnootpasta"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 400,
|
||||
"price": 2.99,
|
||||
"brands": ["Nutella", "Boni", "Kwatta"],
|
||||
"image_hint": "choco",
|
||||
"tags": ["broodbeleg"],
|
||||
"allergens": ["milk", "nuts"],
|
||||
"substitution_group": "spread_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_confituur",
|
||||
"name": "Confituur",
|
||||
"category_id": "pantry",
|
||||
"aliases": ["confituur", "jam", "aardbeienconfituur"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 450,
|
||||
"price": 2.39,
|
||||
"brands": ["Materne", "Boni", "Delhaize"],
|
||||
"image_hint": "confituur",
|
||||
"tags": ["broodbeleg"],
|
||||
"allergens": [],
|
||||
"substitution_group": "jam_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_mayonaise",
|
||||
"name": "Mayonaise",
|
||||
"category_id": "pantry",
|
||||
"aliases": ["mayonaise", "mayo", "fritessaus"],
|
||||
"default_unit": "ml",
|
||||
"default_quantity": 500,
|
||||
"price": 2.89,
|
||||
"brands": ["Devos Lemmens", "Boni", "Delhaize"],
|
||||
"image_hint": "mayonaise",
|
||||
"tags": ["belgisch"],
|
||||
"allergens": ["egg", "mustard"],
|
||||
"substitution_group": "mayonnaise_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_pickles",
|
||||
"name": "Pickles",
|
||||
"category_id": "pantry",
|
||||
"aliases": ["pickles", "belgische pickles", "piccalilly"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 350,
|
||||
"price": 2.49,
|
||||
"brands": ["Devos Lemmens", "Bister", "Boni"],
|
||||
"image_hint": "pickles",
|
||||
"tags": ["belgisch"],
|
||||
"allergens": ["mustard"],
|
||||
"substitution_group": "sauce_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_olijfolie",
|
||||
"name": "Olijfolie",
|
||||
"category_id": "pantry",
|
||||
"aliases": ["olijfolie", "extra vierge olijfolie"],
|
||||
"default_unit": "ml",
|
||||
"default_quantity": 750,
|
||||
"price": 6.49,
|
||||
"brands": ["Boni", "Bertolli", "Delhaize"],
|
||||
"image_hint": "olijfolie",
|
||||
"tags": ["basis"],
|
||||
"allergens": [],
|
||||
"substitution_group": "oil_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_zout",
|
||||
"name": "Zout",
|
||||
"category_id": "pantry",
|
||||
"aliases": ["zout", "keukenzout", "zeezout"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 500,
|
||||
"price": 0.79,
|
||||
"brands": ["Boni", "Jozo", "Delhaize"],
|
||||
"image_hint": "zout",
|
||||
"tags": ["basis"],
|
||||
"allergens": [],
|
||||
"substitution_group": "salt_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_peper",
|
||||
"name": "Peper",
|
||||
"category_id": "pantry",
|
||||
"aliases": ["peper", "zwarte peper", "pepermolen"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 50,
|
||||
"price": 2.29,
|
||||
"brands": ["Boni", "Ducros", "Delhaize"],
|
||||
"image_hint": "peper",
|
||||
"tags": ["basis"],
|
||||
"allergens": [],
|
||||
"substitution_group": "spice_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_spa_bruis",
|
||||
"name": "Bruiswater",
|
||||
"category_id": "beverages",
|
||||
"aliases": ["bruiswater", "spuitwater", "water bruis"],
|
||||
"default_unit": "L",
|
||||
"default_quantity": 1.5,
|
||||
"price": 0.89,
|
||||
"brands": ["Spa", "Chaudfontaine", "Boni"],
|
||||
"image_hint": "bruiswater",
|
||||
"tags": [],
|
||||
"allergens": [],
|
||||
"substitution_group": "water_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_plat_water",
|
||||
"name": "Plat water",
|
||||
"category_id": "beverages",
|
||||
"aliases": ["plat water", "mineraalwater", "water"],
|
||||
"default_unit": "L",
|
||||
"default_quantity": 1.5,
|
||||
"price": 0.75,
|
||||
"brands": ["Spa", "Chaudfontaine", "Boni"],
|
||||
"image_hint": "plat_water",
|
||||
"tags": [],
|
||||
"allergens": [],
|
||||
"substitution_group": "water_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_sinaasappelsap",
|
||||
"name": "Sinaasappelsap",
|
||||
"category_id": "beverages",
|
||||
"aliases": ["sinaasappelsap", "appelsiensap", "fruitsap"],
|
||||
"default_unit": "L",
|
||||
"default_quantity": 1,
|
||||
"price": 2.19,
|
||||
"brands": ["Minute Maid", "Boni", "Delhaize"],
|
||||
"image_hint": "sinaasappelsap",
|
||||
"tags": [],
|
||||
"allergens": [],
|
||||
"substitution_group": "juice_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_pils",
|
||||
"name": "Pils",
|
||||
"category_id": "beverages",
|
||||
"aliases": ["pils", "bier", "pintjes"],
|
||||
"default_unit": "stuks",
|
||||
"default_quantity": 6,
|
||||
"price": 5.99,
|
||||
"brands": ["Jupiler", "Stella Artois", "Maes"],
|
||||
"image_hint": "pils",
|
||||
"tags": ["belgisch", "alcohol"],
|
||||
"allergens": ["gluten"],
|
||||
"substitution_group": "beer_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_coladrank",
|
||||
"name": "Cola",
|
||||
"category_id": "beverages",
|
||||
"aliases": ["cola", "coca cola", "frisdrank"],
|
||||
"default_unit": "L",
|
||||
"default_quantity": 1.5,
|
||||
"price": 2.19,
|
||||
"brands": ["Coca-Cola", "Pepsi", "Boni"],
|
||||
"image_hint": "cola",
|
||||
"tags": [],
|
||||
"allergens": [],
|
||||
"substitution_group": "soft_drink_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_speculoos",
|
||||
"name": "Speculoos",
|
||||
"category_id": "snacks",
|
||||
"aliases": ["speculoos", "speculaas", "lotus koekjes"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 250,
|
||||
"price": 2.29,
|
||||
"brands": ["Lotus", "Boni", "Delhaize"],
|
||||
"image_hint": "speculoos",
|
||||
"tags": ["belgisch"],
|
||||
"allergens": ["gluten"],
|
||||
"substitution_group": "biscuit_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_chocolade",
|
||||
"name": "Chocolade",
|
||||
"category_id": "snacks",
|
||||
"aliases": ["chocolade", "tablet chocolade", "melkchocolade"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 200,
|
||||
"price": 2.49,
|
||||
"brands": ["Cote d'Or", "Jacques", "Boni"],
|
||||
"image_hint": "chocolade",
|
||||
"tags": ["belgisch"],
|
||||
"allergens": ["milk", "soy", "nuts"],
|
||||
"substitution_group": "chocolate_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_chips_paprika",
|
||||
"name": "Paprikachips",
|
||||
"category_id": "snacks",
|
||||
"aliases": ["paprikachips", "chips paprika", "chips"],
|
||||
"default_unit": "g",
|
||||
"default_quantity": 200,
|
||||
"price": 1.79,
|
||||
"brands": ["Lay's", "Boni", "Croky"],
|
||||
"image_hint": "paprikachips",
|
||||
"tags": [],
|
||||
"allergens": [],
|
||||
"substitution_group": "chips_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_wasmiddel",
|
||||
"name": "Wasmiddel",
|
||||
"category_id": "household",
|
||||
"aliases": ["wasmiddel", "vloeibaar wasmiddel", "waspoeder"],
|
||||
"default_unit": "L",
|
||||
"default_quantity": 1.5,
|
||||
"price": 8.49,
|
||||
"brands": ["Dash", "Ariel", "Boni"],
|
||||
"image_hint": "wasmiddel",
|
||||
"tags": [],
|
||||
"allergens": [],
|
||||
"substitution_group": "laundry_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_afwasmiddel",
|
||||
"name": "Afwasmiddel",
|
||||
"category_id": "household",
|
||||
"aliases": ["afwasmiddel", "dreft", "vaatwasmiddel handwas"],
|
||||
"default_unit": "ml",
|
||||
"default_quantity": 500,
|
||||
"price": 1.99,
|
||||
"brands": ["Dreft", "Boni", "Delhaize"],
|
||||
"image_hint": "afwasmiddel",
|
||||
"tags": [],
|
||||
"allergens": [],
|
||||
"substitution_group": "dish_soap_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_wc_papier",
|
||||
"name": "Toiletpapier",
|
||||
"category_id": "household",
|
||||
"aliases": ["toiletpapier", "wc papier", "wc-papier"],
|
||||
"default_unit": "rollen",
|
||||
"default_quantity": 12,
|
||||
"price": 4.99,
|
||||
"brands": ["Page", "Boni", "Delhaize"],
|
||||
"image_hint": "toiletpapier",
|
||||
"tags": ["basis"],
|
||||
"allergens": [],
|
||||
"substitution_group": "toilet_paper_group",
|
||||
"priority_level": 5
|
||||
},
|
||||
{
|
||||
"id": "prod_be_keukenpapier",
|
||||
"name": "Keukenpapier",
|
||||
"category_id": "household",
|
||||
"aliases": ["keukenpapier", "keukenrol", "huishoudpapier"],
|
||||
"default_unit": "rollen",
|
||||
"default_quantity": 2,
|
||||
"price": 2.29,
|
||||
"brands": ["Boni", "Page", "Delhaize"],
|
||||
"image_hint": "keukenpapier",
|
||||
"tags": [],
|
||||
"allergens": [],
|
||||
"substitution_group": "paper_towel_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_tandpasta",
|
||||
"name": "Tandpasta",
|
||||
"category_id": "health",
|
||||
"aliases": ["tandpasta", "fluoride tandpasta"],
|
||||
"default_unit": "ml",
|
||||
"default_quantity": 75,
|
||||
"price": 2.49,
|
||||
"brands": ["Colgate", "Signal", "Oral-B"],
|
||||
"image_hint": "tandpasta",
|
||||
"tags": [],
|
||||
"allergens": [],
|
||||
"substitution_group": "toothpaste_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_shampoo",
|
||||
"name": "Shampoo",
|
||||
"category_id": "health",
|
||||
"aliases": ["shampoo", "haarshampoo"],
|
||||
"default_unit": "ml",
|
||||
"default_quantity": 300,
|
||||
"price": 3.99,
|
||||
"brands": ["Nivea", "Dove", "Garnier"],
|
||||
"image_hint": "shampoo",
|
||||
"tags": [],
|
||||
"allergens": [],
|
||||
"substitution_group": "shampoo_group",
|
||||
"priority_level": 2
|
||||
},
|
||||
{
|
||||
"id": "prod_be_luiers",
|
||||
"name": "Luiers",
|
||||
"category_id": "baby",
|
||||
"aliases": ["luiers", "pamper", "pampers"],
|
||||
"default_unit": "stuks",
|
||||
"default_quantity": 36,
|
||||
"price": 9.99,
|
||||
"brands": ["Pampers", "Boni", "Delhaize"],
|
||||
"image_hint": "luiers",
|
||||
"tags": [],
|
||||
"allergens": [],
|
||||
"substitution_group": "diaper_group",
|
||||
"priority_level": 4
|
||||
},
|
||||
{
|
||||
"id": "prod_be_babydoekjes",
|
||||
"name": "Babydoekjes",
|
||||
"category_id": "baby",
|
||||
"aliases": ["babydoekjes", "vochtige doekjes", "billendoekjes"],
|
||||
"default_unit": "stuks",
|
||||
"default_quantity": 64,
|
||||
"price": 1.99,
|
||||
"brands": ["Pampers", "Boni", "Zwitsal"],
|
||||
"image_hint": "babydoekjes",
|
||||
"tags": [],
|
||||
"allergens": [],
|
||||
"substitution_group": "baby_wipes_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_kattenvoer",
|
||||
"name": "Kattenvoer",
|
||||
"category_id": "pet",
|
||||
"aliases": ["kattenvoer", "kattenbrokken", "kat voeding"],
|
||||
"default_unit": "kg",
|
||||
"default_quantity": 1.5,
|
||||
"price": 5.99,
|
||||
"brands": ["Whiskas", "Felix", "Boni"],
|
||||
"image_hint": "kattenvoer",
|
||||
"tags": [],
|
||||
"allergens": [],
|
||||
"substitution_group": "cat_food_group",
|
||||
"priority_level": 3
|
||||
},
|
||||
{
|
||||
"id": "prod_be_hondenvoer",
|
||||
"name": "Hondenvoer",
|
||||
"category_id": "pet",
|
||||
"aliases": ["hondenvoer", "hondenbrokken", "hond voeding"],
|
||||
"default_unit": "kg",
|
||||
"default_quantity": 3,
|
||||
"price": 8.49,
|
||||
"brands": ["Pedigree", "Cesar", "Boni"],
|
||||
"image_hint": "hondenvoer",
|
||||
"tags": [],
|
||||
"allergens": [],
|
||||
"substitution_group": "dog_food_group",
|
||||
"priority_level": 3
|
||||
}
|
||||
],
|
||||
"country": "Belgium"
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "shopping_list_manager",
|
||||
"name": "Shopping List Manager",
|
||||
"version": "2.0.0",
|
||||
"version": "2.2.2",
|
||||
"documentation": "https://github.com/thekiwismarthome/shopping-list-manager",
|
||||
"issue_tracker": "https://github.com/thekiwismarthome/shopping-list-manager/issues",
|
||||
"requirements": [
|
||||
|
||||
@@ -96,6 +96,28 @@ class Item:
|
||||
self.estimated_total = self.quantity * self.price
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoyaltyCard:
|
||||
"""Loyalty card model."""
|
||||
id: str
|
||||
name: str
|
||||
number: str
|
||||
barcode: str = ""
|
||||
barcode_type: str = "barcode" # "barcode" or "qrcode"
|
||||
logo: str = ""
|
||||
notes: str = ""
|
||||
color: str = "#9fa8da"
|
||||
created_at: str = field(default_factory=current_timestamp)
|
||||
updated_at: str = field(default_factory=current_timestamp)
|
||||
# Ownership: None = visible to all users; set = private to owner + allowed_users
|
||||
owner_id: Optional[str] = None
|
||||
allowed_users: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShoppingList:
|
||||
"""Shopping list model."""
|
||||
@@ -107,6 +129,9 @@ class ShoppingList:
|
||||
item_order: List[str] = field(default_factory=list)
|
||||
category_order: List[str] = field(default_factory=list)
|
||||
active: bool = False
|
||||
# Ownership: None = visible to all users; set = private to owner + allowed_users
|
||||
owner_id: Optional[str] = None
|
||||
allowed_users: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Storage management for Shopping List Manager."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
from .utils.search import ProductSearch
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -11,9 +16,14 @@ from .const import (
|
||||
STORAGE_KEY_ITEMS,
|
||||
STORAGE_KEY_PRODUCTS,
|
||||
STORAGE_KEY_CATEGORIES,
|
||||
STORAGE_KEY_LOYALTY_CARDS,
|
||||
IMAGES_LOCAL_DIR,
|
||||
LEGACY_IMAGES_LOCAL_DIR,
|
||||
LOCAL_IMAGE_URL_PREFIX,
|
||||
LEGACY_IMAGE_URL_PREFIX,
|
||||
)
|
||||
from .data.catalog_loader import load_product_catalog
|
||||
from .models import ShoppingList, Item, Product, Category, generate_id
|
||||
from .models import ShoppingList, Item, Product, Category, LoyaltyCard, generate_id
|
||||
from .data.category_loader import load_categories
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -37,12 +47,16 @@ class ShoppingListStorage:
|
||||
self._store_items = Store(hass, STORAGE_VERSION, STORAGE_KEY_ITEMS)
|
||||
self._store_products = Store(hass, STORAGE_VERSION, STORAGE_KEY_PRODUCTS)
|
||||
self._store_categories = Store(hass, STORAGE_VERSION, STORAGE_KEY_CATEGORIES)
|
||||
|
||||
self._store_loyalty_cards = Store(hass, STORAGE_VERSION, STORAGE_KEY_LOYALTY_CARDS)
|
||||
|
||||
self._lists: Dict[str, ShoppingList] = {}
|
||||
self._items: Dict[str, List[Item]] = {}
|
||||
self._products: Dict[str, Product] = {}
|
||||
self._categories: List[Category] = []
|
||||
self._loyalty_cards: Dict[str, LoyaltyCard] = {}
|
||||
self._search_engine: Optional[ProductSearch] = None
|
||||
self._images_dir = Path(hass.config.path(IMAGES_LOCAL_DIR))
|
||||
self._legacy_images_dir = Path(hass.config.path(LEGACY_IMAGES_LOCAL_DIR))
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load data from storage."""
|
||||
@@ -83,22 +97,13 @@ class ShoppingListStorage:
|
||||
for product_id, product_data in products_data.items()
|
||||
}
|
||||
_LOGGER.debug("Loaded %d products", len(self._products))
|
||||
|
||||
# Ensure image directory exists and migrate legacy image paths/URLs.
|
||||
self._images_dir.mkdir(parents=True, exist_ok=True)
|
||||
await self._migrate_legacy_images_and_urls()
|
||||
|
||||
# Load categories
|
||||
categories_data = await self._store_categories.async_load()
|
||||
if categories_data:
|
||||
self._categories = [Category(**cat_data) for cat_data in categories_data]
|
||||
_LOGGER.debug("Loaded %d categories", len(self._categories))
|
||||
else:
|
||||
# Initialize with default categories from JSON file
|
||||
default_categories = await load_categories(self._component_path, self._country) # Use self._country
|
||||
self._categories = [Category(**cat) for cat in default_categories]
|
||||
await self._save_categories()
|
||||
_LOGGER.info(
|
||||
"Initialized %d default categories for country: %s",
|
||||
len(self._categories),
|
||||
self._country # Use self._country
|
||||
)
|
||||
# Load country-specific system categories so labels follow the selected country.
|
||||
await self._load_categories_for_country(self._country)
|
||||
|
||||
# Load product catalog if products are empty
|
||||
if not self._products:
|
||||
@@ -141,6 +146,15 @@ class ShoppingListStorage:
|
||||
await self._save_products()
|
||||
_LOGGER.info("Successfully imported %d products from catalog", len(self._products))
|
||||
|
||||
# Load loyalty cards
|
||||
loyalty_data = await self._store_loyalty_cards.async_load()
|
||||
if loyalty_data:
|
||||
self._loyalty_cards = {
|
||||
card_id: LoyaltyCard(**card_data)
|
||||
for card_id, card_data in loyalty_data.items()
|
||||
}
|
||||
_LOGGER.debug("Loaded %d loyalty cards", len(self._loyalty_cards))
|
||||
|
||||
# Initialize search engine after products are loaded
|
||||
if self._products:
|
||||
products_dict = {pid: p.to_dict() for pid, p in self._products.items()}
|
||||
@@ -156,9 +170,21 @@ class ShoppingListStorage:
|
||||
data = {list_id: lst.to_dict() for list_id, lst in self._lists.items()}
|
||||
await self._store_lists.async_save(data)
|
||||
|
||||
def get_lists(self) -> List[ShoppingList]:
|
||||
"""Get all lists."""
|
||||
return list(self._lists.values())
|
||||
def get_lists(self, user_id: str = None, is_admin: bool = False) -> List[ShoppingList]:
|
||||
"""Get lists visible to the specified user.
|
||||
|
||||
Global lists (owner_id=None) are visible to everyone.
|
||||
Private lists are visible to their owner, anyone in allowed_users, and admins.
|
||||
"""
|
||||
all_lists = list(self._lists.values())
|
||||
if is_admin or user_id is None:
|
||||
return all_lists
|
||||
return [
|
||||
lst for lst in all_lists
|
||||
if lst.owner_id is None
|
||||
or lst.owner_id == user_id
|
||||
or user_id in (lst.allowed_users or [])
|
||||
]
|
||||
|
||||
def get_list(self, list_id: str) -> Optional[ShoppingList]:
|
||||
"""Get a specific list."""
|
||||
@@ -171,17 +197,19 @@ class ShoppingListStorage:
|
||||
return lst
|
||||
return None
|
||||
|
||||
async def create_list(self, name: str, icon: str = "mdi:cart") -> ShoppingList:
|
||||
"""Create a new list."""
|
||||
async def create_list(self, name: str, icon: str = "mdi:cart", owner_id: str = None) -> ShoppingList:
|
||||
"""Create a new list. Pass owner_id to make the list private to that user."""
|
||||
new_list = ShoppingList(
|
||||
id=generate_id(),
|
||||
name=name,
|
||||
icon=icon,
|
||||
category_order=[cat.id for cat in self._categories]
|
||||
category_order=[cat.id for cat in self._categories],
|
||||
owner_id=owner_id,
|
||||
)
|
||||
self._lists[new_list.id] = new_list
|
||||
self._items[new_list.id] = []
|
||||
await self._save_lists()
|
||||
await self._write_config_backup()
|
||||
_LOGGER.info("Created new list: %s", name)
|
||||
return new_list
|
||||
|
||||
@@ -202,6 +230,18 @@ class ShoppingListStorage:
|
||||
_LOGGER.debug("Updated list: %s", list_id)
|
||||
return lst
|
||||
|
||||
async def update_list_members(self, list_id: str, allowed_users: List[str]) -> Optional[ShoppingList]:
|
||||
"""Update the allowed_users for a private list."""
|
||||
if list_id not in self._lists:
|
||||
return None
|
||||
lst = self._lists[list_id]
|
||||
lst.allowed_users = allowed_users
|
||||
from .models import current_timestamp
|
||||
lst.updated_at = current_timestamp()
|
||||
await self._save_lists()
|
||||
_LOGGER.debug("Updated members for list: %s", list_id)
|
||||
return lst
|
||||
|
||||
async def delete_list(self, list_id: str) -> bool:
|
||||
"""Delete a list."""
|
||||
if list_id not in self._lists:
|
||||
@@ -378,6 +418,56 @@ class ShoppingListStorage:
|
||||
"""Save products to storage."""
|
||||
data = {product_id: product.to_dict() for product_id, product in self._products.items()}
|
||||
await self._store_products.async_save(data)
|
||||
|
||||
async def _migrate_legacy_images_and_urls(self) -> None:
|
||||
"""Move old image files and rewrite stored URLs to the new path."""
|
||||
moved_files = 0
|
||||
updated_product_urls = 0
|
||||
updated_item_urls = 0
|
||||
|
||||
if self._legacy_images_dir.exists() and self._legacy_images_dir != self._images_dir:
|
||||
for src in self._legacy_images_dir.glob("*"):
|
||||
if not src.is_file():
|
||||
continue
|
||||
dest = self._images_dir / src.name
|
||||
if dest.exists():
|
||||
continue
|
||||
try:
|
||||
shutil.move(str(src), str(dest))
|
||||
moved_files += 1
|
||||
except Exception as err:
|
||||
_LOGGER.debug("Could not move legacy image %s: %s", src, err)
|
||||
|
||||
for product in self._products.values():
|
||||
image_url = product.image_url or ""
|
||||
if image_url.startswith(LEGACY_IMAGE_URL_PREFIX):
|
||||
product.image_url = image_url.replace(
|
||||
LEGACY_IMAGE_URL_PREFIX, LOCAL_IMAGE_URL_PREFIX, 1
|
||||
)
|
||||
updated_product_urls += 1
|
||||
|
||||
for items in self._items.values():
|
||||
for item in items:
|
||||
image_url = item.image_url or ""
|
||||
if image_url.startswith(LEGACY_IMAGE_URL_PREFIX):
|
||||
item.image_url = image_url.replace(
|
||||
LEGACY_IMAGE_URL_PREFIX, LOCAL_IMAGE_URL_PREFIX, 1
|
||||
)
|
||||
updated_item_urls += 1
|
||||
|
||||
if updated_product_urls:
|
||||
await self._save_products()
|
||||
if updated_item_urls:
|
||||
await self._save_items()
|
||||
|
||||
if moved_files or updated_product_urls or updated_item_urls:
|
||||
_LOGGER.info(
|
||||
"Migrated shopping list images to %s (moved_files=%d, updated_product_urls=%d, updated_item_urls=%d)",
|
||||
IMAGES_LOCAL_DIR,
|
||||
moved_files,
|
||||
updated_product_urls,
|
||||
updated_item_urls,
|
||||
)
|
||||
|
||||
def get_products(self) -> List[Product]:
|
||||
"""Get all products."""
|
||||
@@ -460,27 +550,194 @@ class ShoppingListStorage:
|
||||
)
|
||||
self._products[new_product.id] = new_product
|
||||
await self._save_products()
|
||||
await self._write_config_backup()
|
||||
# Rebuild search engine so the new product is immediately searchable
|
||||
products_dict = {pid: p.to_dict() for pid, p in self._products.items()}
|
||||
self._search_engine = ProductSearch(products_dict)
|
||||
_LOGGER.debug("Added product: %s", new_product.name)
|
||||
return new_product
|
||||
|
||||
async def reload_catalog(self, country_code: str) -> int:
|
||||
"""Replace catalog-sourced products with those from a new country's catalog.
|
||||
Products with source='user' are preserved."""
|
||||
catalog_ids = [
|
||||
pid for pid, p in self._products.items()
|
||||
if getattr(p, 'source', 'user') == 'catalog'
|
||||
]
|
||||
for pid in catalog_ids:
|
||||
del self._products[pid]
|
||||
|
||||
self._country = country_code
|
||||
await self._load_categories_for_country(country_code)
|
||||
|
||||
catalog_products = await load_product_catalog(self._component_path, country_code)
|
||||
count = 0
|
||||
for prod_data in catalog_products:
|
||||
try:
|
||||
product = Product(
|
||||
id=prod_data.get("id", generate_id()),
|
||||
name=prod_data["name"],
|
||||
category_id=prod_data.get("category_id", "other"),
|
||||
aliases=prod_data.get("aliases", []),
|
||||
default_unit=prod_data.get("default_unit", "units"),
|
||||
default_quantity=prod_data.get("default_quantity", 1),
|
||||
price=prod_data.get("price") or prod_data.get("typical_price"),
|
||||
currency=self.hass.config.currency,
|
||||
barcode=prod_data.get("barcode"),
|
||||
brands=prod_data.get("brands", []),
|
||||
image_url=prod_data.get("image_url", ""),
|
||||
custom=False,
|
||||
source="catalog",
|
||||
tags=prod_data.get("tags", []),
|
||||
collections=prod_data.get("collections", []),
|
||||
taxonomy=prod_data.get("taxonomy", {}),
|
||||
allergens=prod_data.get("allergens", []),
|
||||
substitution_group=prod_data.get("substitution_group", ""),
|
||||
priority_level=prod_data.get("priority_level", 0),
|
||||
image_hint=prod_data.get("image_hint", "")
|
||||
)
|
||||
self._products[product.id] = product
|
||||
count += 1
|
||||
except Exception as err:
|
||||
_LOGGER.error("Failed to import product %s: %s", prod_data.get("name"), err)
|
||||
|
||||
await self._save_products()
|
||||
products_dict = {pid: p.to_dict() for pid, p in self._products.items()}
|
||||
self._search_engine = ProductSearch(products_dict)
|
||||
_LOGGER.info("Reloaded catalog for %s: %d products imported", country_code, count)
|
||||
return count
|
||||
|
||||
async def delete_product(self, product_id: str) -> bool:
|
||||
"""Delete a product from the catalog."""
|
||||
if product_id not in self._products:
|
||||
return False
|
||||
del self._products[product_id]
|
||||
await self._save_products()
|
||||
# Rebuild search engine so the product is no longer searchable
|
||||
products_dict = {pid: p.to_dict() for pid, p in self._products.items()}
|
||||
self._search_engine = ProductSearch(products_dict)
|
||||
_LOGGER.debug("Deleted product: %s", product_id)
|
||||
return True
|
||||
|
||||
async def update_product(self, product_id: str, **kwargs) -> Optional[Product]:
|
||||
"""Update a product."""
|
||||
if product_id not in self._products:
|
||||
return None
|
||||
|
||||
|
||||
product = self._products[product_id]
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(product, key):
|
||||
setattr(product, key, value)
|
||||
|
||||
|
||||
await self._save_products()
|
||||
await self._write_config_backup()
|
||||
_LOGGER.debug("Updated product: %s", product_id)
|
||||
return product
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup / Restore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def export_user_data(self) -> dict:
|
||||
"""Return a serialisable snapshot of all user-created data."""
|
||||
user_products = [
|
||||
p.to_dict() for p in self._products.values()
|
||||
if getattr(p, "source", "user") == "user"
|
||||
]
|
||||
lists = [lst.to_dict() for lst in self._lists.values()]
|
||||
items = {
|
||||
list_id: [item.to_dict() for item in items_list]
|
||||
for list_id, items_list in self._items.items()
|
||||
}
|
||||
return {
|
||||
"slm_backup_version": "1.0",
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"country": self._country,
|
||||
"user_products": user_products,
|
||||
"lists": lists,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def import_user_data(self, data: dict) -> dict:
|
||||
"""Merge a backup into live storage. Skips anything already present by ID."""
|
||||
imported_products = 0
|
||||
imported_lists = 0
|
||||
imported_items = 0
|
||||
|
||||
for prod_data in data.get("user_products", []):
|
||||
prod_id = prod_data.get("id")
|
||||
if prod_id and prod_id not in self._products:
|
||||
try:
|
||||
self._products[prod_id] = Product(**prod_data)
|
||||
imported_products += 1
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Skipped product during import: %s", err)
|
||||
|
||||
if imported_products:
|
||||
await self._save_products()
|
||||
products_dict = {pid: p.to_dict() for pid, p in self._products.items()}
|
||||
self._search_engine = ProductSearch(products_dict)
|
||||
|
||||
for list_data in data.get("lists", []):
|
||||
list_id = list_data.get("id")
|
||||
if list_id and list_id not in self._lists:
|
||||
try:
|
||||
lst = ShoppingList(**list_data)
|
||||
lst.active = False
|
||||
self._lists[list_id] = lst
|
||||
imported_lists += 1
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Skipped list during import: %s", err)
|
||||
|
||||
backup_items = data.get("items", {})
|
||||
for list_id, items_list in backup_items.items():
|
||||
if list_id in self._lists and list_id not in self._items:
|
||||
try:
|
||||
self._items[list_id] = [Item(**d) for d in items_list]
|
||||
imported_items += len(self._items[list_id])
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Skipped items for list %s: %s", list_id, err)
|
||||
|
||||
if imported_lists or imported_items:
|
||||
await self._save_lists()
|
||||
await self._save_items()
|
||||
|
||||
_LOGGER.info(
|
||||
"Import complete: %d products, %d lists, %d items",
|
||||
imported_products, imported_lists, imported_items,
|
||||
)
|
||||
return {"products": imported_products, "lists": imported_lists, "items": imported_items}
|
||||
|
||||
async def _write_config_backup(self) -> None:
|
||||
"""Silently write a backup JSON to the HA config directory."""
|
||||
try:
|
||||
backup_path = os.path.join(
|
||||
self.hass.config.config_dir,
|
||||
"shopping_list_manager_backup.json",
|
||||
)
|
||||
data = await self.export_user_data()
|
||||
|
||||
def _write() -> None:
|
||||
with open(backup_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
await self.hass.async_add_executor_job(_write)
|
||||
_LOGGER.debug("Auto-backup written to %s", backup_path)
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Failed to write config backup: %s", err)
|
||||
|
||||
# Categories methods
|
||||
async def _load_categories_for_country(self, country_code: str) -> None:
|
||||
"""Load and persist system categories for the selected country."""
|
||||
categories = await load_categories(self._component_path, country_code)
|
||||
self._categories = [Category(**cat_data) for cat_data in categories]
|
||||
await self._save_categories()
|
||||
_LOGGER.info(
|
||||
"Loaded %d categories for country: %s",
|
||||
len(self._categories),
|
||||
country_code,
|
||||
)
|
||||
|
||||
async def _save_categories(self) -> None:
|
||||
"""Save categories to storage."""
|
||||
data = [cat.to_dict() for cat in self._categories]
|
||||
@@ -489,3 +746,81 @@ class ShoppingListStorage:
|
||||
def get_categories(self) -> List[Category]:
|
||||
"""Get all categories."""
|
||||
return self._categories
|
||||
|
||||
# Loyalty card methods
|
||||
async def _save_loyalty_cards(self) -> None:
|
||||
"""Save loyalty cards to storage."""
|
||||
data = {card_id: card.to_dict() for card_id, card in self._loyalty_cards.items()}
|
||||
await self._store_loyalty_cards.async_save(data)
|
||||
|
||||
def get_loyalty_cards(self, user_id: str = None, is_admin: bool = False) -> List[LoyaltyCard]:
|
||||
"""Get loyalty cards visible to the specified user.
|
||||
|
||||
Global cards (owner_id=None) are visible to everyone.
|
||||
Private cards are visible to their owner, anyone in allowed_users, and admins.
|
||||
"""
|
||||
all_cards = list(self._loyalty_cards.values())
|
||||
if is_admin or user_id is None:
|
||||
return all_cards
|
||||
return [
|
||||
card for card in all_cards
|
||||
if card.owner_id is None
|
||||
or card.owner_id == user_id
|
||||
or user_id in (card.allowed_users or [])
|
||||
]
|
||||
|
||||
def get_loyalty_card(self, card_id: str) -> Optional[LoyaltyCard]:
|
||||
"""Get a specific loyalty card."""
|
||||
return self._loyalty_cards.get(card_id)
|
||||
|
||||
async def create_loyalty_card(self, owner_id: str = None, **kwargs) -> LoyaltyCard:
|
||||
"""Create a new loyalty card."""
|
||||
from .models import current_timestamp
|
||||
new_card = LoyaltyCard(
|
||||
id=generate_id(),
|
||||
owner_id=owner_id,
|
||||
**kwargs
|
||||
)
|
||||
self._loyalty_cards[new_card.id] = new_card
|
||||
await self._save_loyalty_cards()
|
||||
_LOGGER.debug("Created loyalty card: %s", new_card.name)
|
||||
return new_card
|
||||
|
||||
async def update_loyalty_card(self, card_id: str, **kwargs) -> Optional[LoyaltyCard]:
|
||||
"""Update a loyalty card."""
|
||||
if card_id not in self._loyalty_cards:
|
||||
return None
|
||||
|
||||
card = self._loyalty_cards[card_id]
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(card, key):
|
||||
setattr(card, key, value)
|
||||
|
||||
from .models import current_timestamp
|
||||
card.updated_at = current_timestamp()
|
||||
await self._save_loyalty_cards()
|
||||
_LOGGER.debug("Updated loyalty card: %s", card_id)
|
||||
return card
|
||||
|
||||
async def delete_loyalty_card(self, card_id: str) -> bool:
|
||||
"""Delete a loyalty card."""
|
||||
if card_id not in self._loyalty_cards:
|
||||
return False
|
||||
|
||||
del self._loyalty_cards[card_id]
|
||||
await self._save_loyalty_cards()
|
||||
_LOGGER.debug("Deleted loyalty card: %s", card_id)
|
||||
return True
|
||||
|
||||
async def update_loyalty_card_members(self, card_id: str, allowed_users: List[str]) -> Optional[LoyaltyCard]:
|
||||
"""Update the allowed_users for a private loyalty card."""
|
||||
if card_id not in self._loyalty_cards:
|
||||
return None
|
||||
|
||||
card = self._loyalty_cards[card_id]
|
||||
card.allowed_users = allowed_users
|
||||
from .models import current_timestamp
|
||||
card.updated_at = current_timestamp()
|
||||
await self._save_loyalty_cards()
|
||||
_LOGGER.debug("Updated members for loyalty card: %s", card_id)
|
||||
return card
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Shopping List Manager",
|
||||
"description": "Set up the Shopping List Manager integration. Country and other settings can be configured after setup via the Configure button."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Only a single instance of Shopping List Manager is allowed."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Shopping List Manager Options",
|
||||
"description": "Changing country will reload the product catalog on next restart.",
|
||||
"data": {
|
||||
"country": "Country",
|
||||
"enable_price_tracking": "Enable price tracking",
|
||||
"enable_image_search": "Enable image search",
|
||||
"metric_units_only": "Metric units only"
|
||||
},
|
||||
"data_description": {
|
||||
"country": "Used to localise product catalog and pricing.",
|
||||
"enable_price_tracking": "Track and display product prices.",
|
||||
"enable_image_search": "Search for product images automatically.",
|
||||
"metric_units_only": "Show only metric units (g, kg, ml, L)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Shopping List Manager",
|
||||
"description": "Stel de Shopping List Manager-integratie in. Land en andere instellingen kunnen na de setup worden geconfigureerd via de knop Configureren."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Slechts één exemplaar van Shopping List Manager is toegestaan."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Shopping List Manager Opties",
|
||||
"description": "Het wijzigen van land zal de productcatalogus bij de volgende herstart herladen.",
|
||||
"data": {
|
||||
"country": "Land",
|
||||
"enable_price_tracking": "Prijstracking inschakelen",
|
||||
"enable_image_search": "Afbeeldingen zoeken inschakelen",
|
||||
"metric_units_only": "Alleen metrische eenheden"
|
||||
},
|
||||
"data_description": {
|
||||
"country": "Wordt gebruikt om de productcatalogus en prijzen te lokaliseren.",
|
||||
"enable_price_tracking": "Productprijzen bijhouden en weergeven.",
|
||||
"enable_image_search": "Automatisch naar productafbeeldingen zoeken.",
|
||||
"metric_units_only": "Alleen metrische eenheden weergeven (g, kg, ml, L)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,9 +1,15 @@
|
||||
"""Image handling utilities for Shopping List Manager."""
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from ..const import (
|
||||
IMAGES_LOCAL_DIR,
|
||||
LEGACY_IMAGES_LOCAL_DIR,
|
||||
LOCAL_IMAGE_URL_PREFIX,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -18,11 +24,28 @@ class ImageHandler:
|
||||
config_path: Path to HA config directory
|
||||
"""
|
||||
self.hass = hass
|
||||
# Images stored in /config/www/shopping_list_manager/images/
|
||||
self._local_images_dir = Path(config_path) / "www" / "shopping_list_manager" / "images"
|
||||
# Images stored in /config/www/images/shopping_list_manager/
|
||||
self._local_images_dir = Path(hass.config.path(IMAGES_LOCAL_DIR))
|
||||
self._legacy_images_dir = Path(hass.config.path(LEGACY_IMAGES_LOCAL_DIR))
|
||||
self._local_images_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._migrate_legacy_files()
|
||||
|
||||
_LOGGER.info("Image directory: %s", self._local_images_dir)
|
||||
|
||||
def _migrate_legacy_files(self) -> None:
|
||||
"""Move legacy image files to the new standardized directory."""
|
||||
if not self._legacy_images_dir.exists() or self._legacy_images_dir == self._local_images_dir:
|
||||
return
|
||||
for src in self._legacy_images_dir.glob("*"):
|
||||
if not src.is_file():
|
||||
continue
|
||||
dest = self._local_images_dir / src.name
|
||||
if dest.exists():
|
||||
continue
|
||||
try:
|
||||
shutil.move(str(src), str(dest))
|
||||
except Exception as err:
|
||||
_LOGGER.debug("Could not move legacy image %s: %s", src, err)
|
||||
|
||||
def get_image_url(self, product_name: str, external_url: Optional[str] = None) -> str:
|
||||
"""Get image URL for a product.
|
||||
@@ -73,11 +96,11 @@ class ImageHandler:
|
||||
# Check exact match
|
||||
image_file = self._local_images_dir / f"{normalized_name}{ext}"
|
||||
if image_file.exists():
|
||||
return f"/local/shopping_list_manager/images/{normalized_name}{ext}"
|
||||
return f"{LOCAL_IMAGE_URL_PREFIX}{normalized_name}{ext}"
|
||||
|
||||
# Check for files starting with the product name
|
||||
for file in self._local_images_dir.glob(f"{normalized_name}*{ext}"):
|
||||
return f"/local/shopping_list_manager/images/{file.name}"
|
||||
return f"{LOCAL_IMAGE_URL_PREFIX}{file.name}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,19 +1,31 @@
|
||||
"""WebSocket API handlers for Shopping List Manager."""
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import voluptuous as vol
|
||||
from aiohttp import ClientTimeout
|
||||
from PIL import Image
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from ..const import DOMAIN
|
||||
|
||||
from ..const import (
|
||||
IMAGE_SIZE,
|
||||
IMAGE_QUALITY,
|
||||
IMAGES_LOCAL_DIR,
|
||||
LOCAL_IMAGE_URL_PREFIX,
|
||||
WS_TYPE_LISTS_GET_ALL,
|
||||
WS_TYPE_LISTS_CREATE,
|
||||
WS_TYPE_LISTS_UPDATE,
|
||||
WS_TYPE_LISTS_DELETE,
|
||||
WS_TYPE_LISTS_SET_ACTIVE,
|
||||
WS_TYPE_LISTS_UPDATE_MEMBERS,
|
||||
WS_TYPE_USERS_GET_ALL,
|
||||
WS_TYPE_ITEMS_GET,
|
||||
WS_TYPE_ITEMS_ADD,
|
||||
WS_TYPE_ITEMS_UPDATE,
|
||||
@@ -27,7 +39,14 @@ from ..const import (
|
||||
WS_TYPE_PRODUCTS_SUGGESTIONS,
|
||||
WS_TYPE_PRODUCTS_ADD,
|
||||
WS_TYPE_PRODUCTS_UPDATE,
|
||||
WS_TYPE_PRODUCTS_DELETE,
|
||||
WS_TYPE_OFF_FETCH,
|
||||
WS_TYPE_CATEGORIES_GET_ALL,
|
||||
WS_TYPE_LOYALTY_GET_ALL,
|
||||
WS_TYPE_LOYALTY_ADD,
|
||||
WS_TYPE_LOYALTY_UPDATE,
|
||||
WS_TYPE_LOYALTY_DELETE,
|
||||
WS_TYPE_LOYALTY_UPDATE_MEMBERS,
|
||||
WS_TYPE_SUBSCRIBE,
|
||||
EVENT_ITEM_ADDED,
|
||||
EVENT_ITEM_UPDATED,
|
||||
@@ -40,6 +59,86 @@ from .. import get_storage
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
OPENFOODFACTS_DEFAULT_BASE_URL = "https://world.openfoodfacts.org"
|
||||
OPENFOODFACTS_BASE_URL_BY_COUNTRY = {
|
||||
"BE": "https://be.openfoodfacts.org",
|
||||
}
|
||||
OPENFOODFACTS_ACCEPT_LANGUAGE_BY_COUNTRY = {
|
||||
"BE": "nl-BE,nl;q=0.9,en;q=0.7",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ACCESS-CHECK HELPERS
|
||||
# =============================================================================
|
||||
|
||||
def _get_openfoodfacts_request_config(hass: HomeAssistant) -> tuple[str, Dict[str, str]]:
|
||||
"""Return the OpenFoodFacts base URL and headers for the active catalog."""
|
||||
country = hass.data.get(DOMAIN, {}).get("country", "NZ")
|
||||
base_url = OPENFOODFACTS_BASE_URL_BY_COUNTRY.get(
|
||||
country,
|
||||
OPENFOODFACTS_DEFAULT_BASE_URL,
|
||||
)
|
||||
headers = {
|
||||
"User-Agent": "HomeAssistant/ShoppingListManager (contact@homeassistant.io)",
|
||||
}
|
||||
if accept_language := OPENFOODFACTS_ACCEPT_LANGUAGE_BY_COUNTRY.get(country):
|
||||
headers["Accept-Language"] = accept_language
|
||||
|
||||
return base_url, headers
|
||||
|
||||
|
||||
def _user_can_access_list(lst, user) -> bool:
|
||||
"""Return True if the user may read or write to this list.
|
||||
|
||||
Global lists (owner_id=None) are accessible to everyone.
|
||||
Private lists are accessible to their owner, anyone in allowed_users, and admins.
|
||||
"""
|
||||
if lst.owner_id is None:
|
||||
return True
|
||||
if user is None:
|
||||
return False
|
||||
if user.is_admin or user.id == lst.owner_id:
|
||||
return True
|
||||
return user.id in (lst.allowed_users or [])
|
||||
|
||||
|
||||
def _check_list_access(storage, connection, msg, list_id, require_owner=False):
|
||||
"""Verify the connected user may access list_id.
|
||||
|
||||
Sends the appropriate WebSocket error if access is denied.
|
||||
Returns the ShoppingList object on success, or None if an error was sent.
|
||||
|
||||
Args:
|
||||
require_owner: When True, only the list owner (or an admin) is allowed.
|
||||
Use for destructive/administrative operations.
|
||||
"""
|
||||
lst = storage.get_list(list_id)
|
||||
if lst is None:
|
||||
connection.send_error(msg["id"], "not_found", "List not found")
|
||||
return None
|
||||
|
||||
user = connection.user
|
||||
if require_owner:
|
||||
if lst.owner_id is not None and not (user and (user.is_admin or user.id == lst.owner_id)):
|
||||
connection.send_error(msg["id"], "forbidden", "Only the list owner can perform this action")
|
||||
return None
|
||||
else:
|
||||
if not _user_can_access_list(lst, user):
|
||||
connection.send_error(msg["id"], "forbidden", "You do not have access to this list")
|
||||
return None
|
||||
|
||||
return lst
|
||||
|
||||
|
||||
def _find_item_list_id(storage, item_id):
|
||||
"""Return the list_id that contains item_id, or None if not found."""
|
||||
for list_id, items in storage._items.items():
|
||||
for item in items:
|
||||
if item.id == item_id:
|
||||
return list_id
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LIST HANDLERS
|
||||
@@ -55,16 +154,28 @@ async def websocket_subscribe(
|
||||
msg: dict,
|
||||
) -> None:
|
||||
"""Subscribe to shopping list manager events via WebSocket."""
|
||||
|
||||
storage = get_storage(hass)
|
||||
|
||||
@callback
|
||||
def forward_event(event):
|
||||
"""Forward HA bus event to WebSocket connection."""
|
||||
"""Forward HA bus event to WebSocket connection.
|
||||
|
||||
Events that reference a list_id are only forwarded if the connected
|
||||
user has access to that list, preventing cross-user data leakage.
|
||||
"""
|
||||
data = event.data
|
||||
list_id = data.get("list_id")
|
||||
if list_id:
|
||||
lst = storage.get_list(list_id)
|
||||
if lst and not _user_can_access_list(lst, connection.user):
|
||||
return # skip — user cannot see this list
|
||||
|
||||
connection.send_message(
|
||||
websocket_api.event_message(
|
||||
msg["id"],
|
||||
{
|
||||
"event_type": event.event_type,
|
||||
"data": event.data,
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -171,8 +282,11 @@ def websocket_get_lists(
|
||||
) -> None:
|
||||
"""Handle get all lists command."""
|
||||
storage = get_storage(hass)
|
||||
lists = storage.get_lists()
|
||||
|
||||
user = connection.user
|
||||
user_id = user.id if user else None
|
||||
is_admin = user.is_admin if user else False
|
||||
lists = storage.get_lists(user_id=user_id, is_admin=is_admin)
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
@@ -186,6 +300,7 @@ def websocket_get_lists(
|
||||
vol.Required("type"): WS_TYPE_LISTS_CREATE,
|
||||
vol.Required("name"): str,
|
||||
vol.Optional("icon", default="mdi:cart"): str,
|
||||
vol.Optional("private", default=True): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@@ -196,10 +311,15 @@ async def websocket_create_list(
|
||||
) -> None:
|
||||
"""Handle create list command."""
|
||||
storage = get_storage(hass)
|
||||
|
||||
|
||||
# Private lists are owned by the creating user; global lists have no owner.
|
||||
is_private = msg.get("private", True)
|
||||
owner_id = connection.user.id if is_private and connection.user else None
|
||||
|
||||
new_list = await storage.create_list(
|
||||
name=msg["name"],
|
||||
icon=msg.get("icon", "mdi:cart")
|
||||
icon=msg.get("icon", "mdi:cart"),
|
||||
owner_id=owner_id,
|
||||
)
|
||||
|
||||
# Fire event
|
||||
@@ -232,7 +352,10 @@ async def websocket_update_list(
|
||||
"""Handle update list command."""
|
||||
storage = get_storage(hass)
|
||||
list_id = msg["list_id"]
|
||||
|
||||
|
||||
if _check_list_access(storage, connection, msg, list_id, require_owner=True) is None:
|
||||
return
|
||||
|
||||
# Build update kwargs
|
||||
update_data = {}
|
||||
if "name" in msg:
|
||||
@@ -275,9 +398,21 @@ async def websocket_delete_list(
|
||||
"""Handle delete list command."""
|
||||
storage = get_storage(hass)
|
||||
list_id = msg["list_id"]
|
||||
|
||||
|
||||
lst = storage.get_list(list_id)
|
||||
if lst is None:
|
||||
connection.send_error(msg["id"], "not_found", "List not found")
|
||||
return
|
||||
|
||||
# Only the owner or an admin may delete a private list
|
||||
if lst.owner_id is not None:
|
||||
user = connection.user
|
||||
if not (user and (user.is_admin or user.id == lst.owner_id)):
|
||||
connection.send_error(msg["id"], "forbidden", "Only the list owner can delete this list")
|
||||
return
|
||||
|
||||
success = await storage.delete_list(list_id)
|
||||
|
||||
|
||||
if not success:
|
||||
connection.send_error(msg["id"], "not_found", "List not found")
|
||||
return
|
||||
@@ -306,7 +441,10 @@ async def websocket_set_active_list(
|
||||
"""Handle set active list command."""
|
||||
storage = get_storage(hass)
|
||||
list_id = msg["list_id"]
|
||||
|
||||
|
||||
if _check_list_access(storage, connection, msg, list_id) is None:
|
||||
return
|
||||
|
||||
success = await storage.set_active_list(list_id)
|
||||
|
||||
if not success:
|
||||
@@ -341,7 +479,10 @@ def websocket_get_items(
|
||||
"""Handle get items command."""
|
||||
storage = get_storage(hass)
|
||||
list_id = msg["list_id"]
|
||||
|
||||
|
||||
if _check_list_access(storage, connection, msg, list_id) is None:
|
||||
return
|
||||
|
||||
items = storage.get_items(list_id)
|
||||
|
||||
connection.send_result(
|
||||
@@ -357,7 +498,7 @@ def websocket_get_items(
|
||||
vol.Required("type"): WS_TYPE_ITEMS_ADD,
|
||||
vol.Required("list_id"): str,
|
||||
vol.Required("name"): str,
|
||||
vol.Required("category_id"): str,
|
||||
vol.Optional("category_id", default="other"): str,
|
||||
vol.Optional("product_id"): str,
|
||||
vol.Optional("quantity", default=1): vol.Coerce(float),
|
||||
vol.Optional("unit", default="units"): str,
|
||||
@@ -376,7 +517,10 @@ async def websocket_add_item(
|
||||
"""Handle add item command."""
|
||||
storage = get_storage(hass)
|
||||
list_id = msg["list_id"]
|
||||
|
||||
|
||||
if _check_list_access(storage, connection, msg, list_id) is None:
|
||||
return
|
||||
|
||||
# Build item data
|
||||
item_data = {
|
||||
"name": msg["name"],
|
||||
@@ -435,7 +579,14 @@ async def websocket_update_item(
|
||||
"""Handle update item command."""
|
||||
storage = get_storage(hass)
|
||||
item_id = msg["item_id"]
|
||||
|
||||
|
||||
list_id = _find_item_list_id(storage, item_id)
|
||||
if list_id is None:
|
||||
connection.send_error(msg["id"], "not_found", "Item not found")
|
||||
return
|
||||
if _check_list_access(storage, connection, msg, list_id) is None:
|
||||
return
|
||||
|
||||
# Build update data
|
||||
update_data = {}
|
||||
update_fields = ["name", "quantity", "unit", "note", "price", "category_id", "image_url"]
|
||||
@@ -520,7 +671,14 @@ async def websocket_delete_item(
|
||||
"""Handle delete item command."""
|
||||
storage = get_storage(hass)
|
||||
item_id = msg["item_id"]
|
||||
|
||||
|
||||
list_id = _find_item_list_id(storage, item_id)
|
||||
if list_id is None:
|
||||
connection.send_error(msg["id"], "not_found", "Item not found")
|
||||
return
|
||||
if _check_list_access(storage, connection, msg, list_id) is None:
|
||||
return
|
||||
|
||||
success = await storage.delete_item(item_id)
|
||||
|
||||
if not success:
|
||||
@@ -660,6 +818,87 @@ def websocket_get_list_total(
|
||||
# PRODUCT HANDLERS
|
||||
# =============================================================================
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "shopping_list_manager/products/download_image",
|
||||
vol.Required("image_url"): str,
|
||||
vol.Required("product_name"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_download_product_image(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Download a remote image and save it as WebP to the local images directory."""
|
||||
raw_url: str = msg["image_url"]
|
||||
product_name: str = msg["product_name"]
|
||||
|
||||
safe_stem = re.sub(r"[^a-z0-9_]", "", product_name.lower().replace(" ", "_")) or "product"
|
||||
filename = f"{safe_stem}.webp"
|
||||
images_dir = Path(hass.config.path(IMAGES_LOCAL_DIR))
|
||||
images_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = images_dir / filename
|
||||
|
||||
try:
|
||||
session = async_get_clientsession(hass)
|
||||
headers = {"User-Agent": "Mozilla/5.0 (compatible; HomeAssistant/ShoppingListManager)"}
|
||||
async with session.get(raw_url, timeout=ClientTimeout(total=15), headers=headers) as resp:
|
||||
if resp.status != 200:
|
||||
connection.send_error(msg["id"], "download_failed", f"HTTP {resp.status}")
|
||||
return
|
||||
raw = await resp.read()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
connection.send_error(msg["id"], "download_failed", str(exc))
|
||||
return
|
||||
|
||||
try:
|
||||
img = Image.open(io.BytesIO(raw))
|
||||
# Convert to RGB for reliable lossy WebP encoding
|
||||
# (RGBA, palette, grayscale modes can fail or produce oversized files)
|
||||
if img.mode == "RGBA":
|
||||
bg = Image.new("RGB", img.size, (255, 255, 255))
|
||||
bg.paste(img, mask=img.split()[3])
|
||||
img = bg
|
||||
elif img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
img.thumbnail((IMAGE_SIZE, IMAGE_SIZE), Image.LANCZOS)
|
||||
out = io.BytesIO()
|
||||
img.save(out, format="WEBP", quality=IMAGE_QUALITY)
|
||||
dest.write_bytes(out.getvalue())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
connection.send_error(msg["id"], "conversion_failed", str(exc))
|
||||
return
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{"local_url": f"{LOCAL_IMAGE_URL_PREFIX}{filename}"},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "shopping_list_manager/products/search_by_barcode",
|
||||
vol.Required("barcode"): str,
|
||||
}
|
||||
)
|
||||
@callback
|
||||
def websocket_search_by_barcode(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Find a single product by exact barcode match."""
|
||||
storage = get_storage(hass)
|
||||
barcode = msg["barcode"].strip()
|
||||
match = next(
|
||||
(p for p in storage._products.values() if p.barcode and p.barcode == barcode),
|
||||
None,
|
||||
)
|
||||
connection.send_result(msg["id"], {"product": match.to_dict() if match else None})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "shopping_list_manager/products/substitutes",
|
||||
@@ -844,6 +1083,27 @@ async def websocket_update_product(
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): WS_TYPE_PRODUCTS_DELETE,
|
||||
vol.Required("product_id"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_delete_product(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Handle delete product command."""
|
||||
storage = get_storage(hass)
|
||||
deleted = await storage.delete_product(msg["product_id"])
|
||||
if not deleted:
|
||||
connection.send_error(msg["id"], "not_found", "Product not found")
|
||||
return
|
||||
connection.send_result(msg["id"], {"deleted": True})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CATEGORY HANDLERS
|
||||
# =============================================================================
|
||||
@@ -869,3 +1129,381 @@ def websocket_get_categories(
|
||||
"categories": [cat.to_dict() for cat in categories]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OPENFOODFACTS PROXY HANDLERS
|
||||
# =============================================================================
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): WS_TYPE_OFF_FETCH,
|
||||
vol.Optional("query"): str,
|
||||
vol.Optional("barcode"): str,
|
||||
vol.Optional("page_size", default=5): int,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_off_fetch(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Proxy OpenFoodFacts requests through HA to avoid browser CORS restrictions."""
|
||||
session = async_get_clientsession(hass)
|
||||
base_url, headers = _get_openfoodfacts_request_config(hass)
|
||||
|
||||
try:
|
||||
if msg.get("barcode"):
|
||||
barcode = msg["barcode"]
|
||||
fields = "product_name,categories_tags,image_front_thumb_url,image_front_url,image_url,price"
|
||||
url = f"{base_url}/api/v2/product/{barcode}.json?fields={fields}"
|
||||
async with session.get(url, timeout=ClientTimeout(total=10), headers=headers) as resp:
|
||||
if not resp.ok:
|
||||
connection.send_result(msg["id"], {"status": 0})
|
||||
return
|
||||
data = await resp.json(content_type=None)
|
||||
connection.send_result(msg["id"], {
|
||||
"status": data.get("status", 0),
|
||||
"product": data.get("product"),
|
||||
})
|
||||
else:
|
||||
query = msg.get("query", "")
|
||||
page_size = msg.get("page_size", 5)
|
||||
fields = "product_name,categories_tags,image_front_thumb_url,image_front_url,image_url,price"
|
||||
url = (
|
||||
f"{base_url}/api/v2/search"
|
||||
f"?search_terms={query}&fields={fields}&page_size={page_size}"
|
||||
)
|
||||
async with session.get(url, timeout=ClientTimeout(total=10), headers=headers) as resp:
|
||||
if not resp.ok:
|
||||
connection.send_result(msg["id"], {"products": []})
|
||||
return
|
||||
data = await resp.json(content_type=None)
|
||||
connection.send_result(msg["id"], {"products": data.get("products", [])})
|
||||
except Exception as err:
|
||||
_LOGGER.warning("OpenFoodFacts proxy request failed: %s", err)
|
||||
connection.send_error(msg["id"], "fetch_failed", str(err))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INTEGRATION SETTINGS HANDLERS
|
||||
# =============================================================================
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "shopping_list_manager/get_integration_settings",
|
||||
}
|
||||
)
|
||||
@callback
|
||||
def websocket_get_integration_settings(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Return current country and available country options."""
|
||||
country = hass.data[DOMAIN].get("country", "NZ")
|
||||
version = hass.data[DOMAIN].get("version", "unknown")
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"country": country,
|
||||
"version": version,
|
||||
"available_countries": {
|
||||
"NZ": "New Zealand",
|
||||
"AU": "Australia",
|
||||
"US": "United States",
|
||||
"GB": "United Kingdom",
|
||||
"CA": "Canada",
|
||||
"BE": "Belgium (Dutch)",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
_VALID_COUNTRIES = ["NZ", "AU", "US", "GB", "CA", "BE"]
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "shopping_list_manager/set_country",
|
||||
vol.Required("country"): vol.In(_VALID_COUNTRIES),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_set_country(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Switch to a different country catalog. Preserves user-added products."""
|
||||
country = msg["country"].upper()
|
||||
storage = get_storage(hass)
|
||||
|
||||
count = await storage.reload_catalog(country)
|
||||
|
||||
# Persist to HA config entry so country survives restart
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
if entries:
|
||||
entry = entries[0]
|
||||
hass.config_entries.async_update_entry(entry, options={**entry.options, "country": country})
|
||||
|
||||
hass.data[DOMAIN]["country"] = country
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{"success": True, "country": country, "products_loaded": count}
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BACKUP / RESTORE HANDLERS
|
||||
# =============================================================================
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "shopping_list_manager/export_data",
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_export_data(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Export all user-created data as a JSON-serialisable dict."""
|
||||
storage = get_storage(hass)
|
||||
data = await storage.export_user_data()
|
||||
connection.send_result(msg["id"], data)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "shopping_list_manager/import_data",
|
||||
vol.Required("data"): dict,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_import_data(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Import user data from a backup payload."""
|
||||
storage = get_storage(hass)
|
||||
counts = await storage.import_user_data(msg["data"])
|
||||
connection.send_result(msg["id"], {"success": True, "imported": counts})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LIST MEMBERS HANDLER
|
||||
# =============================================================================
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): WS_TYPE_LISTS_UPDATE_MEMBERS,
|
||||
vol.Required("list_id"): str,
|
||||
vol.Required("allowed_users"): [str],
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_update_list_members(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Update the allowed_users for a private list."""
|
||||
storage = get_storage(hass)
|
||||
list_id = msg["list_id"]
|
||||
|
||||
lst = storage.get_list(list_id)
|
||||
if lst is None:
|
||||
connection.send_error(msg["id"], "not_found", "List not found")
|
||||
return
|
||||
|
||||
# Only the owner or an admin may manage members
|
||||
user = connection.user
|
||||
if lst.owner_id is not None and not (user and (user.is_admin or user.id == lst.owner_id)):
|
||||
connection.send_error(msg["id"], "forbidden", "Only the list owner can manage members")
|
||||
return
|
||||
|
||||
updated = await storage.update_list_members(list_id, msg["allowed_users"])
|
||||
hass.bus.async_fire(
|
||||
EVENT_LIST_UPDATED,
|
||||
{"list_id": list_id, "action": "members_updated"}
|
||||
)
|
||||
connection.send_result(msg["id"], {"list": updated.to_dict()})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HA USERS HANDLER
|
||||
# =============================================================================
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): WS_TYPE_USERS_GET_ALL,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_get_ha_users(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Return all active, non-system HA users."""
|
||||
users = await hass.auth.async_get_users()
|
||||
result = [
|
||||
{"id": u.id, "name": u.name}
|
||||
for u in users
|
||||
if not u.system_generated and u.is_active
|
||||
]
|
||||
connection.send_result(msg["id"], {"users": result})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LOYALTY CARD HANDLERS
|
||||
# =============================================================================
|
||||
|
||||
@websocket_api.websocket_command({
|
||||
vol.Required("type"): WS_TYPE_LOYALTY_GET_ALL,
|
||||
})
|
||||
@websocket_api.async_response
|
||||
async def websocket_get_loyalty_cards(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Return all loyalty cards visible to the current user."""
|
||||
storage = get_storage(hass)
|
||||
user = connection.user
|
||||
user_id = user.id if user else None
|
||||
is_admin = user.is_admin if user else False
|
||||
cards = storage.get_loyalty_cards(user_id=user_id, is_admin=is_admin)
|
||||
connection.send_result(msg["id"], {"cards": [c.to_dict() for c in cards]})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({
|
||||
vol.Required("type"): WS_TYPE_LOYALTY_ADD,
|
||||
vol.Required("name"): str,
|
||||
vol.Required("number"): str,
|
||||
vol.Optional("barcode", default=""): str,
|
||||
vol.Optional("barcode_type", default="barcode"): str,
|
||||
vol.Optional("logo", default=""): str,
|
||||
vol.Optional("notes", default=""): str,
|
||||
vol.Optional("color", default="#9fa8da"): str,
|
||||
vol.Optional("private", default=True): bool,
|
||||
})
|
||||
@websocket_api.async_response
|
||||
async def websocket_add_loyalty_card(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Add a new loyalty card."""
|
||||
storage = get_storage(hass)
|
||||
user = connection.user
|
||||
owner_id = user.id if (user and msg.get("private")) else None
|
||||
|
||||
card = await storage.create_loyalty_card(
|
||||
owner_id=owner_id,
|
||||
name=msg["name"],
|
||||
number=msg["number"],
|
||||
barcode=msg.get("barcode", ""),
|
||||
barcode_type=msg.get("barcode_type", "barcode"),
|
||||
logo=msg.get("logo", ""),
|
||||
notes=msg.get("notes", ""),
|
||||
color=msg.get("color", "#9fa8da"),
|
||||
)
|
||||
connection.send_result(msg["id"], {"card": card.to_dict()})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({
|
||||
vol.Required("type"): WS_TYPE_LOYALTY_UPDATE,
|
||||
vol.Required("card_id"): str,
|
||||
vol.Optional("name"): str,
|
||||
vol.Optional("number"): str,
|
||||
vol.Optional("barcode"): str,
|
||||
vol.Optional("barcode_type"): str,
|
||||
vol.Optional("logo"): str,
|
||||
vol.Optional("notes"): str,
|
||||
vol.Optional("color"): str,
|
||||
})
|
||||
@websocket_api.async_response
|
||||
async def websocket_update_loyalty_card(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Update an existing loyalty card."""
|
||||
storage = get_storage(hass)
|
||||
card_id = msg["card_id"]
|
||||
|
||||
card = storage.get_loyalty_card(card_id)
|
||||
if card is None:
|
||||
connection.send_error(msg["id"], "not_found", "Loyalty card not found")
|
||||
return
|
||||
|
||||
user = connection.user
|
||||
if card.owner_id is not None and not (user and (user.is_admin or user.id == card.owner_id)):
|
||||
connection.send_error(msg["id"], "forbidden", "Only the card owner can update it")
|
||||
return
|
||||
|
||||
fields = {k: v for k, v in msg.items() if k not in ("type", "id", "card_id")}
|
||||
updated = await storage.update_loyalty_card(card_id, **fields)
|
||||
connection.send_result(msg["id"], {"card": updated.to_dict()})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({
|
||||
vol.Required("type"): WS_TYPE_LOYALTY_DELETE,
|
||||
vol.Required("card_id"): str,
|
||||
})
|
||||
@websocket_api.async_response
|
||||
async def websocket_delete_loyalty_card(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Delete a loyalty card."""
|
||||
storage = get_storage(hass)
|
||||
card_id = msg["card_id"]
|
||||
|
||||
card = storage.get_loyalty_card(card_id)
|
||||
if card is None:
|
||||
connection.send_error(msg["id"], "not_found", "Loyalty card not found")
|
||||
return
|
||||
|
||||
user = connection.user
|
||||
if card.owner_id is not None and not (user and (user.is_admin or user.id == card.owner_id)):
|
||||
connection.send_error(msg["id"], "forbidden", "Only the card owner can delete it")
|
||||
return
|
||||
|
||||
await storage.delete_loyalty_card(card_id)
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({
|
||||
vol.Required("type"): WS_TYPE_LOYALTY_UPDATE_MEMBERS,
|
||||
vol.Required("card_id"): str,
|
||||
vol.Required("allowed_users"): [str],
|
||||
})
|
||||
@websocket_api.async_response
|
||||
async def websocket_update_loyalty_card_members(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Update the allowed_users for a private loyalty card."""
|
||||
storage = get_storage(hass)
|
||||
card_id = msg["card_id"]
|
||||
|
||||
card = storage.get_loyalty_card(card_id)
|
||||
if card is None:
|
||||
connection.send_error(msg["id"], "not_found", "Loyalty card not found")
|
||||
return
|
||||
|
||||
user = connection.user
|
||||
if card.owner_id is not None and not (user and (user.is_admin or user.id == card.owner_id)):
|
||||
connection.send_error(msg["id"], "forbidden", "Only the card owner can manage members")
|
||||
return
|
||||
|
||||
updated = await storage.update_loyalty_card_members(card_id, msg["allowed_users"])
|
||||
connection.send_result(msg["id"], {"card": updated.to_dict()})
|
||||
|
||||
Reference in New Issue
Block a user