diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..6d4a3893bdf6019ddf4d2b48c16c12c3c9dc3511 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,24 @@ +# Tested target system is Debian Buster + +variables: + POSTGRES_DB: coin + POSTGRES_USER: coin + POSTGRES_PASSWORD: coin + # https://gitlab.com/gitlab-com/support-forum/issues/5199 + POSTGRES_HOST_AUTH_METHOD: trust + + TZ: "Europe/Paris" + DJANGO_SETTINGS_MODULE: coin.settings_gitlab_ci + +before_script: + - echo 'fr_FR.UTF-8 UTF-8' >> /etc/locale.gen + - apt-get update -qq + - apt-get install -qq -y --no-install-recommends libsasl2-dev python-dev libldap2-dev libssl-dev + - pip install -r requirements.txt pytest-django + - pip freeze # debug + +unit_tests_debian_10: + image: python:2.7-buster + services: + - postgres:11.10 + script: py.test diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..1ec550b9927a92613d840d91065f5a2ee3790f82 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:2.7-buster + +ENV DEBIAN_FRONTEND noninteractive +ENV TZ="Europe/Paris" +ENV LC_ALL fr_FR.UTF-8 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libsasl2-dev python-dev libldap2-dev libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /coin +COPY . . +RUN pip install -r requirements.txt + +RUN useradd --uid 10001 --user-group --shell /bin/bash coin +RUN chown coin:coin /coin + +USER coin:coin + +VOLUME ["/coin"] diff --git a/EXTENDING.md b/EXTENDING.md index 77ec0aeb5d31e9a0ec7f8e39c950a9f9bcca8cf8..8e5972b532f515688a8a9890315c3a06e6806ebc 100644 --- a/EXTENDING.md +++ b/EXTENDING.md @@ -188,7 +188,7 @@ Example: {% extends "base.html" %} {% block extra_css %}{% endblock %} - {% block extra_js %}{% endblock %} + {% block extra_js %}{{ block.super }}{% endblock %} Menu items diff --git a/README.md b/README.md index 137221d0d74906bdb6b9de90b661e2e29f55b3f8..f63eacf851dfe4f4a47754a9f156ff18dae958ec 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,18 @@ The COIN project ================ -`Coin` is Illyse's Information System, designed to manage both members -and Internet accesses, such as through DSL, VPN, wireless… - +COIN is an Information System designed for associative ISPs in the FFDN.  -It is written in Django, and features a generic configuration interface, -which allows to implement custom backends for different technologies. -Currently implemented is a LDAP-based backend for OpenVPN, and a very -simple DSL backend, without any authentication (useful for "white label" -DSL). +It is written in Django, and features : +- a generic configuration interface that correspond to services provided to members +- subscriptions management +- and the corresponding billing / payment handling system -Coin currently only works with python2, because `python-ldap` is (as of -2013) not compatible with python3. +COIN can can be interfaced with the actual infrastructure to fetch the state of +services (e.g. VPS, VPN, ...) and provision them via hooks defined in the +settings. The project page (issue, wiki, etc) is here: @@ -84,84 +82,52 @@ settings: See the end of this README for a reference of available configuration settings. -Database --------- - -At this point, you should setup your database. You have two options. - -### With PostgreSQL (for developpement), recomended - -The official database for coin is postgresql. - -To ease developpement, a postgresql virtual-machine recipe is provided -through [vagrant](https://vagrantup.com). - - -**Note: Vagrant is intended for developpement only and is totaly unsafe for a -production setup**. - -Install requirements: - - sudo apt install virtualbox vagrant - -Then, to boot and configure your dev VM: - - vagrant up - -Default settings target that vagrant+postgreSQL setup, so, you don't have to -change any setting. - - -### With SQLite - -SQLite setup may be simpler, but some features will not be available, namely: +Development +----------- -- 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) +A Dockerfile + docker-compose.yml is provided **for development purposes only**. -To use sqlite instead of PostgreSQL, you have -to [override local settings](#settings) with someting like: +- Install Docker according to Docker's documentation : https://docs.docker.com/engine/install/ (or by running `apt install docker`) +- Validate that docker is running with `sudo systemctl status docker` +- You may need to add your user to the docker group to be able to run docker commands as non-root: -```python -DATABASES = { - # Base de donnée du SI - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'coin.sqlite3', - 'USER': '', # Not needed for SQLite - 'PASSWORD': '', # Not needed for SQLite - 'HOST': '', # Empty for localhost through domain sockets - 'PORT': '', # Empty for default - }, -} +``` +sudo usermod -aG docker $USER +# Then you need to reload your entire session (not just the terminal) for this to propagate... +# or maybe running this command does the trick: newgrp docker ``` -### For both PostgreSQL and SQLite - -The first time, you need to create the database, create a superuser, and -import some base data to play with: - - python manage.py migrate - python manage.py createsuperuser - python manage.py loaddata offers ip_pool # skip this if you don't use PostgreSQL +- Install docker-compose: `sudo pip3 install docker-compose` +- Run `docker-compose up` ... the first time this will download and build a whole bunch of stuff +- You can access your app through http://localhost:8000/ +- Misc tips: -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). +``` +# Launch docker-compose up, but in background +docker-compose up -d -Then, at each code update, you will only need to update dependencies and apply -new migrations: +# Check container logs after startup +docker-compose logs -f coin - pip install -r requirements.txt - python manage.py migrate +# Stop / relaunch the container +docker-compose stop +docker-compose up +# Regen and apply migrations +# (ugly hack: the user coin can't write in the mounted folder ... so gotta run this as root..) +docker-compose exec -u root coin python manage.py makemigrations +docker-compose exec coin python manage.py migrate -At this point, Django should run correctly: +# Enter a shell inside the container +docker-compose exec coin bash +``` - python manage.py runserver +- At some point you will want to initialize the admin and a bunch of dummy data : +``` +docker-compose exec coin python manage.py fill_with_toy_data +docker-compose exec coin python manage.py changepassword admin +``` Running tests ------------- @@ -397,10 +363,12 @@ MEMBERSHIP_FEE_REMINDER_DATES = [ - `MEMBERSHIP_REFERENCE` : Template string to display the label the member should indicates for the bank transfer, default: "ADH-{{ user.pk }}" - `DEFAULT_MEMBERSHIP_FEE` : Default membership fee, if you have a more complex membership fees policy, you could overwrite templates - `PAYMENT_DELAY`: Payment delay in days for issued invoices ( default is 30 days which is the default in french law) +- `MEMBER_TERMS` : You can ask for new user to accept a terms during registration. (example: 'J\'ai lu les statuts de l\'association' ) - `MEMBER_CAN_EDIT_PROFILE`: Allow members to edit their profiles - `HANDLE_BALANCE`: Allows to handle money balances for members (False default) - `INVOICES_INCLUDE_CONFIG_COMMENTS`: Add comment related to a subscription configuration when generating invoices - `MEMBER_CAN_EDIT_VPN_CONF`: Allow members to edit some part of their vpn configuration +- `IP_ALLOCATION_MESSAGE`: Template string that will be used to log IP allocation in the corresponding coin.subnets logging system, defaults to `None` which is disabled logging, full example : `"{ip} to {member.pk} ({member.username} - {member.first_name} {member.last_name}) (for offer {offer}, {ref})"` - `DEBUG` : Enable debug for development **do not use in production** : display stracktraces and enable [django-debug-toolbar](https://django-debug-toolbar.readthedocs.io). - `SITE_TITLE`: the base of site title (displayed in browser window/tab title) @@ -421,18 +389,6 @@ See also [using optional apps](#using-optional-apps). - `{email}`: the mail address of the list - `{short_name}`: the list name -#### vpn - -- `VPN_SECRETS_TRANSMISSION_METHOD` : how are VPN secrets transmited to - subscriber ? Two values are currently supported : - - `gen-password-and-forget` (default, used by Illyse) : generate a - password, push it to LDAP (which holds VPN auth), displays it to user and - forget it. - - `crypto-link` (used by ARN) : credentials are generated by an admin - outside coin, and put on an encrypted burn-after-reading web page, whom - URL is filled-in coin. - - Accounting logs --------------- @@ -453,9 +409,13 @@ LOGGING["handlers"]["coin_accounting"] = { LOGGING["loggers"]["coin.billing"]["handlers"] = [ 'coin_accounting' ] ``` -More information -================ +External account +--------------- -For the rest of the setup (database, LDAP), see https://doc.illyse.net/projects/ils-si/wiki/Mise_en_place_environnement_de_dev + - `VERBOSE_NAME_EXTERNAL_ACCOUNT` : This label is display in the member subscription view (ARN put this to 'Compte sans-nuage') + - `VERBOSE_NAME_PLURAL_EXTERNAL_ACCOUNT` : same for plural + +Deployment +========== For real production deployment, see file `DEPLOYMENT.md`. diff --git a/Vagrantfile b/Vagrantfile deleted file mode 100644 index 6a325f8f098767f339303b03465d9a1361d8907f..0000000000000000000000000000000000000000 --- a/Vagrantfile +++ /dev/null @@ -1,53 +0,0 @@ -# -*- mode: ruby -*- -# vi: set ft=ruby : - -Vagrant.configure("2") do |config| - - config.vm.box = 'debian/jessie64' - config.vm.host_name = 'postgresql' - - config.vm.provider "virtualbox" do |v| - v.customize ["modifyvm", :id, "--memory", 512] - end - - config.vm.network "forwarded_port", guest: 5432, host: 15432 - - config.vm.provision "shell", privileged: true, inline: <<-SHELL - APP_DB_USER=coin - APP_DB_NAME=coin - APP_DB_PASS=coin - - PG_VERSION=9.4 - PG_CONF="/etc/postgresql/$PG_VERSION/main/postgresql.conf" - PG_HBA="/etc/postgresql/$PG_VERSION/main/pg_hba.conf" - - apt-get -y update - apt-get install -y postgresql - - # Edit postgresql.conf to change listen address to '*': - sed -i "s/#listen_addresses = 'localhost'/listen_addresses = '*'/" "$PG_CONF" - - # Append to pg_hba.conf to add password auth: - echo "host all all all md5" >> "$PG_HBA" - - cat << EOF | su - postgres -c psql - -- Cleanup, if required - DROP DATABASE IF EXISTS $APP_DB_NAME; - DROP USER IF EXISTS $APP_DB_USER; - - -- Create the database user: - CREATE USER $APP_DB_USER WITH PASSWORD '$APP_DB_PASS'; - -- Allow db creation (usefull for unit testing) - ALTER USER $APP_DB_USER CREATEDB; - - -- Create the database: - CREATE DATABASE $APP_DB_NAME WITH OWNER=$APP_DB_USER - LC_COLLATE='en_US.utf8' - LC_CTYPE='en_US.utf8' - ENCODING='UTF8' - TEMPLATE=template0; -EOF - - systemctl restart postgresql - SHELL -end diff --git a/coin/admin.py b/coin/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..2c190dc39cc5356c2064db68502f138e42cce6ce --- /dev/null +++ b/coin/admin.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.apps import apps +from django.contrib import admin + + +def collect_admin_cards(): + """ + Collects menu entries among apps and builds the admin main menu from it + + Collects in apps appconfigs, in two variables : + + AppConfig.admin_menu_entry for defining a full per-app menu entry + AppConfig.admin_menu_addons to add links to a shared menu entry + + Format for those vars can be found in apps.py files + """ + + admin_cards = [ + {"id": "configs", + "icon": "gears", + "title": "Configurations", + "models": [] + }, + + {"id": "infra", + "icon": "database", + "title": "Infra", + "models": [], + }, + ] + + # Menu entries that can receive addons + addons_containers = { + 'configs': admin_cards[0]['models'], + 'infra': admin_cards[1]['models'], + } + + for app_config in apps.get_app_configs(): + menu_entry = getattr(app_config, 'admin_menu_entry', None) + menu_addons = getattr(app_config, 'admin_menu_addons', None) + if menu_entry: + admin_cards.append(menu_entry) + + if menu_addons: + for entry_id, links in menu_addons.items(): + addons_containers[entry_id] += links + + return admin_cards + + +original_index = admin.site.index + +def new_index(request, extra_context=None): + extra_context = extra_context or {} + extra_context["admin_cards"] = collect_admin_cards() + return original_index(request, extra_context) + +admin.site.index = new_index diff --git a/coin/billing/admin.py b/coin/billing/admin.py index 2f9707b54d7217277bbe1332e6eea1ec20413538..3969933843835af3ed6f8e1eac1fe0b6fde6ee85 100644 --- a/coin/billing/admin.py +++ b/coin/billing/admin.py @@ -10,8 +10,11 @@ from django import forms from django.shortcuts import render from coin.filtering_queryset import LimitedAdminInlineMixin -from coin.billing.models import Invoice, InvoiceDetail, Payment, PaymentAllocation -from coin.billing.utils import get_invoice_from_id_or_number +from coin.billing.models import Invoice, InvoiceDetail, Payment, \ + PaymentAllocation, MembershipFee, Donation +from coin.billing.utils import get_bill_from_id_or_number +from coin.members.models import Member +from coin.members.admin import MemberAdmin from django.core.urlresolvers import reverse import autocomplete_light @@ -204,8 +207,8 @@ class InvoiceAdmin(admin.ModelAdmin): # TODO : Add better perm here if request.user.is_superuser: - invoice = get_invoice_from_id_or_number(id) - if invoice.amount() == 0: + invoice = get_bill_from_id_or_number(id) + if invoice.amount == 0: messages.error(request, 'Une facture validée ne peut pas avoir' ' un total de 0€.') else: @@ -223,8 +226,8 @@ class InvoiceAdmin(admin.ModelAdmin): class PaymentAllocationInlineReadOnly(admin.TabularInline): model = PaymentAllocation extra = 0 - fields = ("invoice", "amount") - readonly_fields = ("invoice", "amount") + fields = ("bill", "amount") + readonly_fields = ("bill", "amount") verbose_name = None verbose_name_plural = "Alloué à" @@ -245,8 +248,7 @@ class PaymentAdmin(admin.ModelAdmin): ('amount_already_allocated')) readonly_fields = ('amount_already_allocated',) list_filter = ['payment_mean'] - search_fields = ['member__username', 'member__first_name', - 'member__last_name', 'member__email', 'member__nickname'] + search_fields = ['member__username', 'member__first_name', 'member__last_name', 'member__email', 'member__nickname'] form = autocomplete_light.modelform_factory(Payment, fields='__all__') def get_readonly_fields(self, request, obj=None): @@ -271,7 +273,6 @@ class PaymentAdmin(admin.ModelAdmin): return my_urls + urls - def wizard_import_payment_csv(self, request): template = "admin/billing/payment/wizard_import_payment_csv.html" @@ -289,9 +290,15 @@ class PaymentAdmin(admin.ModelAdmin): 'adminform': form, 'opts': self.model._meta, 'new_payments': new_payments - }) + }) else: add_new_payments(new_payments) + + if "send_reminders" in request.POST: + members_to_remind = Member.objects.filter(balance__lt=0) + for member in members_to_remind: + member.send_reminder_negative_balance(auto=True) + return HttpResponseRedirect('../') else: form = WizardImportPaymentCSV() @@ -301,6 +308,18 @@ class PaymentAdmin(admin.ModelAdmin): 'opts': self.model._meta }) +class MembershipFeeAdmin(admin.ModelAdmin): + list_display = ('member', 'end_date', '_amount') + search_fields = ['member__username', 'member__first_name', 'member__last_name', 'member__email', 'member__nickname'] + list_filter = ['date'] + form = autocomplete_light.modelform_factory(MembershipFee, fields='__all__') + + +class DonationAdmin(admin.ModelAdmin): + list_display = ('member', 'date', '_amount') + form = autocomplete_light.modelform_factory(MembershipFee, fields='__all__') admin.site.register(Invoice, InvoiceAdmin) admin.site.register(Payment, PaymentAdmin) +admin.site.register(MembershipFee, MembershipFeeAdmin) +admin.site.register(Donation, DonationAdmin) diff --git a/coin/billing/app.py b/coin/billing/app.py index c1d0efa0b6f6e11b7dd7e7f77ecbda9a887d2810..13182f3f2848095d1bc300fda1544eb169f27d73 100644 --- a/coin/billing/app.py +++ b/coin/billing/app.py @@ -7,3 +7,15 @@ from django.apps import AppConfig class BillingConfig(AppConfig): name = 'coin.billing' verbose_name = 'Facturation' + + admin_menu_entry = { + "id": "billing", + "icon": "euro", + "title": "Facturation", + "models": [ + ("Cotisations", "billing/membershipfee"), + ("Dons", "billing/donation"), + ("Paiements", "billing/payment"), + ("Factures", "billing/invoice"), + ] + } diff --git a/coin/billing/create_subscriptions_invoices.py b/coin/billing/create_subscriptions_invoices.py index 3add25dccb9d75e2ba6921594f197ae8c2a3c47b..3891489c051d0d74e9b970f40552b57f83f490dd 100644 --- a/coin/billing/create_subscriptions_invoices.py +++ b/coin/billing/create_subscriptions_invoices.py @@ -13,7 +13,7 @@ from coin.members.models import Member from coin.billing.models import Invoice, InvoiceDetail from django.conf import settings -def create_all_members_invoices_for_a_period(date=None): +def create_all_members_invoices_for_a_period(date=None, antidate=False): """ 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 @@ -27,14 +27,14 @@ def create_all_members_invoices_for_a_period(date=None): invoices = [] for member in members: - invoice = create_member_invoice_for_a_period(member, date) + invoice = create_member_invoice_for_a_period(member, date, antidate) if invoice is not None: invoices.append(invoice) return invoices @transaction.atomic -def create_member_invoice_for_a_period(member, date): +def create_member_invoice_for_a_period(member, date, antidate=False): """ Créé si nécessaire une facture pour un membre en prenant la date passée en paramètre comme premier mois de période. Renvoi la facture générée @@ -157,7 +157,12 @@ def create_member_invoice_for_a_period(member, date): if invoice.details.count() > 0: invoice.save() transaction.savepoint_commit(sid) - invoice.validate() # Valide la facture et génère le PDF + # Valide la facture et génère le PDF + invoice.date_due = None # (reset the due date, will automatically be redefined when validating, to date+PAYMENT_DELAY) + if antidate: + invoice.validate(period_to) + else: + invoice.validate() return invoice else: transaction.savepoint_rollback(sid) diff --git a/coin/billing/management/commands/charge_subscriptions.py b/coin/billing/management/commands/charge_subscriptions.py index e248f65d6f6b8174a81444a1f4fe91ac2f1361e1..a2327d3b82c6ca9eddade6ae1b1eb87eca04537e 100644 --- a/coin/billing/management/commands/charge_subscriptions.py +++ b/coin/billing/management/commands/charge_subscriptions.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- import datetime + +from argparse import RawTextHelpFormatter from django.core.management.base import BaseCommand, CommandError from django.conf import settings @@ -8,13 +10,39 @@ from coin.billing.create_subscriptions_invoices import create_all_members_invoic class Command(BaseCommand): - args = '[date=2011-07-04]' + help = 'Create invoices for members subscriptions for date specified (or today if no date passed)' + def create_parser(self, *args, **kwargs): + parser = super(Command, self).create_parser(*args, **kwargs) + parser.formatter_class = RawTextHelpFormatter + return parser + + def add_arguments(self, parser): + + parser.add_argument( + 'date', + type=str, + help="The date for the period for which to charge subscription (e.g. 2011-07-04)" + ) + + parser.add_argument( + '--antidate', + action='store_true', + dest='antidate', + default=False, + help="'Antidate' invoices, in the sense that invoices won't be validated with today's date but using the date of the end of the service. Meant to be use to charge subscription from a few months in the past..." + ) + + + def handle(self, *args, **options): verbosity = int(options['verbosity']) + antidate = options['antidate'] + date = options["date"] + try: - date = datetime.datetime.strptime(args[0], '%Y-%m-%d').date() + date = datetime.datetime.strptime(date, '%Y-%m-%d').date() except IndexError: date = datetime.date.today() except ValueError: @@ -25,7 +53,7 @@ class Command(BaseCommand): self.stdout.write( 'Create invoices for all members for the date : %s' % date) with respect_language(settings.LANGUAGE_CODE): - invoices = create_all_members_invoices_for_a_period(date) + invoices = create_all_members_invoices_for_a_period(date, antidate) if len(invoices) > 0 or verbosity >= 2: self.stdout.write( diff --git a/coin/billing/migrations/0001_initial.py b/coin/billing/migrations/0001_initial.py index 4a6edc9f29b9ad2e4d3141e71a299a8356afa427..474e66cf510c41eb5850f87ff992aa0660430702 100644 --- a/coin/billing/migrations/0001_initial.py +++ b/coin/billing/migrations/0001_initial.py @@ -23,7 +23,7 @@ class Migration(migrations.Migration): ('status', models.CharField(default='open', max_length=50, verbose_name='statut', choices=[('open', 'A payer'), ('closed', 'Regl\xe9e'), ('trouble', 'Litige')])), ('date', models.DateField(default=datetime.date.today, null=True, verbose_name='date')), ('date_due', models.DateField(default=coin.utils.end_of_month, null=True, verbose_name="date d'\xe9ch\xe9ance de paiement")), - ('pdf', models.FileField(storage=coin.utils.private_files_storage, upload_to=coin.billing.models.invoice_pdf_filename, null=True, verbose_name='PDF', blank=True)), + ('pdf', models.FileField(storage=coin.utils.private_files_storage, upload_to=coin.billing.models.bill_pdf_filename, null=True, verbose_name='PDF', blank=True)), ], options={ 'verbose_name': 'facture', diff --git a/coin/billing/migrations/0010_new_billing_system_data.py b/coin/billing/migrations/0010_new_billing_system_data.py index 485fac6ce4d56cc272c1d25e725b48b3a2d7364c..3e7948869071857502a3746ab426c3c95f451ae8 100755 --- a/coin/billing/migrations/0010_new_billing_system_data.py +++ b/coin/billing/migrations/0010_new_billing_system_data.py @@ -5,12 +5,15 @@ import sys from django.db import migrations -from coin.members.models import Member -from coin.billing.models import Invoice, InvoiceDetail, Payment - def check_current_state(apps, schema_editor): + Member = apps.get_model('members', 'Member') + Invoice = apps.get_model('billing', 'Invoice') + InvoiceDetail = apps.get_model('billing', 'InvoiceDetail') + Payment = apps.get_model('billing', 'Payment') + + for invoice in Invoice.objects.all(): invoice_name = invoice.__unicode__() @@ -30,6 +33,11 @@ def check_current_state(apps, schema_editor): def forwards(apps, schema_editor): + Member = apps.get_model('members', 'Member') + Invoice = apps.get_model('billing', 'Invoice') + InvoiceDetail = apps.get_model('billing', 'InvoiceDetail') + Payment = apps.get_model('billing', 'Payment') + # Create allocation for all payment to their respective invoice for payment in Payment.objects.all(): payment.member = payment.invoice.member diff --git a/coin/billing/migrations/0011_auto_20180414_2250.py b/coin/billing/migrations/0011_auto_20180414_2250.py new file mode 100644 index 0000000000000000000000000000000000000000..a7381942cdf529641558aa44da3f08cb9987fc69 --- /dev/null +++ b/coin/billing/migrations/0011_auto_20180414_2250.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0010_new_billing_system_data'), + ] + + operations = [ + migrations.AlterField( + model_name='payment', + name='amount', + field=models.DecimalField(null=True, verbose_name='montant', max_digits=6, decimal_places=2), + ), + ] diff --git a/coin/billing/migrations/0012_auto_20180415_1502.py b/coin/billing/migrations/0012_auto_20180415_1502.py new file mode 100644 index 0000000000000000000000000000000000000000..3f12f8af75d43335a42c1488fc85ab454f6266d4 --- /dev/null +++ b/coin/billing/migrations/0012_auto_20180415_1502.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import datetime +import coin.billing.models +import django.db.models.deletion +import django.core.files.storage +from django.conf import settings + + +def migrate_invoices_to_bills(apps, schema_editor): + from django.core.management.color import no_style + + Bill = apps.get_model('billing', 'Bill') + Invoice = apps.get_model('billing', 'Invoice') + + for invoice in Invoice.objects.all(): + Bill.objects.create( + id=invoice.id, + member=invoice.member, + status=invoice.status, + date=invoice.date, + pdf=invoice.pdf, + ) + # When specifying id manually, sequence should then be reset, at least with postgresql + # https://django.readthedocs.io/en/stable/ref/databases.html#manually-specifying-values-of-auto-incrementing-primary-keys + # https://stackoverflow.com/a/50275895/1377500 + sequence_sql = schema_editor.connection.ops.sequence_reset_sql( + no_style(), + [Bill], + ) + for sql in sequence_sql: + schema_editor.execute(sql) + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('billing', '0011_auto_20180414_2250'), + ] + + operations = [ + # 1/ Create Bill model + migrations.CreateModel( + name='Bill', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('status', models.CharField(default='open', max_length=50, verbose_name='statut', choices=[('open', '\xc0 payer'), ('closed', 'R\xe9gl\xe9e'), ('trouble', 'Litige')])), + ('date', models.DateField(default=datetime.date.today, help_text='Cette date sera d\xe9finie \xe0 la date de validation dans le document final', null=True, verbose_name='date')), + ('pdf', models.FileField(storage=django.core.files.storage.FileSystemStorage(location='/vagrant/apps/extra/coin2/smedia/'), upload_to=coin.billing.models.bill_pdf_filename, null=True, verbose_name='PDF', blank=True)), + ('member', models.ForeignKey(related_name='bills', on_delete=django.db.models.deletion.SET_NULL, default=None, blank=True, to=settings.AUTH_USER_MODEL, null=True, verbose_name='membre')), + ], + options={ + 'verbose_name': 'note', + }, + ), + # 2/ Create 1 Bill object per Invoice object, copying status, date and pdf, member + migrations.RunPython(migrate_invoices_to_bills), + # 3/ Create a link between Bill and Invoice + migrations.RenameField( + model_name='invoice', + old_name='id', + new_name='bill_ptr', + ), + migrations.AlterField( + model_name='invoice', + name='bill_ptr', + field=models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, default=1, serialize=False, to='billing.Bill'), + ), + # 4/ Delete duplicate Invoice fields that are present on Bill : status, date, pdf, member + + migrations.RemoveField(model_name='invoice', name='status'), + migrations.RemoveField(model_name='invoice', name='date'), + migrations.RemoveField(model_name='invoice', name='pdf'), + migrations.RemoveField(model_name='invoice', name='member'), + + migrations.AlterField( + model_name='payment', + name='invoice', + field=models.ForeignKey(related_name='payments_old', verbose_name='facture associ\xe9e', blank=True, to='billing.Invoice', null=True), + ), + migrations.AlterField( + model_name='paymentallocation', + name='invoice', + field=models.ForeignKey(related_name='allocations', verbose_name='facture associ\xe9e', to='billing.Bill'), + ), + migrations.CreateModel( + name='Donation', + fields=[ + ('bill_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='billing.Bill')), + ('_amount', models.DecimalField(verbose_name='Montant', max_digits=8, decimal_places=2)), + ], + options={ + 'verbose_name': 'don', + }, + bases=('billing.bill',), + ), + migrations.CreateModel( + name='TempMembershipFee', + fields=[ + ('bill_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='billing.Bill')), + ('_amount', models.DecimalField(default=None, help_text='en \u20ac', verbose_name='montant', max_digits=5, decimal_places=2)), + ('start_date', models.DateField(verbose_name='date de d\xe9but de cotisation')), + ('end_date', models.DateField(help_text='par d\xe9faut, la cotisation dure un an', verbose_name='date de fin de cotisation', blank=True)), + ], + options={ + 'verbose_name': 'cotisation', + }, + bases=('billing.bill',), + ), + migrations.RenameField( + model_name='payment', + old_name='invoice', + new_name='bill', + ), + migrations.AlterField( + model_name='payment', + name='bill', + field=models.ForeignKey(related_name='payments', verbose_name='facture associ\xe9e', blank=True, to='billing.Bill', null=True), + ), + ] diff --git a/coin/billing/migrations/0013_auto_20180415_0413.py b/coin/billing/migrations/0013_auto_20180415_0413.py new file mode 100644 index 0000000000000000000000000000000000000000..2e8a872e9feff24414ff570f1a1e7eb1f2cfb938 --- /dev/null +++ b/coin/billing/migrations/0013_auto_20180415_0413.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + +def fill_empty_fee_payment_date(apps, schema_editor): + """ + If a MembershipFee lacks a payment date, we fill it with the start of the + corresponding membership period, considering all members are very serious + and pay exactly at the due date. + """ + MembershipFee = apps.get_model('members', 'MembershipFee') + for fee in MembershipFee.objects.filter(payment_date=None): + fee.payment_date = fee.start_date + fee.save() + +def forwards(apps, schema_editor): + + Payment = apps.get_model('billing', 'Payment') + MembershipFee = apps.get_model('members', 'MembershipFee') + TempMembershipFee = apps.get_model('billing', 'TempMembershipFee') + PaymentAllocation = apps.get_model('billing', 'PaymentAllocation') + + # Update balance for all members + for fee in MembershipFee.objects.all(): + + temp_fee = TempMembershipFee() + temp_fee._amount = fee.amount + temp_fee.start_date = fee.start_date + temp_fee.end_date = fee.end_date + temp_fee.status = 'closed' + temp_fee.date = temp_fee.start_date + temp_fee.member = fee.member + temp_fee.save() + + payment = Payment() + payment.member = fee.member + payment.payment_mean = fee.payment_method + payment.amount = fee.amount + payment.date = fee.payment_date + payment.label = fee.reference + payment.bill = temp_fee + payment.save() + + allocation = PaymentAllocation() + allocation.invoice = temp_fee + allocation.payment = payment + allocation.amount = fee.amount + allocation.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0012_auto_20180415_1502'), + ] + + operations = [ + migrations.RunPython( + fill_empty_fee_payment_date, + reverse_code=migrations.RunPython.noop, + ), + migrations.RunPython(forwards), + ] diff --git a/coin/billing/migrations/0014_auto_20180415_1814.py b/coin/billing/migrations/0014_auto_20180415_1814.py new file mode 100644 index 0000000000000000000000000000000000000000..4b941d0b46d10a0d49be54bcfaef598b3ec6f14b --- /dev/null +++ b/coin/billing/migrations/0014_auto_20180415_1814.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0013_auto_20180415_0413'), + # After MembershipFee model from members app has been deleted + ('members', '0019_auto_20180415_1814'), + ] + + operations = [ + migrations.RenameModel( + old_name='TempMembershipFee', + new_name='MembershipFee', + ), + ] diff --git a/coin/billing/migrations/0015_remove_payment_invoice.py b/coin/billing/migrations/0015_remove_payment_invoice.py new file mode 100644 index 0000000000000000000000000000000000000000..fe1609d3d025fcf9e3fdf8c012ba3c49760ef387 --- /dev/null +++ b/coin/billing/migrations/0015_remove_payment_invoice.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0014_auto_20180415_1814'), + ] + + operations = [ + # Replaced by a RenameField in 0012_auto_20180415_1502 + # migrations.RemoveField( + # model_name='payment', + # name='invoice', + # ), + ] diff --git a/coin/billing/migrations/0016_auto_20180415_2208.py b/coin/billing/migrations/0016_auto_20180415_2208.py new file mode 100644 index 0000000000000000000000000000000000000000..7f610917b7126b33ea24650189c506f52220f446 --- /dev/null +++ b/coin/billing/migrations/0016_auto_20180415_2208.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0015_remove_payment_invoice'), + ] + + operations = [ + migrations.RenameField( + model_name='paymentallocation', + old_name='invoice', + new_name='bill', + ), + ] diff --git a/coin/billing/migrations/0017_merge.py b/coin/billing/migrations/0017_merge.py new file mode 100644 index 0000000000000000000000000000000000000000..d7ffbcf8fef1491b15beb1458aece40638fb0848 --- /dev/null +++ b/coin/billing/migrations/0017_merge.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0016_auto_20180415_2208'), + ('billing', '0011_auto_20180819_0221'), + ] + + operations = [ + ] diff --git a/coin/billing/migrations/0018_auto_20181118_2001.py b/coin/billing/migrations/0018_auto_20181118_2001.py new file mode 100644 index 0000000000000000000000000000000000000000..d8ff559b50f2fb84cbbf385f7a8b12b14ef9d26b --- /dev/null +++ b/coin/billing/migrations/0018_auto_20181118_2001.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.core.files.storage +import coin.billing.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0017_merge'), + ] + + operations = [ + migrations.AlterField( + model_name='bill', + name='pdf', + field=models.FileField(storage=django.core.files.storage.FileSystemStorage(location='/var/www/adherents.arn-fai.net/smedia/'), upload_to=coin.billing.models.bill_pdf_filename, null=True, verbose_name='PDF', blank=True), + ), + migrations.AlterField( + model_name='invoicedetail', + name='amount', + field=models.DecimalField(verbose_name='montant', max_digits=8, decimal_places=2), + ), + migrations.AlterField( + model_name='membershipfee', + name='_amount', + field=models.DecimalField(default=None, help_text='en \u20ac', verbose_name='montant', max_digits=8, decimal_places=2), + ), + migrations.AlterField( + model_name='payment', + name='amount', + field=models.DecimalField(null=True, verbose_name='montant', max_digits=8, decimal_places=2), + ), + migrations.AlterField( + model_name='payment', + name='payment_mean', + field=models.CharField(default='transfer', max_length=100, null=True, verbose_name='moyen de paiement', choices=[('cash', 'Esp\xe8ces'), ('check', 'Ch\xe8que'), ('transfer', 'Virement'), ('creditnote', 'Avoir'), ('other', 'Autre')]), + ), + migrations.AlterField( + model_name='paymentallocation', + name='amount', + field=models.DecimalField(null=True, verbose_name='montant', max_digits=8, decimal_places=2), + ), + ] diff --git a/coin/billing/migrations/0019_auto_20190623_1256.py b/coin/billing/migrations/0019_auto_20190623_1256.py new file mode 100644 index 0000000000000000000000000000000000000000..5fab37f25490079dd0d887cccc77e94ace632ea4 --- /dev/null +++ b/coin/billing/migrations/0019_auto_20190623_1256.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0018_auto_20181118_2001'), + ] + + operations = [ + migrations.AlterField( + model_name='invoicedetail', + name='label', + field=models.CharField(max_length=255), + ), + ] diff --git a/coin/billing/migrations/0020_auto_20200717_1733.py b/coin/billing/migrations/0020_auto_20200717_1733.py new file mode 100644 index 0000000000000000000000000000000000000000..9bb497aa09afa4f0dec548ad995832ce4e31d8a3 --- /dev/null +++ b/coin/billing/migrations/0020_auto_20200717_1733.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0019_auto_20190623_1256'), + ] + + operations = [ + migrations.AlterField( + model_name='membershipfee', + name='_amount', + field=models.DecimalField(default=15, help_text='en \u20ac', verbose_name='montant', max_digits=8, decimal_places=2), + ), + ] diff --git a/coin/billing/migrations/0021_auto_20201203_1855.py b/coin/billing/migrations/0021_auto_20201203_1855.py new file mode 100644 index 0000000000000000000000000000000000000000..77f4bfc04bbdfda11c545b57bbfcd750d92c2aba --- /dev/null +++ b/coin/billing/migrations/0021_auto_20201203_1855.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.core.files.storage +import coin.members.models +import coin.billing.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0020_auto_20200717_1733'), + ] + + operations = [ + migrations.AlterField( + model_name='membershipfee', + name='_amount', + field=models.DecimalField(default=coin.members.models.default_membership_fee, help_text='en \u20ac', verbose_name='montant', max_digits=8, decimal_places=2), + ), + ] diff --git a/coin/billing/migrations/0022_auto_20201203_1913.py b/coin/billing/migrations/0022_auto_20201203_1913.py new file mode 100644 index 0000000000000000000000000000000000000000..b7d7b2fe2f4285be19d9345853dc381da1a2094c --- /dev/null +++ b/coin/billing/migrations/0022_auto_20201203_1913.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import coin.billing.models +import coin.utils + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0021_auto_20201203_1855'), + ] + + operations = [ + migrations.AlterField( + model_name='bill', + name='pdf', + field=models.FileField(storage=coin.utils.PrivateFileStorage(), upload_to=coin.billing.models.bill_pdf_filename, null=True, verbose_name='PDF', blank=True), + ), + ] diff --git a/coin/billing/migrations/0023_auto_20201203_1932.py b/coin/billing/migrations/0023_auto_20201203_1932.py new file mode 100644 index 0000000000000000000000000000000000000000..3406d610900267ae2569a5c059249dbc0aef7864 --- /dev/null +++ b/coin/billing/migrations/0023_auto_20201203_1932.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0022_auto_20201203_1913'), + ] + + operations = [ + migrations.AlterField( + model_name='invoice', + name='bill_ptr', + field=models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='billing.Bill'), + ), + ] diff --git a/coin/billing/models.py b/coin/billing/models.py index 79a6408316ce5954a1670e99f21894f5dab45bc3..210e355c34a19c7d15ed3300ca3982703f292c15 100644 --- a/coin/billing/models.py +++ b/coin/billing/models.py @@ -5,6 +5,7 @@ import datetime import logging import uuid import re +import abc from decimal import Decimal from dateutil.relativedelta import relativedelta @@ -18,7 +19,7 @@ from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from coin.offers.models import OfferSubscription -from coin.members.models import Member +from coin.members.models import Member, default_membership_fee from coin.html2pdf import render_as_pdf from coin.utils import private_files_storage, start_of_month, end_of_month, \ postgresql_regexp, send_templated_email, \ @@ -29,13 +30,144 @@ from coin.isp_database.models import ISPInfo accounting_log = logging.getLogger("coin.billing") -def invoice_pdf_filename(instance, filename): - """Nom et chemin du fichier pdf à stocker pour les factures""" +def bill_pdf_filename(instance, filename): + """Nom et chemin du fichier pdf à stocker""" member_id = instance.member.id if instance.member else 0 - return 'invoices/%d_%s_%s.pdf' % (member_id, - instance.number, - uuid.uuid4()) + number = instance.number if hasattr(instance, "number") else instance.pk + bill_type = instance.type.lower() + return '%ss/%d_%s_%s.pdf' % (bill_type, member_id, number, uuid.uuid4()) +class Bill(models.Model): + + CHILD_CLASS_NAMES = ( + 'Invoice', + 'MembershipFee', + 'Donation', + ) + + BILL_STATUS_CHOICES = ( + ('open', 'À payer'), + ('closed', 'Réglée'), + ('trouble', 'Litige') + ) + + status = models.CharField(max_length=50, choices=BILL_STATUS_CHOICES, + default='open', + verbose_name='statut') + date = models.DateField( + default=datetime.date.today, null=True, verbose_name='date', + help_text='Cette date sera définie à la date de validation dans le document final') + member = models.ForeignKey(Member, null=True, blank=True, default=None, + related_name='bills', + verbose_name='membre', + on_delete=models.SET_NULL) + pdf = models.FileField(storage=private_files_storage, + upload_to=bill_pdf_filename, + null=True, blank=True, + verbose_name='PDF') + + + def as_child(self): + for child_class_name in self.CHILD_CLASS_NAMES: + try: + return self.__getattribute__(child_class_name.lower()) + except eval(child_class_name).DoesNotExist: + pass + return self + + @property + def type(self): + for child_class_name in self.CHILD_CLASS_NAMES: + if hasattr(self, child_class_name.lower()): + return child_class_name + return self.__class__.__name__ + + @property + def amount(self): + """ Return bill amount """ + return self.cast.amount + amount.fget.short_description = 'Montant' + + def amount_paid(self): + """ + Calcul le montant déjà payé à partir des allocations de paiements + """ + return sum([a.amount for a in self.allocations.all()]) + amount_paid.short_description = 'Montant payé' + + def amount_remaining_to_pay(self): + """ + Calcul le montant restant à payer + """ + return self.amount - self.amount_paid() + amount_remaining_to_pay.short_description = 'Reste à payer' + + def has_owner(self, username): + """ + Check if passed username (ex gmajax) is owner of the invoice + """ + return (self.member and self.member.username == username) + + def generate_pdf(self): + """ + Make and store a pdf file for the invoice + """ + context = {"bill": self} + context.update(branding(None)) + pdf_file = render_as_pdf('billing/{bill_type}_pdf.html'.format(bill_type=self.type.lower()), context) + self.pdf.save('%s.pdf' % self.number if hasattr(self, "number") else self.pk, pdf_file) + + def pdf_exists(self): + return (bool(self.pdf) + and private_files_storage.exists(self.pdf.name)) + + def get_absolute_url(self): + return reverse('billing:bill_pdf', args=[self.number]) + + def __unicode__(self): + return '%s - %s - %i€' % (self.member, self.date, self.amount) + + @property + def reference(self): + if hasattr(self, 'membershipfee'): + return 'Cotisation' + elif hasattr(self, 'donation'): + return 'Don' + elif hasattr(self, 'invoice'): + return self.invoice.number + + def log_change(self, created): + if created: + accounting_log.info( + "Creating draft bill DRAFT-{} (Member: {}).".format( + self.pk, self.member)) + else: + if hasattr(self, 'validated') and not self.validated: + accounting_log.info( + "Updating draft bill DRAFT-{} (Member: {}).".format( + self.pk, self.member)) + else: + accounting_log.info( + "Updating bill {} (Member: {}, Total amount: {}, Amount paid: {}).".format( + self.pk, self.member, + self.amount, self.amount_paid())) + + + @property + def cast(bill): + if hasattr(bill, 'membershipfee'): + return bill.membershipfee + elif hasattr(bill, 'donation'): + return bill.donation + elif hasattr(bill, 'invoice'): + return bill.invoice + @staticmethod + def get_member_validated_bills(member): + related_fields = ['membershipfee', 'donation', 'invoice'] + return [i.cast for i in member.bills.order_by("date") if i.cast.validated] + + class Meta: + verbose_name = 'note' @python_2_unicode_compatible class InvoiceNumber: @@ -108,7 +240,7 @@ class InvoiceQuerySet(models.QuerySet): def _get_last_invoice_number(self, date): same_seq_filter = InvoiceNumber.time_sequence_filter(date) return self.filter(**same_seq_filter).with_valid_number().aggregate( - models.Max('number'))['number__max'] + models.Max('number'))["number__max"] def with_valid_number(self): """ Excludes previous numbering schemes or draft invoices @@ -117,13 +249,8 @@ class InvoiceQuerySet(models.QuerySet): InvoiceNumber.RE_INVOICE_NUMBER)) -class Invoice(models.Model): +class Invoice(Bill): - INVOICES_STATUS_CHOICES = ( - ('open', 'À payer'), - ('closed', 'Réglée'), - ('trouble', 'Litige') - ) validated = models.BooleanField(default=False, verbose_name='validée', help_text='Once validated, a PDF is generated' @@ -131,24 +258,10 @@ class Invoice(models.Model): number = models.CharField(max_length=25, unique=True, verbose_name='numéro') - status = models.CharField(max_length=50, choices=INVOICES_STATUS_CHOICES, - default='open', - verbose_name='statut') - date = models.DateField( - default=datetime.date.today, null=True, verbose_name='date', - help_text='Cette date sera définie à la date de validation dans la facture finale') date_due = models.DateField( null=True, blank=True, verbose_name="date d'échéance de paiement", help_text='Le délai de paiement sera fixé à {} jours à la validation si laissé vide'.format(settings.PAYMENT_DELAY)) - member = models.ForeignKey(Member, null=True, blank=True, default=None, - related_name='invoices', - verbose_name='membre', - on_delete=models.SET_NULL) - pdf = models.FileField(storage=private_files_storage, - upload_to=invoice_pdf_filename, - null=True, blank=True, - verbose_name='PDF') date_last_reminder_email = models.DateTimeField(null=True, blank=True, verbose_name="Date du dernier email de relance envoyé") @@ -161,6 +274,7 @@ class Invoice(models.Model): self.number = 'DRAFT-{}'.format(self.pk) self.save() + @property def amount(self): """ Calcul le montant de la facture @@ -170,7 +284,7 @@ class Invoice(models.Model): for detail in self.details.all(): total += detail.total() return total.quantize(Decimal('0.01')) - amount.short_description = 'Montant' + amount.fget.short_description = 'Montant' def amount_before_tax(self): total = Decimal('0.0') @@ -179,42 +293,16 @@ class Invoice(models.Model): return total.quantize(Decimal('0.01')) amount_before_tax.short_description = 'Montant HT' - def amount_paid(self): - """ - Calcul le montant déjà payé à partir des allocations de paiements - """ - return sum([a.amount for a in self.allocations.all()]) - amount_paid.short_description = 'Montant payé' - - def amount_remaining_to_pay(self): - """ - Calcul le montant restant à payer - """ - return self.amount() - self.amount_paid() - amount_remaining_to_pay.short_description = 'Reste à payer' - - def has_owner(self, username): - """ - Check if passed username (ex gmajax) is owner of the invoice - """ - return (self.member and self.member.username == username) - - def generate_pdf(self): - """ - Make and store a pdf file for the invoice - """ - context = {"invoice": self} - context.update(branding(None)) - pdf_file = render_as_pdf('billing/invoice_pdf.html', context) - self.pdf.save('%s.pdf' % self.number, pdf_file) @transaction.atomic - def validate(self): + def validate(self, custom_date=None): """ Switch invoice to validate mode. This set to False the draft field and generate the pdf """ - self.date = datetime.date.today() + + self.date = custom_date or datetime.date.today() + if not self.date_due: self.date_due = self.date + datetime.timedelta(days=settings.PAYMENT_DELAY) old_number = self.number @@ -228,7 +316,7 @@ class Invoice(models.Model): "Draft invoice {} validated as invoice {}. ".format( old_number, self.number) + "(Total amount : {} ; Member : {})".format( - self.amount(), self.member)) + self.amount, self.member)) assert self.pdf_exists() if self.member is not None: update_accounting_for_member(self.member) @@ -239,12 +327,13 @@ class Invoice(models.Model): and bool(self.pdf) and private_files_storage.exists(self.pdf.name)) - def get_absolute_url(self): - return reverse('billing:invoice', args=[self.number]) + @property + def pdf_title(self): + return "Facture N°"+self.number def __unicode__(self): return '#{} {:0.2f}€ {}'.format( - self.number, self.amount(), self.date_due) + self.number, self.amount, self.date_due) def reminder_needed(self): @@ -306,6 +395,18 @@ class Invoice(models.Model): self.save() return True + def log_change(self, created): + + if created: + accounting_log.info("Creating draft invoice %s (Member: %s)." + % ('DRAFT-{}'.format(self.pk), self.member)) + else: + if not self.validated: + accounting_log.info("Updating draft invoice %s (Member: %s)." + % (self.number, self.member)) + else: + accounting_log.info("Updating invoice %s (Member: %s, Total amount: %s, Amount paid: %s)." + % (self.number, self.member, self.amount, self.amount_paid() )) class Meta: verbose_name = 'facture' @@ -314,8 +415,8 @@ class Invoice(models.Model): class InvoiceDetail(models.Model): - label = models.CharField(max_length=100) - amount = models.DecimalField(max_digits=5, decimal_places=2, + label = models.CharField(max_length=255) + amount = models.DecimalField(max_digits=8, decimal_places=2, verbose_name='montant') quantity = models.DecimalField(null=True, verbose_name='quantité', default=1.0, decimal_places=2, max_digits=4) @@ -359,12 +460,97 @@ class InvoiceDetail(models.Model): verbose_name = 'détail de facture' +class Donation(Bill): + _amount = models.DecimalField(max_digits=8, decimal_places=2, + verbose_name='Montant') + + @property + def amount(self): + return self._amount + amount.fget.short_description = 'Montant' + + @property + def validated(self): + return True + + def save(self, *args, **kwargs): + + super(Donation, self).save(*args, **kwargs) + + if not self.pdf_exists(): + self.generate_pdf() + + def clean(self): + + # Only if no amount already allocated... + if self.pk is None and settings.HANDLE_BALANCE and (not self.member or self.member.balance < self.amount): + raise ValidationError("Le solde n'est pas suffisant pour payer ce don. \ + Merci de commencer par enregistrer un paiement pour ce membre.") + + @property + def pdf_title(self): + return "Reçu de don" + + + class Meta: + verbose_name = 'don' + +class MembershipFee(Bill): + _amount = models.DecimalField(null=False, max_digits=8, decimal_places=2, + default=default_membership_fee, + verbose_name='montant', help_text='en €') + start_date = models.DateField( + null=False, + blank=False, + verbose_name='date de début de cotisation') + end_date = models.DateField( + null=False, + blank=True, + verbose_name='date de fin de cotisation', + help_text='par défaut, la cotisation dure un an') + + @property + def amount(self): + return self._amount + amount.fget.short_description = 'Montant' + @property + def validated(self): + return True + + def save(self, *args, **kwargs): + super(MembershipFee, self).save(*args, **kwargs) + + today = datetime.date.today() + if self.start_date <= today and today <= self.end_date: + self.member.status = self.member.MEMBER_STATUS_MEMBER + self.member.save() + + if not self.pdf_exists(): + self.generate_pdf() + + def clean(self): + # Only if no amount already allocated... + if self.pk is None and settings.HANDLE_BALANCE and (not self.member or self.member.balance < self.amount): + raise ValidationError("Le solde n'est pas suffisant pour payer cette cotisation. \ + Merci de commencer par enregistrer un paiement pour ce membre.") + + if self.start_date is not None and self.end_date is None: + self.end_date = self.start_date + datetime.timedelta(364) + + @property + def pdf_title(self): + return "Reçu de cotisation" + + class Meta: + verbose_name = 'cotisation' + class Payment(models.Model): PAYMENT_MEAN_CHOICES = ( ('cash', 'Espèces'), ('check', 'Chèque'), ('transfer', 'Virement'), + ('creditnote', 'Avoir'), ('other', 'Autre') ) @@ -377,10 +563,10 @@ class Payment(models.Model): default='transfer', choices=PAYMENT_MEAN_CHOICES, verbose_name='moyen de paiement') - amount = models.DecimalField(max_digits=5, decimal_places=2, null=True, + amount = models.DecimalField(max_digits=8, decimal_places=2, null=True, verbose_name='montant') date = models.DateField(default=datetime.date.today) - invoice = models.ForeignKey(Invoice, verbose_name='facture associée', null=True, + bill = models.ForeignKey(Bill, verbose_name='facture associée', null=True, blank=True, related_name='payments') label = models.CharField(max_length=500, @@ -393,9 +579,9 @@ class Payment(models.Model): if self.amount_already_allocated() == 0: # If there's a linked invoice and no member defined - if self.invoice and not self.member: + if self.bill and not self.member: # Automatically set member to invoice's member - self.member = self.invoice.member + self.member = self.bill.member super(Payment, self).save(*args, **kwargs) @@ -407,7 +593,7 @@ class Payment(models.Model): # If there's a linked invoice and this payment would pay more than # the remaining amount needed to pay the invoice... - if self.invoice and self.amount > self.invoice.amount_remaining_to_pay(): + if self.bill and self.amount > self.bill.amount_remaining_to_pay(): raise ValidationError("This payment would pay more than the invoice's remaining to pay") def amount_already_allocated(self): @@ -417,13 +603,13 @@ class Payment(models.Model): return self.amount - self.amount_already_allocated() @transaction.atomic - def allocate_to_invoice(self, invoice): + def allocate_to_bill(self, bill): # FIXME - Add asserts about remaining amount > 0, unpaid amount > 0, # ... amount_can_pay = self.amount_not_allocated() - amount_to_pay = invoice.amount_remaining_to_pay() + amount_to_pay = bill.amount_remaining_to_pay() amount_to_allocate = min(amount_can_pay, amount_to_pay) if amount_to_allocate <= 0: @@ -433,21 +619,21 @@ class Payment(models.Model): return accounting_log.info( - "Allocating {} from payment {} to invoice {}".format( - amount_to_allocate, self.date, invoice.number)) + "Allocating {} from payment {} to bill {} {}".format( + amount_to_allocate, self.date, bill.reference, bill.pk)) - PaymentAllocation.objects.create(invoice=invoice, + PaymentAllocation.objects.create(bill=bill, payment=self, amount=amount_to_allocate) # Close invoice if relevant - if (invoice.amount_remaining_to_pay() <= 0) and (invoice.status == "open"): + if (bill.amount_remaining_to_pay() <= 0) and (bill.status == "open"): accounting_log.info( - "Invoice {} has been paid and is now closed".format( - invoice.number)) - invoice.status = "closed" + "Bill {} {} has been paid and is now closed".format( + bill.reference, bill.pk)) + bill.status = "closed" - invoice.save() + bill.save() self.save() def __unicode__(self): @@ -468,31 +654,31 @@ class Payment(models.Model): # There can be for example an allocation of 3.14€ from P to I. class PaymentAllocation(models.Model): - invoice = models.ForeignKey(Invoice, verbose_name='facture associée', + bill = models.ForeignKey(Bill, verbose_name='facture associée', null=False, blank=False, related_name='allocations') payment = models.ForeignKey(Payment, verbose_name='facture associée', null=False, blank=False, related_name='allocations') - amount = models.DecimalField(max_digits=5, decimal_places=2, null=True, + amount = models.DecimalField(max_digits=8, decimal_places=2, null=True, verbose_name='montant') -def get_active_payment_and_invoices(member): +def get_active_payment_and_bills(member): # Fetch relevant and active payments / invoices # and sort then by chronological order : olders first, newers last. - this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")] + this_member_bills = Bill.get_member_validated_bills(member) this_member_payments = [p for p in member.payments.order_by("date")] # TODO / FIXME ^^^ maybe also consider only 'opened' invoices (i.e. not # conflict / trouble invoices) active_payments = [p for p in this_member_payments if p.amount_not_allocated() > 0] - active_invoices = [p for p in this_member_invoices if p.amount_remaining_to_pay() > 0] + active_bills = [p for p in this_member_bills if p.amount_remaining_to_pay() > 0] - return active_payments, active_invoices + return active_payments, active_bills def update_accounting_for_member(member): @@ -507,12 +693,12 @@ def update_accounting_for_member(member): accounting_log.info( "Member {} current balance is {} ...".format(member, member.balance)) - reconcile_invoices_and_payments(member) + reconcile_bills_and_payments(member) - this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")] + this_member_bills = Bill.get_member_validated_bills(member) this_member_payments = [p for p in member.payments.order_by("date")] - member.balance = compute_balance(this_member_invoices, + member.balance = compute_balance(this_member_bills, this_member_payments) member.save() @@ -520,22 +706,22 @@ def update_accounting_for_member(member): member, member.balance)) -def reconcile_invoices_and_payments(member): +def reconcile_bills_and_payments(member): """ Rapproche des factures et des paiements qui sont actifs (paiement non alloué ou factures non entièrement payées) automatiquement. """ - active_payments, active_invoices = get_active_payment_and_invoices(member) + active_payments, active_bills = get_active_payment_and_bills(member) if active_payments == []: accounting_log.info( "(No active payment for {}.".format(member) - + " No invoice/payment reconciliation needed.).") + + " No bill/payment reconciliation needed.).") return - elif active_invoices == []: + elif active_bills == []: accounting_log.info( - "(No active invoice for {}. No invoice/payment ".format(member) + + "(No active bill for {}. No bill/payment ".format(member) + "reconciliation needed.).") return @@ -543,32 +729,32 @@ def reconcile_invoices_and_payments(member): "Initiating reconciliation between invoice and payments for {}".format( member)) - while active_payments != [] and active_invoices != []: + while active_payments != [] and active_bills != []: - # Only consider the oldest active payment and the oldest active invoice + # Only consider the oldest active payment and the oldest active bill p = active_payments[0] - # If this payment is to be allocated for a specific invoice... - if p.invoice: + # If this payment is to be allocated for a specific bill... + if p.bill: # Assert that the invoice is still 'active' - assert p.invoice in active_invoices - i = p.invoice + assert p.bill.pk in [b.pk for b in active_bills] + i = p.bill accounting_log.info( - "Payment is to be allocated specifically to invoice {}".format( - i.number)) + "Payment is to be allocated specifically to bill {}".format( + i.pk)) else: - i = active_invoices[0] + i = active_bills[0] # TODO : should add an assert that the ammount not allocated / remaining to - # pay is lower before and after calling the allocate_to_invoice + # pay is lower before and after calling the allocate_to_bill - p.allocate_to_invoice(i) + p.allocate_to_bill(i) - active_payments, active_invoices = get_active_payment_and_invoices(member) + active_payments, active_bills = get_active_payment_and_bills(member) if active_payments == []: accounting_log.info("No more active payment. Nothing to reconcile anymore.") - elif active_invoices == []: + elif active_bills == []: accounting_log.info("No more active invoice. Nothing to reconcile anymore.") return @@ -585,7 +771,7 @@ def compute_balance(invoices, payments): return s -@receiver(post_save, sender=Payment) +@receiver(post_save, sender=Payment, dispatch_uid='payment_payment_changed') @disable_for_loaddata def payment_changed(sender, instance, created, **kwargs): @@ -605,8 +791,8 @@ def payment_changed(sender, instance, created, **kwargs): and (instance.member is not None): # Not using the auto-accounting module ... and apparently the user did chose explicitly to which invoice - if not settings.HANDLE_BALANCE and instance.invoice: - instance.allocate_to_invoice(instance.invoice) + if not settings.HANDLE_BALANCE and instance.bill: + instance.allocate_to_bill(instance.bill) # Otherwise, if we know to which member to use this payment for, we trigger an update of its accounting elif instance.member is not None: @@ -614,38 +800,37 @@ def payment_changed(sender, instance, created, **kwargs): -@receiver(post_save, sender=Invoice) +@receiver(post_save, sender=Bill, dispatch_uid='bill_changed') @disable_for_loaddata -def invoice_changed(sender, instance, created, **kwargs): +def bill_changed(sender, instance, created, **kwargs): - if created: - accounting_log.info( - "Creating draft invoice DRAFT-{} (Member: {}).".format( - instance.pk, instance.member)) - else: - if not instance.validated: - accounting_log.info( - "Updating draft invoice DRAFT-{} (Member: {}).".format( - instance.number, instance.member)) - else: - accounting_log.info( - "Updating invoice {} (Member: {}, Total amount: {}, Amount paid: {}).".format( - instance.number, instance.member, - instance.amount(), instance.amount_paid())) + instance.log_change(created) + +@receiver(post_save, sender=MembershipFee, dispatch_uid='membershipfee_changed') +@disable_for_loaddata +def membershipfee_changed(sender, instance, created, **kwargs): + if created and instance.member is not None: + update_accounting_for_member(instance.member) -@receiver(post_delete, sender=PaymentAllocation) +@receiver(post_save, sender=Donation, dispatch_uid='donation_changed') +@disable_for_loaddata +def donation_changed(sender, instance, created, **kwargs): + if created and instance.member is not None: + update_accounting_for_member(instance.member) + +@receiver(post_delete, sender=PaymentAllocation, dispatch_uid='paymentallocation_deleted') def paymentallocation_deleted(sender, instance, **kwargs): - invoice = instance.invoice + bill = instance.bill # Reopen invoice if relevant - if (invoice.amount_remaining_to_pay() > 0) and (invoice.status == "closed"): - accounting_log.info("Reopening invoice {} ...".format(invoice.number)) - invoice.status = "open" - invoice.save() + if (bill.amount_remaining_to_pay() > 0) and (bill.status == "closed"): + accounting_log.info("Reopening bill {} ...".format(bill.number)) + bill.status = "open" + bill.save() -@receiver(post_delete, sender=Payment) +@receiver(post_delete, sender=Payment, dispatch_uid='payment_deleted') def payment_deleted(sender, instance, **kwargs): accounting_log.info( @@ -657,11 +842,9 @@ def payment_deleted(sender, instance, **kwargs): if member is None: return - this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")] + this_member_bills = Bill.get_member_validated_bills(member) this_member_payments = [p for p in member.payments.order_by("date")] - member.balance = compute_balance(this_member_invoices, + member.balance = compute_balance(this_member_bills, this_member_payments) member.save() - - diff --git a/coin/billing/templates/admin/billing/invoice/change_form.html b/coin/billing/templates/admin/billing/invoice/change_form.html index 281786eaec0ae1aff590c08b30a3b71bfedd7fa5..b5b1b093a9dc1afcc688efaef2bf246844e1bbae 100644 --- a/coin/billing/templates/admin/billing/invoice/change_form.html +++ b/coin/billing/templates/admin/billing/invoice/change_form.html @@ -1,10 +1,9 @@ {% extends "admin/change_form.html" %} -{% load url from future %} {% block object-tools-items %} {% if not original.validated %}