feat(access): демо-QR — многоразовое приглашение для показа
CI / ruff · mypy · pytest (pull_request) Successful in 1h3m54s
CI / ruff · mypy · pytest (pull_request) Successful in 1h3m54s
- invites.max_uses/uses: обычное приглашение — 1 вход, демо-QR — до DEMO_INVITE_MAX_USES (50), живёт DEMO_INVITE_TTL_HOURS (3 ч); захват атомарный, UPDATE … WHERE uses < max_uses - users.guest: вошедшие по демо-QR; «🧹 Отключить гостей демо» отзывает всех и гасит демо-QR - «👥 Участники»: кнопка «🎤 Демо-QR», гости в списке — 🎟 - миграция 0003 (ADD COLUMN без batch — SQLite не пересоздаёт users); проверена на Postgres - SPEC A-3a, ADR-017, DEMO.md, .env.example Refs #55 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""«👥 Участники» for admins: list, invite by QR, revoke access (SPEC A-1, A-7)."""
|
||||
"""«👥 Участники» for admins: list, invite by QR (personal or the demo one, #55), revoke access (SPEC A-1, A-7)."""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
@@ -21,6 +21,9 @@ class MembersAction(StrEnum):
|
||||
ASK_REVOKE = "ask"
|
||||
REVOKE = "rev"
|
||||
LIST = "list"
|
||||
DEMO_INVITE = "demo"
|
||||
ASK_REVOKE_GUESTS = "askg"
|
||||
REVOKE_GUESTS = "revg"
|
||||
|
||||
|
||||
class MembersCb(CallbackData, prefix="mem"):
|
||||
@@ -38,7 +41,7 @@ def render_members(texts: Texts, members: list[User]) -> tuple[str, InlineKeyboa
|
||||
for m in members:
|
||||
lines.append(
|
||||
t.line.format(
|
||||
icon="👑" if m.role is Role.ADMIN else "👤",
|
||||
icon="👑" if m.role is Role.ADMIN else "🎟" if m.guest else "👤",
|
||||
name=m.full_name,
|
||||
username=f" @{m.username}" if m.username else "",
|
||||
joined=_fmt(m.joined_at, t.never),
|
||||
@@ -47,6 +50,9 @@ def render_members(texts: Texts, members: list[User]) -> tuple[str, InlineKeyboa
|
||||
)
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=t.invite, callback_data=MembersCb(action=MembersAction.INVITE))
|
||||
kb.button(text=t.demo_invite, callback_data=MembersCb(action=MembersAction.DEMO_INVITE))
|
||||
if any(m.guest and m.role is not Role.ADMIN for m in members):
|
||||
kb.button(text=t.revoke_guests, callback_data=MembersCb(action=MembersAction.ASK_REVOKE_GUESTS))
|
||||
for m in members:
|
||||
if m.role is not Role.ADMIN:
|
||||
kb.button(
|
||||
@@ -81,11 +87,14 @@ def build_router(texts: Texts, access: AccessService) -> Router:
|
||||
return
|
||||
msg = query.message
|
||||
match callback_data.action:
|
||||
case MembersAction.INVITE:
|
||||
invite = await access.create_invite(member.tg_id)
|
||||
case MembersAction.INVITE | MembersAction.DEMO_INVITE:
|
||||
demo = callback_data.action is MembersAction.DEMO_INVITE
|
||||
invite = await access.create_invite(member.tg_id, demo=demo)
|
||||
me = await bot.me()
|
||||
link = invite_link(me.username or "", invite.token)
|
||||
caption = t.invite_caption.format(link=link, expires=invite.expires_at.strftime("%d.%m %H:%M"))
|
||||
caption = (t.demo_invite_caption if demo else t.invite_caption).format(
|
||||
link=link, expires=invite.expires_at.strftime("%d.%m %H:%M"), max_uses=invite.max_uses
|
||||
)
|
||||
await msg.answer_photo(BufferedInputFile(qr_png(link), "invite.png"), caption=caption)
|
||||
case MembersAction.ASK_REVOKE:
|
||||
target = await access.get_member(callback_data.tg_id)
|
||||
@@ -103,6 +112,15 @@ def build_router(texts: Texts, access: AccessService) -> Router:
|
||||
text, kb_markup = render_members(texts, await access.list_members())
|
||||
note = t.revoked.format(name=name) if ok else t.not_revoked
|
||||
await msg.edit_text(f"{note}\n\n{text}", reply_markup=kb_markup)
|
||||
case MembersAction.ASK_REVOKE_GUESTS:
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=t.confirm_yes, callback_data=MembersCb(action=MembersAction.REVOKE_GUESTS))
|
||||
kb.button(text=t.confirm_no, callback_data=MembersCb(action=MembersAction.LIST))
|
||||
await msg.edit_text(t.confirm_guests, reply_markup=kb.as_markup())
|
||||
case MembersAction.REVOKE_GUESTS:
|
||||
count = await access.revoke_guests()
|
||||
text, kb_markup = render_members(texts, await access.list_members())
|
||||
await msg.edit_text(f"{t.guests_revoked.format(count=count)}\n\n{text}", reply_markup=kb_markup)
|
||||
case MembersAction.LIST:
|
||||
text, kb_markup = render_members(texts, await access.list_members())
|
||||
await msg.edit_text(text, reply_markup=kb_markup)
|
||||
|
||||
Reference in New Issue
Block a user