diff --git a/README.md b/README.md index 05beee3..11dd201 100644 --- a/README.md +++ b/README.md @@ -11,28 +11,41 @@ Everytime you start: `pipenv shell` Check that is works by running `which python` +# To run +Requires Daphne (websockets) +`python manage.py runserver` + +## So long as the migrations are not part of the repo +`python manage.py makemigrations` +`python manage.py migrate` + +## To add a superuser +`python manage.py createsuperuser --username --email ` # Ideas and notes ## Een - - -## Twee -De invite code onderdeel maken van de RSVP, invite helemaal verwijderen? -Een invite juist helemaal loskoppelen, geen connectie meer met RSVP. Alles kopieren. - Een invite kan dan ook Guests hebben? - -## Drie Emails! Om te editen, als er comments of wijzigingen zijn! -## Vier -Geen nieuwe gasten toevoegen, als er al een activity geselecteerd is die vol is! +## Twee +Admin paneel: 4 kolommen op basis van de status: open, incomplete, yes, no +Met een websocket. +En dat je dan ook live kan zien dat er gasten toegevoegd worden, en als ze dingen veranderen enzo. -## Vijf +## Drie Als iemand geen gasten mag meenemen, die hele stap niet tonen. +## Vier +Activity start en eind tijd klopt niks van hoe het getoond wordt.. + +## Vijf +Extra gasten als property van een activity, die gewoon getoont worden als groep? + ## Zes -Gasten (template): koppelen aan een invite! +De buttons tijdens een rsvp renderen serverside en met oob-swapping elke keer terugsturen: +- Geen previous op eerste pagina +- Geen next, maar finish op laatste pagina +- hx-confirm op back-to-event als nog niet voltooid. # Things to keep in mind - Move to Postgress diff --git a/events/admin.py b/events/admin.py index 23dfa2b..3d4ca03 100644 --- a/events/admin.py +++ b/events/admin.py @@ -2,11 +2,13 @@ from django.contrib import admin from events.models import * +class ActivityAdmin(admin.ModelAdmin): + list_display = ('title', 'group', 'event', 'guest_limit', 'remaining_capacity', 'attendee_count', 'attendee_list',) + admin.site.register(Event) -admin.site.register(Activity) +admin.site.register(Activity, ActivityAdmin) admin.site.register(ActivityGroup) admin.site.register(ActivitySelection) -admin.site.register(Invite) admin.site.register(RSVP) admin.site.register(Guest) admin.site.register(Question) @@ -14,5 +16,3 @@ admin.site.register(QuestionChoice) admin.site.register(Response) admin.site.register(ResponseChoice) admin.site.register(Comment) -admin.site.register(GuestListTemplate) -admin.site.register(GuestListItem) diff --git a/events/models.py b/events/models.py index d34de1f..9f5554a 100644 --- a/events/models.py +++ b/events/models.py @@ -7,6 +7,7 @@ from django.conf import settings from django.utils import timezone from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ +import markdown from events.utils.pageutils import is_next_page, is_previous_page @@ -58,7 +59,11 @@ class Event(TimestampedModel): return self.title @property - def image_url(self) -> str: + def markdown(self) -> str: + return markdown.markdown(self.description) + + @property + def static_image_url(self) -> str: image_str = str(self.image) if len(image_str) > 0: return image_str[14:] @@ -66,12 +71,12 @@ class Event(TimestampedModel): @property def start_time(self): - first = self.activities.order_by('start_time').first() + first = self.activities.order_by('start_time').first() # pyright: ignore[reportAttributeAccessIssue] return first.start_time if first else None @property def end_time(self): - last = self.activities.order_by('-end_time').first() + last = self.activities.order_by('-end_time').first() # pyright: ignore[reportAttributeAccessIssue] return last.end_time if last else None @property @@ -80,7 +85,7 @@ class Event(TimestampedModel): return expiretime def is_locked(self) -> bool: - last = self.activities.order_by('-end_time').first() + last = self.activities.order_by('-end_time').first() # pyright: ignore[reportAttributeAccessIssue] if not last: return False return timezone.now() >= (last.start_time - self.response_lock_before) @@ -120,6 +125,8 @@ class Activity(TimestampedModel): title = models.CharField(max_length=255) description = models.TextField(blank=True) + location = models.CharField(max_length=255) + start_time = models.DateTimeField() end_time = models.DateTimeField(null=True, blank=True) @@ -133,12 +140,21 @@ class Activity(TimestampedModel): return f"{self.event.title} — {self.title}" @property - def image_url(self) -> str: + def markdown(self) -> str: + return markdown.markdown(self.description) + + def static_image_url(self) -> str: image_str = str(self.image) if len(image_str) > 0: return image_str[14:] return "img/placeholder_activity.jpg" + def is_locked(self) -> bool: + if not self.allow_rsvp: + return True + + return timezone.now() >= (self.start_time - self.event.response_lock_before) + def remaining_capacity(self) -> int: if self.guest_limit is None: return 999 @@ -237,19 +253,6 @@ class Activity(TimestampedModel): return guest_list -class Invite(TimestampedModel): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - event = models.ForeignKey(Event, related_name='invites', on_delete=models.CASCADE) - code = models.CharField(max_length=64, unique=True, db_index=True) - recipient_name = models.CharField(max_length=255, blank=True) - recipient_email = models.EmailField(blank=True) - personalized_message = models.TextField(blank=True) - allow_bring_guests = models.BooleanField(default=False) - - def __str__(self): - return f"Invite {self.code} -> {self.recipient_name or self.recipient_email}" - - class RSVP(TimestampedModel): class Page(models.TextChoices): @@ -261,6 +264,7 @@ class RSVP(TimestampedModel): COMPLETED = 'completed', _('Completed') class Status(models.TextChoices): + UNOPENED = 'unopened', _('Not Opened') NOT_STARTED = 'not_started', _('Not Started') INCOMPLETE = 'incomplete', _('Incomplete') NOT_COMING = 'not_coming', _('Not coming') @@ -268,17 +272,20 @@ class RSVP(TimestampedModel): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) event = models.ForeignKey(Event, related_name='rsvps', on_delete=models.CASCADE) - invite = models.OneToOneField(Invite, null=True, blank=True, related_name='rsvp', on_delete=models.SET_NULL) + invite = models.SlugField(max_length=255, db_index=True, unique=True) edit_token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) responder_name = models.CharField(max_length=255, blank=True) responder_email = models.EmailField(blank=False) send_email_updates = models.BooleanField(default=True) + + personalized_message = models.TextField(blank=True) + allow_bring_guests = models.BooleanField(default=False) page = models.CharField(max_length=16, choices=Page.choices, default=Page.EMAIL) furthest_page = models.CharField(max_length=16, choices=Page.choices, default=Page.EMAIL) - status = models.CharField(max_length=16, choices=Status.choices, default=Status.NOT_STARTED) + status = models.CharField(max_length=16, choices=Status.choices, default=Status.UNOPENED) notes = models.TextField(blank=True) @@ -288,10 +295,10 @@ class RSVP(TimestampedModel): def __str__(self): return f"RSVP {self.edit_token} — {self.event.title} — {self.responder_email} — {self.status}" - def guest_count(self) -> int: - return self.guests.count() - @property + def guest_count(self) -> int: + return self.guests.count() # pyright: ignore[reportAttributeAccessIssue] + def selected_activities_ids(self): selected_activities = ( ActivitySelection.objects @@ -300,6 +307,17 @@ class RSVP(TimestampedModel): selected_activities_ids = [act.activity.id for act in selected_activities] return selected_activities_ids + @property + def name(self) -> str: + main_guest = self.guests.filter(is_main_invitee=True).first() # pyright: ignore[reportAttributeAccessIssue] + if(main_guest): + return main_guest.name + + if(self.responder_name): + return self.responder_name + + return "friend" + PAGE_ORDER = [ 'email', 'guests', @@ -326,6 +344,7 @@ class RSVP(TimestampedModel): self.furthest_page = new_page if self.status == self.Status.NOT_STARTED: + #TODO send an email here self.status = self.Status.INCOMPLETE self.page = new_page @@ -400,7 +419,7 @@ class RSVP(TimestampedModel): return f'Please answer required question: "{q.text}"' elif q.scope == "guest": - for guest in self.guests.all(): + for guest in self.guests.all(): # pyright: ignore[reportAttributeAccessIssue] resp = Response.objects.filter(guest=guest, question=q).first() if not resp: return f'Please answer required question "{q.text}" for all guests.' @@ -450,7 +469,6 @@ class Guest(TimestampedModel): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) event = models.ForeignKey(Event, related_name='guests', on_delete=models.CASCADE) rsvp = models.ForeignKey(RSVP, related_name='guests', on_delete=models.CASCADE) - invite = models.ForeignKey(Invite, null=True, blank=True, related_name='guests', on_delete=models.SET_NULL) name = models.CharField(max_length=255) age_group = models.CharField(max_length=10, choices=AGE_GROUP, default='adult') @@ -459,6 +477,15 @@ class Guest(TimestampedModel): def __str__(self): return f"{self.name} ({self.age_group})" + + @property + def firstname(self) -> str: + return self.name.split(" ")[0] + + @property + def lastname(self) -> str: + return self.name.split(" ")[1:] + class Question(TimestampedModel): KIND_CHOICES = [ @@ -573,38 +600,10 @@ class ResponseChoice(TimestampedModel): class Comment(TimestampedModel): event = models.ForeignKey(Event, related_name='comments', on_delete=models.CASCADE) - name = models.CharField(max_length=255) + guest = models.ForeignKey(Guest, related_name='comments', on_delete=models.CASCADE) + text = models.TextField() notify_subscribers = models.BooleanField(default=False) def __str__(self): - return f"Comment by {self.name} on {self.event}" - - -class GuestListTemplate(TimestampedModel): - owner = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL) - title = models.CharField(max_length=255) - - def __str__(self): - return self.title - - -class GuestListItem(TimestampedModel): - template = models.ForeignKey(GuestListTemplate, related_name='items', on_delete=models.CASCADE) - name = models.CharField(max_length=255) - email = models.EmailField(blank=True) - is_main_invitee = models.BooleanField(default=True) - - def __str__(self): - return self.name - -class GuestItemInvite(TimestampedModel): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - guest = models.ForeignKey(GuestListItem, related_name="guest_invite", on_delete=models.CASCADE) - invite = models.ForeignKey(Invite, related_name="guest_invite", on_delete=models.CASCADE) - - class Meta: - unique_together = (("guest", "invite"),) - - def __str__(self): - return f"{self.guest} added to {self.invite}" \ No newline at end of file + return f"Comment by {self.guest.name} on {self.event.title}" \ No newline at end of file diff --git a/events/signals.py b/events/signals.py index 681a448..cdaa308 100644 --- a/events/signals.py +++ b/events/signals.py @@ -1,9 +1,8 @@ -import json import logging -from asgiref.sync import async_to_sync, sync_to_async +from asgiref.sync import async_to_sync -from django.db.models.signals import post_save, post_delete +from django.db.models.signals import post_save, post_delete, pre_delete from django.dispatch import receiver from channels.layers import get_channel_layer from events.models import RSVP, Activity, Event, ActivitySelection, Guest, Response @@ -14,6 +13,7 @@ logger = logging.getLogger(__name__) ### # Guests ### + @receiver(post_delete, sender=Guest) def guest_deleted(sender, instance, **kwargs): """Delete question responses for this guest""" @@ -21,9 +21,70 @@ def guest_deleted(sender, instance, **kwargs): logger.debug(f"Signal {guest}") + guest_delete_responses(guest) + guest_notify_activities(guest) + +def guest_delete_responses(guest:Guest): # Remove all responses from the guest that is deleted Response.objects.filter(guest=guest).delete() +def guest_notify_activities(guest:Guest): + """Broadcast activity selection changes to all connected clients.""" + rsvp:RSVP = guest.rsvp + event:Event = rsvp.event + + # Get all activities with this RSVP + activities = rsvp.activities + + for activity in activities.all(): + logger.debug(f"Signal {activity}") + + capacity = activity.remaining_capacity() + if capacity is None: + capacity = 100 + + activity_json = { + "activity": str(activity.id), + "rsvp": str(rsvp.id), + "action": "select", + "attendees": activity.attendee_count(), + "capacity": capacity, + "guests": activity.attendee_list() + } + + # Send message to the group + async_to_sync(send_channel)(event.id, activity_json) + +@receiver(post_save, sender=Guest) +def activity_guests_changed(sender, instance, created, **kwargs): + """Broadcast activity selection changes to all connected clients.""" + guest:Guest = instance + rsvp:RSVP = guest.rsvp + event:Event = rsvp.event + + logger.debug(f"Guest added: {guest.name}") + + # Get all activities with this RSVP + activities = rsvp.activities + + for activity in activities.all(): + logger.debug(f"Signal {activity}") + + capacity = activity.remaining_capacity() + if capacity is None: + capacity = 100 + + activity_json = { + "activity": str(activity.id), + "rsvp": str(rsvp.id), + "action": "select", + "attendees": activity.attendee_count(), + "capacity": capacity, + "guests": activity.attendee_list() + } + + # Send message to the group + async_to_sync(send_channel)(event.id, activity_json) ### # Activity Selections diff --git a/events/static/css/styles.css b/events/static/css/styles.css index 43f8285..5007a80 100644 --- a/events/static/css/styles.css +++ b/events/static/css/styles.css @@ -181,12 +181,12 @@ button:hover, .button:hover, a.button:hover, input[type="submit"]:hover, input[t } .bg-gray { - background: gray !important; + background: darkgray !important; } .bd-gray { border: 2px solid transparent; - border-color: gray !important; + border-color: darkgray !important; } .locked { @@ -263,6 +263,17 @@ button { margin: 0.25rem; } +.button-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} + +.button-row-right { + margin-left: auto; +} + /* media layout: text left, image right, image height matches text column */ .subcard .media-row { display: flex; @@ -311,4 +322,8 @@ button { .group-title { font-size: 0.85rem; } +} + +.hidden { + display: none; } \ No newline at end of file diff --git a/events/static/js/rsvp.js b/events/static/js/rsvp.js index ed128ba..c29f3f1 100644 --- a/events/static/js/rsvp.js +++ b/events/static/js/rsvp.js @@ -98,19 +98,29 @@ function activitiesPage() { // Activity capacity (when there is a guest limit) const capacity = $('#activity-' + data.activity + ' .numcap'); + const fullwarn = $('#activity-' + data.activity + ' .full-warning'); if(capacity !== null && data.capacity !== null){ capacity.html(data.capacity); + + if(data.capacity == 0){ + fullwarn.removeClass("hidden"); + } else { + fullwarn.addClass("hidden"); + } } // Activity capacity (when there is a guest limit) const activity = $('#activity-' + data.activity); + const spacewarn = $('#activity-' + data.activity + ' .space-warning'); if(activity !== null && data.capacity !== null){ if(!(activity.hasClass("attending"))){ if(data.capacity >= guestCount){ activity.removeClass("bg-gray bd-gray"); + spacewarn.addClass("hidden"); } else{ activity.addClass("bg-gray bd-gray"); + spacewarn.removeClass("hidden"); } } } diff --git a/events/templates/base.html b/events/templates/base.html index 1a70347..c015a56 100644 --- a/events/templates/base.html +++ b/events/templates/base.html @@ -18,6 +18,14 @@
+ {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% block content %}{% endblock %}
diff --git a/events/templates/event/event.html b/events/templates/event/event.html index e6bf64d..8226960 100644 --- a/events/templates/event/event.html +++ b/events/templates/event/event.html @@ -9,7 +9,7 @@ {% if event.description %}
- {{ event.description }} + {{ event.markdown|safe }}
{% endif %} @@ -17,12 +17,12 @@
{% load static %} - +
{% if response %} -
-

