6856d09cd1
Limit world overview payload, optimize SQL joins, cache bundle and tiles, reuse map instance, and show loading state.
287 lines
10 KiB
Python
287 lines
10 KiB
Python
"""Export Intel Cockpit page + API proxy."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Query, Request
|
|
from fastapi.responses import RedirectResponse, Response, StreamingResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
from pathlib import Path
|
|
|
|
router = APIRouter()
|
|
BASE = Path(__file__).resolve().parent.parent.parent
|
|
templates = Jinja2Templates(directory=str(BASE / "templates"))
|
|
TOOLS = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
|
|
|
|
_TILE_CACHE: dict[str, tuple[float, bytes]] = {}
|
|
_TILE_CACHE_TTL = 86400.0
|
|
_TILE_CACHE_MAX = 900
|
|
|
|
_BUNDLE_CACHE: dict[str, tuple[float, bytes]] = {}
|
|
_BUNDLE_CACHE_TTL = 120.0
|
|
|
|
|
|
|
|
async def _proxy(method: str, path: str, **kwargs) -> Any:
|
|
url = f"{TOOLS}/export-intel{path}"
|
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
r = await client.request(method, url, **kwargs)
|
|
if r.status_code >= 400:
|
|
return {"error": r.text, "status": r.status_code}
|
|
if "text/csv" in r.headers.get("content-type", ""):
|
|
return r
|
|
return r.json()
|
|
|
|
|
|
@router.get("/foodlinkk")
|
|
def foodlinkk_home():
|
|
return RedirectResponse(url="/export-intel", status_code=302)
|
|
|
|
|
|
@router.get("/export-intel")
|
|
def export_intel_page(request: Request):
|
|
return templates.TemplateResponse(
|
|
"export_intel.html",
|
|
{"request": request, "page_title": "Wereldexport"},
|
|
)
|
|
|
|
|
|
@router.get("/api/export-intel/stats")
|
|
async def api_stats(country: Optional[str] = None, region: Optional[str] = None):
|
|
params = {k: v for k, v in {"country": country, "region": region}.items() if v}
|
|
return await _proxy("GET", "/stats", params=params)
|
|
|
|
|
|
@router.get("/api/export-intel/regions")
|
|
async def api_regions():
|
|
return await _proxy("GET", "/regions")
|
|
|
|
|
|
@router.get("/api/export-intel/territories")
|
|
async def api_territories(region: Optional[str] = None):
|
|
params = {"region": region} if region else {}
|
|
return await _proxy("GET", "/territories", params=params)
|
|
|
|
|
|
@router.get("/api/export-intel/entities")
|
|
async def api_entities(
|
|
country: Optional[str] = None,
|
|
region: Optional[str] = None,
|
|
entity_type: Optional[str] = None,
|
|
entity_types: Optional[str] = None,
|
|
q: Optional[str] = None,
|
|
favorite_only: bool = False,
|
|
crm_linked: Optional[bool] = None,
|
|
limit: int = 200,
|
|
offset: int = 0,
|
|
):
|
|
params = {k: v for k, v in {
|
|
"country": country, "region": region, "entity_type": entity_type,
|
|
"entity_types": entity_types, "q": q, "limit": limit, "offset": offset,
|
|
"favorite_only": favorite_only, "crm_linked": crm_linked,
|
|
}.items() if v is not None and v is not False}
|
|
return await _proxy("GET", "/entities", params=params)
|
|
|
|
|
|
@router.get("/api/export-intel/entities/{entity_id}")
|
|
async def api_entity(entity_id: int):
|
|
return await _proxy("GET", f"/entities/{entity_id}")
|
|
|
|
|
|
@router.get("/api/export-intel/contacts")
|
|
async def api_contacts(
|
|
country: Optional[str] = None,
|
|
region: Optional[str] = None,
|
|
entity_type: Optional[str] = None,
|
|
has_email: Optional[bool] = None,
|
|
crm_linked: Optional[bool] = None,
|
|
q: Optional[str] = None,
|
|
limit: int = 200,
|
|
offset: int = 0,
|
|
):
|
|
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
|
if country:
|
|
params["country"] = country
|
|
if region:
|
|
params["region"] = region
|
|
if entity_type:
|
|
params["entity_type"] = entity_type
|
|
if has_email is not None:
|
|
params["has_email"] = has_email
|
|
if crm_linked is not None:
|
|
params["crm_linked"] = crm_linked
|
|
if q:
|
|
params["q"] = q
|
|
return await _proxy("GET", "/contacts", params=params)
|
|
|
|
|
|
@router.get("/api/export-intel/contacts/export.csv")
|
|
async def api_contacts_export(
|
|
country: Optional[str] = None,
|
|
region: Optional[str] = None,
|
|
entity_type: Optional[str] = None,
|
|
has_email: Optional[bool] = None,
|
|
crm_linked: Optional[bool] = None,
|
|
q: Optional[str] = None,
|
|
):
|
|
params = {
|
|
k: v
|
|
for k, v in {
|
|
"country": country,
|
|
"region": region,
|
|
"entity_type": entity_type,
|
|
"has_email": has_email,
|
|
"crm_linked": crm_linked,
|
|
"q": q,
|
|
}.items()
|
|
if v is not None and v != ""
|
|
}
|
|
url = f"{TOOLS}/export-intel/contacts/export.csv"
|
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
r = await client.get(url, params=params)
|
|
return StreamingResponse(
|
|
iter([r.content]),
|
|
media_type="text/csv",
|
|
headers={"Content-Disposition": "attachment; filename=export-intel-contacts.csv"},
|
|
)
|
|
|
|
|
|
@router.get("/api/export-intel/caterers/brands")
|
|
async def api_caterer_brands():
|
|
return await _proxy("GET", "/caterers/brands")
|
|
|
|
|
|
@router.get("/api/export-intel/caterers/presence")
|
|
async def api_caterer_presence(country: Optional[str] = None):
|
|
params = {"country": country} if country else {}
|
|
return await _proxy("GET", "/caterers/presence", params=params)
|
|
|
|
|
|
@router.get("/api/export-intel/gov-sources")
|
|
async def api_gov_sources(country: Optional[str] = None):
|
|
params = {"country": country} if country else {}
|
|
return await _proxy("GET", "/gov-sources", params=params)
|
|
|
|
|
|
|
|
|
|
@router.get("/api/map/tiles/{z}/{x}/{y}.png")
|
|
async def map_tile_proxy(z: int, x: int, y: int) -> Response:
|
|
"""Proxy Carto dark basemap tiles via cockpit (cached, no client internet needed)."""
|
|
key = f"{z}/{x}/{y}"
|
|
hit = _TILE_CACHE.get(key)
|
|
if hit and time.time() - hit[0] < _TILE_CACHE_TTL:
|
|
return Response(content=hit[1], media_type="image/png", headers={"Cache-Control": "public, max-age=604800"})
|
|
|
|
host = ("a", "b", "c", "d")[(x + y) % 4]
|
|
url = f"https://{host}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"
|
|
try:
|
|
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
|
|
r = await client.get(url)
|
|
if r.status_code != 200:
|
|
return Response(status_code=502, content=b"")
|
|
body = r.content
|
|
if len(_TILE_CACHE) >= _TILE_CACHE_MAX:
|
|
oldest = min(_TILE_CACHE.items(), key=lambda item: item[1][0])[0]
|
|
_TILE_CACHE.pop(oldest, None)
|
|
_TILE_CACHE[key] = (time.time(), body)
|
|
return Response(content=body, media_type="image/png", headers={"Cache-Control": "public, max-age=604800"})
|
|
except Exception:
|
|
return Response(status_code=502, content=b"")
|
|
|
|
@router.get("/api/export-intel/map/bundle")
|
|
async def api_map_bundle(
|
|
country: Optional[str] = None,
|
|
region: Optional[str] = None,
|
|
entity_type: Optional[str] = None,
|
|
entity_types: Optional[str] = None,
|
|
q: Optional[str] = None,
|
|
halal_min: Optional[float] = None,
|
|
favorite_only: bool = False,
|
|
crm_linked: Optional[bool] = None,
|
|
):
|
|
params = {k: v for k, v in {
|
|
"country": country, "region": region, "entity_type": entity_type,
|
|
"entity_types": entity_types, "q": q, "halal_min": halal_min,
|
|
"favorite_only": favorite_only, "crm_linked": crm_linked,
|
|
}.items() if v is not None and v is not False}
|
|
import hashlib
|
|
import json
|
|
|
|
cache_key = hashlib.md5(json.dumps(params, sort_keys=True, default=str).encode()).hexdigest()
|
|
hit = _BUNDLE_CACHE.get(cache_key)
|
|
if hit and time.time() - hit[0] < _BUNDLE_CACHE_TTL:
|
|
return Response(content=hit[1], media_type="application/json")
|
|
|
|
url = f"{TOOLS}/export-intel/map/bundle"
|
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
r = await client.get(url, params=params)
|
|
if r.status_code >= 400:
|
|
return {"error": r.text, "status": r.status_code}
|
|
body = r.content
|
|
if len(_BUNDLE_CACHE) > 32:
|
|
oldest = min(_BUNDLE_CACHE.items(), key=lambda item: item[1][0])[0]
|
|
_BUNDLE_CACHE.pop(oldest, None)
|
|
_BUNDLE_CACHE[cache_key] = (time.time(), body)
|
|
return Response(content=body, media_type="application/json")
|
|
|
|
|
|
@router.post("/api/export-intel/entities/favorites")
|
|
async def api_set_favorites(request: Request):
|
|
body = await request.json()
|
|
return await _proxy("POST", "/entities/favorites", json=body)
|
|
|
|
|
|
@router.get("/api/export-intel/entities/favorites")
|
|
async def api_list_favorites(country: Optional[str] = None, region: Optional[str] = None, limit: int = 200):
|
|
params = {k: v for k, v in {"country": country, "region": region, "limit": limit}.items() if v is not None}
|
|
return await _proxy("GET", "/entities/favorites", params=params)
|
|
|
|
|
|
@router.post("/api/export-intel/crm/push")
|
|
async def api_crm_push(request: Request):
|
|
body = await request.json()
|
|
return await _proxy("POST", "/crm/push", json=body)
|
|
|
|
|
|
@router.get("/api/export-intel/crm/pipeline")
|
|
async def api_crm_pipeline(country: Optional[str] = None, region: Optional[str] = None, limit: int = 100):
|
|
params = {k: v for k, v in {"country": country, "region": region, "limit": limit}.items() if v is not None}
|
|
return await _proxy("GET", "/crm/pipeline", params=params)
|
|
|
|
|
|
@router.get("/api/export-intel/halal/markets")
|
|
async def api_halal_markets(
|
|
region: Optional[str] = None,
|
|
country: Optional[str] = None,
|
|
limit: int = 30,
|
|
):
|
|
params = {k: v for k, v in {"region": region, "country": country, "limit": limit}.items() if v is not None}
|
|
return await _proxy("GET", "/halal/markets", params=params)
|
|
|
|
|
|
@router.get("/api/export-intel/tenders")
|
|
async def api_tenders(country: Optional[str] = None, status: Optional[str] = "open", limit: int = 100):
|
|
params = {k: v for k, v in {"country": country, "status": status, "limit": limit}.items() if v is not None}
|
|
return await _proxy("GET", "/tenders", params=params)
|
|
|
|
|
|
@router.post("/api/export-intel/sync/{kind}")
|
|
async def api_sync(kind: str, request: Request):
|
|
body = {}
|
|
if request.headers.get("content-type", "").startswith("application/json"):
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
if kind not in ("contacts", "caterers", "distributors", "customers", "tenders", "all", "world", "region"):
|
|
return {"error": "unknown sync kind"}
|
|
timeout = 7200.0 if kind in ("world", "region", "all") else 600.0
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
url = f"{TOOLS}/export-intel/sync/{kind}"
|
|
r = await client.post(url, json=body)
|
|
return r.json()
|