Improve PyLint score
This commit is contained in:
Vendored
+4
-1
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"recommendations": ["otovo-oss.htmx-tags", "esbenp.prettier-vscode"]
|
||||
"recommendations": [
|
||||
"otovo-oss.htmx-tags",
|
||||
"ms-python.black-formatter"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+10
-10
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
},
|
||||
"python.analysis.autoImportCompletions": true,
|
||||
"python.analysis.autoFormatStrings": true,
|
||||
"python.analysis.completeFunctionParens": true,
|
||||
"python.analysis.enableTroubleshootMissingImports": true,
|
||||
"python.analysis.autoImportCompletions": true,
|
||||
"python.analysis.completeFunctionParens": true,
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"python.analysis.autoSearchPaths": true,
|
||||
|
||||
}
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "ms-python.black-formatter",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ Requirements (installed from Pipfile):
|
||||
- Daphne
|
||||
- BlackNoise
|
||||
|
||||
### Pylint
|
||||
|
||||
Make sure to have the Pipfile dev dependencies installed:
|
||||
|
||||
`pylint --load-plugins=pylint_django --django-settings-module=rsvpproject.settings --disable=C0111,C0301 rsvpproject events`
|
||||
|
||||
### Publish docker image
|
||||
|
||||
Build
|
||||
|
||||
+24
-2
@@ -1,9 +1,31 @@
|
||||
# Register your models here.
|
||||
from django.contrib import admin
|
||||
from events.models import *
|
||||
from events.models import (
|
||||
ActivityGroup,
|
||||
Comment,
|
||||
Event,
|
||||
Guest,
|
||||
Activity,
|
||||
RSVP,
|
||||
ActivitySelection,
|
||||
Question,
|
||||
QuestionChoice,
|
||||
Response,
|
||||
ResponseChoice,
|
||||
)
|
||||
|
||||
|
||||
class ActivityAdmin(admin.ModelAdmin):
|
||||
list_display = ('title', 'group', 'event', 'guest_limit', 'remaining_capacity', 'attendee_count', 'attendee_list',)
|
||||
list_display = (
|
||||
"title",
|
||||
"group",
|
||||
"event",
|
||||
"guest_limit",
|
||||
"remaining_capacity",
|
||||
"attendee_count",
|
||||
"attendee_list",
|
||||
)
|
||||
|
||||
|
||||
admin.site.register(Event)
|
||||
admin.site.register(Activity, ActivityAdmin)
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class EventsConfig(AppConfig):
|
||||
name = 'events'
|
||||
name = "events"
|
||||
|
||||
def ready(self):
|
||||
import events.signals
|
||||
import events.signals
|
||||
|
||||
+48
-49
@@ -4,75 +4,74 @@ from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
connected_websocket_consumers = 0
|
||||
CONNECTED_WEBSOCKET_CONSUMERS = 0
|
||||
|
||||
|
||||
class ActivityConsumer(AsyncWebsocketConsumer):
|
||||
|
||||
async def connect(self):
|
||||
global connected_websocket_consumers
|
||||
|
||||
global CONNECTED_WEBSOCKET_CONSUMERS
|
||||
|
||||
if "url_route" not in self.scope:
|
||||
raise ValueError
|
||||
|
||||
|
||||
self.event_id = self.scope["url_route"]["kwargs"]["event_id"]
|
||||
|
||||
logger.debug(f"Connection for Event={self.event_id}")
|
||||
|
||||
|
||||
logger.debug("Connection for Event=%s", self.event_id)
|
||||
|
||||
# Subscribe to database changes for this event
|
||||
await self.channel_layer.group_add(
|
||||
f"activity_{self.event_id}",
|
||||
self.channel_name
|
||||
f"activity_{self.event_id}", self.channel_name
|
||||
)
|
||||
|
||||
await self.accept()
|
||||
|
||||
connected_websocket_consumers += 1
|
||||
|
||||
logger.debug(f"Websocket for Event={self.event_id} opened ({connected_websocket_consumers} total)")
|
||||
|
||||
|
||||
await self.update_connected()
|
||||
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
global connected_websocket_consumers
|
||||
logger.debug(f"Disconnection for Event={self.event_id}")
|
||||
|
||||
await self.accept()
|
||||
|
||||
CONNECTED_WEBSOCKET_CONSUMERS += 1
|
||||
|
||||
logger.debug(
|
||||
"Websocket for Event=%s opened (%s total)",
|
||||
self.event_id,
|
||||
CONNECTED_WEBSOCKET_CONSUMERS,
|
||||
)
|
||||
|
||||
await self.update_connected()
|
||||
|
||||
async def disconnect(self, code):
|
||||
global CONNECTED_WEBSOCKET_CONSUMERS
|
||||
logger.debug("Disconnection for Event=%s", self.event_id)
|
||||
|
||||
# Remove from group on disconnect
|
||||
await self.channel_layer.group_discard(
|
||||
f"activity_{self.event_id}",
|
||||
self.channel_name
|
||||
f"activity_{self.event_id}", self.channel_name
|
||||
)
|
||||
connected_websocket_consumers -= 1
|
||||
|
||||
logger.debug(f"Websocket for Event={self.event_id} closed ({connected_websocket_consumers} total)")
|
||||
|
||||
CONNECTED_WEBSOCKET_CONSUMERS -= 1
|
||||
|
||||
logger.debug(
|
||||
"Websocket for Event=%s closed (%s total)",
|
||||
self.event_id,
|
||||
CONNECTED_WEBSOCKET_CONSUMERS,
|
||||
)
|
||||
|
||||
await self.update_connected()
|
||||
|
||||
|
||||
async def update_connected(self):
|
||||
"""Update all connected """
|
||||
"""Update all connected"""
|
||||
logger.debug("Sending connected update...")
|
||||
|
||||
await self.channel_layer.group_send(
|
||||
f"activity_{self.event_id}",
|
||||
{
|
||||
"type": "group.message",
|
||||
"activity_json": {} # connected will be filled
|
||||
}
|
||||
)
|
||||
|
||||
await self.channel_layer.group_send(
|
||||
f"activity_{self.event_id}",
|
||||
{"type": "group.message", "activity_json": {}}, # connected will be filled
|
||||
)
|
||||
|
||||
async def group_message(self, event):
|
||||
global connected_websocket_consumers
|
||||
|
||||
logger.debug(f"Message {self.event_id}: {event}")
|
||||
|
||||
logger.debug("Message Event=%s: `%s`", self.event_id, event)
|
||||
|
||||
activity_json = event.get("activity_json")
|
||||
logger.debug("Ready to send: " + str(activity_json))
|
||||
logger.debug("Ready to send: %s", activity_json)
|
||||
if activity_json is not None:
|
||||
activity_json["connected"] = connected_websocket_consumers
|
||||
activity_json["connected"] = CONNECTED_WEBSOCKET_CONSUMERS
|
||||
activity_str = json.dumps(activity_json)
|
||||
|
||||
logger.debug("Sending: " + str(activity_str))
|
||||
|
||||
await self.send(text_data=activity_str)
|
||||
|
||||
logger.debug("Sending: %s", activity_str)
|
||||
|
||||
await self.send(text_data=activity_str)
|
||||
|
||||
+7
-5
@@ -3,21 +3,23 @@ from django import forms
|
||||
from django.forms import Textarea
|
||||
from .models import RSVP, Guest
|
||||
|
||||
|
||||
class RSVPForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = RSVP
|
||||
fields = ['responder_email', 'send_email_updates']
|
||||
fields = ["responder_email", "send_email_updates"]
|
||||
|
||||
|
||||
class GuestForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Guest
|
||||
fields = ['name', 'age_group']
|
||||
|
||||
fields = ["name", "age_group"]
|
||||
|
||||
|
||||
class NotesForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = RSVP
|
||||
fields = ['notes']
|
||||
fields = ["notes"]
|
||||
widgets = {
|
||||
"notes": Textarea(attrs={"cols": 80, "rows": 6}),
|
||||
}
|
||||
|
||||
+245
-156
@@ -1,7 +1,6 @@
|
||||
# models.py
|
||||
import os
|
||||
import uuid
|
||||
from typing import Self
|
||||
from datetime import timedelta
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
@@ -25,15 +24,20 @@ class TimestampedModel(models.Model):
|
||||
|
||||
def event_image_path(instance, filename):
|
||||
# file will be uploaded to MEDIA_ROOT/<event_slug>/overview<.extension>
|
||||
return "{0}/overview{1}".format(instance.slug, os.path.splitext(filename)[1])
|
||||
extension = os.path.splitext(filename)[1]
|
||||
return f"{instance.slug}/overview{extension}"
|
||||
|
||||
|
||||
class Event(TimestampedModel):
|
||||
"""The main event which contains activities.
|
||||
|
||||
|
||||
An event can have multiple activities, invites, RSVPs, and guests.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
owner = models.ForeignKey(User, null=True, blank=True, related_name="+", on_delete=models.SET_NULL)
|
||||
owner = models.ForeignKey(
|
||||
User, null=True, blank=True, related_name="+", on_delete=models.SET_NULL
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255, db_index=True)
|
||||
@@ -54,7 +58,7 @@ class Event(TimestampedModel):
|
||||
cookie_expire_after_event = models.DurationField(default=timedelta(days=7))
|
||||
|
||||
class Meta:
|
||||
ordering = ['-id']
|
||||
ordering = ["-id"]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
@@ -65,21 +69,27 @@ class Event(TimestampedModel):
|
||||
|
||||
@property
|
||||
def start_time(self):
|
||||
first = self.activities.order_by('start_time').first() # pyright: ignore[reportAttributeAccessIssue]
|
||||
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() # pyright: ignore[reportAttributeAccessIssue]
|
||||
last = self.activities.order_by(
|
||||
"-end_time"
|
||||
).first() # pyright: ignore[reportAttributeAccessIssue]
|
||||
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() # pyright: ignore[reportAttributeAccessIssue]
|
||||
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)
|
||||
@@ -95,8 +105,11 @@ class Event(TimestampedModel):
|
||||
|
||||
class ActivityGroup(TimestampedModel):
|
||||
"""Optional grouping for activities; groups can enforce single-choice selection."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
event = models.ForeignKey(Event, related_name='activity_groups', on_delete=models.CASCADE)
|
||||
event = models.ForeignKey(
|
||||
Event, related_name="activity_groups", on_delete=models.CASCADE
|
||||
)
|
||||
title = models.CharField(max_length=255)
|
||||
single_choice = models.BooleanField(default=False)
|
||||
|
||||
@@ -105,22 +118,33 @@ class ActivityGroup(TimestampedModel):
|
||||
|
||||
|
||||
def activity_image_path(instance, filename):
|
||||
# file will be uploaded to MEDIA_ROOT/<event_slug>/overview<.extension>
|
||||
return "{0}/{1}{2}".format(instance.event.slug, instance.id, os.path.splitext(filename)[1])
|
||||
# file will be uploaded to MEDIA_ROOT/<event_slug>/<id><.extension>
|
||||
extension = os.path.splitext(filename)[1]
|
||||
return f"{instance.event.slug}/{instance.id}{extension}"
|
||||
|
||||
|
||||
class Activity(TimestampedModel):
|
||||
"""An individual activity within an event.
|
||||
|
||||
|
||||
Activities can be grouped into ActivityGroups. This is the main object that guests RSVP to.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
event = models.ForeignKey(Event, related_name='activities', on_delete=models.CASCADE)
|
||||
group = models.ForeignKey(ActivityGroup, null=True, blank=True, related_name='activities', on_delete=models.SET_NULL)
|
||||
event = models.ForeignKey(
|
||||
Event, related_name="activities", on_delete=models.CASCADE
|
||||
)
|
||||
group = models.ForeignKey(
|
||||
ActivityGroup,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="activities",
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
@@ -140,7 +164,7 @@ class Activity(TimestampedModel):
|
||||
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:
|
||||
@@ -154,14 +178,14 @@ class Activity(TimestampedModel):
|
||||
def attendee_list(self) -> list[list[str]]:
|
||||
"""
|
||||
Create a json list of names who are joining this activity
|
||||
|
||||
|
||||
Context:
|
||||
- An activity is linked to RSVPs in ActivitySelection
|
||||
- An RSVP has a guests property which is a list of Guest objects for that group
|
||||
- A guest can have the main_invitee property set, but not neceserilly
|
||||
- Each Guest has a name property
|
||||
- A name can be: "John", or "John Doe", or "John Peter Doe", or for foreign guests: "John van Doe"
|
||||
|
||||
|
||||
Steps:
|
||||
1. Get the RSVPs which are coming to Activity (using ActivitySelection)
|
||||
2. For each RSVP, get the guest list
|
||||
@@ -172,27 +196,25 @@ class Activity(TimestampedModel):
|
||||
For example: "John D.", or "John van D."
|
||||
3. Create a list of lists: [['John D.', 'Jane', 'Jack H.'], ['Maria'], ['John van D.', 'Peter'], ['Jack K.']]
|
||||
4. Return this list
|
||||
|
||||
|
||||
"""
|
||||
|
||||
selected_rsvps = ActivitySelection.objects.filter(activity=self).values_list('rsvp', flat=True)
|
||||
|
||||
|
||||
selected_rsvps = ActivitySelection.objects.filter(activity=self).values_list(
|
||||
"rsvp", flat=True
|
||||
)
|
||||
|
||||
if not selected_rsvps:
|
||||
return []
|
||||
|
||||
age_order = {
|
||||
'adult': 0,
|
||||
'child': 1,
|
||||
'baby': 2
|
||||
}
|
||||
|
||||
|
||||
age_order = {"adult": 0, "child": 1, "baby": 2}
|
||||
|
||||
def sort_key(guest):
|
||||
# main_invitee first (False = 0, True = 1)
|
||||
main_invitee_priority = 0 if guest.is_main_invitee else 1
|
||||
# Then by age group (old to young)
|
||||
age_priority = age_order.get(guest.age_group, 2)
|
||||
return (main_invitee_priority, age_priority)
|
||||
|
||||
|
||||
# Store all first names
|
||||
first_names_tracker = {}
|
||||
for rsvp_id in selected_rsvps:
|
||||
@@ -200,7 +222,7 @@ class Activity(TimestampedModel):
|
||||
for guest in guests:
|
||||
name_parts = guest.name.split()
|
||||
first_name = name_parts[0]
|
||||
|
||||
|
||||
if first_name in first_names_tracker:
|
||||
first_names_tracker[first_name] += 1
|
||||
else:
|
||||
@@ -209,57 +231,62 @@ class Activity(TimestampedModel):
|
||||
# Create the lists
|
||||
guest_list = []
|
||||
for rsvp_id in selected_rsvps:
|
||||
guests_non_sort = Guest.objects.filter(rsvp=rsvp_id, )
|
||||
guests_non_sort = Guest.objects.filter(
|
||||
rsvp=rsvp_id,
|
||||
)
|
||||
guests = sorted(guests_non_sort, key=sort_key)
|
||||
|
||||
|
||||
rsvp_list = []
|
||||
|
||||
|
||||
for guest in guests:
|
||||
name_parts = guest.name.split()
|
||||
first_name = name_parts[0]
|
||||
|
||||
|
||||
# If no duplicate and no last name, just first_name
|
||||
name_to_store = first_name
|
||||
|
||||
|
||||
# Check if name is duplicate
|
||||
if first_name in first_names_tracker and first_names_tracker[first_name] > 1:
|
||||
if (
|
||||
first_name in first_names_tracker
|
||||
and first_names_tracker[first_name] > 1
|
||||
):
|
||||
if len(name_parts) == 2:
|
||||
name_to_store = first_name + " " + name_parts[-1][0] + "."
|
||||
elif len(name_parts) > 2:
|
||||
name_to_store = first_name
|
||||
|
||||
|
||||
for name_part in name_parts[1:-1]:
|
||||
name_to_store += " " + name_part
|
||||
|
||||
|
||||
name_to_store += " " + name_parts[-1][0] + "."
|
||||
|
||||
|
||||
rsvp_list += [name_to_store]
|
||||
|
||||
|
||||
guest_list += [rsvp_list]
|
||||
|
||||
|
||||
# Step 5: Return as JSON string
|
||||
return guest_list
|
||||
|
||||
|
||||
class RSVP(TimestampedModel):
|
||||
|
||||
|
||||
class Page(models.TextChoices):
|
||||
EMAIL = 'email', _('Email')
|
||||
GUESTS = 'guests', _('Guests')
|
||||
ACTIVITIES = 'activities', _('Activities')
|
||||
QUESTIONS = 'questions', _('Questions')
|
||||
NOTES = 'notes', _('Notes')
|
||||
COMPLETED = 'completed', _('Completed')
|
||||
EMAIL = "email", _("Email")
|
||||
GUESTS = "guests", _("Guests")
|
||||
ACTIVITIES = "activities", _("Activities")
|
||||
QUESTIONS = "questions", _("Questions")
|
||||
NOTES = "notes", _("Notes")
|
||||
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')
|
||||
YES_JOINING = 'joining', _('Yes, joining')
|
||||
UNOPENED = "unopened", _("Not Opened")
|
||||
NOT_STARTED = "not_started", _("Not Started")
|
||||
INCOMPLETE = "incomplete", _("Incomplete")
|
||||
NOT_COMING = "not_coming", _("Not coming")
|
||||
YES_JOINING = "joining", _("Yes, joining")
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
event = models.ForeignKey(Event, related_name='rsvps', on_delete=models.CASCADE)
|
||||
event = models.ForeignKey(Event, related_name="rsvps", on_delete=models.CASCADE)
|
||||
|
||||
invite = models.SlugField(max_length=255, db_index=True, unique=True)
|
||||
edit_token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||
@@ -267,52 +294,57 @@ class RSVP(TimestampedModel):
|
||||
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.UNOPENED)
|
||||
furthest_page = models.CharField(
|
||||
max_length=16, choices=Page.choices, default=Page.EMAIL
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.UNOPENED
|
||||
)
|
||||
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
# Activities selected by this RSVP (through ActivitySelection)
|
||||
activities = models.ManyToManyField(Activity, through='ActivitySelection', related_name='rsvps')
|
||||
activities = models.ManyToManyField(
|
||||
Activity, through="ActivitySelection", related_name="rsvps"
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"RSVP {self.edit_token} — {self.event.title} — {self.responder_email} — {self.status}"
|
||||
|
||||
|
||||
@property
|
||||
def guest_count(self) -> int:
|
||||
return self.guests.count() # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
return self.guests.count() # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
def selected_activities_ids(self):
|
||||
selected_activities = (
|
||||
ActivitySelection.objects
|
||||
.filter(rsvp=self)
|
||||
)
|
||||
selected_activities = ActivitySelection.objects.filter(rsvp=self)
|
||||
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):
|
||||
main_guest = self.guests.filter(
|
||||
is_main_invitee=True
|
||||
).first() # pyright: ignore[reportAttributeAccessIssue]
|
||||
if main_guest:
|
||||
return main_guest.name
|
||||
|
||||
if(self.responder_name):
|
||||
|
||||
if self.responder_name:
|
||||
return self.responder_name
|
||||
|
||||
|
||||
return "friend"
|
||||
|
||||
|
||||
PAGE_ORDER = [
|
||||
'email',
|
||||
'guests',
|
||||
'activities',
|
||||
'questions',
|
||||
'notes',
|
||||
'completed',
|
||||
"email",
|
||||
"guests",
|
||||
"activities",
|
||||
"questions",
|
||||
"notes",
|
||||
"completed",
|
||||
]
|
||||
|
||||
def next_page(self) -> Page:
|
||||
@@ -327,28 +359,28 @@ class RSVP(TimestampedModel):
|
||||
except ValueError:
|
||||
# If page is not in the list (e.g., INVALID), start from EMAIL
|
||||
pass
|
||||
|
||||
|
||||
if is_next_page(new_page, self.Page(self.furthest_page)):
|
||||
self.furthest_page = new_page
|
||||
|
||||
|
||||
if self.status == self.Status.NOT_STARTED:
|
||||
#TODO send an email here
|
||||
# TODO send an email here
|
||||
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
|
||||
|
||||
def previous_page(self) -> Page:
|
||||
"""Move to the previous page and save to model"""
|
||||
|
||||
|
||||
new_page = self.page
|
||||
|
||||
|
||||
try:
|
||||
current_index = self.PAGE_ORDER.index(self.page)
|
||||
if current_index > 0:
|
||||
@@ -356,72 +388,93 @@ class RSVP(TimestampedModel):
|
||||
except ValueError:
|
||||
# If page is not in the list (e.g., INVALID), stay at INVALID
|
||||
pass
|
||||
|
||||
|
||||
self.page = new_page
|
||||
self.save()
|
||||
|
||||
|
||||
return new_page
|
||||
|
||||
def set_page(self, page:str) -> Page:
|
||||
|
||||
def set_page(self, page: str) -> Page:
|
||||
"""Move to the specific page and save to model"""
|
||||
|
||||
|
||||
new_page = self.page
|
||||
|
||||
|
||||
try:
|
||||
new_page = self.Page(page)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
self.page = new_page
|
||||
self.save()
|
||||
|
||||
|
||||
return new_page
|
||||
|
||||
def is_page_finished(self, page:Page) -> bool:
|
||||
|
||||
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:
|
||||
|
||||
def is_next_page_allowed(self) -> tuple[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
|
||||
return False, "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)
|
||||
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}"'
|
||||
return False, 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}"'
|
||||
return (
|
||||
False,
|
||||
f'Please answer required question: "{q.text}"',
|
||||
)
|
||||
else: # single / multiple choice
|
||||
if resp.choices.count() == 0:
|
||||
return f'Please answer required question: "{q.text}"'
|
||||
return (
|
||||
False,
|
||||
f'Please answer required question: "{q.text}"',
|
||||
)
|
||||
|
||||
elif q.scope == "guest":
|
||||
for guest in self.guests.all(): # pyright: ignore[reportAttributeAccessIssue]
|
||||
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.'
|
||||
return (
|
||||
False,
|
||||
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.'
|
||||
return (
|
||||
False,
|
||||
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 (
|
||||
False,
|
||||
f'Please answer required question "{q.text}" for all guests.',
|
||||
)
|
||||
|
||||
return True
|
||||
return True, ""
|
||||
|
||||
# Default: allow moving forward
|
||||
return True
|
||||
return True, ""
|
||||
|
||||
|
||||
class ActivitySelection(models.Model):
|
||||
@@ -431,87 +484,102 @@ class ActivitySelection(models.Model):
|
||||
because cross-table unique constraints involving a related table's column can't be expressed simply
|
||||
as a DB constraint here.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
rsvp = models.ForeignKey(RSVP, related_name='activity_selections', on_delete=models.CASCADE)
|
||||
activity = models.ForeignKey(Activity, related_name='activity_selections', on_delete=models.CASCADE)
|
||||
rsvp = models.ForeignKey(
|
||||
RSVP, related_name="activity_selections", on_delete=models.CASCADE
|
||||
)
|
||||
activity = models.ForeignKey(
|
||||
Activity, related_name="activity_selections", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = (('rsvp', 'activity'),)
|
||||
unique_together = (("rsvp", "activity"),)
|
||||
|
||||
def clean(self):
|
||||
# If the activity belongs to a group that is single_choice, ensure the RSVP has no other selections
|
||||
group = self.activity.group
|
||||
if group and group.single_choice:
|
||||
other = ActivitySelection.objects.filter(rsvp=self.rsvp, activity__group=group).exclude(pk=self.pk)
|
||||
other = ActivitySelection.objects.filter(
|
||||
rsvp=self.rsvp, activity__group=group
|
||||
).exclude(pk=self.pk)
|
||||
if other.exists():
|
||||
raise ValidationError(f"You may only select one activity from the group '{group.title}'.")
|
||||
raise ValidationError(
|
||||
f"You may only select one activity from the group '{group.title}'."
|
||||
)
|
||||
|
||||
|
||||
class Guest(TimestampedModel):
|
||||
AGE_GROUP = [
|
||||
("baby", "Baby (0-2)"),
|
||||
("child", "Child (3-15)"),
|
||||
("adult", "Adult (15+)")
|
||||
("adult", "Adult (15+)"),
|
||||
]
|
||||
|
||||
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)
|
||||
event = models.ForeignKey(Event, related_name="guests", on_delete=models.CASCADE)
|
||||
rsvp = models.ForeignKey(RSVP, related_name="guests", on_delete=models.CASCADE)
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
age_group = models.CharField(max_length=10, choices=AGE_GROUP, default='adult')
|
||||
age_group = models.CharField(max_length=10, choices=AGE_GROUP, default="adult")
|
||||
is_main_invitee = models.BooleanField(default=False)
|
||||
created_by_host = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.age_group})"
|
||||
|
||||
|
||||
@property
|
||||
def firstname(self) -> str:
|
||||
return self.name.split(" ")[0]
|
||||
|
||||
return self.name.split(" ", maxsplit=1)[0]
|
||||
|
||||
@property
|
||||
def lastname(self) -> str:
|
||||
return self.name.split(" ")[1:]
|
||||
|
||||
return self.name.rsplit(" ", maxsplit=1)[-1]
|
||||
|
||||
|
||||
class Question(TimestampedModel):
|
||||
KIND_CHOICES = [
|
||||
('text', 'Text'),
|
||||
('single', 'Single choice'),
|
||||
('multiple', 'Multiple choice'),
|
||||
('integer', 'Integer'),
|
||||
('boolean', 'Yes/No')
|
||||
("text", "Text"),
|
||||
("single", "Single choice"),
|
||||
("multiple", "Multiple choice"),
|
||||
("integer", "Integer"),
|
||||
("boolean", "Yes/No"),
|
||||
]
|
||||
|
||||
|
||||
SCOPE_CHOICES = [
|
||||
("rsvp", "Once per RSVP"),
|
||||
("guest", "Once per Guest"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
activity = models.ForeignKey(Activity, related_name='questions', on_delete=models.CASCADE)
|
||||
|
||||
activity = models.ForeignKey(
|
||||
Activity, related_name="questions", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
text = models.CharField(max_length=1000)
|
||||
help_text = models.CharField(max_length=1000, blank=True)
|
||||
|
||||
kind = models.CharField(max_length=10, choices=KIND_CHOICES, default='text')
|
||||
|
||||
kind = models.CharField(max_length=10, choices=KIND_CHOICES, default="text")
|
||||
scope = models.CharField(max_length=10, choices=SCOPE_CHOICES, default="rsvp")
|
||||
required = models.BooleanField(default=False)
|
||||
|
||||
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
|
||||
def __str__(self):
|
||||
return f"Q: {self.text} (on `{self.activity} - {self.scope}` )"
|
||||
|
||||
|
||||
class QuestionChoice(models.Model):
|
||||
question = models.ForeignKey(Question, related_name='choices', on_delete=models.CASCADE)
|
||||
question = models.ForeignKey(
|
||||
Question, related_name="choices", on_delete=models.CASCADE
|
||||
)
|
||||
value = models.CharField(max_length=255)
|
||||
label = models.CharField(max_length=255)
|
||||
|
||||
def __str__(self):
|
||||
return self.label
|
||||
|
||||
|
||||
class Response(TimestampedModel):
|
||||
"""Answer to a Question. Questions live on Activities.
|
||||
|
||||
@@ -521,15 +589,26 @@ class Response(TimestampedModel):
|
||||
|
||||
To keep the model simple, question-level scoping is enforced at form/validation time.
|
||||
"""
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
activity = models.ForeignKey(Activity, related_name='responses', on_delete=models.CASCADE)
|
||||
question = models.ForeignKey(Question, related_name='responses', on_delete=models.CASCADE)
|
||||
|
||||
rsvp = models.ForeignKey(RSVP, null=True, blank=True, related_name='responses', on_delete=models.CASCADE)
|
||||
guest = models.ForeignKey(Guest, null=True, blank=True, related_name='responses', on_delete=models.CASCADE)
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
activity = models.ForeignKey(
|
||||
Activity, related_name="responses", on_delete=models.CASCADE
|
||||
)
|
||||
question = models.ForeignKey(
|
||||
Question, related_name="responses", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
rsvp = models.ForeignKey(
|
||||
RSVP, null=True, blank=True, related_name="responses", on_delete=models.CASCADE
|
||||
)
|
||||
guest = models.ForeignKey(
|
||||
Guest, null=True, blank=True, related_name="responses", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
# Choice (single) if applicable
|
||||
choices = models.ManyToManyField(QuestionChoice, through="ResponseChoice", related_name="responses", blank=True)
|
||||
choices = models.ManyToManyField(
|
||||
QuestionChoice, through="ResponseChoice", related_name="responses", blank=True
|
||||
)
|
||||
value_text = models.TextField(blank=True)
|
||||
|
||||
def clean(self):
|
||||
@@ -550,7 +629,9 @@ class Response(TimestampedModel):
|
||||
pass
|
||||
else:
|
||||
if self.choices.count() != 1:
|
||||
raise ValidationError("Single-choice question requires exactly one choice.")
|
||||
raise ValidationError(
|
||||
"Single-choice question requires exactly one choice."
|
||||
)
|
||||
if kind == "multiple":
|
||||
if not self.pk:
|
||||
pass
|
||||
@@ -563,22 +644,29 @@ class Response(TimestampedModel):
|
||||
if self.question.activity.id != self.activity.id:
|
||||
raise ValidationError("Question must belong to the same activity.")
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
whom = f"{self.question.scope}:"
|
||||
if(self.question.scope == "rsvp"):
|
||||
if self.question.scope == "rsvp":
|
||||
whom += " " + self.rsvp.responder_name
|
||||
elif(self.question.scope == "guest"):
|
||||
elif self.question.scope == "guest":
|
||||
whom += " " + self.guest.name
|
||||
|
||||
|
||||
if self.question.kind in ("text", "integer", "boolean"):
|
||||
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} by {whom}: {self.choices.all}"
|
||||
|
||||
return f"Response to {self.question}"
|
||||
|
||||
|
||||
class ResponseChoice(TimestampedModel):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
response = models.ForeignKey("Response", related_name="response_choices", on_delete=models.CASCADE)
|
||||
choice = models.ForeignKey(QuestionChoice, related_name="response_choices", on_delete=models.CASCADE)
|
||||
response = models.ForeignKey(
|
||||
"Response", related_name="response_choices", on_delete=models.CASCADE
|
||||
)
|
||||
choice = models.ForeignKey(
|
||||
QuestionChoice, related_name="response_choices", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = (("response", "choice"),)
|
||||
@@ -586,12 +674,13 @@ class ResponseChoice(TimestampedModel):
|
||||
def __str__(self):
|
||||
return f"{self.choice} for {self.response}"
|
||||
|
||||
|
||||
class Comment(TimestampedModel):
|
||||
event = models.ForeignKey(Event, related_name='comments', on_delete=models.CASCADE)
|
||||
guest = models.ForeignKey(Guest, related_name='comments', on_delete=models.CASCADE)
|
||||
|
||||
event = models.ForeignKey(Event, related_name="comments", on_delete=models.CASCADE)
|
||||
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.guest.name} on {self.event.title}"
|
||||
return f"Comment by {self.guest.name} on {self.event.title}"
|
||||
|
||||
+5
-2
@@ -4,5 +4,8 @@ from django.urls import re_path
|
||||
from . import consumers
|
||||
|
||||
websocket_urlpatterns = [
|
||||
re_path(r"ws/event/(?P<event_id>[0-9a-f-]{8}-[0-9a-f-]{4}-[0-9a-f-]{4}-[0-9a-f-]{4}-[0-9a-f-]{12})/$", consumers.ActivityConsumer.as_asgi()),
|
||||
]
|
||||
re_path(
|
||||
r"ws/event/(?P<event_id>[0-9a-f-]{8}-[0-9a-f-]{4}-[0-9a-f-]{4}-[0-9a-f-]{4}-[0-9a-f-]{12})/$",
|
||||
consumers.ActivityConsumer.as_asgi(),
|
||||
),
|
||||
]
|
||||
|
||||
+57
-53
@@ -2,7 +2,7 @@ import logging
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
from django.db.models.signals import post_save, post_delete, pre_delete
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
from channels.layers import get_channel_layer
|
||||
from events.models import RSVP, Activity, Event, ActivitySelection, Guest, Response
|
||||
@@ -14,141 +14,145 @@ logger = logging.getLogger(__name__)
|
||||
# Guests
|
||||
###
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Guest)
|
||||
def guest_deleted(sender, instance, **kwargs):
|
||||
"""Delete question responses for this guest"""
|
||||
guest:Guest = instance
|
||||
|
||||
logger.debug(f"Signal {guest}")
|
||||
|
||||
guest: Guest = instance
|
||||
|
||||
logger.debug("Signal guest=`%s`", guest)
|
||||
|
||||
guest_delete_responses(guest)
|
||||
guest_notify_activities(guest)
|
||||
|
||||
def guest_delete_responses(guest: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):
|
||||
|
||||
def guest_notify_activities(guest: Guest):
|
||||
"""Broadcast activity selection changes to all connected clients."""
|
||||
rsvp:RSVP = guest.rsvp
|
||||
event:Event = rsvp.event
|
||||
|
||||
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}")
|
||||
|
||||
logger.debug("Signal activity=`%s`", 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()
|
||||
"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}")
|
||||
|
||||
guest: Guest = instance
|
||||
rsvp: RSVP = guest.rsvp
|
||||
event: Event = rsvp.event
|
||||
|
||||
logger.debug("Guest added: name=`%s`", guest.name)
|
||||
|
||||
# Get all activities with this RSVP
|
||||
activities = rsvp.activities
|
||||
|
||||
|
||||
for activity in activities.all():
|
||||
logger.debug(f"Signal {activity}")
|
||||
|
||||
logger.debug("Signal activity=`%s`", 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()
|
||||
"guests": activity.attendee_list(),
|
||||
}
|
||||
|
||||
|
||||
# Send message to the group
|
||||
async_to_sync(send_channel)(event.id, activity_json)
|
||||
|
||||
|
||||
###
|
||||
# Activity Selections
|
||||
###
|
||||
|
||||
|
||||
@receiver(post_save, sender=ActivitySelection)
|
||||
def activity_selection_changed(sender, instance, created, **kwargs):
|
||||
"""Broadcast activity selection changes to all connected clients."""
|
||||
activity:Activity = instance.activity
|
||||
rsvp:RSVP = instance.rsvp
|
||||
event:Event = activity.event
|
||||
|
||||
logger.debug(f"Signal {activity}")
|
||||
|
||||
activity: Activity = instance.activity
|
||||
rsvp: RSVP = instance.rsvp
|
||||
event: Event = activity.event
|
||||
|
||||
logger.debug("Signal activity=`%s`", 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()
|
||||
"guests": activity.attendee_list(),
|
||||
}
|
||||
|
||||
|
||||
# Send message to the group
|
||||
async_to_sync(send_channel)(event.id, activity_json)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=ActivitySelection)
|
||||
def activity_selection_deleted(sender, instance, **kwargs):
|
||||
"""Broadcast activity selection changes to all connected clients."""
|
||||
activity:Activity = instance.activity
|
||||
rsvp:RSVP = instance.rsvp
|
||||
event:Event = activity.event
|
||||
|
||||
logger.debug(f"Signal {activity}")
|
||||
|
||||
activity: Activity = instance.activity
|
||||
rsvp: RSVP = instance.rsvp
|
||||
event: Event = activity.event
|
||||
|
||||
logger.debug("Signal activity=`%s`", activity)
|
||||
|
||||
capacity = activity.remaining_capacity()
|
||||
if capacity is None:
|
||||
capacity = 100
|
||||
|
||||
|
||||
activity_json = {
|
||||
"activity": str(activity.id),
|
||||
"rsvp": str(rsvp.id),
|
||||
"action": "remove",
|
||||
"attendees": activity.attendee_count(),
|
||||
"capacity": capacity,
|
||||
"guests": activity.attendee_list()
|
||||
"guests": activity.attendee_list(),
|
||||
}
|
||||
|
||||
|
||||
# Send message to the group
|
||||
async_to_sync(send_channel)(event.id, activity_json)
|
||||
|
||||
|
||||
async def send_channel(event_id, activity_json):
|
||||
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
if channel_layer:
|
||||
await channel_layer.group_send(
|
||||
f"activity_{event_id}",
|
||||
{
|
||||
"type": "group.message",
|
||||
"activity_json": activity_json
|
||||
}
|
||||
)
|
||||
f"activity_{event_id}",
|
||||
{"type": "group.message", "activity_json": activity_json},
|
||||
)
|
||||
|
||||
+16
-35
@@ -1,95 +1,76 @@
|
||||
# urls.py
|
||||
from django.urls import path
|
||||
|
||||
from events.views.rsvp import post_step_activities
|
||||
from . import views
|
||||
|
||||
app_name = "events"
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
# Main event page
|
||||
path("events/<slug:slug>/", views.event, name="event"),
|
||||
|
||||
path("events/<slug:slug>/", views.event_page, name="event"),
|
||||
# Invite URL, can only be used once
|
||||
path("invite/<slug:slug>", views.invite_redirect, name="invite"),
|
||||
|
||||
# RSVP Form
|
||||
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("htmx/rsvp/<str:rid>/page/<str:page>", views.rsvp_pages, name="rsvp-pages"),
|
||||
path(
|
||||
"htmx/rsvp/<str:id>/navigate/<str:page>",
|
||||
"htmx/rsvp/<str:rid>/navigate/<str:page>",
|
||||
views.page_change,
|
||||
name="rsvp-page-change",
|
||||
),
|
||||
|
||||
path(
|
||||
"htmx/rsvp/<str:id>/post/email",
|
||||
"htmx/rsvp/<str:rid>/post/email",
|
||||
views.post_step_email,
|
||||
name="rsvp-post-email",
|
||||
),
|
||||
|
||||
path(
|
||||
"htmx/rsvp/<str:id>/post/guest",
|
||||
views.post_step_guest,
|
||||
name="rsvp-post-guest"
|
||||
"htmx/rsvp/<str:rid>/post/guest", views.post_step_guest, name="rsvp-post-guest"
|
||||
),
|
||||
|
||||
path(
|
||||
"htmx/rsvp/<str:id>/delete/guest/<str:gid>",
|
||||
"htmx/rsvp/<str:rid>/delete/guest/<str:gid>",
|
||||
views.delete_step_guest,
|
||||
name="rsvp-delete-guest"
|
||||
name="rsvp-delete-guest",
|
||||
),
|
||||
|
||||
path(
|
||||
"htmx/rsvp/<str:id>/post/activity/<str:aid>",
|
||||
"htmx/rsvp/<str:rid>/post/activity/<str:aid>",
|
||||
views.post_step_activities,
|
||||
name="rsvp-post-activity"
|
||||
name="rsvp-post-activity",
|
||||
),
|
||||
|
||||
# POST response for RSVP-scoped question
|
||||
path(
|
||||
"htmx/rsvp/<str:id>/post/question/<str:qid>/",
|
||||
"htmx/rsvp/<str:rid>/post/question/<str:qid>/",
|
||||
views.post_response_rsvp,
|
||||
name="rsvp-post-response",
|
||||
),
|
||||
|
||||
# POST response for Guest-scoped question
|
||||
path(
|
||||
"htmx/rsvp/<str:id>/post/question/<str:qid>/guest/<str:gid>/",
|
||||
"htmx/rsvp/<str:rid>/post/question/<str:qid>/guest/<str:gid>/",
|
||||
views.post_response_guest,
|
||||
name="rsvp-post-response-guest",
|
||||
),
|
||||
|
||||
path(
|
||||
"htmx/rsvp/<str:id>/post/notes",
|
||||
"htmx/rsvp/<str:rid>/post/notes",
|
||||
views.post_step_notes,
|
||||
name="rsvp-post-notes",
|
||||
),
|
||||
|
||||
path(
|
||||
"htmx/rsvp/<str:id>/start",
|
||||
"htmx/rsvp/<str:rid>/start",
|
||||
views.rsvp_start,
|
||||
name="rsvp-start",
|
||||
),
|
||||
|
||||
path(
|
||||
"htmx/rsvp/<str:id>/restart",
|
||||
"htmx/rsvp/<str:rid>/restart",
|
||||
views.rsvp_restart,
|
||||
name="rsvp-restart",
|
||||
),
|
||||
|
||||
path(
|
||||
"htmx/rsvp/<str:id>/update",
|
||||
"htmx/rsvp/<str:rid>/update",
|
||||
views.rsvp_update,
|
||||
name="rsvp-update",
|
||||
),
|
||||
|
||||
path(
|
||||
"htmx/rsvp/<str:id>/decline",
|
||||
"htmx/rsvp/<str:rid>/decline",
|
||||
views.rsvp_decline,
|
||||
name="rsvp-decline",
|
||||
),
|
||||
|
||||
]
|
||||
|
||||
@@ -11,14 +11,14 @@ 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}")
|
||||
|
||||
logger.debug("Cookies: %s", request.COOKIES)
|
||||
|
||||
edit_token = request.COOKIES.get(slug)
|
||||
|
||||
logger.debug(f"RSVP Edit Token: {edit_token}")
|
||||
|
||||
|
||||
logger.debug("RSVP Edit Token: %s", edit_token)
|
||||
|
||||
rsvp = RSVP.objects.filter(edit_token=edit_token).first()
|
||||
if rsvp is not None:
|
||||
return rsvp
|
||||
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
+12
-11
@@ -1,16 +1,16 @@
|
||||
|
||||
PAGE_ORDER = [
|
||||
'invalid',
|
||||
'email',
|
||||
'guests',
|
||||
'activities',
|
||||
'questions',
|
||||
'notes',
|
||||
'completed',
|
||||
"invalid",
|
||||
"email",
|
||||
"guests",
|
||||
"activities",
|
||||
"questions",
|
||||
"notes",
|
||||
"completed",
|
||||
]
|
||||
|
||||
|
||||
### PAGES
|
||||
def is_previous_page(first:str, second:str) -> bool:
|
||||
def is_previous_page(first: str, second: str) -> bool:
|
||||
"""return True if `first` is a previous page of `second`"""
|
||||
try:
|
||||
first_idx = PAGE_ORDER.index(first)
|
||||
@@ -20,7 +20,8 @@ def is_previous_page(first:str, second:str) -> bool:
|
||||
# If either page is not in the list, return False
|
||||
return False
|
||||
|
||||
def is_next_page(first:str, second:str) -> bool:
|
||||
|
||||
def is_next_page(first: str, second: str) -> bool:
|
||||
"""return True if `first` is a next page of `second`"""
|
||||
try:
|
||||
first_idx = PAGE_ORDER.index(first)
|
||||
@@ -28,4 +29,4 @@ def is_next_page(first:str, second:str) -> bool:
|
||||
return first_idx > second_idx
|
||||
except ValueError:
|
||||
# If either page is not in the list, return False
|
||||
return False
|
||||
return False
|
||||
|
||||
+37
-36
@@ -1,9 +1,7 @@
|
||||
import json
|
||||
from events.models import RSVP, Activity, ActivitySelection, Event
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from ..models import RSVP, Activity, ActivitySelection, Event
|
||||
|
||||
def create_or_switch_activity_response(rsvp: RSVP, activity: Activity):
|
||||
def create_or_switch_activity_response(rsvp: RSVP, activity: Activity) -> bool:
|
||||
"""
|
||||
This function will:
|
||||
1. Check if there already is a ActivitySelection for this pair
|
||||
@@ -14,78 +12,81 @@ def create_or_switch_activity_response(rsvp: RSVP, activity: Activity):
|
||||
- then remove the old one
|
||||
- add the new selection (switch choice)
|
||||
"""
|
||||
|
||||
|
||||
# Step 1: Remove existing selection for this activity pair (unselecting)
|
||||
existing_selection = ActivitySelection.objects.filter(rsvp=rsvp, activity=activity).first()
|
||||
existing_selection = ActivitySelection.objects.filter(
|
||||
rsvp=rsvp, activity=activity
|
||||
).first()
|
||||
if existing_selection:
|
||||
existing_selection.delete()
|
||||
return
|
||||
|
||||
return False
|
||||
|
||||
# Step 2: Check if adding guests will fit in the selected activity
|
||||
remaining_capacity = activity.remaining_capacity()
|
||||
if(remaining_capacity is None):
|
||||
if remaining_capacity is None:
|
||||
# Hardcoded, because why not
|
||||
remaining_capacity = 100
|
||||
|
||||
|
||||
if rsvp.guest_count > remaining_capacity:
|
||||
return
|
||||
|
||||
return False
|
||||
|
||||
# Step 3: Check if new selection is illegal (choosing one in same group)
|
||||
# Get the activity's group if it exists
|
||||
activity_group = activity.group
|
||||
|
||||
|
||||
if activity_group and activity_group.single_choice:
|
||||
# Get all other activities in the same group that are already selected
|
||||
other_selections = ActivitySelection.objects.filter(
|
||||
rsvp=rsvp,
|
||||
activity__group=activity_group
|
||||
rsvp=rsvp, activity__group=activity_group
|
||||
).exclude(activity=activity)
|
||||
|
||||
|
||||
if other_selections.exists():
|
||||
# Remove the old selection (switch choice)
|
||||
for other_selection in other_selections:
|
||||
other_selection.delete()
|
||||
|
||||
|
||||
# Create and save the new selection
|
||||
selection = ActivitySelection(rsvp=rsvp, activity=activity)
|
||||
selection.save()
|
||||
|
||||
return selection
|
||||
|
||||
def create_context(rsvp:RSVP, error:str="") -> dict:
|
||||
context = {
|
||||
"rsvp": rsvp,
|
||||
"event": rsvp.event
|
||||
}
|
||||
|
||||
if(error != ""):
|
||||
return True
|
||||
|
||||
|
||||
def create_context(rsvp: RSVP, error: str = "") -> dict:
|
||||
context = {"rsvp": rsvp, "event": rsvp.event}
|
||||
|
||||
if error != "":
|
||||
context["page_error"] = error
|
||||
|
||||
|
||||
return context
|
||||
|
||||
def add_to_context(context:dict, key:str, value) -> dict:
|
||||
|
||||
def add_to_context(context: dict, key: str, value) -> dict:
|
||||
context[key] = value
|
||||
|
||||
|
||||
return context
|
||||
|
||||
def get_rsvp(context:dict) -> RSVP:
|
||||
|
||||
def get_rsvp(context: dict) -> RSVP:
|
||||
rsvp = get_from_context(context, "rsvp")
|
||||
|
||||
if not rsvp:
|
||||
raise KeyError
|
||||
|
||||
|
||||
return rsvp
|
||||
|
||||
def get_event(context:dict) -> Event:
|
||||
|
||||
def get_event(context: dict) -> Event:
|
||||
event = get_from_context(context, "event")
|
||||
|
||||
if not event:
|
||||
raise KeyError
|
||||
|
||||
|
||||
return event
|
||||
|
||||
def get_from_context(context:dict, key:str):
|
||||
|
||||
def get_from_context(context: dict, key: str):
|
||||
if key in context:
|
||||
return context[key]
|
||||
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
+36
-29
@@ -1,50 +1,55 @@
|
||||
from events.models import RSVP, Activity, Question, Response
|
||||
|
||||
|
||||
def summary_email(rsvp:RSVP) -> str:
|
||||
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."
|
||||
|
||||
|
||||
if rsvp.send_email_updates is False:
|
||||
summary = "Email: You do not want any updates."
|
||||
|
||||
return summary
|
||||
|
||||
def summary_guests(rsvp:RSVP) -> str:
|
||||
summary = f"Guests: let us know who will come!"
|
||||
|
||||
|
||||
|
||||
def summary_guests(rsvp: RSVP) -> str:
|
||||
summary = "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!"
|
||||
summary = "Guests: Don't forget to add yourself!"
|
||||
if guests == 1:
|
||||
summary = f"Guests: Just you."
|
||||
summary = "Guests: Just you."
|
||||
if guests > 1:
|
||||
summary = f"Guests: You are coming with {guests} people."
|
||||
|
||||
|
||||
return summary
|
||||
|
||||
def summary_activities(rsvp:RSVP) -> str:
|
||||
summary = f"Activities"
|
||||
|
||||
|
||||
def summary_activities(rsvp: RSVP) -> str:
|
||||
summary = "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!"
|
||||
summary = "Activities: You have selected one activity!"
|
||||
if chosen_activities_count > 1:
|
||||
summary = f"Activities: You have selected {chosen_activities_count} activities!"
|
||||
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:
|
||||
|
||||
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."
|
||||
@@ -80,8 +85,9 @@ def summary_questions(rsvp:RSVP) -> str:
|
||||
|
||||
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))
|
||||
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:
|
||||
@@ -96,10 +102,11 @@ def summary_questions(rsvp:RSVP) -> str:
|
||||
|
||||
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
|
||||
def summary_notes(rsvp: RSVP) -> str:
|
||||
summary = "Notes: anything you want to share?"
|
||||
|
||||
if len(rsvp.notes) > 1:
|
||||
summary = "Notes: you have left a note."
|
||||
|
||||
return summary
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
from .views import *
|
||||
from .rsvp import *
|
||||
from .rsvp import *
|
||||
|
||||
+246
-177
@@ -1,27 +1,35 @@
|
||||
# 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.http import HttpResponse, HttpResponseBadRequest
|
||||
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 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
|
||||
from events.utils.rsvputils import (
|
||||
add_to_context,
|
||||
create_or_switch_activity_response,
|
||||
create_context,
|
||||
get_event,
|
||||
get_rsvp,
|
||||
)
|
||||
|
||||
from ..forms import GuestForm, NotesForm, RSVPForm
|
||||
from events.forms import GuestForm, NotesForm, RSVPForm
|
||||
|
||||
from ..models import (
|
||||
Event,
|
||||
from events.models import (
|
||||
Guest,
|
||||
Activity,
|
||||
ActivityGroup,
|
||||
RSVP,
|
||||
ActivitySelection,
|
||||
Question,
|
||||
@@ -33,19 +41,21 @@ 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):
|
||||
# Start a new or unfinished RSVP
|
||||
def rsvp_start(request, rid: str):
|
||||
rsvp = get_object_or_404(RSVP, id=rid)
|
||||
|
||||
if rsvp.status not in (RSVP.Status.NOT_STARTED, RSVP.Status.INCOMPLETE):
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
response = redirect(reverse('events:rsvp-base', args=[rsvp.edit_token, rsvp.page]))
|
||||
response = redirect(reverse("events:rsvp-base", args=[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)
|
||||
def rsvp_update(request, rid: str):
|
||||
rsvp = get_object_or_404(RSVP, id=rid)
|
||||
|
||||
if rsvp.status != RSVP.Status.YES_JOINING:
|
||||
return HttpResponseBadRequest()
|
||||
@@ -53,41 +63,42 @@ def rsvp_update(request, id:str):
|
||||
rsvp.page = RSVP.Page.ACTIVITIES
|
||||
rsvp.save()
|
||||
|
||||
response = redirect(reverse('events:rsvp-base', args=[rsvp.edit_token, rsvp.page]))
|
||||
response = redirect(reverse("events:rsvp-base", args=[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)
|
||||
def rsvp_decline(request, rid: str):
|
||||
rsvp = get_object_or_404(RSVP, id=rid)
|
||||
|
||||
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]))
|
||||
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)
|
||||
|
||||
def rsvp_restart(request, rid: str):
|
||||
rsvp = get_object_or_404(RSVP, id=rid)
|
||||
|
||||
rsvp.status = RSVP.Status.INCOMPLETE
|
||||
rsvp.save()
|
||||
|
||||
response = redirect(reverse('events:rsvp-base', args=[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, code:str, page:str):
|
||||
def rsvp_base_page(request, code: str, page: str):
|
||||
rsvp = get_object_or_404(RSVP, edit_token=code)
|
||||
|
||||
context = {
|
||||
@@ -95,7 +106,7 @@ def rsvp_base_page(request, code:str, page:str):
|
||||
"event": rsvp.event,
|
||||
"invite": rsvp.invite,
|
||||
"page": page,
|
||||
"is_locked": rsvp.event.is_locked()
|
||||
"is_locked": rsvp.event.is_locked(),
|
||||
}
|
||||
|
||||
response = render(request, "rsvp/rsvp.html", context)
|
||||
@@ -104,10 +115,10 @@ def rsvp_base_page(request, code:str, page:str):
|
||||
|
||||
|
||||
# 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)
|
||||
def rsvp_pages(request, rid: str, page: str, error: str = ""):
|
||||
logger.debug("rsvp_pages: id=%s, page=%s", rid, page)
|
||||
|
||||
rsvp = get_object_or_404(RSVP, id=rid)
|
||||
|
||||
if page == "":
|
||||
page = rsvp.page
|
||||
@@ -119,105 +130,111 @@ def rsvp_pages(request, id:str, page:str, error:str=""):
|
||||
"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)
|
||||
"page_notes": _render_page_notes(request, context, page),
|
||||
}
|
||||
|
||||
|
||||
response = render(request, "rsvp/rsvp_pages.html", rendercontent)
|
||||
response["HX-Push-Url"] = reverse('events:rsvp-base', args=[rsvp.edit_token, page])
|
||||
response["HX-Push-Url"] = reverse("events:rsvp-base", args=[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_summary, summary = _render_page(
|
||||
request, get_rsvp(context), note, RSVP.Page.EMAIL, page
|
||||
)
|
||||
if render_summary is True:
|
||||
return summary
|
||||
|
||||
# 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))
|
||||
|
||||
render_summary, summary = _render_page(
|
||||
request, get_rsvp(context), note, RSVP.Page.GUESTS, page
|
||||
)
|
||||
|
||||
add_to_context(context, "guests", guests)
|
||||
if render_summary is True:
|
||||
return summary
|
||||
|
||||
guests = Guest.objects.filter(rsvp=get_rsvp(context))
|
||||
|
||||
add_to_context(context, "guests", guests)
|
||||
add_to_context(context, "form", GuestForm())
|
||||
|
||||
selected_activities = (
|
||||
ActivitySelection.objects
|
||||
.filter(rsvp=get_rsvp(context))
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
selected_activities_full = [
|
||||
act.activity.remaining_capacity() < 1 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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
render_summary, summary = _render_page(
|
||||
request, get_rsvp(context), note, RSVP.Page.ACTIVITIES, page
|
||||
)
|
||||
if render_summary is True:
|
||||
return summary
|
||||
|
||||
activities = (
|
||||
Activity.objects
|
||||
.filter(event=get_event(context))
|
||||
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))
|
||||
)
|
||||
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
|
||||
|
||||
render_summary, summary = _render_page(
|
||||
request, get_rsvp(context), note, RSVP.Page.QUESTIONS, page
|
||||
)
|
||||
if render_summary is True:
|
||||
return summary
|
||||
|
||||
rsvp = get_rsvp(context)
|
||||
|
||||
@@ -228,8 +245,7 @@ def _render_page_questions(request, context, page) -> str:
|
||||
# Get all questions for the selected activities, with related activity and choices prefetched
|
||||
if activities:
|
||||
questions = (
|
||||
Question.objects
|
||||
.filter(activity__in=activities)
|
||||
Question.objects.filter(activity__in=activities)
|
||||
.select_related("activity")
|
||||
.prefetch_related("choices")
|
||||
.order_by("activity__start_time", "activity__title", "order")
|
||||
@@ -242,9 +258,17 @@ def _render_page_questions(request, context, page) -> str:
|
||||
|
||||
# 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")
|
||||
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")
|
||||
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)
|
||||
@@ -254,88 +278,99 @@ def _render_page_questions(request, context, page) -> str:
|
||||
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
|
||||
|
||||
|
||||
render_summary, summary = _render_page(
|
||||
request, get_rsvp(context), note, RSVP.Page.NOTES, page
|
||||
)
|
||||
if render_summary is True:
|
||||
return summary
|
||||
|
||||
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:
|
||||
def _render_page(
|
||||
request,
|
||||
rsvp: RSVP,
|
||||
note: str,
|
||||
page: RSVP.Page,
|
||||
current_page: RSVP.Page,
|
||||
oob: bool = False,
|
||||
) -> tuple[bool, str]:
|
||||
if page == current_page:
|
||||
return True
|
||||
|
||||
return False, ""
|
||||
|
||||
template = get_template("rsvp/rsvp_page_inactive.html")
|
||||
|
||||
|
||||
context = {
|
||||
"note": note,
|
||||
"rsvp": rsvp,
|
||||
"page": page,
|
||||
"complete": rsvp.is_page_finished(page),
|
||||
"oob": oob
|
||||
"oob": oob,
|
||||
}
|
||||
|
||||
return template.render(context=context, request=request)
|
||||
|
||||
return True, 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)
|
||||
def page_change(request, rid: str, page: str):
|
||||
logger.debug("page_change: id=%s, page=%s", rid, page)
|
||||
|
||||
rsvp = get_object_or_404(RSVP, id=rid)
|
||||
error = ""
|
||||
|
||||
|
||||
if page == "next":
|
||||
allowed = rsvp.is_next_page_allowed()
|
||||
if(type(allowed) is bool and allowed == True):
|
||||
allowed, error = rsvp.is_next_page_allowed()
|
||||
if allowed is 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:
|
||||
messages.success(request, "Thank you for responding!")
|
||||
response = HttpResponse()
|
||||
response["HX-Redirect"] = reverse('events:event', args=[rsvp.event.slug])
|
||||
response["HX-Redirect"] = reverse("events:event", args=[rsvp.event.slug])
|
||||
return response
|
||||
|
||||
return rsvp_pages(request, id, rsvp.page, error=error)
|
||||
|
||||
return rsvp_pages(request, rid, 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':
|
||||
def post_step_email(request, rid: str):
|
||||
rsvp = get_object_or_404(RSVP, id=rid)
|
||||
|
||||
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.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 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':
|
||||
def post_step_guest(request, rid: str):
|
||||
rsvp = get_object_or_404(RSVP, id=rid)
|
||||
|
||||
if request.method != "POST":
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
|
||||
form = GuestForm(request.POST)
|
||||
if form.is_valid():
|
||||
guest = form.save(commit=False)
|
||||
@@ -349,47 +384,55 @@ def post_step_guest(request, id:str):
|
||||
|
||||
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)
|
||||
def delete_step_guest(request, rid: str, gid: str):
|
||||
rsvp = get_object_or_404(RSVP, id=rid)
|
||||
guest = get_object_or_404(Guest, id=gid)
|
||||
|
||||
if request.method != 'DELETE':
|
||||
|
||||
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)
|
||||
def post_step_activities(request, rid: str, aid: str):
|
||||
rsvp = get_object_or_404(RSVP, id=rid)
|
||||
activity = get_object_or_404(Activity, id=aid)
|
||||
|
||||
if request.method != 'POST':
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
render_summary, 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):
|
||||
def _create_or_update_response(
|
||||
response_kwargs, value_text=None, choice_obj=None
|
||||
) -> None:
|
||||
"""
|
||||
Ensure a single Response exists for (activity, question, rsvp|guest).
|
||||
- For non-choice questions: store `value_text` and clear any choices.
|
||||
@@ -398,10 +441,10 @@ def _create_or_update_response(response_kwargs, value_text=None, choice_obj=None
|
||||
"""
|
||||
# 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'),
|
||||
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
|
||||
@@ -426,61 +469,74 @@ def _create_or_update_response(response_kwargs, value_text=None, choice_obj=None
|
||||
# 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
|
||||
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.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):
|
||||
|
||||
def post_response_rsvp(request, rid: str, qid: str):
|
||||
|
||||
if request.method != "POST":
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
rsvp = get_object_or_404(RSVP, id=id)
|
||||
|
||||
rsvp = get_object_or_404(RSVP, id=rid)
|
||||
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(
|
||||
_create_or_update_response(
|
||||
{"activity": activity, "question": question, "rsvp": rsvp},
|
||||
choice_obj=choice
|
||||
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()
|
||||
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)
|
||||
_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)
|
||||
_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)
|
||||
_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):
|
||||
|
||||
def post_response_guest(request, rid: str, qid: str, gid: str):
|
||||
if request.method != "POST":
|
||||
return HttpResponseBadRequest()
|
||||
rsvp = get_object_or_404(RSVP, id=id)
|
||||
rsvp = get_object_or_404(RSVP, id=rid)
|
||||
question = get_object_or_404(Question, id=qid)
|
||||
guest = get_object_or_404(Guest, id=gid)
|
||||
activity = question.activity
|
||||
@@ -491,36 +547,49 @@ def post_response_guest(request, id:str, qid:str, gid:str):
|
||||
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)
|
||||
_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()
|
||||
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)
|
||||
_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)
|
||||
_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)
|
||||
_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':
|
||||
def post_step_notes(request, rid: str):
|
||||
rsvp = get_object_or_404(RSVP, id=rid)
|
||||
|
||||
if request.method != "POST":
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
|
||||
form = NotesForm(request.POST)
|
||||
if form.is_valid():
|
||||
rsvp.notes = form.cleaned_data['notes']
|
||||
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)
|
||||
return HttpResponseBadRequest(form.errors)
|
||||
|
||||
+33
-32
@@ -1,5 +1,4 @@
|
||||
# views.py
|
||||
from email import message
|
||||
import logging
|
||||
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
@@ -18,76 +17,78 @@ from ..models import (
|
||||
# Logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def invite_redirect(request:HttpRequest, slug:str):
|
||||
|
||||
def invite_redirect(request: HttpRequest, slug: str):
|
||||
"""
|
||||
Redirect from the unique invite page
|
||||
to the rsvp response page which is linked to the response
|
||||
"""
|
||||
|
||||
|
||||
rsvp = get_object_or_404(RSVP, invite=slug)
|
||||
response = redirect(reverse('events:event', args=[rsvp.event.slug]))
|
||||
|
||||
response = redirect(reverse("events:event", args=[rsvp.event.slug]))
|
||||
|
||||
# Opened for the first time
|
||||
#TODO what is email not sent out?
|
||||
if(rsvp.status == RSVP.Status.UNOPENED):
|
||||
# TODO what if 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.")
|
||||
|
||||
logger.info(f"Invite `{slug}` opened for Event `{rsvp.event.title}`")
|
||||
|
||||
messages.warning(
|
||||
request,
|
||||
"You can only use the invite once, please use the link in the email.",
|
||||
)
|
||||
|
||||
logger.info("Invite `%s` opened for Event `%s`", slug, rsvp.event.title)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def event(request:HttpRequest, slug):
|
||||
def event_page(request: HttpRequest, slug):
|
||||
event = get_object_or_404(Event, slug=slug)
|
||||
|
||||
|
||||
activities = (
|
||||
Activity.objects
|
||||
.filter(event=event)
|
||||
Activity.objects.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,
|
||||
|
||||
}
|
||||
|
||||
|
||||
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 or rsvp.status == RSVP.Status.UNOPENED else False
|
||||
|
||||
if isinstance(rsvp, RSVP):
|
||||
|
||||
joining = rsvp.status == RSVP.Status.YES_JOINING
|
||||
not_coming = rsvp.status == RSVP.Status.NOT_COMING
|
||||
in_progress = rsvp.status == RSVP.Status.INCOMPLETE
|
||||
not_started = rsvp.status in (RSVP.Status.NOT_STARTED, RSVP.Status.UNOPENED)
|
||||
|
||||
response = {
|
||||
"rsvp": rsvp,
|
||||
"joining": joining,
|
||||
"not_coming": not_coming,
|
||||
"in_progress": in_progress,
|
||||
"not_started": not_started
|
||||
"not_started": not_started,
|
||||
}
|
||||
|
||||
|
||||
context["response"] = response
|
||||
|
||||
return render(request, "event/event.html", context)
|
||||
return render(request, "event/event.html", context)
|
||||
|
||||
+9
-8
@@ -15,31 +15,32 @@ from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
from channels.security.websocket import AllowedHostsOriginValidator
|
||||
|
||||
from blacknoise import BlackNoise
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
logger = logging.getLogger(__file__)
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rsvpproject.settings')
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
from events.routing import websocket_urlpatterns
|
||||
|
||||
logger = logging.getLogger(__file__)
|
||||
|
||||
if settings.DEBUG:
|
||||
logger.warning("DEBUG enabled")
|
||||
|
||||
if settings.DOCKER:
|
||||
logger.warning("DOCKER enabled")
|
||||
|
||||
logger.info(f"ALLOWED_HOSTS={settings.ALLOWED_HOSTS}")
|
||||
|
||||
logger.info("ALLOWED_HOSTS=%s", settings.ALLOWED_HOSTS)
|
||||
|
||||
django_asgi_app = BlackNoise(get_asgi_application())
|
||||
django_asgi_app.add(settings.STATIC_ROOT, "/static")
|
||||
django_asgi_app.add(settings.MEDIA_ROOT, "/media")
|
||||
|
||||
from events.routing import websocket_urlpatterns
|
||||
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": django_asgi_app,
|
||||
"websocket": AllowedHostsOriginValidator(
|
||||
AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
+76
-80
@@ -13,13 +13,16 @@ https://docs.djangoproject.com/en/6.0/ref/settings/
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.messages import constants as message_constants
|
||||
|
||||
bool_map = {"true": True, "false": False}
|
||||
|
||||
def extract_bool(env_key, default:bool):
|
||||
|
||||
|
||||
def extract_bool(env_key, default: bool):
|
||||
|
||||
retval = default
|
||||
env_val = os.getenv(env_key)
|
||||
|
||||
|
||||
if env_val:
|
||||
# Try as int
|
||||
try:
|
||||
@@ -29,31 +32,34 @@ def extract_bool(env_key, default:bool):
|
||||
except ValueError:
|
||||
# Convert string to boolean using the dictionary
|
||||
retval = bool_map.get(env_val.lower(), default)
|
||||
|
||||
|
||||
return retval
|
||||
|
||||
|
||||
def extract_comma_list(env_key, default=None):
|
||||
if os.getenv(env_key):
|
||||
return [item.strip() for item in os.getenv(env_key).split(',')] # pyright: ignore[reportOptionalMemberAccess]
|
||||
else:
|
||||
if default:
|
||||
return [default]
|
||||
else:
|
||||
return []
|
||||
return [
|
||||
item.strip() for item in os.getenv(env_key).split(",")
|
||||
] # pyright: ignore[reportOptionalMemberAccess]
|
||||
|
||||
if default:
|
||||
return [default]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'INSECURE_STANDARD_KEY_SET_IN_ENV')
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "INSECURE_STANDARD_KEY_SET_IN_ENV")
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = extract_bool('DEBUG', False)
|
||||
DOCKER = extract_bool('DOCKER', False)
|
||||
DEBUG = extract_bool("DEBUG", False)
|
||||
DOCKER = extract_bool("DOCKER", False)
|
||||
|
||||
ALLOWED_HOSTS = extract_comma_list('ALLOWED_HOSTS', '127.0.0.1')
|
||||
CSRF_TRUSTED_ORIGINS = extract_comma_list('CSRF_TRUSTED_ORIGINS')
|
||||
ALLOWED_HOSTS = extract_comma_list("ALLOWED_HOSTS", "127.0.0.1")
|
||||
CSRF_TRUSTED_ORIGINS = extract_comma_list("CSRF_TRUSTED_ORIGINS")
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
@@ -61,64 +67,60 @@ DOCKER_DIR = BASE_DIR
|
||||
|
||||
if DOCKER:
|
||||
# We are running in docker
|
||||
DOCKER_DIR = Path('/data')
|
||||
DOCKER_DIR = Path("/data")
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'daphne',
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'events',
|
||||
"daphne",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"events",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'rsvpproject.urls'
|
||||
ROOT_URLCONF = "rsvpproject.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'django.template.context_processors.csrf',
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"django.template.context_processors.csrf",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'standard': {
|
||||
'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
|
||||
},
|
||||
'simple':{
|
||||
'format': '%(levelname)s %(message)s'
|
||||
},
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"standard": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"},
|
||||
"simple": {"format": "%(levelname)s %(message)s"},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'standard',
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "standard",
|
||||
},
|
||||
# 'file': {
|
||||
# 'level': 'DEBUG' if DEBUG else 'INFO',
|
||||
@@ -127,54 +129,48 @@ LOGGING = {
|
||||
# 'formatter': 'simple',
|
||||
# },
|
||||
},
|
||||
'loggers': {
|
||||
'events': {
|
||||
'handlers': ['console'],
|
||||
'level': 'DEBUG' if DEBUG else 'INFO',
|
||||
'propagate': True,
|
||||
"loggers": {
|
||||
"events": {
|
||||
"handlers": ["console"],
|
||||
"level": "DEBUG" if DEBUG else "INFO",
|
||||
"propagate": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ASGI_APPLICATION = 'rsvpproject.asgi.application'
|
||||
ASGI_APPLICATION = "rsvpproject.asgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': DOCKER_DIR / 'db.sqlite3',
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": DOCKER_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels.layers.InMemoryChannelLayer"
|
||||
}
|
||||
}
|
||||
CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}}
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
from django.contrib.messages import constants as message_constants
|
||||
|
||||
MESSAGE_TAGS = {
|
||||
message_constants.INFO: "bg-blue",
|
||||
message_constants.SUCCESS: "bg-green",
|
||||
@@ -185,9 +181,9 @@ MESSAGE_TAGS = {
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
@@ -197,8 +193,8 @@ USE_TZ = True
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
STATIC_ROOT = BASE_DIR / 'static'
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "static"
|
||||
|
||||
MEDIA_URL = 'media/'
|
||||
MEDIA_ROOT = DOCKER_DIR / 'media'
|
||||
MEDIA_URL = "media/"
|
||||
MEDIA_ROOT = DOCKER_DIR / "media"
|
||||
|
||||
Reference in New Issue
Block a user