diff --git a/.gitignore b/.gitignore index 555e90e..6b6923f 100644 --- a/.gitignore +++ b/.gitignore @@ -188,3 +188,6 @@ cython_debug/ # Built Visual Studio Code Extensions *.vsix +# Custom +events/static/img/* +!events/static/img/placeholder*.jpg \ No newline at end of file diff --git a/README.md b/README.md index b9ac5b7..05beee3 100644 --- a/README.md +++ b/README.md @@ -15,36 +15,26 @@ Check that is works by running `which python` # Ideas and notes ## Een -Steps: Allemaal altijd renderen. Ze vragen de huidige stap op, en checken of ze zelf aan de beurt zijn en renderen op basis daarvan: klein en groen als klaar. -Dat bovenste is dus meteen een slecht idee. De pagina kan beter alle stukjes meteen renderen? En dan met javascript op de pagina bepalen welke uitgeklapt moet zijn? -Oplossing wordt het toevoegen van en step nummer, die toch dan bijhoudt waar je precies in de stappen bent + ## Twee -Voor de paginas. Het moet gewoon altijd paginas zijn. Link heet invite/. -Die maakt rsvp aan, en stuurt door naar rsvp// -Status renamen naar step, en die opslaan in de database -Status is dan nog maar 3 dingen: incomplete, going, not going. -Een copy dingetje om de unique url te kopiëren met uitleg voor later +De invite code onderdeel maken van de RSVP, invite helemaal verwijderen? +Een invite juist helemaal loskoppelen, geen connectie meer met RSVP. Alles kopieren. + Een invite kan dan ook Guests hebben? ## Drie -Invalidate invite na gebruik? Ze moeten nu de edit token gebruiken, want die krijgen ze per mail! -Als ze niet binnen zoveel minuten de rsvp afronden, of afbreken, dan krijgen ze de mail eerder? +Emails! Om te editen, als er comments of wijzigingen zijn! ## Vier -Geen nieuwe gasten toevoegen, als er al een activity geselecteerd is die vol is +Geen nieuwe gasten toevoegen, als er al een activity geselecteerd is die vol is! ## Vijf Als iemand geen gasten mag meenemen, die hele stap niet tonen. ## Zes -Het plaatje en de beschrijving van het event moeten misschien tussendoor nog erbij komen. -Als in: invite link -> event page (create rsvp & token, store in cookie) -> Knop ik kom!, Knop ik kom niet? -> Redirect naar RSVP flow +Gasten (template): koppelen aan een invite! -## Zeven -De websocket overschrijft de huidige keuze. Dus als iets vol zit, wordt hij grijs, ondanks dat die er zelf ook heen gaan en hij groen had moeten zijn! - -## Acht -Mails! - -## Negen -Gasten (template): koppelen aan een invite! \ No newline at end of file +# Things to keep in mind +- Move to Postgress +- Dockerfile +- Redis for channels? \ No newline at end of file diff --git a/events/models.py b/events/models.py index 157c814..d34de1f 100644 --- a/events/models.py +++ b/events/models.py @@ -23,7 +23,7 @@ class TimestampedModel(models.Model): def event_image_path(instance, filename): # file will be uploaded to MEDIA_ROOT/user_/ - return "events/{0}/{1}".format(instance.slug, filename) + return "events/static/img/{0}/{1}".format(instance.slug, filename) class Event(TimestampedModel): """The main event which contains activities. @@ -57,6 +57,13 @@ class Event(TimestampedModel): def __str__(self): return self.title + @property + def image_url(self) -> str: + image_str = str(self.image) + if len(image_str) > 0: + return image_str[14:] + return "img/placeholder.jpg" + @property def start_time(self): first = self.activities.order_by('start_time').first() @@ -66,6 +73,11 @@ class Event(TimestampedModel): def end_time(self): last = self.activities.order_by('-end_time').first() return last.end_time if last else None + + @property + def cookie_expire_datetime(self): + expiretime = self.end_time + self.cookie_expire_after_event + return expiretime def is_locked(self) -> bool: last = self.activities.order_by('-end_time').first() @@ -95,7 +107,7 @@ class ActivityGroup(TimestampedModel): def activity_image_path(instance, filename): # file will be uploaded to MEDIA_ROOT/user_/ - return "events/{0}/{1}".format(instance.event.slug, filename) + return "events/static/img/{0}/{1}".format(instance.slug, filename) class Activity(TimestampedModel): """An individual activity within an event. @@ -120,9 +132,16 @@ class Activity(TimestampedModel): return f"{self.event.title} — {self.group.title} — {self.title}" return f"{self.event.title} — {self.title}" - def remaining_capacity(self) -> int | None: + @property + def image_url(self) -> str: + image_str = str(self.image) + if len(image_str) > 0: + return image_str[14:] + return "img/placeholder_activity.jpg" + + def remaining_capacity(self) -> int: if self.guest_limit is None: - return None + return 999 return max(self.guest_limit - self.attendee_count(), 0) def attendee_count(self) -> int: @@ -234,7 +253,6 @@ class Invite(TimestampedModel): class RSVP(TimestampedModel): class Page(models.TextChoices): - INVALID = 'invalid', _('Invalid') EMAIL = 'email', _('Email') GUESTS = 'guests', _('Guests') ACTIVITIES = 'activities', _('Activities') @@ -243,6 +261,7 @@ class RSVP(TimestampedModel): COMPLETED = 'completed', _('Completed') class Status(models.TextChoices): + NOT_STARTED = 'not_started', _('Not Started') INCOMPLETE = 'incomplete', _('Incomplete') NOT_COMING = 'not_coming', _('Not coming') YES_JOINING = 'joining', _('Yes, joining') @@ -253,12 +272,13 @@ class RSVP(TimestampedModel): edit_token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + responder_name = models.CharField(max_length=255, blank=True) responder_email = models.EmailField(blank=False) send_email_updates = models.BooleanField(default=True) - page = models.CharField(max_length=16, choices=Page.choices, default=Page.INVALID) - furthest_page = models.CharField(max_length=16, choices=Page.choices, default=Page.INVALID) - status = models.CharField(max_length=16, choices=Status.choices, default=Status.INCOMPLETE) + page = models.CharField(max_length=16, choices=Page.choices, default=Page.EMAIL) + furthest_page = models.CharField(max_length=16, choices=Page.choices, default=Page.EMAIL) + status = models.CharField(max_length=16, choices=Status.choices, default=Status.NOT_STARTED) notes = models.TextField(blank=True) @@ -266,13 +286,21 @@ class RSVP(TimestampedModel): activities = models.ManyToManyField(Activity, through='ActivitySelection', related_name='rsvps') def __str__(self): - return f"RSVP {self.id} — {self.event.title} — {self.responder_email} — {self.status}" + return f"RSVP {self.edit_token} — {self.event.title} — {self.responder_email} — {self.status}" def guest_count(self) -> int: return self.guests.count() + @property + def selected_activities_ids(self): + selected_activities = ( + ActivitySelection.objects + .filter(rsvp=self) + ) + selected_activities_ids = [act.activity.id for act in selected_activities] + return selected_activities_ids + PAGE_ORDER = [ - 'invalid', 'email', 'guests', 'activities', @@ -284,7 +312,7 @@ class RSVP(TimestampedModel): def next_page(self) -> Page: """Move to the next page and save to model.""" - new_page = self.Page.INVALID + new_page = self.page try: current_index = self.PAGE_ORDER.index(self.page) @@ -297,7 +325,14 @@ class RSVP(TimestampedModel): if is_next_page(new_page, self.Page(self.furthest_page)): self.furthest_page = new_page + if self.status == self.Status.NOT_STARTED: + self.status = self.Status.INCOMPLETE + self.page = new_page + + if self.page == self.Page.COMPLETED: + self.status = self.Status.YES_JOINING + self.save() return new_page @@ -305,7 +340,7 @@ class RSVP(TimestampedModel): def previous_page(self) -> Page: """Move to the previous page and save to model""" - new_page = self.Page.INVALID + new_page = self.page try: current_index = self.PAGE_ORDER.index(self.page) @@ -323,7 +358,7 @@ class RSVP(TimestampedModel): def set_page(self, page:str) -> Page: """Move to the specific page and save to model""" - new_page = self.Page.INVALID + new_page = self.page try: new_page = self.Page(page) @@ -338,6 +373,48 @@ class RSVP(TimestampedModel): def is_page_finished(self, page:Page) -> bool: """return `True` if page is previous to the furthest reached page""" return is_previous_page(page, self.Page(self.furthest_page)) + + def is_next_page_allowed(self) -> bool | str: + # Activities page: require at least one selected activity + if self.page == self.Page.ACTIVITIES: + if not self.activities.exists(): + return "You must choose at least one activity." + return True + + # Questions page: ensure every required question for selected activities is answered + if self.page == self.Page.QUESTIONS: + selected_activities = self.activities.all() + required_questions = Question.objects.filter(activity__in=selected_activities, required=True) + + for q in required_questions: + if q.scope == "rsvp": + resp_qs = Response.objects.filter(rsvp=self, question=q) + if not resp_qs.exists(): + return f'Please answer required question: "{q.text}"' + for resp in resp_qs: + if q.kind in ("text", "integer", "boolean"): + if not (resp.value_text and resp.value_text.strip()): + return f'Please answer required question: "{q.text}"' + else: # single / multiple choice + if resp.choices.count() == 0: + return f'Please answer required question: "{q.text}"' + + elif q.scope == "guest": + for guest in self.guests.all(): + resp = Response.objects.filter(guest=guest, question=q).first() + if not resp: + return f'Please answer required question "{q.text}" for all guests.' + if q.kind in ("text", "integer", "boolean"): + if not (resp.value_text and resp.value_text.strip()): + return f'Please answer required question "{q.text}" for all guests.' + else: + if resp.choices.count() == 0: + return f'Please answer required question "{q.text}" for all guests.' + + return True + + # Default: allow moving forward + return True class ActivitySelection(models.Model): @@ -462,9 +539,9 @@ class Response(TimestampedModel): if kind == "multiple": if not self.pk: pass - else: - if self.choices.count() < 1: - raise ValidationError("Multiple-choice question requires at least one choice.") + # else: + # if self.choices.count() < 1: + # raise ValidationError("Multiple-choice question requires at least one choice.") if kind in ("text", "integer", "boolean") and self.choices.exists(): raise ValidationError("This question does not use choices.") @@ -472,10 +549,16 @@ class Response(TimestampedModel): raise ValidationError("Question must belong to the same activity.") def __str__(self): + whom = f"{self.question.scope}:" + if(self.question.scope == "rsvp"): + whom += " " + self.rsvp.responder_name + elif(self.question.scope == "guest"): + whom += " " + self.guest.name + if self.question.kind in ("text", "integer", "boolean"): - return f"Response to {self.question}: {self.value_text}" + return f"Response to {self.question} by {whom}: {self.value_text}" if self.question.kind in ("single", "multiple") and self.choices.count() > 0: - return f"Response to {self.question}: {self.choices.all}" + return f"Response to {self.question} by {whom}: {self.choices.all}" class ResponseChoice(TimestampedModel): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) diff --git a/events/static/css/styles.css b/events/static/css/styles.css index 57ddeec..43f8285 100644 --- a/events/static/css/styles.css +++ b/events/static/css/styles.css @@ -1,10 +1,19 @@ :root { + --accent: #006e53; + --accent-hover: #02aa80; + --neutral: #e0e0e0ff; - --green: #d1e9d3ff; - --red: #e9d0d0ff; - --orange: #e7e0ceff; - --blue: #b5bed8ff; - --purple: #e1c9e2ff; + --neutral-hover: #bdbbbb; + --green: #00640d; + --green-hover: #008011; + --red: #880101; + --red-hover: #aa0000; + --orange: #975b00; + --orange-hover: #bb7001; + --blue: #001f7c; + --blue-hover: #002bad; + --purple: #76007a; + --purple-hover: #9e00a3; } h1, h2, h3, h4 { @@ -21,6 +30,14 @@ h4 { font-size: 1rem; } +button, .button, a.button, input[type="submit"], input[type="reset"], input[type="button"] { + background-color: var(--accent); +} + +button:hover, .button:hover, a.button:hover, input[type="submit"]:hover, input[type="reset"]:hover, input[type="button"]:hover { + background-color: var(--accent-hover); +} + .card { background: var(--accent-bg); border-radius: 8px; @@ -75,8 +92,25 @@ h4 { margin: 0.25rem 0.25rem 0.25rem 0; } +.bg-accent { + background: var(--accent) !important; + color: var(--neutral); +} +.bg-accent.hover:hover { + background: var(--accent-hover) !important; +} + +.bd-accent { + border: 2px solid transparent; + border-color: var(--accent) !important; +} + .bg-green { background: var(--green) !important; + color: var(--neutral); +} +.bg-green.hover:hover { + background: var(--green-hover) !important; } .bd-green { @@ -86,6 +120,10 @@ h4 { .bg-red { background: var(--red) !important; + color: var(--neutral); +} +.bg-red.hover:hover { + background: var(--red-hover) !important; } .bd-red { @@ -95,6 +133,10 @@ h4 { .bg-orange { background: var(--orange) !important; + color: var(--neutral); +} +.bg-orange.hover:hover { + background: var(--orange-hover) !important; } .bd-orange { @@ -104,6 +146,10 @@ h4 { .bg-blue { background: var(--blue) !important; + color: var(--neutral); +} +.bg-blue.hover:hover { + background: var(--blue-hover) !important; } .bd-blue { @@ -113,6 +159,10 @@ h4 { .bg-purple { background: var(--pruple) !important; + color: var(--neutral); +} +.bg-purple.hover:hover { + background: var(--purple-hover) !important; } .bd-purple { @@ -122,6 +172,7 @@ h4 { .bg-black { background: black !important; + color: var(--neutral); } .bd-black { @@ -185,7 +236,6 @@ h4 { p { margin: 0.5rem 0; font-size: 0.9rem; - color: var(--text-light); } /* Compact spacing for activity cards */ @@ -213,6 +263,41 @@ button { margin: 0.25rem; } +/* media layout: text left, image right, image height matches text column */ +.subcard .media-row { + display: flex; + gap: 1rem; + align-items: stretch; /* make children the same height */ +} + +.subcard .media-content { + flex: 1 1 auto; + min-width: 0; /* allow contents to shrink properly */ +} + +/* thumbnail becomes a fixed-width column that stretches to content height */ +.subcard .thumbnail { + float: none; + display: block; + height: 100%; + width: auto; + max-width: 22%; /* adjust percent to control width */ + object-fit: cover; /* preserve aspect, fill the height */ + border-radius: 6px; +} + +/* mobile: stack, image full width and natural height */ +@media (max-width: 768px) { + .subcard .media-row { + display: block; + } + .subcard .thumbnail { + width: 100%; + height: auto; + margin-top: 0.75rem; + } +} + /* Responsive adjustments */ @media (max-width: 768px) { .card { diff --git a/events/static/img/placeholder.jpg b/events/static/img/placeholder.jpg new file mode 100644 index 0000000..9fdbbf7 Binary files /dev/null and b/events/static/img/placeholder.jpg differ diff --git a/events/static/img/placeholder_activity.jpg b/events/static/img/placeholder_activity.jpg new file mode 100644 index 0000000..913d409 Binary files /dev/null and b/events/static/img/placeholder_activity.jpg differ diff --git a/events/templates/event/event.html b/events/templates/event/event.html new file mode 100644 index 0000000..e6bf64d --- /dev/null +++ b/events/templates/event/event.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} + +{% block title %}{{ event.title }}{% endblock %} + +{% block content %} + +
+

{{ event.title }}

+ + {% if event.description %} +
+ {{ event.description }} +
+ {% endif %} + +
+ +
+ {% load static %} + +
+ +{% if response %} +
+

Hello {{ response.rsvp.responder_name }}!

+ {% if response.not_started %} + + You have been invited! Please let us know if you are coming! +

+ Respond now! + I am not coming + + {% elif response.in_progress %} + + You have not finished responding! Please let us know if you are coming! +

+ Continue responding now! + I am not coming + + {% elif response.not_coming %} + + Sad that you are not coming? +

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

+ Update response! + I can no longer come + + {% endif %} + +
+{% else %} +
+

Want to come?

+ Use your invite link, or the one in your inbox to respond! +
+{% endif %} + +
+

Activities

+ {% for group, activities in groups.items %} +

+ {{ group.title }} + {% if group.single_choice %} + Only one + {% endif %} +

+ + {% for activity in activities %} + {% include "event/event_activity_details.html" %} + {% endfor %} + {% endfor %} + + {% if ungrouped_activities %} +

Other activities

+ + {% for activity in ungrouped_activities %} + {% include "event/event_activity_details.html" %} + {% endfor %} + {% endif %} + +
+{% endblock %} \ No newline at end of file diff --git a/events/templates/event/event_activity_details.html b/events/templates/event/event_activity_details.html new file mode 100644 index 0000000..c4b6efd --- /dev/null +++ b/events/templates/event/event_activity_details.html @@ -0,0 +1,38 @@ +
+
+
+

{{ activity.title }}

+ +

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

+ + {% 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 +
+
\ No newline at end of file diff --git a/events/templates/events/event_details.html b/events/templates/events/event_details.html deleted file mode 100644 index 71963c9..0000000 --- a/events/templates/events/event_details.html +++ /dev/null @@ -1,53 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ event.title }}{% endblock %} - -{% block content %} - -
-

{{ event.title }}

- - {% if event.description %} -

{{ event.description }}

- {% endif %} - -

- When: - {{ event.start_time|date:"DATETIME_FORMAT" }} - – - {{ event.end_time|date:"DATETIME_FORMAT" }} -

- - {% if is_locked %} -

- RSVPs are closed for this event. -

- {% endif %} -
- -{% for group, activities in groups.items %} -

- {{ group.title }} - {% if group.single_choice %} - Choose one - {% endif %} -

- - {% for activity in activities %} - {% include "events/partials/activity_card.html" %} - {% endfor %} -{% endfor %} - -{% if ungrouped_activities %} -

Other activities

- - {% for activity in ungrouped_activities %} - {% include "events/partials/activity_card.html" %} - {% endfor %} -{% endif %} - - -

View details

-
None selected
- -{% endblock %} diff --git a/events/templates/events/partials/activity_card.html b/events/templates/events/partials/activity_card.html deleted file mode 100644 index 53d8b3c..0000000 --- a/events/templates/events/partials/activity_card.html +++ /dev/null @@ -1,23 +0,0 @@ -
-

{{ activity.title }}

- -

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

- - {% if activity.guest_limit %} -

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

- {% endif %} -
diff --git a/events/templates/events/partials/activity_detail.html b/events/templates/events/partials/activity_detail.html deleted file mode 100644 index 01af347..0000000 --- a/events/templates/events/partials/activity_detail.html +++ /dev/null @@ -1,56 +0,0 @@ -
-

{{ activity.title }}

- - {% if activity.description %} -

{{ activity.description }}

- {% endif %} - - {% if activity.questions.exists %} -

Questions

- - {% for question in activity.questions.all %} -
- {{ question.text }} - - {% if question.scope == "guest" %} -

Asked for each guest

- {% endif %} - - {% if question.kind == "single" %} - {% for choice in question.choices.all %} -
- -
- {% endfor %} - {% elif question.kind == "multiple" %} - {% for choice in question.choices.all %} -
- -
- {% endfor %} - {% elif question.kind == "boolean" %} -
- -
-
- -
- {% else %} - - {% endif %} -
- {% endfor %} - {% endif %} -
diff --git a/events/templates/events/partials/rsvp_page_guest_add.html b/events/templates/events/partials/rsvp_page_guest_add.html deleted file mode 100644 index f4f2e1e..0000000 --- a/events/templates/events/partials/rsvp_page_guest_add.html +++ /dev/null @@ -1,11 +0,0 @@ - - -
-
- {% csrf_token %} - {{ form.as_p }} - -
-
diff --git a/events/templates/events/partials/rsvp_page_questions.html b/events/templates/events/partials/rsvp_page_questions.html deleted file mode 100644 index be3668f..0000000 --- a/events/templates/events/partials/rsvp_page_questions.html +++ /dev/null @@ -1,6 +0,0 @@ -
-

Questions

- {% for question in questions %} - {% include "events/partials/rsvp_page_questions_detail.html" %} - {% endfor %} -
diff --git a/events/templates/events/event_rsvp.html b/events/templates/rsvp/rsvp.html similarity index 79% rename from events/templates/events/event_rsvp.html rename to events/templates/rsvp/rsvp.html index f1db87b..3d9a222 100644 --- a/events/templates/events/event_rsvp.html +++ b/events/templates/rsvp/rsvp.html @@ -24,18 +24,18 @@ hx-trigger="load"> - +>Previous - +>Next -
-
+Not coming {% endif %} @@ -46,6 +46,7 @@ {% endblock %} \ No newline at end of file diff --git a/events/templates/events/partials/rsvp_page_activities.html b/events/templates/rsvp/rsvp_page_activities.html similarity index 74% rename from events/templates/events/partials/rsvp_page_activities.html rename to events/templates/rsvp/rsvp_page_activities.html index a3144b8..69bbec3 100644 --- a/events/templates/events/partials/rsvp_page_activities.html +++ b/events/templates/rsvp/rsvp_page_activities.html @@ -1,6 +1,11 @@

What do you want to go to? Connecting...

+ {% if page_error %} +
+ {{page_error}} +
+ {% endif %} {% for group, activities in groups.items %}

@@ -11,7 +16,7 @@

{% for activity in activities %} - {% include "events/partials/rsvp_page_activities_details.html" %} + {% include "rsvp/rsvp_page_activities_details.html" %} {% endfor %} {% endfor %} @@ -19,7 +24,7 @@

Other activities

{% for activity in ungrouped_activities %} - {% include "events/partials/rsvp_page_activities_details.html" %} + {% include "rsvp/rsvp_page_activities_details.html" %} {% endfor %} {% endif %} diff --git a/events/templates/events/partials/rsvp_page_activities_details.html b/events/templates/rsvp/rsvp_page_activities_details.html similarity index 100% rename from events/templates/events/partials/rsvp_page_activities_details.html rename to events/templates/rsvp/rsvp_page_activities_details.html diff --git a/events/templates/events/partials/rsvp_page_email.html b/events/templates/rsvp/rsvp_page_email.html similarity index 62% rename from events/templates/events/partials/rsvp_page_email.html rename to events/templates/rsvp/rsvp_page_email.html index 89ba12c..c0d9700 100644 --- a/events/templates/events/partials/rsvp_page_email.html +++ b/events/templates/rsvp/rsvp_page_email.html @@ -9,8 +9,12 @@
-

Email and updates

- You will always receive an email with a link to edit your response +

Email updates

