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:
@@ -188,3 +188,6 @@ cython_debug/
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
# Custom
|
||||
events/static/img/*
|
||||
!events/static/img/placeholder*.jpg
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 |
@@ -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 %}
|
||||
+7
-2
@@ -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 %}
|
||||
|
||||
+6
-2
@@ -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 }}
|
||||
+1
@@ -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
-1
@@ -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>
|
||||
+2
-2
@@ -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
@@ -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",
|
||||
),
|
||||
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
Reference in New Issue
Block a user