From d4acbaa72a0344feecb6aeeadb582240b2a07eee Mon Sep 17 00:00:00 2001 From: Leah Peker Date: Mon, 14 Sep 2026 01:06:42 +0300 Subject: [PATCH 1/6] feat(surveys): add open/close scheduling and response cap (Issue 1465) Adds opens_at, closes_at, and max_responses to Survey, with a single survey_is_open helper enforcing the window/cap on both public endpoints. The cap check is skippable so an existing one-per-user response can still be upserted after the cap is hit. --- backend/community/_survey_helpers.py | 32 ++++ backend/community/_survey_schemas.py | 12 ++ backend/community/_surveys.py | 11 ++ backend/community/_surveys_public.py | 36 ++-- backend/community/_validation.py | 1 + .../0096_survey_scheduling_and_cap.py | 27 +++ backend/community/models/survey.py | 3 + backend/tests/test_surveys_public.py | 158 ++++++++++++++++++ 8 files changed, 265 insertions(+), 15 deletions(-) create mode 100644 backend/community/migrations/0096_survey_scheduling_and_cap.py diff --git a/backend/community/_survey_helpers.py b/backend/community/_survey_helpers.py index 849d9f64e..fc43ee9c6 100644 --- a/backend/community/_survey_helpers.py +++ b/backend/community/_survey_helpers.py @@ -1,6 +1,9 @@ """Helper functions for survey output serialization and tally logic.""" +from datetime import datetime + from config.media_proxy import media_path +from django.utils import timezone from users._helpers import visible_display_name from users.permissions import PermissionKey @@ -71,6 +74,9 @@ def _survey_out( visibility=survey.visibility, is_active=survey.is_active, one_response_per_user=survey.one_response_per_user, + opens_at=survey.opens_at, + closes_at=survey.closes_at, + max_responses=survey.max_responses, linked_event_id=str(survey.linked_event_id) if survey.linked_event_id else None, created_by_id=str(survey.created_by_id) if survey.created_by_id else None, created_at=survey.created_at, @@ -82,6 +88,32 @@ def _survey_out( ) +def survey_at_cap(survey: Survey) -> bool: + if survey.max_responses is None: + return False + return survey.responses.count() >= survey.max_responses + + +def survey_is_open(survey: Survey, now: datetime | None = None, *, check_cap: bool = True) -> bool: + """Whether the survey accepts responses right now. + + check_cap(bool): skip the response cap — for a user updating their own response. + """ + now = now or timezone.now() + if not survey.is_active: + return False + if survey.opens_at and now < survey.opens_at: + return False + if survey.closes_at and now >= survey.closes_at: + return False + return not (check_cap and survey_at_cap(survey)) + + +def _validate_survey_window(opens_at: datetime | None, closes_at: datetime | None) -> None: + if opens_at and closes_at and closes_at <= opens_at: + raise_validation(Code.Survey.CLOSES_BEFORE_OPENS, field="closes_at", status_code=400) + + def _apply_linked_event_update(updates: dict) -> dict: """Resolve linked_event_id → linked_event object in update dict. diff --git a/backend/community/_survey_schemas.py b/backend/community/_survey_schemas.py index 9d3f41c75..8243f17a7 100644 --- a/backend/community/_survey_schemas.py +++ b/backend/community/_survey_schemas.py @@ -45,6 +45,9 @@ class SurveyOut(BaseModel): visibility: str is_active: bool one_response_per_user: bool = False + opens_at: datetime | None = None + closes_at: datetime | None = None + max_responses: int | None = None linked_event_id: str | None = None created_by_id: str | None = None created_at: datetime @@ -61,6 +64,9 @@ class SurveyListOut(BaseModel): slug: str visibility: str is_active: bool + opens_at: datetime | None = None + closes_at: datetime | None = None + max_responses: int | None = None linked_event_id: str | None = None created_at: datetime response_count: int = 0 @@ -73,6 +79,9 @@ class SurveyIn(BaseModel): visibility: str = Field(default=SurveyVisibility.PUBLIC, max_length=FieldLimit.CHOICE) is_active: bool = True one_response_per_user: bool = False + opens_at: datetime | None = None + closes_at: datetime | None = None + max_responses: int | None = Field(default=None, ge=1) linked_event_id: str | None = None @@ -83,6 +92,9 @@ class SurveyPatchIn(BaseModel): visibility: str | None = Field(default=None, max_length=FieldLimit.CHOICE) is_active: bool | None = None one_response_per_user: bool | None = None + opens_at: datetime | None = None + closes_at: datetime | None = None + max_responses: int | None = Field(default=None, ge=1) linked_event_id: str | None = None diff --git a/backend/community/_surveys.py b/backend/community/_surveys.py index 055f0e687..be6eb7ee1 100644 --- a/backend/community/_surveys.py +++ b/backend/community/_surveys.py @@ -15,6 +15,7 @@ _apply_linked_event_update, _survey_out, _survey_question_out, + _validate_survey_window, ) from community._survey_schemas import ( SurveyIn, @@ -69,6 +70,9 @@ def list_surveys_admin(request): slug=s.slug, visibility=s.visibility, is_active=s.is_active, + opens_at=s.opens_at, + closes_at=s.closes_at, + max_responses=s.max_responses, linked_event_id=str(s.linked_event_id) if s.linked_event_id else None, created_at=s.created_at, response_count=s.responses.count(), @@ -100,6 +104,7 @@ def create_survey(request, payload: SurveyIn): raise_validation(Code.Perm.DENIED, status_code=403, action="manage_surveys") if Survey.objects.filter(slug=payload.slug).exists(): raise_validation(Code.Survey.SLUG_ALREADY_EXISTS, field="slug", status_code=400) + _validate_survey_window(payload.opens_at, payload.closes_at) linked_event = None if payload.linked_event_id: try: @@ -113,6 +118,9 @@ def create_survey(request, payload: SurveyIn): visibility=payload.visibility, is_active=payload.is_active, one_response_per_user=payload.one_response_per_user, + opens_at=payload.opens_at, + closes_at=payload.closes_at, + max_responses=payload.max_responses, linked_event=linked_event, created_by=request.auth, ) @@ -190,6 +198,9 @@ def update_survey(request, survey_id: UUID, payload: SurveyPatchIn): if "slug" in updates and updates["slug"] != survey.slug: if Survey.objects.filter(slug=updates["slug"]).exists(): raise_validation(Code.Survey.SLUG_ALREADY_EXISTS, field="slug", status_code=400) + _validate_survey_window( + updates.get("opens_at", survey.opens_at), updates.get("closes_at", survey.closes_at) + ) for key, value in updates.items(): setattr(survey, key, value) survey.save(update_fields=list(updates.keys())) diff --git a/backend/community/_surveys_public.py b/backend/community/_surveys_public.py index 7f2aac6b0..121b311a1 100644 --- a/backend/community/_surveys_public.py +++ b/backend/community/_surveys_public.py @@ -16,6 +16,7 @@ _response_out, _survey_out, _tally_question, + survey_is_open, ) from community._survey_schemas import ( FinalizePollIn, @@ -48,6 +49,12 @@ def _visible_survey_or_404(slug: str, auth_user) -> Survey: return survey +def _existing_response(survey: Survey, auth_user) -> SurveyResponse | None: + if not survey.one_response_per_user or auth_user is None: + return None + return SurveyResponse.objects.filter(survey=survey, user=auth_user).first() + + @router.get( "/surveys/view/{slug}/", response={200: SurveyOut, 404: ErrorOut}, @@ -74,26 +81,25 @@ def get_survey_public(request, slug: str): def submit_survey_response(request, slug: str, payload: SurveyAnswersIn): auth_user = _authenticated_user(request.auth) survey = _visible_survey_or_404(slug, auth_user) - if not survey.is_active: + existing = _existing_response(survey, auth_user) + if not survey_is_open(survey, check_cap=existing is None): raise_validation(Code.Survey.CLOSED, status_code=400) questions = {str(q.id): q for q in survey.questions.all()} _validate_survey_answers(payload.answers, questions) answers = _build_survey_answers(payload.answers, questions) user_name = visible_display_name(auth_user, auth_user) if auth_user else None - if survey.one_response_per_user and auth_user is not None: - existing = SurveyResponse.objects.filter(survey=survey, user=auth_user).first() - if existing: - existing.answers = answers - existing.save(update_fields=["answers"]) - audit_log( - logging.INFO, - "survey_response_updated", - request, - target=AuditTarget( - type=AuditTargetType.SURVEY, id=str(survey.id), details={"slug": slug} - ), - ) - return Status(200, _response_out(existing, user_name)) + if existing: + existing.answers = answers + existing.save(update_fields=["answers"]) + audit_log( + logging.INFO, + "survey_response_updated", + request, + target=AuditTarget( + type=AuditTargetType.SURVEY, id=str(survey.id), details={"slug": slug} + ), + ) + return Status(200, _response_out(existing, user_name)) response = SurveyResponse.objects.create(survey=survey, user=auth_user, answers=answers) audit_log( logging.INFO, diff --git a/backend/community/_validation.py b/backend/community/_validation.py index b7bb5864e..8d8f5fd8e 100644 --- a/backend/community/_validation.py +++ b/backend/community/_validation.py @@ -149,6 +149,7 @@ class Survey: NOT_FOUND = "survey.not_found" CLOSED = "survey.closed" SLUG_ALREADY_EXISTS = "survey.slug_already_exists" + CLOSES_BEFORE_OPENS = "survey.closes_before_opens" QUESTION_NOT_FOUND = "survey.question_not_found" NO_DATETIME_POLL_QUESTION = "survey.no_datetime_poll_question" WINNING_DATETIME_NOT_IN_OPTIONS = "survey.winning_datetime_not_in_options" diff --git a/backend/community/migrations/0096_survey_scheduling_and_cap.py b/backend/community/migrations/0096_survey_scheduling_and_cap.py new file mode 100644 index 000000000..e5b3eee42 --- /dev/null +++ b/backend/community/migrations/0096_survey_scheduling_and_cap.py @@ -0,0 +1,27 @@ +# Generated by Django 6.0.3 on 2026-09-13 21:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("community", "0095_event_is_legacy"), + ] + + operations = [ + migrations.AddField( + model_name="survey", + name="closes_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="survey", + name="max_responses", + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="survey", + name="opens_at", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/backend/community/models/survey.py b/backend/community/models/survey.py index 00844933d..cda9f892d 100644 --- a/backend/community/models/survey.py +++ b/backend/community/models/survey.py @@ -23,6 +23,9 @@ class Survey(models.Model): ) is_active = models.BooleanField(default=True) one_response_per_user = models.BooleanField(default=False) + opens_at = models.DateTimeField(null=True, blank=True) + closes_at = models.DateTimeField(null=True, blank=True) + max_responses = models.PositiveIntegerField(null=True, blank=True) linked_event = models.ForeignKey( "community.Event", null=True, diff --git a/backend/tests/test_surveys_public.py b/backend/tests/test_surveys_public.py index d55537a97..3334a7ef8 100644 --- a/backend/tests/test_surveys_public.py +++ b/backend/tests/test_surveys_public.py @@ -317,3 +317,161 @@ def test_submit_to_finalized_poll_rejected(self, api_client, finalized_poll_surv assert resp.status_code == 400 assert_error_code(resp, Code.Survey.CLOSED) assert not SurveyResponse.objects.filter(survey=finalized_poll_survey).exists() + +# --------------------------------------------------------------------------- +# Open/close scheduling + response cap (Issue 1465) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def surveys_admin_headers(db): + admin = User.objects.create_user( + phone_number="+12025557020", password="x", first_name="Surveys", last_name="Admin" + ) + role = Role.objects.create(name="surveys_admin", permissions=[PermissionKey.MANAGE_SURVEYS]) + admin.roles.add(role) + return {"HTTP_AUTHORIZATION": f"Bearer {RefreshToken.for_user(admin).access_token}"} # type: ignore + + +def _view_url(survey): + return f"/api/community/surveys/view/{survey.slug}/" + + +def _submit(api_client, survey, headers=None): + question = survey.questions.first() + return api_client.post( + f"{_view_url(survey)}respond/", + data=json.dumps({"answers": {str(question.id): "looks good"}}), + content_type="application/json", + **(headers or {}), + ) + + +@pytest.mark.django_db +class TestSurveyScheduling: + # A survey outside its window or at cap still resolves on GET, so the UI can + # tell a scheduled or capped survey apart from a closed one (Issue 1460). + def test_before_opens_at_rejects_submits(self, api_client, public_text_survey): + opens_at = timezone.now() + timedelta(hours=1) + public_text_survey.opens_at = opens_at + public_text_survey.save() + view = api_client.get(_view_url(public_text_survey)) + assert view.status_code == 200 + assert view.json()["opens_at"] is not None + resp = _submit(api_client, public_text_survey) + assert resp.status_code == 400 + assert_error_code(resp, Code.Survey.CLOSED) + + def test_after_closes_at_rejects_submits(self, api_client, public_text_survey): + public_text_survey.closes_at = timezone.now() - timedelta(minutes=1) + public_text_survey.save() + assert api_client.get(_view_url(public_text_survey)).status_code == 200 + resp = _submit(api_client, public_text_survey) + assert resp.status_code == 400 + assert_error_code(resp, Code.Survey.CLOSED) + + def test_inside_window_is_open(self, api_client, public_text_survey): + public_text_survey.opens_at = timezone.now() - timedelta(hours=1) + public_text_survey.closes_at = timezone.now() + timedelta(hours=1) + public_text_survey.save() + assert api_client.get(_view_url(public_text_survey)).status_code == 200 + assert _submit(api_client, public_text_survey).status_code == 201 + + def test_at_cap_rejects_submits(self, api_client, public_text_survey): + public_text_survey.max_responses = 1 + public_text_survey.save() + assert _submit(api_client, public_text_survey).status_code == 201 + resp = _submit(api_client, public_text_survey) + assert resp.status_code == 400 + assert_error_code(resp, Code.Survey.CLOSED) + view = api_client.get(_view_url(public_text_survey)) + assert view.status_code == 200 + assert view.json()["response_count"] == view.json()["max_responses"] + + def test_upsert_not_blocked_by_cap(self, api_client, auth_headers, public_text_survey): + public_text_survey.one_response_per_user = True + public_text_survey.max_responses = 1 + public_text_survey.save() + assert _submit(api_client, public_text_survey, auth_headers).status_code == 201 + assert _submit(api_client, public_text_survey, auth_headers).status_code == 200 + assert public_text_survey.responses.count() == 1 + assert api_client.get(_view_url(public_text_survey), **auth_headers).status_code == 200 + + def test_inactive_still_closed(self, api_client, public_text_survey): + public_text_survey.is_active = False + public_text_survey.save() + resp = _submit(api_client, public_text_survey) + assert resp.status_code == 400 + assert_error_code(resp, Code.Survey.CLOSED) + + +@pytest.mark.django_db +class TestSurveyScheduleAdmin: + url = "/api/community/surveys/" + + def _create(self, api_client, headers, **extra): + payload = {"title": "Scheduled", "slug": "scheduled", **extra} + return api_client.post( + self.url, data=json.dumps(payload), content_type="application/json", **headers + ) + + def test_create_with_schedule_and_cap(self, api_client, surveys_admin_headers): + resp = self._create( + api_client, + surveys_admin_headers, + opens_at=future_iso(days=1), + closes_at=future_iso(days=2), + max_responses=5, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["opens_at"] and body["closes_at"] + assert body["max_responses"] == 5 + listed = api_client.get(f"{self.url}admin/", **surveys_admin_headers).json() + assert listed[0]["max_responses"] == 5 + assert listed[0]["opens_at"] and listed[0]["closes_at"] + + def test_create_rejects_closes_before_opens(self, api_client, surveys_admin_headers): + resp = self._create( + api_client, + surveys_admin_headers, + opens_at=future_iso(days=2), + closes_at=future_iso(days=1), + ) + assert resp.status_code == 400 + assert_error_code(resp, Code.Survey.CLOSES_BEFORE_OPENS) + + def test_create_rejects_zero_cap(self, api_client, surveys_admin_headers): + assert self._create(api_client, surveys_admin_headers, max_responses=0).status_code == 422 + + def test_patch_validates_against_stored_opens_at(self, api_client, surveys_admin_headers): + survey = Survey.objects.create( + title="S", slug="s", opens_at=timezone.now() + timedelta(days=2) + ) + resp = api_client.patch( + f"{self.url}{survey.id}/", + data=json.dumps({"closes_at": future_iso(days=1)}), + content_type="application/json", + **surveys_admin_headers, + ) + assert resp.status_code == 400 + assert_error_code(resp, Code.Survey.CLOSES_BEFORE_OPENS) + + def test_patch_can_clear_fields(self, api_client, surveys_admin_headers): + survey = Survey.objects.create( + title="S", + slug="s", + opens_at=timezone.now(), + closes_at=timezone.now() + timedelta(days=1), + max_responses=3, + ) + resp = api_client.patch( + f"{self.url}{survey.id}/", + data=json.dumps({"opens_at": None, "closes_at": None, "max_responses": None}), + content_type="application/json", + **surveys_admin_headers, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["opens_at"] is None and body["closes_at"] is None + assert body["max_responses"] is None From e773f1ee53b6e90a73a302a3610bdbdbf2c7703d Mon Sep 17 00:00:00 2001 From: Leah Peker Date: Mon, 14 Sep 2026 01:23:15 +0300 Subject: [PATCH 2/6] feat(surveys): builder fields and status badge for scheduling/cap (Issue 1465) Adds opens_at/closes_at/max_responses to the create-survey dialog and a scheduled/active/closed status badge to the admin list, plus the regenerated OpenAPI types and validation-code catalog for the new survey.closes_before_opens code. --- backend/community/validation_codes.json | 5 + backend/openapi_schema.json | 142 ++++++++++++++++++ frontend/src/api/surveyAdmin.ts | 18 +++ frontend/src/api/types.gen.ts | 24 +++ frontend/src/api/validationCodes.gen.ts | 3 + frontend/src/api/validationCodes.ts | 2 + .../admin/SurveyAdminListScreen.test.tsx | 128 ++++++++++++++-- .../screens/admin/SurveyAdminListScreen.tsx | 27 +++- frontend/src/screens/admin/SurveyFields.tsx | 30 ++++ frontend/src/screens/admin/surveyStatus.ts | 16 ++ 10 files changed, 377 insertions(+), 18 deletions(-) create mode 100644 frontend/src/screens/admin/surveyStatus.ts diff --git a/backend/community/validation_codes.json b/backend/community/validation_codes.json index 0901a9592..be5f9a731 100644 --- a/backend/community/validation_codes.json +++ b/backend/community/validation_codes.json @@ -620,6 +620,11 @@ "value": "survey.slug_already_exists", "params": [] }, + { + "name": "CLOSES_BEFORE_OPENS", + "value": "survey.closes_before_opens", + "params": [] + }, { "name": "QUESTION_NOT_FOUND", "value": "survey.question_not_found", diff --git a/backend/openapi_schema.json b/backend/openapi_schema.json index fd5bce8c7..832356909 100644 --- a/backend/openapi_schema.json +++ b/backend/openapi_schema.json @@ -5908,6 +5908,18 @@ }, "SurveyIn": { "properties": { + "closes_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Closes At" + }, "description": { "default": "", "maxLength": 2000, @@ -5930,11 +5942,35 @@ ], "title": "Linked Event Id" }, + "max_responses": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Responses" + }, "one_response_per_user": { "default": false, "title": "One Response Per User", "type": "boolean" }, + "opens_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Opens At" + }, "slug": { "maxLength": 100, "title": "Slug", @@ -5961,6 +5997,18 @@ }, "SurveyListOut": { "properties": { + "closes_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Closes At" + }, "created_at": { "format": "date-time", "title": "Created At", @@ -5985,6 +6033,29 @@ ], "title": "Linked Event Id" }, + "max_responses": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Responses" + }, + "opens_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Opens At" + }, "response_count": { "default": 0, "title": "Response Count", @@ -6016,6 +6087,18 @@ }, "SurveyOut": { "properties": { + "closes_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Closes At" + }, "created_at": { "format": "date-time", "title": "Created At", @@ -6055,6 +6138,17 @@ ], "title": "Linked Event Id" }, + "max_responses": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Responses" + }, "my_answers": { "anyOf": [ { @@ -6083,6 +6177,18 @@ "title": "One Response Per User", "type": "boolean" }, + "opens_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Opens At" + }, "poll_result": { "anyOf": [ { @@ -6133,6 +6239,18 @@ }, "SurveyPatchIn": { "properties": { + "closes_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Closes At" + }, "description": { "anyOf": [ { @@ -6167,6 +6285,18 @@ ], "title": "Linked Event Id" }, + "max_responses": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Responses" + }, "one_response_per_user": { "anyOf": [ { @@ -6178,6 +6308,18 @@ ], "title": "One Response Per User" }, + "opens_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Opens At" + }, "slug": { "anyOf": [ { diff --git a/frontend/src/api/surveyAdmin.ts b/frontend/src/api/surveyAdmin.ts index 1c1077c86..3188beb32 100644 --- a/frontend/src/api/surveyAdmin.ts +++ b/frontend/src/api/surveyAdmin.ts @@ -9,6 +9,9 @@ export interface SurveySummary { slug: string; visibility: string; isActive: boolean; + opensAt: string | null; + closesAt: string | null; + maxResponses: number | null; linkedEventId: string | null; createdAt: string; responseCount: number; @@ -20,6 +23,9 @@ interface WireSummary { slug: string; visibility: string; is_active: boolean; + opens_at: string | null; + closes_at: string | null; + max_responses: number | null; linked_event_id: string | null; created_at: string; response_count: number; @@ -32,6 +38,9 @@ function mapSummary(w: WireSummary): SurveySummary { slug: w.slug, visibility: w.visibility, isActive: w.is_active, + opensAt: w.opens_at, + closesAt: w.closes_at, + maxResponses: w.max_responses, linkedEventId: w.linked_event_id, createdAt: w.created_at, responseCount: w.response_count, @@ -132,6 +141,9 @@ export interface SurveyInput { visibility: string; isActive: boolean; oneResponsePerUser: boolean; + opensAt: string | null; + closesAt: string | null; + maxResponses: number | null; linkedEventId: string | null; } @@ -146,6 +158,9 @@ export function useCreateSurvey() { visibility: input.visibility, is_active: input.isActive, one_response_per_user: input.oneResponsePerUser, + opens_at: input.opensAt, + closes_at: input.closesAt, + max_responses: input.maxResponses, linked_event_id: input.linkedEventId, }); return mapSurveyFull(data); @@ -168,6 +183,9 @@ export function useUpdateSurvey(surveyId: string) { if (patch.isActive !== undefined) body.is_active = patch.isActive; if (patch.oneResponsePerUser !== undefined) body.one_response_per_user = patch.oneResponsePerUser; + if (patch.opensAt !== undefined) body.opens_at = patch.opensAt; + if (patch.closesAt !== undefined) body.closes_at = patch.closesAt; + if (patch.maxResponses !== undefined) body.max_responses = patch.maxResponses; if (patch.linkedEventId !== undefined) body.linked_event_id = patch.linkedEventId; const { data } = await apiClient.patch( `/api/community/surveys/${surveyId}/`, diff --git a/frontend/src/api/types.gen.ts b/frontend/src/api/types.gen.ts index a3399bc92..74d656631 100644 --- a/frontend/src/api/types.gen.ts +++ b/frontend/src/api/types.gen.ts @@ -4817,6 +4817,8 @@ export interface components { }; /** SurveyIn */ SurveyIn: { + /** Closes At */ + closes_at?: string | null; /** * Description * @default @@ -4829,11 +4831,15 @@ export interface components { is_active: boolean; /** Linked Event Id */ linked_event_id?: string | null; + /** Max Responses */ + max_responses?: number | null; /** * One Response Per User * @default false */ one_response_per_user: boolean; + /** Opens At */ + opens_at?: string | null; /** Slug */ slug: string; /** Title */ @@ -4846,6 +4852,8 @@ export interface components { }; /** SurveyListOut */ SurveyListOut: { + /** Closes At */ + closes_at?: string | null; /** * Created At * Format: date-time @@ -4857,6 +4865,10 @@ export interface components { is_active: boolean; /** Linked Event Id */ linked_event_id?: string | null; + /** Max Responses */ + max_responses?: number | null; + /** Opens At */ + opens_at?: string | null; /** * Response Count * @default 0 @@ -4871,6 +4883,8 @@ export interface components { }; /** SurveyOut */ SurveyOut: { + /** Closes At */ + closes_at?: string | null; /** * Created At * Format: date-time @@ -4886,6 +4900,8 @@ export interface components { is_active: boolean; /** Linked Event Id */ linked_event_id?: string | null; + /** Max Responses */ + max_responses?: number | null; /** My Answers */ my_answers?: { [key: string]: unknown; @@ -4897,6 +4913,8 @@ export interface components { * @default false */ one_response_per_user: boolean; + /** Opens At */ + opens_at?: string | null; poll_result?: components["schemas"]["PollResultOut"] | null; /** * Questions @@ -4917,14 +4935,20 @@ export interface components { }; /** SurveyPatchIn */ SurveyPatchIn: { + /** Closes At */ + closes_at?: string | null; /** Description */ description?: string | null; /** Is Active */ is_active?: boolean | null; /** Linked Event Id */ linked_event_id?: string | null; + /** Max Responses */ + max_responses?: number | null; /** One Response Per User */ one_response_per_user?: boolean | null; + /** Opens At */ + opens_at?: string | null; /** Slug */ slug?: string | null; /** Title */ diff --git a/frontend/src/api/validationCodes.gen.ts b/frontend/src/api/validationCodes.gen.ts index d6b3e3b03..686c65e13 100644 --- a/frontend/src/api/validationCodes.gen.ts +++ b/frontend/src/api/validationCodes.gen.ts @@ -149,6 +149,7 @@ export const Code = { NotFound: 'survey.not_found', Closed: 'survey.closed', SlugAlreadyExists: 'survey.slug_already_exists', + ClosesBeforeOpens: 'survey.closes_before_opens', QuestionNotFound: 'survey.question_not_found', NoDatetimePollQuestion: 'survey.no_datetime_poll_question', WinningDatetimeNotInOptions: 'survey.winning_datetime_not_in_options', @@ -365,6 +366,7 @@ export type ValidationCode = | 'survey.not_found' | 'survey.closed' | 'survey.slug_already_exists' + | 'survey.closes_before_opens' | 'survey.question_not_found' | 'survey.no_datetime_poll_question' | 'survey.winning_datetime_not_in_options' @@ -545,6 +547,7 @@ export const CODE_PARAMS: Record = { 'survey.not_found': [], 'survey.closed': [], 'survey.slug_already_exists': [], + 'survey.closes_before_opens': [], 'survey.question_not_found': [], 'survey.no_datetime_poll_question': [], 'survey.winning_datetime_not_in_options': [], diff --git a/frontend/src/api/validationCodes.ts b/frontend/src/api/validationCodes.ts index 3babcd2fa..35d16c61f 100644 --- a/frontend/src/api/validationCodes.ts +++ b/frontend/src/api/validationCodes.ts @@ -334,6 +334,8 @@ function messageForKnownCode(code: KnownCode, err: FieldError): string { return 'this survey is closed — responses are no longer accepted'; case Code.Survey.SlugAlreadyExists: return 'a survey with that slug already exists'; + case Code.Survey.ClosesBeforeOpens: + return 'closes at must be after opens at'; case Code.Survey.QuestionNotFound: return 'question not found'; case Code.Survey.NoDatetimePollQuestion: diff --git a/frontend/src/screens/admin/SurveyAdminListScreen.test.tsx b/frontend/src/screens/admin/SurveyAdminListScreen.test.tsx index 768b98945..f57988e7f 100644 --- a/frontend/src/screens/admin/SurveyAdminListScreen.test.tsx +++ b/frontend/src/screens/admin/SurveyAdminListScreen.test.tsx @@ -8,34 +8,49 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { SurveySummary } from '@/api/surveyAdmin'; import SurveyAdminListScreen from './SurveyAdminListScreen'; +import { surveyStatus } from './surveyStatus'; const toastSuccess = vi.fn(); const createMutateAsync = vi.fn(); const navigate = vi.fn(); -const surveys: SurveySummary[] = [ - { +function makeSurvey(overrides: Partial = {}): SurveySummary { + return { id: 's1', - title: 'spring potluck', - slug: 'spring-potluck', + title: 'feedback', + slug: 'feedback', visibility: 'members_only', isActive: true, + opensAt: null, + closesAt: null, + maxResponses: null, linkedEventId: null, + createdAt: '2026-01-01T00:00:00.000Z', + responseCount: 0, + ...overrides, + }; +} + +const defaultSurveys: SurveySummary[] = [ + makeSurvey({ + id: 's1', + title: 'spring potluck', + slug: 'spring-potluck', createdAt: '2026-03-01T00:00:00Z', responseCount: 2, - }, - { + }), + makeSurvey({ id: 's2', title: 'summer picnic', slug: 'summer-picnic', visibility: 'public', isActive: false, - linkedEventId: null, createdAt: '2026-04-01T00:00:00Z', - responseCount: 0, - }, + }), ]; +let surveysMock: SurveySummary[] = defaultSurveys; + vi.mock('sonner', () => ({ toast: { success: (m: string) => { @@ -46,7 +61,7 @@ vi.mock('sonner', () => ({ })); vi.mock('@/api/surveyAdmin', () => ({ - useAdminSurveys: () => ({ data: surveys, isPending: false, isError: false }), + useAdminSurveys: () => ({ data: surveysMock, isPending: false, isError: false }), useCreateSurvey: () => ({ mutateAsync: createMutateAsync, isPending: false }), useDeleteSurvey: () => ({ mutate: vi.fn(), isPending: false }), })); @@ -70,7 +85,7 @@ function fieldError(code: string, field: string) { } function renderScreen() { - render( + return render( , @@ -87,6 +102,74 @@ async function fillCreateForm() { await userEvent.selectOptions(screen.getByLabelText('linked event'), 'evt-1'); } +beforeEach(() => { + surveysMock = defaultSurveys; +}); + +describe('surveyStatus', () => { + const now = new Date('2026-06-01T12:00:00.000Z'); + + it('is closed when inactive, regardless of window', () => { + expect(surveyStatus(makeSurvey({ isActive: false }), now)).toBe('closed'); + }); + + it('is scheduled when opens_at is in the future', () => { + const survey = makeSurvey({ opensAt: '2026-06-02T00:00:00.000Z' }); + expect(surveyStatus(survey, now)).toBe('scheduled'); + }); + + it('is closed once closes_at has passed', () => { + const survey = makeSurvey({ closesAt: '2026-05-31T00:00:00.000Z' }); + expect(surveyStatus(survey, now)).toBe('closed'); + }); + + it('is capped once the response cap is reached', () => { + const survey = makeSurvey({ maxResponses: 5, responseCount: 5 }); + expect(surveyStatus(survey, now)).toBe('capped'); + }); + + // A survey that is both scheduled and already at cap reads as scheduled — + // the cap only becomes the reason it's shut once the window has opened. + it('prefers scheduled over capped before the window opens', () => { + const survey = makeSurvey({ + opensAt: '2026-06-02T00:00:00.000Z', + maxResponses: 1, + responseCount: 1, + }); + expect(surveyStatus(survey, now)).toBe('scheduled'); + }); + + it('is active inside the window and under the cap', () => { + const survey = makeSurvey({ + opensAt: '2026-05-01T00:00:00.000Z', + closesAt: '2026-07-01T00:00:00.000Z', + maxResponses: 10, + responseCount: 3, + }); + expect(surveyStatus(survey, now)).toBe('active'); + }); +}); + +describe('SurveyAdminListScreen badges', () => { + it('shows a scheduled badge for a survey that has not opened yet', () => { + surveysMock = [makeSurvey({ opensAt: '2999-01-01T00:00:00.000Z' })]; + renderScreen(); + expect(screen.getByText('scheduled')).toBeInTheDocument(); + }); + + it('shows an at capacity badge for a survey at its response cap', () => { + surveysMock = [makeSurvey({ maxResponses: 2, responseCount: 2 })]; + renderScreen(); + expect(screen.getByText('at capacity')).toBeInTheDocument(); + }); + + it('shows an active badge for an open survey', () => { + surveysMock = [makeSurvey()]; + renderScreen(); + expect(screen.getByText('active')).toBeInTheDocument(); + }); +}); + describe('SurveyAdminListScreen', () => { beforeEach(() => { toastSuccess.mockReset(); @@ -118,6 +201,29 @@ describe('SurveyAdminListScreen create dialog', () => { navigate.mockReset(); }); + it('renders opens at, closes at, and max responses fields', async () => { + renderScreen(); + await openCreateDialog(); + expect(screen.getByText('opens at')).toBeInTheDocument(); + expect(screen.getByText('closes at')).toBeInTheDocument(); + expect(screen.getByLabelText('max responses')).toBeInTheDocument(); + }); + + it('submits max responses as a number, and null when left blank', async () => { + renderScreen(); + await openCreateDialog(); + await fillCreateForm(); + await userEvent.type(screen.getByLabelText('max responses'), '25'); + await userEvent.click(screen.getByRole('button', { name: 'create' })); + + await waitFor(() => { + expect(createMutateAsync).toHaveBeenCalled(); + }); + expect(createMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ maxResponses: 25, opensAt: null, closesAt: null }), + ); + }); + it('resets the form after a successful create', async () => { renderScreen(); await openCreateDialog(); diff --git a/frontend/src/screens/admin/SurveyAdminListScreen.tsx b/frontend/src/screens/admin/SurveyAdminListScreen.tsx index df559a0aa..fd02415eb 100644 --- a/frontend/src/screens/admin/SurveyAdminListScreen.tsx +++ b/frontend/src/screens/admin/SurveyAdminListScreen.tsx @@ -19,6 +19,7 @@ import { cn } from '@/utils/cn'; import { SurveyCopyLinkButton } from './SurveyCopyLinkButton'; import { SurveyFields } from './SurveyFields'; +import { type SurveyStatus, surveyStatus } from './surveyStatus'; import { useSurveyFieldErrors } from './useSurveyFieldErrors'; export default function SurveyAdminListScreen() { @@ -83,6 +84,20 @@ export default function SurveyAdminListScreen() { ); } +const STATUS_LABEL: Record = { + active: 'active', + scheduled: 'scheduled', + capped: 'at capacity', + closed: 'closed', +}; + +const STATUS_CLASS: Record = { + active: 'bg-success-subtle text-success', + scheduled: 'bg-warning-subtle text-warning', + capped: 'bg-warning-subtle text-warning', + closed: 'bg-surface-raised text-foreground-secondary', +}; + function SurveyRow({ survey, onDelete, @@ -106,14 +121,9 @@ function SurveyRow({
- {survey.isActive ? 'active' : 'closed'} + {STATUS_LABEL[surveyStatus(survey)]} (null); diff --git a/frontend/src/screens/admin/SurveyFields.tsx b/frontend/src/screens/admin/SurveyFields.tsx index a5175fc71..2bebb6a3b 100644 --- a/frontend/src/screens/admin/SurveyFields.tsx +++ b/frontend/src/screens/admin/SurveyFields.tsx @@ -2,6 +2,7 @@ import { format } from 'date-fns'; import { useEvents } from '@/api/events'; import type { SurveyInput } from '@/api/surveyAdmin'; +import { DateTimePicker } from '@/components/ui/DateTimePicker'; import { Select } from '@/components/ui/Select'; import { Textarea } from '@/components/ui/Textarea'; import { TextField } from '@/components/ui/TextField'; @@ -76,6 +77,35 @@ export function SurveyFields({ values, onChange, slugError, linkedEventError }: /> one response per user +
+ { + onChange({ opensAt }); + }} + optional + /> + { + onChange({ closesAt }); + }} + optional + /> +
+ { + const raw = e.target.value; + onChange({ maxResponses: raw === '' ? null : Number(raw) }); + }} + hint="optional — leave blank for unlimited" + /> ); } diff --git a/frontend/src/screens/admin/surveyStatus.ts b/frontend/src/screens/admin/surveyStatus.ts new file mode 100644 index 000000000..60089c995 --- /dev/null +++ b/frontend/src/screens/admin/surveyStatus.ts @@ -0,0 +1,16 @@ +import type { SurveySummary } from '@/api/surveyAdmin'; + +export type SurveyStatus = 'active' | 'scheduled' | 'capped' | 'closed'; + +type StatusInput = Pick< + SurveySummary, + 'isActive' | 'opensAt' | 'closesAt' | 'maxResponses' | 'responseCount' +>; + +export function surveyStatus(survey: StatusInput, now: Date = new Date()): SurveyStatus { + if (!survey.isActive) return 'closed'; + if (survey.closesAt && now >= new Date(survey.closesAt)) return 'closed'; + if (survey.opensAt && now < new Date(survey.opensAt)) return 'scheduled'; + if (survey.maxResponses !== null && survey.responseCount >= survey.maxResponses) return 'capped'; + return 'active'; +} From bf3c248da1f24b0d51f51812ede30368fa6d6c66 Mon Sep 17 00:00:00 2001 From: Leah Peker Date: Mon, 14 Sep 2026 17:23:12 +0300 Subject: [PATCH 3/6] fix(surveys): close the cap race and fix stale event survey links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - submit_survey_response now locks the Survey row with select_for_update() inside a transaction before re-checking survey_is_open()/max_responses, so concurrent submits at the cap boundary can't both land (same pattern as _apply_rsvp_in_transaction and the poll-option cap fix). Adds test_survey_capacity_race.py mirroring test_rsvp_capacity_race.py. - _event_out's survey_slugs/datetime_poll_slug filtered only on is_active, so a scheduled (opens_at in future), closed (closes_at passed), or at-cap survey with is_active=True still showed up as a clickable link on the event page even though survey_is_open() would 404 it. Added _open_event_surveys() using survey_is_open() as the single source of truth for both fields, with regression tests. Note for whoever merges after PR #1474: that PR renames survey_slugs to linked_surveys on this same line — the merge needs to carry both the renamed field and this window/cap filter. Left as-is / out of scope: - SurveyResponse has no DB unique constraint on (survey, user); the cap and one-per-user checks are app-layer only. A constraint would need a dedupe pass over existing rows first, so not done here. --- backend/community/_event_helpers.py | 28 +++++----- backend/community/_surveys_public.py | 55 ++++++++++-------- backend/tests/test_survey_capacity_race.py | 61 ++++++++++++++++++++ backend/tests/test_surveys_public.py | 65 ++++++++++++++++++++++ 4 files changed, 173 insertions(+), 36 deletions(-) create mode 100644 backend/tests/test_survey_capacity_race.py diff --git a/backend/community/_event_helpers.py b/backend/community/_event_helpers.py index 9255d438f..44e97aad3 100644 --- a/backend/community/_event_helpers.py +++ b/backend/community/_event_helpers.py @@ -26,6 +26,7 @@ ) from community._rsvp_payment import can_see_payment_details, payment_enforced_for_event from community._shared import _authenticated_user, _gated +from community._survey_helpers import survey_is_open from community._validation import Code, raise_validation from community.models import ( Event, @@ -35,6 +36,7 @@ FeatureFlag, PageVisibility, RSVPStatus, + Survey, SurveyQuestionType, flag_enabled, ) @@ -278,16 +280,16 @@ def _set_event_tags(event: Event, tag_ids: Iterable[str]) -> None: event.tags.set(tags) -def _get_datetime_poll_slug(event: Event) -> str | None: - poll_survey = ( - event.surveys.filter( - is_active=True, - questions__field_type=SurveyQuestionType.DATETIME_POLL, - ) - .values_list("slug", flat=True) - .first() - ) - return poll_survey +def _open_event_surveys(event: Event) -> list[Survey]: + """Surveys linked to this event that are currently accepting responses.""" + return [s for s in event.surveys.prefetch_related("questions").all() if survey_is_open(s)] + + +def _get_datetime_poll_slug(event: Event, open_surveys: list[Survey]) -> str | None: + for survey in open_surveys: + if any(q.field_type == SurveyQuestionType.DATETIME_POLL for q in survey.questions.all()): + return survey.slug + return None def _annotated_or(event: Event, attr: str, fallback): @@ -368,6 +370,7 @@ def _event_out(event: Event, requesting_user=None) -> EventOut: pending_invites_out = _pending_cohost_invites_out(event, auth_user, co_host_ids) my_pending_invite = get_my_pending_invite(event, auth_user) my_pending_invite_id = str(my_pending_invite.id) if my_pending_invite else None + open_surveys = _open_event_surveys(event) return EventOut( id=str(event.id), slug=event.slug, @@ -425,10 +428,9 @@ def _event_out(event: Event, requesting_user=None) -> EventOut: photo_url=media_path(event.photo), photo_updated_at=_iso_or_none(event.photo_updated_at), linked_surveys=[ - EventSurveyOut(id=str(s.id), title=s.title, slug=s.slug) - for s in event.surveys.filter(is_active=True) + EventSurveyOut(id=str(s.id), title=s.title, slug=s.slug) for s in open_surveys ], - datetime_poll_slug=_get_datetime_poll_slug(event), + datetime_poll_slug=_get_datetime_poll_slug(event, open_surveys), has_poll=hasattr(event, "poll"), invited_user_ids=[str(u.id) for u in invited], invited_user_names=[visible_display_name(u, auth_user) for u in invited], diff --git a/backend/community/_surveys_public.py b/backend/community/_surveys_public.py index 121b311a1..2dabe0422 100644 --- a/backend/community/_surveys_public.py +++ b/backend/community/_surveys_public.py @@ -6,6 +6,7 @@ from config.audit import AuditTarget, AuditTargetType, audit_log from config.auth import gated_jwt from config.ratelimit import auth_or_ip_key, rate_limit +from django.db import transaction from ninja import Router from ninja.responses import Status from users._helpers import visible_display_name @@ -38,10 +39,13 @@ router = Router() -def _visible_survey_or_404(slug: str, auth_user) -> Survey: +def _visible_survey_or_404(slug: str, auth_user, *, for_update: bool = False) -> Survey: # Closed surveys still resolve so the UI can render a closed state. + qs = Survey.objects.prefetch_related("questions") + if for_update: + qs = qs.select_for_update() try: - survey = Survey.objects.prefetch_related("questions").get(slug=slug) + survey = qs.get(slug=slug) except Survey.DoesNotExist: raise_validation(Code.Survey.NOT_FOUND, status_code=404) if survey.visibility == SurveyVisibility.MEMBERS_ONLY and auth_user is None: @@ -80,34 +84,39 @@ def get_survey_public(request, slug: str): @rate_limit(key_func=auth_or_ip_key, rate="20/h") def submit_survey_response(request, slug: str, payload: SurveyAnswersIn): auth_user = _authenticated_user(request.auth) - survey = _visible_survey_or_404(slug, auth_user) - existing = _existing_response(survey, auth_user) - if not survey_is_open(survey, check_cap=existing is None): - raise_validation(Code.Survey.CLOSED, status_code=400) - questions = {str(q.id): q for q in survey.questions.all()} - _validate_survey_answers(payload.answers, questions) - answers = _build_survey_answers(payload.answers, questions) - user_name = visible_display_name(auth_user, auth_user) if auth_user else None - if existing: - existing.answers = answers - existing.save(update_fields=["answers"]) + # select_for_update() serializes concurrent submits against the same survey row, + # so the max_responses check-then-create can't race past the cap (issue #1465). + with transaction.atomic(): + survey = _visible_survey_or_404(slug, auth_user, for_update=True) + existing = _existing_response(survey, auth_user) + if not survey_is_open(survey, check_cap=existing is None): + raise_validation(Code.Survey.CLOSED, status_code=400) + questions = {str(q.id): q for q in survey.questions.all()} + _validate_survey_answers(payload.answers, questions) + answers = _build_survey_answers(payload.answers, questions) + user_name = visible_display_name(auth_user, auth_user) if auth_user else None + if existing: + existing.answers = answers + existing.save(update_fields=["answers"]) + audit_log( + logging.INFO, + "survey_response_updated", + request, + target=AuditTarget( + type=AuditTargetType.SURVEY, id=str(survey.id), details={"slug": slug} + ), + ) + return Status(200, _response_out(existing, user_name)) + response = SurveyResponse.objects.create(survey=survey, user=auth_user, answers=answers) audit_log( logging.INFO, - "survey_response_updated", + "survey_response_submitted", request, target=AuditTarget( type=AuditTargetType.SURVEY, id=str(survey.id), details={"slug": slug} ), ) - return Status(200, _response_out(existing, user_name)) - response = SurveyResponse.objects.create(survey=survey, user=auth_user, answers=answers) - audit_log( - logging.INFO, - "survey_response_submitted", - request, - target=AuditTarget(type=AuditTargetType.SURVEY, id=str(survey.id), details={"slug": slug}), - ) - return Status(201, _response_out(response, user_name)) + return Status(201, _response_out(response, user_name)) @router.get( diff --git a/backend/tests/test_survey_capacity_race.py b/backend/tests/test_survey_capacity_race.py new file mode 100644 index 000000000..80a745663 --- /dev/null +++ b/backend/tests/test_survey_capacity_race.py @@ -0,0 +1,61 @@ +import threading + +import pytest +from community.models import Survey, SurveyQuestion, SurveyQuestionType, SurveyResponse +from django import db +from django.test import Client +from ninja_jwt.tokens import RefreshToken +from users.models import User + + +def _make_user(i): + return User.objects.create_user( + phone_number=f"+1415556{9200 + i}", + password="Testpass123!", + first_name=f"Racer{i}", + last_name="", + ) + + +def _jwt_headers(user): + refresh = RefreshToken.for_user(user) + return {"HTTP_AUTHORIZATION": f"Bearer {refresh.access_token}"} # type: ignore + + +@pytest.mark.django_db(transaction=True) +class TestSurveyCapacityRace: + def test_concurrent_submits_never_exceed_max_responses(self): + """N threads submit to a 1-response survey at once; select_for_update() on the + Survey row must serialize them so exactly 1 response is ever created (issue #1465).""" + survey = Survey.objects.create(title="Race Survey", slug="race-survey", max_responses=1) + question = SurveyQuestion.objects.create( + survey=survey, + label="Thoughts?", + field_type=SurveyQuestionType.TEXT, + ) + thread_count = 8 + users = [_make_user(i) for i in range(thread_count)] + results = [None] * thread_count + + def submit(i): + try: + client = Client() + resp = client.post( + f"/api/community/surveys/view/{survey.slug}/respond/", + {"answers": {str(question.id): f"answer {i}"}}, + content_type="application/json", + **_jwt_headers(users[i]), + ) + results[i] = resp.status_code + finally: + db.connections.close_all() + + threads = [threading.Thread(target=submit, args=(i,)) for i in range(thread_count)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert results.count(201) == 1, results + assert results.count(404) == thread_count - 1, results + assert SurveyResponse.objects.filter(survey=survey).count() == 1 diff --git a/backend/tests/test_surveys_public.py b/backend/tests/test_surveys_public.py index 3334a7ef8..69b27a433 100644 --- a/backend/tests/test_surveys_public.py +++ b/backend/tests/test_surveys_public.py @@ -4,9 +4,11 @@ from datetime import timedelta import pytest +from community._event_helpers import _event_out from community._validation import Code from community.models import ( DatetimePollResult, + Event, Survey, SurveyQuestion, SurveyQuestionType, @@ -318,6 +320,7 @@ def test_submit_to_finalized_poll_rejected(self, api_client, finalized_poll_surv assert_error_code(resp, Code.Survey.CLOSED) assert not SurveyResponse.objects.filter(survey=finalized_poll_survey).exists() + # --------------------------------------------------------------------------- # Open/close scheduling + response cap (Issue 1465) # --------------------------------------------------------------------------- @@ -475,3 +478,65 @@ def test_patch_can_clear_fields(self, api_client, surveys_admin_headers): body = resp.json() assert body["opens_at"] is None and body["closes_at"] is None assert body["max_responses"] is None + + +# --------------------------------------------------------------------------- +# Event survey links respect scheduling + cap, not just is_active (Issue 1465) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestEventSurveyLinkVisibility: + def _event(self): + return Event.objects.create( + title="Potluck", start_datetime=timezone.now() + timedelta(days=10) + ) + + def test_scheduled_survey_excluded_from_links(self): + event = self._event() + Survey.objects.create( + title="Feedback", + slug="scheduled-feedback", + linked_event=event, + opens_at=timezone.now() + timedelta(hours=1), + ) + assert _event_out(event).linked_surveys == [] + + def test_closed_survey_excluded_from_links(self): + event = self._event() + Survey.objects.create( + title="Feedback", + slug="closed-feedback", + linked_event=event, + closes_at=timezone.now() - timedelta(minutes=1), + ) + assert _event_out(event).linked_surveys == [] + + def test_at_cap_survey_excluded_from_links(self): + event = self._event() + survey = Survey.objects.create( + title="Feedback", slug="capped-feedback", linked_event=event, max_responses=1 + ) + SurveyResponse.objects.create(survey=survey) + assert _event_out(event).linked_surveys == [] + + def test_open_survey_included_in_links(self): + event = self._event() + Survey.objects.create(title="Feedback", slug="open-feedback", linked_event=event) + assert [s.slug for s in _event_out(event).linked_surveys] == ["open-feedback"] + + def test_scheduled_poll_excluded_from_datetime_poll_slug(self): + event = self._event() + survey = Survey.objects.create( + title="When?", + slug="when-poll", + linked_event=event, + opens_at=timezone.now() + timedelta(hours=1), + ) + SurveyQuestion.objects.create( + survey=survey, + label="Pick a time", + field_type=SurveyQuestionType.DATETIME_POLL, + options=[future_iso(days=10)], + ) + assert _event_out(event).datetime_poll_slug is None From 94a6af9483fdac194c45fb6bd051835194f8f083 Mon Sep 17 00:00:00 2001 From: Leah Peker Date: Mon, 14 Sep 2026 22:24:00 +0300 Subject: [PATCH 4/6] fix(surveys): render scheduled and capped as their own states A survey outside its window or at its response cap now resolves on GET and rejects submits with 400 survey.closed, matching the closed-state contract from Issue 1460 instead of 404-ing. surveyStatus moves to models/survey.ts so the public screen and the admin badge derive the same four states, and 'capped' splits out of 'closed' so each says why responses are shut. Co-Authored-By: Claude Opus 5 --- backend/tests/test_survey_capacity_race.py | 2 +- backend/tests/test_surveys_public.py | 223 ---------------- backend/tests/test_surveys_scheduling.py | 240 ++++++++++++++++++ frontend/src/api/surveyAdmin.ts | 8 + frontend/src/api/surveys.ts | 12 + frontend/src/models/survey.ts | 28 ++ .../admin/SurveyAdminListScreen.test.tsx | 14 +- .../screens/admin/SurveyAdminListScreen.tsx | 2 +- .../admin/SurveySettingsDialog.test.tsx | 7 + .../screens/admin/SurveySettingsDialog.tsx | 3 + frontend/src/screens/admin/surveyStatus.ts | 16 -- .../src/screens/surveys/SurveyScreen.test.tsx | 36 +++ frontend/src/screens/surveys/SurveyScreen.tsx | 23 +- 13 files changed, 363 insertions(+), 251 deletions(-) create mode 100644 backend/tests/test_surveys_scheduling.py create mode 100644 frontend/src/models/survey.ts delete mode 100644 frontend/src/screens/admin/surveyStatus.ts diff --git a/backend/tests/test_survey_capacity_race.py b/backend/tests/test_survey_capacity_race.py index 80a745663..6dab61d3d 100644 --- a/backend/tests/test_survey_capacity_race.py +++ b/backend/tests/test_survey_capacity_race.py @@ -57,5 +57,5 @@ def submit(i): t.join() assert results.count(201) == 1, results - assert results.count(404) == thread_count - 1, results + assert results.count(400) == thread_count - 1, results assert SurveyResponse.objects.filter(survey=survey).count() == 1 diff --git a/backend/tests/test_surveys_public.py b/backend/tests/test_surveys_public.py index 69b27a433..d55537a97 100644 --- a/backend/tests/test_surveys_public.py +++ b/backend/tests/test_surveys_public.py @@ -4,11 +4,9 @@ from datetime import timedelta import pytest -from community._event_helpers import _event_out from community._validation import Code from community.models import ( DatetimePollResult, - Event, Survey, SurveyQuestion, SurveyQuestionType, @@ -319,224 +317,3 @@ def test_submit_to_finalized_poll_rejected(self, api_client, finalized_poll_surv assert resp.status_code == 400 assert_error_code(resp, Code.Survey.CLOSED) assert not SurveyResponse.objects.filter(survey=finalized_poll_survey).exists() - - -# --------------------------------------------------------------------------- -# Open/close scheduling + response cap (Issue 1465) -# --------------------------------------------------------------------------- - - -@pytest.fixture -def surveys_admin_headers(db): - admin = User.objects.create_user( - phone_number="+12025557020", password="x", first_name="Surveys", last_name="Admin" - ) - role = Role.objects.create(name="surveys_admin", permissions=[PermissionKey.MANAGE_SURVEYS]) - admin.roles.add(role) - return {"HTTP_AUTHORIZATION": f"Bearer {RefreshToken.for_user(admin).access_token}"} # type: ignore - - -def _view_url(survey): - return f"/api/community/surveys/view/{survey.slug}/" - - -def _submit(api_client, survey, headers=None): - question = survey.questions.first() - return api_client.post( - f"{_view_url(survey)}respond/", - data=json.dumps({"answers": {str(question.id): "looks good"}}), - content_type="application/json", - **(headers or {}), - ) - - -@pytest.mark.django_db -class TestSurveyScheduling: - # A survey outside its window or at cap still resolves on GET, so the UI can - # tell a scheduled or capped survey apart from a closed one (Issue 1460). - def test_before_opens_at_rejects_submits(self, api_client, public_text_survey): - opens_at = timezone.now() + timedelta(hours=1) - public_text_survey.opens_at = opens_at - public_text_survey.save() - view = api_client.get(_view_url(public_text_survey)) - assert view.status_code == 200 - assert view.json()["opens_at"] is not None - resp = _submit(api_client, public_text_survey) - assert resp.status_code == 400 - assert_error_code(resp, Code.Survey.CLOSED) - - def test_after_closes_at_rejects_submits(self, api_client, public_text_survey): - public_text_survey.closes_at = timezone.now() - timedelta(minutes=1) - public_text_survey.save() - assert api_client.get(_view_url(public_text_survey)).status_code == 200 - resp = _submit(api_client, public_text_survey) - assert resp.status_code == 400 - assert_error_code(resp, Code.Survey.CLOSED) - - def test_inside_window_is_open(self, api_client, public_text_survey): - public_text_survey.opens_at = timezone.now() - timedelta(hours=1) - public_text_survey.closes_at = timezone.now() + timedelta(hours=1) - public_text_survey.save() - assert api_client.get(_view_url(public_text_survey)).status_code == 200 - assert _submit(api_client, public_text_survey).status_code == 201 - - def test_at_cap_rejects_submits(self, api_client, public_text_survey): - public_text_survey.max_responses = 1 - public_text_survey.save() - assert _submit(api_client, public_text_survey).status_code == 201 - resp = _submit(api_client, public_text_survey) - assert resp.status_code == 400 - assert_error_code(resp, Code.Survey.CLOSED) - view = api_client.get(_view_url(public_text_survey)) - assert view.status_code == 200 - assert view.json()["response_count"] == view.json()["max_responses"] - - def test_upsert_not_blocked_by_cap(self, api_client, auth_headers, public_text_survey): - public_text_survey.one_response_per_user = True - public_text_survey.max_responses = 1 - public_text_survey.save() - assert _submit(api_client, public_text_survey, auth_headers).status_code == 201 - assert _submit(api_client, public_text_survey, auth_headers).status_code == 200 - assert public_text_survey.responses.count() == 1 - assert api_client.get(_view_url(public_text_survey), **auth_headers).status_code == 200 - - def test_inactive_still_closed(self, api_client, public_text_survey): - public_text_survey.is_active = False - public_text_survey.save() - resp = _submit(api_client, public_text_survey) - assert resp.status_code == 400 - assert_error_code(resp, Code.Survey.CLOSED) - - -@pytest.mark.django_db -class TestSurveyScheduleAdmin: - url = "/api/community/surveys/" - - def _create(self, api_client, headers, **extra): - payload = {"title": "Scheduled", "slug": "scheduled", **extra} - return api_client.post( - self.url, data=json.dumps(payload), content_type="application/json", **headers - ) - - def test_create_with_schedule_and_cap(self, api_client, surveys_admin_headers): - resp = self._create( - api_client, - surveys_admin_headers, - opens_at=future_iso(days=1), - closes_at=future_iso(days=2), - max_responses=5, - ) - assert resp.status_code == 201 - body = resp.json() - assert body["opens_at"] and body["closes_at"] - assert body["max_responses"] == 5 - listed = api_client.get(f"{self.url}admin/", **surveys_admin_headers).json() - assert listed[0]["max_responses"] == 5 - assert listed[0]["opens_at"] and listed[0]["closes_at"] - - def test_create_rejects_closes_before_opens(self, api_client, surveys_admin_headers): - resp = self._create( - api_client, - surveys_admin_headers, - opens_at=future_iso(days=2), - closes_at=future_iso(days=1), - ) - assert resp.status_code == 400 - assert_error_code(resp, Code.Survey.CLOSES_BEFORE_OPENS) - - def test_create_rejects_zero_cap(self, api_client, surveys_admin_headers): - assert self._create(api_client, surveys_admin_headers, max_responses=0).status_code == 422 - - def test_patch_validates_against_stored_opens_at(self, api_client, surveys_admin_headers): - survey = Survey.objects.create( - title="S", slug="s", opens_at=timezone.now() + timedelta(days=2) - ) - resp = api_client.patch( - f"{self.url}{survey.id}/", - data=json.dumps({"closes_at": future_iso(days=1)}), - content_type="application/json", - **surveys_admin_headers, - ) - assert resp.status_code == 400 - assert_error_code(resp, Code.Survey.CLOSES_BEFORE_OPENS) - - def test_patch_can_clear_fields(self, api_client, surveys_admin_headers): - survey = Survey.objects.create( - title="S", - slug="s", - opens_at=timezone.now(), - closes_at=timezone.now() + timedelta(days=1), - max_responses=3, - ) - resp = api_client.patch( - f"{self.url}{survey.id}/", - data=json.dumps({"opens_at": None, "closes_at": None, "max_responses": None}), - content_type="application/json", - **surveys_admin_headers, - ) - assert resp.status_code == 200 - body = resp.json() - assert body["opens_at"] is None and body["closes_at"] is None - assert body["max_responses"] is None - - -# --------------------------------------------------------------------------- -# Event survey links respect scheduling + cap, not just is_active (Issue 1465) -# --------------------------------------------------------------------------- - - -@pytest.mark.django_db -class TestEventSurveyLinkVisibility: - def _event(self): - return Event.objects.create( - title="Potluck", start_datetime=timezone.now() + timedelta(days=10) - ) - - def test_scheduled_survey_excluded_from_links(self): - event = self._event() - Survey.objects.create( - title="Feedback", - slug="scheduled-feedback", - linked_event=event, - opens_at=timezone.now() + timedelta(hours=1), - ) - assert _event_out(event).linked_surveys == [] - - def test_closed_survey_excluded_from_links(self): - event = self._event() - Survey.objects.create( - title="Feedback", - slug="closed-feedback", - linked_event=event, - closes_at=timezone.now() - timedelta(minutes=1), - ) - assert _event_out(event).linked_surveys == [] - - def test_at_cap_survey_excluded_from_links(self): - event = self._event() - survey = Survey.objects.create( - title="Feedback", slug="capped-feedback", linked_event=event, max_responses=1 - ) - SurveyResponse.objects.create(survey=survey) - assert _event_out(event).linked_surveys == [] - - def test_open_survey_included_in_links(self): - event = self._event() - Survey.objects.create(title="Feedback", slug="open-feedback", linked_event=event) - assert [s.slug for s in _event_out(event).linked_surveys] == ["open-feedback"] - - def test_scheduled_poll_excluded_from_datetime_poll_slug(self): - event = self._event() - survey = Survey.objects.create( - title="When?", - slug="when-poll", - linked_event=event, - opens_at=timezone.now() + timedelta(hours=1), - ) - SurveyQuestion.objects.create( - survey=survey, - label="Pick a time", - field_type=SurveyQuestionType.DATETIME_POLL, - options=[future_iso(days=10)], - ) - assert _event_out(event).datetime_poll_slug is None diff --git a/backend/tests/test_surveys_scheduling.py b/backend/tests/test_surveys_scheduling.py new file mode 100644 index 000000000..f2dc3f8f3 --- /dev/null +++ b/backend/tests/test_surveys_scheduling.py @@ -0,0 +1,240 @@ +import json +from datetime import timedelta + +import pytest +from community._event_helpers import _event_out +from community._validation import Code +from community.models import Event, Survey, SurveyQuestion, SurveyQuestionType, SurveyResponse +from django.utils import timezone +from ninja_jwt.tokens import RefreshToken +from users.models import User +from users.permissions import PermissionKey +from users.roles import Role + +from tests._asserts import assert_error_code +from tests.conftest import future_iso + + +@pytest.fixture +def public_text_survey(db): + survey = Survey.objects.create(title="Feedback", slug="feedback", visibility="public") + SurveyQuestion.objects.create( + survey=survey, label="Thoughts?", field_type=SurveyQuestionType.TEXT + ) + return survey + + +@pytest.fixture +def surveys_admin_headers(db): + admin = User.objects.create_user( + phone_number="+12025557020", password="x", first_name="Surveys", last_name="Admin" + ) + role = Role.objects.create(name="surveys_admin", permissions=[PermissionKey.MANAGE_SURVEYS]) + admin.roles.add(role) + return {"HTTP_AUTHORIZATION": f"Bearer {RefreshToken.for_user(admin).access_token}"} # type: ignore + + +def _view_url(survey): + return f"/api/community/surveys/view/{survey.slug}/" + + +def _submit(api_client, survey, headers=None): + question = survey.questions.first() + return api_client.post( + f"{_view_url(survey)}respond/", + data=json.dumps({"answers": {str(question.id): "looks good"}}), + content_type="application/json", + **(headers or {}), + ) + + +@pytest.mark.django_db +class TestSurveyScheduling: + # A survey outside its window or at cap still resolves on GET, so the UI can + # tell a scheduled or capped survey apart from a closed one (Issue 1460). + def test_before_opens_at_rejects_submits(self, api_client, public_text_survey): + opens_at = timezone.now() + timedelta(hours=1) + public_text_survey.opens_at = opens_at + public_text_survey.save() + view = api_client.get(_view_url(public_text_survey)) + assert view.status_code == 200 + assert view.json()["opens_at"] is not None + resp = _submit(api_client, public_text_survey) + assert resp.status_code == 400 + assert_error_code(resp, Code.Survey.CLOSED) + + def test_after_closes_at_rejects_submits(self, api_client, public_text_survey): + public_text_survey.closes_at = timezone.now() - timedelta(minutes=1) + public_text_survey.save() + assert api_client.get(_view_url(public_text_survey)).status_code == 200 + resp = _submit(api_client, public_text_survey) + assert resp.status_code == 400 + assert_error_code(resp, Code.Survey.CLOSED) + + def test_inside_window_is_open(self, api_client, public_text_survey): + public_text_survey.opens_at = timezone.now() - timedelta(hours=1) + public_text_survey.closes_at = timezone.now() + timedelta(hours=1) + public_text_survey.save() + assert api_client.get(_view_url(public_text_survey)).status_code == 200 + assert _submit(api_client, public_text_survey).status_code == 201 + + def test_at_cap_rejects_submits(self, api_client, public_text_survey): + public_text_survey.max_responses = 1 + public_text_survey.save() + assert _submit(api_client, public_text_survey).status_code == 201 + resp = _submit(api_client, public_text_survey) + assert resp.status_code == 400 + assert_error_code(resp, Code.Survey.CLOSED) + view = api_client.get(_view_url(public_text_survey)) + assert view.status_code == 200 + assert view.json()["response_count"] == view.json()["max_responses"] + + def test_upsert_not_blocked_by_cap(self, api_client, auth_headers, public_text_survey): + public_text_survey.one_response_per_user = True + public_text_survey.max_responses = 1 + public_text_survey.save() + assert _submit(api_client, public_text_survey, auth_headers).status_code == 201 + assert _submit(api_client, public_text_survey, auth_headers).status_code == 200 + assert public_text_survey.responses.count() == 1 + assert api_client.get(_view_url(public_text_survey), **auth_headers).status_code == 200 + + def test_inactive_still_closed(self, api_client, public_text_survey): + public_text_survey.is_active = False + public_text_survey.save() + resp = _submit(api_client, public_text_survey) + assert resp.status_code == 400 + assert_error_code(resp, Code.Survey.CLOSED) + + +@pytest.mark.django_db +class TestSurveyScheduleAdmin: + url = "/api/community/surveys/" + + def _create(self, api_client, headers, **extra): + payload = {"title": "Scheduled", "slug": "scheduled", **extra} + return api_client.post( + self.url, data=json.dumps(payload), content_type="application/json", **headers + ) + + def test_create_with_schedule_and_cap(self, api_client, surveys_admin_headers): + resp = self._create( + api_client, + surveys_admin_headers, + opens_at=future_iso(days=1), + closes_at=future_iso(days=2), + max_responses=5, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["opens_at"] and body["closes_at"] + assert body["max_responses"] == 5 + listed = api_client.get(f"{self.url}admin/", **surveys_admin_headers).json() + assert listed[0]["max_responses"] == 5 + assert listed[0]["opens_at"] and listed[0]["closes_at"] + + def test_create_rejects_closes_before_opens(self, api_client, surveys_admin_headers): + resp = self._create( + api_client, + surveys_admin_headers, + opens_at=future_iso(days=2), + closes_at=future_iso(days=1), + ) + assert resp.status_code == 400 + assert_error_code(resp, Code.Survey.CLOSES_BEFORE_OPENS) + + def test_create_rejects_zero_cap(self, api_client, surveys_admin_headers): + assert self._create(api_client, surveys_admin_headers, max_responses=0).status_code == 422 + + def test_patch_validates_against_stored_opens_at(self, api_client, surveys_admin_headers): + survey = Survey.objects.create( + title="S", slug="s", opens_at=timezone.now() + timedelta(days=2) + ) + resp = api_client.patch( + f"{self.url}{survey.id}/", + data=json.dumps({"closes_at": future_iso(days=1)}), + content_type="application/json", + **surveys_admin_headers, + ) + assert resp.status_code == 400 + assert_error_code(resp, Code.Survey.CLOSES_BEFORE_OPENS) + + def test_patch_can_clear_fields(self, api_client, surveys_admin_headers): + survey = Survey.objects.create( + title="S", + slug="s", + opens_at=timezone.now(), + closes_at=timezone.now() + timedelta(days=1), + max_responses=3, + ) + resp = api_client.patch( + f"{self.url}{survey.id}/", + data=json.dumps({"opens_at": None, "closes_at": None, "max_responses": None}), + content_type="application/json", + **surveys_admin_headers, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["opens_at"] is None and body["closes_at"] is None + assert body["max_responses"] is None + + +# --------------------------------------------------------------------------- +# Event survey links respect scheduling + cap, not just is_active (Issue 1465) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestEventSurveyLinkVisibility: + def _event(self): + return Event.objects.create( + title="Potluck", start_datetime=timezone.now() + timedelta(days=10) + ) + + def test_scheduled_survey_excluded_from_links(self): + event = self._event() + Survey.objects.create( + title="Feedback", + slug="scheduled-feedback", + linked_event=event, + opens_at=timezone.now() + timedelta(hours=1), + ) + assert _event_out(event).linked_surveys == [] + + def test_closed_survey_excluded_from_links(self): + event = self._event() + Survey.objects.create( + title="Feedback", + slug="closed-feedback", + linked_event=event, + closes_at=timezone.now() - timedelta(minutes=1), + ) + assert _event_out(event).linked_surveys == [] + + def test_at_cap_survey_excluded_from_links(self): + event = self._event() + survey = Survey.objects.create( + title="Feedback", slug="capped-feedback", linked_event=event, max_responses=1 + ) + SurveyResponse.objects.create(survey=survey) + assert _event_out(event).linked_surveys == [] + + def test_open_survey_included_in_links(self): + event = self._event() + Survey.objects.create(title="Feedback", slug="open-feedback", linked_event=event) + assert [s.slug for s in _event_out(event).linked_surveys] == ["open-feedback"] + + def test_scheduled_poll_excluded_from_datetime_poll_slug(self): + event = self._event() + survey = Survey.objects.create( + title="When?", + slug="when-poll", + linked_event=event, + opens_at=timezone.now() + timedelta(hours=1), + ) + SurveyQuestion.objects.create( + survey=survey, + label="Pick a time", + field_type=SurveyQuestionType.DATETIME_POLL, + options=[future_iso(days=10)], + ) + assert _event_out(event).datetime_poll_slug is None diff --git a/frontend/src/api/surveyAdmin.ts b/frontend/src/api/surveyAdmin.ts index 3188beb32..6812b26fb 100644 --- a/frontend/src/api/surveyAdmin.ts +++ b/frontend/src/api/surveyAdmin.ts @@ -69,6 +69,10 @@ interface WireSurveyFull { visibility: string; is_active: boolean; one_response_per_user?: boolean; + opens_at?: string | null; + closes_at?: string | null; + max_responses?: number | null; + response_count?: number; linked_event_id?: string | null; questions?: { id: string; @@ -97,6 +101,10 @@ function mapSurveyFull(w: WireSurveyFull): PublicSurvey { visibility: w.visibility, isActive: w.is_active, oneResponsePerUser: w.one_response_per_user ?? false, + opensAt: w.opens_at ?? null, + closesAt: w.closes_at ?? null, + maxResponses: w.max_responses ?? null, + responseCount: w.response_count ?? 0, linkedEventId: w.linked_event_id ?? null, questions: (w.questions ?? []) .map((q) => ({ diff --git a/frontend/src/api/surveys.ts b/frontend/src/api/surveys.ts index 36215f369..3c4181824 100644 --- a/frontend/src/api/surveys.ts +++ b/frontend/src/api/surveys.ts @@ -24,6 +24,10 @@ export interface Survey { visibility: string; isActive: boolean; oneResponsePerUser: boolean; + opensAt: string | null; + closesAt: string | null; + maxResponses: number | null; + responseCount: number; linkedEventId: string | null; questions: SurveyQuestion[]; myResponseId: string | null; @@ -53,6 +57,10 @@ interface WireSurvey { visibility: string; is_active: boolean; one_response_per_user?: boolean; + opens_at?: string | null; + closes_at?: string | null; + max_responses?: number | null; + response_count?: number; linked_event_id?: string | null; questions?: WireQuestion[]; my_response_id?: string | null; @@ -82,6 +90,10 @@ function mapSurvey(w: WireSurvey): Survey { visibility: w.visibility, isActive: w.is_active, oneResponsePerUser: w.one_response_per_user ?? false, + opensAt: w.opens_at ?? null, + closesAt: w.closes_at ?? null, + maxResponses: w.max_responses ?? null, + responseCount: w.response_count ?? 0, linkedEventId: w.linked_event_id ?? null, questions: (w.questions ?? []) .map((q) => ({ diff --git a/frontend/src/models/survey.ts b/frontend/src/models/survey.ts new file mode 100644 index 000000000..3366be0b2 --- /dev/null +++ b/frontend/src/models/survey.ts @@ -0,0 +1,28 @@ +export type SurveyStatus = 'active' | 'scheduled' | 'capped' | 'closed'; + +export interface SurveyScheduleFields { + isActive: boolean; + opensAt: string | null; + closesAt: string | null; + maxResponses: number | null; + responseCount: number; +} + +interface Options { + now?: Date; + /** Mirrors the backend's check_cap — a user editing their own response is exempt. */ + checkCap?: boolean; +} + +export function surveyStatus( + survey: SurveyScheduleFields, + { now = new Date(), checkCap = true }: Options = {}, +): SurveyStatus { + if (!survey.isActive) return 'closed'; + if (survey.closesAt && now >= new Date(survey.closesAt)) return 'closed'; + if (survey.opensAt && now < new Date(survey.opensAt)) return 'scheduled'; + if (checkCap && survey.maxResponses !== null && survey.responseCount >= survey.maxResponses) { + return 'capped'; + } + return 'active'; +} diff --git a/frontend/src/screens/admin/SurveyAdminListScreen.test.tsx b/frontend/src/screens/admin/SurveyAdminListScreen.test.tsx index f57988e7f..a263c421c 100644 --- a/frontend/src/screens/admin/SurveyAdminListScreen.test.tsx +++ b/frontend/src/screens/admin/SurveyAdminListScreen.test.tsx @@ -6,9 +6,9 @@ import { MemoryRouter } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { SurveySummary } from '@/api/surveyAdmin'; +import { surveyStatus } from '@/models/survey'; import SurveyAdminListScreen from './SurveyAdminListScreen'; -import { surveyStatus } from './surveyStatus'; const toastSuccess = vi.fn(); const createMutateAsync = vi.fn(); @@ -110,22 +110,22 @@ describe('surveyStatus', () => { const now = new Date('2026-06-01T12:00:00.000Z'); it('is closed when inactive, regardless of window', () => { - expect(surveyStatus(makeSurvey({ isActive: false }), now)).toBe('closed'); + expect(surveyStatus(makeSurvey({ isActive: false }), { now })).toBe('closed'); }); it('is scheduled when opens_at is in the future', () => { const survey = makeSurvey({ opensAt: '2026-06-02T00:00:00.000Z' }); - expect(surveyStatus(survey, now)).toBe('scheduled'); + expect(surveyStatus(survey, { now })).toBe('scheduled'); }); it('is closed once closes_at has passed', () => { const survey = makeSurvey({ closesAt: '2026-05-31T00:00:00.000Z' }); - expect(surveyStatus(survey, now)).toBe('closed'); + expect(surveyStatus(survey, { now })).toBe('closed'); }); it('is capped once the response cap is reached', () => { const survey = makeSurvey({ maxResponses: 5, responseCount: 5 }); - expect(surveyStatus(survey, now)).toBe('capped'); + expect(surveyStatus(survey, { now })).toBe('capped'); }); // A survey that is both scheduled and already at cap reads as scheduled — @@ -136,7 +136,7 @@ describe('surveyStatus', () => { maxResponses: 1, responseCount: 1, }); - expect(surveyStatus(survey, now)).toBe('scheduled'); + expect(surveyStatus(survey, { now })).toBe('scheduled'); }); it('is active inside the window and under the cap', () => { @@ -146,7 +146,7 @@ describe('surveyStatus', () => { maxResponses: 10, responseCount: 3, }); - expect(surveyStatus(survey, now)).toBe('active'); + expect(surveyStatus(survey, { now })).toBe('active'); }); }); diff --git a/frontend/src/screens/admin/SurveyAdminListScreen.tsx b/frontend/src/screens/admin/SurveyAdminListScreen.tsx index fd02415eb..bac64ed2d 100644 --- a/frontend/src/screens/admin/SurveyAdminListScreen.tsx +++ b/frontend/src/screens/admin/SurveyAdminListScreen.tsx @@ -14,12 +14,12 @@ import { import { Button } from '@/components/ui/Button'; import { Dialog } from '@/components/ui/Dialog'; import { useConfirm } from '@/components/ui/useConfirm'; +import { type SurveyStatus, surveyStatus } from '@/models/survey'; import { ContentContainer, ContentError, ContentLoading } from '@/screens/public/ContentContainer'; import { cn } from '@/utils/cn'; import { SurveyCopyLinkButton } from './SurveyCopyLinkButton'; import { SurveyFields } from './SurveyFields'; -import { type SurveyStatus, surveyStatus } from './surveyStatus'; import { useSurveyFieldErrors } from './useSurveyFieldErrors'; export default function SurveyAdminListScreen() { diff --git a/frontend/src/screens/admin/SurveySettingsDialog.test.tsx b/frontend/src/screens/admin/SurveySettingsDialog.test.tsx index 90b21ac42..fcd7aae04 100644 --- a/frontend/src/screens/admin/SurveySettingsDialog.test.tsx +++ b/frontend/src/screens/admin/SurveySettingsDialog.test.tsx @@ -27,6 +27,10 @@ const survey: Survey = { visibility: 'members_only', isActive: true, oneResponsePerUser: false, + opensAt: null, + closesAt: null, + maxResponses: null, + responseCount: 0, linkedEventId: null, questions: [], myResponseId: null, @@ -74,6 +78,9 @@ describe('SurveySettingsDialog', () => { slug: 'old-slug', visibility: 'public', oneResponsePerUser: true, + opensAt: null, + closesAt: null, + maxResponses: null, linkedEventId: 'evt-1', }); }); diff --git a/frontend/src/screens/admin/SurveySettingsDialog.tsx b/frontend/src/screens/admin/SurveySettingsDialog.tsx index 9ed9a2d60..ca10e489f 100644 --- a/frontend/src/screens/admin/SurveySettingsDialog.tsx +++ b/frontend/src/screens/admin/SurveySettingsDialog.tsx @@ -28,6 +28,9 @@ function SurveySettingsDialogBody({ survey, open, onClose }: Props) { slug: survey.slug, visibility: survey.visibility, oneResponsePerUser: survey.oneResponsePerUser, + opensAt: survey.opensAt, + closesAt: survey.closesAt, + maxResponses: survey.maxResponses, linkedEventId: survey.linkedEventId, }); const [error, setError] = useState(null); diff --git a/frontend/src/screens/admin/surveyStatus.ts b/frontend/src/screens/admin/surveyStatus.ts deleted file mode 100644 index 60089c995..000000000 --- a/frontend/src/screens/admin/surveyStatus.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { SurveySummary } from '@/api/surveyAdmin'; - -export type SurveyStatus = 'active' | 'scheduled' | 'capped' | 'closed'; - -type StatusInput = Pick< - SurveySummary, - 'isActive' | 'opensAt' | 'closesAt' | 'maxResponses' | 'responseCount' ->; - -export function surveyStatus(survey: StatusInput, now: Date = new Date()): SurveyStatus { - if (!survey.isActive) return 'closed'; - if (survey.closesAt && now >= new Date(survey.closesAt)) return 'closed'; - if (survey.opensAt && now < new Date(survey.opensAt)) return 'scheduled'; - if (survey.maxResponses !== null && survey.responseCount >= survey.maxResponses) return 'capped'; - return 'active'; -} diff --git a/frontend/src/screens/surveys/SurveyScreen.test.tsx b/frontend/src/screens/surveys/SurveyScreen.test.tsx index 11fea8047..96651d21f 100644 --- a/frontend/src/screens/surveys/SurveyScreen.test.tsx +++ b/frontend/src/screens/surveys/SurveyScreen.test.tsx @@ -113,6 +113,42 @@ describe('SurveyScreen', () => { expect(screen.queryByLabelText('thoughts')).not.toBeInTheDocument(); }); + it('renders a scheduled state for a survey that has not opened yet', async () => { + mockedGet.mockResolvedValueOnce({ + data: { ...baseWireSurvey, opens_at: '2999-01-01T00:00:00Z' }, + }); + renderScreen(); + + expect(await screen.findByRole('status')).toHaveTextContent("this survey isn't open yet"); + expect(screen.queryByRole('button', { name: /submit/ })).not.toBeInTheDocument(); + }); + + it('renders a capped state for a survey at its response limit', async () => { + mockedGet.mockResolvedValueOnce({ + data: { ...baseWireSurvey, max_responses: 2, response_count: 2 }, + }); + renderScreen(); + + expect(await screen.findByRole('status')).toHaveTextContent('reached its response limit'); + expect(screen.queryByRole('button', { name: /submit/ })).not.toBeInTheDocument(); + }); + + it('still lets a user past the cap edit their own response', async () => { + mockedGet.mockResolvedValueOnce({ + data: { + ...baseWireSurvey, + max_responses: 2, + response_count: 2, + my_response_id: 'resp-1', + my_answers: { q1: { label: 'thoughts', answer: 'previous answer' } }, + }, + }); + renderScreen(); + + expect(await screen.findByRole('button', { name: 'update response' })).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + it('renders the form with a submit button for an active survey', async () => { mockedGet.mockResolvedValueOnce({ data: baseWireSurvey }); renderScreen(); diff --git a/frontend/src/screens/surveys/SurveyScreen.tsx b/frontend/src/screens/surveys/SurveyScreen.tsx index bb081c49a..3d8193b18 100644 --- a/frontend/src/screens/surveys/SurveyScreen.tsx +++ b/frontend/src/screens/surveys/SurveyScreen.tsx @@ -8,6 +8,7 @@ import { type AnswerValue, type Survey, useSubmitSurvey, useSurvey } from '@/api import { Code } from '@/api/validationCodes'; import { QuestionField } from '@/components/questions/QuestionField'; import { Button } from '@/components/ui/Button'; +import { type SurveyStatus, surveyStatus } from '@/models/survey'; import { ContentContainer, ContentError, ContentLoading } from '@/screens/public/ContentContainer'; export default function SurveyScreen() { @@ -15,11 +16,27 @@ export default function SurveyScreen() { const { data: survey, isPending, isError } = useSurvey(slug); if (isPending) return ; if (isError) return ; - if (!survey.isActive && survey.pollResult === null) return ; + const status = surveyStatus(survey, { checkCap: survey.myResponseId === null }); + // A finalized poll keeps rendering its result rather than a shut-door message. + if (status !== 'active' && survey.pollResult === null) { + return ; + } return ; } -function SurveyClosed({ survey }: { survey: Survey }) { +const UNAVAILABLE_MESSAGE: Record, string> = { + scheduled: "this survey isn't open yet — check back when it opens", + capped: 'this survey has reached its response limit', + closed: 'this survey is closed — responses are no longer accepted', +}; + +function SurveyUnavailable({ + survey, + status, +}: { + survey: Survey; + status: Exclude; +}) { return (

{survey.title}

@@ -30,7 +47,7 @@ function SurveyClosed({ survey }: { survey: Survey }) { role="status" className="bg-surface-dim text-foreground-secondary rounded-md px-3 py-2 text-sm" > - this survey is closed — responses are no longer accepted + {UNAVAILABLE_MESSAGE[status]}

); From 95372938bd4b8715680773b52c8fc7b525e81bd5 Mon Sep 17 00:00:00 2001 From: Leah Peker Date: Mon, 14 Sep 2026 22:28:17 +0300 Subject: [PATCH 5/6] revert(events): keep event survey links on is_active only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit datetime_poll_slug is an identity key — EventMemberSection uses it to exclude the poll from the feedback list — not an openness gate. Filtering it by survey_is_open() nulls it once the poll shuts, which drops the poll back into the feedback list. Both fields go back to main's is_active filter, leaving _event_helpers.py untouched by this branch. Co-Authored-By: Claude Opus 5 --- backend/community/_event_helpers.py | 28 +++++----- backend/tests/test_surveys_scheduling.py | 65 +----------------------- 2 files changed, 14 insertions(+), 79 deletions(-) diff --git a/backend/community/_event_helpers.py b/backend/community/_event_helpers.py index 44e97aad3..9255d438f 100644 --- a/backend/community/_event_helpers.py +++ b/backend/community/_event_helpers.py @@ -26,7 +26,6 @@ ) from community._rsvp_payment import can_see_payment_details, payment_enforced_for_event from community._shared import _authenticated_user, _gated -from community._survey_helpers import survey_is_open from community._validation import Code, raise_validation from community.models import ( Event, @@ -36,7 +35,6 @@ FeatureFlag, PageVisibility, RSVPStatus, - Survey, SurveyQuestionType, flag_enabled, ) @@ -280,16 +278,16 @@ def _set_event_tags(event: Event, tag_ids: Iterable[str]) -> None: event.tags.set(tags) -def _open_event_surveys(event: Event) -> list[Survey]: - """Surveys linked to this event that are currently accepting responses.""" - return [s for s in event.surveys.prefetch_related("questions").all() if survey_is_open(s)] - - -def _get_datetime_poll_slug(event: Event, open_surveys: list[Survey]) -> str | None: - for survey in open_surveys: - if any(q.field_type == SurveyQuestionType.DATETIME_POLL for q in survey.questions.all()): - return survey.slug - return None +def _get_datetime_poll_slug(event: Event) -> str | None: + poll_survey = ( + event.surveys.filter( + is_active=True, + questions__field_type=SurveyQuestionType.DATETIME_POLL, + ) + .values_list("slug", flat=True) + .first() + ) + return poll_survey def _annotated_or(event: Event, attr: str, fallback): @@ -370,7 +368,6 @@ def _event_out(event: Event, requesting_user=None) -> EventOut: pending_invites_out = _pending_cohost_invites_out(event, auth_user, co_host_ids) my_pending_invite = get_my_pending_invite(event, auth_user) my_pending_invite_id = str(my_pending_invite.id) if my_pending_invite else None - open_surveys = _open_event_surveys(event) return EventOut( id=str(event.id), slug=event.slug, @@ -428,9 +425,10 @@ def _event_out(event: Event, requesting_user=None) -> EventOut: photo_url=media_path(event.photo), photo_updated_at=_iso_or_none(event.photo_updated_at), linked_surveys=[ - EventSurveyOut(id=str(s.id), title=s.title, slug=s.slug) for s in open_surveys + EventSurveyOut(id=str(s.id), title=s.title, slug=s.slug) + for s in event.surveys.filter(is_active=True) ], - datetime_poll_slug=_get_datetime_poll_slug(event, open_surveys), + datetime_poll_slug=_get_datetime_poll_slug(event), has_poll=hasattr(event, "poll"), invited_user_ids=[str(u.id) for u in invited], invited_user_names=[visible_display_name(u, auth_user) for u in invited], diff --git a/backend/tests/test_surveys_scheduling.py b/backend/tests/test_surveys_scheduling.py index f2dc3f8f3..b59c04bb7 100644 --- a/backend/tests/test_surveys_scheduling.py +++ b/backend/tests/test_surveys_scheduling.py @@ -2,9 +2,8 @@ from datetime import timedelta import pytest -from community._event_helpers import _event_out from community._validation import Code -from community.models import Event, Survey, SurveyQuestion, SurveyQuestionType, SurveyResponse +from community.models import Survey, SurveyQuestion, SurveyQuestionType from django.utils import timezone from ninja_jwt.tokens import RefreshToken from users.models import User @@ -176,65 +175,3 @@ def test_patch_can_clear_fields(self, api_client, surveys_admin_headers): body = resp.json() assert body["opens_at"] is None and body["closes_at"] is None assert body["max_responses"] is None - - -# --------------------------------------------------------------------------- -# Event survey links respect scheduling + cap, not just is_active (Issue 1465) -# --------------------------------------------------------------------------- - - -@pytest.mark.django_db -class TestEventSurveyLinkVisibility: - def _event(self): - return Event.objects.create( - title="Potluck", start_datetime=timezone.now() + timedelta(days=10) - ) - - def test_scheduled_survey_excluded_from_links(self): - event = self._event() - Survey.objects.create( - title="Feedback", - slug="scheduled-feedback", - linked_event=event, - opens_at=timezone.now() + timedelta(hours=1), - ) - assert _event_out(event).linked_surveys == [] - - def test_closed_survey_excluded_from_links(self): - event = self._event() - Survey.objects.create( - title="Feedback", - slug="closed-feedback", - linked_event=event, - closes_at=timezone.now() - timedelta(minutes=1), - ) - assert _event_out(event).linked_surveys == [] - - def test_at_cap_survey_excluded_from_links(self): - event = self._event() - survey = Survey.objects.create( - title="Feedback", slug="capped-feedback", linked_event=event, max_responses=1 - ) - SurveyResponse.objects.create(survey=survey) - assert _event_out(event).linked_surveys == [] - - def test_open_survey_included_in_links(self): - event = self._event() - Survey.objects.create(title="Feedback", slug="open-feedback", linked_event=event) - assert [s.slug for s in _event_out(event).linked_surveys] == ["open-feedback"] - - def test_scheduled_poll_excluded_from_datetime_poll_slug(self): - event = self._event() - survey = Survey.objects.create( - title="When?", - slug="when-poll", - linked_event=event, - opens_at=timezone.now() + timedelta(hours=1), - ) - SurveyQuestion.objects.create( - survey=survey, - label="Pick a time", - field_type=SurveyQuestionType.DATETIME_POLL, - options=[future_iso(days=10)], - ) - assert _event_out(event).datetime_poll_slug is None From 213cab9798e1934da8f45645541be9ab682fb9c0 Mon Sep 17 00:00:00 2001 From: Leah Peker Date: Mon, 14 Sep 2026 22:30:39 +0300 Subject: [PATCH 6/6] fix(events): hide scheduled and capped surveys from event links linked_surveys filtered on is_active only, so a survey that had not opened yet or was at its cap still showed as a live link on the event page. datetime_poll_slug deliberately keeps main's is_active filter: it is an identity key the frontend uses to keep the poll out of the feedback list, not an openness gate. survey_is_open implies is_active, so narrowing only linked_surveys leaves that exclusion intact. Co-Authored-By: Claude Opus 5 --- backend/community/_event_helpers.py | 4 +- backend/tests/test_surveys_scheduling.py | 76 +++++++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/backend/community/_event_helpers.py b/backend/community/_event_helpers.py index 9255d438f..5686adcce 100644 --- a/backend/community/_event_helpers.py +++ b/backend/community/_event_helpers.py @@ -26,6 +26,7 @@ ) from community._rsvp_payment import can_see_payment_details, payment_enforced_for_event from community._shared import _authenticated_user, _gated +from community._survey_helpers import survey_is_open from community._validation import Code, raise_validation from community.models import ( Event, @@ -426,7 +427,8 @@ def _event_out(event: Event, requesting_user=None) -> EventOut: photo_updated_at=_iso_or_none(event.photo_updated_at), linked_surveys=[ EventSurveyOut(id=str(s.id), title=s.title, slug=s.slug) - for s in event.surveys.filter(is_active=True) + for s in event.surveys.all() + if survey_is_open(s) ], datetime_poll_slug=_get_datetime_poll_slug(event), has_poll=hasattr(event, "poll"), diff --git a/backend/tests/test_surveys_scheduling.py b/backend/tests/test_surveys_scheduling.py index b59c04bb7..3cd276ef5 100644 --- a/backend/tests/test_surveys_scheduling.py +++ b/backend/tests/test_surveys_scheduling.py @@ -2,8 +2,15 @@ from datetime import timedelta import pytest +from community._event_helpers import _event_out from community._validation import Code -from community.models import Survey, SurveyQuestion, SurveyQuestionType +from community.models import ( + Event, + Survey, + SurveyQuestion, + SurveyQuestionType, + SurveyResponse, +) from django.utils import timezone from ninja_jwt.tokens import RefreshToken from users.models import User @@ -175,3 +182,70 @@ def test_patch_can_clear_fields(self, api_client, surveys_admin_headers): body = resp.json() assert body["opens_at"] is None and body["closes_at"] is None assert body["max_responses"] is None + + +# --------------------------------------------------------------------------- +# Event survey links respect scheduling + cap, not just is_active (Issue 1465) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestEventSurveyLinkVisibility: + def _event(self): + return Event.objects.create( + title="Potluck", start_datetime=timezone.now() + timedelta(days=10) + ) + + def test_scheduled_survey_excluded_from_links(self): + event = self._event() + Survey.objects.create( + title="Feedback", + slug="scheduled-feedback", + linked_event=event, + opens_at=timezone.now() + timedelta(hours=1), + ) + assert _event_out(event).linked_surveys == [] + + def test_closed_survey_excluded_from_links(self): + event = self._event() + Survey.objects.create( + title="Feedback", + slug="closed-feedback", + linked_event=event, + closes_at=timezone.now() - timedelta(minutes=1), + ) + assert _event_out(event).linked_surveys == [] + + def test_at_cap_survey_excluded_from_links(self): + event = self._event() + survey = Survey.objects.create( + title="Feedback", slug="capped-feedback", linked_event=event, max_responses=1 + ) + SurveyResponse.objects.create(survey=survey) + assert _event_out(event).linked_surveys == [] + + def test_open_survey_included_in_links(self): + event = self._event() + Survey.objects.create(title="Feedback", slug="open-feedback", linked_event=event) + assert [s.slug for s in _event_out(event).linked_surveys] == ["open-feedback"] + + # datetime_poll_slug identifies which linked survey is the poll; the frontend + # uses it to keep the poll out of the feedback list. Scheduling must not + # touch it, or a shut poll would lose its identity and land in that list. + def test_scheduling_does_not_affect_datetime_poll_slug(self): + event = self._event() + survey = Survey.objects.create( + title="When?", + slug="when-poll", + linked_event=event, + closes_at=timezone.now() - timedelta(minutes=1), + ) + SurveyQuestion.objects.create( + survey=survey, + label="Pick a time", + field_type=SurveyQuestionType.DATETIME_POLL, + options=[future_iso(days=10)], + ) + out = _event_out(event) + assert out.datetime_poll_slug == "when-poll" + assert out.linked_surveys == []