From 6c47dada00dcacac6e3d4eb976b135ce36b0f6d9 Mon Sep 17 00:00:00 2001 From: aaverbitskiy Date: Fri, 17 Jul 2026 15:13:27 +0000 Subject: [PATCH] Import mapdash (FastAPI + vis-timeline) + docker-compose with static volume --- .gitignore | 3 + Dockerfile | 8 + docker-compose.yml | 12 + main.py | 104 ++++++ requirements.txt | 3 + static/index.html | 912 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1042 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 static/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e26682 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.bak-* +*.predeploy-* +__pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..61e2474 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY main.py . +COPY static ./static +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4cf722d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + mapdash: + build: . + image: mapdash:latest + container_name: mapdash + restart: unless-stopped + networks: [edge] + volumes: + - ./static:/app/static:ro +networks: + edge: + external: true diff --git a/main.py b/main.py new file mode 100644 index 0000000..bca159e --- /dev/null +++ b/main.py @@ -0,0 +1,104 @@ +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() + +FILTER_SQL = """ + char_length(city) > 0 + AND (length({cities:Array(String)}) = 0 OR city IN {cities:Array(String)}) + AND (length({dimensions:Array(String)}) = 0 OR dimension IN {dimensions:Array(String)}) + AND ( + {search:String} = '' + OR positionCaseInsensitive(address, {search:String}) > 0 + OR positionCaseInsensitive(board_id, {search:String}) > 0 + ) +""" + + +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, search: 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 [] + return {"cities": cities, "dimensions": dimensions, "search": search or ""} + + +@app.get("/api/meta") +async def meta(): + cities = await ch_query( + "SELECT DISTINCT city FROM default.pf_board WHERE char_length(city) > 0 ORDER BY city", + {}, + ) + dimensions = await ch_query( + "SELECT DISTINCT dimension FROM default.pf_board WHERE char_length(dimension) > 0 ORDER BY dimension", + {}, + ) + return { + "cities": [r["city"] for r in cities], + "dimensions": [r["dimension"] for r in dimensions], + } + + +@app.get("/api/boards") +async def boards(city: str = "", dimension: str = "", search: str = ""): + params = parse_filters(city, dimension, search) + 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 {FILTER_SQL} + 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 = "", search: str = ""): + params = parse_filters(city, dimension, search) + 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 {FILTER_SQL} + ORDER BY board_id, start_date + """ + rows = await ch_query(sql, params) + return rows + + +app.mount("/", StaticFiles(directory="static", html=True), name="static") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e5dd7aa --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.0 +uvicorn==0.30.6 +httpx==0.27.2 diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..0bf588b --- /dev/null +++ b/static/index.html @@ -0,0 +1,912 @@ + + + + + +Адресная программа + + + + + +
+
+
Green Media
+
Адресная программа
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+
+ +
+
+
+ +
+ +
+ + + + +