From b1667592ba560b49b0096dd8a865e3c4e1433c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Bouttier?= Date: Wed, 2 Aug 2017 01:45:38 +0200 Subject: [PATCH] mailing with participants & about talks --- cfp/forms.py | 2 +- cfp/migrations/0003_auto_20170801_1400.py | 46 ++++++ cfp/migrations/0004_auto_20170801_1408.py | 59 ++++++++ cfp/migrations/0005_conference_reply_email.py | 20 +++ cfp/models.py | 38 ++--- cfp/signals.py | 54 +++++++- .../cfp/staff/participant_details.html | 7 + cfp/templates/cfp/staff/talk_details.html | 13 +- cfp/views.py | 42 ++++-- locale/fr/LC_MESSAGES/django.mo | Bin 19609 -> 20240 bytes locale/fr/LC_MESSAGES/django.po | 131 ++++++++++++------ mailing/__init__.py | 0 mailing/forms.py | 6 + mailing/management/__init__.py | 0 mailing/management/commands/__init__.py | 0 mailing/management/commands/fetchmail.py | 34 +++++ mailing/migrations/0001_initial.py | 52 +++++++ mailing/migrations/__init__.py | 0 mailing/models.py | 73 ++++++++++ mailing/templates/mailing/_message_form.html | 16 +++ mailing/templates/mailing/_message_list.html | 14 ++ mailing/utils.py | 121 ++++++++++++++++ ponyconf/settings.py | 3 +- 23 files changed, 647 insertions(+), 84 deletions(-) create mode 100644 cfp/migrations/0003_auto_20170801_1400.py create mode 100644 cfp/migrations/0004_auto_20170801_1408.py create mode 100644 cfp/migrations/0005_conference_reply_email.py create mode 100644 mailing/__init__.py create mode 100644 mailing/forms.py create mode 100644 mailing/management/__init__.py create mode 100644 mailing/management/commands/__init__.py create mode 100644 mailing/management/commands/fetchmail.py create mode 100644 mailing/migrations/0001_initial.py create mode 100644 mailing/migrations/__init__.py create mode 100644 mailing/models.py create mode 100644 mailing/templates/mailing/_message_form.html create mode 100644 mailing/templates/mailing/_message_list.html create mode 100644 mailing/utils.py 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 9fe5a7bc27a1a79873f5752d2c291954b2e4b82e..620873f2a29f8077be7396b88e43584b62d2ff32 100644 GIT binary patch delta 6703 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 delta 6019 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 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" +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 }} +
+
+
+ {% csrf_token %} +
+ +
+ +
+
+
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