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_country': {
'tbl': 'lu_country',
@@ -53,4 +55,31 @@ lu_obj_li = {
'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_location, event_person,
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,
person_user, qr, site, site_domain, user,
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_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_v3.router, prefix='/v3/lookup', tags=['Lookup V3'])
# app.include_router(organization.router, prefix='/organization', tags=['Organization'], dependencies=[Depends(DeprecationParams)])
# app.include_router(page.router, prefix='/page', tags=['Page'], dependencies=[Depends(DeprecationParams)])