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>
417 lines
18 KiB
Python
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")
|