diff --git a/accounts/__init__.py b/accounts/__init__.py deleted file mode 100644 index 8319823..0000000 --- a/accounts/__init__.py +++ /dev/null @@ -1 +0,0 @@ -default_app_config = 'accounts.apps.AccountsConfig' diff --git a/accounts/admin.py b/accounts/admin.py deleted file mode 100644 index 11bd7e9..0000000 --- a/accounts/admin.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.contrib import admin - -from accounts.models import Participation, Profile, Transport, Connector - - -admin.site.register(Profile) # FIXME extend user admin -admin.site.register(Participation) -admin.site.register(Transport) -admin.site.register(Connector) diff --git a/accounts/apps.py b/accounts/apps.py deleted file mode 100644 index cf90676..0000000 --- a/accounts/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.apps import AppConfig -from django.db.models.signals import post_migrate - - -class AccountsConfig(AppConfig): - name = 'accounts' - - def ready(self): - import accounts.signals # noqa - post_migrate.connect(accounts.signals.create_default_options, sender=self) diff --git a/accounts/forms.py b/accounts/forms.py deleted file mode 100644 index 8ab1146..0000000 --- a/accounts/forms.py +++ /dev/null @@ -1,48 +0,0 @@ -from django import forms -from django.contrib.auth.models import User -from django.forms.models import modelform_factory -from django.utils.translation import ugettext_lazy as _ - -from django_select2.forms import Select2Widget - -from .models import Participation, Profile - -UserForm = modelform_factory(User, fields=['first_name', 'last_name', 'email', 'username']) - -ProfileForm = modelform_factory(Profile, fields=['phone_number', 'biography', 'twitter', 'website', 'linkedin', 'facebook', 'mastodon']) - -ParticipationForm = modelform_factory(Participation, - fields=['need_transport', 'transport', 'transport_city_outward', 'transport_city_return', - 'accommodation', - 'connector', 'sound', 'videotaped', - 'video_licence', 'constraints'], - widgets={'transport': forms.CheckboxSelectMultiple(), - 'connector': forms.CheckboxSelectMultiple()}, - help_texts = { - 'constraints': _('For example, you need to be back on saturday evening, you cannot eat meat.'), - }) - -ProfileOrgaForm = modelform_factory(Profile, fields=['biography']) - -ParticipationOrgaForm = modelform_factory(Participation, - fields=['need_transport', 'transport', 'transport_city_outward', 'transport_city_return', 'transport_booked', - 'accommodation', 'accommodation_booked', - 'connector', 'sound', 'videotaped', - 'video_licence', - 'constraints', 'notes', 'orga'], - widgets={'transport': forms.CheckboxSelectMultiple(), - 'connector': forms.CheckboxSelectMultiple()}) - - -class ParticipationField(forms.ModelChoiceField): - def label_from_instance(self, obj): - return obj.profile.__str__() - - -class NewParticipationForm(forms.Form): - def __init__(self, *args, **kwargs): - site = kwargs.pop('site') - super().__init__(*args, **kwargs) - queryset = User.objects.exclude(participation__site=site).all() - self.fields['participant'] = ParticipationField(queryset, widget=Select2Widget(), - label='Add participant from existing account') diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py deleted file mode 100644 index 36a5856..0000000 --- a/accounts/migrations/0001_initial.py +++ /dev/null @@ -1,107 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.3 on 2017-01-13 10:49 -from __future__ import unicode_literals - -import accounts.utils -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), - ('sites', '0002_alter_domain_unique'), - ] - - operations = [ - migrations.CreateModel( - name='AvailabilityTimeslot', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('start', models.DateTimeField(blank=True)), - ('end', models.DateTimeField(blank=True)), - ], - ), - migrations.CreateModel( - name='Connector', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=64, unique=True)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='Participation', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('need_transport', models.NullBooleanField(default=None, verbose_name='Defray transportation?')), - ('arrival', models.DateTimeField(blank=True, null=True)), - ('departure', models.DateTimeField(blank=True, null=True)), - ('transport_city_outward', models.CharField(blank=True, default='', max_length=256, verbose_name='Departure city')), - ('transport_city_return', models.CharField(blank=True, default='', help_text='If different from departure city', max_length=256, verbose_name='Return city')), - ('transport_booked', models.BooleanField(default=False)), - ('accommodation', models.IntegerField(blank=True, choices=[(0, 'No'), (1, 'Hotel'), (2, 'Homestay')], null=True, verbose_name='Need accommodation?')), - ('accommodation_booked', models.BooleanField(default=False)), - ('constraints', models.TextField(blank=True, verbose_name='Constraints')), - ('sound', models.BooleanField(default=False, verbose_name='I need sound')), - ('videotaped', models.BooleanField(default=True, verbose_name="I'm ok to be recorded on video")), - ('video_licence', models.IntegerField(choices=[(1, 'CC-Zero'), (2, 'CC-BY'), (3, 'CC-BY-SA'), (4, 'CC-BY-ND'), (5, 'CC-BY-NC'), (6, 'CC-BY-NC-SA'), (7, 'CC-BY-NC-ND')], default=2, verbose_name='Video licence')), - ('notes', models.TextField(blank=True, default='', help_text='This field is only visible by organizers.', verbose_name='Notes')), - ('orga', models.BooleanField(default=False)), - ('connector', models.ManyToManyField(blank=True, to='accounts.Connector', verbose_name='I can output')), - ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.Site')), - ], - ), - migrations.CreateModel( - name='Profile', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('phone_number', models.CharField(blank=True, default='', max_length=16, verbose_name='Phone number')), - ('biography', models.TextField(blank=True, verbose_name='Biography')), - ('email_token', models.CharField(default=accounts.utils.generate_user_uid, max_length=12, unique=True)), - ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='Transport', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=64, unique=True)), - ], - options={ - 'abstract': False, - }, - ), - migrations.AddField( - model_name='participation', - name='transport', - field=models.ManyToManyField(blank=True, to='accounts.Transport', verbose_name='I want to travel by'), - ), - migrations.AddField( - model_name='participation', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='availabilitytimeslot', - name='participation', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='availabilities', to='accounts.Participation'), - ), - migrations.AlterUniqueTogether( - name='participation', - unique_together=set([('site', 'user')]), - ), - ] diff --git a/accounts/migrations/0002_auto_20170429_2134.py b/accounts/migrations/0002_auto_20170429_2134.py deleted file mode 100644 index daa957c..0000000 --- a/accounts/migrations/0002_auto_20170429_2134.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.7 on 2017-04-29 21:34 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='profile', - name='facebook', - field=models.CharField(blank=True, default='', max_length=100, verbose_name='Facebook'), - ), - migrations.AddField( - model_name='profile', - name='linkedin', - field=models.CharField(blank=True, default='', max_length=100, verbose_name='LinkedIn'), - ), - migrations.AddField( - model_name='profile', - name='mastodon', - field=models.CharField(blank=True, default='', max_length=100, verbose_name='Mastodon'), - ), - migrations.AddField( - model_name='profile', - name='twitter', - field=models.CharField(blank=True, default='', max_length=100, verbose_name='Twitter'), - ), - migrations.AddField( - model_name='profile', - name='website', - field=models.CharField(blank=True, default='', max_length=100, verbose_name='Website'), - ), - ] diff --git a/accounts/migrations/0003_profile_github.py b/accounts/migrations/0003_profile_github.py deleted file mode 100644 index cb5cb0f..0000000 --- a/accounts/migrations/0003_profile_github.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11 on 2017-04-30 11:29 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0002_auto_20170429_2134'), - ] - - operations = [ - migrations.AddField( - model_name='profile', - name='github', - field=models.CharField(blank=True, default='', max_length=100, verbose_name='Github'), - ), - ] diff --git a/accounts/mixins.py b/accounts/mixins.py deleted file mode 100644 index d1a5689..0000000 --- a/accounts/mixins.py +++ /dev/null @@ -1,18 +0,0 @@ -from django.contrib.auth.mixins import UserPassesTestMixin - -from .utils import is_orga, is_staff - - -class OrgaRequiredMixin(UserPassesTestMixin): - def test_func(self): - return is_orga(self.request, self.request.user) - - -class StaffRequiredMixin(UserPassesTestMixin): - def test_func(self): - return is_staff(self.request, self.request.user) - - -class SuperuserRequiredMixin(UserPassesTestMixin): - def test_func(self): - return self.request.user.is_superuser diff --git a/accounts/models.py b/accounts/models.py deleted file mode 100644 index 79b5f1c..0000000 --- a/accounts/models.py +++ /dev/null @@ -1,153 +0,0 @@ -from enum import IntEnum - -from django.contrib.auth.models import User -from django.contrib.sites.models import Site -from django.core.urlresolvers import reverse -from django.db import models -from django.utils.translation import ugettext_lazy as _ -from django.utils.translation import ugettext - -from ponyconf.utils import PonyConfModel, enum_to_choices - -from .utils import generate_user_uid - - -class Profile(PonyConfModel): - - user = models.OneToOneField(User) - phone_number = models.CharField(max_length=16, blank=True, default='', verbose_name=_('Phone number')) - biography = models.TextField(blank=True, verbose_name=_('Biography')) - email_token = models.CharField(max_length=12, default=generate_user_uid, unique=True) - - twitter = models.CharField(max_length=100, blank=True, default='', verbose_name=_('Twitter')) - linkedin = models.CharField(max_length=100, blank=True, default='', verbose_name=_('LinkedIn')) - github = models.CharField(max_length=100, blank=True, default='', verbose_name=_('Github')) - website = models.CharField(max_length=100, blank=True, default='', verbose_name=_('Website')) - facebook = models.CharField(max_length=100, blank=True, default='', verbose_name=_('Facebook')) - mastodon = models.CharField(max_length=100, blank=True, default='', verbose_name=_('Mastodon')) - - def __str__(self): - return self.user.get_full_name() or self.user.username - - def get_absolute_url(self): - return reverse('profile') - - -class Option(models.Model): - name = models.CharField(max_length=64, unique=True) - - class Meta: - abstract = True - - def __str__(self): - return ugettext(self.name) - - -class Transport(Option): - pass - - -class Connector(Option): - pass - - -class Participation(PonyConfModel): - - LICENCES = IntEnum('Video licence', 'CC-Zero CC-BY CC-BY-SA CC-BY-ND CC-BY-NC CC-BY-NC-SA CC-BY-NC-ND') - ACCOMMODATION_NO = 0 - ACCOMMODATION_HOTEL = 1 - ACCOMMODATION_HOMESTAY = 2 - ACCOMMODATION_CHOICES = ( - (ACCOMMODATION_NO, _('No')), - (ACCOMMODATION_HOTEL, _('Hotel')), - (ACCOMMODATION_HOMESTAY, _('Homestay')), - ) - - site = models.ForeignKey(Site, on_delete=models.CASCADE) - user = models.ForeignKey(User) - - - need_transport = models.NullBooleanField(verbose_name=_('Defray transportation?'), default=None) - arrival = models.DateTimeField(blank=True, null=True) - departure = models.DateTimeField(blank=True, null=True) - transport = models.ManyToManyField(Transport, verbose_name=_("I want to travel by"), blank=True) - transport_city_outward = models.CharField(blank=True, default='', max_length=256, verbose_name=_("Departure city")) - transport_city_return = models.CharField(blank=True, default='', max_length=256, verbose_name=_("Return city"), help_text=_("If different from departure city")) - transport_booked = models.BooleanField(default=False) - - accommodation = models.IntegerField(choices=ACCOMMODATION_CHOICES, verbose_name=_('Need accommodation?'), null=True, blank=True) - accommodation_booked = models.BooleanField(default=False) - - constraints = models.TextField(blank=True, verbose_name=_("Constraints")) - connector = models.ManyToManyField(Connector, verbose_name=_("I can output"), blank=True) - sound = models.BooleanField(_("I need sound"), default=False) - - videotaped = models.BooleanField(_("I'm ok to be recorded on video"), default=True) - video_licence = models.IntegerField(choices=enum_to_choices(LICENCES), default=2, verbose_name=_("Video licence")) - - notes = models.TextField(default='', blank=True, verbose_name=_("Notes"), help_text=_('This field is only visible by organizers.')) - orga = models.BooleanField(default=False) - - class Meta: - # A User can participe only once to a Conference (= Site) - unique_together = ('site', 'user') - - def __str__(self): - return str(self.user.profile) - - def get_absolute_url(self): - return reverse('show-participant', kwargs={'username': self.user.username}) - - def is_orga(self): - return self.orga - - def is_staff(self): - return self.is_orga() or self.topic_set.exists() or self.track_set.exists() - - @property - def topic_set(self): - return self.user.topic_set.filter(site=self.site) - - @property - def track_set(self): - return self.user.track_set.filter(site=self.site) - - @property - def talk_set(self): - return self.user.talk_set.filter(site=self.site) - - @property - def accepted_talk_set(self): - return self.talk_set.filter(accepted=True) - @property - def pending_talk_set(self): - return self.talk_set.filter(accepted=None) - @property - def refused_talk_set(self): - return self.talk_set.filter(accepted=False) - @property - def not_refused_talk_set(self): # accepted + pending - return self.talk_set.exclude(accepted=False) - - # return True, False or None if availabilities have not been filled - def is_available(self, start, end=None): - if not self.availabilities.exists(): - return None - for timeslot in self.availabilities.all(): - if start < timeslot.start: - continue - if start > timeslot.end: - continue - if end: - assert(start < end) - if end > timeslot.end: - continue - return True - return False - - -class AvailabilityTimeslot(models.Model): - - participation = models.ForeignKey(Participation, related_name='availabilities') - start = models.DateTimeField(blank=True) - end = models.DateTimeField(blank=True) diff --git a/accounts/signals.py b/accounts/signals.py deleted file mode 100644 index 85726f9..0000000 --- a/accounts/signals.py +++ /dev/null @@ -1,45 +0,0 @@ -from django.contrib import messages -from django.contrib.auth.models import User -from django.contrib.auth.signals import user_logged_in, user_logged_out -from django.contrib.sites.shortcuts import get_current_site -from django.db.models.signals import post_save -from django.dispatch import receiver -from django.utils.translation import ugettext_lazy as _ -from django.utils.translation import ugettext_noop - -from ponyconf.decorators import disable_for_loaddata - -from .models import Connector, Participation, Profile, Transport - - -def create_default_options(sender, **kwargs): - Transport.objects.get_or_create(name=ugettext_noop('Train')) - Transport.objects.get_or_create(name=ugettext_noop('Plane')) - Transport.objects.get_or_create(name=ugettext_noop('Carpooling')) - Connector.objects.get_or_create(name=ugettext_noop('VGA')) - Connector.objects.get_or_create(name=ugettext_noop('HDMI')) - Connector.objects.get_or_create(name=ugettext_noop('miniDP')) - Connector.objects.get_or_create(name=ugettext_noop('I need a computer')) - - -@receiver(user_logged_in) -def on_user_logged_in(sender, request, user, **kwargs): - participation, created = Participation.objects.get_or_create(user=user, site=get_current_site(request)) - if user.is_superuser: - participation.orga = True - participation.save() - if created: - messages.info(request, "Please check your profile!\n", fail_silently=True) # FIXME - messages.success(request, _('Welcome!'), fail_silently=True) # FIXME - - -@receiver(user_logged_out) -def on_user_logged_out(sender, request, **kwargs): - messages.success(request, _('Goodbye!'), fail_silently=True) # FIXME - - -@receiver(post_save, sender=User, weak=False, dispatch_uid='create_profile') -@disable_for_loaddata -def create_profile(sender, instance, created, **kwargs): - if created: - Profile.objects.create(user=instance) diff --git a/accounts/templates/accounts/participant_details.html b/accounts/templates/accounts/participant_details.html deleted file mode 100644 index 581fc52..0000000 --- a/accounts/templates/accounts/participant_details.html +++ /dev/null @@ -1,76 +0,0 @@ -{% extends 'staff.html' %} - -{% load accounts_tags i18n avatar_tags %} - -{% block participantstab %} class="active"{% endblock %} - -{% block content %} - -

{{ profile }}

- -{% if request|staff %} -{% trans "Contact" %} -{% endif %} -{% if request|edit_profile:profile %} -{% trans "Edit" %} -{% endif %} - - - -

{% trans "Biography" %}

-

{{ profile.biography }}

- -{% if request|staff %} -

{{ profile.user.email }}

-{% endif %} - -

{% trans "Talks" %}

-{% include "proposals/_talk_list.html" %} - -{% if request|edit_profile:profile %} - -

{% trans "Information" %}

- -

{% trans "Travels and hosting" %}

- - - -

{% trans "Talk needs" %}

- - - -

{% trans "Constraints" %}

-

{{ participation.constraints|linebreaksbr }}

- -

{% trans "Notes" %}

-

{{ participation.notes|linebreaksbr }}

- -{% endif %} - - -{% endblock %} diff --git a/accounts/templates/accounts/participant_edit.html b/accounts/templates/accounts/participant_edit.html deleted file mode 100644 index d2eb024..0000000 --- a/accounts/templates/accounts/participant_edit.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends 'staff.html' %} - -{% load i18n %} - -{% block participantstab %} class="active"{% endblock %} - -{% block content %} - -
-
-

{% blocktrans %}{{ profile }}'s profile{% endblocktrans %}

-
-
- {% include "_form.html" %} -
-
- -{% endblock %} diff --git a/accounts/templates/accounts/participant_list.html b/accounts/templates/accounts/participant_list.html deleted file mode 100644 index ee07385..0000000 --- a/accounts/templates/accounts/participant_list.html +++ /dev/null @@ -1,62 +0,0 @@ -{% extends 'staff.html' %} - -{% load bootstrap3 accounts_tags i18n %} - -{% block participantstab %} class="active"{% endblock %} - -{% block content %} - -

{% trans "Participants" %}

- - - - - - - - - - {% for participation in participation_list %} - - - - - - - - {% endfor %} -
UsernameFull nameOrgaReviewsConversations
{{ participation.user.username }}{{ participation.user.get_full_name }}{{ participation.is_orga|yesno:"✔,✘" }}{% for topic in participation.topic_set.all %}{{ topic.get_link }}{% if not forloop.last %}, - {% endif %}{% endfor %} - - {% if request.user in participation.conversation.subscribers.all %} - - - {% else %} - - - {% endif %} -
- -{% if request|orga %} -{% include "_form.html" %} -{% endif %} - -{% endblock %} - -{% block css %} -{{ block.css }} -{{ form.media.css }} -{% endblock %} - -{% block js_end %} -{{ block.super }} -{{ form.media.js }} - -{% endblock %} diff --git a/accounts/templates/accounts/profile.html b/accounts/templates/accounts/profile.html deleted file mode 100644 index 6eb8e8d..0000000 --- a/accounts/templates/accounts/profile.html +++ /dev/null @@ -1,65 +0,0 @@ -{% extends 'base.html' %} - -{% load bootstrap3 i18n avatar_tags %} - -{% block profiletab %} class="active"{% endblock %} - - -{% block content %} - -
-
-

{% trans "Profile" %}

-
-
-
- {% csrf_token %} -
- - {% avatar request.user %} - {% trans "Change avatar" %} -
- {% bootstrap_form user_form layout="horizontal" %} - {% bootstrap_form profile_form layout="horizontal" %} - {% bootstrap_field participation_form.need_transport layout="horizontal" %} -
- {% bootstrap_field participation_form.transport layout="horizontal" %} - {% bootstrap_field participation_form.transport_city_outward layout="horizontal" %} - {% bootstrap_field participation_form.transport_city_return layout="horizontal" %} -
- {% bootstrap_form participation_form exclude="need_transport,transport,transport_city_outward,transport_city_return" layout="horizontal" %} - {% buttons layout="horizontal" %} - - {% for url, class, text in buttons %} - {{ text }} - {% endfor %} - {% trans "Cancel" %} - {% endbuttons %} -
-
-
- -{% endblock %} - -{% block css %} -{{ block.super }} -{{ form.media.css }} -{% endblock %} - -{% block js_end %} -{{ block.super }} -{{ form.media.js }} - -{% endblock %} diff --git a/accounts/templates/avatar/add.html b/accounts/templates/avatar/add.html deleted file mode 100644 index ad0bcd3..0000000 --- a/accounts/templates/avatar/add.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "avatar/base.html" %} -{% load i18n avatar_tags %} - -{% block avatarcontent %} -

{% trans "Your current avatar: " %}

- {% avatar user %} - {% if not avatars %} -

{% trans "You haven't uploaded an avatar yet. Please upload one now." %}

- {% endif %} -
- {{ upload_avatar_form.as_p }} -

{% csrf_token %}

-
-{% endblock %} diff --git a/accounts/templates/avatar/avatar_tag.html b/accounts/templates/avatar/avatar_tag.html deleted file mode 100644 index 79f3e61..0000000 --- a/accounts/templates/avatar/avatar_tag.html +++ /dev/null @@ -1 +0,0 @@ -{{ alt }} \ No newline at end of file diff --git a/accounts/templates/avatar/base.html b/accounts/templates/avatar/base.html deleted file mode 100644 index efe9906..0000000 --- a/accounts/templates/avatar/base.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends 'base.html' %} - -{% load i18n %} - -{% block profiletab %} class="active"{% endblock %} - -{% block content %} - -
-
-

{% trans "Avatar" %} - {% trans "Back to profile" %} -

-
-
- {% block avatarcontent %}{% endblock %} -
-
- -{% endblock %} diff --git a/accounts/templates/avatar/change.html b/accounts/templates/avatar/change.html deleted file mode 100644 index 13755f2..0000000 --- a/accounts/templates/avatar/change.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends "avatar/base.html" %} -{% load i18n avatar_tags %} - -{% block avatarcontent %} -

{% trans "Your current avatar: " %}

- {% avatar user %} - {% if not avatars %} -

{% trans "You haven't uploaded an avatar yet. Please upload one now." %}

- {% else %} -
- -

{% csrf_token %}

-
- {% endif %} -
- {{ upload_avatar_form.as_p }} -

{% csrf_token %}

-
-{% endblock %} diff --git a/accounts/templates/avatar/confirm_delete.html b/accounts/templates/avatar/confirm_delete.html deleted file mode 100644 index 8f9ad4d..0000000 --- a/accounts/templates/avatar/confirm_delete.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "avatar/base.html" %} -{% load i18n %} - -{% block avatarcontent %} -

{% trans "Please select the avatars that you would like to delete." %}

- {% if not avatars %} - {% url 'avatar_change' as avatar_change_url %} -

{% blocktrans %}You have no avatars to delete. Please upload one now.{% endblocktrans %}

- {% else %} -
- -

{% csrf_token %}

