Merge branch 'Kerl/calendar' into 'master'

Ajout d'un calendrier dynamique

Ce patch propose aux adhérents du COF de télécharger un calendrier
dynamique (`.ics`).

Il est configurable : 

    - On peut s'abonner ou non aux événements du COF.
    - On peut choisir les spectacles auxquels on veut s'abonner.
        - Une checkbox permet d'ajouter d'un coup les spectacles pour lesquels on a obtenu une place
        - On peut en ajouter d'autres 

Pour faire fonctionner ce patch, il faut installer la bibliothèque python `icalendar` : 
    
    pip install --upgrade -r requirements.txt

Fixes #20
Fixes #14 

See merge request !63
This commit is contained in:
Martin Pepin 2016-07-29 21:42:58 +02:00
commit bbc4b59bfe
15 changed files with 211 additions and 73 deletions

View file

@ -1,10 +0,0 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//GESTIOCOF/bda//EN
{% for spectacle in spectacles %}BEGIN:VEVENT
DTSTART;TZID=Europe/Paris:{{ spectacle.date|date:'Ymd\\THis' }}
DTEND;TZID=Europe/Paris:{{ spectacle.dtend|date:'Ymd\\THis' }}
SUMMARY:{{ spectacle.title|safe }}
LOCATION:{{ spectacle.location.name|safe }}
END:VEVENT
{% endfor %}END:VCALENDAR

View file

