From 29ea8403e7a73eac5aa6f08a4b5f850a80c2664b Mon Sep 17 00:00:00 2001 From: Lionel Porcheron Date: Tue, 6 Jun 2017 01:12:47 +0200 Subject: [PATCH] WIP: complete the public CFP form. More testing is needed We will probabily recreate migrations of the cfp application from scratch before deploying in production --- cfp/migrations/0002_auto_20170605_2023.py | 31 ++++++ cfp/migrations/0003_auto_20170605_2035.py | 36 +++++++ cfp/migrations/0004_talk_token.py | 21 ++++ cfp/migrations/0005_auto_20170605_2243.py | 40 +++++++ cfp/models.py | 58 +++++----- cfp/templates/cfp/complete.html | 6 +- cfp/templates/cfp/propose.html | 10 +- cfp/templates/cfp/speaker.html | 26 +++++ cfp/urls.py | 6 +- cfp/views.py | 125 +++++++++++++++++----- ponyconf/settings.py | 9 ++ ponyconf/templates/base.html | 2 +- 12 files changed, 311 insertions(+), 59 deletions(-) create mode 100644 cfp/migrations/0002_auto_20170605_2023.py create mode 100644 cfp/migrations/0003_auto_20170605_2035.py create mode 100644 cfp/migrations/0004_talk_token.py create mode 100644 cfp/migrations/0005_auto_20170605_2243.py create mode 100644 cfp/templates/cfp/speaker.html diff --git a/cfp/migrations/0002_auto_20170605_2023.py b/cfp/migrations/0002_auto_20170605_2023.py new file mode 100644 index 0000000..b74d5e0 --- /dev/null +++ b/cfp/migrations/0002_auto_20170605_2023.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11 on 2017-06-05 20:23 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('cfp', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='participant', + name='name', + field=models.CharField(blank=True, max_length=128), + ), + migrations.AlterField( + model_name='talk', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cfp.TalkCategory', verbose_name='Intervention kind'), + ), + migrations.AlterField( + model_name='talk', + name='title', + field=models.CharField(max_length=128, verbose_name='Title'), + ), + ] diff --git a/cfp/migrations/0003_auto_20170605_2035.py b/cfp/migrations/0003_auto_20170605_2035.py new file mode 100644 index 0000000..e2f0754 --- /dev/null +++ b/cfp/migrations/0003_auto_20170605_2035.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11 on 2017-06-05 20:35 +from __future__ import unicode_literals + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('cfp', '0002_auto_20170605_2023'), + ] + + operations = [ + migrations.AddField( + model_name='participant', + name='token', + field=models.UUIDField(default=uuid.uuid4, editable=False), + ), + migrations.AlterField( + model_name='talk', + name='description', + field=models.TextField(blank=True, verbose_name='Description of your talk'), + ), + migrations.AlterField( + model_name='talk', + name='notes', + field=models.TextField(blank=True, 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', verbose_name='Message to organizers'), + ), + migrations.AlterField( + model_name='talk', + name='title', + field=models.CharField(max_length=128, verbose_name='Talk Title'), + ), + ] diff --git a/cfp/migrations/0004_talk_token.py b/cfp/migrations/0004_talk_token.py new file mode 100644 index 0000000..3fb4db9 --- /dev/null +++ b/cfp/migrations/0004_talk_token.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11 on 2017-06-05 20:43 +from __future__ import unicode_literals + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('cfp', '0003_auto_20170605_2035'), + ] + + operations = [ + migrations.AddField( + model_name='talk', + name='token', + field=models.UUIDField(default=uuid.uuid4, editable=False), + ), + ] diff --git a/cfp/migrations/0005_auto_20170605_2243.py b/cfp/migrations/0005_auto_20170605_2243.py new file mode 100644 index 0000000..cab8388 --- /dev/null +++ b/cfp/migrations/0005_auto_20170605_2243.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11 on 2017-06-05 22:43 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('cfp', '0004_talk_token'), + ] + + operations = [ + migrations.AlterModelOptions( + name='talkcategory', + options={'ordering': ('pk',), 'verbose_name': 'category', 'verbose_name_plural': 'categories'}, + ), + migrations.AddField( + model_name='conference', + name='contact_email', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='conference', + name='name', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AlterField( + model_name='talk', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cfp.TalkCategory', verbose_name='Talk Category'), + ), + migrations.AlterField( + model_name='talk', + name='video_licence', + field=models.CharField(choices=[('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')], default='CC-BY-SA', max_length=10, verbose_name='Video licence'), + ), + ] diff --git a/cfp/models.py b/cfp/models.py index 56643b4..03469e8 100644 --- a/cfp/models.py +++ b/cfp/models.py @@ -1,6 +1,7 @@ -from enum import IntEnum + +import uuid + from datetime import timedelta -from os.path import join, basename from django.contrib.auth.models import User from django.contrib.sites.models import Site @@ -12,29 +13,12 @@ from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext from django.utils import timezone -from ponyconf.utils import PonyConfModel, enum_to_choices - -from enum import IntEnum -from datetime import timedelta -from os.path import join, basename - -from django.contrib.auth.models import User -from django.contrib.sites.models import Site -from django.core.urlresolvers import reverse -from django.core.validators import MaxValueValidator, MinValueValidator -from django.db import models -from django.db.models import Q -from django.utils.translation import ugettext_lazy as _ -from django.utils.translation import ugettext -from django.utils import timezone +from ponyconf.utils import PonyConfModel from autoslug import AutoSlugField from colorful.fields import RGBColorField from .utils import query_sum -from .utils import generate_user_uid - -from enum import IntEnum from django.contrib.auth.models import User from django.contrib.sites.models import Site @@ -43,6 +27,7 @@ from django.db import models from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext + #from ponyconf.utils import PonyConfModel, enum_to_choices @@ -50,9 +35,11 @@ from django.utils.translation import ugettext class Conference(models.Model): site = models.OneToOneField(Site, on_delete=models.CASCADE) + name = models.CharField(blank=True, max_length=100) home = models.TextField(blank=True, default="") venue = models.TextField(blank=True, default="") city = models.CharField(max_length=64, blank=True, default="") + contact_email = models.CharField(max_length=100, blank=True) #subscriptions_open = models.BooleanField(default=False) # workshop subscription #def cfp_is_open(self): @@ -66,6 +53,9 @@ class Conference(models.Model): # .filter(Q(opening_date__isnull=True) | Q(opening_date__lte=now))\ # .filter(Q(closing_date__isnull=True) | Q(closing_date__gte=now)) + def from_email(self): + return self.name+' <'+self.contact_email+'>' + def __str__(self): return str(self.site) @@ -76,13 +66,12 @@ class Participant(PonyConfModel): site = models.ForeignKey(Site, on_delete=models.CASCADE) - name = models.CharField(max_length=128) + name = models.CharField(max_length=128, blank=True) email = models.EmailField() phone_number = models.CharField(max_length=16, blank=True, default='', verbose_name=_('Phone number')) biography = models.TextField(blank=True, verbose_name=_('Biography')) - #email_token = models.CharField(max_length=12, default=generate_user_uid, unique=True) - + token = models.UUIDField(default=uuid.uuid4, editable=False) # TALK #videotaped = models.BooleanField(_("I'm ok to be recorded on video"), default=True) @@ -178,6 +167,8 @@ class TalkCategory(models.Model): # type of talk (conf 30min, 1h, stand, …) class Meta: unique_together = ('site', 'name') ordering = ('pk',) + verbose_name = "category" + verbose_name_plural = "categories" def __str__(self): return ugettext(self.name) @@ -216,21 +207,28 @@ class TalkCategory(models.Model): # type of talk (conf 30min, 1h, stand, …) class Talk(PonyConfModel): - LICENCES = IntEnum('Video licence', 'CC-Zero CC-BY CC-BY-SA CC-BY-ND CC-BY-NC CC-BY-NC-SA CC-BY-NC-ND') + 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'), + ) site = models.ForeignKey(Site, on_delete=models.CASCADE) #proposer = models.ForeignKey(User, related_name='+') speakers = models.ManyToManyField(Participant, verbose_name=_('Speakers')) - title = models.CharField(max_length=128, verbose_name=_('Title'), help_text=_('After submission, title can only be changed by the staff.')) + title = models.CharField(max_length=128, verbose_name=_('Talk Title')) slug = AutoSlugField(populate_from='title', unique=True) #abstract = models.CharField(max_length=255, blank=True, verbose_name=_('Abstract')) - description = models.TextField(blank=True, verbose_name=_('Description')) + description = models.TextField(blank=True, verbose_name=_('Description of your talk')) track = models.ForeignKey(Track, blank=True, null=True, verbose_name=_('Track')) - notes = models.TextField(blank=True, verbose_name=_('Message to organizers')) - category = models.ForeignKey(TalkCategory, verbose_name=_('Intervention kind')) + 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')) + category = models.ForeignKey(TalkCategory, blank=True, null=True, verbose_name=_('Talk Category')) videotaped = models.BooleanField(_("I'm ok to be recorded on video"), default=True) - video_licence = models.IntegerField(choices=enum_to_choices(LICENCES), default=2, verbose_name=_("Video licence")) + video_licence = models.CharField(choices=LICENCES, default='CC-BY-SA', max_length=10, verbose_name=_("Video licence")) sound = models.BooleanField(_("I need sound"), default=False) accepted = models.NullBooleanField(default=None) #start_date = models.DateTimeField(null=True, blank=True, default=None) @@ -240,6 +238,8 @@ class Talk(PonyConfModel): #materials = models.FileField(null=True, upload_to=talk_materials_destination, verbose_name=_('Materials'), # help_text=_('You can use this field to share some materials related to your intervention.')) + token = models.UUIDField(default=uuid.uuid4, editable=False) + class Meta: ordering = ('title',) diff --git a/cfp/templates/cfp/complete.html b/cfp/templates/cfp/complete.html index e0dea3f..3bfb695 100644 --- a/cfp/templates/cfp/complete.html +++ b/cfp/templates/cfp/complete.html @@ -13,7 +13,11 @@
-

Merci pour votre participation !

+

{% trans "Thanks for your proposal" %}

+

{% trans "You can edit your talk at anytime:" %} {% if request.is_secure %}https{% else %}http{% endif %}://{{ site.domain }}{% url 'talk-proposal-edit' talk.token participant.token %}

+

{% trans "You can add an additionnal speaker:" %} {% if request.is_secure %}https{% else %}http{% endif %}://{{ site.domain }}{% url 'talk-proposal-speaker-add' talk.token %}

+

{% trans "You can edit your profile:" %} {% if request.is_secure %}https{% else %}http{% endif %}://{{ site.domain }}{% url 'talk-proposal-speaker-edit' talk.token participant.token %}

+

{% trans "An email has been sent to you with those URLs" %}

{% endblock %} diff --git a/cfp/templates/cfp/propose.html b/cfp/templates/cfp/propose.html index 114bed1..a59db31 100644 --- a/cfp/templates/cfp/propose.html +++ b/cfp/templates/cfp/propose.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load crispy_forms_tags %} {% load ponyconf_tags i18n %} @@ -13,7 +14,14 @@
- {% include "_form.html" %} +
+ {% csrf_token %} + {{ participant_form|crispy }} + {{ talk_form|crispy }} +
+ +
+
{% endblock %} diff --git a/cfp/templates/cfp/speaker.html b/cfp/templates/cfp/speaker.html new file mode 100644 index 0000000..6a98b01 --- /dev/null +++ b/cfp/templates/cfp/speaker.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} +{% load crispy_forms_tags %} + +{% load ponyconf_tags i18n %} + +{% block proposetab %} class="active"{% endblock %} + +{% block content %} + + +
+
+
+ {% csrf_token %} + {{ participant_form|crispy }} +
+ +
+
+
+
+{% endblock %} diff --git a/cfp/urls.py b/cfp/urls.py index 895f01c..f9da443 100644 --- a/cfp/urls.py +++ b/cfp/urls.py @@ -3,8 +3,10 @@ from django.conf.urls import url from . import views urlpatterns = [ - url(r'^propose/$', views.ProposeView.as_view(), name='propose'), - url(r'^thanks/$', views.CompleteView.as_view(), name='propose-complete'), + url(r'^$', views.talk_proposal, name='talk-proposal'), + url(r'^(?P[\w\-]+)/speaker/add/$', views.talk_proposal_speaker_edit, name='talk-proposal-speaker-add'), + url(r'^(?P[\w\-]+)/speaker/(?P[\w\-]+)/$', views.talk_proposal_speaker_edit, name='talk-proposal-speaker-edit'), + url(r'^(?P[\w\-]+)/(?P[\w\-]+)/$', views.talk_proposal, name='talk-proposal-edit'), #url(r'^markdown/$', views.markdown_preview, name='markdown'), #url(r'^$', views.home, name='home'), #url(r'^staff/$', views.staff, name='staff'), diff --git a/cfp/views.py b/cfp/views.py index fc43450..813d02c 100644 --- a/cfp/views.py +++ b/cfp/views.py @@ -1,36 +1,111 @@ -from django.views.generic import FormView, TemplateView + +from django.contrib.sites.shortcuts import get_current_site +from django.core.mail import send_mail from django.core.urlresolvers import reverse_lazy +from django.forms.models import modelform_factory +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ +from django.views.generic import FormView, TemplateView -from .forms import ProposeForm +from .models import Participant, Talk -class ProposeView(FormView): - form_class = ProposeForm - template_name = 'cfp/propose.html' - success_url = reverse_lazy('propose-complete') +def talk_proposal(request, talk_id=None, participant_id=None): -def propose(request): - TalkForm = modelform_factory(Talk) - ParticipantForm = modelform_factory(Participant) - talk_form = TalkForm(request.POST or None) - participant_form = ParticipantForm(request.POST or None) - forms = [talk_form, participant_form] - if request.method == 'POST' and all([form.is_valid() for form in forms]): + site = get_current_site(request) + talk = None + participant = None + + if talk_id and participant_id: + talk = get_object_or_404(Talk, token=talk_id, site=site) + participant = get_object_or_404(Participant, token=participant_id, site=site) + + ParticipantForm = modelform_factory(Participant, fields=('name','email', 'biography')) + participant_form = ParticipantForm(request.POST or None, instance=participant) + TalkForm = modelform_factory(Talk, fields=('category', 'title', 'description','notes')) + talk_form = TalkForm(request.POST or None, instance=talk) + + if request.method == 'POST' and talk_form.is_valid() and participant_form.is_valid(): talk = talk_form.save(commit=False) - talk.site = get_current_site(request) - email = participant.cleaned_data['email'] - try: - participant = Participant.objects.get(email=email) - except Participant.DoesNoExist: - participant = participant_form.save() - talk.participant = participant + talk.site = site + + participant, created = Participant.objects.get_or_create(email=participant_form.cleaned_data['email'], site=site) + participant_form = ParticipantForm(request.POST, instance=participant) + participant = participant_form.save() + participant.save() + talk.save() - return redirect(reverse('propose-complete')) - return render('cfp/propose.html', { - 'talk_form': talk_form, + talk.speakers.add(participant) + + protocol = 'http' if request.is_secure() else 'http' + base_url = protocol+'://'+site.domain + url_talk_proposal_edit = base_url + reverse('talk-proposal-edit', args=[talk.token, participant.token]) + url_talk_proposal_speaker_add = base_url + reverse('talk-proposal-speaker-add', args=[talk.token]) + url_talk_proposal_speaker_edit = base_url + reverse('talk-proposal-speaker-edit', args=[talk.token, participant.token]) + msg_title = _('Your talk "{}" has been submitted for {}').format(talk.title, site.conference.name) + msg_body = _("""Hi {}, + +Your talk has been submitted for {}. + +Here are the details of your talk: +Title: {} +Description {} + +You can edit your talk at anytume: {} +You can add a new co-speaker here: {} +You can edit your profile here: {} + +If you have any question, your can answer to this email. + +Thanks! + +{} + +""").format(participant.name, site.conference.name, talk.title, talk.description, url_talk_proposal_edit, url_talk_proposal_speaker_add, url_talk_proposal_speaker_edit, site.conference.name) + + send_mail( + msg_title, + msg_body, + site.conference.from_email(), + [participant.email], + fail_silently=False, + ) + + return render(request, 'cfp/complete.html', {'talk': talk, 'participant': participant}) + + return render(request, 'cfp/propose.html', { 'participant_form': participant_form, + 'site': site, + 'talk_form': talk_form, }) -class CompleteView(TemplateView): - template_name = 'cfp/complete.html' +def talk_proposal_speaker_edit(request, talk_id, participant_id=None): + + site = get_current_site(request) + talk = get_object_or_404(Talk, token=talk_id, site=site) + participant = None + + if participant_id: + participant = get_object_or_404(Participant, token=participant_id, site=site) + + ParticipantForm = modelform_factory(Participant, fields=('name','email', 'biography')) + participant_form = ParticipantForm(request.POST or None, instance=participant) + + if request.method == 'POST' and participant_form.is_valid(): + + participant, created = Participant.objects.get_or_create(email=participant_form.cleaned_data['email'], site=site) + participant_form = ParticipantForm(request.POST, instance=participant) + participant = participant_form.save() + participant.save() + + talk.speakers.add(participant) + + return render(request,'cfp/complete.html', {'talk': talk, 'participant': participant}) + + return render(request, 'cfp/speaker.html', { + 'participant_form': participant_form, + 'site': site, + }) + diff --git a/ponyconf/settings.py b/ponyconf/settings.py index 0a9782f..b6a7e12 100644 --- a/ponyconf/settings.py +++ b/ponyconf/settings.py @@ -51,6 +51,7 @@ INSTALLED_APPS = [ #'registration', #'django_select2', #'avatar', + 'crispy_forms', # build-in apps 'django.contrib.admin', @@ -209,6 +210,8 @@ SELECT2_CSS = 'select2/dist/css/select2.min.css' #AUTHENTICATION_BACKENDS = ['yeouia.backends.YummyEmailOrUsernameInsensitiveAuth'] LOGOUT_REDIRECT_URL = 'home' +CRISPY_TEMPLATE_PACK='bootstrap3' + # django-registration ACCOUNT_ACTIVATION_DAYS = 7 INCLUDE_REGISTER_URL = True @@ -218,3 +221,9 @@ CACHES = { 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', } } + +SERVER_EMAIL = 'ponyconf@example.com' +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = 'localhost' +EMAIL_PORT = 1025 + diff --git a/ponyconf/templates/base.html b/ponyconf/templates/base.html index 8b8f877..4bcde22 100644 --- a/ponyconf/templates/base.html +++ b/ponyconf/templates/base.html @@ -18,7 +18,7 @@