- Added markdown support for event descriptions and activity details. - Removed Invite model and adjusted RSVP to use a slug for invites. - Enhanced guest management with real-time updates using websockets. - Improved RSVP page layout and user experience with new button styles. - Added message handling for user notifications. - Updated URL patterns for better clarity and structure.
609 lines
23 KiB
Python
609 lines
23 KiB
Python
# models.py
|
|
import uuid
|
|
from typing import Self
|
|
from datetime import timedelta
|
|
from django.db import models
|
|
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
|
|
|
|
User = settings.AUTH_USER_MODEL
|
|
|
|
|
|
class TimestampedModel(models.Model):
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
def event_image_path(instance, filename):
|
|
# file will be uploaded to MEDIA_ROOT/user_<id>/<filename>
|
|
return "events/static/img/{0}/{1}".format(instance.slug, filename)
|
|
|
|
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)
|
|
|
|
title = models.CharField(max_length=255)
|
|
slug = models.SlugField(max_length=255, db_index=True)
|
|
description = models.TextField(blank=True) # markdown-supported
|
|
|
|
image = models.ImageField(upload_to=event_image_path, null=True, blank=True)
|
|
|
|
# Behavioural settings
|
|
allow_rsvp_without_invite = models.BooleanField(default=False)
|
|
allow_comments = models.BooleanField(default=True)
|
|
default_email_updates_optin = models.BooleanField(default=True)
|
|
|
|
# Limits (optional)
|
|
guest_limit = models.PositiveIntegerField(null=True, blank=True)
|
|
|
|
# Response locking and cookie behaviour
|
|
response_lock_before = models.DurationField(default=timedelta(hours=24))
|
|
cookie_expire_after_event = models.DurationField(default=timedelta(days=7))
|
|
|
|
class Meta:
|
|
ordering = ['-id']
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
@property
|
|
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:]
|
|
return "img/placeholder.jpg"
|
|
|
|
@property
|
|
def start_time(self):
|
|
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]
|
|
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]
|
|
if not last:
|
|
return False
|
|
return timezone.now() >= (last.start_time - self.response_lock_before)
|
|
|
|
def total_attendees(self) -> int:
|
|
return Guest.objects.filter(rsvp__event=self).count()
|
|
|
|
def remaining_capacity(self) -> int | None:
|
|
if self.guest_limit is None:
|
|
return None
|
|
return max(self.guest_limit - self.total_attendees(), 0)
|
|
|
|
|
|
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)
|
|
title = models.CharField(max_length=255)
|
|
single_choice = models.BooleanField(default=False)
|
|
|
|
def __str__(self):
|
|
return f"{self.event.title} — {self.title}"
|
|
|
|
|
|
def activity_image_path(instance, filename):
|
|
# file will be uploaded to MEDIA_ROOT/user_<id>/<filename>
|
|
return "events/static/img/{0}/{1}".format(instance.slug, filename)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
guest_limit = models.PositiveIntegerField(null=True, blank=True)
|
|
image = models.ImageField(upload_to=activity_image_path, null=True, blank=True)
|
|
allow_rsvp = models.BooleanField(default=True)
|
|
|
|
def __str__(self):
|
|
if self.group is not None:
|
|
return f"{self.event.title} — {self.group.title} — {self.title}"
|
|
return f"{self.event.title} — {self.title}"
|
|
|
|
@property
|
|
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
|
|
return max(self.guest_limit - self.attendee_count(), 0)
|
|
|
|
def attendee_count(self) -> int:
|
|
return Guest.objects.filter(rsvp__activity_selections__activity=self).count()
|
|
|
|
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
|
|
2.1. Sort the list so that the Guest with property main_invitee is first,
|
|
then based on the age group (old to young)
|
|
2.2. Transform all names for privacy reasons to be the first name only
|
|
2.3. If a first name is duplicate in another rsvp guest group, then add the last name letter
|
|
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)
|
|
|
|
if not selected_rsvps:
|
|
return []
|
|
|
|
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:
|
|
guests = Guest.objects.filter(rsvp=rsvp_id)
|
|
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:
|
|
first_names_tracker[first_name] = 1
|
|
|
|
# Create the lists
|
|
guest_list = []
|
|
for rsvp_id in selected_rsvps:
|
|
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 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')
|
|
|
|
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')
|
|
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
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)
|
|
|
|
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)
|
|
|
|
notes = models.TextField(blank=True)
|
|
|
|
# Activities selected by this RSVP (through ActivitySelection)
|
|
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]
|
|
|
|
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
|
|
|
|
@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',
|
|
'activities',
|
|
'questions',
|
|
'notes',
|
|
'completed',
|
|
]
|
|
|
|
def next_page(self) -> Page:
|
|
"""Move to the next page and save to model."""
|
|
|
|
new_page = self.page
|
|
|
|
try:
|
|
current_index = self.PAGE_ORDER.index(self.page)
|
|
if current_index < len(self.PAGE_ORDER) - 1:
|
|
new_page = self.Page(self.PAGE_ORDER[current_index + 1])
|
|
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
|
|
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:
|
|
new_page = self.Page(self.PAGE_ORDER[current_index - 1])
|
|
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:
|
|
"""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:
|
|
"""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(): # 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.'
|
|
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):
|
|
"""Join table: which activities an RSVP selected.
|
|
|
|
NOTE: single-choice enforcement for groups is handled at the application/form level (clean/transaction)
|
|
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)
|
|
|
|
class Meta:
|
|
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)
|
|
if other.exists():
|
|
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+)")
|
|
]
|
|
|
|
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)
|
|
|
|
name = models.CharField(max_length=255)
|
|
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]
|
|
|
|
@property
|
|
def lastname(self) -> str:
|
|
return self.name.split(" ")[1:]
|
|
|
|
|
|
class Question(TimestampedModel):
|
|
KIND_CHOICES = [
|
|
('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)
|
|
|
|
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')
|
|
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)
|
|
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.
|
|
|
|
A Response must link to the appropriate owner according to the question context:
|
|
- An activity-level question that applies to the entire RSVP: store `rsvp`.
|
|
- A per-person question: store `guest`.
|
|
|
|
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)
|
|
|
|
# Choice (single) if applicable
|
|
choices = models.ManyToManyField(QuestionChoice, through="ResponseChoice", related_name="responses", blank=True)
|
|
value_text = models.TextField(blank=True)
|
|
|
|
def clean(self):
|
|
scope = self.question.scope
|
|
if scope == "rsvp":
|
|
if not self.rsvp or self.guest:
|
|
raise ValidationError("This question must be answered once per RSVP.")
|
|
if scope == "guest":
|
|
if not self.guest or self.rsvp:
|
|
raise ValidationError("This question must be answered once per Guest.")
|
|
|
|
kind = self.question.kind
|
|
# For choice-backed questions, validation happens relative to choices M2M
|
|
if kind == "single":
|
|
# Expect exactly one selected choice (or at least one)
|
|
if not self.pk:
|
|
# if not saved yet, can't reliably check M2M, so skip strict check here
|
|
pass
|
|
else:
|
|
if self.choices.count() != 1:
|
|
raise ValidationError("Single-choice question requires exactly one choice.")
|
|
if kind == "multiple":
|
|
if not self.pk:
|
|
pass
|
|
# 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.")
|
|
|
|
if self.question.activity.id != self.activity.id:
|
|
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} 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}"
|
|
|
|
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)
|
|
|
|
class Meta:
|
|
unique_together = (("response", "choice"),)
|
|
|
|
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)
|
|
|
|
text = models.TextField()
|
|
notify_subscribers = models.BooleanField(default=False)
|
|
|
|
def __str__(self):
|
|
return f"Comment by {self.guest.name} on {self.event.title}" |