File size: 10,211 Bytes
ae83e31
c0cfae9
ae83e31
c0cfae9
 
ae83e31
 
 
 
 
 
 
 
 
 
 
 
 
c0cfae9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ae83e31
c0cfae9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ae83e31
c0cfae9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ae83e31
c0cfae9
 
 
 
 
 
 
 
 
 
 
ae83e31
c0cfae9
 
 
 
 
 
ae83e31
c0cfae9
ae83e31
 
 
 
 
 
 
 
 
 
c0cfae9
 
 
 
 
ae83e31
 
c0cfae9
 
 
ae83e31
c0cfae9
 
ae83e31
 
 
 
c0cfae9
 
 
 
 
 
ae83e31
 
c0cfae9
 
ae83e31
 
 
 
 
 
 
 
 
 
c0cfae9
 
ae83e31
c0cfae9
ae83e31
c0cfae9
 
 
 
ae83e31
 
 
 
 
 
 
 
 
 
c0cfae9
ae83e31
 
 
c0cfae9
ae83e31
 
 
c0cfae9
 
 
 
ae83e31
 
 
 
 
 
 
 
 
 
 
 
 
c0cfae9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ae83e31
c0cfae9
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
#!/usr/bin/env python3
"""Verify Notion DB schemas (inventory and shopping) against Pydantic models.

Derives expected properties from `db_models.py` metadata, not a manual dict.
Run with: uv run python scripts/verify_schema.py [--db inventory|shopping|all] [DB_ID]
"""

import sys
import os
from pathlib import Path

# Add src to path so we can import our modules
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))

from dotenv import load_dotenv
load_dotenv()

from notion_client import Client
from typing import Any, Dict, cast, Type
import argparse

# Derive expected schema from Pydantic models rather than hardcoding
from foodwise.database.db_models import FoodInventoryItem, ShoppingListItem
import re


def _expected_properties_from_model(model: Type[Any]) -> dict:
    """Build expected Notion properties by parsing Pydantic Field descriptions.

    We avoid hand-maintained JSON by deriving property names and types from the
    model metadata. Special casing is minimal and limited to known Notion label
    formats and title properties.
    """
    fields = model.model_fields

    def to_title(label: str) -> str:
        # Snake to words, handling custom cases used in the Notion DB
        special = {
            "best_by_date": "Best By Date",
            "purchase_date": "Purchase Date",
            "opened_date": "Opened Date",
            "freeze_date": "Freeze Date",
            "location_shelf": "Location/Shelf",
            "fridge_zone": "Fridge Zone",
            "storage_type": "Storage Type",
            "cooked_raw_status": "Cooked/Raw Status",
            "prep_notes": "Prep Notes",
        }
        if label in special:
            return special[label]
        if label == "name":  # inventory title label
            return "Name"
        return " ".join(w.capitalize() for w in label.split("_"))

    def parse_notion_type(description: str | None, field_name: str) -> str:
        if not description:
            return "rich_text"  # safe default for free text
        m = re.search(r"Notion:\s*([^\)]+)\)", description)
        notion_hint = m.group(1).strip() if m else ""
        # Normalize common hints
        mapping = {
            "Text": "rich_text",
            "Rich text": "rich_text",
            "Title": "title",
            "Select": "select",
            "Multi-select": "multi_select",
            "Number": "number",
            "Date": "date",
            "Checkbox": "checkbox",
            "Checkbox/Boolean": "checkbox",
            "Boolean": "checkbox",
            "id": "id",
            "URL": "url",
        }
        # Some descriptions include multiple hints like "Text/Relation" – pick first known
        for part in [p.strip() for p in re.split(r"[,/]", notion_hint)]:
            if part in mapping:
                return mapping[part]
        # Fallbacks by annotation kinds
        ann = fields[field_name].annotation
        ann_str = str(ann)
        if "date" in ann_str:
            return "date"
        if "float" in ann_str or "int" in ann_str:
            return "number"
        if "List" in ann_str or "list" in ann_str:
            return "multi_select"
        if "bool" in ann_str:
            return "checkbox"
        return "rich_text"

    expected: dict = {}
    # Identify fields that must be treated as Notion title when descriptions don't specify it
    forced_title_fields = {"name"} if model is FoodInventoryItem else set()

    for fname, finfo in fields.items():
        if fname in {"id", "url"}:
            continue  # not database properties
        notion_name = to_title(fname)
        notion_type = parse_notion_type(getattr(finfo, "description", None), fname)
        if fname in forced_title_fields:
            notion_type = "title"
        # Required if title
        expected[notion_name] = {"type": notion_type, "required": notion_type == "title"}
    return expected


