Gate STO workplace by role
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-16 10:33:33 +09:00
parent 83ad880b9d
commit ac5845d5a0
10 changed files with 1612 additions and 593 deletions

View File

@@ -116,6 +116,13 @@ class ApiClient:
async def register_service_center(self, telegram_id: int, payload: dict[str, Any]) -> dict[str, Any]:
return await self.request("POST", "/api/service-centers", telegram_id=telegram_id, json=payload)
async def accept_sto_invite(self, telegram_id: int, invite_token: str) -> dict[str, Any]:
return await self.request(
"POST",
f"/api/service-centers/employees/invites/{invite_token}/accept",
telegram_id=telegram_id,
)
async def pending_service_centers(self, telegram_id: int) -> list[dict[str, Any]]:
return await self.request("GET", "/api/admin/service-centers/pending", telegram_id=telegram_id)

View File

@@ -24,6 +24,33 @@ logging.basicConfig(level=logging.INFO)
dp = Dispatcher()
api = ApiClient()
APPROVED_SERVICE_STATUSES = {"approved", "verified"}
STO_WORKPLACE_ROLES = {"owner", "mechanic"}
def webapp_url(path: str = "") -> str:
base = settings.effective_webapp_url.rstrip("/")
if not path:
return base
return f"{base}/{path.lstrip('/')}"
def sto_workplace_url() -> str:
return webapp_url("sto.html")
async def sto_workplace_centers(telegram_id: int) -> list[dict]:
try:
centers = await api.my_service_centers(telegram_id)
except httpx.HTTPStatusError:
return []
return [
center
for center in centers
if center.get("verification_status") in APPROVED_SERVICE_STATUSES
and (center.get("employee_role") or "owner") in STO_WORKPLACE_ROLES
]
def main_keyboard() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
@@ -35,28 +62,39 @@ def main_keyboard() -> ReplyKeyboardMarkup:
)
def webapp_inline_keyboard(text: str = "Открыть CarPass") -> InlineKeyboardMarkup:
async def main_keyboard_for(telegram_id: int) -> ReplyKeyboardMarkup:
rows = [
[KeyboardButton(text="Меню"), KeyboardButton(text="Мои авто")],
]
if await sto_workplace_centers(telegram_id):
rows.append([KeyboardButton(text="Панель СТО")])
rows.append([KeyboardButton(text="Помощь")])
return ReplyKeyboardMarkup(keyboard=rows, resize_keyboard=True)
def webapp_inline_keyboard(text: str = "Открыть CarPass", path: str = "") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=text, web_app=WebAppInfo(url=settings.effective_webapp_url))],
[InlineKeyboardButton(text=text, web_app=WebAppInfo(url=webapp_url(path)))],
]
)
def menu_inline_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="Открыть Mini App", web_app=WebAppInfo(url=settings.effective_webapp_url))],
[
InlineKeyboardButton(text="Мои авто", callback_data="menu:garage"),
InlineKeyboardButton(text="Аналитика", callback_data="menu:analytics"),
],
[
InlineKeyboardButton(text="Добавить запись", callback_data="menu:add_record"),
InlineKeyboardButton(text="СТО", callback_data="menu:sto"),
],
]
)
def menu_inline_keyboard(include_sto_workplace: bool = False) -> InlineKeyboardMarkup:
rows = [
[InlineKeyboardButton(text="Открыть Mini App", web_app=WebAppInfo(url=webapp_url()))],
[
InlineKeyboardButton(text="Мои авто", callback_data="menu:garage"),
InlineKeyboardButton(text="Аналитика", callback_data="menu:analytics"),
],
[
InlineKeyboardButton(text="Добавить запись", callback_data="menu:add_record"),
InlineKeyboardButton(text="СТО", callback_data="menu:sto"),
],
]
if include_sto_workplace:
rows.append([InlineKeyboardButton(text="Панель СТО", web_app=WebAppInfo(url=sto_workplace_url()))])
return InlineKeyboardMarkup(inline_keyboard=rows)
def admin_card_keyboard(center_id: int) -> InlineKeyboardMarkup:
@@ -127,17 +165,19 @@ async def start(message: Message) -> None:
"CarPass ведет цифровой паспорт автомобиля: заправки, ТО, страховку, штрафы, стоимость владения и подтвержденную историю СТО.\n\n"
"Mini App открывай кнопкой под сообщением: так Telegram передает защищенную авторизацию."
)
await message.answer(text, reply_markup=menu_inline_keyboard())
await message.answer("Клавиатура ниже открывает команды бота.", reply_markup=main_keyboard())
centers = await sto_workplace_centers(message.from_user.id)
await message.answer(text, reply_markup=menu_inline_keyboard(include_sto_workplace=bool(centers)))
await message.answer("Клавиатура ниже открывает команды бота.", reply_markup=await main_keyboard_for(message.from_user.id))
@dp.message(F.text == "Меню")
@dp.message(Command("menu"))
async def menu(message: Message) -> None:
await upsert(message)
centers = await sto_workplace_centers(message.from_user.id)
await message.answer(
"Главное меню CarPass. Быстрые команды доступны здесь, а полный интерфейс работает в Mini App.",
reply_markup=menu_inline_keyboard(),
reply_markup=menu_inline_keyboard(include_sto_workplace=bool(centers)),
)
@@ -315,13 +355,9 @@ async def appointments(message: Message) -> None:
@dp.message(Command("sto_bookings"))
async def sto_bookings(message: Message) -> None:
await upsert(message)
try:
centers = await api.my_service_centers(message.from_user.id)
except httpx.HTTPStatusError as error:
await message.answer(f"Кабинет СТО не доступен: {error.response.text}")
return
centers = await sto_workplace_centers(message.from_user.id)
if not centers:
await message.answer("У вас пока нет СТО. Подайте заявку через /register_sto или Mini App.")
await message.answer("Панель СТО доступна только владельцу подтвержденного СТО и активным механикам.")
return
center = centers[0]
try:
@@ -338,7 +374,40 @@ async def sto_bookings(message: Message) -> None:
]
for item in pending[:8]:
lines.append(f"#{item['id']} {item['service_name']}{item['requested_start_at'][:16].replace('T', ' ')}")
await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Кабинет СТО"))
await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Открыть панель СТО", "sto.html"))
@dp.message(Command("sto_workplace"))
@dp.message(F.text == "Панель СТО")
async def sto_workplace(message: Message) -> None:
await upsert(message)
centers = await sto_workplace_centers(message.from_user.id)
if not centers:
await message.answer("Панель СТО доступна только владельцу подтвержденного СТО и активным механикам.")
return
center = centers[0]
await message.answer(
f"Панель СТО: {center.get('display_name') or center.get('name')}",
reply_markup=webapp_inline_keyboard("Открыть панель СТО", "sto.html"),
)
@dp.message(Command("accept_sto_invite"))
async def accept_sto_invite(message: Message, command: CommandObject) -> None:
await upsert(message)
token = command.args.strip() if command.args else ""
if not token:
await message.answer("Пришлите команду вместе с токеном: /accept_sto_invite <token>")
return
try:
employee = await api.accept_sto_invite(message.from_user.id, token)
except httpx.HTTPStatusError as error:
await message.answer(f"Приглашение не принято: {error.response.text}")
return
await message.answer(
f"Приглашение принято. Роль: {employee['role']}.",
reply_markup=webapp_inline_keyboard("Открыть панель СТО", "sto.html"),
)
@dp.message(Command("register_sto"))
@@ -508,6 +577,18 @@ async def admin_callback(callback: CallbackQuery) -> None:
@dp.message(F.text == "Помощь")
@dp.message(Command("help"))
async def help_message(message: Message) -> None:
centers = await sto_workplace_centers(message.from_user.id)
sto_workplace_help = (
"• /sto_bookings или /sto_workplace — панель подтвержденного СТО;\n"
"• /accept_sto_invite <token> — принять приглашение сотрудника;\n"
if centers
else ""
)
sto_business_help = (
"СТО: прими заявку, создай заказ-наряд, добавь работы/товары/жидкости, отправь владельцу на согласование и закрой работу после выполнения.\n"
if centers
else ""
)
await message.answer(
"CarPass помогает вести цифровой паспорт автомобиля.\n\n"
"Главное:\n"
@@ -519,13 +600,13 @@ async def help_message(message: Message) -> None:
"• /analytics — стоимость владения и расход;\n"
"• /sto — каталог проверенных СТО;\n"
"• /appointments — мои записи в СТО;\n"
"• /sto_bookings — заявки и календарь для владельца СТО;\n"
"• /register_sto — заявка на СТО.\n\n"
f"{sto_workplace_help}"
"\n"
"Владелец: добавь авто, выбери проверенное СТО, создай запись, согласуй заказ-наряд и смотри завершенные работы в истории автомобиля.\n"
"СТО: прими заявку, создай заказ-наряд, добавь работы/товары/жидкости, отправь владельцу на согласование и закрой работу после выполнения.\n"
f"{sto_business_help}"
"Безопасность: СТО видит автомобиль только после подтверждения владельца, а спорные изменения VIN, номера и пробега идут через согласование.\n\n"
"Mini App открывай только кнопкой под сообщением: Telegram передает initData, и авторизация проходит корректно.",
reply_markup=menu_inline_keyboard(),
reply_markup=menu_inline_keyboard(include_sto_workplace=bool(centers)),
)