-
- {% endif %} -{% endblock %} diff --git a/accounts/templates/registration/login.html b/accounts/templates/registration/login.html deleted file mode 100644 index 8566b0f..0000000 --- a/accounts/templates/registration/login.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends 'base.html' %} - -{% load bootstrap3 i18n %} - -{% block logintab %} class="active"{% endblock %} - -{% block content %} - - - -
-
-
- {% include '_form.html' %} -
-
-
- -
-
- {% url 'registration_register' as reg_url %} - {% blocktrans %}You do not have an account yet? Please register.{% endblocktrans %} -
-
- -{% endblock %} diff --git a/accounts/templates/registration/password_reset_form.html b/accounts/templates/registration/password_reset_form.html deleted file mode 100644 index 7290203..0000000 --- a/accounts/templates/registration/password_reset_form.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends 'base.html' %} - -{% load bootstrap3 i18n %} - -{% block content %} - - - -
-
-
- {% include "_form.html" %} -
-
-
- -{% endblock %} diff --git a/accounts/templates/registration/registration_form.html b/accounts/templates/registration/registration_form.html deleted file mode 100644 index 792b357..0000000 --- a/accounts/templates/registration/registration_form.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends 'base.html' %} - -{% load bootstrap3 i18n %} - -{% block registrationtab %} class="active"{% endblock %} - -{% block content %} - - - -
-
-
- {% include '_form.html' %} -
-
-
- -
-
- {% url 'login' as login_url %} - {% blocktrans %}You already have an account? Please login.{% endblocktrans %} -
-
- -{% endblock %} diff --git a/accounts/templatetags/accounts_tags.py b/accounts/templatetags/accounts_tags.py deleted file mode 100644 index aa19b1c..0000000 --- a/accounts/templatetags/accounts_tags.py +++ /dev/null @@ -1,20 +0,0 @@ -from django import template - -from accounts.utils import can_edit_profile, is_orga, is_staff - -register = template.Library() - - -@register.filter -def orga(request): - return is_orga(request, request.user) - - -@register.filter -def staff(request): - return is_staff(request, request.user) - - -@register.filter -def edit_profile(request, profile): - return can_edit_profile(request, profile) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index ba43320..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,108 +0,0 @@ -from django.contrib.auth.models import User -from django.contrib.sites.models import Site -from django.core.urlresolvers import reverse -from django.test import TestCase - -from .models import Participation, Profile - -ROOT_URL = 'accounts' - - -class AccountTests(TestCase): - def setUp(self): - a, b, c = (User.objects.create_user(guy, email='%s@example.org' % guy, password=guy) for guy in 'abc') - Participation.objects.create(user=a, site=Site.objects.first()) - Participation.objects.create(user=b, site=Site.objects.first()) - Participation.objects.create(user=c, site=Site.objects.first(), orga=True) - - def test_models(self): - self.assertEqual(Profile.objects.count(), 3) - self.client.login(username='c', password='c') - for model in [Profile, Participation]: - item = model.objects.first() - self.assertEqual(self.client.get(item.full_link()).status_code, 200) - self.assertTrue(str(item)) - - def test_views(self): - # User b wants to update its username, email and biography - user = User.objects.get(username='b') - self.assertEqual(user.email, 'b@example.org') - self.assertEqual(user.profile.biography, '') - - self.client.login(username='b', password='b') - - # He tries with an invalid address - self.client.post(reverse('profile'), {'email': 'bnewdomain.com', 'username': 'z', 'biography': 'tester', - 'video_licence': 1}) - self.assertEqual(User.objects.filter(username='z').count(), 0) - - self.client.post(reverse('profile'), {'email': 'b@newdomain.com', 'username': 'z', 'biography': 'tester', - 'video_licence': 1}) - - user = User.objects.get(username='z') - self.assertEqual(user.email, 'b@newdomain.com') - self.assertEqual(user.profile.biography, 'tester') - self.client.logout() - - def test_participant_views(self): - self.assertEqual(self.client.get(reverse('registration_register')).status_code, 200) - self.client.login(username='b', password='b') - self.assertEqual(self.client.get(reverse('list-participants')).status_code, 403) - self.assertEqual(self.client.post(reverse('edit-participant', kwargs={'username': 'a'}), - {'biography': 'foo'}).status_code, 403) - b = User.objects.get(username='b') - b.is_superuser = True - b.save() - p = Participation.objects.get(user=b) - self.assertFalse(p.orga) - self.assertEqual(self.client.get(reverse('list-participants')).status_code, 403) - # login signal should set orga to True due to superuser status - self.client.login(username='b', password='b') - p = Participation.objects.get(user=b) - self.assertTrue(p.orga) - self.assertEqual(self.client.get(reverse('list-participants')).status_code, 200) - self.assertEqual(self.client.post(reverse('edit-participant', kwargs={'username': 'a'}), - {'biography': 'foo', 'nootes': 'bar'}).status_code, 200) - self.assertEqual(User.objects.get(username='a').profile.biography, '') - self.assertEqual(self.client.post(reverse('edit-participant', kwargs={'username': 'a'}), - {'biography': 'foo', 'notes': 'bar', 'first_name': 'Jules', 'username': 'a', - 'last_name': 'César', 'email': 'a@example.org', 'transport': 1, - 'connector': 1, 'video_licence': 2, 'constraints': 'nope', 'orga': 0, - }).status_code, 200) - self.assertEqual(User.objects.get(username='a').profile.biography, 'foo') - self.assertEqual(Participation.objects.get(user=User.objects.get(username='a')).video_licence, 2) - - -from datetime import datetime -from .models import AvailabilityTimeslot -class DisponibilitiesTests(TestCase): - - def setUp(self): - self.user = User.objects.create_user('a', email='a@example.org', password='a') - self.participation = Participation.objects.create(user=self.user, site=Site.objects.first()) - - def test_is_available(self): - from django.utils.timezone import is_naive, get_default_timezone - tz = get_default_timezone() - d = {} - for i in range(8, 18, 1): - d[i] = datetime(2016, 10, 10, i, 0, 0, tzinfo=tz) - self.assertEquals(self.participation.is_available(d[10]), None) - AvailabilityTimeslot.objects.create(participation=self.participation, start=d[10], end=d[12]) - self.assertEquals(self.participation.is_available(d[9]), False) - self.assertEquals(self.participation.is_available(d[11]), True) - self.assertEquals(self.participation.is_available(d[13]), False) - self.assertEquals(self.participation.is_available(d[8], d[9]), False) - self.assertEquals(self.participation.is_available(d[9], d[11]), False) - self.assertEquals(self.participation.is_available(d[10], d[11]), True) - self.assertEquals(self.participation.is_available(d[11], d[12]), True) - self.assertEquals(self.participation.is_available(d[10], d[12]), True) - self.assertEquals(self.participation.is_available(d[11], d[13]), False) - self.assertEquals(self.participation.is_available(d[13], d[14]), False) - AvailabilityTimeslot.objects.create(participation=self.participation, start=d[14], end=d[16]) - self.assertEquals(self.participation.is_available(d[10], d[12]), True) - self.assertEquals(self.participation.is_available(d[14], d[16]), True) - self.assertEquals(self.participation.is_available(d[11], d[15]), False) - self.assertEquals(self.participation.is_available(d[11], d[17]), False) - self.assertEquals(self.participation.is_available(d[13], d[17]), False) - self.assertEquals(self.participation.is_available(d[9], d[15]), False) diff --git a/accounts/urls.py b/accounts/urls.py deleted file mode 100644 index 4f722ea..0000000 --- a/accounts/urls.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.conf import settings -from django.conf.urls import include, url -from django.contrib.auth import views as auth_views - -from . import views - -urlpatterns = [ - url(r'^profile/$', views.profile, name='profile'), - url(r'^login/$', auth_views.login, {'extra_context': {'buttons': [views.RESET_PASSWORD_BUTTON]}}, name='login'), - url(r'^logout/$', auth_views.logout, {'next_page': settings.LOGOUT_REDIRECT_URL}, name='logout'), - url(r'^participant/$', views.participation_list, name='list-participants'), - url(r'^participant/(?P[\w.@+-]+)$', views.participant_details, name='show-participant'), - url(r'^participant/(?P[\w.@+-]+)/edit/$', views.participant_edit, name='edit-participant'), - url(r'^avatar/', include('avatar.urls')), - url(r'', include('django.contrib.auth.urls')), - url(r'', include('registration.backends.default.urls')), -] diff --git a/accounts/utils.py b/accounts/utils.py deleted file mode 100644 index 8854676..0000000 --- a/accounts/utils.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.contrib.sites.shortcuts import get_current_site -from django.utils.crypto import get_random_string - - -def generate_user_uid(): - return get_random_string(length=12, allowed_chars='abcdefghijklmnopqrstuvwxyz0123456789') - - -def is_orga(request, user): - return user.is_authenticated and user.participation_set.get(site=get_current_site(request)).is_orga() - - -def is_staff(request, user): - return user.is_authenticated and user.participation_set.get(site=get_current_site(request)).is_staff() - - -def can_edit_profile(request, profile): - editor = request.user.participation_set.get(site=get_current_site(request)) - return editor.is_orga() or editor.topic_set.filter(talk__speakers=profile.user).exists() diff --git a/accounts/views.py b/accounts/views.py deleted file mode 100644 index da2bfd6..0000000 --- a/accounts/views.py +++ /dev/null @@ -1,102 +0,0 @@ -from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.contrib.sites.shortcuts import get_current_site -from django.core.exceptions import PermissionDenied -from django.core.urlresolvers import reverse -from django.shortcuts import get_object_or_404, redirect, render -from django.utils.translation import ugettext_lazy as _ - -from .decorators import staff_required -from .forms import (NewParticipationForm, ParticipationForm, - ParticipationOrgaForm, ProfileForm, ProfileOrgaForm, UserForm) -from .models import Participation, Profile, User -from .utils import can_edit_profile, is_orga - -from proposals.models import Talk -from proposals.utils import allowed_talks - -RESET_PASSWORD_BUTTON = ('password_reset', 'warning', _('Reset your password')) -CHANGE_PASSWORD_BUTTON = ('password_change', 'warning', _('Change password')) -CHANGE_AVATAR_BUTTON = ('avatar_change', 'default', _('Change avatar')) - - -@login_required -def profile(request): - - user_form = UserForm(request.POST or None, instance=request.user) - profile_form = ProfileForm(request.POST or None, instance=request.user.profile) - participation_form = ParticipationForm(request.POST or None, instance=Participation.objects.get(site=get_current_site(request), - user=request.user)) - forms = [user_form, profile_form, participation_form] - - if request.method == 'POST': - if all(form.is_valid() for form in forms): - for form in forms: - form.save() - messages.success(request, _('Profile updated successfully.')) - else: - messages.error(request, _('Please correct those errors.')) - - return render(request, 'accounts/profile.html', { - 'user_form': user_form, - 'profile_form': profile_form, - 'participation_form': participation_form, - 'buttons': [CHANGE_PASSWORD_BUTTON] - }) - - -@login_required -def participant_edit(request, username): - - profile = get_object_or_404(Profile, user__username=username) - if not can_edit_profile(request, profile): - raise PermissionDenied() - - participation_form = ParticipationOrgaForm if is_orga(request, request.user) else ParticipationForm - forms = [UserForm(request.POST or None, instance=profile.user), - ProfileOrgaForm(request.POST or None, instance=profile), - participation_form(request.POST or None, - instance=Participation.objects.get(site=get_current_site(request), user=profile.user))] - - if request.method == 'POST': - if all(form.is_valid() for form in forms): - for form in forms: - form.save() - messages.success(request, _('Profile updated successfully.')) - else: - messages.error(request, _('Please correct those errors.')) - - return render(request, 'accounts/participant_edit.html', {'forms': forms, 'profile': profile}) - - -@staff_required -def participation_list(request): - participation_list = Participation.objects.filter(site=get_current_site(request)).all() - form = NewParticipationForm(request.POST or None, site=get_current_site(request)) - - if request.method == 'POST' and form.is_valid(): - if not Participation.objects.get(user=request.user, site=get_current_site(request)).is_orga(): - raise PermissionDenied() - user = User.objects.get(username=form.cleaned_data['participant']) - participation, created = Participation.objects.get_or_create(user=user, site=get_current_site(request)) - if created: - messages.success(request, _("%(name)s added to participants") % {'name': user.profile}) - else: - messages.info(request, _("%(name)s is already a participant") % {'name': user.profile}) - return redirect(reverse('list-participants')) - - return render(request, 'accounts/participant_list.html', { - 'participation_list': participation_list, - 'form': form, - }) - - -@login_required -def participant_details(request, username): - user = get_object_or_404(User, username=username) - participation = get_object_or_404(Participation, user=user, site=get_current_site(request)) - return render(request, 'accounts/participant_details.html', { - 'profile': user.profile, - 'participation': participation, - 'talk_list': allowed_talks(Talk.objects.filter(site=get_current_site(request), speakers=user), request), - }) diff --git a/cfp/__init__.py b/cfp/__init__.py new file mode 100644 index 0000000..9d019f2 --- /dev/null +++ b/cfp/__init__.py @@ -0,0 +1 @@ +default_app_config = 'cfp.apps.CFPConfig' diff --git a/cfp/admin.py b/cfp/admin.py new file mode 100644 index 0000000..dc2d484 --- /dev/null +++ b/cfp/admin.py @@ -0,0 +1,43 @@ +from django.contrib import admin +from django.contrib.sites.models import Site + +from ponyconf.admin import SiteAdminMixin +from .models import Conference, Participant, Talk, TalkCategory, Track, Vote + + +class ConferenceAdmin(SiteAdminMixin, admin.ModelAdmin): + filter_horizontal = ('staff',) + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False + + +class ParticipantAdmin(SiteAdminMixin, admin.ModelAdmin): + pass + + +class TrackAdmin(SiteAdminMixin, admin.ModelAdmin): + pass + + +class TalkCategoryAdmin(SiteAdminMixin, admin.ModelAdmin): + pass + + +class TalkAdmin(SiteAdminMixin, admin.ModelAdmin): + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + form.base_fields['speakers'].queryset = Participant.objects.filter(site=request.conference.site) + form.base_fields['track'].queryset = Track.objects.filter(site=request.conference.site) + form.base_fields['category'].queryset = TalkCategory.objects.filter(site=request.conference.site) + return form + + +admin.site.register(Conference, ConferenceAdmin) +admin.site.register(Participant, ParticipantAdmin) +admin.site.register(Talk, TalkAdmin) +admin.site.register(TalkCategory, TalkCategoryAdmin) +admin.site.register(Vote) diff --git a/cfp/apps.py b/cfp/apps.py new file mode 100644 index 0000000..e9f8694 --- /dev/null +++ b/cfp/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig +from django.db.models.signals import post_migrate + + +class CFPConfig(AppConfig): + name = 'cfp' + + def ready(self): + import cfp.signals # noqa + post_migrate.connect(cfp.signals.call_first_site_post_save, sender=self) diff --git a/cfp/context_processors.py b/cfp/context_processors.py new file mode 100644 index 0000000..4a24df3 --- /dev/null +++ b/cfp/context_processors.py @@ -0,0 +1,4 @@ + + +def conference(request): + return {'conference': request.conference} diff --git a/accounts/decorators.py b/cfp/decorators.py similarity index 55% rename from accounts/decorators.py rename to cfp/decorators.py index 027a5b6..a5ca0ac 100644 --- a/accounts/decorators.py +++ b/cfp/decorators.py @@ -3,18 +3,7 @@ from functools import wraps from django.core.exceptions import PermissionDenied from django.contrib.auth.decorators import login_required -from accounts.utils import is_orga, is_staff - - -def orga_required(view_func): - def _is_orga(request, *args, **kwargs): - if not request.user.is_authenticated(): - return login_required(view_func)(request, *args, **kwargs) - elif is_orga(request, request.user): - return view_func(request, *args, **kwargs) - else: - raise PermissionDenied - return wraps(view_func)(_is_orga) +from cfp.utils import is_staff def staff_required(view_func): diff --git a/cfp/forms.py b/cfp/forms.py new file mode 100644 index 0000000..efb5122 --- /dev/null +++ b/cfp/forms.py @@ -0,0 +1,191 @@ +from django import forms +from django.forms.models import modelform_factory +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import User +from django.contrib.auth.forms import UsernameField +from django.utils.translation import ugettext_lazy as _ +from django.template.defaultfilters import slugify +from django.utils.crypto import get_random_string + +from django_select2.forms import ModelSelect2MultipleWidget + +from .models import Participant, Talk, TalkCategory, Track, Conference, Room + + +STATUS_CHOICES = [ + ('pending', _('Pending decision')), + ('accepted', _('Accepted')), + ('declined', _('Declined')), +] +STATUS_VALUES = [ + ('pending', None), + ('accepted', True), + ('declined', False), +] + + +class TalkForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + categories = kwargs.pop('categories') + super().__init__(*args, **kwargs) + if categories.exists(): + self.fields['category'].queryset = categories + else: + del self.fields['category'] + + class Meta: + model = Talk + fields = ('category', 'title', 'description','notes') + + +class TalkStaffForm(TalkForm): + def __init__(self, *args, **kwargs): + tracks = kwargs.pop('tracks') + super().__init__(*args, **kwargs) + self.fields['track'].queryset = tracks + + class Meta(TalkForm.Meta): + fields = ('category', 'track', 'title', 'description', 'notes', 'start_date', 'duration', 'room',) + labels = { + 'category': _('Category'), + 'title': _('Title'), + 'description': _('Description'), + 'notes': _('Notes'), + } + help_texts = { + 'notes': _('Visible by speakers'), + } + + +class TalkFilterForm(forms.Form): + category = forms.MultipleChoiceField( + label=_('Category'), + required=False, + widget=forms.CheckboxSelectMultiple, + choices=[], + ) + status = forms.MultipleChoiceField( + label=_('Status'), + required=False, + widget=forms.CheckboxSelectMultiple, + choices=STATUS_CHOICES, + ) + track = forms.MultipleChoiceField( + label=_('Track'), + required=False, + widget=forms.CheckboxSelectMultiple, + choices=[], + ) + vote = forms.NullBooleanField( + label=_('Vote'), + help_text=_('Filter talks you already / not yet voted for'), + ) + room = forms.NullBooleanField( + label=_('Room'), + help_text=_('Filter talks already / not yet affected to a room'), + ) + scheduled = forms.NullBooleanField( + label=_('Scheduled'), + help_text=_('Filter talks already / not yet scheduled'), + ) + + def __init__(self, *args, **kwargs): + site = kwargs.pop('site') + super().__init__(*args, **kwargs) + categories = TalkCategory.objects.filter(site=site) + self.fields['category'].choices = categories.values_list('pk', 'name') + tracks = Track.objects.filter(site=site) + self.fields['track'].choices = [('none', _('Not assigned'))] + list(tracks.values_list('slug', 'name')) + + +ParticipantForm = modelform_factory(Participant, fields=('name', 'email', 'biography')) + + +class ParticipantStaffForm(ParticipantForm): + class Meta(ParticipantForm.Meta): + labels = { + 'name': _('Name'), + } + + +class UsersWidget(ModelSelect2MultipleWidget): + model = User + search_fields = [ '%s__icontains' % field for field in UserAdmin.search_fields ] + + +class ConferenceForm(forms.ModelForm): + class Meta: + model = Conference + fields = ['name', 'home', 'venue', 'city', 'contact_email', 'reply_email', 'secure_domain', 'staff',] + widgets = { + 'staff': UsersWidget(), + } + help_texts = { + 'staff': _('New staff members will be informed of their new position by e-mail.'), + } + + +class CreateUserForm(forms.ModelForm): + class Meta: + model = User + fields = ("first_name", "last_name", "email") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['first_name'].required = True + self.fields['last_name'].required = True + self.fields['email'].required = True + + def clean(self): + super().clean() + user = User(first_name=self.cleaned_data.get('first_name'), last_name=self.cleaned_data.get('last_name')) + username = slugify(user.get_full_name()) + if User.objects.filter(username=username).exists(): + raise forms.ValidationError(_('An user with that firstname and that lastname already exists.')) + + def clean_email(self): + email = self.cleaned_data.get('email') + if email and User.objects.filter(email=email).exists(): + raise forms.ValidationError(_('A user with that email already exists.')) + return email + + def save(self, commit=True): + user = super().save(commit=False) + user.username = slugify(user.get_full_name()) + user.set_password(get_random_string(length=32)) + if commit: + user.save() + return user + + +class OnSiteNamedModelForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + self.conference = kwargs.pop('conference') + super().__init__(*args, **kwargs) + + # we should manually check (site, name) uniqueness as the site is not part of the form + def clean_name(self): + name = self.cleaned_data['name'] + if (not self.instance or self.instance.name != name) \ + and Track.objects.filter(site=self.conference.site, name=name).exists(): + raise self.instance.unique_error_message(self._meta.model, ['name']) + return name + + def save(self, commit=True): + obj = super().save(commit=False) + obj.site = self.conference.site + if commit: + obj.save() + return obj + + +class TrackForm(OnSiteNamedModelForm): + class Meta: + model = Track + fields = ['name', 'description'] + + +class RoomForm(OnSiteNamedModelForm): + class Meta: + model = Room + fields = ['name', 'label', 'capacity'] diff --git a/cfp/middleware.py b/cfp/middleware.py new file mode 100644 index 0000000..61501dc --- /dev/null +++ b/cfp/middleware.py @@ -0,0 +1,16 @@ +from django.contrib.sites.shortcuts import get_current_site + +from .models import Conference + + +class ConferenceMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + return self.get_response(request) + + def process_view(self, request, view, view_args, view_kwargs): + site = get_current_site(request) + conf = Conference.objects.select_related('site').prefetch_related('staff').get(site=site) + request.conference = conf diff --git a/proposals/migrations/0001_initial.py b/cfp/migrations/0001_initial.py similarity index 58% rename from proposals/migrations/0001_initial.py rename to cfp/migrations/0001_initial.py index 8cb8222..9d3376d 100644 --- a/proposals/migrations/0001_initial.py +++ b/cfp/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.3 on 2017-01-13 10:49 +# Generated by Django 1.11 on 2017-06-06 22:14 from __future__ import unicode_literals import autoslug.fields @@ -8,7 +8,7 @@ from django.conf import settings import django.core.validators from django.db import migrations, models import django.db.models.deletion -import proposals.models +import uuid class Migration(migrations.Migration): @@ -16,39 +16,72 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('planning', '0001_initial'), ('sites', '0002_alter_domain_unique'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ - migrations.CreateModel( - name='Attendee', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('name', models.CharField(blank=True, default='', max_length=64)), - ('email', models.EmailField(blank=True, default='', max_length=254)), - ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - }, - ), migrations.CreateModel( name='Conference', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, max_length=100)), ('home', models.TextField(blank=True, default='')), ('venue', models.TextField(blank=True, default='')), ('city', models.CharField(blank=True, default='', max_length=64)), - ('subscriptions_open', models.BooleanField(default=False)), + ('contact_email', models.CharField(blank=True, max_length=100)), + ('custom_css', models.TextField(blank=True)), + ('external_css_link', models.URLField(blank=True)), ('site', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='sites.Site')), ], ), migrations.CreateModel( - name='Event', + name='Participant', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=128, verbose_name='Your Name')), + ('email', models.EmailField(max_length=254)), + ('biography', models.TextField(verbose_name='Biography')), + ('token', models.UUIDField(default=uuid.uuid4, editable=False)), + ('twitter', models.CharField(blank=True, default='', max_length=100, verbose_name='Twitter')), + ('linkedin', models.CharField(blank=True, default='', max_length=100, verbose_name='LinkedIn')), + ('github', models.CharField(blank=True, default='', max_length=100, verbose_name='Github')), + ('website', models.CharField(blank=True, default='', max_length=100, verbose_name='Website')), + ('facebook', models.CharField(blank=True, default='', max_length=100, verbose_name='Facebook')), + ('mastodon', models.CharField(blank=True, default='', max_length=100, verbose_name='Mastodon')), + ('phone_number', models.CharField(blank=True, default='', max_length=64, verbose_name='Phone number')), + ('language', models.CharField(blank=True, max_length=10)), + ('notes', models.TextField(blank=True, default='', help_text='This field is only visible by organizers.', verbose_name='Notes')), + ('vip', models.BooleanField(default=False)), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.Site')), + ], + ), + migrations.CreateModel( + name='Talk', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=128, verbose_name='Talk Title')), + ('slug', autoslug.fields.AutoSlugField(editable=False, populate_from='title', unique=True)), + ('description', models.TextField(verbose_name='Description of your talk')), + ('notes', models.TextField(blank=True, help_text='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 here', verbose_name='Message to organizers')), + ('videotaped', models.BooleanField(default=True, verbose_name="I'm ok to be recorded on video")), + ('video_licence', models.CharField(choices=[('CC-Zero CC-BY', 'CC-Zero CC-BY'), ('CC-BY-SA', 'CC-BY-SA'), ('CC-BY-ND', 'CC-BY-ND'), ('CC-BY-NC', 'CC-BY-NC'), ('CC-BY-NC-SA', 'CC-BY-NC-SA'), ('CC-BY-NC-ND', 'CC-BY-NC-ND')], default='CC-BY-SA', max_length=10, verbose_name='Video licence')), + ('sound', models.BooleanField(default=False, verbose_name='I need sound')), + ('accepted', models.NullBooleanField(default=None)), + ('duration', models.PositiveIntegerField(default=0, verbose_name='Duration (min)')), + ('plenary', models.BooleanField(default=False)), + ('token', models.UUIDField(default=uuid.uuid4, editable=False)), + ], + options={ + 'ordering': ('category__id', 'title'), + }, + ), + migrations.CreateModel( + name='TalkCategory', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=64)), @@ -60,51 +93,11 @@ class Migration(migrations.Migration): ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.Site')), ], options={ + 'verbose_name': 'category', + 'verbose_name_plural': 'categories', 'ordering': ('pk',), }, ), - migrations.CreateModel( - name='Talk', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('title', models.CharField(help_text='After submission, title can only be changed by the staff.', max_length=128, verbose_name='Title')), - ('slug', autoslug.fields.AutoSlugField(editable=False, populate_from='title', unique=True)), - ('abstract', models.CharField(blank=True, max_length=255, verbose_name='Abstract')), - ('description', models.TextField(blank=True, verbose_name='Description')), - ('notes', models.TextField(blank=True, verbose_name='Notes')), - ('accepted', models.NullBooleanField(default=None)), - ('start_date', models.DateTimeField(blank=True, default=None, null=True)), - ('duration', models.PositiveIntegerField(default=0, verbose_name='Duration (min)')), - ('plenary', models.BooleanField(default=False)), - ('registration_required', models.BooleanField(default=False)), - ('attendees_limit', models.PositiveIntegerField(default=0, verbose_name='Max. number of attendees')), - ('materials', models.FileField(help_text='You can use this field to share some materials related to your intervention.', null=True, upload_to=proposals.models.talk_materials_destination, verbose_name='Materials')), - ('attendees', models.ManyToManyField(to='proposals.Attendee', verbose_name='Attendees')), - ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='proposals.Event', verbose_name='Intervention kind')), - ('proposer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), - ('room', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='planning.Room')), - ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.Site')), - ('speakers', models.ManyToManyField(to=settings.AUTH_USER_MODEL, verbose_name='Speakers')), - ], - options={ - 'ordering': ('event__id',), - }, - ), - migrations.CreateModel( - name='Topic', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=128, verbose_name='Name')), - ('slug', autoslug.fields.AutoSlugField(editable=False, populate_from='name', unique=True)), - ('description', models.TextField(blank=True, verbose_name='Description')), - ('reviewers', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL, verbose_name='Reviewers')), - ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.Site')), - ], - ), migrations.CreateModel( name='Track', fields=[ @@ -114,7 +107,6 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=128, verbose_name='Name')), ('slug', autoslug.fields.AutoSlugField(editable=False, populate_from='name')), ('description', models.TextField(blank=True, verbose_name='Description')), - ('managers', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL, verbose_name='Managers')), ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.Site')), ], ), @@ -125,24 +117,29 @@ class Migration(migrations.Migration): ('created', models.DateTimeField(auto_now_add=True)), ('updated', models.DateTimeField(auto_now=True)), ('vote', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(-2), django.core.validators.MaxValueValidator(2)])), - ('talk', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='proposals.Talk')), + ('talk', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cfp.Talk')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), migrations.AddField( - model_name='topic', - name='track', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='proposals.Track', verbose_name='Destination track'), + model_name='talk', + name='category', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cfp.TalkCategory', verbose_name='Talk Category'), ), migrations.AddField( model_name='talk', - name='topics', - field=models.ManyToManyField(blank=True, help_text='The topics can not be changed after submission.', to='proposals.Topic', verbose_name='Topics'), + name='site', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.Site'), + ), + migrations.AddField( + model_name='talk', + name='speakers', + field=models.ManyToManyField(to='cfp.Participant', verbose_name='Speakers'), ), migrations.AddField( model_name='talk', name='track', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='proposals.Track', verbose_name='Track'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cfp.Track', verbose_name='Track'), ), migrations.AlterUniqueTogether( name='vote', @@ -153,11 +150,11 @@ class Migration(migrations.Migration): unique_together=set([('site', 'name')]), ), migrations.AlterUniqueTogether( - name='topic', + name='talkcategory', unique_together=set([('site', 'name')]), ), migrations.AlterUniqueTogether( - name='event', - unique_together=set([('site', 'name')]), + name='participant', + unique_together=set([('site', 'email')]), ), ] diff --git a/cfp/migrations/0002_conference_staff.py b/cfp/migrations/0002_conference_staff.py new file mode 100644 index 0000000..ddc6e91 --- /dev/null +++ b/cfp/migrations/0002_conference_staff.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-07-29 23:30 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('cfp', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='conference', + name='staff', + field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL, verbose_name='Staff'), + ), + ] diff --git a/cfp/migrations/0003_auto_20170801_1400.py b/cfp/migrations/0003_auto_20170801_1400.py new file mode 100644 index 0000000..8fc79c3 --- /dev/null +++ b/cfp/migrations/0003_auto_20170801_1400.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-08-01 14:00 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cfp', '0002_conference_staff'), + ] + + operations = [ + migrations.AlterField( + model_name='conference', + name='city', + field=models.CharField(blank=True, default='', max_length=64, verbose_name='City'), + ), + migrations.AlterField( + model_name='conference', + name='contact_email', + field=models.CharField(blank=True, max_length=100, verbose_name='Contact email'), + ), + migrations.AlterField( + model_name='conference', + name='home', + field=models.TextField(blank=True, default='', verbose_name='Homepage (markdown)'), + ), + migrations.AlterField( + model_name='conference', + name='name', + field=models.CharField(blank=True, max_length=100, verbose_name='Conference name'), + ), + migrations.AlterField( + model_name='conference', + name='staff', + field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL, verbose_name='Staff members'), + ), + migrations.AlterField( + model_name='conference', + name='venue', + field=models.TextField(blank=True, default='', verbose_name='Venue information'), + ), + ] diff --git a/cfp/migrations/0004_auto_20170801_1408.py b/cfp/migrations/0004_auto_20170801_1408.py new file mode 100644 index 0000000..62c3be9 --- /dev/null +++ b/cfp/migrations/0004_auto_20170801_1408.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-08-01 14:08 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +def generate_participant_conversation(apps, schema_editor): + MessageThread = apps.get_model("mailing", "MessageThread") + Participant = apps.get_model("cfp", "Participant") + db_alias = schema_editor.connection.alias + for participant in Participant.objects.using(db_alias).filter(conversation=None): + participant.conversation = MessageThread.objects.create() + participant.save() + + +def generate_talk_conversation(apps, schema_editor): + MessageThread = apps.get_model("mailing", "MessageThread") + Talk = apps.get_model("cfp", "Talk") + db_alias = schema_editor.connection.alias + for talk in Talk.objects.using(db_alias).filter(conversation=None): + talk.conversation = MessageThread.objects.create() + talk.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('mailing', '0001_initial'), + ('cfp', '0003_auto_20170801_1400'), + ] + + operations = [ + migrations.AddField( + model_name='participant', + name='conversation', + field=models.OneToOneField(null=True, default=None, on_delete=django.db.models.deletion.CASCADE, to='mailing.MessageThread'), + preserve_default=False, + ), + migrations.RunPython(generate_participant_conversation), + migrations.AlterField( + model_name='participant', + name='conversation', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='mailing.MessageThread'), + ), + migrations.AddField( + model_name='talk', + name='conversation', + field=models.OneToOneField(null=True, default=None, on_delete=django.db.models.deletion.CASCADE, to='mailing.MessageThread'), + preserve_default=False, + ), + migrations.RunPython(generate_talk_conversation), + migrations.AlterField( + model_name='talk', + name='conversation', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='mailing.MessageThread'), + ), + ] diff --git a/cfp/migrations/0005_conference_reply_email.py b/cfp/migrations/0005_conference_reply_email.py new file mode 100644 index 0000000..c69c183 --- /dev/null +++ b/cfp/migrations/0005_conference_reply_email.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-08-01 16:40 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cfp', '0004_auto_20170801_1408'), + ] + + operations = [ + migrations.AddField( + model_name='conference', + name='reply_email', + field=models.CharField(blank=True, max_length=100, verbose_name='Reply email'), + ), + ] diff --git a/cfp/migrations/0006_auto_20170811_1457.py b/cfp/migrations/0006_auto_20170811_1457.py new file mode 100644 index 0000000..f084e8c --- /dev/null +++ b/cfp/migrations/0006_auto_20170811_1457.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-08-11 14:57 +from __future__ import unicode_literals + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('cfp', '0005_conference_reply_email'), + ] + + operations = [ + migrations.AlterField( + model_name='participant', + name='token', + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + migrations.AlterField( + model_name='talk', + name='token', + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ] diff --git a/cfp/migrations/0007_conference_secure_domain.py b/cfp/migrations/0007_conference_secure_domain.py new file mode 100644 index 0000000..66e9705 --- /dev/null +++ b/cfp/migrations/0007_conference_secure_domain.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-08-11 21:18 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cfp', '0006_auto_20170811_1457'), + ] + + operations = [ + migrations.AddField( + model_name='conference', + name='secure_domain', + field=models.BooleanField(default=True, verbose_name='Secure domain'), + ), + ] diff --git a/planning/migrations/0001_initial.py b/cfp/migrations/0008_auto_20170811_2342.py similarity index 60% rename from planning/migrations/0001_initial.py rename to cfp/migrations/0008_auto_20170811_2342.py index fff5ab1..62effac 100644 --- a/planning/migrations/0001_initial.py +++ b/cfp/migrations/0008_auto_20170811_2342.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.3 on 2017-01-13 10:49 +# Generated by Django 1.11.3 on 2017-08-11 23:42 from __future__ import unicode_literals import autoslug.fields @@ -9,10 +9,9 @@ import django.db.models.deletion class Migration(migrations.Migration): - initial = True - dependencies = [ ('sites', '0002_alter_domain_unique'), + ('cfp', '0007_conference_secure_domain'), ] operations = [ @@ -30,6 +29,21 @@ class Migration(migrations.Migration): 'ordering': ['name'], }, ), + migrations.AddField( + model_name='talk', + name='start_date', + field=models.DateTimeField(blank=True, default=None, null=True, verbose_name='Beginning date and time'), + ), + migrations.AlterField( + model_name='conference', + name='secure_domain', + field=models.BooleanField(default=True, verbose_name='Secure domain (HTTPS)'), + ), + migrations.AddField( + model_name='talk', + name='room', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='cfp.Room'), + ), migrations.AlterUniqueTogether( name='room', unique_together=set([('site', 'name')]), diff --git a/accounts/migrations/__init__.py b/cfp/migrations/__init__.py similarity index 100% rename from accounts/migrations/__init__.py rename to cfp/migrations/__init__.py diff --git a/cfp/mixins.py b/cfp/mixins.py new file mode 100644 index 0000000..a916d61 --- /dev/null +++ b/cfp/mixins.py @@ -0,0 +1,13 @@ +from django.contrib.auth.mixins import UserPassesTestMixin + +from .utils import is_staff + + +class StaffRequiredMixin(UserPassesTestMixin): + def test_func(self): + return is_staff(self.request, self.request.user) + + +class OnSiteMixin: + def get_queryset(self): + return super().get_queryset().filter(site=self.request.conference.site) diff --git a/cfp/models.py b/cfp/models.py new file mode 100644 index 0000000..cd72dc9 --- /dev/null +++ b/cfp/models.py @@ -0,0 +1,347 @@ +from django.contrib.auth.models import User +from django.contrib.sites.models import Site +from django.core.urlresolvers import reverse +from django.core.validators import MaxValueValidator, MinValueValidator +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Q, Sum, Avg, Case, When +from django.db.models.functions import Coalesce +from django.utils import timezone +from django.utils.translation import ugettext, ugettext_lazy as _ + +from autoslug import AutoSlugField +from colorful.fields import RGBColorField + +import uuid +from datetime import timedelta + +from ponyconf.utils import PonyConfModel +from mailing.models import MessageThread + + + +class Conference(models.Model): + + site = models.OneToOneField(Site, on_delete=models.CASCADE) + name = models.CharField(blank=True, max_length=100, verbose_name=_('Conference name')) + home = models.TextField(blank=True, default="", verbose_name=_('Homepage (markdown)')) + venue = models.TextField(blank=True, default="", verbose_name=_('Venue information')) + city = models.CharField(max_length=64, blank=True, default="", verbose_name=_('City')) + contact_email = models.CharField(max_length=100, blank=True, verbose_name=_('Contact email')) + reply_email = models.CharField(max_length=100, blank=True, verbose_name=_('Reply email')) + staff = models.ManyToManyField(User, blank=True, verbose_name=_('Staff members')) + secure_domain = models.BooleanField(default=True, verbose_name=_('Secure domain (HTTPS)')) + + custom_css = models.TextField(blank=True) + external_css_link = models.URLField(blank=True) + + #subscriptions_open = models.BooleanField(default=False) # workshop subscription + + #def cfp_is_open(self): + # events = Event.objects.filter(site=self.site) + # return any(map(lambda x: x.is_open(), events)) + + @property + def opened_categories(self): + now = timezone.now() + return TalkCategory.objects.filter(site=self.site)\ + .filter(Q(opening_date__isnull=True) | Q(opening_date__lte=now))\ + .filter(Q(closing_date__isnull=True) | Q(closing_date__gte=now)) + + def from_email(self): + return self.name+' <'+self.contact_email+'>' + + def clean_fields(self, exclude=None): + super().clean_fields(exclude) + if self.reply_email is not None: + try: + self.reply_email.format(token='a' * 80) + except Exception: + raise ValidationError({ + 'reply_email': _('The reply email should be a formatable string accepting a token argument (e.g. ponyconf+{token}@exemple.com).'), + }) + + def __str__(self): + return str(self.site) + + +class ParticipantManager(models.Manager): + def get_queryset(self): + qs = super().get_queryset() + qs = qs.annotate( + accepted_talk_count=Sum(Case(When(talk__accepted=True, then=1), default=0, output_field=models.IntegerField())), + pending_talk_count=Sum(Case(When(talk__accepted=None, then=1), default=0, output_field=models.IntegerField())), + refused_talk_count=Sum(Case(When(talk__accepted=False, then=1), default=0, output_field=models.IntegerField())), + ) + return qs + + +class Participant(PonyConfModel): + + site = models.ForeignKey(Site, on_delete=models.CASCADE) + + name = models.CharField(max_length=128, verbose_name=_('Your Name')) + email = models.EmailField() + + biography = models.TextField(verbose_name=_('Biography')) + token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + + twitter = models.CharField(max_length=100, blank=True, default='', verbose_name=_('Twitter')) + linkedin = models.CharField(max_length=100, blank=True, default='', verbose_name=_('LinkedIn')) + github = models.CharField(max_length=100, blank=True, default='', verbose_name=_('Github')) + website = models.CharField(max_length=100, blank=True, default='', verbose_name=_('Website')) + facebook = models.CharField(max_length=100, blank=True, default='', verbose_name=_('Facebook')) + mastodon = models.CharField(max_length=100, blank=True, default='', verbose_name=_('Mastodon')) + + phone_number = models.CharField(max_length=64, blank=True, default='', verbose_name=_('Phone number')) + + language = models.CharField(max_length=10, blank=True) + + notes = models.TextField(default='', blank=True, verbose_name=_("Notes"), help_text=_('This field is only visible by organizers.')) + + vip = models.BooleanField(default=False) + + conversation = models.OneToOneField(MessageThread) + + objects = ParticipantManager() + + def get_absolute_url(self): + return reverse('participant-details', kwargs={'participant_id': self.token}) + + class Meta: + # A User can participe only once to a Conference (= Site) + unique_together = ('site', 'email') + + def __str__(self): + return str(self.name) + + @property + def accepted_talk_set(self): + return self.talk_set.filter(accepted=True) + @property + def pending_talk_set(self): + return self.talk_set.filter(accepted=None) + @property + def refused_talk_set(self): + return self.talk_set.filter(accepted=False) + + +class Track(PonyConfModel): + + site = models.ForeignKey(Site, on_delete=models.CASCADE) + + name = models.CharField(max_length=128, verbose_name=_('Name')) + slug = AutoSlugField(populate_from='name') + description = models.TextField(blank=True, verbose_name=_('Description')) + + #managers = models.ManyToManyField(User, blank=True, verbose_name=_('Managers')) + + class Meta: + unique_together = ('site', 'name') + + def estimated_duration(self): + return sum([talk.estimated_duration for talk in self.talk_set.all()]) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('talk-list') + '?track=%s' % self.slug + + +class Room(models.Model): + + site = models.ForeignKey(Site, on_delete=models.CASCADE) + slug = AutoSlugField(populate_from='name') + name = models.CharField(max_length=256, blank=True, default="") + label = models.CharField(max_length=256, blank=True, default="") + capacity = models.IntegerField(default=0) + + class Meta: + unique_together = ['site', 'name'] + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('room-details', kwargs={'slug': self.slug}) + + @property + def talks(self): + return self.talk_set.exclude(accepted=False) + + @property + def talks_by_date(self): + return self.talks.filter(start_date__isnull=False).exclude(duration=0, category__duration=0).order_by('start_date').all() + + @property + def unscheduled_talks(self): + return self.talks.filter(Q(start_date__isnull=True) | Q(duration=0, category__duration=0)).all() + + +class TalkCategory(models.Model): # type of talk (conf 30min, 1h, stand, …) + + site = models.ForeignKey(Site, on_delete=models.CASCADE) + name = models.CharField(max_length=64) + duration = models.PositiveIntegerField(default=0, verbose_name=_('Default duration (min)')) + color = RGBColorField(default='#ffffff', verbose_name=_("Color on program")) + label = models.CharField(max_length=64, verbose_name=_("Label on program"), blank=True, default="") + opening_date = models.DateTimeField(null=True, blank=True, default=None) + closing_date = models.DateTimeField(null=True, blank=True, default=None) + + def is_open(self): + now = timezone.now() + if self.opening_date and now < self.opening_date: + return False + if self.closing_date and now > self.closing_date: + return False + return True + + class Meta: + unique_together = ('site', 'name') + ordering = ('pk',) + verbose_name = "category" + verbose_name_plural = "categories" + + def __str__(self): + return ugettext(self.name) + + def get_absolute_url(self): + return reverse('talk-list') + '?category=%d' % self.pk + + +#class Attendee(PonyConfModel): +# +# user = models.ForeignKey(User, null=True) +# name = models.CharField(max_length=64, blank=True, default="") +# email = models.EmailField(blank=True, default="") +# +# def get_name(self): +# if self.user: +# return str(self.user.profile) +# else: +# return self.name +# get_name.short_description = _('Name') +# +# def get_email(self): +# if self.user: +# return self.user.email +# else: +# return self.email +# get_email.short_description = _('Email') +# +# def __str__(self): +# return self.get_name() + + +#def talk_materials_destination(talk, filename): +# return join(talk.site.name, talk.slug, filename) + + +class TalkManager(models.Manager): + def get_queryset(self): + qs = super().get_queryset() + qs = qs.annotate(score=Coalesce(Avg('vote__vote'), 0)) + return qs + + +class Talk(PonyConfModel): + + LICENCES = ( + ('CC-Zero CC-BY', 'CC-Zero CC-BY'), + ('CC-BY-SA', 'CC-BY-SA'), + ('CC-BY-ND', 'CC-BY-ND'), + ('CC-BY-NC', 'CC-BY-NC'), + ('CC-BY-NC-SA','CC-BY-NC-SA'), + ('CC-BY-NC-ND', 'CC-BY-NC-ND'), + ) + + site = models.ForeignKey(Site, on_delete=models.CASCADE) + + speakers = models.ManyToManyField(Participant, verbose_name=_('Speakers')) + title = models.CharField(max_length=128, verbose_name=_('Talk Title')) + slug = AutoSlugField(populate_from='title', unique=True) + #abstract = models.CharField(max_length=255, blank=True, verbose_name=_('Abstract')) + description = models.TextField(verbose_name=_('Description of your talk')) + track = models.ForeignKey(Track, blank=True, null=True, verbose_name=_('Track')) + notes = models.TextField(blank=True, verbose_name=_('Message to organizers'), help_text=_('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 here')) + category = models.ForeignKey(TalkCategory, verbose_name=_('Talk Category')) + videotaped = models.BooleanField(_("I'm ok to be recorded on video"), default=True) + video_licence = models.CharField(choices=LICENCES, default='CC-BY-SA', max_length=10, verbose_name=_("Video licence")) + sound = models.BooleanField(_("I need sound"), default=False) + accepted = models.NullBooleanField(default=None) + start_date = models.DateTimeField(null=True, blank=True, default=None, verbose_name=_('Beginning date and time')) + duration = models.PositiveIntegerField(default=0, verbose_name=_('Duration (min)')) + room = models.ForeignKey(Room, blank=True, null=True, default=None) + plenary = models.BooleanField(default=False) + #materials = models.FileField(null=True, upload_to=talk_materials_destination, verbose_name=_('Materials'), + # help_text=_('You can use this field to share some materials related to your intervention.')) + + token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + + conversation = models.OneToOneField(MessageThread) + + + objects = TalkManager() + + + class Meta: + ordering = ('title',) + + def __str__(self): + return self.title + + def get_speakers_str(self): + speakers = list(map(str, self.speakers.all())) + if len(speakers) == 0: + return 'superman' + elif len(speakers) == 1: + return speakers[0] + else: + return ', '.join(speakers[:-1]) + ' & ' + str(speakers[-1]) + + @property + def estimated_duration(self): + return self.duration or self.category.duration + + def get_absolute_url(self): + return reverse('talk-details', kwargs={'talk_id': self.token}) + + @property + def end_date(self): + if self.estimated_duration: + return self.start_date + timedelta(minutes=self.estimated_duration) + else: + return None + + @property + def dtstart(self): + return self.start_date.strftime('%Y%m%dT%H%M%SZ') + + @property + def dtend(self): + return self.end_date.strftime('%Y%m%dT%H%M%SZ') + + #@property + #def materials_name(self): + # return basename(self.materials.name) + + class Meta: + ordering = ('category__id', 'title',) + + +class Vote(PonyConfModel): + + talk = models.ForeignKey(Talk) + user = models.ForeignKey(User) + vote = models.IntegerField(validators=[MinValueValidator(-2), MaxValueValidator(2)], default=0) + + class Meta: + unique_together = ('talk', 'user') + + def __str__(self): + return "%+i by %s for %s" % (self.vote, self.user, self.talk) + + def get_absolute_url(self): + return self.talk.get_absolute_url() diff --git a/cfp/signals.py b/cfp/signals.py new file mode 100644 index 0000000..1675b8f --- /dev/null +++ b/cfp/signals.py @@ -0,0 +1,110 @@ +from django.db.models.signals import pre_save, post_save +from django.dispatch import receiver +from django.contrib.sites.models import Site +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse + +from ponyconf.decorators import disable_for_loaddata +from mailing.models import MessageThread, Message +from .models import Participant, Talk, Conference + + +@receiver(post_save, sender=Site, dispatch_uid="Create Conference for Site") +@disable_for_loaddata +def create_conference(sender, instance, **kwargs): + conference, created = Conference.objects.get_or_create(site=instance) + + +def create_conversation(sender, instance, **kwargs): + if not hasattr(instance, 'conversation'): + instance.conversation = MessageThread.objects.create() +pre_save.connect(create_conversation, sender=Participant) +pre_save.connect(create_conversation, sender=Talk) + + +@receiver(pre_save, sender=Message, dispatch_uid="Set message author") +def set_message_author(sender, instance, **kwargs): + message = instance + if message.author is None: + # Try users + try: + instance.author = User.objects.get(email=message.from_email) + except User.DoesNotExist: + pass + else: + return + # Try participants + try: + instance.author = Participant.objects.get(email=message.from_email) + except User.DoesNotExist: + pass + else: + return + # Try conferences + try: + instance.author = Conference.objects.get(contact_email=message.from_email) + except Conference.DoesNotExist: + pass + else: + return + + +@receiver(post_save, sender=Message, dispatch_uid="Send message notifications") +def send_message_notifications(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 = (message.author_display, 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} + proto = 'https' if conf.secure_domain else 'http' + footer = '\n\n--\n%s://' % proto + conf.site.domain + reverse('participant-details', args=[participant.token]) + if message.from_email == conf.contact_email: # this is a talk notification message + # send it only to the participant + message.send_notification(subject=subject_prefix+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=subject_prefix+staff_subject, sender=sender, dests=staff_dests, + reply_to=reply_to, message_id=message_id, reference=reference, footer=footer) + if message.from_email != thread.participant.email: # message from staff: sent it to the participant too + message.send_notification(subject=subject_prefix+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} + proto = 'https' if conf.secure_domain else 'http' + footer = '\n\n--\n%s://' % proto + conf.site.domain + reverse('talk-details', args=[thread.talk.token]) + message.send_notification(subject=subject_prefix+subject, sender=sender, dests=staff_dests, + reply_to=reply_to, message_id=message_id, reference=reference, footer=footer) + + +# connected in apps.py +def call_first_site_post_save(apps, **kwargs): + try: + site = Site.objects.get(id=getattr(settings, 'SITE_ID', 1)) + except Site.DoesNotExist: + pass + else: + site.save() diff --git a/cfp/templates/cfp/closed.html b/cfp/templates/cfp/closed.html new file mode 100644 index 0000000..792703c --- /dev/null +++ b/cfp/templates/cfp/closed.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} +{% load i18n %} + +{% block proposetab %} class="active"{% endblock %} + +{% block content %} + + +

