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
@@ -0,0 +1,33 @@
"""multi-use demo invite (#55): invites.max_uses/uses, users.guest
Revision ID: 0003
Revises: 0002
Create Date: 2026-10-22 12:00:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0003"
down_revision: str | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Plain ADD COLUMN, not batch: on SQLite a batch rebuilds `users`, which invites reference, and the FKs fail
op.add_column("invites", sa.Column("max_uses", sa.Integer(), server_default="1", nullable=False))
op.add_column("invites", sa.Column("uses", sa.Integer(), server_default="0", nullable=False))
op.add_column("users", sa.Column("guest", sa.Boolean(), server_default=sa.false(), nullable=False))
# Invites redeemed before this migration are spent
op.execute("UPDATE invites SET uses = 1 WHERE used_by IS NOT NULL")
def downgrade() -> None:
with op.batch_alter_table("users", schema=None) as batch_op:
batch_op.drop_column("guest")
with op.batch_alter_table("invites", schema=None) as batch_op:
batch_op.drop_column("uses")
batch_op.drop_column("max_uses")
+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]