@ -18,7 +18,9 @@
</table> </table>
<h4 class="bda-prix">Total à payer : {{ total|floatformat }}€</h4> <h4 class="bda-prix">Total à payer : {{ total|floatformat }}€</h4>
<br/> <br/>
<h4><a href="{% url "bda-places-attribuees-ics" tirage.id %}">Exporter au format calendrier</a> (.ics, compatible avec tous les logiciels d'agenda)</h4> <p>Ne manque pas un spectacle avec le
<a href="{% url "gestioncof.views.calendar" %}">calendrier
automatique&#8239;!</a></p>
{% else %} {% else %}
<h3>Vous n'avez aucune place :(</h3> <h3>Vous n'avez aucune place :(</h3>
{% endif %} {% endif %}

View file

@ -1,10 +0,0 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//GESTIOCOF/bda//EN
{% for place in places %}BEGIN:VEVENT
DTSTART;TZID=Europe/Paris:{{ place.spectacle.date|date:'Ymd\\THis' }}
DTEND;TZID=Europe/Paris:{{ place.spectacle.dtend|date:'Ymd\\THis' }}
SUMMARY:{{ place.spectacle.title|safe }}{% if place.double %} (deux places){% endif %}
LOCATION:{{ place.spectacle.location.name|safe }}
END:VEVENT
{% endfor %}END:VCALENDAR

View file

@ -48,6 +48,5 @@
<h3> Exports </h3> <h3> Exports </h3>
<ul> <ul>
<li><a href="{% url 'bda-unpaid' tirage_id %}">Mailing list impayés</a> <li><a href="{% url 'bda-unpaid' tirage_id %}">Mailing list impayés</a>
<li><a href="{% url 'bda-liste-spectacles-ics' tirage_id %}">Calendrier des spectacles (.ics)</a>
</ul> </ul>
{% endblock %} {% endblock %}

View file

@ -15,9 +15,6 @@ urlpatterns = patterns(
url(r'^places/(?P<tirage_id>\d+)$', url(r'^places/(?P<tirage_id>\d+)$',
'bda.views.places', 'bda.views.places',
name="bda-places-attribuees"), name="bda-places-attribuees"),
url(r'^places/(?P<tirage_id>\d+)/places_bda.ics$',
'bda.views.places_ics',
name="bda-places-attribuees-ics"),
url(r'^revente/(?P<tirage_id>\d+)$', url(r'^revente/(?P<tirage_id>\d+)$',
'bda.views.revente', 'bda.views.revente',
name='bda-revente'), name='bda-revente'),
@ -31,9 +28,6 @@ urlpatterns = patterns(
url(r'^spectacles/(?P<tirage_id>\d+)/(?P<spectacle_id>\d+)$', url(r'^spectacles/(?P<tirage_id>\d+)/(?P<spectacle_id>\d+)$',
"bda.views.spectacle", "bda.views.spectacle",
name="bda-spectacle"), name="bda-spectacle"),
url(r'^spectacles-ics/(?P<tirage_id>\d+)$',
'bda.views.liste_spectacles_ics',
name="bda-liste-spectacles-ics"),
url(r'^spectacles/unpaid/(?P<tirage_id>\d+)$', url(r'^spectacles/unpaid/(?P<tirage_id>\d+)$',
"bda.views.unpaid", "bda.views.unpaid",
name="bda-unpaid"), name="bda-unpaid"),

View file

@ -106,31 +106,6 @@ def places(request, tirage_id):
"warning": warning}) "warning": warning})
@cof_required
def places_ics(request, tirage_id):
tirage = get_object_or_404(Tirage, id=tirage_id)
participant, created = Participant.objects.get_or_create(
user=request.user, tirage=tirage)
places = participant.attribution_set.order_by(
"spectacle__date", "spectacle").all()
filtered_places = []
places_dict = {}
spectacles = []
for place in places:
if place.spectacle in spectacles:
places_dict[place.spectacle].double = True
else:
place.double = False
place.spectacle.dtend = place.spectacle.date \
+ timedelta(seconds=7200)
places_dict[place.spectacle] = place
spectacles.append(place.spectacle)
filtered_places.append(place)
return render(request, "resume_places.ics",
{"participant": participant,
"places": filtered_places}, content_type="text/calendar")
@cof_required @cof_required
def inscription(request, tirage_id): def inscription(request, tirage_id):
tirage = get_object_or_404(Tirage, id=tirage_id) tirage = get_object_or_404(Tirage, id=tirage_id)
@ -365,17 +340,6 @@ def unpaid(request, tirage_id):
return render(request, "bda-unpaid.html", {"unpaid": unpaid}) return render(request, "bda-unpaid.html", {"unpaid": unpaid})
@buro_required
def liste_spectacles_ics(request, tirage_id):
tirage = get_object_or_404(Tirage, id=tirage_id)
spectacles = tirage.spectacle_set.order_by("date").all()
for spectacle in spectacles:
spectacle.dtend = spectacle.date + timedelta(seconds=7200)
return render(request, "liste_spectacles.ics",
{"spectacles": spectacles, "tirage": tirage},
content_type="text/calendar")
@buro_required @buro_required
def send_rappel(request, spectacle_id): def send_rappel(request, spectacle_id):
show = get_object_or_404(Spectacle, id=spectacle_id) show = get_object_or_404(Spectacle, id=spectacle_id)

View file