def verify_database_schema(database_id: str | None = None, db_kind: str = "inventory"):
    """Verify the database schema matches FoodWise expectations."""

    # Resolve database id from env if not provided
    env_key = "NOTION_INVENTORY_DB_ID" if db_kind == "inventory" else "NOTION_SHOPPING_DB_ID"
    database_id = database_id or os.getenv(env_key)
    if not database_id:
        print(f"❌ {env_key} not found in environment and no ID was provided")
        return False

    model: Type[Any] = FoodInventoryItem if db_kind == "inventory" else ShoppingListItem
    expected_properties = _expected_properties_from_model(model)

    try:
        notion_secret = os.getenv("NOTION_SECRET")
        if not notion_secret:
            print("❌ NOTION_SECRET not found in environment")
            return False
        client = Client(auth=notion_secret)

        print(f"πŸ” Retrieving database schema for ID: {database_id}")
        database = cast(Dict[str, Any], client.databases.retrieve(database_id))
        
        database_title = "Unknown"
        if database.get("title") and len(database["title"]) > 0:
            database_title = database["title"][0]["text"]["content"]
        
        print(f"πŸ“Š Database: {database_title}")
        print(f"πŸ”— URL: {database['url']}")
        
        actual_properties = database.get("properties", {})
        print(f"\nπŸ“‹ Found {len(actual_properties)} properties in database")

        # Normalize names so minor punctuation/case diffs don't flag as missing/unexpected
        def _norm(label: str) -> str:
            return "".join(ch for ch in label.lower() if ch.isalnum())
        actual_norm_to_name: Dict[str, str] = {_norm(n): n for n in actual_properties.keys()}
        
        # Check each expected property
        print("\n" + "=" * 60)
        print(f"πŸ” {db_kind.upper()} SCHEMA CHECK")
        print("=" * 60)
        
        missing_required = []
        missing_optional = []
        type_mismatches = []
        correct_properties = []
        
        for prop_name, expected in expected_properties.items():
            actual_key = actual_norm_to_name.get(_norm(prop_name))
            if actual_key is None:
                if expected.get("required"):
                    missing_required.append(prop_name)
                else:
                    missing_optional.append(prop_name)
                print(f"❌ MISSING: {prop_name} ({expected['type']})")
                continue

            actual_prop = actual_properties[actual_key]
            actual_type = actual_prop["type"]
            expected_type = expected["type"]
            
            if actual_type == expected_type:
                correct_properties.append(prop_name)
            else:
                type_mismatches.append((prop_name, expected_type, actual_type))
                print(f"❌ TYPE MISMATCH: {prop_name} (expected: {expected_type}, actual: {actual_type})")
        
        # Check for unexpected properties
        expected_norms = {_norm(n) for n in expected_properties.keys()}
        unexpected_properties = [prop for prop in actual_properties if _norm(prop) not in expected_norms]
        
        print("\n" + "-" * 60)
        print("πŸ“Š SUMMARY")
        print(
            f"  Correct: {len(correct_properties)} | Missing required: {len(missing_required)} | "
            f"Missing optional: {len(missing_optional)} | Mismatches: {len(type_mismatches)} | Unexpected: {len(unexpected_properties)}"
        )
        
        if unexpected_properties:
            print(f"\nπŸ” Unexpected properties found:")
            for prop in unexpected_properties[:5]:  # Show first 5
                actual_type = actual_properties[prop]["type"]
                print(f"   β€’ {prop} ({actual_type})")
            if len(unexpected_properties) > 5:
                print(f"   ... and {len(unexpected_properties) - 5} more")
        
        # Overall assessment
        if not missing_required and not type_mismatches:
            print(f"\nπŸŽ‰ SCHEMA VERIFICATION PASSED!")
            print(f"   Your database is fully compatible with FoodWise!")
            print(f"\nπŸ’‘ Next step: Add this to your .env file:")
            print(f"   {env_key}={database_id}")
            return True
        else:
            print(f"\n⚠️  SCHEMA ISSUES DETECTED")
            if missing_required:
                print(f"   Missing {len(missing_required)} required properties")
            if missing_optional:
                print(f"   Missing {len(missing_optional)} optional properties")
            if type_mismatches:
                print(f"   {len(type_mismatches)} properties have incorrect types")
            print(f"\nπŸ”§ You may need to update your Notion database schema")
            return False
            
    except Exception as e:
        print(f"❌ Failed to verify schema: {e}")
        return False


if __name__ == "__main__":
    print("🍽️ FoodWise Schema Verification")
    print("=" * 40)

    parser = argparse.ArgumentParser(description="Verify Notion DB schema against FoodWise models")
    parser.add_argument("database_id", nargs="?", help="Override database ID for the selected DB kind")
    parser.add_argument("--db", choices=["inventory", "shopping", "all"], default="all", help="Which schema to verify")
    args = parser.parse_args()

    def run_one(kind: str) -> bool:
        env_key_local = "NOTION_INVENTORY_DB_ID" if kind == "inventory" else "NOTION_SHOPPING_DB_ID"
        env_db_id_local = os.getenv(env_key_local)
        target_local = args.database_id if args.db != "all" else env_db_id_local
        if not target_local:
            print(f"🎯 {kind}: set {env_key_local} or pass DB_ID when using --db {kind}")
        return verify_database_schema(target_local, db_kind=kind)

    if args.db == "all":
        ok_inv = run_one("inventory")
        ok_shop = run_one("shopping")
        if ok_inv and ok_shop:
            print("\nβœ… Both schemas look compatible.")
            sys.exit(0)
        print("\n❌ One or more schema checks failed.")
        sys.exit(1)
    else:
        success = run_one(args.db)
        if success:
            print("\nβœ… Schema looks compatible.")
            sys.exit(0)
        print("\n❌ Schema check failed.")
        sys.exit(1)