LeoWalker's picture
MCP: relax Notion DB checks to method-level; add masked env diagnostics at startup for Space logs
ea07b3b
"""
Simplified Notion client for FoodWise inventory management.
"""
import os
from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta, date
from notion_client import Client
from pydantic import ValidationError
from ..database.db_models import PartialFoodInventoryItem
class FoodWiseNotionClient:
"""Simple client for FoodWise Notion database."""
def __init__(self):
"""Initialize the Notion client."""
self.client = Client(auth=os.getenv("NOTION_SECRET"))
self.database_id = os.getenv("NOTION_INVENTORY_DB_ID")
self.shopping_database_id = os.getenv("NOTION_SHOPPING_DB_ID")
# Do not enforce database presence at construction time.
# Each method will validate only the specific DB it requires.
def query_inventory(self, filter_conditions=None):
"""Query inventory items."""
if not self.database_id:
raise ValueError("NOTION_INVENTORY_DB_ID must be set in environment")
try:
params = {"database_id": self.database_id}
if filter_conditions:
params["filter"] = filter_conditions
response = self.client.databases.query(**params)
return [self._simplify_item(page) for page in response["results"]]
except Exception as e:
raise Exception(f"Failed to query inventory: {e}")
def add_inventory_item(self, item_data):
"""Add a new item to inventory."""
if not self.database_id:
raise ValueError("NOTION_INVENTORY_DB_ID must be set in environment")
try:
properties = {}
# Required fields
if "name" in item_data:
properties["Name"] = {"title": [{"text": {"content": item_data["name"]}}]}
if "category" in item_data:
properties["Category"] = {"select": {"name": item_data["category"]}}
if "storage_type" in item_data:
properties["Storage Type"] = {"select": {"name": item_data["storage_type"]}}
if "quantity" in item_data:
properties["Quantity"] = {"number": float(item_data["quantity"])}
if "unit" in item_data:
properties["Unit"] = {"select": {"name": item_data["unit"]}}
# Optional fields
if item_data.get("best_by_date"):
properties["Best By Date"] = {"date": {"start": item_data["best_by_date"]}}
if item_data.get("purchase_date"):
properties["Purchase Date"] = {"date": {"start": item_data["purchase_date"]}}
if item_data.get("fridge_zone"):
properties["Fridge Zone"] = {"select": {"name": item_data["fridge_zone"]}}
if item_data.get("location_shelf"):
properties["Location/Shelf"] = {"select": {"name": item_data["location_shelf"]}}
if item_data.get("cooked_raw_status"):
properties["Cooked/Raw Status"] = {"select": {"name": item_data["cooked_raw_status"]}}
if "temperature_sensitive" in item_data:
properties["Temperature Sensitive"] = {"checkbox": bool(item_data["temperature_sensitive"])}
if item_data.get("prep_notes"):
properties["Prep Notes"] = {"rich_text": [{"text": {"content": item_data["prep_notes"]}}]}
if item_data.get("tags"):
properties["Tags"] = {"multi_select": [{"name": tag} for tag in item_data["tags"]]}
response = self.client.pages.create(
parent={"database_id": self.database_id},
properties=properties
)
return self._simplify_item(response)
except Exception as e:
raise Exception(f"Failed to add item: {e}")
def search_items(self, search_term):
"""Search for items by name."""
filter_condition = {
"property": "Name",
"title": {"contains": search_term}
}
return self.query_inventory(filter_condition)
def get_expiring_items(self, days=7):
"""Get items expiring within specified days."""
if not self.database_id:
raise ValueError("NOTION_INVENTORY_DB_ID must be set in environment")
target_date = (datetime.now() + timedelta(days=days)).isoformat()[:10] # YYYY-MM-DD format
filter_condition = {
"and": [
{
"property": "Best By Date",
"date": {"on_or_before": target_date}
},
{
"property": "Best By Date",
"date": {"is_not_empty": True}
}
]
}
return self.query_inventory(filter_condition)
def update_inventory_item(self, page_id: str, updated_data: dict) -> dict:
"""Update an existing inventory item with partial data validation."""
if not self.database_id:
raise ValueError("NOTION_INVENTORY_DB_ID must be set in environment")
try:
# Validate input data
validated = PartialFoodInventoryItem.model_validate(updated_data)
data_dict = validated.model_dump(exclude_none=True)
# Mapping from field names to Notion property formats
property_map = {
"name": lambda v: {"title": [{"text": {"content": v}}]},
"category": lambda v: {"select": {"name": v}},
"storage_type": lambda v: {"select": {"name": v}},
"quantity": lambda v: {"number": float(v)},
"unit": lambda v: {"select": {"name": v}},
"best_by_date": lambda v: {"date": {"start": v.isoformat() if isinstance(v, date) else v}},
"purchase_date": lambda v: {"date": {"start": v.isoformat() if isinstance(v, date) else v}},
"opened_date": lambda v: {"date": {"start": v.isoformat() if isinstance(v, date) else v}},
"freeze_date": lambda v: {"date": {"start": v.isoformat() if isinstance(v, date) else v}},
"fridge_zone": lambda v: {"select": {"name": v}},
"location_shelf": lambda v: {"select": {"name": v}},
"cooked_raw_status": lambda v: {"select": {"name": v}},
"temperature_sensitive": lambda v: {"checkbox": bool(v)},
"prep_notes": lambda v: {"rich_text": [{"text": {"content": v}}]},
"tags": lambda v: {"multi_select": [{"name": tag} for tag in v]}
}
# Build Notion properties, using capitalized field names
properties = {}
for field, value in data_dict.items():
notion_field = field.replace("_", " ").title().replace("By Date", "By Date").replace("Raw Status", "Raw Status")
if field in property_map:
properties[notion_field] = property_map[field](value)
if not properties:
raise ValueError("No valid fields provided for update")
response = self.client.pages.update(
page_id=page_id,
properties=properties
)
return self._simplify_item(response)
except ValidationError as ve:
raise ValueError(f"Invalid update data: {ve}")
except Exception as e:
raise Exception(f"Failed to update item: {e}")
def remove_inventory_item(self, page_id):
"""Remove (archive) an inventory item."""
if not self.database_id:
raise ValueError("NOTION_INVENTORY_DB_ID must be set in environment")
try:
response = self.client.pages.update(
page_id=page_id,
archived=True
)
return {"success": True, "message": f"Item {page_id} archived successfully"}
except Exception as e:
raise Exception(f"Failed to remove item: {e}")
def _simplify_item(self, page):
"""Convert Notion page to simple dictionary."""
props = page["properties"]
def get_title(prop):
return prop["title"][0]["text"]["content"] if prop.get("title") and len(prop["title"]) > 0 else ""
def get_select(prop):
return prop["select"]["name"] if prop.get("select") else None
def get_number(prop):
return prop.get("number")
def get_checkbox(prop):
return prop.get("checkbox", False)
def get_date(prop):
return prop["date"]["start"] if prop.get("date") else None
def get_rich_text(prop):
return prop["rich_text"][0]["text"]["content"] if prop.get("rich_text") and len(prop["rich_text"]) > 0 else ""
def get_multi_select(prop):
return [item["name"] for item in prop.get("multi_select", [])]
return {
"id": page["id"],
"url": page["url"],
"name": get_title(props.get("Name", {})),
"category": get_select(props.get("Category", {})),
"storage_type": get_select(props.get("Storage Type", {})),
"quantity": get_number(props.get("Quantity", {})),
"unit": get_select(props.get("Unit", {})),
"best_by_date": get_date(props.get("Best By Date", {})),
"purchase_date": get_date(props.get("Purchase Date", {})),
"opened_date": get_date(props.get("Opened Date", {})),
"location_shelf": get_select(props.get("Location/Shelf", {})),
"fridge_zone": get_select(props.get("Fridge Zone", {})),
"tags": get_multi_select(props.get("Tags", {})),
"temperature_sensitive": get_checkbox(props.get("Temperature Sensitive", {})),
"cooked_raw_status": get_select(props.get("Cooked/Raw Status", {})),
"prep_notes": get_rich_text(props.get("Prep Notes", {}))
}
# ===== SHOPPING LIST METHODS =====
def query_shopping_list(self, filter_conditions=None):
"""Query shopping list items."""
if not self.shopping_database_id:
raise ValueError("NOTION_SHOPPING_DB_ID must be set in environment to use shopping list features")
try:
params = {"database_id": self.shopping_database_id}
if filter_conditions:
params["filter"] = filter_conditions
response = self.client.databases.query(**params)
return [self._simplify_shopping_item(page) for page in response["results"]]
except Exception as e:
raise Exception(f"Failed to query shopping list: {e}")
def add_shopping_item(self, item_data):
"""Add a new item to shopping list."""
if not self.shopping_database_id:
raise ValueError("NOTION_SHOPPING_DB_ID must be set in environment to use shopping list features")
try:
properties = {}
# Required fields
if "name" in item_data:
properties["Item Name"] = {"title": [{"text": {"content": item_data["name"]}}]}
if "quantity" in item_data:
properties["Quantity"] = {"number": float(item_data["quantity"])}
if "unit" in item_data:
properties["Unit"] = {"rich_text": [{"text": {"content": item_data["unit"]}}]}
if "priority" in item_data:
properties["Priority"] = {"select": {"name": item_data["priority"]}}
if "status" in item_data:
properties["Status"] = {"select": {"name": item_data["status"]}}
# Optional fields
if "store" in item_data and item_data["store"]:
properties["Store"] = {"select": {"name": item_data["store"]}}
if "category" in item_data and item_data["category"]:
properties["Category"] = {"select": {"name": item_data["category"]}}
if "estimated_price" in item_data and item_data["estimated_price"]:
properties["Estimated Price"] = {"number": float(item_data["estimated_price"])}
if "brand_preference" in item_data and item_data["brand_preference"]:
properties["Brand Preference"] = {"rich_text": [{"text": {"content": item_data["brand_preference"]}}]}
if "size_package" in item_data and item_data["size_package"]:
properties["Size/Package"] = {"rich_text": [{"text": {"content": item_data["size_package"]}}]}
if "notes" in item_data and item_data["notes"]:
properties["Notes"] = {"rich_text": [{"text": {"content": item_data["notes"]}}]}
if "recipe_source" in item_data and item_data["recipe_source"]:
properties["Recipe Source"] = {"rich_text": [{"text": {"content": item_data["recipe_source"]}}]}
# Add current date
properties["Date Added"] = {"date": {"start": datetime.now().strftime("%Y-%m-%d")}}
response = self.client.pages.create(
parent={"database_id": self.shopping_database_id},
properties=properties
)
return self._simplify_shopping_item(response)
except Exception as e:
raise Exception(f"Failed to add shopping item: {e}")
def update_shopping_item(self, page_id, updated_data):
"""Update an existing shopping item."""
if not self.shopping_database_id:
raise ValueError("NOTION_SHOPPING_DB_ID must be set in environment to use shopping list features")
try:
properties = {}
# Map field names to Notion properties
field_mapping = {
"name": "Item Name",
"quantity": "Quantity",
"unit": "Unit",
"store": "Store",
"priority": "Priority",
"category": "Category",
"estimated_price": "Estimated Price",
"brand_preference": "Brand Preference",
"size_package": "Size/Package",
"notes": "Notes",
"recipe_source": "Recipe Source",
"status": "Status"
}
for field, value in updated_data.items():
if field in field_mapping:
notion_field = field_mapping[field]
if field == "name":
properties[notion_field] = {"title": [{"text": {"content": str(value)}}]}
elif field in ["quantity", "estimated_price"]:
properties[notion_field] = {"number": float(value)}
elif field in ["store", "priority", "category", "status"]:
properties[notion_field] = {"select": {"name": str(value)}}
else: # Text fields
properties[notion_field] = {"rich_text": [{"text": {"content": str(value)}}]}
response = self.client.pages.update(
page_id=page_id,
properties=properties
)
return self._simplify_shopping_item(response)
except Exception as e:
raise Exception(f"Failed to update shopping item: {e}")
def remove_shopping_item(self, page_id):
"""Remove (archive) a shopping item."""
if not self.shopping_database_id:
raise ValueError("NOTION_SHOPPING_DB_ID must be set in environment to use shopping list features")
try:
self.client.pages.update(
page_id=page_id,
archived=True
)
return {"success": True, "message": f"Shopping item {page_id} archived successfully"}
except Exception as e:
raise Exception(f"Failed to remove shopping item: {e}")
def _simplify_shopping_item(self, page):
"""Convert Notion page to simplified shopping item dict."""
props = page.get("properties", {})
# Helper functions to safely extract data
def get_title(prop):
if prop.get("title") and len(prop["title"]) > 0:
return prop["title"][0]["text"]["content"]
return None
def get_rich_text(prop):
if prop.get("rich_text") and len(prop["rich_text"]) > 0:
return prop["rich_text"][0]["text"]["content"]
return None
def get_number(prop):
return prop.get("number")
def get_select(prop):
if prop.get("select"):
return prop["select"]["name"]
return None
def get_date(prop):
if prop.get("date") and prop["date"].get("start"):
return prop["date"]["start"]
return None
return {
"id": page["id"],
"url": page["url"],
"name": get_title(props.get("Item Name", {})),
"quantity": get_number(props.get("Quantity", {})),
"unit": get_rich_text(props.get("Unit", {})),
"store": get_select(props.get("Store", {})),
"priority": get_select(props.get("Priority", {})),
"category": get_select(props.get("Category", {})),
"estimated_price": get_number(props.get("Estimated Price", {})),
"brand_preference": get_rich_text(props.get("Brand Preference", {})),
"size_package": get_rich_text(props.get("Size/Package", {})),
"notes": get_rich_text(props.get("Notes", {})),
"recipe_source": get_rich_text(props.get("Recipe Source", {})),
"status": get_select(props.get("Status", {})),
"date_added": get_date(props.get("Date Added", {}))
}