Hello {{ response.rsvp.responder_name }}!

+
+

Hello {{ response.rsvp.name }}!

{% if response.not_started %} You have been invited! Please let us know if you are coming! @@ -41,13 +41,13 @@ Sad that you are not coming?

- I changed my mind! + I changed my mind! {% elif response.joining %} You have responded!

- Update response! + Update response! I can no longer come {% endif %} diff --git a/events/templates/event/event_activity_details.html b/events/templates/event/event_activity_details.html index c4b6efd..3b5c501 100644 --- a/events/templates/event/event_activity_details.html +++ b/events/templates/event/event_activity_details.html @@ -1,38 +1,58 @@
-

{{ activity.title }}

+

{{ activity.title }} {% if activity.remaining_capacity <= 0 %}FULL{% endif %}{% if activity.is_locked %}LOCKED{% endif %}

-

- {{ activity.start_time|date:"DATETIME_FORMAT" }} - {% if activity.end_time %} - – {{ activity.end_time|date:"TIME_FORMAT" }} - {% endif %} -

+
+

📖 About

+

{{ activity.markdown|safe }}

+
- {% if activity.guest_limit %} -

- Places remaining: - {{ activity.remaining_capacity }} / {{ activity.guest_limit }} -

- {% else %} -

- People going: - {{ activity.attendee_count }} -

