8d2766efb6
Stable map grid, no fitBounds on world view, entity PATCH edit form, CRM buttons on distributors, and loading animation for sync/wait states.
294 lines
10 KiB
Python
294 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.patch("/api/export-intel/entities/{entity_id}")
|
|
async def api_update_entity(entity_id: int, request: Request):
|
|
body = await request.json()
|
|
return await _proxy("PATCH", f"/entities/{entity_id}", json=body)
|
|
|
|
@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()
|