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/
 
 
+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
 ==========
 
@@ -92,6 +105,22 @@ 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.
+
+
 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
 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
 ================

+ 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,

+ 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
     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
-        # TODO: bad practice de tout matcher comme ca
-        except:
-            return None
     end_date_of_membership.short_description = "Date de fin d'adhésion"
 
     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)
         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) |

+ 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']