diff --git a/accounts/admin.py b/accounts/admin.py index b6cb776..5a90ca6 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from accounts.models import Profile, Speaker +from accounts.models import Profile, Participation admin.site.register(Profile) # FIXME extend user admin -admin.site.register(Speaker) +admin.site.register(Participation) diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py index 56cb873..e46e974 100644 --- a/accounts/migrations/0001_initial.py +++ b/accounts/migrations/0001_initial.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.9.7 on 2016-06-12 21:41 +# Generated by Django 1.9.7 on 2016-06-13 18:50 from __future__ import unicode_literals +import accounts.models from django.conf import settings import django.contrib.sites.managers from django.db import migrations, models @@ -14,21 +15,13 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('sites', '0002_alter_domain_unique'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('sites', '0002_alter_domain_unique'), ] operations = [ migrations.CreateModel( - name='Profile', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('biography', models.TextField(blank=True, verbose_name='Biography')), - ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.CreateModel( - name='Speaker', + name='Participation', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('arrival', models.DateTimeField(blank=True, null=True)), @@ -44,8 +37,17 @@ class Migration(migrations.Migration): ('on_site', django.contrib.sites.managers.CurrentSiteManager()), ], ), + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('biography', models.TextField(blank=True, verbose_name='Biography')), + ('email_token', models.CharField(default=accounts.models.generate_user_uid, max_length=12)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), migrations.AlterUniqueTogether( - name='speaker', + name='participation', unique_together=set([('site', 'user')]), ), ] diff --git a/accounts/models.py b/accounts/models.py index db4ad50..0cafe85 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -5,18 +5,23 @@ from django.contrib.sites.managers import CurrentSiteManager from django.contrib.sites.models import Site from django.core.urlresolvers import reverse from django.db import models +from django.utils.crypto import get_random_string -__all__ = ['Profile', 'Speaker'] +__all__ = ['Profile', 'Participation'] def enum_to_choices(enum): return ((item.value, item.name) for item in list(enum)) +def generate_user_uid(): + return get_random_string(length=12, allowed_chars='abcdefghijklmnopqrstuvwxyz0123456789') + class Profile(models.Model): user = models.OneToOneField(User) biography = models.TextField(blank=True, verbose_name='Biography') + email_token = models.CharField(max_length=12, default=generate_user_uid) def __str__(self): return self.user.get_full_name() or self.user.username @@ -25,16 +30,17 @@ class Profile(models.Model): return reverse('profile') -class Speaker(models.Model): +class Participation(models.Model): TRANSPORTS = IntEnum('Transport', 'train plane') CONNECTORS = IntEnum('Connector', 'VGA HDMI miniDP') site = models.ForeignKey(Site, on_delete=models.CASCADE) - user = models.ForeignKey(User) + arrival = models.DateTimeField(blank=True, null=True) departure = models.DateTimeField(blank=True, null=True) + # TODO: These should multi-choice fields 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(blank=True) @@ -43,13 +49,14 @@ class Speaker(models.Model): on_site = CurrentSiteManager() class Meta: + # A User can participe only once to a Conference (= Site) unique_together = ('site', 'user') def __str__(self): - return str(self.user.profile) + return "%s participation to %s" % (str(self.user.profile), self.site.name) def get_absolute_url(self): - return reverse('show-speaker', kwargs={'username': self.user.username}) + return reverse('show-participation', kwargs={'username': self.user.username}) def create_profile(sender, instance, created, **kwargs): diff --git a/accounts/signals.py b/accounts/signals.py index 5001fd2..34bc1e8 100644 --- a/accounts/signals.py +++ b/accounts/signals.py @@ -1,10 +1,14 @@ -from django.contrib import messages from django.contrib.auth.signals import user_logged_in, user_logged_out from django.dispatch import receiver +from django.contrib import messages +from django.contrib.sites.shortcuts import get_current_site + +from .models import Participation @receiver(user_logged_in) def on_user_logged_in(sender, request, **kwargs): + Participation.on_site.get_or_create(user=request.user, site=get_current_site(request)) messages.success(request, 'Welcome!', fail_silently=True) # FIXME diff --git a/accounts/tests.py b/accounts/tests.py index 7ef22ac..7e63be5 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -3,7 +3,7 @@ from django.contrib.sites.models import Site from django.core.urlresolvers import reverse from django.test import TestCase -from .models import Profile, Speaker +from .models import Profile, Participation ROOT_URL = 'accounts' @@ -12,12 +12,12 @@ class AccountTests(TestCase): def setUp(self): for guy in 'ab': User.objects.create_user(guy, email='%s@example.org' % guy, password=guy) - Speaker.objects.create(user=User.objects.first(), site=Site.objects.first()) + Participation.objects.create(user=User.objects.first(), site=Site.objects.first()) def test_models(self): self.assertEqual(Profile.objects.count(), 2) self.client.login(username='b', password='b') - for model in [Profile, Speaker]: + for model in [Profile, Participation]: item = model.objects.first() self.assertEqual(self.client.get(item.get_absolute_url()).status_code, 200) self.assertTrue(str(item)) diff --git a/conversations/admin.py b/conversations/admin.py index 46913f3..d12718a 100644 --- a/conversations/admin.py +++ b/conversations/admin.py @@ -2,5 +2,6 @@ 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 index 7fc0e2b..73892cf 100644 --- a/conversations/apps.py +++ b/conversations/apps.py @@ -5,4 +5,4 @@ class ConversationsConfig(AppConfig): name = 'conversations' def ready(self): - import conversations.signals # noqa + import conversations.signals diff --git a/conversations/emails.py b/conversations/emails.py new file mode 100644 index 0000000..e76052c --- /dev/null +++ b/conversations/emails.py @@ -0,0 +1,83 @@ +from django.shortcuts import get_object_or_404 +from django.core.exceptions import PermissionDenied +from django.views.decorators.http import require_http_methods +from django.conf import settings +from django.http import HttpResponse +from django.views.decorators.csrf import csrf_exempt +from django.http import Http404 +from django.contrib.auth.models import User +from django.core.mail import mail_admins + +from .utils import hexdigest_sha256 +from .models import Message + +import email +import re +from sys import version_info as python_version + + +@csrf_exempt +@require_http_methods(["POST"]) +def email_recv(request): + + if not hasattr(settings, 'REPLY_EMAIL') \ + or not hasattr(settings, 'REPLY_KEY'): + return HttpResponse(status=501) # Not Implemented + + key = request.POST.get('key') + if key != settings.REPLY_KEY: + raise PermissionDenied + + if 'email' not in request.FILES: + raise HttpResponse(status=400) # Bad Request + + msg = request.FILES['email'] + if python_version < (3,): + msg = email.message_from_file(msg) + else: + msg = email.message_from_bytes(msg.read()) + + mfrom = msg.get('From') + mto = msg.get('To') + subject = msg.get('Subject') + + if msg.is_multipart(): + msgs = msg.get_payload() + for m in msgs: + if m.get_content_type == 'text/plain': + content = m.get_payload(decode=True) + break + else: + content = msgs[0].get_payload(decode=True) + else: + content = msg.get_payload(decode=True) + + if python_version < (3,): + content = content.decode('utf-8') + + addr = settings.REPLY_EMAIL + pos = addr.find('@') + name = addr[:pos] + domain = addr[pos+1:] + + regexp = '^%s\+(?P[a-z0-9]{12})(?P[a-z0-9]{60})(?P[a-z0-9]{12})@%s$' % (name, domain) + p = re.compile(regexp) + m = None + for _mto in map(lambda x: x.strip(), mto.split(',')): + m = p.match(_mto) + if m: + break + if not m: # no one matches + raise Http404 + + author = get_object_or_404(User, profile__email_token=m.group('dest')) + message = get_object_or_404(Message, token=m.group('token')) + key = hexdigest_sha256(settings.SECRET_KEY, message.token, author.pk)[0:12] + if key != m.group('key'): + raise PermissionDenied + + answer = Message(conversation=message.conversation, + author=author, content=content) + answer.save() + + return HttpResponse() diff --git a/conversations/forms.py b/conversations/forms.py new file mode 100644 index 0000000..92c6b8e --- /dev/null +++ b/conversations/forms.py @@ -0,0 +1,10 @@ +from django.forms.models import modelform_factory + +from .models import Message + + +MessageForm = modelform_factory(Message, + fields=['content']) + + + diff --git a/conversations/migrations/0001_initial.py b/conversations/migrations/0001_initial.py index 7dff8b4..39b2c35 100644 --- a/conversations/migrations/0001_initial.py +++ b/conversations/migrations/0001_initial.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.9.7 on 2016-06-12 21:41 +# Generated by Django 1.9.7 on 2016-06-13 18:50 from __future__ import unicode_literals +import conversations.utils from django.conf import settings from django.db import migrations, models import django.db.models.deletion @@ -21,7 +22,7 @@ class Migration(migrations.Migration): 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')), + ('participation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='conversation', to='accounts.Participation')), ('subscribers', models.ManyToManyField(related_name='_conversation_subscribers_+', to=settings.AUTH_USER_MODEL)), ], ), @@ -29,7 +30,7 @@ class Migration(migrations.Migration): name='Message', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('token', models.CharField(max_length=64)), + ('token', models.CharField(default=conversations.utils.generate_message_token, 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)), diff --git a/conversations/models.py b/conversations/models.py index c0ea7e9..082ab5e 100644 --- a/conversations/models.py +++ b/conversations/models.py @@ -1,27 +1,25 @@ -from django.contrib.auth.models import User -from django.core.urlresolvers import reverse from django.db import models +from django.contrib.auth.models import User -from accounts.models import Speaker +from accounts.models import Participation +from .utils import generate_message_token class Conversation(models.Model): - speaker = models.ForeignKey(Speaker, related_name='conversation') + participation = models.OneToOneField(Participation, related_name='conversation') subscribers = models.ManyToManyField(User, related_name='+') def __str__(self): - return "Conversation with %s" % self.speaker - - def get_absolute_url(self): - return reverse('show-conversation', kwargs={'conversation': self.pk}) + return "Conversation with %s" % self.participation.user class Message(models.Model): - token = models.CharField(max_length=64) conversation = models.ForeignKey(Conversation, related_name='messages') + token = models.CharField(max_length=64, default=generate_message_token) + author = models.ForeignKey(User) date = models.DateTimeField(auto_now_add=True) content = models.TextField() diff --git a/conversations/sieve-filter b/conversations/sieve-filter new file mode 100755 index 0000000..cf5ff04 --- /dev/null +++ b/conversations/sieve-filter @@ -0,0 +1,24 @@ +#!/usr/bin/env python + +import sys +import requests + + +if len(sys.argv) != 2: + print("Usage: %s KEY@URL" % sys.argv[0]) + sys.exit(1) + +key, url = sys.argv[1].split('@') + +email = sys.stdin.buffer.raw.read() +sys.stdout.buffer.write(email) # DO NOT REMOVE + +requests.post( + url, + data={ + 'key': key, + }, + files={ + 'email': ('email.txt', email), + } +) diff --git a/conversations/signals.py b/conversations/signals.py index 6522fb0..20fbafa 100644 --- a/conversations/signals.py +++ b/conversations/signals.py @@ -1,13 +1,23 @@ -from django.conf import settings -from django.core import mail -from django.core.mail import EmailMultiAlternatives -from django.core.urlresolvers import reverse 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 .models import Conversation, Message from .utils import get_reply_addr +from proposals.models import Talk, Topic +from accounts.models import Participation + + +@receiver(post_save, sender=Participation, dispatch_uid="Create Conversation") +def create_conversation(sender, instance, created, **kwargs): + if not created: + return + conversation = Conversation(participation=instance).save() @receiver(post_save, sender=Message, dispatch_uid="Notify new message") @@ -17,15 +27,16 @@ def notify_new_message(sender, instance, created, **kwargs): return message = instance conversation = message.conversation - site = conversation.speaker.site + site = conversation.participation.site subject = site.name - sender = instance.author + sender = message.author + if sender != conversation.participation.user \ + and sender not in conversation.subscribers: + conversation.subscribers.add(sender) 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]), + 'uri': site.domain + reverse('messaging'), } message_id = message.token ref = None @@ -36,7 +47,7 @@ def notify_new_message(sender, instance, created, **kwargs): def notify_by_email(data, template, subject, sender, dests, message_id, ref=None): - if hasattr(settings, 'REPLY_EMAIL'): + if hasattr(settings, 'REPLY_EMAIL') and hasattr(settings, 'REPLY_KEY'): data.update({'answering': True}) text_message = render_to_string('conversations/%s.txt' % template, data) @@ -47,9 +58,9 @@ def notify_by_email(data, template, subject, sender, dests, message_id, ref=None email=settings.DEFAULT_FROM_EMAIL) # Generating headers - headers = { + headers = { 'Message-ID': "<%s.%s>" % (message_id, settings.DEFAULT_FROM_EMAIL), - } + } if ref: # This email reference a previous one headers.update({ diff --git a/conversations/templates/conversations/messaging.html b/conversations/templates/conversations/messaging.html new file mode 100644 index 0000000..17b3e22 --- /dev/null +++ b/conversations/templates/conversations/messaging.html @@ -0,0 +1,38 @@ +{% extends 'base.html' %} + +{% block messagingtab %} class="active"{% endblock %} + +{% block content %} + + + +{% for message in message_list %} +
+
+ {{ message.date }} | from {{ message.author.profile }} +
+
+ {{ message.content }} +
+
+{% endfor %} + +
+
+ Send a message +
+
+
+ {% csrf_token %} +
+ +
+ +
+
+
+ +{% endblock %} diff --git a/conversations/templates/conversations/new_message.html b/conversations/templates/conversations/new_message.html index 7f261f4..40cc17f 100644 --- a/conversations/templates/conversations/new_message.html +++ b/conversations/templates/conversations/new_message.html @@ -2,7 +2,7 @@
{% if answering %} -Reply to this email directly or view it online. +Reply to this email directly or view it online. {% else %} -Reply online. +Reply online. {% endif %} diff --git a/conversations/templates/conversations/new_message.txt b/conversations/templates/conversations/new_message.txt index 133a36c..060506a 100644 --- a/conversations/templates/conversations/new_message.txt +++ b/conversations/templates/conversations/new_message.txt @@ -1,4 +1,4 @@ {{ content|safe }} -- -Reply {% if answering %}to this email directly or view it {% endif %}online: {{ uri }} +Reply {% if answering %}to this email directly or view it {% endif %}online: https://{{ uri }} diff --git a/conversations/tests.py b/conversations/tests.py index d76175b..e860168 100644 --- a/conversations/tests.py +++ b/conversations/tests.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from django.contrib.sites.models import Site from django.test import TestCase -from accounts.models import Speaker +from accounts.models import Participation from .models import Conversation, Message from .utils import get_reply_addr @@ -11,7 +11,7 @@ from .utils import get_reply_addr class ConversationTests(TestCase): def setUp(self): a, b = (User.objects.create_user(guy, email='%s@example.org' % guy, password=guy) for guy in 'ab') - speaker = Speaker.objects.create(user=a, site=Site.objects.first()) + participation = Participation.objects.create(user=a, site=Site.objects.first()) conversation = Conversation.objects.create(speaker=speaker) Message.objects.create(token='pipo', conversation=conversation, author=a, content='allo') diff --git a/conversations/urls.py b/conversations/urls.py index 89999ba..95bea24 100644 --- a/conversations/urls.py +++ b/conversations/urls.py @@ -1,7 +1,9 @@ from django.conf.urls import url -from conversations import views +from conversations import views, emails + urlpatterns = [ - url(r'^(?P[0-9]+)$', views.conversation_details, name='show-conversation'), + url(r'^recv/$', emails.email_recv), + url(r'^$', views.messaging, name='messaging'), ] diff --git a/conversations/utils.py b/conversations/utils.py index 10afbb1..286a576 100644 --- a/conversations/utils.py +++ b/conversations/utils.py @@ -1,6 +1,7 @@ -import hashlib - from django.conf import settings +from django.utils.crypto import get_random_string + +import hashlib def hexdigest_sha256(*args): @@ -21,6 +22,10 @@ def get_reply_addr(message_id, dest): pos = addr.find('@') name = addr[:pos] domain = addr[pos:] - key = hexdigest_sha256(settings.SECRET_KEY, message_id, dest.pk) + key = hexdigest_sha256(settings.SECRET_KEY, message_id, dest.pk)[0:12] - return ['%s+%10s%s%10s%s' % (name, dest.pk, message_id, key, domain)] + return ['%s+%s%s%s%s' % (name, dest.profile.email_token, message_id, key, domain)] + + +def generate_message_token(): + return get_random_string(length=60, allowed_chars='abcdefghijklmnopqrstuvwxyz0123456789') diff --git a/conversations/views.py b/conversations/views.py index ac35d6f..cace437 100644 --- a/conversations/views.py +++ b/conversations/views.py @@ -1,11 +1,33 @@ +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 django.shortcuts import get_object_or_404, render - -from .models import Conversation +from django.contrib.auth.decorators import login_required +from django.contrib import messages -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', { - 'conversation': conversation, +from accounts.models import Participation +from .models import Message +from .forms import MessageForm + + +@login_required +def messaging(request): + + participation = get_object_or_404(Participation, user=request.user, site=get_current_site(request)) + conversation = participation.conversation + message_list = conversation.messages.all() + + form = MessageForm(request.POST or None) + + if request.method == 'POST' and form.is_valid(): + message = form.save(commit=False) + message.conversation = conversation + message.author = request.user + message.save() + messages.success(request, 'Message sent!') + return redirect('messaging') + + return render(request, 'conversations/messaging.html', { + 'message_list': message_list, + 'form': form, }) diff --git a/ponyconf/settings.py b/ponyconf/settings.py index 17cd7ab..11f8a45 100644 --- a/ponyconf/settings.py +++ b/ponyconf/settings.py @@ -31,6 +31,17 @@ ALLOWED_HOSTS = [] # Application definition INSTALLED_APPS = [ + # our apps + 'accounts', + 'ponyconf', + 'proposals', + 'conversations', + + # external apps + 'djangobower', + 'bootstrap3', + 'registration', + # build-in apps 'django.contrib.admin', 'django.contrib.auth', @@ -39,17 +50,6 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.sites', - - # external apps - 'djangobower', - 'bootstrap3', - 'registration', - - # our apps - 'accounts', - 'ponyconf', - 'proposals', - 'conversations', ] MIDDLEWARE_CLASSES = [ @@ -178,4 +178,3 @@ BOOTSTRAP3 = { AUTHENTICATION_BACKENDS = ['yeouia.backends.YummyEmailOrUsernameInsensitiveAuth'] LOGOUT_REDIRECT_URL = 'home' -REPLY_EMAIL = 'pipo@example.org' diff --git a/ponyconf/templates/base.html b/ponyconf/templates/base.html index dc9b584..9ac3bce 100644 --- a/ponyconf/templates/base.html +++ b/ponyconf/templates/base.html @@ -55,6 +55,7 @@ {% if request.user.is_staff %}
  •  Administration
  • {% endif %} + Messaging {{ request.user.username }}
  • {% else %} diff --git a/proposals/migrations/0001_initial.py b/proposals/migrations/0001_initial.py index 056070a..e087928 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 21:41 +# Generated by Django 1.9.7 on 2016-06-13 18:50 from __future__ import unicode_literals import autoslug.fields @@ -67,6 +67,6 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='speech', - unique_together=set([('order', 'talk'), ('speaker', 'talk')]), + unique_together=set([('speaker', 'talk'), ('order', 'talk')]), ), ] diff --git a/proposals/tests.py b/proposals/tests.py index bcc5e12..e0b442b 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 Speech, Talk, Topic +from .models import Talk, Topic, Speech class ProposalsTests(TestCase): diff --git a/proposals/views.py b/proposals/views.py index 025e96a..c3af312 100644 --- a/proposals/views.py +++ b/proposals/views.py @@ -7,7 +7,6 @@ from django.core.exceptions import PermissionDenied from django.shortcuts import get_object_or_404, redirect, render from django.views.generic import DetailView, ListView -from accounts.models import Speaker from proposals.forms import TalkForm from proposals.models import Speech, Talk, Topic @@ -62,7 +61,6 @@ def talk_edit(request, talk=None): talk.site = site talk.save() form.save_m2m() - Speaker.on_site.get_or_create(user=request.user, site=site) Speech.objects.create(speaker=request.user, talk=talk) messages.success(request, 'Talk proposed successfully!') return redirect(talk.get_absolute_url())