roles: multi-role foundation (manager / client / measurer / assembler)

Users.role теперь хранит CSV-список ролей: 'manager,measurer'.
Парсим, добавляем, отзываем — все через sheets.parse_roles / grant_role /
revoke_role / list_users_with_role. Старые однострочные значения работают
как раньше (legacy compat).

Backend:
- /api/me возвращает roles[] (массив), role (главная для legacy-UI),
  plus capabilities {measurer, assembler} для staff
- /api/grant_role (admin-only) — добавить/отозвать роль
- /api/staff_list (manager-only) — список сотрудников по роли
  (будет использоваться в dropdown «выбрать замерщика»)
- При role=staff отдаём отдельный кабинет; если у юзера нет measurer/
  assembler — возвращаем error=no_staff_role

Bot:
- /start — 3-я reply-кнопка [🔧 Я сотрудник]. При тапе MiniApp получает
  ?role=staff и решает кабинет по capabilities.
- /whoami — сотрудник присылает свой Telegram ID, пересылает куратору
  чтобы тот выдал роль через /api/grant_role.

MiniApp:
- renderStaff() — заглушка кабинета сотрудника с шапкой (имя, аватар,
  список ролей) и пустым inbox («Пока пусто»). Если есть measurer —
  быстрая кнопка «Сделать новый замер».
- При error=no_staff_role — экран с инструкцией как получить роль.
- CSS .staff-head / .staff-no-role.

Cache bust v=20260513f.
This commit is contained in:
wasrusgen 2026-05-12 19:14:39 +03:00
parent 6d57372b0b
commit d859e9791c
6 changed files with 349 additions and 25 deletions

View File

