PonyConf/cfp/models.py

540 lines
22 KiB
Python
Raw Normal View History

2017-05-29 20:48:49 +00:00
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
2023-01-26 21:36:04 +00:00
from django.conf import settings
2017-12-10 12:32:19 +00:00
from django.urls import reverse
2017-05-29 20:48:49 +00:00
from django.core.validators import MaxValueValidator, MinValueValidator
from django.core.exceptions import ValidationError
2017-05-29 20:48:49 +00:00
from django.db import models
2017-08-12 20:24:55 +00:00
from django.db.models import Q, Count, Avg, Case, When
from django.db.models.functions import Coalesce
2017-05-29 20:48:49 +00:00
from django.utils import timezone
2023-03-18 17:30:34 +00:00
from django.utils.translation import gettext, gettext_lazy as _
2017-10-09 17:48:33 +00:00
from django.utils.safestring import mark_safe
from django.utils.html import escape, format_html
2017-05-29 20:48:49 +00:00
from autoslug import AutoSlugField
from colorful.fields import RGBColorField
from functools import partial
2023-01-26 21:36:04 +00:00
import phonenumbers
2017-05-29 20:48:49 +00:00
import uuid
from datetime import timedelta
2017-09-27 19:53:35 +00:00
from os.path import join, basename
from ponyconf.utils import PonyConfModel
from mailing.models import MessageThread
2017-05-29 20:48:49 +00:00
2017-05-30 19:50:40 +00:00
class Conference(models.Model):
site = models.OneToOneField(Site, on_delete=models.CASCADE)
2017-10-05 23:33:57 +00:00
2017-07-30 18:11:13 +00:00
name = models.CharField(blank=True, max_length=100, verbose_name=_('Conference name'))
home = models.TextField(blank=True, default="", verbose_name=_('Homepage (markdown)'))
venue = models.TextField(blank=True, default="", verbose_name=_('Venue information'))
city = models.CharField(max_length=64, blank=True, default="", verbose_name=_('City'))
contact_email = models.CharField(max_length=100, blank=True, verbose_name=_('Contact email'))
reply_email = models.CharField(max_length=100, blank=True, verbose_name=_('Reply email'))
2017-07-30 18:11:13 +00:00
staff = models.ManyToManyField(User, blank=True, verbose_name=_('Staff members'))
2017-08-11 21:25:42 +00:00
secure_domain = models.BooleanField(default=True, verbose_name=_('Secure domain (HTTPS)'))
2017-11-04 14:30:00 +00:00
acceptances_disclosure_date = models.DateTimeField(null=True, blank=True, default=None, verbose_name=_('Acceptances disclosure date'))
2017-08-15 18:52:12 +00:00
schedule_publishing_date = models.DateTimeField(null=True, blank=True, default=None, verbose_name=_('Schedule publishing date'))
2017-10-18 18:37:16 +00:00
schedule_redirection_url = models.URLField(blank=True, default='', verbose_name=_('Schedule redirection URL'),
help_text=_('If specified, schedule tab will redirect to this URL.'))
2017-10-05 23:33:57 +00:00
volunteers_opening_date = models.DateTimeField(null=True, blank=True, default=None, verbose_name=_('Volunteers enrollment opening date'))
volunteers_closing_date = models.DateTimeField(null=True, blank=True, default=None, verbose_name=_('Volunteers enrollment closing date'))
2017-11-27 20:17:20 +00:00
video_publishing_date = models.DateTimeField(null=True, blank=True, default=None, verbose_name=_('Video publishing date'))
2019-06-08 11:02:19 +00:00
end_date = models.DateTimeField(null=True, blank=True, default=None, verbose_name=_('End of the conference date'))
custom_css = models.TextField(blank=True)
external_css_link = models.URLField(blank=True)
2017-10-05 23:33:57 +00:00
def volunteers_enrollment_is_open(self):
now = timezone.now()
opening = self.volunteers_opening_date
closing = self.volunteers_closing_date
return opening and opening < now and (not closing or closing > now)
2019-06-08 11:02:19 +00:00
@property
def completed(self):
return self.end_date and self.end_date <= timezone.now()
@property
def opened_categories(self):
now = timezone.now()
return TalkCategory.objects.filter(site=self.site)\
.filter(Q(opening_date__isnull=True) | Q(opening_date__lte=now))\
.filter(Q(closing_date__isnull=True) | Q(closing_date__gte=now))
2017-05-30 19:50:40 +00:00
2017-11-04 14:30:00 +00:00
@property
def disclosed_acceptances(self):
# acceptances are automatically disclosed if the schedule is published
2017-11-22 13:12:38 +00:00
return (self.acceptances_disclosure_date and self.acceptances_disclosure_date <= timezone.now()) or self.schedule_available
2017-11-04 14:30:00 +00:00
2017-08-15 18:52:12 +00:00
@property
def schedule_available(self):
return self.schedule_publishing_date and self.schedule_publishing_date <= timezone.now()
2017-11-27 20:17:20 +00:00
@property
def videos_available(self):
return self.video_publishing_date and self.video_publishing_date <= timezone.now()
def from_email(self):
return self.name+' <'+self.contact_email+'>'
def clean_fields(self, exclude=None):
super().clean_fields(exclude)
if self.reply_email is not None:
try:
self.reply_email.format(token='a' * 80)
except Exception:
raise ValidationError({
'reply_email': _('The reply email should be a formatable string accepting a token argument (e.g. ponyconf+{token}@exemple.com).'),
})
2017-05-30 19:50:40 +00:00
def __str__(self):
2017-11-30 19:34:12 +00:00
return self.name
2017-05-30 19:50:40 +00:00
2017-08-02 19:07:36 +00:00
class ParticipantManager(models.Manager):
def get_queryset(self):
qs = super().get_queryset()
qs = qs.annotate(
accepted_talk_count=Count(Case(When(Q(talk__accepted=True) & (Q(talk__confirmed=True) | Q(talk__confirmed__isnull=True)), then='talk__pk'), output_field=models.IntegerField()), distinct=True),
2017-08-12 20:24:55 +00:00
pending_talk_count=Count(Case(When(talk__accepted=None, then='talk__pk'), output_field=models.IntegerField()), distinct=True),
refused_talk_count=Count(Case(When(talk__accepted=False, then='talk__pk'), output_field=models.IntegerField()), distinct=True),
canceled_talk_count=Count(Case(When(talk__confirmed=False, then='talk__pk'), output_field=models.IntegerField()), distinct=True),
2017-08-02 19:07:36 +00:00
)
return qs
2017-05-29 20:48:49 +00:00
class Participant(PonyConfModel):
site = models.ForeignKey(Site, on_delete=models.CASCADE)
2017-11-04 14:30:00 +00:00
name = models.CharField(max_length=128, verbose_name=_('Name'))
2017-05-29 20:48:49 +00:00
email = models.EmailField()
biography = models.TextField(verbose_name=_('Biography'))
2017-08-11 14:58:16 +00:00
token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
twitter = models.CharField(max_length=100, blank=True, default='', verbose_name=_('Twitter'))
linkedin = models.CharField(max_length=100, blank=True, default='', verbose_name=_('LinkedIn'))
github = models.CharField(max_length=100, blank=True, default='', verbose_name=_('Github'))
website = models.CharField(max_length=100, blank=True, default='', verbose_name=_('Website'))
facebook = models.CharField(max_length=100, blank=True, default='', verbose_name=_('Facebook'))
mastodon = models.CharField(max_length=100, blank=True, default='', verbose_name=_('Mastodon'))
phone_number = models.CharField(max_length=64, blank=True, default='', verbose_name=_('Phone number'))
language = models.CharField(max_length=10, blank=True)
2017-10-05 23:33:57 +00:00
notes = models.TextField(default='', blank=True, verbose_name=_("Notes"),
help_text=_('This field is only visible by organizers.'))
2017-11-03 19:35:24 +00:00
vip = models.BooleanField(default=False, verbose_name=_('Invited speaker'))
2017-12-10 12:32:19 +00:00
conversation = models.OneToOneField(MessageThread, on_delete=models.PROTECT)
2017-08-02 19:07:36 +00:00
objects = ParticipantManager()
2017-11-05 20:37:34 +00:00
def get_absolute_url(self):
2017-11-25 21:41:21 +00:00
return reverse('participant-details', kwargs={'participant_id': self.pk})
2017-11-05 20:37:34 +00:00
2017-11-04 14:59:58 +00:00
def get_secret_url(self, full=False):
url = reverse('proposal-dashboard', kwargs={'speaker_token': self.token})
if full:
url = ('https' if self.site.conference.secure_domain else 'http') + '://' + self.site.domain + url
return url
2017-08-11 22:50:42 +00:00
def get_csv_row(self):
return map(partial(getattr, self), ['pk', 'name', 'email', 'biography', 'twitter', 'linkedin', 'github', 'website', 'facebook', 'mastodon', 'phone_number', 'notes'])
2017-05-29 20:48:49 +00:00
class Meta:
# A User can participe only once to a Conference (= Site)
2017-11-04 14:30:00 +00:00
unique_together = ('site', 'name')
2017-05-29 20:48:49 +00:00
unique_together = ('site', 'email')
def __str__(self):
return str(self.name)
@property
def co_speaker_set(self):
return Participant.objects.filter(site=self.site, talk__in=self.talk_set.values_list('pk')).exclude(pk=self.pk).order_by('name').distinct()
2017-07-30 14:57:38 +00:00
@property
def accepted_talk_set(self):
return self.talk_set.filter(accepted=True).exclude(confirmed=False)
@property
def canceled_talk_set(self):
return self.talk_set.filter(confirmed=False)
2017-07-30 14:57:38 +00:00
@property
def pending_talk_set(self):
return self.talk_set.filter(accepted=None)
@property
def refused_talk_set(self):
return self.talk_set.filter(accepted=False)
2017-05-29 20:48:49 +00:00
class Track(PonyConfModel):
site = models.ForeignKey(Site, on_delete=models.CASCADE)
name = models.CharField(max_length=128, verbose_name=_('Name'))
slug = AutoSlugField(populate_from='name')
description = models.TextField(blank=True, verbose_name=_('Description'))
class Meta:
unique_together = ('site', 'name')
2017-10-16 17:13:59 +00:00
ordering = ['name']
2017-05-29 20:48:49 +00:00
2017-08-02 18:25:43 +00:00
def estimated_duration(self):
return sum([talk.estimated_duration for talk in self.talk_set.all()])
2017-05-29 20:48:49 +00:00
def __str__(self):
return self.name
2017-08-02 18:25:43 +00:00
def get_absolute_url(self):
return reverse('talk-list') + '?track=%s' % self.slug
2017-05-29 20:48:49 +00:00
2017-08-12 00:05:53 +00:00
class Room(models.Model):
site = models.ForeignKey(Site, on_delete=models.CASCADE)
2017-11-22 22:12:31 +00:00
name = models.CharField(max_length=256, blank=True, default='', verbose_name=_('Name'))
2017-10-05 23:33:57 +00:00
slug = AutoSlugField(populate_from='name')
2017-11-22 22:12:31 +00:00
label = models.CharField(max_length=256, blank=True, default='', verbose_name=_('Label'))
capacity = models.IntegerField(default=0, verbose_name=_('Capacity'))
2017-08-12 00:05:53 +00:00
class Meta:
unique_together = ['site', 'name']
ordering = ['name']
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('room-details', kwargs={'slug': self.slug})
@property
def talks(self):
return self.talk_set.exclude(accepted=False)
@property
def talks_by_date(self):
return self.talks.filter(start_date__isnull=False).exclude(duration=0, category__duration=0).order_by('start_date').all()
@property
def unscheduled_talks(self):
return self.talks.filter(Q(start_date__isnull=True) | Q(duration=0, category__duration=0)).all()
2017-10-09 17:48:33 +00:00
class Tag(models.Model):
site = models.ForeignKey(Site, on_delete=models.CASCADE)
name = models.CharField(max_length=256, verbose_name=_('Name'))
slug = AutoSlugField(populate_from='name')
color = RGBColorField(default='#ffffff', verbose_name=_("Color"))
inverted = models.BooleanField(default=False)
2017-11-16 10:01:27 +00:00
public = models.BooleanField(default=False, verbose_name=_('Show the tag on the public program'))
staff = models.BooleanField(default=False, verbose_name=_('Show the tag on the staff program'))
2017-10-09 17:48:33 +00:00
2017-11-07 22:54:09 +00:00
def get_absolute_url(self):
return reverse('tag-list')
def get_filter_url(self):
return reverse('talk-list') + '?tag=' + self.slug
2017-10-09 17:48:33 +00:00
@property
def link(self):
2017-11-07 22:54:09 +00:00
return format_html('<a href="{url}">{content}</a>', **{
'url': self.get_filter_url(),
2017-10-09 17:48:33 +00:00
'content': self.label,
})
@property
def label(self):
return format_html('<span class="label" style="{style}">{name}</span>', **{
'style': self.style,
'name': self.name,
})
@property
def style(self):
return mark_safe('background-color: {bg}; color: {fg}; vertical-align: middle;'.format(**{
'fg': '#fff' if self.inverted else '#000',
'bg': self.color,
}))
def __str__(self):
return self.name
2017-05-29 20:48:49 +00:00
class TalkCategory(models.Model): # type of talk (conf 30min, 1h, stand, …)
site = models.ForeignKey(Site, on_delete=models.CASCADE)
name = models.CharField(max_length=64)
duration = models.PositiveIntegerField(default=0, verbose_name=_('Default duration (min)'))
color = RGBColorField(default='#ffffff', verbose_name=_("Color on program"))
label = models.CharField(max_length=64, verbose_name=_("Label on program"), blank=True, default="")
opening_date = models.DateTimeField(null=True, blank=True, default=None)
closing_date = models.DateTimeField(null=True, blank=True, default=None)
def is_open(self):
now = timezone.now()
if self.opening_date and now < self.opening_date:
return False
if self.closing_date and now > self.closing_date:
return False
return True
class Meta:
unique_together = ('site', 'name')
ordering = ('pk',)
verbose_name = "category"
verbose_name_plural = "categories"
2017-05-29 20:48:49 +00:00
def __str__(self):
2023-03-18 17:30:34 +00:00
return gettext(self.name)
2017-05-29 20:48:49 +00:00
2017-08-12 00:05:53 +00:00
def get_absolute_url(self):
2017-11-07 22:54:09 +00:00
return reverse('category-list')
def get_filter_url(self):
2017-08-12 00:05:53 +00:00
return reverse('talk-list') + '?category=%d' % self.pk
2017-05-29 20:48:49 +00:00
#class Attendee(PonyConfModel):
#
# user = models.ForeignKey(User, null=True)
# name = models.CharField(max_length=64, blank=True, default="")
# email = models.EmailField(blank=True, default="")
#
# def get_name(self):
# if self.user:
# return str(self.user.profile)
# else:
# return self.name
# get_name.short_description = _('Name')
#
# def get_email(self):
# if self.user:
# return self.user.email
# else:
# return self.email
# get_email.short_description = _('Email')
#
# def __str__(self):
# return self.get_name()
class TalkManager(models.Manager):
def get_queryset(self):
qs = super().get_queryset()
2023-03-18 17:30:34 +00:00
qs = qs.annotate(score=Coalesce(Avg('vote__vote'), 0.0))
return qs
2017-09-27 18:39:29 +00:00
def talks_materials_destination(talk, filename):
return join(talk.site.name, talk.slug, filename)
2017-05-29 20:48:49 +00:00
class Talk(PonyConfModel):
LICENCES = (
('CC-Zero CC-BY', 'CC-Zero CC-BY'),
('CC-BY-SA', 'CC-BY-SA'),
('CC-BY-ND', 'CC-BY-ND'),
('CC-BY-NC', 'CC-BY-NC'),
('CC-BY-NC-SA','CC-BY-NC-SA'),
('CC-BY-NC-ND', 'CC-BY-NC-ND'),
)
2017-05-29 20:48:49 +00:00
site = models.ForeignKey(Site, on_delete=models.CASCADE)
speakers = models.ManyToManyField(Participant, verbose_name=_('Speakers'))
title = models.CharField(max_length=128, verbose_name=_('Talk Title'))
2017-05-29 20:48:49 +00:00
slug = AutoSlugField(populate_from='title', unique=True)
2017-10-05 23:33:57 +00:00
description = models.TextField(verbose_name=_('Description of your talk'),
help_text=_('This description will be visible on the program.'))
2017-12-10 12:32:19 +00:00
track = models.ForeignKey(Track, blank=True, null=True, verbose_name=_('Track'), on_delete=models.SET_NULL)
2017-10-11 08:19:44 +00:00
tags = models.ManyToManyField(Tag, blank=True)
2017-10-05 23:33:57 +00:00
notes = models.TextField(blank=True, verbose_name=_('Message to organizers'),
help_text=_('If you have any constraint or if you have anything that may '
'help you to select your talk, like a video or slides of your'
' talk, please write it down here. This field will only be '
'visible by organizers.'))
2017-12-10 12:32:19 +00:00
category = models.ForeignKey(TalkCategory, verbose_name=_('Talk Category'), on_delete=models.PROTECT)
2017-05-29 20:48:49 +00:00
videotaped = models.BooleanField(_("I'm ok to be recorded on video"), default=True)
2017-10-05 23:33:57 +00:00
video_licence = models.CharField(choices=LICENCES, default='CC-BY-SA',
max_length=32, verbose_name=_("Video licence"))
2017-05-29 20:48:49 +00:00
sound = models.BooleanField(_("I need sound"), default=False)
accepted = models.BooleanField(null=True, default=None)
confirmed = models.BooleanField(null=True, default=None)
2017-08-12 00:05:53 +00:00
start_date = models.DateTimeField(null=True, blank=True, default=None, verbose_name=_('Beginning date and time'))
2017-05-29 20:48:49 +00:00
duration = models.PositiveIntegerField(default=0, verbose_name=_('Duration (min)'))
2017-12-10 12:32:19 +00:00
room = models.ForeignKey(Room, blank=True, null=True, default=None, on_delete=models.SET_NULL)
2017-05-29 20:48:49 +00:00
plenary = models.BooleanField(default=False)
2017-11-14 21:43:05 +00:00
materials = models.FileField(null=True, blank=True, upload_to=talks_materials_destination, verbose_name=_('Materials'),
2017-09-27 18:39:29 +00:00
help_text=_('You can use this field to share some materials related to your intervention.'))
video = models.URLField(max_length=1000, blank=True, default='', verbose_name='Video URL')
2017-08-11 14:58:16 +00:00
token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
2017-12-10 12:32:19 +00:00
conversation = models.OneToOneField(MessageThread, on_delete=models.PROTECT)
objects = TalkManager()
2017-05-29 20:48:49 +00:00
class Meta:
ordering = ('title',)
def __str__(self):
return self.title
def get_speakers_str(self):
2017-08-12 00:05:53 +00:00
speakers = list(map(str, self.speakers.all()))
2017-05-29 20:48:49 +00:00
if len(speakers) == 0:
return 'superman'
elif len(speakers) == 1:
return speakers[0]
else:
return ', '.join(speakers[:-1]) + ' & ' + str(speakers[-1])
def get_status_str(self):
if self.accepted is True:
if self.confirmed is True:
return _('Confirmed')
elif self.confirmed is False:
return _('Cancelled')
else:
return _('Waiting confirmation')
elif self.accepted is False:
return _('Refused')
else:
return _('Pending decision, score: %(score).1f') % {'score': self.score}
def get_status_color(self):
if self.accepted is True:
if self.confirmed is True:
return 'success'
elif self.confirmed is False:
return 'danger'
else:
return 'info'
elif self.accepted is False:
2017-11-14 21:57:15 +00:00
return 'default'
else:
return 'warning'
2017-10-09 17:48:33 +00:00
def get_tags_html(self):
return mark_safe(' '.join(map(lambda tag: tag.link, self.tags.all())))
def get_csv_row(self):
return [
self.pk,
self.title,
self.description,
self.category,
self.track,
[speaker.pk for speaker in self.speakers.all()],
[speaker.name for speaker in self.speakers.all()],
[tag.name for tag in self.tags.all()],
1 if self.videotaped else 0,
self.video_licence,
1 if self.sound else 0,
self.estimated_duration,
self.room,
1 if self.plenary else 0,
self.materials,
self.video,
]
2017-05-29 20:48:49 +00:00
@property
def estimated_duration(self):
return self.duration or self.category.duration
2017-07-30 14:57:38 +00:00
def get_absolute_url(self):
2017-11-25 21:41:21 +00:00
return reverse('talk-details', kwargs={'talk_id': self.pk})
2017-05-29 20:48:49 +00:00
@property
def end_date(self):
if self.estimated_duration:
return self.start_date + timedelta(minutes=self.estimated_duration)
else:
return None
2017-09-27 19:53:35 +00:00
@property
def materials_name(self):
return basename(self.materials.name)
2017-05-29 20:48:49 +00:00
class Meta:
ordering = ('category__id', 'title',)
class Vote(PonyConfModel):
2017-12-10 12:32:19 +00:00
talk = models.ForeignKey(Talk, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
2017-05-29 20:48:49 +00:00
vote = models.IntegerField(validators=[MinValueValidator(-2), MaxValueValidator(2)], default=0)
class Meta:
unique_together = ('talk', 'user')
def __str__(self):
return "%+i by %s for %s" % (self.vote, self.user, self.talk)
2017-08-12 00:05:53 +00:00
def get_absolute_url(self):
return self.talk.get_absolute_url()
2017-10-05 23:33:57 +00:00
class Activity(models.Model):
site = models.ForeignKey(Site, on_delete=models.CASCADE)
name = models.CharField(max_length=256, verbose_name=_('Name'))
slug = AutoSlugField(populate_from='name')
description = models.TextField(blank=True, verbose_name=_('Description'))
class Meta:
unique_together = ('site', 'name')
verbose_name = _('Activity')
verbose_name_plural = _('Activities')
def get_absolute_url(self):
return reverse('activity-list')
def get_filter_url(self):
return reverse('volunteer-list') + '?activity=' + self.slug
def __str__(self):
return self.name
2023-01-26 21:36:04 +00:00
def validate_phone_number(phone_number: str):
try:
number = phonenumbers.parse(phone_number, region=settings.DEFAULT_PHONE_REGION)
except phonenumbers.phonenumberutil.NumberParseException as err:
raise ValidationError(str(err))
else:
if not phonenumbers.is_valid_number(number):
2023-01-26 22:01:38 +00:00
raise ValidationError(_("Invalid phone number, try using the country code (like +33 for France)"))
2023-01-26 21:36:04 +00:00
2017-10-05 23:33:57 +00:00
class Volunteer(PonyConfModel):
site = models.ForeignKey(Site, on_delete=models.CASCADE)
name = models.CharField(max_length=128, verbose_name=_('Your Name'))
2017-11-03 18:48:57 +00:00
email = models.EmailField(verbose_name=_('Email'))
2017-10-05 23:33:57 +00:00
token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
2023-01-26 21:36:04 +00:00
phone_number = models.CharField(max_length=64, blank=True, default='', verbose_name=_('Phone number'), validators=[validate_phone_number])
2017-11-03 18:48:57 +00:00
sms_prefered = models.BooleanField(default=False, verbose_name=_('SMS prefered'))
2017-10-05 23:33:57 +00:00
language = models.CharField(max_length=10, blank=True)
notes = models.TextField(default='', blank=True, verbose_name=_('Notes'),
help_text=_('If you have some constraints, you can indicate them here.'))
activities = models.ManyToManyField(Activity, blank=True, related_name='volunteers', verbose_name=_('Activities'))
2017-12-10 12:32:19 +00:00
conversation = models.OneToOneField(MessageThread, on_delete=models.PROTECT)
2017-10-05 23:33:57 +00:00
def get_absolute_url(self):
2017-11-25 21:41:21 +00:00
return reverse('volunteer-details', kwargs={'volunteer_id': self.pk})
2017-10-05 23:33:57 +00:00
2017-11-05 22:18:27 +00:00
def get_secret_url(self, full=False):
2017-11-25 21:41:21 +00:00
url = reverse('volunteer-dashboard', kwargs={'volunteer_token': self.token})
2017-11-05 22:18:27 +00:00
if full:
url = ('https' if self.site.conference.secure_domain else 'http') + '://' + self.site.domain + url
return url
def get_csv_row(self):
return [
self.pk,
self.name,
self.email,
self.phone_number,
1 if self.sms_prefered else 0,
self.notes,
]
2017-10-05 23:33:57 +00:00
class Meta:
# A volunteer can participe only once to a Conference (= Site)
unique_together = ('site', 'email')
def __str__(self):
return str(self.name)