Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/community/_event_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand Down
32 changes: 32 additions & 0 deletions backend/community/_survey_helpers.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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.

Expand Down
12 changes: 12 additions & 0 deletions backend/community/_survey_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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


Expand All @@ -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


Expand Down
11 changes: 11 additions & 0 deletions backend/community/_surveys.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
_apply_linked_event_update,
_survey_out,
_survey_question_out,
_validate_survey_window,
)
from community._survey_schemas import (
SurveyIn,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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:
Expand All @@ -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,
)
Expand Down Expand Up @@ -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()))
Expand Down
53 changes: 34 additions & 19 deletions backend/community/_surveys_public.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +17,7 @@
_response_out,
_survey_out,
_tally_question,
survey_is_open,
)
from community._survey_schemas import (
FinalizePollIn,
Expand All @@ -37,17 +39,26 @@
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:
raise_validation(Code.Survey.NOT_FOUND, status_code=404)
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},
Expand All @@ -73,15 +84,17 @@ 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)
if not survey.is_active:
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()
# 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"])
Expand All @@ -94,14 +107,16 @@ def submit_survey_response(request, slug: str, payload: SurveyAnswersIn):
),
)
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))
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))


@router.get(
Expand Down
1 change: 1 addition & 0 deletions backend/community/_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 27 additions & 0 deletions backend/community/migrations/0096_survey_scheduling_and_cap.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
3 changes: 3 additions & 0 deletions backend/community/models/survey.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions backend/community/validation_codes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading