Refactor RSVP system and enhance event management features

- Added markdown support for event descriptions and activity details.
- Removed Invite model and adjusted RSVP to use a slug for invites.
- Enhanced guest management with real-time updates using websockets.
- Improved RSVP page layout and user experience with new button styles.
- Added message handling for user notifications.
- Updated URL patterns for better clarity and structure.
This commit is contained in:
2026-03-27 15:20:55 +01:00
parent b1cf2deda1
commit 8b0685d907
21 changed files with 369 additions and 216 deletions
+25 -12
View File
@@ -11,28 +11,41 @@ Everytime you start: `pipenv shell`
Check that is works by running `which python`
# To run
Requires Daphne (websockets)
`python manage.py runserver`
## So long as the migrations are not part of the repo
`python manage.py makemigrations`
`python manage.py migrate`
## To add a superuser
`python manage.py createsuperuser --username <username> --email <email>`
# Ideas and notes
## Een
## Twee
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
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!
## Twee
Admin paneel: 4 kolommen op basis van de status: open, incomplete, yes, no
Met een websocket.
En dat je dan ook live kan zien dat er gasten toegevoegd worden, en als ze dingen veranderen enzo.
## Vijf
## Drie
Als iemand geen gasten mag meenemen, die hele stap niet tonen.
## Vier
Activity start en eind tijd klopt niks van hoe het getoond wordt..
## Vijf
Extra gasten als property van een activity, die gewoon getoont worden als groep?
## Zes
Gasten (template): koppelen aan een invite!
De buttons tijdens een rsvp renderen serverside en met oob-swapping elke keer terugsturen:
- Geen previous op eerste pagina
- Geen next, maar finish op laatste pagina
- hx-confirm op back-to-event als nog niet voltooid.
# Things to keep in mind
- Move to Postgress
+4 -4
View File
@@ -2,11 +2,13 @@
from django.contrib import admin
from events.models import *
class ActivityAdmin(admin.ModelAdmin):
list_display = ('title', 'group', 'event', 'guest_limit', 'remaining_capacity', 'attendee_count', 'attendee_list',)
admin.site.register(Event)
admin.site.register(Activity)
admin.site.register(Activity, ActivityAdmin)
admin.site.register(ActivityGroup)
admin.site.register(ActivitySelection)
admin.site.register(Invite)
admin.site.register(RSVP)
admin.site.register(Guest)
admin.site.register(Question)
@@ -14,5 +16,3 @@ admin.site.register(QuestionChoice)
admin.site.register(Response)
admin.site.register(ResponseChoice)
admin.site.register(Comment)
admin.site.register(GuestListTemplate)
admin.site.register(GuestListItem)
+55 -56
View File
@@ -7,6 +7,7 @@ from django.conf import settings
from django.utils import timezone
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
import markdown
from events.utils.pageutils import is_next_page, is_previous_page
@@ -58,7 +59,11 @@ class Event(TimestampedModel):
return self.title
@property
def image_url(self) -> str:
def markdown(self) -> str:
return markdown.markdown(self.description)
@property
def static_image_url(self) -> str:
image_str = str(self.image)
if len(image_str) > 0:
return image_str[14:]
@@ -66,12 +71,12 @@ class Event(TimestampedModel):
@property
def start_time(self):
first = self.activities.order_by('start_time').first()
first = self.activities.order_by('start_time').first() # pyright: ignore[reportAttributeAccessIssue]
return first.start_time if first else None
@property
def end_time(self):
last = self.activities.order_by('-end_time').first()
last = self.activities.order_by('-end_time').first() # pyright: ignore[reportAttributeAccessIssue]
return last.end_time if last else None
@property
@@ -80,7 +85,7 @@ class Event(TimestampedModel):
return expiretime
def is_locked(self) -> bool:
last = self.activities.order_by('-end_time').first()
last = self.activities.order_by('-end_time').first() # pyright: ignore[reportAttributeAccessIssue]
if not last:
return False
return timezone.now() >= (last.start_time - self.response_lock_before)
@@ -120,6 +125,8 @@ class Activity(TimestampedModel):
title = models.CharField(max_length=255)
description = models.TextField(blank=True)
location = models.CharField(max_length=255)
start_time = models.DateTimeField()
end_time = models.DateTimeField(null=True, blank=True)
@@ -133,12 +140,21 @@ class Activity(TimestampedModel):
return f"{self.event.title}{self.title}"
@property
def image_url(self) -> str:
def markdown(self) -> str:
return markdown.markdown(self.description)
def static_image_url(self) -> str:
image_str = str(self.image)
if len(image_str) > 0:
return image_str[14:]
return "img/placeholder_activity.jpg"
def is_locked(self) -> bool:
if not self.allow_rsvp:
return True
return timezone.now() >= (self.start_time - self.event.response_lock_before)
def remaining_capacity(self) -> int:
if self.guest_limit is None:
return 999
@@ -237,19 +253,6 @@ class Activity(TimestampedModel):
return guest_list
class Invite(TimestampedModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
event = models.ForeignKey(Event, related_name='invites', on_delete=models.CASCADE)
code = models.CharField(max_length=64, unique=True, db_index=True)
recipient_name = models.CharField(max_length=255, blank=True)
recipient_email = models.EmailField(blank=True)
personalized_message = models.TextField(blank=True)
allow_bring_guests = models.BooleanField(default=False)
def __str__(self):
return f"Invite {self.code} -> {self.recipient_name or self.recipient_email}"
class RSVP(TimestampedModel):
class Page(models.TextChoices):
@@ -261,6 +264,7 @@ class RSVP(TimestampedModel):
COMPLETED = 'completed', _('Completed')
class Status(models.TextChoices):
UNOPENED = 'unopened', _('Not Opened')
NOT_STARTED = 'not_started', _('Not Started')
INCOMPLETE = 'incomplete', _('Incomplete')
NOT_COMING = 'not_coming', _('Not coming')
@@ -268,17 +272,20 @@ class RSVP(TimestampedModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
event = models.ForeignKey(Event, related_name='rsvps', on_delete=models.CASCADE)
invite = models.OneToOneField(Invite, null=True, blank=True, related_name='rsvp', on_delete=models.SET_NULL)
invite = models.SlugField(max_length=255, db_index=True, unique=True)
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)
personalized_message = models.TextField(blank=True)
allow_bring_guests = models.BooleanField(default=False)
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)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.UNOPENED)
notes = models.TextField(blank=True)
@@ -288,10 +295,10 @@ class RSVP(TimestampedModel):
def __str__(self):
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 guest_count(self) -> int:
return self.guests.count() # pyright: ignore[reportAttributeAccessIssue]
def selected_activities_ids(self):
selected_activities = (
ActivitySelection.objects
@@ -300,6 +307,17 @@ class RSVP(TimestampedModel):
selected_activities_ids = [act.activity.id for act in selected_activities]
return selected_activities_ids
@property
def name(self) -> str:
main_guest = self.guests.filter(is_main_invitee=True).first() # pyright: ignore[reportAttributeAccessIssue]
if(main_guest):
return main_guest.name
if(self.responder_name):
return self.responder_name
return "friend"
PAGE_ORDER = [
'email',
'guests',
@@ -326,6 +344,7 @@ class RSVP(TimestampedModel):
self.furthest_page = new_page
if self.status == self.Status.NOT_STARTED:
#TODO send an email here
self.status = self.Status.INCOMPLETE
self.page = new_page
@@ -400,7 +419,7 @@ class RSVP(TimestampedModel):
return f'Please answer required question: "{q.text}"'
elif q.scope == "guest":
for guest in self.guests.all():
for guest in self.guests.all(): # pyright: ignore[reportAttributeAccessIssue]
resp = Response.objects.filter(guest=guest, question=q).first()
if not resp:
return f'Please answer required question "{q.text}" for all guests.'
@@ -450,7 +469,6 @@ class Guest(TimestampedModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
event = models.ForeignKey(Event, related_name='guests', on_delete=models.CASCADE)
rsvp = models.ForeignKey(RSVP, related_name='guests', on_delete=models.CASCADE)
invite = models.ForeignKey(Invite, null=True, blank=True, related_name='guests', on_delete=models.SET_NULL)
name = models.CharField(max_length=255)
age_group = models.CharField(max_length=10, choices=AGE_GROUP, default='adult')
@@ -459,6 +477,15 @@ class Guest(TimestampedModel):
def __str__(self):
return f"{self.name} ({self.age_group})"
@property
def firstname(self) -> str:
return self.name.split(" ")[0]
@property
def lastname(self) -> str:
return self.name.split(" ")[1:]
class Question(TimestampedModel):
KIND_CHOICES = [
@@ -573,38 +600,10 @@ class ResponseChoice(TimestampedModel):
class Comment(TimestampedModel):
event = models.ForeignKey(Event, related_name='comments', on_delete=models.CASCADE)
name = models.CharField(max_length=255)
guest = models.ForeignKey(Guest, related_name='comments', on_delete=models.CASCADE)
text = models.TextField()
notify_subscribers = models.BooleanField(default=False)
def __str__(self):
return f"Comment by {self.name} on {self.event}"
class GuestListTemplate(TimestampedModel):
owner = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL)
title = models.CharField(max_length=255)
def __str__(self):
return self.title
class GuestListItem(TimestampedModel):
template = models.ForeignKey(GuestListTemplate, related_name='items', on_delete=models.CASCADE)
name = models.CharField(max_length=255)
email = models.EmailField(blank=True)
is_main_invitee = models.BooleanField(default=True)
def __str__(self):
return self.name
class GuestItemInvite(TimestampedModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
guest = models.ForeignKey(GuestListItem, related_name="guest_invite", on_delete=models.CASCADE)
invite = models.ForeignKey(Invite, related_name="guest_invite", on_delete=models.CASCADE)
class Meta:
unique_together = (("guest", "invite"),)
def __str__(self):
return f"{self.guest} added to {self.invite}"
return f"Comment by {self.guest.name} on {self.event.title}"
+64 -3
View File
@@ -1,9 +1,8 @@
import json
import logging
from asgiref.sync import async_to_sync, sync_to_async
from asgiref.sync import async_to_sync
from django.db.models.signals import post_save, post_delete
from django.db.models.signals import post_save, post_delete, pre_delete
from django.dispatch import receiver
from channels.layers import get_channel_layer
from events.models import RSVP, Activity, Event, ActivitySelection, Guest, Response
@@ -14,6 +13,7 @@ logger = logging.getLogger(__name__)
###
# Guests
###
@receiver(post_delete, sender=Guest)
def guest_deleted(sender, instance, **kwargs):
"""Delete question responses for this guest"""
@@ -21,9 +21,70 @@ def guest_deleted(sender, instance, **kwargs):
logger.debug(f"Signal {guest}")
guest_delete_responses(guest)
guest_notify_activities(guest)
def guest_delete_responses(guest:Guest):
# Remove all responses from the guest that is deleted
Response.objects.filter(guest=guest).delete()
def guest_notify_activities(guest:Guest):
"""Broadcast activity selection changes to all connected clients."""
rsvp:RSVP = guest.rsvp
event:Event = rsvp.event
# Get all activities with this RSVP
activities = rsvp.activities
for activity in activities.all():
logger.debug(f"Signal {activity}")
capacity = activity.remaining_capacity()
if capacity is None:
capacity = 100
activity_json = {
"activity": str(activity.id),
"rsvp": str(rsvp.id),
"action": "select",
"attendees": activity.attendee_count(),
"capacity": capacity,
"guests": activity.attendee_list()
}
# Send message to the group
async_to_sync(send_channel)(event.id, activity_json)
@receiver(post_save, sender=Guest)
def activity_guests_changed(sender, instance, created, **kwargs):
"""Broadcast activity selection changes to all connected clients."""
guest:Guest = instance
rsvp:RSVP = guest.rsvp
event:Event = rsvp.event
logger.debug(f"Guest added: {guest.name}")
# Get all activities with this RSVP
activities = rsvp.activities
for activity in activities.all():
logger.debug(f"Signal {activity}")
capacity = activity.remaining_capacity()
if capacity is None:
capacity = 100
activity_json = {
"activity": str(activity.id),
"rsvp": str(rsvp.id),
"action": "select",
"attendees": activity.attendee_count(),
"capacity": capacity,
"guests": activity.attendee_list()
}
# Send message to the group
async_to_sync(send_channel)(event.id, activity_json)
###
# Activity Selections
+17 -2
View File
@@ -181,12 +181,12 @@ button:hover, .button:hover, a.button:hover, input[type="submit"]:hover, input[t
}
.bg-gray {
background: gray !important;
background: darkgray !important;
}
.bd-gray {
border: 2px solid transparent;
border-color: gray !important;
border-color: darkgray !important;
}
.locked {
@@ -263,6 +263,17 @@ button {
margin: 0.25rem;
}
.button-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.button-row-right {
margin-left: auto;
}
/* media layout: text left, image right, image height matches text column */
.subcard .media-row {
display: flex;
@@ -311,4 +322,8 @@ button {
.group-title {
font-size: 0.85rem;
}
}
.hidden {
display: none;
}
+10
View File
@@ -98,19 +98,29 @@ function activitiesPage() {
// Activity capacity (when there is a guest limit)
const capacity = $('#activity-' + data.activity + ' .numcap');
const fullwarn = $('#activity-' + data.activity + ' .full-warning');
if(capacity !== null && data.capacity !== null){
capacity.html(data.capacity);
if(data.capacity == 0){
fullwarn.removeClass("hidden");
} else {
fullwarn.addClass("hidden");
}
}
// Activity capacity (when there is a guest limit)
const activity = $('#activity-' + data.activity);
const spacewarn = $('#activity-' + data.activity + ' .space-warning');
if(activity !== null && data.capacity !== null){
if(!(activity.hasClass("attending"))){
if(data.capacity >= guestCount){
activity.removeClass("bg-gray bd-gray");
spacewarn.addClass("hidden");
}
else{
activity.addClass("bg-gray bd-gray");
spacewarn.removeClass("hidden");
}
}
}
+8
View File
@@ -18,6 +18,14 @@
<body hx-headers='{"X-CSRFTOKEN": "{{ csrf_token }}"}' hx-ext="ws">
<main>
{% if messages %}
<div class="messages">
{% for message in messages %}
<div class="card {% if message.tags %}{{ message.tags }}{% endif %}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
+6 -6
View File
@@ -9,7 +9,7 @@
{% if event.description %}
<div class="subcard">
{{ event.description }}
{{ event.markdown|safe }}
</div>
{% endif %}
@@ -17,12 +17,12 @@
<div class="card">
{% load static %}
<img src="{% static event.image_url %}">
<img src="{% static event.static_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>
<div class="card {% if response.joining %}bg-green{% elif response.not_coming %}bg-red{% elif response.in_progress %}bg-orange{% endif %}">
<h3>Hello {{ response.rsvp.name }}!</h3>
{% if response.not_started %}
You have been invited! Please let us know if you are coming!
@@ -41,13 +41,13 @@
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>
<a href="{% url 'events:rsvp-restart' response.rsvp.id %}" class="button bg-accent 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-update' response.rsvp.id %}" class="button bg-accent 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 %}
@@ -1,38 +1,58 @@
<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>
<h3>{{ activity.title }} {% if activity.remaining_capacity <= 0 %}<span class="badge bg-orange">FULL</span>{% endif %}{% if activity.is_locked %}<span class="badge bg-red">LOCKED</span>{% endif %}</h3>
<p>
{{ activity.start_time|date:"DATETIME_FORMAT" }}
{% if activity.end_time %}
{{ activity.end_time|date:"TIME_FORMAT" }}
{% endif %}
</p>
<div class="subcard {% if response and activity.id in response.rsvp.selected_activities_ids %}bg-green attending{% endif %}">
<h4>📖 About</h4>
<p>{{ activity.markdown|safe }}</p>
</div>
{% 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 %}
<div class="subcard {% if response and activity.id in response.rsvp.selected_activities_ids %}bg-green attending{% endif %}">
<h4>🕙 When</h4>
<p>
{{ activity.start_time|date:"DATETIME_FORMAT" }}
{% if activity.end_time %}
{{ activity.end_time|date:"TIME_FORMAT" }}
{% endif %}
</p>
</div>
<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>
<div class="subcard {% if response and activity.id in response.rsvp.selected_activities_ids %}bg-green attending{% endif %}">
<h4>📍Where</h4>
<p>
{{ activity.location }}
</p>
</div>
<div class="subcard {% if response and activity.id in response.rsvp.selected_activities_ids %}bg-green attending{% endif %}">
<h4>👥Capacity</h4>
{% 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 %}
</div>
<div class="subcard {% if response and activity.id in response.rsvp.selected_activities_ids %}bg-green attending{% endif %}">
<h4>🚶‍➡️Attendees:</h4>
<span class="stratt">
{% for guest_list in activity.attendee_list %}
<span class="badge bg-blue">
{{ guest_list|join:", " }}
</span>
{% endfor %}
</span>
</div>
</div>
{% load static %}
<img class="thumbnail" src="{% static activity.image_url %}" alt="{{ activity.title }} image">
<img class="thumbnail" src="{% static activity.static_image_url %}" alt="{{ activity.title }} image">
</div>
</div>
+21 -12
View File
@@ -13,8 +13,8 @@
{% else %}
<h2 class="group-title">
Hello {{ invite.recipient_name }}
{% if invite.allow_bring_guests %}
Hello {{ rsvp.name }}
{% if rsvp.allow_bring_guests %}
<span class="badge bg-green">Allowed to bring guests</span>
{% endif %}
</h2>
@@ -24,18 +24,27 @@
hx-trigger="load">
</div>
<a class="button bg-orange hover"
hx-get="{% url 'events:rsvp-page-change' rsvp.id "previous" %}"
hx-target="#rsvp-flow"
>Previous</a>
<div class="button-row">
<a class="button bg-blue hover"
hx-get="{% url 'events:rsvp-page-change' rsvp.id "next" %}"
hx-target="#rsvp-flow"
>Next</a>
<a class="button bg-orange hover"
hx-get="{% url 'events:rsvp-page-change' rsvp.id "previous" %}"
hx-target="#rsvp-flow"
>Previous</a>
<a class="button bg-red hover"
href="{% url 'events:rsvp-decline' rsvp.id %}" >Not coming</a>
<a class="button bg-blue hover"
hx-get="{% url 'events:rsvp-page-change' rsvp.id "next" %}"
hx-target="#rsvp-flow"
>Next</a>
<div class="button-row-right">
<a class="button bg-red hover"
href="{% url 'events:rsvp-decline' rsvp.id %}" >Not coming</a>
<a class="button bg-green hover"
href="{% url 'events:event' event.slug %}" >Back to event</a>
</div>
</div>
{% endif %}
@@ -1,3 +1,4 @@
{% if not activity.is_locked %}
<div class="subcard cursor {% if activity.id in selected_activities %}bg-green attending{% elif activity.remaining_capacity < rsvp.guest_count %}bg-gray bd-gray{% endif %}"
hx-post="{% url 'events:rsvp-post-activity' rsvp.id activity.id %}"
hx-target="#rsvp-activity"
@@ -5,33 +6,46 @@
id="activity-{{activity.id}}"
>
<h3>{{ activity.title }}</h3>
<h3>{{ activity.title }} <span class="full-warning badge bg-orange {% if activity.remaining_capacity > 0 %}hidden{% endif %}">FULL</span></h3>
<p>
🕙
{{ activity.start_time|date:"DATETIME_FORMAT" }}
{% if activity.end_time %}
{{ activity.end_time|date:"TIME_FORMAT" }}
{% endif %}
</p>
<p>
📍{{ activity.location }}
</p>
{% if activity.guest_limit %}
<p>
Places remaining:
<span class="numcap">{{ activity.remaining_capacity }}</span> / {{ activity.guest_limit }}
👥Places remaining:
<span class="numcap">{{ activity.remaining_capacity }}</span> / {{ activity.guest_limit }}
<span class="space-warning badge bg-red {% if activity.id in selected_activities or activity.remaining_capacity >= rsvp.guest_count %}hidden{% endif %}">Not enough space for {{rsvp.guest_count}} people</span>
</p>
{% else %}
<p>
People going:
👥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 %}
{% if activity.attendee_list|length == 0 %}
<p>🚶‍➡️No-one is coming yet</p>
{% endif %}
</span>
</div>
</div>
{% else %}
<div class="subcard {% if activity.id in selected_activities %}bg-green attending{% endif %}">
<h3>{{ activity.title }} <span class="badge bg-red">LOCKED</span></h3>
</div>
{% endif %}
+4 -5
View File
@@ -1,21 +1,20 @@
<div class="card" id="rsvp-invite">
<div class="card" id="rsvp-email">
<h3>You are invited to {{ event.title }}</h3>
<p> {{ event.description }} </p>
<div class="subcard">
<h4>Message from the host</h4>
<p> {{ invite.personalized_message }} </p>
<p> {{ rsvp.personalized_message }} </p>
</div>
<div class="subcard">
<h4>Email updates</h4>
<span class="badge bg-green">Your email will never be shared with other people</span>
<span class="badge bg-gray">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">
<form method="post" hx-post="{% url 'events:rsvp-post-email' rsvp.id %}" hx-target="#post-result" style="margin-top:1em;">
{% csrf_token %}
{{ form.as_p }}
<button type="submit"><div id="post-result">Save</div></button>
+21 -7
View File
@@ -3,18 +3,26 @@
{% for guest in guests %}
<div class="subcard">
{{ guest.name }} <span class="badge">{{ guest.age_group }}</span>
<div class="button-row">
{{ guest.name }} <span class="badge">{{ guest.age_group }}</span>
<button
hx-delete="{% url 'events:rsvp-delete-guest' rsvp.id guest.id %}"
hx-target="#rsvp-guest"
hx-swap="outerHTML"
>Delete</button>
<div class="button-row-right">
{% if not guest.is_main_invitee %}
<button class="bg-red"
hx-delete="{% url 'events:rsvp-delete-guest' rsvp.id guest.id %}"
hx-target="#rsvp-guest"
hx-swap="outerHTML"
>Delete</button>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% if space_for_extra_guest %}
<div class="subcard" id="rsvp-guest-add">
<span class="badge bg-orange">Last names are not shown to other people</span>
<h4>Add guest <span class="badge bg-orange">Last names are not shown to other people</span></h4>
<form method="post" hx-post="{% url 'events:rsvp-post-guest' rsvp.id %}"
hx-target="#rsvp-guest"
hx-swap="outerHTML"
@@ -24,4 +32,10 @@
<button type="submit">Add guest</button>
</form>
</div>
{% else %}
<div class="subcard bg-red">
<h4>Add guest</h4>
<p>There is no space left in some of your selected activities.</p>
</div>
{% endif %}
</div>
@@ -10,4 +10,10 @@
{% for question in questions %}
{% include "rsvp/rsvp_page_questions_detail.html" %}
{% endfor %}
{% if questions|length <= 0 %}
<p>
There are no questions to answer.
</p>
{% endif %}
</div>
+5 -2
View File
@@ -7,12 +7,15 @@ from . import views
app_name = "events"
urlpatterns = [
# Main event page
path("events/<slug:slug>/", views.event, name="event"),
path("invite/<slug:slug>/rsvp/<str:code>/", views.invite_redirect, name="invite"),
# Invite URL, can only be used once
path("invite/<slug:slug>", views.invite_redirect, name="invite"),
# RSVP Form
path("events/<slug:slug>/rsvp/<uuid:code>/page/<str:page>", views.rsvp_base_page, name="rsvp-base"),
path("rsvp/<uuid:code>/page/<str:page>", views.rsvp_base_page, name="rsvp-base"),
path("htmx/rsvp/<str:id>/page/<str:page>", views.rsvp_pages, name="rsvp-pages"),
path(
+3 -25
View File
@@ -1,20 +1,7 @@
import json
from django.core.exceptions import ValidationError
from ..models import RSVP, Activity, ActivitySelection, Event, Invite
def create_rsvp(event: Event, invite: Invite):
rsvp = RSVP(event=event, invite=invite)
if(invite.recipient_name != ""):
rsvp.responder_name = invite.recipient_name
if(invite.recipient_email != ""):
rsvp.responder_email = invite.recipient_email
rsvp.save()
return rsvp
from ..models import RSVP, Activity, ActivitySelection, Event
def create_or_switch_activity_response(rsvp: RSVP, activity: Activity):
"""
@@ -40,7 +27,7 @@ def create_or_switch_activity_response(rsvp: RSVP, activity: Activity):
# Hardcoded, because why not
remaining_capacity = 100
if rsvp.guest_count() > remaining_capacity:
if rsvp.guest_count > remaining_capacity:
return
# Step 3: Check if new selection is illegal (choosing one in same group)
@@ -68,8 +55,7 @@ def create_or_switch_activity_response(rsvp: RSVP, activity: Activity):
def create_context(rsvp:RSVP, error:str="") -> dict:
context = {
"rsvp": rsvp,
"event": rsvp.event,
"invite": rsvp.invite
"event": rsvp.event
}
if(error != ""):
@@ -90,14 +76,6 @@ def get_rsvp(context:dict) -> RSVP:
return rsvp
def get_invite(context:dict) -> Invite:
invite = get_from_context(context, "invite")
if not invite:
raise KeyError
return invite
def get_event(context:dict) -> Event:
event = get_from_context(context, "event")
+3 -3
View File
@@ -13,13 +13,13 @@ 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()
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."
summary = f"Guests: You are coming with {guests} people."
return summary
@@ -31,7 +31,7 @@ def summary_activities(rsvp:RSVP) -> str:
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()
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:
+22 -16
View File
@@ -8,18 +8,18 @@ from django.db import transaction
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
from django.template.loader import get_template
from django.urls import reverse
from django.contrib import messages
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 ..utils.rsvputils import add_to_context, create_or_switch_activity_response, create_context, get_event, get_from_context, get_rsvp
from ..forms import GuestForm, NotesForm, RSVPForm
from ..models import (
Event,
Guest,
Invite,
Activity,
ActivityGroup,
RSVP,
@@ -40,7 +40,7 @@ def rsvp_start(request, id:str):
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]))
response = redirect(reverse('events:rsvp-base', args=[rsvp.edit_token, rsvp.page]))
return response
# Update a completed RSVP
@@ -53,7 +53,7 @@ def rsvp_update(request, id:str):
rsvp.page = RSVP.Page.ACTIVITIES
rsvp.save()
response = redirect(reverse('events:rsvp-base', args=[rsvp.event.slug, rsvp.edit_token, rsvp.page]))
response = redirect(reverse('events:rsvp-base', args=[rsvp.edit_token, rsvp.page]))
return response
# Stop an existing RSVP
@@ -81,26 +81,25 @@ def rsvp_restart(request, id:str):
rsvp.status = RSVP.Status.INCOMPLETE
rsvp.save()
response = redirect(reverse('events:rsvp-base', args=[rsvp.event.slug, rsvp.edit_token, rsvp.page]))
response = redirect(reverse('events:rsvp-base', args=[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)
def rsvp_base_page(request, code:str, page:str):
rsvp = get_object_or_404(RSVP, edit_token=code)
context = {
"rsvp": rsvp,
"event": event,
"event": rsvp.event,
"invite": rsvp.invite,
"page": page,
"is_locked": event.is_locked()
"is_locked": rsvp.event.is_locked()
}
response = render(request, "rsvp/rsvp.html", context)
set_rsvp_cookie(response, event, rsvp)
set_rsvp_cookie(response, rsvp.event, rsvp)
return response
@@ -122,11 +121,9 @@ def rsvp_pages(request, id:str, page:str, error:str=""):
"page_questions": _render_page_questions(request, context, page),
"page_notes": _render_page_notes(request, context, page)
}
# Add `hx-push` header and set to value of `reverse('events:rsvp-base', args=[slug, rsvp.edit_token, rsvp.page])`
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])
response["HX-Push-Url"] = reverse('events:rsvp-base', args=[rsvp.edit_token, page])
return response
# Email page
@@ -159,7 +156,16 @@ def _render_page_guest(request, context, page) -> str:
)
add_to_context(context, "guests", guests)
add_to_context(context, "form", GuestForm())
add_to_context(context, "form", GuestForm())
selected_activities = (
ActivitySelection.objects
.filter(rsvp=get_rsvp(context))
)
# Check if any of the activities is full
selected_activities_full = [(True if act.activity.remaining_capacity() < 1 else False) for act in selected_activities]
add_to_context(context, "space_for_extra_guest", True not in selected_activities_full)
template = get_template("rsvp/rsvp_page_guest.html")
return template.render(context=context, request=request)
@@ -299,7 +305,7 @@ def page_change(request, id:str, page:str):
# Done with rsvp
if rsvp.page == RSVP.Page.COMPLETED:
#response = redirect(reverse('events:event', args=[rsvp.event.slug]))
messages.success(request, "Thank you for responding!")
response = HttpResponse()
response["HX-Redirect"] = reverse('events:event', args=[rsvp.event.slug])
return response
+18 -28
View File
@@ -1,16 +1,16 @@
# views.py
from email import message
import logging
from django.shortcuts import get_object_or_404, redirect, render
from django.http import Http404, HttpResponseBadRequest, HttpRequest
from django.http import HttpRequest
from django.urls import reverse
from django.contrib import messages
from events.utils.cookieutils import get_rsvp_from_cookie, set_rsvp_cookie
from events.utils.rsvputils import create_rsvp
from ..models import (
Event,
Invite,
Activity,
RSVP,
)
@@ -18,36 +18,28 @@ from ..models import (
# Logger
logger = logging.getLogger(__name__)
def invite_redirect(request:HttpRequest, slug, code):
def invite_redirect(request:HttpRequest, slug:str):
"""
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)
response = redirect(reverse('events:event', args=[slug]))
rsvp = get_object_or_404(RSVP, invite=slug)
response = redirect(reverse('events:event', args=[rsvp.event.slug]))
logger.info(f"Invite `{code}` opened for Event `{slug}`")
# Opened for the first time
#TODO what is email not sent out?
if(rsvp.status == RSVP.Status.UNOPENED):
rsvp.status = RSVP.Status.NOT_STARTED
rsvp.save()
# Only set the cookie the first time, after this they need to use the invite link
set_rsvp_cookie(response, rsvp.event, rsvp)
else:
messages.warning(request, "You can only use the invite once, please use the link in the email.")
rsvp:RSVP
logger.info(f"Invite `{slug}` opened for Event `{rsvp.event.title}`")
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)
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
@@ -71,8 +63,6 @@ def event(request:HttpRequest, slug):
else:
ungrouped.append(activity)
context = {
"event": event,
"groups": groups,
@@ -88,7 +78,7 @@ def event(request:HttpRequest, slug):
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
not_started = True if rsvp.status == RSVP.Status.NOT_STARTED or rsvp.status == RSVP.Status.UNOPENED else False
response = {
"rsvp": rsvp,
+8
View File
@@ -138,6 +138,14 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
from django.contrib.messages import constants as message_constants
MESSAGE_TAGS = {
message_constants.INFO: "bg-blue",
message_constants.SUCCESS: "bg-green",
message_constants.WARNING: "bg-orange",
message_constants.ERROR: "bg-red",
}
# Internationalization
# https://docs.djangoproject.com/en/6.0/topics/i18n/
+1 -1
View File
@@ -18,6 +18,6 @@ from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('django-admin/', admin.site.urls),
path('', include('events.urls', namespace='events')),
]