@ -13,7 +13,7 @@ from django.views.generic.base import TemplateView
import autocomplete_light import autocomplete_light
from gestioncof.urls import export_patterns, petitcours_patterns, \ from gestioncof.urls import export_patterns, petitcours_patterns, \
surveys_patterns, events_patterns surveys_patterns, events_patterns, calendar_patterns
autocomplete_light.autodiscover() autocomplete_light.autodiscover()
admin.autodiscover() admin.autodiscover()
@ -32,6 +32,8 @@ urlpatterns = patterns(
url(r'^survey/', include(surveys_patterns)), url(r'^survey/', include(surveys_patterns)),
# Evenements # Evenements
url(r'^event/', include(events_patterns)), url(r'^event/', include(events_patterns)),
# Calendrier
url(r'^calendar/', include(calendar_patterns)),
# Authentification # Authentification
url(r'^cof/denied$', TemplateView.as_view(template_name='cof-denied.html'), url(r'^cof/denied$', TemplateView.as_view(template_name='cof-denied.html'),
name="cof-denied"), name="cof-denied"),

View file

@ -10,10 +10,13 @@ from django.contrib.auth.models import User
from django.forms.widgets import RadioSelect, CheckboxSelectMultiple from django.forms.widgets import RadioSelect, CheckboxSelectMultiple
from django.db.models import Max from django.db.models import Max
from gestioncof.models import CofProfile, EventCommentValue from gestioncof.models import CofProfile, EventCommentValue, \
CalendarSubscription
from gestioncof.widgets import TriStateCheckbox from gestioncof.widgets import TriStateCheckbox
from gestioncof.shared import lock_table, unlock_table from gestioncof.shared import lock_table, unlock_table
from bda.models import Spectacle
class EventForm(forms.Form): class EventForm(forms.Form):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -333,3 +336,21 @@ class AdminEventForm(forms.Form):
for name, value in self.cleaned_data.items(): for name, value in self.cleaned_data.items():
if name.startswith('comment_'): if name.startswith('comment_'):
yield (self.fields[name].comment_id, value) yield (self.fields[name].comment_id, value)
class CalendarForm(forms.ModelForm):
subscribe_to_events = forms.BooleanField(
initial=True,
label="Événements du COF.")
subscribe_to_my_shows = forms.BooleanField(
initial=True,
label="Les spectacles pour lesquels j'ai obtenu une place.")
other_shows = forms.ModelMultipleChoiceField(
label="Spectacles supplémentaires.",
queryset=Spectacle.objects.filter(tirage__active=True),
widget=forms.CheckboxSelectMultiple)
class Meta:
model = CalendarSubscription
fields = ['subscribe_to_events', 'subscribe_to_my_shows',
'other_shows']

View file

@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
('bda', '0004_mails-rappel'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('gestioncof', '0004_registration_mail'),
]
operations = [
migrations.CreateModel(
name='CalendarSubscription',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False,
auto_created=True, primary_key=True)),
('token', models.UUIDField()),
('subscribe_to_events', models.BooleanField(default=True)),
('subscribe_to_my_shows', models.BooleanField(default=True)),
('other_shows', models.ManyToManyField(to='bda.Spectacle')),
('user', models.OneToOneField(to=settings.AUTH_USER_MODEL)),
],
),
migrations.AlterModelOptions(
name='custommail',
options={'verbose_name': 'Mail personnalisable',
'verbose_name_plural': 'Mails personnalisables'},
),
migrations.AlterModelOptions(
name='eventoptionchoice',
options={'verbose_name': 'Choix', 'verbose_name_plural': 'Choix'},
),
migrations.AlterField(
model_name='event',
name='end_date',
field=models.DateTimeField(null=True, verbose_name=b'Date de fin',
blank=True),
),
migrations.AlterField(
model_name='event',
name='start_date',
field=models.DateTimeField(
null=True, verbose_name=b'Date de d\xc3\xa9but', blank=True),
),
]

View file

