Commit 61738e48 authored by Fabs's avatar Fabs
Browse files

Merge branch 'call_for_membership_fees' of https://code.ffdn.org/zorun/coin...

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
parents 3ee0a348 21be28e7
......@@ -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
================
......
......@@ -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))
......
......@@ -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,
......
......@@ -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(
......@@ -88,16 +103,6 @@ class ISPInfo(SingleInstanceMixin, models.Model):
verbose_name="serveur de listes", blank=True,
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"""
......
......@@ -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)
......@@ -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) |
......
......@@ -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
......
# -*- 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,
),
]
......@@ -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')
......
# -*- 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,
),
]
......@@ -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):
......
......@@ -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']
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment