Commit 8df7209e authored by Baptiste Jonglez's avatar Baptiste Jonglez
Browse files

Merge branch 'call_for_membership_fees'

parents 14bed41d da9764c5
......@@ -103,7 +103,10 @@ Database
At this point, you should setup your database: we highly recommend PostgreSQL.
SQLite might work, but some features will not be available:
- automatic allocation of IP subnet
- automatic allocation of IP subnets (needs proper subnet implementation in
the database)
- sending automated emails to remind of expiring membership fee
(needs aggregation on date fields, see Django doc)
For more information on the database setup, see:
......@@ -147,6 +150,11 @@ membership fee, his or her address will still show up in this list.
PDF version) for each subscriber. You probably want to run this command
every month as a cron task, see below.
`python manage.py call_for_membership_fees`: send reminder emails to members
whose membership fee is about to expire or is already expired (1 month before,
on the day of expiration, 1 month after, 2 months after, and 3 months after).
You should run this command in a cron job every day.
Configuration
=============
......@@ -184,6 +192,10 @@ To generate invoices on the first day of each month, here at 3 am:
`0 3 1 * * /home/coin/venv/bin/python manage.py charge_subscriptions`
To send reminder emails for membership fee expiration:
`42 3 * * * /home/coin/venv/bin/python manage.py call_for_membership_fees`
More information
================
......
# -*- coding: utf-8 -*-
import datetime
from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
from coin.utils import respect_language
from coin.billing.create_subscriptions_invoices import create_all_members_invoices_for_a_period
......@@ -20,8 +22,8 @@ class Command(BaseCommand):
self.stdout.write(
'Create invoices for all members for the date : %s' % date)
invoices = create_all_members_invoices_for_a_period(date)
with respect_language(settings.LANGUAGE_CODE):
invoices = create_all_members_invoices_for_a_period(date)
self.stdout.write(
u'%d invoices were created' % len(invoices))
......
......@@ -9,7 +9,6 @@ from tempfile import NamedTemporaryFile
from django.conf import settings
from django.template import loader, Context
from django.core.files import File
from django.utils import translation
def link_callback(uri, rel):
......@@ -52,8 +51,6 @@ def render_as_pdf(template, context):
converti en PDF via le module xhtml2pdf.
Renvoi un objet de type File
"""
# Force locale, because isn't done when launched from managment command
translation.activate(settings.LANGUAGE_CODE)
template = loader.get_template(template)
html = template.render(Context(context))
......
......@@ -39,7 +39,7 @@ class MemberAdmin(UserAdmin):
search_fields = ['username', 'first_name', 'last_name', 'email']
ordering = ('status', 'username')
actions = [delete_selected, 'set_as_member', 'set_as_non_member',
'bulk_send_welcome_email']
'bulk_send_welcome_email', 'bulk_send_call_for_membership_fee_email']
form = MemberChangeForm
add_form = MemberCreationForm
......@@ -59,7 +59,8 @@ class MemberAdmin(UserAdmin):
('Authentification', {'fields': (
('username', 'password'))}),
('Permissions', {'fields': (
('is_active', 'is_staff', 'is_superuser'))})
('is_active', 'is_staff', 'is_superuser'))}),
(None, {'fields': ('date_last_call_for_membership_fees_email',)})
)
add_fieldsets = (
......@@ -140,18 +141,53 @@ class MemberAdmin(UserAdmin):
if return_httpredirect:
return HttpResponseRedirect(reverse('admin:members_member_changelist'))
def bulk_send_welcome_email(self, request, queryset):
"""
Action appelée lorsque l'admin souhaite envoyer un lot d'email de bienvenue
depuis une sélection de membre dans la vue liste de l'admin
"""
for member in queryset.all():
self.send_welcome_email(request, member.id, return_httpredirect=False)
self.send_welcome_email(
request, member.id, return_httpredirect=False)
messages.success(request,
'Le courriel de bienvenue a été envoyé à %d membre(s).' % queryset.count())
bulk_send_welcome_email.short_description = "Envoyer le courriel de bienvenue"
def bulk_send_call_for_membership_fee_email(self, request, queryset):
# TODO : Add better perm here
if not request.user.is_superuser:
messages.error(
request, 'Vous n\'avez pas l\'autorisation d\'envoyer des '
'courriels de relance.')
return
cpt_success = 0
for member in queryset.all():
if member.send_call_for_membership_fees_email():
cpt_success += 1
else:
messages.warning(request,
"Le courriel de relance de cotisation n\'a pas "
"été envoyé à {member} ({email}) car il a déjà "
"reçu une relance le {last_call_date}"\
.format(member=member,
email=member.email,
last_call_date=member.date_last_call_for_membership_fees_email))
if queryset.count() == 1 and cpt_success == 1:
member = queryset.first()
messages.success(request,
"Le courriel de relance de cotisation a été "
"envoyé à {member} ({email})"\
.format(member=member, email=member.email))
elif cpt_success>1:
messages.success(request,
"Le courriel de relance de cotisation a été "
"envoyé à {cpt} membres"\
.format(cpt=cpt_success))
bulk_send_call_for_membership_fee_email.short_description = 'Envoyer le courriel de relance de cotisation'
class MembershipFeeAdmin(admin.ModelAdmin):
list_display = ('member', 'end_date', 'amount', 'payment_method',
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import datetime
from dateutil.relativedelta import relativedelta
from django.core.management.base import BaseCommand, CommandError
from django.db.models import Max
from django.conf import settings
from coin.utils import respect_language
from coin.members.models import Member, MembershipFee
class Command(BaseCommand):
args = '[date=2011-07-04]'
help = """Send a call for membership email to members.
A mail is sent when end date of membership
reach the anniversary date, 1 month before and once a month
for 3 months.
By default, today is used to compute relative dates, but a date
can be passed as argument."""
def handle(self, *args, **options):
try:
date = datetime.datetime.strptime(args[0], '%Y-%m-%d').date()
except IndexError:
date = datetime.date.today()
except ValueError:
raise CommandError(
'Please enter a valid date : YYYY-mm-dd (ex: 2011-07-04)')
end_dates = [date + relativedelta(months=-3),
date + relativedelta(months=-2),
date + relativedelta(months=-1),
date,
date + relativedelta(months=+1)]
self.stdout.write("Selecting members whose membership fee end at the "
"following dates : {dates}".format(
dates=[str(d) for d in end_dates]))
members = Member.objects.filter(status='member')\
.annotate(end=Max('membership_fees__end_date'))\
.filter(end__in=end_dates)
self.stdout.write(
"Got {number} members.".format(number=members.count()))
cpt = 0
with respect_language(settings.LANGUAGE_CODE):
for member in members:
if member.send_call_for_membership_fees_email():
self.stdout.write(
'Call for membership fees email was sent to {member} ({email})'.format(
member=member, email=member.email))
cpt = cpt + 1
self.stdout.write("{number} call for membership fees emails were "
"sent".format(number=cpt))
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('members', '0011_member_comments'),
]
operations = [
migrations.AddField(
model_name='member',
name='date_last_call_for_membership_fees_email',
field=models.DateTimeField(null=True, verbose_name='Date du dernier email de relance de cotisation envoy\xe9', blank=True),
preserve_default=True,
),
]
......@@ -4,14 +4,16 @@ from __future__ import unicode_literals
import ldapdb.models
import unicodedata
import datetime
from django.db import models
from django.db.models import Q
from django.db.models import Q, Max
from django.db.models.signals import pre_save
from django.dispatch import receiver
from django.contrib.auth.models import AbstractUser
from django.conf import settings
from django.core.validators import RegexValidator
from django.core.exceptions import ValidationError
from django.utils import timezone
from ldapdb.models.fields import CharField, IntegerField, ListField
from coin.offers.models import OfferSubscription
......@@ -70,6 +72,9 @@ class Member(CoinLdapSyncMixin, AbstractUser):
help_text="Commentaires libres (informations"
" spécifiques concernant l'adhésion,"
" raison du départ, etc)")
date_last_call_for_membership_fees_email = models.DateTimeField(null=True,
blank=True,
verbose_name="Date du dernier email de relance de cotisation envoyé")
# Following fields are managed by the parent class AbstractUser :
# username, first_name, last_name, email
......@@ -107,20 +112,20 @@ class Member(CoinLdapSyncMixin, AbstractUser):
# Renvoie la date de fin de la dernière cotisation du membre
def end_date_of_membership(self):
x = self.membership_fees.order_by('-end_date')
if x:
return self.membership_fees.order_by('-end_date')[0].end_date
aggregate = self.membership_fees.aggregate(end=Max('end_date'))
return aggregate['end']
end_date_of_membership.short_description = "Date de fin d'adhésion"
def is_paid_up(self):
def is_paid_up(self, date=None):
"""
True si le membre est à jour de cotisation. False sinon
Teste si le membre est à jour de cotisation à la date donnée.
"""
if self.end_date_of_membership() \
and self.end_date_of_membership() >= datetime.date.today():
return True
else:
if date is None:
date = datetime.date.today()
end_date = self.end_date_of_membership()
if end_date is None:
return False
return (end_date >= date)
def set_password(self, new_password, *args, **kwargs):
"""
......@@ -254,9 +259,33 @@ class Member(CoinLdapSyncMixin, AbstractUser):
from coin.isp_database.models import ISPInfo
utils.send_templated_email(to=self.email,
subject_template='members/emails/welcome_email_subject.txt',
body_template='members/emails/welcome_email.html',
context={'member': self, 'branding':ISPInfo.objects.first()})
subject_template='members/emails/welcome_email_subject.txt',
body_template='members/emails/welcome_email.html',
context={'member': self, 'branding':ISPInfo.objects.first()})
def send_call_for_membership_fees_email(self):
""" Envoi le courriel d'appel à cotisation du membre """
from dateutil.relativedelta import relativedelta
from coin.isp_database.models import ISPInfo
# Si le dernier courriel de relance a été envoyé il y a moins de trois
# semaines, n'envoi pas un nouveau courriel
if (not self.date_last_call_for_membership_fees_email
or (self.date_last_call_for_membership_fees_email
<= timezone.now() + relativedelta(weeks=-3))):
utils.send_templated_email(to=self.email,
subject_template='members/emails/call_for_membership_fees_subject.txt',
body_template='members/emails/call_for_membership_fees.html',
context={'member': self, 'branding':ISPInfo.objects.first(),
'membership_info_url': settings.MEMBER_MEMBERSHIP_INFO_URL,
'today': datetime.date.today})
# Sauvegarde en base la date du dernier envoi de mail de relance
self.date_last_call_for_membership_fees_email = timezone.now()
self.save()
return True
return False
class Meta:
verbose_name = 'membre'
......
<p>Bonjour {{ member }},</p>
<p>Ta cotisation annuelle à l'association {{ branding.shortname|capfirst }}
{% if member.end_date_of_membership >= today %}sera à renouveller à partir du{% else %}est à renouveller depuis le{% endif %} {{ member.end_date_of_membership }}.</p>
<p>Un renouvellement ne necessite pas de remplir une nouvelle fois
le formulaire d'adhésion, tu trouveras toutes les instructions
à cette adresse :<br />
{{ membership_info_url }}</p>
<p>Ce courriel automatique est envoyé
un mois avant la date anniversaire de ton adhésion,
à la date anniversire et
une fois par mois pendant les trois mois suivant la date anniversaire.</p>
<p>L'équipe de l'association {{ branding.shortname|capfirst }}</p>
\ No newline at end of file
Renouvellement de cotisation {{ branding.shortname|capfirst }}
\ No newline at end of file
......@@ -2,15 +2,18 @@
from __future__ import unicode_literals
import os
import datetime
import logging
import ldapdb
from datetime import date
from cStringIO import StringIO
from dateutil.relativedelta import relativedelta
from django import db
from django.test import TestCase, Client, override_settings
from django.contrib.auth.models import User
# from django.contrib.auth.tests.custom_user import ExtensionUser
from django.core import mail, management
from coin.members.models import Member, MembershipFee, LdapUser
import logging
import ldapdb
from pprint import pprint
class MemberTests(TestCase):
......@@ -300,8 +303,8 @@ class MemberTests(TestCase):
last_name=last_name, username=username)
member.save()
start_date = datetime.date.today()
end_date = start_date + datetime.timedelta(365)
start_date = date.today()
end_date = start_date + relativedelta(years=+1)
# Créé une cotisation
membershipfee = MembershipFee(member=member, amount=20,
......@@ -323,17 +326,17 @@ class MemberTests(TestCase):
last_name=last_name, username=username)
member.save()
start_date = datetime.date.today()
end_date = start_date + datetime.timedelta(365)
start_date = date.today()
end_date = start_date + relativedelta(years=+1)
# Test qu'un membre sans cotisation n'est pas à jour
self.assertEqual(member.is_paid_up(), False)
# Créé une cotisation passée
membershipfee = MembershipFee(member=member, amount=20,
start_date=datetime.date.today() -
datetime.timedelta(365),
end_date=datetime.date.today() - datetime.timedelta(10))
start_date=date.today() +
relativedelta(years=-1),
end_date=date.today() + relativedelta(days=-10))
membershipfee.save()
# La cotisation s'étant terminée il y a 10 jours, il ne devrait pas
# être à jour de cotistion
......@@ -341,9 +344,9 @@ class MemberTests(TestCase):
# Créé une cotisation actuelle
membershipfee = MembershipFee(member=member, amount=20,
start_date=datetime.date.today() -
datetime.timedelta(10),
end_date=datetime.date.today() + datetime.timedelta(10))
start_date=date.today() +
relativedelta(days=-10),
end_date=date.today() + relativedelta(days=+10))
membershipfee.save()
# La cotisation se terminant dans 10 jour, il devrait être à jour
# de cotisation
......@@ -403,6 +406,83 @@ class MemberAdminTests(TestCase):
member.delete()
class MemberTestCallForMembershipCommand(TestCase):
def setUp(self):
# Créé un membre
self.username = MemberTestsUtils.get_random_username()
self.member = Member(first_name='Richard', last_name='Stallman',
username=self.username)
self.member.save()
def tearDown(self):
# Supprime le membre
self.member.delete()
MembershipFee.objects.all().delete()
def create_membership_fee(self, end_date):
# Créé une cotisation passée se terminant dans un mois
membershipfee = MembershipFee(member=self.member, amount=20,
start_date=end_date + relativedelta(years=-1),
end_date=end_date)
membershipfee.save()
def create_membership_fee(self, end_date):
# Créé une cotisation se terminant à la date indiquée
membershipfee = MembershipFee(member=self.member, amount=20,
start_date=end_date + relativedelta(years=-1),
end_date=end_date)
membershipfee.save()
return membershipfee
def do_test_email_sent(self, expected_emails = 1, reset_date_last_call = True):
# Vide la outbox
mail.outbox = []
# Call command
management.call_command('call_for_membership_fees', stdout=StringIO())
# Test
self.assertEqual(len(mail.outbox), expected_emails)
# Comme on utilise le même membre, on reset la date de dernier envoi
if reset_date_last_call:
self.member.date_last_call_for_membership_fees_email = None
self.member.save()
def do_test_for_a_end_date(self, end_date, expected_emails=1, reset_date_last_call = True):
# Supprimer toutes les cotisations (au cas ou)
MembershipFee.objects.all().delete()
# Créé la cotisation
membershipfee = self.create_membership_fee(end_date)
self.do_test_email_sent(expected_emails, reset_date_last_call)
membershipfee.delete()
def test_call_email_sent_at_expected_dates(self):
# 1 mois avant la fin, à la fin et chaque mois après la fin pendant 3 mois
self.do_test_for_a_end_date(date.today() + relativedelta(months=+1))
self.do_test_for_a_end_date(date.today())
self.do_test_for_a_end_date(date.today() + relativedelta(months=-1))
self.do_test_for_a_end_date(date.today() + relativedelta(months=-2))
self.do_test_for_a_end_date(date.today() + relativedelta(months=-3))
def test_call_email_not_sent_if_active_membership_fee(self):
# Créé une cotisation se terminant dans un mois
membershipfee = self.create_membership_fee(date.today() + relativedelta(months=+1))
# Un mail devrait être envoyé (ne pas vider date_last_call_for_membership_fees_email)
self.do_test_email_sent(1, False)
# Créé une cotisation enchainant et se terminant dans un an
membershipfee = self.create_membership_fee(date.today() + relativedelta(months=+1, years=+1))
# Pas de mail envoyé
self.do_test_email_sent(0)
def test_date_last_call_for_membership_fees_email(self):
# Créé une cotisation se terminant dans un mois
membershipfee = self.create_membership_fee(date.today() + relativedelta(months=+1))
# Un mail envoyé (ne pas vider date_last_call_for_membership_fees_email)
self.do_test_email_sent(1, False)
# Tente un deuxième envoi, qui devrait être à 0
self.do_test_email_sent(0)
class MemberTestsUtils(object):
@staticmethod
......
......@@ -8,7 +8,9 @@ import base64
import html2text
import re
from datetime import date, timedelta
from contextlib import contextmanager
from django.utils import translation
from django.core.mail import EmailMultiAlternatives
from django.core.files.storage import FileSystemStorage
from django.conf import settings
......@@ -112,5 +114,34 @@ def end_of_month():
else:
return date(today.year, today.month + 1, 1) - timedelta(days=1)
@contextmanager
def respect_language(language):
"""Context manager that changes the current translation language for
all code inside the following block.
Can be used like this::
from amorce.utils import respect_language
def my_func(language='fr'):
with respect_language(language):
pass
"""
if language:
prev = translation.get_language()
translation.activate(language)
try:
yield
finally:
translation.activate(prev)
else:
yield
def respects_language(fun):
"""Associated decorator"""
@wraps(fun)
def _inner(*args, **kwargs):
with respect_language(kwargs.pop('language', None)):
return fun(*args, **kwargs)
return _inner
if __name__ == '__main__':
print(ldap_hash('coin'))
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment