communications app

This commit is contained in:
Élie Bouttier 2016-06-12 23:39:04 +02:00
parent d30ce1e3a5
commit ff9577b1f4
24 changed files with 254 additions and 42 deletions

View File

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- 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 __future__ import unicode_literals
from django.conf import settings from django.conf import settings
@ -14,8 +14,8 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('sites', '0002_alter_domain_unique'), ('sites', '0002_alter_domain_unique'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
@ -35,7 +35,7 @@ class Migration(migrations.Migration):
('departure', models.DateTimeField(blank=True, null=True)), ('departure', models.DateTimeField(blank=True, null=True)),
('transport', models.IntegerField(blank=True, choices=[(1, 'train'), (2, 'plane')], 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)), ('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')), ('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)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
], ],

View File

@ -37,7 +37,7 @@ class Speaker(models.Model):
departure = models.DateTimeField(blank=True, null=True) departure = models.DateTimeField(blank=True, null=True)
transport = models.IntegerField(choices=enum_to_choices(TRANSPORTS), 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) connector = models.IntegerField(choices=enum_to_choices(CONNECTORS), blank=True, null=True)
constraints = models.TextField() constraints = models.TextField(blank=True)
objects = models.Manager() objects = models.Manager()
on_site = CurrentSiteManager() on_site = CurrentSiteManager()

View File

@ -28,7 +28,7 @@
<div class="row"> <div class="row">
<div class="col-md-offset-4 col-md-4"> <div class="col-md-offset-4 col-md-4">
You do not have an account? Please <a href="{% url 'registration_register' %}">register</a>. You do not have an account yet? Please <a href="{% url 'registration_register' %}">register</a>.
</div> </div>
</div> </div>

View File

@ -0,0 +1 @@
default_app_config = 'conversations.apps.ConversationsConfig'

7
conversations/admin.py Normal file
View File

@ -0,0 +1,7 @@
from django.contrib import admin
from .models import Conversation, Message
admin.site.register(Conversation)
admin.site.register(Message)

8
conversations/apps.py Normal file
View File

@ -0,0 +1,8 @@
from django.apps import AppConfig
class ConversationsConfig(AppConfig):
name = 'conversations'
def ready(self):
import conversations.signals

View File

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

View File

29
conversations/models.py Normal file
View File

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

77
conversations/signals.py Normal file
View File

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

View File

@ -0,0 +1,8 @@
{{ content|safe }}
<hr>
{% if answering %}
Reply to this email directly or <a href="{{ uri }}">view it online</a>.
{% else %}
<a href="{{ uri }}">Reply online</a>.
{% endif %}

View File

@ -0,0 +1,4 @@
{{ content|safe }}
--
Reply {% if answering %}to this email directly or view it {% endif %}online: {{ uri }}

3
conversations/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

8
conversations/urls.py Normal file
View File

@ -0,0 +1,8 @@
from django.conf.urls import url
from conversations import views
urlpatterns = [
url(r'^(?P<conversation>[0-9]+)$', views.conversation_details, name='show-conversation'),
]

26
conversations/utils.py Normal file
View File

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

13
conversations/views.py Normal file
View File

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

View File