- {% endif %} +
+

🕙 When

+

+ {{ activity.start_time|date:"DATETIME_FORMAT" }} + {% if activity.end_time %} + – {{ activity.end_time|date:"TIME_FORMAT" }} + {% endif %} +

+
-

Attendees:

- - {% for guest_list in activity.attendee_list %} - - {{ guest_list|join:", " }} - - {% endfor %} - -
+
+

📍Where

+

+ {{ activity.location }} +

+
+ +
+

👥Capacity

+ {% if activity.guest_limit %} +

+ Places remaining: + {{ activity.remaining_capacity }} / {{ activity.guest_limit }} +

+ {% else %} +

+ People going: + {{ activity.attendee_count }} +

+ {% endif %} +
+ +
+

🚶‍➡️Attendees:

+ + {% for guest_list in activity.attendee_list %} + + {{ guest_list|join:", " }} + + {% endfor %} + +
+
{% load static %} - {{ activity.title }} image + {{ activity.title }} image
\ No newline at end of file diff --git a/events/templates/rsvp/rsvp.html b/events/templates/rsvp/rsvp.html index 3d9a222..cdedfcd 100644 --- a/events/templates/rsvp/rsvp.html +++ b/events/templates/rsvp/rsvp.html @@ -13,8 +13,8 @@ {% else %}

