Refactor RSVP flow and add new Event page

- Removed old activity detail, RSVP guest add, and questions partial templates.
- Introduced new RSVP page structure with separate templates for email, guest, activities, questions, and notes.
- Implemented new RSVP management views including start, update, decline, and restart functionalities.
- Enhanced cookie management for RSVP sessions.
- Added summary utilities for email, guests, activities, questions, and notes.
- Updated URL patterns to accommodate new RSVP flow.
- Improved context handling in RSVP views.
This commit is contained in:
2026-03-25 10:54:24 +01:00
parent 4112e0dc0a
commit b1cf2deda1
30 changed files with 713 additions and 308 deletions
+3
View File
@@ -188,3 +188,6 @@ cython_debug/
# Built Visual Studio Code Extensions
*.vsix
# Custom
events/static/img/*
!events/static/img/placeholder*.jpg
+11 -21
View File
@@ -15,36 +15,26 @@ Check that is works by running `which python`
# Ideas and notes
## Een
Steps: Allemaal altijd renderen. Ze vragen de huidige stap op, en checken of ze zelf aan de beurt zijn en renderen op basis daarvan: klein en groen als klaar.
Dat bovenste is dus meteen een slecht idee. De pagina kan beter alle stukjes meteen renderen? En dan met javascript op de pagina bepalen welke uitgeklapt moet zijn?
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>.
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.
Een copy dingetje om de unique url te kopiëren met uitleg voor later
De invite code onderdeel maken van de RSVP, invite helemaal verwijderen?
Een invite juist helemaal loskoppelen, geen connectie meer met RSVP. Alles kopieren.
Een invite kan dan ook Guests hebben?
## Drie
Invalidate invite na gebruik? Ze moeten nu de edit token gebruiken, want die krijgen ze per mail!
Als ze niet binnen zoveel minuten de rsvp afronden, of afbreken, dan krijgen ze de mail eerder?
Emails! Om te editen, als er comments of wijzigingen zijn!
## Vier
Geen nieuwe gasten toevoegen, als er al een activity geselecteerd is die vol is
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
Gasten (template): koppelen aan een invite!
## 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!
## Acht
Mails!
## Negen
Gasten (template): koppelen aan een invite!
# Things to keep in mind
- Move to Postgress
- Dockerfile
- Redis for channels?
+101 -18
View File
@@ -23,7 +23,7 @@ class TimestampedModel(models.Model):
def event_image_path(instance, filename):
# file will be uploaded to MEDIA_ROOT/user_<id>/<filename>
return "events/{0}/{1}".format(instance.slug, filename)
return "events/static/img/{0}/{1}".format(instance.slug, filename)
class Event(TimestampedModel):
"""The main event which contains activities.
@@ -57,6 +57,13 @@ class Event(TimestampedModel):
def __str__(self):
return self.title
@property
def image_url(self) -> str:
image_str = str(self.image)
if len(image_str) > 0:
return image_str[14:]
return "img/placeholder.jpg"
@property
def start_time(self):
first = self.activities.order_by('start_time').first()
@@ -66,6 +73,11 @@ class Event(TimestampedModel):
def end_time(self):
last = self.activities.order_by('-end_time').first()
return last.end_time if last else None
@property
def cookie_expire_datetime(self):
expiretime = self.end_time + self.cookie_expire_after_event
return expiretime
def is_locked(self) -> bool:
last = self.activities.order_by('-end_time').first()
@@ -95,7 +107,7 @@ class ActivityGroup(TimestampedModel):
def activity_image_path(instance, filename):
# file will be uploaded to MEDIA_ROOT/user_<id>/<filename>
return "events/{0}/{1}".format(instance.event.slug, filename)
return "events/static/img/{0}/{1}".format(instance.slug, filename)
class Activity(TimestampedModel):
"""An individual activity within an event.
@@ -120,9 +132,16 @@ class Activity(TimestampedModel):
return f"{self.event.title}{self.group.title}{self.title}"
return f"{self.event.title}{self.title}"
def remaining_capacity(self) -> int | None:
@property
def image_url(self) -> str:
image_str = str(self.image)
if len(image_str) > 0:
return image_str[14:]
return "img/placeholder_activity.jpg"
def remaining_capacity(self) -> int:
if self.guest_limit is None:
return None
return 999
return max(self.guest_limit - self.attendee_count(), 0)
def attendee_count(self) -> int:
@@ -234,7 +253,6 @@ class Invite(TimestampedModel):
class RSVP(TimestampedModel):
class Page(models.TextChoices):
INVALID = 'invalid', _('Invalid')
EMAIL = 'email', _('Email')
GUESTS = 'guests', _('Guests')
ACTIVITIES = 'activities', _('Activities')
@@ -243,6 +261,7 @@ class RSVP(TimestampedModel):
COMPLETED = 'completed', _('Completed')
class Status(models.TextChoices):
NOT_STARTED = 'not_started', _('Not Started')
INCOMPLETE = 'incomplete', _('Incomplete')
NOT_COMING = 'not_coming', _('Not coming')
YES_JOINING = 'joining', _('Yes, joining')
@@ -253,12 +272,13 @@ class RSVP(TimestampedModel):
edit_token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
responder_name = models.CharField(max_length=255, blank=True)
responder_email = models.EmailField(blank=False)
send_email_updates = models.BooleanField(default=True)
page = models.CharField(max_length=16, choices=Page.choices, default=Page.INVALID)
furthest_page = models.CharField(max_length=16, choices=Page.choices, default=Page.INVALID)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.INCOMPLETE)
page = models.CharField(max_length=16, choices=Page.choices, default=Page.EMAIL)
furthest_page = models.CharField(max_length=16, choices=Page.choices, default=Page.EMAIL)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.NOT_STARTED)
notes = models.TextField(blank=True)
@@ -266,13 +286,21 @@ class RSVP(TimestampedModel):
activities = models.ManyToManyField(Activity, through='ActivitySelection', related_name='rsvps')
def __str__(self):
return f"RSVP {self.id}{self.event.title}{self.responder_email}{self.status}"
return f"RSVP {self.edit_token}{self.event.title}{self.responder_email}{self.status}"
def guest_count(self) -> int:
return self.guests.count()
@property
def selected_activities_ids(self):
selected_activities = (
ActivitySelection.objects
.filter(rsvp=self)
)
selected_activities_ids = [act.activity.id for act in selected_activities]
return selected_activities_ids
PAGE_ORDER = [
'invalid',
'email',
'guests',
'activities',
@@ -284,7 +312,7 @@ class RSVP(TimestampedModel):
def next_page(self) -> Page:
"""Move to the next page and save to model."""
new_page = self.Page.INVALID
new_page = self.page
try:
current_index = self.PAGE_ORDER.index(self.page)
@@ -297,7 +325,14 @@ class RSVP(TimestampedModel):
if is_next_page(new_page, self.Page(self.furthest_page)):
self.furthest_page = new_page
if self.status == self.Status.NOT_STARTED:
self.status = self.Status.INCOMPLETE
self.page = new_page
if self.page == self.Page.COMPLETED:
self.status = self.Status.YES_JOINING
self.save()
return new_page
@@ -305,7 +340,7 @@ class RSVP(TimestampedModel):
def previous_page(self) -> Page:
"""Move to the previous page and save to model"""
new_page = self.Page.INVALID
new_page = self.page
try:
current_index = self.PAGE_ORDER.index(self.page)
@@ -323,7 +358,7 @@ class RSVP(TimestampedModel):
def set_page(self, page:str) -> Page:
"""Move to the specific page and save to model"""
new_page = self.Page.INVALID
new_page = self.page
try:
new_page = self.Page(page)
@@ -338,6 +373,48 @@ class RSVP(TimestampedModel):
def is_page_finished(self, page:Page) -> bool:
"""return `True` if page is previous to the furthest reached page"""
return is_previous_page(page, self.Page(self.furthest_page))
def is_next_page_allowed(self) -> bool | str:
# Activities page: require at least one selected activity
if self.page == self.Page.ACTIVITIES:
if not self.activities.exists():
return "You must choose at least one activity."
return True
# Questions page: ensure every required question for selected activities is answered
if self.page == self.Page.QUESTIONS:
selected_activities = self.activities.all()
required_questions = Question.objects.filter(activity__in=selected_activities, required=True)
for q in required_questions:
if q.scope == "rsvp":
resp_qs = Response.objects.filter(rsvp=self, question=q)
if not resp_qs.exists():
return f'Please answer required question: "{q.text}"'
for resp in resp_qs:
if q.kind in ("text", "integer", "boolean"):
if not (resp.value_text and resp.value_text.strip()):
return f'Please answer required question: "{q.text}"'
else: # single / multiple choice
if resp.choices.count() == 0:
return f'Please answer required question: "{q.text}"'
elif q.scope == "guest":
for guest in self.guests.all():
resp = Response.objects.filter(guest=guest, question=q).first()
if not resp:
return f'Please answer required question "{q.text}" for all guests.'
if q.kind in ("text", "integer", "boolean"):
if not (resp.value_text and resp.value_text.strip()):
return f'Please answer required question "{q.text}" for all guests.'
else:
if resp.choices.count() == 0:
return f'Please answer required question "{q.text}" for all guests.'
return True
# Default: allow moving forward
return True
class ActivitySelection(models.Model):
@@ -462,9 +539,9 @@ class Response(TimestampedModel):
if kind == "multiple":
if not self.pk:
pass
else:
if self.choices.count() < 1:
raise ValidationError("Multiple-choice question requires at least one choice.")
# 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.")
@@ -472,10 +549,16 @@ class Response(TimestampedModel):
raise ValidationError("Question must belong to the same activity.")
def __str__(self):
whom = f"{self.question.scope}:"
if(self.question.scope == "rsvp"):
whom += " " + self.rsvp.responder_name
elif(self.question.scope == "guest"):
whom += " " + self.guest.name
if self.question.kind in ("text", "integer", "boolean"):
return f"Response to {self.question}: {self.value_text}"
return f"Response to {self.question} by {whom}: {self.value_text}"
if self.question.kind in ("single", "multiple") and self.choices.count() > 0:
return f"Response to {self.question}: {self.choices.all}"
return f"Response to {self.question} by {whom}: {self.choices.all}"
class ResponseChoice(TimestampedModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+91 -6
View File
@@ -1,10 +1,19 @@
:root {
--accent: #006e53;
--accent-hover: #02aa80;
--neutral: #e0e0e0ff;
--green: #d1e9d3ff;
--red: #e9d0d0ff;
--orange: #e7e0ceff;
--blue: #b5bed8ff;
--purple: #e1c9e2ff;
--neutral-hover: #bdbbbb;
--green: #00640d;
--green-hover: #008011;
--red: #880101;
--red-hover: #aa0000;
--orange: #975b00;
--orange-hover: #bb7001;
--blue: #001f7c;
--blue-hover: #002bad;
--purple: #76007a;
--purple-hover: #9e00a3;
}
h1, h2, h3, h4 {
@@ -21,6 +30,14 @@ h4 {
font-size: 1rem;
}
button, .button, a.button, input[type="submit"], input[type="reset"], input[type="button"] {
background-color: var(--accent);
}
button:hover, .button:hover, a.button:hover, input[type="submit"]:hover, input[type="reset"]:hover, input[type="button"]:hover {
background-color: var(--accent-hover);
}
.card {
background: var(--accent-bg);
border-radius: 8px;
@@ -75,8 +92,25 @@ h4 {
margin: 0.25rem 0.25rem 0.25rem 0;
}
.bg-accent {
background: var(--accent) !important;
color: var(--neutral);
}
.bg-accent.hover:hover {
background: var(--accent-hover) !important;
}
.bd-accent {
border: 2px solid transparent;
border-color: var(--accent) !important;
}
.bg-green {
background: var(--green) !important;
color: var(--neutral);
}
.bg-green.hover:hover {
background: var(--green-hover) !important;
}
.bd-green {
@@ -86,6 +120,10 @@ h4 {
.bg-red {
background: var(--red) !important;
color: var(--neutral);
}
.bg-red.hover:hover {
background: var(--red-hover) !important;
}
.bd-red {
@@ -95,6 +133,10 @@ h4 {
.bg-orange {
background: var(--orange) !important;
color: var(--neutral);
}
.bg-orange.hover:hover {
background: var(--orange-hover) !important;
}
.bd-orange {
@@ -104,6 +146,10 @@ h4 {
.bg-blue {
background: var(--blue) !important;
color: var(--neutral);
}
.bg-blue.hover:hover {
background: var(--blue-hover) !important;
}
.bd-blue {
@@ -113,6 +159,10 @@ h4 {
.bg-purple {
background: var(--pruple) !important;
color: var(--neutral);
}
.bg-purple.hover:hover {
background: var(--purple-hover) !important;
}
.bd-purple {
@@ -122,6 +172,7 @@ h4 {
.bg-black {
background: black !important;
color: var(--neutral);
}
.bd-black {
@@ -185,7 +236,6 @@ h4 {
p {
margin: 0.5rem 0;
font-size: 0.9rem;
color: var(--text-light);
}
/* Compact spacing for activity cards */
@@ -213,6 +263,41 @@ button {
margin: 0.25rem;
}
/* media layout: text left, image right, image height matches text column */
.subcard .media-row {
display: flex;
gap: 1rem;
align-items: stretch; /* make children the same height */
}
.subcard .media-content {
flex: 1 1 auto;
min-width: 0; /* allow contents to shrink properly */
}
/* thumbnail becomes a fixed-width column that stretches to content height */
.subcard .thumbnail {
float: none;
display: block;
height: 100%;
width: auto;
max-width: 22%; /* adjust percent to control width */
object-fit: cover; /* preserve aspect, fill the height */
border-radius: 6px;
}
/* mobile: stack, image full width and natural height */
@media (max-width: 768px) {
.subcard .media-row {
display: block;
}
.subcard .thumbnail {
width: 100%;
height: auto;
margin-top: 0.75rem;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.card {
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

+87
View File
@@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block title %}{{ event.title }}{% endblock %}
{% block content %}
<div class="card">
<h1>{{ event.title }}</h1>
{% if event.description %}
<div class="subcard">
{{ event.description }}
</div>
{% endif %}
</div>
<div class="card">
{% load static %}
<img src="{% static event.image_url %}">
</div>
{% if response %}
<div class="card {% if response.joining %}bg-green{% elif response.not_coming %}bg-red{% endif %}">
<h3>Hello {{ response.rsvp.responder_name }}!</h3>
{% if response.not_started %}
You have been invited! Please let us know if you are coming!
</br></br>
<a href="{% url 'events:rsvp-start' response.rsvp.id %}" class="button bg-accent hover">Respond now!</a>
<a href="{% url 'events:rsvp-decline' response.rsvp.id %}" class="button bg-red hover">I am not coming</a>
{% elif response.in_progress %}
You have not finished responding! Please let us know if you are coming!
</br></br>
<a href="{% url 'events:rsvp-start' response.rsvp.id %}" class="button bg-accent hover">Continue responding now!</a>
<a href="{% url 'events:rsvp-decline' response.rsvp.id %}" class="button bg-red hover">I am not coming</a>
{% elif response.not_coming %}
Sad that you are not coming?
</br></br>
<a href="{% url 'events:rsvp-restart' response.rsvp.id %}" class="button bg-green hover">I changed my mind!</a>
{% elif response.joining %}
You have responded!
</br></br>
<a href="{% url 'events:rsvp-update' response.rsvp.id %}" class="button bg-green hover">Update response!</a>
<a href="{% url 'events:rsvp-decline' response.rsvp.id %}" class="button bg-red hover">I can no longer come</a>
{% endif %}
</div>
{% else %}
<div class="card">
<h3>Want to come?</h3>
Use your invite link, or the one in your inbox to respond!
</div>
{% endif %}
<div class="card">
<h3>Activities</h3>
{% for group, activities in groups.items %}
<h4 class="group-title">
{{ group.title }}
{% if group.single_choice %}
<span class="badge">Only one</span>
{% endif %}
</h4>
{% for activity in activities %}
{% include "event/event_activity_details.html" %}
{% endfor %}
{% endfor %}
{% if ungrouped_activities %}
<h4 class="group-title">Other activities</h4>
{% for activity in ungrouped_activities %}
{% include "event/event_activity_details.html" %}
{% endfor %}
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,38 @@
<div class="subcard {% if response and activity.id in response.rsvp.selected_activities_ids %}bg-green attending{% endif %}" id="activity-{{activity.id}}">
<div class="media-row">
<div class="media-content">
<h3>{{ activity.title }}</h3>
<p>
{{ activity.start_time|date:"DATETIME_FORMAT" }}
{% if activity.end_time %}
{{ activity.end_time|date:"TIME_FORMAT" }}
{% endif %}
</p>
{% if activity.guest_limit %}
<p>
Places remaining:
<span class="numcap">{{ activity.remaining_capacity }}</span> / {{ activity.guest_limit }}
</p>
{% else %}
<p>
People going:
<span class="numatt">{{ activity.attendee_count }}</span>
</p>
{% endif %}
<h3>Attendees:</h3>
<span class="stratt">
{% for guest_list in activity.attendee_list %}
<span class="badge bg-blue">
{{ guest_list|join:", " }}
</span>
{% endfor %}
</span>
</div>
{% load static %}
<img class="thumbnail" src="{% static activity.image_url %}" alt="{{ activity.title }} image">
</div>
</div>
@@ -1,53 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ event.title }}{% endblock %}
{% block content %}
<div class="card">
<h1>{{ event.title }}</h1>
{% if event.description %}
<p>{{ event.description }}</p>
{% endif %}
<p>
<strong>When:</strong>
{{ event.start_time|date:"DATETIME_FORMAT" }}
{{ event.end_time|date:"DATETIME_FORMAT" }}
</p>
{% if is_locked %}
<p class="locked">
RSVPs are closed for this event.
</p>
{% endif %}
</div>
{% for group, activities in groups.items %}
<h2 class="group-title">
{{ group.title }}
{% if group.single_choice %}
<span class="badge">Choose one</span>
{% endif %}
</h2>
{% for activity in activities %}
{% include "events/partials/activity_card.html" %}
{% endfor %}
{% endfor %}
{% if ungrouped_activities %}
<h2 class="group-title">Other activities</h2>
{% for activity in ungrouped_activities %}
{% include "events/partials/activity_card.html" %}
{% endfor %}
{% endif %}
<h2 class="group-title">View details</h2>
<div id="activity-detail-container">None selected</div>
{% endblock %}
@@ -1,23 +0,0 @@
<div
class="card activity"
hx-get="{% url 'events:partial-activity-details' activity.id %}"
hx-target="#activity-detail-container"
hx-swap="innerHTML"
>
<h3>{{ activity.title }}</h3>
<p>
{{ activity.start_time|date:"DATETIME_FORMAT" }}
{% if activity.end_time %}
{{ activity.end_time|date:"TIME_FORMAT" }}
{% endif %}
</p>
{% if activity.guest_limit %}
<p>
Places remaining:
{{ activity.remaining_capacity }} /
{{ activity.guest_limit }}
</p>
{% endif %}
</div>
@@ -1,56 +0,0 @@
<div class="card">
<h2>{{ activity.title }}</h2>
{% if activity.description %}
<p>{{ activity.description }}</p>
{% endif %}
{% if activity.questions.exists %}
<h3>Questions</h3>
{% for question in activity.questions.all %}
<div style="margin-bottom: 1rem;">
<strong>{{ question.text }}</strong>
{% if question.scope == "guest" %}
<p><em>Asked for each guest</em></p>
{% endif %}
{% if question.kind == "single" %}
{% for choice in question.choices.all %}
<div>
<label>
<input type="radio" disabled>
{{ choice.label }}
</label>
</div>
{% endfor %}
{% elif question.kind == "multiple" %}
{% for choice in question.choices.all %}
<div>
<label>
<input type="checkbox" disabled>
{{ choice.label }}
</label>
</div>
{% endfor %}
{% elif question.kind == "boolean" %}
<div>
<label>
<input type="radio" disabled>
Yes
</label>
</div>
<div>
<label>
<input type="radio" disabled>
No
</label>
</div>
{% else %}
<input type="text" disabled style="width:100%;">
{% endif %}
</div>
{% endfor %}
{% endif %}
</div>
@@ -1,11 +0,0 @@
<div class="subcard" id="rsvp-add-guest">
<form method="post" hx-post="{% url 'events:partial-rsvp-guests-add' invite.code %}"
hx-target="#rsvp-add-guest"
hx-swap="outerHTML">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Save and add another guest</button>
</form>
</div>
@@ -1,6 +0,0 @@
<div class="card" id="rsvp-questions">
<h3>Questions</h3>
{% for question in questions %}
{% include "events/partials/rsvp_page_questions_detail.html" %}
{% endfor %}
</div>
@@ -24,18 +24,18 @@
hx-trigger="load">
</div>
<button class="bg-red"
<a class="button bg-orange hover"
hx-get="{% url 'events:rsvp-page-change' rsvp.id "previous" %}"
hx-target="#rsvp-flow"
>Previous</button>
>Previous</a>
<button
<a class="button bg-blue hover"
hx-get="{% url 'events:rsvp-page-change' rsvp.id "next" %}"
hx-target="#rsvp-flow"
>Next</button>
>Next</a>
<div id="rsvp-ws-live">
</div>
<a class="button bg-red hover"
href="{% url 'events:rsvp-decline' rsvp.id %}" >Not coming</a>
{% endif %}
@@ -46,6 +46,7 @@
<script>
const eventId = "{{ event.id }}";
const rsvpId = "{{ rsvp.id }}";
var guestCount = "{{ rsvp.guest_count }}";
</script>
<script src="{% static 'js/rsvp.js' %}"></script>
{% endblock %}
@@ -1,6 +1,11 @@
<div class="card" id="rsvp-activity">
<h3>What do you want to go to? <span class="badge bg-red live">Connecting...</span> </h3>
{% if page_error %}
<div class="subcard bg-red">
{{page_error}}
</div>
{% endif %}
{% for group, activities in groups.items %}
<h4 class="group-title">
@@ -11,7 +16,7 @@
</h4>
{% for activity in activities %}
{% include "events/partials/rsvp_page_activities_details.html" %}
{% include "rsvp/rsvp_page_activities_details.html" %}
{% endfor %}
{% endfor %}
@@ -19,7 +24,7 @@
<h4 class="group-title">Other activities</h4>
{% for activity in ungrouped_activities %}
{% include "events/partials/rsvp_page_activities_details.html" %}
{% include "rsvp/rsvp_page_activities_details.html" %}
{% endfor %}
{% endif %}
@@ -9,8 +9,12 @@
</div>
<div class="subcard">
<h4>Email and updates</h4>
<span class="badge bg-orange">You will always receive an email with a link to edit your response</span>
<h4>Email updates</h4>
<span class="badge bg-green">Your email will never be shared with other people</span>
<p>
You will always receive an email with a link to edit your response, and when a selected activity changes.
Optional emails are comments from other people.
</p>
<form method="post" hx-post="{% url 'events:rsvp-post-email' rsvp.id %}" hx-target="#post-result">
{% csrf_token %}
{{ form.as_p }}
@@ -14,6 +14,7 @@
{% endfor %}
<div class="subcard" id="rsvp-guest-add">
<span class="badge bg-orange">Last names are not shown to other people</span>
<form method="post" hx-post="{% url 'events:rsvp-post-guest' rsvp.id %}"
hx-target="#rsvp-guest"
hx-swap="outerHTML"
@@ -1,5 +1,5 @@
<div class="card collapse {% if complete %}bd-green cursor{% endif %}"
<div id="summary-{{page}}" {% if oob %}hx-swap-oob="true"{% endif %} class="card collapse {% if complete %}bd-green cursor{% endif %}"
{% if complete %}
hx-get="{% url 'events:rsvp-page-change' rsvp.id page %}"
hx-target="#rsvp-flow"
@@ -0,0 +1,13 @@
<div class="card" id="rsvp-questions">
<h3>Questions</h3>
{% if page_error %}
<div class="subcard bg-red">
{{page_error}}
</div>
{% endif %}
{% for question in questions %}
{% include "rsvp/rsvp_page_questions_detail.html" %}
{% endfor %}
</div>
@@ -25,7 +25,7 @@
hx-trigger="change"
>
{% csrf_token %}
{% include "events/partials/rsvp_page_questions_answer.html" %}
{% include "rsvp/rsvp_page_questions_answer.html" %}
</form>
{% endfor %}
@@ -38,7 +38,7 @@
hx-trigger="change"
>
{% csrf_token %}
{% include "events/partials/rsvp_page_questions_answer.html" %}
{% include "rsvp/rsvp_page_questions_answer.html" %}
</form>
{% endif %}
+25 -8
View File
@@ -7,14 +7,7 @@ from . import views
app_name = "events"
urlpatterns = [
path("events/<slug:slug>/", views.event_details, name="event-detail"),
# Partials
path(
"htmx/activity/<uuid:activity_id>/",
views.partial_activity_details,
name="partial-activity-details",
),
path("events/<slug:slug>/", views.event, name="event"),
path("invite/<slug:slug>/rsvp/<str:code>/", views.invite_redirect, name="invite"),
@@ -71,5 +64,29 @@ urlpatterns = [
views.post_step_notes,
name="rsvp-post-notes",
),
path(
"htmx/rsvp/<str:id>/start",
views.rsvp_start,
name="rsvp-start",
),
path(
"htmx/rsvp/<str:id>/restart",
views.rsvp_restart,
name="rsvp-restart",
),
path(
"htmx/rsvp/<str:id>/update",
views.rsvp_update,
name="rsvp-update",
),
path(
"htmx/rsvp/<str:id>/decline",
views.rsvp_decline,
name="rsvp-decline",
),
]
+24
View File
@@ -0,0 +1,24 @@
import logging
from django.http import HttpRequest, HttpResponse
from events.models import Event, RSVP
# Logger
logger = logging.getLogger(__name__)
def set_rsvp_cookie(response:HttpResponse, event:Event, rsvp:RSVP):
response.set_cookie(key=event.slug, value=rsvp.edit_token, expires=event.cookie_expire_datetime)
def get_rsvp_from_cookie(request:HttpRequest, slug:str) -> RSVP | bool:
logger.debug(f"Cookies: {request.COOKIES}")
edit_token = request.COOKIES.get(slug)
logger.debug(f"RSVP Edit Token: {edit_token}")
rsvp = RSVP.objects.filter(edit_token=edit_token).first()
if rsvp is not None:
return rsvp
return False
@@ -5,8 +5,9 @@ from ..models import RSVP, Activity, ActivitySelection, Event, Invite
def create_rsvp(event: Event, invite: Invite):
rsvp = RSVP(event=event, invite=invite)
rsvp.page = RSVP.Page.EMAIL
rsvp.furthest_page = RSVP.Page.EMAIL
if(invite.recipient_name != ""):
rsvp.responder_name = invite.recipient_name
if(invite.recipient_email != ""):
rsvp.responder_email = invite.recipient_email
@@ -64,13 +65,16 @@ def create_or_switch_activity_response(rsvp: RSVP, activity: Activity):
return selection
def create_context(rsvp:RSVP) -> dict:
def create_context(rsvp:RSVP, error:str="") -> dict:
context = {
"rsvp": rsvp,
"event": rsvp.event,
"invite": rsvp.invite
}
if(error != ""):
context["page_error"] = error
return context
def add_to_context(context:dict, key:str, value) -> dict:
+105
View File
@@ -0,0 +1,105 @@
from events.models import RSVP, Activity, Question, Response
def summary_email(rsvp:RSVP) -> str:
summary = f"Email: You will receive updates at `{rsvp.responder_email}`"
if rsvp.send_email_updates == False:
summary = f"Email: You do not want any updates."
return summary
def summary_guests(rsvp:RSVP) -> str:
summary = f"Guests: let us know who will come!"
if rsvp.is_page_finished(RSVP.Page.GUESTS):
guests = rsvp.guest_count()
if guests == 0:
summary = f"Guests: Don't forget to add yourself!"
if guests == 1:
summary = f"Guests: Just you."
if guests > 1:
summary = f"Guests: You are bringing {guests - 1} guests."
return summary
def summary_activities(rsvp:RSVP) -> str:
summary = f"Activities"
chosen_activities_count = rsvp.activities.count()
available_activities = Activity.objects.filter(event=rsvp.event)
total_activities_count = len(available_activities)
activities_with_space_count = sum(
1 for a in available_activities if a.remaining_capacity() >= rsvp.guest_count()
)
if rsvp.is_page_finished(RSVP.Page.ACTIVITIES) and chosen_activities_count > 0:
if chosen_activities_count == 1:
summary = f"Activities: You have selected one activity!"
if chosen_activities_count > 1:
summary = f"Activities: You have selected {chosen_activities_count} activities!"
else:
summary = f"Activities: You can still choose from {activities_with_space_count} / {total_activities_count} activities!"
return summary
def summary_questions(rsvp:RSVP) -> str:
selected_activities = rsvp.activities.all()
if not selected_activities.exists():
return "Questions: No activities selected — no questions to answer."
questions = Question.objects.filter(activity__in=selected_activities)
total = questions.count()
if total == 0:
return "Questions: No questions for your selected activities."
def is_answered(q: Question) -> bool:
if q.scope == "rsvp":
resp = Response.objects.filter(rsvp=rsvp, question=q).first()
if not resp:
return False
if q.kind in ("text", "integer", "boolean"):
return bool(resp.value_text and resp.value_text.strip())
return resp.choices.exists()
# guest-scoped
guests = rsvp.guests.all()
if not guests:
return False
for guest in guests:
resp = Response.objects.filter(guest=guest, question=q).first()
if not resp:
return False
if q.kind in ("text", "integer", "boolean"):
if not (resp.value_text and resp.value_text.strip()):
return False
else:
if resp.choices.count() == 0:
return False
return True
answered = sum(1 for q in questions if is_answered(q))
unanswered = total - answered
required_total = questions.filter(required=True).count()
required_unanswered = sum(1 for q in questions.filter(required=True) if not is_answered(q))
if rsvp.is_page_finished(RSVP.Page.QUESTIONS):
if unanswered == 0:
summary = f"Questions: All {total} answered."
else:
summary = f"Questions: {answered} answered, {unanswered} remaining."
else:
summary = f"Questions: {answered} answered, {unanswered} remaining."
if required_unanswered > 0:
summary += f" ({required_unanswered} required unanswered)"
return summary
def summary_notes(rsvp:RSVP) -> str:
summary = f"Notes: anything you want to share?"
if len(rsvp.notes) > 1:
summary = f"Notes: you have left a note."
return summary
+124 -40
View File
@@ -9,7 +9,10 @@ from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbid
from django.template.loader import get_template
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 events.utils.cookieutils import set_rsvp_cookie
from events.utils.summary import summary_activities, summary_email, summary_guests, summary_notes, summary_questions
from ..utils.rsvputils import add_to_context, create_or_switch_activity_response, create_context, get_event, get_from_context, get_rsvp, create_rsvp
from ..forms import GuestForm, NotesForm, RSVPForm
@@ -30,6 +33,59 @@ from ..models import (
# Logger
logger = logging.getLogger(__name__)
# Start a new or unfinished RSVP
def rsvp_start(request, id:str):
rsvp = get_object_or_404(RSVP, id=id)
if not (rsvp.status == RSVP.Status.NOT_STARTED or rsvp.status == RSVP.Status.INCOMPLETE):
return HttpResponseBadRequest()
response = redirect(reverse('events:rsvp-base', args=[rsvp.event.slug, rsvp.edit_token, rsvp.page]))
return response
# Update a completed RSVP
def rsvp_update(request, id:str):
rsvp = get_object_or_404(RSVP, id=id)
if rsvp.status != RSVP.Status.YES_JOINING:
return HttpResponseBadRequest()
rsvp.page = RSVP.Page.ACTIVITIES
rsvp.save()
response = redirect(reverse('events:rsvp-base', args=[rsvp.event.slug, rsvp.edit_token, rsvp.page]))
return response
# Stop an existing RSVP
def rsvp_decline(request, id:str):
rsvp = get_object_or_404(RSVP, id=id)
rsvp.status = RSVP.Status.NOT_COMING
rsvp.page = RSVP.Page.EMAIL
rsvp.furthest_page = RSVP.Page.EMAIL
rsvp.save()
# Remove any current activity selection
ActivitySelection.objects.filter(rsvp=rsvp).delete()
# Remove any question responses
Response.objects.filter(rsvp=rsvp).delete()
Response.objects.filter(guest__rsvp=rsvp).delete()
response = redirect(reverse('events:event', args=[rsvp.event.slug]))
return response
def rsvp_restart(request, id:str):
rsvp = get_object_or_404(RSVP, id=id)
rsvp.status = RSVP.Status.INCOMPLETE
rsvp.save()
response = redirect(reverse('events:rsvp-base', args=[rsvp.event.slug, rsvp.edit_token, rsvp.page]))
return response
# Main entrypoint for rsvp page
def rsvp_base_page(request, slug:str, code:str, page:str):
event = get_object_or_404(Event, slug=slug)
@@ -43,12 +99,13 @@ def rsvp_base_page(request, slug:str, code:str, page:str):
"is_locked": event.is_locked()
}
return render(request, "events/event_rsvp.html", context)
response = render(request, "rsvp/rsvp.html", context)
set_rsvp_cookie(response, event, rsvp)
return response
# Render selector based on the current step of the RSVP process
def rsvp_pages(request, id:str, page:str):
def rsvp_pages(request, id:str, page:str, error:str=""):
logger.debug(f"rsvp_pages: id={id}, page={page}")
rsvp = get_object_or_404(RSVP, id=id)
@@ -56,7 +113,7 @@ def rsvp_pages(request, id:str, page:str):
if page == "":
page = rsvp.page
context = create_context(rsvp)
context = create_context(rsvp, error)
rendercontent = {
"page_email": _render_page_email(request, context, page),
@@ -68,45 +125,52 @@ def rsvp_pages(request, id:str, page:str):
# Add `hx-push` header and set to value of `reverse('events:rsvp-base', args=[slug, rsvp.edit_token, rsvp.page])`
response = render(request, "events/event_pages.html", rendercontent)
response = render(request, "rsvp/rsvp_pages.html", rendercontent)
response["HX-Push-Url"] = reverse('events:rsvp-base', args=[get_event(context).slug, rsvp.edit_token, page])
return response
# Email page
def _render_page_email(request, context, page):
def _render_page_email(request, context, page) -> str:
should_render = _render_page(request, get_rsvp(context), "Email updates", RSVP.Page.EMAIL, page)
if(should_render != True): return should_render
note = summary_email(get_rsvp(context))
should_render = _render_page(request, get_rsvp(context), note, RSVP.Page.EMAIL, page)
if(type(should_render) is not bool):
return should_render
# Render the form
add_to_context(context, "form", RSVPForm(instance=get_rsvp(context)))
template = get_template("events/partials/rsvp_page_email.html")
template = get_template("rsvp/rsvp_page_email.html")
return template.render(context=context, request=request)
def _render_page_guest(request, context, page):
def _render_page_guest(request, context, page) -> str:
note = summary_guests(get_rsvp(context))
should_render = _render_page(request, get_rsvp(context), note, RSVP.Page.GUESTS, page)
if(type(should_render) is not bool):
return should_render
# Find guests
guests = (
Guest.objects
.filter(rsvp=get_rsvp(context))
)
note = f"You are coming with {len(guests)-1} guests"
if(len(guests) == 1): note = "You are coming!"
if(len(guests) == 0): note = "Nobody is coming (yet)"
should_render = _render_page(request, get_rsvp(context), note, RSVP.Page.GUESTS, page)
if(should_render != True): return should_render
add_to_context(context, "guests", guests)
add_to_context(context, "form", GuestForm())
template = get_template("events/partials/rsvp_page_guest.html")
template = get_template("rsvp/rsvp_page_guest.html")
return template.render(context=context, request=request)
def _render_page_activities(request, context, page):
def _render_page_activities(request, context, page) -> str:
note = summary_activities(get_rsvp(context))
should_render = _render_page(request, get_rsvp(context), note, RSVP.Page.ACTIVITIES, page)
if(type(should_render) is not bool):
return should_render
activities = (
Activity.objects
@@ -115,11 +179,6 @@ def _render_page_activities(request, context, page):
.order_by("start_time")
)
note = f"There are {len(activities)} activities to choose from"
should_render = _render_page(request, get_rsvp(context), note, RSVP.Page.ACTIVITIES, page)
if(should_render != True): return should_render
groups = {}
ungrouped = []
@@ -140,16 +199,18 @@ def _render_page_activities(request, context, page):
selected_activities_ids = [act.activity.id for act in selected_activities]
add_to_context(context, "selected_activities", selected_activities_ids)
template = get_template("events/partials/rsvp_page_activities.html")
template = get_template("rsvp/rsvp_page_activities.html")
return template.render(context=context, request=request)
def _render_page_questions(request, context, page):
def _render_page_questions(request, context, page) -> str:
"""
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:
note = summary_questions(get_rsvp(context))
should_render = _render_page(request, get_rsvp(context), note, RSVP.Page.QUESTIONS, page)
if(type(should_render) is not bool):
return should_render
rsvp = get_rsvp(context)
@@ -184,31 +245,35 @@ def _render_page_questions(request, context, page):
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")
template = get_template("rsvp/rsvp_page_questions.html")
return template.render(context=context, request=request)
def _render_page_notes(request, context, page):
def _render_page_notes(request, context, page) -> str:
should_render = _render_page(request, get_rsvp(context), "Notes", RSVP.Page.NOTES, page)
if(should_render != True): return should_render
note = summary_notes(get_rsvp(context))
should_render = _render_page(request, get_rsvp(context), note, RSVP.Page.NOTES, page)
if(type(should_render) is not bool):
return should_render
add_to_context(context, "form", NotesForm(instance=get_rsvp(context)))
template = get_template("events/partials/rsvp_page_notes.html")
template = get_template("rsvp/rsvp_page_notes.html")
return template.render(context=context, request=request)
# Render generic page placeholder
def _render_page(request, rsvp:RSVP, note:str, page:RSVP.Page, current_page:RSVP.Page) -> bool | str:
def _render_page(request, rsvp:RSVP, note:str, page:RSVP.Page, current_page:RSVP.Page, oob:bool=False) -> bool | str:
if page == current_page:
return True
template = get_template("events/partials/rsvp_page_inactive.html")
template = get_template("rsvp/rsvp_page_inactive.html")
context = {
"note": note,
"rsvp": rsvp,
"page": page,
"complete": rsvp.is_page_finished(page)
"complete": rsvp.is_page_finished(page),
"oob": oob
}
return template.render(context=context, request=request)
@@ -218,15 +283,28 @@ def page_change(request, id:str, page:str):
logger.debug(f"page_change: id={id}, page={page}")
rsvp = get_object_or_404(RSVP, id=id)
error = ""
if page == "next":
rsvp.next_page()
allowed = rsvp.is_next_page_allowed()
if(type(allowed) is bool and allowed == True):
rsvp.next_page()
elif type(allowed) is str:
# allowed contains an error
error = allowed
elif page == "previous":
rsvp.previous_page()
else:
rsvp.set_page(page)
return rsvp_pages(request, id, rsvp.page)
# Done with rsvp
if rsvp.page == RSVP.Page.COMPLETED:
#response = redirect(reverse('events:event', args=[rsvp.event.slug]))
response = HttpResponse()
response["HX-Redirect"] = reverse('events:event', args=[rsvp.event.slug])
return response
return rsvp_pages(request, id, rsvp.page, error=error)
# Email step: handle post data
def post_step_email(request, id:str):
@@ -296,6 +374,12 @@ def post_step_activities(request, id:str, aid:str):
return HttpResponseBadRequest(str(e))
content = _render_page_activities(request, create_context(rsvp), RSVP.Page.ACTIVITIES)
# update the guests summary
note = summary_questions(rsvp)
summary = _render_page(request, rsvp, note, RSVP.Page.QUESTIONS, RSVP.Page.ACTIVITIES, oob=True)
content = content + str(summary)
return HttpResponse(content)
+60 -50
View File
@@ -1,93 +1,103 @@
# views.py
from django.shortcuts import get_object_or_404, render, redirect
from django.db import transaction
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
import logging
from django.shortcuts import get_object_or_404, redirect, render
from django.http import Http404, HttpResponseBadRequest, HttpRequest
from django.urls import reverse
from events.utils.utils import create_rsvp
from ..forms import GuestForm
from events.utils.cookieutils import get_rsvp_from_cookie, set_rsvp_cookie
from events.utils.rsvputils import create_rsvp
from ..models import (
Event,
Guest,
Invite,
Activity,
ActivityGroup,
RSVP,
ActivitySelection,
)
def invite_redirect(request, slug, code):
# Logger
logger = logging.getLogger(__name__)
def invite_redirect(request:HttpRequest, slug, code):
"""
Redirect from the unique invite page
to the rsvp response page which is linked to the response
"""
event = get_object_or_404(Event, slug=slug)
invite = get_object_or_404(Invite, code=code)
# The invite code does not match the event
if invite.event.id != event.id:
return HttpResponseBadRequest()
response = redirect(reverse('events:event', args=[slug]))
logger.info(f"Invite `{code}` opened for Event `{slug}`")
rsvp:RSVP
# Create RSVP if it doesn't exist, otherwise get it
try:
invite = get_object_or_404(Invite, code=code)
# The invite code does not match the event
if invite.event.id != event.id:
return HttpResponseBadRequest()
rsvp = RSVP.objects.get(invite=invite)
except RSVP.DoesNotExist:
set_rsvp_cookie(response, event, rsvp)
except Http404 as e:
logger.debug(str(e))
pass
except RSVP.DoesNotExist as e:
logger.debug(str(e))
rsvp = create_rsvp(event, invite)
set_rsvp_cookie(response, event, rsvp)
return response
return redirect(reverse('events:rsvp-base', args=[slug, rsvp.edit_token, rsvp.page]))
def event_details(request, slug):
"""
RSVP page for an event.
Shows event info and list of activities.
"""
def event(request:HttpRequest, slug):
event = get_object_or_404(Event, slug=slug)
activities = (
Activity.objects
.filter(event=event, allow_rsvp=True)
.filter(event=event)
.select_related("group")
.order_by("start_time")
)
groups = {}
ungrouped = []
for activity in activities:
if activity.group:
groups.setdefault(activity.group, []).append(activity)
else:
ungrouped.append(activity)
context = {
"event": event,
"groups": groups,
"ungrouped_activities": ungrouped,
"allow_rsvp": event.allow_rsvp_without_invite,
"is_locked": event.is_locked(),
}
response = False
rsvp = get_rsvp_from_cookie(request, slug)
if type(rsvp) is RSVP:
joining = True if rsvp.status == RSVP.Status.YES_JOINING else False
not_coming = True if rsvp.status == RSVP.Status.NOT_COMING else False
in_progress = True if rsvp.status == RSVP.Status.INCOMPLETE else False
not_started = True if rsvp.status == RSVP.Status.NOT_STARTED else False
response = {
"rsvp": rsvp,
"joining": joining,
"not_coming": not_coming,
"in_progress": in_progress,
"not_started": not_started
}
context["response"] = response
return render(request, "events/event_details.html", context)
# //// ######## \\\\ #
# PARTIALS #
# //// ######## \\\\ #
def partial_activity_details(request, activity_id):
"""
HTMX partial: loads activity details (description + questions).
"""
activity = get_object_or_404(Activity, id=activity_id)
if not activity.allow_rsvp:
return HttpResponseForbidden()
return render(
request,
"events/partials/activity_detail.html",
{"activity": activity},
)
return render(request, "event/event.html", context)