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") # Credentials come from the environment (see .env / docker-compose env_file). # No secret is kept in source; empty default fails fast if the env is missing. CH_PASSWORD = os.environ.get("CLICKHOUSE_PASSWORD", "") 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)})" # manager is Array(String) in pf_board (a task may have several managers), so we # match with hasAny: keep the row if any selected manager is among its managers. MANAGER_COND = "(length({managers:Array(String)}) = 0 OR hasAny(manager, {managers: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. # NB: the bookings SELECT aliases toString(start_date) AS start_date, which would # shadow the Date column in WHERE and cause a String-vs-Date type error. Qualify # with the table name so the comparison always binds to the real Date column. START_COND = "pf_board.start_date >= {date_start:Date}" END_COND = "pf_board.end_date <= {date_end:Date}" # Map endpoint filters board_info (the surface inventory that carries coordinates). # board_info has no `brand` column, so the brand facet is expressed as "this # surface has at least one booking of the selected brand" via a subquery on pf_board. MAP_BRAND_COND = ( "(length({brands:Array(String)}) = 0" " OR board_key IN (SELECT board_key FROM default.pf_board WHERE brand IN {brands:Array(String)}))" ) # Same idea for the manager facet on the map: keep surfaces that have at least one # booking managed by one of the selected managers. MAP_MANAGER_COND = ( "(length({managers:Array(String)}) = 0" " OR board_key IN (SELECT board_key FROM default.pf_board WHERE hasAny(manager, {managers:Array(String)})))" ) def build_where(city=False, dimension=False, brand=False, manager=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 manager: parts.append(MANAGER_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, manager: 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 [] managers = [m for m in manager.split(",") if m] if manager else [] return { "cities": cities, "dimensions": dimensions, "brands": brands, "managers": managers, "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 = "", manager: str = "", search: str = "", date_start: str = "", date_end: str = ""): params = parse_filters(city, dimension, brand, manager, 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, manager=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, manager=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, manager=True, search=True, date_start=hds, date_end=hde)} AND char_length(dimension) > 0 ORDER BY dimension", params, ) # manager is an Array(String) column — ARRAY JOIN to enumerate distinct names. managers = await ch_query( f"SELECT DISTINCT m AS manager FROM default.pf_board ARRAY JOIN manager AS m WHERE {build_where(city=True, dimension=True, brand=True, search=True, date_start=hds, date_end=hde)} AND char_length(m) > 0 ORDER BY manager", params, ) return { "cities": [r["city"] for r in cities], "dimensions": [r["dimension"] for r in dimensions], "brands": [r["brand"] for r in brands], "managers": [r["manager"] for r in managers], } @app.get("/api/boards") async def boards(city: str = "", dimension: str = "", brand: str = "", manager: str = "", search: str = "", date_start: str = "", date_end: str = ""): params = parse_filters(city, dimension, brand, manager, search, date_start, date_end) where = build_where(city=True, dimension=True, brand=True, manager=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 = "", manager: str = "", search: str = "", date_start: str = "", date_end: str = ""): params = parse_filters(city, dimension, brand, manager, search, date_start, date_end) where = build_where(city=True, dimension=True, brand=True, manager=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.get("/api/map") async def map_data(city: str = "", dimension: str = "", brand: str = "", manager: str = "", search: str = ""): # Surfaces come from board_info (has coordinates). Occupancy is computed "as of # today" and is intentionally NOT affected by the date-range filters — only the # Город/Бренд/Размер/Менеджер/Поиск facets narrow which surfaces are shown. params = parse_filters(city, dimension, brand, manager, search, "", "") where = " AND ".join([ "char_length(city) > 0", CITY_COND, DIM_COND, SEARCH_COND, MAP_BRAND_COND, MAP_MANAGER_COND, ]) surfaces_sql = f""" SELECT board_key, board_id, city, address, dimension, board_type, side, latitude AS lat, longitude AS lon FROM default.board_info WHERE {where} ORDER BY city, address """ surfaces = await ch_query(surfaces_sql, params) # All bookings active today, keyed by surface, for marker color + balloon. current_sql = """ SELECT board_key, 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 board_key != 0 AND pf_board.start_date <= today() AND pf_board.end_date >= today() """ current = await ch_query(current_sql, params) by_key: dict = {} for b in current: by_key.setdefault(b["board_key"], []).append(b) out = [] for s in surfaces: active = by_key.get(s["board_key"], []) out.append({ **s, "occupied_now": len(active) > 0, "bookings": active, }) return out app.mount("/", StaticFiles(directory="static", html=True), name="static")