From ff9577b1f429cedcf846cbcada0db09de9e92724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Bouttier?= Date: Sun, 12 Jun 2016 23:39:04 +0200 Subject: [PATCH] communications app --- accounts/migrations/0001_initial.py | 6 +- accounts/models.py | 2 +- accounts/templates/registration/login.html | 2 +- conversations/__init__.py | 1 + conversations/admin.py | 7 ++ conversations/apps.py | 8 ++ conversations/migrations/0001_initial.py | 42 ++++++++++ conversations/migrations/__init__.py | 0 conversations/models.py | 29 +++++++ conversations/signals.py | 77 +++++++++++++++++++ .../templates/conversations/new_message.html | 8 ++ .../templates/conversations/new_message.txt | 4 + conversations/tests.py | 3 + conversations/urls.py | 8 ++ conversations/utils.py | 26 +++++++ conversations/views.py | 13 ++++ ponyconf/settings.py | 8 +- ponyconf/urls.py | 1 + proposals/admin.py | 4 +- proposals/migrations/0001_initial.py | 11 +-- proposals/migrations/0002_talk_event.py | 20 ----- proposals/models.py | 6 +- proposals/tests.py | 4 +- proposals/views.py | 6 +- 24 files changed, 254 insertions(+), 42 deletions(-) create mode 100644 conversations/__init__.py create mode 100644 conversations/admin.py create mode 100644 conversations/apps.py create mode 100644 conversations/migrations/0001_initial.py create mode 100644 conversations/migrations/__init__.py create mode 100644 conversations/models.py create mode 100644 conversations/signals.py create mode 100644 conversations/templates/conversations/new_message.html create mode 100644 conversations/templates/conversations/new_message.txt create mode 100644 conversations/tests.py create mode 100644 conversations/urls.py create mode 100644 conversations/utils.py create mode 100644 conversations/views.py delete mode 100644 proposals/migrations/0002_talk_event.py diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py index 37b9946..56cb873 100644 --- a/accounts/migrations/0001_initial.py +++ b/accounts/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.9.7 on 2016-06-12 15:10 +# Generated by Django 1.9.7 on 2016-06-12 21:41 from __future__ import unicode_literals from django.conf import settings @@ -14,8 +14,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('sites', '0002_alter_domain_unique'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -35,7 +35,7 @@ class Migration(migrations.Migration): ('departure', models.DateTimeField(blank=True, null=True)), ('transport', models.IntegerField(blank=True, choices=[(1, 'train'), (2, 'plane')], null=True)), ('connector', models.IntegerField(blank=True, choices=[(1, 'VGA'), (2, 'HDMI'), (3, 'miniDP')], null=True)), - ('constraints', models.TextField()), + ('constraints', models.TextField(blank=True)), ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.Site')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], diff --git a/accounts/models.py b/accounts/models.py index 804d9bc..db4ad50 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -37,7 +37,7 @@ class Speaker(models.Model): departure = models.DateTimeField(blank=True, null=True) transport = models.IntegerField(choices=enum_to_choices(TRANSPORTS), blank=True, null=True) connector = models.IntegerField(choices=enum_to_choices(CONNECTORS), blank=True, null=True) - constraints = models.TextField() + constraints = models.TextField(blank=True) objects = models.Manager() on_site = CurrentSiteManager() diff --git a/accounts/templates/registration/login.html b/accounts/templates/registration/login.html index fdc5adc..e95f123 100644 --- a/accounts/templates/registration/login.html +++ b/accounts/templates/registration/login.html @@ -28,7 +28,7 @@
- You do not have an account? Please register. + You do not have an account yet? Please register.
diff --git a/conversations/__init__.py b/conversations/__init__.py new file mode 100644 index 0000000..113bdeb --- /dev/null +++ b/conversations/__init__.py @@ -0,0 +1 @@ +default_app_config = 'conversations.apps.ConversationsConfig' diff --git a/conversations/admin.py b/conversations/admin.py new file mode 100644 index 0000000..d12718a --- /dev/null +++ b/conversations/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin + +from .models import Conversation, Message + + +admin.site.register(Conversation) +admin.site.register(Message) diff --git a/conversations/apps.py b/conversations/apps.py new file mode 100644 index 0000000..73892cf --- /dev/null +++ b/conversations/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class ConversationsConfig(AppConfig): + name = 'conversations' + + def ready(self): + import conversations.signals diff --git a/conversations/migrations/0001_initial.py b/conversations/migrations/0001_initial.py new file mode 100644 index 0000000..7dff8b4 --- /dev/null +++ b/conversations/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-06-12 21:41 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Conversation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('speaker', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversation', to='accounts.Speaker')), + ('subscribers', models.ManyToManyField(related_name='_conversation_subscribers_+', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Message', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.CharField(max_length=64)), + ('date', models.DateTimeField(auto_now_add=True)), + ('content', models.TextField()), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='conversations.Conversation')), + ], + options={ + 'ordering': ['date'], + }, + ), + ] diff --git a/conversations/migrations/__init__.py b/conversations/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/conversations/models.py b/conversations/models.py new file mode 100644 index 0000000..1ee2f04 --- /dev/null +++ b/conversations/models.py @@ -0,0 +1,29 @@ +from django.db import models +from django.contrib.auth.models import User + +from accounts.models import Speaker + + +class Conversation(models.Model): + + speaker = models.ForeignKey(Speaker, related_name='conversation') + subscribers = models.ManyToManyField(User, related_name='+') + + def __str__(self): + return "Conversation with %s" % self.speaker + + +class Message(models.Model): + + token = models.CharField(max_length=64) + conversation = models.ForeignKey(Conversation, related_name='messages') + + author = models.ForeignKey(User) + date = models.DateTimeField(auto_now_add=True) + content = models.TextField() + + class Meta: + ordering = ['date'] + + def __str__(self): + return "Message from %s" % self.author diff --git a/conversations/signals.py b/conversations/signals.py new file mode 100644 index 0000000..c46beb3 --- /dev/null +++ b/conversations/signals.py @@ -0,0 +1,77 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.conf import settings +from django.core.urlresolvers import reverse +from django.template.loader import render_to_string +from django.core import mail +from django.core.mail import EmailMultiAlternatives + + +from .models import Message +from .utils import get_reply_addr +from proposals.models import Talk, Topic + + +@receiver(post_save, sender=Message, dispatch_uid="Notify new message") +def notify_new_message(sender, instance, created, **kwargs): + if not created: + # We could send a modification notification + return + message = instance + conversation = message.conversation + site = conversation.speaker.site + subject = site.name + sender = instance.author + dests = list(conversation.subscribers.all()) + if conversation.speaker.user not in dests: + dests += [conversation.speaker.user] + data = { + 'content': message.content, + 'uri': site.domain + reverse('show-conversation', args=[conversation.id]), + } + message_id = message.token + ref = None + if conversation.messages.first().id != message.id: + ref = conversation.messages.first().token + notify_by_email(data, 'new_message', subject, sender, dests, message_id, ref) + + +def notify_by_email(data, template, subject, sender, dests, message_id, ref=None): + + if hasattr(settings, 'REPLY_EMAIL'): + data.update({'answering': True}) + + text_message = render_to_string('conversations/%s.txt' % template, data) + html_message = render_to_string('conversations/%s.html' % template, data) + + from_email = '{name} <{email}>'.format( + name=sender.get_full_name() or sender.username, + email=settings.DEFAULT_FROM_EMAIL) + + # Generating headers + headers = { + 'Message-ID': "<%s.%s>" % (message_id, settings.DEFAULT_FROM_EMAIL), + } + if ref: + # This email reference a previous one + headers.update({ + 'References': '<%s.%s>' % (ref, settings.DEFAULT_FROM_EMAIL), + }) + + mails = [] + for dest in dests: + if not dest.email: + continue + + reply_to = get_reply_addr(message_id, dest) + + mails += [(subject, (text_message, html_message), from_email, [dest.email], reply_to, headers)] + + messages = [] + for subject, message, from_email, dests, reply_to, headers in mails: + text_message, html_message = message + msg = EmailMultiAlternatives(subject, text_message, from_email, dests, reply_to=reply_to, headers=headers) + msg.attach_alternative(html_message, 'text/html') + messages += [msg] + with mail.get_connection() as connection: + connection.send_messages(messages) diff --git a/conversations/templates/conversations/new_message.html b/conversations/templates/conversations/new_message.html new file mode 100644 index 0000000..7f261f4 --- /dev/null +++ b/conversations/templates/conversations/new_message.html @@ -0,0 +1,8 @@ +{{ content|safe }} + +
+{% if answering %} +Reply to this email directly or view it online. +{% else %} +Reply online. +{% endif %} diff --git a/conversations/templates/conversations/new_message.txt b/conversations/templates/conversations/new_message.txt new file mode 100644 index 0000000..133a36c --- /dev/null +++ b/conversations/templates/conversations/new_message.txt @@ -0,0 +1,4 @@ +{{ content|safe }} + +-- +Reply {% if answering %}to this email directly or view it {% endif %}online: {{ uri }} diff --git a/conversations/tests.py b/conversations/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/conversations/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/conversations/urls.py b/conversations/urls.py new file mode 100644 index 0000000..a6ca3d3 --- /dev/null +++ b/conversations/urls.py @@ -0,0 +1,8 @@ +from django.conf.urls import url + +from conversations import views + + +urlpatterns = [ + url(r'^(?P[0-9]+)$', views.conversation_details, name='show-conversation'), +] diff --git a/conversations/utils.py b/conversations/utils.py new file mode 100644 index 0000000..e00ba87 --- /dev/null +++ b/conversations/utils.py @@ -0,0 +1,26 @@ +from django.conf import settings + +import hashlib + + +def hexdigest_sha256(*args): + + r = hashlib.sha256() + for arg in args: + r.update(str(arg).encode('utf-8')) + + return r.hexdigest() + + +def get_reply_addr(message_id, dest): + + if not hasattr(settings, 'REPLY_EMAIL'): + return [] + + addr = settings.REPLY_EMAIL + pos = addr.find('@') + name = addr[:pos] + domain = addr[pos:] + key = hexdigest_sha256(settings.SECRET_KEY, message_id, dest.pk) + + return ['%s+%10s%s%10s%s' % (name, dest.pk, message_id, key, domain)] diff --git a/conversations/views.py b/conversations/views.py new file mode 100644 index 0000000..ffc0930 --- /dev/null +++ b/conversations/views.py @@ -0,0 +1,13 @@ +from django.shortcuts import render +from django.shortcuts import get_object_or_404, redirect, render +from django.contrib.sites.shortcuts import get_current_site + +from .models import Message + + +def conversation_details(request, conversation): + conversation = get_object_or_404(Conversation, + id=conversation, speaker__site=get_current_site(request)) + return render(request, 'conversations/message.html', { + 'messages': conversation.messages, + }) diff --git a/ponyconf/settings.py b/ponyconf/settings.py index bd1d33f..9a8b33c 100644 --- a/ponyconf/settings.py +++ b/ponyconf/settings.py @@ -31,8 +31,7 @@ ALLOWED_HOSTS = [] # Application definition INSTALLED_APPS = [ - 'accounts', - 'registration', + # build-in apps 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -41,11 +40,16 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'django.contrib.sites', + # external apps 'djangobower', 'bootstrap3', + 'registration', + # our apps + 'accounts', 'ponyconf', 'proposals', + 'conversations', ] MIDDLEWARE_CLASSES = [ diff --git a/ponyconf/urls.py b/ponyconf/urls.py index a29dba5..ad1d5b4 100644 --- a/ponyconf/urls.py +++ b/ponyconf/urls.py @@ -21,4 +21,5 @@ urlpatterns = [ url(r'^accounts/', include('accounts.urls')), url(r'^registration/', include('registration.backends.default.urls')), url(r'^', include('proposals.urls')), + url(r'^conversations/', include('conversations.urls')), ] diff --git a/proposals/admin.py b/proposals/admin.py index e5a36bf..fa94429 100644 --- a/proposals/admin.py +++ b/proposals/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin -from proposals.models import Speach, Talk, Topic +from proposals.models import Speech, Talk, Topic admin.site.register(Topic) admin.site.register(Talk) -admin.site.register(Speach) +admin.site.register(Speech) diff --git a/proposals/migrations/0001_initial.py b/proposals/migrations/0001_initial.py index 927a56a..056070a 100644 --- a/proposals/migrations/0001_initial.py +++ b/proposals/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.9.7 on 2016-06-12 16:42 +# Generated by Django 1.9.7 on 2016-06-12 21:41 from __future__ import unicode_literals import autoslug.fields @@ -21,7 +21,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Speach', + name='Speech', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('order', models.IntegerField(choices=[(1, '1'), (2, '2'), (3, '3'), (4, '4'), (5, '5'), (6, '6'), (7, '7')], default=1)), @@ -38,8 +38,9 @@ class Migration(migrations.Migration): ('title', models.CharField(max_length=128, verbose_name='Title')), ('slug', autoslug.fields.AutoSlugField(editable=False, populate_from='title', unique=True)), ('description', models.TextField(blank=True, verbose_name='Description')), + ('event', models.IntegerField(choices=[(1, 'conference'), (2, 'workshop'), (3, 'stand'), (4, 'other')], default=1)), ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.Site')), - ('speakers', models.ManyToManyField(through='proposals.Speach', to=settings.AUTH_USER_MODEL)), + ('speakers', models.ManyToManyField(through='proposals.Speech', to=settings.AUTH_USER_MODEL)), ], managers=[ ('objects', django.db.models.manager.Manager()), @@ -60,12 +61,12 @@ class Migration(migrations.Migration): field=models.ManyToManyField(blank=True, to='proposals.Topic'), ), migrations.AddField( - model_name='speach', + model_name='speech', name='talk', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='proposals.Talk'), ), migrations.AlterUniqueTogether( - name='speach', + name='speech', unique_together=set([('order', 'talk'), ('speaker', 'talk')]), ), ] diff --git a/proposals/migrations/0002_talk_event.py b/proposals/migrations/0002_talk_event.py deleted file mode 100644 index 9bf800f..0000000 --- a/proposals/migrations/0002_talk_event.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.7 on 2016-06-12 20:26 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('proposals', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='talk', - name='event', - field=models.IntegerField(choices=[(1, 'conference'), (2, 'workshop'), (3, 'stand'), (4, 'other')], default=1), - ), - ] diff --git a/proposals/models.py b/proposals/models.py index ddab9d3..bfaa2de 100644 --- a/proposals/models.py +++ b/proposals/models.py @@ -10,7 +10,7 @@ from autoslug import AutoSlugField from accounts.models import enum_to_choices -__all__ = ['Topic', 'Talk', 'Speach'] +__all__ = ['Topic', 'Talk', 'Speech'] class Topic(models.Model): @@ -31,7 +31,7 @@ class Talk(models.Model): site = models.ForeignKey(Site, on_delete=models.CASCADE) - speakers = models.ManyToManyField(User, through='Speach') + speakers = models.ManyToManyField(User, through='Speech') title = models.CharField(max_length=128, verbose_name='Title') slug = AutoSlugField(populate_from='title', unique=True) description = models.TextField(blank=True, verbose_name='Description') @@ -48,7 +48,7 @@ class Talk(models.Model): return reverse('show-talk', kwargs={'slug': self.slug}) -class Speach(models.Model): +class Speech(models.Model): SPEAKER_NO = tuple((i, str(i)) for i in range(1, 8)) diff --git a/proposals/tests.py b/proposals/tests.py index 954e416..3af5e6d 100644 --- a/proposals/tests.py +++ b/proposals/tests.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from django.core.urlresolvers import reverse from django.test import TestCase -from .models import Talk, Topic, Speach +from .models import Talk, Topic, Speech class ProposalsTests(TestCase): @@ -36,7 +36,7 @@ class ProposalsTests(TestCase): self.assertEqual(self.client.get(reverse('list-talks')).status_code, 200) # Models str & get_asbolute_url - for model in [Talk, Topic, Speach]: + for model in [Talk, Topic, Speech]: item = model.objects.first() self.assertEqual(self.client.get(item.get_absolute_url()).status_code, 200) self.assertTrue(str(item)) diff --git a/proposals/views.py b/proposals/views.py index 4f6b851..025e96a 100644 --- a/proposals/views.py +++ b/proposals/views.py @@ -9,7 +9,7 @@ from django.views.generic import DetailView, ListView from accounts.models import Speaker from proposals.forms import TalkForm -from proposals.models import Speach, Talk, Topic +from proposals.models import Speech, Talk, Topic def home(request): @@ -63,7 +63,7 @@ def talk_edit(request, talk=None): talk.save() form.save_m2m() Speaker.on_site.get_or_create(user=request.user, site=site) - Speach.objects.create(speaker=request.user, talk=talk) + Speech.objects.create(speaker=request.user, talk=talk) messages.success(request, 'Talk proposed successfully!') return redirect(talk.get_absolute_url()) return render(request, 'proposals/talk_edit.html', { @@ -80,7 +80,7 @@ class TopicList(LoginRequiredMixin, ListView): class SpeakerList(LoginRequiredMixin, ListView): - queryset = User.objects.filter(speach__talk=Talk.on_site.all()) + queryset = User.objects.filter(speech__talk=Talk.on_site.all()) template_name = 'proposals/speaker_list.html'