From 758f42142e0d29b7253ea2de43b4bd35950464db Mon Sep 17 00:00:00 2001 From: Brandon Lo Date: Wed, 12 Aug 2026 00:07:57 -0700 Subject: [PATCH 001/185] feat(forms): Added form and form fields model and alembic migration --- ...548626dd_add_form_and_form_field_models.py | 124 ++++++++++++++++++ backend/app/models/models.py | 69 ++++++++++ 2 files changed, 193 insertions(+) create mode 100644 backend/alembic/versions/7c07548626dd_add_form_and_form_field_models.py diff --git a/backend/alembic/versions/7c07548626dd_add_form_and_form_field_models.py b/backend/alembic/versions/7c07548626dd_add_form_and_form_field_models.py new file mode 100644 index 00000000..ea7707a1 --- /dev/null +++ b/backend/alembic/versions/7c07548626dd_add_form_and_form_field_models.py @@ -0,0 +1,124 @@ +"""add form and form field models + +Revision ID: 7c07548626dd +Revises: c7b3f4a9d2e1 +Create Date: 2026-08-11 23:52:51.254099 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '7c07548626dd' +down_revision: Union[str, None] = 'c7b3f4a9d2e1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('forms', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('owner_type', sa.String(length=16), nullable=False), + sa.Column('tournament_id', sa.Integer(), nullable=True), + sa.Column('chapter_id', sa.Integer(), nullable=True), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint("(owner_type = 'tournament' AND tournament_id IS NOT NULL AND chapter_id IS NULL) OR (owner_type = 'chapter' AND chapter_id IS NOT NULL AND tournament_id IS NULL)", name='ck_form_owner_exclusive'), + sa.ForeignKeyConstraint(['chapter_id'], ['alumni_chapters.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), + sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_forms_id'), 'forms', ['id'], unique=False) + op.create_table('form_fields', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('form_id', sa.Integer(), nullable=False), + sa.Column('order', sa.Integer(), nullable=False), + sa.Column('label', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('question_type', sa.String(length=32), nullable=False), + sa.Column('field_key', sa.String(length=64), nullable=False), + sa.Column('config', sa.JSON(), nullable=True), + sa.Column('required', sa.Boolean(), nullable=False), + sa.Column('is_archived', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('form_id', 'field_key', name='uq_form_field_key') + ) + op.create_index(op.f('ix_form_fields_id'), 'form_fields', ['id'], unique=False) + op.alter_column('sheet_configs', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True) + op.alter_column('sheet_configs', 'updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True) + op.alter_column('tournament_events', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True) + op.alter_column('tournament_events', 'updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True) + op.alter_column('tournament_memberships', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True) + op.alter_column('tournament_memberships', 'updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True) + op.alter_column('tournaments', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True) + op.alter_column('tournaments', 'updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True) + op.alter_column('users', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True) + op.alter_column('users', 'updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False) + op.alter_column('users', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False) + op.alter_column('tournaments', 'updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False) + op.alter_column('tournaments', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False) + op.alter_column('tournament_memberships', 'updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False) + op.alter_column('tournament_memberships', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False) + op.alter_column('tournament_events', 'updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False) + op.alter_column('tournament_events', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False) + op.alter_column('sheet_configs', 'updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False) + op.alter_column('sheet_configs', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False) + op.drop_index(op.f('ix_form_fields_id'), table_name='form_fields') + op.drop_table('form_fields') + op.drop_index(op.f('ix_forms_id'), table_name='forms') + op.drop_table('forms') + # ### end Alembic commands ### diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 88dca5a2..b98b9644 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -16,6 +16,7 @@ from app.db.session import Base from app.core.age import meets_age_requirement +from pydantic import field_validator def utcnow(): @@ -184,6 +185,7 @@ class User(Base): university = relationship("University", back_populates="users") chapter_membership = relationship("ChapterMembership", back_populates="user", uselist=False) join_codes = relationship("JoinCode", back_populates="creator") + created_forms = relationship("Form", back_populates="creator") # --------------------------------------------------------------------------- # Competition Experience @@ -280,6 +282,7 @@ class Tournament(Base): join_codes = relationship("JoinCode", back_populates="tournament", cascade="all, delete-orphan") audit_log = relationship("AuditLogEntry", back_populates="tournament", cascade="all, delete-orphan") event_shifts = relationship("TournamentShift", back_populates="tournament", cascade="all, delete-orphan") + forms = relationship("Form", back_populates="tournament", cascade="all, delete-orphan") # Exactly one of university_id/location (XOR). Checked at flush, not @@ -615,6 +618,7 @@ class AlumniChapter(Base): chapter_memberships = relationship("ChapterMembership", back_populates="alumni_chapter", cascade="all, delete-orphan") join_codes = relationship("JoinCode", back_populates="alumni_chapter", cascade="all, delete-orphan") tournament_chapters = relationship("TournamentChapter", back_populates="chapter") + forms = relationship("Form", back_populates="chapter", cascade="all, delete-orphan") # --------------------------------------------------------------------------- @@ -649,3 +653,68 @@ class TournamentChapter(Base): # Relationships tournament = relationship("Tournament", back_populates="tournament_chapters") chapter = relationship("AlumniChapter", back_populates="tournament_chapters") + +class Form(Base): + __tablename__ = "forms" + + id = Column(Integer, primary_key=True, index=True) + owner_type = Column(String(16), nullable=False) # "tournament" | "chapter" + tournament_id = Column(Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=True) + chapter_id = Column(Integer, ForeignKey("alumni_chapters.id", ondelete="CASCADE"), nullable=True) + name = Column(String(255), nullable=False) + created_by = Column(Integer, ForeignKey("users.id"), nullable=False) + + created_at = Column(DateTime(timezone=True), default=utcnow) + updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + tournament = relationship("Tournament", back_populates="forms") + chapter = relationship("AlumniChapter", back_populates="forms") + creator = relationship("User", back_populates="forms") + fields = relationship("FormField", back_populates="form", cascade="all, delete-orphan", order_by="FormField.order") + + __table_args__ = ( + CheckConstraint( + "(owner_type = 'tournament' AND tournament_id IS NOT NULL AND chapter_id IS NULL) OR " + "(owner_type = 'chapter' AND chapter_id IS NOT NULL AND tournament_id IS NULL)", + name="ck_form_owner_exclusive", + ), + ) + + +class FormField(Base): + __tablename__ = "form_fields" + + id = Column(Integer, primary_key=True, index=True) + form_id = Column(Integer, ForeignKey("forms.id", ondelete="CASCADE"), nullable=False) + order = Column(Integer, nullable=False) + label = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + + question_type = Column(String(32), nullable=False) + # short_text | paragraph | single_select_radio | single_select_dropdown + # | multi_select | ranked_choice | grid | shift_select | page_break + + field_key = Column(String(64), nullable=False) + + config = Column(JSON, nullable=True) + # For plain choice questions: {"options": [{"id": "opt_1", "label": "...", + # "archived": false, "next_section_id": null, "allow_other": false}, ...]} + # For "grid": {"rows": [{"id","label"}...], "columns": [{"id","label"}...], + # "column_selection": "single"|"multiple"} + # For "page_break": unused, null + + required = Column(Boolean, nullable=False, default=False) + is_archived = Column(Boolean, nullable=False, default=False) + + form = relationship("Form", back_populates="fields") + + __table_args__ = ( + UniqueConstraint("form_id", "field_key", name="uq_form_field_key"), + ) + + @field_validator("field_key") + @classmethod + def validate_field_key(cls, v: str) -> str: + if not v.replace("_", "").isalnum(): + raise ValueError("field_key must be snake_case alphanumeric") + return v \ No newline at end of file From e461333fd66cf6e06654d440e0212cdd9c34eca5 Mon Sep 17 00:00:00 2001 From: Brandon Lo Date: Wed, 12 Aug 2026 11:56:13 -0700 Subject: [PATCH 002/185] fix(models): fix form relation with user --- ...els.py => 2b134a51cf44_form_and_form_fields_models.py} | 8 ++++---- backend/app/models/models.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename backend/alembic/versions/{7c07548626dd_add_form_and_form_field_models.py => 2b134a51cf44_form_and_form_fields_models.py} (97%) diff --git a/backend/alembic/versions/7c07548626dd_add_form_and_form_field_models.py b/backend/alembic/versions/2b134a51cf44_form_and_form_fields_models.py similarity index 97% rename from backend/alembic/versions/7c07548626dd_add_form_and_form_field_models.py rename to backend/alembic/versions/2b134a51cf44_form_and_form_fields_models.py index ea7707a1..1736e047 100644 --- a/backend/alembic/versions/7c07548626dd_add_form_and_form_field_models.py +++ b/backend/alembic/versions/2b134a51cf44_form_and_form_fields_models.py @@ -1,8 +1,8 @@ -"""add form and form field models +"""form and form fields models -Revision ID: 7c07548626dd +Revision ID: 2b134a51cf44 Revises: c7b3f4a9d2e1 -Create Date: 2026-08-11 23:52:51.254099 +Create Date: 2026-08-12 00:46:13.556533 """ from typing import Sequence, Union @@ -12,7 +12,7 @@ from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. -revision: str = '7c07548626dd' +revision: str = '2b134a51cf44' down_revision: Union[str, None] = 'c7b3f4a9d2e1' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/backend/app/models/models.py b/backend/app/models/models.py index b98b9644..eca93c00 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -669,7 +669,7 @@ class Form(Base): tournament = relationship("Tournament", back_populates="forms") chapter = relationship("AlumniChapter", back_populates="forms") - creator = relationship("User", back_populates="forms") + creator = relationship("User", back_populates="created_forms") fields = relationship("FormField", back_populates="form", cascade="all, delete-orphan", order_by="FormField.order") __table_args__ = ( From 1497e675e880f9838f3c1918b42171587913d2d6 Mon Sep 17 00:00:00 2001 From: Brandon Lo Date: Wed, 12 Aug 2026 16:28:40 -0700 Subject: [PATCH 003/185] feat(forms): add form question presets --- backend/app/core/form_presets.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 backend/app/core/form_presets.py diff --git a/backend/app/core/form_presets.py b/backend/app/core/form_presets.py new file mode 100644 index 00000000..a360ce7c --- /dev/null +++ b/backend/app/core/form_presets.py @@ -0,0 +1,14 @@ +RESERVED_FIELD_KEYS: dict[str, dict] = { + "availability": { + "allowed_question_types": {"shift_select"}, + "write_through_target": "availability", + }, + "lunch": { + "allowed_question_types": {"single_select_radio", "single_select_dropdown", "multi_select"}, + "write_through_target": "lunch", + }, + "event_preference": { + "allowed_question_types": {"multi_select", "ranked_choice", "single_select_dropdown", "grid"}, + "write_through_target": None, + }, +} \ No newline at end of file From 38735edc4db7e923a1a765966c70406cd229d716 Mon Sep 17 00:00:00 2001 From: Brandon Lo Date: Wed, 12 Aug 2026 22:58:44 -0700 Subject: [PATCH 004/185] feat(forms): add form response and answer models --- ...309024e1_form_reponse_and_answer_models.py | 55 +++++++++++++++++++ backend/app/models/models.py | 40 +++++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/0eba309024e1_form_reponse_and_answer_models.py diff --git a/backend/alembic/versions/0eba309024e1_form_reponse_and_answer_models.py b/backend/alembic/versions/0eba309024e1_form_reponse_and_answer_models.py new file mode 100644 index 00000000..b5d16594 --- /dev/null +++ b/backend/alembic/versions/0eba309024e1_form_reponse_and_answer_models.py @@ -0,0 +1,55 @@ +"""form reponse and answer models + +Revision ID: 0eba309024e1 +Revises: 2b134a51cf44 +Create Date: 2026-08-12 22:40:57.539707 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0eba309024e1' +down_revision: Union[str, None] = '2b134a51cf44' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('form_responses', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('form_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('submitted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('form_id', 'user_id', name='uq_form_response_per_user') + ) + op.create_index(op.f('ix_form_responses_id'), 'form_responses', ['id'], unique=False) + op.create_table('form_answers', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('response_id', sa.Integer(), nullable=False), + sa.Column('field_id', sa.Integer(), nullable=False), + sa.Column('value', sa.JSON(), nullable=False), + sa.ForeignKeyConstraint(['field_id'], ['form_fields.id'], ), + sa.ForeignKeyConstraint(['response_id'], ['form_responses.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('response_id', 'field_id', name='uq_answer_per_field') + ) + op.create_index(op.f('ix_form_answers_id'), 'form_answers', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_form_answers_id'), table_name='form_answers') + op.drop_table('form_answers') + op.drop_index(op.f('ix_form_responses_id'), table_name='form_responses') + op.drop_table('form_responses') + # ### end Alembic commands ### diff --git a/backend/app/models/models.py b/backend/app/models/models.py index eca93c00..1b9a30df 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -186,6 +186,7 @@ class User(Base): chapter_membership = relationship("ChapterMembership", back_populates="user", uselist=False) join_codes = relationship("JoinCode", back_populates="creator") created_forms = relationship("Form", back_populates="creator") + form_responses = relationship("FormResponse", back_populates="user") # --------------------------------------------------------------------------- # Competition Experience @@ -671,6 +672,7 @@ class Form(Base): chapter = relationship("AlumniChapter", back_populates="forms") creator = relationship("User", back_populates="created_forms") fields = relationship("FormField", back_populates="form", cascade="all, delete-orphan", order_by="FormField.order") + responses = relationship("FormResponse", back_populates="form", cascade="all, delete-orphan") __table_args__ = ( CheckConstraint( @@ -707,6 +709,7 @@ class FormField(Base): is_archived = Column(Boolean, nullable=False, default=False) form = relationship("Form", back_populates="fields") + answer = relationship("FormAnswer", back_populates="field") __table_args__ = ( UniqueConstraint("form_id", "field_key", name="uq_form_field_key"), @@ -717,4 +720,39 @@ class FormField(Base): def validate_field_key(cls, v: str) -> str: if not v.replace("_", "").isalnum(): raise ValueError("field_key must be snake_case alphanumeric") - return v \ No newline at end of file + return v + + +class FormResponse(Base): + __tablename__ = "form_responses" + + id = Column(Integer, primary_key=True, index=True) + form_id = Column(Integer, ForeignKey("forms.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + submitted_at = Column(DateTime(timezone=True), default=utcnow) + updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + form = relationship("Form", back_populates="responses") + user = relationship("User", back_populates="form_responses") + answers = relationship("FormAnswer", back_populates="response", cascade="all, delete-orphan") + + __table_args__ = ( + UniqueConstraint("form_id", "user_id", name="uq_form_response_per_user"), + ) + + +class FormAnswer(Base): + __tablename__ = "form_answers" + + id = Column(Integer, primary_key=True, index=True) + response_id = Column(Integer, ForeignKey("form_responses.id", ondelete="CASCADE"), nullable=False) + field_id = Column(Integer, ForeignKey("form_fields.id"), nullable=False) + value = Column(JSON, nullable=False) + + response = relationship("FormResponse", back_populates="answers") + field = relationship("FormField", back_populates="answer") + + __table_args__ = ( + UniqueConstraint("response_id", "field_id", name="uq_answer_per_field"), + ) \ No newline at end of file From ab6dd7d4d0d0bef881a70d0d22073ae889fa26dd Mon Sep 17 00:00:00 2001 From: Brandon Lo Date: Fri, 14 Aug 2026 11:37:00 -0700 Subject: [PATCH 005/185] feat(forms): helper functions and tests for form logic --- backend/app/core/form/__init__.py | 143 ++++++++++++++++++ .../core/{form_presets.py => form/presets.py} | 0 backend/tests/api/test_forms.py | 100 ++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 backend/app/core/form/__init__.py rename backend/app/core/{form_presets.py => form/presets.py} (100%) create mode 100644 backend/tests/api/test_forms.py diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py new file mode 100644 index 00000000..3c980315 --- /dev/null +++ b/backend/app/core/form/__init__.py @@ -0,0 +1,143 @@ +from sqlalchemy.orm import Session +from sqlalchemy.orm.attributes import flag_modified +from app.models.models import FormAnswer, FormField + +import re # Regular Expressions for searching, matching, and extracting patterns in text strings + +def remove_form_field( + db: Session, + field: FormField +) -> bool: + + has_answers = db.query(FormAnswer).filter(FormAnswer.field_id == field.id).first() is not None + + if has_answers: + field.is_archived = True + db.commit() + return True + else: + db.delete(field) + db.commit() + return False + +def update_field_text( + db: Session, + field: FormField, + label: str | None, + description: str | None +) -> FormField: + + if label is not None: + field.label = label + if description is not None: + field.description = description + + db.commit() + db.refresh(field) + return field + +def replace_field_type( + db: Session, + field: FormField, + new_type: str +) -> FormField: + + field.is_archived = True + + old_key = field.field_key + field.field_key = f"{old_key}_archived_{field.id}" + + new_field = FormField( + form_id=field.form_id, + order=field.order, + label=field.label, + description=field.description, + question_type=new_type, + field_key=old_key, + required=field.required, + is_archived=False, + ) + + db.add(new_field) + db.commit() + db.refresh(new_field) + return new_field + +def add_option( + db: Session, + field: FormField, + label: str | None = None, +) -> FormField: + + config = dict(field.config or {}) + options = config.get("options", []) + + max_id = 0 + for option in options: + match = re.search(r"^opt_(\d+)$", option.get("id", "")) + if match: + num = int(match.group(1)) + if num > max_id: + max_id = num + + new_option = { + "id": f"opt_{max_id + 1}", + "label": label, + "archived": False, + "next_section_id": None, + "allow_other": False + } + + options.append(new_option) + config["options"] = options + + field.config = config + flag_modified(field, "config") + + db.commit() + db.refresh(field) + + return field + + +def change_option_label( + db: Session, + field: FormField, + option_id: str, + label: str, +) -> FormField: + + config = dict(field.config or {}) + options = config.get("options", []) + + for option in options: + if option.get("id") == option_id: + option["label"] = label + break + + field.config = config + flag_modified(field, "config") + + db.commit() + db.refresh(field) + return field + +def remove_option_from_field( + db: Session, + field: FormField, + option_id: str, +) -> FormField: + config = dict(field.config or {}) + options = config.get("options", []) + + for option in options: + if option.get("id") == option_id: + option["archived"] = True + break + + field.config = config + flag_modified(field, "config") + + db.commit() + db.refresh(field) + return field \ No newline at end of file diff --git a/backend/app/core/form_presets.py b/backend/app/core/form/presets.py similarity index 100% rename from backend/app/core/form_presets.py rename to backend/app/core/form/presets.py diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py new file mode 100644 index 00000000..0c0c0d71 --- /dev/null +++ b/backend/tests/api/test_forms.py @@ -0,0 +1,100 @@ +from app.core.form import remove_form_field, remove_option_from_field, replace_field_type +from app.models.models import Form, FormAnswer, FormField, FormResponse + + +def _make_form(db, user, tournament, name="Test form"): + form = Form( + owner_type="tournament", + tournament_id=tournament.id, + name=name, + created_by=user.id, + ) + db.add(form) + db.flush() + return form + + +def _make_field(db, form, *, order=1, field_key="favorite_color", question_type="single_select_dropdown"): + field = FormField( + form_id=form.id, + order=order, + label="Favorite color", + description="Pick a color", + question_type=question_type, + field_key=field_key, + config={ + "options": [ + {"id": "opt_1", "label": "Red", "archived": False, "next_section_id": None, "allow_other": False}, + {"id": "opt_2", "label": "Blue", "archived": False, "next_section_id": None, "allow_other": False}, + ] + }, + required=False, + is_archived=False, + ) + db.add(field) + db.flush() + return field + + +def test_remove_form_field_archives_when_answers_exist(db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, order=1, field_key="favorite_color") + + response = FormResponse(form_id=form.id, user_id=td_user.id) + db.add(response) + db.flush() + + answer = FormAnswer(response_id=response.id, field_id=field.id, value=["opt_1"]) + db.add(answer) + db.flush() + + removed = remove_form_field(db, field) + + assert removed is True + db.refresh(field) + assert field.is_archived is True + assert db.query(FormField).filter(FormField.id == field.id).one().is_archived is True + assert db.query(FormAnswer).filter(FormAnswer.field_id == field.id).one().value == ["opt_1"] + + +def test_replace_field_type_archives_old_field_and_keeps_order(db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, order=7, field_key="tshirt_size") + + replacement = replace_field_type(db, field, "multi_select") + + db.refresh(field) + assert field.is_archived is True + assert field.field_key.endswith(f"_archived_{field.id}") + + assert replacement is not field + assert replacement.form_id == form.id + assert replacement.order == field.order + assert replacement.question_type == "multi_select" + assert replacement.field_key == "tshirt_size" + assert replacement.is_archived is False + + ordered_fields = db.query(FormField).filter(FormField.form_id == form.id).order_by(FormField.order).all() + assert [f.id for f in ordered_fields] == [replacement.id, field.id] + assert replacement.order == 7 + assert field.order == 7 + + +def test_remove_option_from_field_keeps_existing_answer_values(db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, order=2, field_key="member_role") + + response = FormResponse(form_id=form.id, user_id=td_user.id) + db.add(response) + db.flush() + + answer = FormAnswer(response_id=response.id, field_id=field.id, value=["opt_1"]) + db.add(answer) + db.flush() + + updated = remove_option_from_field(db, field, "opt_1") + + assert updated is field + assert updated.config["options"][0]["archived"] is True + assert updated.config["options"][0]["label"] == "Red" + assert db.query(FormAnswer).filter(FormAnswer.field_id == field.id).one().value == ["opt_1"] From 2d37a07f7a9eb3d93e5dd7daec4b9c31277d1a5e Mon Sep 17 00:00:00 2001 From: Brandon Lo Date: Sun, 16 Aug 2026 16:29:20 -0700 Subject: [PATCH 006/185] feat(forms): form/field CRUD and tests --- backend/app/api/routes/forms.py | 100 +++++++++++++++++++++ backend/app/core/form/__init__.py | 40 ++++++++- backend/app/core/tournament/permissions.py | 8 +- backend/app/main.py | 3 +- backend/app/schemas/form.py | 75 ++++++++++++++++ 5 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 backend/app/api/routes/forms.py create mode 100644 backend/app/schemas/form.py diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py new file mode 100644 index 00000000..b678c03c --- /dev/null +++ b/backend/app/api/routes/forms.py @@ -0,0 +1,100 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.auth import get_current_user +from app.core.form import remove_form_field, update_field_text, replace_field_type, add_option, change_option_label, remove_option_from_field, resolve_field_options +from app.db.session import get_db +from app.models.models import User, Form, FormField, FormAnswer, FormResponse +from app.schemas.form import FormCreate, FormRead, FormUpdate, FormFieldCreate, FormFieldRead, FormFieldUpdate +from app.core.tournament.permissions import has_permission + +router = APIRouter(tags=["forms"]) + + +@router.get("/forms/{form_id}/", response_model=FormRead) +def get_form_for_rendering( + form_id: int, + db: Session = Depends(get_db), +): + form = db.query(Form).filter(Form.id == form_id).first() + if not form: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Form not found") + + active_fields = db.query(FormField).filter(FormField.form_id == form_id, FormField.is_archived == False).all() + + for field in active_fields: + resolved_options = resolve_field_options(db, field) + if resolved_options: + config = dict(field.config or {}) + config["options"] = resolved_options + field.config = config + + form.fields = active_fields + return form + + +@router.post("/tournaments/{tournament_id}/forms/", response_model=FormRead, status_code=status.HTTP_201_CREATED) +def create_tournament_form( + tournament_id: int, + form_in: FormCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Create Tournament Form. MANAGE_FORMS permission required.""" + if not has_permission(tournament_id, "MANAGE_FORMS", db): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authorized to perform this action") + + form = Form( + **form_in.model_dump(), + tournament_id=tournament_id, + chapter_id=None, + ) + db.add(form) + db.commit() + db.refresh(form) + return form + + +@router.patch("/forms/{form_id}/fields/{field_id}/", response_model=FormFieldRead) +def edit_form_field( + form_id: int, + field_id: int, + field_in: FormFieldUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update a form field. MANAGE_FORMS permission required""" + field = db.query(FormField).filter(FormField.id == field_id, FormField.form_id == form_id).first() + + if not field: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Field not found") + + if (field_in.question_type and field_in.question_type != field.question_type): + return replace_field_type(db, field, field_in.question_type) + + +@router.delete("/forms/{form_id}/fields/{field_id}/") +def delete_or_archive_form_field( + form_id: int, + field_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Archives a form field if responses exist. + Hard Deletes if responses do not exist. + Requires MANAGE_FORM permissions. + """ + + field = db.query(FormField).filter(FormField.id == field_id, FormField.form_id == form_id).first() + + if not field: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Field not found") + + was_archived = remove_form_field(db=db, field=field) + + return { + "success": True, + "action": "archived" if was_archived else "deleted", + "field_id": field_id + } \ No newline at end of file diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index 3c980315..0d9255d9 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -1,6 +1,6 @@ from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified -from app.models.models import FormAnswer, FormField +from app.models.models import FormAnswer, FormField, TournamentEvent import re # Regular Expressions for searching, matching, and extracting patterns in text strings @@ -140,4 +140,40 @@ def remove_option_from_field( db.commit() db.refresh(field) - return field \ No newline at end of file + return field + +def resolve_field_options(db: Session, field: FormField) -> list[dict]: + """ + Resolves option items for a given FormField. + If the field depends on live DB data (e.g. event_preference), queries the database. + Otherwise, returns options stored in field.config. + """ + # 1. Dynamic lookup: Tournament Event Preferences + if field.field_key == "event_preference": + if field.form and field.form.tournament_id: + events = ( + db.query(TournamentEvent) + .filter(TournamentEvent.tournament_id == field.form.tournament_id) + .order_by(TournamentEvent.id.asc()) + .all() + ) + return [ + { + "id": f"opt_{event.id}", + "label": event.name, + "archived": False, + "next_section_id": None, + "allow_other": False, + } + for event in events + ] + return [] + + # 2. Stubbed dynamic lookup: Availability & Lunch + elif field.field_key in ("availability", "lunch"): + # TODO(temp): wire up in Step 7 + return [] + + # 3. Static fallback: Read options list directly from config + config = dict(field.config or {}) + return config.get("options", []) \ No newline at end of file diff --git a/backend/app/core/tournament/permissions.py b/backend/app/core/tournament/permissions.py index 4ec715a4..1b7959d2 100644 --- a/backend/app/core/tournament/permissions.py +++ b/backend/app/core/tournament/permissions.py @@ -39,6 +39,7 @@ MANAGE_MEMBERS = "manage_members" # membership data — roster + assign member roles MANAGE_EVENTS = "manage_events" # read + write events page MANAGE_INVITES = "manage_invites" # join codes + staff invites +MANAGE_FORMS = "manage_forms" # forms creation and editing # Ordered list for documentation / UI display purposes ALL_PERMISSIONS: list[str] = [ @@ -47,6 +48,7 @@ MANAGE_MEMBERS, MANAGE_EVENTS, MANAGE_INVITES, + MANAGE_FORMS ] @@ -70,17 +72,17 @@ { "label": "Tournament Director", "rank": 10, - "permissions": [MANAGE_TOURNAMENT, MANAGE_ROLES, MANAGE_MEMBERS, MANAGE_EVENTS, MANAGE_INVITES], + "permissions": [MANAGE_TOURNAMENT, MANAGE_ROLES, MANAGE_MEMBERS, MANAGE_EVENTS, MANAGE_INVITES, MANAGE_FORMS], }, { "label": "Volunteer Coordinator", "rank": 20, - "permissions": [MANAGE_MEMBERS, MANAGE_ROLES, MANAGE_EVENTS, MANAGE_INVITES], + "permissions": [MANAGE_MEMBERS, MANAGE_ROLES, MANAGE_EVENTS, MANAGE_INVITES, MANAGE_FORMS], }, { "label": "Test Coordinator", "rank": 20, - "permissions": [MANAGE_MEMBERS, MANAGE_EVENTS, MANAGE_ROLES, MANAGE_INVITES], + "permissions": [MANAGE_MEMBERS, MANAGE_EVENTS, MANAGE_ROLES, MANAGE_INVITES, MANAGE_FORMS], }, { "label": "Runner", diff --git a/backend/app/main.py b/backend/app/main.py index e645f541..54e75317 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,7 +8,7 @@ from app.db.init_db import init_db, seed_dev_data from app.api.routes import ( auth, events, join, season_event, - sheets, users, user_experience, universities, + sheets, users, user_experience, universities, forms, ) from app.api.routes import tournament as tournament_core from app.api.routes.tournament import events as tournament_events @@ -112,6 +112,7 @@ def _run_archive_job(): app.include_router(chapter_admin.router, prefix="", dependencies=[api_key_dependency]) app.include_router(chapter_memberships.router, prefix="", dependencies=[api_key_dependency]) app.include_router(chapter_join_codes.router, prefix="", dependencies=[api_key_dependency]) +app.include_router(forms.router, prefix="", dependencies=[api_key_dependency]) @app.get("/health", tags=["meta"]) diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py new file mode 100644 index 00000000..63d0a513 --- /dev/null +++ b/backend/app/schemas/form.py @@ -0,0 +1,75 @@ +from datetime import datetime +from typing import Any, Literal +from pydantic import BaseModel, ConfigDict, Field + +# ========================================== +# Form Field Schemas +# ========================================== + +class FormFieldRead(BaseModel): + id: int + form_id: int + field_key: str + order: int + label: str + description: str | None = None + question_type: str + required: bool = False + is_archived: bool = False + config: dict[str, Any] | None = None + + model_config = ConfigDict(from_attributes=True) + + +class FormFieldCreate(BaseModel): + label: str + question_type: str + description: str | None = None + required: bool = False + config: dict[str, Any] | None = None + + +# ========================================== +# Form Schemas +# ========================================== + +class FormRead(BaseModel): + id: int + title: str + description: str | None = None + owner_type: Literal["tournament", "chapter", "global"] + tournament_id: int | None = None + chapter_id: int | None = None + created_by: int + is_published: bool = False + created_at: datetime + updated_at: datetime + fields: list[FormFieldRead] = [] + + model_config = ConfigDict(from_attributes=True) + + +class FormCreate(BaseModel): + title: str + description: str | None = None + owner_type: Literal["tournament", "chapter", "global"] + tournament_id: int | None = None + chapter_id: int | None = None + + +class FormUpdate(BaseModel): + title: str | None = None + description: str | None = None + is_published: bool | None = None + + +class FormFieldUpdate(BaseModel): + label: str | None = None + description: str | None = None + question_type: str | None = None + required: bool | None = None + order: int | None = None + config: dict | None = None + + class Config: + from_attributes = True \ No newline at end of file From e3a24fac392b9eec0518b263e097c2a06887cd77 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Mon, 17 Aug 2026 16:28:10 -0700 Subject: [PATCH 007/185] fix(migrations): rechain forms migration after tournament-events rewrite instead of the branchpoint --- .../versions/2b134a51cf44_form_and_form_fields_models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/alembic/versions/2b134a51cf44_form_and_form_fields_models.py b/backend/alembic/versions/2b134a51cf44_form_and_form_fields_models.py index 1736e047..a9902eea 100644 --- a/backend/alembic/versions/2b134a51cf44_form_and_form_fields_models.py +++ b/backend/alembic/versions/2b134a51cf44_form_and_form_fields_models.py @@ -1,7 +1,7 @@ """form and form fields models Revision ID: 2b134a51cf44 -Revises: c7b3f4a9d2e1 +Revises: d3e4f5a6b7c8 Create Date: 2026-08-12 00:46:13.556533 """ @@ -13,7 +13,7 @@ # revision identifiers, used by Alembic. revision: str = '2b134a51cf44' -down_revision: Union[str, None] = 'c7b3f4a9d2e1' +down_revision: Union[str, None] = 'd3e4f5a6b7c8' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None From f480b347316fd0d3676d48d4cba51c4fc1e98091 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Mon, 17 Aug 2026 18:32:08 -0700 Subject: [PATCH 008/185] refactor(forms): rework Form ownership to many-to-many tournament/chapter links --- backend/app/core/form/__init__.py | 39 +++++++-------- backend/app/models/models.py | 81 ++++++++++++++++++++++--------- 2 files changed, 79 insertions(+), 41 deletions(-) diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index 0d9255d9..04356799 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -54,7 +54,6 @@ def replace_field_type( description=field.description, question_type=new_type, field_key=old_key, - required=field.required, is_archived=False, ) @@ -150,24 +149,26 @@ def resolve_field_options(db: Session, field: FormField) -> list[dict]: """ # 1. Dynamic lookup: Tournament Event Preferences if field.field_key == "event_preference": - if field.form and field.form.tournament_id: - events = ( - db.query(TournamentEvent) - .filter(TournamentEvent.tournament_id == field.form.tournament_id) - .order_by(TournamentEvent.id.asc()) - .all() - ) - return [ - { - "id": f"opt_{event.id}", - "label": event.name, - "archived": False, - "next_section_id": None, - "allow_other": False, - } - for event in events - ] - return [] + tournament_ids = [link.tournament_id for link in field.form.tournament_links] if field.form else [] + if not tournament_ids: + return [] + + events = ( + db.query(TournamentEvent) + .filter(TournamentEvent.tournament_id.in_(tournament_ids)) + .order_by(TournamentEvent.id.asc()) + .all() + ) + return [ + { + "id": f"opt_{event.id}", + "label": event.name, + "archived": False, + "next_section_id": None, + "allow_other": False, + } + for event in events + ] # 2. Stubbed dynamic lookup: Availability & Lunch elif field.field_key in ("availability", "lunch"): diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 1b9a30df..b559e2af 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -283,7 +283,7 @@ class Tournament(Base): join_codes = relationship("JoinCode", back_populates="tournament", cascade="all, delete-orphan") audit_log = relationship("AuditLogEntry", back_populates="tournament", cascade="all, delete-orphan") event_shifts = relationship("TournamentShift", back_populates="tournament", cascade="all, delete-orphan") - forms = relationship("Form", back_populates="tournament", cascade="all, delete-orphan") + form_tournaments = relationship("FormTournament", back_populates="tournament", cascade="all, delete-orphan") # Exactly one of university_id/location (XOR). Checked at flush, not @@ -605,7 +605,6 @@ class SheetConfig(Base): # --------------------------------------------------------------------------- # AlumniChapter — a regional hub (e.g. "Bay Area") for alumni coordination. # --------------------------------------------------------------------------- - class AlumniChapter(Base): __tablename__ = "alumni_chapters" @@ -619,13 +618,12 @@ class AlumniChapter(Base): chapter_memberships = relationship("ChapterMembership", back_populates="alumni_chapter", cascade="all, delete-orphan") join_codes = relationship("JoinCode", back_populates="alumni_chapter", cascade="all, delete-orphan") tournament_chapters = relationship("TournamentChapter", back_populates="chapter") - forms = relationship("Form", back_populates="chapter", cascade="all, delete-orphan") + form_chapters = relationship("FormChapter", back_populates="chapter", cascade="all, delete-orphan") # --------------------------------------------------------------------------- # ChapterMembership — join table, User <-> AlumniChapter. # --------------------------------------------------------------------------- - class ChapterMembership(Base): __tablename__ = "chapter_memberships" @@ -644,7 +642,6 @@ class ChapterMembership(Base): # --------------------------------------------------------------------------- # TournamentChapter — junction table, AlumniChapter <-> Tournament (many-to-many). # --------------------------------------------------------------------------- - class TournamentChapter(Base): __tablename__ = "tournament_chapters" @@ -655,34 +652,64 @@ class TournamentChapter(Base): tournament = relationship("Tournament", back_populates="tournament_chapters") chapter = relationship("AlumniChapter", back_populates="tournament_chapters") +# --------------------------------------------------------------------------- +# Form — a first-party form (replaces the Google Forms + sheet-sync +# pipeline). Owned via FormTournament/FormChapter, not a direct FK — a form +# can be linked to any combination of tournaments and chapters. +# --------------------------------------------------------------------------- class Form(Base): __tablename__ = "forms" id = Column(Integer, primary_key=True, index=True) - owner_type = Column(String(16), nullable=False) # "tournament" | "chapter" - tournament_id = Column(Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=True) - chapter_id = Column(Integer, ForeignKey("alumni_chapters.id", ondelete="CASCADE"), nullable=True) name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + status = Column(String(16), nullable=False, default="draft") # "draft" | "published" | "archived" created_by = Column(Integer, ForeignKey("users.id"), nullable=False) created_at = Column(DateTime(timezone=True), default=utcnow) updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) - tournament = relationship("Tournament", back_populates="forms") - chapter = relationship("AlumniChapter", back_populates="forms") creator = relationship("User", back_populates="created_forms") fields = relationship("FormField", back_populates="form", cascade="all, delete-orphan", order_by="FormField.order") responses = relationship("FormResponse", back_populates="form", cascade="all, delete-orphan") + tournament_links = relationship("FormTournament", back_populates="form", cascade="all, delete-orphan") + chapter_links = relationship("FormChapter", back_populates="form", cascade="all, delete-orphan") - __table_args__ = ( - CheckConstraint( - "(owner_type = 'tournament' AND tournament_id IS NOT NULL AND chapter_id IS NULL) OR " - "(owner_type = 'chapter' AND chapter_id IS NOT NULL AND tournament_id IS NULL)", - name="ck_form_owner_exclusive", - ), - ) + +# --------------------------------------------------------------------------- +# FormTournament — junction table, Form <-> Tournament (many-to-many). A +# form must have at least one FormTournament or FormChapter link, enforced +# at the schema/service layer (can't CHECK across two tables). +# --------------------------------------------------------------------------- +class FormTournament(Base): + __tablename__ = "form_tournaments" + + form_id = Column(Integer, ForeignKey("forms.id", ondelete="CASCADE"), primary_key=True) + tournament_id = Column(Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), primary_key=True) + + form = relationship("Form", back_populates="tournament_links") + tournament = relationship("Tournament", back_populates="form_tournaments") +# --------------------------------------------------------------------------- +# FormChapter — junction table, Form <-> AlumniChapter (many-to-many). See +# FormTournament above — the same at-least-one-link rule applies jointly. +# --------------------------------------------------------------------------- +class FormChapter(Base): + __tablename__ = "form_chapters" + + form_id = Column(Integer, ForeignKey("forms.id", ondelete="CASCADE"), primary_key=True) + chapter_id = Column(Integer, ForeignKey("alumni_chapters.id", ondelete="CASCADE"), primary_key=True) + + form = relationship("Form", back_populates="chapter_links") + chapter = relationship("AlumniChapter", back_populates="form_chapters") + + +# --------------------------------------------------------------------------- +# FormField — a single question on a Form. question_type drives how config +# is shaped (see comments inline below). Removing a field with existing +# answers archives it instead of deleting (see app/core/form). +# --------------------------------------------------------------------------- class FormField(Base): __tablename__ = "form_fields" @@ -695,8 +722,8 @@ class FormField(Base): question_type = Column(String(32), nullable=False) # short_text | paragraph | single_select_radio | single_select_dropdown # | multi_select | ranked_choice | grid | shift_select | page_break - - field_key = Column(String(64), nullable=False) + + field_key = Column(String(64), nullable=True) config = Column(JSON, nullable=True) # For plain choice questions: {"options": [{"id": "opt_1", "label": "...", @@ -705,9 +732,11 @@ class FormField(Base): # "column_selection": "single"|"multiple"} # For "page_break": unused, null - required = Column(Boolean, nullable=False, default=False) is_archived = Column(Boolean, nullable=False, default=False) + created_at = Column(DateTime(timezone=True), default=utcnow) + updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + form = relationship("Form", back_populates="fields") answer = relationship("FormAnswer", back_populates="field") @@ -717,12 +746,16 @@ class FormField(Base): @field_validator("field_key") @classmethod - def validate_field_key(cls, v: str) -> str: - if not v.replace("_", "").isalnum(): + def validate_field_key(cls, v: str | None) -> str | None: + if v is not None and not v.replace("_", "").isalnum(): raise ValueError("field_key must be snake_case alphanumeric") return v +# --------------------------------------------------------------------------- +# FormResponse — one row per (form, user). Resubmitting overwrites the +# existing response's answers in place; no submission history is kept. +# --------------------------------------------------------------------------- class FormResponse(Base): __tablename__ = "form_responses" @@ -742,6 +775,10 @@ class FormResponse(Base): ) +# --------------------------------------------------------------------------- +# FormAnswer — one row per (response, field). Generic value storage; shape +# of `value` depends on the field's question_type. +# --------------------------------------------------------------------------- class FormAnswer(Base): __tablename__ = "form_answers" From 2e165eb821257e3f875589af0df76c3e440b62c2 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Mon, 17 Aug 2026 18:37:43 -0700 Subject: [PATCH 009/185] refactor(migrations): squash forms migrations into one matching the reworked model --- ...309024e1_form_reponse_and_answer_models.py | 55 -------- ...b134a51cf44_form_and_form_fields_models.py | 124 ------------------ .../versions/7db31ae17e3c_forms_core_model.py | 110 ++++++++++++++++ 3 files changed, 110 insertions(+), 179 deletions(-) delete mode 100644 backend/alembic/versions/0eba309024e1_form_reponse_and_answer_models.py delete mode 100644 backend/alembic/versions/2b134a51cf44_form_and_form_fields_models.py create mode 100644 backend/alembic/versions/7db31ae17e3c_forms_core_model.py diff --git a/backend/alembic/versions/0eba309024e1_form_reponse_and_answer_models.py b/backend/alembic/versions/0eba309024e1_form_reponse_and_answer_models.py deleted file mode 100644 index b5d16594..00000000 --- a/backend/alembic/versions/0eba309024e1_form_reponse_and_answer_models.py +++ /dev/null @@ -1,55 +0,0 @@ -"""form reponse and answer models - -Revision ID: 0eba309024e1 -Revises: 2b134a51cf44 -Create Date: 2026-08-12 22:40:57.539707 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '0eba309024e1' -down_revision: Union[str, None] = '2b134a51cf44' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('form_responses', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('form_id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('submitted_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('form_id', 'user_id', name='uq_form_response_per_user') - ) - op.create_index(op.f('ix_form_responses_id'), 'form_responses', ['id'], unique=False) - op.create_table('form_answers', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('response_id', sa.Integer(), nullable=False), - sa.Column('field_id', sa.Integer(), nullable=False), - sa.Column('value', sa.JSON(), nullable=False), - sa.ForeignKeyConstraint(['field_id'], ['form_fields.id'], ), - sa.ForeignKeyConstraint(['response_id'], ['form_responses.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('response_id', 'field_id', name='uq_answer_per_field') - ) - op.create_index(op.f('ix_form_answers_id'), 'form_answers', ['id'], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_form_answers_id'), table_name='form_answers') - op.drop_table('form_answers') - op.drop_index(op.f('ix_form_responses_id'), table_name='form_responses') - op.drop_table('form_responses') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/2b134a51cf44_form_and_form_fields_models.py b/backend/alembic/versions/2b134a51cf44_form_and_form_fields_models.py deleted file mode 100644 index a9902eea..00000000 --- a/backend/alembic/versions/2b134a51cf44_form_and_form_fields_models.py +++ /dev/null @@ -1,124 +0,0 @@ -"""form and form fields models - -Revision ID: 2b134a51cf44 -Revises: d3e4f5a6b7c8 -Create Date: 2026-08-12 00:46:13.556533 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision: str = '2b134a51cf44' -down_revision: Union[str, None] = 'd3e4f5a6b7c8' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('forms', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('owner_type', sa.String(length=16), nullable=False), - sa.Column('tournament_id', sa.Integer(), nullable=True), - sa.Column('chapter_id', sa.Integer(), nullable=True), - sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('created_by', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), - sa.CheckConstraint("(owner_type = 'tournament' AND tournament_id IS NOT NULL AND chapter_id IS NULL) OR (owner_type = 'chapter' AND chapter_id IS NOT NULL AND tournament_id IS NULL)", name='ck_form_owner_exclusive'), - sa.ForeignKeyConstraint(['chapter_id'], ['alumni_chapters.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), - sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_forms_id'), 'forms', ['id'], unique=False) - op.create_table('form_fields', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('form_id', sa.Integer(), nullable=False), - sa.Column('order', sa.Integer(), nullable=False), - sa.Column('label', sa.String(length=255), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('question_type', sa.String(length=32), nullable=False), - sa.Column('field_key', sa.String(length=64), nullable=False), - sa.Column('config', sa.JSON(), nullable=True), - sa.Column('required', sa.Boolean(), nullable=False), - sa.Column('is_archived', sa.Boolean(), nullable=False), - sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('form_id', 'field_key', name='uq_form_field_key') - ) - op.create_index(op.f('ix_form_fields_id'), 'form_fields', ['id'], unique=False) - op.alter_column('sheet_configs', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=True) - op.alter_column('sheet_configs', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=True) - op.alter_column('tournament_events', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=True) - op.alter_column('tournament_events', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=True) - op.alter_column('tournament_memberships', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=True) - op.alter_column('tournament_memberships', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=True) - op.alter_column('tournaments', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=True) - op.alter_column('tournaments', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=True) - op.alter_column('users', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=True) - op.alter_column('users', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=True) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('users', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=False) - op.alter_column('users', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=False) - op.alter_column('tournaments', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=False) - op.alter_column('tournaments', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=False) - op.alter_column('tournament_memberships', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=False) - op.alter_column('tournament_memberships', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=False) - op.alter_column('tournament_events', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=False) - op.alter_column('tournament_events', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=False) - op.alter_column('sheet_configs', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=False) - op.alter_column('sheet_configs', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=False) - op.drop_index(op.f('ix_form_fields_id'), table_name='form_fields') - op.drop_table('form_fields') - op.drop_index(op.f('ix_forms_id'), table_name='forms') - op.drop_table('forms') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/7db31ae17e3c_forms_core_model.py b/backend/alembic/versions/7db31ae17e3c_forms_core_model.py new file mode 100644 index 00000000..1a21b45b --- /dev/null +++ b/backend/alembic/versions/7db31ae17e3c_forms_core_model.py @@ -0,0 +1,110 @@ +"""forms core model + +Revision ID: 7db31ae17e3c +Revises: d3e4f5a6b7c8 +Create Date: 2026-08-17 00:00:00.000000 + +Consolidates what were previously two migrations (form/form_fields, then +form_responses/form_answers) into one, now that the Form ownership model +has been reworked to many-to-many (FormTournament/FormChapter) before +either migration was ever applied anywhere. Local-dev-only history, so +squashing instead of layering a third migration on top of a broken shape. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '7db31ae17e3c' +down_revision: Union[str, None] = 'd3e4f5a6b7c8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('forms', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_forms_id'), 'forms', ['id'], unique=False) + + op.create_table('form_tournaments', + sa.Column('form_id', sa.Integer(), nullable=False), + sa.Column('tournament_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('form_id', 'tournament_id') + ) + + op.create_table('form_chapters', + sa.Column('form_id', sa.Integer(), nullable=False), + sa.Column('chapter_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['chapter_id'], ['alumni_chapters.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('form_id', 'chapter_id') + ) + + op.create_table('form_fields', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('form_id', sa.Integer(), nullable=False), + sa.Column('order', sa.Integer(), nullable=False), + sa.Column('label', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('question_type', sa.String(length=32), nullable=False), + sa.Column('field_key', sa.String(length=64), nullable=True), + sa.Column('config', sa.JSON(), nullable=True), + sa.Column('is_archived', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('form_id', 'field_key', name='uq_form_field_key') + ) + op.create_index(op.f('ix_form_fields_id'), 'form_fields', ['id'], unique=False) + + op.create_table('form_responses', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('form_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('submitted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('form_id', 'user_id', name='uq_form_response_per_user') + ) + op.create_index(op.f('ix_form_responses_id'), 'form_responses', ['id'], unique=False) + + op.create_table('form_answers', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('response_id', sa.Integer(), nullable=False), + sa.Column('field_id', sa.Integer(), nullable=False), + sa.Column('value', sa.JSON(), nullable=False), + sa.ForeignKeyConstraint(['field_id'], ['form_fields.id'], ), + sa.ForeignKeyConstraint(['response_id'], ['form_responses.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('response_id', 'field_id', name='uq_answer_per_field') + ) + op.create_index(op.f('ix_form_answers_id'), 'form_answers', ['id'], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f('ix_form_answers_id'), table_name='form_answers') + op.drop_table('form_answers') + op.drop_index(op.f('ix_form_responses_id'), table_name='form_responses') + op.drop_table('form_responses') + op.drop_index(op.f('ix_form_fields_id'), table_name='form_fields') + op.drop_table('form_fields') + op.drop_table('form_chapters') + op.drop_table('form_tournaments') + op.drop_index(op.f('ix_forms_id'), table_name='forms') + op.drop_table('forms') From 178922392ed6aaaab8a41711f2587410afc70622 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Mon, 17 Aug 2026 18:53:33 -0700 Subject: [PATCH 010/185] refactor(forms): rewrite form schemas to match the reworked model and add response/answer schemas --- backend/app/schemas/form.py | 112 +++++++++++++++++++++++++++--------- 1 file changed, 84 insertions(+), 28 deletions(-) diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index 63d0a513..184f7bd8 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -1,22 +1,23 @@ from datetime import datetime from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, model_validator -# ========================================== +# --------------------------------------------------------------------------- # Form Field Schemas -# ========================================== +# --------------------------------------------------------------------------- class FormFieldRead(BaseModel): id: int form_id: int - field_key: str + field_key: str | None = None order: int label: str description: str | None = None question_type: str - required: bool = False is_archived: bool = False config: dict[str, Any] | None = None + created_at: datetime + updated_at: datetime model_config = ConfigDict(from_attributes=True) @@ -25,51 +26,106 @@ class FormFieldCreate(BaseModel): label: str question_type: str description: str | None = None - required: bool = False + field_key: str | None = None + order: int | None = None config: dict[str, Any] | None = None -# ========================================== +class FormFieldUpdate(BaseModel): + label: str | None = None + description: str | None = None + question_type: str | None = None + order: int | None = None + config: dict | None = None + + model_config = ConfigDict(from_attributes=True) + + +# --------------------------------------------------------------------------- # Form Schemas -# ========================================== +# --------------------------------------------------------------------------- class FormRead(BaseModel): id: int - title: str + name: str description: str | None = None - owner_type: Literal["tournament", "chapter", "global"] - tournament_id: int | None = None - chapter_id: int | None = None + status: Literal["draft", "published", "archived"] + tournament_ids: list[int] = [] + chapter_ids: list[int] = [] created_by: int - is_published: bool = False created_at: datetime updated_at: datetime fields: list[FormFieldRead] = [] model_config = ConfigDict(from_attributes=True) + @model_validator(mode="before") + @classmethod + def _flatten_links(cls, obj): + # ORM objects expose tournament_ids/chapter_ids via the + # FormTournament/FormChapter join rows, not a plain column. + if isinstance(obj, dict): + return obj + return { + "id": obj.id, + "name": obj.name, + "description": obj.description, + "status": obj.status, + "tournament_ids": [link.tournament_id for link in obj.tournament_links], + "chapter_ids": [link.chapter_id for link in obj.chapter_links], + "created_by": obj.created_by, + "created_at": obj.created_at, + "updated_at": obj.updated_at, + "fields": obj.fields, + } + class FormCreate(BaseModel): - title: str + name: str description: str | None = None - owner_type: Literal["tournament", "chapter", "global"] - tournament_id: int | None = None - chapter_id: int | None = None + tournament_ids: list[int] = [] + chapter_ids: list[int] = [] + + @model_validator(mode="after") + def _require_at_least_one_owner(self): + if not self.tournament_ids and not self.chapter_ids: + raise ValueError("Form must be linked to at least one tournament or chapter") + return self class FormUpdate(BaseModel): - title: str | None = None + name: str | None = None description: str | None = None - is_published: bool | None = None + status: Literal["draft", "published", "archived"] | None = None -class FormFieldUpdate(BaseModel): - label: str | None = None - description: str | None = None - question_type: str | None = None - required: bool | None = None - order: int | None = None - config: dict | None = None +# --------------------------------------------------------------------------- +# Form Response / Answer Schemas +# --------------------------------------------------------------------------- + +class FormAnswerCreate(BaseModel): + field_id: int + value: Any + + +class FormAnswerRead(BaseModel): + id: int + field_id: int + value: Any + + model_config = ConfigDict(from_attributes=True) - class Config: - from_attributes = True \ No newline at end of file + +class FormResponseCreate(BaseModel): + answers: list[FormAnswerCreate] + + +class FormResponseRead(BaseModel): + id: int + form_id: int + user_id: int + submitted_at: datetime + updated_at: datetime + answers: list[FormAnswerRead] = [] + + model_config = ConfigDict(from_attributes=True) From de1bfc913ebbb14cab2f8f8a52638c390cadbe9c Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Mon, 17 Aug 2026 19:50:33 -0700 Subject: [PATCH 011/185] fix(forms): rebuild form/field routes with working create, CRUD, and link-aware permission checks --- backend/app/api/routes/forms.py | 218 +++++++++++++++++++++------ backend/app/core/form/__init__.py | 23 +++ backend/app/core/form/permissions.py | 116 ++++++++++++++ 3 files changed, 314 insertions(+), 43 deletions(-) create mode 100644 backend/app/core/form/permissions.py diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index b678c03c..b81ac334 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -1,26 +1,72 @@ from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import func from sqlalchemy.orm import Session from app.core.auth import get_current_user -from app.core.form import remove_form_field, update_field_text, replace_field_type, add_option, change_option_label, remove_option_from_field, resolve_field_options +from app.core.form import ( + remove_form_field, + reorder_field, + replace_field_type, + resolve_field_options, + set_field_config, + update_field_text, +) +from app.core.form.permissions import require_form_manage_access, require_form_view_access, user_can_link_all from app.db.session import get_db -from app.models.models import User, Form, FormField, FormAnswer, FormResponse -from app.schemas.form import FormCreate, FormRead, FormUpdate, FormFieldCreate, FormFieldRead, FormFieldUpdate -from app.core.tournament.permissions import has_permission +from app.models.models import Form, FormChapter, FormField, FormResponse, FormTournament, User +from app.schemas.form import FormCreate, FormFieldCreate, FormFieldRead, FormFieldUpdate, FormRead, FormUpdate router = APIRouter(tags=["forms"]) +# --------------------------------------------------------------------------- +# POST /forms/ — creates the FormTournament/FormChapter links up front, so +# it requires MANAGE_FORMS/lead-officer on EVERY tournament/chapter in the +# payload, not just one (see user_can_link_all). +# --------------------------------------------------------------------------- +@router.post("/forms/", response_model=FormRead, status_code=status.HTTP_201_CREATED) +def create_form( + form_in: FormCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if not user_can_link_all(current_user, form_in.tournament_ids, form_in.chapter_ids, db): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + + form = Form( + name=form_in.name, + description=form_in.description, + created_by=current_user.id, + ) + db.add(form) + db.flush() + + for tournament_id in form_in.tournament_ids: + db.add(FormTournament(form_id=form.id, tournament_id=tournament_id)) + for chapter_id in form_in.chapter_ids: + db.add(FormChapter(form_id=form.id, chapter_id=chapter_id)) + + db.commit() + db.refresh(form) + return form + + +# --------------------------------------------------------------------------- +# GET /forms/{form_id}/ — view/render. Any member of a linked +# tournament/chapter can view (not just managers) — this is what the form +# renderer for people filling it out calls. +# --------------------------------------------------------------------------- @router.get("/forms/{form_id}/", response_model=FormRead) def get_form_for_rendering( - form_id: int, db: Session = Depends(get_db), + form: Form = Depends(require_form_view_access), ): - form = db.query(Form).filter(Form.id == form_id).first() - if not form: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Form not found") - - active_fields = db.query(FormField).filter(FormField.form_id == form_id, FormField.is_archived == False).all() + active_fields = ( + db.query(FormField) + .filter(FormField.form_id == form.id, FormField.is_archived == False) + .order_by(FormField.order) + .all() + ) for field in active_fields: resolved_options = resolve_field_options(db, field) @@ -33,61 +79,147 @@ def get_form_for_rendering( return form -@router.post("/tournaments/{tournament_id}/forms/", response_model=FormRead, status_code=status.HTTP_201_CREATED) -def create_tournament_form( - tournament_id: int, - form_in: FormCreate, +# --------------------------------------------------------------------------- +# PATCH /forms/{form_id}/ — name/description/status only. Adding/removing +# tournament or chapter links isn't handled here yet. +# --------------------------------------------------------------------------- +@router.patch("/forms/{form_id}/", response_model=FormRead) +def update_form( + form_in: FormUpdate, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + form: Form = Depends(require_form_manage_access), ): - """Create Tournament Form. MANAGE_FORMS permission required.""" - if not has_permission(tournament_id, "MANAGE_FORMS", db): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authorized to perform this action") + if form_in.name is not None: + form.name = form_in.name + if form_in.description is not None: + form.description = form_in.description + if form_in.status is not None: + form.status = form_in.status - form = Form( - **form_in.model_dump(), - tournament_id=tournament_id, - chapter_id=None, - ) - db.add(form) db.commit() db.refresh(form) return form +# --------------------------------------------------------------------------- +# POST /forms/{form_id}/archive/ — soft delete via status="archived". +# Responses and fields are left in place. +# --------------------------------------------------------------------------- +@router.post("/forms/{form_id}/archive/", response_model=FormRead) +def archive_form( + db: Session = Depends(get_db), + form: Form = Depends(require_form_manage_access), +): + form.status = "archived" + db.commit() + db.refresh(form) + return form + + +# --------------------------------------------------------------------------- +# DELETE /forms/{form_id}/ — hard delete. Blocked if any responses exist +# (use the archive route above instead — hard delete would cascade away +# submitted response data). Cascades to fields and the tournament/chapter +# links otherwise. +# --------------------------------------------------------------------------- +@router.delete("/forms/{form_id}/", status_code=status.HTTP_204_NO_CONTENT) +def delete_form( + db: Session = Depends(get_db), + form: Form = Depends(require_form_manage_access), +): + has_responses = db.query(FormResponse).filter(FormResponse.form_id == form.id).first() is not None + if has_responses: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Form has existing responses — archive it instead of deleting", + ) + + db.delete(form) + db.commit() + + +# --------------------------------------------------------------------------- +# POST /forms/{form_id}/fields/ — MANAGE_FORMS on any linked tournament, or +# lead/officer on any linked chapter (the form already exists and is +# already linked, so the "any one" rule applies here, unlike form creation). +# --------------------------------------------------------------------------- +@router.post("/forms/{form_id}/fields/", response_model=FormFieldRead, status_code=status.HTTP_201_CREATED) +def create_form_field( + field_in: FormFieldCreate, + db: Session = Depends(get_db), + form: Form = Depends(require_form_manage_access), +): + if field_in.field_key is not None: + existing = ( + db.query(FormField) + .filter(FormField.form_id == form.id, FormField.field_key == field_in.field_key) + .first() + ) + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="field_key already in use on this form") + + order = field_in.order + if order is None: + max_order = db.query(func.max(FormField.order)).filter(FormField.form_id == form.id).scalar() + order = (max_order or 0) + 1 + + field = FormField( + form_id=form.id, + order=order, + label=field_in.label, + description=field_in.description, + question_type=field_in.question_type, + field_key=field_in.field_key, + config=field_in.config, + is_archived=False, + ) + db.add(field) + db.commit() + db.refresh(field) + return field + + +# --------------------------------------------------------------------------- +# PATCH /forms/{form_id}/fields/{field_id}/ +# --------------------------------------------------------------------------- @router.patch("/forms/{form_id}/fields/{field_id}/", response_model=FormFieldRead) def edit_form_field( - form_id: int, field_id: int, field_in: FormFieldUpdate, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), + form: Form = Depends(require_form_manage_access), ): - """Update a form field. MANAGE_FORMS permission required""" - field = db.query(FormField).filter(FormField.id == field_id, FormField.form_id == form_id).first() - + field = db.query(FormField).filter(FormField.id == field_id, FormField.form_id == form.id).first() if not field: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Field not found") - if (field_in.question_type and field_in.question_type != field.question_type): - return replace_field_type(db, field, field_in.question_type) + if field_in.question_type is not None and field_in.question_type != field.question_type: + field = replace_field_type(db, field, field_in.question_type) + + if field_in.label is not None or field_in.description is not None: + field = update_field_text(db, field, field_in.label, field_in.description) + if field_in.order is not None: + field = reorder_field(db, field, field_in.order) + if field_in.config is not None: + field = set_field_config(db, field, field_in.config) + + return field + + +# --------------------------------------------------------------------------- +# DELETE /forms/{form_id}/fields/{field_id}/ +# Archives a form field if responses exist. Hard deletes if responses do +# not exist. +# --------------------------------------------------------------------------- @router.delete("/forms/{form_id}/fields/{field_id}/") def delete_or_archive_form_field( - form_id: int, field_id: int, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), + form: Form = Depends(require_form_manage_access), ): - """ - Archives a form field if responses exist. - Hard Deletes if responses do not exist. - Requires MANAGE_FORM permissions. - """ - - field = db.query(FormField).filter(FormField.id == field_id, FormField.form_id == form_id).first() - + field = db.query(FormField).filter(FormField.id == field_id, FormField.form_id == form.id).first() if not field: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Field not found") @@ -96,5 +228,5 @@ def delete_or_archive_form_field( return { "success": True, "action": "archived" if was_archived else "deleted", - "field_id": field_id - } \ No newline at end of file + "field_id": field_id, + } diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index 04356799..40749a63 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -36,6 +36,29 @@ def update_field_text( db.refresh(field) return field +def set_field_config( + db: Session, + field: FormField, + config: dict, +) -> FormField: + field.config = config + flag_modified(field, "config") + db.commit() + db.refresh(field) + return field + + +def reorder_field( + db: Session, + field: FormField, + order: int, +) -> FormField: + field.order = order + db.commit() + db.refresh(field) + return field + + def replace_field_type( db: Session, field: FormField, diff --git a/backend/app/core/form/permissions.py b/backend/app/core/form/permissions.py new file mode 100644 index 00000000..a81d4080 --- /dev/null +++ b/backend/app/core/form/permissions.py @@ -0,0 +1,116 @@ +from fastapi import Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.auth import get_current_user +from app.core.tournament.memberships import has_any_membership +from app.core.tournament.permissions import MANAGE_FORMS, has_permission +from app.db.session import get_db +from app.models.models import ChapterMembership, Form, User + +# --------------------------------------------------------------------------- +# Form access control. +# +# A Form can be linked to multiple tournaments and/or chapters, so access +# isn't a single require_permission(tournament_id) check like other +# tournament-scoped resources — it's "does the user pass on ANY ONE of the +# form's linked tournaments/chapters." +# --------------------------------------------------------------------------- + + +def user_manages_any_tournament(user: User, tournament_ids: list[int], db: Session) -> bool: + return any(has_permission(user, tid, MANAGE_FORMS, db) for tid in tournament_ids) + + +def user_leads_any_chapter(user: User, chapter_ids: list[int], db: Session) -> bool: + if not chapter_ids: + return False + if user.role == "admin": + return True + return ( + db.query(ChapterMembership) + .filter( + ChapterMembership.user_id == user.id, + ChapterMembership.chapter_id.in_(chapter_ids), + ChapterMembership.role.in_(("lead", "officer")), + ) + .first() + is not None + ) + + +def user_can_manage_form_links(user: User, tournament_ids: list[int], chapter_ids: list[int], db: Session) -> bool: + """True if `user` holds MANAGE_FORMS on any tournament in tournament_ids, + or lead/officer on any chapter in chapter_ids. + + For managing an ALREADY-LINKED form only (edit fields, change status, + etc.) — a co-manager of just one linked tournament can still touch a + form shared across several. Do NOT use this to authorize creating new + links (see user_can_link_all) — "any one" is the wrong rule there, since + it would let someone with MANAGE_FORMS on tournament A link a form to + tournament B too, despite having no authority over B. + """ + return user_manages_any_tournament(user, tournament_ids, db) or user_leads_any_chapter(user, chapter_ids, db) + + +def user_can_link_all(user: User, tournament_ids: list[int], chapter_ids: list[int], db: Session) -> bool: + """True only if `user` holds MANAGE_FORMS on EVERY tournament in + tournament_ids, and lead/officer on EVERY chapter in chapter_ids. + + Use this whenever a request is establishing NEW form<->tournament or + form<->chapter links (currently: form creation only). There's no + cross-TD request/accept flow yet — a TD who wants to link a form into + someone else's tournament needs a MANAGE_FORMS-holding role there first + (existing invite/role machinery). Known gap, not solved here. + """ + tournaments_ok = all(has_permission(user, tid, MANAGE_FORMS, db) for tid in tournament_ids) + chapters_ok = all(user_leads_any_chapter(user, [cid], db) for cid in chapter_ids) + return tournaments_ok and chapters_ok + + +def _load_form_or_404(form_id: int, db: Session) -> Form: + form = db.query(Form).filter(Form.id == form_id).first() + if not form: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Form not found") + return form + + +def require_form_manage_access( + form_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> Form: + """Dependency — loads the Form and requires manage access (MANAGE_FORMS + on any linked tournament, or lead/officer on any linked chapter). + Returns the Form so route handlers don't need a second query.""" + form = _load_form_or_404(form_id, db) + tournament_ids = [link.tournament_id for link in form.tournament_links] + chapter_ids = [link.chapter_id for link in form.chapter_links] + + if not user_can_manage_form_links(current_user, tournament_ids, chapter_ids, db): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + return form + + +def require_form_view_access( + form_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> Form: + """Dependency — loads the Form and requires either manage access, or + plain membership in any linked tournament/chapter (for the people + filling the form out, not just the people managing it).""" + form = _load_form_or_404(form_id, db) + tournament_ids = [link.tournament_id for link in form.tournament_links] + chapter_ids = [link.chapter_id for link in form.chapter_links] + + if user_can_manage_form_links(current_user, tournament_ids, chapter_ids, db): + return form + if any(has_any_membership(current_user, tid, db) for tid in tournament_ids): + return form + if chapter_ids and db.query(ChapterMembership).filter( + ChapterMembership.user_id == current_user.id, + ChapterMembership.chapter_id.in_(chapter_ids), + ).first(): + return form + + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") From ef91177d74d8a20ae2a2d0c7bbfa898cfec2fc59 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Mon, 17 Aug 2026 22:30:53 -0700 Subject: [PATCH 012/185] feat(forms): add form response submission and listing routes --- backend/app/api/routes/forms.py | 100 +++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index b81ac334..7093d292 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -13,8 +13,17 @@ ) from app.core.form.permissions import require_form_manage_access, require_form_view_access, user_can_link_all from app.db.session import get_db -from app.models.models import Form, FormChapter, FormField, FormResponse, FormTournament, User -from app.schemas.form import FormCreate, FormFieldCreate, FormFieldRead, FormFieldUpdate, FormRead, FormUpdate +from app.models.models import Form, FormAnswer, FormChapter, FormField, FormResponse, FormTournament, User, utcnow +from app.schemas.form import ( + FormCreate, + FormFieldCreate, + FormFieldRead, + FormFieldUpdate, + FormRead, + FormResponseCreate, + FormResponseRead, + FormUpdate, +) router = APIRouter(tags=["forms"]) @@ -230,3 +239,90 @@ def delete_or_archive_form_field( "action": "archived" if was_archived else "deleted", "field_id": field_id, } + + +# --------------------------------------------------------------------------- +# POST /forms/{form_id}/responses/ — submit or resubmit. One row per +# (form, user); resubmitting replaces all of that user's answers in place +# (no submission history). View access, not manage — this is what the +# person filling the form out calls. +# --------------------------------------------------------------------------- +@router.post("/forms/{form_id}/responses/", response_model=FormResponseRead) +def submit_form_response( + response_in: FormResponseCreate, + db: Session = Depends(get_db), + form: Form = Depends(require_form_view_access), + current_user: User = Depends(get_current_user), +): + field_ids = [answer_in.field_id for answer_in in response_in.answers] + if field_ids: + valid_field_ids = { + field_id + for (field_id,) in db.query(FormField.id).filter( + FormField.id.in_(field_ids), + FormField.form_id == form.id, + FormField.is_archived == False, + ).all() + } + invalid_field_ids = set(field_ids) - valid_field_ids + if invalid_field_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid field_id(s) for this form: {sorted(invalid_field_ids)}", + ) + + response = ( + db.query(FormResponse) + .filter(FormResponse.form_id == form.id, FormResponse.user_id == current_user.id) + .first() + ) + if response is None: + response = FormResponse(form_id=form.id, user_id=current_user.id) + db.add(response) + db.flush() + else: + db.query(FormAnswer).filter(FormAnswer.response_id == response.id).delete() + response.updated_at = utcnow() + + for answer_in in response_in.answers: + db.add(FormAnswer(response_id=response.id, field_id=answer_in.field_id, value=answer_in.value)) + + db.commit() + db.refresh(response) + return response + + +# --------------------------------------------------------------------------- +# GET /forms/{form_id}/responses/ — all responses to a form. Manage access +# only — this is roster data, not something every member should see. +# --------------------------------------------------------------------------- +@router.get("/forms/{form_id}/responses/", response_model=list[FormResponseRead]) +def list_form_responses( + db: Session = Depends(get_db), + form: Form = Depends(require_form_manage_access), +): + return ( + db.query(FormResponse) + .filter(FormResponse.form_id == form.id) + .order_by(FormResponse.id) + .all() + ) + + +# --------------------------------------------------------------------------- +# GET /forms/{form_id}/responses/me/ — the current user's own response. +# --------------------------------------------------------------------------- +@router.get("/forms/{form_id}/responses/me/", response_model=FormResponseRead) +def get_my_form_response( + db: Session = Depends(get_db), + form: Form = Depends(require_form_view_access), + current_user: User = Depends(get_current_user), +): + response = ( + db.query(FormResponse) + .filter(FormResponse.form_id == form.id, FormResponse.user_id == current_user.id) + .first() + ) + if not response: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No response found") + return response From 4e5695a482f93dc6df2fecfa56596c1edcff3ed5 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 13:37:04 -0700 Subject: [PATCH 013/185] refactor(forms): revert Form ownership to single tournament-or-chapter and add status/description/membership-trigger fields --- .../versions/7db31ae17e3c_forms_core_model.py | 34 ++++-------- backend/app/core/form/__init__.py | 5 +- backend/app/models/models.py | 55 +++++++------------ 3 files changed, 34 insertions(+), 60 deletions(-) diff --git a/backend/alembic/versions/7db31ae17e3c_forms_core_model.py b/backend/alembic/versions/7db31ae17e3c_forms_core_model.py index 1a21b45b..80815f0f 100644 --- a/backend/alembic/versions/7db31ae17e3c_forms_core_model.py +++ b/backend/alembic/versions/7db31ae17e3c_forms_core_model.py @@ -5,16 +5,15 @@ Create Date: 2026-08-17 00:00:00.000000 Consolidates what were previously two migrations (form/form_fields, then -form_responses/form_answers) into one, now that the Form ownership model -has been reworked to many-to-many (FormTournament/FormChapter) before -either migration was ever applied anywhere. Local-dev-only history, so -squashing instead of layering a third migration on top of a broken shape. +form_responses/form_answers) into one. Local-dev-only history, so squashing +instead of layering a third migration on top of a broken shape. Form +ownership is single tournament-or-chapter (owner_type + CHECK constraint) — +multi-tournament "group forms" are a later, separate phase. """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa -from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision: str = '7db31ae17e3c' @@ -26,33 +25,24 @@ def upgrade() -> None: op.create_table('forms', sa.Column('id', sa.Integer(), nullable=False), + sa.Column('owner_type', sa.String(length=16), nullable=False), + sa.Column('tournament_id', sa.Integer(), nullable=True), + sa.Column('chapter_id', sa.Integer(), nullable=True), sa.Column('name', sa.String(length=255), nullable=False), sa.Column('description', sa.Text(), nullable=True), sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('creates_membership_on_submit', sa.Boolean(), nullable=False), sa.Column('created_by', sa.Integer(), nullable=False), sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint("(owner_type = 'tournament' AND tournament_id IS NOT NULL AND chapter_id IS NULL) OR (owner_type = 'chapter' AND chapter_id IS NOT NULL AND tournament_id IS NULL)", name='ck_form_owner_exclusive'), + sa.ForeignKeyConstraint(['chapter_id'], ['alumni_chapters.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), + sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_forms_id'), 'forms', ['id'], unique=False) - op.create_table('form_tournaments', - sa.Column('form_id', sa.Integer(), nullable=False), - sa.Column('tournament_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('form_id', 'tournament_id') - ) - - op.create_table('form_chapters', - sa.Column('form_id', sa.Integer(), nullable=False), - sa.Column('chapter_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['chapter_id'], ['alumni_chapters.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('form_id', 'chapter_id') - ) - op.create_table('form_fields', sa.Column('id', sa.Integer(), nullable=False), sa.Column('form_id', sa.Integer(), nullable=False), @@ -104,7 +94,5 @@ def downgrade() -> None: op.drop_table('form_responses') op.drop_index(op.f('ix_form_fields_id'), table_name='form_fields') op.drop_table('form_fields') - op.drop_table('form_chapters') - op.drop_table('form_tournaments') op.drop_index(op.f('ix_forms_id'), table_name='forms') op.drop_table('forms') diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index 40749a63..fb376ca5 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -172,13 +172,12 @@ def resolve_field_options(db: Session, field: FormField) -> list[dict]: """ # 1. Dynamic lookup: Tournament Event Preferences if field.field_key == "event_preference": - tournament_ids = [link.tournament_id for link in field.form.tournament_links] if field.form else [] - if not tournament_ids: + if not (field.form and field.form.tournament_id): return [] events = ( db.query(TournamentEvent) - .filter(TournamentEvent.tournament_id.in_(tournament_ids)) + .filter(TournamentEvent.tournament_id == field.form.tournament_id) .order_by(TournamentEvent.id.asc()) .all() ) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index b559e2af..4e87a9bc 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -283,7 +283,7 @@ class Tournament(Base): join_codes = relationship("JoinCode", back_populates="tournament", cascade="all, delete-orphan") audit_log = relationship("AuditLogEntry", back_populates="tournament", cascade="all, delete-orphan") event_shifts = relationship("TournamentShift", back_populates="tournament", cascade="all, delete-orphan") - form_tournaments = relationship("FormTournament", back_populates="tournament", cascade="all, delete-orphan") + forms = relationship("Form", back_populates="tournament", cascade="all, delete-orphan") # Exactly one of university_id/location (XOR). Checked at flush, not @@ -618,7 +618,7 @@ class AlumniChapter(Base): chapter_memberships = relationship("ChapterMembership", back_populates="alumni_chapter", cascade="all, delete-orphan") join_codes = relationship("JoinCode", back_populates="alumni_chapter", cascade="all, delete-orphan") tournament_chapters = relationship("TournamentChapter", back_populates="chapter") - form_chapters = relationship("FormChapter", back_populates="chapter", cascade="all, delete-orphan") + forms = relationship("Form", back_populates="chapter", cascade="all, delete-orphan") # --------------------------------------------------------------------------- @@ -654,55 +654,42 @@ class TournamentChapter(Base): # --------------------------------------------------------------------------- # Form — a first-party form (replaces the Google Forms + sheet-sync -# pipeline). Owned via FormTournament/FormChapter, not a direct FK — a form -# can be linked to any combination of tournaments and chapters. +# pipeline). Owned by exactly one tournament OR one chapter (owner_type + +# CHECK constraint) — multi-tournament "group forms" are a later phase. # --------------------------------------------------------------------------- class Form(Base): __tablename__ = "forms" id = Column(Integer, primary_key=True, index=True) + owner_type = Column(String(16), nullable=False) # "tournament" | "chapter" + tournament_id = Column(Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=True) + chapter_id = Column(Integer, ForeignKey("alumni_chapters.id", ondelete="CASCADE"), nullable=True) name = Column(String(255), nullable=False) description = Column(Text, nullable=True) status = Column(String(16), nullable=False, default="draft") # "draft" | "published" | "archived" + + # If true, a user's first response to this form also creates a pending + # membership on the owning tournament/chapter (see app/core/form). + creates_membership_on_submit = Column(Boolean, nullable=False, default=False) + created_by = Column(Integer, ForeignKey("users.id"), nullable=False) created_at = Column(DateTime(timezone=True), default=utcnow) updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + tournament = relationship("Tournament", back_populates="forms") + chapter = relationship("AlumniChapter", back_populates="forms") creator = relationship("User", back_populates="created_forms") fields = relationship("FormField", back_populates="form", cascade="all, delete-orphan", order_by="FormField.order") responses = relationship("FormResponse", back_populates="form", cascade="all, delete-orphan") - tournament_links = relationship("FormTournament", back_populates="form", cascade="all, delete-orphan") - chapter_links = relationship("FormChapter", back_populates="form", cascade="all, delete-orphan") - - -# --------------------------------------------------------------------------- -# FormTournament — junction table, Form <-> Tournament (many-to-many). A -# form must have at least one FormTournament or FormChapter link, enforced -# at the schema/service layer (can't CHECK across two tables). -# --------------------------------------------------------------------------- -class FormTournament(Base): - __tablename__ = "form_tournaments" - - form_id = Column(Integer, ForeignKey("forms.id", ondelete="CASCADE"), primary_key=True) - tournament_id = Column(Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), primary_key=True) - - form = relationship("Form", back_populates="tournament_links") - tournament = relationship("Tournament", back_populates="form_tournaments") - -# --------------------------------------------------------------------------- -# FormChapter — junction table, Form <-> AlumniChapter (many-to-many). See -# FormTournament above — the same at-least-one-link rule applies jointly. -# --------------------------------------------------------------------------- -class FormChapter(Base): - __tablename__ = "form_chapters" - - form_id = Column(Integer, ForeignKey("forms.id", ondelete="CASCADE"), primary_key=True) - chapter_id = Column(Integer, ForeignKey("alumni_chapters.id", ondelete="CASCADE"), primary_key=True) - - form = relationship("Form", back_populates="chapter_links") - chapter = relationship("AlumniChapter", back_populates="form_chapters") + __table_args__ = ( + CheckConstraint( + "(owner_type = 'tournament' AND tournament_id IS NOT NULL AND chapter_id IS NULL) OR " + "(owner_type = 'chapter' AND chapter_id IS NOT NULL AND tournament_id IS NULL)", + name="ck_form_owner_exclusive", + ), + ) # --------------------------------------------------------------------------- From 8a3dbb462cf980bda53021447a19f88f7e9dcd34 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 13:45:21 -0700 Subject: [PATCH 014/185] refactor(forms): revert form schemas to single-owner shape and add creates_membership_on_submit --- backend/app/schemas/form.py | 43 ++++++++++++++----------------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index 184f7bd8..f4e862d8 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -50,8 +50,10 @@ class FormRead(BaseModel): name: str description: str | None = None status: Literal["draft", "published", "archived"] - tournament_ids: list[int] = [] - chapter_ids: list[int] = [] + owner_type: Literal["tournament", "chapter"] + tournament_id: int | None = None + chapter_id: int | None = None + creates_membership_on_submit: bool = False created_by: int created_at: datetime updated_at: datetime @@ -59,37 +61,23 @@ class FormRead(BaseModel): model_config = ConfigDict(from_attributes=True) - @model_validator(mode="before") - @classmethod - def _flatten_links(cls, obj): - # ORM objects expose tournament_ids/chapter_ids via the - # FormTournament/FormChapter join rows, not a plain column. - if isinstance(obj, dict): - return obj - return { - "id": obj.id, - "name": obj.name, - "description": obj.description, - "status": obj.status, - "tournament_ids": [link.tournament_id for link in obj.tournament_links], - "chapter_ids": [link.chapter_id for link in obj.chapter_links], - "created_by": obj.created_by, - "created_at": obj.created_at, - "updated_at": obj.updated_at, - "fields": obj.fields, - } - class FormCreate(BaseModel): name: str description: str | None = None - tournament_ids: list[int] = [] - chapter_ids: list[int] = [] + owner_type: Literal["tournament", "chapter"] + tournament_id: int | None = None + chapter_id: int | None = None + creates_membership_on_submit: bool = False @model_validator(mode="after") - def _require_at_least_one_owner(self): - if not self.tournament_ids and not self.chapter_ids: - raise ValueError("Form must be linked to at least one tournament or chapter") + def _require_matching_owner(self): + if self.owner_type == "tournament": + if self.tournament_id is None or self.chapter_id is not None: + raise ValueError("owner_type 'tournament' requires tournament_id and no chapter_id") + else: + if self.chapter_id is None or self.tournament_id is not None: + raise ValueError("owner_type 'chapter' requires chapter_id and no tournament_id") return self @@ -97,6 +85,7 @@ class FormUpdate(BaseModel): name: str | None = None description: str | None = None status: Literal["draft", "published", "archived"] | None = None + creates_membership_on_submit: bool | None = None # --------------------------------------------------------------------------- From d32e8f258821be93493bfe86257e11d0ed724e54 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 13:47:50 -0700 Subject: [PATCH 015/185] refactor(forms): revert form routes to single-owner permission checks and nested creation --- backend/app/api/routes/forms.py | 74 +++++++++++++++---- backend/app/core/form/permissions.py | 103 +++++++-------------------- 2 files changed, 86 insertions(+), 91 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 7093d292..134596dc 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import Session from app.core.auth import get_current_user +from app.core.chapters import require_officer_or_lead from app.core.form import ( remove_form_field, reorder_field, @@ -11,9 +12,10 @@ set_field_config, update_field_text, ) -from app.core.form.permissions import require_form_manage_access, require_form_view_access, user_can_link_all +from app.core.form.permissions import require_form_manage_access, require_form_view_access +from app.core.tournament.permissions import MANAGE_FORMS, require_permission from app.db.session import get_db -from app.models.models import Form, FormAnswer, FormChapter, FormField, FormResponse, FormTournament, User, utcnow +from app.models.models import Form, FormAnswer, FormField, FormResponse, User, utcnow from app.schemas.form import ( FormCreate, FormFieldCreate, @@ -29,32 +31,74 @@ # --------------------------------------------------------------------------- -# POST /forms/ — creates the FormTournament/FormChapter links up front, so -# it requires MANAGE_FORMS/lead-officer on EVERY tournament/chapter in the -# payload, not just one (see user_can_link_all). +# POST /tournaments/{tournament_id}/forms/ — MANAGE_FORMS on the tournament. # --------------------------------------------------------------------------- -@router.post("/forms/", response_model=FormRead, status_code=status.HTTP_201_CREATED) -def create_form( +@router.post( + "/tournaments/{tournament_id}/forms/", + response_model=FormRead, + status_code=status.HTTP_201_CREATED, + tags=["tournaments"], +) +def create_tournament_form( + tournament_id: int, form_in: FormCreate, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_permission(MANAGE_FORMS)), ): - if not user_can_link_all(current_user, form_in.tournament_ids, form_in.chapter_ids, db): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + if form_in.owner_type != "tournament" or form_in.tournament_id != tournament_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="owner_type must be 'tournament' and tournament_id must match the path", + ) form = Form( name=form_in.name, description=form_in.description, + owner_type="tournament", + tournament_id=tournament_id, + chapter_id=None, + creates_membership_on_submit=form_in.creates_membership_on_submit, created_by=current_user.id, ) db.add(form) - db.flush() + db.commit() + db.refresh(form) + return form + + +# --------------------------------------------------------------------------- +# POST /chapters/{chapter_id}/forms/ — lead/officer on the chapter. +# --------------------------------------------------------------------------- +@router.post( + "/chapters/{chapter_id}/forms/", + response_model=FormRead, + status_code=status.HTTP_201_CREATED, + tags=["chapters"], +) +def create_chapter_form( + chapter_id: int, + form_in: FormCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + require_officer_or_lead(chapter_id, db, current_user) - for tournament_id in form_in.tournament_ids: - db.add(FormTournament(form_id=form.id, tournament_id=tournament_id)) - for chapter_id in form_in.chapter_ids: - db.add(FormChapter(form_id=form.id, chapter_id=chapter_id)) + if form_in.owner_type != "chapter" or form_in.chapter_id != chapter_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="owner_type must be 'chapter' and chapter_id must match the path", + ) + form = Form( + name=form_in.name, + description=form_in.description, + owner_type="chapter", + tournament_id=None, + chapter_id=chapter_id, + creates_membership_on_submit=form_in.creates_membership_on_submit, + created_by=current_user.id, + ) + db.add(form) db.commit() db.refresh(form) return form diff --git a/backend/app/core/form/permissions.py b/backend/app/core/form/permissions.py index a81d4080..ea68fd72 100644 --- a/backend/app/core/form/permissions.py +++ b/backend/app/core/form/permissions.py @@ -2,71 +2,19 @@ from sqlalchemy.orm import Session from app.core.auth import get_current_user +from app.core.chapters import require_officer_or_lead from app.core.tournament.memberships import has_any_membership from app.core.tournament.permissions import MANAGE_FORMS, has_permission from app.db.session import get_db from app.models.models import ChapterMembership, Form, User # --------------------------------------------------------------------------- -# Form access control. -# -# A Form can be linked to multiple tournaments and/or chapters, so access -# isn't a single require_permission(tournament_id) check like other -# tournament-scoped resources — it's "does the user pass on ANY ONE of the -# form's linked tournaments/chapters." +# Form access control. A Form is owned by exactly one tournament or one +# chapter (owner_type), so access is a single check dispatched on that +# owner_type — not an "any one of several links" check. # --------------------------------------------------------------------------- -def user_manages_any_tournament(user: User, tournament_ids: list[int], db: Session) -> bool: - return any(has_permission(user, tid, MANAGE_FORMS, db) for tid in tournament_ids) - - -def user_leads_any_chapter(user: User, chapter_ids: list[int], db: Session) -> bool: - if not chapter_ids: - return False - if user.role == "admin": - return True - return ( - db.query(ChapterMembership) - .filter( - ChapterMembership.user_id == user.id, - ChapterMembership.chapter_id.in_(chapter_ids), - ChapterMembership.role.in_(("lead", "officer")), - ) - .first() - is not None - ) - - -def user_can_manage_form_links(user: User, tournament_ids: list[int], chapter_ids: list[int], db: Session) -> bool: - """True if `user` holds MANAGE_FORMS on any tournament in tournament_ids, - or lead/officer on any chapter in chapter_ids. - - For managing an ALREADY-LINKED form only (edit fields, change status, - etc.) — a co-manager of just one linked tournament can still touch a - form shared across several. Do NOT use this to authorize creating new - links (see user_can_link_all) — "any one" is the wrong rule there, since - it would let someone with MANAGE_FORMS on tournament A link a form to - tournament B too, despite having no authority over B. - """ - return user_manages_any_tournament(user, tournament_ids, db) or user_leads_any_chapter(user, chapter_ids, db) - - -def user_can_link_all(user: User, tournament_ids: list[int], chapter_ids: list[int], db: Session) -> bool: - """True only if `user` holds MANAGE_FORMS on EVERY tournament in - tournament_ids, and lead/officer on EVERY chapter in chapter_ids. - - Use this whenever a request is establishing NEW form<->tournament or - form<->chapter links (currently: form creation only). There's no - cross-TD request/accept flow yet — a TD who wants to link a form into - someone else's tournament needs a MANAGE_FORMS-holding role there first - (existing invite/role machinery). Known gap, not solved here. - """ - tournaments_ok = all(has_permission(user, tid, MANAGE_FORMS, db) for tid in tournament_ids) - chapters_ok = all(user_leads_any_chapter(user, [cid], db) for cid in chapter_ids) - return tournaments_ok and chapters_ok - - def _load_form_or_404(form_id: int, db: Session) -> Form: form = db.query(Form).filter(Form.id == form_id).first() if not form: @@ -79,15 +27,20 @@ def require_form_manage_access( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> Form: - """Dependency — loads the Form and requires manage access (MANAGE_FORMS - on any linked tournament, or lead/officer on any linked chapter). - Returns the Form so route handlers don't need a second query.""" + """Dependency — loads the Form and requires MANAGE_FORMS on the owning + tournament, or lead/officer on the owning chapter. Returns the Form so + route handlers don't need a second query.""" form = _load_form_or_404(form_id, db) - tournament_ids = [link.tournament_id for link in form.tournament_links] - chapter_ids = [link.chapter_id for link in form.chapter_links] - if not user_can_manage_form_links(current_user, tournament_ids, chapter_ids, db): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + if form.owner_type == "tournament": + if not has_any_membership(current_user, form.tournament_id, db): + # 404 to avoid leaking tournament existence, matching require_permission() + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Form not found") + if not has_permission(current_user, form.tournament_id, MANAGE_FORMS, db): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + else: + require_officer_or_lead(form.chapter_id, db, current_user) + return form @@ -97,20 +50,18 @@ def require_form_view_access( current_user: User = Depends(get_current_user), ) -> Form: """Dependency — loads the Form and requires either manage access, or - plain membership in any linked tournament/chapter (for the people + plain membership in the owning tournament/chapter (for the people filling the form out, not just the people managing it).""" form = _load_form_or_404(form_id, db) - tournament_ids = [link.tournament_id for link in form.tournament_links] - chapter_ids = [link.chapter_id for link in form.chapter_links] - if user_can_manage_form_links(current_user, tournament_ids, chapter_ids, db): - return form - if any(has_any_membership(current_user, tid, db) for tid in tournament_ids): - return form - if chapter_ids and db.query(ChapterMembership).filter( - ChapterMembership.user_id == current_user.id, - ChapterMembership.chapter_id.in_(chapter_ids), - ).first(): - return form + if form.owner_type == "tournament": + if not has_any_membership(current_user, form.tournament_id, db): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + else: + if current_user.role != "admin" and not db.query(ChapterMembership).filter( + ChapterMembership.user_id == current_user.id, + ChapterMembership.chapter_id == form.chapter_id, + ).first(): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + return form From 5e89630515872bcfb4ba062485718e9fed019dab Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 13:57:09 -0700 Subject: [PATCH 016/185] feat(forms): add creates_membership_on_submit with per-owner-type membership config --- .../versions/7db31ae17e3c_forms_core_model.py | 17 ++++++ backend/app/api/routes/forms.py | 56 ++++++++++++++++- backend/app/core/form/membership.py | 61 +++++++++++++++++++ backend/app/models/models.py | 40 ++++++++++++ backend/app/schemas/form.py | 32 ++++++++++ 5 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 backend/app/core/form/membership.py diff --git a/backend/alembic/versions/7db31ae17e3c_forms_core_model.py b/backend/alembic/versions/7db31ae17e3c_forms_core_model.py index 80815f0f..1cb6b029 100644 --- a/backend/alembic/versions/7db31ae17e3c_forms_core_model.py +++ b/backend/alembic/versions/7db31ae17e3c_forms_core_model.py @@ -43,6 +43,21 @@ def upgrade() -> None: ) op.create_index(op.f('ix_forms_id'), 'forms', ['id'], unique=False) + op.create_table('form_tournament_membership_configs', + sa.Column('form_id', sa.Integer(), nullable=False), + sa.Column('status_on_submit', sa.String(length=32), nullable=True), + sa.Column('role_ids_on_submit', sa.JSON(), nullable=True), + sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('form_id') + ) + + op.create_table('form_chapter_membership_configs', + sa.Column('form_id', sa.Integer(), nullable=False), + sa.Column('role_on_submit', sa.String(length=32), nullable=False), + sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('form_id') + ) + op.create_table('form_fields', sa.Column('id', sa.Integer(), nullable=False), sa.Column('form_id', sa.Integer(), nullable=False), @@ -94,5 +109,7 @@ def downgrade() -> None: op.drop_table('form_responses') op.drop_index(op.f('ix_form_fields_id'), table_name='form_fields') op.drop_table('form_fields') + op.drop_table('form_chapter_membership_configs') + op.drop_table('form_tournament_membership_configs') op.drop_index(op.f('ix_forms_id'), table_name='forms') op.drop_table('forms') diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 134596dc..9269746d 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -12,10 +12,20 @@ set_field_config, update_field_text, ) +from app.core.form.membership import create_membership_on_first_submit from app.core.form.permissions import require_form_manage_access, require_form_view_access from app.core.tournament.permissions import MANAGE_FORMS, require_permission from app.db.session import get_db -from app.models.models import Form, FormAnswer, FormField, FormResponse, User, utcnow +from app.models.models import ( + Form, + FormAnswer, + FormChapterMembershipConfig, + FormField, + FormResponse, + FormTournamentMembershipConfig, + User, + utcnow, +) from app.schemas.form import ( FormCreate, FormFieldCreate, @@ -61,6 +71,15 @@ def create_tournament_form( created_by=current_user.id, ) db.add(form) + db.flush() + + if form_in.tournament_membership_config is not None: + db.add(FormTournamentMembershipConfig( + form_id=form.id, + status_on_submit=form_in.tournament_membership_config.status_on_submit, + role_ids_on_submit=form_in.tournament_membership_config.role_ids_on_submit or None, + )) + db.commit() db.refresh(form) return form @@ -99,6 +118,14 @@ def create_chapter_form( created_by=current_user.id, ) db.add(form) + db.flush() + + if form_in.chapter_membership_config is not None: + db.add(FormChapterMembershipConfig( + form_id=form.id, + role_on_submit=form_in.chapter_membership_config.role_on_submit, + )) + db.commit() db.refresh(form) return form @@ -133,8 +160,10 @@ def get_form_for_rendering( # --------------------------------------------------------------------------- -# PATCH /forms/{form_id}/ — name/description/status only. Adding/removing -# tournament or chapter links isn't handled here yet. +# PATCH /forms/{form_id}/ — name/description/status/membership config. +# A membership_config payload for the "wrong" owner_type is ignored (PATCH +# is a partial update, not worth 422ing over — FormCreate already prevents +# ever creating a form with a mismatched config). # --------------------------------------------------------------------------- @router.patch("/forms/{form_id}/", response_model=FormRead) def update_form( @@ -148,6 +177,23 @@ def update_form( form.description = form_in.description if form_in.status is not None: form.status = form_in.status + if form_in.creates_membership_on_submit is not None: + form.creates_membership_on_submit = form_in.creates_membership_on_submit + + if form_in.tournament_membership_config is not None and form.owner_type == "tournament": + config = form.tournament_membership_config + if config is None: + config = FormTournamentMembershipConfig(form_id=form.id) + db.add(config) + config.status_on_submit = form_in.tournament_membership_config.status_on_submit + config.role_ids_on_submit = form_in.tournament_membership_config.role_ids_on_submit or None + + if form_in.chapter_membership_config is not None and form.owner_type == "chapter": + config = form.chapter_membership_config + if config is None: + config = FormChapterMembershipConfig(form_id=form.id) + db.add(config) + config.role_on_submit = form_in.chapter_membership_config.role_on_submit db.commit() db.refresh(form) @@ -320,6 +366,7 @@ def submit_form_response( .filter(FormResponse.form_id == form.id, FormResponse.user_id == current_user.id) .first() ) + is_first_response = response is None if response is None: response = FormResponse(form_id=form.id, user_id=current_user.id) db.add(response) @@ -331,6 +378,9 @@ def submit_form_response( for answer_in in response_in.answers: db.add(FormAnswer(response_id=response.id, field_id=answer_in.field_id, value=answer_in.value)) + if is_first_response: + create_membership_on_first_submit(db, form, current_user) + db.commit() db.refresh(response) return response diff --git a/backend/app/core/form/membership.py b/backend/app/core/form/membership.py new file mode 100644 index 00000000..b9b023ca --- /dev/null +++ b/backend/app/core/form/membership.py @@ -0,0 +1,61 @@ +from sqlalchemy.orm import Session + +from app.models.models import ChapterMembership, Form, TournamentMembership, TournamentMembershipRole, User + +# --------------------------------------------------------------------------- +# creates_membership_on_submit side effect. Called only on a user's FIRST +# response to a form — resubmission never touches membership. +# --------------------------------------------------------------------------- + + +def create_membership_on_first_submit(db: Session, form: Form, user: User) -> None: + if not form.creates_membership_on_submit: + return + + if form.owner_type == "tournament": + _create_tournament_membership(db, form, user) + else: + _create_chapter_membership(db, form, user) + + +def _create_tournament_membership(db: Session, form: Form, user: User) -> None: + existing = ( + db.query(TournamentMembership) + .filter( + TournamentMembership.user_id == user.id, + TournamentMembership.tournament_id == form.tournament_id, + ) + .first() + ) + if existing: + return + + config = form.tournament_membership_config + status_value = (config.status_on_submit if config else None) or "interested" + + membership = TournamentMembership( + user_id=user.id, + tournament_id=form.tournament_id, + source="manual", + status=status_value, + ) + db.add(membership) + db.flush() + + role_ids = config.role_ids_on_submit if config and config.role_ids_on_submit else [] + for role_id in role_ids: + db.add(TournamentMembershipRole(membership_id=membership.id, role_id=role_id)) + + +def _create_chapter_membership(db: Session, form: Form, user: User) -> None: + # ChapterMembership.user_id is unique — a user belongs to at most one + # chapter total, so this checks for ANY existing chapter membership, + # not just one scoped to form.chapter_id. + existing = db.query(ChapterMembership).filter(ChapterMembership.user_id == user.id).first() + if existing: + return + + config = form.chapter_membership_config + role_value = config.role_on_submit if config else "member" + + db.add(ChapterMembership(chapter_id=form.chapter_id, user_id=user.id, role=role_value)) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 4e87a9bc..dcd54244 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -682,6 +682,12 @@ class Form(Base): creator = relationship("User", back_populates="created_forms") fields = relationship("FormField", back_populates="form", cascade="all, delete-orphan", order_by="FormField.order") responses = relationship("FormResponse", back_populates="form", cascade="all, delete-orphan") + tournament_membership_config = relationship( + "FormTournamentMembershipConfig", back_populates="form", uselist=False, cascade="all, delete-orphan" + ) + chapter_membership_config = relationship( + "FormChapterMembershipConfig", back_populates="form", uselist=False, cascade="all, delete-orphan" + ) __table_args__ = ( CheckConstraint( @@ -692,6 +698,40 @@ class Form(Base): ) +# --------------------------------------------------------------------------- +# FormTournamentMembershipConfig — optional per-form config for what +# creates_membership_on_submit does on a tournament-owned Form. Absent row = +# use defaults (TournamentMembership's own status default, no extra roles). +# --------------------------------------------------------------------------- +class FormTournamentMembershipConfig(Base): + __tablename__ = "form_tournament_membership_configs" + + form_id = Column(Integer, ForeignKey("forms.id", ondelete="CASCADE"), primary_key=True) + + # "interested" | "confirmed" | None (None = TournamentMembership default + # on create; an existing membership's status is never touched either way) + status_on_submit = Column(String(32), nullable=True) + + # list[int] of TournamentRole ids to attach via TournamentMembershipRole + role_ids_on_submit = Column(JSON, nullable=True) + + form = relationship("Form", back_populates="tournament_membership_config") + + +# --------------------------------------------------------------------------- +# FormChapterMembershipConfig — optional per-form config for what +# creates_membership_on_submit does on a chapter-owned Form. Absent row = +# defaults to role_on_submit="member". +# --------------------------------------------------------------------------- +class FormChapterMembershipConfig(Base): + __tablename__ = "form_chapter_membership_configs" + + form_id = Column(Integer, ForeignKey("forms.id", ondelete="CASCADE"), primary_key=True) + role_on_submit = Column(String(32), nullable=False, default="member") # "lead" | "officer" | "member" + + form = relationship("Form", back_populates="chapter_membership_config") + + # --------------------------------------------------------------------------- # FormField — a single question on a Form. question_type drives how config # is shaped (see comments inline below). Removing a field with existing diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index f4e862d8..3d83490d 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -45,6 +45,28 @@ class FormFieldUpdate(BaseModel): # Form Schemas # --------------------------------------------------------------------------- +class FormTournamentMembershipConfigIn(BaseModel): + status_on_submit: Literal["interested", "confirmed"] | None = None + role_ids_on_submit: list[int] = [] + + +class FormTournamentMembershipConfigRead(BaseModel): + status_on_submit: Literal["interested", "confirmed"] | None = None + role_ids_on_submit: list[int] = [] + + model_config = ConfigDict(from_attributes=True) + + +class FormChapterMembershipConfigIn(BaseModel): + role_on_submit: Literal["lead", "officer", "member"] = "member" + + +class FormChapterMembershipConfigRead(BaseModel): + role_on_submit: Literal["lead", "officer", "member"] = "member" + + model_config = ConfigDict(from_attributes=True) + + class FormRead(BaseModel): id: int name: str @@ -54,6 +76,8 @@ class FormRead(BaseModel): tournament_id: int | None = None chapter_id: int | None = None creates_membership_on_submit: bool = False + tournament_membership_config: FormTournamentMembershipConfigRead | None = None + chapter_membership_config: FormChapterMembershipConfigRead | None = None created_by: int created_at: datetime updated_at: datetime @@ -69,15 +93,21 @@ class FormCreate(BaseModel): tournament_id: int | None = None chapter_id: int | None = None creates_membership_on_submit: bool = False + tournament_membership_config: FormTournamentMembershipConfigIn | None = None + chapter_membership_config: FormChapterMembershipConfigIn | None = None @model_validator(mode="after") def _require_matching_owner(self): if self.owner_type == "tournament": if self.tournament_id is None or self.chapter_id is not None: raise ValueError("owner_type 'tournament' requires tournament_id and no chapter_id") + if self.chapter_membership_config is not None: + raise ValueError("chapter_membership_config only applies to owner_type 'chapter'") else: if self.chapter_id is None or self.tournament_id is not None: raise ValueError("owner_type 'chapter' requires chapter_id and no tournament_id") + if self.tournament_membership_config is not None: + raise ValueError("tournament_membership_config only applies to owner_type 'tournament'") return self @@ -86,6 +116,8 @@ class FormUpdate(BaseModel): description: str | None = None status: Literal["draft", "published", "archived"] | None = None creates_membership_on_submit: bool | None = None + tournament_membership_config: FormTournamentMembershipConfigIn | None = None + chapter_membership_config: FormChapterMembershipConfigIn | None = None # --------------------------------------------------------------------------- From 015c9ccd734bf309682a9f32d40e6002ecdc6265 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 14:06:49 -0700 Subject: [PATCH 017/185] fix(forms): make field_key required and client-supplied with tournament-wide uniqueness --- .../versions/7db31ae17e3c_forms_core_model.py | 2 +- backend/app/api/routes/forms.py | 32 +++++++++++++++---- backend/app/core/form/__init__.py | 23 ++++++++++++- backend/app/models/models.py | 18 ++++++----- backend/app/schemas/form.py | 7 ++-- 5 files changed, 63 insertions(+), 19 deletions(-) diff --git a/backend/alembic/versions/7db31ae17e3c_forms_core_model.py b/backend/alembic/versions/7db31ae17e3c_forms_core_model.py index 1cb6b029..dd0526d0 100644 --- a/backend/alembic/versions/7db31ae17e3c_forms_core_model.py +++ b/backend/alembic/versions/7db31ae17e3c_forms_core_model.py @@ -65,7 +65,7 @@ def upgrade() -> None: sa.Column('label', sa.String(length=255), nullable=False), sa.Column('description', sa.Text(), nullable=True), sa.Column('question_type', sa.String(length=32), nullable=False), - sa.Column('field_key', sa.String(length=64), nullable=True), + sa.Column('field_key', sa.String(length=64), nullable=False), sa.Column('config', sa.JSON(), nullable=True), sa.Column('is_archived', sa.Boolean(), nullable=False), sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 9269746d..d4fdd9e0 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -5,11 +5,13 @@ from app.core.auth import get_current_user from app.core.chapters import require_officer_or_lead from app.core.form import ( + field_key_taken_in_tournament, remove_form_field, reorder_field, replace_field_type, resolve_field_options, set_field_config, + slugify, update_field_text, ) from app.core.form.membership import create_membership_on_first_submit @@ -238,9 +240,14 @@ def delete_form( # --------------------------------------------------------------------------- -# POST /forms/{form_id}/fields/ — MANAGE_FORMS on any linked tournament, or -# lead/officer on any linked chapter (the form already exists and is -# already linked, so the "any one" rule applies here, unlike form creation). +# POST /forms/{form_id}/fields/ — field_key is TD-typed (separate from +# label), normalized server-side via slugify(). For tournament-owned forms +# the normalized key must be unique across every form that tournament owns +# (not just this one) since it's a TD-visible dashboard lookup key; +# collisions 409 rather than auto-suffixing so the TD can pick a more +# distinct key instead of silently getting a different one than they typed. +# Chapter-owned forms fall back to the plain per-form uniqueness the DB +# constraint already enforces. # --------------------------------------------------------------------------- @router.post("/forms/{form_id}/fields/", response_model=FormFieldRead, status_code=status.HTTP_201_CREATED) def create_form_field( @@ -248,14 +255,25 @@ def create_form_field( db: Session = Depends(get_db), form: Form = Depends(require_form_manage_access), ): - if field_in.field_key is not None: + field_key = slugify(field_in.field_key) + + if form.owner_type == "tournament": + if field_key_taken_in_tournament(db, form.tournament_id, field_key): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"field_key '{field_key}' is already in use elsewhere in this tournament — pick a more distinct label", + ) + else: existing = ( db.query(FormField) - .filter(FormField.form_id == form.id, FormField.field_key == field_in.field_key) + .filter(FormField.form_id == form.id, FormField.field_key == field_key) .first() ) if existing: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="field_key already in use on this form") + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"field_key '{field_key}' is already in use on this form — pick a more distinct label", + ) order = field_in.order if order is None: @@ -268,7 +286,7 @@ def create_form_field( label=field_in.label, description=field_in.description, question_type=field_in.question_type, - field_key=field_in.field_key, + field_key=field_key, config=field_in.config, is_archived=False, ) diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index fb376ca5..3b0cc664 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -1,9 +1,30 @@ from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified -from app.models.models import FormAnswer, FormField, TournamentEvent +from app.models.models import Form, FormAnswer, FormField, TournamentEvent import re # Regular Expressions for searching, matching, and extracting patterns in text strings + +def slugify(text: str, max_len: int = 64) -> str: + """Convert a TD-typed label into a snake_case field_key candidate.""" + slug = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_") + return slug[:max_len] + + +def field_key_taken_in_tournament(db: Session, tournament_id: int, field_key: str) -> bool: + """True if `field_key` is already used by any FormField — archived + included, an archived key isn't released for reuse — belonging to any + Form owned by `tournament_id`. field_key is the TD-visible dashboard + lookup key, so it's unique tournament-wide, not just per form.""" + return ( + db.query(FormField) + .join(Form, Form.id == FormField.form_id) + .filter(Form.tournament_id == tournament_id, FormField.field_key == field_key) + .first() + is not None + ) + + def remove_form_field( db: Session, field: FormField diff --git a/backend/app/models/models.py b/backend/app/models/models.py index dcd54244..5465bd6e 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -11,12 +11,11 @@ ForeignKey, UniqueConstraint, CheckConstraint, Column, event, Index, ) from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, validates from typing import Optional from app.db.session import Base from app.core.age import meets_age_requirement -from pydantic import field_validator def utcnow(): @@ -750,7 +749,11 @@ class FormField(Base): # short_text | paragraph | single_select_radio | single_select_dropdown # | multi_select | ranked_choice | grid | shift_select | page_break - field_key = Column(String(64), nullable=True) + # Dashboard lookup key — slugified from the TD-typed label at create + # time (see app/core/form.slugify) and stable afterward, even if the + # label is later edited. Unique per tournament, not just per form (see + # app/core/form.check_field_key_available_in_tournament). + field_key = Column(String(64), nullable=False) config = Column(JSON, nullable=True) # For plain choice questions: {"options": [{"id": "opt_1", "label": "...", @@ -771,12 +774,11 @@ class FormField(Base): UniqueConstraint("form_id", "field_key", name="uq_form_field_key"), ) - @field_validator("field_key") - @classmethod - def validate_field_key(cls, v: str | None) -> str | None: - if v is not None and not v.replace("_", "").isalnum(): + @validates("field_key") + def validate_field_key(self, key, value): + if not value or not value.replace("_", "").isalnum(): raise ValueError("field_key must be snake_case alphanumeric") - return v + return value # --------------------------------------------------------------------------- diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index 3d83490d..37ffb9c8 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -9,7 +9,7 @@ class FormFieldRead(BaseModel): id: int form_id: int - field_key: str | None = None + field_key: str order: int label: str description: str | None = None @@ -23,10 +23,13 @@ class FormFieldRead(BaseModel): class FormFieldCreate(BaseModel): + # field_key is TD-typed, separate from `label` — the TD's own name for + # the dashboard lookup key. Server-side slugify() normalizes it (see + # app/core/form.slugify) label: str + field_key: str question_type: str description: str | None = None - field_key: str | None = None order: int | None = None config: dict[str, Any] | None = None From a83e4124600aad2913d35ef8db45cf54b9fade2f Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 14:18:27 -0700 Subject: [PATCH 018/185] test(forms): add core and route test coverage, fix view access to allow non-members on creates_membership_on_submit forms --- backend/app/core/form/permissions.py | 10 +- backend/tests/api/test_forms.py | 503 +++++++++++++++++++++++--- backend/tests/core/test_forms.py | 504 +++++++++++++++++++++++++++ 3 files changed, 962 insertions(+), 55 deletions(-) create mode 100644 backend/tests/core/test_forms.py diff --git a/backend/app/core/form/permissions.py b/backend/app/core/form/permissions.py index ea68fd72..f524195b 100644 --- a/backend/app/core/form/permissions.py +++ b/backend/app/core/form/permissions.py @@ -51,9 +51,17 @@ def require_form_view_access( ) -> Form: """Dependency — loads the Form and requires either manage access, or plain membership in the owning tournament/chapter (for the people - filling the form out, not just the people managing it).""" + filling the form out, not just the people managing it). + + A creates_membership_on_submit form is open to any authenticated user + regardless of existing membership — that flag exists specifically to + onboard NON-members via their first submission, so gating view access + on membership they don't have yet would make the flag unreachable.""" form = _load_form_or_404(form_id, db) + if form.creates_membership_on_submit: + return form + if form.owner_type == "tournament": if not has_any_membership(current_user, form.tournament_id, db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index 0c0c0d71..5e4e1cc6 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -1,21 +1,59 @@ -from app.core.form import remove_form_field, remove_option_from_field, replace_field_type -from app.models.models import Form, FormAnswer, FormField, FormResponse +"""Route tests for /forms/ (app/api/routes/forms.py). Model CRUD, field +helpers, slugify/uniqueness, the creates_membership_on_submit side effect, +and the access-control dependency functions are covered directly in +tests/core/test_forms.py — this file exercises the HTTP layer on top.""" +import pytest +from tests.conftest import grant_role, login +from tests.api.chapter._helpers import make_chapter, make_university, make_user -def _make_form(db, user, tournament, name="Test form"): - form = Form( +from app.core.form import remove_form_field +from app.models.models import ( + ChapterMembership, + Form, + FormAnswer, + FormField, + FormResponse, + TournamentMembership, +) + + +# --------------------------------------------------------------------------- +# Builders +# --------------------------------------------------------------------------- + +def _make_form(db, user, tournament, **overrides): + defaults = dict( owner_type="tournament", tournament_id=tournament.id, - name=name, + chapter_id=None, + name="Test form", + created_by=user.id, + ) + defaults.update(overrides) + form = Form(**defaults) + db.add(form) + db.flush() + return form + + +def _make_chapter_form(db, user, chapter, **overrides): + defaults = dict( + owner_type="chapter", + chapter_id=chapter.id, + tournament_id=None, + name="Test chapter form", created_by=user.id, ) + defaults.update(overrides) + form = Form(**defaults) db.add(form) db.flush() return form -def _make_field(db, form, *, order=1, field_key="favorite_color", question_type="single_select_dropdown"): - field = FormField( +def _make_field(db, form, *, order=1, field_key="favorite_color", question_type="single_select_dropdown", **overrides): + defaults = dict( form_id=form.id, order=order, label="Favorite color", @@ -28,73 +66,430 @@ def _make_field(db, form, *, order=1, field_key="favorite_color", question_type= {"id": "opt_2", "label": "Blue", "archived": False, "next_section_id": None, "allow_other": False}, ] }, - required=False, is_archived=False, ) + defaults.update(overrides) + field = FormField(**defaults) db.add(field) db.flush() return field -def test_remove_form_field_archives_when_answers_exist(db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - field = _make_field(db, form, order=1, field_key="favorite_color") +@pytest.fixture(name="chapter") +def chapter_fixture(db): + university = make_university(db) + return make_chapter(db, university.id) - response = FormResponse(form_id=form.id, user_id=td_user.id) - db.add(response) - db.flush() - answer = FormAnswer(response_id=response.id, field_id=field.id, value=["opt_1"]) - db.add(answer) - db.flush() +def _chapter_lead(db, chapter, email="chapterlead@test.com", password="LeadPass123!"): + user = make_user(db, email, password=password) + db.add(ChapterMembership(chapter_id=chapter.id, user_id=user.id, role="lead")) + db.commit() + return user - removed = remove_form_field(db, field) - assert removed is True - db.refresh(field) - assert field.is_archived is True - assert db.query(FormField).filter(FormField.id == field.id).one().is_archived is True - assert db.query(FormAnswer).filter(FormAnswer.field_id == field.id).one().value == ["opt_1"] +# --------------------------------------------------------------------------- +# POST /tournaments/{tournament_id}/forms/ and POST /chapters/{chapter_id}/forms/ +# --------------------------------------------------------------------------- +class TestCreateForm: + def test_td_can_create_tournament_form(self, client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + res = client.post( + f"/tournaments/{td_tournament.id}/forms/", + json={"name": "Interest form", "owner_type": "tournament", "tournament_id": td_tournament.id}, + ) + assert res.status_code == 201 + data = res.json() + assert data["owner_type"] == "tournament" + assert data["tournament_id"] == td_tournament.id + assert data["status"] == "draft" -def test_replace_field_type_archives_old_field_and_keeps_order(db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - field = _make_field(db, form, order=7, field_key="tshirt_size") + def test_non_member_forbidden_via_404(self, client, other_user, td_tournament): + login(client, "other@test.com", "otherpass") + res = client.post( + f"/tournaments/{td_tournament.id}/forms/", + json={"name": "Interest form", "owner_type": "tournament", "tournament_id": td_tournament.id}, + ) + assert res.status_code == 404 - replacement = replace_field_type(db, field, "multi_select") + def test_member_without_manage_forms_forbidden(self, client, db, td_tournament, other_user): + grant_role(db, td_tournament, other_user, "Runner") + login(client, "other@test.com", "otherpass") + res = client.post( + f"/tournaments/{td_tournament.id}/forms/", + json={"name": "Interest form", "owner_type": "tournament", "tournament_id": td_tournament.id}, + ) + assert res.status_code == 403 - db.refresh(field) - assert field.is_archived is True - assert field.field_key.endswith(f"_archived_{field.id}") + def test_owner_type_mismatch_rejected(self, client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + res = client.post( + f"/tournaments/{td_tournament.id}/forms/", + json={"name": "Bad", "owner_type": "chapter", "chapter_id": 1}, + ) + assert res.status_code == 422 - assert replacement is not field - assert replacement.form_id == form.id - assert replacement.order == field.order - assert replacement.question_type == "multi_select" - assert replacement.field_key == "tshirt_size" - assert replacement.is_archived is False + def test_chapter_lead_can_create_chapter_form(self, client, db, chapter): + _chapter_lead(db, chapter) + login(client, "chapterlead@test.com", "LeadPass123!") + res = client.post( + f"/chapters/{chapter.id}/forms/", + json={"name": "Alumni form", "owner_type": "chapter", "chapter_id": chapter.id}, + ) + assert res.status_code == 201 + assert res.json()["owner_type"] == "chapter" + assert res.json()["chapter_id"] == chapter.id - ordered_fields = db.query(FormField).filter(FormField.form_id == form.id).order_by(FormField.order).all() - assert [f.id for f in ordered_fields] == [replacement.id, field.id] - assert replacement.order == 7 - assert field.order == 7 + def test_chapter_plain_member_forbidden(self, client, db, chapter): + member = make_user(db, "plainmember@test.com", password="MemberPass123!") + db.add(ChapterMembership(chapter_id=chapter.id, user_id=member.id, role="member")) + db.commit() + login(client, "plainmember@test.com", "MemberPass123!") + res = client.post( + f"/chapters/{chapter.id}/forms/", + json={"name": "Alumni form", "owner_type": "chapter", "chapter_id": chapter.id}, + ) + assert res.status_code == 403 + def test_unauthenticated_forbidden(self, client, td_tournament): + res = client.post( + f"/tournaments/{td_tournament.id}/forms/", + json={"name": "Interest form", "owner_type": "tournament", "tournament_id": td_tournament.id}, + ) + assert res.status_code == 401 -def test_remove_option_from_field_keeps_existing_answer_values(db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - field = _make_field(db, form, order=2, field_key="member_role") - response = FormResponse(form_id=form.id, user_id=td_user.id) - db.add(response) - db.flush() +# --------------------------------------------------------------------------- +# GET /forms/{form_id}/ +# --------------------------------------------------------------------------- - answer = FormAnswer(response_id=response.id, field_id=field.id, value=["opt_1"]) - db.add(answer) - db.flush() +class TestGetForm: + def test_manager_can_view(self, client, td_user, td_tournament, db): + form = _make_form(db, td_user, td_tournament) + db.commit() + login(client, "td@test.com", "tdpass") + res = client.get(f"/forms/{form.id}/") + assert res.status_code == 200 + assert res.json()["id"] == form.id + + def test_plain_member_can_view(self, client, db, td_user, td_tournament, other_user): + grant_role(db, td_tournament, other_user, "Runner") + form = _make_form(db, td_user, td_tournament) + db.commit() + login(client, "other@test.com", "otherpass") + res = client.get(f"/forms/{form.id}/") + assert res.status_code == 200 + + def test_non_member_forbidden(self, client, db, td_user, td_tournament, other_user): + form = _make_form(db, td_user, td_tournament) + db.commit() + login(client, "other@test.com", "otherpass") + res = client.get(f"/forms/{form.id}/") + assert res.status_code == 403 + + def test_non_member_allowed_when_creates_membership_on_submit(self, client, db, td_user, td_tournament, other_user): + form = _make_form(db, td_user, td_tournament, creates_membership_on_submit=True) + db.commit() + login(client, "other@test.com", "otherpass") + res = client.get(f"/forms/{form.id}/") + assert res.status_code == 200 + + def test_missing_form_404(self, client, td_user): + login(client, "td@test.com", "tdpass") + res = client.get("/forms/9999/") + assert res.status_code == 404 + + def test_includes_active_fields_ordered(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + _make_field(db, form, order=2, field_key="second") + _make_field(db, form, order=1, field_key="first") + db.commit() + login(client, "td@test.com", "tdpass") + res = client.get(f"/forms/{form.id}/") + keys = [f["field_key"] for f in res.json()["fields"]] + assert keys == ["first", "second"] + + +# --------------------------------------------------------------------------- +# PATCH / archive / delete /forms/{form_id}/ +# --------------------------------------------------------------------------- + +class TestUpdateArchiveDeleteForm: + def test_patch_updates_fields(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + login(client, "td@test.com", "tdpass") + res = client.patch(f"/forms/{form.id}/", json={"name": "Renamed", "status": "published"}) + assert res.status_code == 200 + assert res.json()["name"] == "Renamed" + assert res.json()["status"] == "published" + + def test_archive_sets_status(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + login(client, "td@test.com", "tdpass") + res = client.post(f"/forms/{form.id}/archive/") + assert res.status_code == 200 + assert res.json()["status"] == "archived" + + def test_delete_succeeds_with_no_responses(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + login(client, "td@test.com", "tdpass") + res = client.delete(f"/forms/{form.id}/") + assert res.status_code == 204 + assert db.query(Form).filter(Form.id == form.id).first() is None + + def test_delete_blocked_when_responses_exist(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.add(FormResponse(form_id=form.id, user_id=td_user.id)) + db.commit() + login(client, "td@test.com", "tdpass") + res = client.delete(f"/forms/{form.id}/") + assert res.status_code == 409 + assert db.query(Form).filter(Form.id == form.id).first() is not None + + +# --------------------------------------------------------------------------- +# POST /forms/{form_id}/fields/ — field_key required, TD-typed, slugified, +# tournament-wide uniqueness for tournament forms. +# --------------------------------------------------------------------------- + +class TestCreateField: + def test_create_field_slugifies_key(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + login(client, "td@test.com", "tdpass") + res = client.post( + f"/forms/{form.id}/fields/", + json={"label": "Test Writing Interest", "field_key": "Test Writing Interest!", "question_type": "short_text"}, + ) + assert res.status_code == 201 + assert res.json()["field_key"] == "test_writing_interest" + + def test_field_key_collision_within_tournament_rejected(self, client, db, td_user, td_tournament): + form_a = _make_form(db, td_user, td_tournament, name="Form A") + form_b = _make_form(db, td_user, td_tournament, name="Form B") + _make_field(db, form_a, field_key="shared_key") + db.commit() + + login(client, "td@test.com", "tdpass") + res = client.post( + f"/forms/{form_b.id}/fields/", + json={"label": "Anything", "field_key": "shared_key", "question_type": "short_text"}, + ) + assert res.status_code == 409 + + def test_field_key_collision_across_forms_in_same_tournament_after_slugify(self, client, db, td_user, td_tournament): + form_a = _make_form(db, td_user, td_tournament, name="Form A") + form_b = _make_form(db, td_user, td_tournament, name="Form B") + _make_field(db, form_a, field_key="shared_key") + db.commit() + + login(client, "td@test.com", "tdpass") + res = client.post( + f"/forms/{form_b.id}/fields/", + json={"label": "Anything", "field_key": "Shared Key!!", "question_type": "short_text"}, + ) + assert res.status_code == 409 + + def test_archived_field_key_not_released_for_reuse(self, client, db, td_user, td_tournament): + form_a = _make_form(db, td_user, td_tournament, name="Form A") + form_b = _make_form(db, td_user, td_tournament, name="Form B") + field = _make_field(db, form_a, field_key="was_used") + response = FormResponse(form_id=form_a.id, user_id=td_user.id) + db.add(response) + db.flush() + db.add(FormAnswer(response_id=response.id, field_id=field.id, value=["opt_1"])) + db.commit() + + was_archived = remove_form_field(db, field) + assert was_archived is True # archived, not deleted, because it has an answer + db.commit() + + login(client, "td@test.com", "tdpass") + res = client.post( + f"/forms/{form_b.id}/fields/", + json={"label": "Anything", "field_key": "was_used", "question_type": "short_text"}, + ) + assert res.status_code == 409 + + def test_chapter_forms_scope_uniqueness_per_form_only(self, client, db, td_user, chapter): + form_a = _make_chapter_form(db, td_user, chapter, name="Form A") + form_b = _make_chapter_form(db, td_user, chapter, name="Form B") + _make_field(db, form_a, field_key="shared_key") + db.commit() + + _chapter_lead(db, chapter) + login(client, "chapterlead@test.com", "LeadPass123!") + res = client.post( + f"/forms/{form_b.id}/fields/", + json={"label": "Anything", "field_key": "shared_key", "question_type": "short_text"}, + ) + # Different form -> allowed for chapter-owned forms (only per-form uniqueness applies) + assert res.status_code == 201 + + def test_missing_field_key_rejected(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + login(client, "td@test.com", "tdpass") + res = client.post( + f"/forms/{form.id}/fields/", + json={"label": "No key", "question_type": "short_text"}, + ) + assert res.status_code == 422 + + +# --------------------------------------------------------------------------- +# PATCH / DELETE /forms/{form_id}/fields/{field_id}/ +# --------------------------------------------------------------------------- + +class TestEditDeleteField: + def test_patch_updates_label_and_order(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, order=1, field_key="color") + db.commit() + login(client, "td@test.com", "tdpass") + res = client.patch(f"/forms/{form.id}/fields/{field.id}/", json={"label": "New label", "order": 3}) + assert res.status_code == 200 + assert res.json()["label"] == "New label" + assert res.json()["order"] == 3 + + def test_patch_question_type_replaces_field_keeping_key(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color") + db.commit() + login(client, "td@test.com", "tdpass") + res = client.patch(f"/forms/{form.id}/fields/{field.id}/", json={"question_type": "multi_select"}) + assert res.status_code == 200 + assert res.json()["question_type"] == "multi_select" + assert res.json()["field_key"] == "color" + assert res.json()["id"] != field.id + + def test_delete_hard_deletes_when_no_answers(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color") + db.commit() + login(client, "td@test.com", "tdpass") + res = client.delete(f"/forms/{form.id}/fields/{field.id}/") + assert res.status_code == 200 + assert res.json()["action"] == "deleted" + + def test_delete_archives_when_answers_exist(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color") + response = FormResponse(form_id=form.id, user_id=td_user.id) + db.add(response) + db.flush() + db.add(FormAnswer(response_id=response.id, field_id=field.id, value=["opt_1"])) + db.commit() + login(client, "td@test.com", "tdpass") + res = client.delete(f"/forms/{form.id}/fields/{field.id}/") + assert res.status_code == 200 + assert res.json()["action"] == "archived" + + +# --------------------------------------------------------------------------- +# POST /forms/{form_id}/responses/ — submission and resubmission +# --------------------------------------------------------------------------- + +class TestSubmitResponse: + def test_first_submission_creates_response(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color") + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post( + f"/forms/{form.id}/responses/", + json={"answers": [{"field_id": field.id, "value": ["opt_1"]}]}, + ) + assert res.status_code == 200 + data = res.json() + assert data["form_id"] == form.id + assert len(data["answers"]) == 1 + assert data["answers"][0]["value"] == ["opt_1"] + + def test_resubmission_overwrites_in_place(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color") + db.commit() + login(client, "td@test.com", "tdpass") + + client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_1"]}]}) + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_2"]}]}) + + assert res.status_code == 200 + assert len(res.json()["answers"]) == 1 + assert res.json()["answers"][0]["value"] == ["opt_2"] + assert db.query(FormResponse).filter(FormResponse.form_id == form.id, FormResponse.user_id == td_user.id).count() == 1 + + def test_invalid_field_id_rejected(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": 9999, "value": "x"}]}) + assert res.status_code == 400 + + def test_non_member_forbidden(self, client, db, td_user, td_tournament, other_user): + form = _make_form(db, td_user, td_tournament) + db.commit() + login(client, "other@test.com", "otherpass") + res = client.post(f"/forms/{form.id}/responses/", json={"answers": []}) + assert res.status_code == 403 + + def test_non_member_can_submit_when_creates_membership_on_submit(self, client, db, td_user, td_tournament, other_user): + form = _make_form(db, td_user, td_tournament, creates_membership_on_submit=True) + db.commit() + login(client, "other@test.com", "otherpass") + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": []}) + assert res.status_code == 200 + + membership = db.query(TournamentMembership).filter( + TournamentMembership.user_id == other_user.id, + TournamentMembership.tournament_id == td_tournament.id, + ).one() + assert membership.status == "interested" + + +# --------------------------------------------------------------------------- +# GET /forms/{form_id}/responses/ and /responses/me/ +# --------------------------------------------------------------------------- + +class TestListAndMyResponses: + def test_manager_can_list_responses(self, client, db, td_user, td_tournament, other_user): + grant_role(db, td_tournament, other_user, "Runner") + form = _make_form(db, td_user, td_tournament) + db.add(FormResponse(form_id=form.id, user_id=other_user.id)) + db.commit() + login(client, "td@test.com", "tdpass") + res = client.get(f"/forms/{form.id}/responses/") + assert res.status_code == 200 + assert len(res.json()) == 1 + + def test_plain_member_cannot_list_responses(self, client, db, td_user, td_tournament, other_user): + grant_role(db, td_tournament, other_user, "Runner") + form = _make_form(db, td_user, td_tournament) + db.commit() + login(client, "other@test.com", "otherpass") + res = client.get(f"/forms/{form.id}/responses/") + assert res.status_code == 403 - updated = remove_option_from_field(db, field, "opt_1") + def test_me_returns_own_response(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.add(FormResponse(form_id=form.id, user_id=td_user.id)) + db.commit() + login(client, "td@test.com", "tdpass") + res = client.get(f"/forms/{form.id}/responses/me/") + assert res.status_code == 200 + assert res.json()["user_id"] == td_user.id - assert updated is field - assert updated.config["options"][0]["archived"] is True - assert updated.config["options"][0]["label"] == "Red" - assert db.query(FormAnswer).filter(FormAnswer.field_id == field.id).one().value == ["opt_1"] + def test_me_404_when_no_response(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + login(client, "td@test.com", "tdpass") + res = client.get(f"/forms/{form.id}/responses/me/") + assert res.status_code == 404 diff --git a/backend/tests/core/test_forms.py b/backend/tests/core/test_forms.py new file mode 100644 index 00000000..e033d439 --- /dev/null +++ b/backend/tests/core/test_forms.py @@ -0,0 +1,504 @@ +"""Tests for app/core/form — model CRUD building blocks, field-editing +helpers, field_key derivation/uniqueness, the creates_membership_on_submit +side effect, and the access-control dependency functions, all exercised +directly (no HTTP layer). See tests/api/test_forms.py for the routes.""" +import pytest +from fastapi import HTTPException +from sqlalchemy.exc import IntegrityError + +from tests.conftest import grant_role +from tests.api.chapter._helpers import make_chapter, make_university, make_user + +from app.core.form import ( + field_key_taken_in_tournament, + remove_form_field, + remove_option_from_field, + replace_field_type, + slugify, +) +from app.core.form.membership import create_membership_on_first_submit +from app.core.form.permissions import require_form_manage_access, require_form_view_access +from app.models.models import ( + ChapterMembership, + Form, + FormAnswer, + FormChapterMembershipConfig, + FormField, + FormResponse, + FormTournamentMembershipConfig, + TournamentMembership, + TournamentMembershipRole, + TournamentRole, +) + + +# --------------------------------------------------------------------------- +# Builders +# --------------------------------------------------------------------------- + +def _make_form(db, user, tournament, **overrides): + defaults = dict( + owner_type="tournament", + tournament_id=tournament.id, + chapter_id=None, + name="Test form", + created_by=user.id, + ) + defaults.update(overrides) + form = Form(**defaults) + db.add(form) + db.flush() + return form + + +def _make_chapter_form(db, user, chapter, **overrides): + defaults = dict( + owner_type="chapter", + chapter_id=chapter.id, + tournament_id=None, + name="Test chapter form", + created_by=user.id, + ) + defaults.update(overrides) + form = Form(**defaults) + db.add(form) + db.flush() + return form + + +def _make_field(db, form, *, order=1, field_key="favorite_color", question_type="single_select_dropdown", **overrides): + defaults = dict( + form_id=form.id, + order=order, + label="Favorite color", + description="Pick a color", + question_type=question_type, + field_key=field_key, + config={ + "options": [ + {"id": "opt_1", "label": "Red", "archived": False, "next_section_id": None, "allow_other": False}, + {"id": "opt_2", "label": "Blue", "archived": False, "next_section_id": None, "allow_other": False}, + ] + }, + is_archived=False, + ) + defaults.update(overrides) + field = FormField(**defaults) + db.add(field) + db.flush() + return field + + +@pytest.fixture +def chapter(db): + university = make_university(db) + return make_chapter(db, university.id) + + +def _chapter_lead(db, chapter, email="chapterlead@test.com", password="LeadPass123!"): + user = make_user(db, email, password=password) + db.add(ChapterMembership(chapter_id=chapter.id, user_id=user.id, role="lead")) + db.commit() + return user + + +# --------------------------------------------------------------------------- +# Model-level CRUD — Form, FormField, FormResponse, FormAnswer, +# FormTournamentMembershipConfig, FormChapterMembershipConfig +# --------------------------------------------------------------------------- + +class TestModelCRUD: + def test_create_tournament_form(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + stored = db.query(Form).filter(Form.id == form.id).one() + assert stored.owner_type == "tournament" + assert stored.tournament_id == td_tournament.id + assert stored.chapter_id is None + assert stored.status == "draft" + + def test_create_chapter_form(self, db, td_user, chapter): + form = _make_chapter_form(db, td_user, chapter) + db.commit() + stored = db.query(Form).filter(Form.id == form.id).one() + assert stored.owner_type == "chapter" + assert stored.chapter_id == chapter.id + assert stored.tournament_id is None + + def test_owner_check_constraint_rejects_both_ids_set(self, db, td_user, td_tournament, chapter): + form = Form( + owner_type="tournament", + tournament_id=td_tournament.id, + chapter_id=chapter.id, + name="Bad form", + created_by=td_user.id, + ) + db.add(form) + with pytest.raises(IntegrityError): + db.flush() + db.rollback() + + def test_owner_check_constraint_rejects_neither_id_set(self, db, td_user): + form = Form(owner_type="tournament", name="Bad form", created_by=td_user.id) + db.add(form) + with pytest.raises(IntegrityError): + db.flush() + db.rollback() + + def test_field_key_is_required(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + with pytest.raises(ValueError): + FormField(form_id=form.id, order=1, label="No key", question_type="short_text", field_key=None) + + def test_field_key_must_be_alnum_underscore(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + with pytest.raises(ValueError): + FormField(form_id=form.id, order=1, label="Bad key", question_type="short_text", field_key="bad key!") + + def test_field_key_unique_within_form(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + _make_field(db, form, field_key="dup") + db.add(FormField(form_id=form.id, order=2, label="Second", question_type="short_text", field_key="dup")) + with pytest.raises(IntegrityError): + db.flush() + db.rollback() + + def test_create_response_and_answer(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form) + + response = FormResponse(form_id=form.id, user_id=td_user.id) + db.add(response) + db.flush() + + answer = FormAnswer(response_id=response.id, field_id=field.id, value=["opt_1"]) + db.add(answer) + db.commit() + + stored = db.query(FormAnswer).filter(FormAnswer.response_id == response.id).one() + assert stored.value == ["opt_1"] + + def test_response_unique_per_form_and_user(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.add(FormResponse(form_id=form.id, user_id=td_user.id)) + db.commit() + + db.add(FormResponse(form_id=form.id, user_id=td_user.id)) + with pytest.raises(IntegrityError): + db.flush() + db.rollback() + + def test_tournament_membership_config_round_trip(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament, creates_membership_on_submit=True) + db.add(FormTournamentMembershipConfig(form_id=form.id, status_on_submit="confirmed", role_ids_on_submit=[1, 2])) + db.commit() + db.refresh(form) + + assert form.tournament_membership_config.status_on_submit == "confirmed" + assert form.tournament_membership_config.role_ids_on_submit == [1, 2] + + def test_chapter_membership_config_round_trip(self, db, td_user, chapter): + form = _make_chapter_form(db, td_user, chapter, creates_membership_on_submit=True) + db.add(FormChapterMembershipConfig(form_id=form.id, role_on_submit="officer")) + db.commit() + db.refresh(form) + + assert form.chapter_membership_config.role_on_submit == "officer" + + def test_deleting_tournament_cascades_to_forms(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + form_id = form.id + + db.delete(td_tournament) + db.commit() + + assert db.query(Form).filter(Form.id == form_id).first() is None + + +# --------------------------------------------------------------------------- +# Field-editing helpers +# --------------------------------------------------------------------------- + +class TestFieldHelpers: + def test_remove_form_field_archives_when_answers_exist(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, order=1, field_key="favorite_color") + + response = FormResponse(form_id=form.id, user_id=td_user.id) + db.add(response) + db.flush() + + answer = FormAnswer(response_id=response.id, field_id=field.id, value=["opt_1"]) + db.add(answer) + db.flush() + + removed = remove_form_field(db, field) + + assert removed is True + db.refresh(field) + assert field.is_archived is True + assert db.query(FormAnswer).filter(FormAnswer.field_id == field.id).one().value == ["opt_1"] + + def test_remove_form_field_hard_deletes_when_no_answers(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="no_answers_yet") + + removed = remove_form_field(db, field) + + assert removed is False + assert db.query(FormField).filter(FormField.id == field.id).first() is None + + def test_replace_field_type_archives_old_field_and_keeps_order(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, order=7, field_key="tshirt_size") + + replacement = replace_field_type(db, field, "multi_select") + + db.refresh(field) + assert field.is_archived is True + assert field.field_key.endswith(f"_archived_{field.id}") + + assert replacement is not field + assert replacement.form_id == form.id + assert replacement.order == field.order + assert replacement.question_type == "multi_select" + assert replacement.field_key == "tshirt_size" + assert replacement.is_archived is False + + def test_remove_option_from_field_keeps_existing_answer_values(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, order=2, field_key="member_role") + + response = FormResponse(form_id=form.id, user_id=td_user.id) + db.add(response) + db.flush() + + answer = FormAnswer(response_id=response.id, field_id=field.id, value=["opt_1"]) + db.add(answer) + db.flush() + + updated = remove_option_from_field(db, field, "opt_1") + + assert updated is field + assert updated.config["options"][0]["archived"] is True + assert db.query(FormAnswer).filter(FormAnswer.field_id == field.id).one().value == ["opt_1"] + + +# --------------------------------------------------------------------------- +# slugify / field_key_taken_in_tournament +# --------------------------------------------------------------------------- + +class TestSlugifyAndUniqueness: + def test_slugify_lowercases_and_strips_punctuation(self): + assert slugify("Test Writing Interest!") == "test_writing_interest" + + def test_slugify_collapses_repeated_separators(self): + assert slugify(" a b--c__d ") == "a_b_c_d" + + def test_slugify_truncates_to_max_len(self): + assert len(slugify("x" * 100, max_len=10)) == 10 + + def test_field_key_taken_true_across_different_forms_in_same_tournament(self, db, td_user, td_tournament): + form_a = _make_form(db, td_user, td_tournament, name="Form A") + form_b = _make_form(db, td_user, td_tournament, name="Form B") + _make_field(db, form_a, field_key="shared") + db.commit() + + assert field_key_taken_in_tournament(db, td_tournament.id, "shared") is True + # form_b hasn't used the key itself, but it's still blocked tournament-wide + assert db.query(FormField).filter(FormField.form_id == form_b.id).count() == 0 + + def test_field_key_taken_false_for_different_tournament(self, db, td_user, td_tournament, other_user, other_tournament): + form = _make_form(db, td_user, td_tournament) + _make_field(db, form, field_key="only_here") + db.commit() + + assert field_key_taken_in_tournament(db, other_tournament.id, "only_here") is False + + def test_field_key_taken_true_when_archived(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + _make_field(db, form, field_key="was_used", is_archived=True) + db.commit() + + assert field_key_taken_in_tournament(db, td_tournament.id, "was_used") is True + + +# --------------------------------------------------------------------------- +# creates_membership_on_submit +# --------------------------------------------------------------------------- + +class TestMembershipOnSubmit: + def test_noop_when_flag_false(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament, creates_membership_on_submit=False) + db.commit() + new_user = make_user(db, "flagoff@test.com", password="Pass123!") + + create_membership_on_first_submit(db, form, new_user) + db.commit() + + assert db.query(TournamentMembership).filter(TournamentMembership.user_id == new_user.id).count() == 0 + + def test_tournament_new_member_gets_default_status_and_no_roles(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament, creates_membership_on_submit=True) + db.commit() + new_user = make_user(db, "newtdmember1@test.com", password="Pass123!") + + create_membership_on_first_submit(db, form, new_user) + db.commit() + + membership = db.query(TournamentMembership).filter( + TournamentMembership.user_id == new_user.id, + TournamentMembership.tournament_id == td_tournament.id, + ).one() + assert membership.status == "interested" + assert membership.source == "manual" + assert db.query(TournamentMembershipRole).filter(TournamentMembershipRole.membership_id == membership.id).count() == 0 + + def test_tournament_config_applies_status_and_roles(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament, creates_membership_on_submit=True) + role = TournamentRole(tournament_id=td_tournament.id, label="Custom Role", rank=99, permissions=[]) + db.add(role) + db.flush() + db.add(FormTournamentMembershipConfig(form_id=form.id, status_on_submit="confirmed", role_ids_on_submit=[role.id])) + db.flush() + new_user = make_user(db, "newtdmember2@test.com", password="Pass123!") + + create_membership_on_first_submit(db, form, new_user) + db.commit() + + membership = db.query(TournamentMembership).filter( + TournamentMembership.user_id == new_user.id, + TournamentMembership.tournament_id == td_tournament.id, + ).one() + assert membership.status == "confirmed" + + role_ids = [ + r.role_id + for r in db.query(TournamentMembershipRole).filter(TournamentMembershipRole.membership_id == membership.id) + ] + assert role_ids == [role.id] + + def test_tournament_skips_and_leaves_status_untouched_when_membership_exists(self, db, td_user, td_tournament): + # td_user already has a "confirmed" membership via the td_tournament fixture + form = _make_form(db, td_user, td_tournament, creates_membership_on_submit=True) + db.add(FormTournamentMembershipConfig(form_id=form.id, status_on_submit="interested")) + db.commit() + + create_membership_on_first_submit(db, form, td_user) + db.commit() + + memberships = db.query(TournamentMembership).filter( + TournamentMembership.user_id == td_user.id, + TournamentMembership.tournament_id == td_tournament.id, + ).all() + assert len(memberships) == 1 + assert memberships[0].status == "confirmed" # untouched, not reset to "interested" + + def test_chapter_new_member_gets_default_role(self, db, chapter): + lead = _chapter_lead(db, chapter) + form = _make_chapter_form(db, lead, chapter, creates_membership_on_submit=True) + db.commit() + new_user = make_user(db, "newchaptermember1@test.com", password="Pass123!") + + create_membership_on_first_submit(db, form, new_user) + db.commit() + + membership = db.query(ChapterMembership).filter(ChapterMembership.user_id == new_user.id).one() + assert membership.role == "member" + assert membership.chapter_id == chapter.id + + def test_chapter_config_applies_role(self, db, chapter): + lead = _chapter_lead(db, chapter) + form = _make_chapter_form(db, lead, chapter, creates_membership_on_submit=True) + db.add(FormChapterMembershipConfig(form_id=form.id, role_on_submit="officer")) + db.commit() + new_user = make_user(db, "newchaptermember2@test.com", password="Pass123!") + + create_membership_on_first_submit(db, form, new_user) + db.commit() + + membership = db.query(ChapterMembership).filter(ChapterMembership.user_id == new_user.id).one() + assert membership.role == "officer" + + def test_chapter_skips_if_user_already_in_a_different_chapter(self, db, chapter): + other_university = make_university(db) + other_chapter = make_chapter(db, other_university.id) + user = make_user(db, "alreadyelsewhere@test.com", password="Pass123!") + db.add(ChapterMembership(chapter_id=other_chapter.id, user_id=user.id, role="member")) + db.commit() + + lead = _chapter_lead(db, chapter) + form = _make_chapter_form(db, lead, chapter, creates_membership_on_submit=True) + db.commit() + + create_membership_on_first_submit(db, form, user) + db.commit() + + membership = db.query(ChapterMembership).filter(ChapterMembership.user_id == user.id).one() + assert membership.chapter_id == other_chapter.id # unchanged, no second row created + + +# --------------------------------------------------------------------------- +# require_form_manage_access / require_form_view_access +# --------------------------------------------------------------------------- + +class TestAccessDependencies: + def test_manage_access_tournament_manager_passes(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + result = require_form_manage_access(form.id, db, td_user) + assert result.id == form.id + + def test_manage_access_tournament_non_member_gets_404(self, db, td_user, td_tournament, other_user): + form = _make_form(db, td_user, td_tournament) + db.commit() + with pytest.raises(HTTPException) as exc_info: + require_form_manage_access(form.id, db, other_user) + assert exc_info.value.status_code == 404 + + def test_manage_access_tournament_member_without_permission_gets_403(self, db, td_user, td_tournament, other_user): + grant_role(db, td_tournament, other_user, "Runner") + form = _make_form(db, td_user, td_tournament) + db.commit() + with pytest.raises(HTTPException) as exc_info: + require_form_manage_access(form.id, db, other_user) + assert exc_info.value.status_code == 403 + + def test_manage_access_chapter_lead_passes(self, db, chapter): + lead = _chapter_lead(db, chapter) + form = _make_chapter_form(db, lead, chapter) + db.commit() + result = require_form_manage_access(form.id, db, lead) + assert result.id == form.id + + def test_manage_access_chapter_plain_member_gets_403(self, db, chapter): + lead = _chapter_lead(db, chapter) + form = _make_chapter_form(db, lead, chapter) + member = make_user(db, "plainaccess@test.com", password="Pass123!") + db.add(ChapterMembership(chapter_id=chapter.id, user_id=member.id, role="member")) + db.commit() + with pytest.raises(HTTPException) as exc_info: + require_form_manage_access(form.id, db, member) + assert exc_info.value.status_code == 403 + + def test_view_access_creates_membership_flag_bypasses_membership_requirement(self, db, td_user, td_tournament, other_user): + form = _make_form(db, td_user, td_tournament, creates_membership_on_submit=True) + db.commit() + # other_user has NO membership in td_tournament at all + result = require_form_view_access(form.id, db, other_user) + assert result.id == form.id + + def test_view_access_without_flag_requires_membership(self, db, td_user, td_tournament, other_user): + form = _make_form(db, td_user, td_tournament, creates_membership_on_submit=False) + db.commit() + with pytest.raises(HTTPException) as exc_info: + require_form_view_access(form.id, db, other_user) + assert exc_info.value.status_code == 403 + + def test_view_access_plain_member_passes_without_manage_permission(self, db, td_user, td_tournament, other_user): + grant_role(db, td_tournament, other_user, "Runner") + form = _make_form(db, td_user, td_tournament) + db.commit() + result = require_form_view_access(form.id, db, other_user) + assert result.id == form.id From 4717d9b01306d9249980cfbc7455ab727c0ababe Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 14:19:18 -0700 Subject: [PATCH 019/185] docs(forms): forms question types reference --- backend/form-question-types-reference.md | 134 +++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 backend/form-question-types-reference.md diff --git a/backend/form-question-types-reference.md b/backend/form-question-types-reference.md new file mode 100644 index 00000000..d56f8c11 --- /dev/null +++ b/backend/form-question-types-reference.md @@ -0,0 +1,134 @@ +# Form Question Types Reference + +Every `FormField` shares the same outer shape: + +```json +{ + "id": 15, + "form_id": 1, + "question_type": "single_select_radio", + "label": "...", + "description": null, + "field_key": "test_writing_interest", + "config": { }, + "order": 4, + "is_archived": false +} +``` + +`config` is type-specific — shapes below. + +**`field_key` is required on every field, no exceptions.** The TD types a normal-language label for how they want the question to show up on their dashboard (e.g. "Test Writing Interest") and it's slugified into `field_key` (lowercase, alphanumeric + underscores, e.g. `test_writing_interest`) — this is what the TD sees when scanning/filtering responses later, not just an internal id. Must be unique **per tournament** — across every `Form` that tournament owns, not just within one form — so creating a field checks existing `field_key`s across all of that tournament's forms, including archived fields (an archived key isn't freed for reuse, to keep historical dashboard references unambiguous). + +Reserved keys (`availability`, `lunch`, `event_preference`) are exact system-defined slugs. When a TD picks a reserved question type (e.g. `shift_select` for availability) from a preset/template, `field_key` should be locked to the reserved value rather than freely typed — otherwise a stray typo (`availibility`) silently breaks write-through with no error. Flagging this as the intended behavior, not yet confirmed. + +**Options-storage rule:** wherever a type has an `options` array, each option is `{ "value": ..., "label": ... }` — `label` is what's shown, `value` is what's actually stored in `FormAnswer` (or referenced by write-through). Options are stored raw and literal — a resolved snapshot at creation/edit time, not a dynamic source reference. Editors may offer an "auto-load from tournament" convenience (events, categories, shifts) that populates this array once; after that it's just a normal static list like any other question's options. `value` is the stable identifier for edit-lifecycle purposes (renaming `label` is a safe in-place edit; old answers referencing `value` still resolve) — for options backed by a real entity (a `TournamentShift`, `TournamentEvent`, etc.) `value` is that entity's real id. + +--- + +## `acknowledgment` +Single confirm checkbox, e.g. an age-verification notice. + +```json +"config": { "required": true, "confirm_label": "I understand" } +``` +Answer value: `true` (boolean; unanswered = not yet confirmed). +Branching: not supported. + +## `single_select_radio` +Pick exactly one, shown as radio buttons. + +```json +"config": { + "required": true, + "options": [ + { "value": "yes", "label": "Yes", "next_field_id": 15 }, + { "value": "no", "label": "No", "action": "submit_form" }, + { "value": "maybe", "label": "Maybe" } + ] +} +``` +Answer value: the chosen option's `value`. +Branching: supported — see Branching section below. + +## `single_select_dropdown` +Same shape and behavior as `single_select_radio`, rendered as a dropdown instead of radio buttons. Used when the option list is long. +Branching: supported, identical mechanics. + +## `multi_select_checkbox` +Pick any number, shown as checkboxes. + +```json +"config": { + "required": true, + "options": [ + { "value": "anat_physio", "label": "Anatomy and Physiology" }, + { "value": "disease_detectives", "label": "Disease Detectives" } + ] +} +``` +Answer value: array of chosen option `value`s. +Branching: not supported (not single-select). + +## `ranked_choice` +Rank a fixed number of options in order of preference. + +```json +"config": { + "required": true, + "ranks": 3, + "allow_duplicates": false, + "options": [ + { "value": "te_anat_physio", "label": "Anatomy and Physiology" }, + { "value": "te_disease_detectives", "label": "Disease Detectives" } + ] +} +``` +Answer value: dict of rank → option `value`, e.g. `{"1": "te_anat_physio", "2": "te_disease_detectives"}`. +Branching: not supported. +Typical use: `field_key = "event_preference"`. + +## `shift_select` +Pick from a TD-defined set of time windows. Options reference real `TournamentShift` rows (auto-loadable from the tournament's shift catalog), not free-typed ranges. + +```json +"config": { + "required": false, + "options": [ + { "value": "1", "label": "Saturday, February 13, 2027" }, + { "value": "2", "label": "Saturday, February 20, 2027" } + ] +} +``` +Answer value: array of chosen `TournamentShift.id`s (the `value`s above). +Branching: not supported. +**Reserved:** this is the only question type allowed for `field_key = "availability"`. On submit, the answer write-throughs into `MembershipAvailability` (diffed against the prior submission) instead of being stored in `FormAnswer`. + +## `short_text` / `long_text` +Free text — `short_text` single line, `long_text` multi-line. + +```json +"config": { "required": false, "max_length": 500 } +``` +Answer value: string. +Branching: not supported. + +--- + +## Branching + +Only `single_select_radio` and `single_select_dropdown` options may carry branching config: +- `next_field_id` — jump straight to that field, skipping everything in between. +- `action: "submit_form"` — end the flow immediately and submit whatever's been answered. +- Neither present — fall through to the next field in document `order` (the default case). + +`next_field_id`/`action` are mutually exclusive per option, and `next_field_id` must reference an existing field in the same form. Next-field computation happens **client-side** — the frontend fetches the full field list once and walks the jump graph locally, no per-answer round trip. Multi-field loops (A→B→A) aren't currently guarded against — deferred until it's a real problem. + +## Reserved `field_key`s + +| `field_key` | Allowed `question_type`(s) | Write-through | +|---|---|---| +| `availability` | `shift_select` only | `MembershipAvailability` | +| `lunch` | single/multi-select (config shape still open — depends on `TournamentLunchOption` category/`allow_multiple` mapping, not yet designed for Forms) | `MembershipLunchSelection` | +| `event_preference` | `ranked_choice`, `multi_select_checkbox`, or `single_select_dropdown` | none — generic `FormAnswer` | +| any TD-typed slug | any type | none — generic `FormAnswer` | From 2c5bfd0de5afe3fec54271d8d68090474679c86d Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 15:01:56 -0700 Subject: [PATCH 020/185] feat(forms): validate field config shape and shift_select options per question_type --- backend/app/api/routes/forms.py | 22 +++++ backend/app/core/form/validation.py | 125 ++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 backend/app/core/form/validation.py diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index d4fdd9e0..5fd472f8 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -16,6 +16,11 @@ ) from app.core.form.membership import create_membership_on_first_submit from app.core.form.permissions import require_form_manage_access, require_form_view_access +from app.core.form.validation import ( + FormFieldValidationError, + validate_field_config, + validate_shift_select_options, +) from app.core.tournament.permissions import MANAGE_FORMS, require_permission from app.db.session import get_db from app.models.models import ( @@ -275,6 +280,13 @@ def create_form_field( detail=f"field_key '{field_key}' is already in use on this form — pick a more distinct label", ) + try: + validate_field_config(field_in.question_type, field_in.config) + if field_in.question_type == "shift_select": + validate_shift_select_options(db, form.tournament_id, field_in.config or {}) + except FormFieldValidationError as e: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) + order = field_in.order if order is None: max_order = db.query(func.max(FormField.order)).filter(FormField.form_id == form.id).scalar() @@ -310,6 +322,16 @@ def edit_form_field( if not field: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Field not found") + final_question_type = field_in.question_type if field_in.question_type is not None else field.question_type + final_config = field_in.config if field_in.config is not None else field.config + + try: + validate_field_config(final_question_type, final_config) + if final_question_type == "shift_select": + validate_shift_select_options(db, form.tournament_id, final_config or {}) + except FormFieldValidationError as e: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) + if field_in.question_type is not None and field_in.question_type != field.question_type: field = replace_field_type(db, field, field_in.question_type) diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py new file mode 100644 index 00000000..c29f9b16 --- /dev/null +++ b/backend/app/core/form/validation.py @@ -0,0 +1,125 @@ +"""Per-question_type config/options validation for FormField — see +backend/form-question-types-reference.md for the shapes enforced here.""" + +from sqlalchemy.orm import Session + +from app.models.models import TournamentShift + + +class FormFieldValidationError(ValueError): + """Raised when a FormField's question_type/config/options don't match + the shape form-question-types-reference.md requires.""" + + +QUESTION_TYPES_WITH_OPTIONS = { + "single_select_radio", + "single_select_dropdown", + "multi_select_checkbox", + "ranked_choice", + "shift_select", +} + +ALL_QUESTION_TYPES = QUESTION_TYPES_WITH_OPTIONS | { + "acknowledgment", + "short_text", + "long_text", +} + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise FormFieldValidationError(message) + + +def _validate_options_list(config: dict) -> list[dict]: + options = config.get("options") + _require(isinstance(options, list), "config.options must be a list") + + seen_values = set() + for option in options: + _require(isinstance(option, dict), "each option must be an object") + value = option.get("value") + label = option.get("label") + _require(isinstance(value, str) and value != "", "each option needs a non-empty string 'value'") + _require(isinstance(label, str) and label != "", "each option needs a non-empty string 'label'") + _require(value not in seen_values, f"duplicate option value '{value}'") + seen_values.add(value) + + return options + + +def validate_field_config(question_type: str, config: dict | None) -> None: + """Validate that `config` matches the shape `question_type` requires. + Raises FormFieldValidationError on any mismatch.""" + _require(question_type in ALL_QUESTION_TYPES, f"unknown question_type '{question_type}'") + + config = config or {} + _require(isinstance(config, dict), "config must be an object") + _require(isinstance(config.get("required"), bool), "config.required must be a boolean") + + if question_type == "acknowledgment": + confirm_label = config.get("confirm_label") + _require( + isinstance(confirm_label, str) and confirm_label != "", + "config.confirm_label must be a non-empty string", + ) + + elif question_type in ("single_select_radio", "single_select_dropdown", "multi_select_checkbox"): + _validate_options_list(config) + + elif question_type == "ranked_choice": + ranks = config.get("ranks") + _require( + isinstance(ranks, int) and not isinstance(ranks, bool) and ranks > 0, + "config.ranks must be a positive integer", + ) + _require(isinstance(config.get("allow_duplicates"), bool), "config.allow_duplicates must be a boolean") + options = _validate_options_list(config) + _require(ranks <= len(options), "config.ranks cannot exceed the number of options") + + elif question_type == "shift_select": + _validate_options_list(config) + + elif question_type in ("short_text", "long_text"): + max_length = config.get("max_length") + _require( + isinstance(max_length, int) and not isinstance(max_length, bool) and max_length > 0, + "config.max_length must be a positive integer", + ) + + +def validate_shift_select_options(db: Session, tournament_id: int | None, config: dict) -> None: + """shift_select option values must reference a real TournamentShift + belonging to the field's own tournament — validated strictly since a + bad value directly corrupts MembershipAvailability write-through. + + Chapter-owned forms have no tournament shift catalog to validate + against, so this is a no-op there (a chapter-owned availability field + is valid but never write-throughs — see form-question-types-reference.md).""" + if tournament_id is None: + return + + options = config.get("options") or [] + if not options: + return + + shift_ids = set() + for option in options: + value = option.get("value") + _require( + value is not None and str(value).isdigit(), + f"shift_select option value '{value}' must be a TournamentShift id", + ) + shift_ids.add(int(value)) + + valid_ids = { + shift_id + for (shift_id,) in db.query(TournamentShift.id) + .filter(TournamentShift.tournament_id == tournament_id, TournamentShift.id.in_(shift_ids)) + .all() + } + missing = shift_ids - valid_ids + _require( + not missing, + f"shift_select option value(s) do not reference a real TournamentShift on this tournament: {sorted(missing)}", + ) From 2cd7152ce11327242b8246e319047083c086417b Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 15:06:28 -0700 Subject: [PATCH 021/185] fix(forms): key availability write-through validation off field_key, drop shift_select type --- backend/app/api/routes/forms.py | 10 ++++---- backend/app/core/form/validation.py | 17 ++++++-------- backend/form-question-types-reference.md | 29 ++++++++---------------- 3 files changed, 21 insertions(+), 35 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 5fd472f8..dacce6bb 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -18,8 +18,8 @@ from app.core.form.permissions import require_form_manage_access, require_form_view_access from app.core.form.validation import ( FormFieldValidationError, + validate_availability_options, validate_field_config, - validate_shift_select_options, ) from app.core.tournament.permissions import MANAGE_FORMS, require_permission from app.db.session import get_db @@ -282,8 +282,8 @@ def create_form_field( try: validate_field_config(field_in.question_type, field_in.config) - if field_in.question_type == "shift_select": - validate_shift_select_options(db, form.tournament_id, field_in.config or {}) + if field_key == "availability" and field_in.question_type == "multi_select_checkbox": + validate_availability_options(db, form.tournament_id, field_in.config or {}) except FormFieldValidationError as e: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) @@ -327,8 +327,8 @@ def edit_form_field( try: validate_field_config(final_question_type, final_config) - if final_question_type == "shift_select": - validate_shift_select_options(db, form.tournament_id, final_config or {}) + if field.field_key == "availability" and final_question_type == "multi_select_checkbox": + validate_availability_options(db, form.tournament_id, final_config or {}) except FormFieldValidationError as e: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index c29f9b16..18ec4ace 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -16,7 +16,6 @@ class FormFieldValidationError(ValueError): "single_select_dropdown", "multi_select_checkbox", "ranked_choice", - "shift_select", } ALL_QUESTION_TYPES = QUESTION_TYPES_WITH_OPTIONS | { @@ -77,9 +76,6 @@ def validate_field_config(question_type: str, config: dict | None) -> None: options = _validate_options_list(config) _require(ranks <= len(options), "config.ranks cannot exceed the number of options") - elif question_type == "shift_select": - _validate_options_list(config) - elif question_type in ("short_text", "long_text"): max_length = config.get("max_length") _require( @@ -88,10 +84,11 @@ def validate_field_config(question_type: str, config: dict | None) -> None: ) -def validate_shift_select_options(db: Session, tournament_id: int | None, config: dict) -> None: - """shift_select option values must reference a real TournamentShift - belonging to the field's own tournament — validated strictly since a - bad value directly corrupts MembershipAvailability write-through. +def validate_availability_options(db: Session, tournament_id: int | None, config: dict) -> None: + """A `multi_select_checkbox` field with field_key = "availability" must + have every option's `value` reference a real TournamentShift belonging + to the field's own tournament — validated strictly since a bad value + directly corrupts MembershipAvailability write-through. Chapter-owned forms have no tournament shift catalog to validate against, so this is a no-op there (a chapter-owned availability field @@ -108,7 +105,7 @@ def validate_shift_select_options(db: Session, tournament_id: int | None, config value = option.get("value") _require( value is not None and str(value).isdigit(), - f"shift_select option value '{value}' must be a TournamentShift id", + f"availability option value '{value}' must be a TournamentShift id", ) shift_ids.add(int(value)) @@ -121,5 +118,5 @@ def validate_shift_select_options(db: Session, tournament_id: int | None, config missing = shift_ids - valid_ids _require( not missing, - f"shift_select option value(s) do not reference a real TournamentShift on this tournament: {sorted(missing)}", + f"availability option value(s) do not reference a real TournamentShift on this tournament: {sorted(missing)}", ) diff --git a/backend/form-question-types-reference.md b/backend/form-question-types-reference.md index d56f8c11..3e4f18f9 100644 --- a/backend/form-question-types-reference.md +++ b/backend/form-question-types-reference.md @@ -20,7 +20,7 @@ Every `FormField` shares the same outer shape: **`field_key` is required on every field, no exceptions.** The TD types a normal-language label for how they want the question to show up on their dashboard (e.g. "Test Writing Interest") and it's slugified into `field_key` (lowercase, alphanumeric + underscores, e.g. `test_writing_interest`) — this is what the TD sees when scanning/filtering responses later, not just an internal id. Must be unique **per tournament** — across every `Form` that tournament owns, not just within one form — so creating a field checks existing `field_key`s across all of that tournament's forms, including archived fields (an archived key isn't freed for reuse, to keep historical dashboard references unambiguous). -Reserved keys (`availability`, `lunch`, `event_preference`) are exact system-defined slugs. When a TD picks a reserved question type (e.g. `shift_select` for availability) from a preset/template, `field_key` should be locked to the reserved value rather than freely typed — otherwise a stray typo (`availibility`) silently breaks write-through with no error. Flagging this as the intended behavior, not yet confirmed. +**Line between `question_type` and `field_key`:** `question_type` is purely structural — how the question is rendered and answered. `field_key` is semantic — when it's a reserved key (`availability`, `event_preference`, `lunch_{custom}`), it changes how a *structurally normal* field's options/answers get parsed and, for tournament forms, written through to a structural table. Reserved keys don't get their own `question_type` — they reuse the existing structural types and layer extra validation on top. When a TD picks a reserved-key preset/template, `field_key` should be locked to the reserved value rather than freely typed — otherwise a stray typo (`availibility`) silently breaks write-through with no error. Flagging this as the intended behavior, not yet confirmed. **Options-storage rule:** wherever a type has an `options` array, each option is `{ "value": ..., "label": ... }` — `label` is what's shown, `value` is what's actually stored in `FormAnswer` (or referenced by write-through). Options are stored raw and literal — a resolved snapshot at creation/edit time, not a dynamic source reference. Editors may offer an "auto-load from tournament" convenience (events, categories, shifts) that populates this array once; after that it's just a normal static list like any other question's options. `value` is the stable identifier for edit-lifecycle purposes (renaming `label` is a safe in-place edit; old answers referencing `value` still resolve) — for options backed by a real entity (a `TournamentShift`, `TournamentEvent`, etc.) `value` is that entity's real id. @@ -70,6 +70,8 @@ Pick any number, shown as checkboxes. Answer value: array of chosen option `value`s. Branching: not supported (not single-select). +**Reserved-key note:** when `field_key = "availability"`, this is the required `question_type`, and `value` on each option must resolve to a real `TournamentShift` belonging to the field's tournament (auto-loadable from the tournament's shift catalog, not free-typed). On submit, the answer write-throughs into `MembershipAvailability` (diffed against the prior submission) instead of being stored in `FormAnswer` — this only fires on tournament-owned forms; on a chapter-owned form the same field is valid but stores as a normal `FormAnswer`, no write-through. + ## `ranked_choice` Rank a fixed number of options in order of preference. @@ -86,23 +88,8 @@ Rank a fixed number of options in order of preference. ``` Answer value: dict of rank → option `value`, e.g. `{"1": "te_anat_physio", "2": "te_disease_detectives"}`. Branching: not supported. -Typical use: `field_key = "event_preference"`. - -## `shift_select` -Pick from a TD-defined set of time windows. Options reference real `TournamentShift` rows (auto-loadable from the tournament's shift catalog), not free-typed ranges. -```json -"config": { - "required": false, - "options": [ - { "value": "1", "label": "Saturday, February 13, 2027" }, - { "value": "2", "label": "Saturday, February 20, 2027" } - ] -} -``` -Answer value: array of chosen `TournamentShift.id`s (the `value`s above). -Branching: not supported. -**Reserved:** this is the only question type allowed for `field_key = "availability"`. On submit, the answer write-throughs into `MembershipAvailability` (diffed against the prior submission) instead of being stored in `FormAnswer`. +**Reserved-key note:** `event_preference` is allowed on this type, `multi_select_checkbox`, or `single_select_dropdown`. When it's used, `value` needs to be the real `TournamentEvent` id so it can be matched back to the tournament's actual events — this strict resolution isn't validated yet, it's tied to a future "auto-load events into options" feature, not this phase. ## `short_text` / `long_text` Free text — `short_text` single line, `long_text` multi-line. @@ -128,7 +115,9 @@ Only `single_select_radio` and `single_select_dropdown` options may carry branch | `field_key` | Allowed `question_type`(s) | Write-through | |---|---|---| -| `availability` | `shift_select` only | `MembershipAvailability` | -| `lunch` | single/multi-select (config shape still open — depends on `TournamentLunchOption` category/`allow_multiple` mapping, not yet designed for Forms) | `MembershipLunchSelection` | -| `event_preference` | `ranked_choice`, `multi_select_checkbox`, or `single_select_dropdown` | none — generic `FormAnswer` | +| `availability` | `multi_select_checkbox` only | `MembershipAvailability` (tournament-owned forms only) | +| `lunch_{custom}` — TD fills in `{custom}` per lunch question (e.g. `lunch_protein`, `lunch_drink`), one per `TournamentLunchOption` category | single/multi-select depending on the category's `allow_multiple` (config shape still open, discussed in the write-through issue) | `MembershipLunchSelection` (tournament-owned forms only) | +| `event_preference` | `ranked_choice`, `multi_select_checkbox`, or `single_select_dropdown` | none — generic `FormAnswer` (option `value` should be a real `TournamentEvent` id, not yet strictly validated) | | any TD-typed slug | any type | none — generic `FormAnswer` | + +Reserved keys are valid on both tournament- and chapter-owned forms — the key itself doesn't require tournament ownership. Only the write-through step is tournament-only; on a chapter-owned form these fields behave exactly like a normal custom question. From a69815afd33de9f7d8f098f40d5c504d2baa8025 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 16:09:59 -0700 Subject: [PATCH 022/185] feat(forms): enforce reserved field_key question_type pairing, drop dead presets.py, align routes on payload naming --- backend/app/api/routes/forms.py | 111 ++++++++++++++-------------- backend/app/core/form/presets.py | 14 ---- backend/app/core/form/validation.py | 23 ++++++ 3 files changed, 80 insertions(+), 68 deletions(-) delete mode 100644 backend/app/core/form/presets.py diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index dacce6bb..ccbbee11 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -20,6 +20,7 @@ FormFieldValidationError, validate_availability_options, validate_field_config, + validate_reserved_field_key, ) from app.core.tournament.permissions import MANAGE_FORMS, require_permission from app.db.session import get_db @@ -58,33 +59,33 @@ ) def create_tournament_form( tournament_id: int, - form_in: FormCreate, + payload: FormCreate, db: Session = Depends(get_db), current_user: User = Depends(require_permission(MANAGE_FORMS)), ): - if form_in.owner_type != "tournament" or form_in.tournament_id != tournament_id: + if payload.owner_type != "tournament" or payload.tournament_id != tournament_id: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="owner_type must be 'tournament' and tournament_id must match the path", ) form = Form( - name=form_in.name, - description=form_in.description, + name=payload.name, + description=payload.description, owner_type="tournament", tournament_id=tournament_id, chapter_id=None, - creates_membership_on_submit=form_in.creates_membership_on_submit, + creates_membership_on_submit=payload.creates_membership_on_submit, created_by=current_user.id, ) db.add(form) db.flush() - if form_in.tournament_membership_config is not None: + if payload.tournament_membership_config is not None: db.add(FormTournamentMembershipConfig( form_id=form.id, - status_on_submit=form_in.tournament_membership_config.status_on_submit, - role_ids_on_submit=form_in.tournament_membership_config.role_ids_on_submit or None, + status_on_submit=payload.tournament_membership_config.status_on_submit, + role_ids_on_submit=payload.tournament_membership_config.role_ids_on_submit or None, )) db.commit() @@ -103,34 +104,34 @@ def create_tournament_form( ) def create_chapter_form( chapter_id: int, - form_in: FormCreate, + payload: FormCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): require_officer_or_lead(chapter_id, db, current_user) - if form_in.owner_type != "chapter" or form_in.chapter_id != chapter_id: + if payload.owner_type != "chapter" or payload.chapter_id != chapter_id: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="owner_type must be 'chapter' and chapter_id must match the path", ) form = Form( - name=form_in.name, - description=form_in.description, + name=payload.name, + description=payload.description, owner_type="chapter", tournament_id=None, chapter_id=chapter_id, - creates_membership_on_submit=form_in.creates_membership_on_submit, + creates_membership_on_submit=payload.creates_membership_on_submit, created_by=current_user.id, ) db.add(form) db.flush() - if form_in.chapter_membership_config is not None: + if payload.chapter_membership_config is not None: db.add(FormChapterMembershipConfig( form_id=form.id, - role_on_submit=form_in.chapter_membership_config.role_on_submit, + role_on_submit=payload.chapter_membership_config.role_on_submit, )) db.commit() @@ -174,33 +175,33 @@ def get_form_for_rendering( # --------------------------------------------------------------------------- @router.patch("/forms/{form_id}/", response_model=FormRead) def update_form( - form_in: FormUpdate, + payload: FormUpdate, db: Session = Depends(get_db), form: Form = Depends(require_form_manage_access), ): - if form_in.name is not None: - form.name = form_in.name - if form_in.description is not None: - form.description = form_in.description - if form_in.status is not None: - form.status = form_in.status - if form_in.creates_membership_on_submit is not None: - form.creates_membership_on_submit = form_in.creates_membership_on_submit - - if form_in.tournament_membership_config is not None and form.owner_type == "tournament": + if payload.name is not None: + form.name = payload.name + if payload.description is not None: + form.description = payload.description + if payload.status is not None: + form.status = payload.status + if payload.creates_membership_on_submit is not None: + form.creates_membership_on_submit = payload.creates_membership_on_submit + + if payload.tournament_membership_config is not None and form.owner_type == "tournament": config = form.tournament_membership_config if config is None: config = FormTournamentMembershipConfig(form_id=form.id) db.add(config) - config.status_on_submit = form_in.tournament_membership_config.status_on_submit - config.role_ids_on_submit = form_in.tournament_membership_config.role_ids_on_submit or None + config.status_on_submit = payload.tournament_membership_config.status_on_submit + config.role_ids_on_submit = payload.tournament_membership_config.role_ids_on_submit or None - if form_in.chapter_membership_config is not None and form.owner_type == "chapter": + if payload.chapter_membership_config is not None and form.owner_type == "chapter": config = form.chapter_membership_config if config is None: config = FormChapterMembershipConfig(form_id=form.id) db.add(config) - config.role_on_submit = form_in.chapter_membership_config.role_on_submit + config.role_on_submit = payload.chapter_membership_config.role_on_submit db.commit() db.refresh(form) @@ -256,11 +257,11 @@ def delete_form( # --------------------------------------------------------------------------- @router.post("/forms/{form_id}/fields/", response_model=FormFieldRead, status_code=status.HTTP_201_CREATED) def create_form_field( - field_in: FormFieldCreate, + payload: FormFieldCreate, db: Session = Depends(get_db), form: Form = Depends(require_form_manage_access), ): - field_key = slugify(field_in.field_key) + field_key = slugify(payload.field_key) if form.owner_type == "tournament": if field_key_taken_in_tournament(db, form.tournament_id, field_key): @@ -281,13 +282,14 @@ def create_form_field( ) try: - validate_field_config(field_in.question_type, field_in.config) - if field_key == "availability" and field_in.question_type == "multi_select_checkbox": - validate_availability_options(db, form.tournament_id, field_in.config or {}) + validate_field_config(payload.question_type, payload.config) + validate_reserved_field_key(field_key, payload.question_type) + if field_key == "availability" and payload.question_type == "multi_select_checkbox": + validate_availability_options(db, form.tournament_id, payload.config or {}) except FormFieldValidationError as e: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) - order = field_in.order + order = payload.order if order is None: max_order = db.query(func.max(FormField.order)).filter(FormField.form_id == form.id).scalar() order = (max_order or 0) + 1 @@ -295,11 +297,11 @@ def create_form_field( field = FormField( form_id=form.id, order=order, - label=field_in.label, - description=field_in.description, - question_type=field_in.question_type, + label=payload.label, + description=payload.description, + question_type=payload.question_type, field_key=field_key, - config=field_in.config, + config=payload.config, is_archived=False, ) db.add(field) @@ -314,7 +316,7 @@ def create_form_field( @router.patch("/forms/{form_id}/fields/{field_id}/", response_model=FormFieldRead) def edit_form_field( field_id: int, - field_in: FormFieldUpdate, + payload: FormFieldUpdate, db: Session = Depends(get_db), form: Form = Depends(require_form_manage_access), ): @@ -322,27 +324,28 @@ def edit_form_field( if not field: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Field not found") - final_question_type = field_in.question_type if field_in.question_type is not None else field.question_type - final_config = field_in.config if field_in.config is not None else field.config + final_question_type = payload.question_type if payload.question_type is not None else field.question_type + final_config = payload.config if payload.config is not None else field.config try: validate_field_config(final_question_type, final_config) + validate_reserved_field_key(field.field_key, final_question_type) if field.field_key == "availability" and final_question_type == "multi_select_checkbox": validate_availability_options(db, form.tournament_id, final_config or {}) except FormFieldValidationError as e: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) - if field_in.question_type is not None and field_in.question_type != field.question_type: - field = replace_field_type(db, field, field_in.question_type) + if payload.question_type is not None and payload.question_type != field.question_type: + field = replace_field_type(db, field, payload.question_type) - if field_in.label is not None or field_in.description is not None: - field = update_field_text(db, field, field_in.label, field_in.description) + if payload.label is not None or payload.description is not None: + field = update_field_text(db, field, payload.label, payload.description) - if field_in.order is not None: - field = reorder_field(db, field, field_in.order) + if payload.order is not None: + field = reorder_field(db, field, payload.order) - if field_in.config is not None: - field = set_field_config(db, field, field_in.config) + if payload.config is not None: + field = set_field_config(db, field, payload.config) return field @@ -379,12 +382,12 @@ def delete_or_archive_form_field( # --------------------------------------------------------------------------- @router.post("/forms/{form_id}/responses/", response_model=FormResponseRead) def submit_form_response( - response_in: FormResponseCreate, + payload: FormResponseCreate, db: Session = Depends(get_db), form: Form = Depends(require_form_view_access), current_user: User = Depends(get_current_user), ): - field_ids = [answer_in.field_id for answer_in in response_in.answers] + field_ids = [answer_in.field_id for answer_in in payload.answers] if field_ids: valid_field_ids = { field_id @@ -415,7 +418,7 @@ def submit_form_response( db.query(FormAnswer).filter(FormAnswer.response_id == response.id).delete() response.updated_at = utcnow() - for answer_in in response_in.answers: + for answer_in in payload.answers: db.add(FormAnswer(response_id=response.id, field_id=answer_in.field_id, value=answer_in.value)) if is_first_response: diff --git a/backend/app/core/form/presets.py b/backend/app/core/form/presets.py deleted file mode 100644 index a360ce7c..00000000 --- a/backend/app/core/form/presets.py +++ /dev/null @@ -1,14 +0,0 @@ -RESERVED_FIELD_KEYS: dict[str, dict] = { - "availability": { - "allowed_question_types": {"shift_select"}, - "write_through_target": "availability", - }, - "lunch": { - "allowed_question_types": {"single_select_radio", "single_select_dropdown", "multi_select"}, - "write_through_target": "lunch", - }, - "event_preference": { - "allowed_question_types": {"multi_select", "ranked_choice", "single_select_dropdown", "grid"}, - "write_through_target": None, - }, -} \ No newline at end of file diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index 18ec4ace..e3c59155 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -24,6 +24,14 @@ class FormFieldValidationError(ValueError): "long_text", } +# field_key values with a system-defined meaning. `lunch_{custom}` is also +# reserved (any key starting with "lunch_") but its config shape isn't +# designed yet, so it isn't enforced here — see form-question-types-reference.md. +RESERVED_FIELD_KEY_QUESTION_TYPES = { + "availability": {"multi_select_checkbox"}, + "event_preference": {"ranked_choice", "multi_select_checkbox", "single_select_dropdown"}, +} + def _require(condition: bool, message: str) -> None: if not condition: @@ -84,6 +92,21 @@ def validate_field_config(question_type: str, config: dict | None) -> None: ) +def validate_reserved_field_key(field_key: str, question_type: str) -> None: + """Reserved field_keys (availability, event_preference) reuse an + existing structural question_type rather than introducing their own — + reject a reserved key paired with a question_type it doesn't allow. + Applies identically regardless of owner_type (tournament vs. chapter); + only write-through, not validation, differs by ownership.""" + allowed_types = RESERVED_FIELD_KEY_QUESTION_TYPES.get(field_key) + if allowed_types is None: + return + _require( + question_type in allowed_types, + f"field_key '{field_key}' requires question_type in {sorted(allowed_types)}, got '{question_type}'", + ) + + def validate_availability_options(db: Session, tournament_id: int | None, config: dict) -> None: """A `multi_select_checkbox` field with field_key = "availability" must have every option's `value` reference a real TournamentShift belonging From 6a2c2817c2caddbb5becd24e5502a47596983ef3 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 16:16:45 -0700 Subject: [PATCH 023/185] feat(forms): add branching validation for single/multi-select option next_field_id/action --- backend/app/api/routes/forms.py | 3 ++ backend/app/core/form/validation.py | 59 ++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index ccbbee11..3c047141 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -19,6 +19,7 @@ from app.core.form.validation import ( FormFieldValidationError, validate_availability_options, + validate_branching_options, validate_field_config, validate_reserved_field_key, ) @@ -284,6 +285,7 @@ def create_form_field( try: validate_field_config(payload.question_type, payload.config) validate_reserved_field_key(field_key, payload.question_type) + validate_branching_options(db, form.id, payload.question_type, payload.config or {}) if field_key == "availability" and payload.question_type == "multi_select_checkbox": validate_availability_options(db, form.tournament_id, payload.config or {}) except FormFieldValidationError as e: @@ -330,6 +332,7 @@ def edit_form_field( try: validate_field_config(final_question_type, final_config) validate_reserved_field_key(field.field_key, final_question_type) + validate_branching_options(db, form.id, final_question_type, final_config or {}, field_id=field.id) if field.field_key == "availability" and final_question_type == "multi_select_checkbox": validate_availability_options(db, form.tournament_id, final_config or {}) except FormFieldValidationError as e: diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index e3c59155..81bac4fa 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import Session -from app.models.models import TournamentShift +from app.models.models import FormField, TournamentShift class FormFieldValidationError(ValueError): @@ -24,6 +24,8 @@ class FormFieldValidationError(ValueError): "long_text", } +BRANCHING_QUESTION_TYPES = {"single_select_radio", "single_select_dropdown"} + # field_key values with a system-defined meaning. `lunch_{custom}` is also # reserved (any key starting with "lunch_") but its config shape isn't # designed yet, so it isn't enforced here — see form-question-types-reference.md. @@ -107,6 +109,61 @@ def validate_reserved_field_key(field_key: str, question_type: str) -> None: ) +def validate_branching_options( + db: Session, + form_id: int, + question_type: str, + config: dict, + field_id: int | None = None, +) -> None: + """`next_field_id`/`action` on an option are only valid on + single_select_radio/single_select_dropdown fields. `field_id` is the + field being edited (None on create, since a new field has no id yet + for an option to self-reference).""" + options = config.get("options") or [] + + if question_type not in BRANCHING_QUESTION_TYPES: + for option in options: + _require( + "next_field_id" not in option and "action" not in option, + "next_field_id/action are only valid on single_select_radio/single_select_dropdown options", + ) + return + + next_field_ids = set() + for option in options: + next_field_id = option.get("next_field_id") + action = option.get("action") + _require( + next_field_id is None or action is None, + "an option cannot have both next_field_id and action", + ) + if action is not None: + _require(action == "submit_form", f"unknown option action '{action}'") + if next_field_id is not None: + _require( + isinstance(next_field_id, int) and not isinstance(next_field_id, bool), + "next_field_id must be an integer", + ) + _require(next_field_id != field_id, "an option cannot jump to the field it belongs to") + next_field_ids.add(next_field_id) + + if not next_field_ids: + return + + valid_ids = { + fid + for (fid,) in db.query(FormField.id) + .filter(FormField.form_id == form_id, FormField.id.in_(next_field_ids), FormField.is_archived == False) + .all() + } + missing = next_field_ids - valid_ids + _require( + not missing, + f"next_field_id(s) do not reference an existing, non-archived field in this form: {sorted(missing)}", + ) + + def validate_availability_options(db: Session, tournament_id: int | None, config: dict) -> None: """A `multi_select_checkbox` field with field_key = "availability" must have every option's `value` reference a real TournamentShift belonging From b6a09fae5167e1ada716eac1a328962c48f0c8e6 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 16:20:53 -0700 Subject: [PATCH 024/185] refactor(forms): back config validation with per-question_type pydantic schemas --- backend/app/api/routes/forms.py | 16 ++-- backend/app/core/form/validation.py | 136 ++++++++-------------------- backend/app/schemas/form.py | 107 +++++++++++++++++++++- 3 files changed, 154 insertions(+), 105 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 3c047141..5f2f47c8 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -283,11 +283,11 @@ def create_form_field( ) try: - validate_field_config(payload.question_type, payload.config) + normalized_config = validate_field_config(payload.question_type, payload.config) validate_reserved_field_key(field_key, payload.question_type) - validate_branching_options(db, form.id, payload.question_type, payload.config or {}) + validate_branching_options(db, form.id, payload.question_type, normalized_config) if field_key == "availability" and payload.question_type == "multi_select_checkbox": - validate_availability_options(db, form.tournament_id, payload.config or {}) + validate_availability_options(db, form.tournament_id, normalized_config) except FormFieldValidationError as e: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) @@ -303,7 +303,7 @@ def create_form_field( description=payload.description, question_type=payload.question_type, field_key=field_key, - config=payload.config, + config=normalized_config, is_archived=False, ) db.add(field) @@ -330,11 +330,11 @@ def edit_form_field( final_config = payload.config if payload.config is not None else field.config try: - validate_field_config(final_question_type, final_config) + normalized_config = validate_field_config(final_question_type, final_config) validate_reserved_field_key(field.field_key, final_question_type) - validate_branching_options(db, form.id, final_question_type, final_config or {}, field_id=field.id) + validate_branching_options(db, form.id, final_question_type, normalized_config, field_id=field.id) if field.field_key == "availability" and final_question_type == "multi_select_checkbox": - validate_availability_options(db, form.tournament_id, final_config or {}) + validate_availability_options(db, form.tournament_id, normalized_config) except FormFieldValidationError as e: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) @@ -348,7 +348,7 @@ def edit_form_field( field = reorder_field(db, field, payload.order) if payload.config is not None: - field = set_field_config(db, field, payload.config) + field = set_field_config(db, field, normalized_config) return field diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index 81bac4fa..d1df6e57 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -1,28 +1,19 @@ -"""Per-question_type config/options validation for FormField — see -backend/form-question-types-reference.md for the shapes enforced here.""" - +"""FormField config/options validation — see +backend/form-question-types-reference.md for the shapes enforced here. + +Structural shape (required keys, types, per-option uniqueness, ranks <= +options, branching mutual-exclusivity/type-restriction) lives in the +pydantic schemas at app/schemas/form.py (QUESTION_TYPE_CONFIG_SCHEMAS) — +validate_field_config below just dispatches to them. Everything here is +what a stateless schema can't check: DB-backed lookups (next_field_id +resolving to a real field, availability options resolving to a real +TournamentShift) and reserved field_key pairing.""" + +from pydantic import ValidationError from sqlalchemy.orm import Session from app.models.models import FormField, TournamentShift - - -class FormFieldValidationError(ValueError): - """Raised when a FormField's question_type/config/options don't match - the shape form-question-types-reference.md requires.""" - - -QUESTION_TYPES_WITH_OPTIONS = { - "single_select_radio", - "single_select_dropdown", - "multi_select_checkbox", - "ranked_choice", -} - -ALL_QUESTION_TYPES = QUESTION_TYPES_WITH_OPTIONS | { - "acknowledgment", - "short_text", - "long_text", -} +from app.schemas.form import QUESTION_TYPE_CONFIG_SCHEMAS BRANCHING_QUESTION_TYPES = {"single_select_radio", "single_select_dropdown"} @@ -35,63 +26,29 @@ class FormFieldValidationError(ValueError): } +class FormFieldValidationError(ValueError): + """Raised when a FormField's question_type/config/options don't match + the shape form-question-types-reference.md requires.""" + + def _require(condition: bool, message: str) -> None: if not condition: raise FormFieldValidationError(message) -def _validate_options_list(config: dict) -> list[dict]: - options = config.get("options") - _require(isinstance(options, list), "config.options must be a list") - - seen_values = set() - for option in options: - _require(isinstance(option, dict), "each option must be an object") - value = option.get("value") - label = option.get("label") - _require(isinstance(value, str) and value != "", "each option needs a non-empty string 'value'") - _require(isinstance(label, str) and label != "", "each option needs a non-empty string 'label'") - _require(value not in seen_values, f"duplicate option value '{value}'") - seen_values.add(value) - - return options - - -def validate_field_config(question_type: str, config: dict | None) -> None: - """Validate that `config` matches the shape `question_type` requires. +def validate_field_config(question_type: str, config: dict | None) -> dict: + """Validate `config` against question_type's pydantic schema and + return the normalized dict (unknown keys stripped, values coerced). Raises FormFieldValidationError on any mismatch.""" - _require(question_type in ALL_QUESTION_TYPES, f"unknown question_type '{question_type}'") - - config = config or {} - _require(isinstance(config, dict), "config must be an object") - _require(isinstance(config.get("required"), bool), "config.required must be a boolean") - - if question_type == "acknowledgment": - confirm_label = config.get("confirm_label") - _require( - isinstance(confirm_label, str) and confirm_label != "", - "config.confirm_label must be a non-empty string", - ) - - elif question_type in ("single_select_radio", "single_select_dropdown", "multi_select_checkbox"): - _validate_options_list(config) + schema_cls = QUESTION_TYPE_CONFIG_SCHEMAS.get(question_type) + _require(schema_cls is not None, f"unknown question_type '{question_type}'") - elif question_type == "ranked_choice": - ranks = config.get("ranks") - _require( - isinstance(ranks, int) and not isinstance(ranks, bool) and ranks > 0, - "config.ranks must be a positive integer", - ) - _require(isinstance(config.get("allow_duplicates"), bool), "config.allow_duplicates must be a boolean") - options = _validate_options_list(config) - _require(ranks <= len(options), "config.ranks cannot exceed the number of options") + try: + parsed = schema_cls.model_validate(config or {}) + except ValidationError as e: + raise FormFieldValidationError(str(e)) - elif question_type in ("short_text", "long_text"): - max_length = config.get("max_length") - _require( - isinstance(max_length, int) and not isinstance(max_length, bool) and max_length > 0, - "config.max_length must be a positive integer", - ) + return parsed.model_dump() def validate_reserved_field_key(field_key: str, question_type: str) -> None: @@ -116,37 +73,24 @@ def validate_branching_options( config: dict, field_id: int | None = None, ) -> None: - """`next_field_id`/`action` on an option are only valid on - single_select_radio/single_select_dropdown fields. `field_id` is the - field being edited (None on create, since a new field has no id yet - for an option to self-reference).""" - options = config.get("options") or [] - + """`next_field_id` must reference an existing, non-archived field in the + same form and can't equal the field the option belongs to. (Mutual + exclusivity with `action` and the single_select-only restriction are + already enforced by the config's pydantic schema — see + QUESTION_TYPE_CONFIG_SCHEMAS — so this only covers what needs the DB.) + `field_id` is the field being edited (None on create — a new field has + no id yet for an option to self-reference).""" if question_type not in BRANCHING_QUESTION_TYPES: - for option in options: - _require( - "next_field_id" not in option and "action" not in option, - "next_field_id/action are only valid on single_select_radio/single_select_dropdown options", - ) return + options = config.get("options") or [] next_field_ids = set() for option in options: next_field_id = option.get("next_field_id") - action = option.get("action") - _require( - next_field_id is None or action is None, - "an option cannot have both next_field_id and action", - ) - if action is not None: - _require(action == "submit_form", f"unknown option action '{action}'") - if next_field_id is not None: - _require( - isinstance(next_field_id, int) and not isinstance(next_field_id, bool), - "next_field_id must be an integer", - ) - _require(next_field_id != field_id, "an option cannot jump to the field it belongs to") - next_field_ids.add(next_field_id) + if next_field_id is None: + continue + _require(next_field_id != field_id, "an option cannot jump to the field it belongs to") + next_field_ids.add(next_field_id) if not next_field_ids: return diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index 37ffb9c8..dc24163d 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -1,6 +1,111 @@ from datetime import datetime from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +# --------------------------------------------------------------------------- +# FormField.config schemas — one per question_type, shape enforced per +# form-question-types-reference.md. These cover structural shape only +# (required keys, types, per-option uniqueness, ranks <= options); DB-backed +# checks (next_field_id resolving to a real field, availability options +# resolving to a real TournamentShift, reserved field_key pairing) stay in +# app/core/form/validation.py since they need a Session, not just the dict. +# --------------------------------------------------------------------------- + +def _unique_option_values(options: list) -> list: + seen = set() + for option in options: + if option.value in seen: + raise ValueError(f"duplicate option value '{option.value}'") + seen.add(option.value) + return options + + +class PlainOption(BaseModel): + """An option with no branching — multi_select_checkbox, ranked_choice. + extra='forbid' rejects a stray next_field_id/action on these types.""" + model_config = ConfigDict(extra="forbid") + value: str = Field(min_length=1) + label: str = Field(min_length=1) + + +class BranchingOption(BaseModel): + """An option that may carry branching — single_select_radio/dropdown only.""" + model_config = ConfigDict(extra="forbid") + value: str = Field(min_length=1) + label: str = Field(min_length=1) + next_field_id: int | None = None + action: Literal["submit_form"] | None = None + + @model_validator(mode="after") + def _mutually_exclusive(self): + if self.next_field_id is not None and self.action is not None: + raise ValueError("an option cannot have both next_field_id and action") + return self + + +class AcknowledgmentConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + required: bool + confirm_label: str = Field(min_length=1) + + +class SingleSelectConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + required: bool + options: list[BranchingOption] + + @field_validator("options") + @classmethod + def _unique_values(cls, options: list[BranchingOption]) -> list[BranchingOption]: + return _unique_option_values(options) + + +class MultiSelectCheckboxConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + required: bool + options: list[PlainOption] + + @field_validator("options") + @classmethod + def _unique_values(cls, options: list[PlainOption]) -> list[PlainOption]: + return _unique_option_values(options) + + +class RankedChoiceConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + required: bool + ranks: int = Field(gt=0) + allow_duplicates: bool + options: list[PlainOption] + + @field_validator("options") + @classmethod + def _unique_values(cls, options: list[PlainOption]) -> list[PlainOption]: + return _unique_option_values(options) + + @model_validator(mode="after") + def _ranks_within_options(self): + if self.ranks > len(self.options): + raise ValueError("ranks cannot exceed the number of options") + return self + + +class TextConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + required: bool + max_length: int = Field(gt=0) + + +QUESTION_TYPE_CONFIG_SCHEMAS: dict[str, type[BaseModel]] = { + "acknowledgment": AcknowledgmentConfig, + "single_select_radio": SingleSelectConfig, + "single_select_dropdown": SingleSelectConfig, + "multi_select_checkbox": MultiSelectCheckboxConfig, + "ranked_choice": RankedChoiceConfig, + "short_text": TextConfig, + "long_text": TextConfig, +} + # --------------------------------------------------------------------------- # Form Field Schemas From 6afdc5eddcb4db8b01ad8b76bfd5393b9cb8faea Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 16:27:40 -0700 Subject: [PATCH 025/185] feat(forms): validate whole form on publish/republish, reject empty forms and dangling branches --- backend/app/api/routes/forms.py | 7 +++++ backend/app/core/form/validation.py | 47 ++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 5f2f47c8..d7986675 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -21,6 +21,7 @@ validate_availability_options, validate_branching_options, validate_field_config, + validate_form_for_publish, validate_reserved_field_key, ) from app.core.tournament.permissions import MANAGE_FORMS, require_permission @@ -180,6 +181,12 @@ def update_form( db: Session = Depends(get_db), form: Form = Depends(require_form_manage_access), ): + if payload.status == "published": + try: + validate_form_for_publish(db, form) + except FormFieldValidationError as e: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) + if payload.name is not None: form.name = payload.name if payload.description is not None: diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index d1df6e57..af5e8e3f 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -12,7 +12,7 @@ from pydantic import ValidationError from sqlalchemy.orm import Session -from app.models.models import FormField, TournamentShift +from app.models.models import Form, FormField, TournamentShift from app.schemas.form import QUESTION_TYPE_CONFIG_SCHEMAS BRANCHING_QUESTION_TYPES = {"single_select_radio", "single_select_dropdown"} @@ -108,6 +108,51 @@ def validate_branching_options( ) +def validate_form_for_publish(db: Session, form: Form) -> None: + """Aggregate pass run on every draft->published transition and every + explicit republish while already published. Per-field validation on + create/update can't catch problems that only exist in aggregate — a + form with zero fields, or a next_field_id left dangling after some + other field got archived later — so this re-runs every check across + the whole active field set. Collects every problem found instead of + stopping at the first, so a TD sees the full list in one pass.""" + fields = ( + db.query(FormField) + .filter(FormField.form_id == form.id, FormField.is_archived == False) + .all() + ) + + errors: list[str] = [] + if not fields: + errors.append("form has no fields") + + for field in fields: + try: + normalized_config = validate_field_config(field.question_type, field.config) + except FormFieldValidationError as e: + errors.append(f"field '{field.field_key}': {e}") + continue + + for check in ( + lambda: validate_reserved_field_key(field.field_key, field.question_type), + lambda: validate_branching_options( + db, form.id, field.question_type, normalized_config, field_id=field.id + ), + ): + try: + check() + except FormFieldValidationError as e: + errors.append(f"field '{field.field_key}': {e}") + + if field.field_key == "availability" and field.question_type == "multi_select_checkbox": + try: + validate_availability_options(db, form.tournament_id, normalized_config) + except FormFieldValidationError as e: + errors.append(f"field '{field.field_key}': {e}") + + _require(not errors, "; ".join(errors)) + + def validate_availability_options(db: Session, tournament_id: int | None, config: dict) -> None: """A `multi_select_checkbox` field with field_key = "availability" must have every option's `value` reference a real TournamentShift belonging From 3d490bf95c128f9f700b4e9497314c9efbeb2bb4 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 16:36:30 -0700 Subject: [PATCH 026/185] feat(forms): replay branching graph at submission to enforce required only on reachable fields --- backend/app/api/routes/forms.py | 37 ++++++++------- backend/app/core/form/branching.py | 72 ++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 15 deletions(-) create mode 100644 backend/app/core/form/branching.py diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index d7986675..1b228534 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -14,6 +14,7 @@ slugify, update_field_text, ) +from app.core.form.branching import missing_required_field_keys from app.core.form.membership import create_membership_on_first_submit from app.core.form.permissions import require_form_manage_access, require_form_view_access from app.core.form.validation import ( @@ -397,22 +398,28 @@ def submit_form_response( form: Form = Depends(require_form_view_access), current_user: User = Depends(get_current_user), ): + active_fields = ( + db.query(FormField) + .filter(FormField.form_id == form.id, FormField.is_archived == False) + .all() + ) + valid_field_ids = {field.id for field in active_fields} + field_ids = [answer_in.field_id for answer_in in payload.answers] - if field_ids: - valid_field_ids = { - field_id - for (field_id,) in db.query(FormField.id).filter( - FormField.id.in_(field_ids), - FormField.form_id == form.id, - FormField.is_archived == False, - ).all() - } - invalid_field_ids = set(field_ids) - valid_field_ids - if invalid_field_ids: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid field_id(s) for this form: {sorted(invalid_field_ids)}", - ) + invalid_field_ids = set(field_ids) - valid_field_ids + if invalid_field_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid field_id(s) for this form: {sorted(invalid_field_ids)}", + ) + + answers_by_field = {answer_in.field_id: answer_in.value for answer_in in payload.answers} + missing_required = missing_required_field_keys(active_fields, answers_by_field) + if missing_required: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Missing required field(s): {sorted(missing_required)}", + ) response = ( db.query(FormResponse) diff --git a/backend/app/core/form/branching.py b/backend/app/core/form/branching.py new file mode 100644 index 00000000..21817739 --- /dev/null +++ b/backend/app/core/form/branching.py @@ -0,0 +1,72 @@ +"""Server-side branching replay for form submissions — mirrors the +client-side jump-graph walk (see form-question-types-reference.md) so +`required` is enforced only on fields the respondent could actually reach, +independent of whatever the client computed.""" + +from typing import Any + +from app.core.form.validation import BRANCHING_QUESTION_TYPES +from app.models.models import FormField + + +def compute_reachable_field_ids(fields: list[FormField], answers: dict[int, Any]) -> set[int]: + """`fields` must be every non-archived field on the form, any order. + `answers` maps field_id -> submitted value. Walks from the + lowest-`order` field, following each branching option's + `next_field_id`/`action` for the field's submitted answer, falling + through to the next field by `order` otherwise. Returns the set of + field ids visited. A revisited field (A->B->A) ends the walk rather + than looping forever — see form-question-types-reference.md, cycles + aren't guarded against upstream, so this just has to not hang.""" + if not fields: + return set() + + by_order = sorted(fields, key=lambda f: f.order) + by_id = {f.id: f for f in fields} + + reachable: set[int] = set() + current: FormField | None = by_order[0] + + while current is not None and current.id not in reachable: + reachable.add(current.id) + + next_field: FormField | None = None + if current.question_type in BRANCHING_QUESTION_TYPES: + answer = answers.get(current.id) + options = (current.config or {}).get("options", []) + matched = next((o for o in options if o.get("value") == answer), None) + if matched is not None: + if matched.get("action") == "submit_form": + return reachable + next_field_id = matched.get("next_field_id") + if next_field_id is not None: + next_field = by_id.get(next_field_id) + + if next_field is None: + idx = by_order.index(current) + next_field = by_order[idx + 1] if idx + 1 < len(by_order) else None + + current = next_field + + return reachable + + +def _is_blank(value: Any) -> bool: + """No answer given, or an empty/false one — treated the same as + "not answered" for `required` enforcement (e.g. an unconfirmed + acknowledgment submits `false`, not an absent key).""" + if value is None or value is False: + return True + if isinstance(value, (str, list, dict)) and len(value) == 0: + return True + return False + + +def missing_required_field_keys(fields: list[FormField], answers: dict[int, Any]) -> list[str]: + """field_keys of reachable, required fields left blank in `answers`.""" + reachable = compute_reachable_field_ids(fields, answers) + return [ + field.field_key + for field in fields + if field.id in reachable and (field.config or {}).get("required") and _is_blank(answers.get(field.id)) + ] From 875783022673a10a44f5d8f0d2bd8ee282848dcf Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 17:09:52 -0700 Subject: [PATCH 027/185] test(forms): add config/reserved-key/branching/reachability coverage, fix pre-existing config-shape test breakage --- backend/tests/api/test_forms.py | 183 +++++++++++- backend/tests/core/test_form_branching.py | 166 +++++++++++ backend/tests/core/test_form_validation.py | 320 +++++++++++++++++++++ 3 files changed, 662 insertions(+), 7 deletions(-) create mode 100644 backend/tests/core/test_form_branching.py create mode 100644 backend/tests/core/test_form_validation.py diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index 5e4e1cc6..22b60590 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -2,6 +2,8 @@ helpers, slugify/uniqueness, the creates_membership_on_submit side effect, and the access-control dependency functions are covered directly in tests/core/test_forms.py — this file exercises the HTTP layer on top.""" +from datetime import datetime, timedelta, timezone + import pytest from tests.conftest import grant_role, login @@ -15,6 +17,7 @@ FormField, FormResponse, TournamentMembership, + TournamentShift, ) @@ -61,10 +64,11 @@ def _make_field(db, form, *, order=1, field_key="favorite_color", question_type= question_type=question_type, field_key=field_key, config={ + "required": False, "options": [ - {"id": "opt_1", "label": "Red", "archived": False, "next_section_id": None, "allow_other": False}, - {"id": "opt_2", "label": "Blue", "archived": False, "next_section_id": None, "allow_other": False}, - ] + {"value": "opt_1", "label": "Red"}, + {"value": "opt_2", "label": "Blue"}, + ], }, is_archived=False, ) @@ -218,6 +222,7 @@ def test_includes_active_fields_ordered(self, client, db, td_user, td_tournament class TestUpdateArchiveDeleteForm: def test_patch_updates_fields(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament) + _make_field(db, form) db.commit() login(client, "td@test.com", "tdpass") res = client.patch(f"/forms/{form.id}/", json={"name": "Renamed", "status": "published"}) @@ -263,7 +268,12 @@ def test_create_field_slugifies_key(self, client, db, td_user, td_tournament): login(client, "td@test.com", "tdpass") res = client.post( f"/forms/{form.id}/fields/", - json={"label": "Test Writing Interest", "field_key": "Test Writing Interest!", "question_type": "short_text"}, + json={ + "label": "Test Writing Interest", + "field_key": "Test Writing Interest!", + "question_type": "short_text", + "config": {"required": False, "max_length": 500}, + }, ) assert res.status_code == 201 assert res.json()["field_key"] == "test_writing_interest" @@ -325,7 +335,12 @@ def test_chapter_forms_scope_uniqueness_per_form_only(self, client, db, td_user, login(client, "chapterlead@test.com", "LeadPass123!") res = client.post( f"/forms/{form_b.id}/fields/", - json={"label": "Anything", "field_key": "shared_key", "question_type": "short_text"}, + json={ + "label": "Anything", + "field_key": "shared_key", + "question_type": "short_text", + "config": {"required": False, "max_length": 500}, + }, ) # Different form -> allowed for chapter-owned forms (only per-form uniqueness applies) assert res.status_code == 201 @@ -361,9 +376,9 @@ def test_patch_question_type_replaces_field_keeping_key(self, client, db, td_use field = _make_field(db, form, field_key="color") db.commit() login(client, "td@test.com", "tdpass") - res = client.patch(f"/forms/{form.id}/fields/{field.id}/", json={"question_type": "multi_select"}) + res = client.patch(f"/forms/{form.id}/fields/{field.id}/", json={"question_type": "multi_select_checkbox"}) assert res.status_code == 200 - assert res.json()["question_type"] == "multi_select" + assert res.json()["question_type"] == "multi_select_checkbox" assert res.json()["field_key"] == "color" assert res.json()["id"] != field.id @@ -493,3 +508,157 @@ def test_me_404_when_no_response(self, client, db, td_user, td_tournament): login(client, "td@test.com", "tdpass") res = client.get(f"/forms/{form.id}/responses/me/") assert res.status_code == 404 + + +# --------------------------------------------------------------------------- +# Reserved field_key <-> question_type pairing (validated identically on +# tournament- and chapter-owned forms — see form-question-types-reference.md) +# --------------------------------------------------------------------------- + +class TestReservedFieldKeyRoutes: + def test_availability_wrong_type_rejected_on_tournament_form(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + login(client, "td@test.com", "tdpass") + res = client.post( + f"/forms/{form.id}/fields/", + json={ + "label": "Availability", + "field_key": "availability", + "question_type": "single_select_dropdown", + "config": {"required": False, "options": [{"value": "1", "label": "Saturday"}]}, + }, + ) + assert res.status_code == 422 + + def test_availability_wrong_type_rejected_on_chapter_form(self, client, db, td_user, chapter): + form = _make_chapter_form(db, td_user, chapter) + db.commit() + _chapter_lead(db, chapter) + login(client, "chapterlead@test.com", "LeadPass123!") + res = client.post( + f"/forms/{form.id}/fields/", + json={ + "label": "Availability", + "field_key": "availability", + "question_type": "single_select_dropdown", + "config": {"required": False, "options": [{"value": "1", "label": "Saturday"}]}, + }, + ) + assert res.status_code == 422 + + def test_availability_valid_type_accepted_on_chapter_form_no_shift_check(self, client, db, td_user, chapter): + # Chapter forms have no tournament shift catalog to validate + # against, so any option value is accepted — stores as a normal + # FormAnswer, no write-through (write-through is tournament-only). + form = _make_chapter_form(db, td_user, chapter) + db.commit() + _chapter_lead(db, chapter) + login(client, "chapterlead@test.com", "LeadPass123!") + res = client.post( + f"/forms/{form.id}/fields/", + json={ + "label": "Availability", + "field_key": "availability", + "question_type": "multi_select_checkbox", + "config": {"required": False, "options": [{"value": "not_a_real_shift_id", "label": "Whenever"}]}, + }, + ) + assert res.status_code == 201 + + def test_availability_option_must_resolve_to_real_shift_on_tournament_form(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + login(client, "td@test.com", "tdpass") + res = client.post( + f"/forms/{form.id}/fields/", + json={ + "label": "Availability", + "field_key": "availability", + "question_type": "multi_select_checkbox", + "config": {"required": False, "options": [{"value": "9999", "label": "Nonexistent shift"}]}, + }, + ) + assert res.status_code == 422 + + def test_availability_valid_shift_accepted_on_tournament_form(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + shift = TournamentShift( + tournament_id=td_tournament.id, + label="Saturday", + start=datetime.now(timezone.utc), + end=datetime.now(timezone.utc) + timedelta(hours=8), + ) + db.add(shift) + db.commit() + login(client, "td@test.com", "tdpass") + res = client.post( + f"/forms/{form.id}/fields/", + json={ + "label": "Availability", + "field_key": "availability", + "question_type": "multi_select_checkbox", + "config": {"required": False, "options": [{"value": str(shift.id), "label": shift.label}]}, + }, + ) + assert res.status_code == 201 + + def test_event_preference_disallowed_type_rejected(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + login(client, "td@test.com", "tdpass") + res = client.post( + f"/forms/{form.id}/fields/", + json={ + "label": "Event Preference", + "field_key": "event_preference", + "question_type": "short_text", + "config": {"required": False, "max_length": 100}, + }, + ) + assert res.status_code == 422 + + +# --------------------------------------------------------------------------- +# Submission-time required enforcement via branching reachability replay +# --------------------------------------------------------------------------- + +class TestSubmissionReachabilityEnforcement: + def test_submission_rejected_when_reachable_required_field_missing(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + required_field = _make_field( + db, form, order=1, field_key="required_field", question_type="short_text", + config={"required": True, "max_length": 100}, + ) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": []}) + assert res.status_code == 400 + + def test_submission_accepted_when_branch_skips_required_field(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + skipped = _make_field( + db, form, order=2, field_key="skipped", question_type="short_text", + config={"required": True, "max_length": 100}, + ) + target = _make_field(db, form, order=3, field_key="target", question_type="short_text", + config={"required": False, "max_length": 100}) + branch_field = _make_field( + db, form, order=1, field_key="branch", question_type="single_select_radio", + config={ + "required": True, + "options": [ + {"value": "yes", "label": "Yes", "next_field_id": target.id}, + {"value": "no", "label": "No"}, + ], + }, + ) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post( + f"/forms/{form.id}/responses/", + json={"answers": [{"field_id": branch_field.id, "value": "yes"}]}, + ) + assert res.status_code == 200 diff --git a/backend/tests/core/test_form_branching.py b/backend/tests/core/test_form_branching.py new file mode 100644 index 00000000..c750e0ce --- /dev/null +++ b/backend/tests/core/test_form_branching.py @@ -0,0 +1,166 @@ +"""Tests for app/core/form/branching.py — the server-side replay of the +branching graph used to enforce `required` only on fields a respondent +could actually reach. Fields here are built directly (not through the DB) +since compute_reachable_field_ids/missing_required_field_keys are pure +functions over a field list + an answers dict.""" +from app.core.form.branching import compute_reachable_field_ids, missing_required_field_keys +from app.models.models import FormField + + +def _field(id, order, question_type="short_text", config=None, field_key=None): + return FormField( + id=id, + form_id=1, + order=order, + label=f"Field {id}", + question_type=question_type, + field_key=field_key or f"field_{id}", + config=config or {"required": False}, + is_archived=False, + ) + + +class TestComputeReachableFieldIds: + def test_linear_form_all_reachable(self): + fields = [_field(1, 1), _field(2, 2), _field(3, 3)] + assert compute_reachable_field_ids(fields, {}) == {1, 2, 3} + + def test_empty_form(self): + assert compute_reachable_field_ids([], {}) == set() + + def test_simple_branch_jumps_over_skipped_field(self): + fields = [ + _field( + 1, + 1, + question_type="single_select_radio", + config={ + "required": True, + "options": [ + {"value": "yes", "label": "Yes", "next_field_id": 3}, + {"value": "no", "label": "No"}, + ], + }, + ), + _field(2, 2), # skipped when the answer is "yes" + _field(3, 3), + ] + assert compute_reachable_field_ids(fields, {1: "yes"}) == {1, 3} + + def test_branch_not_taken_falls_through_in_order(self): + fields = [ + _field( + 1, + 1, + question_type="single_select_radio", + config={ + "required": True, + "options": [ + {"value": "yes", "label": "Yes", "next_field_id": 3}, + {"value": "no", "label": "No"}, + ], + }, + ), + _field(2, 2), + _field(3, 3), + ] + assert compute_reachable_field_ids(fields, {1: "no"}) == {1, 2, 3} + + def test_unanswered_branching_field_falls_through(self): + fields = [ + _field( + 1, + 1, + question_type="single_select_radio", + config={"required": False, "options": [{"value": "yes", "label": "Yes", "next_field_id": 3}]}, + ), + _field(2, 2), + _field(3, 3), + ] + assert compute_reachable_field_ids(fields, {}) == {1, 2, 3} + + def test_submit_form_action_ends_walk_early(self): + fields = [ + _field( + 1, + 1, + question_type="single_select_radio", + config={ + "required": True, + "options": [ + {"value": "no", "label": "No", "action": "submit_form"}, + ], + }, + ), + _field(2, 2), + _field(3, 3), + ] + assert compute_reachable_field_ids(fields, {1: "no"}) == {1} + + def test_cycle_terminates_instead_of_hanging(self): + fields = [ + _field( + 1, + 1, + question_type="single_select_radio", + config={"required": False, "options": [{"value": "a", "label": "A", "next_field_id": 2}]}, + ), + _field( + 2, + 2, + question_type="single_select_radio", + config={"required": False, "options": [{"value": "b", "label": "B", "next_field_id": 1}]}, + ), + ] + assert compute_reachable_field_ids(fields, {1: "a", 2: "b"}) == {1, 2} + + +class TestMissingRequiredFieldKeys: + def test_skipped_required_field_not_enforced(self): + fields = [ + _field( + 1, + 1, + question_type="single_select_radio", + config={ + "required": True, + "options": [ + {"value": "yes", "label": "Yes", "next_field_id": 3}, + {"value": "no", "label": "No"}, + ], + }, + ), + _field(2, 2, config={"required": True}), # branched past — should NOT be enforced + _field(3, 3, config={"required": False}), + ] + assert missing_required_field_keys(fields, {1: "yes"}) == [] + + def test_reachable_required_field_left_blank_is_reported(self): + fields = [_field(1, 1, config={"required": True})] + assert missing_required_field_keys(fields, {}) == ["field_1"] + + def test_answered_but_unreachable_field_not_reported(self): + fields = [ + _field( + 1, + 1, + question_type="single_select_radio", + config={ + "required": True, + "options": [{"value": "no", "label": "No", "action": "submit_form"}], + }, + ), + _field(2, 2, config={"required": True}), + ] + # field 2 has an answer even though it was never reachable — not our + # job to reject that here, just don't let it block the submission + assert missing_required_field_keys(fields, {1: "no", 2: "something"}) == [] + + def test_blank_values_treated_as_unanswered(self): + fields = [_field(1, 1, config={"required": True})] + for blank in (None, "", [], {}, False): + assert missing_required_field_keys(fields, {1: blank}) == ["field_1"] + + def test_non_blank_answer_satisfies_required(self): + fields = [_field(1, 1, config={"required": True})] + assert missing_required_field_keys(fields, {1: "hello"}) == [] diff --git a/backend/tests/core/test_form_validation.py b/backend/tests/core/test_form_validation.py new file mode 100644 index 00000000..44f57d25 --- /dev/null +++ b/backend/tests/core/test_form_validation.py @@ -0,0 +1,320 @@ +"""Tests for app/core/form/validation.py — per-question_type config shape +(delegated to the pydantic schemas in app/schemas/form.py), reserved +field_key pairing, branching option targets, availability's TournamentShift +resolution, and the aggregate whole-form publish pass. See +tests/api/test_forms.py for the route-level wiring of these checks.""" +from datetime import datetime, timedelta, timezone + +import pytest + +from tests.api.chapter._helpers import make_chapter, make_university + +from app.core.form.validation import ( + FormFieldValidationError, + validate_availability_options, + validate_branching_options, + validate_field_config, + validate_form_for_publish, + validate_reserved_field_key, +) +from app.models.models import Form, FormField, TournamentShift + + +# --------------------------------------------------------------------------- +# Builders +# --------------------------------------------------------------------------- + +def _make_form(db, user, tournament, **overrides): + defaults = dict( + owner_type="tournament", + tournament_id=tournament.id, + chapter_id=None, + name="Test form", + created_by=user.id, + ) + defaults.update(overrides) + form = Form(**defaults) + db.add(form) + db.flush() + return form + + +def _make_chapter_form(db, user, chapter, **overrides): + defaults = dict( + owner_type="chapter", + chapter_id=chapter.id, + tournament_id=None, + name="Test chapter form", + created_by=user.id, + ) + defaults.update(overrides) + form = Form(**defaults) + db.add(form) + db.flush() + return form + + +def _make_field(db, form, *, order=1, field_key="favorite_color", question_type="single_select_dropdown", **overrides): + defaults = dict( + form_id=form.id, + order=order, + label="Favorite color", + description=None, + question_type=question_type, + field_key=field_key, + config={ + "required": False, + "options": [ + {"value": "opt_1", "label": "Red"}, + {"value": "opt_2", "label": "Blue"}, + ], + }, + is_archived=False, + ) + defaults.update(overrides) + field = FormField(**defaults) + db.add(field) + db.flush() + return field + + +def _make_shift(db, tournament, label="Saturday"): + shift = TournamentShift( + tournament_id=tournament.id, + label=label, + start=datetime.now(timezone.utc), + end=datetime.now(timezone.utc) + timedelta(hours=8), + ) + db.add(shift) + db.flush() + return shift + + +@pytest.fixture +def chapter(db): + university = make_university(db) + return make_chapter(db, university.id) + + +# --------------------------------------------------------------------------- +# validate_field_config — per-question_type shape +# --------------------------------------------------------------------------- + +class TestValidateFieldConfig: + def test_unknown_question_type_rejected(self): + with pytest.raises(FormFieldValidationError): + validate_field_config("grid", {}) + + def test_missing_required_key_rejected(self): + with pytest.raises(FormFieldValidationError): + validate_field_config("short_text", {"max_length": 100}) + + def test_acknowledgment_missing_confirm_label_rejected(self): + with pytest.raises(FormFieldValidationError): + validate_field_config("acknowledgment", {"required": True}) + + def test_acknowledgment_valid_passes(self): + normalized = validate_field_config("acknowledgment", {"required": True, "confirm_label": "I understand"}) + assert normalized == {"required": True, "confirm_label": "I understand"} + + def test_single_select_missing_options_rejected(self): + with pytest.raises(FormFieldValidationError): + validate_field_config("single_select_radio", {"required": True}) + + def test_single_select_duplicate_option_values_rejected(self): + with pytest.raises(FormFieldValidationError): + validate_field_config( + "single_select_radio", + {"required": True, "options": [{"value": "a", "label": "A"}, {"value": "a", "label": "A2"}]}, + ) + + def test_single_select_option_missing_value_rejected(self): + with pytest.raises(FormFieldValidationError): + validate_field_config("single_select_dropdown", {"required": True, "options": [{"label": "A"}]}) + + def test_multi_select_checkbox_rejects_branching_keys_on_option(self): + with pytest.raises(FormFieldValidationError): + validate_field_config( + "multi_select_checkbox", + {"required": True, "options": [{"value": "a", "label": "A", "next_field_id": 5}]}, + ) + + def test_ranked_choice_missing_allow_duplicates_rejected(self): + with pytest.raises(FormFieldValidationError): + validate_field_config( + "ranked_choice", + {"required": True, "ranks": 1, "options": [{"value": "a", "label": "A"}]}, + ) + + def test_ranked_choice_ranks_exceeds_options_rejected(self): + with pytest.raises(FormFieldValidationError): + validate_field_config( + "ranked_choice", + { + "required": True, + "ranks": 3, + "allow_duplicates": False, + "options": [{"value": "a", "label": "A"}], + }, + ) + + def test_ranked_choice_valid_passes(self): + normalized = validate_field_config( + "ranked_choice", + { + "required": True, + "ranks": 2, + "allow_duplicates": False, + "options": [{"value": "a", "label": "A"}, {"value": "b", "label": "B"}], + }, + ) + assert normalized["ranks"] == 2 + + def test_short_text_missing_max_length_rejected(self): + with pytest.raises(FormFieldValidationError): + validate_field_config("short_text", {"required": False}) + + def test_short_text_negative_max_length_rejected(self): + with pytest.raises(FormFieldValidationError): + validate_field_config("short_text", {"required": False, "max_length": -1}) + + def test_long_text_valid_passes(self): + normalized = validate_field_config("long_text", {"required": False, "max_length": 1000}) + assert normalized == {"required": False, "max_length": 1000} + + +# --------------------------------------------------------------------------- +# validate_reserved_field_key +# --------------------------------------------------------------------------- + +class TestValidateReservedFieldKey: + def test_availability_requires_multi_select_checkbox(self): + with pytest.raises(FormFieldValidationError): + validate_reserved_field_key("availability", "single_select_dropdown") + + def test_availability_with_multi_select_checkbox_passes(self): + validate_reserved_field_key("availability", "multi_select_checkbox") # no raise + + @pytest.mark.parametrize("question_type", ["ranked_choice", "multi_select_checkbox", "single_select_dropdown"]) + def test_event_preference_allowed_types_pass(self, question_type): + validate_reserved_field_key("event_preference", question_type) # no raise + + def test_event_preference_disallowed_type_rejected(self): + with pytest.raises(FormFieldValidationError): + validate_reserved_field_key("event_preference", "short_text") + + def test_non_reserved_key_any_type_allowed(self): + validate_reserved_field_key("favorite_color", "acknowledgment") # no raise + + +# --------------------------------------------------------------------------- +# validate_branching_options +# --------------------------------------------------------------------------- + +class TestValidateBranchingOptions: + def test_missing_next_field_id_target_rejected(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + config = {"required": False, "options": [{"value": "yes", "label": "Yes", "next_field_id": 9999}]} + with pytest.raises(FormFieldValidationError): + validate_branching_options(db, form.id, "single_select_radio", config) + + def test_valid_next_field_id_target_passes(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + target = _make_field(db, form, field_key="target") + db.commit() + config = {"required": False, "options": [{"value": "yes", "label": "Yes", "next_field_id": target.id}]} + validate_branching_options(db, form.id, "single_select_radio", config) # no raise + + def test_self_reference_rejected(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="self_ref") + db.commit() + config = {"required": False, "options": [{"value": "yes", "label": "Yes", "next_field_id": field.id}]} + with pytest.raises(FormFieldValidationError): + validate_branching_options(db, form.id, "single_select_radio", config, field_id=field.id) + + def test_archived_target_rejected(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + target = _make_field(db, form, field_key="target", is_archived=True) + db.commit() + config = {"required": False, "options": [{"value": "yes", "label": "Yes", "next_field_id": target.id}]} + with pytest.raises(FormFieldValidationError): + validate_branching_options(db, form.id, "single_select_radio", config) + + def test_non_branching_type_is_a_noop(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + # multi_select_checkbox options can't structurally carry next_field_id + # (the schema forbids it) — validate_branching_options just skips + # non-branching types entirely regardless of config contents. + validate_branching_options(db, form.id, "multi_select_checkbox", {"options": []}) # no raise + + +# --------------------------------------------------------------------------- +# validate_availability_options +# --------------------------------------------------------------------------- + +class TestValidateAvailabilityOptions: + def test_chapter_owned_form_skips_check(self, db): + # tournament_id=None (chapter-owned) — no shift catalog to check against + config = {"options": [{"value": "not_a_real_shift_id", "label": "Whenever"}]} + validate_availability_options(db, None, config) # no raise + + def test_valid_shift_ids_pass(self, db, td_user, td_tournament): + shift = _make_shift(db, td_tournament) + db.commit() + config = {"options": [{"value": str(shift.id), "label": shift.label}]} + validate_availability_options(db, td_tournament.id, config) # no raise + + def test_shift_id_not_on_tournament_rejected(self, db, td_user, td_tournament, other_user, other_tournament): + shift = _make_shift(db, other_tournament) + db.commit() + config = {"options": [{"value": str(shift.id), "label": shift.label}]} + with pytest.raises(FormFieldValidationError): + validate_availability_options(db, td_tournament.id, config) + + def test_non_numeric_value_rejected(self, db, td_user, td_tournament): + config = {"options": [{"value": "not_a_shift_id", "label": "Whenever"}]} + with pytest.raises(FormFieldValidationError): + validate_availability_options(db, td_tournament.id, config) + + +# --------------------------------------------------------------------------- +# validate_form_for_publish — aggregate pass +# --------------------------------------------------------------------------- + +class TestValidateFormForPublish: + def test_empty_form_rejected(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + with pytest.raises(FormFieldValidationError, match="no fields"): + validate_form_for_publish(db, form) + + def test_valid_form_passes(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + _make_field(db, form) + db.commit() + validate_form_for_publish(db, form) # no raise + + def test_dangling_next_field_id_rejected(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + # a field whose branching option jumps to a field that no longer + # exists (e.g. archived after this field was last saved) — caught + # only by the aggregate pass, not by per-field create/update checks. + _make_field( + db, + form, + question_type="single_select_radio", + config={"required": False, "options": [{"value": "yes", "label": "Yes", "next_field_id": 9999}]}, + ) + db.commit() + with pytest.raises(FormFieldValidationError, match="next_field_id"): + validate_form_for_publish(db, form) + + def test_archived_fields_excluded_from_pass(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + _make_field(db, form, is_archived=True, config={"bad": "shape"}) + _make_field(db, form, order=2, field_key="second") + db.commit() + validate_form_for_publish(db, form) # no raise — archived field's bad config is ignored From c929d8ce18646d441d47656333c5a5de2b4d1b8f Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 20:17:51 -0700 Subject: [PATCH 028/185] refactor(forms): remove creates_membership_on_submit and its config tables from Forms --- .../versions/7db31ae17e3c_forms_core_model.py | 18 -- backend/app/api/routes/forms.py | 48 +----- backend/app/core/form/membership.py | 61 ------- backend/app/core/form/permissions.py | 10 +- backend/app/models/models.py | 44 ----- backend/app/schemas/form.py | 35 ---- backend/tests/api/test_forms.py | 28 +--- backend/tests/core/test_forms.py | 158 +----------------- 8 files changed, 11 insertions(+), 391 deletions(-) delete mode 100644 backend/app/core/form/membership.py diff --git a/backend/alembic/versions/7db31ae17e3c_forms_core_model.py b/backend/alembic/versions/7db31ae17e3c_forms_core_model.py index dd0526d0..157b46ee 100644 --- a/backend/alembic/versions/7db31ae17e3c_forms_core_model.py +++ b/backend/alembic/versions/7db31ae17e3c_forms_core_model.py @@ -31,7 +31,6 @@ def upgrade() -> None: sa.Column('name', sa.String(length=255), nullable=False), sa.Column('description', sa.Text(), nullable=True), sa.Column('status', sa.String(length=16), nullable=False), - sa.Column('creates_membership_on_submit', sa.Boolean(), nullable=False), sa.Column('created_by', sa.Integer(), nullable=False), sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), @@ -43,21 +42,6 @@ def upgrade() -> None: ) op.create_index(op.f('ix_forms_id'), 'forms', ['id'], unique=False) - op.create_table('form_tournament_membership_configs', - sa.Column('form_id', sa.Integer(), nullable=False), - sa.Column('status_on_submit', sa.String(length=32), nullable=True), - sa.Column('role_ids_on_submit', sa.JSON(), nullable=True), - sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('form_id') - ) - - op.create_table('form_chapter_membership_configs', - sa.Column('form_id', sa.Integer(), nullable=False), - sa.Column('role_on_submit', sa.String(length=32), nullable=False), - sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('form_id') - ) - op.create_table('form_fields', sa.Column('id', sa.Integer(), nullable=False), sa.Column('form_id', sa.Integer(), nullable=False), @@ -109,7 +93,5 @@ def downgrade() -> None: op.drop_table('form_responses') op.drop_index(op.f('ix_form_fields_id'), table_name='form_fields') op.drop_table('form_fields') - op.drop_table('form_chapter_membership_configs') - op.drop_table('form_tournament_membership_configs') op.drop_index(op.f('ix_forms_id'), table_name='forms') op.drop_table('forms') diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 1b228534..ac8b8189 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -15,7 +15,6 @@ update_field_text, ) from app.core.form.branching import missing_required_field_keys -from app.core.form.membership import create_membership_on_first_submit from app.core.form.permissions import require_form_manage_access, require_form_view_access from app.core.form.validation import ( FormFieldValidationError, @@ -30,10 +29,8 @@ from app.models.models import ( Form, FormAnswer, - FormChapterMembershipConfig, FormField, FormResponse, - FormTournamentMembershipConfig, User, utcnow, ) @@ -78,19 +75,9 @@ def create_tournament_form( owner_type="tournament", tournament_id=tournament_id, chapter_id=None, - creates_membership_on_submit=payload.creates_membership_on_submit, created_by=current_user.id, ) db.add(form) - db.flush() - - if payload.tournament_membership_config is not None: - db.add(FormTournamentMembershipConfig( - form_id=form.id, - status_on_submit=payload.tournament_membership_config.status_on_submit, - role_ids_on_submit=payload.tournament_membership_config.role_ids_on_submit or None, - )) - db.commit() db.refresh(form) return form @@ -125,18 +112,9 @@ def create_chapter_form( owner_type="chapter", tournament_id=None, chapter_id=chapter_id, - creates_membership_on_submit=payload.creates_membership_on_submit, created_by=current_user.id, ) db.add(form) - db.flush() - - if payload.chapter_membership_config is not None: - db.add(FormChapterMembershipConfig( - form_id=form.id, - role_on_submit=payload.chapter_membership_config.role_on_submit, - )) - db.commit() db.refresh(form) return form @@ -171,10 +149,7 @@ def get_form_for_rendering( # --------------------------------------------------------------------------- -# PATCH /forms/{form_id}/ — name/description/status/membership config. -# A membership_config payload for the "wrong" owner_type is ignored (PATCH -# is a partial update, not worth 422ing over — FormCreate already prevents -# ever creating a form with a mismatched config). +# PATCH /forms/{form_id}/ — name/description/status. # --------------------------------------------------------------------------- @router.patch("/forms/{form_id}/", response_model=FormRead) def update_form( @@ -194,23 +169,6 @@ def update_form( form.description = payload.description if payload.status is not None: form.status = payload.status - if payload.creates_membership_on_submit is not None: - form.creates_membership_on_submit = payload.creates_membership_on_submit - - if payload.tournament_membership_config is not None and form.owner_type == "tournament": - config = form.tournament_membership_config - if config is None: - config = FormTournamentMembershipConfig(form_id=form.id) - db.add(config) - config.status_on_submit = payload.tournament_membership_config.status_on_submit - config.role_ids_on_submit = payload.tournament_membership_config.role_ids_on_submit or None - - if payload.chapter_membership_config is not None and form.owner_type == "chapter": - config = form.chapter_membership_config - if config is None: - config = FormChapterMembershipConfig(form_id=form.id) - db.add(config) - config.role_on_submit = payload.chapter_membership_config.role_on_submit db.commit() db.refresh(form) @@ -426,7 +384,6 @@ def submit_form_response( .filter(FormResponse.form_id == form.id, FormResponse.user_id == current_user.id) .first() ) - is_first_response = response is None if response is None: response = FormResponse(form_id=form.id, user_id=current_user.id) db.add(response) @@ -438,9 +395,6 @@ def submit_form_response( for answer_in in payload.answers: db.add(FormAnswer(response_id=response.id, field_id=answer_in.field_id, value=answer_in.value)) - if is_first_response: - create_membership_on_first_submit(db, form, current_user) - db.commit() db.refresh(response) return response diff --git a/backend/app/core/form/membership.py b/backend/app/core/form/membership.py deleted file mode 100644 index b9b023ca..00000000 --- a/backend/app/core/form/membership.py +++ /dev/null @@ -1,61 +0,0 @@ -from sqlalchemy.orm import Session - -from app.models.models import ChapterMembership, Form, TournamentMembership, TournamentMembershipRole, User - -# --------------------------------------------------------------------------- -# creates_membership_on_submit side effect. Called only on a user's FIRST -# response to a form — resubmission never touches membership. -# --------------------------------------------------------------------------- - - -def create_membership_on_first_submit(db: Session, form: Form, user: User) -> None: - if not form.creates_membership_on_submit: - return - - if form.owner_type == "tournament": - _create_tournament_membership(db, form, user) - else: - _create_chapter_membership(db, form, user) - - -def _create_tournament_membership(db: Session, form: Form, user: User) -> None: - existing = ( - db.query(TournamentMembership) - .filter( - TournamentMembership.user_id == user.id, - TournamentMembership.tournament_id == form.tournament_id, - ) - .first() - ) - if existing: - return - - config = form.tournament_membership_config - status_value = (config.status_on_submit if config else None) or "interested" - - membership = TournamentMembership( - user_id=user.id, - tournament_id=form.tournament_id, - source="manual", - status=status_value, - ) - db.add(membership) - db.flush() - - role_ids = config.role_ids_on_submit if config and config.role_ids_on_submit else [] - for role_id in role_ids: - db.add(TournamentMembershipRole(membership_id=membership.id, role_id=role_id)) - - -def _create_chapter_membership(db: Session, form: Form, user: User) -> None: - # ChapterMembership.user_id is unique — a user belongs to at most one - # chapter total, so this checks for ANY existing chapter membership, - # not just one scoped to form.chapter_id. - existing = db.query(ChapterMembership).filter(ChapterMembership.user_id == user.id).first() - if existing: - return - - config = form.chapter_membership_config - role_value = config.role_on_submit if config else "member" - - db.add(ChapterMembership(chapter_id=form.chapter_id, user_id=user.id, role=role_value)) diff --git a/backend/app/core/form/permissions.py b/backend/app/core/form/permissions.py index f524195b..ea68fd72 100644 --- a/backend/app/core/form/permissions.py +++ b/backend/app/core/form/permissions.py @@ -51,17 +51,9 @@ def require_form_view_access( ) -> Form: """Dependency — loads the Form and requires either manage access, or plain membership in the owning tournament/chapter (for the people - filling the form out, not just the people managing it). - - A creates_membership_on_submit form is open to any authenticated user - regardless of existing membership — that flag exists specifically to - onboard NON-members via their first submission, so gating view access - on membership they don't have yet would make the flag unreachable.""" + filling the form out, not just the people managing it).""" form = _load_form_or_404(form_id, db) - if form.creates_membership_on_submit: - return form - if form.owner_type == "tournament": if not has_any_membership(current_user, form.tournament_id, db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 5465bd6e..093ba377 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -667,10 +667,6 @@ class Form(Base): description = Column(Text, nullable=True) status = Column(String(16), nullable=False, default="draft") # "draft" | "published" | "archived" - # If true, a user's first response to this form also creates a pending - # membership on the owning tournament/chapter (see app/core/form). - creates_membership_on_submit = Column(Boolean, nullable=False, default=False) - created_by = Column(Integer, ForeignKey("users.id"), nullable=False) created_at = Column(DateTime(timezone=True), default=utcnow) @@ -681,12 +677,6 @@ class Form(Base): creator = relationship("User", back_populates="created_forms") fields = relationship("FormField", back_populates="form", cascade="all, delete-orphan", order_by="FormField.order") responses = relationship("FormResponse", back_populates="form", cascade="all, delete-orphan") - tournament_membership_config = relationship( - "FormTournamentMembershipConfig", back_populates="form", uselist=False, cascade="all, delete-orphan" - ) - chapter_membership_config = relationship( - "FormChapterMembershipConfig", back_populates="form", uselist=False, cascade="all, delete-orphan" - ) __table_args__ = ( CheckConstraint( @@ -697,40 +687,6 @@ class Form(Base): ) -# --------------------------------------------------------------------------- -# FormTournamentMembershipConfig — optional per-form config for what -# creates_membership_on_submit does on a tournament-owned Form. Absent row = -# use defaults (TournamentMembership's own status default, no extra roles). -# --------------------------------------------------------------------------- -class FormTournamentMembershipConfig(Base): - __tablename__ = "form_tournament_membership_configs" - - form_id = Column(Integer, ForeignKey("forms.id", ondelete="CASCADE"), primary_key=True) - - # "interested" | "confirmed" | None (None = TournamentMembership default - # on create; an existing membership's status is never touched either way) - status_on_submit = Column(String(32), nullable=True) - - # list[int] of TournamentRole ids to attach via TournamentMembershipRole - role_ids_on_submit = Column(JSON, nullable=True) - - form = relationship("Form", back_populates="tournament_membership_config") - - -# --------------------------------------------------------------------------- -# FormChapterMembershipConfig — optional per-form config for what -# creates_membership_on_submit does on a chapter-owned Form. Absent row = -# defaults to role_on_submit="member". -# --------------------------------------------------------------------------- -class FormChapterMembershipConfig(Base): - __tablename__ = "form_chapter_membership_configs" - - form_id = Column(Integer, ForeignKey("forms.id", ondelete="CASCADE"), primary_key=True) - role_on_submit = Column(String(32), nullable=False, default="member") # "lead" | "officer" | "member" - - form = relationship("Form", back_populates="chapter_membership_config") - - # --------------------------------------------------------------------------- # FormField — a single question on a Form. question_type drives how config # is shaped (see comments inline below). Removing a field with existing diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index dc24163d..c9df4ec7 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -153,28 +153,6 @@ class FormFieldUpdate(BaseModel): # Form Schemas # --------------------------------------------------------------------------- -class FormTournamentMembershipConfigIn(BaseModel): - status_on_submit: Literal["interested", "confirmed"] | None = None - role_ids_on_submit: list[int] = [] - - -class FormTournamentMembershipConfigRead(BaseModel): - status_on_submit: Literal["interested", "confirmed"] | None = None - role_ids_on_submit: list[int] = [] - - model_config = ConfigDict(from_attributes=True) - - -class FormChapterMembershipConfigIn(BaseModel): - role_on_submit: Literal["lead", "officer", "member"] = "member" - - -class FormChapterMembershipConfigRead(BaseModel): - role_on_submit: Literal["lead", "officer", "member"] = "member" - - model_config = ConfigDict(from_attributes=True) - - class FormRead(BaseModel): id: int name: str @@ -183,9 +161,6 @@ class FormRead(BaseModel): owner_type: Literal["tournament", "chapter"] tournament_id: int | None = None chapter_id: int | None = None - creates_membership_on_submit: bool = False - tournament_membership_config: FormTournamentMembershipConfigRead | None = None - chapter_membership_config: FormChapterMembershipConfigRead | None = None created_by: int created_at: datetime updated_at: datetime @@ -200,22 +175,15 @@ class FormCreate(BaseModel): owner_type: Literal["tournament", "chapter"] tournament_id: int | None = None chapter_id: int | None = None - creates_membership_on_submit: bool = False - tournament_membership_config: FormTournamentMembershipConfigIn | None = None - chapter_membership_config: FormChapterMembershipConfigIn | None = None @model_validator(mode="after") def _require_matching_owner(self): if self.owner_type == "tournament": if self.tournament_id is None or self.chapter_id is not None: raise ValueError("owner_type 'tournament' requires tournament_id and no chapter_id") - if self.chapter_membership_config is not None: - raise ValueError("chapter_membership_config only applies to owner_type 'chapter'") else: if self.chapter_id is None or self.tournament_id is not None: raise ValueError("owner_type 'chapter' requires chapter_id and no tournament_id") - if self.tournament_membership_config is not None: - raise ValueError("tournament_membership_config only applies to owner_type 'tournament'") return self @@ -223,9 +191,6 @@ class FormUpdate(BaseModel): name: str | None = None description: str | None = None status: Literal["draft", "published", "archived"] | None = None - creates_membership_on_submit: bool | None = None - tournament_membership_config: FormTournamentMembershipConfigIn | None = None - chapter_membership_config: FormChapterMembershipConfigIn | None = None # --------------------------------------------------------------------------- diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index 22b60590..4b52126b 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -1,7 +1,7 @@ """Route tests for /forms/ (app/api/routes/forms.py). Model CRUD, field -helpers, slugify/uniqueness, the creates_membership_on_submit side effect, -and the access-control dependency functions are covered directly in -tests/core/test_forms.py — this file exercises the HTTP layer on top.""" +helpers, slugify/uniqueness, and the access-control dependency functions are +covered directly in tests/core/test_forms.py — this file exercises the HTTP +layer on top.""" from datetime import datetime, timedelta, timezone import pytest @@ -16,7 +16,6 @@ FormAnswer, FormField, FormResponse, - TournamentMembership, TournamentShift, ) @@ -192,13 +191,6 @@ def test_non_member_forbidden(self, client, db, td_user, td_tournament, other_us res = client.get(f"/forms/{form.id}/") assert res.status_code == 403 - def test_non_member_allowed_when_creates_membership_on_submit(self, client, db, td_user, td_tournament, other_user): - form = _make_form(db, td_user, td_tournament, creates_membership_on_submit=True) - db.commit() - login(client, "other@test.com", "otherpass") - res = client.get(f"/forms/{form.id}/") - assert res.status_code == 200 - def test_missing_form_404(self, client, td_user): login(client, "td@test.com", "tdpass") res = client.get("/forms/9999/") @@ -455,20 +447,6 @@ def test_non_member_forbidden(self, client, db, td_user, td_tournament, other_us res = client.post(f"/forms/{form.id}/responses/", json={"answers": []}) assert res.status_code == 403 - def test_non_member_can_submit_when_creates_membership_on_submit(self, client, db, td_user, td_tournament, other_user): - form = _make_form(db, td_user, td_tournament, creates_membership_on_submit=True) - db.commit() - login(client, "other@test.com", "otherpass") - - res = client.post(f"/forms/{form.id}/responses/", json={"answers": []}) - assert res.status_code == 200 - - membership = db.query(TournamentMembership).filter( - TournamentMembership.user_id == other_user.id, - TournamentMembership.tournament_id == td_tournament.id, - ).one() - assert membership.status == "interested" - # --------------------------------------------------------------------------- # GET /forms/{form_id}/responses/ and /responses/me/ diff --git a/backend/tests/core/test_forms.py b/backend/tests/core/test_forms.py index e033d439..9fe13e32 100644 --- a/backend/tests/core/test_forms.py +++ b/backend/tests/core/test_forms.py @@ -1,7 +1,7 @@ """Tests for app/core/form — model CRUD building blocks, field-editing -helpers, field_key derivation/uniqueness, the creates_membership_on_submit -side effect, and the access-control dependency functions, all exercised -directly (no HTTP layer). See tests/api/test_forms.py for the routes.""" +helpers, field_key derivation/uniqueness, and the access-control dependency +functions, all exercised directly (no HTTP layer). See tests/api/test_forms.py +for the routes.""" import pytest from fastapi import HTTPException from sqlalchemy.exc import IntegrityError @@ -16,19 +16,13 @@ replace_field_type, slugify, ) -from app.core.form.membership import create_membership_on_first_submit from app.core.form.permissions import require_form_manage_access, require_form_view_access from app.models.models import ( ChapterMembership, Form, FormAnswer, - FormChapterMembershipConfig, FormField, FormResponse, - FormTournamentMembershipConfig, - TournamentMembership, - TournamentMembershipRole, - TournamentRole, ) @@ -103,8 +97,7 @@ def _chapter_lead(db, chapter, email="chapterlead@test.com", password="LeadPass1 # --------------------------------------------------------------------------- -# Model-level CRUD — Form, FormField, FormResponse, FormAnswer, -# FormTournamentMembershipConfig, FormChapterMembershipConfig +# Model-level CRUD — Form, FormField, FormResponse, FormAnswer # --------------------------------------------------------------------------- class TestModelCRUD: @@ -188,23 +181,6 @@ def test_response_unique_per_form_and_user(self, db, td_user, td_tournament): db.flush() db.rollback() - def test_tournament_membership_config_round_trip(self, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament, creates_membership_on_submit=True) - db.add(FormTournamentMembershipConfig(form_id=form.id, status_on_submit="confirmed", role_ids_on_submit=[1, 2])) - db.commit() - db.refresh(form) - - assert form.tournament_membership_config.status_on_submit == "confirmed" - assert form.tournament_membership_config.role_ids_on_submit == [1, 2] - - def test_chapter_membership_config_round_trip(self, db, td_user, chapter): - form = _make_chapter_form(db, td_user, chapter, creates_membership_on_submit=True) - db.add(FormChapterMembershipConfig(form_id=form.id, role_on_submit="officer")) - db.commit() - db.refresh(form) - - assert form.chapter_membership_config.role_on_submit == "officer" - def test_deleting_tournament_cascades_to_forms(self, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament) db.commit() @@ -324,121 +300,6 @@ def test_field_key_taken_true_when_archived(self, db, td_user, td_tournament): assert field_key_taken_in_tournament(db, td_tournament.id, "was_used") is True -# --------------------------------------------------------------------------- -# creates_membership_on_submit -# --------------------------------------------------------------------------- - -class TestMembershipOnSubmit: - def test_noop_when_flag_false(self, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament, creates_membership_on_submit=False) - db.commit() - new_user = make_user(db, "flagoff@test.com", password="Pass123!") - - create_membership_on_first_submit(db, form, new_user) - db.commit() - - assert db.query(TournamentMembership).filter(TournamentMembership.user_id == new_user.id).count() == 0 - - def test_tournament_new_member_gets_default_status_and_no_roles(self, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament, creates_membership_on_submit=True) - db.commit() - new_user = make_user(db, "newtdmember1@test.com", password="Pass123!") - - create_membership_on_first_submit(db, form, new_user) - db.commit() - - membership = db.query(TournamentMembership).filter( - TournamentMembership.user_id == new_user.id, - TournamentMembership.tournament_id == td_tournament.id, - ).one() - assert membership.status == "interested" - assert membership.source == "manual" - assert db.query(TournamentMembershipRole).filter(TournamentMembershipRole.membership_id == membership.id).count() == 0 - - def test_tournament_config_applies_status_and_roles(self, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament, creates_membership_on_submit=True) - role = TournamentRole(tournament_id=td_tournament.id, label="Custom Role", rank=99, permissions=[]) - db.add(role) - db.flush() - db.add(FormTournamentMembershipConfig(form_id=form.id, status_on_submit="confirmed", role_ids_on_submit=[role.id])) - db.flush() - new_user = make_user(db, "newtdmember2@test.com", password="Pass123!") - - create_membership_on_first_submit(db, form, new_user) - db.commit() - - membership = db.query(TournamentMembership).filter( - TournamentMembership.user_id == new_user.id, - TournamentMembership.tournament_id == td_tournament.id, - ).one() - assert membership.status == "confirmed" - - role_ids = [ - r.role_id - for r in db.query(TournamentMembershipRole).filter(TournamentMembershipRole.membership_id == membership.id) - ] - assert role_ids == [role.id] - - def test_tournament_skips_and_leaves_status_untouched_when_membership_exists(self, db, td_user, td_tournament): - # td_user already has a "confirmed" membership via the td_tournament fixture - form = _make_form(db, td_user, td_tournament, creates_membership_on_submit=True) - db.add(FormTournamentMembershipConfig(form_id=form.id, status_on_submit="interested")) - db.commit() - - create_membership_on_first_submit(db, form, td_user) - db.commit() - - memberships = db.query(TournamentMembership).filter( - TournamentMembership.user_id == td_user.id, - TournamentMembership.tournament_id == td_tournament.id, - ).all() - assert len(memberships) == 1 - assert memberships[0].status == "confirmed" # untouched, not reset to "interested" - - def test_chapter_new_member_gets_default_role(self, db, chapter): - lead = _chapter_lead(db, chapter) - form = _make_chapter_form(db, lead, chapter, creates_membership_on_submit=True) - db.commit() - new_user = make_user(db, "newchaptermember1@test.com", password="Pass123!") - - create_membership_on_first_submit(db, form, new_user) - db.commit() - - membership = db.query(ChapterMembership).filter(ChapterMembership.user_id == new_user.id).one() - assert membership.role == "member" - assert membership.chapter_id == chapter.id - - def test_chapter_config_applies_role(self, db, chapter): - lead = _chapter_lead(db, chapter) - form = _make_chapter_form(db, lead, chapter, creates_membership_on_submit=True) - db.add(FormChapterMembershipConfig(form_id=form.id, role_on_submit="officer")) - db.commit() - new_user = make_user(db, "newchaptermember2@test.com", password="Pass123!") - - create_membership_on_first_submit(db, form, new_user) - db.commit() - - membership = db.query(ChapterMembership).filter(ChapterMembership.user_id == new_user.id).one() - assert membership.role == "officer" - - def test_chapter_skips_if_user_already_in_a_different_chapter(self, db, chapter): - other_university = make_university(db) - other_chapter = make_chapter(db, other_university.id) - user = make_user(db, "alreadyelsewhere@test.com", password="Pass123!") - db.add(ChapterMembership(chapter_id=other_chapter.id, user_id=user.id, role="member")) - db.commit() - - lead = _chapter_lead(db, chapter) - form = _make_chapter_form(db, lead, chapter, creates_membership_on_submit=True) - db.commit() - - create_membership_on_first_submit(db, form, user) - db.commit() - - membership = db.query(ChapterMembership).filter(ChapterMembership.user_id == user.id).one() - assert membership.chapter_id == other_chapter.id # unchanged, no second row created - - # --------------------------------------------------------------------------- # require_form_manage_access / require_form_view_access # --------------------------------------------------------------------------- @@ -482,15 +343,8 @@ def test_manage_access_chapter_plain_member_gets_403(self, db, chapter): require_form_manage_access(form.id, db, member) assert exc_info.value.status_code == 403 - def test_view_access_creates_membership_flag_bypasses_membership_requirement(self, db, td_user, td_tournament, other_user): - form = _make_form(db, td_user, td_tournament, creates_membership_on_submit=True) - db.commit() - # other_user has NO membership in td_tournament at all - result = require_form_view_access(form.id, db, other_user) - assert result.id == form.id - - def test_view_access_without_flag_requires_membership(self, db, td_user, td_tournament, other_user): - form = _make_form(db, td_user, td_tournament, creates_membership_on_submit=False) + def test_view_access_non_member_requires_membership(self, db, td_user, td_tournament, other_user): + form = _make_form(db, td_user, td_tournament) db.commit() with pytest.raises(HTTPException) as exc_info: require_form_view_access(form.id, db, other_user) From f1d9088d57518dc4898da9b9a37ff66294106716 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 20:27:49 -0700 Subject: [PATCH 029/185] feat(forms): add lunch/availability write-through models and migration --- .../versions/7db31ae17e3c_forms_core_model.py | 33 +++ backend/app/models/models.py | 204 +++++++++++------- 2 files changed, 157 insertions(+), 80 deletions(-) diff --git a/backend/alembic/versions/7db31ae17e3c_forms_core_model.py b/backend/alembic/versions/7db31ae17e3c_forms_core_model.py index 157b46ee..f0e05bea 100644 --- a/backend/alembic/versions/7db31ae17e3c_forms_core_model.py +++ b/backend/alembic/versions/7db31ae17e3c_forms_core_model.py @@ -9,6 +9,11 @@ instead of layering a third migration on top of a broken shape. Form ownership is single tournament-or-chapter (owner_type + CHECK constraint) — multi-tournament "group forms" are a later, separate phase. + +Also includes tournament_membership_availability and tournament_membership_lunch — +write-through targets for the "availability" and "lunch_{date}_{category}" +reserved field_keys. Squashed in here rather than a new migration, same +local-dev-only reasoning as above. """ from typing import Sequence, Union @@ -85,8 +90,36 @@ def upgrade() -> None: ) op.create_index(op.f('ix_form_answers_id'), 'form_answers', ['id'], unique=False) + op.create_table('tournament_membership_availability', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('membership_id', sa.Integer(), nullable=False), + sa.Column('tournament_shift_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['membership_id'], ['tournament_memberships.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tournament_shift_id'], ['tournament_shifts.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('membership_id', 'tournament_shift_id', name='uq_membership_availability') + ) + op.create_index(op.f('ix_tournament_membership_availability_id'), 'tournament_membership_availability', ['id'], unique=False) + + op.create_table('tournament_membership_lunch', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('membership_id', sa.Integer(), nullable=False), + sa.Column('date', sa.Date(), nullable=False), + sa.Column('category', sa.String(length=64), nullable=False), + sa.Column('value', sa.String(length=64), nullable=False), + sa.Column('label', sa.String(length=255), nullable=False), + sa.ForeignKeyConstraint(['membership_id'], ['tournament_memberships.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('membership_id', 'date', 'category', 'value', name='uq_membership_lunch_selection') + ) + op.create_index(op.f('ix_tournament_membership_lunch_id'), 'tournament_membership_lunch', ['id'], unique=False) + def downgrade() -> None: + op.drop_index(op.f('ix_tournament_membership_lunch_id'), table_name='tournament_membership_lunch') + op.drop_table('tournament_membership_lunch') + op.drop_index(op.f('ix_tournament_membership_availability_id'), table_name='tournament_membership_availability') + op.drop_table('tournament_membership_availability') op.drop_index(op.f('ix_form_answers_id'), table_name='form_answers') op.drop_table('form_answers') op.drop_index(op.f('ix_form_responses_id'), table_name='form_responses') diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 093ba377..e944bbee 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -60,11 +60,11 @@ class VerificationToken(Base): token_hash = Column(String(255), nullable=False, index=True, unique=True) purpose = Column(String(32), nullable=False) # "signup_verify" | "email_change" | "password_reset" | "email_change_revert" new_email = Column(String(255), nullable=True) # "email_change": new address. "email_change_revert": address to revert TO (old address). - + expires_at = Column(DateTime(timezone=True), nullable=False) used_at = Column(DateTime(timezone=True), nullable=True) created_at = Column(DateTime(timezone=True), default=utcnow) - + user = relationship("User") # --------------------------------------------------------------------------- @@ -121,6 +121,28 @@ class Event(Base): ) +# --------------------------------------------------------------------------- +# SeasonEvent — admin-curated "this canonical event, in this division, is +# active this year" list. Drives the tournament events bulk-load default +# list (see TournamentEvent); independent of any single tournament. +# --------------------------------------------------------------------------- +class SeasonEvent(Base): + __tablename__ = "season_events" + + id = Column(Integer, primary_key=True, index=True) + event_id = Column(Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False) + year = Column(Integer, nullable=False) + division = Column(String(4), nullable=False) + is_active = Column(Boolean, nullable=False, default=False) + created_at = Column(DateTime(timezone=True), default=utcnow) + + event = relationship("Event") + + __table_args__ = ( + UniqueConstraint("event_id", "year", "division", name="uq_season_event"), + ) + + # --------------------------------------------------------------------------- # User — core identity for volunteers, TDs, and admins. # role="admin" bypasses all tournament permission checks; "user" is gated by @@ -142,7 +164,7 @@ class User(Base): email_verified = Column(Boolean, nullable=False, default=False) role = Column(String(32), nullable=False, default="user") # "admin" | "user" status = Column(String(32), nullable=False, default="active") # "active" | "invited" | "deactivated" | "locked" - + # if a student university_id = Column(Integer, ForeignKey("universities.id"), nullable=True) major = Column(String(255), nullable=True) @@ -152,14 +174,14 @@ class User(Base): # if not a student employer = Column(String(255), nullable=True) - + has_competition_experience = Column(Boolean, nullable=True) has_volunteer_experience = Column(Boolean, nullable=True) # has_stem_experience = Column(Boolean, nullable=True) # debatable shirt_size = Column(String(16), nullable=True) dietary_restriction = Column(String(255), nullable=True) - + created_at = Column(DateTime(timezone=True), default=utcnow) updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) @@ -210,19 +232,54 @@ class UserVolunteerExperience(Base): id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey('users.id', ondelete="CASCADE"), nullable=False) - + # manual add by the user tournament_name = Column(String(255), nullable=False) year = Column(Integer, nullable=False) event_id = Column(Integer, ForeignKey('events.id', ondelete="RESTRICT"), nullable=True) role = Column(String(63), nullable=False) - + notes = Column(JSON, nullable=True) # {"event": custom name, "other": free notes} user = relationship("User", back_populates="volunteer_experience") event = relationship("Event", back_populates="user_volunteer_experience") +# --------------------------------------------------------------------------- +# AlumniChapter — a regional hub (e.g. "Bay Area") for alumni coordination. +# --------------------------------------------------------------------------- +class AlumniChapter(Base): + __tablename__ = "alumni_chapters" + + id = Column(Integer, primary_key=True) + name = Column(String(255), nullable=False) + university_id = Column(Integer, ForeignKey("universities.id"), nullable=False, unique=True) + created_at = Column(DateTime(timezone=True), default=utcnow) + + # Relationships + university = relationship("University", back_populates="alumni_chapter") + chapter_memberships = relationship("ChapterMembership", back_populates="alumni_chapter", cascade="all, delete-orphan") + join_codes = relationship("JoinCode", back_populates="alumni_chapter", cascade="all, delete-orphan") + tournament_chapters = relationship("TournamentChapter", back_populates="chapter") + forms = relationship("Form", back_populates="chapter", cascade="all, delete-orphan") + + +# --------------------------------------------------------------------------- +# ChapterMembership — join table, User <-> AlumniChapter. +# --------------------------------------------------------------------------- +class ChapterMembership(Base): + __tablename__ = "chapter_memberships" + + id = Column(Integer, primary_key=True) + chapter_id = Column(Integer, ForeignKey("alumni_chapters.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True) # one chapter per user + role = Column(String(32), nullable=False, default="member") # "lead" | "officer" | "member" + joined_at = Column(DateTime(timezone=True), default=utcnow) + + # Relationships + alumni_chapter = relationship("AlumniChapter", back_populates="chapter_memberships") + user = relationship("User", back_populates="chapter_membership") + # --------------------------------------------------------------------------- # Tournament @@ -344,21 +401,23 @@ class TournamentMembership(Base): tournament = relationship("Tournament", back_populates="memberships") roles = relationship("TournamentMembershipRole", back_populates="membership", cascade="all, delete-orphan") join_code = relationship("JoinCode") + availability_shifts = relationship("TournamentMembershipAvailability", back_populates="membership", cascade="all, delete-orphan") + lunch_selections = relationship("TournamentMembershipLunch", back_populates="membership", cascade="all, delete-orphan") @hybrid_property def is_over_18(self) -> Optional[bool]: if self.user.date_of_birth is None: return None - + return meets_age_requirement(self.user.date_of_birth, self.tournament.start_date, 18) - + @hybrid_property def is_over_21(self) -> Optional[bool]: if self.user.date_of_birth is None: return None - + return meets_age_requirement(self.user.date_of_birth, self.tournament.start_date, 21) - + # TODO: add .expression variants for server-side age filtering once needed. __table_args__ = ( @@ -476,7 +535,7 @@ class TournamentEvent(Base): tournament_id = Column( Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=False ) - + # Custom (event_id-less) events only name = Column(String(255), nullable=True) division = Column(String(4), nullable=True) # "A" | "B" | "C" @@ -536,6 +595,9 @@ class TournamentShift(Base): tournament_events = relationship( "TournamentEvent", secondary="tournament_event_shifts", back_populates="shifts" ) + membership_availabilities = relationship( + "TournamentMembershipAvailability", back_populates="tournament_shift", cascade="all, delete-orphan" + ) # Read by TournamentShiftRead — how many events this shift is attached # to, for the delete-confirm warning. Callers that list many shifts @@ -556,26 +618,17 @@ class TournamentEventShift(Base): # --------------------------------------------------------------------------- -# SeasonEvent — admin-curated "this canonical event, in this division, is -# active this year" list. Drives the tournament events bulk-load default -# list (see TournamentEvent); independent of any single tournament. +# TournamentChapter — junction table, AlumniChapter <-> Tournament (many-to-many). # --------------------------------------------------------------------------- -class SeasonEvent(Base): - __tablename__ = "season_events" - - id = Column(Integer, primary_key=True, index=True) - event_id = Column(Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False) - year = Column(Integer, nullable=False) - division = Column(String(4), nullable=False) - is_active = Column(Boolean, nullable=False, default=False) - created_at = Column(DateTime(timezone=True), default=utcnow) - - event = relationship("Event") +class TournamentChapter(Base): + __tablename__ = "tournament_chapters" - __table_args__ = ( - UniqueConstraint("event_id", "year", "division", name="uq_season_event"), - ) + tournament_id = Column(Integer, ForeignKey("tournaments.id"), primary_key=True) + chapter_id = Column(Integer, ForeignKey("alumni_chapters.id"), primary_key=True) + # Relationships + tournament = relationship("Tournament", back_populates="tournament_chapters") + chapter = relationship("AlumniChapter", back_populates="tournament_chapters") # --------------------------------------------------------------------------- # SheetConfig @@ -601,56 +654,6 @@ class SheetConfig(Base): tournament = relationship("Tournament", back_populates="sheet_configs") -# --------------------------------------------------------------------------- -# AlumniChapter — a regional hub (e.g. "Bay Area") for alumni coordination. -# --------------------------------------------------------------------------- -class AlumniChapter(Base): - __tablename__ = "alumni_chapters" - - id = Column(Integer, primary_key=True) - name = Column(String(255), nullable=False) - university_id = Column(Integer, ForeignKey("universities.id"), nullable=False, unique=True) - created_at = Column(DateTime(timezone=True), default=utcnow) - - # Relationships - university = relationship("University", back_populates="alumni_chapter") - chapter_memberships = relationship("ChapterMembership", back_populates="alumni_chapter", cascade="all, delete-orphan") - join_codes = relationship("JoinCode", back_populates="alumni_chapter", cascade="all, delete-orphan") - tournament_chapters = relationship("TournamentChapter", back_populates="chapter") - forms = relationship("Form", back_populates="chapter", cascade="all, delete-orphan") - - -# --------------------------------------------------------------------------- -# ChapterMembership — join table, User <-> AlumniChapter. -# --------------------------------------------------------------------------- -class ChapterMembership(Base): - __tablename__ = "chapter_memberships" - - id = Column(Integer, primary_key=True) - chapter_id = Column(Integer, ForeignKey("alumni_chapters.id", ondelete="CASCADE"), nullable=False) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True) # one chapter per user - role = Column(String(32), nullable=False, default="member") # "lead" | "officer" | "member" - joined_at = Column(DateTime(timezone=True), default=utcnow) - - # Relationships - alumni_chapter = relationship("AlumniChapter", back_populates="chapter_memberships") - user = relationship("User", back_populates="chapter_membership") - - - -# --------------------------------------------------------------------------- -# TournamentChapter — junction table, AlumniChapter <-> Tournament (many-to-many). -# --------------------------------------------------------------------------- -class TournamentChapter(Base): - __tablename__ = "tournament_chapters" - - tournament_id = Column(Integer, ForeignKey("tournaments.id"), primary_key=True) - chapter_id = Column(Integer, ForeignKey("alumni_chapters.id"), primary_key=True) - - # Relationships - tournament = relationship("Tournament", back_populates="tournament_chapters") - chapter = relationship("AlumniChapter", back_populates="tournament_chapters") - # --------------------------------------------------------------------------- # Form — a first-party form (replaces the Google Forms + sheet-sync # pipeline). Owned by exactly one tournament OR one chapter (owner_type + @@ -777,4 +780,45 @@ class FormAnswer(Base): __table_args__ = ( UniqueConstraint("response_id", "field_id", name="uq_answer_per_field"), - ) \ No newline at end of file + ) + + +# --------------------------------------------------------------------------- +# TournamentMembershipAvailability — write-through target for a form's "availability" +# field_key answer. Reuses TournamentShift directly, no separate catalog. +# --------------------------------------------------------------------------- +class TournamentMembershipAvailability(Base): + __tablename__ = "tournament_membership_availability" + + id = Column(Integer, primary_key=True, index=True) + membership_id = Column(Integer, ForeignKey("tournament_memberships.id", ondelete="CASCADE"), nullable=False) + tournament_shift_id = Column(Integer, ForeignKey("tournament_shifts.id", ondelete="CASCADE"), nullable=False) + + membership = relationship("TournamentMembership", back_populates="availability_shifts") + tournament_shift = relationship("TournamentShift", back_populates="membership_availabilities") + + __table_args__ = ( + UniqueConstraint("membership_id", "tournament_shift_id", name="uq_membership_availability"), + ) + + +# --------------------------------------------------------------------------- +# TournamentMembershipLunch — write-through target for a form's +# "lunch_{date}_{category}" field_key answers. Stores whatever was actually +# selected, keyed by category string — no dedicated menu/catalog table. +# --------------------------------------------------------------------------- +class TournamentMembershipLunch(Base): + __tablename__ = "tournament_membership_lunch" + + id = Column(Integer, primary_key=True, index=True) + membership_id = Column(Integer, ForeignKey("tournament_memberships.id", ondelete="CASCADE"), nullable=False) + date = Column(Date, nullable=False) + category = Column(String(64), nullable=False) + value = Column(String(64), nullable=False) + label = Column(String(255), nullable=False) + + membership = relationship("TournamentMembership", back_populates="lunch_selections") + + __table_args__ = ( + UniqueConstraint("membership_id", "date", "category", "value", name="uq_membership_lunch_selection"), + ) From 77108578351de78d2f6fef767d73dc468e204c6d Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 20:36:42 -0700 Subject: [PATCH 030/185] feat(forms): validate lunch_{date}_{category} field_key requires single/multi-select --- backend/app/core/form/validation.py | 21 +++++++++++++++++---- backend/form-question-types-reference.md | 4 ++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index af5e8e3f..9ad3b3ff 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -9,6 +9,8 @@ resolving to a real field, availability options resolving to a real TournamentShift) and reserved field_key pairing.""" +import re + from pydantic import ValidationError from sqlalchemy.orm import Session @@ -17,14 +19,18 @@ BRANCHING_QUESTION_TYPES = {"single_select_radio", "single_select_dropdown"} -# field_key values with a system-defined meaning. `lunch_{custom}` is also -# reserved (any key starting with "lunch_") but its config shape isn't -# designed yet, so it isn't enforced here — see form-question-types-reference.md. +# field_key values with a system-defined meaning. RESERVED_FIELD_KEY_QUESTION_TYPES = { "availability": {"multi_select_checkbox"}, "event_preference": {"ranked_choice", "multi_select_checkbox", "single_select_dropdown"}, } +# lunch_{date}_{category}, e.g. "lunch_20270213_protein" — date is baked +# into the key, so per-tournament field_key uniqueness already covers +# per-(date, category) uniqueness with no separate check needed. +LUNCH_FIELD_KEY_PATTERN = re.compile(r"^lunch_\d{8}_[a-z0-9_]+$") +LUNCH_QUESTION_TYPES = {"single_select_radio", "multi_select_checkbox"} + class FormFieldValidationError(ValueError): """Raised when a FormField's question_type/config/options don't match @@ -52,11 +58,18 @@ def validate_field_config(question_type: str, config: dict | None) -> dict: def validate_reserved_field_key(field_key: str, question_type: str) -> None: - """Reserved field_keys (availability, event_preference) reuse an + """Reserved field_keys (availability, event_preference, lunch_*) reuse an existing structural question_type rather than introducing their own — reject a reserved key paired with a question_type it doesn't allow. Applies identically regardless of owner_type (tournament vs. chapter); only write-through, not validation, differs by ownership.""" + if LUNCH_FIELD_KEY_PATTERN.match(field_key): + _require( + question_type in LUNCH_QUESTION_TYPES, + f"field_key '{field_key}' requires question_type in {sorted(LUNCH_QUESTION_TYPES)}, got '{question_type}'", + ) + return + allowed_types = RESERVED_FIELD_KEY_QUESTION_TYPES.get(field_key) if allowed_types is None: return diff --git a/backend/form-question-types-reference.md b/backend/form-question-types-reference.md index 3e4f18f9..c53f78c9 100644 --- a/backend/form-question-types-reference.md +++ b/backend/form-question-types-reference.md @@ -115,8 +115,8 @@ Only `single_select_radio` and `single_select_dropdown` options may carry branch | `field_key` | Allowed `question_type`(s) | Write-through | |---|---|---| -| `availability` | `multi_select_checkbox` only | `MembershipAvailability` (tournament-owned forms only) | -| `lunch_{custom}` — TD fills in `{custom}` per lunch question (e.g. `lunch_protein`, `lunch_drink`), one per `TournamentLunchOption` category | single/multi-select depending on the category's `allow_multiple` (config shape still open, discussed in the write-through issue) | `MembershipLunchSelection` (tournament-owned forms only) | +| `availability` | `multi_select_checkbox` only | `TournamentMembershipAvailability` (tournament-owned forms only) | +| `lunch_{date}_{category}` — e.g. `lunch_20270213_protein` (`^lunch_\d{8}_[a-z0-9_]+$`), one per (date, category) pair | `single_select_radio` or `multi_select_checkbox` | `TournamentMembershipLunch` (tournament-owned forms only); no catalog table — stores whatever option was selected, keyed by category string | | `event_preference` | `ranked_choice`, `multi_select_checkbox`, or `single_select_dropdown` | none — generic `FormAnswer` (option `value` should be a real `TournamentEvent` id, not yet strictly validated) | | any TD-typed slug | any type | none — generic `FormAnswer` | From 98356ed29817d2f6d0fc96f400a7c6bd9e7e0aab Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 21:20:38 -0700 Subject: [PATCH 031/185] feat(forms): add availability/lunch diff-sync write-through functions --- backend/app/core/form/write_through.py | 81 ++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 backend/app/core/form/write_through.py diff --git a/backend/app/core/form/write_through.py b/backend/app/core/form/write_through.py new file mode 100644 index 00000000..fce96276 --- /dev/null +++ b/backend/app/core/form/write_through.py @@ -0,0 +1,81 @@ +"""Diff-sync for the structural tables that a form response's reserved-key +answers (`availability`, `lunch_{date}_{category}`) write through to on +tournament-owned forms — see form-question-types-reference.md. Each function +diffs the submitted values against what's already stored and applies only +the delta (insert new, delete removed) rather than replace-all, so an +untouched row (e.g. a different lunch date/category) is never disturbed. + +Callers commit — these only add/delete/flush, so the write-through and the +FormAnswer rows it's derived from land in the same transaction.""" + +from datetime import date as date_type + +from sqlalchemy.orm import Session + +from app.models.models import TournamentMembershipAvailability, TournamentMembershipLunch + + +def sync_availability(db: Session, membership_id: int, tournament_shift_ids: list[int]) -> None: + """Diffs `tournament_shift_ids` against this membership's existing + TournamentMembershipAvailability rows and applies only the delta.""" + existing_ids = { + shift_id + for (shift_id,) in db.query(TournamentMembershipAvailability.tournament_shift_id) + .filter(TournamentMembershipAvailability.membership_id == membership_id) + .all() + } + incoming_ids = set(tournament_shift_ids) + + to_remove = existing_ids - incoming_ids + if to_remove: + db.query(TournamentMembershipAvailability).filter( + TournamentMembershipAvailability.membership_id == membership_id, + TournamentMembershipAvailability.tournament_shift_id.in_(to_remove), + ).delete(synchronize_session=False) + + for shift_id in incoming_ids - existing_ids: + db.add(TournamentMembershipAvailability(membership_id=membership_id, tournament_shift_id=shift_id)) + + db.flush() + + +def sync_lunch( + db: Session, + membership_id: int, + date: date_type, + category: str, + values: list[dict], +) -> None: + """Diffs `values` (each `{"value": ..., "label": ...}`) against this + membership's existing TournamentMembershipLunch rows for this + (date, category) only — rows for any other date or category on the + same membership are never touched.""" + existing_rows = ( + db.query(TournamentMembershipLunch) + .filter( + TournamentMembershipLunch.membership_id == membership_id, + TournamentMembershipLunch.date == date, + TournamentMembershipLunch.category == category, + ) + .all() + ) + existing_by_value = {row.value: row for row in existing_rows} + incoming_by_value = {str(item["value"]): item for item in values} + + for value, row in existing_by_value.items(): + if value not in incoming_by_value: + db.delete(row) + + for value in set(incoming_by_value) - set(existing_by_value): + item = incoming_by_value[value] + db.add( + TournamentMembershipLunch( + membership_id=membership_id, + date=date, + category=category, + value=value, + label=item["label"], + ) + ) + + db.flush() From f6b3e40f69a5b71a6cc6b81e2e83022b4e5479e4 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 21:45:39 -0700 Subject: [PATCH 032/185] feat(forms): wire availability/lunch write-through into response submission --- backend/app/api/routes/forms.py | 47 ++++++++++++++++++++++++++ backend/app/core/form/validation.py | 2 +- backend/app/core/form/write_through.py | 11 +++++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index ac8b8189..8d5b3e2c 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -17,6 +17,7 @@ from app.core.form.branching import missing_required_field_keys from app.core.form.permissions import require_form_manage_access, require_form_view_access from app.core.form.validation import ( + LUNCH_FIELD_KEY_PATTERN, FormFieldValidationError, validate_availability_options, validate_branching_options, @@ -24,6 +25,7 @@ validate_form_for_publish, validate_reserved_field_key, ) +from app.core.form.write_through import parse_lunch_field_key, sync_availability, sync_lunch from app.core.tournament.permissions import MANAGE_FORMS, require_permission from app.db.session import get_db from app.models.models import ( @@ -31,6 +33,7 @@ FormAnswer, FormField, FormResponse, + TournamentMembership, User, utcnow, ) @@ -395,11 +398,55 @@ def submit_form_response( for answer_in in payload.answers: db.add(FormAnswer(response_id=response.id, field_id=answer_in.field_id, value=answer_in.value)) + if form.owner_type == "tournament": + _write_through_reserved_fields(db, form, active_fields, answers_by_field, current_user) + db.commit() db.refresh(response) return response +def _write_through_reserved_fields( + db: Session, + form: Form, + active_fields: list[FormField], + answers_by_field: dict[int, object], + current_user: User, +) -> None: + """Syncs `availability`/`lunch_{date}_{category}` answers into their + structural tables — tournament-owned forms only (see + form-question-types-reference.md). Runs over every active field, not + just answered ones, so a reserved field left blank on resubmit clears + any previously-synced rows rather than leaving them stale.""" + membership = ( + db.query(TournamentMembership) + .filter( + TournamentMembership.user_id == current_user.id, + TournamentMembership.tournament_id == form.tournament_id, + ) + .first() + ) + if membership is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="No membership found for a tournament-owned form response — require_form_view_access should have guaranteed one", + ) + + for field in active_fields: + value = answers_by_field.get(field.id) + selected = value if isinstance(value, list) else ([value] if value else []) + + if field.field_key == "availability": + sync_availability(db, membership.id, [int(v) for v in selected]) + continue + + if LUNCH_FIELD_KEY_PATTERN.match(field.field_key): + lunch_date, category = parse_lunch_field_key(field.field_key) + options_by_value = {opt["value"]: opt["label"] for opt in (field.config or {}).get("options", [])} + values = [{"value": v, "label": options_by_value.get(v, v)} for v in selected] + sync_lunch(db, membership.id, lunch_date, category, values) + + # --------------------------------------------------------------------------- # GET /forms/{form_id}/responses/ — all responses to a form. Manage access # only — this is roster data, not something every member should see. diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index 9ad3b3ff..15841844 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -28,7 +28,7 @@ # lunch_{date}_{category}, e.g. "lunch_20270213_protein" — date is baked # into the key, so per-tournament field_key uniqueness already covers # per-(date, category) uniqueness with no separate check needed. -LUNCH_FIELD_KEY_PATTERN = re.compile(r"^lunch_\d{8}_[a-z0-9_]+$") +LUNCH_FIELD_KEY_PATTERN = re.compile(r"^lunch_(\d{8})_([a-z0-9_]+)$") LUNCH_QUESTION_TYPES = {"single_select_radio", "multi_select_checkbox"} diff --git a/backend/app/core/form/write_through.py b/backend/app/core/form/write_through.py index fce96276..b5bc8ff5 100644 --- a/backend/app/core/form/write_through.py +++ b/backend/app/core/form/write_through.py @@ -8,13 +8,22 @@ Callers commit — these only add/delete/flush, so the write-through and the FormAnswer rows it's derived from land in the same transaction.""" -from datetime import date as date_type +from datetime import date as date_type, datetime from sqlalchemy.orm import Session +from app.core.form.validation import LUNCH_FIELD_KEY_PATTERN from app.models.models import TournamentMembershipAvailability, TournamentMembershipLunch +def parse_lunch_field_key(field_key: str) -> tuple[date_type, str]: + """Splits a `lunch_{date}_{category}` field_key (already known to match + LUNCH_FIELD_KEY_PATTERN) into its date and category parts.""" + match = LUNCH_FIELD_KEY_PATTERN.match(field_key) + date_str, category = match.group(1), match.group(2) + return datetime.strptime(date_str, "%Y%m%d").date(), category + + def sync_availability(db: Session, membership_id: int, tournament_shift_ids: list[int]) -> None: """Diffs `tournament_shift_ids` against this membership's existing TournamentMembershipAvailability rows and applies only the delta.""" From 6b2bb70a26d2d6d2fd77ee33fb96e9d39fddbc3a Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 22:02:11 -0700 Subject: [PATCH 033/185] feat(forms): block TournamentShift deletion when referenced by membership availability --- backend/app/api/routes/tournament/shifts.py | 19 ++++++++++++++++--- backend/app/models/models.py | 8 ++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/backend/app/api/routes/tournament/shifts.py b/backend/app/api/routes/tournament/shifts.py index 3e34be58..fc74db49 100644 --- a/backend/app/api/routes/tournament/shifts.py +++ b/backend/app/api/routes/tournament/shifts.py @@ -95,9 +95,15 @@ def update_shift( # --------------------------------------------------------------------------- # DELETE /tournaments/{tournament_id}/shifts/{shift_id}/ — manage_events -# No guard — cascades through tournament_event_shifts (ondelete="CASCADE"), -# silently detaching from any events it was attached to. Intentionally -# different from how TimeBlock deletion worked in the old scrapped design. +# Event references are not a guard — deletion cascades through +# tournament_event_shifts (ondelete="CASCADE"), silently detaching from any +# events it was attached to. Intentionally different from how TimeBlock +# deletion worked in the old scrapped design. +# +# Membership availability *is* a hard guard, unlike events — it's +# member-submitted data (write-through from a form response, see +# app/core/form/write_through.py), not planning structure a TD can just +# re-derive, so silently cascading it away on a shift edit isn't acceptable. # --------------------------------------------------------------------------- @router.delete("/{shift_id}/", status_code=status.HTTP_204_NO_CONTENT) def delete_shift( @@ -110,6 +116,13 @@ def delete_shift( require_not_archived(tournament) shift = get_scoped_or_404(db, TournamentShift, shift_id, tournament_id, "Shift") + + if shift.availability_count: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Shift has {shift.availability_count} membership availability selection(s) — cannot delete", + ) + db.delete(shift) db.commit() diff --git a/backend/app/models/models.py b/backend/app/models/models.py index e944bbee..dc5b4082 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -606,6 +606,14 @@ class TournamentShift(Base): def event_count(self) -> int: return len(self.tournament_events) + # Unlike event_count (advisory only — deletion still cascades through + # events), a nonzero availability_count hard-blocks deletion — see + # delete_shift. Availability write-through is membership-owned data, + # not something a shift edit should silently detach. + @property + def availability_count(self) -> int: + return len(self.membership_availabilities) + # --------------------------------------------------------------------------- # TournamentEventShift — bridge table: TournamentEvent <-> TournamentShift. From f529498d76ed87e069c7bfa3b9e953d84752f3b1 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 22:08:04 -0700 Subject: [PATCH 034/185] feat(forms): lock answered fields against edit --- backend/app/api/routes/forms.py | 13 ++++++++++++- backend/app/core/form/__init__.py | 10 +++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 8d5b3e2c..0f96009a 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -5,6 +5,7 @@ from app.core.auth import get_current_user from app.core.chapters import require_officer_or_lead from app.core.form import ( + field_has_answers, field_key_taken_in_tournament, remove_form_field, reorder_field, @@ -282,7 +283,11 @@ def create_form_field( # --------------------------------------------------------------------------- -# PATCH /forms/{form_id}/fields/{field_id}/ +# PATCH /forms/{form_id}/fields/{field_id}/ — locked once the field has any +# FormAnswer (see field_has_answers): editing label/config/type of an +# already-answered field would silently invalidate submitted data, so it's +# a flat reject rather than a partial one. New fields on a published form +# are unaffected — they start with zero answers. # --------------------------------------------------------------------------- @router.patch("/forms/{form_id}/fields/{field_id}/", response_model=FormFieldRead) def edit_form_field( @@ -295,6 +300,12 @@ def edit_form_field( if not field: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Field not found") + if field_has_answers(db, field.id): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Field has existing answers — it cannot be edited", + ) + final_question_type = payload.question_type if payload.question_type is not None else field.question_type final_config = payload.config if payload.config is not None else field.config diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index 3b0cc664..4b15673f 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -25,14 +25,18 @@ def field_key_taken_in_tournament(db: Session, tournament_id: int, field_key: st ) +def field_has_answers(db: Session, field_id: int) -> bool: + """True if any FormAnswer exists for this field — locks it against + edit (see forms.py's edit_form_field) and hard delete (below).""" + return db.query(FormAnswer).filter(FormAnswer.field_id == field_id).first() is not None + + def remove_form_field( db: Session, field: FormField ) -> bool: - - has_answers = db.query(FormAnswer).filter(FormAnswer.field_id == field.id).first() is not None - if has_answers: + if field_has_answers(db, field.id): field.is_archived = True db.commit() return True From c83c1229a66f25c2ef36069f9d7965c2c8f837bc Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 18 Aug 2026 22:45:30 -0700 Subject: [PATCH 035/185] test(forms): cover write-through sync, shift deletion guard, and field edit lock --- backend/tests/api/test_forms.py | 158 +++++++++++++- backend/tests/api/tournament/test_shifts.py | 23 ++ backend/tests/core/test_form_write_through.py | 199 ++++++++++++++++++ 3 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 backend/tests/core/test_form_write_through.py diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index 4b52126b..77658cc8 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -2,7 +2,7 @@ helpers, slugify/uniqueness, and the access-control dependency functions are covered directly in tests/core/test_forms.py — this file exercises the HTTP layer on top.""" -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone import pytest @@ -16,6 +16,9 @@ FormAnswer, FormField, FormResponse, + TournamentMembership, + TournamentMembershipAvailability, + TournamentMembershipLunch, TournamentShift, ) @@ -347,6 +350,29 @@ def test_missing_field_key_rejected(self, client, db, td_user, td_tournament): ) assert res.status_code == 422 + def test_new_field_addable_to_form_with_existing_responses(self, client, db, td_user, td_tournament): + """Locking only applies to fields that already have answers — adding + a brand new field to an already-answered form is unaffected.""" + form = _make_form(db, td_user, td_tournament) + existing_field = _make_field(db, form, field_key="color") + response = FormResponse(form_id=form.id, user_id=td_user.id) + db.add(response) + db.flush() + db.add(FormAnswer(response_id=response.id, field_id=existing_field.id, value=["opt_1"])) + db.commit() + + login(client, "td@test.com", "tdpass") + res = client.post( + f"/forms/{form.id}/fields/", + json={ + "label": "New question", + "field_key": "new_question", + "question_type": "short_text", + "config": {"required": False, "max_length": 100}, + }, + ) + assert res.status_code == 201 + # --------------------------------------------------------------------------- # PATCH / DELETE /forms/{form_id}/fields/{field_id}/ @@ -374,6 +400,27 @@ def test_patch_question_type_replaces_field_keeping_key(self, client, db, td_use assert res.json()["field_key"] == "color" assert res.json()["id"] != field.id + def test_patch_rejected_once_field_has_answers(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color") + response = FormResponse(form_id=form.id, user_id=td_user.id) + db.add(response) + db.flush() + db.add(FormAnswer(response_id=response.id, field_id=field.id, value=["opt_1"])) + db.commit() + login(client, "td@test.com", "tdpass") + res = client.patch(f"/forms/{form.id}/fields/{field.id}/", json={"label": "New label"}) + assert res.status_code == 409 + assert db.query(FormField).filter(FormField.id == field.id).first().label == "Favorite color" + + def test_patch_allowed_when_field_has_no_answers(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color") + db.commit() + login(client, "td@test.com", "tdpass") + res = client.patch(f"/forms/{form.id}/fields/{field.id}/", json={"label": "New label"}) + assert res.status_code == 200 + def test_delete_hard_deletes_when_no_answers(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament) field = _make_field(db, form, field_key="color") @@ -448,6 +495,115 @@ def test_non_member_forbidden(self, client, db, td_user, td_tournament, other_us assert res.status_code == 403 +# --------------------------------------------------------------------------- +# Write-through — availability/lunch reserved-key answers syncing into their +# structural tables (app/core/form/write_through.py), tournament-owned forms +# only. See tests/core/test_form_write_through.py for the diff-sync logic +# itself; this covers the route wiring. +# --------------------------------------------------------------------------- + +class TestWriteThroughOnSubmit: + def test_availability_write_through_on_tournament_form(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + shift = TournamentShift( + tournament_id=td_tournament.id, + label="Saturday", + start=datetime.now(timezone.utc), + end=datetime.now(timezone.utc) + timedelta(hours=8), + ) + db.add(shift) + db.flush() + field = _make_field( + db, form, field_key="availability", question_type="multi_select_checkbox", + config={"required": False, "options": [{"value": str(shift.id), "label": shift.label}]}, + ) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post( + f"/forms/{form.id}/responses/", + json={"answers": [{"field_id": field.id, "value": [str(shift.id)]}]}, + ) + assert res.status_code == 200 + + membership = ( + db.query(TournamentMembership) + .filter(TournamentMembership.user_id == td_user.id, TournamentMembership.tournament_id == td_tournament.id) + .first() + ) + rows = db.query(TournamentMembershipAvailability).filter( + TournamentMembershipAvailability.membership_id == membership.id + ).all() + assert [row.tournament_shift_id for row in rows] == [shift.id] + + def test_lunch_write_through_on_tournament_form(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field( + db, form, field_key="lunch_20270213_protein", question_type="single_select_radio", + config={ + "required": False, + "options": [{"value": "chicken", "label": "Chicken"}, {"value": "tofu", "label": "Tofu"}], + }, + ) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post( + f"/forms/{form.id}/responses/", + json={"answers": [{"field_id": field.id, "value": "chicken"}]}, + ) + assert res.status_code == 200 + + membership = ( + db.query(TournamentMembership) + .filter(TournamentMembership.user_id == td_user.id, TournamentMembership.tournament_id == td_tournament.id) + .first() + ) + rows = db.query(TournamentMembershipLunch).filter( + TournamentMembershipLunch.membership_id == membership.id + ).all() + assert len(rows) == 1 + assert rows[0].value == "chicken" + assert rows[0].label == "Chicken" + assert rows[0].category == "protein" + assert rows[0].date == date(2027, 2, 13) + + def test_availability_answer_on_chapter_form_saves_but_does_not_write_through(self, client, db, td_user, chapter): + form = _make_chapter_form(db, td_user, chapter) + field = _make_field( + db, form, field_key="availability", question_type="multi_select_checkbox", + config={"required": False, "options": [{"value": "not_a_real_shift_id", "label": "Whenever"}]}, + ) + db.commit() + _chapter_lead(db, chapter) + login(client, "chapterlead@test.com", "LeadPass123!") + + res = client.post( + f"/forms/{form.id}/responses/", + json={"answers": [{"field_id": field.id, "value": ["not_a_real_shift_id"]}]}, + ) + assert res.status_code == 200 + assert res.json()["answers"][0]["value"] == ["not_a_real_shift_id"] + assert db.query(TournamentMembershipAvailability).count() == 0 + + def test_lunch_answer_on_chapter_form_saves_but_does_not_write_through(self, client, db, td_user, chapter): + form = _make_chapter_form(db, td_user, chapter) + field = _make_field( + db, form, field_key="lunch_20270213_protein", question_type="single_select_radio", + config={"required": False, "options": [{"value": "chicken", "label": "Chicken"}]}, + ) + db.commit() + _chapter_lead(db, chapter) + login(client, "chapterlead@test.com", "LeadPass123!") + + res = client.post( + f"/forms/{form.id}/responses/", + json={"answers": [{"field_id": field.id, "value": "chicken"}]}, + ) + assert res.status_code == 200 + assert db.query(TournamentMembershipLunch).count() == 0 + + # --------------------------------------------------------------------------- # GET /forms/{form_id}/responses/ and /responses/me/ # --------------------------------------------------------------------------- diff --git a/backend/tests/api/tournament/test_shifts.py b/backend/tests/api/tournament/test_shifts.py index 20a7538e..8e634f1d 100644 --- a/backend/tests/api/tournament/test_shifts.py +++ b/backend/tests/api/tournament/test_shifts.py @@ -4,6 +4,8 @@ from tests.conftest import grant_role, login +from app.models.models import TournamentMembership, TournamentMembershipAvailability + # td_tournament spans [today, today + 1 day] — event/shift times must fall # within that window now that tournament-bounds validation exists. EVENT_DATE = date.today().isoformat() @@ -135,6 +137,27 @@ def test_delete_shift_attached_to_two_events_detaches_both(client, td_user, td_t assert e2["shifts"] == [] +def test_delete_shift_blocked_when_referenced_by_availability(client, db, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + shift = _make_shift(client, td_tournament.id).json() + + membership = ( + db.query(TournamentMembership) + .filter(TournamentMembership.user_id == td_user.id, TournamentMembership.tournament_id == td_tournament.id) + .first() + ) + db.add(TournamentMembershipAvailability(membership_id=membership.id, tournament_shift_id=shift["id"])) + db.commit() + + response = client.delete(f"/tournaments/{td_tournament.id}/shifts/{shift['id']}/") + assert response.status_code == 409 + assert "1" in response.json()["detail"] + + # Not deleted. + listed = client.get(f"/tournaments/{td_tournament.id}/shifts/").json() + assert any(s["id"] == shift["id"] for s in listed) + + def test_shift_routes_require_manage_events(client, td_user, other_tournament, db): grant_role(db, other_tournament, td_user, "Volunteer") login(client, "td@test.com", "tdpass") diff --git a/backend/tests/core/test_form_write_through.py b/backend/tests/core/test_form_write_through.py new file mode 100644 index 00000000..b3849ad8 --- /dev/null +++ b/backend/tests/core/test_form_write_through.py @@ -0,0 +1,199 @@ +"""Tests for app/core/form/write_through.py — the diff-sync functions that +apply availability/lunch reserved-key answers to their structural tables. +See tests/api/test_forms.py for the route-level wiring (fires on +tournament-owned forms, no-ops on chapter-owned forms).""" +from datetime import date, datetime, timedelta, timezone + +import pytest + +from app.core.form.write_through import parse_lunch_field_key, sync_availability, sync_lunch +from app.models.models import ( + TournamentMembership, + TournamentMembershipAvailability, + TournamentMembershipLunch, + TournamentShift, +) + + +def _make_shift(db, tournament, label="Shift"): + shift = TournamentShift( + tournament_id=tournament.id, + label=label, + start=datetime.now(timezone.utc), + end=datetime.now(timezone.utc) + timedelta(hours=4), + ) + db.add(shift) + db.flush() + return shift + + +@pytest.fixture +def membership(db, td_user, td_tournament): + return ( + db.query(TournamentMembership) + .filter(TournamentMembership.user_id == td_user.id, TournamentMembership.tournament_id == td_tournament.id) + .first() + ) + + +def _availability_shift_ids(db, membership_id): + return { + shift_id + for (shift_id,) in db.query(TournamentMembershipAvailability.tournament_shift_id) + .filter(TournamentMembershipAvailability.membership_id == membership_id) + .all() + } + + +def _lunch_rows(db, membership_id, lunch_date, category): + return ( + db.query(TournamentMembershipLunch) + .filter( + TournamentMembershipLunch.membership_id == membership_id, + TournamentMembershipLunch.date == lunch_date, + TournamentMembershipLunch.category == category, + ) + .all() + ) + + +# --------------------------------------------------------------------------- +# sync_availability +# --------------------------------------------------------------------------- + +class TestSyncAvailability: + def test_insert_only(self, db, td_tournament, membership): + s1 = _make_shift(db, td_tournament, "Morning") + s2 = _make_shift(db, td_tournament, "Afternoon") + db.commit() + + sync_availability(db, membership.id, [s1.id, s2.id]) + db.commit() + + assert _availability_shift_ids(db, membership.id) == {s1.id, s2.id} + + def test_delete_only(self, db, td_tournament, membership): + s1 = _make_shift(db, td_tournament, "Morning") + s2 = _make_shift(db, td_tournament, "Afternoon") + db.commit() + sync_availability(db, membership.id, [s1.id, s2.id]) + db.commit() + + sync_availability(db, membership.id, []) + db.commit() + + assert _availability_shift_ids(db, membership.id) == set() + + def test_mixed_diff(self, db, td_tournament, membership): + s1 = _make_shift(db, td_tournament, "Morning") + s2 = _make_shift(db, td_tournament, "Afternoon") + s3 = _make_shift(db, td_tournament, "Evening") + db.commit() + sync_availability(db, membership.id, [s1.id, s2.id]) + db.commit() + + # Drop s1, keep s2, add s3. + sync_availability(db, membership.id, [s2.id, s3.id]) + db.commit() + + assert _availability_shift_ids(db, membership.id) == {s2.id, s3.id} + + def test_resync_with_same_ids_is_a_noop(self, db, td_tournament, membership): + s1 = _make_shift(db, td_tournament, "Morning") + db.commit() + sync_availability(db, membership.id, [s1.id]) + db.commit() + + sync_availability(db, membership.id, [s1.id]) + db.commit() + + assert _availability_shift_ids(db, membership.id) == {s1.id} + + +# --------------------------------------------------------------------------- +# sync_lunch +# --------------------------------------------------------------------------- + +class TestSyncLunch: + LUNCH_DATE = date(2027, 2, 13) + + def test_insert_only(self, db, membership): + sync_lunch( + db, membership.id, self.LUNCH_DATE, "protein", + [{"value": "chicken", "label": "Chicken"}, {"value": "tofu", "label": "Tofu"}], + ) + db.commit() + + rows = _lunch_rows(db, membership.id, self.LUNCH_DATE, "protein") + assert {row.value for row in rows} == {"chicken", "tofu"} + + def test_delete_only(self, db, membership): + sync_lunch(db, membership.id, self.LUNCH_DATE, "protein", [{"value": "chicken", "label": "Chicken"}]) + db.commit() + + sync_lunch(db, membership.id, self.LUNCH_DATE, "protein", []) + db.commit() + + assert _lunch_rows(db, membership.id, self.LUNCH_DATE, "protein") == [] + + def test_mixed_diff(self, db, membership): + sync_lunch( + db, membership.id, self.LUNCH_DATE, "protein", + [{"value": "chicken", "label": "Chicken"}, {"value": "tofu", "label": "Tofu"}], + ) + db.commit() + + # Drop chicken, keep tofu, add beef. + sync_lunch( + db, membership.id, self.LUNCH_DATE, "protein", + [{"value": "tofu", "label": "Tofu"}, {"value": "beef", "label": "Beef"}], + ) + db.commit() + + rows = _lunch_rows(db, membership.id, self.LUNCH_DATE, "protein") + assert {row.value for row in rows} == {"tofu", "beef"} + + def test_category_isolation(self, db, membership): + """Syncing one category never touches another category's rows for + the same membership/date.""" + sync_lunch(db, membership.id, self.LUNCH_DATE, "protein", [{"value": "chicken", "label": "Chicken"}]) + sync_lunch(db, membership.id, self.LUNCH_DATE, "drink", [{"value": "water", "label": "Water"}]) + db.commit() + + # Resync protein down to empty — drink must survive untouched. + sync_lunch(db, membership.id, self.LUNCH_DATE, "protein", []) + db.commit() + + assert _lunch_rows(db, membership.id, self.LUNCH_DATE, "protein") == [] + drink_rows = _lunch_rows(db, membership.id, self.LUNCH_DATE, "drink") + assert {row.value for row in drink_rows} == {"water"} + + def test_date_isolation(self, db, membership): + """Same category, different date — also isolated.""" + other_date = date(2027, 2, 14) + sync_lunch(db, membership.id, self.LUNCH_DATE, "protein", [{"value": "chicken", "label": "Chicken"}]) + sync_lunch(db, membership.id, other_date, "protein", [{"value": "tofu", "label": "Tofu"}]) + db.commit() + + sync_lunch(db, membership.id, self.LUNCH_DATE, "protein", []) + db.commit() + + assert _lunch_rows(db, membership.id, self.LUNCH_DATE, "protein") == [] + other_rows = _lunch_rows(db, membership.id, other_date, "protein") + assert {row.value for row in other_rows} == {"tofu"} + + +# --------------------------------------------------------------------------- +# parse_lunch_field_key +# --------------------------------------------------------------------------- + +class TestParseLunchFieldKey: + def test_splits_date_and_category(self): + lunch_date, category = parse_lunch_field_key("lunch_20270213_protein") + assert lunch_date == date(2027, 2, 13) + assert category == "protein" + + def test_multi_word_category(self): + lunch_date, category = parse_lunch_field_key("lunch_20270213_main_course") + assert lunch_date == date(2027, 2, 13) + assert category == "main_course" From c89f0805e0dac5738edabfe6f1d8b3e4cca26832 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 17:16:54 -0700 Subject: [PATCH 036/185] feat(forms): replace per-field routes with bulk PUT + option-id edit lifecycle --- .../versions/7db31ae17e3c_forms_core_model.py | 14 + backend/app/api/routes/forms.py | 291 +++++++++-------- backend/app/core/form/__init__.py | 162 +++++----- backend/app/core/form/validation.py | 37 ++- backend/app/models/models.py | 26 ++ backend/app/schemas/form.py | 53 +-- backend/tests/api/test_forms.py | 304 ------------------ backend/tests/core/test_forms.py | 23 +- 8 files changed, 336 insertions(+), 574 deletions(-) diff --git a/backend/alembic/versions/7db31ae17e3c_forms_core_model.py b/backend/alembic/versions/7db31ae17e3c_forms_core_model.py index f0e05bea..f50cf76a 100644 --- a/backend/alembic/versions/7db31ae17e3c_forms_core_model.py +++ b/backend/alembic/versions/7db31ae17e3c_forms_core_model.py @@ -114,8 +114,22 @@ def upgrade() -> None: ) op.create_index(op.f('ix_tournament_membership_lunch_id'), 'tournament_membership_lunch', ['id'], unique=False) + op.create_table('form_response_pending_updates', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('response_id', sa.Integer(), nullable=False), + sa.Column('field_key', sa.String(length=64), nullable=False), + sa.Column('reason', sa.String(length=32), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['response_id'], ['form_responses.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('response_id', 'field_key', name='uq_pending_update_per_response_field') + ) + op.create_index(op.f('ix_form_response_pending_updates_id'), 'form_response_pending_updates', ['id'], unique=False) + def downgrade() -> None: + op.drop_index(op.f('ix_form_response_pending_updates_id'), table_name='form_response_pending_updates') + op.drop_table('form_response_pending_updates') op.drop_index(op.f('ix_tournament_membership_lunch_id'), table_name='tournament_membership_lunch') op.drop_table('tournament_membership_lunch') op.drop_index(op.f('ix_tournament_membership_availability_id'), table_name='tournament_membership_availability') diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 0f96009a..6687c4e6 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -1,27 +1,25 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import func from sqlalchemy.orm import Session +from sqlalchemy.orm.attributes import flag_modified from app.core.auth import get_current_user from app.core.chapters import require_officer_or_lead from app.core.form import ( - field_has_answers, + apply_option_archiving, field_key_taken_in_tournament, - remove_form_field, - reorder_field, - replace_field_type, + flag_pending_updates_for_archived_options, + flag_pending_updates_for_field, resolve_field_options, - set_field_config, slugify, - update_field_text, ) from app.core.form.branching import missing_required_field_keys from app.core.form.permissions import require_form_manage_access, require_form_view_access from app.core.form.validation import ( LUNCH_FIELD_KEY_PATTERN, FormFieldValidationError, + collect_active_field_errors, validate_availability_options, - validate_branching_options, validate_field_config, validate_form_for_publish, validate_reserved_field_key, @@ -34,15 +32,15 @@ FormAnswer, FormField, FormResponse, + FormResponsePendingUpdate, TournamentMembership, User, utcnow, ) from app.schemas.form import ( + BulkFieldsUpdate, FormCreate, - FormFieldCreate, FormFieldRead, - FormFieldUpdate, FormRead, FormResponseCreate, FormResponseRead, @@ -217,144 +215,171 @@ def delete_form( # --------------------------------------------------------------------------- -# POST /forms/{form_id}/fields/ — field_key is TD-typed (separate from -# label), normalized server-side via slugify(). For tournament-owned forms -# the normalized key must be unique across every form that tournament owns -# (not just this one) since it's a TD-visible dashboard lookup key; -# collisions 409 rather than auto-suffixing so the TD can pick a more -# distinct key instead of silently getting a different one than they typed. -# Chapter-owned forms fall back to the plain per-form uniqueness the DB -# constraint already enforces. +# PUT /forms/{form_id}/fields/ — replaces the field list wholesale. Supersedes +# the old per-field POST/PATCH/DELETE routes: the client owns in-progress +# edits locally (no server-side draft/staging), and this request is the +# "go live" action. An entry with `id` updates that field; an entry with no +# `id` creates one; a currently-live field whose `id` is absent from the +# payload is removed. +# +# draft-status forms apply directly (hard delete/update/insert) — nothing +# on a draft form has ever been answerable, so there's no history to +# protect. published-status forms archive instead of hard-deleting/losing +# data: a removed or question_type-changed field is archived (and, for a +# type change, replaced by a new field at the same list position inheriting +# the old field_key); an option dropped from an otherwise-unchanged field's +# config is archived in place rather than removed from storage. Either way +# the whole batch is applied inside one transaction, flushed (so newly +# created fields get real ids), validated as a whole via +# collect_active_field_errors, and only committed if that validation +# passes — an invalid next_field_id (including one that would've pointed +# at a field this same request removes) rolls the whole request back. # --------------------------------------------------------------------------- -@router.post("/forms/{form_id}/fields/", response_model=FormFieldRead, status_code=status.HTTP_201_CREATED) -def create_form_field( - payload: FormFieldCreate, +@router.put("/forms/{form_id}/fields/", response_model=list[FormFieldRead]) +def bulk_update_fields( + payload: BulkFieldsUpdate, db: Session = Depends(get_db), form: Form = Depends(require_form_manage_access), ): - field_key = slugify(payload.field_key) + is_published = form.status == "published" - if form.owner_type == "tournament": - if field_key_taken_in_tournament(db, form.tournament_id, field_key): - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"field_key '{field_key}' is already in use elsewhere in this tournament — pick a more distinct label", - ) - else: - existing = ( - db.query(FormField) - .filter(FormField.form_id == form.id, FormField.field_key == field_key) - .first() - ) - if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"field_key '{field_key}' is already in use on this form — pick a more distinct label", - ) - - try: - normalized_config = validate_field_config(payload.question_type, payload.config) - validate_reserved_field_key(field_key, payload.question_type) - validate_branching_options(db, form.id, payload.question_type, normalized_config) - if field_key == "availability" and payload.question_type == "multi_select_checkbox": - validate_availability_options(db, form.tournament_id, normalized_config) - except FormFieldValidationError as e: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) - - order = payload.order - if order is None: - max_order = db.query(func.max(FormField.order)).filter(FormField.form_id == form.id).scalar() - order = (max_order or 0) + 1 - - field = FormField( - form_id=form.id, - order=order, - label=payload.label, - description=payload.description, - question_type=payload.question_type, - field_key=field_key, - config=normalized_config, - is_archived=False, + live_fields = ( + db.query(FormField) + .filter(FormField.form_id == form.id, FormField.is_archived == False) + .all() ) - db.add(field) - db.commit() - db.refresh(field) - return field - + live_by_id = {f.id: f for f in live_fields} -# --------------------------------------------------------------------------- -# PATCH /forms/{form_id}/fields/{field_id}/ — locked once the field has any -# FormAnswer (see field_has_answers): editing label/config/type of an -# already-answered field would silently invalidate submitted data, so it's -# a flat reject rather than a partial one. New fields on a published form -# are unaffected — they start with zero answers. -# --------------------------------------------------------------------------- -@router.patch("/forms/{form_id}/fields/{field_id}/", response_model=FormFieldRead) -def edit_form_field( - field_id: int, - payload: FormFieldUpdate, - db: Session = Depends(get_db), - form: Form = Depends(require_form_manage_access), -): - field = db.query(FormField).filter(FormField.id == field_id, FormField.form_id == form.id).first() - if not field: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Field not found") - - if field_has_answers(db, field.id): + submitted_ids = {e.id for e in payload.fields if e.id is not None} + unknown_ids = submitted_ids - set(live_by_id) + if unknown_ids: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Field has existing answers — it cannot be edited", + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"field id(s) not found on this form: {sorted(unknown_ids)}", ) - final_question_type = payload.question_type if payload.question_type is not None else field.question_type - final_config = payload.config if payload.config is not None else field.config - - try: - normalized_config = validate_field_config(final_question_type, final_config) - validate_reserved_field_key(field.field_key, final_question_type) - validate_branching_options(db, form.id, final_question_type, normalized_config, field_id=field.id) - if field.field_key == "availability" and final_question_type == "multi_select_checkbox": - validate_availability_options(db, form.tournament_id, normalized_config) - except FormFieldValidationError as e: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) - - if payload.question_type is not None and payload.question_type != field.question_type: - field = replace_field_type(db, field, payload.question_type) - - if payload.label is not None or payload.description is not None: - field = update_field_text(db, field, payload.label, payload.description) - - if payload.order is not None: - field = reorder_field(db, field, payload.order) - - if payload.config is not None: - field = set_field_config(db, field, normalized_config) - - return field + new_entries = [e for e in payload.fields if e.id is None] + new_keys = [slugify(e.field_key or "") for e in new_entries] + if len(new_keys) != len(set(new_keys)): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="duplicate field_key among the fields being created in this request", + ) + def _check_field_key_available(field_key: str) -> None: + if form.owner_type == "tournament": + taken = field_key_taken_in_tournament(db, form.tournament_id, field_key) + else: + taken = ( + db.query(FormField) + .filter(FormField.form_id == form.id, FormField.field_key == field_key) + .first() + is not None + ) + if taken: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"field_key '{field_key}' is already in use — pick a more distinct label", + ) -# --------------------------------------------------------------------------- -# DELETE /forms/{form_id}/fields/{field_id}/ -# Archives a form field if responses exist. Hard deletes if responses do -# not exist. -# --------------------------------------------------------------------------- -@router.delete("/forms/{form_id}/fields/{field_id}/") -def delete_or_archive_form_field( - field_id: int, - db: Session = Depends(get_db), - form: Form = Depends(require_form_manage_access), -): - field = db.query(FormField).filter(FormField.id == field_id, FormField.form_id == form.id).first() - if not field: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Field not found") + def _validate_config(question_type: str, config: dict | None, field_key: str) -> dict: + # Only structural/self-contained checks run here, before the batch + # is flushed — next_field_id resolution needs every field (including + # ones this same request creates) to have a real id first, so that's + # deferred to the collect_active_field_errors pass below. + try: + normalized = validate_field_config(question_type, config) + validate_reserved_field_key(field_key, question_type) + if field_key == "availability" and question_type == "multi_select_checkbox": + validate_availability_options(db, form.tournament_id, normalized) + except FormFieldValidationError as e: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) + return normalized + + pending_flags: list[tuple[str, str, int | None, list[str]]] = [] + # (field_key, reason, field_id_for_answer_lookup, archived_option_ids) + + order = 1 + for entry in payload.fields: + if entry.id is not None: + field = live_by_id[entry.id] + normalized_config = _validate_config(entry.question_type, entry.config, field.field_key) + type_changed = entry.question_type != field.question_type + + if type_changed and is_published: + field.is_archived = True + old_key = field.field_key + field.field_key = f"{old_key}_archived_{field.id}" + pending_flags.append((old_key, "field_replaced", field.id, [])) + + new_field = FormField( + form_id=form.id, + order=order, + label=entry.label, + description=entry.description, + question_type=entry.question_type, + field_key=old_key, + config=normalized_config, + is_archived=False, + ) + db.add(new_field) + else: + if is_published: + normalized_config, archived_option_ids = apply_option_archiving(field.config, normalized_config) + if archived_option_ids: + pending_flags.append((field.field_key, "option_archived", field.id, archived_option_ids)) + field.order = order + field.label = entry.label + field.description = entry.description + field.question_type = entry.question_type + field.config = normalized_config + flag_modified(field, "config") + else: + field_key = slugify(entry.field_key or "") + _check_field_key_available(field_key) + normalized_config = _validate_config(entry.question_type, entry.config, field_key) + new_field = FormField( + form_id=form.id, + order=order, + label=entry.label, + description=entry.description, + question_type=entry.question_type, + field_key=field_key, + config=normalized_config, + is_archived=False, + ) + db.add(new_field) + order += 1 + + removed_fields = [f for fid, f in live_by_id.items() if fid not in submitted_ids] + for field in removed_fields: + if is_published: + field.is_archived = True + pending_flags.append((field.field_key, "field_replaced", field.id, [])) + else: + db.delete(field) + + db.flush() + + errors = collect_active_field_errors(db, form) + if errors: + db.rollback() + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="; ".join(errors)) + + for field_key, reason, field_id, archived_option_ids in pending_flags: + if reason == "field_replaced": + flag_pending_updates_for_field(db, field_id, field_key, "field_replaced") + else: + flag_pending_updates_for_archived_options(db, live_by_id[field_id], archived_option_ids) - was_archived = remove_form_field(db=db, field=field) + db.commit() - return { - "success": True, - "action": "archived" if was_archived else "deleted", - "field_id": field_id, - } + return ( + db.query(FormField) + .filter(FormField.form_id == form.id, FormField.is_archived == False) + .order_by(FormField.order) + .all() + ) # --------------------------------------------------------------------------- diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index 4b15673f..0ed43027 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -1,6 +1,6 @@ from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified -from app.models.models import Form, FormAnswer, FormField, TournamentEvent +from app.models.models import Form, FormAnswer, FormField, FormResponsePendingUpdate, TournamentEvent import re # Regular Expressions for searching, matching, and extracting patterns in text strings @@ -110,88 +110,85 @@ def replace_field_type( db.refresh(new_field) return new_field -def add_option( - db: Session, - field: FormField, - label: str | None = None, -) -> FormField: - - config = dict(field.config or {}) - options = config.get("options", []) - - max_id = 0 - for option in options: - match = re.search(r"^opt_(\d+)$", option.get("id", "")) - if match: - num = int(match.group(1)) - if num > max_id: - max_id = num - - new_option = { - "id": f"opt_{max_id + 1}", - "label": label, - "archived": False, - "next_section_id": None, - "allow_other": False +def apply_option_archiving(old_config: dict | None, new_config: dict) -> tuple[dict, list[str]]: + """For an in-place field update on a published form: an option_id + present in the old config but absent from the submitted config is kept + in storage with `is_archived: true` added, rather than dropped — a + response referencing it must keep resolving. Returns the merged config + and the option_ids newly archived by this call (empty if none, or if + `old_config`/`new_config` isn't an options-bearing shape) — the caller + uses that list to flag affected responses (see + flag_pending_updates_for_archived_options).""" + old_options = (old_config or {}).get("options") + if old_options is None or "options" not in new_config: + return new_config, [] + + new_ids = {o["option_id"] for o in new_config["options"]} + newly_archived_ids: list[str] = [] + archived_options: list[dict] = [] + for option in old_options: + if option["option_id"] not in new_ids: + archived_options.append({**option, "is_archived": True}) + if not option.get("is_archived"): + newly_archived_ids.append(option["option_id"]) + + merged = dict(new_config) + merged["options"] = [*new_config["options"], *archived_options] + return merged, newly_archived_ids + + +def _upsert_pending_update(db: Session, response_id: int, field_key: str, reason: str) -> None: + existing = ( + db.query(FormResponsePendingUpdate) + .filter( + FormResponsePendingUpdate.response_id == response_id, + FormResponsePendingUpdate.field_key == field_key, + ) + .first() + ) + if existing is None: + db.add(FormResponsePendingUpdate(response_id=response_id, field_key=field_key, reason=reason)) + elif existing.reason == "option_archived" and reason == "field_replaced": + # Escalate only in this direction — see FormResponsePendingUpdate. + existing.reason = "field_replaced" + + +def flag_pending_updates_for_field(db: Session, field_id: int, field_key: str, reason: str) -> None: + """Upserts a pending-update row for every response that answered + `field_id` — used when that field was archived (removed, or archived + +replaced by a question_type change). Keyed on `field_key`, not + `field_id`, since field_key is what a respondent/TD recognizes and + what survives an archive+replace.""" + response_ids = { + rid for (rid,) in db.query(FormAnswer.response_id).filter(FormAnswer.field_id == field_id).all() } + for response_id in response_ids: + _upsert_pending_update(db, response_id, field_key, reason) + + +def flag_pending_updates_for_archived_options(db: Session, field: FormField, archived_option_ids: list[str]) -> None: + """Upserts option_archived for every response whose stored answer on + `field` (still live, unchanged type) selected one of `archived_option_ids`. + FormAnswer.value is a plain JSON column (not JSONB), so this is a + Python-side scan rather than a DB-side containment query — same + reasoning as the TournamentShift deletion guard's scan.""" + if not archived_option_ids: + return + archived_ids = set(archived_option_ids) + answers = db.query(FormAnswer).filter(FormAnswer.field_id == field.id).all() + for answer in answers: + value = answer.value + selected = value if isinstance(value, list) else ([value] if value else []) + if archived_ids & set(selected): + _upsert_pending_update(db, answer.response_id, field.field_key, "option_archived") - options.append(new_option) - config["options"] = options - - field.config = config - flag_modified(field, "config") - - db.commit() - db.refresh(field) - - return field - - -def change_option_label( - db: Session, - field: FormField, - option_id: str, - label: str, -) -> FormField: - - config = dict(field.config or {}) - options = config.get("options", []) - - for option in options: - if option.get("id") == option_id: - option["label"] = label - break - - field.config = config - flag_modified(field, "config") - - db.commit() - db.refresh(field) - return field - -def remove_option_from_field( - db: Session, - field: FormField, - option_id: str, -) -> FormField: - config = dict(field.config or {}) - options = config.get("options", []) - - for option in options: - if option.get("id") == option_id: - option["archived"] = True - break - - field.config = config - flag_modified(field, "config") - - db.commit() - db.refresh(field) - return field def resolve_field_options(db: Session, field: FormField) -> list[dict]: """ - Resolves option items for a given FormField. + Resolves option items for a given FormField, filtering out any + `is_archived: true` option — archived options stay in `config` for + historical answer/branching resolution but are never shown to a new + respondent. If the field depends on live DB data (e.g. event_preference), queries the database. Otherwise, returns options stored in field.config. """ @@ -208,11 +205,10 @@ def resolve_field_options(db: Session, field: FormField) -> list[dict]: ) return [ { - "id": f"opt_{event.id}", + "option_id": f"opt_evt_{event.id}", + "value": str(event.id), "label": event.name, - "archived": False, - "next_section_id": None, - "allow_other": False, + "is_archived": False, } for event in events ] @@ -224,4 +220,4 @@ def resolve_field_options(db: Session, field: FormField) -> list[dict]: # 3. Static fallback: Read options list directly from config config = dict(field.config or {}) - return config.get("options", []) \ No newline at end of file + return [o for o in config.get("options", []) if not o.get("is_archived")] \ No newline at end of file diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index 15841844..c8b4f24d 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -121,13 +121,16 @@ def validate_branching_options( ) -def validate_form_for_publish(db: Session, form: Form) -> None: - """Aggregate pass run on every draft->published transition and every - explicit republish while already published. Per-field validation on - create/update can't catch problems that only exist in aggregate — a - form with zero fields, or a next_field_id left dangling after some - other field got archived later — so this re-runs every check across - the whole active field set. Collects every problem found instead of +def collect_active_field_errors(db: Session, form: Form) -> list[str]: + """Aggregate, non-raising pass over every non-archived field on `form`, + re-run in full rather than per-field: per-field validation on create/ + update can't catch problems that only exist in aggregate (e.g. a + next_field_id left dangling after some other field got archived/ + replaced later). Used both by the publish-transition gate (below, + which additionally requires >=1 field) and by the bulk field-replace + route in api/routes/forms.py (which runs this straight after flushing + a proposed field set, so newly-created fields already have real ids to + validate next_field_id against). Collects every problem instead of stopping at the first, so a TD sees the full list in one pass.""" fields = ( db.query(FormField) @@ -136,9 +139,6 @@ def validate_form_for_publish(db: Session, form: Form) -> None: ) errors: list[str] = [] - if not fields: - errors.append("form has no fields") - for field in fields: try: normalized_config = validate_field_config(field.question_type, field.config) @@ -163,6 +163,23 @@ def validate_form_for_publish(db: Session, form: Form) -> None: except FormFieldValidationError as e: errors.append(f"field '{field.field_key}': {e}") + return errors + + +def validate_form_for_publish(db: Session, form: Form) -> None: + """Run on every draft->published transition and every explicit + republish while already published — same as collect_active_field_errors + plus the publish-only "must have at least one field" requirement.""" + has_fields = ( + db.query(FormField) + .filter(FormField.form_id == form.id, FormField.is_archived == False) + .first() + is not None + ) + + errors: list[str] = [] if has_fields else ["form has no fields"] + errors += collect_active_field_errors(db, form) + _require(not errors, "; ".join(errors)) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index dc5b4082..1ad6e074 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -765,6 +765,7 @@ class FormResponse(Base): form = relationship("Form", back_populates="responses") user = relationship("User", back_populates="form_responses") answers = relationship("FormAnswer", back_populates="response", cascade="all, delete-orphan") + pending_updates = relationship("FormResponsePendingUpdate", back_populates="response", cascade="all, delete-orphan") __table_args__ = ( UniqueConstraint("form_id", "user_id", name="uq_form_response_per_user"), @@ -791,6 +792,31 @@ class FormAnswer(Base): ) +# --------------------------------------------------------------------------- +# FormResponsePendingUpdate — flags that a response answered a field/option +# which a republish later archived out from under it, so a TD/respondent +# can be shown "this answer needs another look". One row per +# (response, field_key); reason only ever escalates option_archived -> +# field_replaced (never the reverse) on upsert, and the row is deleted once +# that response next submits an answer for whichever field currently holds +# that field_key — see _apply_published_field_changes in api/routes/forms.py. +# --------------------------------------------------------------------------- +class FormResponsePendingUpdate(Base): + __tablename__ = "form_response_pending_updates" + + id = Column(Integer, primary_key=True, index=True) + response_id = Column(Integer, ForeignKey("form_responses.id", ondelete="CASCADE"), nullable=False) + field_key = Column(String(64), nullable=False) + reason = Column(String(32), nullable=False) # "field_replaced" | "option_archived" + created_at = Column(DateTime(timezone=True), default=utcnow) + + response = relationship("FormResponse", back_populates="pending_updates") + + __table_args__ = ( + UniqueConstraint("response_id", "field_key", name="uq_pending_update_per_response_field"), + ) + + # --------------------------------------------------------------------------- # TournamentMembershipAvailability — write-through target for a form's "availability" # field_key answer. Reuses TournamentShift directly, no separate catalog. diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index c9df4ec7..2fecfbac 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -11,12 +11,19 @@ # app/core/form/validation.py since they need a Session, not just the dict. # --------------------------------------------------------------------------- -def _unique_option_values(options: list) -> list: - seen = set() +def _unique_option_fields(options: list) -> list: + """option_id and value each need to be unique within a field's option + list — option_id is the durable identity (edit-lifecycle archiving, + write-through, branching match), value is the TD-facing stored/matched + text. A collision on either would make selection ambiguous.""" + seen_ids, seen_values = set(), set() for option in options: - if option.value in seen: + if option.option_id in seen_ids: + raise ValueError(f"duplicate option_id '{option.option_id}'") + seen_ids.add(option.option_id) + if option.value in seen_values: raise ValueError(f"duplicate option value '{option.value}'") - seen.add(option.value) + seen_values.add(option.value) return options @@ -24,15 +31,19 @@ class PlainOption(BaseModel): """An option with no branching — multi_select_checkbox, ranked_choice. extra='forbid' rejects a stray next_field_id/action on these types.""" model_config = ConfigDict(extra="forbid") + option_id: str = Field(min_length=1) value: str = Field(min_length=1) label: str = Field(min_length=1) + is_archived: bool = False class BranchingOption(BaseModel): """An option that may carry branching — single_select_radio/dropdown only.""" model_config = ConfigDict(extra="forbid") + option_id: str = Field(min_length=1) value: str = Field(min_length=1) label: str = Field(min_length=1) + is_archived: bool = False next_field_id: int | None = None action: Literal["submit_form"] | None = None @@ -57,7 +68,7 @@ class SingleSelectConfig(BaseModel): @field_validator("options") @classmethod def _unique_values(cls, options: list[BranchingOption]) -> list[BranchingOption]: - return _unique_option_values(options) + return _unique_option_fields(options) class MultiSelectCheckboxConfig(BaseModel): @@ -68,7 +79,7 @@ class MultiSelectCheckboxConfig(BaseModel): @field_validator("options") @classmethod def _unique_values(cls, options: list[PlainOption]) -> list[PlainOption]: - return _unique_option_values(options) + return _unique_option_fields(options) class RankedChoiceConfig(BaseModel): @@ -81,7 +92,7 @@ class RankedChoiceConfig(BaseModel): @field_validator("options") @classmethod def _unique_values(cls, options: list[PlainOption]) -> list[PlainOption]: - return _unique_option_values(options) + return _unique_option_fields(options) @model_validator(mode="after") def _ranks_within_options(self): @@ -127,26 +138,22 @@ class FormFieldRead(BaseModel): model_config = ConfigDict(from_attributes=True) -class FormFieldCreate(BaseModel): - # field_key is TD-typed, separate from `label` — the TD's own name for - # the dashboard lookup key. Server-side slugify() normalizes it (see - # app/core/form.slugify) +class BulkFieldEntry(BaseModel): + """One entry in a PUT /forms/{form_id}/fields/ payload. `id` absent + means "create"; `id` present must match a currently-live field on this + form. `field_key` is only meaningful (and required) on create — on an + update it's server-controlled (immutable, or carried over onto a + question_type-change replacement) and any value sent here is ignored.""" + id: int | None = None + field_key: str | None = None label: str - field_key: str - question_type: str description: str | None = None - order: int | None = None + question_type: str config: dict[str, Any] | None = None -class FormFieldUpdate(BaseModel): - label: str | None = None - description: str | None = None - question_type: str | None = None - order: int | None = None - config: dict | None = None - - model_config = ConfigDict(from_attributes=True) +class BulkFieldsUpdate(BaseModel): + fields: list[BulkFieldEntry] # --------------------------------------------------------------------------- diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index 77658cc8..612b81f2 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -9,11 +9,9 @@ from tests.conftest import grant_role, login from tests.api.chapter._helpers import make_chapter, make_university, make_user -from app.core.form import remove_form_field from app.models.models import ( ChapterMembership, Form, - FormAnswer, FormField, FormResponse, TournamentMembership, @@ -251,199 +249,6 @@ def test_delete_blocked_when_responses_exist(self, client, db, td_user, td_tourn assert db.query(Form).filter(Form.id == form.id).first() is not None -# --------------------------------------------------------------------------- -# POST /forms/{form_id}/fields/ — field_key required, TD-typed, slugified, -# tournament-wide uniqueness for tournament forms. -# --------------------------------------------------------------------------- - -class TestCreateField: - def test_create_field_slugifies_key(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - db.commit() - login(client, "td@test.com", "tdpass") - res = client.post( - f"/forms/{form.id}/fields/", - json={ - "label": "Test Writing Interest", - "field_key": "Test Writing Interest!", - "question_type": "short_text", - "config": {"required": False, "max_length": 500}, - }, - ) - assert res.status_code == 201 - assert res.json()["field_key"] == "test_writing_interest" - - def test_field_key_collision_within_tournament_rejected(self, client, db, td_user, td_tournament): - form_a = _make_form(db, td_user, td_tournament, name="Form A") - form_b = _make_form(db, td_user, td_tournament, name="Form B") - _make_field(db, form_a, field_key="shared_key") - db.commit() - - login(client, "td@test.com", "tdpass") - res = client.post( - f"/forms/{form_b.id}/fields/", - json={"label": "Anything", "field_key": "shared_key", "question_type": "short_text"}, - ) - assert res.status_code == 409 - - def test_field_key_collision_across_forms_in_same_tournament_after_slugify(self, client, db, td_user, td_tournament): - form_a = _make_form(db, td_user, td_tournament, name="Form A") - form_b = _make_form(db, td_user, td_tournament, name="Form B") - _make_field(db, form_a, field_key="shared_key") - db.commit() - - login(client, "td@test.com", "tdpass") - res = client.post( - f"/forms/{form_b.id}/fields/", - json={"label": "Anything", "field_key": "Shared Key!!", "question_type": "short_text"}, - ) - assert res.status_code == 409 - - def test_archived_field_key_not_released_for_reuse(self, client, db, td_user, td_tournament): - form_a = _make_form(db, td_user, td_tournament, name="Form A") - form_b = _make_form(db, td_user, td_tournament, name="Form B") - field = _make_field(db, form_a, field_key="was_used") - response = FormResponse(form_id=form_a.id, user_id=td_user.id) - db.add(response) - db.flush() - db.add(FormAnswer(response_id=response.id, field_id=field.id, value=["opt_1"])) - db.commit() - - was_archived = remove_form_field(db, field) - assert was_archived is True # archived, not deleted, because it has an answer - db.commit() - - login(client, "td@test.com", "tdpass") - res = client.post( - f"/forms/{form_b.id}/fields/", - json={"label": "Anything", "field_key": "was_used", "question_type": "short_text"}, - ) - assert res.status_code == 409 - - def test_chapter_forms_scope_uniqueness_per_form_only(self, client, db, td_user, chapter): - form_a = _make_chapter_form(db, td_user, chapter, name="Form A") - form_b = _make_chapter_form(db, td_user, chapter, name="Form B") - _make_field(db, form_a, field_key="shared_key") - db.commit() - - _chapter_lead(db, chapter) - login(client, "chapterlead@test.com", "LeadPass123!") - res = client.post( - f"/forms/{form_b.id}/fields/", - json={ - "label": "Anything", - "field_key": "shared_key", - "question_type": "short_text", - "config": {"required": False, "max_length": 500}, - }, - ) - # Different form -> allowed for chapter-owned forms (only per-form uniqueness applies) - assert res.status_code == 201 - - def test_missing_field_key_rejected(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - db.commit() - login(client, "td@test.com", "tdpass") - res = client.post( - f"/forms/{form.id}/fields/", - json={"label": "No key", "question_type": "short_text"}, - ) - assert res.status_code == 422 - - def test_new_field_addable_to_form_with_existing_responses(self, client, db, td_user, td_tournament): - """Locking only applies to fields that already have answers — adding - a brand new field to an already-answered form is unaffected.""" - form = _make_form(db, td_user, td_tournament) - existing_field = _make_field(db, form, field_key="color") - response = FormResponse(form_id=form.id, user_id=td_user.id) - db.add(response) - db.flush() - db.add(FormAnswer(response_id=response.id, field_id=existing_field.id, value=["opt_1"])) - db.commit() - - login(client, "td@test.com", "tdpass") - res = client.post( - f"/forms/{form.id}/fields/", - json={ - "label": "New question", - "field_key": "new_question", - "question_type": "short_text", - "config": {"required": False, "max_length": 100}, - }, - ) - assert res.status_code == 201 - - -# --------------------------------------------------------------------------- -# PATCH / DELETE /forms/{form_id}/fields/{field_id}/ -# --------------------------------------------------------------------------- - -class TestEditDeleteField: - def test_patch_updates_label_and_order(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - field = _make_field(db, form, order=1, field_key="color") - db.commit() - login(client, "td@test.com", "tdpass") - res = client.patch(f"/forms/{form.id}/fields/{field.id}/", json={"label": "New label", "order": 3}) - assert res.status_code == 200 - assert res.json()["label"] == "New label" - assert res.json()["order"] == 3 - - def test_patch_question_type_replaces_field_keeping_key(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - field = _make_field(db, form, field_key="color") - db.commit() - login(client, "td@test.com", "tdpass") - res = client.patch(f"/forms/{form.id}/fields/{field.id}/", json={"question_type": "multi_select_checkbox"}) - assert res.status_code == 200 - assert res.json()["question_type"] == "multi_select_checkbox" - assert res.json()["field_key"] == "color" - assert res.json()["id"] != field.id - - def test_patch_rejected_once_field_has_answers(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - field = _make_field(db, form, field_key="color") - response = FormResponse(form_id=form.id, user_id=td_user.id) - db.add(response) - db.flush() - db.add(FormAnswer(response_id=response.id, field_id=field.id, value=["opt_1"])) - db.commit() - login(client, "td@test.com", "tdpass") - res = client.patch(f"/forms/{form.id}/fields/{field.id}/", json={"label": "New label"}) - assert res.status_code == 409 - assert db.query(FormField).filter(FormField.id == field.id).first().label == "Favorite color" - - def test_patch_allowed_when_field_has_no_answers(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - field = _make_field(db, form, field_key="color") - db.commit() - login(client, "td@test.com", "tdpass") - res = client.patch(f"/forms/{form.id}/fields/{field.id}/", json={"label": "New label"}) - assert res.status_code == 200 - - def test_delete_hard_deletes_when_no_answers(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - field = _make_field(db, form, field_key="color") - db.commit() - login(client, "td@test.com", "tdpass") - res = client.delete(f"/forms/{form.id}/fields/{field.id}/") - assert res.status_code == 200 - assert res.json()["action"] == "deleted" - - def test_delete_archives_when_answers_exist(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - field = _make_field(db, form, field_key="color") - response = FormResponse(form_id=form.id, user_id=td_user.id) - db.add(response) - db.flush() - db.add(FormAnswer(response_id=response.id, field_id=field.id, value=["opt_1"])) - db.commit() - login(client, "td@test.com", "tdpass") - res = client.delete(f"/forms/{form.id}/fields/{field.id}/") - assert res.status_code == 200 - assert res.json()["action"] == "archived" - - # --------------------------------------------------------------------------- # POST /forms/{form_id}/responses/ — submission and resubmission # --------------------------------------------------------------------------- @@ -644,115 +449,6 @@ def test_me_404_when_no_response(self, client, db, td_user, td_tournament): assert res.status_code == 404 -# --------------------------------------------------------------------------- -# Reserved field_key <-> question_type pairing (validated identically on -# tournament- and chapter-owned forms — see form-question-types-reference.md) -# --------------------------------------------------------------------------- - -class TestReservedFieldKeyRoutes: - def test_availability_wrong_type_rejected_on_tournament_form(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - db.commit() - login(client, "td@test.com", "tdpass") - res = client.post( - f"/forms/{form.id}/fields/", - json={ - "label": "Availability", - "field_key": "availability", - "question_type": "single_select_dropdown", - "config": {"required": False, "options": [{"value": "1", "label": "Saturday"}]}, - }, - ) - assert res.status_code == 422 - - def test_availability_wrong_type_rejected_on_chapter_form(self, client, db, td_user, chapter): - form = _make_chapter_form(db, td_user, chapter) - db.commit() - _chapter_lead(db, chapter) - login(client, "chapterlead@test.com", "LeadPass123!") - res = client.post( - f"/forms/{form.id}/fields/", - json={ - "label": "Availability", - "field_key": "availability", - "question_type": "single_select_dropdown", - "config": {"required": False, "options": [{"value": "1", "label": "Saturday"}]}, - }, - ) - assert res.status_code == 422 - - def test_availability_valid_type_accepted_on_chapter_form_no_shift_check(self, client, db, td_user, chapter): - # Chapter forms have no tournament shift catalog to validate - # against, so any option value is accepted — stores as a normal - # FormAnswer, no write-through (write-through is tournament-only). - form = _make_chapter_form(db, td_user, chapter) - db.commit() - _chapter_lead(db, chapter) - login(client, "chapterlead@test.com", "LeadPass123!") - res = client.post( - f"/forms/{form.id}/fields/", - json={ - "label": "Availability", - "field_key": "availability", - "question_type": "multi_select_checkbox", - "config": {"required": False, "options": [{"value": "not_a_real_shift_id", "label": "Whenever"}]}, - }, - ) - assert res.status_code == 201 - - def test_availability_option_must_resolve_to_real_shift_on_tournament_form(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - db.commit() - login(client, "td@test.com", "tdpass") - res = client.post( - f"/forms/{form.id}/fields/", - json={ - "label": "Availability", - "field_key": "availability", - "question_type": "multi_select_checkbox", - "config": {"required": False, "options": [{"value": "9999", "label": "Nonexistent shift"}]}, - }, - ) - assert res.status_code == 422 - - def test_availability_valid_shift_accepted_on_tournament_form(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - shift = TournamentShift( - tournament_id=td_tournament.id, - label="Saturday", - start=datetime.now(timezone.utc), - end=datetime.now(timezone.utc) + timedelta(hours=8), - ) - db.add(shift) - db.commit() - login(client, "td@test.com", "tdpass") - res = client.post( - f"/forms/{form.id}/fields/", - json={ - "label": "Availability", - "field_key": "availability", - "question_type": "multi_select_checkbox", - "config": {"required": False, "options": [{"value": str(shift.id), "label": shift.label}]}, - }, - ) - assert res.status_code == 201 - - def test_event_preference_disallowed_type_rejected(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - db.commit() - login(client, "td@test.com", "tdpass") - res = client.post( - f"/forms/{form.id}/fields/", - json={ - "label": "Event Preference", - "field_key": "event_preference", - "question_type": "short_text", - "config": {"required": False, "max_length": 100}, - }, - ) - assert res.status_code == 422 - - # --------------------------------------------------------------------------- # Submission-time required enforcement via branching reachability replay # --------------------------------------------------------------------------- diff --git a/backend/tests/core/test_forms.py b/backend/tests/core/test_forms.py index 9fe13e32..58fb5b41 100644 --- a/backend/tests/core/test_forms.py +++ b/backend/tests/core/test_forms.py @@ -12,7 +12,6 @@ from app.core.form import ( field_key_taken_in_tournament, remove_form_field, - remove_option_from_field, replace_field_type, slugify, ) @@ -70,8 +69,8 @@ def _make_field(db, form, *, order=1, field_key="favorite_color", question_type= field_key=field_key, config={ "options": [ - {"id": "opt_1", "label": "Red", "archived": False, "next_section_id": None, "allow_other": False}, - {"id": "opt_2", "label": "Blue", "archived": False, "next_section_id": None, "allow_other": False}, + {"option_id": "opt_1", "value": "red", "label": "Red", "is_archived": False}, + {"option_id": "opt_2", "value": "blue", "label": "Blue", "is_archived": False}, ] }, is_archived=False, @@ -242,24 +241,6 @@ def test_replace_field_type_archives_old_field_and_keeps_order(self, db, td_user assert replacement.field_key == "tshirt_size" assert replacement.is_archived is False - def test_remove_option_from_field_keeps_existing_answer_values(self, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - field = _make_field(db, form, order=2, field_key="member_role") - - response = FormResponse(form_id=form.id, user_id=td_user.id) - db.add(response) - db.flush() - - answer = FormAnswer(response_id=response.id, field_id=field.id, value=["opt_1"]) - db.add(answer) - db.flush() - - updated = remove_option_from_field(db, field, "opt_1") - - assert updated is field - assert updated.config["options"][0]["archived"] is True - assert db.query(FormAnswer).filter(FormAnswer.field_id == field.id).one().value == ["opt_1"] - # --------------------------------------------------------------------------- # slugify / field_key_taken_in_tournament From 3129a910e0489ee3a4c52c61f1758f14994d6e14 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 17:32:28 -0700 Subject: [PATCH 037/185] feat(forms): switch branching/write-through matching to option_id and generate it server-side --- backend/app/api/routes/forms.py | 26 +- backend/app/core/form/__init__.py | 70 ++-- backend/app/core/form/branching.py | 2 +- backend/tests/api/test_forms.py | 394 ++++++++++++++++++++- backend/tests/core/test_form_branching.py | 43 ++- backend/tests/core/test_form_validation.py | 36 +- 6 files changed, 494 insertions(+), 77 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 6687c4e6..3bdf6aab 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -7,6 +7,7 @@ from app.core.chapters import require_officer_or_lead from app.core.form import ( apply_option_archiving, + assign_option_ids, field_key_taken_in_tournament, flag_pending_updates_for_archived_options, flag_pending_updates_for_field, @@ -287,6 +288,7 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> # is flushed — next_field_id resolution needs every field (including # ones this same request creates) to have a real id first, so that's # deferred to the collect_active_field_errors pass below. + config = assign_option_ids(config) try: normalized = validate_field_config(question_type, config) validate_reserved_field_key(field_key, question_type) @@ -434,6 +436,19 @@ def submit_form_response( for answer_in in payload.answers: db.add(FormAnswer(response_id=response.id, field_id=answer_in.field_id, value=answer_in.value)) + # A fresh answer for a field clears any pending-update flag on it — the + # respondent has now seen and re-confirmed whatever changed. Keyed by + # field_key (not field_id) since that's what a pending-update row keys + # on and what survives an archive+replace (see FormResponsePendingUpdate). + answered_field_keys = { + field.field_key for field in active_fields if field.id in answers_by_field + } + if answered_field_keys: + db.query(FormResponsePendingUpdate).filter( + FormResponsePendingUpdate.response_id == response.id, + FormResponsePendingUpdate.field_key.in_(answered_field_keys), + ).delete(synchronize_session=False) + if form.owner_type == "tournament": _write_through_reserved_fields(db, form, active_fields, answers_by_field, current_user) @@ -478,8 +493,15 @@ def _write_through_reserved_fields( if LUNCH_FIELD_KEY_PATTERN.match(field.field_key): lunch_date, category = parse_lunch_field_key(field.field_key) - options_by_value = {opt["value"]: opt["label"] for opt in (field.config or {}).get("options", [])} - values = [{"value": v, "label": options_by_value.get(v, v)} for v in selected] + # `selected` is now option_id(s) (see branching.py's matching and + # PlainOption/BranchingOption's option_id) — resolve each back to + # its stored value/label snapshot before write-through. + options_by_id = {opt["option_id"]: opt for opt in (field.config or {}).get("options", [])} + values = [ + {"value": options_by_id[v]["value"], "label": options_by_id[v]["label"]} + for v in selected + if v in options_by_id + ] sync_lunch(db, membership.id, lunch_date, category, values) diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index 0ed43027..ed25c121 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -1,8 +1,9 @@ from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified -from app.models.models import Form, FormAnswer, FormField, FormResponsePendingUpdate, TournamentEvent +from app.models.models import Form, FormAnswer, FormField, FormResponsePendingUpdate -import re # Regular Expressions for searching, matching, and extracting patterns in text strings +import re +import secrets def slugify(text: str, max_len: int = 64) -> str: @@ -11,6 +12,26 @@ def slugify(text: str, max_len: int = 64) -> str: return slug[:max_len] +def assign_option_ids(config: dict | None) -> dict | None: + """The backend is the sole generator of option_id — the durable + per-option identifier used for edit-lifecycle archiving, write-through, + and branching match (see PlainOption/BranchingOption). Run on a + field's raw submitted config before validate_field_config: an option + that already carries a non-empty option_id (echoing what a prior GET + returned for an option the TD kept) keeps it; an option with none + (freshly added in this edit) gets a fresh one assigned here. No-op for + configs without an `options` key.""" + if not config or "options" not in config: + return config + options = [] + for option in config["options"]: + option = dict(option) + if not option.get("option_id"): + option["option_id"] = f"opt_{secrets.token_hex(5)}" + options.append(option) + return {**config, "options": options} + + def field_key_taken_in_tournament(db: Session, tournament_id: int, field_key: str) -> bool: """True if `field_key` is already used by any FormField — archived included, an archived key isn't released for reuse — belonging to any @@ -184,40 +205,15 @@ def flag_pending_updates_for_archived_options(db: Session, field: FormField, arc def resolve_field_options(db: Session, field: FormField) -> list[dict]: - """ - Resolves option items for a given FormField, filtering out any - `is_archived: true` option — archived options stay in `config` for - historical answer/branching resolution but are never shown to a new - respondent. - If the field depends on live DB data (e.g. event_preference), queries the database. - Otherwise, returns options stored in field.config. - """ - # 1. Dynamic lookup: Tournament Event Preferences - if field.field_key == "event_preference": - if not (field.form and field.form.tournament_id): - return [] - - events = ( - db.query(TournamentEvent) - .filter(TournamentEvent.tournament_id == field.form.tournament_id) - .order_by(TournamentEvent.id.asc()) - .all() - ) - return [ - { - "option_id": f"opt_evt_{event.id}", - "value": str(event.id), - "label": event.name, - "is_archived": False, - } - for event in events - ] - - # 2. Stubbed dynamic lookup: Availability & Lunch - elif field.field_key in ("availability", "lunch"): - # TODO(temp): wire up in Step 7 - return [] - - # 3. Static fallback: Read options list directly from config + """Options for a given FormField, filtering out any `is_archived: true` + option — archived options stay in `config` for historical answer/ + branching resolution but are never shown to a new respondent. + + Every option type (including reserved keys like event_preference, + availability, lunch) is a stored, static list — "auto-load from + tournament" conveniences (pulling in events/shifts/etc.) are a + TD-editor-side action that populates this array once, same as any + manually-authored option list, not a live server-side lookup. See + form-question-types-reference.md's "Options-storage rule".""" config = dict(field.config or {}) return [o for o in config.get("options", []) if not o.get("is_archived")] \ No newline at end of file diff --git a/backend/app/core/form/branching.py b/backend/app/core/form/branching.py index 21817739..095afcbb 100644 --- a/backend/app/core/form/branching.py +++ b/backend/app/core/form/branching.py @@ -34,7 +34,7 @@ def compute_reachable_field_ids(fields: list[FormField], answers: dict[int, Any] if current.question_type in BRANCHING_QUESTION_TYPES: answer = answers.get(current.id) options = (current.config or {}).get("options", []) - matched = next((o for o in options if o.get("value") == answer), None) + matched = next((o for o in options if o.get("option_id") == answer), None) if matched is not None: if matched.get("action") == "submit_form": return reachable diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index 612b81f2..2ab93d9b 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -12,8 +12,10 @@ from app.models.models import ( ChapterMembership, Form, + FormAnswer, FormField, FormResponse, + FormResponsePendingUpdate, TournamentMembership, TournamentMembershipAvailability, TournamentMembershipLunch, @@ -66,8 +68,8 @@ def _make_field(db, form, *, order=1, field_key="favorite_color", question_type= config={ "required": False, "options": [ - {"value": "opt_1", "label": "Red"}, - {"value": "opt_2", "label": "Blue"}, + {"option_id": "opt_1", "value": "opt_1", "label": "Red"}, + {"option_id": "opt_2", "value": "opt_2", "label": "Blue"}, ], }, is_archived=False, @@ -249,6 +251,373 @@ def test_delete_blocked_when_responses_exist(self, client, db, td_user, td_tourn assert db.query(Form).filter(Form.id == form.id).first() is not None +# --------------------------------------------------------------------------- +# PUT /forms/{form_id}/fields/ — bulk field replace (Edit Lifecycle) +# --------------------------------------------------------------------------- + +def _simple_entry(**overrides): + entry = { + "field_key": "color", + "label": "Favorite color", + "question_type": "short_text", + "config": {"required": False, "max_length": 100}, + } + entry.update(overrides) + return entry + + +class TestBulkUpdateFieldsDraft: + def test_create_update_delete_apply_directly(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) # draft by default + existing = _make_field(db, form, field_key="to_delete", question_type="short_text", config={"required": False, "max_length": 50}) + keep = _make_field(db, form, field_key="to_update", question_type="short_text", config={"required": False, "max_length": 50}) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.put( + f"/forms/{form.id}/fields/", + json={ + "fields": [ + {"id": keep.id, "label": "Updated label", "question_type": "short_text", "config": {"required": False, "max_length": 200}}, + _simple_entry(field_key="brand_new"), + ] + }, + ) + assert res.status_code == 200 + data = res.json() + assert {f["field_key"] for f in data} == {"to_update", "brand_new"} + + assert db.query(FormField).filter(FormField.id == existing.id).first() is None # hard-deleted + db.refresh(keep) + assert keep.label == "Updated label" + assert keep.config["max_length"] == 200 + + def test_question_type_change_applies_in_place_no_replacement(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.put( + f"/forms/{form.id}/fields/", + json={"fields": [{"id": field.id, "label": "Color", "question_type": "long_text", "config": {"required": False, "max_length": 500}}]}, + ) + assert res.status_code == 200 + assert res.json()[0]["id"] == field.id + assert res.json()[0]["question_type"] == "long_text" + assert db.query(FormField).filter(FormField.form_id == form.id).count() == 1 + + +class TestBulkUpdateFieldsPublished: + def _publish(self, client, form): + return client.patch(f"/forms/{form.id}/", json={"status": "published"}) + + def test_label_only_edit_applies_in_place(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) + + res = client.put( + f"/forms/{form.id}/fields/", + json={"fields": [{"id": field.id, "label": "New label", "question_type": "short_text", "config": {"required": False, "max_length": 50}}]}, + ) + assert res.status_code == 200 + assert res.json()[0]["id"] == field.id + assert res.json()[0]["label"] == "New label" + + def test_question_type_change_archives_and_replaces_same_key(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, order=1, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) + + res = client.put( + f"/forms/{form.id}/fields/", + json={"fields": [{"id": field.id, "label": "Color", "question_type": "long_text", "config": {"required": False, "max_length": 500}}]}, + ) + assert res.status_code == 200 + data = res.json() + assert len(data) == 1 + assert data[0]["id"] != field.id + assert data[0]["field_key"] == "color" + assert data[0]["question_type"] == "long_text" + + db.refresh(field) + assert field.is_archived is True + assert field.field_key != "color" + + def test_removed_field_archives_not_deletes(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) + + res = client.put(f"/forms/{form.id}/fields/", json={"fields": []}) + assert res.status_code == 200 + assert res.json() == [] + + db.refresh(field) + assert field.is_archived is True + assert db.query(FormField).filter(FormField.id == field.id).first() is not None + + def test_new_entry_inserts(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) + + res = client.put( + f"/forms/{form.id}/fields/", + json={ + "fields": [ + {"id": field.id, "label": "Favorite color", "question_type": "short_text", "config": {"required": False, "max_length": 50}}, + _simple_entry(field_key="brand_new"), + ] + }, + ) + assert res.status_code == 200 + assert {f["field_key"] for f in res.json()} == {"color", "brand_new"} + + def test_whole_batch_rejected_together_on_dangling_next_field_id(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + branch_field = _make_field( + db, form, field_key="branch", question_type="single_select_radio", + config={ + "required": False, + "options": [{"option_id": "opt_yes", "value": "yes", "label": "Yes", "next_field_id": 9999}], + }, + ) + other_field = _make_field(db, form, order=2, field_key="other", question_type="short_text", config={"required": False, "max_length": 50}) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) + + res = client.put( + f"/forms/{form.id}/fields/", + json={ + "fields": [ + { + "id": branch_field.id, "label": "Branch", "question_type": "single_select_radio", + "config": { + "required": False, + "options": [{"option_id": "opt_yes", "value": "yes", "label": "Yes", "next_field_id": 9999}], + }, + }, + {"id": other_field.id, "label": "New label that should not stick", "question_type": "short_text", "config": {"required": False, "max_length": 50}}, + ] + }, + ) + assert res.status_code == 422 + + db.refresh(other_field) + assert other_field.label != "New label that should not stick" + + def test_option_removed_archives_not_dropped(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field( + db, form, field_key="color", question_type="multi_select_checkbox", + config={ + "required": False, + "options": [ + {"option_id": "opt_red", "value": "red", "label": "Red"}, + {"option_id": "opt_blue", "value": "blue", "label": "Blue"}, + ], + }, + ) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) + + # A response answers with the option we're about to remove. + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_red"]}]}) + assert res.status_code == 200 + response_id = res.json()["id"] + + res = client.put( + f"/forms/{form.id}/fields/", + json={ + "fields": [ + { + "id": field.id, "label": "Favorite color", "question_type": "multi_select_checkbox", + "config": {"required": False, "options": [{"option_id": "opt_blue", "value": "blue", "label": "Blue"}]}, + }, + ] + }, + ) + assert res.status_code == 200 + # PUT returns the raw config (the editor's view) — archived options + # stay present with is_archived: true, not silently dropped. + returned_ids = {o["option_id"]: o["is_archived"] for o in res.json()[0]["config"]["options"]} + assert returned_ids == {"opt_blue": False, "opt_red": True} + + db.refresh(field) + stored_ids = {o["option_id"]: o["is_archived"] for o in field.config["options"]} + assert stored_ids == {"opt_blue": False, "opt_red": True} + + # But GET (the respondent-facing render) filters archived options out. + res = client.get(f"/forms/{form.id}/") + rendered_ids = {o["option_id"] for o in res.json()["fields"][0]["config"]["options"]} + assert rendered_ids == {"opt_blue"} + + # The prior answer referencing opt_red is untouched in storage. + answer = db.query(FormAnswer).filter(FormAnswer.field_id == field.id).one() + assert answer.value == ["opt_red"] + + pending = ( + db.query(FormResponsePendingUpdate) + .filter(FormResponsePendingUpdate.response_id == response_id, FormResponsePendingUpdate.field_key == "color") + .first() + ) + assert pending is not None + assert pending.reason == "option_archived" + + def test_pending_update_cleared_on_fresh_submission(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field( + db, form, field_key="color", question_type="multi_select_checkbox", + config={ + "required": False, + "options": [ + {"option_id": "opt_red", "value": "red", "label": "Red"}, + {"option_id": "opt_blue", "value": "blue", "label": "Blue"}, + ], + }, + ) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_red"]}]}) + response_id = res.json()["id"] + + client.put( + f"/forms/{form.id}/fields/", + json={ + "fields": [ + { + "id": field.id, "label": "Favorite color", "question_type": "multi_select_checkbox", + "config": {"required": False, "options": [{"option_id": "opt_blue", "value": "blue", "label": "Blue"}]}, + }, + ] + }, + ) + assert db.query(FormResponsePendingUpdate).filter(FormResponsePendingUpdate.response_id == response_id).count() == 1 + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_blue"]}]}) + assert res.status_code == 200 + assert db.query(FormResponsePendingUpdate).filter(FormResponsePendingUpdate.response_id == response_id).count() == 0 + + def test_field_replaced_flags_pending_update_for_prior_answer(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": "blue"}]}) + response_id = res.json()["id"] + + client.put( + f"/forms/{form.id}/fields/", + json={"fields": [{"id": field.id, "label": "Color", "question_type": "long_text", "config": {"required": False, "max_length": 500}}]}, + ) + + pending = ( + db.query(FormResponsePendingUpdate) + .filter(FormResponsePendingUpdate.response_id == response_id, FormResponsePendingUpdate.field_key == "color") + .first() + ) + assert pending is not None + assert pending.reason == "field_replaced" + + def test_option_without_option_id_gets_one_generated(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.put( + f"/forms/{form.id}/fields/", + json={ + "fields": [ + { + "label": "Color", "field_key": "color", "question_type": "multi_select_checkbox", + "config": { + "required": False, + "options": [{"value": "red", "label": "Red"}, {"value": "blue", "label": "Blue"}], + }, + }, + ] + }, + ) + assert res.status_code == 200 + options = res.json()[0]["config"]["options"] + ids = [o["option_id"] for o in options] + assert all(ids) + assert len(set(ids)) == 2 + + def test_option_id_preserved_across_update_when_echoed_back(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field( + db, form, field_key="color", question_type="multi_select_checkbox", + config={"required": False, "options": [{"option_id": "opt_red", "value": "red", "label": "Red"}]}, + ) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.put( + f"/forms/{form.id}/fields/", + json={ + "fields": [ + { + "id": field.id, "label": "Favorite color", "question_type": "multi_select_checkbox", + "config": { + "required": False, + "options": [ + {"option_id": "opt_red", "value": "red", "label": "Red"}, + {"value": "blue", "label": "Blue"}, + ], + }, + }, + ] + }, + ) + assert res.status_code == 200 + options = res.json()[0]["config"]["options"] + by_value = {o["value"]: o["option_id"] for o in options} + assert by_value["red"] == "opt_red" + assert by_value["blue"] != "opt_red" + assert by_value["blue"] + + def test_duplicate_option_id_within_field_rejected(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.put( + f"/forms/{form.id}/fields/", + json={ + "fields": [ + { + "label": "Color", "field_key": "color", "question_type": "multi_select_checkbox", + "config": { + "required": False, + "options": [ + {"option_id": "opt_1", "value": "red", "label": "Red"}, + {"option_id": "opt_1", "value": "blue", "label": "Blue"}, + ], + }, + }, + ] + }, + ) + assert res.status_code == 422 + + # --------------------------------------------------------------------------- # POST /forms/{form_id}/responses/ — submission and resubmission # --------------------------------------------------------------------------- @@ -320,7 +689,7 @@ def test_availability_write_through_on_tournament_form(self, client, db, td_user db.flush() field = _make_field( db, form, field_key="availability", question_type="multi_select_checkbox", - config={"required": False, "options": [{"value": str(shift.id), "label": shift.label}]}, + config={"required": False, "options": [{"option_id": f"opt_{shift.id}", "value": str(shift.id), "label": shift.label}]}, ) db.commit() login(client, "td@test.com", "tdpass") @@ -347,7 +716,10 @@ def test_lunch_write_through_on_tournament_form(self, client, db, td_user, td_to db, form, field_key="lunch_20270213_protein", question_type="single_select_radio", config={ "required": False, - "options": [{"value": "chicken", "label": "Chicken"}, {"value": "tofu", "label": "Tofu"}], + "options": [ + {"option_id": "opt_chicken", "value": "chicken", "label": "Chicken"}, + {"option_id": "opt_tofu", "value": "tofu", "label": "Tofu"}, + ], }, ) db.commit() @@ -355,7 +727,7 @@ def test_lunch_write_through_on_tournament_form(self, client, db, td_user, td_to res = client.post( f"/forms/{form.id}/responses/", - json={"answers": [{"field_id": field.id, "value": "chicken"}]}, + json={"answers": [{"field_id": field.id, "value": "opt_chicken"}]}, ) assert res.status_code == 200 @@ -377,7 +749,7 @@ def test_availability_answer_on_chapter_form_saves_but_does_not_write_through(se form = _make_chapter_form(db, td_user, chapter) field = _make_field( db, form, field_key="availability", question_type="multi_select_checkbox", - config={"required": False, "options": [{"value": "not_a_real_shift_id", "label": "Whenever"}]}, + config={"required": False, "options": [{"option_id": "opt_1", "value": "not_a_real_shift_id", "label": "Whenever"}]}, ) db.commit() _chapter_lead(db, chapter) @@ -395,7 +767,7 @@ def test_lunch_answer_on_chapter_form_saves_but_does_not_write_through(self, cli form = _make_chapter_form(db, td_user, chapter) field = _make_field( db, form, field_key="lunch_20270213_protein", question_type="single_select_radio", - config={"required": False, "options": [{"value": "chicken", "label": "Chicken"}]}, + config={"required": False, "options": [{"option_id": "opt_chicken", "value": "chicken", "label": "Chicken"}]}, ) db.commit() _chapter_lead(db, chapter) @@ -403,7 +775,7 @@ def test_lunch_answer_on_chapter_form_saves_but_does_not_write_through(self, cli res = client.post( f"/forms/{form.id}/responses/", - json={"answers": [{"field_id": field.id, "value": "chicken"}]}, + json={"answers": [{"field_id": field.id, "value": "opt_chicken"}]}, ) assert res.status_code == 200 assert db.query(TournamentMembershipLunch).count() == 0 @@ -479,8 +851,8 @@ def test_submission_accepted_when_branch_skips_required_field(self, client, db, config={ "required": True, "options": [ - {"value": "yes", "label": "Yes", "next_field_id": target.id}, - {"value": "no", "label": "No"}, + {"option_id": "opt_yes", "value": "yes", "label": "Yes", "next_field_id": target.id}, + {"option_id": "opt_no", "value": "no", "label": "No"}, ], }, ) @@ -489,6 +861,6 @@ def test_submission_accepted_when_branch_skips_required_field(self, client, db, res = client.post( f"/forms/{form.id}/responses/", - json={"answers": [{"field_id": branch_field.id, "value": "yes"}]}, + json={"answers": [{"field_id": branch_field.id, "value": "opt_yes"}]}, ) assert res.status_code == 200 diff --git a/backend/tests/core/test_form_branching.py b/backend/tests/core/test_form_branching.py index c750e0ce..5f90291b 100644 --- a/backend/tests/core/test_form_branching.py +++ b/backend/tests/core/test_form_branching.py @@ -37,15 +37,15 @@ def test_simple_branch_jumps_over_skipped_field(self): config={ "required": True, "options": [ - {"value": "yes", "label": "Yes", "next_field_id": 3}, - {"value": "no", "label": "No"}, + {"option_id": "opt_yes", "value": "yes", "label": "Yes", "next_field_id": 3}, + {"option_id": "opt_no", "value": "no", "label": "No"}, ], }, ), _field(2, 2), # skipped when the answer is "yes" _field(3, 3), ] - assert compute_reachable_field_ids(fields, {1: "yes"}) == {1, 3} + assert compute_reachable_field_ids(fields, {1: "opt_yes"}) == {1, 3} def test_branch_not_taken_falls_through_in_order(self): fields = [ @@ -56,15 +56,15 @@ def test_branch_not_taken_falls_through_in_order(self): config={ "required": True, "options": [ - {"value": "yes", "label": "Yes", "next_field_id": 3}, - {"value": "no", "label": "No"}, + {"option_id": "opt_yes", "value": "yes", "label": "Yes", "next_field_id": 3}, + {"option_id": "opt_no", "value": "no", "label": "No"}, ], }, ), _field(2, 2), _field(3, 3), ] - assert compute_reachable_field_ids(fields, {1: "no"}) == {1, 2, 3} + assert compute_reachable_field_ids(fields, {1: "opt_no"}) == {1, 2, 3} def test_unanswered_branching_field_falls_through(self): fields = [ @@ -72,7 +72,10 @@ def test_unanswered_branching_field_falls_through(self): 1, 1, question_type="single_select_radio", - config={"required": False, "options": [{"value": "yes", "label": "Yes", "next_field_id": 3}]}, + config={ + "required": False, + "options": [{"option_id": "opt_yes", "value": "yes", "label": "Yes", "next_field_id": 3}], + }, ), _field(2, 2), _field(3, 3), @@ -88,14 +91,14 @@ def test_submit_form_action_ends_walk_early(self): config={ "required": True, "options": [ - {"value": "no", "label": "No", "action": "submit_form"}, + {"option_id": "opt_no", "value": "no", "label": "No", "action": "submit_form"}, ], }, ), _field(2, 2), _field(3, 3), ] - assert compute_reachable_field_ids(fields, {1: "no"}) == {1} + assert compute_reachable_field_ids(fields, {1: "opt_no"}) == {1} def test_cycle_terminates_instead_of_hanging(self): fields = [ @@ -103,16 +106,22 @@ def test_cycle_terminates_instead_of_hanging(self): 1, 1, question_type="single_select_radio", - config={"required": False, "options": [{"value": "a", "label": "A", "next_field_id": 2}]}, + config={ + "required": False, + "options": [{"option_id": "opt_a", "value": "a", "label": "A", "next_field_id": 2}], + }, ), _field( 2, 2, question_type="single_select_radio", - config={"required": False, "options": [{"value": "b", "label": "B", "next_field_id": 1}]}, + config={ + "required": False, + "options": [{"option_id": "opt_b", "value": "b", "label": "B", "next_field_id": 1}], + }, ), ] - assert compute_reachable_field_ids(fields, {1: "a", 2: "b"}) == {1, 2} + assert compute_reachable_field_ids(fields, {1: "opt_a", 2: "opt_b"}) == {1, 2} class TestMissingRequiredFieldKeys: @@ -125,15 +134,15 @@ def test_skipped_required_field_not_enforced(self): config={ "required": True, "options": [ - {"value": "yes", "label": "Yes", "next_field_id": 3}, - {"value": "no", "label": "No"}, + {"option_id": "opt_yes", "value": "yes", "label": "Yes", "next_field_id": 3}, + {"option_id": "opt_no", "value": "no", "label": "No"}, ], }, ), _field(2, 2, config={"required": True}), # branched past — should NOT be enforced _field(3, 3, config={"required": False}), ] - assert missing_required_field_keys(fields, {1: "yes"}) == [] + assert missing_required_field_keys(fields, {1: "opt_yes"}) == [] def test_reachable_required_field_left_blank_is_reported(self): fields = [_field(1, 1, config={"required": True})] @@ -147,14 +156,14 @@ def test_answered_but_unreachable_field_not_reported(self): question_type="single_select_radio", config={ "required": True, - "options": [{"value": "no", "label": "No", "action": "submit_form"}], + "options": [{"option_id": "opt_no", "value": "no", "label": "No", "action": "submit_form"}], }, ), _field(2, 2, config={"required": True}), ] # field 2 has an answer even though it was never reachable — not our # job to reject that here, just don't let it block the submission - assert missing_required_field_keys(fields, {1: "no", 2: "something"}) == [] + assert missing_required_field_keys(fields, {1: "opt_no", 2: "something"}) == [] def test_blank_values_treated_as_unanswered(self): fields = [_field(1, 1, config={"required": True})] diff --git a/backend/tests/core/test_form_validation.py b/backend/tests/core/test_form_validation.py index 44f57d25..85156147 100644 --- a/backend/tests/core/test_form_validation.py +++ b/backend/tests/core/test_form_validation.py @@ -65,8 +65,8 @@ def _make_field(db, form, *, order=1, field_key="favorite_color", question_type= config={ "required": False, "options": [ - {"value": "opt_1", "label": "Red"}, - {"value": "opt_2", "label": "Blue"}, + {"option_id": "opt_1", "value": "opt_1", "label": "Red"}, + {"option_id": "opt_2", "value": "opt_2", "label": "Blue"}, ], }, is_archived=False, @@ -125,25 +125,37 @@ def test_single_select_duplicate_option_values_rejected(self): with pytest.raises(FormFieldValidationError): validate_field_config( "single_select_radio", - {"required": True, "options": [{"value": "a", "label": "A"}, {"value": "a", "label": "A2"}]}, + { + "required": True, + "options": [ + {"option_id": "opt_1", "value": "a", "label": "A"}, + {"option_id": "opt_2", "value": "a", "label": "A2"}, + ], + }, ) def test_single_select_option_missing_value_rejected(self): with pytest.raises(FormFieldValidationError): - validate_field_config("single_select_dropdown", {"required": True, "options": [{"label": "A"}]}) + validate_field_config( + "single_select_dropdown", + {"required": True, "options": [{"option_id": "opt_1", "label": "A"}]}, + ) def test_multi_select_checkbox_rejects_branching_keys_on_option(self): with pytest.raises(FormFieldValidationError): validate_field_config( "multi_select_checkbox", - {"required": True, "options": [{"value": "a", "label": "A", "next_field_id": 5}]}, + { + "required": True, + "options": [{"option_id": "opt_1", "value": "a", "label": "A", "next_field_id": 5}], + }, ) def test_ranked_choice_missing_allow_duplicates_rejected(self): with pytest.raises(FormFieldValidationError): validate_field_config( "ranked_choice", - {"required": True, "ranks": 1, "options": [{"value": "a", "label": "A"}]}, + {"required": True, "ranks": 1, "options": [{"option_id": "opt_1", "value": "a", "label": "A"}]}, ) def test_ranked_choice_ranks_exceeds_options_rejected(self): @@ -154,7 +166,7 @@ def test_ranked_choice_ranks_exceeds_options_rejected(self): "required": True, "ranks": 3, "allow_duplicates": False, - "options": [{"value": "a", "label": "A"}], + "options": [{"option_id": "opt_1", "value": "a", "label": "A"}], }, ) @@ -165,7 +177,10 @@ def test_ranked_choice_valid_passes(self): "required": True, "ranks": 2, "allow_duplicates": False, - "options": [{"value": "a", "label": "A"}, {"value": "b", "label": "B"}], + "options": [ + {"option_id": "opt_1", "value": "a", "label": "A"}, + {"option_id": "opt_2", "value": "b", "label": "B"}, + ], }, ) assert normalized["ranks"] == 2 @@ -306,7 +321,10 @@ def test_dangling_next_field_id_rejected(self, db, td_user, td_tournament): db, form, question_type="single_select_radio", - config={"required": False, "options": [{"value": "yes", "label": "Yes", "next_field_id": 9999}]}, + config={ + "required": False, + "options": [{"option_id": "opt_1", "value": "yes", "label": "Yes", "next_field_id": 9999}], + }, ) db.commit() with pytest.raises(FormFieldValidationError, match="next_field_id"): From f7f79353cfad992f7a3e616e2b0746a0ded27d8c Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 17:47:37 -0700 Subject: [PATCH 038/185] feat(forms): snapshot select answer value/label at submission time --- backend/app/api/routes/forms.py | 5 ++- backend/app/core/form/__init__.py | 64 ++++++++++++++++++++++++++++-- backend/tests/api/test_forms.py | 65 ++++++++++++++++++++++++++++++- 3 files changed, 128 insertions(+), 6 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 3bdf6aab..731db054 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -13,6 +13,7 @@ flag_pending_updates_for_field, resolve_field_options, slugify, + snapshot_answer_value, ) from app.core.form.branching import missing_required_field_keys from app.core.form.permissions import require_form_manage_access, require_form_view_access @@ -433,8 +434,10 @@ def submit_form_response( db.query(FormAnswer).filter(FormAnswer.response_id == response.id).delete() response.updated_at = utcnow() + field_by_id = {field.id: field for field in active_fields} for answer_in in payload.answers: - db.add(FormAnswer(response_id=response.id, field_id=answer_in.field_id, value=answer_in.value)) + stored_value = snapshot_answer_value(field_by_id[answer_in.field_id], answer_in.value) + db.add(FormAnswer(response_id=response.id, field_id=answer_in.field_id, value=stored_value)) # A fresh answer for a field clears any pending-update flag on it — the # respondent has now seen and re-confirmed whatever changed. Keyed by diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index ed25c121..87dbc256 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -12,6 +12,66 @@ def slugify(text: str, max_len: int = 64) -> str: return slug[:max_len] +# Types whose answer value references option_id(s) and so can be snapshotted +# at submission time (see snapshot_answer_value). `availability` is +# deliberately excluded even though it's currently multi_select_checkbox — +# its answer still submits a raw TournamentShift id, not an option_id, +# until the shift-grouping work lands. +OPTION_BEARING_TYPES = {"single_select_radio", "single_select_dropdown", "multi_select_checkbox", "ranked_choice"} + + +def _snapshot_option(options_by_id: dict, option_id) -> dict: + if not isinstance(option_id, str): + return option_id + option = options_by_id.get(option_id) + if option is None: + # Doesn't resolve against current config (stale/malformed submission) + # — fall back to echoing the id as its own value/label rather than + # dropping information. + return {"option_id": option_id, "value": option_id, "label": option_id} + return {"option_id": option["option_id"], "value": option["value"], "label": option["label"]} + + +def snapshot_answer_value(field: FormField, value): + """Freezes a submitted select-type answer's value/label at the moment + of submission, alongside its option_id — so a later edit to an + option's `value`/`label` (TD-editable text, unlike option_id) doesn't + retroactively change what a past answer displays as. See the Edit + Lifecycle issue: FormResponsePendingUpdate flags that something + changed, but the old answer itself should still read exactly as the + respondent originally saw it. + + Falls back to returning `value` unchanged for anything that doesn't + match the expected shape for `field.question_type` — answer value is + unvalidated user input, not something to raise on here.""" + if field.field_key == "availability" or field.question_type not in OPTION_BEARING_TYPES or value is None: + return value + + options_by_id = {o["option_id"]: o for o in (field.config or {}).get("options", [])} + + if field.question_type == "multi_select_checkbox": + return [_snapshot_option(options_by_id, v) for v in value] if isinstance(value, list) else value + if field.question_type == "ranked_choice": + return {k: _snapshot_option(options_by_id, v) for k, v in value.items()} if isinstance(value, dict) else value + return _snapshot_option(options_by_id, value) if isinstance(value, str) else value + + +def selected_option_ids(field: FormField, value) -> set: + """Inverse-ish of snapshot_answer_value: pulls the set of option_ids a + stored (already-snapshotted) or raw answer references, regardless of + whether each item is a snapshot dict or a bare option_id string. Used + to check a stored answer against a set of newly-archived option_ids.""" + if value is None: + return set() + if field.question_type == "ranked_choice": + items = value.values() if isinstance(value, dict) else [] + elif isinstance(value, list): + items = value + else: + items = [value] + return {item.get("option_id") if isinstance(item, dict) else item for item in items} + + def assign_option_ids(config: dict | None) -> dict | None: """The backend is the sole generator of option_id — the durable per-option identifier used for edit-lifecycle archiving, write-through, @@ -198,9 +258,7 @@ def flag_pending_updates_for_archived_options(db: Session, field: FormField, arc archived_ids = set(archived_option_ids) answers = db.query(FormAnswer).filter(FormAnswer.field_id == field.id).all() for answer in answers: - value = answer.value - selected = value if isinstance(value, list) else ([value] if value else []) - if archived_ids & set(selected): + if archived_ids & selected_option_ids(field, answer.value): _upsert_pending_update(db, answer.response_id, field.field_key, "option_archived") diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index 2ab93d9b..d61f36c9 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -464,9 +464,11 @@ def test_option_removed_archives_not_dropped(self, client, db, td_user, td_tourn rendered_ids = {o["option_id"] for o in res.json()["fields"][0]["config"]["options"]} assert rendered_ids == {"opt_blue"} - # The prior answer referencing opt_red is untouched in storage. + # The prior answer referencing opt_red is untouched in storage — it + # keeps the value/label snapshot from when it was submitted, even + # though the option itself is now archived. answer = db.query(FormAnswer).filter(FormAnswer.field_id == field.id).one() - assert answer.value == ["opt_red"] + assert answer.value == [{"option_id": "opt_red", "value": "red", "label": "Red"}] pending = ( db.query(FormResponsePendingUpdate) @@ -669,6 +671,65 @@ def test_non_member_forbidden(self, client, db, td_user, td_tournament, other_us assert res.status_code == 403 +class TestAnswerSnapshotting: + """A select-type answer stores a {option_id, value, label} snapshot at + submission time, not a bare option_id — so a later edit to the option's + value/label doesn't retroactively change how a past answer displays.""" + + def test_multi_select_answer_stores_value_label_snapshot(self, client, db, td_user, td_tournament): + form = _make_form( + db, td_user, td_tournament, status="published", + ) + field = _make_field( + db, form, field_key="topics", question_type="multi_select_checkbox", + config={ + "required": False, + "options": [ + {"option_id": "opt_red", "value": "red", "label": "Red"}, + {"option_id": "opt_blue", "value": "blue", "label": "Blue"}, + ], + }, + ) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_red"]}]}) + assert res.status_code == 200 + assert res.json()["answers"][0]["value"] == [{"option_id": "opt_red", "value": "red", "label": "Red"}] + + def test_snapshot_survives_later_value_rename(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field( + db, form, field_key="topics", question_type="multi_select_checkbox", + config={"required": False, "options": [{"option_id": "opt_red", "value": "red", "label": "Red"}]}, + ) + db.commit() + login(client, "td@test.com", "tdpass") + client.patch(f"/forms/{form.id}/", json={"status": "published"}) + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_red"]}]}) + response_id = res.json()["id"] + + client.put( + f"/forms/{form.id}/fields/", + json={ + "fields": [ + { + "id": field.id, "label": "Topics", "question_type": "multi_select_checkbox", + "config": { + "required": False, + "options": [{"option_id": "opt_red", "value": "crimson", "label": "Crimson"}], + }, + }, + ] + }, + ) + + answer = db.query(FormAnswer).filter(FormAnswer.response_id == response_id).one() + # Old answer still reads "Red" even though the option is now "Crimson". + assert answer.value == [{"option_id": "opt_red", "value": "red", "label": "Red"}] + + # --------------------------------------------------------------------------- # Write-through — availability/lunch reserved-key answers syncing into their # structural tables (app/core/form/write_through.py), tournament-owned forms From 96e4a9d94b0b90d6eb12fc9bd59293f9dd0de028 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 17:57:45 -0700 Subject: [PATCH 039/185] feat(forms): allow value to be a shift-id list and validate both availability question types --- backend/app/api/routes/forms.py | 2 +- backend/app/core/form/validation.py | 22 ++++++++-------- backend/app/schemas/form.py | 25 +++++++++++++------ backend/tests/core/test_form_validation.py | 29 ++++++++++++++++------ 4 files changed, 52 insertions(+), 26 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 731db054..2e8f3fc2 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -293,7 +293,7 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> try: normalized = validate_field_config(question_type, config) validate_reserved_field_key(field_key, question_type) - if field_key == "availability" and question_type == "multi_select_checkbox": + if field_key == "availability": validate_availability_options(db, form.tournament_id, normalized) except FormFieldValidationError as e: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index c8b4f24d..08308dc8 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -21,7 +21,7 @@ # field_key values with a system-defined meaning. RESERVED_FIELD_KEY_QUESTION_TYPES = { - "availability": {"multi_select_checkbox"}, + "availability": {"single_select_radio", "multi_select_checkbox"}, "event_preference": {"ranked_choice", "multi_select_checkbox", "single_select_dropdown"}, } @@ -157,7 +157,7 @@ def collect_active_field_errors(db: Session, form: Form) -> list[str]: except FormFieldValidationError as e: errors.append(f"field '{field.field_key}': {e}") - if field.field_key == "availability" and field.question_type == "multi_select_checkbox": + if field.field_key == "availability": try: validate_availability_options(db, form.tournament_id, normalized_config) except FormFieldValidationError as e: @@ -184,10 +184,12 @@ def validate_form_for_publish(db: Session, form: Form) -> None: def validate_availability_options(db: Session, tournament_id: int | None, config: dict) -> None: - """A `multi_select_checkbox` field with field_key = "availability" must - have every option's `value` reference a real TournamentShift belonging - to the field's own tournament — validated strictly since a bad value - directly corrupts MembershipAvailability write-through. + """A field with field_key = "availability" (single_select_radio or + multi_select_checkbox) must have every option's `value` be a non-empty + list[int] of real TournamentShift ids belonging to the field's own + tournament — one option groups one or more shifts under a single + TD-labeled choice (e.g. "All Day" -> [1, 2, 3]). Validated strictly + since a bad value directly corrupts MembershipAvailability write-through. Chapter-owned forms have no tournament shift catalog to validate against, so this is a no-op there (a chapter-owned availability field @@ -199,14 +201,14 @@ def validate_availability_options(db: Session, tournament_id: int | None, config if not options: return - shift_ids = set() + shift_ids: set[int] = set() for option in options: value = option.get("value") _require( - value is not None and str(value).isdigit(), - f"availability option value '{value}' must be a TournamentShift id", + isinstance(value, list) and len(value) > 0 and all(isinstance(v, int) for v in value), + f"availability option value '{value}' must be a non-empty list of TournamentShift ids", ) - shift_ids.add(int(value)) + shift_ids.update(value) valid_ids = { shift_id diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index 2fecfbac..4ced398d 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -15,33 +15,44 @@ def _unique_option_fields(options: list) -> list: """option_id and value each need to be unique within a field's option list — option_id is the durable identity (edit-lifecycle archiving, write-through, branching match), value is the TD-facing stored/matched - text. A collision on either would make selection ambiguous.""" + payload. A collision on either would make selection ambiguous. value is + normally a string, but an entity-backed reserved field_key (e.g. + availability grouping several TournamentShifts, event_preference + grouping several TournamentEvents under one option) may set it to a + list[int] instead — hashed as a tuple here since lists aren't hashable.""" seen_ids, seen_values = set(), set() for option in options: if option.option_id in seen_ids: raise ValueError(f"duplicate option_id '{option.option_id}'") seen_ids.add(option.option_id) - if option.value in seen_values: + value_key = tuple(option.value) if isinstance(option.value, list) else option.value + if value_key in seen_values: raise ValueError(f"duplicate option value '{option.value}'") - seen_values.add(option.value) + seen_values.add(value_key) return options class PlainOption(BaseModel): """An option with no branching — multi_select_checkbox, ranked_choice. - extra='forbid' rejects a stray next_field_id/action on these types.""" + extra='forbid' rejects a stray next_field_id/action on these types. + value is usually TD-facing display text, but for an entity-backed + reserved field_key it's list[int] instead — the ids of the underlying + entities (TournamentShifts, TournamentEvents, ...) this option groups + together; the client is responsible for interpreting which shape to + expect based on the field's field_key.""" model_config = ConfigDict(extra="forbid") option_id: str = Field(min_length=1) - value: str = Field(min_length=1) + value: str | list[int] = Field(min_length=1) label: str = Field(min_length=1) is_archived: bool = False class BranchingOption(BaseModel): - """An option that may carry branching — single_select_radio/dropdown only.""" + """An option that may carry branching — single_select_radio/dropdown only. + See PlainOption for value's dual str/list[int] shape.""" model_config = ConfigDict(extra="forbid") option_id: str = Field(min_length=1) - value: str = Field(min_length=1) + value: str | list[int] = Field(min_length=1) label: str = Field(min_length=1) is_archived: bool = False next_field_id: int | None = None diff --git a/backend/tests/core/test_form_validation.py b/backend/tests/core/test_form_validation.py index 85156147..d8fb81f2 100644 --- a/backend/tests/core/test_form_validation.py +++ b/backend/tests/core/test_form_validation.py @@ -203,12 +203,13 @@ def test_long_text_valid_passes(self): # --------------------------------------------------------------------------- class TestValidateReservedFieldKey: - def test_availability_requires_multi_select_checkbox(self): + def test_availability_disallowed_type_rejected(self): with pytest.raises(FormFieldValidationError): validate_reserved_field_key("availability", "single_select_dropdown") - def test_availability_with_multi_select_checkbox_passes(self): - validate_reserved_field_key("availability", "multi_select_checkbox") # no raise + @pytest.mark.parametrize("question_type", ["single_select_radio", "multi_select_checkbox"]) + def test_availability_allowed_types_pass(self, question_type): + validate_reserved_field_key("availability", question_type) # no raise @pytest.mark.parametrize("question_type", ["ranked_choice", "multi_select_checkbox", "single_select_dropdown"]) def test_event_preference_allowed_types_pass(self, question_type): @@ -273,24 +274,36 @@ def test_non_branching_type_is_a_noop(self, db, td_user, td_tournament): class TestValidateAvailabilityOptions: def test_chapter_owned_form_skips_check(self, db): # tournament_id=None (chapter-owned) — no shift catalog to check against - config = {"options": [{"value": "not_a_real_shift_id", "label": "Whenever"}]} + config = {"options": [{"value": ["not_a_real_shift_id"], "label": "Whenever"}]} validate_availability_options(db, None, config) # no raise def test_valid_shift_ids_pass(self, db, td_user, td_tournament): shift = _make_shift(db, td_tournament) db.commit() - config = {"options": [{"value": str(shift.id), "label": shift.label}]} + config = {"options": [{"value": [shift.id], "label": shift.label}]} + validate_availability_options(db, td_tournament.id, config) # no raise + + def test_grouped_shift_ids_all_validated(self, db, td_user, td_tournament): + s1 = _make_shift(db, td_tournament, "Morning") + s2 = _make_shift(db, td_tournament, "Afternoon") + db.commit() + config = {"options": [{"value": [s1.id, s2.id], "label": "All Day"}]} validate_availability_options(db, td_tournament.id, config) # no raise def test_shift_id_not_on_tournament_rejected(self, db, td_user, td_tournament, other_user, other_tournament): shift = _make_shift(db, other_tournament) db.commit() - config = {"options": [{"value": str(shift.id), "label": shift.label}]} + config = {"options": [{"value": [shift.id], "label": shift.label}]} + with pytest.raises(FormFieldValidationError): + validate_availability_options(db, td_tournament.id, config) + + def test_non_list_value_rejected(self, db, td_user, td_tournament): + config = {"options": [{"value": "not_a_list", "label": "Whenever"}]} with pytest.raises(FormFieldValidationError): validate_availability_options(db, td_tournament.id, config) - def test_non_numeric_value_rejected(self, db, td_user, td_tournament): - config = {"options": [{"value": "not_a_shift_id", "label": "Whenever"}]} + def test_empty_list_value_rejected(self, db, td_user, td_tournament): + config = {"options": [{"value": [], "label": "Whenever"}]} with pytest.raises(FormFieldValidationError): validate_availability_options(db, td_tournament.id, config) From c72e4c68bef9685edf0086a7ce1c4754d789fdb5 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 18:18:44 -0700 Subject: [PATCH 040/185] feat(forms): resolve availability options into combined shift time ranges --- backend/app/core/form/__init__.py | 37 +++++++++++-- backend/tests/core/test_forms.py | 89 +++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 3 deletions(-) diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index 87dbc256..e2a757a1 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -1,6 +1,6 @@ from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified -from app.models.models import Form, FormAnswer, FormField, FormResponsePendingUpdate +from app.models.models import Form, FormAnswer, FormField, FormResponsePendingUpdate, TournamentShift import re import secrets @@ -262,6 +262,29 @@ def flag_pending_updates_for_archived_options(db: Session, field: FormField, arc _upsert_pending_update(db, answer.response_id, field.field_key, "option_archived") +def _resolve_availability_option(db: Session, option: dict) -> dict: + """Responder-facing view of one availability option: its label plus the + combined start/end across every TournamentShift its `value` groups + together — never the raw shift id list itself. A respondent selects + "All Day", not the three shifts underneath it; `option_id` is what + they actually submit back on answer (see write-through, which resolves + it server-side against the field's stored config, not this rendering).""" + shift_ids = option.get("value") or [] + rows = ( + db.query(TournamentShift.start, TournamentShift.end) + .filter(TournamentShift.id.in_(shift_ids)) + .all() + ) + starts = [start for start, _ in rows] + ends = [end for _, end in rows] + return { + "option_id": option["option_id"], + "label": option["label"], + "start": min(starts) if starts else None, + "end": max(ends) if ends else None, + } + + def resolve_field_options(db: Session, field: FormField) -> list[dict]: """Options for a given FormField, filtering out any `is_archived: true` option — archived options stay in `config` for historical answer/ @@ -272,6 +295,14 @@ def resolve_field_options(db: Session, field: FormField) -> list[dict]: tournament" conveniences (pulling in events/shifts/etc.) are a TD-editor-side action that populates this array once, same as any manually-authored option list, not a live server-side lookup. See - form-question-types-reference.md's "Options-storage rule".""" + form-question-types-reference.md's "Options-storage rule". availability + is the one exception to "just return config as-is": its `value` is + internal (real TournamentShift ids), so rendering resolves it into a + combined start/end range instead of exposing the raw shift list.""" config = dict(field.config or {}) - return [o for o in config.get("options", []) if not o.get("is_archived")] \ No newline at end of file + options = [o for o in config.get("options", []) if not o.get("is_archived")] + + if field.field_key == "availability": + return [_resolve_availability_option(db, o) for o in options] + + return options \ No newline at end of file diff --git a/backend/tests/core/test_forms.py b/backend/tests/core/test_forms.py index 58fb5b41..b03f3b10 100644 --- a/backend/tests/core/test_forms.py +++ b/backend/tests/core/test_forms.py @@ -9,10 +9,13 @@ from tests.conftest import grant_role from tests.api.chapter._helpers import make_chapter, make_university, make_user +from datetime import datetime, timedelta, timezone + from app.core.form import ( field_key_taken_in_tournament, remove_form_field, replace_field_type, + resolve_field_options, slugify, ) from app.core.form.permissions import require_form_manage_access, require_form_view_access @@ -22,6 +25,7 @@ FormAnswer, FormField, FormResponse, + TournamentShift, ) @@ -95,6 +99,13 @@ def _chapter_lead(db, chapter, email="chapterlead@test.com", password="LeadPass1 return user +def _make_shift(db, tournament, label, start, end): + shift = TournamentShift(tournament_id=tournament.id, label=label, start=start, end=end) + db.add(shift) + db.flush() + return shift + + # --------------------------------------------------------------------------- # Model-level CRUD — Form, FormField, FormResponse, FormAnswer # --------------------------------------------------------------------------- @@ -242,6 +253,84 @@ def test_replace_field_type_archives_old_field_and_keeps_order(self, db, td_user assert replacement.is_archived is False +# --------------------------------------------------------------------------- +# resolve_field_options — availability combined display +# --------------------------------------------------------------------------- + +class TestResolveAvailabilityOptions: + def test_single_shift_option_resolves_its_own_range(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + start = datetime(2027, 2, 13, 7, 0, tzinfo=timezone.utc) + end = datetime(2027, 2, 13, 16, 0, tzinfo=timezone.utc) + shift = _make_shift(db, td_tournament, "Saturday", start, end) + field = _make_field( + db, form, field_key="availability", question_type="single_select_radio", + config={"options": [{"option_id": "opt_1", "value": [shift.id], "label": "Saturday", "is_archived": False}]}, + ) + db.commit() + + options = resolve_field_options(db, field) + assert options == [{"option_id": "opt_1", "label": "Saturday", "start": start, "end": end}] + + def test_grouped_shifts_resolve_combined_range(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + morning_start = datetime(2027, 2, 13, 7, 0, tzinfo=timezone.utc) + morning_end = datetime(2027, 2, 13, 12, 0, tzinfo=timezone.utc) + afternoon_start = datetime(2027, 2, 13, 12, 0, tzinfo=timezone.utc) + afternoon_end = datetime(2027, 2, 13, 16, 0, tzinfo=timezone.utc) + morning = _make_shift(db, td_tournament, "Morning", morning_start, morning_end) + afternoon = _make_shift(db, td_tournament, "Afternoon", afternoon_start, afternoon_end) + field = _make_field( + db, form, field_key="availability", question_type="multi_select_checkbox", + config={ + "options": [ + {"option_id": "opt_all_day", "value": [morning.id, afternoon.id], "label": "All Day", "is_archived": False}, + ], + }, + ) + db.commit() + + options = resolve_field_options(db, field) + assert options == [{"option_id": "opt_all_day", "label": "All Day", "start": morning_start, "end": afternoon_end}] + + def test_raw_shift_ids_not_exposed(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + shift = _make_shift( + db, td_tournament, "Saturday", + datetime(2027, 2, 13, 7, 0, tzinfo=timezone.utc), datetime(2027, 2, 13, 16, 0, tzinfo=timezone.utc), + ) + field = _make_field( + db, form, field_key="availability", question_type="single_select_radio", + config={"options": [{"option_id": "opt_1", "value": [shift.id], "label": "Saturday", "is_archived": False}]}, + ) + db.commit() + + options = resolve_field_options(db, field) + assert "value" not in options[0] + + def test_archived_option_excluded(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + shift = _make_shift( + db, td_tournament, "Saturday", + datetime(2027, 2, 13, 7, 0, tzinfo=timezone.utc), datetime(2027, 2, 13, 16, 0, tzinfo=timezone.utc), + ) + field = _make_field( + db, form, field_key="availability", question_type="single_select_radio", + config={"options": [{"option_id": "opt_1", "value": [shift.id], "label": "Saturday", "is_archived": True}]}, + ) + db.commit() + + assert resolve_field_options(db, field) == [] + + def test_non_availability_field_returns_raw_options(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form) # default config, field_key="favorite_color" + db.commit() + + options = resolve_field_options(db, field) + assert options == field.config["options"] + + # --------------------------------------------------------------------------- # slugify / field_key_taken_in_tournament # --------------------------------------------------------------------------- From 36e11ee454a4552e103c53f029d7f3a2fe8602e9 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 18:28:46 -0700 Subject: [PATCH 041/185] feat(forms): resolve event_preference options into grouped tournament events --- backend/app/core/form/__init__.py | 37 ++++++++++++++++-- backend/tests/core/test_forms.py | 63 +++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index e2a757a1..c051dc79 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -1,6 +1,6 @@ from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified -from app.models.models import Form, FormAnswer, FormField, FormResponsePendingUpdate, TournamentShift +from app.models.models import Form, FormAnswer, FormField, FormResponsePendingUpdate, TournamentEvent, TournamentShift import re import secrets @@ -285,6 +285,32 @@ def _resolve_availability_option(db: Session, option: dict) -> dict: } +def _resolve_event_preference_option(db: Session, option: dict) -> dict: + """Responder-facing view of one event_preference option: its label + plus the actual TournamentEvents its `value` groups together, instead + of raw ids — same "resolve, don't expose ids" treatment as availability. + A `value` that's still a plain string (a single legacy id, per the + Branching/Config-Validation issue's not-yet-strict event_preference + validation) passes through unchanged rather than being resolved.""" + value = option.get("value") + if not isinstance(value, list): + return option + + events = ( + db.query(TournamentEvent.id, TournamentEvent.name, TournamentEvent.division) + .filter(TournamentEvent.id.in_(value)) + .all() + ) + return { + "option_id": option["option_id"], + "label": option["label"], + "events": [ + {"id": event_id, "name": name, "division": division} + for event_id, name, division in events + ], + } + + def resolve_field_options(db: Session, field: FormField) -> list[dict]: """Options for a given FormField, filtering out any `is_archived: true` option — archived options stay in `config` for historical answer/ @@ -296,13 +322,16 @@ def resolve_field_options(db: Session, field: FormField) -> list[dict]: TD-editor-side action that populates this array once, same as any manually-authored option list, not a live server-side lookup. See form-question-types-reference.md's "Options-storage rule". availability - is the one exception to "just return config as-is": its `value` is - internal (real TournamentShift ids), so rendering resolves it into a - combined start/end range instead of exposing the raw shift list.""" + and event_preference are the exceptions to "just return config as-is": + when their `value` groups real entity ids (TournamentShifts, + TournamentEvents), rendering resolves it into the actual entities + instead of exposing raw ids.""" config = dict(field.config or {}) options = [o for o in config.get("options", []) if not o.get("is_archived")] if field.field_key == "availability": return [_resolve_availability_option(db, o) for o in options] + if field.field_key == "event_preference": + return [_resolve_event_preference_option(db, o) for o in options] return options \ No newline at end of file diff --git a/backend/tests/core/test_forms.py b/backend/tests/core/test_forms.py index b03f3b10..698c6ce4 100644 --- a/backend/tests/core/test_forms.py +++ b/backend/tests/core/test_forms.py @@ -25,6 +25,7 @@ FormAnswer, FormField, FormResponse, + TournamentEvent, TournamentShift, ) @@ -106,6 +107,13 @@ def _make_shift(db, tournament, label, start, end): return shift +def _make_event(db, tournament, name, division=None): + event = TournamentEvent(tournament_id=tournament.id, name=name, division=division) + db.add(event) + db.flush() + return event + + # --------------------------------------------------------------------------- # Model-level CRUD — Form, FormField, FormResponse, FormAnswer # --------------------------------------------------------------------------- @@ -331,6 +339,61 @@ def test_non_availability_field_returns_raw_options(self, db, td_user, td_tourna assert options == field.config["options"] +# --------------------------------------------------------------------------- +# resolve_field_options — event_preference resolved entities +# --------------------------------------------------------------------------- + +class TestResolveEventPreferenceOptions: + def test_grouped_events_resolve_to_id_name_and_division(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + anat = _make_event(db, td_tournament, "Anatomy and Physiology", division="B") + disease = _make_event(db, td_tournament, "Disease Detectives", division="C") + field = _make_field( + db, form, field_key="event_preference", question_type="multi_select_checkbox", + config={ + "options": [ + {"option_id": "opt_life_science", "value": [anat.id, disease.id], "label": "Life Science", "is_archived": False}, + ], + }, + ) + db.commit() + + options = resolve_field_options(db, field) + assert options == [ + { + "option_id": "opt_life_science", + "label": "Life Science", + "events": [ + {"id": anat.id, "name": "Anatomy and Physiology", "division": "B"}, + {"id": disease.id, "name": "Disease Detectives", "division": "C"}, + ], + } + ] + + def test_legacy_string_value_passes_through_unresolved(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + event = _make_event(db, td_tournament, "Anatomy and Physiology") + field = _make_field( + db, form, field_key="event_preference", question_type="multi_select_checkbox", + config={"options": [{"option_id": "opt_1", "value": str(event.id), "label": "Anatomy and Physiology", "is_archived": False}]}, + ) + db.commit() + + options = resolve_field_options(db, field) + assert options == field.config["options"] + + def test_archived_option_excluded(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + event = _make_event(db, td_tournament, "Anatomy and Physiology") + field = _make_field( + db, form, field_key="event_preference", question_type="multi_select_checkbox", + config={"options": [{"option_id": "opt_1", "value": [event.id], "label": "Anatomy and Physiology", "is_archived": True}]}, + ) + db.commit() + + assert resolve_field_options(db, field) == [] + + # --------------------------------------------------------------------------- # slugify / field_key_taken_in_tournament # --------------------------------------------------------------------------- From ea85549d8ec07560fb4cda11a293342a31fd252c Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 18:31:54 -0700 Subject: [PATCH 042/185] feat(forms): expand availability write-through from grouped option shift ids --- backend/app/api/routes/forms.py | 11 +++- backend/tests/api/test_forms.py | 94 ++++++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 2e8f3fc2..c8838db9 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -491,7 +491,16 @@ def _write_through_reserved_fields( selected = value if isinstance(value, list) else ([value] if value else []) if field.field_key == "availability": - sync_availability(db, membership.id, [int(v) for v in selected]) + # `selected` is the chosen option_id(s) — each option's `value` + # is the list of real TournamentShift ids it groups together + # (see validate_availability_options); expand and flatten + # before diffing, so overlapping shifts across multiple + # selected options naturally dedupe via set union. + options_by_id = {opt["option_id"]: opt for opt in (field.config or {}).get("options", [])} + shift_ids: set[int] = set() + for option_id in selected: + shift_ids.update(options_by_id.get(option_id, {}).get("value") or []) + sync_availability(db, membership.id, list(shift_ids)) continue if LUNCH_FIELD_KEY_PATTERN.match(field.field_key): diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index d61f36c9..81f537ce 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -750,14 +750,14 @@ def test_availability_write_through_on_tournament_form(self, client, db, td_user db.flush() field = _make_field( db, form, field_key="availability", question_type="multi_select_checkbox", - config={"required": False, "options": [{"option_id": f"opt_{shift.id}", "value": str(shift.id), "label": shift.label}]}, + config={"required": False, "options": [{"option_id": f"opt_{shift.id}", "value": [shift.id], "label": shift.label}]}, ) db.commit() login(client, "td@test.com", "tdpass") res = client.post( f"/forms/{form.id}/responses/", - json={"answers": [{"field_id": field.id, "value": [str(shift.id)]}]}, + json={"answers": [{"field_id": field.id, "value": [f"opt_{shift.id}"]}]}, ) assert res.status_code == 200 @@ -771,6 +771,96 @@ def test_availability_write_through_on_tournament_form(self, client, db, td_user ).all() assert [row.tournament_shift_id for row in rows] == [shift.id] + def _shift_ids(self, db, membership_id): + return { + row.tournament_shift_id + for row in db.query(TournamentMembershipAvailability).filter( + TournamentMembershipAvailability.membership_id == membership_id + ).all() + } + + def _membership_id(self, db, user, tournament): + return ( + db.query(TournamentMembership) + .filter(TournamentMembership.user_id == user.id, TournamentMembership.tournament_id == tournament.id) + .first() + .id + ) + + def test_grouped_availability_option_writes_one_row_per_shift(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + morning = TournamentShift(tournament_id=td_tournament.id, label="Morning", start=datetime.now(timezone.utc), end=datetime.now(timezone.utc) + timedelta(hours=4)) + afternoon = TournamentShift(tournament_id=td_tournament.id, label="Afternoon", start=datetime.now(timezone.utc) + timedelta(hours=4), end=datetime.now(timezone.utc) + timedelta(hours=8)) + db.add_all([morning, afternoon]) + db.flush() + field = _make_field( + db, form, field_key="availability", question_type="single_select_radio", + config={"required": False, "options": [{"option_id": "opt_all_day", "value": [morning.id, afternoon.id], "label": "All Day"}]}, + ) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": "opt_all_day"}]}) + assert res.status_code == 200 + + membership_id = self._membership_id(db, td_user, td_tournament) + assert self._shift_ids(db, membership_id) == {morning.id, afternoon.id} + + def test_overlapping_selected_options_dedupe_shared_shift(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + morning = TournamentShift(tournament_id=td_tournament.id, label="Morning", start=datetime.now(timezone.utc), end=datetime.now(timezone.utc) + timedelta(hours=4)) + afternoon = TournamentShift(tournament_id=td_tournament.id, label="Afternoon", start=datetime.now(timezone.utc) + timedelta(hours=4), end=datetime.now(timezone.utc) + timedelta(hours=8)) + db.add_all([morning, afternoon]) + db.flush() + field = _make_field( + db, form, field_key="availability", question_type="multi_select_checkbox", + config={ + "required": False, + "options": [ + {"option_id": "opt_morning", "value": [morning.id], "label": "Morning"}, + {"option_id": "opt_all_day", "value": [morning.id, afternoon.id], "label": "All Day"}, + ], + }, + ) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post( + f"/forms/{form.id}/responses/", + json={"answers": [{"field_id": field.id, "value": ["opt_morning", "opt_all_day"]}]}, + ) + assert res.status_code == 200 + + membership_id = self._membership_id(db, td_user, td_tournament) + assert self._shift_ids(db, membership_id) == {morning.id, afternoon.id} + + def test_deselecting_option_keeps_shift_still_covered_by_another(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + morning = TournamentShift(tournament_id=td_tournament.id, label="Morning", start=datetime.now(timezone.utc), end=datetime.now(timezone.utc) + timedelta(hours=4)) + afternoon = TournamentShift(tournament_id=td_tournament.id, label="Afternoon", start=datetime.now(timezone.utc) + timedelta(hours=4), end=datetime.now(timezone.utc) + timedelta(hours=8)) + db.add_all([morning, afternoon]) + db.flush() + field = _make_field( + db, form, field_key="availability", question_type="multi_select_checkbox", + config={ + "required": False, + "options": [ + {"option_id": "opt_morning", "value": [morning.id], "label": "Morning"}, + {"option_id": "opt_all_day", "value": [morning.id, afternoon.id], "label": "All Day"}, + ], + }, + ) + db.commit() + login(client, "td@test.com", "tdpass") + + client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_morning", "opt_all_day"]}]}) + # Deselect "All Day" — "Morning" alone still covers the morning shift. + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_morning"]}]}) + assert res.status_code == 200 + + membership_id = self._membership_id(db, td_user, td_tournament) + assert self._shift_ids(db, membership_id) == {morning.id} + def test_lunch_write_through_on_tournament_form(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament) field = _make_field( From b18e498997db7e3066fbe5c3b44475204ab51318 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 18:33:48 -0700 Subject: [PATCH 043/185] feat(tournament): block shift deletion when referenced by a live availability field option --- backend/app/api/routes/tournament/shifts.py | 11 +++++ backend/app/core/form/__init__.py | 28 +++++++++++++ backend/tests/api/tournament/test_shifts.py | 45 ++++++++++++++++++++- 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/backend/app/api/routes/tournament/shifts.py b/backend/app/api/routes/tournament/shifts.py index fc74db49..f2fc84ec 100644 --- a/backend/app/api/routes/tournament/shifts.py +++ b/backend/app/api/routes/tournament/shifts.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session, selectinload +from app.core.form import shift_referenced_by_live_field from app.core.tournament import get_scoped_or_404, get_tournament, require_not_archived, tournament_local_date from app.core.tournament.permissions import MANAGE_EVENTS, require_permission from app.db.session import get_db @@ -104,6 +105,10 @@ def update_shift( # member-submitted data (write-through from a form response, see # app/core/form/write_through.py), not planning structure a TD can just # re-derive, so silently cascading it away on a shift edit isn't acceptable. +# +# A live (non-archived) availability field's option grouping is the same +# kind of guard, even before anyone's answered: a shift that's part of a +# published question's choices can't be silently pulled out from under it. # --------------------------------------------------------------------------- @router.delete("/{shift_id}/", status_code=status.HTTP_204_NO_CONTENT) def delete_shift( @@ -123,6 +128,12 @@ def delete_shift( detail=f"Shift has {shift.availability_count} membership availability selection(s) — cannot delete", ) + if shift_referenced_by_live_field(db, tournament_id, shift_id): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Shift is referenced by a live form field's availability option — cannot delete", + ) + db.delete(shift) db.commit() diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index c051dc79..5bd3a486 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -106,6 +106,34 @@ def field_key_taken_in_tournament(db: Session, tournament_id: int, field_key: st ) +def shift_referenced_by_live_field(db: Session, tournament_id: int, shift_id: int) -> bool: + """True if `shift_id` appears inside any non-archived availability + field's option `value` (the list of grouped TournamentShift ids) on + any form owned by `tournament_id` — used by the shift deletion guard + so a shift can't be pulled out from under a live option's grouping, + independent of whether anyone's answered yet (that's the separate, + pre-existing MembershipAvailability guard). `FormField.config` is a + plain JSON column (not JSONB) and tests run on SQLite, which has no + JSON operators at all, so this is a Python-side scan rather than a + DB-side containment query — same reasoning as the pending-updates scan + over FormAnswer.value.""" + fields = ( + db.query(FormField) + .join(Form, Form.id == FormField.form_id) + .filter( + Form.tournament_id == tournament_id, + FormField.field_key == "availability", + FormField.is_archived == False, + ) + .all() + ) + for field in fields: + for option in (field.config or {}).get("options", []): + if not option.get("is_archived") and shift_id in (option.get("value") or []): + return True + return False + + def field_has_answers(db: Session, field_id: int) -> bool: """True if any FormAnswer exists for this field — locks it against edit (see forms.py's edit_form_field) and hard delete (below).""" diff --git a/backend/tests/api/tournament/test_shifts.py b/backend/tests/api/tournament/test_shifts.py index 8e634f1d..b9a99173 100644 --- a/backend/tests/api/tournament/test_shifts.py +++ b/backend/tests/api/tournament/test_shifts.py @@ -4,7 +4,7 @@ from tests.conftest import grant_role, login -from app.models.models import TournamentMembership, TournamentMembershipAvailability +from app.models.models import Form, FormField, TournamentMembership, TournamentMembershipAvailability # td_tournament spans [today, today + 1 day] — event/shift times must fall # within that window now that tournament-bounds validation exists. @@ -158,6 +158,49 @@ def test_delete_shift_blocked_when_referenced_by_availability(client, db, td_use assert any(s["id"] == shift["id"] for s in listed) +def test_delete_shift_blocked_when_referenced_by_live_field_option(client, db, td_user, td_tournament): + """Guard fires even with zero answers — a shift grouped into a live + availability option can't be pulled out from under it, independent of + the separate MembershipAvailability guard above.""" + login(client, "td@test.com", "tdpass") + shift = _make_shift(client, td_tournament.id).json() + + form = Form(owner_type="tournament", tournament_id=td_tournament.id, name="Volunteer form", created_by=td_user.id) + db.add(form) + db.flush() + db.add(FormField( + form_id=form.id, order=1, label="Availability", field_key="availability", + question_type="multi_select_checkbox", + config={"options": [{"option_id": "opt_1", "value": [shift["id"]], "label": "All Day"}]}, + is_archived=False, + )) + db.commit() + + response = client.delete(f"/tournaments/{td_tournament.id}/shifts/{shift['id']}/") + assert response.status_code == 409 + + listed = client.get(f"/tournaments/{td_tournament.id}/shifts/").json() + assert any(s["id"] == shift["id"] for s in listed) + + +def test_delete_shift_allowed_when_only_referenced_by_archived_field(client, db, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + shift = _make_shift(client, td_tournament.id).json() + + form = Form(owner_type="tournament", tournament_id=td_tournament.id, name="Old form", created_by=td_user.id) + db.add(form) + db.flush() + db.add(FormField( + form_id=form.id, order=1, label="Availability", field_key="availability_archived_1", + question_type="multi_select_checkbox", + config={"options": [{"option_id": "opt_1", "value": [shift["id"]], "label": "All Day"}]}, + is_archived=True, + )) + db.commit() + + assert client.delete(f"/tournaments/{td_tournament.id}/shifts/{shift['id']}/").status_code == 204 + + def test_shift_routes_require_manage_events(client, td_user, other_tournament, db): grant_role(db, other_tournament, td_user, "Volunteer") login(client, "td@test.com", "tdpass") From 9608a5c03881cc46f3c0b0206b1caa5d3e7bb2da Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 18:38:16 -0700 Subject: [PATCH 044/185] docs(forms): rewrite question-types reference for edit lifecycle, option_id, and availability/event_preference grouping; drop opt_ prefix from generated option ids --- backend/app/core/form/__init__.py | 2 +- backend/form-question-types-reference.md | 76 ++++++++++++++++++------ 2 files changed, 60 insertions(+), 18 deletions(-) diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index 5bd3a486..6a1877c3 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -87,7 +87,7 @@ def assign_option_ids(config: dict | None) -> dict | None: for option in config["options"]: option = dict(option) if not option.get("option_id"): - option["option_id"] = f"opt_{secrets.token_hex(5)}" + option["option_id"] = secrets.token_hex(5) options.append(option) return {**config, "options": options} diff --git a/backend/form-question-types-reference.md b/backend/form-question-types-reference.md index c53f78c9..b9246190 100644 --- a/backend/form-question-types-reference.md +++ b/backend/form-question-types-reference.md @@ -22,7 +22,15 @@ Every `FormField` shares the same outer shape: **Line between `question_type` and `field_key`:** `question_type` is purely structural — how the question is rendered and answered. `field_key` is semantic — when it's a reserved key (`availability`, `event_preference`, `lunch_{custom}`), it changes how a *structurally normal* field's options/answers get parsed and, for tournament forms, written through to a structural table. Reserved keys don't get their own `question_type` — they reuse the existing structural types and layer extra validation on top. When a TD picks a reserved-key preset/template, `field_key` should be locked to the reserved value rather than freely typed — otherwise a stray typo (`availibility`) silently breaks write-through with no error. Flagging this as the intended behavior, not yet confirmed. -**Options-storage rule:** wherever a type has an `options` array, each option is `{ "value": ..., "label": ... }` — `label` is what's shown, `value` is what's actually stored in `FormAnswer` (or referenced by write-through). Options are stored raw and literal — a resolved snapshot at creation/edit time, not a dynamic source reference. Editors may offer an "auto-load from tournament" convenience (events, categories, shifts) that populates this array once; after that it's just a normal static list like any other question's options. `value` is the stable identifier for edit-lifecycle purposes (renaming `label` is a safe in-place edit; old answers referencing `value` still resolve) — for options backed by a real entity (a `TournamentShift`, `TournamentEvent`, etc.) `value` is that entity's real id. +**Options-storage rule:** wherever a type has an `options` array, each option is `{ "option_id": ..., "value": ..., "label": ..., "is_archived": false }`: +- `option_id` — system-generated, opaque, required, and the **sole stable identifier**: what a submitted answer actually references, what branching matches against, and what Edit Lifecycle diffs/archives by (see "Reserved `field_key`s" and the Edit Lifecycle section below). Never client-authored; a create/update request may omit it (new option) or echo back one from a prior `GET` (existing option, kept stable). +- `value` — normally TD-facing display text (typically a shortened version of `label`). For an entity-backed reserved `field_key` (`availability` grouping `TournamentShift`s, `event_preference` grouping `TournamentEvent`s), it's instead `list[int]` — the real ids of the underlying entities this option groups together — and the client is responsible for interpreting which shape to expect based on `field_key`. A bare `list[int]` for `event_preference` is resolved on render (see below); a legacy plain-string `value` there passes through unresolved. +- `label` — responder-facing display text. +- `is_archived` — set by the server during a published-form republish (see Edit Lifecycle); an archived option is dropped from what a new respondent sees/can select, but stays in storage so a past answer referencing its `option_id` still resolves. + +Options are stored raw and literal — a resolved snapshot at creation/edit time, not a dynamic source reference. Editors may offer an "auto-load from tournament" convenience (events, shifts) that populates `value`'s entity-id list once; after that it's just a normal static list like any other question's options, no live server-side lookup involved. + +**Answer-value snapshotting:** for any option-bearing type except `availability` (still on its own raw-shift-id submission path, see below), `FormAnswer.value` doesn't store a bare `option_id` — it stores a `{ "option_id": ..., "value": ..., "label": ... }` snapshot captured at submission time (a list of snapshots for `multi_select_checkbox`, a rank→snapshot dict for `ranked_choice`). This means a later edit to an option's `value`/`label` (TD-editable text, unlike `option_id`) never retroactively changes how a past answer displays — see `FormResponsePendingUpdate` below for how a TD/respondent actually finds out something changed. --- @@ -42,13 +50,13 @@ Pick exactly one, shown as radio buttons. "config": { "required": true, "options": [ - { "value": "yes", "label": "Yes", "next_field_id": 15 }, - { "value": "no", "label": "No", "action": "submit_form" }, - { "value": "maybe", "label": "Maybe" } + { "option_id": "a1b2c3d4e5", "value": "yes", "label": "Yes", "next_field_id": 15 }, + { "option_id": "f6e5d4c3b2", "value": "no", "label": "No", "action": "submit_form" }, + { "option_id": "7g8h9i0j1k", "value": "maybe", "label": "Maybe" } ] } ``` -Answer value: the chosen option's `value`. +Answer value: the chosen option's `option_id` — stored as a `{option_id, value, label}` snapshot (see "Answer-value snapshotting" above). Branching: supported — see Branching section below. ## `single_select_dropdown` @@ -62,15 +70,21 @@ Pick any number, shown as checkboxes. "config": { "required": true, "options": [ - { "value": "anat_physio", "label": "Anatomy and Physiology" }, - { "value": "disease_detectives", "label": "Disease Detectives" } + { "option_id": "a1b2c3d4e5", "value": "anat_physio", "label": "Anatomy and Physiology" }, + { "option_id": "f6e5d4c3b2", "value": "disease_detectives", "label": "Disease Detectives" } ] } ``` -Answer value: array of chosen option `value`s. +Answer value: array of chosen option `option_id`s — stored as a list of `{option_id, value, label}` snapshots. Branching: not supported (not single-select). -**Reserved-key note:** when `field_key = "availability"`, this is the required `question_type`, and `value` on each option must resolve to a real `TournamentShift` belonging to the field's tournament (auto-loadable from the tournament's shift catalog, not free-typed). On submit, the answer write-throughs into `MembershipAvailability` (diffed against the prior submission) instead of being stored in `FormAnswer` — this only fires on tournament-owned forms; on a chapter-owned form the same field is valid but stores as a normal `FormAnswer`, no write-through. +**Reserved-key note (`availability`):** `field_key = "availability"` is allowed on either `single_select_radio` or `multi_select_checkbox` — the TD's choice of type determines whether a respondent can select more than one grouped option at once (multi-select, e.g. "free both Morning and Evening but not Afternoon") or at most one (single-select, for a tournament that only wants one blanket answer per person); it doesn't change write-through, only how many options can be selected. Each option's `value` is `list[int]` — one or more real `TournamentShift` ids belonging to the field's tournament, grouped under a single TD-labeled choice (e.g. `"All Day"` → `[1, 2, 3]`), auto-loadable from the tournament's shift catalog. A responder never sees the raw shift list: `GET`-rendering resolves each option into its `label` plus the combined `start`/`end` across every shift it groups (`resolve_field_options`'s availability branch), e.g.: + +```json +{ "option_id": "a1b2c3d4e5", "label": "All Day", "start": "2027-02-13T07:00:00Z", "end": "2027-02-13T16:00:00Z" } +``` + +On submit, the answer's selected option_id(s) are expanded into their grouped shift ids (deduped via set union across selections) and write-through into `MembershipAvailability` (diffed against the prior submission) — this fires only on tournament-owned forms; on a chapter-owned form the same field is valid but stores as a normal `FormAnswer`, no write-through. Availability answers are **not** snapshotted the way other option types are (see "Answer-value snapshotting" above) — `FormAnswer.value` stores the raw selected `option_id`(s) directly. Deleting a `TournamentShift` is rejected if it's referenced either by an existing `MembershipAvailability` row, or inside any non-archived field's option `value` list — a shift that's part of a live option's grouping can't be pulled out from under it even before anyone's answered. ## `ranked_choice` Rank a fixed number of options in order of preference. @@ -81,15 +95,21 @@ Rank a fixed number of options in order of preference. "ranks": 3, "allow_duplicates": false, "options": [ - { "value": "te_anat_physio", "label": "Anatomy and Physiology" }, - { "value": "te_disease_detectives", "label": "Disease Detectives" } + { "option_id": "a1b2c3d4e5", "value": "te_anat_physio", "label": "Anatomy and Physiology" }, + { "option_id": "f6e5d4c3b2", "value": "te_disease_detectives", "label": "Disease Detectives" } ] } ``` -Answer value: dict of rank → option `value`, e.g. `{"1": "te_anat_physio", "2": "te_disease_detectives"}`. +Answer value: dict of rank → option `option_id`, e.g. `{"1": "a1b2c3d4e5", "2": "f6e5d4c3b2"}` — stored as rank → `{option_id, value, label}` snapshot. Branching: not supported. -**Reserved-key note:** `event_preference` is allowed on this type, `multi_select_checkbox`, or `single_select_dropdown`. When it's used, `value` needs to be the real `TournamentEvent` id so it can be matched back to the tournament's actual events — this strict resolution isn't validated yet, it's tied to a future "auto-load events into options" feature, not this phase. +**Reserved-key note (`event_preference`):** allowed on this type, `multi_select_checkbox`, or `single_select_dropdown`. An option's `value` may be `list[int]` — one or more real `TournamentEvent` ids grouped under a single label (the same grouping pattern as availability's shift ids), auto-loadable from the tournament's event catalog. `GET`-rendering resolves a `list[int]` value into the actual events instead of exposing raw ids (`resolve_field_options`'s event_preference branch): + +```json +{ "option_id": "a1b2c3d4e5", "label": "Life Science", "events": [{ "id": 5, "name": "Anatomy and Physiology", "division": "B" }, { "id": 9, "name": "Disease Detectives", "division": "C" }] } +``` + +A `value` that's still a plain string (a single legacy id) passes through unresolved — strict validation that every `event_preference` option's ids are real `TournamentEvent`s isn't built yet, unlike `availability`'s strict shift-id check. ## `short_text` / `long_text` Free text — `short_text` single line, `long_text` multi-line. @@ -109,15 +129,37 @@ Only `single_select_radio` and `single_select_dropdown` options may carry branch - `action: "submit_form"` — end the flow immediately and submit whatever's been answered. - Neither present — fall through to the next field in document `order` (the default case). -`next_field_id`/`action` are mutually exclusive per option, and `next_field_id` must reference an existing field in the same form. Next-field computation happens **client-side** — the frontend fetches the full field list once and walks the jump graph locally, no per-answer round trip. Multi-field loops (A→B→A) aren't currently guarded against — deferred until it's a real problem. +`next_field_id`/`action` are mutually exclusive per option, and `next_field_id` must reference an existing field in the same form. The branching replay (both the frontend's client-side jump-graph walk and the backend's submission-time reachability check) matches a submitted answer against an option by `option_id`, not `value`. Multi-field loops (A→B→A) aren't currently guarded against — deferred until it's a real problem. ## Reserved `field_key`s | `field_key` | Allowed `question_type`(s) | Write-through | |---|---|---| -| `availability` | `multi_select_checkbox` only | `TournamentMembershipAvailability` (tournament-owned forms only) | -| `lunch_{date}_{category}` — e.g. `lunch_20270213_protein` (`^lunch_\d{8}_[a-z0-9_]+$`), one per (date, category) pair | `single_select_radio` or `multi_select_checkbox` | `TournamentMembershipLunch` (tournament-owned forms only); no catalog table — stores whatever option was selected, keyed by category string | -| `event_preference` | `ranked_choice`, `multi_select_checkbox`, or `single_select_dropdown` | none — generic `FormAnswer` (option `value` should be a real `TournamentEvent` id, not yet strictly validated) | +| `availability` | `single_select_radio` or `multi_select_checkbox` | `TournamentMembershipAvailability` (tournament-owned forms only); selected option_id(s) expand into their grouped `TournamentShift` ids before diffing | +| `lunch_{date}_{category}` — e.g. `lunch_20270213_protein` (`^lunch_\d{8}_[a-z0-9_]+$`), one per (date, category) pair | `single_select_radio` or `multi_select_checkbox` | `TournamentMembershipLunch` (tournament-owned forms only); selected option_id(s) resolve to their stored `value`/`label`, no catalog table — stores whatever option was selected, keyed by category string | +| `event_preference` | `ranked_choice`, `multi_select_checkbox`, or `single_select_dropdown` | none — generic `FormAnswer` (option `value` may be `list[int]` of real `TournamentEvent` ids, resolved on render; not yet strictly validated against real events) | | any TD-typed slug | any type | none — generic `FormAnswer` | Reserved keys are valid on both tournament- and chapter-owned forms — the key itself doesn't require tournament ownership. Only the write-through step is tournament-only; on a chapter-owned form these fields behave exactly like a normal custom question. + +--- + +## Edit Lifecycle + +Once a form is `published`, someone may have already answered it, so editing its fields doesn't work the way it does on a `draft` form. There's no server-side draft/staging table — the client holds an in-progress edit locally and sends the complete target field list in one request, which the server treats as "go live now." + +**`PUT /forms/{id}/fields/`** replaces the old per-field `POST`/`PATCH`/`DELETE` routes entirely. Body is the full ordered target list of fields: +- Entry with an existing field `id` → update. +- Entry with no `id` → create. +- A currently-live, non-archived field whose `id` is missing from the list → removal. + +**`draft`-status forms:** applied directly — hard delete removed fields, update changed ones (including `question_type` changes, in place), insert new ones. No archiving, since nothing on a form that's never been published has ever been answerable. + +**`published`-status forms:** the server diffs the submitted list against current live fields, then validates the whole proposed end-state (config shape, options, branching `next_field_id` resolution) before anything commits — a dangling branch reference, including one that would point at a field this same request removes, rejects the whole batch atomically. If valid: +- Label/description/config-only changes → update in place. +- `question_type` change → archive the old field, create a replacement at the same list position, inheriting the same `field_key` (an explicit exception to "archived keys stay reserved forever" — this is the same logical question continuing, not a new one). +- Missing from the submitted list → archive, not delete. +- No `id` → insert as new. +- Within an updated field, options are diffed by `option_id` the same way — one missing from the submitted config gets `is_archived: true` added rather than being dropped from storage. + +**`FormResponsePendingUpdate`** (`response_id`, `field_key`, `reason`: `"field_replaced"` | `"option_archived"`, unique on `(response_id, field_key)`) is generated whenever a republish archives a field or option that a response had already answered — this is how a TD or respondent finds out an existing answer needs another look. Keyed by `field_key` (not a field id) so it always resolves to whichever field currently holds that key, regardless of further edits. `reason` only ever escalates `option_archived` → `field_replaced`, never the reverse. Cleared when the response next submits a fresh answer to whichever field currently holds that `field_key`. From 362d252c6cffcf22b80ef50b7fe38de0ffa8a01a Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 18:48:44 -0700 Subject: [PATCH 045/185] docs(forms): document Form.status gates and update option shapes for edit lifecycle/grouping --- backend/form-question-types-reference.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/form-question-types-reference.md b/backend/form-question-types-reference.md index b9246190..8f4533a2 100644 --- a/backend/form-question-types-reference.md +++ b/backend/form-question-types-reference.md @@ -144,6 +144,13 @@ Reserved keys are valid on both tournament- and chapter-owned forms — the key --- +## `Form.status` + +`Form.status` is `"draft"` | `"published"` | `"archived"`, set/transitioned via `PATCH /forms/{form_id}/`: +- **Only a `published` form accepts responses.** `POST /forms/{form_id}/responses/` rejects with `409` on a `draft` or `archived` form, regardless of the requester's access level. +- **A `published` form can't be reverted to `draft`.** `PATCH .../status: "draft"` on a currently-`published` form is rejected with `409` — archive it instead if it should stop accepting responses. This exists because `draft`-status editing is a hard-delete/direct-apply path (see Edit Lifecycle below); allowing published → draft would let a TD silently destroy already-answered fields/options through a path that was never meant to touch live data. +- Publishing (`draft` → `published`, or an explicit republish while already `published`) runs a whole-form validation pass (`validate_form_for_publish`): the form must have at least one active field, and every field's `config`/branching/`next_field_id` resolution must be valid in aggregate — not just individually — before the transition/republish is allowed. + ## Edit Lifecycle Once a form is `published`, someone may have already answered it, so editing its fields doesn't work the way it does on a `draft` form. There's no server-side draft/staging table — the client holds an in-progress edit locally and sends the complete target field list in one request, which the server treats as "go live now." From 927628eed6b8c0e6b5b9c713326b73d9d2239615 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 19:01:34 -0700 Subject: [PATCH 046/185] feat(forms): require published status to accept responses; block reverting a published form to draft --- backend/app/api/routes/forms.py | 12 +++++++++ backend/tests/api/test_forms.py | 44 +++++++++++++++++++++++---------- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index c8838db9..3104fa76 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -161,6 +161,12 @@ def update_form( db: Session = Depends(get_db), form: Form = Depends(require_form_manage_access), ): + if payload.status == "draft" and form.status == "published": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A published form cannot be reverted to draft — archive it instead if it should stop accepting responses", + ) + if payload.status == "published": try: validate_form_for_publish(db, form) @@ -398,6 +404,12 @@ def submit_form_response( form: Form = Depends(require_form_view_access), current_user: User = Depends(get_current_user), ): + if form.status != "published": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Form is '{form.status}', not published — responses aren't accepted", + ) + active_fields = ( db.query(FormField) .filter(FormField.form_id == form.id, FormField.is_archived == False) diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index 81f537ce..d9caf75d 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -626,7 +626,7 @@ def test_duplicate_option_id_within_field_rejected(self, client, db, td_user, td class TestSubmitResponse: def test_first_submission_creates_response(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) + form = _make_form(db, td_user, td_tournament, status="published") field = _make_field(db, form, field_key="color") db.commit() login(client, "td@test.com", "tdpass") @@ -642,7 +642,7 @@ def test_first_submission_creates_response(self, client, db, td_user, td_tournam assert data["answers"][0]["value"] == ["opt_1"] def test_resubmission_overwrites_in_place(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) + form = _make_form(db, td_user, td_tournament, status="published") field = _make_field(db, form, field_key="color") db.commit() login(client, "td@test.com", "tdpass") @@ -656,7 +656,7 @@ def test_resubmission_overwrites_in_place(self, client, db, td_user, td_tourname assert db.query(FormResponse).filter(FormResponse.form_id == form.id, FormResponse.user_id == td_user.id).count() == 1 def test_invalid_field_id_rejected(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) + form = _make_form(db, td_user, td_tournament, status="published") db.commit() login(client, "td@test.com", "tdpass") @@ -664,12 +664,30 @@ def test_invalid_field_id_rejected(self, client, db, td_user, td_tournament): assert res.status_code == 400 def test_non_member_forbidden(self, client, db, td_user, td_tournament, other_user): - form = _make_form(db, td_user, td_tournament) + form = _make_form(db, td_user, td_tournament, status="published") db.commit() login(client, "other@test.com", "otherpass") res = client.post(f"/forms/{form.id}/responses/", json={"answers": []}) assert res.status_code == 403 + def test_draft_form_rejects_submission(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) # draft by default + field = _make_field(db, form, field_key="color") + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_1"]}]}) + assert res.status_code == 409 + + def test_archived_form_rejects_submission(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament, status="archived") + field = _make_field(db, form, field_key="color") + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_1"]}]}) + assert res.status_code == 409 + class TestAnswerSnapshotting: """A select-type answer stores a {option_id, value, label} snapshot at @@ -739,7 +757,7 @@ def test_snapshot_survives_later_value_rename(self, client, db, td_user, td_tour class TestWriteThroughOnSubmit: def test_availability_write_through_on_tournament_form(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) + form = _make_form(db, td_user, td_tournament, status="published") shift = TournamentShift( tournament_id=td_tournament.id, label="Saturday", @@ -788,7 +806,7 @@ def _membership_id(self, db, user, tournament): ) def test_grouped_availability_option_writes_one_row_per_shift(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) + form = _make_form(db, td_user, td_tournament, status="published") morning = TournamentShift(tournament_id=td_tournament.id, label="Morning", start=datetime.now(timezone.utc), end=datetime.now(timezone.utc) + timedelta(hours=4)) afternoon = TournamentShift(tournament_id=td_tournament.id, label="Afternoon", start=datetime.now(timezone.utc) + timedelta(hours=4), end=datetime.now(timezone.utc) + timedelta(hours=8)) db.add_all([morning, afternoon]) @@ -807,7 +825,7 @@ def test_grouped_availability_option_writes_one_row_per_shift(self, client, db, assert self._shift_ids(db, membership_id) == {morning.id, afternoon.id} def test_overlapping_selected_options_dedupe_shared_shift(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) + form = _make_form(db, td_user, td_tournament, status="published") morning = TournamentShift(tournament_id=td_tournament.id, label="Morning", start=datetime.now(timezone.utc), end=datetime.now(timezone.utc) + timedelta(hours=4)) afternoon = TournamentShift(tournament_id=td_tournament.id, label="Afternoon", start=datetime.now(timezone.utc) + timedelta(hours=4), end=datetime.now(timezone.utc) + timedelta(hours=8)) db.add_all([morning, afternoon]) @@ -835,7 +853,7 @@ def test_overlapping_selected_options_dedupe_shared_shift(self, client, db, td_u assert self._shift_ids(db, membership_id) == {morning.id, afternoon.id} def test_deselecting_option_keeps_shift_still_covered_by_another(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) + form = _make_form(db, td_user, td_tournament, status="published") morning = TournamentShift(tournament_id=td_tournament.id, label="Morning", start=datetime.now(timezone.utc), end=datetime.now(timezone.utc) + timedelta(hours=4)) afternoon = TournamentShift(tournament_id=td_tournament.id, label="Afternoon", start=datetime.now(timezone.utc) + timedelta(hours=4), end=datetime.now(timezone.utc) + timedelta(hours=8)) db.add_all([morning, afternoon]) @@ -862,7 +880,7 @@ def test_deselecting_option_keeps_shift_still_covered_by_another(self, client, d assert self._shift_ids(db, membership_id) == {morning.id} def test_lunch_write_through_on_tournament_form(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) + form = _make_form(db, td_user, td_tournament, status="published") field = _make_field( db, form, field_key="lunch_20270213_protein", question_type="single_select_radio", config={ @@ -897,7 +915,7 @@ def test_lunch_write_through_on_tournament_form(self, client, db, td_user, td_to assert rows[0].date == date(2027, 2, 13) def test_availability_answer_on_chapter_form_saves_but_does_not_write_through(self, client, db, td_user, chapter): - form = _make_chapter_form(db, td_user, chapter) + form = _make_chapter_form(db, td_user, chapter, status="published") field = _make_field( db, form, field_key="availability", question_type="multi_select_checkbox", config={"required": False, "options": [{"option_id": "opt_1", "value": "not_a_real_shift_id", "label": "Whenever"}]}, @@ -915,7 +933,7 @@ def test_availability_answer_on_chapter_form_saves_but_does_not_write_through(se assert db.query(TournamentMembershipAvailability).count() == 0 def test_lunch_answer_on_chapter_form_saves_but_does_not_write_through(self, client, db, td_user, chapter): - form = _make_chapter_form(db, td_user, chapter) + form = _make_chapter_form(db, td_user, chapter, status="published") field = _make_field( db, form, field_key="lunch_20270213_protein", question_type="single_select_radio", config={"required": False, "options": [{"option_id": "opt_chicken", "value": "chicken", "label": "Chicken"}]}, @@ -978,7 +996,7 @@ def test_me_404_when_no_response(self, client, db, td_user, td_tournament): class TestSubmissionReachabilityEnforcement: def test_submission_rejected_when_reachable_required_field_missing(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) + form = _make_form(db, td_user, td_tournament, status="published") required_field = _make_field( db, form, order=1, field_key="required_field", question_type="short_text", config={"required": True, "max_length": 100}, @@ -990,7 +1008,7 @@ def test_submission_rejected_when_reachable_required_field_missing(self, client, assert res.status_code == 400 def test_submission_accepted_when_branch_skips_required_field(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) + form = _make_form(db, td_user, td_tournament, status="published") skipped = _make_field( db, form, order=2, field_key="skipped", question_type="short_text", config={"required": True, "max_length": 100}, From fa1d19d8b0b5611002a55e5a8160230e088de141 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 19:06:04 -0700 Subject: [PATCH 047/185] feat(forms): resolve availability/event_preference option value into per-entity detail instead of a summary --- backend/app/core/form/__init__.py | 42 ++++++++++++++---------- backend/form-question-types-reference.md | 11 ++++--- backend/tests/core/test_forms.py | 36 +++++++++----------- 3 files changed, 47 insertions(+), 42 deletions(-) diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index 6a1877c3..c85dc039 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -291,35 +291,40 @@ def flag_pending_updates_for_archived_options(db: Session, field: FormField, arc def _resolve_availability_option(db: Session, option: dict) -> dict: - """Responder-facing view of one availability option: its label plus the - combined start/end across every TournamentShift its `value` groups - together — never the raw shift id list itself. A respondent selects - "All Day", not the three shifts underneath it; `option_id` is what - they actually submit back on answer (see write-through, which resolves - it server-side against the field's stored config, not this rendering).""" + """Responder-facing view of one availability option: `value` (normally + the raw list[int] of grouped TournamentShift ids) is resolved in place + into one `{id, label, start, end}` dict per shift — reusing `value` + rather than inventing new keys, and keeping every underlying shift's own + id/label/range intact (not collapsed into one combined range) so a + builder UI can reconstruct exactly what's grouped, not just the result. + `option_id` is what's actually submitted back on answer (see + write-through, which resolves it server-side against the field's stored + config, not this rendering).""" shift_ids = option.get("value") or [] rows = ( - db.query(TournamentShift.start, TournamentShift.end) + db.query(TournamentShift.id, TournamentShift.label, TournamentShift.start, TournamentShift.end) .filter(TournamentShift.id.in_(shift_ids)) + .order_by(TournamentShift.start) .all() ) - starts = [start for start, _ in rows] - ends = [end for _, end in rows] return { "option_id": option["option_id"], "label": option["label"], - "start": min(starts) if starts else None, - "end": max(ends) if ends else None, + "value": [ + {"id": shift_id, "label": label, "start": start, "end": end} + for shift_id, label, start, end in rows + ], } def _resolve_event_preference_option(db: Session, option: dict) -> dict: - """Responder-facing view of one event_preference option: its label - plus the actual TournamentEvents its `value` groups together, instead - of raw ids — same "resolve, don't expose ids" treatment as availability. - A `value` that's still a plain string (a single legacy id, per the - Branching/Config-Validation issue's not-yet-strict event_preference - validation) passes through unchanged rather than being resolved.""" + """Responder-facing view of one event_preference option: `value` + (list[int] of grouped TournamentEvent ids) is resolved in place into one + `{id, name, division}` dict per event — same "reuse value, one entry per + grouped entity" treatment as availability. A `value` that's still a + plain string (a single legacy id, per the Branching/Config-Validation + issue's not-yet-strict event_preference validation) passes through + unchanged rather than being resolved.""" value = option.get("value") if not isinstance(value, list): return option @@ -327,12 +332,13 @@ def _resolve_event_preference_option(db: Session, option: dict) -> dict: events = ( db.query(TournamentEvent.id, TournamentEvent.name, TournamentEvent.division) .filter(TournamentEvent.id.in_(value)) + .order_by(TournamentEvent.id) .all() ) return { "option_id": option["option_id"], "label": option["label"], - "events": [ + "value": [ {"id": event_id, "name": name, "division": division} for event_id, name, division in events ], diff --git a/backend/form-question-types-reference.md b/backend/form-question-types-reference.md index 8f4533a2..c64c5fc4 100644 --- a/backend/form-question-types-reference.md +++ b/backend/form-question-types-reference.md @@ -78,10 +78,13 @@ Pick any number, shown as checkboxes. Answer value: array of chosen option `option_id`s — stored as a list of `{option_id, value, label}` snapshots. Branching: not supported (not single-select). -**Reserved-key note (`availability`):** `field_key = "availability"` is allowed on either `single_select_radio` or `multi_select_checkbox` — the TD's choice of type determines whether a respondent can select more than one grouped option at once (multi-select, e.g. "free both Morning and Evening but not Afternoon") or at most one (single-select, for a tournament that only wants one blanket answer per person); it doesn't change write-through, only how many options can be selected. Each option's `value` is `list[int]` — one or more real `TournamentShift` ids belonging to the field's tournament, grouped under a single TD-labeled choice (e.g. `"All Day"` → `[1, 2, 3]`), auto-loadable from the tournament's shift catalog. A responder never sees the raw shift list: `GET`-rendering resolves each option into its `label` plus the combined `start`/`end` across every shift it groups (`resolve_field_options`'s availability branch), e.g.: +**Reserved-key note (`availability`):** `field_key = "availability"` is allowed on either `single_select_radio` or `multi_select_checkbox` — the TD's choice of type determines whether a respondent can select more than one grouped option at once (multi-select, e.g. "free both Morning and Evening but not Afternoon") or at most one (single-select, for a tournament that only wants one blanket answer per person); it doesn't change write-through, only how many options can be selected. Each option's stored `value` is `list[int]` — one or more real `TournamentShift` ids belonging to the field's tournament, grouped under a single TD-labeled choice (e.g. `"All Day"` → `[1, 2, 3]`), auto-loadable from the tournament's shift catalog. `GET`-rendering resolves `value` in place (`resolve_field_options`'s availability branch) from that raw id list into one `{id, label, start, end}` entry per shift, ordered by `start` — reusing `value` rather than inventing new keys, and keeping every underlying shift's own id/label/range intact (not collapsed into one combined range) so an editor can see exactly what's grouped: ```json -{ "option_id": "a1b2c3d4e5", "label": "All Day", "start": "2027-02-13T07:00:00Z", "end": "2027-02-13T16:00:00Z" } +{ "option_id": "a1b2c3d4e5", "label": "All Day", "value": [ + { "id": 1, "label": "Morning", "start": "2027-02-13T07:00:00Z", "end": "2027-02-13T12:00:00Z" }, + { "id": 2, "label": "Afternoon", "start": "2027-02-13T12:00:00Z", "end": "2027-02-13T16:00:00Z" } +] } ``` On submit, the answer's selected option_id(s) are expanded into their grouped shift ids (deduped via set union across selections) and write-through into `MembershipAvailability` (diffed against the prior submission) — this fires only on tournament-owned forms; on a chapter-owned form the same field is valid but stores as a normal `FormAnswer`, no write-through. Availability answers are **not** snapshotted the way other option types are (see "Answer-value snapshotting" above) — `FormAnswer.value` stores the raw selected `option_id`(s) directly. Deleting a `TournamentShift` is rejected if it's referenced either by an existing `MembershipAvailability` row, or inside any non-archived field's option `value` list — a shift that's part of a live option's grouping can't be pulled out from under it even before anyone's answered. @@ -103,10 +106,10 @@ Rank a fixed number of options in order of preference. Answer value: dict of rank → option `option_id`, e.g. `{"1": "a1b2c3d4e5", "2": "f6e5d4c3b2"}` — stored as rank → `{option_id, value, label}` snapshot. Branching: not supported. -**Reserved-key note (`event_preference`):** allowed on this type, `multi_select_checkbox`, or `single_select_dropdown`. An option's `value` may be `list[int]` — one or more real `TournamentEvent` ids grouped under a single label (the same grouping pattern as availability's shift ids), auto-loadable from the tournament's event catalog. `GET`-rendering resolves a `list[int]` value into the actual events instead of exposing raw ids (`resolve_field_options`'s event_preference branch): +**Reserved-key note (`event_preference`):** allowed on this type, `multi_select_checkbox`, or `single_select_dropdown`. An option's stored `value` may be `list[int]` — one or more real `TournamentEvent` ids grouped under a single label (the same grouping pattern as availability's shift ids), auto-loadable from the tournament's event catalog. `GET`-rendering resolves `value` in place into one `{id, name, division}` entry per event, ordered by id (`resolve_field_options`'s event_preference branch) — same "reuse `value`, one entry per grouped entity" treatment as availability: ```json -{ "option_id": "a1b2c3d4e5", "label": "Life Science", "events": [{ "id": 5, "name": "Anatomy and Physiology", "division": "B" }, { "id": 9, "name": "Disease Detectives", "division": "C" }] } +{ "option_id": "a1b2c3d4e5", "label": "Life Science", "value": [{ "id": 5, "name": "Anatomy and Physiology", "division": "B" }, { "id": 9, "name": "Disease Detectives", "division": "C" }] } ``` A `value` that's still a plain string (a single legacy id) passes through unresolved — strict validation that every `event_preference` option's ids are real `TournamentEvent`s isn't built yet, unlike `availability`'s strict shift-id check. diff --git a/backend/tests/core/test_forms.py b/backend/tests/core/test_forms.py index 698c6ce4..6d45eaa2 100644 --- a/backend/tests/core/test_forms.py +++ b/backend/tests/core/test_forms.py @@ -266,7 +266,7 @@ def test_replace_field_type_archives_old_field_and_keeps_order(self, db, td_user # --------------------------------------------------------------------------- class TestResolveAvailabilityOptions: - def test_single_shift_option_resolves_its_own_range(self, db, td_user, td_tournament): + def test_single_shift_option_resolves_to_one_value_entry(self, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament) start = datetime(2027, 2, 13, 7, 0, tzinfo=timezone.utc) end = datetime(2027, 2, 13, 16, 0, tzinfo=timezone.utc) @@ -278,9 +278,11 @@ def test_single_shift_option_resolves_its_own_range(self, db, td_user, td_tourna db.commit() options = resolve_field_options(db, field) - assert options == [{"option_id": "opt_1", "label": "Saturday", "start": start, "end": end}] + assert options == [ + {"option_id": "opt_1", "label": "Saturday", "value": [{"id": shift.id, "label": "Saturday", "start": start, "end": end}]} + ] - def test_grouped_shifts_resolve_combined_range(self, db, td_user, td_tournament): + def test_grouped_shifts_resolve_to_one_entry_each(self, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament) morning_start = datetime(2027, 2, 13, 7, 0, tzinfo=timezone.utc) morning_end = datetime(2027, 2, 13, 12, 0, tzinfo=timezone.utc) @@ -299,22 +301,16 @@ def test_grouped_shifts_resolve_combined_range(self, db, td_user, td_tournament) db.commit() options = resolve_field_options(db, field) - assert options == [{"option_id": "opt_all_day", "label": "All Day", "start": morning_start, "end": afternoon_end}] - - def test_raw_shift_ids_not_exposed(self, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - shift = _make_shift( - db, td_tournament, "Saturday", - datetime(2027, 2, 13, 7, 0, tzinfo=timezone.utc), datetime(2027, 2, 13, 16, 0, tzinfo=timezone.utc), - ) - field = _make_field( - db, form, field_key="availability", question_type="single_select_radio", - config={"options": [{"option_id": "opt_1", "value": [shift.id], "label": "Saturday", "is_archived": False}]}, - ) - db.commit() - - options = resolve_field_options(db, field) - assert "value" not in options[0] + assert options == [ + { + "option_id": "opt_all_day", + "label": "All Day", + "value": [ + {"id": morning.id, "label": "Morning", "start": morning_start, "end": morning_end}, + {"id": afternoon.id, "label": "Afternoon", "start": afternoon_start, "end": afternoon_end}, + ], + } + ] def test_archived_option_excluded(self, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament) @@ -363,7 +359,7 @@ def test_grouped_events_resolve_to_id_name_and_division(self, db, td_user, td_to { "option_id": "opt_life_science", "label": "Life Science", - "events": [ + "value": [ {"id": anat.id, "name": "Anatomy and Physiology", "division": "B"}, {"id": disease.id, "name": "Disease Detectives", "division": "C"}, ], From d2a665f39cace04b9e12d0277a2fc551316701b9 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 19:59:17 -0700 Subject: [PATCH 048/185] feat(forms): make Form.id a public nanoid string; add title and response_count --- .../versions/7db31ae17e3c_forms_core_model.py | 7 ++--- backend/app/api/routes/forms.py | 4 +++ backend/app/core/form/permissions.py | 6 ++--- backend/app/core/form/validation.py | 2 +- backend/app/models/models.py | 27 ++++++++++++++++--- backend/app/schemas/form.py | 10 ++++--- backend/requirements.txt | 3 +++ 7 files changed, 46 insertions(+), 13 deletions(-) diff --git a/backend/alembic/versions/7db31ae17e3c_forms_core_model.py b/backend/alembic/versions/7db31ae17e3c_forms_core_model.py index f50cf76a..17569130 100644 --- a/backend/alembic/versions/7db31ae17e3c_forms_core_model.py +++ b/backend/alembic/versions/7db31ae17e3c_forms_core_model.py @@ -29,11 +29,12 @@ def upgrade() -> None: op.create_table('forms', - sa.Column('id', sa.Integer(), nullable=False), + sa.Column('id', sa.String(length=12), nullable=False), sa.Column('owner_type', sa.String(length=16), nullable=False), sa.Column('tournament_id', sa.Integer(), nullable=True), sa.Column('chapter_id', sa.Integer(), nullable=True), sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('title', sa.String(length=255), nullable=True), sa.Column('description', sa.Text(), nullable=True), sa.Column('status', sa.String(length=16), nullable=False), sa.Column('created_by', sa.Integer(), nullable=False), @@ -49,7 +50,7 @@ def upgrade() -> None: op.create_table('form_fields', sa.Column('id', sa.Integer(), nullable=False), - sa.Column('form_id', sa.Integer(), nullable=False), + sa.Column('form_id', sa.String(length=12), nullable=False), sa.Column('order', sa.Integer(), nullable=False), sa.Column('label', sa.String(length=255), nullable=False), sa.Column('description', sa.Text(), nullable=True), @@ -67,7 +68,7 @@ def upgrade() -> None: op.create_table('form_responses', sa.Column('id', sa.Integer(), nullable=False), - sa.Column('form_id', sa.Integer(), nullable=False), + sa.Column('form_id', sa.String(length=12), nullable=False), sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('submitted_at', sa.DateTime(timezone=True), nullable=True), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 3104fa76..36801839 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -75,6 +75,7 @@ def create_tournament_form( form = Form( name=payload.name, + title=payload.title or payload.name, description=payload.description, owner_type="tournament", tournament_id=tournament_id, @@ -112,6 +113,7 @@ def create_chapter_form( form = Form( name=payload.name, + title=payload.title or payload.name, description=payload.description, owner_type="chapter", tournament_id=None, @@ -175,6 +177,8 @@ def update_form( if payload.name is not None: form.name = payload.name + if payload.title is not None: + form.title = payload.title if payload.description is not None: form.description = payload.description if payload.status is not None: diff --git a/backend/app/core/form/permissions.py b/backend/app/core/form/permissions.py index ea68fd72..684d675d 100644 --- a/backend/app/core/form/permissions.py +++ b/backend/app/core/form/permissions.py @@ -15,7 +15,7 @@ # --------------------------------------------------------------------------- -def _load_form_or_404(form_id: int, db: Session) -> Form: +def _load_form_or_404(form_id: str, db: Session) -> Form: form = db.query(Form).filter(Form.id == form_id).first() if not form: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Form not found") @@ -23,7 +23,7 @@ def _load_form_or_404(form_id: int, db: Session) -> Form: def require_form_manage_access( - form_id: int, + form_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> Form: @@ -45,7 +45,7 @@ def require_form_manage_access( def require_form_view_access( - form_id: int, + form_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> Form: diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index 08308dc8..5dca42ce 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -81,7 +81,7 @@ def validate_reserved_field_key(field_key: str, question_type: str) -> None: def validate_branching_options( db: Session, - form_id: int, + form_id: str, question_type: str, config: dict, field_id: int | None = None, diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 1ad6e074..807e8ae8 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -6,6 +6,7 @@ """ from datetime import datetime, timezone +from nanoid import generate as generate_nanoid from sqlalchemy import ( Integer, String, Text, Boolean, Date, DateTime, JSON, ForeignKey, UniqueConstraint, CheckConstraint, Column, event, Index, @@ -18,6 +19,14 @@ from app.core.age import meets_age_requirement +def generate_form_id() -> str: + """Form.id is a public-facing 12-char random string (nanoid's default + alphabet is already URL-safe), not an auto-increment int — forms are + referenced directly in URLs (/forms/{id}/edit) the way a user-facing + document id is, not as an internal implementation detail.""" + return generate_nanoid(size=12) + + def utcnow(): """Timezone-aware UTC timestamp.""" return datetime.now(timezone.utc) @@ -670,11 +679,16 @@ class SheetConfig(Base): class Form(Base): __tablename__ = "forms" - id = Column(Integer, primary_key=True, index=True) + id = Column(String(12), primary_key=True, default=generate_form_id) owner_type = Column(String(16), nullable=False) # "tournament" | "chapter" tournament_id = Column(Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=True) chapter_id = Column(Integer, ForeignKey("alumni_chapters.id", ondelete="CASCADE"), nullable=True) name = Column(String(255), nullable=False) + # Respondent-facing title, shown on the actual form — distinct from + # `name` (the TD-facing dashboard/list label). Independently editable; + # callers default it to `name` at creation time if not given, but + # nothing here enforces them staying in sync afterward. + title = Column(String(255), nullable=True) description = Column(Text, nullable=True) status = Column(String(16), nullable=False, default="draft") # "draft" | "published" | "archived" @@ -697,6 +711,13 @@ class Form(Base): ), ) + # Read by the forms list page to preemptively disable Delete (which + # 409s server-side if any responses exist) rather than let a TD hit a + # dead-end click. + @property + def response_count(self) -> int: + return len(self.responses) + # --------------------------------------------------------------------------- # FormField — a single question on a Form. question_type drives how config @@ -707,7 +728,7 @@ class FormField(Base): __tablename__ = "form_fields" id = Column(Integer, primary_key=True, index=True) - form_id = Column(Integer, ForeignKey("forms.id", ondelete="CASCADE"), nullable=False) + form_id = Column(String(12), ForeignKey("forms.id", ondelete="CASCADE"), nullable=False) order = Column(Integer, nullable=False) label = Column(String(255), nullable=False) description = Column(Text, nullable=True) @@ -756,7 +777,7 @@ class FormResponse(Base): __tablename__ = "form_responses" id = Column(Integer, primary_key=True, index=True) - form_id = Column(Integer, ForeignKey("forms.id", ondelete="CASCADE"), nullable=False) + form_id = Column(String(12), ForeignKey("forms.id", ondelete="CASCADE"), nullable=False) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) submitted_at = Column(DateTime(timezone=True), default=utcnow) diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index 4ced398d..82d85940 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -135,7 +135,7 @@ class TextConfig(BaseModel): class FormFieldRead(BaseModel): id: int - form_id: int + form_id: str field_key: str order: int label: str @@ -172,8 +172,9 @@ class BulkFieldsUpdate(BaseModel): # --------------------------------------------------------------------------- class FormRead(BaseModel): - id: int + id: str name: str + title: str | None = None description: str | None = None status: Literal["draft", "published", "archived"] owner_type: Literal["tournament", "chapter"] @@ -182,6 +183,7 @@ class FormRead(BaseModel): created_by: int created_at: datetime updated_at: datetime + response_count: int = 0 fields: list[FormFieldRead] = [] model_config = ConfigDict(from_attributes=True) @@ -189,6 +191,7 @@ class FormRead(BaseModel): class FormCreate(BaseModel): name: str + title: str | None = None description: str | None = None owner_type: Literal["tournament", "chapter"] tournament_id: int | None = None @@ -207,6 +210,7 @@ def _require_matching_owner(self): class FormUpdate(BaseModel): name: str | None = None + title: str | None = None description: str | None = None status: Literal["draft", "published", "archived"] | None = None @@ -234,7 +238,7 @@ class FormResponseCreate(BaseModel): class FormResponseRead(BaseModel): id: int - form_id: int + form_id: str user_id: int submitted_at: datetime updated_at: datetime diff --git a/backend/requirements.txt b/backend/requirements.txt index 30286ff2..a74e46d2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,6 +10,9 @@ sqlalchemy==2.0.36 alembic==1.14.0 psycopg2-binary==2.9.10 +# Public-facing short random ids (Form.id) +nanoid==2.0.0 + # Scheduled jobs (daily auto-archive) apscheduler==3.11.3 From 924b8bad54662230357f5ddce25aa3c7f5a45d6b Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 20:09:48 -0700 Subject: [PATCH 049/185] feat(forms): add GET list endpoints for tournament/chapter forms --- backend/app/api/routes/forms.py | 45 ++++++++++++++++++++++++++++++ backend/tests/api/test_forms.py | 49 +++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 36801839..c2c0026c 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -126,6 +126,51 @@ def create_chapter_form( return form +# --------------------------------------------------------------------------- +# GET /tournaments/{tournament_id}/forms/ — MANAGE_FORMS on the tournament. +# Listing is a manage action (draft forms shouldn't be visible to just any +# member), unlike GET /forms/{form_id}/ below which is view-access. +# --------------------------------------------------------------------------- +@router.get( + "/tournaments/{tournament_id}/forms/", + response_model=list[FormRead], + tags=["tournaments"], +) +def list_tournament_forms( + tournament_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_FORMS)), +): + return ( + db.query(Form) + .filter(Form.tournament_id == tournament_id, Form.owner_type == "tournament") + .order_by(Form.updated_at.desc()) + .all() + ) + + +# --------------------------------------------------------------------------- +# GET /chapters/{chapter_id}/forms/ — lead/officer on the chapter. +# --------------------------------------------------------------------------- +@router.get( + "/chapters/{chapter_id}/forms/", + response_model=list[FormRead], + tags=["chapters"], +) +def list_chapter_forms( + chapter_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + require_officer_or_lead(chapter_id, db, current_user) + return ( + db.query(Form) + .filter(Form.chapter_id == chapter_id, Form.owner_type == "chapter") + .order_by(Form.updated_at.desc()) + .all() + ) + + # --------------------------------------------------------------------------- # GET /forms/{form_id}/ — view/render. Any member of a linked # tournament/chapter can view (not just managers) — this is what the form diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index d9caf75d..a7e4daad 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -166,6 +166,55 @@ def test_unauthenticated_forbidden(self, client, td_tournament): assert res.status_code == 401 +# --------------------------------------------------------------------------- +# GET /tournaments/{tournament_id}/forms/ and GET /chapters/{chapter_id}/forms/ +# --------------------------------------------------------------------------- + +class TestListForms: + def test_manager_lists_tournament_forms(self, client, db, td_user, td_tournament): + _make_form(db, td_user, td_tournament, name="First") + _make_form(db, td_user, td_tournament, name="Second") + db.commit() + login(client, "td@test.com", "tdpass") + res = client.get(f"/tournaments/{td_tournament.id}/forms/") + assert res.status_code == 200 + names = {f["name"] for f in res.json()} + assert names == {"First", "Second"} + + def test_list_excludes_other_tournaments_and_chapter_forms(self, client, db, td_user, td_tournament, chapter): + _make_form(db, td_user, td_tournament, name="Mine") + _make_chapter_form(db, td_user, chapter, name="Not mine") + db.commit() + login(client, "td@test.com", "tdpass") + res = client.get(f"/tournaments/{td_tournament.id}/forms/") + assert res.status_code == 200 + names = [f["name"] for f in res.json()] + assert names == ["Mine"] + + def test_member_without_manage_forms_forbidden(self, client, db, td_tournament, other_user): + grant_role(db, td_tournament, other_user, "Runner") + login(client, "other@test.com", "otherpass") + res = client.get(f"/tournaments/{td_tournament.id}/forms/") + assert res.status_code == 403 + + def test_chapter_lead_lists_chapter_forms(self, client, db, chapter, td_user): + lead = _chapter_lead(db, chapter) + _make_chapter_form(db, lead, chapter, name="Alumni interest") + db.commit() + login(client, "chapterlead@test.com", "LeadPass123!") + res = client.get(f"/chapters/{chapter.id}/forms/") + assert res.status_code == 200 + assert [f["name"] for f in res.json()] == ["Alumni interest"] + + def test_chapter_plain_member_forbidden(self, client, db, chapter): + member = make_user(db, "plainmember@test.com", password="MemberPass123!") + db.add(ChapterMembership(chapter_id=chapter.id, user_id=member.id, role="member")) + db.commit() + login(client, "plainmember@test.com", "MemberPass123!") + res = client.get(f"/chapters/{chapter.id}/forms/") + assert res.status_code == 403 + + # --------------------------------------------------------------------------- # GET /forms/{form_id}/ # --------------------------------------------------------------------------- From f45350dd14472a4457540d7fb69c30f9cc76eaa3 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 20:10:00 -0700 Subject: [PATCH 050/185] feat(forms): add formsApi client --- frontend/lib/api.ts | 163 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 162 insertions(+), 1 deletion(-) diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 56bd00ae..348a0e68 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -2,7 +2,7 @@ // In prod: NEXT_PUBLIC_API_URL is unset → goes through /api/proxy → Next.js adds API key server-side const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '/api/proxy' -type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE' +type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' interface RequestOptions { method?: HttpMethod @@ -61,6 +61,7 @@ export const api = { get: (path: string) => request(path), post: (path: string, body: unknown) => request(path, { method: 'POST', body }), patch: (path: string, body: unknown) => request(path, { method: 'PATCH', body }), + put: (path: string, body: unknown) => request(path, { method: 'PUT', body }), delete: (path: string, body?: unknown) => request(path, { method: 'DELETE', body }), } @@ -1131,3 +1132,163 @@ export const sheetsApi = { return memberships.map((m) => m.user.email) }, } + +// ------------------------------------------------------------------------- +// Forms — see backend/form-question-types-reference.md for the full +// question_type/config/reserved-field_key shape reference this mirrors. +// ------------------------------------------------------------------------- +export type FormQuestionType = + | 'acknowledgment' + | 'single_select_radio' + | 'single_select_dropdown' + | 'multi_select_checkbox' + | 'ranked_choice' + | 'short_text' + | 'long_text' + +export type FormStatus = 'draft' | 'published' | 'archived' +export type FormOwnerType = 'tournament' | 'chapter' + +// value is normally TD-facing display text; for an entity-backed reserved +// field_key (availability → TournamentShift ids, event_preference → +// TournamentEvent ids) it's the raw list[int] instead — resolved in place +// into FormFieldOptionResolved on GET (see resolveFieldOptionValue below). +export interface FormFieldOption { + option_id: string + value: string | number[] + label: string + is_archived?: boolean + // single_select_radio/dropdown only — mutually exclusive with each other. + next_field_id?: number | null + action?: 'submit_form' | null +} + +// value shape after GET-time resolution for availability/event_preference — +// one entry per grouped entity, kept separate rather than collapsed. +export interface ResolvedShiftOption { + id: number + label: string + start: string + end: string +} + +export interface ResolvedEventOption { + id: number + name: string + division: string +} + +export interface FormFieldConfig { + required?: boolean + confirm_label?: string + options?: FormFieldOption[] + ranks?: number + allow_duplicates?: boolean + max_length?: number +} + +export interface FormField { + id: number + form_id: string + field_key: string + order: number + label: string + description: string | null + question_type: FormQuestionType + is_archived: boolean + config: FormFieldConfig | null + created_at: string + updated_at: string +} + +// One entry in a PUT .../fields/ bulk-update payload. `id` omitted = create; +// `id` present must match a currently-live field. `field_key` only matters +// on create — the server ignores/derives it otherwise (see BulkFieldEntry +// in backend/app/schemas/form.py). +export interface FormFieldInput { + id?: number + field_key?: string + label: string + description?: string | null + question_type: FormQuestionType + config?: FormFieldConfig | null +} + +export interface Form { + id: string + name: string + title: string | null + description: string | null + status: FormStatus + owner_type: FormOwnerType + tournament_id: number | null + chapter_id: number | null + created_by: number + created_at: string + updated_at: string + response_count: number + fields: FormField[] +} + +export interface FormCreateInput { + name: string + title?: string | null + description?: string | null + owner_type: FormOwnerType + tournament_id?: number | null + chapter_id?: number | null +} + +export interface FormUpdateInput { + name?: string + title?: string + description?: string + status?: FormStatus +} + +export interface FormAnswer { + id: number + field_id: number + value: unknown +} + +export interface FormAnswerInput { + field_id: number + value: unknown +} + +export interface FormResponse { + id: number + form_id: string + user_id: number + submitted_at: string + updated_at: string + answers: FormAnswer[] +} + +export const formsApi = { + listForTournament: (tournamentId: number) => + api.get(`/tournaments/${tournamentId}/forms/`), + listForChapter: (chapterId: number) => + api.get(`/chapters/${chapterId}/forms/`), + createForTournament: (tournamentId: number, body: { name: string; title?: string | null; description?: string | null }) => + api.post
(`/tournaments/${tournamentId}/forms/`, { ...body, owner_type: 'tournament', tournament_id: tournamentId }), + createForChapter: (chapterId: number, body: { name: string; title?: string | null; description?: string | null }) => + api.post(`/chapters/${chapterId}/forms/`, { ...body, owner_type: 'chapter', chapter_id: chapterId }), + // Renders with option values already resolved (availability/event_preference). + get: (formId: string) => api.get(`/forms/${formId}/`), + update: (formId: string, body: FormUpdateInput) => api.patch(`/forms/${formId}/`, body), + archive: (formId: string) => api.post(`/forms/${formId}/archive/`, {}), + // 409s if the form has any responses — check response_count client-side first. + delete: (formId: string) => api.delete(`/forms/${formId}/`), + // Full ordered target field list — see FormFieldInput and the Edit + // Lifecycle section of form-question-types-reference.md. On a published + // form, an existing option missing from the submitted config must still + // be echoed back (via its option_id) or the server archives it. + putFields: (formId: string, fields: FormFieldInput[]) => + api.put(`/forms/${formId}/fields/`, { fields }), + submitResponse: (formId: string, answers: FormAnswerInput[]) => + api.post(`/forms/${formId}/responses/`, { answers }), + listResponses: (formId: string) => api.get(`/forms/${formId}/responses/`), + getMyResponse: (formId: string) => api.get(`/forms/${formId}/responses/me/`), +} From 32c1f2cba18e2f517cbd8ee44ac4318a288aa05f Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 20:17:34 -0700 Subject: [PATCH 051/185] feat(forms): scaffold standalone /forms/{formId}/edit route --- frontend/app/forms/[formId]/edit/page.tsx | 15 +++++++++++++++ frontend/app/forms/[formId]/layout.tsx | 16 ++++++++++++++++ frontend/proxy.ts | 2 +- 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 frontend/app/forms/[formId]/edit/page.tsx create mode 100644 frontend/app/forms/[formId]/layout.tsx diff --git a/frontend/app/forms/[formId]/edit/page.tsx b/frontend/app/forms/[formId]/edit/page.tsx new file mode 100644 index 00000000..07e691c9 --- /dev/null +++ b/frontend/app/forms/[formId]/edit/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { use } from "react"; + +export default function FormEditPage({ params }: { params: Promise<{ formId: string }> }) { + const { formId } = use(params); + + return ( +
+

+ Form builder — {formId} +

+
+ ); +} diff --git a/frontend/app/forms/[formId]/layout.tsx b/frontend/app/forms/[formId]/layout.tsx new file mode 100644 index 00000000..b0ab1eee --- /dev/null +++ b/frontend/app/forms/[formId]/layout.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { Topbar } from "@/components/layout/Topbar"; + +// Standalone shell for the form builder/preview — deliberately outside +// app/dashboard/ since a form's URL never encodes who owns it (tournament +// vs. chapter), the way a Google Doc's URL doesn't encode its Drive folder. +// Shared by /forms/{formId}/edit and /forms/{formId}/preview. +export default function FormLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/frontend/proxy.ts b/frontend/proxy.ts index 462cb7fe..c419756a 100644 --- a/frontend/proxy.ts +++ b/frontend/proxy.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { safeRedirectPath } from '@/lib/auth' -const PROTECTED_PREFIXES = ['/dashboard', '/onboarding'] +const PROTECTED_PREFIXES = ['/dashboard', '/onboarding', '/forms'] const AUTH_ROUTES = ['/', '/sign-in', '/sign-up'] export function proxy(request: NextRequest) { From d7bf91fc729b9f7fa64bd13227951c9b99f014d6 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 20:24:19 -0700 Subject: [PATCH 052/185] feat(forms): scaffold /forms/{formId}/preview route stub --- frontend/app/forms/[formId]/preview/page.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 frontend/app/forms/[formId]/preview/page.tsx diff --git a/frontend/app/forms/[formId]/preview/page.tsx b/frontend/app/forms/[formId]/preview/page.tsx new file mode 100644 index 00000000..c646cc21 --- /dev/null +++ b/frontend/app/forms/[formId]/preview/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { use } from "react"; + +// TD-only, read-only-but-simulated view of the form as a respondent would +// see it — must work on draft forms (that's the point of previewing before +// publishing). No FormResponse is created here. Content lands once +// QuestionRenderer's interactive mode is built. +export default function FormPreviewPage({ params }: { params: Promise<{ formId: string }> }) { + const { formId } = use(params); + + return ( +
+

+ Form preview — {formId} +

+
+ ); +} From 091decf264caee2a8c9be0da3cb1da01a29a2b7e Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 20:36:28 -0700 Subject: [PATCH 053/185] feat(forms): add tournament forms list page skeleton --- .../dashboard/tournaments/[id]/forms/page.tsx | 95 +++++++++++++++++++ frontend/components/layout/Sidebar.tsx | 6 +- frontend/components/ui/Icons.tsx | 8 ++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 frontend/app/dashboard/tournaments/[id]/forms/page.tsx diff --git a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx new file mode 100644 index 00000000..4642dac7 --- /dev/null +++ b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { formsApi, Form, ApiError } from "@/lib/api"; +import { useAuth } from "@/lib/useAuth"; +import { useMyMembership } from "@/lib/useMyMembership"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { Card } from "@/components/ui/Card"; +import { Spinner } from "@/components/ui/Spinner"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { IconForms, IconLock } from "@/components/ui/Icons"; + +export default function FormsPage() { + const params = useParams(); + const tournamentId = Number(params.id); + + const { user: currentUser } = useAuth(); + const { membership, hasPermission, loading: membershipLoading } = useMyMembership(); + const canManageForms = currentUser?.role === "admin" || !!membership?.is_owner || hasPermission("manage_forms"); + + const [forms, setForms] = useState(null); + const [loadError, setLoadError] = useState(null); + + useEffect(() => { + if (!canManageForms) return; + formsApi.listForTournament(tournamentId) + .then(setForms) + .catch((e) => setLoadError(e instanceof ApiError ? e.message : "Failed to load forms.")); + }, [tournamentId, canManageForms]); + + if (membershipLoading) { + return ( +
+ +
+ ); + } + + if (!canManageForms) { + return ( +
+ + + } + title="No access" + description="You need the manage forms permission to view this page." + /> + +
+ ); + } + + if (forms === null) { + return ( +
+ +
+ +
+
+ ); + } + + return ( +
+ + + {loadError && ( +

+ {loadError} +

+ )} + + {forms.length === 0 ? ( + + } + title="No forms yet" + description="Create a form to start collecting responses from members." + /> + + ) : ( + + {forms.map((form) => ( +

+ {form.name} +

+ ))} +
+ )} +
+ ); +} diff --git a/frontend/components/layout/Sidebar.tsx b/frontend/components/layout/Sidebar.tsx index 0f3bcd08..4fe0d22c 100644 --- a/frontend/components/layout/Sidebar.tsx +++ b/frontend/components/layout/Sidebar.tsx @@ -8,6 +8,7 @@ import { IconAssignments, IconEvents, IconSheets, + IconForms, IconMembers, IconSettings, IconChevronDown, @@ -23,6 +24,7 @@ const NAV_ITEMS = [ { segment: "assignments", icon: , label: "Assignments" }, { segment: "events", icon: , label: "Events" }, { segment: "sheets", icon: , label: "Sheets" }, + { segment: "forms", icon: , label: "Forms" }, { segment: "members", icon: , label: "Members" }, ]; @@ -53,6 +55,7 @@ export function Sidebar({ onExpandedChange, tournamentId }: SidebarProps) { const canManageTournament = currentUser?.role === "admin" || !!membership?.is_owner || hasPermission("manage_tournament"); const canManageMembers = currentUser?.role === "admin" || !!membership?.is_owner || hasPermission("manage_members"); const canManageEvents = currentUser?.role === "admin" || !!membership?.is_owner || hasPermission("manage_events"); + const canManageForms = currentUser?.role === "admin" || !!membership?.is_owner || hasPermission("manage_forms"); const settingsSubitems = SETTINGS_SUBITEMS.filter( ({ segment }) => (segment !== "roles" || canManageRoles) && @@ -62,7 +65,8 @@ export function Sidebar({ onExpandedChange, tournamentId }: SidebarProps) { const navItems = NAV_ITEMS.filter( ({ segment }) => (segment !== "members" || canManageMembers) && - (segment !== "events" || canManageEvents) + (segment !== "events" || canManageEvents) && + (segment !== "forms" || canManageForms) ); // Locked open on settings routes — the sub-nav labels need to stay // readable without requiring the mouse to stay parked on the rail. diff --git a/frontend/components/ui/Icons.tsx b/frontend/components/ui/Icons.tsx index 11c5e1c2..99d52cbc 100644 --- a/frontend/components/ui/Icons.tsx +++ b/frontend/components/ui/Icons.tsx @@ -67,6 +67,14 @@ export function IconSheets({ size = 18, ...props }: IconProps) { ); } +export function IconForms({ size = 18, ...props }: IconProps) { + return ( + + + + ); +} + export function IconMembers({ size = 18, ...props }: IconProps) { return ( From 6ac0a4779c41166ccd2e0db61111932fa513c03b Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 20:36:41 -0700 Subject: [PATCH 054/185] fix(roles): add missing manage_forms permission to frontend catalog --- frontend/lib/api.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 348a0e68..c6d8bf48 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -748,9 +748,10 @@ export type Permission = | 'manage_members' | 'manage_events' | 'manage_invites' + | 'manage_forms' export const ALL_PERMISSIONS: Permission[] = [ - 'manage_tournament', 'manage_roles', 'manage_members', 'manage_events', 'manage_invites', + 'manage_tournament', 'manage_roles', 'manage_members', 'manage_events', 'manage_invites', 'manage_forms', ] export const PERMISSION_INFO: Record = { @@ -774,6 +775,10 @@ export const PERMISSION_INFO: Record Date: Wed, 19 Aug 2026 21:25:12 -0700 Subject: [PATCH 055/185] feat(forms): render forms list table --- .../dashboard/tournaments/[id]/forms/page.tsx | 105 ++++++++++++++++-- 1 file changed, 95 insertions(+), 10 deletions(-) diff --git a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx index 4642dac7..7b13ddf8 100644 --- a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx @@ -1,15 +1,106 @@ "use client"; import { useEffect, useState } from "react"; -import { useParams } from "next/navigation"; -import { formsApi, Form, ApiError } from "@/lib/api"; +import { useParams, useRouter } from "next/navigation"; +import { formsApi, Form, FormStatus, ApiError } from "@/lib/api"; import { useAuth } from "@/lib/useAuth"; import { useMyMembership } from "@/lib/useMyMembership"; import { PageHeader } from "@/components/ui/PageHeader"; import { Card } from "@/components/ui/Card"; +import { Badge } from "@/components/ui/Badge"; +import { Button } from "@/components/ui/Button"; import { Spinner } from "@/components/ui/Spinner"; import { EmptyState } from "@/components/ui/EmptyState"; -import { IconForms, IconLock } from "@/components/ui/Icons"; +import { IconForms, IconLock, IconEdit, IconEye } from "@/components/ui/Icons"; +import { formatRelativeTime } from "@/lib/timeFormat"; + +// Name / Status / Fields / Updated / Actions +const FORM_ROW_COLUMNS = "1.6fr 110px 90px 110px 76px"; + +const STATUS_BADGE_VARIANT: Record = { + draft: "default", + published: "confirmed", + archived: "removed", +}; + +function FormRow({ form, isLast }: { + form: Form; + isLast: boolean; +}) { + const router = useRouter(); + const [hovered, setHovered] = useState(false); + const fieldCount = form.fields.filter((f) => !f.is_archived).length; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + onClick={() => router.push(`/forms/${form.id}/edit`)} + style={{ + display: "grid", gridTemplateColumns: FORM_ROW_COLUMNS, alignItems: "center", + gap: "8px", padding: "10px 12px", cursor: "pointer", + borderBottom: isLast ? "none" : "1px solid var(--color-border)", + background: hovered ? "var(--color-bg)" : "transparent", + transition: "background 100ms ease", + }} + > + + {form.name} + + + {form.status} + + + {fieldCount} + + + {formatRelativeTime(form.updated_at)} + +
+ + +
+
+ ); +} + +function FormTable({ forms }: { forms: Form[] }) { + return ( + +
+ Forms — {forms.length} + Status + Fields + Updated + +
+ + {forms.map((form, i) => ( + + ))} +
+ ); +} export default function FormsPage() { const params = useParams(); @@ -82,13 +173,7 @@ export default function FormsPage() { /> ) : ( - - {forms.map((form) => ( -

- {form.name} -

- ))} -
+ )} ); From 735a29980d9f9c582255725d9c0a47d8c137234b Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 21:29:11 -0700 Subject: [PATCH 056/185] feat(forms): add New Form modal and wire it into the list page --- .../dashboard/tournaments/[id]/forms/page.tsx | 33 +++++++++- .../tournament/forms/NewFormModal.tsx | 63 +++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 frontend/components/tournament/forms/NewFormModal.tsx diff --git a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx index 7b13ddf8..41be3b54 100644 --- a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx @@ -11,8 +11,9 @@ import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; import { Spinner } from "@/components/ui/Spinner"; import { EmptyState } from "@/components/ui/EmptyState"; -import { IconForms, IconLock, IconEdit, IconEye } from "@/components/ui/Icons"; +import { IconForms, IconLock, IconEdit, IconEye, IconPlus } from "@/components/ui/Icons"; import { formatRelativeTime } from "@/lib/timeFormat"; +import { NewFormModal } from "@/components/tournament/forms/NewFormModal"; // Name / Status / Fields / Updated / Actions const FORM_ROW_COLUMNS = "1.6fr 110px 90px 110px 76px"; @@ -104,6 +105,7 @@ function FormTable({ forms }: { forms: Form[] }) { export default function FormsPage() { const params = useParams(); + const router = useRouter(); const tournamentId = Number(params.id); const { user: currentUser } = useAuth(); @@ -112,6 +114,7 @@ export default function FormsPage() { const [forms, setForms] = useState(null); const [loadError, setLoadError] = useState(null); + const [creating, setCreating] = useState(false); useEffect(() => { if (!canManageForms) return; @@ -154,9 +157,22 @@ export default function FormsPage() { ); } + // Submit -> POST -> redirect straight into the builder. title/description + // are set later, inside the builder — not part of this modal. + function handleCreated(form: Form) { + router.push(`/forms/${form.id}/edit`); + } + return (
- + setCreating(true)}> + New Form + + } + /> {loadError && (

@@ -170,11 +186,24 @@ export default function FormsPage() { icon={} title="No forms yet" description="Create a form to start collecting responses from members." + action={ + + } /> ) : ( )} + + {creating && ( + setCreating(false)} + onCreated={handleCreated} + /> + )}

); } diff --git a/frontend/components/tournament/forms/NewFormModal.tsx b/frontend/components/tournament/forms/NewFormModal.tsx new file mode 100644 index 00000000..7d381167 --- /dev/null +++ b/frontend/components/tournament/forms/NewFormModal.tsx @@ -0,0 +1,63 @@ +'use client' + +import { useState } from 'react' +import { formsApi, Form, ApiError } from '@/lib/api' +import { Modal } from '@/components/ui/Modal' +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' + +interface NewFormModalProps { + tournamentId: number + onClose: () => void + onCreated: (form: Form) => void +} + +// Name only — no template/preset picker. Every form starts blank, including +// reserved-key presets, which the TD adds field-by-field once inside the +// builder. title/description are set later, inside the builder. +export function NewFormModal({ tournamentId, onClose, onCreated }: NewFormModalProps) { + const [name, setName] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(undefined) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + const trimmed = name.trim() + if (!trimmed) { setError('Name is required'); return } + + setLoading(true); setError(undefined) + try { + const form = await formsApi.createForTournament(tournamentId, { name: trimmed }) + onCreated(form) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to create form') + } finally { + setLoading(false) + } + } + + return ( + + + { setName(e.target.value); setError(undefined) }} + error={error} + placeholder="e.g. Volunteer Interest Form" + fullWidth + autoFocus + /> +
+ + +
+ +
+ ) +} From c447210c2dc248e0285172c6f64efb60d8539999 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 21:30:10 -0700 Subject: [PATCH 057/185] feat(forms): add response count column to forms list table --- frontend/app/dashboard/tournaments/[id]/forms/page.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx index 41be3b54..7ac31e20 100644 --- a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx @@ -15,8 +15,8 @@ import { IconForms, IconLock, IconEdit, IconEye, IconPlus } from "@/components/u import { formatRelativeTime } from "@/lib/timeFormat"; import { NewFormModal } from "@/components/tournament/forms/NewFormModal"; -// Name / Status / Fields / Updated / Actions -const FORM_ROW_COLUMNS = "1.6fr 110px 90px 110px 76px"; +// Name / Status / Fields / Responses / Updated / Actions +const FORM_ROW_COLUMNS = "1.6fr 110px 80px 100px 110px 76px"; const STATUS_BADGE_VARIANT: Record = { draft: "default", @@ -57,6 +57,9 @@ function FormRow({ form, isLast }: { {fieldCount} + + {form.response_count} + {formatRelativeTime(form.updated_at)} @@ -92,6 +95,7 @@ function FormTable({ forms }: { forms: Form[] }) { Forms — {forms.length} Status Fields + Responses Updated From 32f604429361358956a9cb9fa8d0a663bc0e202e Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 21:44:59 -0700 Subject: [PATCH 058/185] feat(forms): resolve creator on forms-list endpoints, drop fields/field_count --- backend/app/api/routes/forms.py | 58 ++++++++++++++++++++++++++++++--- backend/app/schemas/form.py | 32 +++++++++++++++++- frontend/lib/api.ts | 30 +++++++++++++++-- 3 files changed, 113 insertions(+), 7 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index c2c0026c..c4ba3c84 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -27,9 +27,11 @@ validate_reserved_field_key, ) from app.core.form.write_through import parse_lunch_field_key, sync_availability, sync_lunch +from app.core.tournament.memberships import resolve_memberships_or_users from app.core.tournament.permissions import MANAGE_FORMS, require_permission from app.db.session import get_db from app.models.models import ( + ChapterMembership, Form, FormAnswer, FormField, @@ -39,15 +41,19 @@ User, utcnow, ) +from app.schemas.chapter.membership import ChapterMemberResponse from app.schemas.form import ( BulkFieldsUpdate, FormCreate, FormFieldRead, + FormListRead, FormRead, FormResponseCreate, FormResponseRead, FormUpdate, ) +from app.schemas.tournament.membership import MembershipSlimResponse +from app.schemas.user import UserSlimResponse router = APIRouter(tags=["forms"]) @@ -133,7 +139,7 @@ def create_chapter_form( # --------------------------------------------------------------------------- @router.get( "/tournaments/{tournament_id}/forms/", - response_model=list[FormRead], + response_model=list[FormListRead], tags=["tournaments"], ) def list_tournament_forms( @@ -141,12 +147,14 @@ def list_tournament_forms( db: Session = Depends(get_db), current_user: User = Depends(require_permission(MANAGE_FORMS)), ): - return ( + forms = ( db.query(Form) .filter(Form.tournament_id == tournament_id, Form.owner_type == "tournament") .order_by(Form.updated_at.desc()) .all() ) + creators = resolve_memberships_or_users(db, tournament_id, {f.created_by for f in forms}) + return [_to_list_read(f, creators[f.created_by]) for f in forms] # --------------------------------------------------------------------------- @@ -154,7 +162,7 @@ def list_tournament_forms( # --------------------------------------------------------------------------- @router.get( "/chapters/{chapter_id}/forms/", - response_model=list[FormRead], + response_model=list[FormListRead], tags=["chapters"], ) def list_chapter_forms( @@ -163,12 +171,54 @@ def list_chapter_forms( current_user: User = Depends(get_current_user), ): require_officer_or_lead(chapter_id, db, current_user) - return ( + forms = ( db.query(Form) .filter(Form.chapter_id == chapter_id, Form.owner_type == "chapter") .order_by(Form.updated_at.desc()) .all() ) + creators = _resolve_chapter_creators(db, chapter_id, {f.created_by for f in forms}) + return [_to_list_read(f, creators[f.created_by]) for f in forms] + + +def _resolve_chapter_creators( + db: Session, chapter_id: int, user_ids: set[int], +) -> dict[int, ChapterMemberResponse | UserSlimResponse]: + """Same fallback pattern as resolve_memberships_or_users (tournament side) + — resolve to the creator's ChapterMembership in this chapter, falling + back to the bare User for ids with no membership row. No shared helper + exists for chapters yet (resolve_memberships_or_users is tournament-only), + so this mirrors it locally rather than generalizing prematurely.""" + memberships = ( + db.query(ChapterMembership) + .filter(ChapterMembership.chapter_id == chapter_id, ChapterMembership.user_id.in_(user_ids)) + .all() + ) + resolved: dict[int, ChapterMemberResponse | UserSlimResponse] = { + m.user_id: ChapterMemberResponse.model_validate(m) for m in memberships + } + missing_ids = user_ids - resolved.keys() + if missing_ids: + users = db.query(User).filter(User.id.in_(missing_ids)).all() + resolved.update({u.id: UserSlimResponse.model_validate(u) for u in users}) + return resolved + + +def _to_list_read(form: Form, creator: MembershipSlimResponse | ChapterMemberResponse | UserSlimResponse) -> FormListRead: + return FormListRead( + id=form.id, + name=form.name, + title=form.title, + description=form.description, + status=form.status, + owner_type=form.owner_type, + tournament_id=form.tournament_id, + chapter_id=form.chapter_id, + creator=creator, + created_at=form.created_at, + updated_at=form.updated_at, + response_count=form.response_count, + ) # --------------------------------------------------------------------------- diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index 82d85940..cffbe6ae 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -1,7 +1,12 @@ -from datetime import datetime +from __future__ import annotations +from datetime import datetime from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from app.schemas.user import UserSlimResponse +from app.schemas.tournament.membership import MembershipSlimResponse +from app.schemas.chapter.membership import ChapterMemberResponse + # --------------------------------------------------------------------------- # FormField.config schemas — one per question_type, shape enforced per # form-question-types-reference.md. These cover structural shape only @@ -189,6 +194,31 @@ class FormRead(BaseModel): model_config = ConfigDict(from_attributes=True) +# Forms-list rows (GET /tournaments/{id}/forms/, /chapters/{id}/forms/) don't +# need each form's full field list — field_count is enough for the list UI, +# and skipping `fields` avoids serializing every field/option on every form +# just to render a table row. +class FormListRead(BaseModel): + id: str + name: str + title: str | None = None + description: str | None = None + status: Literal["draft", "published", "archived"] + owner_type: Literal["tournament", "chapter"] + tournament_id: int | None = None + chapter_id: int | None = None + # Resolved server-side to the creator's membership in the form's own + # tournament/chapter, falling back to the bare user when they have none + # (e.g. a site admin acting without ever joining) — same pattern as + # JoinCodeResponse.creator. + creator: MembershipSlimResponse | ChapterMemberResponse | UserSlimResponse + created_at: datetime + updated_at: datetime + response_count: int = 0 + + model_config = ConfigDict(from_attributes=True) + + class FormCreate(BaseModel): name: str title: str | None = None diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index c6d8bf48..3b5586fc 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1235,6 +1235,32 @@ export interface Form { fields: FormField[] } +// Matches ChapterMemberResponse — no chapter dashboard exists yet, so this +// exists only to type FormListItem.creator for chapter-owned forms. +export interface ChapterMember extends UserSlim { + membership_id: number + role: string + joined_at: string +} + +// GET /tournaments/{id}/forms/ and /chapters/{id}/forms/ — a lighter row +// shape than Form: no fields array, and creator resolved server-side the +// same way Invite.creator/AuditLogEntry.actor are. +export interface FormListItem { + id: string + name: string + title: string | null + description: string | null + status: FormStatus + owner_type: FormOwnerType + tournament_id: number | null + chapter_id: number | null + creator: MembershipSlim | ChapterMember | UserSlim + created_at: string + updated_at: string + response_count: number +} + export interface FormCreateInput { name: string title?: string | null @@ -1273,9 +1299,9 @@ export interface FormResponse { export const formsApi = { listForTournament: (tournamentId: number) => - api.get(`/tournaments/${tournamentId}/forms/`), + api.get(`/tournaments/${tournamentId}/forms/`), listForChapter: (chapterId: number) => - api.get(`/chapters/${chapterId}/forms/`), + api.get(`/chapters/${chapterId}/forms/`), createForTournament: (tournamentId: number, body: { name: string; title?: string | null; description?: string | null }) => api.post
(`/tournaments/${tournamentId}/forms/`, { ...body, owner_type: 'tournament', tournament_id: tournamentId }), createForChapter: (chapterId: number, body: { name: string; title?: string | null; description?: string | null }) => From db093b23e22f653ef517dc3a07ebf8d1304405b7 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 21:45:19 -0700 Subject: [PATCH 059/185] feat(forms): add Creator column to forms list, extract CreatorHoverCard --- .../dashboard/tournaments/[id]/forms/page.tsx | 24 +++--- .../[id]/settings/invites/page.tsx | 57 ++------------- .../tournament/CreatorHoverCard.tsx | 73 +++++++++++++++++++ frontend/lib/personDisplay.ts | 11 ++- 4 files changed, 98 insertions(+), 67 deletions(-) create mode 100644 frontend/components/tournament/CreatorHoverCard.tsx diff --git a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx index 7ac31e20..b2250f9e 100644 --- a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useParams, useRouter } from "next/navigation"; -import { formsApi, Form, FormStatus, ApiError } from "@/lib/api"; +import { formsApi, Form, FormListItem, FormStatus, ApiError } from "@/lib/api"; import { useAuth } from "@/lib/useAuth"; import { useMyMembership } from "@/lib/useMyMembership"; import { PageHeader } from "@/components/ui/PageHeader"; @@ -13,10 +13,11 @@ import { Spinner } from "@/components/ui/Spinner"; import { EmptyState } from "@/components/ui/EmptyState"; import { IconForms, IconLock, IconEdit, IconEye, IconPlus } from "@/components/ui/Icons"; import { formatRelativeTime } from "@/lib/timeFormat"; +import { CreatorHoverCard } from "@/components/tournament/CreatorHoverCard"; import { NewFormModal } from "@/components/tournament/forms/NewFormModal"; -// Name / Status / Fields / Responses / Updated / Actions -const FORM_ROW_COLUMNS = "1.6fr 110px 80px 100px 110px 76px"; +// Name / Status / Creator / Responses / Updated / Actions +const FORM_ROW_COLUMNS = "1.4fr 110px 0.275fr 100px 110px 76px"; const STATUS_BADGE_VARIANT: Record = { draft: "default", @@ -25,12 +26,11 @@ const STATUS_BADGE_VARIANT: Record !f.is_archived).length; return (
{form.status} - - {fieldCount} - + {form.response_count} @@ -83,7 +85,7 @@ function FormRow({ form, isLast }: { ); } -function FormTable({ forms }: { forms: Form[] }) { +function FormTable({ forms }: { forms: FormListItem[] }) { return (
Forms — {forms.length} Status - Fields + Creator Responses Updated @@ -116,7 +118,7 @@ export default function FormsPage() { const { membership, hasPermission, loading: membershipLoading } = useMyMembership(); const canManageForms = currentUser?.role === "admin" || !!membership?.is_owner || hasPermission("manage_forms"); - const [forms, setForms] = useState(null); + const [forms, setForms] = useState(null); const [loadError, setLoadError] = useState(null); const [creating, setCreating] = useState(false); diff --git a/frontend/app/dashboard/tournaments/[id]/settings/invites/page.tsx b/frontend/app/dashboard/tournaments/[id]/settings/invites/page.tsx index 072a6868..4f390da4 100644 --- a/frontend/app/dashboard/tournaments/[id]/settings/invites/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/settings/invites/page.tsx @@ -13,12 +13,10 @@ import { Badge } from "@/components/ui/Badge"; import { Input } from "@/components/ui/Input"; import { Spinner } from "@/components/ui/Spinner"; import { EmptyState } from "@/components/ui/EmptyState"; -import { AvatarCircle } from "@/components/ui/AvatarCircle"; -import { HoverCard } from "@/components/ui/HoverCard"; import { IconArchive, IconInvite, IconLock, IconPlus, IconTrash } from "@/components/ui/Icons"; import { CreateInviteModal } from "@/components/tournament/settings/CreateInviteModal"; import { AddTimePopover } from "@/components/tournament/settings/AddTimePopover"; -import { personUser, personName, personRoles } from "@/lib/personDisplay"; +import { CreatorHoverCard } from "@/components/tournament/CreatorHoverCard"; import { formatCountdown } from "@/lib/timeFormat"; // Label / Code / Creator / Expiry / Uses / Actions @@ -110,7 +108,6 @@ function InviteRow({ }) { const [deactivating, setDeactivating] = useState(false); const [hovered, setHovered] = useState(false); - const user = personUser(invite.creator); const expiry = invite.expires_at === null ? "∞" : formatCountdown(new Date(invite.expires_at).getTime() - now); @@ -146,55 +143,11 @@ function InviteRow({ > {invite.code} - -

- {personName(invite.creator)} -

-

- {user.email} -

-
- {(() => { - const roles = personRoles(invite.creator); - if (roles === null) { - return ( - - No membership in this tournament - - ); - } - if (roles.length === 0) { - return ( - - No roles - - ); - } - return roles.map((role) => {role.label}); - })()} -
- - } - > -
- - - {personName(invite.creator)} - -
-
+ /> {expiry} diff --git a/frontend/components/tournament/CreatorHoverCard.tsx b/frontend/components/tournament/CreatorHoverCard.tsx new file mode 100644 index 00000000..c6c2f3cc --- /dev/null +++ b/frontend/components/tournament/CreatorHoverCard.tsx @@ -0,0 +1,73 @@ +'use client' + +import { CSSProperties } from "react"; +import { PersonRef, personUser, personName, personRoles } from "@/lib/personDisplay"; +import { AvatarCircle } from "@/components/ui/AvatarCircle"; +import { HoverCard } from "@/components/ui/HoverCard"; +import { Badge } from "@/components/ui/Badge"; + +interface CreatorHoverCardProps { + creator: PersonRef; + /** What to show when personRoles() is null — the bare-UserSlim fallback, + * no membership at all. Wording differs by container (tournament/chapter). */ + noMembershipLabel?: string; + /** Merged onto the HoverCard's trigger wrapper — e.g. to center/stretch + * within a grid cell. */ + style?: CSSProperties; +} + +// Shared "who did this" cell — avatar + name, hover for email + roles. +// Used anywhere a row surfaces a PersonRef (Invite.creator, FormListItem.creator, ...). +export function CreatorHoverCard({ creator, noMembershipLabel = "No membership", style }: CreatorHoverCardProps) { + return ( + +

+ {personName(creator)} +

+

+ {personUser(creator).email} +

+
+ {(() => { + const roles = personRoles(creator); + if (roles === null) { + return ( + + {noMembershipLabel} + + ); + } + if (roles.length === 0) { + return ( + + No roles + + ); + } + return roles.map((role) => {role.label}); + })()} +
+ + } + > +
+ + + {personName(creator)} + +
+
+ ); +} diff --git a/frontend/lib/personDisplay.ts b/frontend/lib/personDisplay.ts index 5cdda980..9d8ce8f3 100644 --- a/frontend/lib/personDisplay.ts +++ b/frontend/lib/personDisplay.ts @@ -1,9 +1,12 @@ -import { MembershipSlim, UserSlim } from "@/lib/api"; +import { ChapterMember, MembershipSlim, UserSlim } from "@/lib/api"; // Shared shape for anything the backend resolves to "membership if they have -// one in this tournament, bare user otherwise" — Invite.creator and -// AuditLogEntry.actor both use it. -export type PersonRef = MembershipSlim | UserSlim; +// one in this tournament/chapter, bare user otherwise" — Invite.creator, +// AuditLogEntry.actor, and FormListItem.creator all use it. ChapterMember +// has no nested `user` (its UserSlim fields are flattened onto it directly), +// so it falls through personUser's "user" in ref check the same way a bare +// UserSlim does. +export type PersonRef = MembershipSlim | ChapterMember | UserSlim; export function personUser(ref: PersonRef): UserSlim { return "user" in ref ? ref.user : ref; From 546c5899d415c7849fca7361055558e8396c4058 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 21:51:15 -0700 Subject: [PATCH 060/185] refactor(ui): tokenize Button/SplitButton colors, rebuild SplitButton on Button+Popover --- frontend/app/globals.css | 1 + frontend/components/ui/Button.tsx | 14 +- frontend/components/ui/SplitButton.tsx | 252 ++++++------------------- 3 files changed, 69 insertions(+), 198 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 8308bfd9..dad4aba4 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -152,6 +152,7 @@ for the ones that are (accent, border, success, danger, text-*, etc.) */ --color-bg: #F7F7F5; --color-accent-hover: #2A2A2A; + --color-danger-hover: #C53030; --color-text-primary: #0A0A0A; --color-text-secondary: #6B6B65; diff --git a/frontend/components/ui/Button.tsx b/frontend/components/ui/Button.tsx index 61e89285..a9962747 100644 --- a/frontend/components/ui/Button.tsx +++ b/frontend/components/ui/Button.tsx @@ -24,11 +24,11 @@ interface ButtonProps extends ButtonHTMLAttributes { // borderColor without conflicting with the shorthand `border` property. const variantStyles: Record = { primary: { - background: '#0A0A0A', - color: '#FFFFFF', + background: 'var(--color-accent)', + color: 'var(--color-text-inverse)', borderWidth: '1px', borderStyle: 'solid', - borderColor: '#0A0A0A', + borderColor: 'var(--color-accent)', }, secondary: { background: 'var(--color-surface)', @@ -46,7 +46,7 @@ const variantStyles: Record = { }, danger: { background: 'var(--color-danger)', - color: '#FFFFFF', + color: 'var(--color-text-inverse)', borderWidth: '1px', borderStyle: 'solid', borderColor: 'var(--color-danger)', @@ -55,10 +55,10 @@ const variantStyles: Record = { /** Background applied on hover per variant */ const variantHoverBg: Record = { - primary: '#2A2A2A', + primary: 'var(--color-accent-hover)', secondary: 'var(--color-accent-subtle)', ghost: 'var(--color-accent-subtle)', - danger: '#C53030', + danger: 'var(--color-danger-hover)', } /** Border color applied on hover (null = no change) */ @@ -150,7 +150,7 @@ export const Button = forwardRef( width: '14px', height: '14px', border: '2px solid rgba(255,255,255,0.4)', - borderTopColor: variant === 'primary' || variant === 'danger' ? '#fff' : 'var(--color-text-primary)', + borderTopColor: variant === 'primary' || variant === 'danger' ? 'var(--color-text-inverse)' : 'var(--color-text-primary)', borderRadius: '50%', display: 'inline-block', animation: 'btn-spin 600ms linear infinite', diff --git a/frontend/components/ui/SplitButton.tsx b/frontend/components/ui/SplitButton.tsx index 266de953..c435980b 100644 --- a/frontend/components/ui/SplitButton.tsx +++ b/frontend/components/ui/SplitButton.tsx @@ -1,10 +1,18 @@ 'use client' -import { useEffect, useRef, useState, ReactNode } from 'react' +import { ReactNode } from 'react' +import { Button } from '@/components/ui/Button' +import { Popover } from '@/components/ui/Popover' +import { IconChevronDown } from '@/components/ui/Icons' export interface SplitButtonOption { label: string + /** Secondary line under the label — same field name as DropdownOption.subtitle. */ + subtitle?: string + icon?: ReactNode action: () => void + /** Visually distinct destructive style — e.g. Delete next to a plain Archive. */ + danger?: boolean } interface SplitButtonProps { @@ -20,55 +28,9 @@ interface SplitButtonProps { disabled?: boolean } -type Variant = 'primary' | 'secondary' | 'ghost' - -const variantTokens: Record = { - primary: { - bg: '#0A0A0A', - bgHover: '#2A2A2A', - color: '#FFFFFF', - border: '1px solid #0A0A0A', - divider: 'rgba(255,255,255,0.2)', - dropdownBg: 'var(--color-surface)', - dropdownBorder: 'var(--color-border)', - dropdownItemHover:'var(--color-accent-subtle)', - }, - secondary: { - bg: 'var(--color-surface)', - bgHover: 'var(--color-accent-subtle)', - color: 'var(--color-text-primary)', - border: '1px solid var(--color-border)', - divider: 'var(--color-border)', - dropdownBg: 'var(--color-surface)', - dropdownBorder: 'var(--color-border)', - dropdownItemHover:'var(--color-accent-subtle)', - }, - ghost: { - bg: 'transparent', - bgHover: 'var(--color-accent-subtle)', - color: 'var(--color-text-primary)', - border: '1px solid transparent', - divider: 'var(--color-border)', - dropdownBg: 'var(--color-surface)', - dropdownBorder: 'var(--color-border)', - dropdownItemHover:'var(--color-accent-subtle)', - }, -} - -const sizeTokens = { - sm: { height: '34px', fontSize: '12px', px: '12px', chevronW: '30px', gap: '6px' }, - md: { height: '38px', fontSize: '13px', px: '14px', chevronW: '34px', gap: '8px' }, -} - +// A primary action segment plus a chevron that opens a menu of secondary +// actions — composed from Button (both segments) and Popover (the menu), +// rather than reimplementing hover/focus/positioning from scratch. export function SplitButton({ label, onClick, @@ -78,149 +40,57 @@ export function SplitButton({ loading = false, disabled = false, }: SplitButtonProps) { - const [open, setOpen] = useState(false) - const [mainHovered, setMainHovered] = useState(false) - const [chevronHovered, setChevronHovered] = useState(false) - const ref = useRef(null) - - const v = variantTokens[variant] - const s = sizeTokens[size] - - useEffect(() => { - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) - } - document.addEventListener('mousedown', handleClick) - return () => document.removeEventListener('mousedown', handleClick) - }, []) - - const isDisabled = disabled || loading - const mainBg = mainHovered && !isDisabled ? v.bgHover : v.bg - const chevBg = chevronHovered && !isDisabled ? v.bgHover : v.bg - return ( -
- {/* Wrapper — gives shared border + radius */} -
- {/* Primary segment */} - +
+ - {/* Divider */} -
- - {/* Chevron segment */} - -
- - {/* Dropdown */} - {open && ( -
- {options.map((opt, i) => ( - - ))} -
- )} + + + } + items={options} + getKey={(opt) => opt.label} + align="right" + width={220} + onSelect={(opt) => opt.action()} + renderLabel={(opt) => ( +
+ {opt.icon && ( + + {opt.icon} + + )} +
+
+ {opt.label} +
+ {opt.subtitle && ( +
+ {opt.subtitle} +
+ )} +
+
+ )} + />
) -} \ No newline at end of file +} From 92558867fe11e42231af9eb98733fbe07b6308f7 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 21:59:46 -0700 Subject: [PATCH 061/185] feat(ui): rework SplitButton into a controlled status selector --- .../dashboard/tournaments/[id]/forms/page.tsx | 25 ++++- frontend/components/ui/Button.tsx | 4 +- frontend/components/ui/Popover.tsx | 9 +- frontend/components/ui/SplitButton.tsx | 93 ++++++++++++------- 4 files changed, 88 insertions(+), 43 deletions(-) diff --git a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx index b2250f9e..fd284dba 100644 --- a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx @@ -11,7 +11,8 @@ import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; import { Spinner } from "@/components/ui/Spinner"; import { EmptyState } from "@/components/ui/EmptyState"; -import { IconForms, IconLock, IconEdit, IconEye, IconPlus } from "@/components/ui/Icons"; +import { SplitButton } from "@/components/ui/SplitButton"; +import { IconForms, IconLock, IconEdit, IconEye, IconPlus, IconArchive, IconTrash } from "@/components/ui/Icons"; import { formatRelativeTime } from "@/lib/timeFormat"; import { CreatorHoverCard } from "@/components/tournament/CreatorHoverCard"; import { NewFormModal } from "@/components/tournament/forms/NewFormModal"; @@ -121,6 +122,7 @@ export default function FormsPage() { const [forms, setForms] = useState(null); const [loadError, setLoadError] = useState(null); const [creating, setCreating] = useState(false); + const [stubStatus, setStubStatus] = useState("published"); // stub — SplitButton preview only useEffect(() => { if (!canManageForms) return; @@ -174,9 +176,24 @@ export default function FormsPage() { setCreating(true)}> - New Form - +
+ {/* stub — just to preview SplitButton styling, not wired up */} + {}} + variant="primary" + size="md" + options={[ + { value: "published", label: "Published", subtitle: "Accepting responses" }, + { value: "archived", label: "Archive", subtitle: "Stop accepting responses", icon: }, + { value: "delete", label: "Delete", subtitle: "Permanently remove this form", icon: , danger: true }, + ]} + /> + +
} /> diff --git a/frontend/components/ui/Button.tsx b/frontend/components/ui/Button.tsx index a9962747..b1977316 100644 --- a/frontend/components/ui/Button.tsx +++ b/frontend/components/ui/Button.tsx @@ -63,10 +63,10 @@ const variantHoverBg: Record = { /** Border color applied on hover (null = no change) */ const variantHoverBorderColor: Record = { - primary: null, + primary: 'var(--color-accent-hover)', secondary: 'var(--color-border-strong)', ghost: null, - danger: null, + danger: 'var(--color-danger-hover)', } const sizeStyles: Record = { diff --git a/frontend/components/ui/Popover.tsx b/frontend/components/ui/Popover.tsx index ad24bc36..d75042de 100644 --- a/frontend/components/ui/Popover.tsx +++ b/frontend/components/ui/Popover.tsx @@ -23,6 +23,8 @@ interface PopoverProps { isDisabled?: (item: T) => boolean; /** Tooltip text for a disabled row. Only consulted when isDisabled(item) is true; a falsy return skips the tooltip. */ disabledReason?: (item: T) => string | undefined; + /** Fires whenever the panel opens/closes — e.g. to rotate a chevron on the trigger. */ + onOpenChange?: (open: boolean) => void; } // Anchored from the top (below the trigger) normally; flips to bottom @@ -47,9 +49,14 @@ const PANEL_MAX_HEIGHT = 260; // side panel's scroll container), which silently truncates or hides it. export function Popover({ trigger, items, getKey, renderLabel, onSelect, emptyMessage = "Nothing to show", width = 180, align = "right", - checklist = false, isSelected, isDisabled, disabledReason, + checklist = false, isSelected, isDisabled, disabledReason, onOpenChange, }: PopoverProps) { const [open, setOpen] = useState(false); + + useEffect(() => { + onOpenChange?.(open); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); const [pendingKey, setPendingKey] = useState(null); const [error, setError] = useState(undefined); const [panelPos, setPanelPos] = useState(null); diff --git a/frontend/components/ui/SplitButton.tsx b/frontend/components/ui/SplitButton.tsx index c435980b..8088d1db 100644 --- a/frontend/components/ui/SplitButton.tsx +++ b/frontend/components/ui/SplitButton.tsx @@ -1,58 +1,70 @@ 'use client' -import { ReactNode } from 'react' +import { ReactNode, useState } from 'react' import { Button } from '@/components/ui/Button' import { Popover } from '@/components/ui/Popover' import { IconChevronDown } from '@/components/ui/Icons' export interface SplitButtonOption { + /** Stable key — matches SplitButtonProps.value to determine the checkmark and, indirectly, the primary segment's label. */ + value: string + /** Shown in the dropdown row, and mirrored onto the primary segment once selected. */ label: string - /** Secondary line under the label — same field name as DropdownOption.subtitle. */ subtitle?: string + /** Shown left of the label when this option isn't the current selection — the selection itself always shows a checkmark instead. */ icon?: ReactNode - action: () => void /** Visually distinct destructive style — e.g. Delete next to a plain Archive. */ danger?: boolean } interface SplitButtonProps { - /** Label shown on the primary left segment */ - label: string - /** Called when the primary left segment is clicked */ - onClick: () => void - /** Dropdown options shown when the chevron is clicked */ + /** The currently-selected option's value — drives the primary segment's label and which dropdown row shows a checkmark. */ + value: string options: SplitButtonOption[] + /** Picking a different row in the dropdown — just changes the selection, no side effect. Confirm via onConfirm. */ + onSelect: (value: string) => void + /** Clicking the primary segment — the actual action for whatever's currently selected. */ + onConfirm: () => void variant?: 'primary' | 'secondary' | 'ghost' size?: 'sm' | 'md' loading?: boolean disabled?: boolean } -// A primary action segment plus a chevron that opens a menu of secondary -// actions — composed from Button (both segments) and Popover (the menu), +// A primary segment (label mirrors the current selection) plus a chevron +// that opens a menu to change the selection — picking a menu row doesn't +// act on its own, it just updates what the primary segment will do when +// clicked. Composed from Button (both segments) and Popover (the menu) // rather than reimplementing hover/focus/positioning from scratch. export function SplitButton({ - label, - onClick, + value, options, + onSelect, + onConfirm, variant = 'secondary', size = 'sm', loading = false, disabled = false, }: SplitButtonProps) { + const [open, setOpen] = useState(false) + const selected = options.find((opt) => opt.value === value) + const dividerColor = variant === 'primary' ? 'rgba(255,255,255,0.24)' : 'var(--color-border)' + return ( -
+
+
+ - + } items={options} - getKey={(opt) => opt.label} + getKey={(opt) => opt.value} align="right" - width={220} - onSelect={(opt) => opt.action()} - renderLabel={(opt) => ( -
- {opt.icon && ( - - {opt.icon} + width={240} + onOpenChange={setOpen} + onSelect={(opt) => onSelect(opt.value)} + renderLabel={(opt) => { + const isSelected = opt.value === value + return ( +
+ + {isSelected ? ( + + + + ) : opt.icon} - )} -
-
- {opt.label} -
- {opt.subtitle && ( -
- {opt.subtitle} +
+
+ {opt.label}
- )} + {opt.subtitle && ( +
+ {opt.subtitle} +
+ )} +
-
- )} + ) + }} />
) From 8c6edfdb0d963715621189e79e4957f8bf6514ae Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 22:03:34 -0700 Subject: [PATCH 062/185] chore(forms): remove SplitButton preview stub from forms list page --- .../dashboard/tournaments/[id]/forms/page.tsx | 25 +++---------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx index fd284dba..b2250f9e 100644 --- a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx @@ -11,8 +11,7 @@ import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; import { Spinner } from "@/components/ui/Spinner"; import { EmptyState } from "@/components/ui/EmptyState"; -import { SplitButton } from "@/components/ui/SplitButton"; -import { IconForms, IconLock, IconEdit, IconEye, IconPlus, IconArchive, IconTrash } from "@/components/ui/Icons"; +import { IconForms, IconLock, IconEdit, IconEye, IconPlus } from "@/components/ui/Icons"; import { formatRelativeTime } from "@/lib/timeFormat"; import { CreatorHoverCard } from "@/components/tournament/CreatorHoverCard"; import { NewFormModal } from "@/components/tournament/forms/NewFormModal"; @@ -122,7 +121,6 @@ export default function FormsPage() { const [forms, setForms] = useState(null); const [loadError, setLoadError] = useState(null); const [creating, setCreating] = useState(false); - const [stubStatus, setStubStatus] = useState("published"); // stub — SplitButton preview only useEffect(() => { if (!canManageForms) return; @@ -176,24 +174,9 @@ export default function FormsPage() { - {/* stub — just to preview SplitButton styling, not wired up */} - {}} - variant="primary" - size="md" - options={[ - { value: "published", label: "Published", subtitle: "Accepting responses" }, - { value: "archived", label: "Archive", subtitle: "Stop accepting responses", icon: }, - { value: "delete", label: "Delete", subtitle: "Permanently remove this form", icon: , danger: true }, - ]} - /> - -
+ } /> From d45ab5671236cb184f57915036ec0ed352721ca7 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 22:03:47 -0700 Subject: [PATCH 063/185] feat(ui): add IconUpload to Icons.tsx --- frontend/components/ui/Icons.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/components/ui/Icons.tsx b/frontend/components/ui/Icons.tsx index 99d52cbc..b1cb290b 100644 --- a/frontend/components/ui/Icons.tsx +++ b/frontend/components/ui/Icons.tsx @@ -83,6 +83,14 @@ export function IconMembers({ size = 18, ...props }: IconProps) { ); } +export function IconUpload({ size = 18, ...props }: IconProps) { + return ( + + + + ); +} + export function IconSettings({ size = 18, ...props }: IconProps) { return ( From 1de40b81ea01bf3b3acaf5f8eaa42a4757afea10 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 22:09:47 -0700 Subject: [PATCH 064/185] feat(ui): support disabled+tooltip rows in Popover list mode, add SplitButton.primaryDisabled --- frontend/components/ui/Popover.tsx | 14 ++-- frontend/components/ui/SplitButton.tsx | 91 ++++++++++++-------------- 2 files changed, 51 insertions(+), 54 deletions(-) diff --git a/frontend/components/ui/Popover.tsx b/frontend/components/ui/Popover.tsx index d75042de..0da71d56 100644 --- a/frontend/components/ui/Popover.tsx +++ b/frontend/components/ui/Popover.tsx @@ -19,7 +19,7 @@ interface PopoverProps { /** Checklist mode: rows render as a checkbox + label (like a picker list), stay open across selections instead of closing after each one. Requires isSelected. */ checklist?: boolean; isSelected?: (item: T) => boolean; - /** Checklist mode only: rows for which this returns true show a lock icon instead of a checkbox and can't be toggled (e.g. a role the actor isn't allowed to touch), but stay visible rather than being filtered out. */ + /** Rows for which this returns true can't be selected (e.g. a role the actor isn't allowed to touch, or a delete blocked by existing data) but stay visible rather than being filtered out. In checklist mode they show a lock icon instead of a checkbox; in list mode they render inert with a tooltip. */ isDisabled?: (item: T) => boolean; /** Tooltip text for a disabled row. Only consulted when isDisabled(item) is true; a falsy return skips the tooltip. */ disabledReason?: (item: T) => string | undefined; @@ -178,22 +178,24 @@ export function Popover({ ) : ( items.map((item) => { const key = getKey(item); + const disabled = isDisabled?.(item) ?? false; + const reason = disabled ? disabledReason?.(item) : undefined; return ( diff --git a/frontend/components/ui/SplitButton.tsx b/frontend/components/ui/SplitButton.tsx index 8088d1db..d8c6d1ea 100644 --- a/frontend/components/ui/SplitButton.tsx +++ b/frontend/components/ui/SplitButton.tsx @@ -6,48 +6,49 @@ import { Popover } from '@/components/ui/Popover' import { IconChevronDown } from '@/components/ui/Icons' export interface SplitButtonOption { - /** Stable key — matches SplitButtonProps.value to determine the checkmark and, indirectly, the primary segment's label. */ - value: string - /** Shown in the dropdown row, and mirrored onto the primary segment once selected. */ label: string subtitle?: string - /** Shown left of the label when this option isn't the current selection — the selection itself always shows a checkmark instead. */ icon?: ReactNode + /** Fires immediately on click — may throw/reject, which the menu shows inline and stays open for. */ + action: () => void | Promise /** Visually distinct destructive style — e.g. Delete next to a plain Archive. */ danger?: boolean + /** Renders inert with a tooltip instead of attempted-then-rejected — e.g. Delete when responses already exist. */ + disabled?: boolean + disabledReason?: string } interface SplitButtonProps { - /** The currently-selected option's value — drives the primary segment's label and which dropdown row shows a checkmark. */ - value: string + /** Label shown on the primary left segment */ + label: string + /** Called when the primary left segment is clicked */ + onClick: () => void + /** Dropdown options shown when the chevron is clicked — each fires its own action immediately. */ options: SplitButtonOption[] - /** Picking a different row in the dropdown — just changes the selection, no side effect. Confirm via onConfirm. */ - onSelect: (value: string) => void - /** Clicking the primary segment — the actual action for whatever's currently selected. */ - onConfirm: () => void variant?: 'primary' | 'secondary' | 'ghost' size?: 'sm' | 'md' loading?: boolean + /** Disables both segments — the whole control is unusable. */ disabled?: boolean + /** Disables just the primary segment (e.g. it has no forward action right now) while the chevron menu stays usable. */ + primaryDisabled?: boolean } -// A primary segment (label mirrors the current selection) plus a chevron -// that opens a menu to change the selection — picking a menu row doesn't -// act on its own, it just updates what the primary segment will do when -// clicked. Composed from Button (both segments) and Popover (the menu) -// rather than reimplementing hover/focus/positioning from scratch. +// A primary action segment plus a chevron that opens a menu of one-off +// secondary actions (Archive, Delete, ...) — composed from Button (both +// segments) and Popover (the menu) rather than reimplementing hover/focus/ +// positioning from scratch. export function SplitButton({ - value, + label, + onClick, options, - onSelect, - onConfirm, variant = 'secondary', size = 'sm', loading = false, disabled = false, + primaryDisabled = false, }: SplitButtonProps) { const [open, setOpen] = useState(false) - const selected = options.find((opt) => opt.value === value) const dividerColor = variant === 'primary' ? 'rgba(255,255,255,0.24)' : 'var(--color-border)' return ( @@ -56,11 +57,11 @@ export function SplitButton({ variant={variant} size={size} loading={loading} - disabled={disabled} - onClick={onConfirm} + disabled={disabled || primaryDisabled} + onClick={onClick} style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0, borderRightWidth: 0 }} > - {selected?.label ?? value} + {label}
@@ -79,38 +80,32 @@ export function SplitButton({ } items={options} - getKey={(opt) => opt.value} + getKey={(opt) => opt.label} align="right" width={240} onOpenChange={setOpen} - onSelect={(opt) => onSelect(opt.value)} - renderLabel={(opt) => { - const isSelected = opt.value === value - return ( -
- - {isSelected ? ( - - - - ) : opt.icon} + onSelect={(opt) => opt.action()} + isDisabled={(opt) => opt.disabled ?? false} + disabledReason={(opt) => opt.disabledReason} + renderLabel={(opt) => ( +
+ {opt.icon && ( + + {opt.icon} -
-
- {opt.label} -
- {opt.subtitle && ( -
- {opt.subtitle} -
- )} + )} +
+
+ {opt.label}
+ {opt.subtitle && ( +
+ {opt.subtitle} +
+ )}
- ) - }} +
+ )} />
) From e036ec6da4bcd4d975acb05bc216eaa53d91a62b Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 22:09:58 -0700 Subject: [PATCH 065/185] feat(forms): wire real publish/status sub-header on the builder edit page --- frontend/app/forms/[formId]/edit/page.tsx | 242 +++++++++++++++++++++- 1 file changed, 237 insertions(+), 5 deletions(-) diff --git a/frontend/app/forms/[formId]/edit/page.tsx b/frontend/app/forms/[formId]/edit/page.tsx index 07e691c9..28483879 100644 --- a/frontend/app/forms/[formId]/edit/page.tsx +++ b/frontend/app/forms/[formId]/edit/page.tsx @@ -1,15 +1,247 @@ "use client"; -import { use } from "react"; +import { use, useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { formsApi, Form, FormStatus, ApiError } from "@/lib/api"; +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { Badge } from "@/components/ui/Badge"; +import { Spinner } from "@/components/ui/Spinner"; +import { SplitButton, SplitButtonOption } from "@/components/ui/SplitButton"; +import { IconArrowLeft, IconArchive, IconTrash } from "@/components/ui/Icons"; + +const STATUS_BADGE_VARIANT: Record = { + draft: "default", + published: "confirmed", + archived: "removed", +}; + +function EditableName({ form, onUpdated }: { + form: Form; + onUpdated: (form: Form) => void; +}) { + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(form.name); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(undefined); + const inputRef = useRef(null); + + useEffect(() => { + if (editing) inputRef.current?.focus(); + }, [editing]); + + function startEdit() { + setValue(form.name); + setError(undefined); + setEditing(true); + } + + async function save() { + const trimmed = value.trim(); + if (!trimmed || trimmed === form.name) { + setEditing(false); + return; + } + setSaving(true); + try { + const updated = await formsApi.update(form.id, { name: trimmed }); + onUpdated(updated); + setEditing(false); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Failed to update name."); + } finally { + setSaving(false); + } + } + + if (editing) { + return ( + setValue(e.target.value)} + onBlur={save} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); save(); } + if (e.key === "Escape") { e.preventDefault(); setEditing(false); } + }} + error={error} + disabled={saving} + size="sm" + font="sans" + /> + ); + } + + return ( + + {form.name} + + ); +} + +function StatusControl({ form, onUpdated, onDeleted }: { + form: Form; + onUpdated: (form: Form) => void; + onDeleted: () => void; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(undefined); + + async function publish() { + setBusy(true); setError(undefined); + try { + onUpdated(await formsApi.update(form.id, { status: "published" })); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Failed to publish form."); + } finally { + setBusy(false); + } + } + + async function archive() { + setError(undefined); + try { + onUpdated(await formsApi.archive(form.id)); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Failed to archive form."); + } + } + + async function deleteForm() { + setError(undefined); + try { + await formsApi.delete(form.id); + onDeleted(); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Failed to delete form."); + } + } + + const PRIMARY_LABEL: Record = { + draft: "Publish", + published: "Published", + archived: "Archived", + }; + + const options: SplitButtonOption[] = [ + ...(form.status !== "archived" + ? [{ label: "Archive", subtitle: "Stop accepting responses", icon: , action: archive }] + : []), + { + label: "Delete", + subtitle: "Permanently remove this form", + icon: , + danger: true, + disabled: form.response_count > 0, + disabledReason: form.response_count > 0 ? "Archive instead — this form already has responses" : undefined, + action: deleteForm, + }, + ]; + + return ( +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} + +function SubHeader({ form, onUpdated, onDeleted }: { + form: Form; + onUpdated: (form: Form) => void; + onDeleted: () => void; +}) { + const router = useRouter(); + const backHref = form.owner_type === "tournament" ? `/dashboard/tournaments/${form.tournament_id}/forms` : null; + + return ( +
+
+ + + {form.status} +
+ +
+ ); +} export default function FormEditPage({ params }: { params: Promise<{ formId: string }> }) { const { formId } = use(params); + const router = useRouter(); + + const [form, setForm] = useState(null); + const [loadError, setLoadError] = useState(null); + + useEffect(() => { + formsApi.get(formId) + .then(setForm) + .catch((e) => setLoadError(e instanceof ApiError ? e.message : "Failed to load form.")); + }, [formId]); + + function handleDeleted() { + if (form?.owner_type === "tournament") { + router.push(`/dashboard/tournaments/${form.tournament_id}/forms`); + } else { + router.back(); + } + } + + if (loadError) { + return ( +
+

+ {loadError} +

+
+ ); + } + + if (!form) { + return ( +
+ +
+ ); + } return ( -
-

- Form builder — {formId} -

+
+ +
+

+ Field list and title card land in later steps. +

+
); } From c7de4f62d6ff06667734102c1cb8f7e68b82ec2d Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 22:35:33 -0700 Subject: [PATCH 066/185] fix(forms): fix editable-name width bug, extract EditableText, put sub-header in a Card --- frontend/app/forms/[formId]/edit/page.tsx | 122 ++++++--------------- frontend/components/ui/EditableText.tsx | 123 ++++++++++++++++++++++ 2 files changed, 155 insertions(+), 90 deletions(-) create mode 100644 frontend/components/ui/EditableText.tsx diff --git a/frontend/app/forms/[formId]/edit/page.tsx b/frontend/app/forms/[formId]/edit/page.tsx index 28483879..483fc397 100644 --- a/frontend/app/forms/[formId]/edit/page.tsx +++ b/frontend/app/forms/[formId]/edit/page.tsx @@ -1,92 +1,27 @@ "use client"; -import { use, useEffect, useRef, useState } from "react"; +import { use, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { formsApi, Form, FormStatus, ApiError } from "@/lib/api"; import { Button } from "@/components/ui/Button"; -import { Input } from "@/components/ui/Input"; +import { Card } from "@/components/ui/Card"; import { Badge } from "@/components/ui/Badge"; import { Spinner } from "@/components/ui/Spinner"; +import { EditableText } from "@/components/ui/EditableText"; import { SplitButton, SplitButtonOption } from "@/components/ui/SplitButton"; import { IconArrowLeft, IconArchive, IconTrash } from "@/components/ui/Icons"; +// Matches the eventual centered content column (title card, field list) — +// the sub-header's content is constrained the same way, Google-Forms-style, +// rather than stretching edge to edge. +const CONTENT_MAX_WIDTH = 800; + const STATUS_BADGE_VARIANT: Record = { draft: "default", published: "confirmed", archived: "removed", }; -function EditableName({ form, onUpdated }: { - form: Form; - onUpdated: (form: Form) => void; -}) { - const [editing, setEditing] = useState(false); - const [value, setValue] = useState(form.name); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(undefined); - const inputRef = useRef(null); - - useEffect(() => { - if (editing) inputRef.current?.focus(); - }, [editing]); - - function startEdit() { - setValue(form.name); - setError(undefined); - setEditing(true); - } - - async function save() { - const trimmed = value.trim(); - if (!trimmed || trimmed === form.name) { - setEditing(false); - return; - } - setSaving(true); - try { - const updated = await formsApi.update(form.id, { name: trimmed }); - onUpdated(updated); - setEditing(false); - } catch (err) { - setError(err instanceof ApiError ? err.message : "Failed to update name."); - } finally { - setSaving(false); - } - } - - if (editing) { - return ( - setValue(e.target.value)} - onBlur={save} - onKeyDown={(e) => { - if (e.key === "Enter") { e.preventDefault(); save(); } - if (e.key === "Escape") { e.preventDefault(); setEditing(false); } - }} - error={error} - disabled={saving} - size="sm" - font="sans" - /> - ); - } - - return ( - - {form.name} - - ); -} - function StatusControl({ form, onUpdated, onDeleted }: { form: Form; onUpdated: (form: Form) => void; @@ -175,22 +110,29 @@ function SubHeader({ form, onUpdated, onDeleted }: { const backHref = form.owner_type === "tournament" ? `/dashboard/tournaments/${form.tournament_id}/forms` : null; return ( -
-
- - - {form.status} -
- +
+ +
+ + onUpdated(await formsApi.update(form.id, { name }))} + textStyle={{ fontSize: "15px", fontWeight: 600 }} + title="Click to edit name" + /> + {form.status} +
+ +
); } @@ -237,7 +179,7 @@ export default function FormEditPage({ params }: { params: Promise<{ formId: str return (
-
+

Field list and title card land in later steps.

diff --git a/frontend/components/ui/EditableText.tsx b/frontend/components/ui/EditableText.tsx new file mode 100644 index 00000000..4282841d --- /dev/null +++ b/frontend/components/ui/EditableText.tsx @@ -0,0 +1,123 @@ +'use client' + +import { CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react' + +interface EditableTextProps { + value: string + /** May throw/reject — the error's message is shown under the field and editing stays open. */ + onSave: (value: string) => void | Promise + /** Applied to both the display text and the input — keep them identical so entering edit mode doesn't shift surrounding layout. */ + textStyle?: CSSProperties + title?: string +} + +const DEFAULT_TEXT_STYLE: CSSProperties = { + fontFamily: 'var(--font-sans)', fontSize: '14px', fontWeight: 500, +} + +// Click-to-edit text that reads as plain text until clicked — no visible +// field chrome, just an underline once active. The input's width is driven +// by a hidden mirror span (same font) rather than a fixed size, so the +// span->input swap never shifts whatever sits next to it, and the box keeps +// tracking width as the user types. +export function EditableText({ value, onSave, textStyle, title = 'Click to edit' }: EditableTextProps) { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(value) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(undefined) + const [inputWidth, setInputWidth] = useState(0) + const inputRef = useRef(null) + const measureRef = useRef(null) + + // Depends on `editing` too: startEdit's setDraft(value) is a no-op when + // draft is already `value`, so `draft` alone wouldn't change on the + // span's first mount and this effect would skip, leaving inputWidth + // stuck at its stale (0) value. + useLayoutEffect(() => { + if (measureRef.current) setInputWidth(measureRef.current.offsetWidth) + }, [draft, editing]) + + useEffect(() => { + if (editing && inputRef.current) { + inputRef.current.focus() + const len = inputRef.current.value.length + inputRef.current.setSelectionRange(len, len) + } + }, [editing]) + + function startEdit() { + setDraft(value) + setError(undefined) + setEditing(true) + } + + async function save() { + const trimmed = draft.trim() + if (!trimmed || trimmed === value) { + setEditing(false) + return + } + setSaving(true) + try { + await onSave(trimmed) + setEditing(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save') + } finally { + setSaving(false) + } + } + + const style = { ...DEFAULT_TEXT_STYLE, ...textStyle } + + if (editing) { + return ( + + + {draft || ' '} + + setDraft(e.target.value)} + onBlur={save} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); save() } + if (e.key === 'Escape') { e.preventDefault(); setEditing(false) } + }} + disabled={saving} + style={{ + ...style, + width: `${Math.max(inputWidth, 20)}px`, + boxSizing: 'content-box', + color: 'var(--color-text-primary)', + background: 'transparent', + border: 'none', + borderBottom: `1px solid ${error ? 'var(--color-danger)' : 'var(--color-border-strong)'}`, + outline: 'none', + padding: 0, + margin: 0, + }} + /> + {error && ( + + {error} + + )} + + ) + } + + return ( + + {value} + + ) +} From 24aac5b689b02a6301f02c1162751508c438e5c9 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 19 Aug 2026 22:56:38 -0700 Subject: [PATCH 067/185] fix(ui): Input no longer clobbers caller onFocus/onBlur; Textarea gets label parity and variant prop --- frontend/components/ui/Input.tsx | 4 +++- frontend/components/ui/Textarea.tsx | 17 ++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/frontend/components/ui/Input.tsx b/frontend/components/ui/Input.tsx index 6863a669..cfd2902d 100644 --- a/frontend/components/ui/Input.tsx +++ b/frontend/components/ui/Input.tsx @@ -51,7 +51,7 @@ const CHARSET_PATTERNS: Record = { } export const Input = forwardRef( - ({ label, error, helper, fullWidth, font = 'mono', size = 'md', variant = 'primary', className = '', id, value, locked, disabled, required, charset, icon, onChange, inputMode, max, ...props }, ref) => { + ({ label, error, helper, fullWidth, font = 'mono', size = 'md', variant = 'primary', className = '', id, value, locked, disabled, required, charset, icon, onChange, inputMode, max, onFocus, onBlur, ...props }, ref) => { const generatedId = useId() const inputId = id ?? generatedId const sizing = SIZE_MAP[size] @@ -121,9 +121,11 @@ export const Input = forwardRef( }} onFocus={e => { e.target.style.borderColor = error ? 'var(--color-danger)' : 'var(--color-border-strong)' + onFocus?.(e) }} onBlur={e => { e.target.style.borderColor = error ? 'var(--color-danger)' : 'var(--color-border)' + onBlur?.(e) }} className={className} value={value ?? ''} diff --git a/frontend/components/ui/Textarea.tsx b/frontend/components/ui/Textarea.tsx index 5e810bea..8c0c0623 100644 --- a/frontend/components/ui/Textarea.tsx +++ b/frontend/components/ui/Textarea.tsx @@ -4,6 +4,8 @@ import { forwardRef, TextareaHTMLAttributes, useEffect, useId, useRef, useState type InputFont = 'sans' | 'mono' | 'serif' type InputSize = 'xs' | 'sm' | 'md' +// primary -- light gray; secondary -- white (matches Input's variant) +type InputVariant = 'primary' | 'secondary' interface TextProps extends TextareaHTMLAttributes { label?: string @@ -12,6 +14,7 @@ interface TextProps extends TextareaHTMLAttributes { rows?: number font?: InputFont size?: InputSize + variant?: InputVariant /** * Pops the textarea out into a larger floating box (top-left corner * anchored to the collapsed box) while focused or hovered. Collapses once @@ -35,9 +38,14 @@ const SIZE_MAP: Record = { + primary: 'var(--color-bg)', + secondary: 'var(--color-surface)', +} + export const Textarea = forwardRef( ({ - label, error, fullWidth, rows = 4, font = 'mono', size = 'md', className = '', id, value, style, + label, error, fullWidth, rows = 4, font = 'mono', size = 'md', variant = 'primary', className = '', id, value, style, expandable = false, expandedWidth = '280px', expandedHeight = '140px', onFocus, onBlur, onMouseEnter, onMouseLeave, ...props @@ -97,7 +105,10 @@ export const Textarea = forwardRef( {label && ( )}