feat: Enhance RSVP functionality with question handling and notes support
This commit is contained in:
@@ -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!
|
||||
@@ -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
@@ -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
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
),
|
||||
|
||||
]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
Reference in New Issue
Block a user