Browse Source

Merge branch 'call_for_membership_fees'

Baptiste Jonglez 10 years ago
parent
commit
8df7209e01

+ 13 - 1
README.md

@@ -103,7 +103,10 @@ Database
 At this point, you should setup your database: we highly recommend PostgreSQL.
 At this point, you should setup your database: we highly recommend PostgreSQL.
 SQLite might work, but some features will not be available:
 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:
 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
 PDF version) for each subscriber.  You probably want to run this command
 every month as a cron task, see below.
 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
 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`
 `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
 More information
 ================
 ================

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

@@ -1,7 +1,9 @@
 # -*- coding: utf-8 -*-
 # -*- coding: utf-8 -*-
 import datetime
 import datetime
 from django.core.management.base import BaseCommand, CommandError
 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
 from coin.billing.create_subscriptions_invoices import create_all_members_invoices_for_a_period
 
 
 
 
@@ -20,8 +22,8 @@ class Command(BaseCommand):
 
 
         self.stdout.write(
         self.stdout.write(
             'Create invoices for all members for the date : %s' % date)
             '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(
         self.stdout.write(
             u'%d invoices were created' % len(invoices))
             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.conf import settings
 from django.template import loader, Context
 from django.template import loader, Context
 from django.core.files import File
 from django.core.files import File
-from django.utils import translation
 
 
 
 
 def link_callback(uri, rel):
 def link_callback(uri, rel):
@@ -52,8 +51,6 @@ def render_as_pdf(template, context):
     converti en PDF via le module xhtml2pdf.
     converti en PDF via le module xhtml2pdf.
     Renvoi un objet de type File
     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)
     template = loader.get_template(template)
     html = template.render(Context(context))
     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']
     search_fields = ['username', 'first_name', 'last_name', 'email']
     ordering = ('status', 'username')
     ordering = ('status', 'username')
     actions = [delete_selected, 'set_as_member', 'set_as_non_member',
     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
     form = MemberChangeForm
     add_form = MemberCreationForm
     add_form = MemberCreationForm
@@ -59,7 +59,8 @@ class MemberAdmin(UserAdmin):
         ('Authentification', {'fields': (
         ('Authentification', {'fields': (
             ('username', 'password'))}),
             ('username', 'password'))}),
         ('Permissions', {'fields': (
         ('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 = (
     add_fieldsets = (
@@ -140,18 +141,53 @@ class MemberAdmin(UserAdmin):
         if return_httpredirect:
         if return_httpredirect:
             return HttpResponseRedirect(reverse('admin:members_member_changelist'))
             return HttpResponseRedirect(reverse('admin:members_member_changelist'))
 
 
-
     def bulk_send_welcome_email(self, request, queryset):
     def bulk_send_welcome_email(self, request, queryset):
         """
         """
         Action appelée lorsque l'admin souhaite envoyer un lot d'email de bienvenue
         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
         depuis une sélection de membre dans la vue liste de l'admin
         """
         """
         for member in queryset.all():
         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,
         messages.success(request,
                          'Le courriel de bienvenue a été envoyé à %d membre(s).' % queryset.count())
                          'Le courriel de bienvenue a été envoyé à %d membre(s).' % queryset.count())
     bulk_send_welcome_email.short_description = "Envoyer le courriel de bienvenue"
     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):
 class MembershipFeeAdmin(admin.ModelAdmin):
     list_display = ('member', 'end_date', 'amount', 'payment_method',
     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 ldapdb.models
 import unicodedata
 import unicodedata
 import datetime
 import datetime
+
 from django.db import models
 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.db.models.signals import pre_save
 from django.dispatch import receiver
 from django.dispatch import receiver
 from django.contrib.auth.models import AbstractUser
 from django.contrib.auth.models import AbstractUser
 from django.conf import settings
 from django.conf import settings
 from django.core.validators import RegexValidator
 from django.core.validators import RegexValidator
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
+from django.utils import timezone
 from ldapdb.models.fields import CharField, IntegerField, ListField
 from ldapdb.models.fields import CharField, IntegerField, ListField
 
 
 from coin.offers.models import OfferSubscription
 from coin.offers.models import OfferSubscription
@@ -70,6 +72,9 @@ class Member(CoinLdapSyncMixin, AbstractUser):
                                 help_text="Commentaires libres (informations"
                                 help_text="Commentaires libres (informations"
                                 " spécifiques concernant l'adhésion,"
                                 " spécifiques concernant l'adhésion,"
                                 " raison du départ, etc)")
                                 " 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 :
     # Following fields are managed by the parent class AbstractUser :
     # username, first_name, last_name, email
     # 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
     # Renvoie la date de fin de la dernière cotisation du membre
     def end_date_of_membership(self):
     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"
     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 False
+        return (end_date >= date)
 
 
     def set_password(self, new_password, *args, **kwargs):
     def set_password(self, new_password, *args, **kwargs):
         """
         """
@@ -254,9 +259,33 @@ class Member(CoinLdapSyncMixin, AbstractUser):
         from coin.isp_database.models import ISPInfo
         from coin.isp_database.models import ISPInfo
 
 
         utils.send_templated_email(to=self.email,
         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:
     class Meta:
         verbose_name = 'membre'
         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
 from __future__ import unicode_literals
 
 
 import os
 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 import db
 from django.test import TestCase, Client, override_settings
 from django.test import TestCase, Client, override_settings
 from django.contrib.auth.models import User
 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
 from coin.members.models import Member, MembershipFee, LdapUser
-import logging
-import ldapdb
-from pprint import pprint
 
 
 
 
 class MemberTests(TestCase):
 class MemberTests(TestCase):
@@ -300,8 +303,8 @@ class MemberTests(TestCase):
                         last_name=last_name, username=username)
                         last_name=last_name, username=username)
         member.save()
         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
         # Créé une cotisation
         membershipfee = MembershipFee(member=member, amount=20,
         membershipfee = MembershipFee(member=member, amount=20,
@@ -323,17 +326,17 @@ class MemberTests(TestCase):
                         last_name=last_name, username=username)
                         last_name=last_name, username=username)
         member.save()
         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
         # Test qu'un membre sans cotisation n'est pas à jour
         self.assertEqual(member.is_paid_up(), False)
         self.assertEqual(member.is_paid_up(), False)
 
 
         # Créé une cotisation passée
         # Créé une cotisation passée
         membershipfee = MembershipFee(member=member, amount=20,
         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()
         membershipfee.save()
         # La cotisation s'étant terminée il y a 10 jours, il ne devrait pas
         # La cotisation s'étant terminée il y a 10 jours, il ne devrait pas
         # être à jour de cotistion
         # être à jour de cotistion
@@ -341,9 +344,9 @@ class MemberTests(TestCase):
 
 
         # Créé une cotisation actuelle
         # Créé une cotisation actuelle
         membershipfee = MembershipFee(member=member, amount=20,
         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()
         membershipfee.save()
         # La cotisation se terminant dans 10 jour, il devrait être à jour
         # La cotisation se terminant dans 10 jour, il devrait être à jour
         # de cotisation
         # de cotisation
@@ -403,6 +406,83 @@ class MemberAdminTests(TestCase):
         member.delete()
         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):
 class MemberTestsUtils(object):
 
 
     @staticmethod
     @staticmethod

+ 31 - 0
coin/utils.py

@@ -8,7 +8,9 @@ import base64
 import html2text
 import html2text
 import re
 import re
 from datetime import date, timedelta
 from datetime import date, timedelta
+from contextlib import contextmanager
 
 
+from django.utils import translation
 from django.core.mail import EmailMultiAlternatives
 from django.core.mail import EmailMultiAlternatives
 from django.core.files.storage import FileSystemStorage
 from django.core.files.storage import FileSystemStorage
 from django.conf import settings
 from django.conf import settings
@@ -112,5 +114,34 @@ def end_of_month():
     else:
     else:
         return date(today.year, today.month + 1, 1) - timedelta(days=1)
         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__':
 if __name__ == '__main__':
     print(ldap_hash('coin'))
     print(ldap_hash('coin'))