feat: Enhance RSVP functionality with question handling and notes support

This commit is contained in:
2026-03-22 15:24:27 +01:00
parent ac7293244c
commit 8d6f261f3d
15 changed files with 541 additions and 45 deletions
+9 -1
View File
@@ -20,7 +20,7 @@ Dat bovenste is dus meteen een slecht idee. De pagina kan beter alle stukjes met
Oplossing wordt het toevoegen van en step nummer, die toch dan bijhoudt waar je precies in de stappen bent
## Twee
VOor de paginas. Het moet gewoon altijd paginas zijn. Link heet invite/<code>.
Voor de paginas. Het moet gewoon altijd paginas zijn. Link heet invite/<code>.
Die maakt rsvp aan, en stuurt door naar rsvp/<unique>/<step>
Status renamen naar step, en die opslaan in de database
Status is dan nog maar 3 dingen: incomplete, going, not going.
@@ -34,3 +34,11 @@ Als ze niet binnen zoveel minuten de rsvp afronden, of afbreken, dan krijgen ze
Geen nieuwe gasten toevoegen, als er al een activity geselecteerd is die vol is
## Vijf
Als iemand geen gasten mag meenemen, die hele stap niet tonen.
## Zes
Het plaatje en de beschrijving van het event moeten misschien tussendoor nog erbij komen.
Als in: invite link -> event page (create rsvp & token, store in cookie) -> Knop ik kom!, Knop ik kom niet? -> Redirect naar RSVP flow
## Zeven
De websocket overschrijft de huidige keuze. Dus als iets vol zit, wordt hij grijs, ondanks dat die er zelf ook heen gaan en hij groen had moeten zijn!
+1
View File
@@ -12,6 +12,7 @@ admin.site.register(Guest)
admin.site.register(Question)
admin.site.register(QuestionChoice)
admin.site.register(Response)
admin.site.register(ResponseChoice)
admin.site.register(Comment)
admin.site.register(GuestListTemplate)
admin.site.register(GuestListItem)
+11 -1
View File
@@ -1,5 +1,6 @@
# forms.py
from django import forms
from django.forms import Textarea
from .models import RSVP, Guest
class RSVPForm(forms.ModelForm):
@@ -10,4 +11,13 @@ class RSVPForm(forms.ModelForm):
class GuestForm(forms.ModelForm):
class Meta:
model = Guest
fields = ['name', 'age_group']
fields = ['name', 'age_group']
class NotesForm(forms.ModelForm):
class Meta:
model = RSVP
fields = ['notes']
widgets = {
"notes": Textarea(attrs={"cols": 80, "rows": 6}),
}
+29 -8
View File
@@ -437,7 +437,7 @@ class Response(TimestampedModel):
guest = models.ForeignKey(Guest, null=True, blank=True, related_name='responses', on_delete=models.CASCADE)
# Choice (single) if applicable
choice = models.ForeignKey(QuestionChoice, null=True, blank=True, on_delete=models.SET_NULL)
choices = models.ManyToManyField(QuestionChoice, through="ResponseChoice", related_name="responses", blank=True)
value_text = models.TextField(blank=True)
def clean(self):
@@ -450,11 +450,22 @@ class Response(TimestampedModel):
raise ValidationError("This question must be answered once per Guest.")
kind = self.question.kind
if kind == "single" and not self.choice:
raise ValidationError("Single-choice question requires a choice.")
if kind == "multiple" and not self.choice:
raise ValidationError("Multiple-choice responses must have a choice.")
if kind in ("text", "integer", "boolean") and self.choice:
# For choice-backed questions, validation happens relative to choices M2M
if kind == "single":
# Expect exactly one selected choice (or at least one)
if not self.pk:
# if not saved yet, can't reliably check M2M, so skip strict check here
pass
else:
if self.choices.count() != 1:
raise ValidationError("Single-choice question requires exactly one choice.")
if kind == "multiple":
if not self.pk:
pass
else:
if self.choices.count() < 1:
raise ValidationError("Multiple-choice question requires at least one choice.")
if kind in ("text", "integer", "boolean") and self.choices.exists():
raise ValidationError("This question does not use choices.")
if self.question.activity.id != self.activity.id:
@@ -463,9 +474,19 @@ class Response(TimestampedModel):
def __str__(self):
if self.question.kind in ("text", "integer", "boolean"):
return f"Response to {self.question}: {self.value_text}"
if self.question.kind in ("single", "multiple") and self.choice:
return f"Response to {self.question}: {self.choice.label}"
if self.question.kind in ("single", "multiple") and self.choices.count() > 0:
return f"Response to {self.question}: {self.choices.all}"
class ResponseChoice(TimestampedModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
response = models.ForeignKey("Response", related_name="response_choices", on_delete=models.CASCADE)
choice = models.ForeignKey(QuestionChoice, related_name="response_choices", on_delete=models.CASCADE)
class Meta:
unique_together = (("response", "choice"),)
def __str__(self):
return f"{self.choice} for {self.response}"
class Comment(TimestampedModel):
event = models.ForeignKey(Event, related_name='comments', on_delete=models.CASCADE)
+20 -1
View File
@@ -6,10 +6,29 @@ from asgiref.sync import async_to_sync, sync_to_async
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from channels.layers import get_channel_layer
from events.models import RSVP, Activity, Event, ActivitySelection
from events.models import RSVP, Activity, Event, ActivitySelection, Guest, Response
logger = logging.getLogger(__name__)
###
# Guests
###
@receiver(post_delete, sender=Guest)
def guest_deleted(sender, instance, **kwargs):
"""Delete question responses for this guest"""
guest:Guest = instance
logger.debug(f"Signal {guest}")
# Remove all responses from the guest that is deleted
Response.objects.filter(guest=guest).delete()
###
# Activity Selections
###
@receiver(post_save, sender=ActivitySelection)
def activity_selection_changed(sender, instance, created, **kwargs):
"""Broadcast activity selection changes to all connected clients."""
+72 -6
View File
@@ -9,12 +9,22 @@
h1, h2, h3, h4 {
margin-top: 0;
margin-bottom: 0.5rem;
}
h3 {
font-size: 1.1rem;
font-weight: 600;
}
h4 {
font-size: 1rem;
}
.card {
background: var(--accent-bg);
border-radius: 8px;
padding: 1.25rem;
padding: 1rem;
margin-bottom: 1rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
@@ -22,8 +32,8 @@ h1, h2, h3, h4 {
.subcard {
background: var(--bg);
border-radius: 8px;
padding: 1.25rem;
margin-top: 2rem;
padding: 1rem;
margin-top: 1.5rem;
margin-bottom: 1rem;
box-shadow: inset 0 1px 3px rgba(0,0,0,.08);
}
@@ -31,29 +41,38 @@ h1, h2, h3, h4 {
.activity {
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s ease;
}
.activity:hover {
border-color: var(--accent-hover);
transform: translateY(-1px);
}
.activity.selected {
border-color: var(--accent);
background: var(--green) !important;
padding: 1rem;
}
.group-title {
margin-top: 2rem;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
font-weight: 600;
font-size: 0.95rem;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge {
display: inline-block;
font-size: 0.8rem;
font-size: 0.75rem;
background: var(--neutral);
color: var(--text-light);
padding: 0.2rem 0.5rem;
padding: 0.25rem 0.6rem;
border-radius: 999px;
margin: 0.25rem 0.25rem 0.25rem 0;
}
.bg-green {
@@ -160,4 +179,51 @@ h1, h2, h3, h4 {
.horizontal-form button {
margin-top: 0;
white-space: nowrap;
}
/* Modern typography improvements */
p {
margin: 0.5rem 0;
font-size: 0.9rem;
color: var(--text-light);
}
/* Compact spacing for activity cards */
.subcard h3 {
margin-bottom: 0.5rem;
}
.subcard p {
margin: 0.25rem 0;
}
/* Better badge spacing */
.stratt {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
/* Compact button styling */
button {
padding: 0.5rem 1rem;
font-size: 0.9rem;
border-radius: 4px;
margin: 0.25rem;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.card {
padding: 0.75rem;
}
.subcard {
padding: 0.75rem;
}
.group-title {
font-size: 0.85rem;
}
}
+3 -3
View File
@@ -11,13 +11,13 @@ activitySocket.onmessage = function(e) {
data = JSON.parse(e.data);
// Activity attendees (when everyone can join)
attendees = document.querySelector('#activity-' + data.activity + ' > .numatt');
attendees = document.querySelector('#activity-' + data.activity + ' .numatt');
if(attendees){
attendees.innerHTML = data.attendees;
}
// Activity capacity (when there is a guest limit)
capacity = document.querySelector('#activity-' + data.activity + ' > .numcap');
capacity = document.querySelector('#activity-' + data.activity + ' .numcap');
if(capacity){
capacity.innerHTML = data.capacity;
}
@@ -36,7 +36,7 @@ activitySocket.onmessage = function(e) {
}
// Activity capacity (when there is a guest limit)
attendees_names = document.querySelector('#activity-' + data.activity + ' > .stratt');
attendees_names = document.querySelector('#activity-' + data.activity + ' .stratt');
if(attendees_names){
html = "";
@@ -17,28 +17,23 @@
{% if activity.guest_limit %}
<p>
Places remaining:
<div class="numcap">{{ activity.remaining_capacity }}</div> /
{{ activity.guest_limit }}
<span class="numcap">{{ activity.remaining_capacity }}</span> / {{ activity.guest_limit }}
</p>
{% else %}
<p>
People going:
<div class="numatt">{{ activity.attendee_count }}</div>
<span class="numatt">{{ activity.attendee_count }}</span>
</p>
{% endif %}
<h3>Attendees:</h3>
<p>
<div class="stratt">
{% for guest_list in activity.attendee_list %}
<span class="badge bg-blue">
{{ guest_list|join:", " }}
</span>
{% endfor %}
</div>
</p>
<span class="stratt">
{% for guest_list in activity.attendee_list %}
<span class="badge bg-blue">
{{ guest_list|join:", " }}
</span>
{% endfor %}
</span>
</div>
<script>
@@ -1,3 +1,10 @@
<div class="subcard">
Notes?
</div>
<div class="card" id="rsvp-notes">
<h3>Notes</h3>
<div class="subcard">
<h4>Is there anything you want to say?</h4>
<form hx-post="{% url 'events:rsvp-post-notes' rsvp.id %}" hx-target="#rsvp-notes" hx-swap="outerHTML" hx-trigger="change">
{% csrf_token %}
{{ form.as_p }}
</form>
</div>
</div>
@@ -1,3 +1,6 @@
<div class="subcard">
Questions?
<div class="card" id="rsvp-questions">
<h3>Questions</h3>
{% for question in questions %}
{% include "events/partials/rsvp_page_questions_detail.html" %}
{% endfor %}
</div>
@@ -0,0 +1,121 @@
{# events/partials/rsvp_page_questions_answer.html #}
{# Render inputs only — the wrapping form handles submission. #}
{% comment %}
Helper pattern: scan the responses QuerySets to see if an existing Response exists
for this question (and guest when relevant). The template uses loops to determine
whether to render `checked` or prefill `value`.
{% endcomment %}
{% if question.kind == "single" %}
{% for choice in question.choices.all %}
<div>
<label>
<input type="radio" name="choice" value="{{ choice.id }}"
{% if question.scope == "rsvp" %}
{% for resp in responses_rsvp %}
{% if resp.question.id == question.id %}
{% for resp_choice in resp.choices.all %}
{% if resp_choice.id == choice.id %}checked{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{% else %}
{% for resp in responses_guest %}
{% if resp.question.id == question.id and resp.guest.id == guest.id %}
{% for resp_choice in resp.choices.all %}
{% if resp_choice.id == choice.id %}checked{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{% endif %}
>
{{ choice.label }}
</label>
</div>
{% endfor %}
{% elif question.kind == "multiple" %}
{% for choice in question.choices.all %}
<div>
<label>
<input type="checkbox" name="choices" value="{{ choice.id }}"
{% if question.scope == "rsvp" %}
{% for resp in responses_rsvp %}
{% if resp.question.id == question.id %}
{% for resp_choice in resp.choices.all %}
{% if resp_choice.id == choice.id %}checked{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{% else %}
{% for resp in responses_guest %}
{% if resp.question.id == question.id and resp.guest.id == guest.id %}
{% for resp_choice in resp.choices.all %}
{% if resp_choice.id == choice.id %}checked{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{% endif %}
>
{{ choice.label }}
</label>
</div>
{% endfor %}
{% elif question.kind == "boolean" %}
<div>
<label>
<input type="radio" name="value_bool" value="true"
{% if question.scope == "rsvp" %}
{% for resp in responses_rsvp %}
{% if resp.question.id == question.id and resp.value_text == "true" %}checked{% endif %}
{% endfor %}
{% else %}
{% for resp in responses_guest %}
{% if resp.question.id == question.id and resp.guest.id == guest.id and resp.value_text == "true" %}checked{% endif %}
{% endfor %}
{% endif %}
>
Yes
</label>
</div>
<div>
<label>
<input type="radio" name="value_bool" value="false"
{% if question.scope == "rsvp" %}
{% for resp in responses_rsvp %}
{% if resp.question.id == question.id and resp.value_text == "false" %}checked{% endif %}
{% endfor %}
{% else %}
{% for resp in responses_guest %}
{% if resp.question.id == question.id and resp.guest.id == guest.id and resp.value_text == "false" %}checked{% endif %}
{% endfor %}
{% endif %}
>
No
</label>
</div>
{% elif question.kind == "integer" %}
<input
type="number"
name="value"
{% if question.scope == "rsvp" %}
value="{% for resp in responses_rsvp %}{% if resp.question.id == question.id %}{{ resp.value_text|default:'' }}{% endif %}{% endfor %}"
{% else %}
value="{% for resp in responses_guest %}{% if resp.question.id == question.id and resp.guest.id == guest.id %}{{ resp.value_text|default:'' }}{% endif %}{% endfor %}"
{% endif %}
>
{% else %}
<input
type="text"
name="value"
{% if question.scope == "rsvp" %}
value="{% for resp in responses_rsvp %}{% if resp.question.id == question.id %}{{ resp.value_text|default:'' }}{% endif %}{% endfor %}"
{% else %}
value="{% for resp in responses_guest %}{% if resp.question.id == question.id and resp.guest.id == guest.id %}{{ resp.value_text|default:'' }}{% endif %}{% endfor %}"
{% endif %}
>
{% endif %}
@@ -0,0 +1,45 @@
<div class="subcard" id="question-{{ question.id }}">
<h3>
{{ question.text }}
{% if question.required %}
<span class="badge bg-red">Required</span>
{% endif %}
{% if question.scope == "guest" %}
<span class="badge bg-blue">Asked for each guest</span>
{% endif %}
</h3>
{% if question.help_text %}
<p class="help-text">{{ question.help_text }}</p>
{% endif %}
{% if question.scope == "guest" %}
{% for guest in guests %}
<h4>{{ guest.name }}</h4>
<form
hx-post="{% url 'events:rsvp-post-response-guest' rsvp.id question.id guest.id %}"
hx-target="#rsvp-questions"
hx-swap="outerHTML"
hx-trigger="change"
>
{% csrf_token %}
{% include "events/partials/rsvp_page_questions_answer.html" %}
</form>
{% endfor %}
{% elif question.scope == "rsvp" %}
<form
hx-post="{% url 'events:rsvp-post-response' rsvp.id question.id %}"
hx-target="#rsvp-questions"
hx-swap="outerHTML"
hx-trigger="change"
>
{% csrf_token %}
{% include "events/partials/rsvp_page_questions_answer.html" %}
</form>
{% endif %}
</div>
+21 -1
View File
@@ -50,6 +50,26 @@ urlpatterns = [
"htmx/rsvp/<str:id>/post/activity/<str:aid>",
views.post_step_activities,
name="rsvp-post-activity"
)
),
# POST response for RSVP-scoped question
path(
"htmx/rsvp/<str:id>/post/question/<str:qid>/",
views.post_response_rsvp,
name="rsvp-post-response",
),
# POST response for Guest-scoped question
path(
"htmx/rsvp/<str:id>/post/question/<str:qid>/guest/<str:gid>/",
views.post_response_guest,
name="rsvp-post-response-guest",
),
path(
"htmx/rsvp/<str:id>/post/notes",
views.post_step_notes,
name="rsvp-post-notes",
),
]
+1
View File
@@ -36,6 +36,7 @@ def create_or_switch_activity_response(rsvp: RSVP, activity: Activity):
# Step 2: Check if adding guests will fit in the selected activity
remaining_capacity = activity.remaining_capacity()
if(remaining_capacity is None):
# Hardcoded, because why not
remaining_capacity = 100
if rsvp.guest_count() > remaining_capacity:
+184 -5
View File
@@ -11,7 +11,7 @@ from django.urls import reverse
from ..utils.utils import add_to_context, create_or_switch_activity_response, create_context, get_event, get_from_context, get_rsvp, create_rsvp
from ..forms import GuestForm, RSVPForm
from ..forms import GuestForm, NotesForm, RSVPForm
from ..models import (
Event,
@@ -21,6 +21,10 @@ from ..models import (
ActivityGroup,
RSVP,
ActivitySelection,
Question,
QuestionChoice,
Response,
ResponseChoice,
)
# Logger
@@ -140,10 +144,46 @@ def _render_page_activities(request, context, page):
return template.render(context=context, request=request)
def _render_page_questions(request, context, page):
"""
Gather questions for activities selected by this RSVP and the guests list.
Pass `questions` and `guests` into the template context.
"""
should_render = _render_page(request, get_rsvp(context), "Questions", RSVP.Page.QUESTIONS, page)
if(should_render != True): return should_render
if should_render != True:
return should_render
rsvp = get_rsvp(context)
# Get activities that were selected for this RSVP
selections = ActivitySelection.objects.filter(rsvp=rsvp).select_related("activity")
activities = [sel.activity for sel in selections]
# Get all questions for the selected activities, with related activity and choices prefetched
if activities:
questions = (
Question.objects
.filter(activity__in=activities)
.select_related("activity")
.prefetch_related("choices")
.order_by("activity__start_time", "activity__title", "order")
)
else:
questions = Question.objects.none()
# Guests are needed for guest-scoped questions
guests = Guest.objects.filter(rsvp=rsvp).order_by("name")
# Existing responses so templates can prefill current answers.
# RSVP-scoped responses
responses_rsvp = Response.objects.filter(rsvp=rsvp, question__in=questions).select_related("question").prefetch_related("choices")
# Guest-scoped responses (responses for guests of this rsvp)
responses_guest = Response.objects.filter(guest__rsvp=rsvp, question__in=questions).select_related("question", "guest").prefetch_related("choices")
add_to_context(context, "questions", questions)
add_to_context(context, "guests", guests)
add_to_context(context, "responses_rsvp", responses_rsvp)
add_to_context(context, "responses_guest", responses_guest)
template = get_template("events/partials/rsvp_page_questions.html")
return template.render(context=context, request=request)
@@ -152,6 +192,8 @@ def _render_page_notes(request, context, page):
should_render = _render_page(request, get_rsvp(context), "Notes", RSVP.Page.NOTES, page)
if(should_render != True): return should_render
add_to_context(context, "form", NotesForm(instance=get_rsvp(context)))
template = get_template("events/partials/rsvp_page_notes.html")
return template.render(context=context, request=request)
@@ -254,4 +296,141 @@ def post_step_activities(request, id:str, aid:str):
return HttpResponseBadRequest(str(e))
content = _render_page_activities(request, create_context(rsvp), RSVP.Page.ACTIVITIES)
return HttpResponse(content)
return HttpResponse(content)
def _create_or_update_response(response_kwargs, value_text=None, choice_obj=None):
"""
Ensure a single Response exists for (activity, question, rsvp|guest).
- For non-choice questions: store `value_text` and clear any choices.
- For single-choice questions: set exactly one ResponseChoice (replace existing).
- For multiple-choice questions: add the provided choice (caller can call repeatedly for multiple selections).
"""
# Find existing response for this owner/question/activity
existing = Response.objects.filter(
activity=response_kwargs['activity'],
question=response_kwargs['question'],
rsvp=response_kwargs.get('rsvp'),
guest=response_kwargs.get('guest'),
).first()
# Create response if it doesn't exist
if not existing:
resp = Response(**response_kwargs)
# Clear choice-related storage; we'll attach choices via ResponseChoice below
resp.value_text = "" if choice_obj else (value_text or "")
resp.full_clean()
resp.save()
else:
resp = existing
if choice_obj:
# When attaching choices, keep value_text empty
resp.value_text = ""
resp.save()
else:
# Non-choice answer: set value_text and remove any existing selected choices
resp.value_text = value_text or ""
resp.save()
ResponseChoice.objects.filter(response=resp).delete()
# If a choice was supplied, attach it via ResponseChoice
if choice_obj:
# question kind influences behavior: single should replace, multiple should add
kind = response_kwargs['question'].kind if 'question' in response_kwargs else None
if kind == "single":
# Keep exactly this single choice for the response
ResponseChoice.objects.filter(response=resp).exclude(choice=choice_obj).delete()
ResponseChoice.objects.get_or_create(response=resp, choice=choice_obj)
else:
# multiple: ensure this choice exists (caller may call repeatedly for several choices)
ResponseChoice.objects.get_or_create(response=resp, choice=choice_obj)
return resp
# Question step: handle post data
def post_response_rsvp(request, id:str, qid:str):
if request.method != "POST":
return HttpResponseBadRequest()
rsvp = get_object_or_404(RSVP, id=id)
question = get_object_or_404(Question, id=qid)
activity = question.activity
# Parse inputs:
if question.kind == "single":
choice_id = request.POST.get("choice")
choice = get_object_or_404(QuestionChoice, id=choice_id) if choice_id else None
resp = _create_or_update_response(
{"activity": activity, "question": question, "rsvp": rsvp},
choice_obj=choice
)
elif question.kind == "multiple":
choice_ids = request.POST.getlist("choices")
# For simplicity: create one Response per choice (or replace existing)
# Implementation detail depends on Response model use-case.
# (Example: delete existing and create new ones)
Response.objects.filter(activity=activity, question=question, rsvp=rsvp).delete()
for cid in choice_ids:
choice = get_object_or_404(QuestionChoice, id=cid)
_create_or_update_response({"activity": activity, "question": question, "rsvp": rsvp}, choice_obj=choice)
elif question.kind == "boolean":
val = request.POST.get("value_bool")
_create_or_update_response({"activity": activity, "question": question, "rsvp": rsvp}, value_text=val)
else:
val = request.POST.get("value", "")
_create_or_update_response({"activity": activity, "question": question, "rsvp": rsvp}, value_text=val)
# Re-render the questions page (or the single question partial). For simplicity render the whole questions page:
content = _render_page_questions(request, create_context(rsvp), RSVP.Page.QUESTIONS)
return HttpResponse(content)
def post_response_guest(request, id:str, qid:str, gid:str):
if request.method != "POST":
return HttpResponseBadRequest()
rsvp = get_object_or_404(RSVP, id=id)
question = get_object_or_404(Question, id=qid)
guest = get_object_or_404(Guest, id=gid)
activity = question.activity
if question.scope != "guest":
return HttpResponseBadRequest("Question is not guest-scoped")
if question.kind == "single":
choice_id = request.POST.get("choice")
choice = get_object_or_404(QuestionChoice, id=choice_id) if choice_id else None
_create_or_update_response({"activity": activity, "question": question, "guest": guest}, choice_obj=choice)
elif question.kind == "multiple":
choice_ids = request.POST.getlist("choices")
Response.objects.filter(activity=activity, question=question, guest=guest).delete()
for cid in choice_ids:
choice = get_object_or_404(QuestionChoice, id=cid)
_create_or_update_response({"activity": activity, "question": question, "guest": guest}, choice_obj=choice)
elif question.kind == "boolean":
val = request.POST.get("value_bool")
_create_or_update_response({"activity": activity, "question": question, "guest": guest}, value_text=val)
else:
val = request.POST.get("value", "")
_create_or_update_response({"activity": activity, "question": question, "guest": guest}, value_text=val)
content = _render_page_questions(request, create_context(rsvp), RSVP.Page.QUESTIONS)
return HttpResponse(content)
# Guest step: handle post data
def post_step_notes(request, id:str):
rsvp = get_object_or_404(RSVP, id=id)
if request.method != 'POST':
return HttpResponseBadRequest()
form = NotesForm(request.POST)
if form.is_valid():
rsvp.notes = form.cleaned_data['notes']
rsvp.save()
content = _render_page_notes(request, create_context(rsvp), RSVP.Page.NOTES)
return HttpResponse(content)
return HttpResponseBadRequest(form.errors)