- Hello {{ invite.recipient_name }} - {% if invite.allow_bring_guests %} + Hello {{ rsvp.name }} + {% if rsvp.allow_bring_guests %} Allowed to bring guests {% endif %}

@@ -24,18 +24,27 @@ hx-trigger="load">
-Previous + {% endif %} diff --git a/events/templates/rsvp/rsvp_page_activities_details.html b/events/templates/rsvp/rsvp_page_activities_details.html index 7826735..ce3316d 100644 --- a/events/templates/rsvp/rsvp_page_activities_details.html +++ b/events/templates/rsvp/rsvp_page_activities_details.html @@ -1,3 +1,4 @@ +{% if not activity.is_locked %}
-

{{ activity.title }}

+

{{ activity.title }} FULL

+ 🕙 {{ activity.start_time|date:"DATETIME_FORMAT" }} {% if activity.end_time %} – {{ activity.end_time|date:"TIME_FORMAT" }} {% endif %}

+

+ 📍{{ activity.location }} +

+ {% if activity.guest_limit %}

- Places remaining: - {{ activity.remaining_capacity }} / {{ activity.guest_limit }} + 👥Places remaining: + {{ activity.remaining_capacity }} / {{ activity.guest_limit }} + Not enough space for {{rsvp.guest_count}} people