{% trans "Sorry, the Call for Participation is closed!" %}

+ +{% endblock %} diff --git a/cfp/templates/cfp/complete.html b/cfp/templates/cfp/complete.html new file mode 100644 index 0000000..c25b4a2 --- /dev/null +++ b/cfp/templates/cfp/complete.html @@ -0,0 +1,27 @@ +{% extends 'base.html' %} + +{% load ponyconf_tags i18n %} + +{% block proposetab %} class="active"{% endblock %} + +{% block content %} + + + +{% endblock %} diff --git a/proposals/templates/proposals/home.html b/cfp/templates/cfp/home.html similarity index 63% rename from proposals/templates/proposals/home.html rename to cfp/templates/cfp/home.html index b91fd71..412c415 100644 --- a/proposals/templates/proposals/home.html +++ b/cfp/templates/cfp/home.html @@ -1,11 +1,11 @@ {% extends 'base.html' %} -{% load proposals_tags i18n %} +{% load ponyconf_tags i18n %} {% block hometab %} class="active"{% endblock %} {% block content %} -{% markdown site.conference.home %} +{% markdown conference.home %} {% endblock %} diff --git a/cfp/templates/cfp/propose.html b/cfp/templates/cfp/propose.html new file mode 100644 index 0000000..a59db31 --- /dev/null +++ b/cfp/templates/cfp/propose.html @@ -0,0 +1,27 @@ +{% extends 'base.html' %} +{% load crispy_forms_tags %} + +{% load ponyconf_tags i18n %} + +{% block proposetab %} class="active"{% endblock %} + +{% block content %} + + +
+
+
+ {% csrf_token %} + {{ participant_form|crispy }} + {{ talk_form|crispy }} +
+ +
+
+
+
+{% endblock %} diff --git a/cfp/templates/cfp/speaker.html b/cfp/templates/cfp/speaker.html new file mode 100644 index 0000000..6a98b01 --- /dev/null +++ b/cfp/templates/cfp/speaker.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} +{% load crispy_forms_tags %} + +{% load ponyconf_tags i18n %} + +{% block proposetab %} class="active"{% endblock %} + +{% block content %} + + +
+
+
+ {% csrf_token %} + {{ participant_form|crispy }} +
+ +
+
+
+
+{% endblock %} diff --git a/ponyconf/templates/staff.html b/cfp/templates/cfp/staff/base.html similarity index 57% rename from ponyconf/templates/staff.html rename to cfp/templates/cfp/staff/base.html index 74713a5..fd50435 100644 --- a/ponyconf/templates/staff.html +++ b/cfp/templates/cfp/staff/base.html @@ -6,16 +6,24 @@ {% block navbar %} {{ block.super }} {% endblock %} + +{% block content %} +{% trans "Please select a category." %} +{% endblock %} diff --git a/cfp/templates/cfp/staff/conference.html b/cfp/templates/cfp/staff/conference.html new file mode 100644 index 0000000..852c44c --- /dev/null +++ b/cfp/templates/cfp/staff/conference.html @@ -0,0 +1,27 @@ +{% extends 'cfp/staff/base.html' %} +{% load i18n crispy_forms_tags %} + +{% block conferencetab %} class="active"{% endblock %} + +{% block content %} + +

{% trans "Conference" %}

+ +
+ {% csrf_token %} + {{ form|crispy }} + {% trans "Add a new user" %} + +
+ +{% endblock %} + +{% block js_end %} +{{ block.super }} +{{ form.media.js }} +{% endblock %} + +{% block css %} +{{ block.super }} +{{ form.media.css }} +{% endblock %} diff --git a/cfp/templates/cfp/staff/create_user.html b/cfp/templates/cfp/staff/create_user.html new file mode 100644 index 0000000..7e433cc --- /dev/null +++ b/cfp/templates/cfp/staff/create_user.html @@ -0,0 +1,17 @@ +{% extends 'cfp/staff/base.html' %} +{% load i18n crispy_forms_tags %} + +{% block conferencetab %} class="active"{% endblock %} + +{% block content %} + +

{% trans "Add a new user" %}

+ +
+ {% csrf_token %} + {{ form|crispy }} + + {% trans "Cancel" %} +
+ +{% endblock %} diff --git a/cfp/templates/cfp/staff/participant_details.html b/cfp/templates/cfp/staff/participant_details.html new file mode 100644 index 0000000..0b55dcd --- /dev/null +++ b/cfp/templates/cfp/staff/participant_details.html @@ -0,0 +1,62 @@ +{% extends 'cfp/staff/base.html' %} +{% load i18n %} + +{% block speakerstab %} class="active"{% endblock %} + +{% block content %} + +

{{ participant }}

+ +

{% trans "Edit" %}

+ +

{% trans "Biography" %}

+

{{ participant.biography|linebreaksbr }}

+ +{% if participant.notes %} +

{% trans "Notes" %}

+

{{ participant.notes|linebreaksbr }}

+{% endif %} + +

{% trans "Informations" %}

+ + +

{% trans "Talks" %}

+{% regroup participant.talk_set.all by category as category_list %} +{% for category in category_list %} +

{{ category.list.0.category }}

+ +{% empty %}{% trans "No talks" %} +{% endfor %} + +

{% trans "Messaging" %}

+ +{% include 'mailing/_message_list.html' with messages=participant.conversation.message_set.all %} + +{% trans "Send a message – this message will be received by this participant and all the staff team" as message_form_title %} +{% include 'mailing/_message_form.html' %} + +{% endblock %} diff --git a/cfp/templates/cfp/staff/participant_form.html b/cfp/templates/cfp/staff/participant_form.html new file mode 100644 index 0000000..997444b --- /dev/null +++ b/cfp/templates/cfp/staff/participant_form.html @@ -0,0 +1,13 @@ +{% extends 'cfp/staff/base.html' %} +{% load i18n crispy_forms_tags %} + +{% block speakerstab %} class="active"{% endblock %} + +{% block content %} + +

{% trans "Edit a speaker" %}

+ +{% url 'participant-details' participant.token as cancel_url %} +{% include '_form.html' %} + +{% endblock %} diff --git a/proposals/templates/proposals/speaker_list.html b/cfp/templates/cfp/staff/participant_list.html similarity index 56% rename from proposals/templates/proposals/speaker_list.html rename to cfp/templates/cfp/staff/participant_list.html index 055d72c..ea90495 100644 --- a/proposals/templates/proposals/speaker_list.html +++ b/cfp/templates/cfp/staff/participant_list.html @@ -1,5 +1,4 @@ -{% extends 'staff.html' %} - +{% extends 'cfp/staff/base.html' %} {% load bootstrap3 i18n %} {% block speakerstab %} class="active"{% endblock %} @@ -8,6 +7,7 @@

