Browse Source

Merge branch 'call_for_membership_fees' of https://code.ffdn.org/zorun/coin into call_for_membership_fees
+ Fix tests

Conflicts:
coin/members/management/commands/call_for_membership_fees.py

Fabs 10 years ago
parent
commit
61738e48b2

+ 75 - 7
README.md

@@ -23,11 +23,27 @@ A mirror of the code is available at:
   https://code.ffdn.org/zorun/coin/
 
 
+Demo
+====
+
+A demo of COIN is publicly available at:
+
+  https://coin-dev.illyse.org
+
+Login: ffdn
+Password: internet
+
+This user account has access to the administration interface.
+
+
 Quickstart
 ==========
 
-Get yourself a virtualenv. On Debian, install `python-virtualenv`. On
-Archlinux, the package is called `python2-virtualenv`, and you must
+Virtualenv
+----------
+
+Using a virtualenv is recommended.  On Debian, install `python-virtualenv`.
+On Archlinux, the package is called `python2-virtualenv`, and you must
 replace the `virtualenv` command with `virtualenv2` in the following.
 
 To create the virtualenv (the first time):
@@ -56,6 +72,9 @@ You may experience problems with SSL certificates du to self-signed cert used by
 You should now be able to run `python manage.py` (within the
 virtualenv, obviously) without error.
 
+Settings
+--------
+
 The `coin/settings_local.py` file is ignored by Git: feel free to override any
 setting by writing into that file. For example, to override the `DEBUG`
 settings:
@@ -63,14 +82,27 @@ settings:
     echo '# -*- coding: utf-8 -*-' > coin/settings_local.py
     echo 'DEBUG = TEMPLATE_DEBUG = True' >> coin/settings_local.py
 
-
-At this point, you should setup your database.  Recommended is postgreSQL,
-but you might be able to use SQLite.  For more information, see https://www.illyse.org/projects/ils-si/wiki/Mise_en_place_environnement_de_dev
-
 If you don't want to use LDAP, just set in your `settings_local.py`:
 
     LDAP_ACTIVATE = False
 
+See the end of this README for a reference of available configuration settings.
+
+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 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:
+
+  https://www.illyse.org/projects/ils-si/wiki/Mise_en_place_environnement_de_dev
+
 The first time, you need to create the database, create a superuser, and
 import some base data to play with:
 
@@ -82,8 +114,10 @@ Note that the superuser will be inserted into the LDAP backend exactly in the
 same way as all other members, so you should use a real account (not just
 admin/admin).
 
-Then, at each code update, you only need to apply migrations:
+Then, at each code update, you will only need to update dependencies and apply
+new migrations:
 
+    pip install -r requirements.txt
     python manage.py migrate
 
 
@@ -92,6 +126,27 @@ At this point, Django should run correctly:
     python manage.py runserver
 
 
+Available commands
+==================
+
+Some useful administration commands are available via `manage.py`.
+
+`python manage.py members_email`: returns email addresses of all members, one
+per line.  This may be useful to automatically feed a mailing list software.
+Note that membership is based on the `status` field of users, not on
+membership fees.  That is, even if a member has forgot to renew his or her
+membership fee, his or her address will still show up in this list.
+
+`python manage.py charge_subscriptions`: generate invoices (including a
+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
 =============
 
@@ -112,6 +167,19 @@ in the admin.  Information entered in this application has two purposes:
 Some bits of configuration are done in `settings.py`: LDAP branches, RSS feeds
 to display on the home page, and so on.
 
+Cron tasks
+----------
+
+You may want to run cron jobs for repetitive tasks.
+
+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
 ================

+ 3 - 1
coin/billing/create_subscriptions_invoices.py

@@ -13,11 +13,13 @@ from coin.members.models import Member
 from coin.billing.models import Invoice, InvoiceDetail
 
 
-def create_all_members_invoices_for_a_period(date=datetime.date.today()):
+def create_all_members_invoices_for_a_period(date=None):
     """
     Pour chaque membre ayant au moins un abonnement actif, génère les factures
     en prenant la date comme premier mois de la période de facturation
     """
+    if date is None:
+        date = datetime.date.today()
     members = Member.objects.filter(
         Q(offersubscription__resign_date__isnull=True) |
         Q(offersubscription__resign_date__gte=date))

+ 3 - 1
coin/billing/models.py

@@ -41,7 +41,9 @@ class Invoice(models.Model):
         ('trouble', 'Litige')
     )
 
