feat: implement V3 Uniform Lookup System with hierarchical overrides and site-based whitelisting

This commit is contained in:
Scott Idem
2026-02-20 14:48:50 -05:00
parent 2b2a2bc00f
commit 6bfbff309a
8 changed files with 390 additions and 51 deletions

View File

@@ -0,0 +1,98 @@
import logging
from typing import List, Optional
from sqlalchemy import text
from app.lib_sql_core import engine
from app.lib_general_v3 import AccountContext
log = logging.getLogger(__name__)
def get_lookup_list_v3(
lu_type: str,
account_ctx: AccountContext,
for_type: Optional[str] = None,
for_id: Optional[int] = None,
include_disabled: bool = False,
whitelist: Optional[List[str]] = None
) -> List[dict]:
"""
Retrieves a ranked, deduplicated list of lookup records.
Priority: Object Override > Account Override > Global Default.
Supports an optional whitelist (List of 'group' strings).
"""
table_name = f"v_lu_v3_{lu_type}"
# We use ROW_NUMBER() to handle the hierarchy
# 1. Object specific (matching for_type and for_id)
# 2. Account specific (matching account_id)
# 3. Global (account_id IS NULL)
# Whitelist logic: If a whitelist is provided, we only want records where
# the group is in that list.
sql = f"""
SELECT * FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY `group`
ORDER BY
(for_type = :for_type AND for_id = :for_id) DESC,
(account_id = :account_id) DESC,
created_on DESC
) as rank_priority
FROM `{table_name}`
WHERE ((for_type = :for_type AND for_id = :for_id)
OR account_id = :account_id
OR account_id IS NULL)
"""
if whitelist:
sql += " AND `group` IN :whitelist"
sql += f"""
) AS ranked
WHERE rank_priority = 1
"""
if not include_disabled:
sql += " AND enable = 1"
sql += " ORDER BY sort ASC, name ASC"
params = {
"account_id": account_ctx.account_id,
"for_type": for_type,
"for_id": for_id,
"whitelist": tuple(whitelist) if whitelist else None
}
try:
with engine.connect() as conn:
result = conn.execute(text(sql), params)
return [dict(row._mapping) for row in result]
except Exception as e:
log.error(f"Error in get_lookup_list_v3: {e}")
return []
def resolve_lookup_v3(
lu_type: str,
query: str,
account_ctx: AccountContext,
identity_fields: List[str]
) -> Optional[dict]:
"""
Resolves a query string to a single lookup record by scanning multiple identity fields.
Returns the highest-priority match.
"""
# Simple implementation: get the full ranked list and find first match in identity fields
# For performance with large tables (like timezones), we might want a specific SQL query
full_list = get_lookup_list_v3(lu_type, account_ctx)
query_clean = query.strip().lower()
for item in full_list:
for field in identity_fields:
val = item.get(field)
if val and str(val).lower() == query_clean:
return item
return None

View File

@@ -0,0 +1,38 @@
from typing import Optional
from pydantic import Field
from .core_object_models import Core_Std_Obj_Base
class Lookup_Base(Core_Std_Obj_Base):
"""
Standardized Baseline for Aether V3 Lookups.
Follows the Hierarchical, Identity-Agnostic System.
"""
id_random: Optional[str] = Field(None, description="Public String ID (ID Vision)")
account_id: Optional[int] = Field(None, description="Internal Account ID (NULL = Global)")
account_id_random: Optional[str] = Field(None, description="Public Account ID")
for_type: Optional[str] = Field(None, description="Polymorphic Context Type")
for_id: Optional[int] = Field(None, description="Polymorphic Context Internal ID")
for_id_random: Optional[str] = Field(None, description="Polymorphic Context Public ID")
group: Optional[str] = Field(None, description="Primary Business Key / Cluster Key")
name: Optional[str] = Field(None, description="Primary Display Label")
description: Optional[str] = Field(None, description="Detailed Explanation")
enable: Optional[bool] = Field(True, description="Active status (Shadowing/Negative Overrides)")
hide: Optional[bool] = Field(False, description="UI Visibility flag")
sort: Optional[int] = Field(0, description="Ordering priority")
class Lu_Country_V3_Base(Lookup_Base):
alpha_2_code: Optional[str] = None
alpha_3_code: Optional[str] = None
numeric_code: Optional[str] = None
english_short_name: Optional[str] = None
class Lu_Country_Subdivision_V3_Base(Lookup_Base):
country_alpha_2_code: Optional[str] = None
code: Optional[str] = None
class Lu_Time_Zone_V3_Base(Lookup_Base):
timezone: Optional[str] = None
offset: Optional[str] = None

