diff --git a/cfp/forms.py b/cfp/forms.py
index d108128..ff68702 100644
--- a/cfp/forms.py
+++ b/cfp/forms.py
@@ -81,7 +81,7 @@ class UsersWidget(ModelSelect2MultipleWidget):
class ConferenceForm(forms.ModelForm):
class Meta:
model = Conference
- fields = ['name', 'home', 'venue', 'city', 'contact_email', 'staff',]
+ fields = ['name', 'home', 'venue', 'city', 'contact_email', 'reply_email', 'staff',]
widgets = {
'staff': UsersWidget(),
}
diff --git a/cfp/migrations/0003_auto_20170801_1400.py b/cfp/migrations/0003_auto_20170801_1400.py
new file mode 100644
index 0000000..8fc79c3
--- /dev/null
+++ b/cfp/migrations/0003_auto_20170801_1400.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.3 on 2017-08-01 14:00
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('cfp', '0002_conference_staff'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='conference',
+ name='city',
+ field=models.CharField(blank=True, default='', max_length=64, verbose_name='City'),
+ ),
+ migrations.AlterField(
+ model_name='conference',
+ name='contact_email',
+ field=models.CharField(blank=True, max_length=100, verbose_name='Contact email'),
+ ),
+ migrations.AlterField(
+ model_name='conference',
+ name='home',
+ field=models.TextField(blank=True, default='', verbose_name='Homepage (markdown)'),
+ ),
+ migrations.AlterField(
+ model_name='conference',
+ name='name',
+ field=models.CharField(blank=True, max_length=100, verbose_name='Conference name'),
+ ),
+ migrations.AlterField(
+ model_name='conference',
+ name='staff',
+ field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL, verbose_name='Staff members'),
+ ),
+ migrations.AlterField(
+ model_name='conference',
+ name='venue',
+ field=models.TextField(blank=True, default='', verbose_name='Venue information'),
+ ),
+ ]
diff --git a/cfp/migrations/0004_auto_20170801_1408.py b/cfp/migrations/0004_auto_20170801_1408.py
new file mode 100644
index 0000000..62c3be9
--- /dev/null
+++ b/cfp/migrations/0004_auto_20170801_1408.py
@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.3 on 2017-08-01 14:08
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def generate_participant_conversation(apps, schema_editor):
+ MessageThread = apps.get_model("mailing", "MessageThread")
+ Participant = apps.get_model("cfp", "Participant")
+ db_alias = schema_editor.connection.alias
+ for participant in Participant.objects.using(db_alias).filter(conversation=None):
+ participant.conversation = MessageThread.objects.create()
+ participant.save()
+
+
+def generate_talk_conversation(apps, schema_editor):
+ MessageThread = apps.get_model("mailing", "MessageThread")
+ Talk = apps.get_model("cfp", "Talk")
+ db_alias = schema_editor.connection.alias
+ for talk in Talk.objects.using(db_alias).filter(conversation=None):
+ talk.conversation = MessageThread.objects.create()
+ talk.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('mailing', '0001_initial'),
+ ('cfp', '0003_auto_20170801_1400'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='participant',
+ name='conversation',
+ field=models.OneToOneField(null=True, default=None, on_delete=django.db.models.deletion.CASCADE, to='mailing.MessageThread'),
+ preserve_default=False,
+ ),
+ migrations.RunPython(generate_participant_conversation),
+ migrations.AlterField(
+ model_name='participant',
+ name='conversation',
+ field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='mailing.MessageThread'),
+ ),
+ migrations.AddField(
+ model_name='talk',
+ name='conversation',
+ field=models.OneToOneField(null=True, default=None, on_delete=django.db.models.deletion.CASCADE, to='mailing.MessageThread'),
+ preserve_default=False,
+ ),
+ migrations.RunPython(generate_talk_conversation),
+ migrations.AlterField(
+ model_name='talk',
+ name='conversation',
+ field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='mailing.MessageThread'),
+ ),
+ ]
diff --git a/cfp/migrations/0005_conference_reply_email.py b/cfp/migrations/0005_conference_reply_email.py
new file mode 100644
index 0000000..c69c183
--- /dev/null
+++ b/cfp/migrations/0005_conference_reply_email.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.3 on 2017-08-01 16:40
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('cfp', '0004_auto_20170801_1408'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='conference',
+ name='reply_email',
+ field=models.CharField(blank=True, max_length=100, verbose_name='Reply email'),
+ ),
+ ]
diff --git a/cfp/models.py b/cfp/models.py
index 02dc37f..3f8f4c1 100644
--- a/cfp/models.py
+++ b/cfp/models.py
@@ -1,32 +1,21 @@
-
-import uuid
-
-from datetime import timedelta
-
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.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q, Avg, Case, When
-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 django.utils.translation import ugettext, ugettext_lazy as _
from autoslug import AutoSlugField
from colorful.fields import RGBColorField
-from django.contrib.auth.models import User
-from django.contrib.sites.models import Site
-from django.core.urlresolvers import reverse
-from django.db import models
-from django.utils.translation import ugettext_lazy as _
-from django.utils.translation import ugettext
+import uuid
+from datetime import timedelta
-
-#from ponyconf.utils import PonyConfModel, enum_to_choices
+from ponyconf.utils import PonyConfModel
+from mailing.models import MessageThread
@@ -38,6 +27,7 @@ class Conference(models.Model):
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'))
staff = models.ManyToManyField(User, blank=True, verbose_name=_('Staff members'))
custom_css = models.TextField(blank=True)
@@ -59,6 +49,16 @@ class Conference(models.Model):
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).'),
+ })
+
def __str__(self):
return str(self.site)
@@ -88,6 +88,8 @@ class Participant(PonyConfModel):
vip = models.BooleanField(default=False)
+ conversation = models.OneToOneField(MessageThread)
+
class Meta:
# A User can participe only once to a Conference (= Site)
unique_together = ('site', 'email')
@@ -254,6 +256,8 @@ class Talk(PonyConfModel):
token = models.UUIDField(default=uuid.uuid4, editable=False)
+ conversation = models.OneToOneField(MessageThread)
+
objects = TalkManager()
diff --git a/cfp/signals.py b/cfp/signals.py
index 7e70d2b..b7556e7 100644
--- a/cfp/signals.py
+++ b/cfp/signals.py
@@ -1,10 +1,12 @@
-from django.db.models.signals import post_save
+from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver
from django.contrib.sites.models import Site
from django.conf import settings
+from django.utils.translation import ugettext_lazy as _
from ponyconf.decorators import disable_for_loaddata
-from .models import Conference
+from mailing.models import MessageThread, Message
+from .models import Participant, Talk, Conference
@receiver(post_save, sender=Site, dispatch_uid="Create Conference for Site")
@@ -13,6 +15,54 @@ def create_conference(sender, instance, **kwargs):
conference, created = Conference.objects.get_or_create(site=instance)
+def create_conversation(sender, instance, **kwargs):
+ if not hasattr(instance, 'conversation'):
+ instance.conversation = MessageThread.objects.create()
+pre_save.connect(create_conversation, sender=Participant)
+pre_save.connect(create_conversation, sender=Talk)
+
+
+@receiver(post_save, sender=Message, dispatch_uid="Send message notifications")
+def send_message_notification(sender, instance, **kwargs):
+ message = instance
+ thread = message.thread
+ first_message = thread.message_set.first()
+ if message == first_message:
+ reference = None
+ else:
+ reference = first_message.token
+ subject_prefix = 'Re: ' if reference else ''
+ if hasattr(thread, 'participant'):
+ conf = thread.participant.site.conference
+ elif hasattr(thread, 'talk'):
+ conf = thread.talk.site.conference
+ message_id = '<{id}@%s>' % conf.site.domain
+ if conf.reply_email:
+ reply_to = (conf.name, conf.reply_email)
+ else:
+ reply_to = None
+ sender = (conf.name, conf.contact_email)
+ staff_dests = [ (user.get_full_name(), user.email) for user in conf.staff.all() ]
+ if hasattr(thread, 'participant'):
+ conf = thread.participant.site.conference
+ participant = thread.participant
+ participant_dests = [ (participant.name, participant.email) ]
+ participant_subject = _('[%(prefix)s] Message from the staff') % {'prefix': conf.name}
+ staff_subject = _('[%(prefix)s] Conversation with %(dest)s') % {'prefix': conf.name, 'dest': participant.name}
+ if message.author == conf.contact_email: # this is a talk notification message
+ # sent it only the participant
+ message.send_notification(subject=participant_subject, sender=sender, dests=participant_dests, reply_to=reply_to, message_id=message_id, reference=reference)
+ else:
+ # this is a message between the staff and the participant
+ message.send_notification(subject=staff_subject, sender=sender, dests=staff_dests, reply_to=reply_to, message_id=message_id, reference=reference)
+ if message.author != thread.participant.email: # message from staff: sent it to the participant too
+ message.send_notification(subject=participant_subject, sender=sender, dests=participant_dests, reply_to=reply_to, message_id=message_id, reference=reference)
+ elif hasattr(thread, 'talk'):
+ conf = thread.talk.site.conference
+ subject = _('[%(prefix)s] Talk: %(talk)s') % {'prefix': conf.name, 'talk': thread.talk.title}
+ message.send_notification(subject=subject, sender=sender, dests=staff_dests, reply_to=reply_to, message_id=message_id, reference=reference)
+
+
# connected in apps.py
def call_first_site_post_save(apps, **kwargs):
try:
diff --git a/cfp/templates/cfp/staff/participant_details.html b/cfp/templates/cfp/staff/participant_details.html
index eeb23d1..345601e 100644
--- a/cfp/templates/cfp/staff/participant_details.html
+++ b/cfp/templates/cfp/staff/participant_details.html
@@ -50,4 +50,11 @@
{% empty %}{% trans "No talks" %}
{% endfor %}
+
{% trans "Messaging" %}
+
+{% include 'mailing/_message_list.html' with messages=participant.conversation.message_set.all %}
+
+{% trans "Send a message – this message will be received by this participant and all the staff team" as message_form_title %}
+{% include 'mailing/_message_form.html' %}
+
{% endblock %}
diff --git a/cfp/templates/cfp/staff/talk_details.html b/cfp/templates/cfp/staff/talk_details.html
index 23a51e9..1fcc5ab 100644
--- a/cfp/templates/cfp/staff/talk_details.html
+++ b/cfp/templates/cfp/staff/talk_details.html
@@ -134,14 +134,11 @@
{% endif %}
{% endcomment %}
-{% comment %}
-{% trans "Messages" %}
-{% trans "These messages are for organization team only." %}
-{% for message in talk.conversation.messages.all %}
-{% include 'conversations/_message_detail.html' %}
-{% endfor %}
+{% trans "Messaging" %}
-{% include 'conversations/_message_form.html' %}
-{% endcomment %}
+{% include 'mailing/_message_list.html' with messages=talk.conversation.message_set.all %}
+
+{% trans "Send a message – this message will be received by the staff team only" as message_form_title %}
+{% include 'mailing/_message_form.html' %}
{% endblock %}
diff --git a/cfp/views.py b/cfp/views.py
index d47f51f..7ce51d7 100644
--- a/cfp/views.py
+++ b/cfp/views.py
@@ -11,7 +11,9 @@ from django_select2.views import AutoResponseView
from functools import reduce
-from cfp.decorators import staff_required
+from mailing.models import Message
+from mailing.forms import MessageForm
+from .decorators import staff_required
from .mixins import StaffRequiredMixin
from .utils import is_staff
from .models import Participant, Talk, TalkCategory, Vote
@@ -62,8 +64,7 @@ def talk_proposal(request, conference, talk_id=None, participant_id=None):
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, conference.name)
- msg_body = _("""Hi {},
+ body = _("""Hi {},
Your talk has been submitted for {}.
@@ -84,12 +85,10 @@ Thanks!
""").format(participant.name, conference.name, talk.title, talk.description, url_talk_proposal_edit, url_talk_proposal_speaker_add, url_talk_proposal_speaker_edit, conference.name)
- send_mail(
- msg_title,
- msg_body,
- conference.from_email(),
- [participant.email],
- fail_silently=False,
+ Message.objects.create(
+ thread=participant.conversation,
+ author=conference.contact_email,
+ content=body,
)
return render(request, 'cfp/complete.html', {'talk': talk, 'participant': participant})
@@ -163,9 +162,9 @@ def talk_list(request, conference):
talks = talks.exclude(vote__user=request.user)
# Sorting
if request.GET.get('order') == 'desc':
- reverse = True
+ sort_reverse = True
else:
- reverse = False
+ sort_reverse = False
SORT_MAPPING = {
'title': 'title',
'category': 'category',
@@ -173,7 +172,7 @@ def talk_list(request, conference):
}
sort = request.GET.get('sort')
if sort in SORT_MAPPING.keys():
- if reverse:
+ if sort_reverse:
talks = talks.order_by('-' + SORT_MAPPING[sort])
else:
talks = talks.order_by(SORT_MAPPING[sort])
@@ -184,7 +183,7 @@ def talk_list(request, conference):
url = request.GET.copy()
url['sort'] = c
if c == sort:
- if reverse:
+ if sort_reverse:
del url['order']
glyphicon = 'sort-by-attributes-alt'
else:
@@ -207,6 +206,14 @@ def talk_list(request, conference):
@staff_required
def talk_details(request, conference, talk_id):
talk = get_object_or_404(Talk, token=talk_id, site=conference.site)
+ message_form = MessageForm(request.POST or None)
+ if request.method == 'POST' and message_form.is_valid():
+ message = message_form.save(commit=False)
+ message.author = request.user.email
+ message.thread = talk.conversation
+ message.save()
+ messages.success(request, _('Message sent!'))
+ return redirect(reverse('talk-details', args=[talk.token]))
return render(request, 'cfp/staff/talk_details.html', {
'talk': talk,
})
@@ -260,6 +267,14 @@ def participant_list(request, conference):
@staff_required
def participant_details(request, conference, participant_id):
participant = get_object_or_404(Participant, token=participant_id, site=conference.site)
+ message_form = MessageForm(request.POST or None)
+ if request.method == 'POST' and message_form.is_valid():
+ message = message_form.save(commit=False)
+ message.author = request.user.email
+ message.thread = participant.conversation
+ message.save()
+ messages.success(request, _('Message sent!'))
+ return redirect(reverse('participant-details', args=[participant.token]))
return render(request, 'cfp/staff/participant_details.html', {
'participant': participant,
})
@@ -290,6 +305,7 @@ You can now:
{}
""")
+ # TODO: send bulk emails
for user in added_staff:
msg_body = msg_body_template.format(user.get_full_name(), url_login, url_password_reset, conference.name)
send_mail(
diff --git a/locale/fr/LC_MESSAGES/django.mo b/locale/fr/LC_MESSAGES/django.mo
index 9fe5a7b..620873f 100644
Binary files a/locale/fr/LC_MESSAGES/django.mo and b/locale/fr/LC_MESSAGES/django.mo differ
diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po
index cb285fa..0199607 100644
--- a/locale/fr/LC_MESSAGES/django.po
+++ b/locale/fr/LC_MESSAGES/django.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2017-08-01 12:23+0000\n"
+"POT-Creation-Date: 2017-08-02 00:42+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
@@ -313,7 +313,7 @@ msgstr "Catégorie"
msgid "Status"
msgstr "Statut"
-#: cfp/forms.py:54 cfp/models.py:234
+#: cfp/forms.py:54 cfp/models.py:243
#: cfp/templates/cfp/staff/talk_details.html:89
#: cfp/templates/cfp/staff/talk_list.html:41 proposals/models.py:160
#: proposals/templates/proposals/talk_detail.html:33
@@ -352,30 +352,41 @@ msgstr "Un utilisateur avec ce prénom et ce nom existe déjà."
msgid "A user with that email already exists."
msgstr "Un utilisateur avec cet email existe déjà."
-#: cfp/models.py:36
+#: cfp/models.py:25
msgid "Conference name"
msgstr "Nom de la conférence"
-#: cfp/models.py:37
+#: cfp/models.py:26
msgid "Homepage (markdown)"
msgstr "Page d’accueil (markdown)"
-#: cfp/models.py:38
+#: cfp/models.py:27
msgid "Venue information"
msgstr "Informations sur le lieu"
-#: cfp/models.py:39
+#: cfp/models.py:28
msgid "City"
msgstr "Ville"
-#: cfp/models.py:40
+#: cfp/models.py:29
msgid "Contact email"
msgstr "Email de contact"
-#: cfp/models.py:41
+#: cfp/models.py:30
+msgid "Reply email"
+msgstr ""
+
+#: cfp/models.py:31
msgid "Staff members"
msgstr "Membres du staff"
+#: cfp/models.py:59
+#, python-brace-format
+msgid ""
+"The reply email should be a formatable string accepting a token argument (e."
+"g. ponyconf+{token}@exemple.com)."
+msgstr ""
+
#: cfp/models.py:70
msgid "Your Name"
msgstr "Votre Nom"
@@ -384,31 +395,31 @@ msgstr "Votre Nom"
msgid "This field is only visible by organizers."
msgstr "Ce champs est uniquement visible par les organisateurs."
-#: cfp/models.py:137 cfp/templates/cfp/staff/participant_list.html:49
+#: cfp/models.py:139 cfp/templates/cfp/staff/participant_list.html:49
#: proposals/models.py:52 proposals/models.py:75 proposals/models.py:132
#: volunteers/models.py:12
msgid "Name"
msgstr "Nom"
-#: cfp/models.py:139 cfp/templates/cfp/staff/talk_details.html:75
+#: cfp/models.py:141 cfp/templates/cfp/staff/talk_details.html:75
#: proposals/models.py:54 proposals/models.py:77 proposals/models.py:158
#: proposals/templates/proposals/talk_detail.html:72 volunteers/models.py:14
msgid "Description"
msgstr "Description"
-#: cfp/models.py:160 proposals/models.py:96
+#: cfp/models.py:162 proposals/models.py:96
msgid "Default duration (min)"
msgstr "Durée par défaut (min)"
-#: cfp/models.py:161 proposals/models.py:97
+#: cfp/models.py:163 proposals/models.py:97
msgid "Color on program"
msgstr "Couleur sur le programme"
-#: cfp/models.py:162 proposals/models.py:98
+#: cfp/models.py:164 proposals/models.py:98
msgid "Label on program"
msgstr "Label dans le xml du programme"
-#: cfp/models.py:229 cfp/templates/cfp/staff/base.html:20
+#: cfp/models.py:238 cfp/templates/cfp/staff/base.html:20
#: cfp/templates/cfp/staff/participant_list.html:8
#: cfp/templates/cfp/staff/talk_details.html:79
#: cfp/templates/cfp/staff/talk_list.html:40 proposals/models.py:154
@@ -418,19 +429,19 @@ msgstr "Label dans le xml du programme"
msgid "Speakers"
msgstr "Orateurs"
-#: cfp/models.py:230
+#: cfp/models.py:239
msgid "Talk Title"
msgstr "Titre de votre proposition:"
-#: cfp/models.py:233
+#: cfp/models.py:242
msgid "Description of your talk"
msgstr "Description de votre proposition"
-#: cfp/models.py:235
+#: cfp/models.py:244
msgid "Message to organizers"
msgstr "Message aux organisateurs"
-#: cfp/models.py:235
+#: cfp/models.py:244
msgid ""
"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 "
@@ -440,26 +451,40 @@ msgstr ""
"votre proposition, comme une vidéo, des slides, n'hésitez pas à les ajouter "
"ici."
-#: cfp/models.py:236
+#: cfp/models.py:245
msgid "Talk Category"
msgstr "Catégorie de proposition"
-#: cfp/models.py:237
+#: cfp/models.py:246
msgid "I'm ok to be recorded on video"
msgstr "J’accepte d’être enregistré en vidéo"
-#: cfp/models.py:238
+#: cfp/models.py:247
msgid "Video licence"
msgstr "Licence vidéo"
-#: cfp/models.py:239
+#: cfp/models.py:248
msgid "I need sound"
msgstr "J’ai besoin de son"
-#: cfp/models.py:242 proposals/models.py:165
+#: cfp/models.py:251 proposals/models.py:165
msgid "Duration (min)"
msgstr "Durée (min)"
+#: cfp/signals.py:50
+#, python-format
+msgid "[%(prefix)s] Message from the staff"
+msgstr "[%(prefix)s] Message du staff"
+
+#: cfp/signals.py:51
+msgid "[%(prefix)s] Conversation with %(dest)s"
+msgstr "[%(prefix)s] Conversation avec %(dest)s"
+
+#: cfp/signals.py:62
+#, python-format
+msgid "[%(prefix)s] Talk: %(talk)s"
+msgstr "[%(prefix)s] Talk: %(talk)s"
+
#: cfp/templates/cfp/closed.html:9 cfp/templates/cfp/propose.html:11
#: cfp/templates/cfp/speaker.html:11
#: proposals/templates/proposals/participate.html:9
@@ -581,6 +606,20 @@ msgstr "dans la session"
msgid "No talks"
msgstr "Aucun exposé"
+#: cfp/templates/cfp/staff/participant_details.html:53
+#: cfp/templates/cfp/staff/talk_details.html:137
+#: conversations/templates/conversations/inbox.html:9
+msgid "Messaging"
+msgstr "Messagerie"
+
+#: cfp/templates/cfp/staff/participant_details.html:57
+msgid ""
+"Send a message – this message will be received by this participant and "
+"all the staff team"
+msgstr ""
+"Envoyer un message – ce message sera reçu par le participant et l’équipe "
+"d’organisation"
+
#: cfp/templates/cfp/staff/participant_list.html:45
#: cfp/templates/cfp/staff/talk_list.html:33
#: proposals/templates/proposals/speaker_list.html:44
@@ -695,6 +734,14 @@ msgstr "vote"
msgid "average:"
msgstr "moyenne :"
+#: cfp/templates/cfp/staff/talk_details.html:141
+msgid ""
+"Send a message – this message will be received by the staff team only"
+"em>"
+msgstr ""
+"Envoyer un message – ce message sera reçu uniquement par l’équipe "
+"d’organisation"
+
#: cfp/templates/cfp/staff/talk_list.html:10
#: proposals/templates/proposals/speaker_list.html:11
#: proposals/templates/proposals/talk_list.html:11
@@ -733,11 +780,7 @@ msgstr "Type d’intervention"
msgid "Pending, score: %(score)s"
msgstr "En cours, score : %(score)s"
-#: cfp/views.py:65
-msgid "Your talk \"{}\" has been submitted for {}"
-msgstr "Votre proposition \"{}\" a été transmise à {}"
-
-#: cfp/views.py:66
+#: cfp/views.py:67
msgid ""
"Hi {},\n"
"\n"
@@ -777,23 +820,27 @@ msgstr ""
"{}\n"
"\n"
-#: cfp/views.py:220 proposals/views.py:321
+#: cfp/views.py:215 cfp/views.py:276 conversations/views.py:40
+msgid "Message sent!"
+msgstr "Message envoyé !"
+
+#: cfp/views.py:228 proposals/views.py:321
msgid "Vote successfully created"
msgstr "A voté !"
-#: cfp/views.py:220 proposals/views.py:321
+#: cfp/views.py:228 proposals/views.py:321
msgid "Vote successfully updated"
msgstr "Vote mis à jour"
-#: cfp/views.py:243 proposals/views.py:347
+#: cfp/views.py:251 proposals/views.py:347
msgid "Decision taken in account"
msgstr "Décision enregistrée"
-#: cfp/views.py:280
+#: cfp/views.py:296
msgid "[{}] You have been added to the staff team"
msgstr "[{}] Vous avez été ajouté aux membres du staff"
-#: cfp/views.py:281
+#: cfp/views.py:297
msgid ""
"Hi {},\n"
"\n"
@@ -817,11 +864,11 @@ msgstr ""
"{}\n"
"\n"
-#: cfp/views.py:301
+#: cfp/views.py:318
msgid "Modifications successfully saved."
msgstr "Modification enregistrée avec succès."
-#: cfp/views.py:315
+#: cfp/views.py:332
msgid "User created successfully."
msgstr "Utilisateur créé avec succès."
@@ -830,6 +877,7 @@ msgid "Send a message"
msgstr "Envoyer un message"
#: conversations/templates/conversations/_message_form.html:12
+#: mailing/templates/mailing/_message_form.html:13
msgid "Send"
msgstr "Envoyer"
@@ -846,18 +894,14 @@ msgstr "Correspondants"
msgid "This is the list of participants that you follow."
msgstr "Ceci est la liste des participants que vous suivez."
-#: conversations/templates/conversations/inbox.html:9
-msgid "Messaging"
-msgstr "Messagerie"
-
#: conversations/templates/conversations/inbox.html:10
msgid "You can use this page to communicate with the staff."
msgstr ""
"Vous pouvez utiliser cette page pour communiquer avec l’équipe organisatrice."
-#: conversations/views.py:40
-msgid "Message sent!"
-msgstr "Message envoyé !"
+#: mailing/templates/mailing/_message_list.html:13
+msgid "No messages."
+msgstr "Aucun message."
#: planning/templates/planning/public-program.html:8
#: planning/templates/planning/schedule.html:9
@@ -1348,3 +1392,6 @@ msgstr "Bénévoles"
#: volunteers/templates/volunteers/volunteer_list.html:25
msgid "volunteer"
msgstr "bénévole"
+
+#~ msgid "Your talk \"{}\" has been submitted for {}"
+#~ msgstr "Votre proposition \"{}\" a été transmise à {}"
diff --git a/mailing/__init__.py b/mailing/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/mailing/forms.py b/mailing/forms.py
new file mode 100644
index 0000000..80eab2f
--- /dev/null
+++ b/mailing/forms.py
@@ -0,0 +1,6 @@
+from django.forms.models import modelform_factory
+
+from .models import Message
+
+
+MessageForm = modelform_factory(Message, fields=['content'])
diff --git a/mailing/management/__init__.py b/mailing/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/mailing/management/commands/__init__.py b/mailing/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/mailing/management/commands/fetchmail.py b/mailing/management/commands/fetchmail.py
new file mode 100644
index 0000000..180183e
--- /dev/null
+++ b/mailing/management/commands/fetchmail.py
@@ -0,0 +1,34 @@
+from django.core.management.base import BaseCommand
+
+from mailing.utils import fetch_imap_box
+
+
+class Command(BaseCommand):
+ help = 'Fetch emails from IMAP inbox'
+
+ def add_arguments(self, parser):
+ parser.add_argument('--host', required=True)
+ parser.add_argument('--port', type=int)
+ parser.add_argument('--user', required=True)
+ parser.add_argument('--password', required=True)
+ parser.add_argument('--inbox')
+ grp = parser.add_mutually_exclusive_group()
+ grp.add_argument('--trash')
+ grp.add_argument('--no-trash', action='store_true')
+
+
+ def handle(self, *args, **options):
+ params = {
+ 'host': options['host'],
+ 'user': options['user'],
+ 'password': options['password'],
+ }
+ if options['port']:
+ params['port'] = options['port']
+ if options['inbox']:
+ params['inbox'] = options['inbox']
+ if options['trash']:
+ params['trash'] = options['trash']
+ elif options['no_trash']:
+ params['trash'] = None
+ fetch_imap_box(**params)
diff --git a/mailing/migrations/0001_initial.py b/mailing/migrations/0001_initial.py
new file mode 100644
index 0000000..2155da5
--- /dev/null
+++ b/mailing/migrations/0001_initial.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.3 on 2017-08-01 15:48
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import mailing.models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Message',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created', models.DateTimeField(auto_now_add=True)),
+ ('author', models.EmailField(blank=True, max_length=254)),
+ ('content', models.TextField(blank=True)),
+ ('token', models.CharField(default=mailing.models.generate_message_token, max_length=64, unique=True)),
+ ],
+ options={
+ 'ordering': ['created'],
+ },
+ ),
+ migrations.CreateModel(
+ name='MessageCorrespondent',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('email', models.EmailField(max_length=254)),
+ ('token', models.CharField(default=mailing.models.generate_message_token, max_length=64, unique=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='MessageThread',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created', models.DateTimeField(auto_now_add=True)),
+ ('token', models.CharField(default=mailing.models.generate_message_token, max_length=64, unique=True)),
+ ],
+ ),
+ migrations.AddField(
+ model_name='message',
+ name='thread',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='mailing.MessageThread'),
+ ),
+ ]
diff --git a/mailing/migrations/__init__.py b/mailing/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/mailing/models.py b/mailing/models.py
new file mode 100644
index 0000000..4a86ff0
--- /dev/null
+++ b/mailing/models.py
@@ -0,0 +1,73 @@
+from django.db import models
+from django.utils.crypto import get_random_string
+from django.core.mail import EmailMessage, get_connection
+from django.conf import settings
+
+import hashlib
+
+
+def generate_message_token():
+ # /!\ birthday problem
+ return get_random_string(length=32)
+
+
+def hexdigest_sha256(*args):
+ r = hashlib.sha256()
+ for arg in args:
+ r.update(str(arg).encode('utf-8'))
+ return r.hexdigest()
+
+
+class MessageCorrespondent(models.Model):
+ email = models.EmailField()
+ token = models.CharField(max_length=64, default=generate_message_token, unique=True)
+
+
+class MessageThread(models.Model):
+ created = models.DateTimeField(auto_now_add=True)
+ token = models.CharField(max_length=64, default=generate_message_token, unique=True)
+
+
+class Message(models.Model):
+ created = models.DateTimeField(auto_now_add=True)
+ thread = models.ForeignKey(MessageThread)
+ author = models.EmailField(blank=True)
+ content = models.TextField(blank=True)
+ token = models.CharField(max_length=64, default=generate_message_token, unique=True)
+
+ class Meta:
+ ordering = ['created']
+
+ def send_notification(self, subject, sender, dests, reply_to=None, message_id=None, reference=None):
+ messages = []
+ for dest_name, dest_email in dests:
+ correspondent, created = MessageCorrespondent.objects.get_or_create(email=dest_email)
+ token = self.thread.token + correspondent.token + hexdigest_sha256(settings.SECRET_KEY, self.thread.token, correspondent.token)[:16]
+ sender_name, sender_email = sender
+ if reply_to:
+ reply_to_name, reply_to_email = reply_to
+ reply_to_list = ['%s <%s>' % (reply_to_name, reply_to_email.format(token=token))]
+ else:
+ reply_to_list = []
+ headers = dict()
+ if message_id:
+ headers.update({
+ 'Message-ID': message_id.format(id=self.token),
+ })
+ if message_id and reference:
+ headers.update({
+ 'References': message_id.format(id=reference),
+ })
+ messages.append(EmailMessage(
+ subject=subject,
+ body=self.content,
+ from_email='%s <%s>' % (sender_name, sender_email),
+ to=['%s <%s>' % (dest_name, dest_email)],
+ reply_to=reply_to_list,
+ headers=headers,
+ ))
+ connection = get_connection()
+ connection.send_messages(messages)
+
+ def __str__(self):
+ return "Message from %s" % self.author
diff --git a/mailing/templates/mailing/_message_form.html b/mailing/templates/mailing/_message_form.html
new file mode 100644
index 0000000..f03d90b
--- /dev/null
+++ b/mailing/templates/mailing/_message_form.html
@@ -0,0 +1,16 @@
+{% load i18n %}
+
+
+
+ {{ message_form_title }}
+
+
+
diff --git a/mailing/templates/mailing/_message_list.html b/mailing/templates/mailing/_message_list.html
new file mode 100644
index 0000000..e83a9a5
--- /dev/null
+++ b/mailing/templates/mailing/_message_list.html
@@ -0,0 +1,14 @@
+{% load i18n %}
+
+{% for message in messages %}
+
+
+ {{ message.created }} | {{ message.author }}
+
+
+ {{ message.content|linebreaksbr }}
+
+
+{% empty %}
+{% trans "No messages." %}
+{% endfor %}
diff --git a/mailing/utils.py b/mailing/utils.py
new file mode 100644
index 0000000..7a0fdd4
--- /dev/null
+++ b/mailing/utils.py
@@ -0,0 +1,121 @@
+from django.conf import settings
+
+import imaplib
+import ssl
+import logging
+from email import policy
+from email.parser import BytesParser
+import chardet
+import re
+
+from .models import MessageThread, MessageCorrespondent, Message, hexdigest_sha256
+
+
+class NoTokenFoundException(Exception):
+ pass
+
+class InvalidTokenException(Exception):
+ pass
+
+class InvalidKeyException(Exception):
+ pass
+
+
+def fetch_imap_box(user, password, host, port=993, inbox='INBOX', trash='Trash'):
+ logging.basicConfig(level=logging.DEBUG)
+ context = ssl.create_default_context()
+ success, failure = 0, 0
+ with imaplib.IMAP4_SSL(host=host, port=port, ssl_context=context) as M:
+ typ, data = M.login(user, password)
+ if typ != 'OK':
+ raise Exception(data[0].decode('utf-8'))
+ typ, data = M.enable('UTF8=ACCEPT')
+ if typ != 'OK':
+ raise Exception(data[0].decode('utf-8'))
+ if trash is not None:
+ # Vérification de l’existence de la poubelle
+ typ, data = M.select(mailbox=trash)
+ if typ != 'OK':
+ raise Exception(data[0].decode('utf-8'))
+ typ, data = M.select(mailbox=inbox)
+ if typ != 'OK':
+ raise Exception(data[0].decode('utf-8'))
+ typ, data = M.uid('search', None, 'UNSEEN')
+ if typ != 'OK':
+ raise Exception(data[0].decode('utf-8'))
+ logging.info("Fetching %d messages" % len(data[0].split()))
+ for num in data[0].split():
+ typ, data = M.uid('fetch', num, '(RFC822)')
+ if typ != 'OK':
+ failure += 1
+ logging.warning(data[0].decode('utf-8'))
+ continue
+ raw_email = data[0][1]
+ try:
+ process_email(raw_email)
+ except Exception as e:
+ failure += 1
+ logging.exception("An error occured during mail processing")
+ if type(e) == NoTokenFoundException:
+ tag = 'NoTokenFound'
+ if type(e) == InvalidTokenException:
+ tag = 'InvalidToken'
+ if type(e) == InvalidKeyException:
+ tag = 'InvalidKey'
+ else:
+ tag = 'UnknowError'
+ typ, data = M.uid('store', num, '+FLAGS', tag)
+ if typ != 'OK':
+ logging.warning(data[0].decode('utf-8'))
+ continue
+ if trash is not None:
+ typ, data = M.uid('copy', num, trash)
+ if typ != 'OK':
+ failure += 1
+ logging.warning(data[0].decode('utf-8'))
+ continue
+ typ, data = M.uid('store', num, '+FLAGS', '\Deleted')
+ if typ != 'OK':
+ failure += 1
+ logging.warning(data[0].decode('utf-8'))
+ continue
+ success += 1
+ typ, data = M.expunge()
+ if typ != 'OK':
+ failure += 1
+ raise Exception(data[0].decode('utf-8'))
+ logging.info("Finished, success: %d, failure: %d" % (success, failure))
+
+
+def process_email(raw_email):
+ msg = BytesParser(policy=policy.default).parsebytes(raw_email)
+ body = msg.get_body(preferencelist=['plain'])
+ content = body.get_payload(decode=True)
+
+ charset = body.get_content_charset()
+ if not charset:
+ charset = chardet.detect(content)['encoding']
+ content = content.decode(charset)
+
+ regex = re.compile('^[^+@]+\+(?P[a-zA-Z0-9]{80})@[^@]+$')
+
+ for addr in msg.get('To', '').split(','):
+ m = regex.match(addr.strip())
+ if m:
+ break
+
+ if not m:
+ raise NoTokenFoundException
+
+ token = m.group('token')
+ key = token[64:]
+ try:
+ thread = MessageThread.objects.get(token=token[:32])
+ sender = MessageCorrespondent.objects.get(token=token[32:64])
+ except models.DoesNotExist:
+ raise InvalidTokenException
+
+ if key != hexdigest_sha256(settings.SECRET_KEY, thread.token, sender.token)[:16]:
+ raise InvalidKeyException
+
+ Message.objects.create(thread=thread, author=sender.email, content=content)
diff --git a/ponyconf/settings.py b/ponyconf/settings.py
index ce22f05..15e7c5d 100644
--- a/ponyconf/settings.py
+++ b/ponyconf/settings.py
@@ -40,6 +40,7 @@ INSTALLED_APPS = [
#'accounts',
'ponyconf',
'cfp',
+ 'mailing',
#'proposals',
#'conversations',
#'planning',
@@ -237,5 +238,5 @@ SELECT2_CACHE_BACKEND = 'select2'
SERVER_EMAIL = 'ponyconf@example.com'
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'localhost'
-EMAIL_PORT = 1025
+EMAIL_PORT = 25