{% trans "Speakers" %}

+{% comment %} {% trans "Show filtering options…" %}

@@ -39,63 +39,43 @@ +{% endcomment %} - - - + - - - - + {% comment %}{% endcomment %} + {% comment %} - {% for speaker in speaker_list %} + {% endcomment %} + {% for participant in participant_list %} {% if forloop.first %} {% endif %} - - - - {% if speaker.need_transport %} - + - {% elif speaker.need_transport is None %} - - {% else %} - - {% endif %} - - {% if speaker.accommodation is None %} - ? - {% else %} - {{ speaker.get_accommodation_display }} - {% endif %} - - {% if speaker.sound %} - - {% else %} - - {% endif %} + {% comment %} + {% endcomment %} {% if forloop.last %} @@ -105,6 +85,7 @@ {% endblock %} +{% comment %} {% block js_end %} {% endblock %} +{% endcomment %} diff --git a/cfp/templates/cfp/staff/room_details.html b/cfp/templates/cfp/staff/room_details.html new file mode 100644 index 0000000..5c309ab --- /dev/null +++ b/cfp/templates/cfp/staff/room_details.html @@ -0,0 +1,48 @@ +{% extends 'cfp/staff/base.html' %} + +{% load bootstrap3 cfp_tags i18n %} + +{% block roomstab %} class="active"{% endblock %} + +{% block content %} + +

{{ room.name }} + {{ room.label }} +

+ +

{% trans "Scheduled talks" %}

+{% for talk in room.talks_by_date %} +{% if forloop.first %}{% endif %} +{% empty %} +{% trans "No talks." %} +{% endfor %} + +

{% trans "Unscheduled talks" %}

+{% for talk in room.unscheduled_talks %} +{% if forloop.first %}{% endif %} +{% empty %} +{% trans "No talks." %} +{% endfor %} + +{% endblock %} diff --git a/proposals/templates/proposals/topic_form.html b/cfp/templates/cfp/staff/room_form.html similarity index 67% rename from proposals/templates/proposals/topic_form.html rename to cfp/templates/cfp/staff/room_form.html index 5e50057..393955d 100644 --- a/proposals/templates/proposals/topic_form.html +++ b/cfp/templates/cfp/staff/room_form.html @@ -1,8 +1,8 @@ -{% extends 'staff.html' %} +{% extends 'cfp/staff/base.html' %} {% load bootstrap3 i18n %} -{% block topicstab %} class="active"{% endblock %} +{% block roomstab %} class="active"{% endblock %} {% block css %} {{ block.super }} @@ -11,7 +11,7 @@ {% block content %} -

{% trans "Topic" %}

+

{% trans "Room" %}

{% include "_form.html" %} diff --git a/cfp/templates/cfp/staff/room_list.html b/cfp/templates/cfp/staff/room_list.html new file mode 100644 index 0000000..0016d1d --- /dev/null +++ b/cfp/templates/cfp/staff/room_list.html @@ -0,0 +1,46 @@ +{% extends 'cfp/staff/base.html' %} + +{% load bootstrap3 cfp_tags i18n %} + +{% block roomstab %} class="active"{% endblock %} + +{% block content %} + +

{% trans "Rooms" %}

+ +

{% trans "Add a room" %}

+ +

+ {% for room in room_list %} +
+

+ {{ room }} +

+ {% if room.label %}

{{ room.label }}

{% endif %} +

+ {{ room.capacity }} {% trans "place" %}{{ room.capacity|pluralize }} + | + + {{ room.talks.count }} {% trans "talk" %}{{ room.talks.count|pluralize }} + + | + {% bootstrap_icon "pencil" %} +

+
+ {% cycle '' '
' %} + {% cycle '' '' '' %} + {% empty %} +
{% trans "No rooms." %}
+ {% endfor %} +
+ +{% endblock %} + +{% block js_end %} +{{ block.super }} + +{% endblock %} diff --git a/proposals/templates/proposals/talk_decide.html b/cfp/templates/cfp/staff/talk_decide.html similarity index 77% rename from proposals/templates/proposals/talk_decide.html rename to cfp/templates/cfp/staff/talk_decide.html index 5df73f6..6d4ca56 100644 --- a/proposals/templates/proposals/talk_decide.html +++ b/cfp/templates/cfp/staff/talk_decide.html @@ -1,8 +1,7 @@ -{% extends 'base.html' %} - +{% extends 'cfp/staff/base.html' %} {% load i18n %} -{% block listingtab %} active{% endblock %} +{% block talkstab %} class="active"{% endblock %} {% block content %} @@ -10,17 +9,17 @@

{% trans "Information about the proposals" %}

{% trans "Title:" %} {{ talk.title }}
-{% trans "Kind:" %} {{ talk.event }}
+{% trans "Kind:" %} {{ talk.category }}

{% trans "Information for the proposer" %}

- + {% csrf_token %}
- {% trans "Cancel" %} + {% trans "Cancel" %} {% endblock %} diff --git a/proposals/templates/proposals/talk_detail.html b/cfp/templates/cfp/staff/talk_details.html similarity index 52% rename from proposals/templates/proposals/talk_detail.html rename to cfp/templates/cfp/staff/talk_details.html index e95e19d..5d9c21d 100644 --- a/proposals/templates/proposals/talk_detail.html +++ b/cfp/templates/cfp/staff/talk_details.html @@ -1,50 +1,38 @@ -{% extends base_template %} +{% extends 'cfp/staff/base.html' %} +{% load bootstrap3 i18n %} -{% if staff %} {% block talkstab %} class="active"{% endblock %} -{% else %} -{% block exhibitortab %} class="active"{% endblock %} -{% endif %} - -{% load i18n %} {% block content %}

{{ talk.title }}

-{% if edit_perm %} -{% trans "Edit" %}
-{% endif %} +

{% trans "Edit" %}

-

{% if talk.abstract %}{{ talk.abstract }}{% else %}{% trans "No abstract provided." %}{% endif %}

- -{% if moderate_perm %} +

{% trans "Information" %}

-
{% trans "Format" %}
-
{{ talk.event }}
-
{% trans "Topics" %}
-
{% for topic in talk.topics.all %} - {{ topic }}{% if not forloop.last %}, {% endif %} - {% empty %} - {% trans "No topics." %} - {% endfor %}
+
{% trans "Category" %}
+
{{ talk.category }}
+ +
{% trans "Status" %}
+
{{ talk.accepted|yesno:"Accepted,Declined,Pending decision" }}
{% trans "Track" %}
{% if talk.track %} {{ talk.track }} {% else %} - {% trans "No assigned yet." %} + {% trans "No assigned yet." context "session" %} {% endif %}
-
Horaire
+
{% trans "Timeslot" %}
{% if talk.start_date %} {{ talk.start_date|date:"l d b" }}, {{ talk.start_date|date:"H:i" }} – {% if talk.end_date %}{{ talk.end_date|date:"H:i" }}{% else %}?{% endif %} {% else %}{% trans "not defined" %} {% endif %}
-
Salle
+
{% trans "Room" %}
{% if talk.room %} {{ talk.room }} @@ -52,6 +40,7 @@ {% else %}{% trans "not defined" %} {% endif %}
+ {% comment %} {% if talk.registration_required %}
{% trans "Registrations" %}
{% if talk.attendees_limit %}{{ talk.attendees.count }} / {{ talk.attendees_limit }}{% else %}{% trans "required but unlimited" %}{% endif %}
@@ -64,69 +53,49 @@
{% trans "Video" %}
{% trans "download" %}
{% endif %} + {% endcomment %}
-{% endif %} -

{% trans "Description" %}

{% if talk.description %}{{ talk.description|linebreaksbr }}{% else %}{% trans "No description provided." %}{% endif %}

{% trans "Speakers" %}

-{% for speaker in talk.speakers.all %} +{% for participant in talk.speakers.all %} {% if forloop.first %}{% endif %} {% empty %} {% trans "No speakers." %} {% endfor %} -{% if moderate_perm %} - -{% if not talk.track %} -

{% trans "Track" %}

-

{% trans "No assigned yet." %}

- {% for topic in talk.topics.distinct %} - {% if forloop.first %}

{% endif %} - {% if topic.track %} - {% trans "Assign to" %} {{ topic.track }} - {% endif %} - {% if forloop.last %}

{% endif %} - {% endfor %} -{% endif %} - -{% endif %} -

{% trans "Notes" %}

{% if talk.notes %}{{ talk.notes|linebreaksbr }}{% else %}{% trans "No notes." %}{% endif %}

-{% if moderate_perm %} -

{% trans "Moderation" %}

{% trans "Status" %}

-{{ talk.accepted|yesno:"Accepted,Declined,Pending decision" }}
- {% if talk.accepted == None %}

{% trans "Vote" %}

- -2 - -1 - 0 - +1 - +2 + -2 + -1 + 0 + +1 + +2


-

{{ talk.vote_set.all|length }} {% trans "vote" %}{{ talk.vote_set.all|length|pluralize }}, {% trans "average:" %} {{ talk.score|floatformat:1 }}

+

{{ talk.vote_set.count }} {% trans "vote" %}{{ talk.vote_set.count|pluralize }}, {% trans "average:" %} {{ talk.score|floatformat:1 }}

-Accept -Decline +Accept +Decline {% endif %} +{% comment %} {% if talk.registration_required %}

{% trans "Attendees" %}

@@ -139,15 +108,13 @@ {% endfor %} {% endif %} +{% endcomment %} -

{% trans "Messages" %}

-{% trans "These messages are for organization team only." %}

-{% for message in talk.conversation.messages.all %} -{% include 'conversations/_message_detail.html' %} -{% endfor %} +

{% trans "Messaging" %}

-{% include 'conversations/_message_form.html' %} +{% include 'mailing/_message_list.html' with messages=talk.conversation.message_set.all %} -{% endif %} +{% trans "Comment this talk – this message will be received by the staff team only" as message_form_title %} +{% include 'mailing/_message_form.html' %} {% endblock %} diff --git a/cfp/templates/cfp/staff/talk_form.html b/cfp/templates/cfp/staff/talk_form.html new file mode 100644 index 0000000..6123e62 --- /dev/null +++ b/cfp/templates/cfp/staff/talk_form.html @@ -0,0 +1,13 @@ +{% extends 'cfp/staff/base.html' %} +{% load i18n crispy_forms_tags %} + +{% block talkstab %} class="active"{% endblock %} + +{% block content %} + +

{% trans "Edit a talk" %}

+ +{% url 'talk-details' talk.token as cancel_url %} +{% include '_form.html' %} + +{% endblock %} diff --git a/proposals/templates/proposals/talk_list.html b/cfp/templates/cfp/staff/talk_list.html similarity index 57% rename from proposals/templates/proposals/talk_list.html rename to cfp/templates/cfp/staff/talk_list.html index 4a12d8f..6503f4c 100644 --- a/proposals/templates/proposals/talk_list.html +++ b/cfp/templates/cfp/staff/talk_list.html @@ -1,6 +1,5 @@ -{% extends 'staff.html' %} - -{% load bootstrap3 i18n accounts_tags %} +{% extends 'cfp/staff/base.html' %} +{% load bootstrap3 i18n %} {% block talkstab %} class="active"{% endblock %} @@ -16,19 +15,14 @@
-
+
{% bootstrap_field filter_form.status layout="horizontal" %} - {% bootstrap_field filter_form.kind layout="horizontal" %} + {% bootstrap_field filter_form.category layout="horizontal" %} {% bootstrap_field filter_form.vote layout="horizontal" %} - {% bootstrap_field filter_form.room layout="horizontal" %} {% bootstrap_field filter_form.scheduled layout="horizontal" %} - {% bootstrap_field filter_form.materials layout="horizontal" %} - {% bootstrap_field filter_form.video layout="horizontal" %} + {% bootstrap_field filter_form.room layout="horizontal" %}
-
- {% bootstrap_field filter_form.topic layout="horizontal" %} -
-
+
{% bootstrap_field filter_form.track layout="horizontal" %}
@@ -37,17 +31,14 @@
- -
{% trans "Total:" %} {{ speaker_list|length }} {% trans "speaker" %}{{ speaker_list|length|pluralize }} + {% trans "Total:" %} {{ participant_list|length }} {% trans "speaker" %}{{ participant_list|length|pluralize }}
{% trans "Username" %}{% trans "Fullname" %}{% trans "Name" %} {% trans "Talk count" %}{% blocktrans context "table column title" %}Need transport?{% endblocktrans %}{% blocktrans context "table column title" %}Need accommodation?{% endblocktrans %}{% trans "Need sound?" %}
{% trans "Contact:" %} {% trans "link" %}
{{ speaker.user.username }}{{ speaker.user.get_full_name }}{{ speaker.not_refused_talk_set.count }}{% if speaker.pending_talk_set.count %} ({{ speaker.pending_talk_set.count }} pending){% endif %} - {% for transport in speaker.transport.all %} - {% if not forloop.first %}, {% endif %} - {{ transport }} - {% empty %} - Yes - {% endfor %} + {{ participant }} + {% blocktrans count accepted=participant.accepted_talk_count %}accepted: {{ accepted }}{% plural %}accepted: {{ accepted }}{% endblocktrans %} + — + {% blocktrans count pending=participant.pending_talk_count %}pending: {{ pending }}{% plural %}pending: {{ pending }}{% endblocktrans %} + — + {% blocktrans count refused=participant.refused_talk_count %}refused: {{ refused }}{% plural %}refused: {{ refused }}{% endblocktrans %} ?NoYesNo {% trans "Contact" %}
- + {% comment %}{% endcomment %} - + - @@ -57,21 +48,16 @@ {% endif %} - - - + {% comment %}{% endcomment %} + + -
{% trans "Total:" %} {{ talk_list|length }} {% trans "talk" %}{{ talk_list|length|pluralize }}
{% trans "Title" %} {% trans "Intervention kind" %} {% trans "Intervention kind" %} {% trans "Speakers" %}{% trans "Topics" %} {% trans "Track" %} {% trans "Status" %}
{{ talk.title }}{{ talk.event }}{{ talk.title }}{{ talk.category }} - {% for speaker in talk.speakers.all %} - {{ speaker.profile }} + {% for participant in talk.speakers.all %} + {{ participant }} {% if forloop.revcounter == 2 %} {% trans "and" %} {% elif not forloop.last %}, {% endif %} {% empty %}– {% endfor %} - {% for topic in talk.topics.all %} - {{ topic }} - {% endfor %} - {{ talk.track|default:"–" }} {% if talk.accepted == True %} @@ -89,34 +75,4 @@ {% endfor %}
-{% if action_form %} -
-
-

{% trans "For selected talks:" %}

- {% csrf_token %} - {% bootstrap_field action_form.decision %} - {% if request|orga %} - {% bootstrap_field action_form.track %} - {% bootstrap_field action_form.room %} - {% endif %} - {% buttons %} - - {% endbuttons %} -
-
-{% endif %} - - - -{% endblock %} - -{% block js_end %} - {% endblock %} diff --git a/proposals/templates/proposals/track_form.html b/cfp/templates/cfp/staff/track_form.html similarity index 89% rename from proposals/templates/proposals/track_form.html rename to cfp/templates/cfp/staff/track_form.html index 8fc12db..2f463c3 100644 --- a/proposals/templates/proposals/track_form.html +++ b/cfp/templates/cfp/staff/track_form.html @@ -1,4 +1,4 @@ -{% extends 'staff.html' %} +{% extends 'cfp/staff/base.html' %} {% load bootstrap3 i18n %} diff --git a/proposals/templates/proposals/track_list.html b/cfp/templates/cfp/staff/track_list.html similarity index 78% rename from proposals/templates/proposals/track_list.html rename to cfp/templates/cfp/staff/track_list.html index 924cc19..67f065b 100644 --- a/proposals/templates/proposals/track_list.html +++ b/cfp/templates/cfp/staff/track_list.html @@ -1,6 +1,6 @@ -{% extends 'staff.html' %} +{% extends 'cfp/staff/base.html' %} -{% load bootstrap3 accounts_tags proposals_tags i18n %} +{% load bootstrap3 cfp_tags i18n %} {% block trackstab %} class="active"{% endblock %} @@ -8,9 +8,7 @@

{% trans "Tracks" %}

-{% if request|orga %} -

{% trans "Add a track" %}

-{% endif %} +

{% trans "Add a track" %}

{% for track in track_list %} @@ -24,7 +22,7 @@ | {{ track.estimated_duration|duration_format }} | - {% bootstrap_icon "pencil" %} + {% bootstrap_icon "pencil" %} {% endif %}
{% cycle '' '
' %} diff --git a/accounts/templatetags/__init__.py b/cfp/templatetags/__init__.py similarity index 100% rename from accounts/templatetags/__init__.py rename to cfp/templatetags/__init__.py diff --git a/proposals/templatetags/proposals_tags.py b/cfp/templatetags/cfp_tags.py similarity index 66% rename from proposals/templatetags/proposals_tags.py rename to cfp/templatetags/cfp_tags.py index 2656151..297676d 100644 --- a/proposals/templatetags/proposals_tags.py +++ b/cfp/templatetags/cfp_tags.py @@ -1,14 +1,14 @@ from django import template -from proposals.utils import markdown_to_html +from cfp.utils import is_staff register = template.Library() -@register.simple_tag -def markdown(value): - return markdown_to_html(value) +@register.filter +def staff(request): + return is_staff(request, request.user) @register.filter('duration_format') def duration_format(value): diff --git a/cfp/urls.py b/cfp/urls.py new file mode 100644 index 0000000..fb0cd70 --- /dev/null +++ b/cfp/urls.py @@ -0,0 +1,54 @@ +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url(r'^$', views.home, name='home'), + url(r'^cfp/$', views.talk_proposal, name='talk-proposal'), + url(r'^cfp/(?P[\w\-]+)/speaker/add/$', views.talk_proposal_speaker_edit, name='talk-proposal-speaker-add'), + url(r'^cfp/(?P[\w\-]+)/speaker/(?P[\w\-]+)/$', views.talk_proposal_speaker_edit, name='talk-proposal-speaker-edit'), + url(r'^cfp/(?P[\w\-]+)/(?P[\w\-]+)/$', views.talk_proposal, name='talk-proposal-edit'), + url(r'^staff/$', views.staff, name='staff'), + url(r'^staff/conference/$', views.conference, name='conference'), + url(r'^staff/talks/$', views.talk_list, name='talk-list'), + url(r'^staff/talks/(?P[\w\-]+)/$', views.talk_details, name='talk-details'), + url(r'^staff/talks/(?P[\w\-]+)/vote/(?P[-+0-2]+)/$', views.talk_vote, name='talk-vote'), + url(r'^staff/talks/(?P[\w\-]+)/accept/$', views.talk_decide, {'accept': True}, name='talk-accept'), + url(r'^staff/talks/(?P[\w\-]+)/decline/$', views.talk_decide, {'accept': False}, name='talk-decline'), + url(r'^staff/talks/(?P[\w\-]+)/edit/$', views.TalkUpdate.as_view(), name='talk-edit'), + url(r'^staff/speakers/$', views.participant_list, name='participant-list'), + url(r'^staff/speakers/(?P[\w\-]+)/$', views.participant_details, name='participant-details'), + url(r'^staff/speakers/(?P[\w\-]+)/edit/$', views.ParticipantUpdate.as_view(), name='participant-edit'), + url(r'^staff/tracks/$', views.TrackList.as_view(), name='track-list'), + url(r'^staff/tracks/add/$', views.TrackCreate.as_view(), name='track-add'), + url(r'^staff/tracks/(?P[-\w]+)/edit/$', views.TrackUpdate.as_view(), name='track-edit'), + url(r'^staff/rooms/$', views.RoomList.as_view(), name='room-list'), + url(r'^staff/rooms/add/$', views.RoomCreate.as_view(), name='room-add'), + url(r'^staff/rooms/(?P[-\w]+)/$', views.RoomDetail.as_view(), name='room-details'), + url(r'^staff/rooms/(?P[-\w]+)/edit/$', views.RoomUpdate.as_view(), name='room-edit'), + url(r'^staff/add-user/$', views.create_user, name='create-user'), + url(r'^staff/select2/$', views.Select2View.as_view(), name='django_select2-json'), + + #url(r'^markdown/$', views.markdown_preview, name='markdown'), + #url(r'^$', views.home, name='home'), + #url(r'^staff/$', views.staff, name='staff'), + #url(r'^conference/$', views.conference, name='edit-conference'), + #url(r'^talk/propose/$', views.participate, name='participate-as-speaker'), + #url(r'^talk/$', views.talk_list, name='list-talks'), + #url(r'^talk/add/$', views.talk_edit, name='add-talk'), + #url(r'^talk/edit/(?P[-\w]+)$', views.talk_edit, name='edit-talk'), + #url(r'^talk/vote/(?P[-\w]+)/(?P[-0-2]+)$', views.vote, name='vote'), + #url(r'^talk/details/(?P[-\w]+)$', views.TalkDetail.as_view(), name='show-talk'), + #url(r'^talk/accept/(?P[-\w]+)/$', views.talk_decide, {'accepted': True}, name='accept-talk'), + #url(r'^talk/decline/(?P[-\w]+)/$', views.talk_decide, {'accepted': False}, name='decline-talk'), + #url(r'^talk/assign-to-track/(?P[-\w]+)/(?P[-\w]+)/$', views.talk_assign_to_track, name='assign-talk-to-track'), + #url(r'^topic/$', views.TopicList.as_view(), name='list-topics'), + #url(r'^topic/add/$', views.TopicCreate.as_view(), name='add-topic'), + #url(r'^topic/(?P[-\w]+)/edit/$', views.TopicUpdate.as_view(), name='edit-topic'), + #url(r'^track/$', views.TrackList.as_view(), name='list-tracks'), + #url(r'^track/add/$', views.TrackCreate.as_view(), name='add-track'), + #url(r'^track/(?P[-\w]+)/edit/$', views.TrackUpdate.as_view(), name='edit-track'), + #url(r'^speakers/$', views.speaker_list, name='list-speakers'), + #url(r'^register/$', views.talk_registrable_list, name='list-registrable-talks'), + #url(r'^register/(?P[-\w]+)$', views.talk_register, name='register-for-a-talk'), +] diff --git a/proposals/utils.py b/cfp/utils.py similarity index 62% rename from proposals/utils.py rename to cfp/utils.py index a6a44c5..2404de9 100644 --- a/proposals/utils.py +++ b/cfp/utils.py @@ -1,10 +1,8 @@ -from django.contrib.sites.shortcuts import get_current_site +from django.utils.crypto import get_random_string from django.db.models import Q, Sum from django.db.models.functions import Coalesce from django.utils.safestring import mark_safe -from accounts.models import Participation - from markdown import markdown import bleach @@ -13,13 +11,22 @@ def query_sum(queryset, field): return queryset.aggregate(s=Coalesce(Sum(field), 0))['s'] +def generate_user_uid(): + return get_random_string(length=12, allowed_chars='abcdefghijklmnopqrstuvwxyz0123456789') + + def allowed_talks(talks, request): - if not Participation.objects.get(site=get_current_site(request), user=request.user).is_orga(): + if not Participation.objects.get(site=request.conference.site, user=request.user).is_orga(): talks = talks.filter(Q(topics__reviewers=request.user) | Q(speakers=request.user) | Q(proposer=request.user)) return talks.distinct() + def markdown_to_html(md): html = markdown(md) allowed_tags = bleach.ALLOWED_TAGS + ['p', 'pre', 'span' ] + ['h%d' % i for i in range(1, 7) ] html = bleach.clean(html, tags=allowed_tags) return mark_safe(html) + + +def is_staff(request, user): + return user.is_authenticated and (user.is_superuser or request.conference.staff.filter(pk=user.pk).exists()) diff --git a/cfp/views.py b/cfp/views.py new file mode 100644 index 0000000..486a82a --- /dev/null +++ b/cfp/views.py @@ -0,0 +1,438 @@ +from django.core.mail import send_mail +from django.core.urlresolvers import reverse_lazy +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ +from django.views.generic import FormView, TemplateView +from django.contrib import messages +from django.db.models import Q +from django.views.generic import CreateView, DetailView, ListView, UpdateView + +from django_select2.views import AutoResponseView + +from functools import reduce + +from mailing.models import Message +from mailing.forms import MessageForm +from .decorators import staff_required +from .mixins import StaffRequiredMixin, OnSiteMixin +from .utils import is_staff +from .models import Participant, Talk, TalkCategory, Vote, Track, Room +from .forms import TalkForm, TalkStaffForm, TalkFilterForm, ParticipantForm, ParticipantStaffForm, ConferenceForm, CreateUserForm, STATUS_VALUES, TrackForm, RoomForm + + +def home(request): + if request.conference.home: + return render(request, 'cfp/home.html') + else: + return redirect(reverse('talk-proposal')) + + +def talk_proposal(request, talk_id=None, participant_id=None): + + conference = request.conference + site = conference.site + if is_staff(request, request.user): + categories = TalkCategory.objects.filter(site=site) + else: + categories = conference.opened_categories + talk = None + participant = None + + if talk_id and participant_id: + talk = get_object_or_404(Talk, token=talk_id, site=site) + participant = get_object_or_404(Participant, token=participant_id, site=site) + elif not categories.exists(): + return render(request, 'cfp/closed.html') + + participant_form = ParticipantForm(request.POST or None, instance=participant) + talk_form = TalkForm(request.POST or None, categories=categories, instance=talk) + + if request.method == 'POST' and talk_form.is_valid() and participant_form.is_valid(): + talk = talk_form.save(commit=False) + talk.site = site + + participant, created = Participant.objects.get_or_create(email=participant_form.cleaned_data['email'], site=site) + participant_form = ParticipantForm(request.POST, instance=participant) + participant = participant_form.save() + participant.language = request.LANGUAGE_CODE + participant.save() + + talk.save() + talk.speakers.add(participant) + + protocol = 'https' if request.is_secure() else 'http' + base_url = protocol+'://'+site.domain + url_talk_proposal_edit = base_url + reverse('talk-proposal-edit', args=[talk.token, participant.token]) + url_talk_proposal_speaker_add = base_url + reverse('talk-proposal-speaker-add', args=[talk.token]) + url_talk_proposal_speaker_edit = base_url + reverse('talk-proposal-speaker-edit', args=[talk.token, participant.token]) + body = _("""Hi {}, + +Your talk has been submitted for {}. + +Here are the details of your talk: +Title: {} +Description: {} + +You can at anytime: +- edit your talk: {} +- add a new co-speaker: {} +- edit your profile: {} + +If you have any question, your can answer to this email. + +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) + + Message.objects.create( + thread=participant.conversation, + author=conference, + from_email=conference.contact_email, + content=body, + ) + + return render(request, 'cfp/complete.html', {'talk': talk, 'participant': participant}) + + return render(request, 'cfp/propose.html', { + 'participant_form': participant_form, + 'site': site, + 'talk_form': talk_form, + }) + + +def talk_proposal_speaker_edit(request, talk_id, participant_id=None): + + talk = get_object_or_404(Talk, token=talk_id, site=request.conference.site) + participant = None + + if participant_id: + participant = get_object_or_404(Participant, token=participant_id, site=request.conference.site) + + participant_form = ParticipantForm(request.POST or None, instance=participant) + + if request.method == 'POST' and participant_form.is_valid(): + + participant, created = Participant.objects.get_or_create(email=participant_form.cleaned_data['email'], site=request.conference.site) + participant_form = ParticipantForm(request.POST, instance=participant) + participant = participant_form.save() + participant.save() + + talk.speakers.add(participant) + + return render(request,'cfp/complete.html', {'talk': talk, 'participant': participant}) + + return render(request, 'cfp/speaker.html', { + 'participant_form': participant_form, + }) + + +@staff_required +def staff(request): + return render(request, 'cfp/staff/base.html') + + +@staff_required +def talk_list(request): + show_filters = False + talks = Talk.objects.filter(site=request.conference.site) + filter_form = TalkFilterForm(request.GET or None, site=request.conference.site) + # Filtering + if filter_form.is_valid(): + data = filter_form.cleaned_data + if len(data['category']): + show_filters = True + talks = talks.filter(reduce(lambda x, y: x | y, [Q(category__pk=pk) for pk in data['category']])) + if len(data['status']): + show_filters = True + talks = talks.filter(reduce(lambda x, y: x | y, [Q(accepted=dict(STATUS_VALUES)[status]) for status in data['status']])) + if data['room'] != None: + show_filters = True + talks = talks.filter(room__isnull=not data['room']) + if data['scheduled'] != None: + show_filters = True + talks = talks.filter(start_date__isnull=not data['scheduled']) + if len(data['track']): + show_filters = True + q = Q() + if 'none' in data['track']: + data['track'].remove('none') + q |= Q(track__isnull=True) + if len(data['track']): + q |= Q(track__slug__in=data['track']) + talks = talks.filter(q) + if data['vote'] != None: + show_filters = True + if data['vote']: + talks = talks.filter(vote__user=request.user) + else: + talks = talks.exclude(vote__user=request.user) + # Sorting + if request.GET.get('order') == 'desc': + sort_reverse = True + else: + sort_reverse = False + SORT_MAPPING = { + 'title': 'title', + 'category': 'category', + 'status': 'accepted', + } + sort = request.GET.get('sort') + if sort in SORT_MAPPING.keys(): + if sort_reverse: + talks = talks.order_by('-' + SORT_MAPPING[sort]) + else: + talks = talks.order_by(SORT_MAPPING[sort]) + # Sorting URLs + sort_urls = dict() + sort_glyphicons = dict() + for c in SORT_MAPPING.keys(): + url = request.GET.copy() + url['sort'] = c + if c == sort: + if sort_reverse: + del url['order'] + glyphicon = 'sort-by-attributes-alt' + else: + url['order'] = 'desc' + glyphicon = 'sort-by-attributes' + else: + glyphicon = 'sort' + sort_urls[c] = url.urlencode() + sort_glyphicons[c] = glyphicon + talks = talks.prefetch_related('category', 'speakers', 'track') + return render(request, 'cfp/staff/talk_list.html', { + 'show_filters': show_filters, + 'talk_list': talks, + 'filter_form': filter_form, + 'sort_urls': sort_urls, + 'sort_glyphicons': sort_glyphicons, + }) + + +@staff_required +def talk_details(request, talk_id): + talk = get_object_or_404(Talk, token=talk_id, site=request.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 + message.from_email = request.user.email + message.thread = talk.conversation + message.save() + messages.success(request, _('Message sent!')) + return redirect(reverse('talk-details', args=[talk.token])) + return render(request, 'cfp/staff/talk_details.html', { + 'talk': talk, + }) + + +@staff_required +def talk_vote(request, talk_id, score): + talk = get_object_or_404(Talk, token=talk_id, site=request.conference.site) + vote, created = Vote.objects.get_or_create(talk=talk, user=request.user) + vote.vote = int(score) + vote.save() + messages.success(request, _('Vote successfully created') if created else _('Vote successfully updated')) + return redirect(talk.get_absolute_url()) + + +@staff_required +def talk_decide(request, talk_id, accept): + talk = get_object_or_404(Talk, token=talk_id, site=request.conference.site) + if request.method == 'POST': + # Does we need to send a notification to the proposer? + m = request.POST.get('message', '').strip() + if m: + for participant in talk.speakers.all(): + Message.objects.create(thread=talk.conversation, author=request.user, content=m) + # Save the decision in the talk's conversation + if accept: + note = _("The talk has been accepted.") + else: + note = _("The talk has been declined.") + Message.objects.create(thread=talk.conversation, author=request.user, content=note) + talk.accepted = accept + talk.save() + messages.success(request, _('Decision taken in account')) + return redirect(talk.get_absolute_url()) + return render(request, 'cfp/staff/talk_decide.html', { + 'talk': talk, + 'accept': accept, + }) + + +@staff_required +def participant_list(request): + participants = Participant.objects.filter(site=request.conference.site) \ + .extra(select={'lower_name': 'lower(name)'}) \ + .order_by('lower_name') + return render(request, 'cfp/staff/participant_list.html', { + 'participant_list': participants, + }) + + +@staff_required +def participant_details(request, participant_id): + participant = get_object_or_404(Participant, token=participant_id, site=request.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 + message.from_email = request.user.email + message.thread = participant.conversation + message.save() + messages.success(request, _('Message sent!')) + return redirect(reverse('participant-details', args=[participant.token])) + return render(request, 'cfp/staff/participant_details.html', { + 'participant': participant, + }) + + +class ParticipantUpdate(StaffRequiredMixin, OnSiteMixin, UpdateView): + model = Participant + slug_field = 'token' + slug_url_kwarg = 'participant_id' + form_class = ParticipantStaffForm + template_name = 'cfp/staff/participant_form.html' + + +@staff_required +def conference(request): + form = ConferenceForm(request.POST or None, instance=request.conference) + + if request.method == 'POST' and form.is_valid(): + old_staff = set(request.conference.staff.all()) + new_conference = form.save() + new_staff = set(new_conference.staff.all()) + added_staff = new_staff - old_staff + protocol = 'https' if request.is_secure() else 'http' + base_url = protocol+'://'+request.conference.site.domain + url_login = base_url + reverse('login') + url_password_reset = base_url + reverse('password_reset') + msg_title = _('[{}] You have been added to the staff team').format(request.conference.name) + msg_body_template = _("""Hi {}, + +You have been added to the staff team. + +You can now: +- login: {} +- reset your password: {} + +{} + +""") + # TODO: send bulk emails + for user in added_staff: + msg_body = msg_body_template.format(user.get_full_name(), url_login, url_password_reset, request.conference.name) + send_mail( + msg_title, + msg_body, + request.conference.from_email(), + [user.email], + fail_silently=False, + ) + messages.success(request, _('Modifications successfully saved.')) + return redirect(reverse('conference')) + + return render(request, 'cfp/staff/conference.html', { + 'form': form, + }) + + +class TalkUpdate(StaffRequiredMixin, OnSiteMixin, UpdateView): + model = Talk + slug_field = 'token' + slug_url_kwarg = 'talk_id' + form_class = TalkStaffForm + template_name = 'cfp/staff/talk_form.html' + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.update({ + 'categories': TalkCategory.objects.filter(site=self.request.conference.site), + 'tracks': Track.objects.filter(site=self.request.conference.site), + }) + return kwargs + + +class TrackMixin(OnSiteMixin): + model = Track + + +class TrackList(StaffRequiredMixin, TrackMixin, ListView): + template_name = 'cfp/staff/track_list.html' + + +class TrackFormMixin(TrackMixin): + template_name = 'cfp/staff/track_form.html' + form_class = TrackForm + success_url = reverse_lazy('track-list') + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.update({ + 'conference': self.request.conference, + }) + return kwargs + + +class TrackCreate(StaffRequiredMixin, TrackFormMixin, CreateView): + pass + + +class TrackUpdate(StaffRequiredMixin, TrackFormMixin, UpdateView): + pass + + +class RoomMixin(OnSiteMixin): + model = Room + + +class RoomList(StaffRequiredMixin, RoomMixin, ListView): + template_name = 'cfp/staff/room_list.html' + + +class RoomDetail(StaffRequiredMixin, RoomMixin, DetailView): + template_name = 'cfp/staff/room_details.html' + + +class RoomFormMixin(RoomMixin): + template_name = 'cfp/staff/room_form.html' + form_class = RoomForm + success_url = reverse_lazy('room-list') + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.update({ + 'conference': self.request.conference, + }) + return kwargs + + +class RoomCreate(StaffRequiredMixin, RoomFormMixin, CreateView): + pass + + +class RoomUpdate(StaffRequiredMixin, RoomFormMixin, UpdateView): + pass + + +@staff_required +def create_user(request): + form = CreateUserForm(request.POST or None) + + if request.method == 'POST' and form.is_valid(): + form.save() + messages.success(request, _('User created successfully.')) + return redirect(reverse('create-user')) + + return render(request, 'cfp/staff/create_user.html', { + 'form': form, + }) + + +class Select2View(StaffRequiredMixin, AutoResponseView): + pass diff --git a/conversations/__init__.py b/conversations/__init__.py deleted file mode 100644 index 113bdeb..0000000 --- a/conversations/__init__.py +++ /dev/null @@ -1 +0,0 @@ -default_app_config = 'conversations.apps.ConversationsConfig' diff --git a/conversations/admin.py b/conversations/admin.py deleted file mode 100644 index 431bad1..0000000 --- a/conversations/admin.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.contrib import admin - -from .models import ConversationAboutTalk, ConversationWithParticipant, Message - -admin.site.register(ConversationWithParticipant) -admin.site.register(ConversationAboutTalk) -admin.site.register(Message) diff --git a/conversations/apps.py b/conversations/apps.py deleted file mode 100644 index 7fc0e2b..0000000 --- a/conversations/apps.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.apps import AppConfig - - -class ConversationsConfig(AppConfig): - name = 'conversations' - - def ready(self): - import conversations.signals # noqa diff --git a/conversations/emails.py b/conversations/emails.py deleted file mode 100644 index ba00c6a..0000000 --- a/conversations/emails.py +++ /dev/null @@ -1,76 +0,0 @@ -import re -import chardet -import logging -from functools import reduce - -from email import policy -from email.parser import BytesParser -from email.message import EmailMessage - -from django.conf import settings -from django.contrib.auth.models import User -from django.core.exceptions import PermissionDenied -from django.http import Http404, HttpResponse -from django.shortcuts import get_object_or_404 -from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.http import require_http_methods - -from .models import Message -from .utils import hexdigest_sha256 - - -@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').strip() - if key != settings.REPLY_KEY: - raise PermissionDenied - - if 'email' not in request.FILES: - return HttpResponse(status=400) # Bad Request - - msg = request.FILES['email'] - - msg = BytesParser(policy=policy.default).parsebytes(msg.read()) - body = msg.get_body(preferencelist=('plain',)) - content = body.get_payload(decode=True) - - try: - content = content.decode(body.get_content_charset()) - except Exception: - encoding = chardet.detect(content)['encoding'] - content = content.decode(encoding) - - 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 - addrs = map(lambda x: x.split(',') if x else [], [msg.get('To'), msg.get('Cc')]) - addrs = reduce(lambda x, y: x + y, addrs) - for _mto in map(lambda x: x.strip(), addrs): - 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/migrations/0001_initial.py b/conversations/migrations/0001_initial.py deleted file mode 100644 index 2784369..0000000 --- a/conversations/migrations/0001_initial.py +++ /dev/null @@ -1,64 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.3 on 2017-01-13 10:49 -from __future__ import unicode_literals - -import conversations.utils -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), - ('contenttypes', '0002_remove_content_type_name'), - ('accounts', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='ConversationAboutTalk', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('subscribers', models.ManyToManyField(blank=True, related_name='_conversationabouttalk_subscribers_+', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='ConversationWithParticipant', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('participation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='conversation', to='accounts.Participation')), - ('subscribers', models.ManyToManyField(blank=True, related_name='_conversationwithparticipant_subscribers_+', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - }, - ), - 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)), - ('updated', models.DateTimeField(auto_now=True)), - ('object_id', models.PositiveIntegerField()), - ('token', models.CharField(default=conversations.utils.generate_message_token, max_length=64, unique=True)), - ('content', models.TextField(blank=True)), - ('system', models.BooleanField(default=False)), - ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), - ], - options={ - 'ordering': ['created'], - }, - ), - ] diff --git a/conversations/migrations/0002_conversationabouttalk_talk.py b/conversations/migrations/0002_conversationabouttalk_talk.py deleted file mode 100644 index ddaa634..0000000 --- a/conversations/migrations/0002_conversationabouttalk_talk.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.3 on 2017-01-13 10:49 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('proposals', '0001_initial'), - ('conversations', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='conversationabouttalk', - name='talk', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='conversation', to='proposals.Talk'), - ), - ] diff --git a/conversations/models.py b/conversations/models.py deleted file mode 100644 index f05b7ef..0000000 --- a/conversations/models.py +++ /dev/null @@ -1,127 +0,0 @@ -from django.contrib.auth.models import User -from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation -from django.contrib.contenttypes.models import ContentType -from django.core.urlresolvers import reverse -from django.db import models - -from accounts.models import Participation -from ponyconf.utils import PonyConfModel -from proposals.models import Talk - -from .utils import generate_message_token, notify_by_email - - -class Message(PonyConfModel): - - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) - object_id = models.PositiveIntegerField() - conversation = GenericForeignKey('content_type', 'object_id') - - token = models.CharField(max_length=64, default=generate_message_token, unique=True) - - author = models.ForeignKey(User) - content = models.TextField(blank=True) - system = models.BooleanField(default=False) - - class Meta: - ordering = ['created'] - - def __str__(self): - return "Message from %s" % self.author - - def get_absolute_url(self): - return self.conversation.get_absolute_url() - - -class Conversation(PonyConfModel): - - subscribers = models.ManyToManyField(User, related_name='+', blank=True) - - class Meta: - abstract = True - - -class ConversationWithParticipant(Conversation): - - participation = models.OneToOneField(Participation, related_name='conversation') - messages = GenericRelation(Message) - - uri = 'inbox' - template = 'participant_message' - - def __str__(self): - return "Conversation with %s" % self.participation.user - - def get_absolute_url(self): - return reverse('user-conversation', kwargs={'username': self.participation.user.username}) - - def get_site(self): - return self.participation.site - - def new_message(self, message): - site = self.get_site() - subject = '[%s] Conversation with %s' % (site.name, self.participation.user.profile) - recipients = list(self.subscribers.all()) - # Auto-subscribe - if message.author != self.participation.user and message.author not in recipients: - self.subscribers.add(message.author) - data = { - 'content': message.content, - 'uri': site.domain + reverse('user-conversation', args=[self.participation.user.username]), - } - first = self.messages.first() - if first != message: - ref = first.token - else: - ref = None - notify_by_email('message', data, subject, message.author, recipients, message.token, ref) - - if message.author != self.participation.user: - subject = '[%s] Message notification' % site.name - data.update({ - 'uri': site.domain + reverse('inbox') - }) - notify_by_email('message', data, subject, message.author, [self.participation.user], message.token, ref) - - -class ConversationAboutTalk(Conversation): - - talk = models.OneToOneField(Talk, related_name='conversation') - messages = GenericRelation(Message) - - uri = 'inbox' - template = 'talk_message' - - def __str__(self): - return "Conversation about %s" % self.talk.title - - def get_absolute_url(self): - return self.talk.get_absolute_url() - - def get_site(self): - return self.talk.site - - def new_message(self, message): - site = self.get_site() - first = self.messages.first() - if not message.system and message.author not in self.subscribers.all(): - self.subscribers.add(message.author) - recipients = self.subscribers.all() - data = { - 'uri': site.domain + reverse('show-talk', args=[self.talk.slug]), - } - if first == message: - subject = '[%s] Talk: %s' % (site.name, self.talk.title) - template = 'talk_notification' - ref = None - data.update({ - 'talk': self.talk, - 'proposer': message.author, - 'proposer_uri': site.domain + reverse('show-participant', args=[message.author.username]) - }) - else: - subject = 'Re: [%s] Talk: %s' % (site.name, self.talk.title) - template = 'message' - ref = first.token - data.update({'content': message.content}) - notify_by_email(template, data, subject, message.author, recipients, message.token, ref) diff --git a/conversations/post-mail.sh b/conversations/post-mail.sh deleted file mode 100755 index 3b04715..0000000 --- a/conversations/post-mail.sh +++ /dev/null @@ -1,10 +0,0 @@ -#! /bin/bash - -# Usage: cat email.txt | post-mail.sh REPLY_KEY@https://example.org/conversations/recv/ -# Get the value of REPLY_KEY from the django setting. - -# Postfix users can set up an alias file with this content: -# reply: "|/path/to/post-mail.sh mykey@https://example.org/conversations/recv/ -# don't forget to run postalias and to add the alias file to main.cf under alias_map. - -curl ${@#*\@} -F key=${@%\@*} -F "email=@-;filename=email.txt" diff --git a/conversations/sieve-filter b/conversations/sieve-filter deleted file mode 100755 index c64b574..0000000 --- a/conversations/sieve-filter +++ /dev/null @@ -1,24 +0,0 @@ -#!/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 deleted file mode 100644 index e3c7d4c..0000000 --- a/conversations/signals.py +++ /dev/null @@ -1,64 +0,0 @@ -from django.contrib.auth.models import User -from django.db.models import Q -from django.db.models.signals import post_save -from django.dispatch import receiver - -from ponyconf.decorators import disable_for_loaddata -from accounts.models import Participation -from proposals.models import Talk -from proposals.signals import talk_added, talk_edited - -from .models import ConversationAboutTalk, ConversationWithParticipant, Message - - -@receiver(post_save, sender=Participation, dispatch_uid="Create ConversationWithParticipant") -@disable_for_loaddata -def create_conversation_with_participant(sender, instance, created, **kwargs): - if not created: - return - conversation = ConversationWithParticipant(participation=instance) - conversation.save() - - -@receiver(post_save, sender=Talk, dispatch_uid="Create ConversationAboutTalk") -@disable_for_loaddata -def create_conversation_about_talk(sender, instance, created, **kwargs): - if not created: - return - conversation = ConversationAboutTalk(talk=instance) - conversation.save() - - -def check_talk(talk): - reviewers = User.objects.filter(Q(topic__talk=talk) | Q(participation__site=talk.site, participation__orga=True)) - # Subscribe the reviewers to the conversation about the talk - talk.conversation.subscribers.add(*reviewers) - # Subscribe the reviewers to the conversations with each speaker - for user in talk.speakers.all(): - participation, created = Participation.objects.get_or_create(user=user, site=talk.site) - participation.conversation.subscribers.add(*reviewers) - - -@receiver(talk_added, dispatch_uid="Notify talk added") -def notify_talk_added(sender, instance, author, **kwargs): - check_talk(instance) - message = Message(conversation=instance.conversation, author=author, - content='The talk has been proposed.', system=True) - message.save() - - -@receiver(talk_edited, dispatch_uid="Notify talk edited") -def notify_talk_edited(sender, instance, author, **kwargs): - check_talk(instance) - message = Message(conversation=instance.conversation, author=author, - content='The talk has been modified.', system=True) - message.save() - - -@receiver(post_save, sender=Message, dispatch_uid="Notify new message") -@disable_for_loaddata -def notify_new_message(sender, instance, created, **kwargs): - if not created: - # Possibly send a modification notification? - return - instance.conversation.new_message(instance) diff --git a/conversations/templates/conversations/_message_detail.html b/conversations/templates/conversations/_message_detail.html deleted file mode 100644 index 3766a26..0000000 --- a/conversations/templates/conversations/_message_detail.html +++ /dev/null @@ -1,8 +0,0 @@ -
-
- {{ message.created }} | {{ message.author.profile }} -
-
- {{ message.content|linebreaksbr }} -
-
diff --git a/conversations/templates/conversations/conversation.html b/conversations/templates/conversations/conversation.html deleted file mode 100644 index 47438f6..0000000 --- a/conversations/templates/conversations/conversation.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends 'staff.html' %} - -{% load i18n %} - -{% block correspondentstab %} class="active"{% endblock %} - -{% block content %} - -

{% blocktrans with correspondent=correspondent.profile %}Conversation with {{ correspondent }}{% endblocktrans %}

- -{% for message in message_list %} -{% include 'conversations/_message_detail.html' %} -{% endfor %} - -{% include 'conversations/_message_form.html' %} - -{% endblock %} diff --git a/conversations/templates/conversations/correspondent_list.html b/conversations/templates/conversations/correspondent_list.html deleted file mode 100644 index d4590fa..0000000 --- a/conversations/templates/conversations/correspondent_list.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends 'staff.html' %} - -{% load bootstrap3 i18n %} - -{% block correspondentstab %} class="active"{% endblock %} - -{% block content %} - -

{% trans "Correspondents" %}

-

{% trans "This is the list of participants that you follow." %}

- - - - - - - - - {% for correspondent in correspondent_list %} - - - - - - - {% endfor %} -
#UsernameFull nameAdministration
{{ forloop.counter }}{{ correspondent.user.username }}{{ correspondent.user.get_full_name }} - - {% if request.user in correspondent.conversation.subscribers.all %} - - {% else %} - - {% endif %} -
- -{% endblock %} diff --git a/conversations/templates/conversations/emails/message.html b/conversations/templates/conversations/emails/message.html deleted file mode 100644 index af1a03d..0000000 --- a/conversations/templates/conversations/emails/message.html +++ /dev/null @@ -1,8 +0,0 @@ -{{ content|linebreaksbr }} - -
-{% if answering %} -Reply to this email directly or view it online. -{% else %} -Reply online. -{% endif %} diff --git a/conversations/templates/conversations/emails/message.txt b/conversations/templates/conversations/emails/message.txt deleted file mode 100644 index 060506a..0000000 --- a/conversations/templates/conversations/emails/message.txt +++ /dev/null @@ -1,4 +0,0 @@ -{{ content|safe }} - --- -Reply {% if answering %}to this email directly or view it {% endif %}online: https://{{ uri }} diff --git a/conversations/templates/conversations/emails/talk_notification.html b/conversations/templates/conversations/emails/talk_notification.html deleted file mode 100644 index 1db0dae..0000000 --- a/conversations/templates/conversations/emails/talk_notification.html +++ /dev/null @@ -1,11 +0,0 @@ -Hi!
-
-A new {{ talk.event }} has been proposed by {{ proposer.profile }}!
-
-Title: {{ talk.title }}
-
-Description:
-

{{ talk.description|linebreaksbr }}

-{% if answering %} -
-Reply to this email directly to comment this talk.{% endif %} diff --git a/conversations/templates/conversations/emails/talk_notification.txt b/conversations/templates/conversations/emails/talk_notification.txt deleted file mode 100644 index e02d1cc..0000000 --- a/conversations/templates/conversations/emails/talk_notification.txt +++ /dev/null @@ -1,12 +0,0 @@ -Hi! - -A new talk has been proposed by {{ proposer.profile }}! -See it online: https://{{ uri }} - -Title: {{ talk.title }} - -Description: -{{ talk.description }} -{% if answering %} --- -Reply to this email directly to comment this talk.{% endif %} diff --git a/conversations/templates/conversations/inbox.html b/conversations/templates/conversations/inbox.html deleted file mode 100644 index 4fc83df..0000000 --- a/conversations/templates/conversations/inbox.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends 'base.html' %} - -{% load i18n %} - -{% block inboxtab %} class="active"{% endblock %} - -{% block content %} - -

{% trans "Messaging" %}

-

{% trans "You can use this page to communicate with the staff." %}

