Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ dependencies = [
"requests>=2.32.3",
"googleads==46.0.0", # Pinned to avoid API deprecation issues
"flask>=3.0.0",
"flask-caching>=2.3.0", # Response caching for improved performance
"psycopg2-binary>=2.9.9",
"authlib>=1.3.0",
"requests-oauthlib>=1.3.1",
Expand Down
11 changes: 11 additions & 0 deletions scripts/deploy/entrypoint_admin.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ done
echo "📦 Running database migrations..."
python migrate.py

# Debug: Check Python environment
echo "🔍 Debugging Python environment..."
echo "Python location: $(which python)"
echo "Python version: $(python --version)"
python -c "import sys; print('Python executable:', sys.executable)"
python -c "import sys; print('sys.path:', sys.path)"

# Debug: Check if flask_caching is importable
echo "🔍 Checking flask_caching availability..."
python -c "import flask_caching; print('✅ flask_caching version:', flask_caching.__version__)" || echo "❌ flask_caching import failed"

# Start the admin UI
echo "🌐 Starting Admin UI..."
exec python -m src.admin.server
11 changes: 11 additions & 0 deletions src/admin/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,17 @@ def from_json_filter(s):
# Initialize OAuth
init_oauth(app)

# Initialize Flask-Caching for improved performance
from flask_caching import Cache

cache_config = {
"CACHE_TYPE": "SimpleCache", # In-memory cache (good for single-process deployments)
"CACHE_DEFAULT_TIMEOUT": 300, # 5 minutes default
}
app.config.update(cache_config)
cache = Cache(app)
app.cache = cache # Make cache available to blueprints

# Initialize SocketIO
socketio = SocketIO(app, cors_allowed_origins="*", async_mode="threading")
app.socketio = socketio
Expand Down
114 changes: 108 additions & 6 deletions src/admin/blueprints/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -770,10 +770,26 @@ def get_sync_status(tenant_id):
def get_inventory_tree(tenant_id):
"""Get ad unit hierarchy tree structure for tree view.

Query Parameters:
search (str, optional): Search term to filter ad units by name or path

Returns:
JSON with hierarchical tree of ad units including parent-child relationships
"""
logger.info(f"Inventory tree request for tenant: {tenant_id}")
from flask import current_app, request

search = request.args.get("search", "").strip()

# Use cache if available (5 minute TTL) - only cache when no search
cache = getattr(current_app, "cache", None)
if cache and not search:
cache_key = f"inventory_tree:v2:{tenant_id}" # v2: added search_active/matching_count fields
cached_result = cache.get(cache_key)
if cached_result:
logger.info(f"Returning cached inventory tree for tenant: {tenant_id}")
return cached_result

logger.info(f"Building inventory tree for tenant: {tenant_id}, search: '{search}'")
try:
with get_db_session() as db_session:
from src.core.database.models import GAMInventory
Expand All @@ -784,14 +800,73 @@ def get_inventory_tree(tenant_id):
GAMInventory.inventory_type == "ad_unit",
GAMInventory.status == "ACTIVE",
)
all_units = db_session.scalars(stmt).all()

logger.info(f"Found {len(all_units)} active ad units in database")
# If search term provided, filter by name or path
if search:
stmt = stmt.where(
or_(
GAMInventory.name.ilike(f"%{search}%"),
func.cast(GAMInventory.path, String).ilike(f"%{search}%"),
)
)

matching_units = db_session.scalars(stmt).all()

logger.info(f"Found {len(matching_units)} matching ad units")

# If search is active, we need to include all ancestor nodes
# to build the proper tree hierarchy
if search and matching_units:
# Collect all parent IDs from matching units
ancestor_ids = set()
for unit in matching_units:
metadata = unit.inventory_metadata or {}
if isinstance(metadata, dict):
parent_id = metadata.get("parent_id")
# Walk up the tree to get all ancestors
while parent_id:
if parent_id not in ancestor_ids:
ancestor_ids.add(parent_id)
# Fetch the parent to get its parent_id
parent_stmt = select(GAMInventory).where(
GAMInventory.tenant_id == tenant_id,
GAMInventory.inventory_id == parent_id,
)
parent_unit = db_session.scalars(parent_stmt).first()
if parent_unit:
parent_metadata = parent_unit.inventory_metadata or {}
if isinstance(parent_metadata, dict):
parent_id = parent_metadata.get("parent_id")
else:
break
else:
break
else:
break

