diff --git a/README.md b/README.md
index 6720bdf..47b4a06 100644
--- a/README.md
+++ b/README.md
@@ -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/.
+Voor de paginas. Het moet gewoon altijd paginas zijn. Link heet invite/.
Die maakt rsvp aan, en stuurt door naar rsvp//
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!
\ No newline at end of file
diff --git a/events/admin.py b/events/admin.py
index 41f730b..23dfa2b 100644
--- a/events/admin.py
+++ b/events/admin.py
@@ -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)
diff --git a/events/forms.py b/events/forms.py
index cfdf6a9..5f56533 100644
--- a/events/forms.py
+++ b/events/forms.py
@@ -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']
\ No newline at end of file
+ fields = ['name', 'age_group']
+
+class NotesForm(forms.ModelForm):
+ class Meta:
+ model = RSVP
+ fields = ['notes']
+ widgets = {
+ "notes": Textarea(attrs={"cols": 80, "rows": 6}),
+ }
+
\ No newline at end of file
diff --git a/events/models.py b/events/models.py
index c6e0c8a..b61fbf6 100644
--- a/events/models.py
+++ b/events/models.py
@@ -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)
diff --git a/events/signals.py b/events/signals.py
index d7fc980..25430f0 100644
--- a/events/signals.py
+++ b/events/signals.py
@@ -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."""
diff --git a/events/static/css/styles.css b/events/static/css/styles.css
index f78bbf5..57ddeec 100644
--- a/events/static/css/styles.css
+++ b/events/static/css/styles.css
@@ -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;
+ }
}
\ No newline at end of file
diff --git a/events/static/js/rsvp.js b/events/static/js/rsvp.js
index 5822a2c..3887d2a 100644
--- a/events/static/js/rsvp.js
+++ b/events/static/js/rsvp.js
@@ -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 = "";
diff --git a/events/templates/events/partials/rsvp_page_activities_details.html b/events/templates/events/partials/rsvp_page_activities_details.html
index 52ec9cf..0eb7db9 100644
--- a/events/templates/events/partials/rsvp_page_activities_details.html
+++ b/events/templates/events/partials/rsvp_page_activities_details.html
@@ -17,28 +17,23 @@
{% if activity.guest_limit %}
Places remaining:
-
{{ activity.remaining_capacity }}
/
- {{ activity.guest_limit }}
+ {{ activity.remaining_capacity }} / {{ activity.guest_limit }}
{% else %}
People going:
-
{{ activity.attendee_count }}
+ {{ activity.attendee_count }}
{% endif %}
Attendees:
-
-
-
- {% for guest_list in activity.attendee_list %}
-
- {{ guest_list|join:", " }}
-
- {% endfor %}
-
-
-
+
+ {% for guest_list in activity.attendee_list %}
+
+ {{ guest_list|join:", " }}
+
+ {% endfor %}
+