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
+6
View File
@@ -22,6 +22,7 @@ from sqlalchemy import (
String,
Text,
UniqueConstraint,
false,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -128,6 +129,8 @@ class User(Base):
full_name: Mapped[str] = mapped_column(String(256))
role: Mapped[Role] = mapped_column(enum(Role, "role"), default=Role.MEMBER)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Came in by a multi-use demo invite: cut off all at once after the talk (#55)
guest: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
invited_by: Mapped[int | None] = mapped_column(TgId, ForeignKey("users.tg_id"))
joined_at: Mapped[datetime] = mapped_column(default=utcnow)
last_seen_at: Mapped[datetime | None]
@@ -142,6 +145,9 @@ class Invite(Base):
created_by: Mapped[int] = mapped_column(TgId, ForeignKey("users.tg_id"))
created_at: Mapped[datetime] = mapped_column(default=utcnow)
expires_at: Mapped[datetime]
# 1 — a personal invite; more — the demo QR shown to an audience (#55). used_by/used_at — the latest entrant.
max_uses: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
uses: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
used_by: Mapped[int | None] = mapped_column(TgId, ForeignKey("users.tg_id"))
used_at: Mapped[datetime | None]