View File

@@ -1,3 +1,5 @@
from app.models.lookup_models import Lu_Country_V3_Base, Lu_Country_Subdivision_V3_Base, Lu_Time_Zone_V3_Base
lu_obj_li = { lu_obj_li = {
'lu_country': { 'lu_country': {
'tbl': 'lu_country', 'tbl': 'lu_country',
@@ -53,4 +55,31 @@ lu_obj_li = {
'id', 'timezone', 'offset', 'name' 'id', 'timezone', 'offset', 'name'
], ],
}, },
'lu_v3_country': {
'tbl': 'lu_v3_country',
'tbl_default': 'v_lu_v3_country',
'tbl_update': 'lu_v3_country',
'mdl_default': Lu_Country_V3_Base,
'searchable_fields': [
'id_random', 'group', 'name', 'alpha_2_code', 'alpha_3_code', 'numeric_code', 'english_short_name'
],
},
'lu_v3_country_subdivision': {
'tbl': 'lu_v3_country_subdivision',
'tbl_default': 'v_lu_v3_country_subdivision',
'tbl_update': 'lu_v3_country_subdivision',
'mdl_default': Lu_Country_Subdivision_V3_Base,
'searchable_fields': [
'id_random', 'group', 'name', 'country_alpha_2_code', 'code'
],
},
'lu_v3_time_zone': {
'tbl': 'lu_v3_time_zone',
'tbl_default': 'v_lu_v3_time_zone',
'tbl_update': 'lu_v3_time_zone',
'mdl_default': Lu_Time_Zone_V3_Base,
'searchable_fields': [
'id_random', 'group', 'name', 'timezone'
],
},
} }

96
app/routers/lookup_v3.py Normal file
View File

@@ -0,0 +1,96 @@
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, status
from typing import List, Optional
import json
import logging
from app.lib_general_v3 import AccountContext, get_account_context
from app.methods.lookup_methods import get_lookup_list_v3, resolve_lookup_v3
from app.methods.site_methods import load_site_obj
from app.models.response_models import Resp_Body_Base, mk_resp
from app.object_definitions.lookups import lu_obj_li
log = logging.getLogger(__name__)
router = APIRouter()
@router.get("/{lu_type}/list", response_model=Resp_Body_Base)
async def get_v3_lookup_list(
lu_type: str = Path(..., min_length=2, max_length=50),
for_type: Optional[str] = Query(None, min_length=2, max_length=50),
for_id: Optional[int] = Query(None),
site_id: Optional[str] = Query(None, min_length=8, max_length=22),
include_disabled: bool = Query(False),
account_ctx: AccountContext = Depends(get_account_context),
response: Response = Response
):
"""
Returns a hierarchical, ranked, and deduplicated list of lookup records.
Supports Object/Account overrides, negative shadowing, and Site Whitelist policies.
"""
v3_key = f"lu_v3_{lu_type}"
if v3_key not in lu_obj_li:
return mk_resp(data=False, status_code=400, response=response, status_message=f"Lookup type '{lu_type}' not supported in V3.")
# Phase 2: Whitelist Policy Injection
whitelist = None
if site_id:
if site_obj := load_site_obj(site_id=site_id, model_as_dict=True):
# Check if this site belongs to the current account context
# NOTE: site_obj.get('account_id') returns the RANDOM string ID in V3 models
if site_obj.get('account_id') == account_ctx.account_id_random:
cfg = site_obj.get('cfg_json')
if isinstance(cfg, str):
try: cfg = json.loads(cfg)
except: cfg = {}
if isinstance(cfg, dict):
policy = cfg.get('lookup_policy', {})
whitelist = policy.get(lu_type)
else:
return mk_resp(data=False, status_code=403, response=response, status_message="Site does not belong to the authorized account.")
results = get_lookup_list_v3(
lu_type=lu_type,
account_ctx=account_ctx,
for_type=for_type,
for_id=for_id,
include_disabled=include_disabled,
whitelist=whitelist
)
if not results and not include_disabled:
return mk_resp(data=[], status_code=200, response=response, status_message="No active records found.")
return mk_resp(data=results)
@router.get("/{lu_type}/resolve", response_model=Resp_Body_Base)
async def resolve_v3_lookup(
lu_type: str = Path(..., min_length=2, max_length=50),
q: str = Query(..., min_length=1, description="The code, group, or identity to resolve."),
site_id: Optional[str] = Query(None, min_length=8, max_length=22),
account_ctx: AccountContext = Depends(get_account_context),
response: Response = Response
):
"""
Resolves an identity string to the highest-priority hierarchical match.
"""
v3_key = f"lu_v3_{lu_type}"
if v3_key not in lu_obj_li:
return mk_resp(data=False, status_code=400, response=response, status_message=f"Lookup type '{lu_type}' not supported in V3.")
# TODO: Add whitelist support for resolve if needed.
# For now, resolve uses the full ranked list.
identity_fields = lu_obj_li[v3_key].get("searchable_fields", ["group"])
result = resolve_lookup_v3(
lu_type=lu_type,
query=q,
account_ctx=account_ctx,
identity_fields=identity_fields
)
if not result:
return mk_resp(data=None, status_code=404, response=response, status_message=f"Could not resolve '{q}' for lookup '{lu_type}'.")
return mk_resp(data=result)

