import os import httpx from fastapi import FastAPI, Query from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles CH_HOST = os.environ.get("CLICKHOUSE_HOST", "ClickHouse") CH_PORT = os.environ.get("CLICKHOUSE_PORT", "8123") CH_USER = os.environ.get("CLICKHOUSE_USER", "default") CH_PASSWORD = os.environ.get("CLICKHOUSE_PASSWORD", "1234567890") CH_URL = f"http://{CH_HOST}:{CH_PORT}/" app = FastAPI() CITY_COND = "(length({cities:Array(String)}) = 0 OR city IN {cities:Array(String)})" DIM_COND = "(length({dimensions:Array(String)}) = 0 OR dimension IN {dimensions:Array(String)})" BRAND_COND = "(length({brands:Array(String)}) = 0 OR brand IN {brands:Array(String)})" SEARCH_COND = ( "({search:String} = ''" " OR positionCaseInsensitive(address, {search:String}) > 0" " OR positionCaseInsensitive(board_id, {search:String}) > 0)" ) # Independent date bounds: start_date >= X ("Дата начала"), end_date <= Y # ("Дата окончания"). Each included only when its value is provided. START_COND = "pf_board.start_date >= {date_start:Date}" END_COND = "pf_board.end_date <= {date_end:Date}" def build_where(city=False, dimension=False, brand=False, search=False, date_start=False, date_end=False): # Faceted WHERE: include only the requested facet conditions. For the # dependent-filter option lists we exclude a facet's own condition so its # available values reflect the OTHER filters (all-but-self). parts = ["char_length(city) > 0"] if city: parts.append(CITY_COND) if dimension: parts.append(DIM_COND) if brand: parts.append(BRAND_COND) if search: parts.append(SEARCH_COND) if date_start: parts.append(START_COND) if date_end: parts.append(END_COND) return "\n AND ".join(parts) async def ch_query(sql: str, params: dict): form = {"query": sql, "default_format": "JSONEachRow"} for k, v in params.items(): if isinstance(v, list): form[f"param_{k}"] = "[" + ",".join("'" + str(x).replace("'", "\\'") + "'" for x in v) + "]" else: form[f"param_{k}"] = str(v) async with httpx.AsyncClient(timeout=15) as client: r = await client.post(CH_URL, params=form, auth=(CH_USER, CH_PASSWORD)) r.raise_for_status() text = r.text.strip() if not text: return [] import json return [json.loads(line) for line in text.splitlines()] def parse_filters(city: str, dimension: str, brand: str, search: str, date_start: str, date_end: str): cities = [c for c in city.split(",") if c] if city else [] dimensions = [d for d in dimension.split(",") if d] if dimension else [] brands = [b for b in brand.split(",") if b] if brand else [] return { "cities": cities, "dimensions": dimensions, "brands": brands, "search": search or "", "date_start": date_start or "", "date_end": date_end or "", } @app.get("/api/meta") async def meta(city: str = "", dimension: str = "", brand: str = "", search: str = "", date_start: str = "", date_end: str = ""): params = parse_filters(city, dimension, brand, search, date_start, date_end) hds = bool(params["date_start"]) hde = bool(params["date_end"]) cities = await ch_query( f"SELECT DISTINCT city FROM default.pf_board WHERE {build_where(dimension=True, brand=True, search=True, date_start=hds, date_end=hde)} AND char_length(city) > 0 ORDER BY city", params, ) brands = await ch_query( f"SELECT DISTINCT brand FROM default.pf_board WHERE {build_where(city=True, dimension=True, search=True, date_start=hds, date_end=hde)} AND char_length(brand) > 0 ORDER BY brand", params, ) dimensions = await ch_query( f"SELECT DISTINCT dimension FROM default.pf_board WHERE {build_where(city=True, brand=True, search=True, date_start=hds, date_end=hde)} AND char_length(dimension) > 0 ORDER BY dimension", params, ) return { "cities": [r["city"] for r in cities], "dimensions": [r["dimension"] for r in dimensions], "brands": [r["brand"] for r in brands], } @app.get("/api/boards") async def boards(city: str = "", dimension: str = "", brand: str = "", search: str = "", date_start: str = "", date_end: str = ""): params = parse_filters(city, dimension, brand, search, date_start, date_end) where = build_where(city=True, dimension=True, brand=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"])) sql = f""" SELECT board_id, argMax(address, last_activity) AS board_address, argMax(city, last_activity) AS board_city, argMax(dimension, last_activity) AS board_dimension FROM default.pf_board WHERE {where} GROUP BY board_id ORDER BY board_address """ rows = await ch_query(sql, params) return rows @app.get("/api/bookings") async def bookings(city: str = "", dimension: str = "", brand: str = "", search: str = "", date_start: str = "", date_end: str = ""): params = parse_filters(city, dimension, brand, search, date_start, date_end) where = build_where(city=True, dimension=True, brand=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"])) sql = f""" SELECT board_id, toString(task_id) AS task_id, task_status, toString(start_date) AS start_date, toString(end_date) AS end_date, brand, company_name FROM default.pf_board WHERE {where} ORDER BY board_id, start_date """ rows = await ch_query(sql, params) return rows app.mount("/", StaticFiles(directory="static", html=True), name="static")