⚠ Коллизия
');
- for (const pIdx of partnerIdxs!) {
- const p = bookings[pIdx]!;
- const pLabel = p.brand || p.company_name || 'Без названия';
- tt.push(`
${esc(pLabel)} ${fmtDate(p.start_date)} – ${fmtDate(p.end_date)}
`);
- }
+ const datesRow = `
📅${fmtDate(bk.start_date)} – ${fmtDate(bk.end_date)}${dur ? ' · ' + esc(dur) : ''}
`;
+ let tooltipHtml: string;
+ if (!flags.managerView) {
+ // Anonymous tier: date range only — no title, task №, status or Planfix hint.
+ tooltipHtml = `
${datesRow}
`;
+ } else {
+ const title = brand || company || 'Без названия';
+ const managers = (bk.manager || []).filter(Boolean).join(', ');
+ const tt: string[] = [];
+ tt.push('
');
+ tt.push(`${esc(title)}`);
+ if (bk.task_id) tt.push(`№${esc(bk.task_id)}`);
tt.push('
');
+ if (brand && company) tt.push(`
${esc(company)}
`);
+ tt.push('
');
+ tt.push(datesRow);
+ if (bk.task_status) tt.push(`
${esc(bk.task_status)}
`);
+ if (managers) tt.push(`
👤${esc(managers)}
`);
+ if (isCollision) {
+ tt.push('
⚠ Коллизия
');
+ for (const pIdx of partnerIdxs!) {
+ const p = bookings[pIdx]!;
+ const pLabel = p.brand || p.company_name || 'Без названия';
+ tt.push(`
${esc(pLabel)} ${fmtDate(p.start_date)} – ${fmtDate(p.end_date)}
`);
+ }
+ tt.push('
');
+ }
+ tt.push('
↗ Клик — открыть в ПланФикс
');
+ tooltipHtml = `
${tt.join('')}
`;
}
- tt.push('
↗ Клик — открыть в ПланФикс
');
- const tooltipHtml = `
${tt.join('')}
`;
return {
id: bk.board_id + '__' + idx,
diff --git a/main.py b/main.py
index 7e2dbda..71d98ef 100644
--- a/main.py
+++ b/main.py
@@ -1,10 +1,18 @@
import asyncio
import os
import httpx
-from fastapi import FastAPI, Query
+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")
@@ -156,7 +164,11 @@ def parse_filters(city: str, dimension: str, brand: str, manager: str, status: s
@app.get("/api/meta")
-async def meta(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
+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"])
@@ -185,6 +197,15 @@ async def meta(city: str = "", dimension: str = "", brand: str = "", manager: st
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],
@@ -195,7 +216,11 @@ async def meta(city: str = "", dimension: str = "", brand: str = "", manager: st
@app.get("/api/boards")
-async def boards(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
+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 = ""):
+ # Board inventory (address/city/dimension) is visible to everyone; anonymous
+ # just can't filter by the hidden facets.
+ 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"""
@@ -214,7 +239,9 @@ async def boards(city: str = "", dimension: str = "", brand: str = "", manager:
@app.get("/api/bookings")
-async def bookings(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
+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"""
@@ -236,11 +263,23 @@ async def bookings(city: str = "", dimension: str = "", brand: str = "", manager
# 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
@app.get("/api/map")
-async def map_data(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = ""):
+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.
@@ -292,13 +331,16 @@ async def map_data(city: str = "", dimension: str = "", brand: str = "", manager
for b in current:
by_key.setdefault(b["board_key"], []).append(b)
+ anon = role != "manager"
out = []
for s in surfaces:
active = by_key.get(s["board_key"], [])
out.append({
**s,
"occupied_now": len(active) > 0,
- "bookings": active,
+ # Truncated tier keeps the free/occupied marker colour but hides the
+ # balloon's booking details (company/brand/status).
+ "bookings": [] if anon else active,
})
return out
diff --git a/requirements.txt b/requirements.txt
index e5dd7aa..61352f1 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
fastapi==0.115.0
uvicorn==0.30.6
httpx==0.27.2
+PyJWT[crypto]==2.10.1
diff --git a/static/silent-check-sso.html b/static/silent-check-sso.html
new file mode 100644
index 0000000..6bb81c2
--- /dev/null
+++ b/static/silent-check-sso.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+