- -{% for message in message_list %} -{% include 'conversations/_message_detail.html' %} -{% endfor %} - -{% include 'conversations/_message_form.html' %} - -{% endblock %} diff --git a/conversations/tests.py b/conversations/tests.py deleted file mode 100644 index 54acbce..0000000 --- a/conversations/tests.py +++ /dev/null @@ -1,96 +0,0 @@ -from django.contrib.auth.models import User -from django.contrib.sites.models import Site -from django.core.urlresolvers import reverse -from django.test import TestCase, override_settings -from django.core import mail -from django.conf import settings - -from accounts.models import Participation -from proposals.models import Topic, Talk, Event - -from .models import ConversationAboutTalk, ConversationWithParticipant, Message - - -class ConversationTests(TestCase): - def setUp(self): - a, b, c, d = (User.objects.create_user(guy, email='%s@example.org' % guy, password=guy) for guy in 'abcd') - d.is_superuser = True - d.save() - pa, _ = Participation.objects.get_or_create(user=a, site=Site.objects.first()) - conversation, _ = ConversationWithParticipant.objects.get_or_create(participation=pa) - Message.objects.create(content='allo', conversation=conversation, author=b) - Message.objects.create(content='aluil', conversation=conversation, author=a) - site = Site.objects.first() - Talk.objects.get_or_create(site=site, proposer=a, title='a talk', description='yay', event=Event.objects.get(site=site, name='other')) - - def test_models(self): - talk, participant, message = (model.objects.first() for model in - (ConversationAboutTalk, ConversationWithParticipant, Message)) - self.assertEqual(str(talk), 'Conversation about a talk') - self.assertEqual(str(participant), 'Conversation with a') - self.assertEqual(str(message), 'Message from b') - self.assertEqual(message.get_absolute_url(), '/conversations/with/a/') - self.assertEqual(talk.get_absolute_url(), '/talk/details/a-talk') - - def test_views(self): - url = ConversationWithParticipant.objects.first().get_absolute_url() - self.assertEqual(self.client.get(url).status_code, 302) - self.client.login(username='c', password='c') - self.assertEqual(self.client.get(url).status_code, 403) - self.assertEqual(self.client.get(reverse('list-correspondents')).status_code, 403) # c is not staff - self.assertEqual(self.client.get(reverse('inbox')).status_code, 200) - self.client.post(reverse('inbox'), {'content': 'coucou'}) - self.client.login(username='d', password='d') - self.client.post(url, {'content': 'im superuser'}) - self.assertEqual(Message.objects.last().content, 'im superuser') - self.client.login(username='d', password='d') - self.assertEqual(self.client.get(reverse('list-correspondents')).status_code, 200) - - -@override_settings(DEFAULT_FROM_EMAIL='noreply@example.org', - REPLY_EMAIL='reply@example.org', - REPLY_KEY='secret') -class EmailTests(TestCase): - def setUp(self): - for guy in 'abcd': - setattr(self, guy, User.objects.create_user(guy, email='%s@example.org' % guy, password=guy)) - a_p = Participation(user=self.a, site=Site.objects.first()) - a_p.orga = True - a_p.save() - t = Topic(name='Topic 1', site=Site.objects.first()) - t.save() - t.reviewers.add(self.b) - - - def test_talk_notification(self): - self.client.login(username='c', password='c') - # Check that login create participation - self.assertTrue(Participation.objects.filter(user=self.c, site=Site.objects.first()).exists()) - # Propose new talk - topic = Topic.objects.get(name='Topic 1') - response = self.client.post(reverse('add-talk'), { - 'title': 'Talk 1', - 'description': 'This is the first talk', - 'topics': (topic.pk,), - 'event': 1, - 'speakers': (self.c.pk, self.d.pk), - }, follow=True) - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'Talk proposed') # check messages notification - talk = Talk.objects.get(site=Site.objects.first(), title='Talk 1') - conv = ConversationAboutTalk.objects.get(talk=talk) - # Orga and reviewer should have been subscribed to the conversation about the talk - self.assertEqual(set([self.a, self.b]), set(conv.subscribers.all())) - # Both should have received an email notification - self.assertEqual(len(mail.outbox), 2) - for m in mail.outbox: - self.assertEqual(m.from_email, '%s <%s>' % (self.c.profile, settings.DEFAULT_FROM_EMAIL)) - self.assertTrue('Talk: %s' % talk.title) - self.assertTrue(len(m.to), 1) - self.assertTrue(m.to[0] in [ self.a.email, self.b.email ]) - # Both should have been subscribed to conversations with each speakers - for user in [self.c, self.d]: - # Participation should have been created as the user is a speaker - p = Participation.objects.get(user=user, site=Site.objects.first()) - conv = ConversationWithParticipant.objects.get(participation=p) - self.assertEqual(set([self.a, self.b]), set(conv.subscribers.all())) diff --git a/conversations/urls.py b/conversations/urls.py deleted file mode 100644 index 34fd082..0000000 --- a/conversations/urls.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.conf.urls import url - -from conversations import emails, views - -urlpatterns = [ - url(r'^recv/$', emails.email_recv), # API - url(r'^inbox/$', views.user_conversation, name='inbox'), - url(r'^$', views.correspondent_list, name='list-correspondents'), - url(r'^with/(?P[\w.@+-]+)/$', views.user_conversation, name='user-conversation'), - url(r'^about/(?P[\w.@+-]+)/$', views.talk_conversation, name='talk-conversation'), - url(r'^subscribe/(?P[\w.@+-]+)/$', views.subscribe, name='subscribe-conversation'), - url(r'^unsubscribe/(?P[\w.@+-]+)/$', views.unsubscribe, name='unsubscribe-conversation'), -] diff --git a/conversations/utils.py b/conversations/utils.py deleted file mode 100644 index 1a48cda..0000000 --- a/conversations/utils.py +++ /dev/null @@ -1,74 +0,0 @@ -import hashlib - -from django.conf import settings -from django.core import mail -from django.core.mail import EmailMultiAlternatives -from django.template.loader import render_to_string -from django.utils.crypto import get_random_string - - -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)[0:12] - - 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') - - -def notify_by_email(template, data, subject, sender, dests, message_id, ref=None): - - if hasattr(settings, 'REPLY_EMAIL') and hasattr(settings, 'REPLY_KEY'): - data.update({'answering': True}) - - text_message = render_to_string('conversations/emails/%s.txt' % template, data) - html_message = render_to_string('conversations/emails/%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, dest_emails, reply_to, headers in mails: - text_message, html_message = message - msg = EmailMultiAlternatives(subject, text_message, from_email, dest_emails, 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/views.py b/conversations/views.py deleted file mode 100644 index 476876c..0000000 --- a/conversations/views.py +++ /dev/null @@ -1,100 +0,0 @@ -from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.contrib.auth.models import User -from django.contrib.sites.shortcuts import get_current_site -from django.core.exceptions import PermissionDenied -from django.core.urlresolvers import reverse -from django.shortcuts import get_object_or_404, redirect, render -from django.utils.translation import ugettext_lazy as _ - -from accounts.decorators import staff_required -from accounts.models import Participation -from proposals.models import Talk - -from .forms import MessageForm - - -@login_required -def user_conversation(request, username=None): - - if username: - p = Participation.objects.get(user=request.user, site=get_current_site(request)) - if not p.is_staff() and not p.is_orga(): - raise PermissionDenied() - user = get_object_or_404(User, username=username) - template = 'conversations/conversation.html' - else: - user = request.user - template = 'conversations/inbox.html' - - participation = get_object_or_404(Participation, user=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(): - form.instance.conversation = conversation - form.instance.author = request.user - form.save() - messages.success(request, _('Message sent!')) - if username: - return redirect(reverse('user-conversation', args=[username])) - else: - return redirect('inbox') - - return render(request, template, { - 'correspondent': user, - 'message_list': message_list, - 'form': form, - }) - - -@login_required -def talk_conversation(request, talk): - - talk = get_object_or_404(Talk, slug=talk) - form = MessageForm(request.POST or None) - - if request.method == 'POST' and form.is_valid(): - form.instance.conversation = talk.conversation - form.instance.author = request.user - form.save() - messages.success(request, 'Message sent!') - - return redirect(talk.get_absolute_url()) - - -@staff_required -def correspondent_list(request): - - correspondent_list = Participation.objects.filter(site=get_current_site(request), - conversation__subscribers=request.user) - - return render(request, 'conversations/correspondent_list.html', { - 'correspondent_list': correspondent_list, - }) - - -@staff_required -def subscribe(request, username): - - participation = get_object_or_404(Participation, user__username=username, site=get_current_site(request)) - participation.conversation.subscribers.add(request.user) - messages.success(request, 'Subscribed.') - - next_url = request.GET.get('next') or reverse('user-conversation', args=[username]) - - return redirect(next_url) - - -@staff_required -def unsubscribe(request, username): - - participation = get_object_or_404(Participation, user__username=username, site=get_current_site(request)) - participation.conversation.subscribers.remove(request.user) - messages.success(request, 'Unsubscribed.') - - next_url = request.GET.get('next') or reverse('user-conversation', args=[username]) - - return redirect(next_url) diff --git a/locale/fr/LC_MESSAGES/django.mo b/locale/fr/LC_MESSAGES/django.mo index 10fa80a..b25001e 100644 Binary files a/locale/fr/LC_MESSAGES/django.mo and b/locale/fr/LC_MESSAGES/django.mo differ diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 561c8f7..2db855c 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -3,12 +3,11 @@ # This file is distributed under the same license as the PonyConf package. # FIRST AUTHOR , YEAR. # -#, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2016-11-25 13:33+0000\n" +"POT-Creation-Date: 2017-08-12 00:02+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -25,81 +24,33 @@ msgstr "" "Par exemple, vous devez rentrer le samedi soir, vous ne pouvez pas manger de " "viande, etc." -#: accounts/models.py:18 +#: accounts/models.py:18 cfp/models.py:96 msgid "Phone number" msgstr "Numéro de téléphone" -#: accounts/models.py:19 -#: accounts/templates/accounts/participant_details.html:20 -msgid "Biography" -msgstr "Biographie" +#: accounts/models.py:20 cfp/models.py:89 +msgid "Twitter" +msgstr "" -#: accounts/models.py:54 -msgid "No" -msgstr "Non" +#: accounts/models.py:21 cfp/models.py:90 +msgid "LinkedIn" +msgstr "" -#: accounts/models.py:55 -msgid "Hotel" -msgstr "Hotel" +#: accounts/models.py:22 cfp/models.py:91 +msgid "Github" +msgstr "" -#: accounts/models.py:56 -msgid "Homestay" -msgstr "Logement chez habitant" +#: accounts/models.py:23 cfp/models.py:92 +msgid "Website" +msgstr "" -#: accounts/models.py:63 -msgid "Defray transportation?" -msgstr "Défraiement du transport ?" +#: accounts/models.py:24 cfp/models.py:93 +msgid "Facebook" +msgstr "" -#: accounts/models.py:66 -msgid "I want to travel by" -msgstr "Je souhaite voyager en" - -#: accounts/models.py:67 -msgid "Departure city" -msgstr "Ville de départ" - -#: accounts/models.py:68 -msgid "Return city" -msgstr "Ville de retour" - -#: accounts/models.py:68 -msgid "If different from departure city" -msgstr "Si différent de la ville de départ" - -#: accounts/models.py:71 -msgid "Need accommodation?" -msgstr "Besoin d'un hébergement ?" - -#: accounts/models.py:74 -#: accounts/templates/accounts/participant_details.html:68 -msgid "Constraints" -msgstr "Contraintes" - -#: accounts/models.py:75 -msgid "I can output" -msgstr "Sortie vidéo" - -#: accounts/models.py:76 -msgid "I need sound" -msgstr "J’ai besoin de son" - -#: accounts/models.py:78 -msgid "I'm ok to be recorded on video" -msgstr "J’accepte d’être enregistré en vidéo" - -#: accounts/models.py:79 -msgid "Video licence" -msgstr "Licence vidéo" - -#: accounts/models.py:81 -#: accounts/templates/accounts/participant_details.html:71 -#: proposals/models.py:161 proposals/templates/proposals/talk_detail.html:98 -msgid "Notes" -msgstr "Notes" - -#: accounts/models.py:81 -msgid "This field is only visible by organizers." -msgstr "Ce champs est uniquement visible par les organisateurs." +#: accounts/models.py:25 cfp/models.py:94 +msgid "Mastodon" +msgstr "" #: accounts/signals.py:16 msgid "Train" @@ -144,17 +95,27 @@ msgid "Contact" msgstr "Contacter" #: accounts/templates/accounts/participant_details.html:15 +#: cfp/templates/cfp/staff/participant_details.html:10 +#: cfp/templates/cfp/staff/talk_details.html:10 #: proposals/templates/proposals/talk_detail.html:16 msgid "Edit" msgstr "Éditer" +#: accounts/templates/accounts/participant_details.html:20 cfp/models.py:86 +#: cfp/templates/cfp/staff/participant_details.html:12 +msgid "Biography" +msgstr "Biographie" + #: accounts/templates/accounts/participant_details.html:27 -#: ponyconf/templates/staff.html:11 +#: cfp/templates/cfp/staff/base.html:17 +#: cfp/templates/cfp/staff/participant_details.html:33 +#: cfp/templates/cfp/staff/talk_list.html:8 #: proposals/templates/proposals/talk_list.html:9 msgid "Talks" msgstr "Exposés" #: accounts/templates/accounts/participant_details.html:32 +#: cfp/templates/cfp/staff/talk_details.html:12 msgid "Information" msgstr "Informations" @@ -210,13 +171,23 @@ msgstr "Accepte d’être enregistré en vidéo :" msgid "Video licence:" msgstr "Licence vidéo :" +#: accounts/templates/accounts/participant_details.html:68 +msgid "Constraints" +msgstr "Contraintes" + +#: accounts/templates/accounts/participant_details.html:71 cfp/forms.py:53 +#: cfp/models.py:100 cfp/templates/cfp/staff/participant_details.html:16 +#: cfp/templates/cfp/staff/talk_details.html:75 proposals/models.py:161 +#: proposals/templates/proposals/talk_detail.html:102 +msgid "Notes" +msgstr "Notes" + #: accounts/templates/accounts/participant_edit.html:11 #, python-format msgid "%(profile)s's profile" msgstr "Profil de %(profile)s" -#: accounts/templates/accounts/participant_list.html:9 -#: ponyconf/templates/staff.html:16 volunteers/models.py:15 +#: accounts/templates/accounts/participant_list.html:9 volunteers/models.py:15 msgid "Participants" msgstr "Participants" @@ -225,16 +196,14 @@ msgid "View conversation" msgstr "Afficher la discussion" #: accounts/templates/accounts/participant_list.html:31 -#: conversations/templates/conversations/correspondent_list.html:27 msgid "Unsubscribe from the conversation" msgstr "Se désabonner de la discussion" #: accounts/templates/accounts/participant_list.html:35 -#: conversations/templates/conversations/correspondent_list.html:29 msgid "Subscribe to the conversation" msgstr "S’abonner à la discussion" -#: accounts/templates/accounts/profile.html:12 ponyconf/templates/base.html:33 +#: accounts/templates/accounts/profile.html:12 msgid "Profile" msgstr "Profil" @@ -246,7 +215,9 @@ msgstr "Changer d’avatar" msgid "Submit" msgstr "Envoyer" -#: accounts/templates/accounts/profile.html:36 ponyconf/templates/_form.html:16 +#: accounts/templates/accounts/profile.html:36 +#: cfp/templates/cfp/staff/create_user.html:14 +#: cfp/templates/cfp/staff/talk_decide.html:22 ponyconf/templates/_form.html:16 #: proposals/templates/proposals/talk_decide.html:23 msgid "Cancel" msgstr "Annuler" @@ -292,35 +263,6 @@ msgstr "" msgid "Delete These" msgstr "Supprimer ces" -#: accounts/templates/registration/login.html:11 -#: ponyconf/templates/base.html:37 -msgid "Login" -msgstr "Se connecter" - -#: accounts/templates/registration/login.html:26 -#, python-format -msgid "" -"You do not have an account yet? Please register." -msgstr "Pas encore de compte ? Enregistrez-vous." - -#: accounts/templates/registration/password_change_form.html:9 -msgid "Password Change" -msgstr "Changement de mot de passe" - -#: accounts/templates/registration/password_reset_form.html:9 -msgid "Password Reset" -msgstr "Réinitialisation du mot de passe" - -#: accounts/templates/registration/registration_form.html:11 -msgid "Registration" -msgstr "Inscription" - -#: accounts/templates/registration/registration_form.html:26 -#, python-format -msgid "" -"You already have an account? Please login." -msgstr "Déjà inscrit ? Connectez-vous." - #: accounts/views.py:18 msgid "Reset your password" msgstr "Réinitialiser son mot de passe" @@ -347,156 +289,804 @@ msgstr "%(name)s a été ajouté aux participants" msgid "%(name)s is already a participant" msgstr "%(name)s est déjà participant" -#: conversations/templates/conversations/_message_form.html:4 -msgid "Send a message" -msgstr "Envoyer un message" +#: cfp/forms.py:16 +msgid "Pending decision" +msgstr "Décision en attente" -#: conversations/templates/conversations/_message_form.html:12 -msgid "Send" +#: cfp/forms.py:17 cfp/templates/cfp/staff/talk_list.html:66 +#: proposals/templates/proposals/talk_list.html:78 +msgid "Accepted" +msgstr "Accepté" + +#: cfp/forms.py:18 cfp/templates/cfp/staff/talk_list.html:68 +#: proposals/templates/proposals/talk_list.html:80 +msgid "Declined" +msgstr "Décliné" + +#: cfp/forms.py:50 cfp/forms.py:62 cfp/templates/cfp/staff/talk_details.html:15 +msgid "Category" +msgstr "Catégorie" + +#: cfp/forms.py:51 cfp/templates/cfp/staff/talk_list.html:40 +#: proposals/models.py:155 proposals/templates/proposals/talk_list.html:47 +msgid "Title" +msgstr "Titre" + +#: cfp/forms.py:52 cfp/models.py:135 +#: cfp/templates/cfp/staff/talk_details.html:61 proposals/models.py:54 +#: proposals/models.py:77 proposals/models.py:158 +#: proposals/templates/proposals/talk_detail.html:72 volunteers/models.py:14 +msgid "Description" +msgstr "Description" + +#: cfp/forms.py:56 +msgid "Visible by speakers" +msgstr "Visible par les orateurs" + +#: cfp/forms.py:68 cfp/templates/cfp/staff/talk_details.html:18 +#: cfp/templates/cfp/staff/talk_details.html:81 +#: cfp/templates/cfp/staff/talk_list.html:44 +#: proposals/templates/proposals/talk_detail.html:110 +#: proposals/templates/proposals/talk_list.html:52 +msgid "Status" +msgstr "Statut" + +#: cfp/forms.py:74 cfp/models.py:267 +#: cfp/templates/cfp/staff/talk_details.html:21 +#: cfp/templates/cfp/staff/talk_list.html:43 +#: cfp/templates/cfp/staff/track_form.html:14 proposals/models.py:160 +#: proposals/templates/proposals/talk_detail.html:33 +#: proposals/templates/proposals/talk_detail.html:89 +#: proposals/templates/proposals/talk_list.html:51 +#: proposals/templates/proposals/track_form.html:14 +msgid "Track" +msgstr "Session" + +#: cfp/forms.py:80 cfp/templates/cfp/staff/talk_details.html:84 +#: proposals/templates/proposals/talk_detail.html:115 +msgid "Vote" +msgstr "Vote" + +#: cfp/forms.py:81 proposals/forms.py:72 +msgid "Filter talks you already / not yet voted for" +msgstr "" +"Filtrer les propositions pour lesquelles vous avez déjà voté / pas encore " +"voté" + +#: cfp/forms.py:84 cfp/templates/cfp/staff/room_form.html:14 +#: cfp/templates/cfp/staff/talk_details.html:35 +msgid "Room" +msgstr "Salle" + +#: cfp/forms.py:85 proposals/forms.py:73 +msgid "Filter talks already / not yet affected to a room" +msgstr "Filtrer les exposés déjà / pas encore affectées à une salle" + +#: cfp/forms.py:88 +msgid "Scheduled" +msgstr "Programmé" + +#: cfp/forms.py:89 proposals/forms.py:74 +msgid "Filter talks already / not yet scheduled" +msgstr "Filtrer les exposés déjà / pas encore planifiées" + +#: cfp/forms.py:98 +msgid "Not assigned" +msgstr "Pas encore assignée." + +#: cfp/forms.py:107 cfp/models.py:133 +#: cfp/templates/cfp/staff/participant_list.html:49 proposals/models.py:52 +#: proposals/models.py:75 proposals/models.py:132 volunteers/models.py:12 +msgid "Name" +msgstr "Nom" + +#: cfp/forms.py:124 +msgid "New staff members will be informed of their new position by e-mail." +msgstr "" +"Les nouveaux membres du staff seront informés de leur nouveau rôle par " +"courrier électronique." + +#: cfp/forms.py:144 +msgid "An user with that firstname and that lastname already exists." +msgstr "Un utilisateur avec ce prénom et ce nom existe déjà." + +#: cfp/forms.py:149 +msgid "A user with that email already exists." +msgstr "Un utilisateur avec cet email existe déjà." + +#: cfp/models.py:26 +msgid "Conference name" +msgstr "Nom de la conférence" + +#: cfp/models.py:27 +msgid "Homepage (markdown)" +msgstr "Page d’accueil (markdown)" + +#: cfp/models.py:28 +msgid "Venue information" +msgstr "Informations sur le lieu" + +#: cfp/models.py:29 +msgid "City" +msgstr "Ville" + +#: cfp/models.py:30 +msgid "Contact email" +msgstr "Email de contact" + +#: cfp/models.py:31 +msgid "Reply email" +msgstr "Adresse de réponse" + +#: cfp/models.py:32 +msgid "Staff members" +msgstr "Membres du staff" + +#: cfp/models.py:33 +msgid "Secure domain (HTTPS)" +msgstr "Domaine sécurisé (HTTPS)" + +#: cfp/models.py:61 +#, python-brace-format +msgid "" +"The reply email should be a formatable string accepting a token argument (e." +"g. ponyconf+{token}@exemple.com)." +msgstr "" +"L’adresse de réponse doit être une chaine de texte formatable avec un " +"argument « token » (e.g. ponyconf+{token}@exemple.com)." + +#: cfp/models.py:83 +msgid "Your Name" +msgstr "Votre Nom" + +#: cfp/models.py:100 +msgid "This field is only visible by organizers." +msgstr "Ce champs est uniquement visible par les organisateurs." + +#: cfp/models.py:187 proposals/models.py:96 +msgid "Default duration (min)" +msgstr "Durée par défaut (min)" + +#: cfp/models.py:188 proposals/models.py:97 +msgid "Color on program" +msgstr "Couleur sur le programme" + +#: cfp/models.py:189 proposals/models.py:98 +msgid "Label on program" +msgstr "Label dans le xml du programme" + +#: cfp/models.py:262 cfp/templates/cfp/staff/base.html:18 +#: cfp/templates/cfp/staff/participant_list.html:8 +#: cfp/templates/cfp/staff/talk_details.html:65 +#: cfp/templates/cfp/staff/talk_list.html:42 proposals/models.py:154 +#: proposals/templates/proposals/speaker_list.html:9 +#: proposals/templates/proposals/talk_detail.html:76 +#: proposals/templates/proposals/talk_list.html:49 +msgid "Speakers" +msgstr "Orateurs" + +#: cfp/models.py:263 +msgid "Talk Title" +msgstr "Titre de votre proposition:" + +#: cfp/models.py:266 +msgid "Description of your talk" +msgstr "Description de votre proposition" + +#: cfp/models.py:268 +msgid "Message to organizers" +msgstr "Message aux organisateurs" + +#: cfp/models.py:268 +msgid "" +"If you have any constraint or if you have anything that may help you to " +"select your talk, like a video or slides of your talk, please write it down " +"here" +msgstr "" +"Si vous avez une contrainte, ou un élément qui nous aide à sélectionner " +"votre proposition, comme une vidéo, des slides, n'hésitez pas à les ajouter " +"ici." + +#: cfp/models.py:269 +msgid "Talk Category" +msgstr "Catégorie de proposition" + +#: cfp/models.py:270 +msgid "I'm ok to be recorded on video" +msgstr "J’accepte d’être enregistré en vidéo" + +#: cfp/models.py:271 +msgid "Video licence" +msgstr "Licence vidéo" + +#: cfp/models.py:272 +msgid "I need sound" +msgstr "J’ai besoin de son" + +#: cfp/models.py:274 +msgid "Beginning date and time" +msgstr "Date et heure de début" + +#: cfp/models.py:275 proposals/models.py:165 +msgid "Duration (min)" +msgstr "Durée (min)" + +#: cfp/signals.py:79 +#, python-format +msgid "[%(prefix)s] Message from the staff" +msgstr "[%(prefix)s] Message du staff" + +#: cfp/signals.py:80 +#, python-format +msgid "[%(prefix)s] Conversation with %(dest)s" +msgstr "[%(prefix)s] Conversation avec %(dest)s" + +#: cfp/signals.py:96 +#, python-format +msgid "[%(prefix)s] Talk: %(talk)s" +msgstr "[%(prefix)s] Talk: %(talk)s" + +#: cfp/templates/cfp/closed.html:9 cfp/templates/cfp/propose.html:11 +#: cfp/templates/cfp/speaker.html:11 +#: proposals/templates/proposals/participate.html:9 +msgid "Participate" +msgstr "Participer" + +#: cfp/templates/cfp/closed.html:13 +msgid "Sorry, the Call for Participation is closed!" +msgstr "Désolé, l’appel à participation est fermé." + +#: cfp/templates/cfp/complete.html:10 +msgid "Your proposition have been successfully submitted!" +msgstr "Votre proposition a été transmise avec succès !" + +#: cfp/templates/cfp/complete.html:16 +msgid "Thanks for your proposal" +msgstr "Merci pour votre proposition" + +#: cfp/templates/cfp/complete.html:17 +msgid "You can at anytime:" +msgstr "Vous pouvez à tout moment :" + +#: cfp/templates/cfp/complete.html:19 +msgid "Edit your talk:" +msgstr "Éditer un exposé" + +#: cfp/templates/cfp/complete.html:20 +msgid "Add an additionnal speaker:" +msgstr "Ajouter un co-speaker :" + +#: cfp/templates/cfp/complete.html:21 +msgid "Edit your profile:" +msgstr "Éditer votre profil :" + +#: cfp/templates/cfp/complete.html:24 +msgid "An email has been sent to you with those URLs" +msgstr "Un mail vous a été envoyé avec toutes les URLs" + +#: cfp/templates/cfp/propose.html:22 cfp/templates/cfp/speaker.html:21 +#: cfp/templates/cfp/staff/conference.html:14 +#: cfp/templates/cfp/staff/create_user.html:13 +msgid "Save" msgstr "Envoyer" -#: conversations/templates/conversations/conversation.html:9 -#, python-format -msgid "Conversation with %(correspondent)s" -msgstr "Conversation avec %(correspondent)s" +#: cfp/templates/cfp/staff/base.html:19 +#: cfp/templates/cfp/staff/track_list.html:9 +#: proposals/templates/proposals/track_list.html:9 +msgid "Tracks" +msgstr "Sessions" -#: conversations/templates/conversations/correspondent_list.html:9 -#: ponyconf/templates/staff.html:17 -msgid "Correspondents" -msgstr "Correspondants" +#: cfp/templates/cfp/staff/base.html:20 +#: cfp/templates/cfp/staff/room_list.html:9 +#: planning/templates/planning/room_list.html:9 +msgid "Rooms" +msgstr "Salles" -#: conversations/templates/conversations/correspondent_list.html:10 -msgid "This is the list of participants that you follow." -msgstr "Ceci est la liste des participants que vous suivez." +#: cfp/templates/cfp/staff/base.html:21 +#: cfp/templates/cfp/staff/conference.html:8 +msgid "Conference" +msgstr "Conférence" -#: conversations/templates/conversations/inbox.html:9 +#: cfp/templates/cfp/staff/base.html:29 +msgid "Please select a category." +msgstr "Veuillez sélectionner une catégorie." + +#: cfp/templates/cfp/staff/conference.html:13 +#: cfp/templates/cfp/staff/create_user.html:8 +msgid "Add a new user" +msgstr "Ajouter un nouvel utilisateur" + +#: cfp/templates/cfp/staff/participant_details.html:20 +msgid "Informations" +msgstr "Informations" + +#: cfp/templates/cfp/staff/participant_details.html:22 +msgid "E-mail:" +msgstr "E-mail :" + +#: cfp/templates/cfp/staff/participant_details.html:23 +msgid "Twitter:" +msgstr "Twitter :" + +#: cfp/templates/cfp/staff/participant_details.html:24 +msgid "LinkedIn:" +msgstr "LinkedIn :" + +#: cfp/templates/cfp/staff/participant_details.html:25 +msgid "Github:" +msgstr "Github :" + +#: cfp/templates/cfp/staff/participant_details.html:26 +msgid "Website:" +msgstr "Website :" + +#: cfp/templates/cfp/staff/participant_details.html:27 +msgid "Facebook:" +msgstr "Facebook :" + +#: cfp/templates/cfp/staff/participant_details.html:28 +msgid "Mastodon:" +msgstr "Mastodon :" + +#: cfp/templates/cfp/staff/participant_details.html:29 +msgid "Phone number:" +msgstr "Numéro de téléphone :" + +#: cfp/templates/cfp/staff/participant_details.html:30 +msgid "Language:" +msgstr "Langue :" + +#: cfp/templates/cfp/staff/participant_details.html:40 +#: proposals/templates/proposals/_talk_list.html:8 +msgid "by" +msgstr "par" + +#: cfp/templates/cfp/staff/participant_details.html:43 +#: cfp/templates/cfp/staff/room_details.html:21 +#: cfp/templates/cfp/staff/room_details.html:39 +#: cfp/templates/cfp/staff/talk_list.html:59 +#: proposals/templates/proposals/_talk_list.html:11 +#: proposals/templates/proposals/_talk_list.html:17 +#: proposals/templates/proposals/talk_list.html:66 +msgid "and" +msgstr "et" + +#: cfp/templates/cfp/staff/participant_details.html:46 +#: proposals/templates/proposals/_talk_list.html:14 +msgid "in" +msgstr "dans la session" + +#: cfp/templates/cfp/staff/participant_details.html:52 +#: proposals/templates/proposals/_talk_list.html:23 +msgid "No talks" +msgstr "Aucun exposé" + +#: cfp/templates/cfp/staff/participant_details.html:55 +#: cfp/templates/cfp/staff/talk_details.html:114 msgid "Messaging" msgstr "Messagerie" -#: conversations/templates/conversations/inbox.html:10 -msgid "You can use this page to communicate with the staff." +#: cfp/templates/cfp/staff/participant_details.html:59 +msgid "" +"Send a message – this message will be received by this participant and " +"all the staff team" msgstr "" -"Vous pouvez utiliser cette page pour communiquer avec l’équipe organisatrice." +"Envoyer un message – ce message sera reçu par le participant et l’équipe " +"d’organisation" -#: conversations/views.py:40 -msgid "Message sent!" -msgstr "Message envoyé !" +#: cfp/templates/cfp/staff/participant_form.html:8 +msgid "Edit a speaker" +msgstr "Éditer un orateur" -#: planning/templates/planning/public-program.html:8 -#: planning/templates/planning/schedule.html:9 ponyconf/templates/staff.html:15 -msgid "Schedule" -msgstr "Programme" +#: cfp/templates/cfp/staff/participant_list.html:45 +#: cfp/templates/cfp/staff/talk_list.html:35 +#: proposals/templates/proposals/speaker_list.html:44 +#: proposals/templates/proposals/talk_list.html:43 +#: volunteers/templates/volunteers/volunteer_list.html:25 +msgid "Total:" +msgstr "Total :" -#: planning/templates/planning/room_detail.html:13 +#: cfp/templates/cfp/staff/participant_list.html:45 +#: proposals/templates/proposals/speaker_list.html:44 +msgid "speaker" +msgstr "orateur" + +#: cfp/templates/cfp/staff/participant_list.html:50 +#: proposals/templates/proposals/speaker_list.html:50 +msgid "Talk count" +msgstr "Nombre d’exposé" + +#: cfp/templates/cfp/staff/participant_list.html:70 +#, python-format +msgid "accepted: %(accepted)s" +msgid_plural "accepted: %(accepted)s" +msgstr[0] "accepté : %(accepted)s" +msgstr[1] "acceptés : %(accepted)s" + +#: cfp/templates/cfp/staff/participant_list.html:72 +#, python-format +msgid "pending: %(pending)s" +msgid_plural "pending: %(pending)s" +msgstr[0] "en attente : %(pending)s" +msgstr[1] "en attente : %(pending)s" + +#: cfp/templates/cfp/staff/participant_list.html:74 +#, python-format +msgid "refused: %(refused)s" +msgid_plural "refused: %(refused)s" +msgstr[0] "refusé : %(refused)s" +msgstr[1] "refusés : %(refused)s" + +#: cfp/templates/cfp/staff/room_details.html:13 +#: planning/templates/planning/room_detail.html:14 msgid "Scheduled talks" msgstr "Exposés planifiés" -#: planning/templates/planning/room_detail.html:22 -#: planning/templates/planning/room_detail.html:33 +#: cfp/templates/cfp/staff/room_details.html:28 +#: cfp/templates/cfp/staff/room_details.html:45 +#: planning/templates/planning/room_detail.html:23 +#: planning/templates/planning/room_detail.html:34 msgid "No talks." msgstr "Aucun exposé." -#: planning/templates/planning/room_detail.html:25 +#: cfp/templates/cfp/staff/room_details.html:31 +#: planning/templates/planning/room_detail.html:26 msgid "Unscheduled talks" msgstr "Exposés non planifiés" -#: planning/templates/planning/room_form.html:14 -msgid "Modify the room" -msgstr "Modifier la salle" - +#: cfp/templates/cfp/staff/room_list.html:11 #: planning/templates/planning/room_form.html:14 #: planning/templates/planning/room_list.html:12 msgid "Add a room" msgstr "Ajouter une salle" -#: planning/templates/planning/room_list.html:9 -#: ponyconf/templates/staff.html:14 -msgid "Rooms" -msgstr "Salles" - -#: planning/templates/planning/room_list.html:21 +#: cfp/templates/cfp/staff/room_list.html:21 +#: planning/templates/planning/room_list.html:24 msgid "place" msgstr "place" -#: planning/templates/planning/room_list.html:24 +#: cfp/templates/cfp/staff/room_list.html:23 +#: planning/templates/planning/room_list.html:27 msgid "Some talks are not scheduled yet." msgstr "Certains exposés ne sont pas encore planifiés." -#: planning/templates/planning/room_list.html:25 -#: proposals/templates/proposals/talk_list.html:42 +#: cfp/templates/cfp/staff/room_list.html:24 +#: cfp/templates/cfp/staff/talk_list.html:35 +#: cfp/templates/cfp/staff/track_list.html:21 +#: planning/templates/planning/room_list.html:28 +#: proposals/templates/proposals/talk_list.html:43 #: proposals/templates/proposals/topic_list.html:23 #: proposals/templates/proposals/track_list.html:23 msgid "talk" msgstr "exposé" -#: planning/templates/planning/room_list.html:37 +#: cfp/templates/cfp/staff/room_list.html:33 +#: planning/templates/planning/room_list.html:40 msgid "No rooms." msgstr "Aucune salle." -#: ponyconf/settings.py:141 +#: cfp/templates/cfp/staff/talk_decide.html:8 +#: proposals/templates/proposals/talk_decide.html:9 +msgid "Are you sure to accept this proposals?" +msgstr "Êtes-vous sûr d’accepter cette propositon d’intervention ?" + +#: cfp/templates/cfp/staff/talk_decide.html:8 +#: proposals/templates/proposals/talk_decide.html:9 +msgid "Are you sure to decline this proposals?" +msgstr "Êtes-vous sûr de décliner cette propositon d’intervention ?" + +#: cfp/templates/cfp/staff/talk_decide.html:10 +#: proposals/templates/proposals/talk_decide.html:11 +msgid "Information about the proposals" +msgstr "Information sur la propositon d’intervention" + +#: cfp/templates/cfp/staff/talk_decide.html:11 +#: proposals/templates/proposals/talk_decide.html:12 +msgid "Title:" +msgstr "Titre :" + +#: cfp/templates/cfp/staff/talk_decide.html:12 +#: proposals/templates/proposals/talk_decide.html:13 +msgid "Kind:" +msgstr "Type d’intervention :" + +#: cfp/templates/cfp/staff/talk_decide.html:14 +#: proposals/templates/proposals/talk_decide.html:15 +msgid "Information for the proposer" +msgstr "Information à destination de l’auteur de la proposition" + +#: cfp/templates/cfp/staff/talk_decide.html:18 +#: proposals/templates/proposals/talk_decide.html:19 +msgid "" +"If you want to send a message to the proposer, please enter it below. " +"Remember to indicate which talk your message is reffering." +msgstr "" +"Si vous souhaitez envoyer un message à l’auteur de la proposition, saisissez-" +"le ci-dessous. N’oubliez pas de spécifier à quelle proposition " +"d’intervention votre message fait référence." + +#: cfp/templates/cfp/staff/talk_decide.html:21 +#: proposals/templates/proposals/talk_decide.html:22 +msgid "Accept the proposal" +msgstr "Accepter la proposition" + +#: cfp/templates/cfp/staff/talk_decide.html:21 +#: proposals/templates/proposals/talk_decide.html:22 +msgid "Decline the proposal" +msgstr "Décliner la proposition" + +#: cfp/templates/cfp/staff/talk_details.html:25 +msgctxt "session" +msgid "No assigned yet." +msgstr "Pas encore assignée." + +#: cfp/templates/cfp/staff/talk_details.html:28 +msgid "Timeslot" +msgstr "Créneau" + +#: cfp/templates/cfp/staff/talk_details.html:32 +#: cfp/templates/cfp/staff/talk_details.html:40 +#: proposals/templates/proposals/talk_detail.html:44 +#: proposals/templates/proposals/talk_detail.html:52 +msgid "not defined" +msgstr "non défini" + +#: cfp/templates/cfp/staff/talk_details.html:63 +#: proposals/templates/proposals/talk_detail.html:74 +msgid "No description provided." +msgstr "Aucune description fournie." + +#: cfp/templates/cfp/staff/talk_details.html:72 +#: proposals/templates/proposals/talk_detail.html:83 +msgid "No speakers." +msgstr "Aucun orateur." + +#: cfp/templates/cfp/staff/talk_details.html:77 +#: proposals/templates/proposals/talk_detail.html:104 +msgid "No notes." +msgstr "Aucune note." + +#: cfp/templates/cfp/staff/talk_details.html:79 +#: proposals/templates/proposals/talk_detail.html:108 +msgid "Moderation" +msgstr "Modération" + +#: cfp/templates/cfp/staff/talk_details.html:93 +#: proposals/templates/proposals/talk_detail.html:124 +msgid "vote" +msgstr "vote" + +#: cfp/templates/cfp/staff/talk_details.html:93 +#: proposals/templates/proposals/talk_detail.html:124 +msgid "average:" +msgstr "moyenne :" + +#: cfp/templates/cfp/staff/talk_details.html:118 +msgid "" +"Comment this talk – this message will be received by the staff team " +"only" +msgstr "" +"Commenter cette proposition – ce message sera reçu uniquement par " +"l’équipe d’organisation" + +#: cfp/templates/cfp/staff/talk_form.html:8 +#: proposals/templates/proposals/talk_edit.html:10 +msgid "Edit a talk" +msgstr "Éditer un exposé" + +#: cfp/templates/cfp/staff/talk_list.html:10 +#: proposals/templates/proposals/speaker_list.html:11 +#: proposals/templates/proposals/talk_list.html:11 +#: volunteers/templates/volunteers/volunteer_list.html:11 +msgid "Show filtering options…" +msgstr "Afficher les options de filtrage…" + +#: cfp/templates/cfp/staff/talk_list.html:29 +#: proposals/templates/proposals/speaker_list.html:38 +#: proposals/templates/proposals/talk_list.html:35 +#: volunteers/templates/volunteers/volunteer_list.html:19 +msgid "Filter" +msgstr "Filtrer" + +#: cfp/templates/cfp/staff/talk_list.html:41 proposals/models.py:162 +#: proposals/templates/proposals/talk_list.html:48 +msgid "Intervention kind" +msgstr "Type d’intervention" + +#: cfp/templates/cfp/staff/talk_list.html:70 +#: proposals/templates/proposals/talk_list.html:82 +#, python-format +msgid "Pending, score: %(score)s" +msgstr "En cours, score : %(score)s" + +#: cfp/templates/cfp/staff/track_list.html:11 +#: proposals/templates/proposals/track_list.html:12 +msgid "Add a track" +msgstr "Ajouter une session" + +#: cfp/templates/cfp/staff/track_list.html:19 +#: proposals/templates/proposals/track_list.html:21 +msgid "manager" +msgstr "responsable" + +#: cfp/templates/cfp/staff/track_list.html:31 +#: proposals/templates/proposals/track_list.html:33 +msgid "No tracks." +msgstr "Aucune session." + +#: cfp/views.py:68 +msgid "" +"Hi {},\n" +"\n" +"Your talk has been submitted for {}.\n" +"\n" +"Here are the details of your talk:\n" +"Title: {}\n" +"Description: {}\n" +"\n" +"You can at anytime:\n" +"- edit your talk: {}\n" +"- add a new co-speaker: {}\n" +"- edit your profile: {}\n" +"\n" +"If you have any question, your can answer to this email.\n" +"\n" +"Thanks!\n" +"\n" +"{}\n" +"\n" +msgstr "" +"Bonjour {},\n" +"\n" +"Votre proposition a été transmise à {}.\n" +"\n" +"Vous trouverez ci-dessous les détails de votre proposition:\n" +"Titre: {}\n" +"Description: {}\n" +"\n" +"Vous pouvez à tout moment:\n" +"- éditer votre proposition: {}\n" +"- ajouter un co-speaker: {}\n" +"- éditer votre profil: {}\n" +"\n" +"Si vous avez une question, vous pouvez répondre à ce mail.\n" +"\n" +"{}\n" +"\n" + +#: cfp/views.py:224 cfp/views.py:286 +msgid "Message sent!" +msgstr "Message envoyé !" + +#: cfp/views.py:237 proposals/views.py:321 +msgid "Vote successfully created" +msgstr "A voté !" + +#: cfp/views.py:237 proposals/views.py:321 +msgid "Vote successfully updated" +msgstr "Vote mis à jour" + +#: cfp/views.py:252 +msgid "The talk has been accepted." +msgstr "L’exposé a été accepté." + +#: cfp/views.py:254 +msgid "The talk has been declined." +msgstr "L’exposé a été décliné." + +#: cfp/views.py:258 proposals/views.py:347 +msgid "Decision taken in account" +msgstr "Décision enregistrée" + +#: cfp/views.py:314 +msgid "[{}] You have been added to the staff team" +msgstr "[{}] Vous avez été ajouté aux membres du staff" + +#: cfp/views.py:315 +msgid "" +"Hi {},\n" +"\n" +"You have been added to the staff team.\n" +"\n" +"You can now:\n" +"- login: {}\n" +"- reset your password: {}\n" +"\n" +"{}\n" +"\n" +msgstr "" +"Bonjour {},\n" +"\n" +"Vous avez été ajouté à l’équipe d’organisation.\n" +"\n" +"Vous pouvez à présent :\n" +"- vous connecter : {}\n" +"- réinitialiser votre mot de passe : {}\n" +"\n" +"{}\n" +"\n" + +#: cfp/views.py:336 +msgid "Modifications successfully saved." +msgstr "Modification enregistrée avec succès." + +#: cfp/views.py:428 +msgid "User created successfully." +msgstr "Utilisateur créé avec succès." + +#: mailing/models.py:93 +#, python-format +msgid "Message from %(author)s" +msgstr "Message de %(author)s" + +#: mailing/templates/mailing/_message_form.html:13 +msgid "Send" +msgstr "Envoyer" + +#: mailing/templates/mailing/_message_list.html:13 +msgid "No messages." +msgstr "Aucun message." + +#: planning/templates/planning/public-program.html:8 +#: planning/templates/planning/schedule.html:9 +msgid "Schedule" +msgstr "Programme" + +#: planning/templates/planning/room_form.html:14 +msgid "Modify the room" +msgstr "Modifier la salle" + +#: ponyconf/settings.py:145 msgid "English" msgstr "Anglais" -#: ponyconf/settings.py:142 +#: ponyconf/settings.py:146 msgid "French" msgstr "Français" -#: ponyconf/templates/base.html:20 +#: ponyconf/templates/base.html:21 msgid "Home" msgstr "Accueil" -#: ponyconf/templates/base.html:22 -msgid "Exhibitor" -msgstr "Exposant" - #: ponyconf/templates/base.html:23 -msgid "Volunteer" -msgstr "Bénévole" +msgid "Call for participation" +msgstr "Appel à participation" -#: ponyconf/templates/base.html:25 -msgid "Workshop registration" -msgstr "Inscription aux ateliers" - -#: ponyconf/templates/base.html:30 +#: ponyconf/templates/base.html:36 ponyconf/templates/base.html:50 msgid "Staff" msgstr "Staff" -#: ponyconf/templates/base.html:36 -#: proposals/templates/proposals/talk_registrable_list.html:25 -msgid "Register" -msgstr "S’inscrire" +#: ponyconf/templates/base.html:48 +msgid "Logout" +msgstr "Déconnection" -#: ponyconf/templates/base.html:54 +#: ponyconf/templates/base.html:67 msgid "Powered by" msgstr "Propulsé par" -#: ponyconf/templates/staff.html:9 proposals/models.py:159 -#: proposals/templates/proposals/talk_detail.html:26 -#: proposals/templates/proposals/talk_list.html:49 -#: proposals/templates/proposals/topic_list.html:9 -msgid "Topics" -msgstr "Thèmes" +#: ponyconf/templates/registration/login.html:10 +msgid "Staff area" +msgstr "Espace organisateurs" -#: ponyconf/templates/staff.html:10 -#: proposals/templates/proposals/track_list.html:9 -msgid "Tracks" -msgstr "Sessions" +#: ponyconf/templates/registration/login.html:12 +msgid "Please login to access staff area." +msgstr "Veuillez vous connecter pour accéder à l’espace organisateurs." -#: ponyconf/templates/staff.html:12 proposals/models.py:154 -#: proposals/templates/proposals/speaker_list.html:9 -#: proposals/templates/proposals/talk_detail.html:72 -#: proposals/templates/proposals/talk_list.html:48 -msgid "Speakers" -msgstr "Orateurs" +#: ponyconf/templates/registration/login.html:24 +msgid "Login" +msgstr "Se connecter" -#: ponyconf/templates/staff.html:13 -#: volunteers/templates/volunteers/volunteer_list.html:9 -msgid "Volunteers" -msgstr "Bénévoles" +#: ponyconf/templates/registration/login.html:25 +msgid "Forgot password?" +msgstr "Mot de passe oublié ?" -#: ponyconf/templates/staff.html:18 -msgid "Conference" -msgstr "Conférence" +#: ponyconf/templates/registration/password_change_form.html:9 +msgid "Password Change" +msgstr "Changement de mot de passe" + +#: ponyconf/urls.py:27 +msgid "Email address" +msgstr "Adresse e-mail" #: proposals/forms.py:46 msgid "Should be less than 255 characters" @@ -506,62 +1096,42 @@ msgstr "Texte court, moins de 255 caractères" msgid "If you want to add some precisions for the organizers." msgstr "Si vous souhaitez apporter des précisions à l'équipe d'organisation." -#: proposals/forms.py:72 -msgid "Filter talks you already / not yet voted for" -msgstr "" -"Filtrer les propositions pour lesquelles vous avez déjà voté / pas encore " -"voté" - -#: proposals/forms.py:73 -msgid "Filter talks already / not yet affected to a room" -msgstr "Filtrer les exposés déjà / pas encore affectées à une salle" - -#: proposals/forms.py:74 -msgid "Filter talks already / not yet scheduled" -msgstr "Filtrer les exposés déjà / pas encore planifiées" - #: proposals/forms.py:75 msgid "Filter talks with / without materials" msgstr "Filtrer les exposés avec / sans supports" -#: proposals/forms.py:90 +#: proposals/forms.py:76 +msgid "Filter talks with / without video" +msgstr "Filtrer les exposés avec / sans vidéo" + +#: proposals/forms.py:91 msgid "Accept talk?" msgstr "Accepter la proposition ?" -#: proposals/forms.py:91 +#: proposals/forms.py:92 msgid "Assign to a track" msgstr "Assigner à une session" -#: proposals/forms.py:92 +#: proposals/forms.py:93 msgid "Put in a room" msgstr "Assigner à une salle" -#: proposals/forms.py:194 +#: proposals/forms.py:195 msgid "Name or nickname" msgstr "Nom ou pseudo" -#: proposals/forms.py:195 +#: proposals/forms.py:196 msgid "How much is 3+4?" msgstr "Combien font 3+4 ?" -#: proposals/forms.py:195 +#: proposals/forms.py:196 msgid "Anti-bot" msgstr "Anti-robot" -#: proposals/forms.py:200 +#: proposals/forms.py:201 msgid "Please re-do the maths." msgstr "Refaites les calculs." -#: proposals/models.py:52 proposals/models.py:75 proposals/models.py:132 -#: volunteers/models.py:12 -msgid "Name" -msgstr "Nom" - -#: proposals/models.py:54 proposals/models.py:77 proposals/models.py:158 -#: proposals/templates/proposals/talk_detail.html:68 volunteers/models.py:14 -msgid "Description" -msgstr "Description" - #: proposals/models.py:56 msgid "Managers" msgstr "Responsables" @@ -574,26 +1144,10 @@ msgstr "Session de destination" msgid "Reviewers" msgstr "Responsables" -#: proposals/models.py:96 -msgid "Default duration (min)" -msgstr "Durée par défaut (min)" - -#: proposals/models.py:97 -msgid "Color on program" -msgstr "Couleur sur le programme" - -#: proposals/models.py:98 -msgid "Label on program" -msgstr "Label dans le xml du programme" - #: proposals/models.py:139 msgid "Email" msgstr "E-mail" -#: proposals/models.py:155 proposals/templates/proposals/talk_list.html:46 -msgid "Title" -msgstr "Titre" - #: proposals/models.py:155 msgid "After submission, title can only be changed by the staff." msgstr "" @@ -604,26 +1158,17 @@ msgstr "" msgid "Abstract" msgstr "Résumé" +#: proposals/models.py:159 proposals/templates/proposals/talk_detail.html:26 +#: proposals/templates/proposals/talk_list.html:50 +#: proposals/templates/proposals/topic_list.html:9 +msgid "Topics" +msgstr "Thèmes" + #: proposals/models.py:159 msgid "The topics can not be changed after submission." msgstr "Les thèmes ne peuvent pas être modifiés après soumission." -#: proposals/models.py:160 proposals/templates/proposals/talk_detail.html:33 -#: proposals/templates/proposals/talk_detail.html:85 -#: proposals/templates/proposals/talk_list.html:50 -#: proposals/templates/proposals/track_form.html:14 -msgid "Track" -msgstr "Session" - -#: proposals/models.py:162 proposals/templates/proposals/talk_list.html:47 -msgid "Intervention kind" -msgstr "Type d’intervention" - -#: proposals/models.py:165 -msgid "Duration (min)" -msgstr "Durée (min)" - -#: proposals/models.py:169 proposals/templates/proposals/talk_detail.html:127 +#: proposals/models.py:169 proposals/templates/proposals/talk_detail.html:131 msgid "Attendees" msgstr "Inscrits" @@ -662,32 +1207,10 @@ msgstr "stand" msgid "other" msgstr "autre" -#: proposals/templates/proposals/_talk_list.html:8 -msgid "by" -msgstr "par" - -#: proposals/templates/proposals/_talk_list.html:11 -#: proposals/templates/proposals/_talk_list.html:17 -#: proposals/templates/proposals/talk_list.html:65 -msgid "and" -msgstr "et" - -#: proposals/templates/proposals/_talk_list.html:14 -msgid "in" -msgstr "portant sur" - -#: proposals/templates/proposals/_talk_list.html:23 -msgid "No talks" -msgstr "Aucun exposé" - #: proposals/templates/proposals/conference.html:11 msgid "Home page" msgstr "Page d’accueil" -#: proposals/templates/proposals/participate.html:9 -msgid "Participate" -msgstr "Participer" - #: proposals/templates/proposals/participate.html:12 msgid "My talks:" msgstr "Mes exposés :" @@ -716,28 +1239,6 @@ msgstr "Proposer un exposé" msgid "Sorry, the Call for Participation is closed." msgstr "Désolé, l’appel à participation est fermé." -#: proposals/templates/proposals/speaker_list.html:11 -#: proposals/templates/proposals/talk_list.html:11 -#: volunteers/templates/volunteers/volunteer_list.html:11 -msgid "Show filtering options…" -msgstr "Afficher les options de filtrage…" - -#: proposals/templates/proposals/speaker_list.html:38 -#: proposals/templates/proposals/talk_list.html:34 -#: volunteers/templates/volunteers/volunteer_list.html:19 -msgid "Filter" -msgstr "Filtrer" - -#: proposals/templates/proposals/speaker_list.html:44 -#: proposals/templates/proposals/talk_list.html:42 -#: volunteers/templates/volunteers/volunteer_list.html:25 -msgid "Total:" -msgstr "Total :" - -#: proposals/templates/proposals/speaker_list.html:44 -msgid "speaker" -msgstr "orateur" - #: proposals/templates/proposals/speaker_list.html:48 #: volunteers/templates/volunteers/volunteer_list.html:29 msgid "Username" @@ -748,10 +1249,6 @@ msgstr "Nom d’utilisateur" msgid "Fullname" msgstr "Prénom et nom" -#: proposals/templates/proposals/speaker_list.html:50 -msgid "Talk count" -msgstr "Nombre d’exposé" - #: proposals/templates/proposals/speaker_list.html:51 msgctxt "table column title" msgid "Need transport?" @@ -774,47 +1271,6 @@ msgstr "Contacter :" msgid "link" msgstr "lien" -#: proposals/templates/proposals/talk_decide.html:9 -msgid "Are you sure to accept this proposals?" -msgstr "Êtes-vous sûr d’accepter cette propositon d’intervention ?" - -#: proposals/templates/proposals/talk_decide.html:9 -msgid "Are you sure to decline this proposals?" -msgstr "Êtes-vous sûr de décliner cette propositon d’intervention ?" - -#: proposals/templates/proposals/talk_decide.html:11 -msgid "Information about the proposals" -msgstr "Information sur la propositon d’intervention" - -#: proposals/templates/proposals/talk_decide.html:12 -msgid "Title:" -msgstr "Titre :" - -#: proposals/templates/proposals/talk_decide.html:13 -msgid "Kind:" -msgstr "Type d’intervention :" - -#: proposals/templates/proposals/talk_decide.html:15 -msgid "Information for the proposer" -msgstr "Information à destination de l’auteur de la proposition" - -#: proposals/templates/proposals/talk_decide.html:19 -msgid "" -"If you want to send a message to the proposer, please enter it below. " -"Remember to indicate which talk your message is reffering." -msgstr "" -"Si vous souhaitez envoyer un message à l’auteur de la proposition, saisissez-" -"le ci-dessous. N’oubliez pas de spécifier à quelle proposition " -"d’intervention votre message fait référence." - -#: proposals/templates/proposals/talk_decide.html:22 -msgid "Accept the proposal" -msgstr "Accepter la proposition" - -#: proposals/templates/proposals/talk_decide.html:22 -msgid "Decline the proposal" -msgstr "Décliner la proposition" - #: proposals/templates/proposals/talk_detail.html:19 msgid "No abstract provided." msgstr "Aucun résumé fourni." @@ -829,15 +1285,10 @@ msgid "No topics." msgstr "Aucun thème." #: proposals/templates/proposals/talk_detail.html:37 -#: proposals/templates/proposals/talk_detail.html:86 +#: proposals/templates/proposals/talk_detail.html:90 msgid "No assigned yet." msgstr "Pas encore assigné." -#: proposals/templates/proposals/talk_detail.html:44 -#: proposals/templates/proposals/talk_detail.html:52 -msgid "not defined" -msgstr "non défini" - #: proposals/templates/proposals/talk_detail.html:56 msgid "Registrations" msgstr "Inscriptions" @@ -846,77 +1297,35 @@ msgstr "Inscriptions" msgid "required but unlimited" msgstr "requis mais non limité" -#: proposals/templates/proposals/talk_detail.html:70 -msgid "No description provided." -msgstr "Aucune description fournie." +#: proposals/templates/proposals/talk_detail.html:64 +msgid "Video" +msgstr "Vidéo" -#: proposals/templates/proposals/talk_detail.html:79 -msgid "No speakers." -msgstr "Aucun orateur." +#: proposals/templates/proposals/talk_detail.html:65 +msgid "download" +msgstr "Télécharger" -#: proposals/templates/proposals/talk_detail.html:90 +#: proposals/templates/proposals/talk_detail.html:94 msgid "Assign to" msgstr "Assigner à" -#: proposals/templates/proposals/talk_detail.html:100 -msgid "No notes." -msgstr "Aucune note." - -#: proposals/templates/proposals/talk_detail.html:104 -msgid "Moderation" -msgstr "Modération" - -#: proposals/templates/proposals/talk_detail.html:106 -#: proposals/templates/proposals/talk_list.html:51 -msgid "Status" -msgstr "Statut" - -#: proposals/templates/proposals/talk_detail.html:111 -msgid "Vote" -msgstr "Vote" - -#: proposals/templates/proposals/talk_detail.html:120 -msgid "vote" -msgstr "vote" - -#: proposals/templates/proposals/talk_detail.html:120 -msgid "average:" -msgstr "moyenne :" - -#: proposals/templates/proposals/talk_detail.html:134 +#: proposals/templates/proposals/talk_detail.html:138 msgid "No attendees yet." msgstr "Il n’y a pas encore d’inscrit." -#: proposals/templates/proposals/talk_detail.html:139 +#: proposals/templates/proposals/talk_detail.html:143 msgid "Messages" msgstr "Messages" -#: proposals/templates/proposals/talk_detail.html:140 +#: proposals/templates/proposals/talk_detail.html:144 msgid "These messages are for organization team only." msgstr "Ces messages sont à destination de la team d’organisation seulement." -#: proposals/templates/proposals/talk_edit.html:10 -msgid "Edit a talk" -msgstr "Éditer un exposé" - -#: proposals/templates/proposals/talk_list.html:77 -msgid "Accepted" -msgstr "Accepté" - -#: proposals/templates/proposals/talk_list.html:79 -msgid "Declined" -msgstr "Décliné" - -#: proposals/templates/proposals/talk_list.html:81 -#, python-format -msgid "Pending, score: %(score)s" -msgstr "En cours, score : %(score)s" - -#: proposals/templates/proposals/talk_list.html:94 +#: proposals/templates/proposals/talk_list.html:95 msgid "For selected talks:" msgstr "Pour les exposés sélectionnés :" -#: proposals/templates/proposals/talk_list.html:102 +#: proposals/templates/proposals/talk_list.html:103 msgid "Apply" msgstr "Appliquer" @@ -943,6 +1352,10 @@ msgstr[1] "%(remaining)s places restantes" msgid "Unregister" msgstr "Se désinscrire" +#: proposals/templates/proposals/talk_registrable_list.html:25 +msgid "Register" +msgstr "S’inscrire" + #: proposals/templates/proposals/talk_registrable_list.html:33 msgid "" "There are no workshops requiring registration for now … come back later!" @@ -962,58 +1375,34 @@ msgstr "Ajouter un thème" msgid "reviewer" msgstr "Responsable" -#: proposals/templates/proposals/track_list.html:12 -msgid "Add a track" -msgstr "Ajouter une session" - -#: proposals/templates/proposals/track_list.html:21 -msgid "manager" -msgstr "responsable" - -#: proposals/templates/proposals/track_list.html:33 -msgid "No tracks." -msgstr "Aucune session." - -#: proposals/views.py:203 +#: proposals/views.py:213 #, python-format msgid "Note: the room %(room)s has %(capacity)s seat." msgid_plural "Note: the room %(room)s has %(capacity)s seats." msgstr[0] "Note : la salle %(room)s a %(capacity)s place." msgstr[1] "Note : la salle %(room)s a %(capacity)s places." -#: proposals/views.py:214 +#: proposals/views.py:225 msgid "Talk modified successfully!" msgstr "Exposé modifié avec succès !" -#: proposals/views.py:220 +#: proposals/views.py:231 msgid "Talk proposed successfully!" msgstr "Exposé proposé avec succès !" -#: proposals/views.py:237 +#: proposals/views.py:248 msgid "Talk assigned to track successfully!" msgstr "Exposé assigné à la session avec succès !" -#: proposals/views.py:310 -msgid "Vote successfully created" -msgstr "A voté !" - -#: proposals/views.py:310 -msgid "Vote successfully updated" -msgstr "Vote mis à jour" - -#: proposals/views.py:336 -msgid "Decision taken in account" -msgstr "Décision enregistrée" - -#: proposals/views.py:434 +#: proposals/views.py:445 msgid "Unregistered :-(" msgstr "Vous avez été désinscrit :-(" -#: proposals/views.py:436 +#: proposals/views.py:447 msgid "Already registered!" msgstr "Vous êtes déjà inscrit !" -#: proposals/views.py:441 +#: proposals/views.py:452 msgid "Registered!" msgstr "Vous avez été inscrit !" @@ -1044,6 +1433,30 @@ msgstr "" "Nous ne sommes pas encore en recherche de bénévoles … mais revenez plus " "tard !" +#: volunteers/templates/volunteers/volunteer_list.html:9 +msgid "Volunteers" +msgstr "Bénévoles" + #: volunteers/templates/volunteers/volunteer_list.html:25 msgid "volunteer" msgstr "bénévole" + +#~ msgid "Send a message" +#~ msgstr "Envoyer un message" + +#~ msgid "Conversation with %(correspondent)s" +#~ msgstr "Conversation avec %(correspondent)s" + +#~ msgid "Correspondents" +#~ msgstr "Correspondants" + +#~ msgid "This is the list of participants that you follow." +#~ msgstr "Ceci est la liste des participants que vous suivez." + +#~ msgid "You can use this page to communicate with the staff." +#~ msgstr "" +#~ "Vous pouvez utiliser cette page pour communiquer avec l’équipe " +#~ "organisatrice." + +#~ msgid "Your talk \"{}\" has been submitted for {}" +#~ msgstr "Votre proposition \"{}\" a été transmise à {}" diff --git a/conversations/migrations/__init__.py b/mailing/__init__.py similarity index 100% rename from conversations/migrations/__init__.py rename to mailing/__init__.py diff --git a/mailing/admin.py b/mailing/admin.py new file mode 100644 index 0000000..3a95dfb --- /dev/null +++ b/mailing/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from .models import Message + + +admin.site.register(Message) diff --git a/conversations/forms.py b/mailing/forms.py similarity index 97% rename from conversations/forms.py rename to mailing/forms.py index f84879c..80eab2f 100644 --- a/conversations/forms.py +++ b/mailing/forms.py @@ -1,5 +1,6 @@ from django.forms.models import modelform_factory - + from .models import Message + MessageForm = modelform_factory(Message, fields=['content']) diff --git a/proposals/migrations/__init__.py b/mailing/management/__init__.py similarity index 100% rename from proposals/migrations/__init__.py rename to mailing/management/__init__.py diff --git a/mailing/management/commands/__init__.py b/mailing/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mailing/management/commands/fetchmail.py b/mailing/management/commands/fetchmail.py new file mode 100644 index 0000000..180183e --- /dev/null +++ b/mailing/management/commands/fetchmail.py @@ -0,0 +1,34 @@ +from django.core.management.base import BaseCommand + +from mailing.utils import fetch_imap_box + + +class Command(BaseCommand): + help = 'Fetch emails from IMAP inbox' + + def add_arguments(self, parser): + parser.add_argument('--host', required=True) + parser.add_argument('--port', type=int) + parser.add_argument('--user', required=True) + parser.add_argument('--password', required=True) + parser.add_argument('--inbox') + grp = parser.add_mutually_exclusive_group() + grp.add_argument('--trash') + grp.add_argument('--no-trash', action='store_true') + + + def handle(self, *args, **options): + params = { + 'host': options['host'], + 'user': options['user'], + 'password': options['password'], + } + if options['port']: + params['port'] = options['port'] + if options['inbox']: + params['inbox'] = options['inbox'] + if options['trash']: + params['trash'] = options['trash'] + elif options['no_trash']: + params['trash'] = None + fetch_imap_box(**params) diff --git a/mailing/migrations/0001_initial.py b/mailing/migrations/0001_initial.py new file mode 100644 index 0000000..2155da5 --- /dev/null +++ b/mailing/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-08-01 15:48 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import mailing.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Message', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('author', models.EmailField(blank=True, max_length=254)), + ('content', models.TextField(blank=True)), + ('token', models.CharField(default=mailing.models.generate_message_token, max_length=64, unique=True)), + ], + options={ + 'ordering': ['created'], + }, + ), + migrations.CreateModel( + name='MessageCorrespondent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254)), + ('token', models.CharField(default=mailing.models.generate_message_token, max_length=64, unique=True)), + ], + ), + migrations.CreateModel( + name='MessageThread', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('token', models.CharField(default=mailing.models.generate_message_token, max_length=64, unique=True)), + ], + ), + migrations.AddField( + model_name='message', + name='thread', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='mailing.MessageThread'), + ), + ] diff --git a/mailing/migrations/0002_message_author.py b/mailing/migrations/0002_message_author.py new file mode 100644 index 0000000..05045a9 --- /dev/null +++ b/mailing/migrations/0002_message_author.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-08-02 09:27 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('mailing', '0001_initial'), + ] + + operations = [ + migrations.RenameField( + model_name='message', + old_name='author', + new_name='from_email', + ), + migrations.AlterField( + model_name='message', + name='from_email', + field=models.EmailField(max_length=254), + ), + migrations.AddField( + model_name='message', + name='author_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='message', + name='author_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + ] diff --git a/mailing/migrations/__init__.py b/mailing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mailing/models.py b/mailing/models.py new file mode 100644 index 0000000..d6fa09c --- /dev/null +++ b/mailing/models.py @@ -0,0 +1,103 @@ +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 +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.utils.translation import ugettext_lazy as _ +from django.contrib.auth import get_user_model + +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 MessageManager(models.Manager): + def get_queyset(self): + qs = super().get_queryset() + # Does not work so well as prefetch_related is limited to one content type for generic foreign keys + qs = qs.prefetch_related('author') + return qs + + +class Message(models.Model): + created = models.DateTimeField(auto_now_add=True) + thread = models.ForeignKey(MessageThread) + author_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True) + author_id = models.PositiveIntegerField(null=True, blank=True) + author = GenericForeignKey('author_type', 'author_id') + from_email = models.EmailField() + content = models.TextField(blank=True) + token = models.CharField(max_length=64, default=generate_message_token, unique=True) + + objects = MessageManager() + + class Meta: + ordering = ['created'] + + def send_notification(self, subject, sender, dests, reply_to=None, message_id=None, reference=None, footer=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] + 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), + }) + body = self.content + if footer is not None: + body += footer + messages.append(EmailMessage( + subject=subject, + body=body, + from_email='%s <%s>' % sender, + to=['%s <%s>' % (dest_name, dest_email)], + reply_to=reply_to_list, + headers=headers, + )) + connection = get_connection() + connection.send_messages(messages) + + @property + def author_display(self): + if self.author: + author_class = ContentType.objects.get_for_model(self.author).model_class() + if author_class == get_user_model(): + return self.author.get_full_name() + else: + return str(self.author) + else: + return self.from_email + + def __str__(self): + return _("Message from %(author)s") % {'author': self.author_display} diff --git a/conversations/templates/conversations/_message_form.html b/mailing/templates/mailing/_message_form.html similarity index 81% rename from conversations/templates/conversations/_message_form.html rename to mailing/templates/mailing/_message_form.html index 7ef8e91..f03d90b 100644 --- a/conversations/templates/conversations/_message_form.html +++ b/mailing/templates/mailing/_message_form.html @@ -1,10 +1,11 @@ {% load i18n %} +
- {% trans "Send a message" %} + {{ message_form_title }}
-
+ {% csrf_token %}
diff --git a/mailing/templates/mailing/_message_list.html b/mailing/templates/mailing/_message_list.html new file mode 100644 index 0000000..f79d128 --- /dev/null +++ b/mailing/templates/mailing/_message_list.html @@ -0,0 +1,14 @@ +{% load i18n %} + +{% for message in messages %} +
+
+ {{ message.created }} | {{ message.author_display }} +
+
+ {{ message.content|linebreaksbr }} +
+
+{% empty %} +

