feat: implement V3 Uniform Lookup System with hierarchical overrides and site-based whitelisting
This commit is contained in:
98
app/methods/lookup_methods.py
Normal file
98
app/methods/lookup_methods.py
Normal 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
|
||||
38
app/models/lookup_models.py
Normal file
38
app/models/lookup_models.py
Normal 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
|
||||
@@ -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
96
app/routers/lookup_v3.py
Normal 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)
|
||||
@@ -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)])
|
||||
|
||||
Reference in New Issue
Block a user