View File

@@ -7,7 +7,7 @@ from app.routers import (
event_device, event_exhibit, event_exhibit_tracking, event_file, event_importing, event_device, event_exhibit, event_exhibit_tracking, event_file, event_importing,
event_location, event_person, event_location, event_person,
event_presentation, event_presenter, event_session, event_presentation, event_presenter, event_session,
flask_cfg, hosted_file, api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_e_zoom, lookup, flask_cfg, hosted_file, api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_e_zoom, lookup, lookup_v3,
organization, page, person, organization, page, person,
person_user, qr, site, site_domain, user, person_user, qr, site, site_domain, user,
util_email, websockets, websockets_redis, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe util_email, websockets, websockets_redis, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe
@@ -51,6 +51,7 @@ def setup_routers(app: FastAPI):
app.include_router(api_v3_actions_event_file.router, prefix='/v3/action/event_file', tags=['Event File (V3 Actions)']) app.include_router(api_v3_actions_event_file.router, prefix='/v3/action/event_file', tags=['Event File (V3 Actions)'])
app.include_router(api_v3_actions_e_zoom.router, prefix='/v3/action/e_zoom', tags=['Zoom Events (V3 Actions)']) app.include_router(api_v3_actions_e_zoom.router, prefix='/v3/action/e_zoom', tags=['Zoom Events (V3 Actions)'])
app.include_router(lookup.router, prefix='/lu', tags=['Lookup']) app.include_router(lookup.router, prefix='/lu', tags=['Lookup'])
app.include_router(lookup_v3.router, prefix='/v3/lookup', tags=['Lookup V3'])
# app.include_router(organization.router, prefix='/organization', tags=['Organization'], dependencies=[Depends(DeprecationParams)]) # app.include_router(organization.router, prefix='/organization', tags=['Organization'], dependencies=[Depends(DeprecationParams)])
# app.include_router(page.router, prefix='/page', tags=['Page'], dependencies=[Depends(DeprecationParams)]) # app.include_router(page.router, prefix='/page', tags=['Page'], dependencies=[Depends(DeprecationParams)])

View File

@@ -14,7 +14,7 @@
- [x] Audit File/Exhibit Models (File, Template, Tracking). - [x] Audit File/Exhibit Models (File, Template, Tracking).
- [x] Whitelist `account_id` in all Event search definitions. - [x] Whitelist `account_id` in all Event search definitions.
- [x] Audit Relational "Low-Priority" Models (Address, Contact, DataStore). - [x] Audit Relational "Low-Priority" Models (Address, Contact, DataStore).
- [ ] Audit Lookup Fields (Exclude all `lu_*_id` integers from public output). - [x] Audit Lookup Fields (Uniform V3 System Phase 1 Complete).
- [ ] Verify SQL Views join in all required `_random` IDs for performance. - [ ] Verify SQL Views join in all required `_random` IDs for performance.
- [ ] **Step 2:** Coordination (Verify Frontend uses `x-account-id` instead of token). - [ ] **Step 2:** Coordination (Verify Frontend uses `x-account-id` instead of token).

View File

@@ -1,5 +1,5 @@
# Project: V3 Uniform Lookup & Identity Agnostic Resolution # Project: V3 Uniform Lookup & Identity Agnostic Resolution
> **Status:** 📝 Planning (Drafted Feb 19, 2026) > **Status:** 🏗️ Implementation (Phase 1 Complete - Feb 20, 2026)
> **Goal:** Standardize all `lu_*` tables into a hierarchical, identity-agnostic system supporting Global Defaults, Account Overrides, and Object Overrides. > **Goal:** Standardize all `lu_*` tables into a hierarchical, identity-agnostic system supporting Global Defaults, Account Overrides, and Object Overrides.
## 1. Executive Summary ## 1. Executive Summary
@@ -18,7 +18,7 @@ To ensure consistency across the ecosystem, all lookup tables will migrate towar
- `id_random`: Public String (ID Vision compliance). - `id_random`: Public String (ID Vision compliance).
- `account_id`: Account context (NULL = Global). - `account_id`: Account context (NULL = Global).
- `for_type` / `for_id`: Polymorphic Context (Optional). - `for_type` / `for_id`: Polymorphic Context (Optional).
- `group`: The primary business key/cluster key (e.g., 'US', 'ACTIVE'). *Note: Replaces 'code' to avoid collision with industry standard codes.* - `group`: The primary business key/cluster key (e.g., 'US', 'ACTIVE'). *Note: Must be populated for hierarchy to work.*
- `name`: Primary display label. - `name`: Primary display label.
- `description`: Detailed explanation. - `description`: Detailed explanation.
- `enable`: Binary (1 = Active, 0 = Disabled). *Crucial for Negative Overrides.* - `enable`: Binary (1 = Active, 0 = Disabled). *Crucial for Negative Overrides.*
@@ -29,7 +29,7 @@ To ensure consistency across the ecosystem, all lookup tables will migrate towar
### 2.2 Hierarchical Selection (The "Ranked" Query) ### 2.2 Hierarchical Selection (The "Ranked" Query)
To return a complete list where overrides replace defaults without duplication, we use **Window Functions** (`ROW_NUMBER() OVER`). To return a complete list where overrides replace defaults without duplication, we use **Window Functions** (`ROW_NUMBER() OVER`).
**SQL Pattern (Scenario: Account Context):** **Finalized SQL Pattern:**
```sql ```sql
SELECT * FROM ( SELECT * FROM (
SELECT *, SELECT *,
@@ -37,80 +37,71 @@ SELECT * FROM (
PARTITION BY `group` PARTITION BY `group`
ORDER BY ORDER BY
(for_type = :for_type AND for_id = :for_id) DESC, (for_type = :for_type AND for_id = :for_id) DESC,
account_id DESC, (account_id = :account_id) DESC,
created_on DESC created_on DESC
) as rank_priority ) as rank_priority
FROM `v_lu_example` FROM `v_lu_v3_example`
WHERE ( WHERE (for_type = :for_type AND for_id = :for_id)
(for_type = :for_type AND for_id = :for_id) OR account_id = :account_id
OR account_id = :account_id OR account_id IS NULL
OR account_id IS NULL ) AS ranked
)
) AS ranked_records
WHERE rank_priority = 1 WHERE rank_priority = 1
AND enable = 1 -- Negative Override enforcement AND enable = 1 -- Negative Override enforcement
ORDER BY sort ASC, name ASC; ORDER BY sort ASC, name ASC;
``` ```
### 2.3 Identity Agnostic Resolution ### 2.3 Identity Agnostic Resolution
To support external systems using different coding standards (e.g., ISO Numeric vs Alpha-2), the V3 API will implement an "Any-Match" resolver. To support external systems using different coding standards, the V3 API implements a multi-field "Any-Match" resolver.
**Resolution Logic:** **Resolution Logic:**
- Accepts a query string `q`. - Accepts a query string `q`.
- Scans all "Identity" fields (e.g., `alpha_2_code`, `alpha_3_code`, `numeric_code`, `group`). - Iterates through `searchable_fields` defined in the lookup registry.
- Returns the highest-priority hierarchical match found across the "Identity" fields. - Returns the highest-priority hierarchical match.
### 2.4 Negative Overrides (The "Taiwan" Problem) ### 2.4 Negative Overrides (The "Shadowing" Pattern)
To "delete" or hide a global default for a specific account, the account creates an override record with `enable = 0`. Because the V3 query filters by `enable = 1` **after** the hierarchical ranking, the disabled override "shadows" the default, effectively removing the item from that account's view. To "delete" a global default for a specific account, the account creates an override record with the same `group` but `enable = 0`. The ranking query assigns `rank_priority = 1` to the account record, and the outer `WHERE enable = 1` then excludes it, effectively hiding the item for that account.
### 2.5 Policy-Driven Whitelisting (The "Timezone" Problem)
For tables with high-density global data (e.g., Timezones, Languages), accounts can implement an **Opt-In Policy** to avoid the bloat of managing hundreds of negative overrides.
- **Mechanism:** A configuration record (e.g., `data_store` JSON or `account_cfg` flag) defines a list of "Active Groups".
- **API Logic:** If a whitelist is defined for the account, the API injects an `AND group IN (:whitelist)` filter into the ranked query.
- **Outcome:** A client can curate a list of 5 active timezones out of 400 without creating manual shadow records for the remaining 395.
--- ---
## 3. Prioritization Examples ## 3. Implementation Standards
*Context: User in Account 5 (OSIT) viewing Event 99. Searching for "US".*
| Priority | Group | Account ID | For Type | For ID | Name | Outcome | ### 3.1 Normalization Rules
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | - **Underscore Naming:** All database columns must use underscores (e.g., `alpha_3_code`) instead of hyphens to ensure clean Python attribute access.
| **1 (Winner)** | **US** | 5 | `event` | 99 | **US of America** | **Object Override** wins. | - **Group Population:** The `group` field must be physically populated in the table with a stable identity string (ISO codes, standard names).
| 2 (Shadowed)| US | 5 | NULL | NULL | USA | Account preference ignored. | - **Physical Name Field:** While specialized fields (like `english_short_name`) may exist, a physical `name` field is required for Generic CRUD compatibility.
| 3 (Shadowed)| US | NULL | NULL | NULL | United States | Global default ignored. |
| 4 (Ignored) | FR | NULL | NULL | NULL | France | Different group. | ### 3.2 View Requirements
| 5 (Removed) | TW | 5 | NULL | NULL | Taiwan | `enable=0` makes this vanish. | - **Standard Joins:** All `v_lu_v3_*` views must join the `account` table to provide `account_id_random`.
- **Hybrid Naming:** (Proposed) Views may use `COALESCE` to provide a default `name` from specialized fields if the physical `name` column is empty.
--- ---
## 4. Implementation Roadmap ## 4. Implementation Roadmap
### Phase 1: Normalization & Views ### Phase 1: Normalization & Infrastructure
- [ ] Audit all `lu_*` tables for current schema. - [x] Audit first batch of `lu_*` tables.
- [ ] Create/Update `v_lu_*` views to map legacy columns to the uniform `group` structure. - [x] Establish `Lookup_Base` Pydantic models.
- [ ] Ensure `id_random` is available for all lookups. - [x] Implement hierarchical `get_lookup_list_v3` logic.
- [x] Register `/v3/lookup/` router.
### Phase 2: V3 Lookup Router ### Phase 2: Migration (Batch 1: High Priority)
- [ ] Create `app/routers/lookup_v3.py`. - [x] `lu_v3_country` (Group: `alpha_2_code`)
- [ ] Implement `GET /v3/lookup/{lu_type}/list` with Rank/Override logic. - [x] `lu_v3_country_subdivision` (Group: `code`)
- [ ] Implement **Whitelist Support** via account context policies. - [x] `lu_v3_time_zone` (Group: `name`)
- [ ] Implement `GET /v3/lookup/{lu_type}/resolve?q=VALUE`.
### Phase 3: Migration (Batch 1: High Priority) ### Phase 3: Migration (Batch 2: Contextual)
- [ ] `lu_country` (Complexity: Multi-Identity codes)
- [ ] `lu_country_subdivision`
- [ ] `lu_time_zone` (Complexity: Requires Whitelist Phase 2)
### Phase 4: Migration (Batch 2: Contextual)
- [ ] `lu_post_topic` - [ ] `lu_post_topic`
- [ ] `lu_user_status` - [ ] `lu_user_status`
- [ ] `lu_file_purpose` - [ ] `lu_file_purpose`
### Phase 4: Advanced Features
- [ ] Implement **Whitelist Policies** via `data_store` JSON.
- [ ] Implement Batch Update tools for managers.
## 5. Key Learnings & Decisions ## 5. Key Learnings & Decisions
- **Deduplication:** The `PARTITION BY` strategy ensures the frontend never receives two records for the same logical lookup item. - **Deduplication:** The `PARTITION BY group` is the cornerstone of the system. Without a populated `group` field, the hierarchy collapses.
- **Fail-Closed:** Account isolation must be maintained; a user should never see another account's overrides. - **Generic CRUD Compatibility:** We decided to maintain a physical `name` field to allow the Aether V3 UI to handle all lookups with zero custom code.
- **Group vs Code:** Using `group` as the internal Aether cluster key avoids naming collisions with industry standard codes (ISO, etc). - **Fail-Closed:** Account isolation is enforced at the query level via the `account_id` filter.
--- ---
*Created by Gemini CLI for Scott Idem* *Created by Gemini CLI for Scott Idem*