# Fetch all ancestor nodes
if ancestor_ids:
ancestor_stmt = select(GAMInventory).where(
GAMInventory.tenant_id == tenant_id,
GAMInventory.inventory_id.in_(ancestor_ids),
)
ancestor_units = db_session.scalars(ancestor_stmt).all()
all_units = list(matching_units) + list(ancestor_units)
logger.info(f"Added {len(ancestor_units)} ancestor nodes for tree structure")
else:
all_units = list(matching_units)
else:
all_units = matching_units

logger.info(f"Building tree from {len(all_units)} total nodes")

# Build tree structure
units_by_id = {}
root_units = []

# Track which units matched the search (for highlighting)
matching_ids = {unit.inventory_id for unit in matching_units} if search else set()

# First pass: create all unit objects
for unit in all_units:
metadata = unit.inventory_metadata or {}
Expand All @@ -806,6 +881,8 @@ def get_inventory_tree(tenant_id):
"path": unit.path or [unit.name],
"parent_id": metadata.get("parent_id"),
"has_children": metadata.get("has_children", False),
"matched_search": unit.inventory_id in matching_ids, # Flag for highlighting
"sizes": metadata.get("sizes", []), # Include sizes for format matching
"children": [],
}
units_by_id[unit.inventory_id] = unit_obj
Expand Down Expand Up @@ -872,7 +949,7 @@ def get_inventory_tree(tenant_id):
f"Labels: {labels_count}, Targeting Keys: {targeting_count}, Audience Segments: {segments_count}"
)

return jsonify(
result = jsonify(
{
"root_units": root_units,
"total_units": len(all_units),
Expand All @@ -881,9 +958,17 @@ def get_inventory_tree(tenant_id):
"labels": labels_count,
"custom_targeting_keys": targeting_count,
"audience_segments": segments_count,
"search_active": bool(search), # Flag to indicate filtered results
"matching_count": len(matching_ids) if search else 0,
}
)

# Cache the result for 5 minutes - only when no search
if cache and not search:
cache.set(cache_key, result, timeout=300)

return result

except Exception as e:
logger.error(f"Error building inventory tree for tenant {tenant_id}: {e}", exc_info=True)
return jsonify({"error": str(e)}), 500
Expand All @@ -903,14 +988,25 @@ def get_inventory_list(tenant_id):
Returns:
JSON array of inventory items with id, name, type, path, status
"""
from flask import current_app

try:
inventory_type = request.args.get("type") # 'ad_unit' or 'placement' or None for both
search = request.args.get("search", "").strip()
status = request.args.get("status", "ACTIVE")
ids_param = request.args.get("ids", "").strip() # Comma-separated IDs

# Use cache if available and no search term (5 minute TTL)
cache = getattr(current_app, "cache", None)
if cache and not search:
cache_key = f"inventory_list:{tenant_id}:{inventory_type or 'all'}:{status}"
cached_result = cache.get(cache_key)
if cached_result:
logger.info(f"Returning cached inventory list for tenant: {tenant_id}")
return cached_result

logger.info(
f"Inventory list query: tenant={tenant_id}, type={inventory_type or 'all'}, "
f"Building inventory list: tenant={tenant_id}, type={inventory_type or 'all'}, "
f"search='{search}', status={status}"
)

Expand Down Expand Up @@ -1013,7 +1109,13 @@ def get_inventory_list(tenant_id):
)

logger.info(f"Returning {len(result)} formatted inventory items to UI")
return jsonify({"items": result, "count": len(result), "has_more": len(result) >= 500})
response = jsonify({"items": result, "count": len(result), "has_more": len(result) >= 500})

# Cache the result for 5 minutes (only if no search term)
if cache and not search:
cache.set(cache_key, response, timeout=300)

return response

except Exception as e:
logger.error(f"Error fetching inventory list: {e}", exc_info=True)
Expand Down
Loading