Browse Source

Merge branch 'master' into call_for_membership_fees

Baptiste Jonglez 10 years ago
parent
commit
8c997809b1

+ 38 - 0
README.md

@@ -23,6 +23,19 @@ A mirror of the code is available at:
   https://code.ffdn.org/zorun/coin/
   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
 Quickstart
 ==========
 ==========
 
 
@@ -92,6 +105,22 @@ At this point, Django should run correctly:
     python manage.py runserver
     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.
+
+
 Configuration
 Configuration
 =============
 =============
 
 
@@ -112,6 +141,15 @@ in the admin.  Information entered in this application has two purposes:
 Some bits of configuration are done in `settings.py`: LDAP branches, RSS feeds
 Some bits of configuration are done in `settings.py`: LDAP branches, RSS feeds
 to display on the home page, and so on.
 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`
+
 
 
 More information
 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
 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
     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
     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(
     members = Member.objects.filter(
         Q(offersubscription__resign_date__isnull=True) |
         Q(offersubscription__resign_date__isnull=True) |
         Q(offersubscription__resign_date__gte=date))
         Q(offersubscription__resign_date__gte=date))

+ 3 - 1
coin/billing/models.py

@@ -41,7 +41,9 @@ class Invoice(models.Model):
         ('trouble', 'Litige')
         ('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,
     number = models.CharField(max_length=25,
                               default=next_invoice_number,
                               default=next_invoice_number,
                               unique=True,
                               unique=True,

+ 8 - 6
coin/members/models.py

@@ -107,11 +107,9 @@ 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):
-        try:
+        x = self.membership_fees.order_by('-end_date')
+        if x:
             return self.membership_fees.order_by('-end_date')[0].end_date
             return self.membership_fees.order_by('-end_date')[0].end_date
-        # TODO: bad practice de tout matcher comme ca
-        except:
-            return None
     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, date=datetime.date.today()):
     def is_paid_up(self, date=datetime.date.today()):
@@ -131,19 +129,23 @@ class Member(CoinLdapSyncMixin, AbstractUser):
         super(Member, self).set_password(new_password, *args, **kwargs)
         super(Member, self).set_password(new_password, *args, **kwargs)
         self._password_ldap = utils.ldap_hash(new_password)
         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
         Return list of OfferSubscription which are active today
         """
         """
+        if date is None:
+            date = datetime.date.today()
         return OfferSubscription.objects.filter(
         return OfferSubscription.objects.filter(
             Q(member__exact=self.pk),
             Q(member__exact=self.pk),
             Q(subscription_date__lte=date),
             Q(subscription_date__lte=date),
             Q(resign_date__isnull=True) | Q(resign_date__gte=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
         Return list of OfferSubscription which are not active today
         """
         """
+        if date is None:
+            date = datetime.date.today()
         return OfferSubscription.objects.filter(
         return OfferSubscription.objects.filter(
             Q(member__exact=self.pk),
             Q(member__exact=self.pk),
             Q(subscription_date__gt=date) |
             Q(subscription_date__gt=date) |

+ 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 import models
 from django.db.models import Q
 from django.db.models import Q
+from django.core.validators import MinValueValidator
 
 
 
 
 class Offer(models.Model):
 class Offer(models.Model):
@@ -24,7 +25,8 @@ class Offer(models.Model):
                             help_text="Type de configuration à utiliser avec cette offre")
                             help_text="Type de configuration à utiliser avec cette offre")
     billing_period = models.IntegerField(blank=False, null=False, default=1,
     billing_period = models.IntegerField(blank=False, null=False, default=1,
                                          verbose_name='période de facturation',
                                          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,
     period_fees = models.DecimalField(max_digits=5, decimal_places=2,
                                       blank=False, null=False,
                                       blank=False, null=False,
                                       verbose_name='montant par période de '
                                       verbose_name='montant par période de '
@@ -49,11 +51,25 @@ class Offer(models.Model):
         return self.configuration_type
         return self.configuration_type
     get_configuration_type_display.short_description = 'type de configuration'
     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):
     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:
     class Meta:
         verbose_name = 'offre'
         verbose_name = 'offre'
@@ -81,7 +97,8 @@ class OfferSubscription(models.Model):
     # TODO: move this to offers?
     # TODO: move this to offers?
     commitment = models.IntegerField(blank=False, null=False,
     commitment = models.IntegerField(blank=False, null=False,
                                      verbose_name="période d'engagement",
                                      verbose_name="période d'engagement",
-                                     help_text = 'en mois',
+                                     help_text='en mois',
+                                     validators=[MinValueValidator(0)],
                                      default=0)
                                      default=0)
     member = models.ForeignKey('members.Member', verbose_name='membre')
     member = models.ForeignKey('members.Member', verbose_name='membre')
     offer = models.ForeignKey('Offer', verbose_name='offre')
     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):
 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],
     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")
                             help_text="Laisser vide pour allouer automatiquement")
     objects = NetManager()
     objects = NetManager()
     ip_pool = models.ForeignKey(IPPool, verbose_name="pool d'IP")
     ip_pool = models.ForeignKey(IPPool, verbose_name="pool d'IP")
@@ -91,6 +86,8 @@ class IPSubnet(models.Model):
             first_free = available.next()
             first_free = available.next()
         except StopIteration:
         except StopIteration:
             raise ValidationError("Impossible d'allouer un sous-réseau : bloc d'IP rempli.")
             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()
         self.inet = first_free.subnet(self.ip_pool.default_subnetsize, 1).next()
 
 
     def validate_inclusion(self):
     def validate_inclusion(self):

+ 1 - 2
coin/vpn/models.py

@@ -157,8 +157,7 @@ class VPNConfiguration(CoinLdapSyncMixin, Configuration):
 
 
 
 
 class LdapVPNConfig(ldapdb.models.Model):
 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',
     object_classes = [b'person', b'organizationalPerson', b'inetOrgPerson',
                       b'top', b'radiusprofile']
                       b'top', b'radiusprofile']