{% else %}

- People going: + 👥People going: {{ activity.attendee_count }}

{% endif %} -

Attendees:

{% for guest_list in activity.attendee_list %} {{ guest_list|join:", " }} {% endfor %} + {% if activity.attendee_list|length == 0 %} +

🚶‍➡️No-one is coming yet

+ {% endif %}
-
\ No newline at end of file + +{% else %} +
+

{{ activity.title }} LOCKED

+
+{% endif %} \ No newline at end of file diff --git a/events/templates/rsvp/rsvp_page_email.html b/events/templates/rsvp/rsvp_page_email.html index c0d9700..2c7d6c9 100644 --- a/events/templates/rsvp/rsvp_page_email.html +++ b/events/templates/rsvp/rsvp_page_email.html @@ -1,21 +1,20 @@ -
+

You are invited to {{ event.title }}

-

{{ event.description }}

Message from the host

-

{{ invite.personalized_message }}

+

{{ rsvp.personalized_message }}

Email updates

- Your email will never be shared with other people + Your email will never be shared with other people

You will always receive an email with a link to edit your response, and when a selected activity changes. Optional emails are comments from other people.

-
+ {% csrf_token %} {{ form.as_p }} diff --git a/events/templates/rsvp/rsvp_page_guest.html b/events/templates/rsvp/rsvp_page_guest.html index 70935f8..7257b56 100644 --- a/events/templates/rsvp/rsvp_page_guest.html +++ b/events/templates/rsvp/rsvp_page_guest.html @@ -3,18 +3,26 @@ {% for guest in guests %}
- {{ guest.name }} {{ guest.age_group }} +
+ {{ guest.name }} {{ guest.age_group }} - +
+ {% if not guest.is_main_invitee %} + + + {% endif %} +
+
{% endfor %} + {% if space_for_extra_guest %}
- Last names are not shown to other people +

Add guest Last names are not shown to other people

Add guest
+ {% else %} +
+

Add guest

+

There is no space left in some of your selected activities.

+
+ {% endif %}
\ No newline at end of file diff --git a/events/templates/rsvp/rsvp_page_questions.html b/events/templates/rsvp/rsvp_page_questions.html index a30ecb5..16151e9 100644 --- a/events/templates/rsvp/rsvp_page_questions.html +++ b/events/templates/rsvp/rsvp_page_questions.html @@ -10,4 +10,10 @@ {% for question in questions %} {% include "rsvp/rsvp_page_questions_detail.html" %} {% endfor %} + + {% if questions|length <= 0 %} +

+ There are no questions to answer. +

+ {% endif %}
diff --git a/events/urls.py b/events/urls.py index 2a3af53..0a6187c 100644 --- a/events/urls.py +++ b/events/urls.py @@ -7,12 +7,15 @@ from . import views app_name = "events" urlpatterns = [ + + # Main event page path("events//", views.event, name="event"), - path("invite//rsvp//", views.invite_redirect, name="invite"), + # Invite URL, can only be used once + path("invite/", views.invite_redirect, name="invite"), # RSVP Form - path("events//rsvp//page/", views.rsvp_base_page, name="rsvp-base"), + path("rsvp//page/", views.rsvp_base_page, name="rsvp-base"), path("htmx/rsvp//page/", views.rsvp_pages, name="rsvp-pages"), path( diff --git a/events/utils/rsvputils.py b/events/utils/rsvputils.py index 39ff733..407cba6 100644 --- a/events/utils/rsvputils.py +++ b/events/utils/rsvputils.py @@ -1,20 +1,7 @@ import json from django.core.exceptions import ValidationError -from ..models import RSVP, Activity, ActivitySelection, Event, Invite - -def create_rsvp(event: Event, invite: Invite): - rsvp = RSVP(event=event, invite=invite) - - if(invite.recipient_name != ""): - rsvp.responder_name = invite.recipient_name - - if(invite.recipient_email != ""): - rsvp.responder_email = invite.recipient_email - - rsvp.save() - - return rsvp +from ..models import RSVP, Activity, ActivitySelection, Event def create_or_switch_activity_response(rsvp: RSVP, activity: Activity): """ @@ -40,7 +27,7 @@ def create_or_switch_activity_response(rsvp: RSVP, activity: Activity): # Hardcoded, because why not remaining_capacity = 100 - if rsvp.guest_count() > remaining_capacity: + if rsvp.guest_count > remaining_capacity: return # Step 3: Check if new selection is illegal (choosing one in same group) @@ -68,8 +55,7 @@ def create_or_switch_activity_response(rsvp: RSVP, activity: Activity): def create_context(rsvp:RSVP, error:str="") -> dict: context = { "rsvp": rsvp, - "event": rsvp.event, - "invite": rsvp.invite + "event": rsvp.event } if(error != ""): @@ -90,14 +76,6 @@ def get_rsvp(context:dict) -> RSVP: return rsvp -def get_invite(context:dict) -> Invite: - invite = get_from_context(context, "invite") - - if not invite: - raise KeyError - - return invite - def get_event(context:dict) -> Event: event = get_from_context(context, "event") diff --git a/events/utils/summary.py b/events/utils/summary.py index 43a8f02..caccfff 100644 --- a/events/utils/summary.py +++ b/events/utils/summary.py @@ -13,13 +13,13 @@ def summary_guests(rsvp:RSVP) -> str: summary = f"Guests: let us know who will come!" if rsvp.is_page_finished(RSVP.Page.GUESTS): - guests = rsvp.guest_count() + guests = rsvp.guest_count if guests == 0: summary = f"Guests: Don't forget to add yourself!" if guests == 1: summary = f"Guests: Just you." if guests > 1: - summary = f"Guests: You are bringing {guests - 1} guests." + summary = f"Guests: You are coming with {guests} people." return summary @@ -31,7 +31,7 @@ def summary_activities(rsvp:RSVP) -> str: available_activities = Activity.objects.filter(event=rsvp.event) total_activities_count = len(available_activities) activities_with_space_count = sum( - 1 for a in available_activities if a.remaining_capacity() >= rsvp.guest_count() + 1 for a in available_activities if a.remaining_capacity() >= rsvp.guest_count ) if rsvp.is_page_finished(RSVP.Page.ACTIVITIES) and chosen_activities_count > 0: diff --git a/events/views/rsvp.py b/events/views/rsvp.py index dd57d59..43edf15 100644 --- a/events/views/rsvp.py +++ b/events/views/rsvp.py @@ -8,18 +8,18 @@ from django.db import transaction from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden from django.template.loader import get_template from django.urls import reverse +from django.contrib import messages from events.utils.cookieutils import set_rsvp_cookie from events.utils.summary import summary_activities, summary_email, summary_guests, summary_notes, summary_questions -from ..utils.rsvputils import add_to_context, create_or_switch_activity_response, create_context, get_event, get_from_context, get_rsvp, create_rsvp +from ..utils.rsvputils import add_to_context, create_or_switch_activity_response, create_context, get_event, get_from_context, get_rsvp from ..forms import GuestForm, NotesForm, RSVPForm from ..models import ( Event, Guest, - Invite, Activity, ActivityGroup, RSVP, @@ -40,7 +40,7 @@ def rsvp_start(request, id:str): if not (rsvp.status == RSVP.Status.NOT_STARTED or rsvp.status == RSVP.Status.INCOMPLETE): return HttpResponseBadRequest() - response = redirect(reverse('events:rsvp-base', args=[rsvp.event.slug, rsvp.edit_token, rsvp.page])) + response = redirect(reverse('events:rsvp-base', args=[rsvp.edit_token, rsvp.page])) return response # Update a completed RSVP @@ -53,7 +53,7 @@ def rsvp_update(request, id:str): rsvp.page = RSVP.Page.ACTIVITIES rsvp.save() - response = redirect(reverse('events:rsvp-base', args=[rsvp.event.slug, rsvp.edit_token, rsvp.page])) + response = redirect(reverse('events:rsvp-base', args=[rsvp.edit_token, rsvp.page])) return response # Stop an existing RSVP @@ -81,26 +81,25 @@ def rsvp_restart(request, id:str): rsvp.status = RSVP.Status.INCOMPLETE rsvp.save() - response = redirect(reverse('events:rsvp-base', args=[rsvp.event.slug, rsvp.edit_token, rsvp.page])) + response = redirect(reverse('events:rsvp-base', args=[rsvp.edit_token, rsvp.page])) return response # Main entrypoint for rsvp page -def rsvp_base_page(request, slug:str, code:str, page:str): - event = get_object_or_404(Event, slug=slug) +def rsvp_base_page(request, code:str, page:str): rsvp = get_object_or_404(RSVP, edit_token=code) context = { "rsvp": rsvp, - "event": event, + "event": rsvp.event, "invite": rsvp.invite, "page": page, - "is_locked": event.is_locked() + "is_locked": rsvp.event.is_locked() } response = render(request, "rsvp/rsvp.html", context) - set_rsvp_cookie(response, event, rsvp) + set_rsvp_cookie(response, rsvp.event, rsvp) return response @@ -122,11 +121,9 @@ def rsvp_pages(request, id:str, page:str, error:str=""): "page_questions": _render_page_questions(request, context, page), "page_notes": _render_page_notes(request, context, page) } - - # Add `hx-push` header and set to value of `reverse('events:rsvp-base', args=[slug, rsvp.edit_token, rsvp.page])` - + response = render(request, "rsvp/rsvp_pages.html", rendercontent) - response["HX-Push-Url"] = reverse('events:rsvp-base', args=[get_event(context).slug, rsvp.edit_token, page]) + response["HX-Push-Url"] = reverse('events:rsvp-base', args=[rsvp.edit_token, page]) return response # Email page @@ -159,7 +156,16 @@ def _render_page_guest(request, context, page) -> str: ) add_to_context(context, "guests", guests) - add_to_context(context, "form", GuestForm()) + add_to_context(context, "form", GuestForm()) + + selected_activities = ( + ActivitySelection.objects + .filter(rsvp=get_rsvp(context)) + ) + + # Check if any of the activities is full + selected_activities_full = [(True if act.activity.remaining_capacity() < 1 else False) for act in selected_activities] + add_to_context(context, "space_for_extra_guest", True not in selected_activities_full) template = get_template("rsvp/rsvp_page_guest.html") return template.render(context=context, request=request) @@ -299,7 +305,7 @@ def page_change(request, id:str, page:str): # Done with rsvp if rsvp.page == RSVP.Page.COMPLETED: - #response = redirect(reverse('events:event', args=[rsvp.event.slug])) + messages.success(request, "Thank you for responding!") response = HttpResponse() response["HX-Redirect"] = reverse('events:event', args=[rsvp.event.slug]) return response diff --git a/events/views/views.py b/events/views/views.py index 280a8ec..5d993ab 100644 --- a/events/views/views.py +++ b/events/views/views.py @@ -1,16 +1,16 @@ # views.py +from email import message import logging from django.shortcuts import get_object_or_404, redirect, render -from django.http import Http404, HttpResponseBadRequest, HttpRequest +from django.http import HttpRequest from django.urls import reverse +from django.contrib import messages from events.utils.cookieutils import get_rsvp_from_cookie, set_rsvp_cookie -from events.utils.rsvputils import create_rsvp from ..models import ( Event, - Invite, Activity, RSVP, ) @@ -18,36 +18,28 @@ from ..models import ( # Logger logger = logging.getLogger(__name__) -def invite_redirect(request:HttpRequest, slug, code): +def invite_redirect(request:HttpRequest, slug:str): """ Redirect from the unique invite page to the rsvp response page which is linked to the response """ - event = get_object_or_404(Event, slug=slug) - response = redirect(reverse('events:event', args=[slug])) + rsvp = get_object_or_404(RSVP, invite=slug) + response = redirect(reverse('events:event', args=[rsvp.event.slug])) - logger.info(f"Invite `{code}` opened for Event `{slug}`") + # Opened for the first time + #TODO what is email not sent out? + if(rsvp.status == RSVP.Status.UNOPENED): + rsvp.status = RSVP.Status.NOT_STARTED + rsvp.save() + + # Only set the cookie the first time, after this they need to use the invite link + set_rsvp_cookie(response, rsvp.event, rsvp) + else: + messages.warning(request, "You can only use the invite once, please use the link in the email.") - rsvp:RSVP + logger.info(f"Invite `{slug}` opened for Event `{rsvp.event.title}`") - try: - invite = get_object_or_404(Invite, code=code) - - # The invite code does not match the event - if invite.event.id != event.id: - return HttpResponseBadRequest() - - rsvp = RSVP.objects.get(invite=invite) - set_rsvp_cookie(response, event, rsvp) - except Http404 as e: - logger.debug(str(e)) - pass - except RSVP.DoesNotExist as e: - logger.debug(str(e)) - rsvp = create_rsvp(event, invite) - set_rsvp_cookie(response, event, rsvp) - return response @@ -71,8 +63,6 @@ def event(request:HttpRequest, slug): else: ungrouped.append(activity) - - context = { "event": event, "groups": groups, @@ -88,7 +78,7 @@ def event(request:HttpRequest, slug): joining = True if rsvp.status == RSVP.Status.YES_JOINING else False not_coming = True if rsvp.status == RSVP.Status.NOT_COMING else False in_progress = True if rsvp.status == RSVP.Status.INCOMPLETE else False - not_started = True if rsvp.status == RSVP.Status.NOT_STARTED else False + not_started = True if rsvp.status == RSVP.Status.NOT_STARTED or rsvp.status == RSVP.Status.UNOPENED else False response = { "rsvp": rsvp, diff --git a/rsvpproject/settings.py b/rsvpproject/settings.py index cd14509..1c38333 100644 --- a/rsvpproject/settings.py +++ b/rsvpproject/settings.py @@ -138,6 +138,14 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] +from django.contrib.messages import constants as message_constants + +MESSAGE_TAGS = { + message_constants.INFO: "bg-blue", + message_constants.SUCCESS: "bg-green", + message_constants.WARNING: "bg-orange", + message_constants.ERROR: "bg-red", +} # Internationalization # https://docs.djangoproject.com/en/6.0/topics/i18n/ diff --git a/rsvpproject/urls.py b/rsvpproject/urls.py index afab5a6..513ffe0 100644 --- a/rsvpproject/urls.py +++ b/rsvpproject/urls.py @@ -18,6 +18,6 @@ from django.contrib import admin from django.urls import path, include urlpatterns = [ - path('admin/', admin.site.urls), + path('django-admin/', admin.site.urls), path('', include('events.urls', namespace='events')), ]