mailing with participants & about talks

This commit is contained in:
Élie Bouttier 2017-08-02 01:45:38 +02:00
parent 54e4205ea7
commit b1667592ba
23 changed files with 647 additions and 84 deletions

View File

@ -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(),
}

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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()

View File

@ -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:

View File

@ -50,4 +50,11 @@
{% empty %}{% trans "No talks" %}
{% endfor %}
<h2>{% trans "Messaging" %}</h2>
{% include 'mailing/_message_list.html' with messages=participant.conversation.message_set.all %}
{% trans "Send a message <em>this message will be received by this participant and all the staff team</em>" as message_form_title %}
{% include 'mailing/_message_form.html' %}
{% endblock %}

View File

@ -134,14 +134,11 @@
{% endif %}
{% endcomment %}
{% comment %}
<h3>{% trans "Messages" %}</h3>
{% trans "These messages are for organization team only." %}<br /><br />
{% for message in talk.conversation.messages.all %}
{% include 'conversations/_message_detail.html' %}
{% endfor %}
<h3>{% trans "Messaging" %}</h3>
{% include 'conversations/_message_form.html' %}
{% endcomment %}
{% include 'mailing/_message_list.html' with messages=talk.conversation.message_set.all %}
{% trans "Send a message <em>this message will be received by the staff team only</em>" as message_form_title %}
{% include 'mailing/_message_form.html' %}
{% endblock %}

View File

@ -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(

Binary file not shown.

View File

@ -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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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 daccueil (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 "Jaccepte 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 "Jai 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 <em>this message will be received by this participant and "
"all the staff team</em>"
msgstr ""
"Envoyer un message <em>ce message sera reçu par le participant et léquipe "
"dorganisation</em>"
#: 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 <em>this message will be received by the staff team only</"
"em>"
msgstr ""
"Envoyer un message <em>ce message sera reçu uniquement par léquipe "
"dorganisation</em>"
#: 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 dintervention"
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 à {}"

0
mailing/__init__.py Normal file
View File

6
mailing/forms.py Normal file
View File

@ -0,0 +1,6 @@
from django.forms.models import modelform_factory
from .models import Message
MessageForm = modelform_factory(Message, fields=['content'])

View File

View File

View File

@ -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)

View File

@ -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'),
),
]

View File

73
mailing/models.py Normal file
View File

@ -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

View File

@ -0,0 +1,16 @@
{% load i18n %}
<div class="panel panel-default">
<div class="panel-heading">
{{ message_form_title }}
</div>
<div class="panel-body">
<form action="{{ message_form_url }}" method="post" role="form">
{% csrf_token %}
<div class="form-group">
<textarea class="form-control" name="content" rows="6" required></textarea>
</div>
<button type="submit" class="btn btn-success"><span class="glyphicon glyphicon-envelope"></span> {% trans "Send" %}</button>
</form>
</div>
</div>

View File

@ -0,0 +1,14 @@
{% load i18n %}
{% for message in messages %}
<div class="panel panel-{% if message.author == participant.email %}info{% else %}default{% endif %}">
<div class="panel-heading">
{{ message.created }} | {{ message.author }}
</div>
<div class="panel-body">
{{ message.content|linebreaksbr }}
</div>
</div>
{% empty %}
<p><em>{% trans "No messages." %}</em></p>
{% endfor %}

121
mailing/utils.py Normal file
View File

@ -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 lexistence 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<token>[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)

View File

@ -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