Parcourir la source

Merge branch 'call_for_membership_fees'

Baptiste Jonglez il y a 10 ans
Parent
commit
8df7209e01

+ 13 - 1
README.md

@@ -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
 ================

+ 4 - 2
coin/billing/management/commands/charge_subscriptions.py

@@ -1,7 +1,9 @@
 # -*- 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))

+ 0 - 3
coin/html2pdf.py

@@ -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))

+ 40 - 4
coin/members/admin.py

@@ -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',

+ 58 - 0
coin/members/management/commands/call_for_membership_fees.py

@@ -0,0 +1,58 @@
+# -*- 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))

+ 20 - 0
coin/members/migrations/0012_member_date_last_call_for_membership_fees_email.py

@@ -0,0 +1,20 @@
+# -*- 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,
+        ),
+    ]

+ 42 - 13
coin/members/models.py

@@ -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'

+ 16 - 0
coin/members/templates/members/emails/call_for_membership_fees.html

@@ -0,0 +1,16 @@
+<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>

+ 1 - 0
coin/members/templates/members/emails/call_for_membership_fees_subject.txt

@@ -0,0 +1 @@
+Renouvellement de cotisation {{ branding.shortname|capfirst }}

+ 95 - 15
coin/members/tests.py

@@ -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

+ 31 - 0
coin/utils.py

@@ -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'))