-    validated = models.BooleanField(default=False, verbose_name='validée')
+    validated = models.BooleanField(default=False, verbose_name='validée',
+                                    help_text='Once validated, a PDF is generated'
+                                    ' and the invoice cannot be modified')
     number = models.CharField(max_length=25,
                               default=next_invoice_number,
                               unique=True,

+ 19 - 14
coin/isp_database/models.py

@@ -36,6 +36,17 @@ class ISPInfo(SingleInstanceMixin, models.Model):
     The naming convention is different from Python/django so that it
     matches exactly the format (which uses CamelCase...)
     """
+    # These two properties can be overriden with static counters, see below.
+    @property
+    def memberCount(self):
+        """Number of members"""
+        return count_active_members()
+
+    @property
+    def subscriberCount(self):
+        """Number of subscribers to an internet access"""
+        return count_active_subscriptions()
+
     name = models.CharField(max_length=512,
                             help_text="The ISP's name")
     # Length required by the spec
@@ -70,10 +81,14 @@ class ISPInfo(SingleInstanceMixin, models.Model):
     longitude = models.FloatField(blank=True, null=True,
         help_text="Coordinates of the registered office (longitude)")
 
-    # Uncomment this if you want to manage these counters by hand.
-    #member_count = models.PositiveIntegerField(help_text="Number of members")
-    #subscriber_count = models.PositiveIntegerField(
-    #    help_text="Number of subscribers to an internet access")
+    # Uncomment this (and handle the necessary migrations) if you want to
+    # manage one of the counters by hand.  Otherwise, they are computed
+    # automatically, which is probably what you want.
+    #memberCount = models.PositiveIntegerField(help_text="Number of members",
+    #                                          default=0)
+    #subscriberCount = models.PositiveIntegerField(
+    #    help_text="Number of subscribers to an internet access",
+    #    default=0)
 
     # field outside of db-ffdn format:
     administrative_email = models.EmailField(
@@ -89,16 +104,6 @@ class ISPInfo(SingleInstanceMixin, models.Model):
         help_text="URL du serveur de listes de discussions/diffusion")
 
     @property
-    def memberCount(self):
-        """Number of members"""
-        return count_active_members()
-
-    @property
-    def subscriberCount(self):
-        """Number of subscribers to an internet access"""
-        return count_active_subscriptions()
-
-    @property
     def version(self):
         """Version of the API"""
         return API_VERSION

+ 16 - 13
coin/members/management/commands/call_for_membership_fees.py

@@ -4,6 +4,7 @@ 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 django.db.models import Max
 
@@ -29,26 +30,28 @@ class Command(BaseCommand):
             raise CommandError(
                 'Please enter a valid date : YYYY-mm-dd (ex: 2011-07-04)')
 
-        # Get membership_fees filtered by end date of membership at specific
-        # date relative to today
-        call_dates = [date + relativedelta(months=-3),
-                      date + relativedelta(months=-2),
-                      date + relativedelta(months=-1),
-                      date,
-                      date + relativedelta(months=+1)]
+        end_dates = [date + relativedelta(months=-3),
+                     date + relativedelta(months=-2),
+                     date + relativedelta(months=-1),
+                     date,
+                     date + relativedelta(months=+1)]
 
-        self.stdout.write(
-            'Select membership fees for following end dates : %s' % call_dates)
+        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.annotate(end_date_membership=Max(
-            'membership_fees__end_date')).filter(end_date_membership__in=call_dates)
+        members = Member.objects.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 %s' % member)
-                    cpt=cpt+1
+                        'Call for membership fees email was sent to {member} ({email})'.format(
+                            member=member, email=member.email))
+                    cpt = cpt + 1
 
         self.stdout.write('%d call for membership fees emails were sent' % cpt)

+ 16 - 14
coin/members/models.py

@@ -6,7 +6,7 @@ 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
@@ -112,22 +112,20 @@ class Member(CoinLdapSyncMixin, AbstractUser):
 
     # Renvoie la date de fin de la dernière cotisation du membre
     def end_date_of_membership(self):
-        try:
-            return self.membership_fees.order_by('-end_date')[0].end_date
-        # TODO: bad practice de tout matcher comme ca
-        except:
-            return None
+        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, date=datetime.date.today()):
+    def is_paid_up(self, date=None):
         """
-        True si le membre est à jour de cotisation à la date passée. 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() >= date:
-            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):
         """
@@ -136,19 +134,23 @@ class Member(CoinLdapSyncMixin, AbstractUser):
         super(Member, self).set_password(new_password, *args, **kwargs)
         self._password_ldap = utils.ldap_hash(new_password)
 
-    def get_active_subscriptions(self, date=datetime.date.today()):
+    def get_active_subscriptions(self, date=None):
         """
         Return list of OfferSubscription which are active today
         """
+        if date is None:
+            date = datetime.date.today()
         return OfferSubscription.objects.filter(
             Q(member__exact=self.pk),
             Q(subscription_date__lte=date),
             Q(resign_date__isnull=True) | Q(resign_date__gte=date))
 
-    def get_inactive_subscriptions(self, date=datetime.date.today()):
+    def get_inactive_subscriptions(self, date=None):
         """
         Return list of OfferSubscription which are not active today
         """
+        if date is None:
+            date = datetime.date.today()
         return OfferSubscription.objects.filter(
             Q(member__exact=self.pk),
             Q(subscription_date__gt=date) |

+ 18 - 5
coin/members/tests.py

@@ -436,20 +436,24 @@ class MemberTestCallForMembershipCommand(TestCase):
         membershipfee.save()
         return membershipfee
 
-    def do_test_email_sent(self, expected_emails=1):
+    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):
+    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)
+        self.do_test_email_sent(expected_emails, reset_date_last_call)
         membershipfee.delete()
 
     def test_call_email_sent_at_expected_dates(self):
@@ -463,13 +467,22 @@ class MemberTestCallForMembershipCommand(TestCase):
     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 envoyé
-        self.do_test_email_sent(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

+ 27 - 0
coin/offers/migrations/0005_auto_20150210_0923.py

@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import django.core.validators
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('offers', '0004_auto_20150120_2309'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='offer',
+            name='billing_period',
+            field=models.IntegerField(default=1, help_text='en mois', verbose_name='p\xe9riode de facturation', validators=[django.core.validators.MinValueValidator(1)]),
+            preserve_default=True,
+        ),
+        migrations.AlterField(
+            model_name='offersubscription',
+            name='commitment',
+            field=models.IntegerField(default=0, help_text='en mois', verbose_name="p\xe9riode d'engagement", validators=[django.core.validators.MinValueValidator(0)]),
+            preserve_default=True,
+        ),
+    ]

+ 23 - 6
coin/offers/models.py

@@ -5,6 +5,7 @@ import datetime
 
 from django.db import models
 from django.db.models import Q
+from django.core.validators import MinValueValidator
 
 
 class Offer(models.Model):
@@ -24,7 +25,8 @@ class Offer(models.Model):
                             help_text="Type de configuration à utiliser avec cette offre")
     billing_period = models.IntegerField(blank=False, null=False, default=1,
                                          verbose_name='période de facturation',
-                                         help_text='en mois')
+                                         help_text='en mois',
+                                         validators=[MinValueValidator(1)])
     period_fees = models.DecimalField(max_digits=5, decimal_places=2,
                                       blank=False, null=False,
                                       verbose_name='montant par période de '
@@ -49,11 +51,25 @@ class Offer(models.Model):
         return self.configuration_type
     get_configuration_type_display.short_description = 'type de configuration'
 
+    def display_price(self):
+        """Displays the price of an offer in a human-readable manner
+        (for instance "30€ / month")
+        """
+        if int(self.period_fees) == self.period_fees:
+            fee = int(self.period_fees)
+        else:
+            fee = self.period_fees
+        if self.billing_period == 1:
+            period = ""
+        else:
+            period = self.billing_period
+        return "{period_fee}€ / {billing_period} mois".format(
+            period_fee=fee,
+            billing_period=period)
+
     def __unicode__(self):
-        return '{name} - {period_fee}€ / {billing_period}m'.format(
-            name=self.name,
-            period_fee=self.period_fees,
-            billing_period=self.billing_period)
+        return '{name} - {price}'.format(name=self.name,
+                                         price=self.display_price())
 
     class Meta:
         verbose_name = 'offre'
@@ -81,7 +97,8 @@ class OfferSubscription(models.Model):
     # TODO: move this to offers?
     commitment = models.IntegerField(blank=False, null=False,
                                      verbose_name="période d'engagement",
-                                     help_text = 'en mois',
+                                     help_text='en mois',
+                                     validators=[MinValueValidator(0)],
                                      default=0)
     member = models.ForeignKey('members.Member', verbose_name='membre')
     offer = models.ForeignKey('Offer', verbose_name='offre')

+ 22 - 0
coin/resources/migrations/0003_auto_20150203_1043.py

@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import netfields.fields
+import coin.resources.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('resources', '0002_ipsubnet_name_server'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='ipsubnet',
+            name='inet',
+            field=netfields.fields.CidrAddressField(validators=[coin.resources.models.validate_subnet], max_length=43, blank=True, help_text='Laisser vide pour allouer automatiquement', unique=True, verbose_name='sous-r\xe9seau'),
+            preserve_default=True,
+        ),
+    ]

+ 3 - 6
coin/resources/models.py

@@ -55,13 +55,8 @@ class IPPool(models.Model):
 
 
 class IPSubnet(models.Model):
-    # TODO: find some way to signal to Subscriptions objects when a subnet
-    # gets modified (so that the subscription can update the LDAP backend
-    # accordingly)
-    # Actually, a better idea would be to build a custom relation and update
-    # LDAP in the relation itself.
     inet = CidrAddressField(blank=True, validators=[validate_subnet],
-                            verbose_name="sous-réseau",
+                            unique=True, verbose_name="sous-réseau",
                             help_text="Laisser vide pour allouer automatiquement")
     objects = NetManager()
     ip_pool = models.ForeignKey(IPPool, verbose_name="pool d'IP")
@@ -91,6 +86,8 @@ class IPSubnet(models.Model):
             first_free = available.next()
         except StopIteration:
             raise ValidationError("Impossible d'allouer un sous-réseau : bloc d'IP rempli.")
+        # first_free is a subnet, but it might be too large for our needs.
+        # This selects the first sub-subnet of the right size.
         self.inet = first_free.subnet(self.ip_pool.default_subnetsize, 1).next()
 
     def validate_inclusion(self):

+ 1 - 2
coin/vpn/models.py

@@ -157,8 +157,7 @@ class VPNConfiguration(CoinLdapSyncMixin, Configuration):
 
 
 class LdapVPNConfig(ldapdb.models.Model):
-    # TODO: déplacer ligne suivante dans settings.py
-    base_dn = settings.VPN_CONF_BASE_DN # "ou=vpn,o=ILLYSE,l=Villeurbanne,st=RHA,c=FR"
+    base_dn = settings.VPN_CONF_BASE_DN
     object_classes = [b'person', b'organizationalPerson', b'inetOrgPerson',
                       b'top', b'radiusprofile']