feat(access): демо-QR — многоразовое приглашение для показа
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:
2026-10-22 18:11:53 +03:00
co-authored by Claude Opus 5.5
parent 7a12b3525a
commit e8c3a321a3
14 changed files with 247 additions and 15 deletions
+36 -8
View File
@@ -36,6 +36,7 @@ class TgUser:
class CreatedInvite:
token: str
expires_at: datetime
max_uses: int = 1
def hash_token(token: str) -> str:
@@ -80,16 +81,25 @@ class AccessService:
.values(last_seen_at=now, full_name=seen.full_name, username=seen.username)
)
async def create_invite(self, admin_id: int) -> CreatedInvite:
async def create_invite(self, admin_id: int, demo: bool = False) -> CreatedInvite:
"""A personal one-time invite, or the demo QR (#55): up to `demo_invite_max_uses` entrants, short-lived."""
token = secrets.token_urlsafe(16) # 128 bits, 22 chars of [A-Za-z0-9_-]: fits the 64-char deep link payload
expires_at = self._clock() + timedelta(hours=self._settings.invite_ttl_hours)
if demo:
max_uses, ttl = self._settings.demo_invite_max_uses, self._settings.demo_invite_ttl_hours
else:
max_uses, ttl = 1, self._settings.invite_ttl_hours
expires_at = self._clock() + timedelta(hours=ttl)
async with self._sessions.begin() as s:
s.add(
Invite(
token_hash=hash_token(token), created_by=admin_id, created_at=self._clock(), expires_at=expires_at
token_hash=hash_token(token),
created_by=admin_id,
created_at=self._clock(),
expires_at=expires_at,
max_uses=max_uses,
)
)
return CreatedInvite(token=token, expires_at=expires_at)
return CreatedInvite(token=token, expires_at=expires_at, max_uses=max_uses)
async def redeem(self, token: str, who: TgUser) -> RedeemStatus:
now = self._clock()
@@ -111,8 +121,9 @@ class AccessService:
return RedeemStatus.THROTTLED
invite = await s.scalar(select(Invite).where(Invite.token_hash == hash_token(token)))
if invite is None or invite.used_by is not None or _aware(invite.expires_at) <= now:
if invite is None or invite.uses >= invite.max_uses or _aware(invite.expires_at) <= now:
raise _Rejected
guest = invite.max_uses > 1
if existing is None:
s.add(
@@ -122,21 +133,23 @@ class AccessService:
username=who.username,
role=Role.MEMBER,
invited_by=invite.created_by,
guest=guest,
joined_at=now,
last_seen_at=now,
)
)
else: # revoked earlier, invited again
existing.is_active, existing.role, existing.invited_by = True, Role.MEMBER, invite.created_by
existing.guest = guest
existing.full_name, existing.username, existing.last_seen_at = who.full_name, who.username, now
await s.flush() # the user row must exist before invites.used_by points at it
# Claim atomically: of two people scanning the same QR only one UPDATE matches the row,
# Claim atomically: when two people race for the last use only one UPDATE matches the row,
# the loser's transaction (with its freshly created user) is rolled back.
result = await s.execute(
update(Invite)
.where(Invite.id == invite.id, Invite.used_by.is_(None))
.values(used_by=who.tg_id, used_at=now)
.where(Invite.id == invite.id, Invite.uses < Invite.max_uses)
.values(uses=Invite.uses + 1, used_by=who.tg_id, used_at=now)
)
if cast(CursorResult[Any], result).rowcount != 1:
raise _Rejected
@@ -164,6 +177,21 @@ class AccessService:
await s.execute(delete(FsmState).where(FsmState.key.like(f"%:{tg_id}:{tg_id}:%")))
return True
async def revoke_guests(self) -> int:
"""After the talk: cut off everybody who came by the demo QR and expire it — a photo of it lets nobody in."""
now = self._clock()
async with self._sessions.begin() as s:
await s.execute(update(Invite).where(Invite.max_uses > 1, Invite.expires_at > now).values(expires_at=now))
ids = list(
await s.scalars(
select(User.tg_id).where(User.guest.is_(True), User.is_active.is_(True), User.role != Role.ADMIN)
)
)
revoked = 0
for tg_id in ids:
revoked += await self.revoke(tg_id)
return revoked
class _Rejected(Exception):
pass