mirror of
https://github.com/thekiwismarthome/shopping-list-manager.git
synced 2026-06-30 21:46:30 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 433c03035b | |||
| 8eb403ed8e | |||
| 03fb9a9a67 | |||
| ae133ae59b | |||
| 2a8b12a07e |
@@ -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)
|
[](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**.
|
## Features
|
||||||
4. Restart Home Assistant.
|
|
||||||
|
### 🛒 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
|
| Component | Minimum Version |
|
||||||
|
|---|---|
|
||||||
Repository type: **Integration**
|
| Home Assistant | 2024.1 |
|
||||||
|
| HACS | 2.x |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Manual Installation (Optional)
|
## Installation
|
||||||
|
|
||||||
1. Copy the folder:
|
### Via HACS (Recommended)
|
||||||
custom_components/shopping_list_manager
|
|
||||||
|
|
||||||
2. Paste it into:
|
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=thekiwismarthome&repository=shopping-list-manager&category=integration)
|
||||||
/config/custom_components/
|
|
||||||
|
|
||||||
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)
|
[](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.
|
||||||
|
|||||||
@@ -171,6 +171,10 @@ async def _async_register_websocket_handlers(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Products handlers
|
# Products handlers
|
||||||
|
websocket_api.async_register_command(
|
||||||
|
hass,
|
||||||
|
handlers.websocket_search_by_barcode,
|
||||||
|
)
|
||||||
websocket_api.async_register_command(
|
websocket_api.async_register_command(
|
||||||
hass,
|
hass,
|
||||||
handlers.websocket_search_products,
|
handlers.websocket_search_products,
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -48,6 +48,62 @@ from .. import get_storage
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ACCESS-CHECK HELPERS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
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
|
# LIST HANDLERS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -62,16 +118,28 @@ async def websocket_subscribe(
|
|||||||
msg: dict,
|
msg: dict,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Subscribe to shopping list manager events via WebSocket."""
|
"""Subscribe to shopping list manager events via WebSocket."""
|
||||||
|
storage = get_storage(hass)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def forward_event(event):
|
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(
|
connection.send_message(
|
||||||
websocket_api.event_message(
|
websocket_api.event_message(
|
||||||
msg["id"],
|
msg["id"],
|
||||||
{
|
{
|
||||||
"event_type": event.event_type,
|
"event_type": event.event_type,
|
||||||
"data": event.data,
|
"data": data,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -249,6 +317,9 @@ async def websocket_update_list(
|
|||||||
storage = get_storage(hass)
|
storage = get_storage(hass)
|
||||||
list_id = msg["list_id"]
|
list_id = msg["list_id"]
|
||||||
|
|
||||||
|
if _check_list_access(storage, connection, msg, list_id, require_owner=True) is None:
|
||||||
|
return
|
||||||
|
|
||||||
# Build update kwargs
|
# Build update kwargs
|
||||||
update_data = {}
|
update_data = {}
|
||||||
if "name" in msg:
|
if "name" in msg:
|
||||||
@@ -335,6 +406,9 @@ async def websocket_set_active_list(
|
|||||||
storage = get_storage(hass)
|
storage = get_storage(hass)
|
||||||
list_id = msg["list_id"]
|
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)
|
success = await storage.set_active_list(list_id)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
@@ -370,6 +444,9 @@ def websocket_get_items(
|
|||||||
storage = get_storage(hass)
|
storage = get_storage(hass)
|
||||||
list_id = msg["list_id"]
|
list_id = msg["list_id"]
|
||||||
|
|
||||||
|
if _check_list_access(storage, connection, msg, list_id) is None:
|
||||||
|
return
|
||||||
|
|
||||||
items = storage.get_items(list_id)
|
items = storage.get_items(list_id)
|
||||||
|
|
||||||
connection.send_result(
|
connection.send_result(
|
||||||
@@ -405,6 +482,9 @@ async def websocket_add_item(
|
|||||||
storage = get_storage(hass)
|
storage = get_storage(hass)
|
||||||
list_id = msg["list_id"]
|
list_id = msg["list_id"]
|
||||||
|
|
||||||
|
if _check_list_access(storage, connection, msg, list_id) is None:
|
||||||
|
return
|
||||||
|
|
||||||
# Build item data
|
# Build item data
|
||||||
item_data = {
|
item_data = {
|
||||||
"name": msg["name"],
|
"name": msg["name"],
|
||||||
@@ -464,6 +544,13 @@ async def websocket_update_item(
|
|||||||
storage = get_storage(hass)
|
storage = get_storage(hass)
|
||||||
item_id = msg["item_id"]
|
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
|
# Build update data
|
||||||
update_data = {}
|
update_data = {}
|
||||||
update_fields = ["name", "quantity", "unit", "note", "price", "category_id", "image_url"]
|
update_fields = ["name", "quantity", "unit", "note", "price", "category_id", "image_url"]
|
||||||
@@ -549,6 +636,13 @@ async def websocket_delete_item(
|
|||||||
storage = get_storage(hass)
|
storage = get_storage(hass)
|
||||||
item_id = msg["item_id"]
|
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)
|
success = await storage.delete_item(item_id)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
@@ -688,6 +782,28 @@ def websocket_get_list_total(
|
|||||||
# PRODUCT HANDLERS
|
# PRODUCT HANDLERS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
@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(
|
@websocket_api.websocket_command(
|
||||||
{
|
{
|
||||||
vol.Required("type"): "shopping_list_manager/products/substitutes",
|
vol.Required("type"): "shopping_list_manager/products/substitutes",
|
||||||
@@ -931,10 +1047,12 @@ def websocket_get_integration_settings(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_VALID_COUNTRIES = ["NZ", "AU", "US", "GB", "CA"]
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
{
|
{
|
||||||
vol.Required("type"): "shopping_list_manager/set_country",
|
vol.Required("type"): "shopping_list_manager/set_country",
|
||||||
vol.Required("country"): str,
|
vol.Required("country"): vol.In(_VALID_COUNTRIES),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@websocket_api.async_response
|
@websocket_api.async_response
|
||||||
|
|||||||
Reference in New Issue
Block a user