""" 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", {})) }