@ -105,6 +105,8 @@ async def _dispatch_post(request: Request):
"podbor": _handle_podbor, "podbor": _handle_podbor,
"clients": _handle_clients, "clients": _handle_clients,
"lead": _handle_lead, "lead": _handle_lead,
"grant_role": _handle_grant_role,
"staff_list": _handle_staff_list,
"ping": lambda b: {"pong": True, "time": _now_iso()}, "ping": lambda b: {"pong": True, "time": _now_iso()},
"seed_admin": lambda b: _handle_seed_admin(), "seed_admin": lambda b: _handle_seed_admin(),
"test_ai": lambda b: _handle_test_ai(), "test_ai": lambda b: _handle_test_ai(),
@ -177,6 +179,20 @@ async def api_measurement_detail(request: Request):
return _handle_measurement_detail(body) return _handle_measurement_detail(body)
@app.post("/api/grant_role")
async def api_grant_role(request: Request):
"""Админ выдаёт роль другому пользователю.
body: {initData, target_tg_id, role: 'measurer'|'assembler'|'manager'|'client', action: 'grant'|'revoke'}"""
body = await _safe_json(request)
return _handle_grant_role(body)
@app.post("/api/staff_list")
async def api_staff_list(request: Request):
body = await _safe_json(request)
return _handle_staff_list(body)
@app.get("/api/photo/{measurement_id}/{filename}") @app.get("/api/photo/{measurement_id}/{filename}")
async def api_photo(measurement_id: str, filename: str): async def api_photo(measurement_id: str, filename: str):
"""Отдаёт фото замера. Защита от path traversal — только разрешённые id и имена.""" """Отдаёт фото замера. Защита от path traversal — только разрешённые id и имена."""
@ -393,16 +409,48 @@ def _handle_me(body: dict[str, Any]) -> dict[str, Any]:
tg_user = auth["user"] tg_user = auth["user"]
tg_id = tg_user["id"] tg_id = tg_user["id"]
start_param = body.get("startParam") or auth.get("start_param") start_param = body.get("startParam") or auth.get("start_param")
explicit_role = body.get("role") if body.get("role") in ("manager", "client") else None explicit_role = body.get("role") if body.get("role") in ("manager", "client", "staff") else None
user = sheets.get_or_create_user(tg_user, start_param, explicit_role) user = sheets.get_or_create_user(tg_user, start_param, explicit_role)
roles = sheets.parse_roles(user.get("role", ""))
if user.get("role") == "manager": # Staff (замерщик / сборщик) — отдельный кабинет, доступен только тем у кого роль выдана
if explicit_role == "staff":
has_measurer = "measurer" in roles
has_assembler = "assembler" in roles
if not (has_measurer or has_assembler):
return {
"role": "staff",
"roles": roles,
"error": "no_staff_role",
"user": {
"tg_id": tg_id,
"full_name": user.get("full_name", ""),
"avatar_initial": _initial(user.get("full_name") or tg_user.get("first_name", "")),
},
}
full_name = user.get("full_name", "") or tg_user.get("first_name", "")
return {
"role": "staff",
"roles": roles,
"user": {
"tg_id": tg_id,
"full_name": full_name,
"avatar_initial": _initial(full_name),
},
"capabilities": {
"measurer": has_measurer,
"assembler": has_assembler,
},
}
if "manager" in roles:
m = sheets.get_manager_profile(tg_id) or { m = sheets.get_manager_profile(tg_id) or {
"full_name": user.get("full_name", ""), "salon": "", "full_name": user.get("full_name", ""), "salon": "",
"is_zov_employee": False, "status": "lapsed", "active_until": None, "is_zov_employee": False, "status": "lapsed", "active_until": None,
} }
return { return {
"role": "manager", "role": "manager",
"roles": roles,
"user": { "user": {
"tg_id": tg_id, "tg_id": tg_id,
"full_name": m.get("full_name") or user.get("full_name", ""), "full_name": m.get("full_name") or user.get("full_name", ""),
@ -429,6 +477,7 @@ def _handle_me(body: dict[str, Any]) -> dict[str, Any]:
full_name = c.get("full_name") or user.get("full_name", "") full_name = c.get("full_name") or user.get("full_name", "")
return { return {
"role": "client", "role": "client",
"roles": roles,
"user": { "user": {
"tg_id": tg_id, "tg_id": tg_id,
"full_name": full_name, "full_name": full_name,
@ -764,6 +813,63 @@ def _handle_lead(body: dict[str, Any]) -> dict[str, Any]:
} }
def _handle_grant_role(body: dict[str, Any]) -> dict[str, Any]:
"""Только админ может выдавать/отзывать роли."""
cfg = get_config()
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
if not auth or not auth.get("user"):
return {"error": "invalid_init_data"}
tg_id = auth["user"]["id"]
if int(tg_id) != int(cfg.admin_tg_id):
return {"error": "admin_only"}
target = body.get("target_tg_id")
role = body.get("role", "").strip()
action = body.get("action", "grant")
if not target or not role:
return {"error": "missing_fields"}
try:
target_int = int(target)
except (TypeError, ValueError):
return {"error": "bad_target"}
if role not in sheets.VALID_ROLES:
return {"error": "unknown_role", "valid": sorted(sheets.VALID_ROLES)}
if action == "revoke":
changed = sheets.revoke_role(target_int, role)
else:
changed = sheets.grant_role(target_int, role)
sheets.log_event("role_changed", tg_id, {"target": target_int, "role": role, "action": action, "changed": changed})
updated_user = sheets.find_user(target_int) or {}
return {
"ok": True,
"target_tg_id": target_int,
"changed": changed,
"roles": sheets.parse_roles(updated_user.get("role", "")),
}
def _handle_staff_list(body: dict[str, Any]) -> dict[str, Any]:
"""Список сотрудников с указанной ролью — для dropdown «выбрать замерщика»."""
cfg = get_config()
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
if not auth or not auth.get("user"):
return {"error": "invalid_init_data"}
tg_id = auth["user"]["id"]
user = sheets.find_user(tg_id)
if not user or not sheets.has_role(user, "manager"):
return {"error": "only_manager"}
role = (body.get("role") or "").strip()
if role not in sheets.VALID_ROLES:
return {"error": "unknown_role"}
return {"ok": True, "role": role, "staff": sheets.list_users_with_role(role)}
def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]: def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]:
"""Возвращает один замер целиком — для детальной страницы и печати.""" """Возвращает один замер целиком — для детальной страницы и печати."""
cfg = get_config() cfg = get_config()

View File

@ -108,7 +108,87 @@ def find_user(tg_id: int) -> dict[str, Any] | None:
return None return None
full_name = (f"{row.get('first_name', '')} {row.get('last_name', '')}".strip() full_name = (f"{row.get('first_name', '')} {row.get('last_name', '')}".strip()
or row.get("tg_username", "")) or row.get("tg_username", ""))
return {**row, "full_name": full_name} roles = parse_roles(row.get("role", ""))
return {**row, "full_name": full_name, "roles": roles}
# ---- Multi-role helpers ----
VALID_ROLES = {"manager", "client", "measurer", "assembler"}
def parse_roles(role_str: str) -> list[str]:
"""Парсит CSV-роли: 'manager,measurer' → ['manager', 'measurer'].
Старые однострочные значения тоже работают: 'manager' ['manager']."""
if not role_str:
return []
parts = [p.strip() for p in str(role_str).split(",") if p.strip()]
return [p for p in parts if p in VALID_ROLES]
def has_role(user: dict[str, Any] | None, role: str) -> bool:
if not user:
return False
return role in parse_roles(user.get("role", ""))
def primary_role(user: dict[str, Any] | None) -> str:
"""Первая (главная) роль для legacy-кода: manager > measurer > assembler > client."""
if not user:
return ""
roles = parse_roles(user.get("role", ""))
for r in ("manager", "measurer", "assembler", "client"):
if r in roles:
return r
return roles[0] if roles else ""
def grant_role(tg_id: int, role: str) -> bool:
"""Добавляет роль пользователю (если её ещё нет). Возвращает True если что-то изменилось."""
if role not in VALID_ROLES:
return False
user = find_user(tg_id)
if not user:
return False
current = parse_roles(user.get("role", ""))
if role in current:
return False
current.append(role)
return update_cell_by_key("Users", "tg_id", tg_id, "role", ",".join(current))
def revoke_role(tg_id: int, role: str) -> bool:
user = find_user(tg_id)
if not user:
return False
current = parse_roles(user.get("role", ""))
if role not in current:
return False
current.remove(role)
new_val = ",".join(current) if current else "client" # fallback роль
return update_cell_by_key("Users", "tg_id", tg_id, "role", new_val)
def list_users_with_role(role: str) -> list[dict[str, Any]]:
"""Все пользователи, у которых есть указанная роль (для dropdown «выбрать замерщика»)."""
s = sheet("Users")
rows = s.get_all_values()
if not rows:
return []
headers = rows[0]
out: list[dict[str, Any]] = []
for r in rows[1:]:
row = dict(zip(headers, r + [""] * (len(headers) - len(r))))
if role in parse_roles(row.get("role", "")):
full_name = (f"{row.get('first_name', '')} {row.get('last_name', '')}".strip()
or row.get("tg_username", ""))
out.append({
"tg_id": row.get("tg_id"),
"full_name": full_name,
"tg_username": row.get("tg_username", ""),
"roles": parse_roles(row.get("role", "")),
})
return out
def get_or_create_user(tg_user: dict[str, Any], start_param: str | None, def get_or_create_user(tg_user: dict[str, Any], start_param: str | None,
@ -122,14 +202,18 @@ def get_or_create_user(tg_user: dict[str, Any], start_param: str | None,
if existing: if existing:
update_cell_by_key("Users", "tg_id", tg_id, "last_seen_at", now_str) update_cell_by_key("Users", "tg_id", tg_id, "last_seen_at", now_str)
# Админ всегда manager # Админ всегда имеет роль manager (могут быть и другие)
if tg_id == admin_id and existing.get("role") != "manager": if tg_id == admin_id and not has_role(existing, "manager"):
update_cell_by_key("Users", "tg_id", tg_id, "role", "manager") grant_role(tg_id, "manager")
ensure_admin_manager(tg_user) ensure_admin_manager(tg_user)
existing["role"] = "manager" existing["roles"] = parse_roles((find_user(tg_id) or {}).get("role", ""))
elif explicit_role and tg_id != admin_id and existing.get("role") != explicit_role: # explicit_role из query (?role=manager|client|staff) — не перетираем уже выданные роли,
update_cell_by_key("Users", "tg_id", tg_id, "role", explicit_role) # только добавляем если человек впервые открыл эту секцию
existing["role"] = explicit_role elif explicit_role and explicit_role in VALID_ROLES and not has_role(existing, explicit_role):
# client/manager — стандартные роли любой может получить через выбор в боте
if explicit_role in ("manager", "client"):
grant_role(tg_id, explicit_role)
existing["roles"] = parse_roles((find_user(tg_id) or {}).get("role", ""))
return existing return existing
# Новый пользователь # Новый пользователь
@ -148,7 +232,7 @@ def get_or_create_user(tg_user: dict[str, Any], start_param: str | None,
tg_user.get("username", ""), tg_user.get("username", ""),
tg_user.get("first_name", ""), tg_user.get("first_name", ""),
tg_user.get("last_name", ""), tg_user.get("last_name", ""),
role, role, # хранится как CSV; для новых = одна роль
now_str, now_str,
now_str, now_str,
invite_code, invite_code,

View File

@ -36,7 +36,7 @@ def _wapp(miniapp_url: str, role: str) -> WebAppInfo:
# ============================================================ # ============================================================
# Reply keyboard — выбор роли. Оба кнопки сразу открывают MiniApp. # Reply keyboard — выбор роли. Три кнопки, все WebApp.
# ============================================================ # ============================================================
def role_choice_kb(miniapp_url: str) -> ReplyKeyboardMarkup: def role_choice_kb(miniapp_url: str) -> ReplyKeyboardMarkup:
@ -46,6 +46,9 @@ def role_choice_kb(miniapp_url: str) -> ReplyKeyboardMarkup:
KeyboardButton(text="👤 Я менеджер", web_app=_wapp(miniapp_url, "manager")), KeyboardButton(text="👤 Я менеджер", web_app=_wapp(miniapp_url, "manager")),
KeyboardButton(text="🏠 Я клиент", web_app=_wapp(miniapp_url, "client")), KeyboardButton(text="🏠 Я клиент", web_app=_wapp(miniapp_url, "client")),
], ],
[
KeyboardButton(text="🔧 Я сотрудник", web_app=_wapp(miniapp_url, "staff")),
],
], ],
resize_keyboard=True, resize_keyboard=True,
is_persistent=True, is_persistent=True,
@ -61,7 +64,8 @@ def role_choice_kb(miniapp_url: str) -> ReplyKeyboardMarkup:
async def cmd_start(message: Message, config: Config) -> None: async def cmd_start(message: Message, config: Config) -> None:
await message.answer( await message.answer(
"👋 Здравствуйте, я бот-помощник от Руслана ВАСИЛЬЕВА.\n\n" "👋 Здравствуйте, я бот-помощник от Руслана ВАСИЛЬЕВА.\n\n"
"Выберите, кто вы — кабинет откроется одним тапом.", "Выберите, кто вы — кабинет откроется одним тапом.\n\n"
"<i>«Сотрудник» — для замерщиков и сборщиков ЗОВ. Если вы менеджер или клиент — выбирайте свою роль.</i>",
reply_markup=role_choice_kb(config.miniapp_url), reply_markup=role_choice_kb(config.miniapp_url),
) )
@ -74,3 +78,21 @@ async def cmd_menu(message: Message, config: Config) -> None:
@router.message(Command("hide")) @router.message(Command("hide"))
async def cmd_hide(message: Message) -> None: async def cmd_hide(message: Message) -> None:
await message.answer("Клавиатура скрыта. Вернуть — /menu", reply_markup=ReplyKeyboardRemove()) await message.answer("Клавиатура скрыта. Вернуть — /menu", reply_markup=ReplyKeyboardRemove())
# ============================================================
# /whoami — сотрудник присылает свой ID куратору, чтобы тот выдал роль
# ============================================================
@router.message(Command("whoami"))
async def cmd_whoami(message: Message) -> None:
user = message.from_user
if not user:
return
await message.answer(
f"<b>Ваш Telegram ID:</b> <code>{user.id}</code>\n"
f"Username: @{user.username or ''}\n"
f"Имя: {user.first_name or ''} {user.last_name or ''}".strip()
+ "\n\n"
"<i>Перешлите это сообщение куратору @wasrusgen чтобы вам выдали роль замерщика/сборщика.</i>"
)

View File

@ -343,6 +343,72 @@ function buildMenu(items) {
return menu; return menu;
} }
/* ----------------- Staff (замерщик / сборщик) ----------------- */
function renderStaff(me) {
app.innerHTML = "";
if (me.error === "no_staff_role") {
app.appendChild(el(`
<div class="staff-no-role">
<div class="staff-no-role-ico">🔒</div>
<h2 class="display-title">У вас нет<br><span class="accent">прав сотрудника</span></h2>
<p class="lede">Чтобы получить роль замерщика или сборщика отправьте куратору ваш Telegram ID.</p>
<div class="block">
<div class="kv"><span>Ваш ID</span><strong><code>${me.user?.tg_id || ""}</code></strong></div>
<div class="kv"><span>Имя</span><strong>${me.user?.full_name || ""}</strong></div>
</div>
<p class="muted" style="text-align:center;margin-top:16px;">
В боте отправьте <code>/whoami</code> и перешлите ответ
<a href="https://t.me/wasrusgen" target="_blank">@wasrusgen</a>.
</p>
</div>
`));
return;
}
const caps = me.capabilities || {};
const labels = [];
if (caps.measurer) labels.push("замерщик");
if (caps.assembler) labels.push("сборщик");
const subtitle = labels.length ? labels.join(" · ") : "сотрудник";
app.appendChild(el(`
<div class="staff-head">
<div class="staff-avatar">${me.user?.avatar_initial || "?"}</div>
<div>
<div class="kicker">${subtitle}</div>
<h2 class="display-title">${me.user?.full_name || "Сотрудник"}</h2>
</div>
</div>
`));
// Заглушка — реальный инбокс заявок будет в следующем коммите
const inbox = el(`
<section class="block">
<div class="block-head">📥 Входящие заявки</div>
<div class="empty" style="padding:24px 12px;text-align:center;color:var(--muted);">
Пока пусто менеджеры ещё не назначили вам заявки.<br>
Здесь появятся ${caps.measurer ? "замеры" : ""}${caps.measurer && caps.assembler ? " и " : ""}${caps.assembler ? "сборки" : ""}.
</div>
</section>
`);
app.appendChild(inbox);
// Если у сотрудника также есть роль measurer — показываем быструю кнопку «Сделать замер»
if (caps.measurer) {
const quick = el(`
<div class="podbor-cta-row" style="margin-top:16px;">
<button class="btn-primary" id="newMeasure">📐 Сделать новый замер</button>
</div>
`);
quick.querySelector("#newMeasure").addEventListener("click", () => {
haptic && haptic("impact");
location.hash = "#/measure";
});
app.appendChild(quick);
}
}
function renderError() { function renderError() {
app.innerHTML = ""; app.innerHTML = "";
app.appendChild(el(` app.appendChild(el(`
@ -403,8 +469,13 @@ async function init() {
hideSplash(); hideSplash();
return; return;
} }
if (me.role === "manager") renderManager(me); if (me.role === "staff") {
else renderClient(me); renderStaff(me);
} else if (me.role === "manager") {
renderManager(me);
} else {
renderClient(me);
}
hideSplash(); hideSplash();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -424,7 +495,8 @@ function routeByHash() {
// Главный экран по роли // Главный экран по роли
const me = window.__zovMe; const me = window.__zovMe;
if (!me) { init(); return; } if (!me) { init(); return; }
if (me.role === "manager") renderManager(me); if (me.role === "staff") renderStaff(me);
else if (me.role === "manager") renderManager(me);
else renderClient(me); else renderClient(me);
} }
} }

View File

@ -1989,6 +1989,46 @@
overflow-x: auto; overflow-x: auto;
} }
/* ===== Кабинет сотрудника (замерщик/сборщик) ===== */
.staff-head {
display: flex;
align-items: center;
gap: 14px;
margin: 16px 4px 22px;
}
.staff-avatar {
width: 52px;
height: 52px;
border-radius: 14px;
background: var(--walnut, #6B4A2B);
color: var(--paper, #FBF7F0);
display: grid;
place-items: center;
font-weight: 700;
font-size: 22px;
font-family: var(--font-display, "Newsreader", serif);
}
.staff-no-role {
padding: 32px 16px;
text-align: center;
}
.staff-no-role-ico {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.6;
}
.staff-no-role .block {
margin-top: 18px;
text-align: left;
}
.staff-no-role code {
background: var(--warm, rgba(107, 74, 43, 0.08));
padding: 2px 6px;
border-radius: 4px;
font-family: var(--font-mono, "JetBrains Mono", monospace);
font-size: 12px;
}
/* ===== Фото замера ===== */ /* ===== Фото замера ===== */
.photo-uploader { margin: 12px 0 14px; } .photo-uploader { margin: 12px 0 14px; }

View File

@ -12,8 +12,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap">
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=20260513e"> <link rel="stylesheet" href="assets/styles.css?v=20260513f">
<link rel="stylesheet" href="assets/podbor.css?v=20260513e"> <link rel="stylesheet" href="assets/podbor.css?v=20260513f">
</head> </head>
<body> <body>
<!-- Splash — за пределами #app, render-функции его не смывают --> <!-- Splash — за пределами #app, render-функции его не смывают -->
@ -34,12 +34,12 @@
</div> </div>
</div> </div>
<main id="app"></main> <main id="app"></main>
<script src="assets/icons.js?v=20260513e"></script> <script src="assets/icons.js?v=20260513f"></script>
<script src="assets/podbor.config.js?v=20260513e"></script> <script src="assets/podbor.config.js?v=20260513f"></script>
<script src="assets/podbor.picts.js?v=20260513e"></script> <script src="assets/podbor.picts.js?v=20260513f"></script>
<script src="assets/podbor.js?v=20260513e"></script> <script src="assets/podbor.js?v=20260513f"></script>
<script src="assets/clients.js?v=20260513e"></script> <script src="assets/clients.js?v=20260513f"></script>
<script src="assets/measurements.js?v=20260513e"></script> <script src="assets/measurements.js?v=20260513f"></script>
<script src="assets/app.js?v=20260513e"></script> <script src="assets/app.js?v=20260513f"></script>
</body> </body>
</html> </html>