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 ConferenceForm(forms.ModelForm):
class Meta: class Meta:
model = Conference model = Conference
fields = ['name', 'home', 'venue', 'city', 'contact_email', 'staff',] fields = ['name', 'home', 'venue', 'city', 'contact_email', 'reply_email', 'staff',]
widgets = { widgets = {
'staff': UsersWidget(), '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.auth.models import User
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Q, Avg, Case, When 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 django.utils import timezone
from django.utils.translation import ugettext, ugettext_lazy as _
from ponyconf.utils import PonyConfModel
from autoslug import AutoSlugField from autoslug import AutoSlugField
from colorful.fields import RGBColorField from colorful.fields import RGBColorField
from django.contrib.auth.models import User import uuid
from django.contrib.sites.models import Site from datetime import timedelta
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
from ponyconf.utils import PonyConfModel
#from ponyconf.utils import PonyConfModel, enum_to_choices from mailing.models import MessageThread
@ -38,6 +27,7 @@ class Conference(models.Model):
venue = models.TextField(blank=True, default="", verbose_name=_('Venue information')) venue = models.TextField(blank=True, default="", verbose_name=_('Venue information'))
city = models.CharField(max_length=64, blank=True, default="", verbose_name=_('City')) 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')) 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')) staff = models.ManyToManyField(User, blank=True, verbose_name=_('Staff members'))
custom_css = models.TextField(blank=True) custom_css = models.TextField(blank=True)
@ -59,6 +49,16 @@ class Conference(models.Model):
def from_email(self): def from_email(self):
return self.name+' <'+self.contact_email+'>' 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): def __str__(self):
return str(self.site) return str(self.site)
@ -88,6 +88,8 @@ class Participant(PonyConfModel):
vip = models.BooleanField(default=False) vip = models.BooleanField(default=False)
conversation = models.OneToOneField(MessageThread)
class Meta: class Meta:
# A User can participe only once to a Conference (= Site) # A User can participe only once to a Conference (= Site)
unique_together = ('site', 'email') unique_together = ('site', 'email')
@ -254,6 +256,8 @@ class Talk(PonyConfModel):
token = models.UUIDField(default=uuid.uuid4, editable=False) token = models.UUIDField(default=uuid.uuid4, editable=False)
conversation = models.OneToOneField(MessageThread)
objects = TalkManager() 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.dispatch import receiver
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.conf import settings from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from ponyconf.decorators import disable_for_loaddata 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") @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) 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 # connected in apps.py
def call_first_site_post_save(apps, **kwargs): def call_first_site_post_save(apps, **kwargs):
try: try:

View File

@ -50,4 +50,11 @@
{% empty %}{% trans "No talks" %} {% empty %}{% trans "No talks" %}
{% endfor %} {% 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 %} {% endblock %}

View File

@ -134,14 +134,11 @@
{% endif %} {% endif %}
{% endcomment %} {% endcomment %}
{% comment %} <h3>{% trans "Messaging" %}</h3>
<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 %}
{% include 'conversations/_message_form.html' %} {% include 'mailing/_message_list.html' with messages=talk.conversation.message_set.all %}
{% endcomment %}
{% 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 %} {% endblock %}

View File