@ -31,8 +31,7 @@ ALLOWED_HOSTS = []
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'accounts', # build-in apps
'registration',
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
@ -41,11 +40,16 @@ INSTALLED_APPS = [
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.contrib.sites', 'django.contrib.sites',
# external apps
'djangobower', 'djangobower',
'bootstrap3', 'bootstrap3',
'registration',
# our apps
'accounts',
'ponyconf', 'ponyconf',
'proposals', 'proposals',
'conversations',
] ]
MIDDLEWARE_CLASSES = [ MIDDLEWARE_CLASSES = [

View File

@ -21,4 +21,5 @@ urlpatterns = [
url(r'^accounts/', include('accounts.urls')), url(r'^accounts/', include('accounts.urls')),
url(r'^registration/', include('registration.backends.default.urls')), url(r'^registration/', include('registration.backends.default.urls')),
url(r'^', include('proposals.urls')), url(r'^', include('proposals.urls')),
url(r'^conversations/', include('conversations.urls')),
] ]

View File

@ -1,7 +1,7 @@
from django.contrib import admin 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(Topic)
admin.site.register(Talk) admin.site.register(Talk)
admin.site.register(Speach) admin.site.register(Speech)

View File

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- 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 from __future__ import unicode_literals
import autoslug.fields import autoslug.fields
@ -21,7 +21,7 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Speach', name='Speech',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('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')), ('title', models.CharField(max_length=128, verbose_name='Title')),
('slug', autoslug.fields.AutoSlugField(editable=False, populate_from='title', unique=True)), ('slug', autoslug.fields.AutoSlugField(editable=False, populate_from='title', unique=True)),
('description', models.TextField(blank=True, verbose_name='Description')), ('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')), ('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=[ managers=[
('objects', django.db.models.manager.Manager()), ('objects', django.db.models.manager.Manager()),
@ -60,12 +61,12 @@ class Migration(migrations.Migration):
field=models.ManyToManyField(blank=True, to='proposals.Topic'), field=models.ManyToManyField(blank=True, to='proposals.Topic'),
), ),
migrations.AddField( migrations.AddField(
model_name='speach', model_name='speech',
name='talk', name='talk',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='proposals.Talk'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='proposals.Talk'),
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name='speach', name='speech',
unique_together=set([('order', 'talk'), ('speaker', 'talk')]), unique_together=set([('order', 'talk'), ('speaker', 'talk')]),
), ),
] ]

View File

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

View File

@ -10,7 +10,7 @@ from autoslug import AutoSlugField
from accounts.models import enum_to_choices from accounts.models import enum_to_choices
__all__ = ['Topic', 'Talk', 'Speach'] __all__ = ['Topic', 'Talk', 'Speech']
class Topic(models.Model): class Topic(models.Model):
@ -31,7 +31,7 @@ class Talk(models.Model):
site = models.ForeignKey(Site, on_delete=models.CASCADE) 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') title = models.CharField(max_length=128, verbose_name='Title')
slug = AutoSlugField(populate_from='title', unique=True) slug = AutoSlugField(populate_from='title', unique=True)
description = models.TextField(blank=True, verbose_name='Description') description = models.TextField(blank=True, verbose_name='Description')
@ -48,7 +48,7 @@ class Talk(models.Model):
return reverse('show-talk', kwargs={'slug': self.slug}) 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)) SPEAKER_NO = tuple((i, str(i)) for i in range(1, 8))

View File

@ -2,7 +2,7 @@ from django.contrib.auth.models import User
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test import TestCase from django.test import TestCase
from .models import Talk, Topic, Speach from .models import Talk, Topic, Speech
class ProposalsTests(TestCase): class ProposalsTests(TestCase):
@ -36,7 +36,7 @@ class ProposalsTests(TestCase):
self.assertEqual(self.client.get(reverse('list-talks')).status_code, 200) self.assertEqual(self.client.get(reverse('list-talks')).status_code, 200)
# Models str & get_asbolute_url # Models str & get_asbolute_url
for model in [Talk, Topic, Speach]: for model in [Talk, Topic, Speech]:
item = model.objects.first() item = model.objects.first()
self.assertEqual(self.client.get(item.get_absolute_url()).status_code, 200) self.assertEqual(self.client.get(item.get_absolute_url()).status_code, 200)
self.assertTrue(str(item)) self.assertTrue(str(item))

View File

@ -9,7 +9,7 @@ from django.views.generic import DetailView, ListView
from accounts.models import Speaker from accounts.models import Speaker
from proposals.forms import TalkForm from proposals.forms import TalkForm
from proposals.models import Speach, Talk, Topic from proposals.models import Speech, Talk, Topic
def home(request): def home(request):
@ -63,7 +63,7 @@ def talk_edit(request, talk=None):
talk.save() talk.save()
form.save_m2m() form.save_m2m()
Speaker.on_site.get_or_create(user=request.user, site=site) 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!') messages.success(request, 'Talk proposed successfully!')
return redirect(talk.get_absolute_url()) return redirect(talk.get_absolute_url())
return render(request, 'proposals/talk_edit.html', { return render(request, 'proposals/talk_edit.html', {
@ -80,7 +80,7 @@ class TopicList(LoginRequiredMixin, ListView):
class SpeakerList(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' template_name = 'proposals/speaker_list.html'