Files
friend-event-rsvp/events/views/rsvp.py
T
kennyboy55 b1cf2deda1 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.
2026-03-25 10:54:24 +01:00

520 lines
18 KiB
Python

# views.py
import logging
import re
from django.forms import ValidationError
from django.shortcuts import get_object_or_404, render, redirect
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 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
from ..models import (
Event,
Guest,
Invite,
Activity,
ActivityGroup,
RSVP,
ActivitySelection,
Question,
QuestionChoice,
Response,
ResponseChoice,
)
# 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)
rsvp = get_object_or_404(RSVP, edit_token=code)
context = {
"rsvp": rsvp,
"event": event,
"invite": rsvp.invite,
"page": page,
"is_locked": event.is_locked()
}
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, error:str=""):
logger.debug(f"rsvp_pages: id={id}, page={page}")
rsvp = get_object_or_404(RSVP, id=id)
if page == "":
page = rsvp.page
context = create_context(rsvp, error)
rendercontent = {
"page_email": _render_page_email(request, context, page),
"page_guest": _render_page_guest(request, context, page),
"page_activities": _render_page_activities(request, context, page),
"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])
return response
# Email page
def _render_page_email(request, context, page) -> str:
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("rsvp/rsvp_page_email.html")
return template.render(context=context, request=request)
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
guests = (
Guest.objects
.filter(rsvp=get_rsvp(context))
)
add_to_context(context, "guests", guests)
add_to_context(context, "form", GuestForm())
template = get_template("rsvp/rsvp_page_guest.html")
return template.render(context=context, request=request)
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
.filter(event=get_event(context))
.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)
add_to_context(context, "groups", groups)
add_to_context(context, "ungrouped_activities", ungrouped)
selected_activities = (
ActivitySelection.objects
.filter(rsvp=get_rsvp(context))
)
selected_activities_ids = [act.activity.id for act in selected_activities]
add_to_context(context, "selected_activities", selected_activities_ids)
template = get_template("rsvp/rsvp_page_activities.html")
return template.render(context=context, request=request)
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.
"""
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)
# Get activities that were selected for this RSVP
selections = ActivitySelection.objects.filter(rsvp=rsvp).select_related("activity")
activities = [sel.activity for sel in selections]
# Get all questions for the selected activities, with related activity and choices prefetched
if activities:
questions = (
Question.objects
.filter(activity__in=activities)
.select_related("activity")
.prefetch_related("choices")
.order_by("activity__start_time", "activity__title", "order")
)
else:
questions = Question.objects.none()
# Guests are needed for guest-scoped questions
guests = Guest.objects.filter(rsvp=rsvp).order_by("name")
# Existing responses so templates can prefill current answers.
# RSVP-scoped responses
responses_rsvp = Response.objects.filter(rsvp=rsvp, question__in=questions).select_related("question").prefetch_related("choices")
# Guest-scoped responses (responses for guests of this rsvp)
responses_guest = Response.objects.filter(guest__rsvp=rsvp, question__in=questions).select_related("question", "guest").prefetch_related("choices")
add_to_context(context, "questions", questions)
add_to_context(context, "guests", guests)
add_to_context(context, "responses_rsvp", responses_rsvp)
add_to_context(context, "responses_guest", responses_guest)
template = get_template("rsvp/rsvp_page_questions.html")
return template.render(context=context, request=request)
def _render_page_notes(request, context, page) -> str:
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("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, oob:bool=False) -> bool | str:
if page == current_page:
return True
template = get_template("rsvp/rsvp_page_inactive.html")
context = {
"note": note,
"rsvp": rsvp,
"page": page,
"complete": rsvp.is_page_finished(page),
"oob": oob
}
return template.render(context=context, request=request)
# Change the page of the request
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":
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)
# 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):
rsvp = get_object_or_404(RSVP, id=id)
if request.method != 'POST':
return HttpResponseBadRequest()
form = RSVPForm(request.POST)
if form.is_valid():
rsvp.responder_email = form.cleaned_data['responder_email']
rsvp.send_email_updates = form.cleaned_data['send_email_updates']
rsvp.save()
return HttpResponse('Success', status=200)
return HttpResponseBadRequest(form.errors)
# Guest step: handle post data
def post_step_guest(request, id:str):
rsvp = get_object_or_404(RSVP, id=id)
if request.method != 'POST':
return HttpResponseBadRequest()
form = GuestForm(request.POST)
if form.is_valid():
guest = form.save(commit=False)
guest.event = rsvp.event
guest.invite = rsvp.invite
guest.rsvp = rsvp
guest.save()
content = _render_page_guest(request, create_context(rsvp), RSVP.Page.GUESTS)
return HttpResponse(content)
return HttpResponseBadRequest(form.errors)
# Guest step: handle post data
def delete_step_guest(request, id:str, gid:str):
rsvp = get_object_or_404(RSVP, id=id)
guest = get_object_or_404(Guest, id=gid)
if request.method != 'DELETE':
return HttpResponseBadRequest()
if guest.rsvp.id != rsvp.id:
return HttpResponseBadRequest()
guest.delete()
content = _render_page_guest(request, create_context(rsvp), RSVP.Page.GUESTS)
return HttpResponse(content)
# Guest step: handle post data
def post_step_activities(request, id:str, aid:str):
rsvp = get_object_or_404(RSVP, id=id)
activity = get_object_or_404(Activity, id=aid)
if request.method != 'POST':
return HttpResponseBadRequest()
try:
create_or_switch_activity_response(rsvp, activity)
except ValidationError as e:
logger.info(str(e))
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)
def _create_or_update_response(response_kwargs, value_text=None, choice_obj=None):
"""
Ensure a single Response exists for (activity, question, rsvp|guest).
- For non-choice questions: store `value_text` and clear any choices.
- For single-choice questions: set exactly one ResponseChoice (replace existing).
- For multiple-choice questions: add the provided choice (caller can call repeatedly for multiple selections).
"""
# Find existing response for this owner/question/activity
existing = Response.objects.filter(
activity=response_kwargs['activity'],
question=response_kwargs['question'],
rsvp=response_kwargs.get('rsvp'),
guest=response_kwargs.get('guest'),
).first()
# Create response if it doesn't exist
if not existing:
resp = Response(**response_kwargs)
# Clear choice-related storage; we'll attach choices via ResponseChoice below
resp.value_text = "" if choice_obj else (value_text or "")
resp.full_clean()
resp.save()
else:
resp = existing
if choice_obj:
# When attaching choices, keep value_text empty
resp.value_text = ""
resp.save()
else:
# Non-choice answer: set value_text and remove any existing selected choices
resp.value_text = value_text or ""
resp.save()
ResponseChoice.objects.filter(response=resp).delete()
# If a choice was supplied, attach it via ResponseChoice
if choice_obj:
# question kind influences behavior: single should replace, multiple should add
kind = response_kwargs['question'].kind if 'question' in response_kwargs else None
if kind == "single":
# Keep exactly this single choice for the response
ResponseChoice.objects.filter(response=resp).exclude(choice=choice_obj).delete()
ResponseChoice.objects.get_or_create(response=resp, choice=choice_obj)
else:
# multiple: ensure this choice exists (caller may call repeatedly for several choices)
ResponseChoice.objects.get_or_create(response=resp, choice=choice_obj)
return resp
# Question step: handle post data
def post_response_rsvp(request, id:str, qid:str):
if request.method != "POST":
return HttpResponseBadRequest()
rsvp = get_object_or_404(RSVP, id=id)
question = get_object_or_404(Question, id=qid)
activity = question.activity
# Parse inputs:
if question.kind == "single":
choice_id = request.POST.get("choice")
choice = get_object_or_404(QuestionChoice, id=choice_id) if choice_id else None
resp = _create_or_update_response(
{"activity": activity, "question": question, "rsvp": rsvp},
choice_obj=choice
)
elif question.kind == "multiple":
choice_ids = request.POST.getlist("choices")
# For simplicity: create one Response per choice (or replace existing)
# Implementation detail depends on Response model use-case.
# (Example: delete existing and create new ones)
Response.objects.filter(activity=activity, question=question, rsvp=rsvp).delete()
for cid in choice_ids:
choice = get_object_or_404(QuestionChoice, id=cid)
_create_or_update_response({"activity": activity, "question": question, "rsvp": rsvp}, choice_obj=choice)
elif question.kind == "boolean":
val = request.POST.get("value_bool")
_create_or_update_response({"activity": activity, "question": question, "rsvp": rsvp}, value_text=val)
else:
val = request.POST.get("value", "")
_create_or_update_response({"activity": activity, "question": question, "rsvp": rsvp}, value_text=val)
# Re-render the questions page (or the single question partial). For simplicity render the whole questions page:
content = _render_page_questions(request, create_context(rsvp), RSVP.Page.QUESTIONS)
return HttpResponse(content)
def post_response_guest(request, id:str, qid:str, gid:str):
if request.method != "POST":
return HttpResponseBadRequest()
rsvp = get_object_or_404(RSVP, id=id)
question = get_object_or_404(Question, id=qid)
guest = get_object_or_404(Guest, id=gid)
activity = question.activity
if question.scope != "guest":
return HttpResponseBadRequest("Question is not guest-scoped")
if question.kind == "single":
choice_id = request.POST.get("choice")
choice = get_object_or_404(QuestionChoice, id=choice_id) if choice_id else None
_create_or_update_response({"activity": activity, "question": question, "guest": guest}, choice_obj=choice)
elif question.kind == "multiple":
choice_ids = request.POST.getlist("choices")
Response.objects.filter(activity=activity, question=question, guest=guest).delete()
for cid in choice_ids:
choice = get_object_or_404(QuestionChoice, id=cid)
_create_or_update_response({"activity": activity, "question": question, "guest": guest}, choice_obj=choice)
elif question.kind == "boolean":
val = request.POST.get("value_bool")
_create_or_update_response({"activity": activity, "question": question, "guest": guest}, value_text=val)
else:
val = request.POST.get("value", "")
_create_or_update_response({"activity": activity, "question": question, "guest": guest}, value_text=val)
content = _render_page_questions(request, create_context(rsvp), RSVP.Page.QUESTIONS)
return HttpResponse(content)
# Guest step: handle post data
def post_step_notes(request, id:str):
rsvp = get_object_or_404(RSVP, id=id)
if request.method != 'POST':
return HttpResponseBadRequest()
form = NotesForm(request.POST)
if form.is_valid():
rsvp.notes = form.cleaned_data['notes']
rsvp.save()
content = _render_page_notes(request, create_context(rsvp), RSVP.Page.NOTES)
return HttpResponse(content)
return HttpResponseBadRequest(form.errors)