@ -11,7 +11,9 @@ from django_select2.views import AutoResponseView
from functools import reduce 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 .mixins import StaffRequiredMixin
from .utils import is_staff from .utils import is_staff
from .models import Participant, Talk, TalkCategory, Vote 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_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_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]) 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) body = _("""Hi {},
msg_body = _("""Hi {},
Your talk has been submitted for {}. 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) """).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( Message.objects.create(
msg_title, thread=participant.conversation,
msg_body, author=conference.contact_email,
conference.from_email(), content=body,
[participant.email],
fail_silently=False,
) )
return render(request, 'cfp/complete.html', {'talk': talk, 'participant': participant}) 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) talks = talks.exclude(vote__user=request.user)
# Sorting # Sorting
if request.GET.get('order') == 'desc': if request.GET.get('order') == 'desc':
reverse = True sort_reverse = True
else: else:
reverse = False sort_reverse = False
SORT_MAPPING = { SORT_MAPPING = {
'title': 'title', 'title': 'title',
'category': 'category', 'category': 'category',
@ -173,7 +172,7 @@ def talk_list(request, conference):
} }
sort = request.GET.get('sort') sort = request.GET.get('sort')
if sort in SORT_MAPPING.keys(): if sort in SORT_MAPPING.keys():
if reverse: if sort_reverse:
talks = talks.order_by('-' + SORT_MAPPING[sort]) talks = talks.order_by('-' + SORT_MAPPING[sort])
else: else:
talks = talks.order_by(SORT_MAPPING[sort]) talks = talks.order_by(SORT_MAPPING[sort])
@ -184,7 +183,7 @@ def talk_list(request, conference):
url = request.GET.copy() url = request.GET.copy()
url['sort'] = c url['sort'] = c
if c == sort: if c == sort:
if reverse: if sort_reverse:
del url['order'] del url['order']
glyphicon = 'sort-by-attributes-alt' glyphicon = 'sort-by-attributes-alt'
else: else:
@ -207,6 +206,14 @@ def talk_list(request, conference):
@staff_required @staff_required
def talk_details(request, conference, talk_id): def talk_details(request, conference, talk_id):
talk = get_object_or_404(Talk, token=talk_id, site=conference.site) 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', { return render(request, 'cfp/staff/talk_details.html', {
'talk': talk, 'talk': talk,
}) })
@ -260,6 +267,14 @@ def participant_list(request, conference):
@staff_required @staff_required
def participant_details(request, conference, participant_id): def participant_details(request, conference, participant_id):
participant = get_object_or_404(Participant, token=participant_id, site=conference.site) 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', { return render(request, 'cfp/staff/participant_details.html', {
'participant': participant, 'participant': participant,
}) })
@ -290,6 +305,7 @@ You can now:
{} {}
""") """)
# TODO: send bulk emails
for user in added_staff: for user in added_staff:
msg_body = msg_body_template.format(user.get_full_name(), url_login, url_password_reset, conference.name) msg_body = msg_body_template.format(user.get_full_name(), url_login, url_password_reset, conference.name)
send_mail( send_mail(

Binary file not shown.

View File

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -313,7 +313,7 @@ msgstr "Catégorie"
msgid "Status" msgid "Status"
msgstr "Statut" 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_details.html:89
#: cfp/templates/cfp/staff/talk_list.html:41 proposals/models.py:160 #: cfp/templates/cfp/staff/talk_list.html:41 proposals/models.py:160
#: proposals/templates/proposals/talk_detail.html:33 #: 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." msgid "A user with that email already exists."
msgstr "Un utilisateur avec cet email existe déjà." msgstr "Un utilisateur avec cet email existe déjà."
#: cfp/models.py:36 #: cfp/models.py:25
msgid "Conference name" msgid "Conference name"
msgstr "Nom de la conférence" msgstr "Nom de la conférence"
#: cfp/models.py:37 #: cfp/models.py:26
msgid "Homepage (markdown)" msgid "Homepage (markdown)"
msgstr "Page daccueil (markdown)" msgstr "Page daccueil (markdown)"
#: cfp/models.py:38 #: cfp/models.py:27
msgid "Venue information" msgid "Venue information"
msgstr "Informations sur le lieu" msgstr "Informations sur le lieu"
#: cfp/models.py:39 #: cfp/models.py:28
msgid "City" msgid "City"
msgstr "Ville" msgstr "Ville"
#: cfp/models.py:40 #: cfp/models.py:29
msgid "Contact email" msgid "Contact email"
msgstr "Email de contact" msgstr "Email de contact"
#: cfp/models.py:41 #: cfp/models.py:30
msgid "Reply email"
msgstr ""
#: cfp/models.py:31
msgid "Staff members" msgid "Staff members"
msgstr "Membres du staff" 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 #: cfp/models.py:70
msgid "Your Name" msgid "Your Name"
msgstr "Votre Nom" msgstr "Votre Nom"
@ -384,31 +395,31 @@ msgstr "Votre Nom"
msgid "This field is only visible by organizers." msgid "This field is only visible by organizers."
msgstr "Ce champs est uniquement visible par les organisateurs." 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 #: proposals/models.py:52 proposals/models.py:75 proposals/models.py:132
#: volunteers/models.py:12 #: volunteers/models.py:12
msgid "Name" msgid "Name"
msgstr "Nom" 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/models.py:54 proposals/models.py:77 proposals/models.py:158
#: proposals/templates/proposals/talk_detail.html:72 volunteers/models.py:14 #: proposals/templates/proposals/talk_detail.html:72 volunteers/models.py:14
msgid "Description" msgid "Description"
msgstr "Description" msgstr "Description"
#: cfp/models.py:160 proposals/models.py:96 #: cfp/models.py:162 proposals/models.py:96
msgid "Default duration (min)" msgid "Default duration (min)"
msgstr "Durée par défaut (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" msgid "Color on program"
msgstr "Couleur sur le programme" 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" msgid "Label on program"
msgstr "Label dans le xml du programme" 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/participant_list.html:8
#: cfp/templates/cfp/staff/talk_details.html:79 #: cfp/templates/cfp/staff/talk_details.html:79
#: cfp/templates/cfp/staff/talk_list.html:40 proposals/models.py:154 #: 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" msgid "Speakers"
msgstr "Orateurs" msgstr "Orateurs"
#: cfp/models.py:230 #: cfp/models.py:239
msgid "Talk Title" msgid "Talk Title"
msgstr "Titre de votre proposition:" msgstr "Titre de votre proposition:"
#: cfp/models.py:233 #: cfp/models.py:242
msgid "Description of your talk" msgid "Description of your talk"
msgstr "Description de votre proposition" msgstr "Description de votre proposition"
#: cfp/models.py:235 #: cfp/models.py:244
msgid "Message to organizers" msgid "Message to organizers"
msgstr "Message aux organisateurs" msgstr "Message aux organisateurs"
#: cfp/models.py:235 #: cfp/models.py:244
msgid "" msgid ""
"If you have any constraint or if you have anything that may help you to " "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 " "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 " "votre proposition, comme une vidéo, des slides, n'hésitez pas à les ajouter "
"ici." "ici."
#: cfp/models.py:236 #: cfp/models.py:245
msgid "Talk Category" msgid "Talk Category"
msgstr "Catégorie de proposition" msgstr "Catégorie de proposition"
#: cfp/models.py:237 #: cfp/models.py:246
msgid "I'm ok to be recorded on video" msgid "I'm ok to be recorded on video"
msgstr "Jaccepte dêtre enregistré en vidéo" msgstr "Jaccepte dêtre enregistré en vidéo"
#: cfp/models.py:238 #: cfp/models.py:247
msgid "Video licence" msgid "Video licence"
msgstr "Licence vidéo" msgstr "Licence vidéo"
#: cfp/models.py:239 #: cfp/models.py:248
msgid "I need sound" msgid "I need sound"
msgstr "Jai besoin de son" msgstr "Jai besoin de son"
#: cfp/models.py:242 proposals/models.py:165 #: cfp/models.py:251 proposals/models.py:165
msgid "Duration (min)" msgid "Duration (min)"
msgstr "Durée (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/closed.html:9 cfp/templates/cfp/propose.html:11
#: cfp/templates/cfp/speaker.html:11 #: cfp/templates/cfp/speaker.html:11
#: proposals/templates/proposals/participate.html:9 #: proposals/templates/proposals/participate.html:9
@ -581,6 +606,20 @@ msgstr "dans la session"
msgid "No talks" msgid "No talks"
msgstr "Aucun exposé" 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/participant_list.html:45
#: cfp/templates/cfp/staff/talk_list.html:33 #: cfp/templates/cfp/staff/talk_list.html:33
#: proposals/templates/proposals/speaker_list.html:44 #: proposals/templates/proposals/speaker_list.html:44
@ -695,6 +734,14 @@ msgstr "vote"
msgid "average:" msgid "average:"
msgstr "moyenne :" 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 #: cfp/templates/cfp/staff/talk_list.html:10
#: proposals/templates/proposals/speaker_list.html:11 #: proposals/templates/proposals/speaker_list.html:11
#: proposals/templates/proposals/talk_list.html:11 #: proposals/templates/proposals/talk_list.html:11
@ -733,11 +780,7 @@ msgstr "Type dintervention"
msgid "Pending, score: %(score)s" msgid "Pending, score: %(score)s"
msgstr "En cours, score : %(score)s" msgstr "En cours, score : %(score)s"
#: cfp/views.py:65 #: cfp/views.py:67
msgid "Your talk \"{}\" has been submitted for {}"
msgstr "Votre proposition \"{}\" a été transmise à {}"
#: cfp/views.py:66
msgid "" msgid ""
"Hi {},\n" "Hi {},\n"
"\n" "\n"
@ -777,23 +820,27 @@ msgstr ""
"{}\n" "{}\n"
"\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" msgid "Vote successfully created"
msgstr "A voté !" msgstr "A voté !"
#: cfp/views.py:220 proposals/views.py:321 #: cfp/views.py:228 proposals/views.py:321
msgid "Vote successfully updated" msgid "Vote successfully updated"
msgstr "Vote mis à jour" 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" msgid "Decision taken in account"
msgstr "Décision enregistrée" msgstr "Décision enregistrée"
#: cfp/views.py:280 #: cfp/views.py:296
msgid "[{}] You have been added to the staff team" msgid "[{}] You have been added to the staff team"
msgstr "[{}] Vous avez été ajouté aux membres du staff" msgstr "[{}] Vous avez été ajouté aux membres du staff"
#: cfp/views.py:281 #: cfp/views.py:297
msgid "" msgid ""
"Hi {},\n" "Hi {},\n"
"\n" "\n"
@ -817,11 +864,11 @@ msgstr ""
"{}\n" "{}\n"
"\n" "\n"
#: cfp/views.py:301 #: cfp/views.py:318
msgid "Modifications successfully saved." msgid "Modifications successfully saved."
msgstr "Modification enregistrée avec succès." msgstr "Modification enregistrée avec succès."
#: cfp/views.py:315 #: cfp/views.py:332
msgid "User created successfully." msgid "User created successfully."
msgstr "Utilisateur créé avec succès." msgstr "Utilisateur créé avec succès."
@ -830,6 +877,7 @@ msgid "Send a message"
msgstr "Envoyer un message" msgstr "Envoyer un message"
#: conversations/templates/conversations/_message_form.html:12 #: conversations/templates/conversations/_message_form.html:12
#: mailing/templates/mailing/_message_form.html:13
msgid "Send" msgid "Send"
msgstr "Envoyer" msgstr "Envoyer"
@ -846,18 +894,14 @@ msgstr "Correspondants"
msgid "This is the list of participants that you follow." msgid "This is the list of participants that you follow."
msgstr "Ceci est la liste des participants que vous suivez." 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 #: conversations/templates/conversations/inbox.html:10
msgid "You can use this page to communicate with the staff." msgid "You can use this page to communicate with the staff."
msgstr "" msgstr ""
"Vous pouvez utiliser cette page pour communiquer avec léquipe organisatrice." "Vous pouvez utiliser cette page pour communiquer avec léquipe organisatrice."
#: conversations/views.py:40 #: mailing/templates/mailing/_message_list.html:13
msgid "Message sent!" msgid "No messages."
msgstr "Message envoyé !" msgstr "Aucun message."
#: planning/templates/planning/public-program.html:8 #: planning/templates/planning/public-program.html:8
#: planning/templates/planning/schedule.html:9 #: planning/templates/planning/schedule.html:9
@ -1348,3 +1392,6 @@ msgstr "Bénévoles"
#: volunteers/templates/volunteers/volunteer_list.html:25 #: volunteers/templates/volunteers/volunteer_list.html:25
msgid "volunteer" msgid "volunteer"
msgstr "bénévole" 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', #'accounts',
'ponyconf', 'ponyconf',
'cfp', 'cfp',
'mailing',
#'proposals', #'proposals',
#'conversations', #'conversations',
#'planning', #'planning',
@ -237,5 +238,5 @@ SELECT2_CACHE_BACKEND = 'select2'
SERVER_EMAIL = 'ponyconf@example.com' SERVER_EMAIL = 'ponyconf@example.com'
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'localhost' EMAIL_HOST = 'localhost'
EMAIL_PORT = 1025 EMAIL_PORT = 25