mapdash/main.py
aaverbitskiy 8e6c19e9a4 feat: surface photos in the map balloon + fullscreen lightbox (v0.2.7)
Photos live on disk as photos/<board_key>/<file>.{jpg,jpeg,png} (a read-only
volume). A cached directory index (60s TTL) attaches a `photos` URL list to each
surface in /api/map; a /photos StaticFiles mount serves the files. Photos are
public — shown to anonymous visitors and managers alike.

The balloon renders a gallery (main image + thumbnail strip + "1 / N" counter);
clicking a thumbnail swaps the main image, and clicking the main image / "+N"
opens a fullscreen lightbox over the map (prev/next, Esc, backdrop close).
Surfaces without photos render exactly as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 08:00:10 +00:00

417 lines
18 KiB
Python

import asyncio
import os
import time
from urllib.parse import quote
import httpx
from fastapi import Depends, FastAPI, Header, Query
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from auth import role_from_authorization
def get_role(authorization: str | None = Header(default=None)) -> str:
# Resolve the caller's tier from the Bearer token. "anonymous" is the safe
# default (missing/invalid token); "manager" unlocks the full data view.
return role_from_authorization(authorization)
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()
# --- Manager name normalization -------------------------------------------
# Planfix has emitted manager names in two word orders over time ("Имя Фамилия"
# from the 26 Jul 2026 backfill, "Фамилия Имя" from later re-syncs), so a single
# person shows up as two distinct strings (e.g. "Александра Столбова" and
# "Столбова Александра"). We collapse them with a word-order- and case-independent
# key: lowercase, split into words, sort the words, rejoin. The SQL key
# expression (MANAGER_KEY_ARR) and the Python key (_mkey) must stay in sync so a
# selected manager matches rows stored under either order. For display we map the
# key back to the canonical "Фамилия Имя" form in CANONICAL_MANAGERS.
MANAGER_KEY_ARR = (
"arrayMap(x -> arrayStringConcat("
"arraySort(splitByChar(' ', lowerUTF8(trimBoth(x)))), ' '), manager)"
)
# Canonical display names, in the chosen "Фамилия Имя" order. Unknown names fall
# through untouched (see canon_manager), so new managers still appear as-is.
CANONICAL_MANAGERS = [
"Вербицкий Александр",
"Иванова Диана",
"Кабатов Евгений",
"Колбина Ирина",
"Столбова Александра",
]
def _mkey(name: str) -> str:
return " ".join(sorted(name.lower().split()))
_MANAGER_CANON = {_mkey(n): n for n in CANONICAL_MANAGERS}
def canon_manager(name: str) -> str:
# Map any word order / casing of a known manager to the canonical
# "Фамилия Имя" string; leave unknown names untouched.
return _MANAGER_CANON.get(_mkey(name), name)
# --------------------------------------------------------------------------
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 = f"(length({{managers:Array(String)}}) = 0 OR hasAny({MANAGER_KEY_ARR}, {{managers:Array(String)}}))"
STATUS_COND = "(length({statuses:Array(String)}) = 0 OR task_status IN {statuses: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"
f" OR board_key IN (SELECT board_key FROM default.pf_board WHERE hasAny({MANAGER_KEY_ARR}, {{managers:Array(String)}})))"
)
# Same idea for the status facet on the map: keep surfaces that have at least one
# booking whose task_status is among the selected values.
MAP_STATUS_COND = (
"(length({statuses:Array(String)}) = 0"
" OR board_key IN (SELECT board_key FROM default.pf_board WHERE task_status IN {statuses:Array(String)}))"
)
def build_where(city=False, dimension=False, brand=False, manager=False, status=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 status:
parts.append(STATUS_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, status: 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 []
# Normalize each selected manager to its word-order-independent key so it
# matches rows stored under either name order (see MANAGER_KEY_ARR).
managers = [_mkey(m) for m in manager.split(",") if m] if manager else []
statuses = [s for s in status.split(",") if s] if status else []
return {
"cities": cities,
"dimensions": dimensions,
"brands": brands,
"managers": managers,
"statuses": statuses,
"search": search or "",
"date_start": date_start or "",
"date_end": date_end or "",
}
@app.get("/api/meta")
async def meta(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
# Anonymous tier: the Brand/Manager/Status facets are hidden, so ignore those
# incoming filters (a crafted request can't use them to probe hidden data).
if role != "manager":
brand = manager = status = ""
params = parse_filters(city, dimension, brand, manager, status, search, date_start, date_end)
hds = bool(params["date_start"])
hde = bool(params["date_end"])
# The five facet lists are independent queries, so run them concurrently
# instead of awaiting one after another (5x latency on every filter change).
cities, brands, dimensions, managers, statuses = await asyncio.gather(
ch_query(
f"SELECT DISTINCT city FROM default.pf_board WHERE {build_where(dimension=True, brand=True, manager=True, status=True, search=True, date_start=hds, date_end=hde)} AND char_length(city) > 0 ORDER BY city",
params,
),
ch_query(
f"SELECT DISTINCT brand FROM default.pf_board WHERE {build_where(city=True, dimension=True, manager=True, status=True, search=True, date_start=hds, date_end=hde)} AND char_length(brand) > 0 ORDER BY brand",
params,
),
ch_query(
f"SELECT DISTINCT dimension FROM default.pf_board WHERE {build_where(city=True, brand=True, manager=True, status=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.
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, status=True, search=True, date_start=hds, date_end=hde)} AND char_length(m) > 0 ORDER BY manager",
params,
),
ch_query(
f"SELECT DISTINCT task_status FROM default.pf_board WHERE {build_where(city=True, dimension=True, brand=True, manager=True, search=True, date_start=hds, date_end=hde)} AND char_length(task_status) > 0 ORDER BY task_status",
params,
),
)
if role != "manager":
# Truncated tier: never expose client-facing facet values.
return {
"cities": [r["city"] for r in cities],
"dimensions": [r["dimension"] for r in dimensions],
"brands": [],
"managers": [],
"statuses": [],
}
return {
"cities": [r["city"] for r in cities],
"dimensions": [r["dimension"] for r in dimensions],
"brands": [r["brand"] for r in brands],
"managers": sorted({canon_manager(r["manager"]) for r in managers}),
"statuses": [r["task_status"] for r in statuses],
}
@app.get("/api/boards")
async def boards(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = "", all_surfaces: bool = False):
# Board inventory (address/city/dimension) is visible to everyone; anonymous
# just can't filter by the hidden facets. The "all surfaces" default for
# anonymous is driven by the client (allSurfacesParam) — the data here is
# non-sensitive inventory, so no server-side force is needed.
if role != "manager":
brand = manager = status = ""
params = parse_filters(city, dimension, brand, manager, status, search, date_start, date_end)
# A "booking facet" (brand/manager/status) narrows to specific bookings, so
# when one is active the timeline shows only the relevant booked surfaces
# regardless of the toggle. Dates are a *window*, not an identity filter:
# they narrow the bars, not the surface set, so free surfaces stay visible.
booking_facet = bool(params["brands"] or params["managers"] or params["statuses"])
if all_surfaces and not booking_facet:
# Full inventory from board_info. Union pf_board so a just-booked surface
# not yet in the inventory snapshot still gets a row (board_info wins the
# label via prio). Only the surface facets (city/dimension/search) apply.
surface_where = " AND ".join(["char_length(city) > 0", CITY_COND, DIM_COND, SEARCH_COND])
sql = f"""
SELECT
board_id,
argMax(board_address, prio) AS board_address,
argMax(board_city, prio) AS board_city,
argMax(board_dimension, prio) AS board_dimension
FROM (
SELECT board_id, address AS board_address, city AS board_city, dimension AS board_dimension, 1 AS prio
FROM default.board_info FINAL WHERE {surface_where}
UNION ALL
SELECT board_id, address AS board_address, city AS board_city, dimension AS board_dimension, 0 AS prio
FROM default.pf_board FINAL WHERE {surface_where}
)
GROUP BY board_id
ORDER BY board_city, board_address
"""
else:
where = build_where(city=True, dimension=True, brand=True, manager=True, status=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(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
if role != "manager":
brand = manager = status = ""
params = parse_filters(city, dimension, brand, manager, status, search, date_start, date_end)
where = build_where(city=True, dimension=True, brand=True, manager=True, status=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,
manager
FROM default.pf_board FINAL
WHERE {where}
ORDER BY board_id, start_date
"""
rows = await ch_query(sql, params)
# manager is Array(String) and comes in mixed word orders (see MANAGER_KEY_ARR);
# collapse to canonical "Фамилия Имя" names for display.
for r in rows:
r["manager"] = sorted({canon_manager(m) for m in (r.get("manager") or []) if m})
if role != "manager":
# Truncated tier: keep the occupancy bar (board_id + dates) but strip every
# client-facing field so the timeline shows neutral, unlabelled bars and the
# board panel / Planfix deep-links have nothing to reveal.
for r in rows:
r["brand"] = ""
r["company_name"] = ""
r["manager"] = []
r["task_id"] = ""
r["task_status"] = ""
return rows
# Surface photos live on disk as photos/<board_key>/<file>.{jpg,jpeg,png}. The
# directory listing is cached briefly so /api/map doesn't stat the tree on every
# request; new photos appear within PHOTO_TTL seconds.
PHOTOS_DIR = "photos"
PHOTO_EXT = (".jpg", ".jpeg", ".png")
PHOTO_TTL = 60.0
_photo_index: dict[str, list[str]] = {}
_photo_index_ts = 0.0
def photo_index() -> dict[str, list[str]]:
global _photo_index, _photo_index_ts
now = time.monotonic()
if _photo_index_ts and now - _photo_index_ts < PHOTO_TTL:
return _photo_index
idx: dict[str, list[str]] = {}
try:
for key in os.listdir(PHOTOS_DIR):
sub = os.path.join(PHOTOS_DIR, key)
if not os.path.isdir(sub):
continue
files = sorted(
f for f in os.listdir(sub)
if f.lower().endswith(PHOTO_EXT) and os.path.isfile(os.path.join(sub, f))
)
if files:
idx[key] = [f"/photos/{quote(key)}/{quote(f)}" for f in files]
except FileNotFoundError:
pass
_photo_index, _photo_index_ts = idx, now
return _photo_index
@app.get("/api/map")
async def map_data(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = ""):
if role != "manager":
brand = manager = status = ""
# 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, status, search, "", "")
where = " AND ".join([
"char_length(city) > 0",
CITY_COND,
DIM_COND,
SEARCH_COND,
MAP_BRAND_COND,
MAP_MANAGER_COND,
MAP_STATUS_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 FINAL
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 FINAL
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)
anon = role != "manager"
photos = photo_index()
out = []
for s in surfaces:
active = by_key.get(s["board_key"], [])
out.append({
**s,
"occupied_now": len(active) > 0,
# Truncated tier keeps the free/occupied marker colour but hides the
# balloon's booking details (company/brand/status).
"bookings": [] if anon else active,
# Surface photos are public inventory — shown to everyone.
"photos": photos.get(str(s["board_key"]), []),
})
return out
app.mount("/photos", StaticFiles(directory=PHOTOS_DIR, check_dir=False), name="photos")
app.mount("/", StaticFiles(directory="static", html=True), name="static")