Commit 131adcd3 authored by jocelyn's avatar jocelyn Committed by Team adminsys FFDN

Merge branch 'jd-maillist-management' of FFDN/coin into master

parents 0af8a91f e0517c2e
......@@ -323,6 +323,10 @@ Some apps are not enabled by default :
any authentication backend or user configuration ("marque blanche")
- *hardware_provisioning* : Self-service app to manage hardware inventory,
hardware lent to members or in different stock sites.
- *maillists* : Self-service mailling-list (un)subscription for members:
handles subscribers list for a third-party mailling-list server (sympa,
mailman…
etc). See [maillists app documentation (fr)](doc/admin/maillists.md).
You can enable them using the `EXTRA_INSTALLED_APPS` setting.
E.g. in `settings_local.py`:
......@@ -343,11 +347,10 @@ Settings
List of available settings in your `settings_local.py` file.
- `EXTRA_INSTALLED_APPS`: See *Customizing app list*
- `EXTRA_TEMPLATE_DIRS`: See *Customizing templates*
- `EXTRA_INSTALLED_APPS`: See [using optional apps](#using-optional-apps)
- `EXTRA_TEMPLATE_DIRS`: See [customizing templates](#customizing-templates)
- `LDAP_ACTIVATE`: See *LDAP*
- `MEMBER_MEMBERSHIP_INFO_URL`: Link to a page with information on how to become a member or pay the membership fee
- `MEMBERSHIP_FEE_REMINDER_DATES`: how long before/after the membership fee
anniversary date we want to send a reminder email to the member. It defaults
to the following:
......@@ -371,6 +374,11 @@ MEMBERSHIP_FEE_REMINDER_DATES = [
- `HANDLE_BALANCE`: Allows to handle money balances for members (False default)
- `INVOICES_INCLUDE_CONFIG_COMMENTS`: Add comment related to a subscription configuration when generating invoices
- `MEMBER_CAN_EDIT_VPN_CONF`: Allow members to edit some part of their vpn configuration
- `MAILLIST_SYNC_COMMAND` : The command to send the list of mail addresses of a
given mailling list to mail list server. The command will receives one
address/line on stdin. This setting could use placholders:
- `{email}`: the mail address of the list
- `{short_name}`: the list name
- `DEBUG` : Enable debug for development **do not use in production** : display
stracktraces and enable [django-debug-toolbar](https://django-debug-toolbar.readthedocs.io).
- `SITE_TITLE`: the base of site title (displayed in browser window/tab title)
......
......@@ -337,3 +337,7 @@ HANDLE_BALANCE = False
# Add subscription comments in invoice items
INVOICES_INCLUDE_CONFIG_COMMENTS = True
## maillist module
# Command that push mailling-list subscribers to the lists server
MAILLIST_SYNC_COMMAND = ''
......@@ -4,6 +4,7 @@ from settings_base import *
EXTRA_INSTALLED_APPS = (
'hardware_provisioning',
'maillists',
'vpn',
)
......
/* Remove the titles from inlines elements
Generaly, this is redundant, with fields content */
form .inline-group .inline-related td.original p, /* TabularInline */
form .inline-group .inline-related h3 { /* TabularStacked */
display: none;
}
.inline-group .tabular tr.has_original td {
padding-top: 5px;
}
/* Remove « Add » and « Edit » icons near member search in mailling list members management
*/
.inline-related.dynamic-maillinglistsubscription_set .related-widget-wrapper-link.change-related ,
.inline-related.dynamic-maillinglistsubscription_set .related-widget-wrapper-link.add-related {
display: none;
}
......@@ -181,6 +181,18 @@ table.no-background tr {
word-break: break-all;
}
/* Specific table: mailling-list subscriptions */
#mail-list-subscriptions .select-col {
min-width: 8rem;
}
#mail-list-subscriptions tr td select:last-child {
margin: 0; /* So that they line-up vertically */
padding: 0;
}
#mail-list-subscriptions input[type=submit] {
min-width: 100%;
}
/* login page */
#login-form {}
#login-form table td {
......@@ -409,12 +421,18 @@ form .helptext {
}
.message.warning {
color: #620
color: #620;
background-color: #FFAE00;
font-style: normal;
border-radius: 0;
}
.message.error {
color: #B90202;
background-color: #FFBABA;
}
.eat-up {
margin-top: -1.5em;
}
......@@ -422,6 +440,10 @@ form .helptext {
content: "✔ ";
}
.message.error:before {
content: "✘ ";
}
.nowrap {
overflow: hidden;
text-overflow: ellipsis;
......
......@@ -5,6 +5,7 @@
{% block extrahead %}
<link rel="stylesheet" type="text/css" href="{% static 'hijack/hijack-styles.css' %}" />
<link rel="stylesheet" type="text/css" href="{% static 'css/admin-local.css' %}" />
<script src="{% static "js/vendor/jquery.js" %}" type="text/javascript"></script>
<link rel="stylesheet" href="{% static "css/font-awesome.min.css"%}" />
{% include 'autocomplete_light/static.html' %}
......
......@@ -7,6 +7,11 @@
<li class="{% ifactive 'hardware_provisioning:loan-list' %}active{% endifactive %}"><a href="{% url 'hardware_provisioning:loan-list' %}"><i
class="fa fa-exchange fa-fw"></i> Mon matériel</a></li>
{% endif %}
{% if 'maillists' in INSTALLED_APPS %}
<li class="{% ifactive 'maillists:lists-list' %}active{% endifactive %}"><a href="{% url 'maillists:lists-list' %}"><i
class="fa fa-envelope fa-fw"></i> Listes mail</a></li>
{% endif %}
<li class="{% ifactive 'members:contact' %}active{% endifactive %}"><a href="{% url 'members:contact' %}"><i class="fa fa-life-ring fa-fw"></i> Contact / Support</a></li>
<li class="divider"></li>
{% if user.is_staff %}<li><a href="{% url 'admin:index' %}"><i class="fa fa-cogs fa-fw"></i> Administration</a></li>{% endif %}
......
<style>
figure img {
max-width: 30rem;
}
</style>
Gestion des abonnements aux listes mail
=======================================
Coin offre un module optionnel pour que les adhérent·e·s puissent
s'abonner/désabonner aux listes mail de l'asso. de manière autonome (listes de
discussions et/ou de diffusion).
Coin n'est pas lui-même un serveur de listes mail. Il se contente de
s'interfacer avec ce dernier pour lui pousser des liste d'inscrit·e·s.
Il existe une interface membre pour auto-gérer ses propres abonnements, et une
interface admin pour gérer l'ensemble des abonnements lorsqu'on possède les
droits admin dans coin.
<figure>
![vue adhérent](../_img/user-maillists.png)
![vue admin](../_img/admin-maillists.png)
<figcaption>Vues adhérent·e et admin</figcaption>
</figure>
Fonctionnement
--------------
Coin stocke les abonnements des membres aux listes dans sa base de données, en
utilisant l'adresse mail de la fiche membre.
Il synchronise ensuite ces listes vers le serveur de listes mail en appelant
une commande pour chaque liste gérée. Cette commande est configurable, et
reçoit sur son entrée standard la liste des emails inscrits (une adresse mail
par ligne).
Si le serveur de base données est sur une machine différente du serveur Coin,
il est possible d'utiliser la commande SSH qui appelle un script distant.
La synchronisation sera faite :
- À l'abonnement/désabonnement à une liste via l'interface admin de Coin
(section « Listes mail ») ;
- à l'abonnement/désabonnement à une liste via l'interface membre de Coin ;
- au changement d'adresse mail d'un·e adhérent·e ;
- à la suppression d'un·e adhérent·e.
Avertissements
---------------
- En l'état, tout membre avec des identifiants Coin aura pouvoir d'auto-gérer
son inscription à toute liste gérée par coin. Il n'est cependant pas
obligatoire donner à Coin la main sur les abonnements à toutes les listes d'un
serveur de listes.
- Il est préférable que les listes mail gérées par Coin le soient totalement. Si
des inscriptions sont faites par d'autres moyens (mail, interface du serveur de
liste de discussions… etc), ces modifications risquent d'être écrasées par la
gestion d'abonnements de Coin.
- La commande de synchro est lancée à chaque abonnement/désabonnement. Si vous
inscrivez 100 membres d'un coup, ça pourrait être un peu long si ça passe
par SSH. Astuce quand vous initialisez vos listes pour la première fois donc :
1. mettre une commande de synchro bidon,
2. faire toutes vos inscriptions
3. mettre la vraie commande de synchro
4. lancer une synchro manuelle de chaque liste
Mise en place
-------------
### 1. Activer l'app *maillist*
Il faut activer l'app *maillists*, cela
fonctionne
[comme les autres apps optionelles](../README.md#using-optional-apps). Il faut
ensuite lancer la commande suivante pour mettre à jour la base de données :
./manage.py migrate
### 2. Configurer la synchronisation.
Il y a un paramètre obligatoire à renseigner dans votre fichier.
*settings_local.py* : `MAILLIST_SYNC_COMMAND`.
Il s'agit de la commande à lancer pour « pousser » la liste d'emails inscrits à
une liste mail donnée.
Vous pouvez utiliser des variables dans la commande à
lancer :
- `{short_name}` : l'identifiant court de la liste (ex: *discussion*)
- `{email}` : l'email de la liste (ex: *discussion@example.com*)
Si par exemple votre serveur de listes attend un bête fichier texte
avec une adresse par ligne dans */etc/lists/nomdelaliste* :
MAILLIST_SYNC_COMMAND = 'tee /etc/lists/{short_name}'
Si le ce même serveur se situe sur un autre serveur, on pourra utiliser par
exemple :
MAILLIST_SYNC_COMMAND = "ssh coin@mail.example.com 'tee /etc/lists/{short_name}'"
Des cas d'usages plus complexes (ex: inscription via une API HTTP) sont
envisageables en recourant à un petit script sur mesure.
### 3. Ajouter des listes
Se rendre dans l'admin de coin et dans la nouvelle catégorie « Listes mail »,
renseigner les listes mail que l'on souhaite voir gérées par Coin.
default_app_config = 'maillists.app.MailListsConfig'
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import autocomplete_light
from django.contrib import admin
from django.contrib import messages
from django.http import HttpResponseRedirect
from .models import MaillingList, MaillingListSubscription, SyncCommandError
import coin.members.admin
class AddMaillingListSubscriptionInline(admin.StackedInline):
model = MaillingListSubscription
extra = 0
fields = ('member', 'maillinglist')
verbose_name_plural = "Ajouter un abonnement à une liste mail"
verbose_name = "abonnement"
form = autocomplete_light.modelform_factory(MaillingListSubscription, fields='__all__')
def get_queryset(self, request):
qs = super(AddMaillingListSubscriptionInline, self).get_queryset(request)
return qs.none()
def has_delete_permission(self, request, obj=None):
return False
class AddSubscriberInline(AddMaillingListSubscriptionInline):
verbose_name_plural = "Ajouter des abonné·e·s"
verbose_name = "abonné·e"
class MaillingListSubscriptionInline(admin.TabularInline):
model = MaillingListSubscription
readonly_fields = ('member', 'maillinglist',)
def has_add_permission(self, request, obj=None):
return False
class SubscribersInline(MaillingListSubscriptionInline):
verbose_name_plural = "Abonné·e·s"
verbose_name = "abonné·e"
class MaillingListAdmin(admin.ModelAdmin):
list_display = ('email', 'verbose_name')
actions = ['sync_to_server']
def sync_to_server(self, request, queryset):
for _list in queryset.all():
try:
_list.sync_to_list_server()
except Exception as e:
messages.error(
request,
'Impossible de synchroniser la liste {} : "{}"'.format(
_list, e))
else:
messages.success(
request,
'Liste {} synchronisée vers le serveur'.format(
_list.email))
sync_to_server.short_description = (
'Synchroniser les listes sélectionnées vers le serveur')
inlines = [AddSubscriberInline, SubscribersInline]
def change_view(self, request, object_id, *args, **kwargs):
try:
return super(MaillingListAdmin, self).change_view(
request, object_id, *args, **kwargs)
except SyncCommandError as e:
try:
ml = MaillingList.objects.get(pk=object_id)
ml_name = "La liste mail « {} »".format(ml.short_name)
except MaillingList.DoesNotExist:
ml_name = "La nouvelle liste mail"
messages.error(
request,
"{} n'a pas pu être synchronisée".format(ml_name) +
" vers le serveur de listes : « {} ».".format(e))
return HttpResponseRedirect(request.path)
admin.site.register(MaillingList, MaillingListAdmin)
class MemberAdmin(coin.members.admin.MemberAdmin):
inlines = coin.members.admin.MemberAdmin.inlines + [
MaillingListSubscriptionInline,
AddMaillingListSubscriptionInline,
]
def change_view(self, request, *args, **kwargs):
try:
return super(MemberAdmin, self).change_view(
request, *args, **kwargs)
except SyncCommandError as e:
messages.error(
request,
"Les listes mails n'ont pas pu être synchronisées" +
" vers le serveur de listes : « {} ».".format(e))
return HttpResponseRedirect(request.path)
admin.site.unregister(coin.members.admin.Member)
admin.site.register(coin.members.admin.Member, MemberAdmin)
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.apps import AppConfig
import coin.apps
class MailListsConfig(AppConfig, coin.apps.AppURLs):
name = 'maillists'
verbose_name = "Listes mail"
exported_urlpatterns = [('maillists', 'maillists.urls')]
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='MaillingList',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('short_name', models.CharField(help_text='c\'est l\'identifiant qui servira \xe0 communiquer avec le syst\xe8me de mailling-list(typiquement, la partie avant le "@" dans l\'adress )', max_length=50, verbose_name='identifiant technique')),
('email', models.EmailField(max_length=254, verbose_name="adresse mail d'envoi")),
('verbose_name', models.CharField(help_text="Nom affich\xe9 dans l'interface membre", max_length=130, verbose_name='nom complet')),
('description', models.TextField()),
],
options={
'verbose_name': 'liste mail',
'verbose_name_plural': 'listes mail',
},
),
migrations.CreateModel(
name='MaillingListSubscription',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('maillinglist', models.ForeignKey(to='maillists.MaillingList')),
('member', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'abonnement \xe0 une liste mail',
'verbose_name_plural': 'abonnements \xe0 des listes mail',
},
),
migrations.AddField(
model_name='maillinglist',
name='subscribers',
field=models.ManyToManyField(related_name='subscribed_maillinglists', verbose_name='abonn\xe9\xb7e\xb7s', to=settings.AUTH_USER_MODEL, through='maillists.MaillingListSubscription', blank=True),
),
]
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import subprocess
from django.conf import settings
from django.db import models
from django.db.models.signals import pre_save, post_save, post_delete
from django.dispatch import receiver
from coin.members.models import Member
class SyncCommandError(SystemError):
pass
class MaillingListSubscription(models.Model):
member = models.ForeignKey(Member, verbose_name='membre')
maillinglist = models.ForeignKey('MaillingList', verbose_name='liste mail')
class Meta:
verbose_name = 'abonnement à une liste mail'
verbose_name_plural = 'abonnements aux listes mail'
unique_together = ('member', 'maillinglist')
def __str__(self):
return str(self.maillinglist)
class MaillingList(models.Model):
short_name = models.CharField(
'identifiant technique', max_length=50,
help_text=(
"c'est l'identifiant qui servira à "
"communiquer avec le serveur de liste mail "
"(typiquement, la partie avant le \"@\" dans l'adress )"
)
)
email = models.EmailField("adresse mail d'envoi")
verbose_name = models.CharField(
'nom complet', max_length=130,
help_text="Nom affiché dans l'interface membre"
)
description = models.TextField()
subscribers = models.ManyToManyField(
Member, related_name='subscribed_maillinglists',
through=MaillingListSubscription,
verbose_name='abonné·e·s', blank=True)
class Meta:
verbose_name = 'liste mail'
verbose_name_plural = 'listes mail'
def __unicode__(self):
return '{} ({})'.format(self.verbose_name, self.email)
def as_text_listing(self):
""" One subscriber email per line
"""
return '\n'.join(
self.subscribers.values_list('email', flat=True))
def sync_to_list_server(self, force_clear=False):
if not settings.MAILLIST_SYNC_COMMAND:
raise ValueError('You should define MAILLIST_SYNC_COMMAND'
' setting to use maillist module')
else:
cmd = settings.MAILLIST_SYNC_COMMAND.format(
email=self.email,
short_name=self.short_name,
)
p = subprocess.Popen(
cmd, shell=True,
stdin=subprocess.PIPE, stderr=subprocess.PIPE)
if force_clear:
text_listing = ''
else:
text_listing = self.as_text_listing()
out_stdout, out_stderr = p.communicate(text_listing)
if p.returncode != 0:
raise SyncCommandError(
"Erreur à l'appel de la commande : \"{}\"".format(
out_stderr.decode('utf-8')))
@receiver(post_save, sender=MaillingListSubscription)
def push_new_subscription(sender, instance, created, raw, *args, **kwargs):
if raw:
print("The synchronization of mailling list with Coin was not performed, please launch it by hand in the admin interface.")
else:
instance.maillinglist.sync_to_list_server()
@receiver(post_delete, sender=MaillingListSubscription)
def push_remove_subscription(sender, instance, *args, **kwargs):
instance.maillinglist.sync_to_list_server()
@receiver(pre_save, sender=Member)
def store_previous_email(sender, instance, *args, **kwargs):
"""Record the email address for post_save handler
update_an_email_address needs the old email address for comparison, but
this information is not available at post_save stage.
"""
member = instance
# if not, this is a user creation, nothing to do
if member.pk:
old_member = Member.objects.get(pk=member.pk)
member._previous_email = old_member.email
@receiver(post_save, sender=Member)
def update_an_email_address(sender, instance, *args, **kwargs):
"""Check if the member email has changed and sync mail lists if so.
We do that at post_save stage because we need the new information to be
recorded in database, otherwise, sync_list_to_server() would use the old
email.
"""
member = instance
old_email = getattr(member, '_previous_email', None)
if old_email and (old_email != member.email):
for maillist in member.subscribed_maillinglists.all():
maillist.sync_to_list_server()
# Error handling ???
# try:
# except SyncCommandError as e:
# print("error", e)
# we cannot send a message because we don't have the request
{% extends "base.html" %}
{% block title %}Listes mail - {{ block.super }}{% endblock %}
{% block content %}
<h2>Listes mail : mes abonnements</h2>
<style>
table {
}
</style>
{% for message in messages %}
<div class="message {{ message.tags }}">{{ message }}</div>
{% endfor %}
<p>
Dans ce tableau, tu peux gérer toi-même tes abonnements/désabonnements aux
listes qui sont en libre accès pour les membres.
</p>
<form action="" method="post" id="mail-list-subscriptions">{% csrf_token %}
{{ formset.management_form }}
<table class="full-width">
<thead>
<tr>
<th>Liste</th>
<th class="select-col">Abonnement</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{% for form in formset %}
<tr>
<td>{{ form.initial.maillinglist }}</td>
<td>{{ form.maillinglist }}{{ form.subscribed }}</td>
<td>{{ form.initial.maillinglist.description|linebreaks }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<input type="submit" class="button" value="Enregistrer mes paramètres d'abonnement" />
</form>
{% endblock %}
from os.path import join, exists
import shutil
import tempfile
from django.conf import settings
from django.test import TestCase, override_settings
from coin.members.models import Member
from .models import MaillingList
@override_settings()
class SubscriptionTestCase(TestCase):
def setUp(self):
self.member = Member.objects.create(
first_name=u"Toto",
last_name=u"L'artichaut",