@ -13,6 +13,8 @@ from django.db.models.signals import post_save
from gestioncof.petits_cours_models import choices_length from gestioncof.petits_cours_models import choices_length
from bda.models import Spectacle
OCCUPATION_CHOICES = ( OCCUPATION_CHOICES = (
('exterieur', _("Extérieur")), ('exterieur', _("Extérieur")),
('1A', _("1A")), ('1A', _("1A")),
@ -113,8 +115,8 @@ class CustomMail(models.Model):
class Event(models.Model): class Event(models.Model):
title = models.CharField("Titre", max_length=200) title = models.CharField("Titre", max_length=200)
location = models.CharField("Lieu", max_length=200) location = models.CharField("Lieu", max_length=200)
start_date = models.DateField("Date de début", blank=True, null=True) start_date = models.DateTimeField("Date de début", blank=True, null=True)
end_date = models.DateField("Date de fin", blank=True, null=True) end_date = models.DateTimeField("Date de fin", blank=True, null=True)
description = models.TextField("Description", blank=True) description = models.TextField("Description", blank=True)
image = models.ImageField("Image", blank=True, null=True, image = models.ImageField("Image", blank=True, null=True,
upload_to="imgs/events/") upload_to="imgs/events/")
@ -262,3 +264,15 @@ class Clipper(models.Model):
def __str__(self): def __str__(self):
return "Clipper %s" % self.username return "Clipper %s" % self.username
@python_2_unicode_compatible
class CalendarSubscription(models.Model):
token = models.UUIDField()
user = models.OneToOneField(User)
other_shows = models.ManyToManyField(Spectacle)
subscribe_to_events = models.BooleanField(default=True)
subscribe_to_my_shows = models.BooleanField(default=True)
def __str__(self):
return "Calendrier de %s" % self.user.get_full_name()

View file

@ -0,0 +1,43 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2>Calendrier dynamique</h2>
{% if success %}
<p class="success">Calendrier mis à jour avec succès</p>
{% endif %}
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<p>Ce formulaire vous permet de définir un calendrier dynamique compatible avec
n'importe quel logiciel ou application d'agenda. Vous pouvez choisir de
souscrire aux événements du COF et/ou aux spectacles BdA.
</p>
{% if token %}
<p>Votre calendrier (compatible avec toutes les applications d'agenda) se trouve à
<a href="{% url 'gestioncof.views.calendar_ics' token %}">cette adresse</a>.</p>
<ul>
<li>Pour l'ajouter à Thunderbird (lightning), il faut copier ce lien et aller
dans <tt>Fichier &gt; Nouveau &gt; Agenda</tt> puis choisir <tt>Sur le
réseau</tt> et le format <tt>.ics</tt>.</li>
<li>Avec Apple, il suffit de cliquer sur lien et d'ouvrir le fichier avec
l'application calendrier.</li>
<li>Google Agenda permet d'importer le fichier au format .ics depuis le menu
<tt>Préférences.</tt></li>
</ul>
{% endif %}
<hr />
<form action="" method="post">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Enregistrer" />
</form>
{% endblock %}

View file

@ -50,7 +50,7 @@
<h3 class="block-title">Divers<span class="pull-right glyphicon glyphicon-question-sign"></span></h3> <h3 class="block-title">Divers<span class="pull-right glyphicon glyphicon-question-sign"></span></h3>
<div class="hm-block"> <div class="hm-block">
<ul> <ul>
<li><a href="{% url "gestioncof.views.calendar" %}">Calendrier dynamique</a></li>
{% if user.profile.is_cof %}<li><a href="{% url "petits-cours-inscription" %}">Inscription pour donner des petits cours</a></li>{% endif %} {% if user.profile.is_cof %}<li><a href="{% url "petits-cours-inscription" %}">Inscription pour donner des petits cours</a></li>{% endif %}
<li><a href="{% url "gestioncof.views.profile" %}">Éditer mon profil</a></li> <li><a href="{% url "gestioncof.views.profile" %}">Éditer mon profil</a></li>

View file

@ -45,3 +45,9 @@ events_patterns = [
url(r'^(?P<event_id>\d+)$', 'gestioncof.views.event'), url(r'^(?P<event_id>\d+)$', 'gestioncof.views.event'),
url(r'^(?P<event_id>\d+)/status$', 'gestioncof.views.event_status'), url(r'^(?P<event_id>\d+)/status$', 'gestioncof.views.event_status'),
] ]
calendar_patterns = [
url(r'^subscription$', 'gestioncof.views.calendar'),
url(r'^(?P<token>[a-z0-9-]+)/calendar.ics$',
'gestioncof.views.calendar_ics')
]

View file

@ -5,6 +5,9 @@ from __future__ import print_function
from __future__ import unicode_literals from __future__ import unicode_literals
import unicodecsv import unicodecsv
import uuid
from datetime import timedelta
from icalendar import Calendar, Event as Vevent
from django.shortcuts import redirect, get_object_or_404, render from django.shortcuts import redirect, get_object_or_404, render
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
@ -17,15 +20,16 @@ from gestioncof.models import Survey, SurveyAnswer, SurveyQuestion, \
SurveyQuestionAnswer SurveyQuestionAnswer
from gestioncof.models import Event, EventRegistration, EventOption, \ from gestioncof.models import Event, EventRegistration, EventOption, \
EventOptionChoice EventOptionChoice
from gestioncof.models import EventCommentField, EventCommentValue from gestioncof.models import EventCommentField, EventCommentValue, \
CalendarSubscription
from gestioncof.shared import send_custom_mail from gestioncof.shared import send_custom_mail
from gestioncof.models import CofProfile, Clipper from gestioncof.models import CofProfile, Clipper
from gestioncof.decorators import buro_required from gestioncof.decorators import buro_required, cof_required
from gestioncof.forms import UserProfileForm, EventStatusFilterForm, \ from gestioncof.forms import UserProfileForm, EventStatusFilterForm, \
SurveyForm, SurveyStatusFilterForm, RegistrationUserForm, \ SurveyForm, SurveyStatusFilterForm, RegistrationUserForm, \
RegistrationProfileForm, AdminEventForm, EventForm RegistrationProfileForm, AdminEventForm, EventForm, CalendarForm
from bda.models import Tirage from bda.models import Tirage, Spectacle
@login_required @login_required
@ -633,3 +637,61 @@ def liste_diffcof(request):
personnes = CofProfile.objects.filter(mailing_cof=True, is_cof=True).all() personnes = CofProfile.objects.filter(mailing_cof=True, is_cof=True).all()
return render(request, "liste_mails.html", {"titre": titre, return render(request, "liste_mails.html", {"titre": titre,
"personnes": personnes}) "personnes": personnes})
@cof_required
def calendar(request):
try:
instance = CalendarSubscription.objects.get(user=request.user)
except CalendarSubscription.DoesNotExist:
instance = None
if request.method == 'POST':
form = CalendarForm(request.POST, instance=instance)
if form.is_valid():
subscription = form.save(commit=False)
if instance is None:
subscription.user = request.user
subscription.token = uuid.uuid4()
subscription.save()
form.save_m2m()
return render(request, "calendar_subscription.html",
{'form': form,
'success': True,
'token': str(subscription.token)})
else:
return render(request, "calendar_subscription.html",
{'form': form, 'error': "Formulaire incorrect"})
else:
return render(request, "calendar_subscription.html",
{'form': CalendarForm(instance=instance),
'token': instance.token if instance else None})
def calendar_ics(request, token):
subscription = get_object_or_404(CalendarSubscription, token=token)
shows = subscription.other_shows.all()
if subscription.subscribe_to_my_shows:
shows |= Spectacle.objects.filter(
attribues__participant__user=subscription.user,
tirage__active=True)
shows = shows.distinct()
vcal = Calendar()
for show in shows:
vevent = Vevent()
vevent.add('dtstart', show.date)
vevent.add('dtend', show.date + timedelta(seconds=7200))
vevent.add('summary', show.title)
vevent.add('location', show.location.name)
vcal.add_component(vevent)
if subscription.subscribe_to_events:
for event in Event.objects.filter(old=False).all():
vevent = Vevent()
vevent.add('dtstart', event.start_date)
vevent.add('dtend', event.end_date)
vevent.add('summary', event.title)
vevent.add('location', event.location)
vevent.add('description', event.description)
vcal.add_component(vevent)
response = HttpResponse(content=vcal.to_ical())
response['Content-Type'] = "text/calendar"
return response

View file

@ -10,4 +10,5 @@ Pillow==2.9.0
simplejson==3.8.2 simplejson==3.8.2
six==1.10.0 six==1.10.0
unicodecsv==0.14.1 unicodecsv==0.14.1
icalendar==3.10
django-bootstrap-form==3.2.1 django-bootstrap-form==3.2.1