{% trans "No messages." %}

+{% endfor %} diff --git a/mailing/tests.py b/mailing/tests.py new file mode 100644 index 0000000..5b67ee3 --- /dev/null +++ b/mailing/tests.py @@ -0,0 +1,93 @@ +from django.contrib.auth.models import User +from django.contrib.sites.models import Site +from django.core.urlresolvers import reverse +from django.test import TestCase, override_settings +from django.core import mail +from django.conf import settings + +from .models import Message, MessageThread, MessageCorrespondent + + +#class MailingTests(TestCase): +# def setUp(self): +# a, b, c, d = (User.objects.create_user(guy, email='%s@example.org' % guy, password=guy) for guy in 'abcd') +# d.is_superuser = True +# d.save() +# pa, _ = Participation.objects.get_or_create(user=a, site=Site.objects.first()) +# conversation, _ = ConversationWithParticipant.objects.get_or_create(participation=pa) +# Message.objects.create(content='allo', conversation=conversation, author=b) +# Message.objects.create(content='aluil', conversation=conversation, author=a) +# site = Site.objects.first() +# Talk.objects.get_or_create(site=site, proposer=a, title='a talk', description='yay', event=Event.objects.get(site=site, name='other')) +# +# def test_models(self): +# talk, participant, message = (model.objects.first() for model in +# (ConversationAboutTalk, ConversationWithParticipant, Message)) +# self.assertEqual(str(talk), 'Conversation about a talk') +# self.assertEqual(str(participant), 'Conversation with a') +# self.assertEqual(str(message), 'Message from b') +# self.assertEqual(message.get_absolute_url(), '/conversations/with/a/') +# self.assertEqual(talk.get_absolute_url(), '/talk/details/a-talk') +# +# def test_views(self): +# url = ConversationWithParticipant.objects.first().get_absolute_url() +# self.assertEqual(self.client.get(url).status_code, 302) +# self.client.login(username='c', password='c') +# self.assertEqual(self.client.get(url).status_code, 403) +# self.assertEqual(self.client.get(reverse('list-correspondents')).status_code, 403) # c is not staff +# self.assertEqual(self.client.get(reverse('inbox')).status_code, 200) +# self.client.post(reverse('inbox'), {'content': 'coucou'}) +# self.client.login(username='d', password='d') +# self.client.post(url, {'content': 'im superuser'}) +# self.assertEqual(Message.objects.last().content, 'im superuser') +# self.client.login(username='d', password='d') +# self.assertEqual(self.client.get(reverse('list-correspondents')).status_code, 200) +# +# +#@override_settings(DEFAULT_FROM_EMAIL='noreply@example.org', +# REPLY_EMAIL='reply@example.org', +# REPLY_KEY='secret') +#class EmailTests(TestCase): +# def setUp(self): +# for guy in 'abcd': +# setattr(self, guy, User.objects.create_user(guy, email='%s@example.org' % guy, password=guy)) +# a_p = Participation(user=self.a, site=Site.objects.first()) +# a_p.orga = True +# a_p.save() +# t = Topic(name='Topic 1', site=Site.objects.first()) +# t.save() +# t.reviewers.add(self.b) +# +# +# def test_talk_notification(self): +# self.client.login(username='c', password='c') +# # Check that login create participation +# self.assertTrue(Participation.objects.filter(user=self.c, site=Site.objects.first()).exists()) +# # Propose new talk +# topic = Topic.objects.get(name='Topic 1') +# response = self.client.post(reverse('add-talk'), { +# 'title': 'Talk 1', +# 'description': 'This is the first talk', +# 'topics': (topic.pk,), +# 'event': 1, +# 'speakers': (self.c.pk, self.d.pk), +# }, follow=True) +# self.assertEqual(response.status_code, 200) +# self.assertContains(response, 'Talk proposed') # check messages notification +# talk = Talk.objects.get(site=Site.objects.first(), title='Talk 1') +# conv = ConversationAboutTalk.objects.get(talk=talk) +# # Orga and reviewer should have been subscribed to the conversation about the talk +# self.assertEqual(set([self.a, self.b]), set(conv.subscribers.all())) +# # Both should have received an email notification +# self.assertEqual(len(mail.outbox), 2) +# for m in mail.outbox: +# self.assertEqual(m.from_email, '%s <%s>' % (self.c.profile, settings.DEFAULT_FROM_EMAIL)) +# self.assertTrue('Talk: %s' % talk.title) +# self.assertTrue(len(m.to), 1) +# self.assertTrue(m.to[0] in [ self.a.email, self.b.email ]) +# # Both should have been subscribed to conversations with each speakers +# for user in [self.c, self.d]: +# # Participation should have been created as the user is a speaker +# p = Participation.objects.get(user=user, site=Site.objects.first()) +# conv = ConversationWithParticipant.objects.get(participation=p) +# self.assertEqual(set([self.a, self.b]), set(conv.subscribers.all())) diff --git a/mailing/utils.py b/mailing/utils.py new file mode 100644 index 0000000..a56e1f6 --- /dev/null +++ b/mailing/utils.py @@ -0,0 +1,122 @@ +from django.conf import settings + +import imaplib +import ssl +import logging +from email import policy +from email.parser import BytesParser +import chardet +import re + +from .models import MessageThread, MessageCorrespondent, Message, hexdigest_sha256 + + +class NoTokenFoundException(Exception): + pass + +class InvalidTokenException(Exception): + pass + +class InvalidKeyException(Exception): + pass + + +def fetch_imap_box(user, password, host, port=993, inbox='INBOX', trash='Trash'): + logging.basicConfig(level=logging.DEBUG) + context = ssl.create_default_context() + success, failure = 0, 0 + with imaplib.IMAP4_SSL(host=host, port=port, ssl_context=context) as M: + typ, data = M.login(user, password) + if typ != 'OK': + raise Exception(data[0].decode('utf-8')) + typ, data = M.enable('UTF8=ACCEPT') + if typ != 'OK': + raise Exception(data[0].decode('utf-8')) + if trash is not None: + # Vérification de l’existence de la poubelle + typ, data = M.select(mailbox=trash) + if typ != 'OK': + raise Exception(data[0].decode('utf-8')) + typ, data = M.select(mailbox=inbox) + if typ != 'OK': + raise Exception(data[0].decode('utf-8')) + typ, data = M.uid('search', None, 'UNSEEN') + if typ != 'OK': + raise Exception(data[0].decode('utf-8')) + 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')) + if failure: + total = success + failure + logging.info("Total: %d, success: %d, failure: %d" % (total, success, failure)) + + +def process_email(raw_email): + msg = BytesParser(policy=policy.default).parsebytes(raw_email) + body = msg.get_body(preferencelist=['plain']) + content = body.get_payload(decode=True) + + charset = body.get_content_charset() + if not charset: + charset = chardet.detect(content)['encoding'] + content = content.decode(charset) + + regex = re.compile('^[^+@]+\+(?P[a-zA-Z0-9]{80})@[^@]+$') + + for addr in msg.get('To', '').split(','): + m = regex.match(addr.strip()) + if m: + break + + if not m: + raise NoTokenFoundException + + token = m.group('token') + key = token[64:] + try: + thread = MessageThread.objects.get(token=token[:32]) + sender = MessageCorrespondent.objects.get(token=token[32:64]) + except models.DoesNotExist: + raise InvalidTokenException + + if key != hexdigest_sha256(settings.SECRET_KEY, thread.token, sender.token)[:16]: + raise InvalidKeyException + + Message.objects.create(thread=thread, from_email=sender.email, content=content) diff --git a/planning/forms.py b/planning/forms.py index 3f4c12f..95443d0 100644 --- a/planning/forms.py +++ b/planning/forms.py @@ -4,16 +4,17 @@ from .models import Room class RoomForm(forms.ModelForm): + class Meta: + model = Room + fields = ['name', 'label', 'capacity'] + def __init__(self, *args, **kwargs): self.site = kwargs.pop('site') super().__init__(*args, **kwargs) - class Meta: - model = Room - fields = ['name', 'label', 'capacity', 'sound'] - def clean_name(self): name = self.cleaned_data['name'] - if self.instance and name != self.instance.name and Room.objects.filter(site=self.site, name=name).exists(): + if (not self.instance or self.instance.name != name) \ + and Room.objects.filter(site=self.site, name=name).exists(): raise self.instance.unique_error_message(self._meta.model, ['name']) return name diff --git a/planning/migrations/0002_room_sound.py b/planning/migrations/0002_room_sound.py deleted file mode 100644 index c6106dd..0000000 --- a/planning/migrations/0002_room_sound.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.7 on 2017-05-26 11:18 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('planning', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='room', - name='sound', - field=models.BooleanField(default=False), - ), - ] diff --git a/planning/models.py b/planning/models.py index 4cebb46..4454805 100644 --- a/planning/models.py +++ b/planning/models.py @@ -14,7 +14,6 @@ class Room(models.Model): name = models.CharField(max_length=256, blank=True, default="") label = models.CharField(max_length=256, blank=True, default="") capacity = models.IntegerField(default=0) - sound = models.BooleanField(default=False) class Meta: unique_together = ['site', 'name'] diff --git a/ponyconf/backends.py b/ponyconf/backends.py new file mode 100644 index 0000000..29d3dea --- /dev/null +++ b/ponyconf/backends.py @@ -0,0 +1,16 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend + + +class EmailBackend(ModelBackend): + def authenticate(self, request, username=None, password=None, **kwargs): + if not username: + return None + UserModel = get_user_model() + try: + user = UserModel._default_manager.get(email__iexact=username) + except UserModel.DoesNotExist: + UserModel().set_password(password) # https://code.djangoproject.com/ticket/20760 + else: + if user.check_password(password) and self.user_can_authenticate(user): + return user diff --git a/ponyconf/settings.py b/ponyconf/settings.py index f2fa409..7f34d22 100644 --- a/ponyconf/settings.py +++ b/ponyconf/settings.py @@ -37,19 +37,18 @@ INSTALLED_APPS = [ 'django.contrib.sites', # our apps - 'accounts', + #'accounts', 'ponyconf', - 'proposals', - 'conversations', - 'planning', - 'volunteers', + 'cfp', + 'mailing', + #'planning', + #'volunteers', # external apps 'djangobower', 'bootstrap3', - 'registration', 'django_select2', - 'avatar', + 'crispy_forms', # build-in apps 'django.contrib.admin', @@ -60,7 +59,7 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', ] -MIDDLEWARE_CLASSES = [ +MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.locale.LocaleMiddleware', @@ -70,6 +69,8 @@ MIDDLEWARE_CLASSES = [ 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + + 'cfp.middleware.ConferenceMiddleware', ] ROOT_URLCONF = 'ponyconf.urls' @@ -86,8 +87,8 @@ TEMPLATES = [ 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', - 'ponyconf.context_processors.site', - 'proposals.context_processors.conference', + #'ponyconf.context_processors.site', + 'cfp.context_processors.conference', ], }, }, @@ -204,10 +205,16 @@ BOOTSTRAP3 = { SELECT2_JS = 'select2/dist/js/select2.min.js' SELECT2_CSS = 'select2/dist/css/select2.min.css' +SELECT2_I18N_PATH = 'select2/dist/js/i18n' -AUTHENTICATION_BACKENDS = ['yeouia.backends.YummyEmailOrUsernameInsensitiveAuth'] +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', + 'ponyconf.backends.EmailBackend', +] LOGOUT_REDIRECT_URL = 'home' +CRISPY_TEMPLATE_PACK='bootstrap3' + # django-registration ACCOUNT_ACTIVATION_DAYS = 7 INCLUDE_REGISTER_URL = True @@ -215,5 +222,17 @@ INCLUDE_REGISTER_URL = True CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', - } + }, + 'select2': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'select2', + }, } + +SELECT2_CACHE_BACKEND = 'select2' + +SERVER_EMAIL = 'ponyconf@example.com' +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = 'localhost' +EMAIL_PORT = 25 + diff --git a/ponyconf/static/css/ponyconf.css b/ponyconf/static/css/ponyconf.css index 6aa60ca..c1e4afc 100644 --- a/ponyconf/static/css/ponyconf.css +++ b/ponyconf/static/css/ponyconf.css @@ -1,3 +1,9 @@ .subnav { padding-bottom: 20px; } + +span.asteriskField { + color: red; + font-size: 15px; +} + diff --git a/ponyconf/templates/_base.html b/ponyconf/templates/_base.html index fa47fb3..091d020 100644 --- a/ponyconf/templates/_base.html +++ b/ponyconf/templates/_base.html @@ -8,7 +8,7 @@ {% comment %}{% endcomment %} - {% block title %}{{ site.name }}{% endblock %} + {% block title %}{{ conference.name }}{% endblock %} {% bootstrap_css %} @@ -16,6 +16,10 @@ {% block js %}{% endblock %} + {% if conference.external_css_link %} + + {% endif %} +