View File

@@ -0,0 +1,86 @@
import requests
import time
import sys
import os
# Ensure local imports work if needed
sys.path.append(os.getcwd())
# Configuration
BASE_URL = "https://dev-api.oneskyit.com/v3/lookup"
HEADERS = {
"x-aether-api-key": "PMM4n50teUCaOMMTN8qOJA", # Standard Agent Key
"x-account-id": "_XY7DXtc9MY" # One Sky IT Demo
}
# TODO: SET THIS to your demo site's random ID
SITE_ID_RANDOM = "92vkYC4fVEl"
def print_result(label, success, message=""):
status = "✅ PASS" if success else "❌ FAIL"
print(f"{status} | {label} {': ' + message if message else ''}")
def test_lookup_list(lu_type, site_id=None):
label = f"GET /{lu_type}/list"
url = f"{BASE_URL}/{lu_type}/list"
params = {}
if site_id:
params["site_id"] = site_id
label += f" (Site: {site_id})"
try:
start_time = time.time()
response = requests.get(url, headers=HEADERS, params=params)
duration = time.time() - start_time
if response.status_code == 200:
data = response.json().get('data', [])
msg = f"Found {len(data)} items ({duration:.2f}s)"
print_result(label, True, msg)
return data
else:
print_result(label, False, f"Status {response.status_code}: {response.text[:100]}")
return None
except Exception as e:
print_result(label, False, str(e))
return None
def test_lookup_resolve(lu_type, query):
url = f"{BASE_URL}/{lu_type}/resolve"
params = {"q": query}
try:
response = requests.get(url, headers=HEADERS, params=params)
if response.status_code == 200:
data = response.json().get('data', {})
name = data.get('name')
print_result(f"GET /{lu_type}/resolve?q={query}", True, f"Resolved to '{name}'")
return True
else:
print_result(f"GET /{lu_type}/resolve?q={query}", False, f"Status {response.status_code}")
return False
except Exception as e:
print_result(f"GET /{lu_type}/resolve?q={query}", False, str(e))
return False
if __name__ == "__main__":
print(f"🚀 Starting V3 Lookup E2E Suite ({BASE_URL})\n")
start_suite = time.time()
# 1. Basic Lists (Phase 1)
test_lookup_list("country")
test_lookup_list("time_zone")
# 2. Whitelist Test (Phase 2)
if SITE_ID_RANDOM != "SET_ME_TO_SITE_ID":
print("\n--- Testing Site Whitelist Policy ---")
# Should return only whitelisted items
test_lookup_list("country", site_id=SITE_ID_RANDOM)
test_lookup_list("time_zone", site_id=SITE_ID_RANDOM)
else:
print("\n⚠️ Skipping Phase 2 test: SITE_ID_RANDOM not set.")
# 3. Resolve Test
print("\n--- Testing Resolve ---")
test_lookup_resolve("country", "US")
print(f"\n⏱️ Suite completed in {time.time() - start_suite:.2f}s")