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:
+36
-8
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user