+ 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/events/partials/rsvp_page_guest.html b/events/templates/rsvp/rsvp_page_guest.html similarity index 90% rename from events/templates/events/partials/rsvp_page_guest.html rename to events/templates/rsvp/rsvp_page_guest.html index b0758a3..70935f8 100644 --- a/events/templates/events/partials/rsvp_page_guest.html +++ b/events/templates/rsvp/rsvp_page_guest.html @@ -14,6 +14,7 @@ {% endfor %}
+ Last names are not shown to other people +

Questions

+ + {% if page_error %} +
+ {{page_error}} +
+ {% endif %} + + {% for question in questions %} + {% include "rsvp/rsvp_page_questions_detail.html" %} + {% endfor %} +
diff --git a/events/templates/events/partials/rsvp_page_questions_answer.html b/events/templates/rsvp/rsvp_page_questions_answer.html similarity index 100% rename from events/templates/events/partials/rsvp_page_questions_answer.html rename to events/templates/rsvp/rsvp_page_questions_answer.html diff --git a/events/templates/events/partials/rsvp_page_questions_detail.html b/events/templates/rsvp/rsvp_page_questions_detail.html similarity index 87% rename from events/templates/events/partials/rsvp_page_questions_detail.html rename to events/templates/rsvp/rsvp_page_questions_detail.html index d4ff84a..7dd63d4 100644 --- a/events/templates/events/partials/rsvp_page_questions_detail.html +++ b/events/templates/rsvp/rsvp_page_questions_detail.html @@ -25,7 +25,7 @@ hx-trigger="change" > {% csrf_token %} - {% include "events/partials/rsvp_page_questions_answer.html" %} + {% include "rsvp/rsvp_page_questions_answer.html" %}
{% endfor %} @@ -38,7 +38,7 @@ hx-trigger="change" > {% csrf_token %} - {% include "events/partials/rsvp_page_questions_answer.html" %} + {% include "rsvp/rsvp_page_questions_answer.html" %} {% endif %} diff --git a/events/templates/events/event_pages.html b/events/templates/rsvp/rsvp_pages.html similarity index 100% rename from events/templates/events/event_pages.html rename to events/templates/rsvp/rsvp_pages.html diff --git a/events/urls.py b/events/urls.py index 335e67d..2a3af53 100644 --- a/events/urls.py +++ b/events/urls.py @@ -7,14 +7,7 @@ from . import views app_name = "events" urlpatterns = [ - path("events//", views.event_details, name="event-detail"), - - # Partials - path( - "htmx/activity//", - views.partial_activity_details, - name="partial-activity-details", - ), + path("events//", views.event, name="event"), path("invite//rsvp//", views.invite_redirect, name="invite"), @@ -71,5 +64,29 @@ urlpatterns = [ views.post_step_notes, name="rsvp-post-notes", ), + + path( + "htmx/rsvp//start", + views.rsvp_start, + name="rsvp-start", + ), + + path( + "htmx/rsvp//restart", + views.rsvp_restart, + name="rsvp-restart", + ), + + path( + "htmx/rsvp//update", + views.rsvp_update, + name="rsvp-update", + ), + + path( + "htmx/rsvp//decline", + views.rsvp_decline, + name="rsvp-decline", + ), ] diff --git a/events/utils/cookieutils.py b/events/utils/cookieutils.py new file mode 100644 index 0000000..ab42be5 --- /dev/null +++ b/events/utils/cookieutils.py @@ -0,0 +1,24 @@ +import logging + +from django.http import HttpRequest, HttpResponse + +from events.models import Event, RSVP + +# Logger +logger = logging.getLogger(__name__) + +def set_rsvp_cookie(response:HttpResponse, event:Event, rsvp:RSVP): + response.set_cookie(key=event.slug, value=rsvp.edit_token, expires=event.cookie_expire_datetime) + +def get_rsvp_from_cookie(request:HttpRequest, slug:str) -> RSVP | bool: + logger.debug(f"Cookies: {request.COOKIES}") + + edit_token = request.COOKIES.get(slug) + + logger.debug(f"RSVP Edit Token: {edit_token}") + + rsvp = RSVP.objects.filter(edit_token=edit_token).first() + if rsvp is not None: + return rsvp + + return False \ No newline at end of file diff --git a/events/utils/utils.py b/events/utils/rsvputils.py similarity index 93% rename from events/utils/utils.py rename to events/utils/rsvputils.py index 34ec1e1..39ff733 100644 --- a/events/utils/utils.py +++ b/events/utils/rsvputils.py @@ -5,8 +5,9 @@ from ..models import RSVP, Activity, ActivitySelection, Event, Invite def create_rsvp(event: Event, invite: Invite): rsvp = RSVP(event=event, invite=invite) - rsvp.page = RSVP.Page.EMAIL - rsvp.furthest_page = RSVP.Page.EMAIL + + if(invite.recipient_name != ""): + rsvp.responder_name = invite.recipient_name if(invite.recipient_email != ""): rsvp.responder_email = invite.recipient_email @@ -64,13 +65,16 @@ def create_or_switch_activity_response(rsvp: RSVP, activity: Activity): return selection -def create_context(rsvp:RSVP) -> dict: +def create_context(rsvp:RSVP, error:str="") -> dict: context = { "rsvp": rsvp, "event": rsvp.event, "invite": rsvp.invite } + if(error != ""): + context["page_error"] = error + return context def add_to_context(context:dict, key:str, value) -> dict: diff --git a/events/utils/summary.py b/events/utils/summary.py new file mode 100644 index 0000000..43a8f02 --- /dev/null +++ b/events/utils/summary.py @@ -0,0 +1,105 @@ +from events.models import RSVP, Activity, Question, Response + + +def summary_email(rsvp:RSVP) -> str: + summary = f"Email: You will receive updates at `{rsvp.responder_email}`" + + if rsvp.send_email_updates == False: + summary = f"Email: You do not want any updates." + + return summary + +def summary_guests(rsvp:RSVP) -> str: + summary = f"Guests: let us know who will come!" + + if rsvp.is_page_finished(RSVP.Page.GUESTS): + guests = rsvp.guest_count() + if guests == 0: + summary = f"Guests: Don't forget to add yourself!" + if guests == 1: + summary = f"Guests: Just you." + if guests > 1: + summary = f"Guests: You are bringing {guests - 1} guests." + + return summary + +def summary_activities(rsvp:RSVP) -> str: + summary = f"Activities" + + chosen_activities_count = rsvp.activities.count() + + available_activities = Activity.objects.filter(event=rsvp.event) + total_activities_count = len(available_activities) + activities_with_space_count = sum( + 1 for a in available_activities if a.remaining_capacity() >= rsvp.guest_count() + ) + + if rsvp.is_page_finished(RSVP.Page.ACTIVITIES) and chosen_activities_count > 0: + if chosen_activities_count == 1: + summary = f"Activities: You have selected one activity!" + if chosen_activities_count > 1: + summary = f"Activities: You have selected {chosen_activities_count} activities!" + else: + summary = f"Activities: You can still choose from {activities_with_space_count} / {total_activities_count} activities!" + + return summary + +def summary_questions(rsvp:RSVP) -> str: + selected_activities = rsvp.activities.all() + if not selected_activities.exists(): + return "Questions: No activities selected — no questions to answer." + + questions = Question.objects.filter(activity__in=selected_activities) + total = questions.count() + if total == 0: + return "Questions: No questions for your selected activities." + + def is_answered(q: Question) -> bool: + if q.scope == "rsvp": + resp = Response.objects.filter(rsvp=rsvp, question=q).first() + if not resp: + return False + if q.kind in ("text", "integer", "boolean"): + return bool(resp.value_text and resp.value_text.strip()) + return resp.choices.exists() + # guest-scoped + guests = rsvp.guests.all() + if not guests: + return False + for guest in guests: + resp = Response.objects.filter(guest=guest, question=q).first() + if not resp: + return False + if q.kind in ("text", "integer", "boolean"): + if not (resp.value_text and resp.value_text.strip()): + return False + else: + if resp.choices.count() == 0: + return False + return True + + answered = sum(1 for q in questions if is_answered(q)) + unanswered = total - answered + required_total = questions.filter(required=True).count() + required_unanswered = sum(1 for q in questions.filter(required=True) if not is_answered(q)) + + if rsvp.is_page_finished(RSVP.Page.QUESTIONS): + if unanswered == 0: + summary = f"Questions: All {total} answered." + else: + summary = f"Questions: {answered} answered, {unanswered} remaining." + else: + summary = f"Questions: {answered} answered, {unanswered} remaining." + + if required_unanswered > 0: + summary += f" ({required_unanswered} required unanswered)" + + return summary + +def summary_notes(rsvp:RSVP) -> str: + summary = f"Notes: anything you want to share?" + + if len(rsvp.notes) > 1: + summary = f"Notes: you have left a note." + + return summary \ No newline at end of file diff --git a/events/views/rsvp.py b/events/views/rsvp.py index e3b30de..dd57d59 100644 --- a/events/views/rsvp.py +++ b/events/views/rsvp.py @@ -9,7 +9,10 @@ from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbid from django.template.loader import get_template from django.urls import reverse -from ..utils.utils import add_to_context, create_or_switch_activity_response, create_context, get_event, get_from_context, get_rsvp, create_rsvp +from events.utils.cookieutils import set_rsvp_cookie +from events.utils.summary import summary_activities, summary_email, summary_guests, summary_notes, summary_questions + +from ..utils.rsvputils import add_to_context, create_or_switch_activity_response, create_context, get_event, get_from_context, get_rsvp, create_rsvp from ..forms import GuestForm, NotesForm, RSVPForm @@ -30,6 +33,59 @@ from ..models import ( # Logger logger = logging.getLogger(__name__) +# Start a new or unfinished RSVP +def rsvp_start(request, id:str): + rsvp = get_object_or_404(RSVP, id=id) + + if not (rsvp.status == RSVP.Status.NOT_STARTED or rsvp.status == RSVP.Status.INCOMPLETE): + return HttpResponseBadRequest() + + response = redirect(reverse('events:rsvp-base', args=[rsvp.event.slug, rsvp.edit_token, rsvp.page])) + return response + +# Update a completed RSVP +def rsvp_update(request, id:str): + rsvp = get_object_or_404(RSVP, id=id) + + if rsvp.status != RSVP.Status.YES_JOINING: + return HttpResponseBadRequest() + + rsvp.page = RSVP.Page.ACTIVITIES + rsvp.save() + + response = redirect(reverse('events:rsvp-base', args=[rsvp.event.slug, rsvp.edit_token, rsvp.page])) + return response + +# Stop an existing RSVP +def rsvp_decline(request, id:str): + rsvp = get_object_or_404(RSVP, id=id) + + rsvp.status = RSVP.Status.NOT_COMING + rsvp.page = RSVP.Page.EMAIL + rsvp.furthest_page = RSVP.Page.EMAIL + rsvp.save() + + # Remove any current activity selection + ActivitySelection.objects.filter(rsvp=rsvp).delete() + + # Remove any question responses + Response.objects.filter(rsvp=rsvp).delete() + Response.objects.filter(guest__rsvp=rsvp).delete() + + response = redirect(reverse('events:event', args=[rsvp.event.slug])) + return response + +def rsvp_restart(request, id:str): + rsvp = get_object_or_404(RSVP, id=id) + + rsvp.status = RSVP.Status.INCOMPLETE + rsvp.save() + + response = redirect(reverse('events:rsvp-base', args=[rsvp.event.slug, rsvp.edit_token, rsvp.page])) + return response + + + # Main entrypoint for rsvp page def rsvp_base_page(request, slug:str, code:str, page:str): event = get_object_or_404(Event, slug=slug) @@ -43,12 +99,13 @@ def rsvp_base_page(request, slug:str, code:str, page:str): "is_locked": event.is_locked() } - return render(request, "events/event_rsvp.html", context) - + response = render(request, "rsvp/rsvp.html", context) + set_rsvp_cookie(response, event, rsvp) + return response # Render selector based on the current step of the RSVP process -def rsvp_pages(request, id:str, page:str): +def rsvp_pages(request, id:str, page:str, error:str=""): logger.debug(f"rsvp_pages: id={id}, page={page}") rsvp = get_object_or_404(RSVP, id=id) @@ -56,7 +113,7 @@ def rsvp_pages(request, id:str, page:str): if page == "": page = rsvp.page - context = create_context(rsvp) + context = create_context(rsvp, error) rendercontent = { "page_email": _render_page_email(request, context, page), @@ -68,45 +125,52 @@ def rsvp_pages(request, id:str, page:str): # Add `hx-push` header and set to value of `reverse('events:rsvp-base', args=[slug, rsvp.edit_token, rsvp.page])` - response = render(request, "events/event_pages.html", rendercontent) + response = render(request, "rsvp/rsvp_pages.html", rendercontent) response["HX-Push-Url"] = reverse('events:rsvp-base', args=[get_event(context).slug, rsvp.edit_token, page]) return response # Email page -def _render_page_email(request, context, page): +def _render_page_email(request, context, page) -> str: - should_render = _render_page(request, get_rsvp(context), "Email updates", RSVP.Page.EMAIL, page) - if(should_render != True): return should_render + note = summary_email(get_rsvp(context)) + + should_render = _render_page(request, get_rsvp(context), note, RSVP.Page.EMAIL, page) + if(type(should_render) is not bool): + return should_render # Render the form add_to_context(context, "form", RSVPForm(instance=get_rsvp(context))) - template = get_template("events/partials/rsvp_page_email.html") + template = get_template("rsvp/rsvp_page_email.html") return template.render(context=context, request=request) -def _render_page_guest(request, context, page): +def _render_page_guest(request, context, page) -> str: + + note = summary_guests(get_rsvp(context)) + + should_render = _render_page(request, get_rsvp(context), note, RSVP.Page.GUESTS, page) + if(type(should_render) is not bool): + return should_render - # Find guests guests = ( Guest.objects .filter(rsvp=get_rsvp(context)) ) - note = f"You are coming with {len(guests)-1} guests" - if(len(guests) == 1): note = "You are coming!" - if(len(guests) == 0): note = "Nobody is coming (yet)" - - should_render = _render_page(request, get_rsvp(context), note, RSVP.Page.GUESTS, page) - if(should_render != True): return should_render - add_to_context(context, "guests", guests) add_to_context(context, "form", GuestForm()) - template = get_template("events/partials/rsvp_page_guest.html") + template = get_template("rsvp/rsvp_page_guest.html") return template.render(context=context, request=request) -def _render_page_activities(request, context, page): +def _render_page_activities(request, context, page) -> str: + + note = summary_activities(get_rsvp(context)) + + should_render = _render_page(request, get_rsvp(context), note, RSVP.Page.ACTIVITIES, page) + if(type(should_render) is not bool): + return should_render activities = ( Activity.objects @@ -115,11 +179,6 @@ def _render_page_activities(request, context, page): .order_by("start_time") ) - note = f"There are {len(activities)} activities to choose from" - - should_render = _render_page(request, get_rsvp(context), note, RSVP.Page.ACTIVITIES, page) - if(should_render != True): return should_render - groups = {} ungrouped = [] @@ -140,16 +199,18 @@ def _render_page_activities(request, context, page): selected_activities_ids = [act.activity.id for act in selected_activities] add_to_context(context, "selected_activities", selected_activities_ids) - template = get_template("events/partials/rsvp_page_activities.html") + template = get_template("rsvp/rsvp_page_activities.html") return template.render(context=context, request=request) -def _render_page_questions(request, context, page): +def _render_page_questions(request, context, page) -> str: """ Gather questions for activities selected by this RSVP and the guests list. Pass `questions` and `guests` into the template context. """ - should_render = _render_page(request, get_rsvp(context), "Questions", RSVP.Page.QUESTIONS, page) - if should_render != True: + note = summary_questions(get_rsvp(context)) + + should_render = _render_page(request, get_rsvp(context), note, RSVP.Page.QUESTIONS, page) + if(type(should_render) is not bool): return should_render rsvp = get_rsvp(context) @@ -184,31 +245,35 @@ def _render_page_questions(request, context, page): add_to_context(context, "responses_rsvp", responses_rsvp) add_to_context(context, "responses_guest", responses_guest) - template = get_template("events/partials/rsvp_page_questions.html") + template = get_template("rsvp/rsvp_page_questions.html") return template.render(context=context, request=request) -def _render_page_notes(request, context, page): +def _render_page_notes(request, context, page) -> str: - should_render = _render_page(request, get_rsvp(context), "Notes", RSVP.Page.NOTES, page) - if(should_render != True): return should_render + note = summary_notes(get_rsvp(context)) + + should_render = _render_page(request, get_rsvp(context), note, RSVP.Page.NOTES, page) + if(type(should_render) is not bool): + return should_render add_to_context(context, "form", NotesForm(instance=get_rsvp(context))) - template = get_template("events/partials/rsvp_page_notes.html") + template = get_template("rsvp/rsvp_page_notes.html") return template.render(context=context, request=request) # Render generic page placeholder -def _render_page(request, rsvp:RSVP, note:str, page:RSVP.Page, current_page:RSVP.Page) -> bool | str: +def _render_page(request, rsvp:RSVP, note:str, page:RSVP.Page, current_page:RSVP.Page, oob:bool=False) -> bool | str: if page == current_page: return True - template = get_template("events/partials/rsvp_page_inactive.html") + template = get_template("rsvp/rsvp_page_inactive.html") context = { "note": note, "rsvp": rsvp, "page": page, - "complete": rsvp.is_page_finished(page) + "complete": rsvp.is_page_finished(page), + "oob": oob } return template.render(context=context, request=request) @@ -218,15 +283,28 @@ def page_change(request, id:str, page:str): logger.debug(f"page_change: id={id}, page={page}") rsvp = get_object_or_404(RSVP, id=id) + error = "" if page == "next": - rsvp.next_page() + allowed = rsvp.is_next_page_allowed() + if(type(allowed) is bool and allowed == True): + rsvp.next_page() + elif type(allowed) is str: + # allowed contains an error + error = allowed elif page == "previous": rsvp.previous_page() else: rsvp.set_page(page) - return rsvp_pages(request, id, rsvp.page) + # Done with rsvp + if rsvp.page == RSVP.Page.COMPLETED: + #response = redirect(reverse('events:event', args=[rsvp.event.slug])) + response = HttpResponse() + response["HX-Redirect"] = reverse('events:event', args=[rsvp.event.slug]) + return response + + return rsvp_pages(request, id, rsvp.page, error=error) # Email step: handle post data def post_step_email(request, id:str): @@ -296,6 +374,12 @@ def post_step_activities(request, id:str, aid:str): return HttpResponseBadRequest(str(e)) content = _render_page_activities(request, create_context(rsvp), RSVP.Page.ACTIVITIES) + + # update the guests summary + note = summary_questions(rsvp) + summary = _render_page(request, rsvp, note, RSVP.Page.QUESTIONS, RSVP.Page.ACTIVITIES, oob=True) + content = content + str(summary) + return HttpResponse(content) diff --git a/events/views/views.py b/events/views/views.py index 9696c52..280a8ec 100644 --- a/events/views/views.py +++ b/events/views/views.py @@ -1,93 +1,103 @@ # views.py -from django.shortcuts import get_object_or_404, render, redirect -from django.db import transaction -from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden +import logging + +from django.shortcuts import get_object_or_404, redirect, render +from django.http import Http404, HttpResponseBadRequest, HttpRequest from django.urls import reverse -from events.utils.utils import create_rsvp - -from ..forms import GuestForm +from events.utils.cookieutils import get_rsvp_from_cookie, set_rsvp_cookie +from events.utils.rsvputils import create_rsvp from ..models import ( Event, - Guest, Invite, Activity, - ActivityGroup, RSVP, - ActivitySelection, ) -def invite_redirect(request, slug, code): +# Logger +logger = logging.getLogger(__name__) + +def invite_redirect(request:HttpRequest, slug, code): """ Redirect from the unique invite page to the rsvp response page which is linked to the response """ event = get_object_or_404(Event, slug=slug) - invite = get_object_or_404(Invite, code=code) - - # The invite code does not match the event - if invite.event.id != event.id: - return HttpResponseBadRequest() + response = redirect(reverse('events:event', args=[slug])) + + logger.info(f"Invite `{code}` opened for Event `{slug}`") + + rsvp:RSVP - # Create RSVP if it doesn't exist, otherwise get it try: + invite = get_object_or_404(Invite, code=code) + + # The invite code does not match the event + if invite.event.id != event.id: + return HttpResponseBadRequest() + rsvp = RSVP.objects.get(invite=invite) - except RSVP.DoesNotExist: + set_rsvp_cookie(response, event, rsvp) + except Http404 as e: + logger.debug(str(e)) + pass + except RSVP.DoesNotExist as e: + logger.debug(str(e)) rsvp = create_rsvp(event, invite) + set_rsvp_cookie(response, event, rsvp) + + return response - return redirect(reverse('events:rsvp-base', args=[slug, rsvp.edit_token, rsvp.page])) -def event_details(request, slug): - """ - RSVP page for an event. - Shows event info and list of activities. - """ +def event(request:HttpRequest, slug): event = get_object_or_404(Event, slug=slug) - + activities = ( Activity.objects - .filter(event=event, allow_rsvp=True) + .filter(event=event) .select_related("group") .order_by("start_time") ) - + groups = {} ungrouped = [] for activity in activities: + if activity.group: groups.setdefault(activity.group, []).append(activity) else: ungrouped.append(activity) - + + + context = { "event": event, "groups": groups, "ungrouped_activities": ungrouped, - "allow_rsvp": event.allow_rsvp_without_invite, - "is_locked": event.is_locked(), + } + + response = False + + rsvp = get_rsvp_from_cookie(request, slug) + if type(rsvp) is RSVP: + + joining = True if rsvp.status == RSVP.Status.YES_JOINING else False + not_coming = True if rsvp.status == RSVP.Status.NOT_COMING else False + in_progress = True if rsvp.status == RSVP.Status.INCOMPLETE else False + not_started = True if rsvp.status == RSVP.Status.NOT_STARTED else False + + response = { + "rsvp": rsvp, + "joining": joining, + "not_coming": not_coming, + "in_progress": in_progress, + "not_started": not_started + } + + context["response"] = response - return render(request, "events/event_details.html", context) - - -# //// ######## \\\\ # -# PARTIALS # -# //// ######## \\\\ # - -def partial_activity_details(request, activity_id): - """ - HTMX partial: loads activity details (description + questions). - """ - activity = get_object_or_404(Activity, id=activity_id) - - if not activity.allow_rsvp: - return HttpResponseForbidden() - - return render( - request, - "events/partials/activity_detail.html", - {"activity": activity}, - ) \ No newline at end of file + return render(request, "event/event.html", context) \ No newline at end of file